* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderJmapc\Service\Remote; use Exception; use JmapClient\Client; use JmapClient\Requests\Files\NodeGet; use JmapClient\Requests\Files\NodeQuery; use JmapClient\Requests\Files\NodeParameters as NodeParametersRequest; use JmapClient\Requests\Files\NodeQueryChanges; use JmapClient\Requests\Files\NodeSet; use JmapClient\Responses\Files\NodeParameters as NodeParametersResponse; use JmapClient\Responses\ResponseException; use KTXF\Resource\Delta\Delta; use KTXF\Resource\Delta\DeltaCollection; use KTXF\Resource\Filter\Filter; use KTXF\Resource\Filter\IFilter; use KTXF\Resource\Range\IRange; use KTXF\Resource\Range\RangeAnchorType; use KTXF\Resource\Range\RangeTally; use KTXF\Resource\Sort\ISort; use KTXF\Resource\Sort\Sort; use KTXM\ProviderJmapc\Exception\JmapUnknownMethod; class RemoteFilesService { private const ROOT_ID = '00000000-0000-0000-0000-000000000000'; private const COLLECTION_FILTER_ATTRIBUTES = ['any', 'label', 'role', 'roles', 'createdBefore', 'createdAfter', 'modifiedBefore', 'modifiedAfter']; private const COLLECTION_SORT_ATTRIBUTES = ['tree']; private const ENTITY_FILTER_ATTRIBUTES = ['any', 'label', 'createdBefore', 'createdAfter', 'modifiedBefore', 'modifiedAfter']; private const ENTITY_SORT_ATTRIBUTES = ['tree']; protected Client $dataStore; protected string $dataAccount; protected ?string $resourceNamespace = null; protected ?string $resourceCollectionLabel = null; protected ?string $resourceEntityLabel = null; public function __construct() { } public function initialize(Client $dataStore, ?string $dataAccount = null) { $this->dataStore = $dataStore; // evaluate if client is connected if (!$this->dataStore->sessionStatus()) { $this->dataStore->connect(); } // determine account if ($dataAccount === null) { if ($this->resourceNamespace !== null) { $account = $dataStore->sessionAccountDefault($this->resourceNamespace, false); } else { $account = $dataStore->sessionAccountDefault('filenode'); } $this->dataAccount = $account !== null ? $account->id() : ''; } else { $this->dataAccount = $dataAccount; } } /** * list of collections in remote storage * * @since Release 1.0.0 */ public function collectionList(?string $location = null, IFilter|null $filter = null, ISort|null $sort = null, IRange|null $range = null): array { // construct request $r0 = new NodeQuery($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->filter()->is(false); $r0->depth(1); // define location if (!empty($location) && $location !== self::ROOT_ID) { $r0->filter()->in($location); } // define filter if ($filter !== null) { foreach ($filter->conditions() as $condition) { $value = $condition['value']; match($condition['attribute']) { 'label' => $r0->filter()->labelMatches($value), default => null }; } } // define order if ($sort !== null) { foreach ($sort->conditions() as $condition) { $direction = $condition['direction']; match($condition['attribute']) { 'tree' => $r0->sort()->tree($direction), default => null }; } } // construct request $r1 = new NodeGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); // define target //$r1->targetFromRequest($r0, '/ids'); if (!empty($location) && $location !== self::ROOT_ID) { $r1->target($location); } // transceive //$bundle = $this->dataStore->perform([$r1]); $bundle = $this->dataStore->perform([$r0, $r1]); // extract response //$response = $bundle->response(0); $response = $bundle->response(1); // 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); } } // convert jmap objects to collection objects $list = []; foreach ($response->objects() as $so) { if (!$so instanceof NodeParametersResponse) { continue; } $id = $so->id(); $list[$id] = $so->parametersRaw(); $list[$id]['signature'] = $response->state(); } // return collection of collections 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 * * @since Release 1.0.0 */ public function collectionListFilter(): Filter { return new Filter(self::COLLECTION_FILTER_ATTRIBUTES); } /** * fresh instance of collection sort object * * @since Release 1.0.0 */ public function collectionListSort(): Sort { return new Sort([self::COLLECTION_SORT_ATTRIBUTES]); } /** * check existence of collections in remote storage * * @since Release 1.0.0 */ public function collectionExtant(string ...$identifiers): array { $extant = []; // construct request $r0 = new NodeGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->target(...$identifiers); $r0->property('id'); // 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); } } // convert jmap objects to collection objects foreach ($response->objects() as $so) { if (!$so instanceof NodeParametersResponse) { continue; } $extant[$so->id()] = true; } return $extant; } /** * retrieve properties for specific collection * * @since Release 1.0.0 */ public function collectionFetch(string $identifier): ?array { // construct request $r0 = new NodeGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->target($identifier); // 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); } } // convert jmap object to collection object $so = $response->object(0); $to = null; if ($so instanceof NodeParametersResponse) { $to = $so->parametersRaw(); $to['signature'] = $response->state(); } return $to; } /** * create collection in remote storage * * @since Release 1.0.0 */ public function collectionCreate(string|null $location, array $so): ?array { // convert entity $to = new NodeParametersRequest(); $to->parametersRaw($so); // define location if (!empty($location) && $location !== self::ROOT_ID) { $to->in($location); } $id = uniqid(); // construct request $r0 = new NodeSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->create($id, $to); // 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); } } // check for success $result = $response->createSuccess($id); if ($result !== null) { return array_merge($so, $result); } // check for failure $result = $response->createFailure($id); if ($result !== null) { $type = $result['type'] ?? 'unknownError'; $description = $result['description'] ?? 'An unknown error occurred during collection creation.'; throw new Exception("$type: $description", 1); } // return null if creation failed without failure reason return null; } /** * modify collection in remote storage * * @since Release 1.0.0 * */ public function collectionModify(string $identifier, array $so): ?array { // convert entity $to = new NodeParametersRequest(); $to->parametersRaw($so); // construct request $r0 = new NodeSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->update($identifier, $to); // 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); } } // check for success $result = $response->updateSuccess($identifier); if ($result !== null) { return array_merge($so, $result); } // check for failure $result = $response->updateFailure($identifier); if ($result !== null) { $type = $result['type'] ?? 'unknownError'; $description = $result['description'] ?? 'An unknown error occurred during collection modification.'; throw new Exception("$type: $description", 1); } // return null if modification failed without failure reason return null; } /** * delete collection in remote storage * * @since Release 1.0.0 * */ public function collectionDestroy(string $identifier, bool $force = false, bool $recursive = false): ?string { // construct request $r0 = new NodeSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->delete($identifier); if ($force) { $r0->destroyContents(true); } // 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); } } // check for success $result = $response->deleteSuccess($identifier); if ($result !== null) { return (string)$result['id']; } // check for failure $result = $response->deleteFailure($identifier); if ($result !== null) { $type = $result['type'] ?? 'unknownError'; $description = $result['description'] ?? 'An unknown error occurred during collection deletion.'; throw new Exception("$type: $description", 1); } // return null if deletion failed without failure reason return null; } /** * retrieve entities from remote storage * * @since Release 1.0.0 */ public function entityList(?string $location = null, IFilter|null $filter = null, ISort|null $sort = null, IRange|null $range = null, string|null $granularity = null): array { // construct request $r0 = new NodeQuery($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); // define location if (!empty($location)) { $r0->filter()->in($location); } // define filter if ($filter !== null) { foreach ($filter->conditions() as $condition) { $value = $condition['value']; match($condition['attribute']) { 'any' => $r0->filter()->text($value), 'in' => $r0->filter()->in($value), 'label' => $r0->filter()->labelMatches($value), 'format' => $r0->filter()->formatIs($value), default => null }; } } // define order if ($sort !== null) { foreach ($sort->conditions() as $condition) { $direction = $condition['direction']; match($condition['attribute']) { 'type' => $r0->sort()->type($direction), default => null }; } } // define range if ($range !== null) { if ($range instanceof RangeTally && $range->getAnchor() === RangeAnchorType::ABSOLUTE) { $r0->limitAbsolute($range->getPosition(), $range->getTally()); } if ($range instanceof RangeTally && $range->getAnchor() === RangeAnchorType::RELATIVE) { $r0->limitRelative($range->getPosition(), $range->getTally()); } } // construct get request $r1 = new NodeGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); // set target to query request //$r1->targetFromRequest($r0, '/ids'); if (!empty($location)) { $r1->target($location); } // transmit request and receive response $bundle = $this->dataStore->perform([$r1]); //$bundle = $this->dataStore->perform([$r0, $r1]); // extract response $response = $bundle->response(0); //$response = $bundle->response(1); // convert json objects to message objects $state = $response->state(); $list = $response->objects(); foreach ($list as $id => $entry) { if (!$entry instanceof NodeParametersResponse) { continue; } $list[$id] = $entry->parametersRaw(); } // return message collection return ['list' => $list, 'state' => $state]; } /** * fresh instance of object filter * * @since Release 1.0.0 */ public function entityListFilter(): Filter { return new Filter(self::ENTITY_FILTER_ATTRIBUTES); } /** * fresh instance of object sort * * @since Release 1.0.0 */ public function entityListSort(): Sort { return new Sort(self::ENTITY_SORT_ATTRIBUTES); } /** * fresh instance of object range * * @since Release 1.0.0 */ public function entityListRange(): RangeTally { return new RangeTally(); } /** * check existence of entities in remote storage * * @since Release 1.0.0 */ public function entityExtant(string ...$identifiers): array { $extant = []; // construct request $r0 = new NodeGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->target(...$identifiers); $r0->property('id'); // transmit request and receive response $bundle = $this->dataStore->perform([$r0]); // extract response $response = $bundle->response(0); // convert json objects to message objects foreach ($response->objects() as $so) { if (!$so instanceof NodeParametersResponse) { continue; } $extant[$so->id()] = true; } return $extant; } /** * delta for entities in remote storage * * @since Release 1.0.0 * * @return Delta */ public function entityDelta(?string $location, string $state, string $granularity = 'D'): Delta { if (empty($state)) { $results = $this->entityList($location, null, null, null, 'B'); $delta = new Delta(); $delta->signature = $results['state']; foreach ($results['list'] as $entry) { $delta->additions[] = $entry['id']; } return $delta; } if (empty($location)) { return $this->entityDeltaDefault($state, $granularity); } else { return $this->entityDeltaSpecific($location, $state, $granularity); } } /** * delta of changes for specific collection in remote storage * * @since Release 1.0.0 * */ public function entityDeltaSpecific(?string $location, string $state, string $granularity = 'D'): Delta { // construct set request $r0 = new NodeQueryChanges($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); // set location constraint if (!empty($location)) { $r0->filter()->in($location); } // set state constraint if (!empty($state)) { $r0->state($state); } else { $r0->state('0'); } // 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); } } // convert jmap object to delta object $delta = new Delta(); $delta->signature = $response->stateNew(); $delta->additions = new DeltaCollection(array_column($response->added(), 'id')); $delta->modifications = new DeltaCollection([]); $delta->deletions = new DeltaCollection(array_column($response->removed(), 'id')); return $delta; } /** * delta of changes in remote storage * * @since Release 1.0.0 * */ public function entityDeltaDefault(string $state, string $granularity = 'D'): Delta { // construct set request $r0 = new NodeQueryChanges($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); // set state constraint if (!empty($state)) { $r0->state($state); } else { $r0->state(''); } // 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); } } // convert jmap object to delta object $delta = new Delta(); $delta->signature = $response->stateNew(); $delta->additions = new DeltaCollection(array_column($response->added(), 'id')); $delta->modifications = new DeltaCollection([]); $delta->deletions = new DeltaCollection(array_column($response->removed(), 'id')); return $delta; } /** * retrieve entity from remote storage * * @since Release 1.0.0 */ public function entityFetch(string ...$identifiers): ?array { // construct request $r0 = new NodeGet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->target(...$identifiers); // transmit request and receive response $bundle = $this->dataStore->perform([$r0]); // extract response $response = $bundle->response(0); // convert json objects to message objects $list = []; foreach ($response->objects() as $so) { if (!$so instanceof NodeParametersResponse) { continue; } $id = $so->id(); $list[$id] = $so->parametersRaw(); $list[$id]['signature'] = $response->state(); } // return message collection return $list; } /** * create entity in remote storage * * a file node must reference a blob, a node without one is a folder * * @since Release 1.0.0 */ public function entityCreate(string|null $location, array $so): ?array { if (empty($so['blobId'])) { throw new Exception('invalidArguments: A file node requires a blob identifier', 1); } // convert entity $to = new NodeParametersRequest(); $to->parametersRaw($so); // define location $location = $this->locationNormalize($location); if ($location !== null) { $to->in($location); } $id = uniqid(); // construct request $r0 = new NodeSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->create($id, $to); // transceive $response = $this->performSet($r0); // check for success $result = $response->createSuccess($id); if ($result !== null) { return array_merge($so, ['parentId' => $location], $result); } // check for failure $this->throwSetFailure($response->createFailure($id), 'entity creation'); return null; } /** * update entity in remote storage * * @since Release 1.0.0 */ public function entityModify(string $identifier, array $so): ?array { // convert entity $to = new NodeParametersRequest(); $to->parametersRaw($so); // construct request $r0 = new NodeSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->update($identifier, $to); // transceive $response = $this->performSet($r0); // check for success $result = $response->updateSuccess($identifier); if ($result !== null) { return array_merge($so, $result, ['id' => $identifier]); } // check for failure $this->throwSetFailure($response->updateFailure($identifier), 'entity modification'); return null; } /** * delete entity from remote storage * * @since Release 1.0.0 * * @return string|null new state on success */ public function entityDelete(string $identifier): ?string { // construct request $r0 = new NodeSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->delete($identifier); // transceive $response = $this->performSet($r0); // check for success if ($response->deleteSuccess($identifier) !== null) { return $response->stateNew(); } // check for failure $this->throwSetFailure($response->deleteFailure($identifier), 'entity deletion'); return null; } /** * copy entity in remote storage * * the copy references the same blob as the source, blobs are immutable * * @since Release 1.0.0 */ public function entityCopy(string|null $location, string $identifier): ?array { $source = $this->entityFetch($identifier)[$identifier] ?? null; if ($source === null) { return null; } return $this->entityCreate($location, array_intersect_key($source, array_flip(['blobId', 'name', 'type', 'executable']))); } /** * move entity in remote storage * * @since Release 1.0.0 */ public function entityMove(string|null $location, string $identifier): ?array { $location = $this->locationNormalize($location); // construct request $r0 = new NodeSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->update($identifier)->in($location); // transceive $response = $this->performSet($r0); // check for success $result = $response->updateSuccess($identifier); if ($result !== null) { return array_merge($result, ['id' => $identifier, 'parentId' => $location]); } // check for failure $this->throwSetFailure($response->updateFailure($identifier), 'entity move'); return null; } /** * upload blob to remote storage * * @since 2026.10.01 * * @return array{blobId: string, type?: string, size?: int} */ public function blobUpload(string $type, string &$data): array { $response = json_decode($this->dataStore->upload($this->dataAccount, $type, $data), true); if (!is_array($response) || empty($response['blobId'])) { throw new Exception('serverFail: Blob upload did not return a blob identifier', 1); } return $response; } /** * download blob from remote storage * * @since 2026.10.01 */ public function blobDownload(string $identifier, string $type = 'application/octet-stream'): string { $data = ''; $this->dataStore->download($this->dataAccount, $identifier, $data, $type); return $data; } /** * download blob from remote storage as stream * * @since 2026.10.01 * * @return resource */ public function blobDownloadStream(string $identifier, string $type = 'application/octet-stream') { $stream = $this->dataStore->downloadStream($this->dataAccount, $identifier, $type); return \GuzzleHttp\Psr7\StreamWrapper::getResource($stream); } /** * root location is represented by a null parent in remote storage */ private function locationNormalize(string|null $location): ?string { return (empty($location) || $location === self::ROOT_ID) ? null : $location; } /** * transceive a set request and return its response, throwing on command error */ private function performSet(NodeSet $request): mixed { $response = $this->dataStore->perform([$request])->response(0); if ($response instanceof ResponseException) { if ($response->type() === 'unknownMethod') { throw new JmapUnknownMethod($response->description(), 1); } else { throw new Exception($response->type() . ': ' . $response->description(), 1); } } return $response; } /** * throw set failure reason if present */ private function throwSetFailure(?array $failure, string $operation): void { if ($failure !== null) { $type = $failure['type'] ?? 'unknownError'; $description = $failure['description'] ?? "An unknown error occurred during $operation."; throw new Exception("$type: $description", 1); } } }