* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderLocalDocuments\Providers\Personal; 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\ProviderLocalDocuments\Store\BlobStore; use KTXM\ProviderLocalDocuments\Store\MetaStore; use KTXF\Blob\Signature; use KTXF\Files\Node\NodeType; use KTXF\Resource\Delta\Delta; use KTXF\Resource\Documents\Collection\CollectionMutableInterface; use KTXF\Resource\Documents\Entity\EntityMutableInterface; use KTXF\Resource\Documents\Service\ServiceBaseInterface; use KTXF\Resource\Documents\Service\ServiceCollectionMutableInterface; use KTXF\Resource\Documents\Service\ServiceEntityMutableInterface; use KTXM\ProviderLocalDocuments\Providers\Personal\CollectionResource; class PersonalService implements ServiceBaseInterface, ServiceCollectionMutableInterface, ServiceEntityMutableInterface { public const ROOT_ID = '00000000-0000-0000-0000-000000000000'; public const ROOT_LABEL = 'Personal Local Documents'; protected const PROVIDER_IDENTIFIER = 'default'; protected const SERVICE_IDENTIFIER = 'personal'; protected const SERVICE_LABEL = 'Personal Documents Storage'; protected array $serviceCollectionCache = []; protected array $serviceEntityCache = []; protected ?string $serviceTenantId = null; protected ?string $serviceUserId = null; protected ?bool $serviceEnabled = true; protected ?string $serviceRoot = null; private array $serviceAbilities = [ self::CAPABILITY_COLLECTION_LIST => true, self::CAPABILITY_COLLECTION_LIST_FILTER => [ self::CAPABILITY_COLLECTION_FILTER_LABEL => 's:100:256:256', ], self::CAPABILITY_COLLECTION_LIST_SORT => [ self::CAPABILITY_COLLECTION_SORT_LABEL, ], self::CAPABILITY_COLLECTION_EXTANT => true, self::CAPABILITY_COLLECTION_FETCH => true, self::CAPABILITY_COLLECTION_CREATE => true, self::CAPABILITY_COLLECTION_UPDATE => true, self::CAPABILITY_COLLECTION_DELETE => true, self::CAPABILITY_ENTITY_LIST => true, self::CAPABILITY_ENTITY_LIST_FILTER => [ self::CAPABILITY_ENTITY_FILTER_ALL => 's:200:256:256', self::CAPABILITY_ENTITY_FILTER_ID => 's:100:256:256', self::CAPABILITY_ENTITY_FILTER_URID => 's:100:256:256', self::CAPABILITY_ENTITY_FILTER_LABEL => 's:100:256:256', ], self::CAPABILITY_ENTITY_LIST_SORT => [ self::CAPABILITY_ENTITY_SORT_ID, self::CAPABILITY_ENTITY_SORT_LABEL, ], self::CAPABILITY_ENTITY_LIST_RANGE => [ self::CAPABILITY_ENTITY_RANGE_TALLY => [ self::CAPABILITY_ENTITY_RANGE_TALLY_ABSOLUTE, self::CAPABILITY_ENTITY_RANGE_TALLY_RELATIVE ], self::CAPABILITY_ENTITY_RANGE_DATE => true, ], self::CAPABILITY_ENTITY_DELTA => true, self::CAPABILITY_ENTITY_EXTANT => true, self::CAPABILITY_ENTITY_FETCH => true, self::CAPABILITY_ENTITY_CREATE => true, self::CAPABILITY_ENTITY_UPDATE => true, self::CAPABILITY_ENTITY_DELETE => true, self::CAPABILITY_ENTITY_READ => true, self::CAPABILITY_ENTITY_WRITE => true, ]; public function __construct( private readonly MetaStore $metaStore, private readonly BlobStore $blobStore, ) {} public function initialize(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 CollectionResource(); $root->fromStore([ 'tid' => $tenantId, 'uid' => $userId, 'cid' => self::ROOT_ID, 'nid' => null, 'properties' => [ 'label' => self::ROOT_LABEL, ], ]); $this->serviceCollectionCache[self::ROOT_ID] = $root; return $this; } public function jsonSerialize(): array { return array_filter([ self::JSON_PROPERTY_TYPE => self::JSON_TYPE, self::JSON_PROPERTY_PROVIDER => self::PROVIDER_IDENTIFIER, self::JSON_PROPERTY_IDENTIFIER => self::SERVICE_IDENTIFIER, self::JSON_PROPERTY_LABEL => self::SERVICE_LABEL, self::JSON_PROPERTY_ENABLED => $this->serviceEnabled, self::JSON_PROPERTY_CAPABILITIES => $this->serviceAbilities, self::JSON_PROPERTY_LOCATION => null, self::JSON_PROPERTY_IDENTITY => null, self::JSON_PROPERTY_AUXILIARY => [], ], fn($v) => $v !== null); } 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 provider(): string { return self::PROVIDER_IDENTIFIER; } public function identifier(): string { return self::SERVICE_IDENTIFIER; } public function getLabel(): string { return (string)self::SERVICE_LABEL; } public function getEnabled(): bool { return (bool)$this->serviceEnabled; } public function setEnabled(bool $enabled): static { $this->serviceEnabled = $enabled; return $this; } public function getLocation(): null { return null; } public function getIdentity(): null { return null; } public function getAuxiliary(): array { return []; } // Collection operations 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 ?? []; } public function collectionListFilter(): IFilter { return new Filter($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_FILTER] ?? []); } public function collectionListSort(): ISort { return new Sort($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_SORT] ?? []); } public function collectionExtant(string|int|null $location, string|int ...$identifiers): array { $cached = []; $toCheck = []; foreach ($identifiers as $id) { if (isset($this->serviceCollectionCache[$id])) { $cached[$id] = true; } else { $toCheck[] = $id; } } if (empty($toCheck)) { return $cached; } $fromStore = $this->metaStore->collectionExtant($this->serviceTenantId, $this->serviceUserId, ...$toCheck); return array_merge($cached, $fromStore); } public function collectionFetch(string|int|null $identifier): ?CollectionResource { // 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; } public function collectionFresh(): CollectionResource { $collection = new CollectionResource(); $collection->fromStore([ 'tid' => $this->serviceTenantId, 'uid' => $this->serviceUserId, 'cid' => null, 'nid' => null, 'type' => NodeType::Collection->value, 'properties' => [ 'label' => '', ], ]); return $collection; } public function collectionCreate(string|int|null $location, CollectionMutableInterface $collection, array $options = []): CollectionResource { // 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->identifier()] = $node; return $node; } public function collectionUpdate(string|int $identifier, CollectionMutableInterface $collection): CollectionResource { // Modify in meta store $node = $this->metaStore->collectionModify($this->serviceTenantId, $this->serviceUserId, $identifier, $collection); // update cache $this->serviceCollectionCache[$node->identifier()] = $node; return $node; } public function collectionDelete(string|int $identifier, bool $force = false, bool $recursive = false): bool { // Protect root collection if ($identifier === self::ROOT_ID) { throw new InvalidParameterException("Cannot destroy root collection"); } // If not forcing and not recursive, ensure the collection is empty if (!$force && !$recursive) { $children = $this->metaStore->nodeList($this->serviceTenantId, $this->serviceUserId, $identifier, false); if (!empty($children)) { throw new InvalidParameterException("Collection is not empty: $identifier"); } } // Delete blob files for all entities within the collection tree $descendants = $this->metaStore->nodeList($this->serviceTenantId, $this->serviceUserId, $identifier, true); foreach ($descendants as $nodeId => $node) { if ($node->isEntity()) { $this->blobStore->blobDelete((string)$nodeId); } } // Delete from meta store (handles recursive collection/entity meta deletion internally) $result = $this->metaStore->collectionDestroy($this->serviceTenantId, $this->serviceUserId, $identifier); // remove from cache unset($this->serviceCollectionCache[$identifier]); return $result; } public function collectionCopy(string|int $identifier, string|int|null $location): CollectionResource { // Protect root collection if ($identifier === self::ROOT_ID) { throw new InvalidParameterException("Cannot copy root collection"); } // Verify collection exists $extant = $this->collectionExtant($identifier, $identifier); if (!($extant[$identifier] ?? false)) { 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->identifier()] = $node; return $node; } public function collectionMove(string|int $identifier, string|int|null $location): CollectionResource { // Protect root collection if ($identifier === self::ROOT_ID) { throw new InvalidParameterException("Cannot move root collection"); } // Verify collection exists $extant = $this->collectionExtant($identifier, $identifier); if (!($extant[$identifier] ?? false)) { 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->identifier()] = $node; return $node; } // Entity operations public function entityList(string|int|null $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = 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[$entity->identifier()] = $entity; } return $entries ?? []; } public function entityListFilter(): IFilter { return new Filter($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_FILTER] ?? []); } public function entityListSort(): ISort { return new Sort($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_SORT] ?? []); } 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(); } 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; } 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; } public function entityFresh(): EntityResource { $entity = new EntityResource(); $entity->fromStore([ 'tid' => $this->serviceTenantId, 'uid' => $this->serviceUserId, 'cid' => null, 'nid' => null, 'created' => (new \DateTimeImmutable())->format('c'), 'properties' => [], ]); return $entity; } public function entityCreate(string|int|null $collection, EntityMutableInterface $entity, array $options = []): EntityResource { // null collection is root if ($collection === null) { $collection = self::ROOT_ID; } // Create in meta store $result = $this->metaStore->entityCreate($this->serviceTenantId, $this->serviceUserId, $collection, $entity, $options); // Write meta file for recovery $this->blobStore->metaWrite((string)$result->identifier(), $this->buildEntityMeta($result)); // cache entity $this->serviceEntityCache[$result->identifier()] = $result; return $result; } public function entityUpdate(string|int|null $collection, string|int $identifier, EntityMutableInterface $entity): EntityResource { // null collection is root if ($collection === null) { $collection = self::ROOT_ID; } // Modify in meta store $result = $this->metaStore->entityModify($this->serviceTenantId, $this->serviceUserId, $collection, $identifier, $entity); // Update meta file for recovery $this->blobStore->metaWrite((string)$result->identifier(), $this->buildEntityMeta($result)); // update cache $this->serviceEntityCache[$result->identifier()] = $result; return $result; } public function entityDelete(string|int|null $collection, string|int $identifier): EntityResource { // null collection is root if ($collection === null) { $collection = self::ROOT_ID; } /** @var EntityResource[] $entities */ $entities = $this->entityFetch($collection, $identifier); if (!isset($entities[$identifier])) { throw new InvalidParameterException("Entity not found: $identifier"); } $entity = $entities[$identifier]; // Delete from blob store $this->blobStore->blobDelete($entity->identifier()); // Delete from meta store $this->metaStore->entityDestroy($this->serviceTenantId, $this->serviceUserId, $collection, $identifier); // remove from cache unset($this->serviceEntityCache[$entity->identifier()]); return $entity; } public function entityDelta(string|int|null $collection, string $signature, string $detail = 'ids'): Delta { // null collection is root if ($collection === null) { $collection = self::ROOT_ID; } return new Delta(); // $this->metaStore->entityDelta($this->serviceTenantId, $this->serviceUserId, $collection, $signature, $detail); } public function entityCopy(string|int|null $collection, string|int $identifier, string|int|null $destination): EntityResource { // 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->identifier(), $content); } // Write meta file for recovery $this->blobStore->metaWrite((string)$node->identifier(), $this->buildEntityMeta($node)); // cache entity $this->serviceEntityCache[$node->identifier()] = $node; return $node; } public function entityMove(string|int|null $collection, string|int $identifier, string|int|null $destination): EntityResource { // 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->identifier(), $this->buildEntityMeta($node)); // update cache $this->serviceEntityCache[$node->identifier()] = $node; return $node; } 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); } 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); } 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); } 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)); // Write blob data $size = $this->blobStore->blobWrite((string)$identifier, $data); if ($size === null) { throw new \RuntimeException("Failed to write to entity: $identifier"); } // Update meta store $result = $this->metaStore->entityModify($this->serviceTenantId, $this->serviceUserId, $collection, $identifier, [ 'properties' => [ 'size' => $size, 'mime' => $signature['mime'], 'format' => $signature['format'], ], ], true); // Update meta blob $this->blobStore->metaWrite($result->identifier(), $this->buildEntityMeta($result)); // update cache $this->serviceEntityCache[$result->identifier()] = $result; return $size; } 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"); } // write blob stream $stream = $this->blobStore->blobWriteStream((string)$identifier); if ($stream === null) { throw new \RuntimeException("Failed to open write stream for entity: $identifier"); } // update meta store $size = $this->blobStore->blobSize((string)$identifier) ?? 0; $result = $this->metaStore->entityModify($this->serviceTenantId, $this->serviceUserId, $collection, $identifier, [ 'properties' => [ 'size' => $size, ], ], true); // update meta blob $this->blobStore->metaWrite($result->identifier(), $this->buildEntityMeta($result)); // update cache $this->serviceEntityCache[$result->identifier()] = $result; return $stream; } 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"); } // update meta store $size = $this->blobStore->blobSize((string)$identifier) ?? 0; $result = $this->metaStore->entityModify($this->serviceTenantId, $this->serviceUserId, $collection, $identifier, [ 'properties' => [ 'size' => $size, 'mime' => $signature['mime'] ?? null, 'format' => $signature['format'] ?? null, ] ], true); // Update meta blob $this->blobStore->metaWrite($result->identifier(), $this->buildEntityMeta($result)); // update cache $this->serviceEntityCache[$result->identifier()] = $result; return $bytes; } // Node operations public function nodeList(string|int|null $collection = null, bool $recursive = false, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array { if ($collection === null) { $collection = self::ROOT_ID; } return $this->metaStore->nodeList($this->serviceTenantId, $this->serviceUserId, $collection, $recursive, $filter, $sort, $range, $properties); } public function nodeListFilter(): IFilter { return new Filter($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_FILTER] ?? []); } public function nodeListSort(): ISort { return new Sort($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_SORT] ?? []); } 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(); } public function nodeDelta(string|int|null $collection, string $signature, string $detail = 'ids'): Delta { return new Delta(); } /** * Build metadata array for an entity to store in .meta file * * @param EntityResource $node The entity node * @return array Metadata array */ protected function buildEntityMeta(EntityResource $node): array { return [ 'tid' => $this->serviceTenantId, 'uid' => $this->serviceUserId, 'cid' => $node->collection(), 'nid' => $node->identifier(), 'created' => $node->created()?->format('c'), 'modified' => $node->modified()?->format('c'), 'size' => $node->getProperties()->size(), 'label' => $node->getProperties()->getLabel(), 'mime' => $node->getProperties()->getMime(), 'format' => $node->getProperties()->getFormat(), ]; } }