From c0fa9cadfbd0bb0122c0b4280fb23df5fc4bfbb3 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 21 Dec 2025 09:57:51 -0500 Subject: [PATCH] Initial commit --- .gitignore | 29 + composer.json | 26 + composer.lock | 23 + lib/Module.php | 71 ++ lib/Providers/Personal/NodeCollection.php | 176 +++++ lib/Providers/Personal/NodeEntity.php | 226 ++++++ lib/Providers/Personal/PersonalService.php | 849 +++++++++++++++++++++ lib/Providers/Provider.php | 214 ++++++ lib/Store/BlobStore.php | 356 +++++++++ lib/Store/MetaStore.php | 808 ++++++++++++++++++++ 10 files changed, 2778 insertions(+) create mode 100644 .gitignore create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 lib/Module.php create mode 100644 lib/Providers/Personal/NodeCollection.php create mode 100644 lib/Providers/Personal/NodeEntity.php create mode 100644 lib/Providers/Personal/PersonalService.php create mode 100644 lib/Providers/Provider.php create mode 100644 lib/Store/BlobStore.php create mode 100644 lib/Store/MetaStore.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..812e4cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Frontend development +node_modules/ +*.local +.env.local +.env.*.local +.cache/ +.vite/ +.temp/ +.tmp/ + +# Frontend build +/static/ + +# Backend development +/lib/vendor/ +coverage/ +phpunit.xml.cache +.phpunit.result.cache +.php-cs-fixer.cache +.phpstan.cache +.phpactor/ + +# Editors +.DS_Store +.vscode/ +.idea/ + +# Logs +*.log diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..9cb13f8 --- /dev/null +++ b/composer.json @@ -0,0 +1,26 @@ +{ + "name": "ktrix/file-provider-local", + "type": "project", + "authors": [ + { + "name": "Sebastian Krupinski", + "email": "krupinski01@gmail.com" + } + ], + "config": { + "optimize-autoloader": true, + "platform": { + "php": "8.2" + }, + "autoloader-suffix": "FileProviderLocal", + "vendor-dir": "lib/vendor" + }, + "require": { + "php": ">=8.2 <=8.5" + }, + "autoload": { + "psr-4": { + "KTXM\\FileProviderLocal\\": "lib/" + } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..1ec6b4f --- /dev/null +++ b/composer.lock @@ -0,0 +1,23 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "bb6e2fa08f6e63a4c5e94f751faccd52", + "packages": [], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.2 <=8.5" + }, + "platform-dev": [], + "platform-overrides": { + "php": "8.2" + }, + "plugin-api-version": "2.3.0" +} diff --git a/lib/Module.php b/lib/Module.php new file mode 100644 index 0000000..e3df8c1 --- /dev/null +++ b/lib/Module.php @@ -0,0 +1,71 @@ + [ + 'label' => 'Access File Provider Local', + 'description' => 'View and access the local file provider module', + 'group' => 'File Providers' + ], + ]; + } + + public function boot(): void + { + $this->providerManager->register(ProviderInterface::TYPE_FILES, 'default', Provider::class); + } + + public function registerBI(): array { + return [ + 'handle' => $this->handle(), + 'namespace' => 'FileProviderLocal', + 'version' => $this->version(), + 'label' => $this->label(), + 'author' => $this->author(), + 'description' => $this->description() + ]; + } +} diff --git a/lib/Providers/Personal/NodeCollection.php b/lib/Providers/Personal/NodeCollection.php new file mode 100644 index 0000000..96194ca --- /dev/null +++ b/lib/Providers/Personal/NodeCollection.php @@ -0,0 +1,176 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\FileProviderLocal\Providers\Personal; + +use DateTimeImmutable; +use KTXF\Files\Node\INodeCollectionBase; +use KTXF\Files\Node\INodeCollectionMutable; +use KTXF\Files\Node\NodeType; + +/** + * NodeCollection implementation - represents a folder/collection in the file system + */ +class NodeCollection implements INodeCollectionBase, INodeCollectionMutable { + + // node system properties + private string|null $tenantId = null; + private string|null $userId = null; + private string|int|null $nodeIn = null; + private string|int|null $nodeId = null; + private string|null $nodeCreatedBy = null; + private DateTimeImmutable|null $nodeCreatedOn = null; + private string|null $nodeModifiedBy = null; + private DateTimeImmutable|null $nodeModifiedOn = null; + private string|null $nodeOwner = null; + private string|null $nodeSignature = null; + // node specific properties + private string|null $nodeLabel = null; + + public function jsonSerialize(): mixed { + return [ + // node meta properties + self::JSON_PROPERTY_TYPE => self::JSON_TYPE, + self::JSON_PROPERTY_IN => $this->nodeIn, + self::JSON_PROPERTY_ID => $this->nodeId, + self::JSON_PROPERTY_CREATED_BY => $this->nodeCreatedBy, + self::JSON_PROPERTY_CREATED_ON => $this->nodeCreatedOn?->format('c'), + self::JSON_PROPERTY_MODIFIED_BY => $this->nodeModifiedBy, + self::JSON_PROPERTY_MODIFIED_ON => $this->nodeModifiedOn?->format('c'), + self::JSON_PROPERTY_OWNER => $this->nodeOwner, + self::JSON_PROPERTY_SIGNATURE => $this->nodeSignature, + // node specific properties + self::JSON_PROPERTY_LABEL => $this->nodeLabel, + ]; + } + + public function jsonDeserialize(array|string $data): static { + if (is_string($data)) { + $data = json_decode($data, true); + } + // node meta properties + $this->nodeIn = $data[self::JSON_PROPERTY_IN] ?? null; + $this->nodeId = $data[self::JSON_PROPERTY_ID] ?? null; + $this->nodeCreatedBy = $data[self::JSON_PROPERTY_CREATED_BY] ?? null; + $this->nodeCreatedOn = isset($data[self::JSON_PROPERTY_CREATED_ON]) + ? new DateTimeImmutable($data[self::JSON_PROPERTY_CREATED_ON]) + : null; + $this->nodeModifiedBy = $data[self::JSON_PROPERTY_MODIFIED_BY] ?? null; + $this->nodeModifiedOn = isset($data[self::JSON_PROPERTY_MODIFIED_ON]) + ? new DateTimeImmutable($data[self::JSON_PROPERTY_MODIFIED_ON]) + : null; + $this->nodeOwner = $data[self::JSON_PROPERTY_OWNER] ?? null; + $this->nodeSignature = $data[self::JSON_PROPERTY_SIGNATURE] ?? null; + // node specific properties + $this->nodeLabel = $data[self::JSON_PROPERTY_LABEL] ?? null; + + return $this; + } + + public function fromStore(array|object $document): self { + if (is_object($document)) { + $document = (array) $document; + } + // node system properties + $this->tenantId = $document['tid'] ?? null; + $this->userId = $document['uid'] ?? null; + $this->nodeId = $document['nid'] ?? null; + $this->nodeIn = $document['pid'] ?? null; + $this->nodeCreatedBy = $document['createdBy'] ?? null; + $this->nodeCreatedOn = isset($document['createdOn']) + ? new DateTimeImmutable($document['createdOn']) + : null; + $this->nodeModifiedBy = $document['modifiedBy'] ?? null; + $this->nodeModifiedOn = isset($document['modifiedOn']) + ? new DateTimeImmutable($document['modifiedOn']) + : null; + $this->nodeOwner = $document['owner'] ?? null; + $this->nodeSignature = $document['signature'] ?? md5(json_encode([ + $this->nodeId, NodeType::Collection->value, $document['modifiedOn'] ?? '' + ])); + // node specific properties + $this->nodeLabel = $document['label'] ?? null; + + return $this; + } + + public function toStore(): array { + $now = date('c'); + return [ + // node system properties + 'tid' => $this->tenantId, + 'uid' => $this->userId, + 'nid' => $this->nodeId, + 'pid' => $this->nodeIn, + 'type' => NodeType::Collection->value, + 'createdBy' => $this->nodeCreatedBy, + 'createdOn' => $this->nodeCreatedOn?->format('c') ?? $now, + 'modifiedBy' => $this->nodeModifiedBy, + 'modifiedOn' => $this->nodeModifiedOn?->format('c') ?? $now, + 'owner' => $this->nodeOwner, + 'signature' => $this->nodeSignature, + // node specific properties + 'label' => $this->nodeLabel, + ]; + } + + // Immutable properties + + public function in(): string|int|null { + return $this->nodeIn; + } + + public function id(): string|int { + return $this->nodeId ?? ''; + } + + public function type(): NodeType { + return NodeType::Collection; + } + + public function createdBy(): string|null { + return $this->nodeCreatedBy; + } + + public function createdOn(): DateTimeImmutable|null { + return $this->nodeCreatedOn; + } + + public function modifiedBy(): string|null { + return $this->nodeModifiedBy; + } + + public function modifiedOn(): DateTimeImmutable|null { + return $this->nodeModifiedOn; + } + + public function signature(): string|null { + return $this->nodeSignature; + } + + public function isCollection(): bool { + return true; + } + + public function isEntity(): bool { + return false; + } + + // Mutable properties + + public function getLabel(): string|null { + return $this->nodeLabel; + } + + public function setLabel(string $value): static { + $this->nodeLabel = $value; + return $this; + } + +} diff --git a/lib/Providers/Personal/NodeEntity.php b/lib/Providers/Personal/NodeEntity.php new file mode 100644 index 0000000..894077b --- /dev/null +++ b/lib/Providers/Personal/NodeEntity.php @@ -0,0 +1,226 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\FileProviderLocal\Providers\Personal; + +use DateTimeImmutable; +use KTXF\Files\Node\INodeEntityBase; +use KTXF\Files\Node\INodeEntityMutable; +use KTXF\Files\Node\NodeType; + +/** + * NodeEntity implementation - represents a file/entity in the file system + */ +class NodeEntity implements INodeEntityBase, INodeEntityMutable { + + // node system properties + private string|null $tenantId = null; + private string|null $userId = null; + private string|int|null $nodeIn = null; + private string|int|null $nodeId = null; + private string|null $nodeCreatedBy = null; + private DateTimeImmutable|null $nodeCreatedOn = null; + private string|null $nodeModifiedBy = null; + private DateTimeImmutable|null $nodeModifiedOn = null; + private string|null $nodeOwner = null; + private string|null $nodeSignature = null; + // node specific properties + private string|null $nodeLabel = null; + private string|null $nodeMime = null; + private string|null $nodeFormat = null; + private string|null $nodeEncoding = null; + private int $nodeSize = 0; + + public function jsonSerialize(): mixed { + return [ + // node meta properties + self::JSON_PROPERTY_TYPE => self::JSON_TYPE, + self::JSON_PROPERTY_IN => $this->nodeIn, + self::JSON_PROPERTY_ID => $this->nodeId, + self::JSON_PROPERTY_CREATED_BY => $this->nodeCreatedBy, + self::JSON_PROPERTY_CREATED_ON => $this->nodeCreatedOn?->format('c'), + self::JSON_PROPERTY_MODIFIED_BY => $this->nodeModifiedBy, + self::JSON_PROPERTY_MODIFIED_ON => $this->nodeModifiedOn?->format('c'), + self::JSON_PROPERTY_OWNER => $this->nodeOwner, + self::JSON_PROPERTY_SIGNATURE => $this->nodeSignature, + self::JSON_PROPERTY_SIZE => $this->nodeSize, + // node specific properties + self::JSON_PROPERTY_LABEL => $this->nodeLabel, + self::JSON_PROPERTY_MIME => $this->nodeMime, + self::JSON_PROPERTY_FORMAT => $this->nodeFormat, + self::JSON_PROPERTY_ENCODING => $this->nodeEncoding, + ]; + } + + public function jsonDeserialize(array|string $data): static { + if (is_string($data)) { + $data = json_decode($data, true); + } + // node meta properties + $this->nodeIn = $data[self::JSON_PROPERTY_IN] ?? null; + $this->nodeId = $data[self::JSON_PROPERTY_ID] ?? null; + $this->nodeCreatedBy = $data[self::JSON_PROPERTY_CREATED_BY] ?? null; + $this->nodeCreatedOn = isset($data[self::JSON_PROPERTY_CREATED_ON]) + ? new DateTimeImmutable($data[self::JSON_PROPERTY_CREATED_ON]) + : null; + $this->nodeModifiedBy = $data[self::JSON_PROPERTY_MODIFIED_BY] ?? null; + $this->nodeModifiedOn = isset($data[self::JSON_PROPERTY_MODIFIED_ON]) + ? new DateTimeImmutable($data[self::JSON_PROPERTY_MODIFIED_ON]) + : null; + $this->nodeOwner = $data[self::JSON_PROPERTY_OWNER] ?? null; + $this->nodeSignature = $data[self::JSON_PROPERTY_SIGNATURE] ?? null; + $this->nodeSize = $data[self::JSON_PROPERTY_SIZE] ?? 0; + // node specific properties + $this->nodeLabel = $data[self::JSON_PROPERTY_LABEL] ?? null; + $this->nodeMime = $data[self::JSON_PROPERTY_MIME] ?? null; + $this->nodeFormat = $data[self::JSON_PROPERTY_FORMAT] ?? null; + $this->nodeEncoding = $data[self::JSON_PROPERTY_ENCODING] ?? null; + + return $this; + } + + public function fromStore(array|object $document): self { + if (is_object($document)) { + $document = (array) $document; + } + // node system properties + $this->tenantId = $document['tid'] ?? null; + $this->userId = $document['uid'] ?? null; + $this->nodeId = $document['nid'] ?? null; + $this->nodeIn = $document['pid'] ?? null; + $this->nodeCreatedBy = $document['createdBy'] ?? null; + $this->nodeCreatedOn = isset($document['createdOn']) + ? new DateTimeImmutable($document['createdOn']) + : null; + $this->nodeModifiedBy = $document['modifiedBy'] ?? null; + $this->nodeModifiedOn = isset($document['modifiedOn']) + ? new DateTimeImmutable($document['modifiedOn']) + : null; + $this->nodeOwner = $document['owner'] ?? null; + $this->nodeSignature = $document['signature'] ?? md5(json_encode([ + $this->nodeId, NodeType::Entity->value, $document['modifiedOn'] ?? '' + ])); + $this->nodeSize = $document['size'] ?? 0; + // node specific properties + $this->nodeLabel = $document['label'] ?? null; + $this->nodeMime = $document['mime'] ?? null; + $this->nodeFormat = $document['format'] ?? null; + $this->nodeEncoding = $document['encoding'] ?? null; + + return $this; + } + + public function toStore(): array { + $now = date('c'); + return [ + // node system properties + 'tid' => $this->tenantId, + 'uid' => $this->userId, + 'nid' => $this->nodeId, + 'pid' => $this->nodeIn, + 'type' => NodeType::Entity->value, + 'createdBy' => $this->nodeCreatedBy, + 'createdOn' => $this->nodeCreatedOn?->format('c') ?? $now, + 'modifiedBy' => $this->nodeModifiedBy, + 'modifiedOn' => $this->nodeModifiedOn?->format('c') ?? $now, + 'owner' => $this->nodeOwner, + 'signature' => $this->nodeSignature, + 'size' => $this->nodeSize, + // node specific properties + 'label' => $this->nodeLabel, + 'mime' => $this->nodeMime, + 'format' => $this->nodeFormat, + 'encoding' => $this->nodeEncoding, + ]; + } + + // Immutable properties + + public function in(): string|int|null { + return $this->nodeIn; + } + + public function id(): string|int { + return $this->nodeId ?? ''; + } + + public function type(): NodeType { + return NodeType::Entity; + } + + public function createdBy(): string|null { + return $this->nodeCreatedBy; + } + + public function createdOn(): DateTimeImmutable|null { + return $this->nodeCreatedOn; + } + + public function modifiedBy(): string|null { + return $this->nodeModifiedBy; + } + + public function modifiedOn(): DateTimeImmutable|null { + return $this->nodeModifiedOn; + } + + public function signature(): string|null { + return $this->nodeSignature; + } + + public function size(): int { + return $this->nodeSize; + } + + public function isCollection(): bool { + return false; + } + + public function isEntity(): bool { + return true; + } + + // Mutable properties + + public function getLabel(): string|null { + return $this->nodeLabel; + } + + public function setLabel(string $value): static { + $this->nodeLabel = $value; + return $this; + } + + public function getMime(): string|null { + return $this->nodeMime; + } + + public function setMime(string $value): static { + $this->nodeMime = $value; + return $this; + } + + public function getFormat(): string|null { + return $this->nodeFormat; + } + + public function setFormat(string $value): static { + $this->nodeFormat = $value; + return $this; + } + + public function getEncoding(): string|null { + return $this->nodeEncoding; + } + + public function setEncoding(string $value): static { + $this->nodeEncoding = $value; + return $this; + } +} diff --git a/lib/Providers/Personal/PersonalService.php b/lib/Providers/Personal/PersonalService.php new file mode 100644 index 0000000..796b2db --- /dev/null +++ b/lib/Providers/Personal/PersonalService.php @@ -0,0 +1,849 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\FileProviderLocal\Providers\Personal; + +use KTXF\Files\Node\INodeBase; +use KTXF\Files\Node\INodeCollectionBase; +use KTXF\Files\Node\INodeCollectionMutable; +use KTXF\Files\Node\INodeEntityBase; +use KTXF\Files\Node\INodeEntityMutable; +use KTXF\Files\Node\NodeType; +use KTXF\Files\Service\IServiceBase; +use KTXF\Files\Service\IServiceCollectionMutable; +use KTXF\Files\Service\IServiceEntityMutable; +use KTXF\Resource\Exceptions\InvalidParameterException; +use KTXF\Resource\Filter\Filter; +use KTXF\Resource\Filter\IFilter; +use KTXF\Resource\Range\IRange; +use KTXF\Resource\Range\RangeTally; +use KTXF\Resource\Range\RangeType; +use KTXF\Resource\Sort\ISort; +use KTXF\Resource\Sort\Sort; +use KTXM\FileProviderLocal\Store\BlobStore; +use KTXM\FileProviderLocal\Store\MetaStore; +use KTXF\Blob\Signature; + +class PersonalService implements IServiceBase, IServiceCollectionMutable, IServiceEntityMutable { + + public const ROOT_ID = '00000000-0000-0000-0000-000000000000'; + public const ROOT_LABEL = 'Personal Files'; + + protected const SERVICE_ID = 'personal'; + protected const SERVICE_LABEL = 'Personal Files Service'; + protected const SERVICE_PROVIDER = 'default'; + + protected array $serviceCollectionCache = []; + protected array $serviceEntityCache = []; + protected ?string $serviceTenantId = null; + protected ?string $serviceUserId = null; + protected ?bool $serviceEnabled = true; + protected ?string $serviceRoot = null; + + protected array $serviceAbilities = [ + // Collection capabilities + self::CAPABILITY_COLLECTION_LIST => true, + self::CAPABILITY_COLLECTION_LIST_FILTER => [ + 'id' => 'a:10:64:192', + 'label' => 's:100:256:771', + ], + self::CAPABILITY_COLLECTION_LIST_SORT => [ + 'label', + 'created', + 'modified' + ], + self::CAPABILITY_COLLECTION_EXTANT => true, + self::CAPABILITY_COLLECTION_FETCH => true, + self::CAPABILITY_COLLECTION_CREATE => true, + self::CAPABILITY_COLLECTION_MODIFY => true, + self::CAPABILITY_COLLECTION_DESTROY => true, + self::CAPABILITY_COLLECTION_COPY => true, + self::CAPABILITY_COLLECTION_MOVE => true, + // Entity capabilities + self::CAPABILITY_ENTITY_LIST => true, + self::CAPABILITY_ENTITY_LIST_FILTER => [ + 'id' => 'a:10:64:192', + 'label' => 's:100:256:771', + 'mimeType' => 's:100:256:771', + ], + self::CAPABILITY_ENTITY_LIST_SORT => [ + 'label', + 'size', + 'created', + 'modified' + ], + self::CAPABILITY_ENTITY_LIST_RANGE => [ + 'tally' => ['absolute', 'relative'] + ], + self::CAPABILITY_ENTITY_DELTA => true, + self::CAPABILITY_ENTITY_EXTANT => true, + self::CAPABILITY_ENTITY_FETCH => true, + self::CAPABILITY_ENTITY_READ => true, + self::CAPABILITY_ENTITY_READ_STREAM => true, + self::CAPABILITY_ENTITY_READ_CHUNK => true, + self::CAPABILITY_ENTITY_CREATE => true, + self::CAPABILITY_ENTITY_MODIFY => true, + self::CAPABILITY_ENTITY_DESTROY => true, + self::CAPABILITY_ENTITY_COPY => true, + self::CAPABILITY_ENTITY_MOVE => true, + self::CAPABILITY_ENTITY_WRITE => true, + self::CAPABILITY_ENTITY_WRITE_STREAM => true, + self::CAPABILITY_ENTITY_WRITE_CHUNK => true, + // Node capabilities (recursive/unified) + self::CAPABILITY_NODE_LIST => true, + self::CAPABILITY_NODE_LIST_FILTER => [ + 'id' => 'a:10:64:192', + 'label' => 's:100:256:771', + 'nodeType' => 's:10:20:192', + 'mimeType' => 's:100:256:771', + ], + self::CAPABILITY_NODE_LIST_SORT => [ + 'label', + 'nodeType', + 'size', + 'modified' + ], + self::CAPABILITY_NODE_LIST_RANGE => [ + 'tally' => ['absolute', 'relative'] + ], + self::CAPABILITY_NODE_DELTA => true, + ]; + + public function __construct( + private readonly MetaStore $metaStore, + private readonly BlobStore $blobStore, + ) {} + + public function jsonSerialize(): mixed { + return [ + self::JSON_PROPERTY_TYPE => self::JSON_TYPE, + self::JSON_PROPERTY_PROVIDER => self::SERVICE_PROVIDER, + self::JSON_PROPERTY_ID => self::SERVICE_ID, + self::JSON_PROPERTY_LABEL => self::SERVICE_LABEL, + self::JSON_PROPERTY_CAPABILITIES => $this->serviceAbilities, + self::JSON_PROPERTY_ENABLED => $this->serviceEnabled, + ]; + } + + public function init(string $tenantId, string $userId, string $root): self { + $this->serviceTenantId = $tenantId; + $this->serviceUserId = $userId; + $this->serviceRoot = $root; + + // configure blob store with root path + $this->blobStore->configureRoot($root); + + $this->serviceCollectionCache = []; + $root = new NodeCollection(); + $root->fromStore([ + 'tid' => $tenantId, + 'uid' => $userId, + 'nid' => self::ROOT_ID, + 'pid' => null, + 'createdBy' => $userId, + 'createdOn' => (new \DateTimeImmutable())->format('c'), + 'owner' => $userId, + 'label' => self::ROOT_LABEL, + ]); + $this->serviceCollectionCache[self::ROOT_ID] = $root; + + return $this; + } + + public function capable(string $value): bool { + if (isset($this->serviceAbilities[$value])) { + return (bool)$this->serviceAbilities[$value]; + } + return false; + } + + public function capabilities(): array { + return $this->serviceAbilities; + } + + public function in(): string { + return self::SERVICE_PROVIDER; + } + + public function id(): string { + return self::SERVICE_ID; + } + + public function getLabel(): string { + return (string)self::SERVICE_LABEL; + } + + public function getEnabled(): bool { + return (bool)$this->serviceEnabled; + } + + // ==================== Collection Methods (IServiceBase) ==================== + + /** + * @inheritdoc + */ + public function collectionList(string|int|null $location = null, ?IFilter $filter = null, ?ISort $sort = null): array { + $entries = $this->metaStore->collectionList($this->serviceTenantId, $this->serviceUserId, $location, $filter, $sort); + // cache collections + foreach ($entries as $id => $collection) { + $this->serviceCollectionCache[$id] = $collection; + } + return $entries ?? []; + } + + /** + * @inheritdoc + */ + public function collectionListFilter(): IFilter { + return new Filter($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_FILTER] ?? []); + } + + /** + * @inheritdoc + */ + public function collectionListSort(): ISort { + return new Sort($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_SORT] ?? []); + } + + /** + * @inheritdoc + */ + public function collectionExtant(string|int|null $identifier): bool { + // null is root + if ($identifier === null) { + return true; + } + // check cache first + if (isset($this->serviceCollectionCache[$identifier])) { + return true; + } + // check store + return $this->metaStore->collectionExtant($this->serviceTenantId, $this->serviceUserId, $identifier); + } + + /** + * @inheritdoc + */ + public function collectionFetch(string|int|null $identifier): ?INodeCollectionBase { + // null is root + if ($identifier === null) { + $identifier = self::ROOT_ID; + } + // check cache first + if (isset($this->serviceCollectionCache[$identifier])) { + return $this->serviceCollectionCache[$identifier]; + } + // fetch from store + $collections = $this->metaStore->collectionFetch($this->serviceTenantId, $this->serviceUserId, $identifier); + if (isset($collections[$identifier])) { + $this->serviceCollectionCache[$identifier] = $collections[$identifier]; + return $collections[$identifier]; + } + return null; + } + + // ==================== Collection Methods (IServiceCollectionMutable) ==================== + + /** + * @inheritdoc + */ + public function collectionFresh(): INodeCollectionMutable { + $collection = new NodeCollection(); + $collection->fromStore([ + 'tid' => $this->serviceTenantId, + 'uid' => $this->serviceUserId, + 'nid' => null, + 'pid' => null, + 'createdBy' => $this->serviceUserId, + 'createdOn' => (new \DateTimeImmutable())->format('c'), + 'owner' => $this->serviceUserId, + 'label' => '', + ]); + return $collection; + } + + /** + * @inheritdoc + */ + public function collectionCreate(string|int|null $location, INodeCollectionMutable $collection, array $options = []): INodeCollectionBase { + // null is root + if ($location === null) { + $location = self::ROOT_ID; + } + // Create in meta store + $node = $this->metaStore->collectionCreate($this->serviceTenantId, $this->serviceUserId, $location, $collection, $options); + // cache collection + $this->serviceCollectionCache[$node->id()] = $node; + return $node; + } + + /** + * @inheritdoc + */ + public function collectionModify(string|int $identifier, INodeCollectionMutable $collection): INodeCollectionBase { + // Modify in meta store + $node = $this->metaStore->collectionModify($this->serviceTenantId, $this->serviceUserId, $identifier, $collection); + + // update cache + $this->serviceCollectionCache[$node->id()] = $node; + return $node; + } + + /** + * @inheritdoc + */ + public function collectionDestroy(string|int $identifier): bool { + // Protect root collection + if ($identifier === self::ROOT_ID) { + throw new InvalidParameterException("Cannot destroy root collection"); + } + + // Delete from meta store + $result = $this->metaStore->collectionDestroy($this->serviceTenantId, $this->serviceUserId, $identifier); + + // remove from cache + unset($this->serviceCollectionCache[$identifier]); + return $result; + } + + /** + * @inheritdoc + */ + public function collectionCopy(string|int $identifier, string|int|null $location): INodeCollectionBase { + // Protect root collection + if ($identifier === self::ROOT_ID) { + throw new InvalidParameterException("Cannot copy root collection"); + } + // Verify collection exists + if (!$this->collectionExtant($identifier)) { + throw new InvalidParameterException("Collection not found: $identifier"); + } + + // null location is root + if ($location === null) { + $location = self::ROOT_ID; + } + // Copy in meta store + $node = $this->metaStore->collectionCopy($this->serviceTenantId, $this->serviceUserId, $identifier, $location); + + // cache collection + $this->serviceCollectionCache[$node->id()] = $node; + return $node; + } + + /** + * @inheritdoc + */ + public function collectionMove(string|int $identifier, string|int|null $location): INodeCollectionBase { + // Protect root collection + if ($identifier === self::ROOT_ID) { + throw new InvalidParameterException("Cannot move root collection"); + } + // Verify collection exists + if (!$this->collectionExtant($identifier)) { + throw new InvalidParameterException("Collection not found: $identifier"); + } + + // null location is root + if ($location === null) { + $location = self::ROOT_ID; + } + // Move in meta store + $node = $this->metaStore->collectionMove($this->serviceTenantId, $this->serviceUserId, $identifier, $location); + + // update cache + $this->serviceCollectionCache[$node->id()] = $node; + return $node; + } + + // ==================== Entity Methods (IServiceBase) ==================== + + /** + * @inheritdoc + */ + public function entityList(string|int|null $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): array { + // null collection is root + if ($collection === null) { + $collection = self::ROOT_ID; + } + + $entries = $this->metaStore->entityList($this->serviceTenantId, $this->serviceUserId, $collection, $filter, $sort, $range); + // cache entities + foreach ($entries as $id => $entity) { + $this->serviceEntityCache[$id] = $entity; + } + return $entries ?? []; + } + + /** + * @inheritdoc + */ + public function entityListFilter(): IFilter { + return new Filter($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_FILTER] ?? []); + } + + /** + * @inheritdoc + */ + public function entityListSort(): ISort { + return new Sort($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_SORT] ?? []); + } + + /** + * @inheritdoc + */ + public function entityListRange(RangeType $type): IRange { + if ($type !== RangeType::TALLY) { + throw new InvalidParameterException("Invalid: Entity range of type '{$type->value}' is not supported"); + } + return new RangeTally(); + } + + /** + * @inheritdoc + */ + public function entityDelta(string|int|null $collection, string $signature, string $detail = 'ids'): array { + // null collection is root + if ($collection === null) { + $collection = self::ROOT_ID; + } + return $this->metaStore->entityDelta($this->serviceTenantId, $this->serviceUserId, $collection, $signature, $detail); + } + + /** + * @inheritdoc + */ + public function entityExtant(string|int|null $collection, string|int ...$identifiers): array { + // null collection is root + if ($collection === null) { + $collection = self::ROOT_ID; + } + + $result = []; + foreach ($identifiers as $id) { + // check cache first + if (isset($this->serviceEntityCache[$id])) { + $result[$id] = true; + continue; + } + // check store + $result[$id] = $this->metaStore->entityExtant($this->serviceTenantId, $this->serviceUserId, $collection, $id); + } + + return $result; + } + + /** + * @inheritdoc + */ + public function entityFetch(string|int|null $collection, string|int ...$identifiers): array { + // null collection is root + if ($collection === null) { + $collection = self::ROOT_ID; + } + + $result = []; + $toFetch = []; + + // check cache first + foreach ($identifiers as $id) { + if (isset($this->serviceEntityCache[$id])) { + $result[$id] = $this->serviceEntityCache[$id]; + } else { + $toFetch[] = $id; + } + } + + // fetch remaining from store + if (!empty($toFetch)) { + $fetched = $this->metaStore->entityFetch($this->serviceTenantId, $this->serviceUserId, $collection, ...$toFetch); + foreach ($fetched as $id => $entity) { + $this->serviceEntityCache[$id] = $entity; + $result[$id] = $entity; + } + } + + return $result; + } + + /** + * @inheritdoc + */ + public function entityRead(string|int|null $collection, string|int $identifier): ?string { + // null collection is root + if ($collection === null) { + $collection = self::ROOT_ID; + } + + $entities = $this->entityFetch($collection, $identifier); + if (!isset($entities[$identifier])) { + return null; + } + return $this->blobStore->blobRead((string)$identifier); + } + + /** + * @inheritdoc + */ + public function entityReadStream(string|int|null $collection, string|int $identifier) { + // null collection is root + if ($collection === null) { + $collection = self::ROOT_ID; + } + + $entities = $this->entityFetch($collection, $identifier); + if (!isset($entities[$identifier])) { + return null; + } + + return $this->blobStore->blobReadStream((string)$identifier); + } + + /** + * @inheritdoc + */ + public function entityReadChunk(string|int|null $collection, string|int $identifier, int $offset, int $length): ?string { + // null collection is root + if ($collection === null) { + $collection = self::ROOT_ID; + } + + $entities = $this->entityFetch($collection, $identifier); + if (!isset($entities[$identifier])) { + return null; + } + + return $this->blobStore->blobReadChunk((string)$identifier, $offset, $length); + } + + // ==================== Entity Methods (IServiceEntityMutable) ==================== + + /** + * @inheritdoc + */ + public function entityFresh(): INodeEntityMutable { + $entity = new NodeEntity(); + $entity->fromStore([ + 'tid' => $this->serviceTenantId, + 'uid' => $this->serviceUserId, + 'nid' => null, + 'pid' => null, + 'createdBy' => $this->serviceUserId, + 'createdOn' => (new \DateTimeImmutable())->format('c'), + 'owner' => $this->serviceUserId, + 'label' => '', + ]); + return $entity; + } + + /** + * Build metadata array for an entity to store in .meta file + * + * @param INodeEntityBase $node The entity node + * @return array Metadata array + */ + protected function buildEntityMeta(INodeEntityBase $node): array { + return [ + 'nid' => $node->id(), + 'pid' => $node->in(), + 'label' => $node->getLabel(), + 'mime' => $node->getMime(), + 'format' => $node->getFormat(), + 'createdBy' => $node->createdBy(), + 'createdOn' => $node->createdOn()?->format('c'), + 'modifiedBy' => $node->modifiedBy(), + 'modifiedOn' => $node->modifiedOn()?->format('c'), + ]; + } + + /** + * @inheritdoc + */ + public function entityCreate(string|int|null $collection, INodeEntityMutable $entity, array $options = []): INodeEntityBase { + // null collection is root + if ($collection === null) { + $collection = self::ROOT_ID; + } + + // Create in meta store + $node = $this->metaStore->entityCreate($this->serviceTenantId, $this->serviceUserId, $collection, $entity, $options); + + // Write meta file for recovery + $this->blobStore->metaWrite((string)$node->id(), $this->buildEntityMeta($node)); + + // cache entity + $this->serviceEntityCache[$node->id()] = $node; + return $node; + } + + /** + * @inheritdoc + */ + public function entityModify(string|int|null $collection, string|int $identifier, INodeEntityMutable $entity): INodeEntityBase { + // null collection is root + if ($collection === null) { + $collection = self::ROOT_ID; + } + + // Modify in meta store + $node = $this->metaStore->entityModify($this->serviceTenantId, $this->serviceUserId, $collection, $identifier, $entity); + + // Update meta file for recovery + $this->blobStore->metaWrite((string)$node->id(), $this->buildEntityMeta($node)); + + // update cache + $this->serviceEntityCache[$node->id()] = $node; + return $node; + } + + /** + * @inheritdoc + */ + public function entityDestroy(string|int|null $collection, string|int $identifier): bool { + // null collection is root + if ($collection === null) { + $collection = self::ROOT_ID; + } + + // Delete from meta store + $result = $this->metaStore->entityDestroy($this->serviceTenantId, $this->serviceUserId, $collection, $identifier); + + // Delete file from filesystem + if ($result) { + $this->blobStore->blobDelete((string)$identifier); + } + + // remove from cache + unset($this->serviceEntityCache[$identifier]); + return $result; + } + + /** + * @inheritdoc + */ + public function entityCopy(string|int|null $collection, string|int $identifier, string|int|null $destination): INodeEntityBase { + // null collection is root + if ($collection === null) { + $collection = self::ROOT_ID; + } + // null destination is root + if ($destination === null) { + $destination = self::ROOT_ID; + } + + // Verify entity exists + $extant = $this->entityExtant($collection, $identifier); + if (!($extant[$identifier] ?? false)) { + throw new InvalidParameterException("Entity not found: $identifier"); + } + + // Copy in meta store (creates new entity with new ID) + $node = $this->metaStore->entityCopy($this->serviceTenantId, $this->serviceUserId, $collection, $identifier, $destination); + + // Copy file content (new entity has new UUID = new file) + $content = $this->blobStore->blobRead((string)$identifier); + if ($content !== null) { + $this->blobStore->blobWrite((string)$node->id(), $content); + } + + // Write meta file for recovery + $this->blobStore->metaWrite((string)$node->id(), $this->buildEntityMeta($node)); + + // cache entity + $this->serviceEntityCache[$node->id()] = $node; + return $node; + } + + /** + * @inheritdoc + */ + public function entityMove(string|int|null $collection, string|int $identifier, string|int|null $destination): INodeEntityBase { + // null collection is root + if ($collection === null) { + $collection = self::ROOT_ID; + } + // null destination is root + if ($destination === null) { + $destination = self::ROOT_ID; + } + + // Verify entity exists + $extant = $this->entityExtant($collection, $identifier); + if (!($extant[$identifier] ?? false)) { + throw new InvalidParameterException("Entity not found: $identifier"); + } + + // Move in meta store (file path never changes since it's based on entity ID) + $node = $this->metaStore->entityMove($this->serviceTenantId, $this->serviceUserId, $collection, $identifier, $destination); + + // Update meta file for recovery (parent changed) + $this->blobStore->metaWrite((string)$node->id(), $this->buildEntityMeta($node)); + + // update cache + $this->serviceEntityCache[$node->id()] = $node; + return $node; + } + + /** + * @inheritdoc + */ + public function entityWrite(string|int|null $collection, string|int $identifier, string $data): int { + // null collection is root + if ($collection === null) { + $collection = self::ROOT_ID; + } + + // Verify entity exists + $extant = $this->entityExtant($collection, $identifier); + if (!($extant[$identifier] ?? false)) { + throw new InvalidParameterException("Entity not found: $identifier"); + } + + // Detect MIME type and format from content header + $signature = Signature::detect(substr($data, 0, Signature::HEADER_SIZE)); + + $bytes = $this->blobStore->blobWrite((string)$identifier, $data); + if ($bytes === null) { + throw new \RuntimeException("Failed to write to entity: $identifier"); + } + + // Update metadata (size, mime, and format) + $this->metaStore->entityUpdate($this->serviceTenantId, $this->serviceUserId, $collection, $identifier, [ + 'size' => $bytes, + 'mime' => $signature['mime'], + 'format' => $signature['format'], + ]); + + // Update meta file + $entities = $this->entityFetch($collection, $identifier); + if (isset($entities[$identifier])) { + $this->blobStore->metaWrite((string)$identifier, $this->buildEntityMeta($entities[$identifier])); + } + + return $bytes; + } + + /** + * @inheritdoc + */ + public function entityWriteStream(string|int|null $collection, string|int $identifier) { + // null collection is root + if ($collection === null) { + $collection = self::ROOT_ID; + } + + // Verify entity exists + $extant = $this->entityExtant($collection, $identifier); + if (!($extant[$identifier] ?? false)) { + throw new InvalidParameterException("Entity not found: $identifier"); + } + + return $this->blobStore->blobWriteStream((string)$identifier); + } + + /** + * @inheritdoc + */ + public function entityWriteChunk(string|int|null $collection, string|int $identifier, int $offset, string $data): int { + // null collection is root + if ($collection === null) { + $collection = self::ROOT_ID; + } + + // Verify entity exists + $extant = $this->entityExtant($collection, $identifier); + if (!($extant[$identifier] ?? false)) { + throw new InvalidParameterException("Entity not found: $identifier"); + } + + // Detect MIME type and format from first chunk (offset === 0) + $signature = null; + if ($offset === 0) { + $signature = Signature::detect(substr($data, 0, Signature::HEADER_SIZE)); + } + + $bytes = $this->blobStore->blobWriteChunk((string)$identifier, $offset, $data); + if ($bytes === null) { + throw new \RuntimeException("Failed to write chunk to entity: $identifier"); + } + + // Build update attributes + $updates = []; + + // Update size metadata + $newSize = $this->blobStore->blobSize((string)$identifier); + if ($newSize !== null) { + $updates['size'] = $newSize; + } + + // Update mime and format if detected (first chunk) + if ($signature !== null) { + $updates['mime'] = $signature['mime']; + $updates['format'] = $signature['format']; + } + + // Perform single database update + if (!empty($updates)) { + $this->metaStore->entityUpdate($this->serviceTenantId, $this->serviceUserId, $collection, $identifier, $updates); + } + + // Update meta file + $entities = $this->entityFetch($collection, $identifier); + if (isset($entities[$identifier])) { + $this->blobStore->metaWrite((string)$identifier, $this->buildEntityMeta($entities[$identifier])); + } + + return $bytes; + } + + // ==================== Node Methods (IServiceBase) ==================== + + /** + * @inheritdoc + */ + public function nodeList(string|int|null $location = null, bool $recursive = false, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): array { + // null location is root + if ($location === null) { + $location = self::ROOT_ID; + } + return $this->metaStore->nodeList($this->serviceTenantId, $this->serviceUserId, $location, $recursive, $filter, $sort, $range); + } + + /** + * @inheritdoc + */ + public function nodeListFilter(): IFilter { + return new Filter($this->serviceAbilities[self::CAPABILITY_NODE_LIST_FILTER] ?? []); + } + + /** + * @inheritdoc + */ + public function nodeListSort(): ISort { + return new Sort($this->serviceAbilities[self::CAPABILITY_NODE_LIST_SORT] ?? []); + } + + /** + * @inheritdoc + */ + public function nodeListRange(RangeType $type): IRange { + if ($type !== RangeType::TALLY) { + throw new InvalidParameterException("Invalid: Node range of type '{$type->value}' is not supported"); + } + return new RangeTally(); + } + + /** + * @inheritdoc + */ + public function nodeDelta(string|int|null $location, string $signature, bool $recursive = false, string $detail = 'ids'): array { + return $this->metaStore->nodeDelta($this->serviceTenantId, $this->serviceUserId, $location, $signature, $recursive, $detail); + } + +} diff --git a/lib/Providers/Provider.php b/lib/Providers/Provider.php new file mode 100644 index 0000000..5a56100 --- /dev/null +++ b/lib/Providers/Provider.php @@ -0,0 +1,214 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\FileProviderLocal\Providers; + +use DI\Attribute\Inject; +use Psr\Container\ContainerInterface; +use KTXF\Files\Provider\IProviderBase; +use KTXF\Files\Service\IServiceBase; +use KTXF\Resource\Provider\ProviderInterface; +use KTXM\FileProviderLocal\Providers\Personal\PersonalService; + +class Provider implements IProviderBase, ProviderInterface { + + protected const PROVIDER_ID = 'default'; + protected const PROVIDER_LABEL = 'Default File Provider'; + protected const PROVIDER_DESCRIPTION = 'Provides local file storage'; + protected const PROVIDER_ICON = 'folder'; + + protected string $storeLocation = '/tmp/ktrix'; + + protected array $providerAbilities = [ + self::CAPABILITY_SERVICE_LIST => true, + self::CAPABILITY_SERVICE_FETCH => true, + self::CAPABILITY_SERVICE_EXTANT => true, + ]; + private ?array $servicesCache = []; + + public function __construct( + private readonly ContainerInterface $container, + #[Inject('rootDir')] private readonly string $rootDir, + ) { + $this->storeLocation = $this->rootDir . '/storage/'; + } + + /** + * @inheritDoc + */ + public function type(): string { + return ProviderInterface::TYPE_FILES; + } + + /** + * @inheritDoc + */ + public function identifier(): string { + return self::PROVIDER_ID; + } + + /** + * @inheritDoc + */ + public function description(): string { + return self::PROVIDER_DESCRIPTION; + } + + /** + * @inheritDoc + */ + public function icon(): string { + return self::PROVIDER_ICON; + } + + public function jsonSerialize(): mixed { + return $this->toJson(); + } + + public function toJson(): array { + return [ + self::JSON_PROPERTY_TYPE => self::JSON_TYPE, + self::JSON_PROPERTY_ID => self::PROVIDER_ID, + self::JSON_PROPERTY_LABEL => self::PROVIDER_LABEL, + self::JSON_PROPERTY_CAPABILITIES => $this->providerAbilities, + ]; + } + + /** + * Confirms if specific capability is supported + * + * @since 1.0.0 + * + * @inheritdoc + */ + public function capable(string $value): bool { + if (isset($this->providerAbilities[$value])) { + return (bool)$this->providerAbilities[$value]; + } + return false; + } + + /** + * Lists all supported capabilities + * + * @since 1.0.0 + * + * @inheritdoc + */ + public function capabilities(): array { + return $this->providerAbilities; + } + + /** + * An arbitrary unique text string identifying this provider + * + * @since 1.0.0 + * + * @inheritdoc + */ + public function id(): string { + return self::PROVIDER_ID; + } + + /** + * The localized human friendly name of this provider + * + * @since 1.0.0 + * + * @inheritdoc + */ + public function label(): string { + return self::PROVIDER_LABEL; + } + + /** + * Retrieve collection of services for a specific user + * + * @since 1.0.0 + * + * @inheritdoc + */ + public function serviceList(string $tenantId, string $userId, array $filter = []): array { + // if no filter is provided, return all services + if ($filter === []) { + $filter = ['personal', 'shared']; + } + // check if services are cached + if (in_array('personal', $filter, true) && !isset($this->servicesCache[$userId]['personal'])) { + $this->servicesCache[$userId]['personal'] = $this->serviceInstancePersonal($tenantId, $userId); + } + /* + if (in_array('shared', $filter, true) && !isset($this->servicesCache[$userId]['shared'])) { + $this->servicesCache[$userId]['shared'] = $this->serviceInstanceShared($tenantId, $userId); + } + */ + // return requested services + return array_intersect_key($this->servicesCache[$userId],array_flip($filter)); + } + + /** + * construct service object instance + * + * @since 1.0.0 + * + * @return PersonalService blank service instance + */ + protected function serviceInstancePersonal(string $tenantId, string $userId): PersonalService { + $service = $this->container->get(PersonalService::class); + $service->init($tenantId, $userId, $this->storeLocation . "$tenantId/$userId"); + return $service; + } + + /** + * Determine if any services are configured for a specific user + * + * @since 1.0.0 + * + * @inheritdoc + */ + public function serviceExtant(string $tenantId, string $userId, int|string ...$identifiers): array { + $data = []; + foreach ($identifiers as $id) { + $data[$id] = match ($id) { + 'personal' => true, + //'shared' => true, + default => false, + }; + } + return $data; + } + + /** + * Retrieve a service with a specific identifier + * + * @since 1.0.0 + * + * @inheritdoc + */ + public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?IServiceBase { + + // check if services are cached + if (isset($this->servicesCache[$userId][$identifier])) { + return $this->servicesCache[$userId][$identifier]; + } + // convert to service object + if ($identifier === 'personal') { + $this->servicesCache[$userId][$identifier] = $this->serviceInstancePersonal($tenantId, $userId); + } + /* + if ($identifier === 'shared') { + $this->servicesCache[$userId][$identifier] = $this->serviceInstanceShared($tenantId, $userId); + } + */ + + return $this->servicesCache[$userId][$identifier]; + + } + +} diff --git a/lib/Store/BlobStore.php b/lib/Store/BlobStore.php new file mode 100644 index 0000000..46d716c --- /dev/null +++ b/lib/Store/BlobStore.php @@ -0,0 +1,356 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\FileProviderLocal\Store; + +use RuntimeException; + +/** + * BlobStore - Handles file read/write operations on the local filesystem + * + * Files are stored using a UUID-based sharded folder structure where the first + * N characters (defined by SHARD_PREFIX_LENGTH) of the entity ID are used as + * a subdirectory to distribute files and prevent filesystem performance issues. + */ +class BlobStore { + + private const BLOB_EXTENSION = '.blob'; + private const META_EXTENSION = '.meta'; + private const SHARD_PREFIX_LENGTH = 2; + + private ?string $rootPath = null; + private int $defaultFolderPermissions = 0755; + + /** + * Configure the root path for file storage + * + * @param string $rootPath Base path for all file operations + * @return self + * @throws RuntimeException If root path doesn't exist and can't be created + */ + public function configureRoot(string $rootPath): self { + $this->rootPath = rtrim($rootPath, DIRECTORY_SEPARATOR); + + if (!is_dir($this->rootPath)) { + if (!mkdir($this->rootPath, $this->defaultFolderPermissions, true)) { + throw new RuntimeException("Failed to create root path: {$this->rootPath}"); + } + } + + return $this; + } + + /** + * Generate the absolute path for an entity's blob file + * Uses first 4 characters of ID as subdirectory for distribution + * + * @param string $id The entity ID (UUID) + * @return string Absolute file path with .blob extension + * @throws RuntimeException If root path not configured + */ + protected function blobPathFromId(string $id): string { + if ($this->rootPath === null) { + throw new RuntimeException("BlobStore root path not configured. Call configureRoot() first."); + } + $prefix = substr($id, 0, self::SHARD_PREFIX_LENGTH); + return $this->rootPath . DIRECTORY_SEPARATOR . $prefix . DIRECTORY_SEPARATOR . $id . self::BLOB_EXTENSION; + } + + /** + * Generate the absolute path for an entity's meta file + * Uses first 4 characters of ID as subdirectory for distribution + * + * @param string $id The entity ID (UUID) + * @return string Absolute file path with .meta extension + * @throws RuntimeException If root path not configured + */ + protected function metaPathFromId(string $id): string { + if ($this->rootPath === null) { + throw new RuntimeException("BlobStore root path not configured. Call configureRoot() first."); + } + $prefix = substr($id, 0, self::SHARD_PREFIX_LENGTH); + return $this->rootPath . DIRECTORY_SEPARATOR . $prefix . DIRECTORY_SEPARATOR . $id . self::META_EXTENSION; + } + + /** + * Ensure parent directory exists for a path + */ + protected function ensureDirectory(string $absPath): bool { + $parentDir = dirname($absPath); + if (!is_dir($parentDir)) { + return mkdir($parentDir, $this->defaultFolderPermissions, true); + } + return true; + } + + // ========== Blob Read Operations ========== + + /** + * Read entire file contents by entity ID + * + * @param string $id Entity ID + * @return string|null File contents or null if not found + */ + public function blobRead(string $id): ?string { + $absPath = $this->blobPathFromId($id); + + if (!is_file($absPath)) { + return null; + } + + $content = file_get_contents($absPath); + return $content !== false ? $content : null; + } + + /** + * Read file chunk by entity ID + * + * @param string $id Entity ID + * @param int $offset Start position + * @param int $length Number of bytes to read + * @return string|null Chunk contents or null if not found + */ + public function blobReadChunk(string $id, int $offset, int $length): ?string { + $absPath = $this->blobPathFromId($id); + + if (!is_file($absPath)) { + return null; + } + + $handle = fopen($absPath, 'rb'); + if ($handle === false) { + return null; + } + + if ($offset > 0) { + fseek($handle, $offset); + } + + $content = fread($handle, $length); + fclose($handle); + + return $content !== false ? $content : null; + } + + /** + * Open read stream by entity ID + * + * @param string $id Entity ID + * @return resource|null File handle or null if not found + */ + public function blobReadStream(string $id) { + $absPath = $this->blobPathFromId($id); + + if (!is_file($absPath)) { + return null; + } + + $handle = fopen($absPath, 'rb'); + return $handle !== false ? $handle : null; + } + + /** + * Get file size by entity ID + * + * @param string $id Entity ID + * @return int|null Size in bytes or null if not found + */ + public function blobSize(string $id): ?int { + $absPath = $this->blobPathFromId($id); + + if (!is_file($absPath)) { + return null; + } + + $size = filesize($absPath); + return $size !== false ? $size : null; + } + + // ========== Blob Write Operations ========== + + /** + * Write content to file by entity ID + * + * @param string $id Entity ID + * @param string $content Content to write + * @return int|null Number of bytes written or null on failure + */ + public function blobWrite(string $id, string $content): ?int { + $absPath = $this->blobPathFromId($id); + + if (!$this->ensureDirectory($absPath)) { + return null; + } + + $bytes = file_put_contents($absPath, $content); + return $bytes !== false ? $bytes : null; + } + + /** + * Write content at specific position by entity ID + * + * @param string $id Entity ID + * @param int $offset Position to write at + * @param string $content Content to write + * @return int|null Number of bytes written or null on failure + */ + public function blobWriteChunk(string $id, int $offset, string $content): ?int { + $absPath = $this->blobPathFromId($id); + + if (!$this->ensureDirectory($absPath)) { + return null; + } + + // Create file if it doesn't exist + if (!is_file($absPath)) { + touch($absPath); + } + + $handle = fopen($absPath, 'r+b'); + if ($handle === false) { + return null; + } + + fseek($handle, $offset); + $bytes = fwrite($handle, $content); + fclose($handle); + + return $bytes !== false ? $bytes : null; + } + + /** + * Open write stream by entity ID + * + * @param string $id Entity ID + * @return resource|null File handle or null on failure + */ + public function blobWriteStream(string $id) { + $absPath = $this->blobPathFromId($id); + + if (!$this->ensureDirectory($absPath)) { + return null; + } + + $handle = fopen($absPath, 'wb'); + return $handle !== false ? $handle : null; + } + + // ========== Blob Delete Operations ========== + + /** + * Delete file by entity ID (both blob and meta files) + * + * @param string $id Entity ID + * + * @return bool Success (true if deleted or didn't exist) + */ + public function blobDelete(string $id): bool { + $blobPath = $this->blobPathFromId($id); + $metaPath = $this->metaPathFromId($id); + + $blobDeleted = true; + $metaDeleted = true; + + if (is_file($blobPath)) { + $blobDeleted = unlink($blobPath); + } + + if (is_file($metaPath)) { + $metaDeleted = unlink($metaPath); + } + + return $blobDeleted && $metaDeleted; + } + + // ========== Blob Existence Check ========== + + /** + * Check if blob file exists by entity ID + * + * @param string $id Entity ID + * @return bool + */ + public function blobExtant(string $id): bool { + return is_file($this->blobPathFromId($id)); + } + + // ========== Meta File Operations ========== + + /** + * Write metadata to file by entity ID + * + * @param string $id Entity ID + * @param array $metadata Metadata array to store + * @return bool Success + */ + public function metaWrite(string $id, array $metadata): bool { + $absPath = $this->metaPathFromId($id); + + if (!$this->ensureDirectory($absPath)) { + return false; + } + + $json = json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($json === false) { + return false; + } + + $bytes = file_put_contents($absPath, $json); + return $bytes !== false; + } + + /** + * Read metadata from file by entity ID + * + * @param string $id Entity ID + * @return array|null Metadata array or null if not found + */ + public function metaRead(string $id): ?array { + $absPath = $this->metaPathFromId($id); + + if (!is_file($absPath)) { + return null; + } + + $content = file_get_contents($absPath); + if ($content === false) { + return null; + } + + $metadata = json_decode($content, true); + return is_array($metadata) ? $metadata : null; + } + + /** + * Check if meta file exists by entity ID + * + * @param string $id Entity ID + * @return bool + */ + public function metaExtant(string $id): bool { + return is_file($this->metaPathFromId($id)); + } + + /** + * Delete only the meta file by entity ID + * + * @param string $id Entity ID + * @return bool Success (true if deleted or didn't exist) + */ + public function metaDelete(string $id): bool { + $absPath = $this->metaPathFromId($id); + + if (!is_file($absPath)) { + return true; + } + + return unlink($absPath); + } + +} diff --git a/lib/Store/MetaStore.php b/lib/Store/MetaStore.php new file mode 100644 index 0000000..53e41b7 --- /dev/null +++ b/lib/Store/MetaStore.php @@ -0,0 +1,808 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\FileProviderLocal\Store; + +use KTXC\Db\DataStore; +use KTXF\Files\Node\INodeCollectionMutable; +use KTXF\Files\Node\INodeEntityMutable; +use KTXF\Files\Node\NodeType; +use KTXF\Resource\Filter\Filter; +use KTXF\Resource\Filter\FilterComparisonOperator; +use KTXF\Resource\Filter\FilterConjunctionOperator; +use KTXF\Resource\Range\IRange; +use KTXF\Resource\Range\IRangeTally; +use KTXF\Resource\Range\RangeType; +use KTXF\Resource\Sort\Sort; +use KTXF\Utile\UUID; +use KTXM\FileProviderLocal\Providers\Personal\NodeCollection; +use KTXM\FileProviderLocal\Providers\Personal\NodeEntity; + +class MetaStore { + + protected string $_NodeTable = 'file_provider_local_node'; + protected string $_ChronicleTable = 'file_provider_local_chronicle'; + + protected array $_CollectionFilterAttributeMap = [ + 'id' => 'nid', + 'label' => 'name', + 'parent' => 'pid', + ]; + + protected array $_EntityFilterAttributeMap = [ + 'id' => 'nid', + 'label' => 'name', + ]; + + protected array $_NodeFilterAttributeMap = [ + 'id' => 'nid', + 'label' => 'name', + 'type' => 'type', + 'parent' => 'pid', + ]; + + protected array $_SortAttributeMap = [ + 'label' => 'name', + 'type' => 'type', + 'size' => 'size', + 'type' => 'type', + 'createdOn' => 'createdOn', + 'modifiedOn' => 'modifiedOn', + ]; + + public function __construct( + protected readonly DataStore $_store + ) { } + + protected function constructFilter(array $map, Filter $filter): array { + $mongoFilter = []; + + foreach ($filter->conditions() as $entry) { + if (!isset($map[$entry['attribute']])) { + continue; + } + + $attribute = $map[$entry['attribute']]; + $value = $entry['value']; + $comparator = $entry['comparator'] ?? FilterComparisonOperator::EQ; + + $condition = match ($comparator) { + FilterComparisonOperator::EQ => $value, + FilterComparisonOperator::NEQ => ['$ne' => $value], + FilterComparisonOperator::GT => ['$gt' => $value], + FilterComparisonOperator::GTE => ['$gte' => $value], + FilterComparisonOperator::LT => ['$lt' => $value], + FilterComparisonOperator::LTE => ['$lte' => $value], + FilterComparisonOperator::IN => ['$in' => is_array($value) ? $value : [$value]], + FilterComparisonOperator::NIN => ['$nin' => is_array($value) ? $value : [$value]], + FilterComparisonOperator::LIKE => ['$regex' => $value, '$options' => 'i'], + FilterComparisonOperator::NLIKE => ['$not' => ['$regex' => $value, '$options' => 'i']], + default => $value + }; + + if (isset($mongoFilter[$attribute])) { + if ($entry['conjunction'] === FilterConjunctionOperator::OR) { + $mongoFilter['$or'][] = [$attribute => $condition]; + } else { + if (is_array($mongoFilter[$attribute]) && !isset($mongoFilter[$attribute]['$and'])) { + $mongoFilter[$attribute] = ['$and' => [$mongoFilter[$attribute], $condition]]; + } else { + $mongoFilter[$attribute] = $condition; + } + } + } else { + $mongoFilter[$attribute] = $condition; + } + } + + return $mongoFilter; + } + + protected function constructSort(array $map, Sort $sort): array { + $mongoSort = []; + + foreach ($sort->conditions() as $entry) { + if (!isset($map[$entry['attribute']])) { + continue; + } + + $attribute = $map[$entry['attribute']]; + $direction = $entry['direction'] ? 1 : -1; + $mongoSort[$attribute] = $direction; + } + + return $mongoSort; + } + + // ========== Collection Operations ========== + + /** + * List collections from data store + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param string|int|null $location parent collection (null for root) + * @param Filter|null $filter filter options + * @param Sort|null $sort sort options + * + * @return array + */ + public function collectionList(string $tenantId, string $userId, string|int|null $location = null, ?Filter $filter = null, ?Sort $sort = null): array { + $query = [ + 'tid' => $tenantId, + 'uid' => $userId, + 'pid' => $location, + 'type' => NodeType::Collection->value, + ]; + + if ($filter !== null) { + $filterConditions = $this->constructFilter($this->_CollectionFilterAttributeMap, $filter); + $query = array_merge($query, $filterConditions); + } + + $options = []; + if ($sort !== null) { + $sortConditions = $this->constructSort($this->_SortAttributeMap, $sort); + $options['sort'] = $sortConditions; + } else { + $options['sort'] = ['name' => 1]; + } + + $cursor = $this->_store->selectCollection($this->_NodeTable)->find($query, $options); + $list = []; + foreach ($cursor as $entry) { + $node = (new NodeCollection())->fromStore($entry); + $list[$node->id()] = $node; + } + return $list; + } + + /** + * Check if a collection exists + */ + public function collectionExtant(string $tenantId, string $userId, string|int $identifier): bool { + $cursor = $this->_store->selectCollection($this->_NodeTable)->findOne([ + 'tid' => $tenantId, + 'uid' => $userId, + 'nid' => $identifier, + 'type' => NodeType::Collection->value, + ]); + return $cursor !== null; + } + + /** + * Fetch a collection + */ + public function collectionFetch(string $tenantId, string $userId, string|int ...$identifiers): array { + $cursor = $this->_store->selectCollection($this->_NodeTable)->find([ + 'tid' => $tenantId, + 'uid' => $userId, + 'nid' => ['$in' => $identifiers], + 'type' => NodeType::Collection->value, + ]); + + $list = []; + foreach ($cursor as $entry) { + $node = (new NodeCollection())->fromStore($entry); + $list[$node->id()] = $node; + } + return $list; + } + + /** + * Create a collection + */ + public function collectionCreate(string $tenantId, string $userId, string|int|null $location, INodeCollectionMutable $collection, array $options = []): NodeCollection { + $data = [ + 'tid' => $tenantId, + 'uid' => $userId, + 'nid' => UUID::v4(), + 'pid' => $location, + 'type' => NodeType::Collection->value, + 'createdBy' => $userId, + 'createdOn' => date('c'), + 'modifiedBy' => $userId, + 'modifiedOn' => date('c'), + 'owner' => $userId, + 'label' => $collection->getLabel(), + ]; + $data['signature'] = md5(json_encode([$data['label'], $data['modifiedOn']])); + + $this->_store->selectCollection($this->_NodeTable)->insertOne($data); + $this->chronicleNode($tenantId, $userId, $data['nid'], 1); + + return (new NodeCollection())->fromStore($data); + } + + /** + * Modify a collection + */ + public function collectionModify(string $tenantId, string $userId, string|int $identifier, INodeCollectionMutable $collection): NodeCollection { + $data = [ + 'modifiedOn' => date('c'), + 'modifiedBy' => $userId, + 'label' => $collection->getLabel(), + ]; + $data['signature'] = md5(json_encode([$data['label'], $data['modifiedOn']])); + + $this->_store->selectCollection($this->_NodeTable)->updateOne( + ['tid' => $tenantId, 'uid' => $userId, 'nid' => $identifier], + ['$set' => $data] + ); + $this->chronicleNode($tenantId, $userId, $identifier, 2); + + $collections = $this->collectionFetch($tenantId, $userId, $identifier); + return $collections[$identifier]; + } + + /** + * Destroy a collection and its children + */ + public function collectionDestroy(string $tenantId, string $userId, string|int $identifier): bool { + // Delete children recursively + $children = $this->nodeList($tenantId, $userId, $identifier, true); + foreach ($children as $childId => $child) { + $this->_store->selectCollection($this->_NodeTable)->deleteOne([ + 'tid' => $tenantId, + 'uid' => $userId, + 'nid' => $childId + ]); + $this->chronicleNode($tenantId, $userId, $childId, 3); + } + + // Delete the collection itself + $result = $this->_store->selectCollection($this->_NodeTable)->deleteOne([ + 'tid' => $tenantId, + 'uid' => $userId, + 'nid' => $identifier + ]); + + if ($result->getDeletedCount() === 1) { + $this->chronicleNode($tenantId, $userId, $identifier, 3); + return true; + } + return false; + } + + /** + * Move a collection + */ + public function collectionMove(string $tenantId, string $userId, string|int $identifier, string|int|null $location): NodeCollection { + $data = [ + 'pid' => $location, + 'modifiedBy' => $userId, + 'modifiedOn' => date('c'), + ]; + + $this->_store->selectCollection($this->_NodeTable)->updateOne( + ['tid' => $tenantId, 'uid' => $userId, 'nid' => $identifier], + ['$set' => $data] + ); + $this->chronicleNode($tenantId, $userId, $identifier, 2); + + $collections = $this->collectionFetch($tenantId, $userId, $identifier); + return $collections[$identifier]; + } + + /** + * Copy a collection + */ + public function collectionCopy(string $tenantId, string $userId, string|int $identifier, string|int|null $location): NodeCollection { + $collections = $this->collectionFetch($tenantId, $userId, $identifier); + if (!isset($collections[$identifier])) { + throw new \RuntimeException("Collection not found: $identifier"); + } + + $source = $collections[$identifier]; + $newCollection = new NodeCollection(); + $newCollection->setLabel($source->getLabel()); + + $newNode = $this->collectionCreate($tenantId, $userId, $location, $newCollection); + + // Copy children recursively + $children = $this->nodeList($tenantId, $userId, $identifier, false); + foreach ($children as $childId => $child) { + if ($child->isCollection()) { + $this->collectionCopy($tenantId, $userId, $childId, $newNode->id()); + } else { + $this->entityCopy($tenantId, $userId, $identifier, $childId, $newNode->id()); + } + } + + return $newNode; + } + + // ========== Entity Operations ========== + + /** + * List entities in a collection + */ + public function entityList(string $tenantId, string $userId, string|int $collection, ?Filter $filter = null, ?Sort $sort = null, ?IRange $range = null): array { + $query = [ + 'tid' => $tenantId, + 'uid' => $userId, + 'pid' => $collection, + 'type' => NodeType::Entity->value, + ]; + + if ($filter !== null) { + $filterConditions = $this->constructFilter($this->_EntityFilterAttributeMap, $filter); + $query = array_merge($query, $filterConditions); + } + + $options = []; + if ($sort !== null) { + $sortConditions = $this->constructSort($this->_SortAttributeMap, $sort); + $options['sort'] = $sortConditions; + } else { + $options['sort'] = ['name' => 1]; + } + + if ($range !== null && $range->type() === RangeType::TALLY && $range instanceof IRangeTally) { + $options['skip'] = $range->getPosition(); + $options['limit'] = $range->getTally(); + } + + $cursor = $this->_store->selectCollection($this->_NodeTable)->find($query, $options); + $list = []; + foreach ($cursor as $entry) { + $node = (new NodeEntity())->fromStore($entry); + $list[$node->id()] = $node; + } + return $list; + } + + /** + * Check if an entity exists + */ + public function entityExtant(string $tenantId, string $userId, string|int $collection, string|int $identifier): bool { + $cursor = $this->_store->selectCollection($this->_NodeTable)->findOne([ + 'tid' => $tenantId, + 'uid' => $userId, + 'pid' => $collection, + 'nid' => $identifier, + 'type' => NodeType::Entity->value, + ]); + return $cursor !== null; + } + + /** + * Fetch entities + */ + public function entityFetch(string $tenantId, string $userId, string|int $collection, string|int ...$identifiers): array { + $cursor = $this->_store->selectCollection($this->_NodeTable)->find([ + 'tid' => $tenantId, + 'uid' => $userId, + 'pid' => $collection, + 'nid' => ['$in' => $identifiers], + 'type' => NodeType::Entity->value, + ]); + + $list = []; + foreach ($cursor as $entry) { + $node = (new NodeEntity())->fromStore($entry); + $list[$node->id()] = $node; + } + return $list; + } + + /** + * Create an entity + */ + public function entityCreate(string $tenantId, string $userId, string|int|null $collection, INodeEntityMutable $entity, array $options = []): NodeEntity { + $data = [ + 'tid' => $tenantId, + 'uid' => $userId, + 'nid' => UUID::v4(), + 'pid' => $collection, + 'type' => NodeType::Entity->value, + 'createdOn' => date('c'), + 'createdBy' => $userId, + 'modifiedOn' => date('c'), + 'modifiedBy' => $userId, + 'size' => 0, + 'mime' => $entity->getMime(), + 'format' => $entity->getFormat(), + 'encoding' => $entity->getEncoding(), + 'label' => $entity->getLabel(), + ]; + $data['signature'] = md5(json_encode([$data['label'], $data['size'], $data['mime'], $data['modifiedOn']])); + + $this->_store->selectCollection($this->_NodeTable)->insertOne($data); + $this->chronicleNode($tenantId, $userId, $data['nid'], 1); + + return (new NodeEntity())->fromStore($data); + } + + /** + * Modify an entity + */ + public function entityModify(string $tenantId, string $userId, string|int|null $collection, string|int $identifier, INodeEntityMutable $entity): NodeEntity { + $data = [ + 'label' => $entity->getLabel(), + 'mime' => $entity->getMime(), + 'format' => $entity->getFormat(), + 'encoding' => $entity->getEncoding(), + 'modifiedOn' => date('c'), + 'modifiedBy' => $userId, + ]; + $data['signature'] = md5(json_encode([$data['label'], $data['mime'], $data['modifiedOn']])); + + $this->_store->selectCollection($this->_NodeTable)->updateOne( + ['tid' => $tenantId, 'uid' => $userId, 'nid' => $identifier], + ['$set' => $data] + ); + $this->chronicleNode($tenantId, $userId, $identifier, 2); + + $entities = $this->entityFetch($tenantId, $userId, $collection, $identifier); + return $entities[$identifier]; + } + + /** + * Update entity attributes + * + * Supported attributes: size, format, mime, encoding, label + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param string|int $collection collection identifier + * @param string|int $identifier entity identifier + * @param array $attributes key-value pairs of attributes to update + */ + public function entityUpdate(string $tenantId, string $userId, string|int $collection, string|int $identifier, array $attributes): void { + // Filter to allowed attributes only + $allowed = ['size', 'format', 'mime', 'encoding', 'label']; + $data = array_intersect_key($attributes, array_flip($allowed)); + + if (empty($data)) { + return; + } + + // Always update modification timestamp + $data['modifiedOn'] = date('c'); + + $this->_store->selectCollection($this->_NodeTable)->updateOne( + ['tid' => $tenantId, 'uid' => $userId, 'nid' => $identifier], + ['$set' => $data] + ); + $this->chronicleNode($tenantId, $userId, $identifier, 2); + } + + + /** + * Destroy an entity + */ + public function entityDestroy(string $tenantId, string $userId, string|int|null $collection, string|int $identifier): bool { + $result = $this->_store->selectCollection($this->_NodeTable)->deleteOne([ + 'tid' => $tenantId, + 'uid' => $userId, + 'nid' => $identifier + ]); + + if ($result->getDeletedCount() === 1) { + $this->chronicleNode($tenantId, $userId, $identifier, 3); + return true; + } + return false; + } + + /** + * Move an entity to another collection + */ + public function entityMove(string $tenantId, string $userId, string|int|null $collection, string|int $identifier, string|int|null $destination): NodeEntity { + $data = [ + 'pid' => $destination, + 'modifiedOn' => date('c'), + 'modifiedBy' => $userId, + ]; + + $this->_store->selectCollection($this->_NodeTable)->updateOne( + ['tid' => $tenantId, 'uid' => $userId, 'nid' => $identifier], + ['$set' => $data] + ); + $this->chronicleNode($tenantId, $userId, $identifier, 2); + + $entities = $this->entityFetch($tenantId, $userId, $destination, $identifier); + return $entities[$identifier]; + } + + /** + * Copy an entity + */ + public function entityCopy(string $tenantId, string $userId, string|int|null $collection, string|int $identifier, string|int|null $destination): NodeEntity { + $entities = $this->entityFetch($tenantId, $userId, $collection, $identifier); + if (!isset($entities[$identifier])) { + throw new \RuntimeException("Entity not found: $identifier"); + } + + $source = $entities[$identifier]; + $newEntity = new NodeEntity(); + $newEntity->setLabel($source->getLabel()); + $newEntity->setMime($source->getMime()); + $newEntity->setFormat($source->getFormat()); + $newEntity->setEncoding($source->getEncoding()); + + return $this->entityCreate($tenantId, $userId, $destination, $newEntity, ['path' => null]); + } + + /** + * Entity delta (changes since signature) + */ + public function entityDelta(string $tenantId, string $userId, string|int $collection, string $signature, string $detail = 'ids'): array { + return $this->internalDelta($tenantId, $userId, $collection, $signature, false, $detail); + } + + // ========== Node Operations (Unified/Recursive) ========== + + /** + * List all nodes (collections and entities) + */ + public function nodeList(string $tenantId, string $userId, string|int|null $location = null, bool $recursive = false, ?Filter $filter = null, ?Sort $sort = null, ?IRange $range = null): array { + $query = [ + 'tid' => $tenantId, + 'uid' => $userId, + ]; + + // For non-recursive, filter by parent + if (!$recursive) { + $query['pid'] = $location; + } elseif ($location !== null) { + // For recursive with specific location, we need to get all descendants + // This requires getting all collections first and building a list of IDs + $allCollectionIds = $this->getDescendantCollectionIds($tenantId, $userId, $location); + $allCollectionIds[] = $location; + $query['$or'] = [ + ['pid' => ['$in' => $allCollectionIds]], + ['nid' => ['$in' => $allCollectionIds]], + ]; + } + + if ($filter !== null) { + $filterConditions = $this->constructFilter($this->_NodeFilterAttributeMap, $filter); + $query = array_merge($query, $filterConditions); + } + + $options = []; + if ($sort !== null) { + $sortConditions = $this->constructSort($this->_SortAttributeMap, $sort); + $options['sort'] = $sortConditions; + } else { + $options['sort'] = ['type' => -1, 'name' => 1]; // folders first + } + + if ($range !== null && $range->type() === RangeType::TALLY && $range instanceof IRangeTally) { + $options['skip'] = $range->getPosition(); + $options['limit'] = $range->getTally(); + } + + $cursor = $this->_store->selectCollection($this->_NodeTable)->find($query, $options); + $list = []; + foreach ($cursor as $entry) { + $nodeType = $entry['type'] ?? NodeType::Entity->value; + if ($nodeType === NodeType::Collection->value) { + $node = (new NodeCollection())->fromStore($entry); + } else { + $node = (new NodeEntity())->fromStore($entry); + } + $list[$node->id()] = $node; + } + return $list; + } + + /** + * Get all descendant collection IDs (helper for recursive operations) + */ + private function getDescendantCollectionIds(string $tenantId, string $userId, string|int $parentId): array { + $cursor = $this->_store->selectCollection($this->_NodeTable)->find([ + 'tid' => $tenantId, + 'uid' => $userId, + 'pid' => $parentId, + 'type' => NodeType::Collection->value, + ]); + + $ids = []; + foreach ($cursor as $entry) { + $id = $entry['nid']; + $ids[] = $id; + $childIds = $this->getDescendantCollectionIds($tenantId, $userId, $id); + $ids = array_merge($ids, $childIds); + } + return $ids; + } + + /** + * Node delta (changes since signature, optionally recursive) + */ + public function nodeDelta(string $tenantId, string $userId, string|int|null $location, string $signature, bool $recursive = false, string $detail = 'ids'): array { + return $this->internalDelta($tenantId, $userId, $location, $signature, $recursive, $detail); + } + + /** + * Internal delta implementation + */ + private function internalDelta(string $tenantId, string $userId, string|int|null $location, string $signature, bool $recursive, string $detail): array { + $tokenApex = $this->chronicleApex($tenantId, $userId, false); + $tokenNadir = !empty($signature) ? base64_decode($signature) : ''; + $initial = !is_numeric($tokenNadir); + $tokenNadir = $initial ? 0 : (int)$tokenNadir; + + $matchStage = [ + '$match' => [ + 'tid' => $tenantId, + 'uid' => $userId, + ] + ]; + + if (!$initial) { + $matchStage['$match']['signature'] = [ + '$gt' => $tokenNadir, + '$lte' => (int)$tokenApex + ]; + } + + $pipeline = [ + $matchStage, + [ + '$group' => [ + '_id' => '$nid', + 'operation' => ['$max' => '$operation'], + 'nid' => ['$first' => '$nid'] + ] + ] + ]; + + if ($initial) { + $pipeline[] = ['$match' => ['operation' => ['$ne' => 3]]]; + } + + $cursor = $this->_store->selectCollection($this->_ChronicleTable)->aggregate($pipeline); + + $added = []; + $updated = []; + $deleted = []; + + foreach ($cursor as $entry) { + $id = $entry['nid']; + $op = $entry['operation']; + + if ($op === 3) { + $deleted[] = $id; + } elseif ($op === 1) { + $added[] = $id; + } else { + $updated[] = $id; + } + } + + // If detail is 'ids', just return IDs + if ($detail === 'ids') { + return [ + 'added' => $added, + 'updated' => $updated, + 'deleted' => $deleted, + 'signature' => base64_encode((string)$tokenApex), + ]; + } + + // For meta/full, fetch node data + $addedNodes = []; + $updatedNodes = []; + + if (!empty($added)) { + $allIds = $added; + $nodes = $this->fetchNodesByIds($tenantId, $userId, $allIds); + foreach ($added as $id) { + if (isset($nodes[$id])) { + $addedNodes[$id] = $nodes[$id]; + } + } + } + + if (!empty($updated)) { + $nodes = $this->fetchNodesByIds($tenantId, $userId, $updated); + foreach ($updated as $id) { + if (isset($nodes[$id])) { + $updatedNodes[$id] = $nodes[$id]; + } + } + } + + return [ + 'added' => $addedNodes, + 'updated' => $updatedNodes, + 'deleted' => $deleted, + 'signature' => base64_encode((string)$tokenApex), + ]; + } + + /** + * Fetch nodes by IDs (returns both collections and entities) + */ + private function fetchNodesByIds(string $tenantId, string $userId, array $ids): array { + $cursor = $this->_store->selectCollection($this->_NodeTable)->find([ + 'tid' => $tenantId, + 'uid' => $userId, + 'nid' => ['$in' => $ids] + ]); + + $list = []; + foreach ($cursor as $entry) { + $nodeType = $entry['type'] ?? NodeType::Entity->value; + if ($nodeType === NodeType::Collection->value) { + $node = (new NodeCollection())->fromStore($entry); + } else { + $node = (new NodeEntity())->fromStore($entry); + } + $list[$node->id()] = $node; + } + return $list; + } + + // ========== Chronicle Operations ========== + + /** + * Chronicle a node operation + * + * @since Release 1.0.0 + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param string|int $nodeId node identifier + * @param int $operation operation type (1 = Created, 2 = Modified, 3 = Deleted) + */ + private function chronicleNode(string $tenantId, string $userId, string|int $nodeId, int $operation): void { + // Get current max signature + $signature = $this->chronicleApex($tenantId, $userId, false); + + // Insert chronicle entry + $this->_store->selectCollection($this->_ChronicleTable)->insertOne([ + 'tid' => $tenantId, + 'uid' => $userId, + 'nid' => $nodeId, + 'operation' => $operation, + 'signature' => (int)$signature + 1, + 'mutatedOn' => time(), + ]); + } + + /** + * Get the apex (highest) signature for a user's chronicle + * + * @since Release 1.0.0 + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param bool $encode whether to encode the result + * + * @return int|string + */ + public function chronicleApex(string $tenantId, string $userId, bool $encode = true): int|string { + $cursor = $this->_store->selectCollection($this->_ChronicleTable)->aggregate([ + [ + '$match' => ['tid' => $tenantId, 'uid' => $userId] + ], + [ + '$group' => [ + '_id' => null, + 'maxToken' => ['$max' => '$signature'] + ] + ] + ]); + + $result = $cursor->toArray(); + $stampApex = !empty($result) ? ($result[0]['maxToken'] ?? 0) : 0; + + if ($encode) { + return base64_encode((string)max(0, $stampApex)); + } else { + return max(0, $stampApex); + } + } + +}