diff --git a/lib/Providers/Document/EntityProperties.php b/lib/Providers/Document/EntityProperties.php index 4fa3c45..cec96b6 100644 --- a/lib/Providers/Document/EntityProperties.php +++ b/lib/Providers/Document/EntityProperties.php @@ -23,24 +23,16 @@ class EntityProperties extends EntityPropertiesMutableAbstract { */ public function fromJmap(array $parameters): static { - if (isset($data['size'])) { - $this->data[self::PROPERTY_SIZE] = (int) $data['size']; - } - - if (isset($data['label'])) { - $this->data[self::PROPERTY_LABEL] = (string) $data['name']; + if (isset($parameters['size'])) { + $this->data[self::PROPERTY_SIZE] = (int) $parameters['size']; } - if (isset($data['mime'])) { - $this->data[self::PROPERTY_MIME] = (string) $data['type']; + if (isset($parameters['name'])) { + $this->data[self::PROPERTY_LABEL] = (string) $parameters['name']; } - if (isset($data['format'])) { - $this->data[self::PROPERTY_FORMAT] = null; - } - - if (isset($data['encoding'])) { - $this->data[self::PROPERTY_ENCODING] = null; + if (isset($parameters['type'])) { + $this->data[self::PROPERTY_MIME] = (string) $parameters['type']; } return $this; diff --git a/lib/Providers/Document/EntityResource.php b/lib/Providers/Document/EntityResource.php index 8805413..1e748a2 100644 --- a/lib/Providers/Document/EntityResource.php +++ b/lib/Providers/Document/EntityResource.php @@ -45,7 +45,6 @@ class EntityResource extends EntityMutableAbstract { if (isset($parameters['modified'])) { $this->data['modified'] = $parameters['modified']; } - if (isset($parameters['accessed'])) $this->getProperties()->fromJmap($parameters); diff --git a/lib/Providers/Document/Service.php b/lib/Providers/Document/Service.php index d51a796..2e7ff33 100644 --- a/lib/Providers/Document/Service.php +++ b/lib/Providers/Document/Service.php @@ -17,7 +17,9 @@ use KTXF\Documents\Service\ServiceBaseInterface; use KTXF\Documents\Service\ServiceCollectionMutableInterface; use KTXF\Documents\Service\ServiceConfigurableInterface; use KTXF\Documents\Service\ServiceMutableInterface; +use KTXF\Documents\Service\ServiceNodeListInterface; use KTXF\Resource\Delta\Delta; +use KTXF\Resource\Exceptions\InvalidParameterException; use KTXF\Resource\Filter\Filter; use KTXF\Resource\Filter\IFilter; use KTXF\Resource\Identifier\CollectionIdentifierInterface; @@ -26,6 +28,8 @@ use KTXF\Resource\Provider\ResourceServiceIdentityInterface; use KTXF\Resource\Provider\ResourceServiceLocationInterface; use KTXF\Resource\Range\IRange; use KTXF\Resource\Range\Range; +use KTXF\Resource\Range\RangeAnchorType; +use KTXF\Resource\Range\RangeTally; use KTXF\Resource\Range\RangeType; use KTXF\Resource\Sort\ISort; use KTXF\Resource\Sort\Sort; @@ -34,7 +38,7 @@ use KTXM\ProviderJmapc\Providers\ServiceLocation; use KTXM\ProviderJmapc\Service\Remote\RemoteFilesService; use KTXM\ProviderJmapc\Service\Remote\RemoteService; -class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface +class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface, ServiceNodeListInterface { public const JSON_TYPE = ServiceBaseInterface::JSON_TYPE; @@ -76,6 +80,22 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC self::CAPABILITY_ENTITY_DELTA => true, self::CAPABILITY_ENTITY_EXTANT => true, self::CAPABILITY_ENTITY_FETCH => true, + self::CAPABILITY_NODE_LIST => true, + self::CAPABILITY_NODE_LIST_FILTER => [ + self::CAPABILITY_NODE_FILTER_LABEL => 's:100:256:256', + ], + self::CAPABILITY_NODE_LIST_SORT => [ + self::CAPABILITY_NODE_SORT_LABEL, + self::CAPABILITY_NODE_SORT_SIZE, + self::CAPABILITY_NODE_SORT_CREATED_ON, + self::CAPABILITY_NODE_SORT_MODIFIED_ON, + ], + self::CAPABILITY_NODE_LIST_RANGE => [ + self::CAPABILITY_NODE_RANGE_TALLY => [ + self::CAPABILITY_NODE_RANGE_TALLY_ABSOLUTE, + self::CAPABILITY_NODE_RANGE_TALLY_RELATIVE, + ], + ], ]; private readonly RemoteFilesService $remoteService; @@ -499,4 +519,111 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC return null; } + // Node operations (unified collections + entities) + + public function nodeList(string|int|null $location, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): Generator + { + $this->initialize(); + + $result = $this->remoteService->nodeList($location !== null ? (string)$location : null); + + // convert and filter, keeping folders and files apart so folders always come first + $label = null; + if ($filter !== null && isset($filter->conditions()[self::CAPABILITY_NODE_FILTER_LABEL])) { + $label = (string)$filter->conditions()[self::CAPABILITY_NODE_FILTER_LABEL]['value']; + } + $collections = []; + $entities = []; + foreach ($result['list'] as $index => $entry) { + unset($result['list'][$index]); + if (!is_array($entry) || !isset($entry['id'])) { + continue; + } + // folders carry no blob + $object = empty($entry['blobId']) + ? new CollectionResource(provider: $this->provider(), service: $this->identifier()) + : new EntityResource(provider: $this->provider(), service: $this->identifier()); + $object->fromJmap($entry); + if ($label !== null && $label !== '' && mb_stripos($object->getProperties()->getLabel(), $label) === false) { + continue; + } + if ($object instanceof CollectionResource) { + $collections[] = $object; + } else { + $entities[] = $object; + } + } + + // sort each group, defaulting to label ascending + $conditions = $sort?->conditions() ?? []; + if ($conditions === []) { + $conditions = [['attribute' => self::CAPABILITY_NODE_SORT_LABEL, 'direction' => true]]; + } + $comparator = function (CollectionResource|EntityResource $a, CollectionResource|EntityResource $b) use ($conditions): int { + foreach ($conditions as $condition) { + $order = match ($condition['attribute']) { + self::CAPABILITY_NODE_SORT_LABEL => strnatcasecmp($a->getProperties()->getLabel(), $b->getProperties()->getLabel()), + self::CAPABILITY_NODE_SORT_SIZE => $this->nodeSize($a) <=> $this->nodeSize($b), + self::CAPABILITY_NODE_SORT_CREATED_ON => ($a->created()?->getTimestamp() ?? 0) <=> ($b->created()?->getTimestamp() ?? 0), + self::CAPABILITY_NODE_SORT_MODIFIED_ON => ($a->modified()?->getTimestamp() ?? 0) <=> ($b->modified()?->getTimestamp() ?? 0), + default => 0, + }; + if ($order !== 0) { + return $condition['direction'] ? $order : -$order; + } + } + return strnatcasecmp($a->getProperties()->getLabel(), $b->getProperties()->getLabel()); + }; + usort($collections, $comparator); + usort($entities, $comparator); + $nodes = array_merge($collections, $entities); + unset($collections, $entities); + + // apply range + if ($range instanceof RangeTally) { + $offset = 0; + if ($range->getAnchor() === RangeAnchorType::RELATIVE) { + // relative ranges start after the anchor node + foreach ($nodes as $index => $node) { + if ((string)$node->identifier() === (string)$range->getPosition()) { + $offset = $index + 1; + break; + } + } + } else { + $offset = max(0, (int)$range->getPosition()); + } + $nodes = array_slice($nodes, $offset, $range->getTally()); + } + + foreach ($nodes as $index => $node) { + unset($nodes[$index]); + yield $node->identifier() => $node; + } + } + + public function nodeListFilter(): IFilter + { + return new Filter($this->serviceAbilities[self::CAPABILITY_NODE_LIST_FILTER] ?? []); + } + + public function nodeListSort(): ISort + { + // sort attributes are matched by key + return new Sort(array_fill_keys($this->serviceAbilities[self::CAPABILITY_NODE_LIST_SORT] ?? [], true)); + } + + 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(); + } + + private function nodeSize(CollectionResource|EntityResource $node): int + { + return $node instanceof EntityResource ? $node->getProperties()->size() : 0; + } + } diff --git a/lib/Service/Remote/RemoteFilesService.php b/lib/Service/Remote/RemoteFilesService.php index da2eeeb..09561f9 100644 --- a/lib/Service/Remote/RemoteFilesService.php +++ b/lib/Service/Remote/RemoteFilesService.php @@ -137,6 +137,54 @@ class RemoteFilesService { return $list; } + /** + * list of nodes (folders and files) directly within a location in remote storage + * + * FileNode/query support is incomplete on some servers (e.g. empty fileNodeQuerySortOptions), + * so all nodes are retrieved and the children of the location are selected locally. + * + * @since 2026.10.01 + * + * @return array{list: array, state: string|null} + */ + public function nodeList(?string $location = null): array { + $location = (empty($location) || $location === self::ROOT_ID) ? null : $location; + // construct request + $r0 = new NodeGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); + // transceive + $bundle = $this->dataStore->perform([$r0]); + // extract response + $response = $bundle->response(0); + // check for command error + if ($response instanceof ResponseException) { + if ($response->type() === 'unknownMethod') { + throw new JmapUnknownMethod($response->description(), 1); + } else { + throw new Exception($response->type() . ': ' . $response->description(), 1); + } + } + // select direct children of location + $state = $response->state(); + $list = []; + foreach ($response->objects() as $so) { + if (!$so instanceof NodeParametersResponse) { + continue; + } + $parameters = $so->parametersRaw(); + $parent = $parameters['parentId'] ?? null; + if ($parent === self::ROOT_ID) { + $parent = null; + } + if ($parent !== $location) { + continue; + } + $parameters['signature'] = $state; + $list[$so->id()] = $parameters; + } + // return node collection + return ['list' => $list, 'state' => $state]; + } + /** * fresh instance of collection filter object *