* 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; } /** * 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 * * @since Release 1.0.0 */ public function entityCreate(string $location, array $so): ?array { // convert entity $to = new NodeParametersRequest(); $to->parametersRaw($so); $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; } /** * update entity in remote storage * * @since Release 1.0.0 */ public function entityModify(array $so): ?array { // extract entity id $id = $so['id']; // convert entity $to = new NodeParametersRequest(); $to->parametersRaw($so); // construct request $r0 = new NodeSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->update($id, $to); // transmit request and receive response $bundle = $this->dataStore->perform([$r0]); // extract response $response = $bundle->response(0); // determine if command succeeded if (array_key_exists($id, $response->updated())) { // update entity $ro = $response->updated()[$id]; $so = array_merge($so, $ro); return $so; } return null; } /** * delete entity from remote storage * * @since Release 1.0.0 */ public function entityDelete(string $id): ?string { // construct set request $r0 = new NodeSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); // construct object $r0->delete($id); // transmit request and receive response $bundle = $this->dataStore->perform([$r0]); // extract response $response = $bundle->response(0); // determine if command succeeded if (array_search($id, $response->deleted()) !== false) { return $response->stateNew(); } return null; } /** * copy entity in remote storage * * @since Release 1.0.0 * */ public function entityCopy(string $location, MailMessageObject $so): ?MailMessageObject { return null; } /** * move entity in remote storage * * @since Release 1.0.0 * */ public function entityMove(string $location, array $so): ?array { // extract entity id $id = $so['id']; // construct request $r0 = new NodeSet($this->dataAccount, null, $this->resourceNamespace, $this->resourceEntityLabel); $r0->update($id)->in($location); // transmit request and receive response $bundle = $this->dataStore->perform([$r0]); // extract response $response = $bundle->response(0); // determine if command succeeded if (array_key_exists($id, $response->updated())) { $so = array_merge($so, ['mailboxIds' => [$location => true]]); return $so; } return null; } }