* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderLocalDocuments\Providers\Personal; use Generator; use KTXF\Blob\Signature; use KTXF\Documents\Collection\CollectionBaseInterface; use KTXF\Documents\Collection\CollectionPropertiesBaseInterface; use KTXF\Documents\Entity\EntityPropertiesMutableInterface; use KTXF\Documents\Service\ServiceBaseInterface; use KTXF\Documents\Service\ServiceCollectionMutableInterface; use KTXF\Documents\Service\ServiceEntityMutableInterface; use KTXF\Files\Node\NodeType; use KTXF\Resource\Delta\Delta; use KTXF\Resource\Exceptions\InvalidParameterException; use KTXF\Resource\Filter\Filter; use KTXF\Resource\Filter\IFilter; use KTXF\Resource\Identifier\CollectionIdentifier; use KTXF\Resource\Identifier\CollectionIdentifierInterface; use KTXF\Resource\Identifier\EntityIdentifier; use KTXF\Resource\Identifier\EntityIdentifierInterface; 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; 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_COLLECTION_MOVE => true, self::CAPABILITY_COLLECTION_COPY => 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_DELTA => true, self::CAPABILITY_ENTITY_EXTANT => true, self::CAPABILITY_ENTITY_FETCH => true, self::CAPABILITY_ENTITY_CREATE => true, self::CAPABILITY_ENTITY_MODIFY => true, self::CAPABILITY_ENTITY_DELETE => true, self::CAPABILITY_ENTITY_MOVE => true, self::CAPABILITY_ENTITY_COPY => 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::PROPERTY_TYPE => self::JSON_TYPE, self::PROPERTY_PROVIDER => self::PROVIDER_IDENTIFIER, self::PROPERTY_IDENTIFIER => self::SERVICE_IDENTIFIER, self::PROPERTY_LABEL => self::SERVICE_LABEL, self::PROPERTY_ENABLED => $this->serviceEnabled, self::PROPERTY_CAPABILITIES => $this->serviceAbilities, self::PROPERTY_LOCATION => null, self::PROPERTY_IDENTITY => null, self::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 []; } /** * Normalize a collection location, treating null and '' as the root collection */ private function normalizeLocation(string|int|null $location): string|int { return ($location === null || $location === '') ? self::ROOT_ID : $location; } // Collection operations public function collectionList(string|int|null $location, ?IFilter $filter = null, ?ISort $sort = null): array { $location = $this->normalizeLocation($location); $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); } /** * Determine whether a single collection exists and belongs to the current user */ private function collectionAccessible(string|int $collection): bool { $response = $this->collectionExtant(null, $collection); return ($response[$collection] ?? false) === true; } public function collectionFetch(string|int|null $identifier): ?CollectionResource { // null is root $identifier = $this->normalizeLocation($identifier); // 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; } /** * Convert incoming collection properties into a native CollectionResource */ private function collectionNative(CollectionPropertiesBaseInterface $properties, string|int|null $identifier = null): CollectionResource { if ($properties instanceof CollectionResource) { $native = clone $properties; } else { $native = $this->collectionFresh(); $native->jsonDeserialize([ CollectionResource::PROPERTY_IDENTIFIER => $identifier, CollectionResource::PROPERTY_PROPERTIES => $properties->jsonSerialize(), ]); } return $native; } public function collectionCreate(CollectionIdentifierInterface|null $target, CollectionPropertiesBaseInterface $properties, array $options = []): CollectionResource { // null target is root $location = $this->normalizeLocation($target?->collection()); // convert properties to a native collection $collection = $this->collectionNative($properties); // 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(CollectionIdentifierInterface $target, CollectionPropertiesBaseInterface $properties): CollectionResource { $identifier = $target->collection(); // convert properties to a native collection $collection = $this->collectionNative($properties, $identifier); // 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(CollectionIdentifierInterface $target, bool $force = false): CollectionBaseInterface | true { $identifier = $target->collection(); // Protect root collection if ($identifier === self::ROOT_ID) { throw new InvalidParameterException("Cannot destroy root collection"); } // If not forcing, ensure the collection is empty if (!$force) { $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 instanceof EntityResource) { $this->blobStore->blobDelete((string)$nodeId); } } // Delete from meta store (handles recursive collection/entity meta deletion internally) $this->metaStore->collectionDestroy($this->serviceTenantId, $this->serviceUserId, $identifier); // remove from cache unset($this->serviceCollectionCache[$identifier]); return true; } public function collectionCopy(CollectionIdentifierInterface|null $target, CollectionIdentifierInterface $source): CollectionResource { $identifier = $source->collection(); // Protect root collection if ($identifier === self::ROOT_ID) { throw new InvalidParameterException("Cannot copy root collection"); } // Verify collection exists if ($this->collectionAccessible($identifier) === false) { throw new InvalidParameterException("Collection not found: $identifier"); } // null target is root $location = $this->normalizeLocation($target?->collection()); // 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(CollectionIdentifierInterface|null $target, CollectionIdentifierInterface $source): CollectionResource { $identifier = $source->collection(); // Protect root collection if ($identifier === self::ROOT_ID) { throw new InvalidParameterException("Cannot move root collection"); } // Verify collection exists if ($this->collectionAccessible($identifier) === false) { throw new InvalidParameterException("Collection not found: $identifier"); } // null target is root $location = $this->normalizeLocation($target?->collection()); // 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 entityListBulk(string|int|null $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array { // null collection is root $collection = $this->normalizeLocation($collection); $entries = $this->metaStore->entityList($this->serviceTenantId, $this->serviceUserId, $collection, $filter, $sort, $range); // cache entities foreach ($entries as $entity) { $this->serviceEntityCache[$entity->identifier()] = $entity; } return $entries ?? []; } public function entityListStream(string|int|null $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): Generator { yield from $this->entityListBulk($collection, $filter, $sort, $range, $properties); } 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 entityFetchBulk(EntityIdentifierInterface ...$identifiers): array { $result = []; // group identifiers by collection $byCollection = []; foreach ($identifiers as $identifier) { $id = $identifier->entity(); // check cache first if (isset($this->serviceEntityCache[$id])) { $result[$id] = $this->serviceEntityCache[$id]; continue; } $byCollection[$this->normalizeLocation($identifier->collection())][] = $id; } // fetch remaining from store foreach ($byCollection as $collection => $entityIds) { $fetched = $this->metaStore->entityFetch($this->serviceTenantId, $this->serviceUserId, $collection, ...$entityIds); foreach ($fetched as $id => $entity) { $this->serviceEntityCache[$id] = $entity; $result[$id] = $entity; } } return $result; } public function entityFetchStream(EntityIdentifierInterface ...$identifiers): Generator { yield from $this->entityFetchBulk(...$identifiers); } public function entityExtant(string|int|null $collection, string|int ...$identifiers): array { // null collection is root $collection = $this->normalizeLocation($collection); $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' => (int) round(microtime(true) * 1000), 'properties' => [], ]); return $entity; } /** * Convert incoming entity properties into a native EntityResource */ private function entityNative(EntityPropertiesMutableInterface $properties, string|int|null $identifier = null): EntityResource { if ($properties instanceof EntityResource) { $native = clone $properties; } else { $native = $this->entityFresh(); $native->jsonDeserialize([ EntityResource::PROPERTY_IDENTIFIER => $identifier, EntityResource::PROPERTY_PROPERTIES => $properties->jsonSerialize(), ]); } return $native; } public function entityCreate(CollectionIdentifierInterface $target, EntityPropertiesMutableInterface $properties, array $options = []): EntityResource { // null collection is root $collection = $this->normalizeLocation($target->collection()); // convert properties to a native entity $entity = $this->entityNative($properties); // 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 entityModify(EntityIdentifierInterface $target, EntityPropertiesMutableInterface $properties): EntityResource { // null collection is root $collection = $this->normalizeLocation($target->collection()); $identifier = $target->entity(); // convert properties to a native entity $entity = $this->entityNative($properties, $identifier); // 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(EntityIdentifierInterface ...$targets): array { $results = []; foreach ($targets as $target) { $key = (string) $target; $collection = $this->normalizeLocation($target->collection()); $identifier = $target->entity(); // validate entity extant and ownership $extant = $this->entityExtant($collection, $identifier); if (($extant[$identifier] ?? false) === false) { $results[$key] = ['disposition' => 'error', 'destination' => null, 'mutation' => $target]; continue; } // Delete from blob store $this->blobStore->blobDelete((string)$identifier); // Delete from meta store $this->metaStore->entityDestroy($this->serviceTenantId, $this->serviceUserId, $collection, $identifier); // remove from cache unset($this->serviceEntityCache[$identifier]); $results[$key] = ['disposition' => 'deleted', 'destination' => null, 'mutation' => $target]; } return $results; } public function entityDelta(string|int|null $collection, string $signature): Delta { // null collection is root $collection = $this->normalizeLocation($collection); return new Delta(); // $this->metaStore->entityDelta($this->serviceTenantId, $this->serviceUserId, $collection, $signature); } public function entityMove(CollectionIdentifierInterface $target, EntityIdentifierInterface ...$sources): array { return $this->entityRelocate($target, true, ...$sources); } public function entityCopy(CollectionIdentifierInterface $target, EntityIdentifierInterface ...$sources): array { return $this->entityRelocate($target, false, ...$sources); } /** * Relocate (move or copy) entities into a target collection. * * Moves retain the entity identifier (blob path is keyed by entity id and never * changes); copies receive a fresh identifier and a duplicated blob. * * @param CollectionIdentifierInterface $target destination collection identifier * @param bool $remove true to move (relocate the source), false to copy * @param EntityIdentifierInterface ...$sources source entity identifiers * * @return array */ private function entityRelocate(CollectionIdentifierInterface $target, bool $remove, EntityIdentifierInterface ...$sources): array { $results = []; $disposition = $remove ? 'moved' : 'copied'; $targetCollection = $this->normalizeLocation($target->collection()); // validate target collection extant and ownership $targetValid = $targetCollection === self::ROOT_ID || $this->collectionAccessible($targetCollection); foreach ($sources as $source) { $key = (string) $source; $sourceCollection = $this->normalizeLocation($source->collection()); $identifier = $source->entity(); // validate target collection if ($targetValid === false) { $results[$key] = ['disposition' => 'error', 'destination' => null, 'mutation' => $source]; continue; } // validate source entity extant and ownership $extant = $this->entityExtant($sourceCollection, $identifier); if (($extant[$identifier] ?? false) === false) { $results[$key] = ['disposition' => 'error', 'destination' => null, 'mutation' => $source]; continue; } if ($remove) { // Move in meta store (blob path never changes since it is keyed by entity id) $node = $this->metaStore->entityMove($this->serviceTenantId, $this->serviceUserId, $sourceCollection, $identifier, $targetCollection); } else { // Copy in meta store (creates new entity with new id) $node = $this->metaStore->entityCopy($this->serviceTenantId, $this->serviceUserId, $sourceCollection, $identifier, $targetCollection); // Copy blob content (new entity has new id = 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; // construct destination and mutation identifiers $destination = new CollectionIdentifier($this->provider(), $this->identifier(), (string)$targetCollection); $mutation = new EntityIdentifier($this->provider(), $this->identifier(), (string)$targetCollection, (string)$node->identifier()); $results[$key] = ['disposition' => $disposition, 'destination' => $destination, 'mutation' => $mutation]; } return $results; } // Entity content (blob) operations /** * Confirm an entity exists, returning its normalized collection identifier */ private function entityAccessible(EntityIdentifierInterface $target): string|int|false { $collection = $this->normalizeLocation($target->collection()); $identifier = $target->entity(); $extant = $this->entityExtant($collection, $identifier); return ($extant[$identifier] ?? false) ? $collection : false; } public function entityRead(EntityIdentifierInterface $target): ?string { if ($this->entityAccessible($target) === false) { return null; } return $this->blobStore->blobRead((string)$target->entity()); } public function entityReadStream(EntityIdentifierInterface $target) { if ($this->entityAccessible($target) === false) { return null; } return $this->blobStore->blobReadStream((string)$target->entity()); } public function entityReadChunk(EntityIdentifierInterface $target, int $offset, int $length): ?string { if ($this->entityAccessible($target) === false) { return null; } return $this->blobStore->blobReadChunk((string)$target->entity(), $offset, $length); } public function entityWrite(EntityIdentifierInterface $target, string $data): int { $collection = $this->entityAccessible($target); $identifier = $target->entity(); if ($collection === 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(EntityIdentifierInterface $target) { $collection = $this->entityAccessible($target); $identifier = $target->entity(); if ($collection === 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(EntityIdentifierInterface $target, int $offset, string $data): int { $collection = $this->entityAccessible($target); $identifier = $target->entity(); if ($collection === 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; } /** * 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()?->getTimestamp() !== null ? (((int) $node->created()?->format('U')) * 1000) + (int) $node->created()?->format('v') : null, 'modified' => $node->modified()?->getTimestamp() !== null ? (((int) $node->modified()?->format('U')) * 1000) + (int) $node->modified()?->format('v') : null, 'size' => $node->getProperties()->size(), 'label' => $node->getProperties()->getLabel(), 'mime' => $node->getProperties()->getMime(), 'format' => $node->getProperties()->getFormat(), ]; } }