diff --git a/lib/Providers/Document/EntityProperties.php b/lib/Providers/Document/EntityProperties.php index cec96b6..1a5bcd9 100644 --- a/lib/Providers/Document/EntityProperties.php +++ b/lib/Providers/Document/EntityProperties.php @@ -44,8 +44,8 @@ class EntityProperties extends EntityPropertiesMutableAbstract { public function toJmap(): array { $parameters = array_filter([ - 'name' => $this->data[self::PROPERTY_LABEL], - 'type' => $this->data[self::PROPERTY_MIME] ?? 'application/octet-stream' + 'name' => $this->data[self::PROPERTY_LABEL] ?? null, + 'type' => $this->data[self::PROPERTY_MIME] ?? null ], static fn($value) => $value !== null); return $parameters; diff --git a/lib/Providers/Document/Service.php b/lib/Providers/Document/Service.php index 2e7ff33..77286c7 100644 --- a/lib/Providers/Document/Service.php +++ b/lib/Providers/Document/Service.php @@ -10,19 +10,27 @@ declare(strict_types=1); namespace KTXM\ProviderJmapc\Providers\Document; use Generator; +use KTXF\Blob\Signature; use KTXF\Documents\Collection\CollectionBaseInterface; use KTXF\Documents\Collection\CollectionMutableInterface; use KTXF\Documents\Collection\CollectionPropertiesBaseInterface; +use KTXF\Documents\Entity\EntityBaseInterface; +use KTXF\Documents\Entity\EntityMutableInterface; +use KTXF\Documents\Entity\EntityPropertiesMutableInterface; use KTXF\Documents\Service\ServiceBaseInterface; use KTXF\Documents\Service\ServiceCollectionMutableInterface; use KTXF\Documents\Service\ServiceConfigurableInterface; +use KTXF\Documents\Service\ServiceEntityMutableInterface; use KTXF\Documents\Service\ServiceMutableInterface; use KTXF\Documents\Service\ServiceNodeListInterface; use KTXF\Resource\Delta\Delta; use KTXF\Resource\Exceptions\InvalidParameterException; +use KTXF\Resource\Exceptions\UnsupportedException; 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\Provider\ResourceServiceIdentityInterface; use KTXF\Resource\Provider\ResourceServiceLocationInterface; @@ -38,7 +46,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, ServiceNodeListInterface +class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface, ServiceEntityMutableInterface, ServiceNodeListInterface { public const JSON_TYPE = ServiceBaseInterface::JSON_TYPE; @@ -80,6 +88,13 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC self::CAPABILITY_ENTITY_DELTA => true, self::CAPABILITY_ENTITY_EXTANT => true, self::CAPABILITY_ENTITY_FETCH => true, + self::CAPABILITY_ENTITY_READ => 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_WRITE => true, self::CAPABILITY_NODE_LIST => true, self::CAPABILITY_NODE_LIST_FILTER => [ self::CAPABILITY_NODE_FILTER_LABEL => 's:100:256:256', @@ -504,19 +519,208 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC yield from $this->entityFetchBulk(...$identifiers); } + public function entityFresh(): EntityMutableInterface + { + return new EntityResource(provider: $this->provider(), service: $this->identifier()); + } + + /** + * Convert incoming entity properties into a native EntityResource + */ + private function entityNative(EntityPropertiesMutableInterface $properties, string|int|null $identifier = null): EntityResource + { + if ($properties instanceof EntityResource) { + return clone $properties; + } + $object = new EntityResource(provider: $this->provider(), service: $this->identifier()); + $object->jsonDeserialize(array_filter([ + EntityResource::PROPERTY_IDENTIFIER => $identifier, + EntityResource::PROPERTY_PROPERTIES => $properties->jsonSerialize(), + ], fn($v) => $v !== null)); + return $object; + } + + /** + * Retrieve the remote parameters of an entity + */ + private function entityRemote(EntityIdentifierInterface $target): ?array + { + $this->initialize(); + + $identifier = (string)$target->entity(); + $entity = $this->remoteService->entityFetch($identifier)[$identifier] ?? null; + // folders are not entities + if (!is_array($entity) || empty($entity['blobId'])) { + return null; + } + return $entity; + } + + public function entityCreate(CollectionIdentifierInterface $target, EntityPropertiesMutableInterface $properties, array $options = []): EntityBaseInterface + { + $this->initialize(); + + $entity = $this->entityNative($properties)->toJmap(); + $entity['type'] ??= 'application/octet-stream'; + // a file node must reference a blob, so start with empty content until it is written + $content = ''; + $blob = $this->remoteService->blobUpload($entity['type'], $content); + $entity['blobId'] = $blob['blobId']; + $entity = $this->remoteService->entityCreate((string)$target->collection(), $entity); + if ($entity === null) { + throw new \RuntimeException("Failed to create entity in collection: {$target->collection()}"); + } + + $object = new EntityResource(provider: $this->provider(), service: $this->identifier()); + $object->fromJmap($entity); + + return $object; + } + + public function entityModify(EntityIdentifierInterface $target, EntityPropertiesMutableInterface $properties): EntityBaseInterface + { + $this->initialize(); + + $identifier = (string)$target->entity(); + $entity = $this->entityNative($properties, $identifier)->toJmap(); + if ($this->remoteService->entityModify($identifier, $entity) === null) { + throw new \RuntimeException("Failed to modify entity: $identifier"); + } + + $object = new EntityResource(provider: $this->provider(), service: $this->identifier()); + $object->fromJmap($this->entityRemote($target) ?? $entity); + + return $object; + } + + public function entityDelete(EntityIdentifierInterface ...$targets): array + { + $this->initialize(); + + $results = []; + foreach ($targets as $target) { + $key = (string)$target; + try { + $deleted = $this->remoteService->entityDelete((string)$target->entity()) !== null; + } catch (\Throwable) { + $deleted = false; + } + $results[$key] = ['disposition' => $deleted ? 'deleted' : 'error', 'destination' => null, 'mutation' => $target]; + } + return $results; + } + + 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 + * + * @return array + */ + private function entityRelocate(CollectionIdentifierInterface $target, bool $remove, EntityIdentifierInterface ...$sources): array + { + $this->initialize(); + + $results = []; + $collection = (string)$target->collection(); + foreach ($sources as $source) { + $key = (string)$source; + try { + $entity = $remove + ? $this->remoteService->entityMove($collection, (string)$source->entity()) + : $this->remoteService->entityCopy($collection, (string)$source->entity()); + } catch (\Throwable) { + $entity = null; + } + if ($entity === null) { + $results[$key] = ['disposition' => 'error', 'destination' => null, 'mutation' => $source]; + continue; + } + $results[$key] = [ + 'disposition' => $remove ? 'moved' : 'copied', + 'destination' => new CollectionIdentifier($this->provider(), $this->identifier(), $collection), + 'mutation' => new EntityIdentifier($this->provider(), $this->identifier(), $collection, (string)$entity['id']), + ]; + } + return $results; + } + public function entityRead(EntityIdentifierInterface $target): ?string { - return null; + $entity = $this->entityRemote($target); + if ($entity === null) { + return null; + } + return $this->remoteService->blobDownload($entity['blobId'], $entity['type'] ?? 'application/octet-stream'); } public function entityReadStream(EntityIdentifierInterface $target) { - return null; + $entity = $this->entityRemote($target); + if ($entity === null) { + return null; + } + return $this->remoteService->blobDownloadStream($entity['blobId'], $entity['type'] ?? 'application/octet-stream'); } public function entityReadChunk(EntityIdentifierInterface $target, int $offset, int $length): ?string { - return null; + $content = $this->entityRead($target); + if ($content === null) { + return null; + } + return substr($content, $offset, $length); + } + + public function entityWrite(EntityIdentifierInterface $target, string $data): int + { + $entity = $this->entityRemote($target); + $identifier = (string)$target->entity(); + if ($entity === null) { + throw new InvalidParameterException("Entity not found: $identifier"); + } + + // blobs are immutable, so content is replaced by pointing the node at a new blob + $type = $entity['type'] ?? null; + if (empty($type) || $type === 'application/octet-stream') { + $type = Signature::detect(substr($data, 0, Signature::SAMPLE_SIZE), $data)['mime']; + } + $blob = $this->remoteService->blobUpload($type, $data); + if ($this->remoteService->entityModify($identifier, ['blobId' => $blob['blobId'], 'type' => $type]) === null) { + throw new \RuntimeException("Failed to write to entity: $identifier"); + } + + return strlen($data); + } + + public function entityWriteChunk(EntityIdentifierInterface $target, int $offset, string $data): int + { + if ($offset === 0) { + $content = $data; + } else { + // blobs can not be partially updated, so the chunk is merged into the current content + $content = $this->entityRead($target); + if ($content === null) { + throw new InvalidParameterException("Entity not found: {$target->entity()}"); + } + $content = substr(str_pad($content, $offset, "\0"), 0, $offset) . $data . substr($content, $offset + strlen($data)); + } + $this->entityWrite($target, $content); + + return strlen($data); + } + + public function entityWriteStream(EntityIdentifierInterface $target) + { + throw new UnsupportedException('Streamed writes are not supported by JMAP file storage'); } // Node operations (unified collections + entities) diff --git a/lib/Service/Remote/RemoteFilesService.php b/lib/Service/Remote/RemoteFilesService.php index 09561f9..9b18dda 100644 --- a/lib/Service/Remote/RemoteFilesService.php +++ b/lib/Service/Remote/RemoteFilesService.php @@ -649,42 +649,35 @@ class RemoteFilesService { /** * 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 $location, array $so): ?array { + 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); - $to->in($location); + // 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 - $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); - } - } + $response = $this->performSet($r0); // check for success $result = $response->createSuccess($id); if ($result !== null) { - return array_merge($so, $result); + return array_merge($so, ['parentId' => $location], $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 + $this->throwSetFailure($response->createFailure($id), 'entity creation'); return null; } @@ -693,26 +686,22 @@ class RemoteFilesService { * * @since Release 1.0.0 */ - public function entityModify(array $so): ?array { - // extract entity id - $id = $so['id']; + 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($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; + $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; } @@ -720,55 +709,130 @@ class RemoteFilesService { * delete entity from remote storage * * @since Release 1.0.0 + * + * @return string|null new state on success */ - public function entityDelete(string $id): ?string { - // construct set request + public function entityDelete(string $identifier): ?string { + // construct 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) { + $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 * - * @since Release 1.0.0 + * the copy references the same blob as the source, blobs are immutable * + * @since Release 1.0.0 */ - public function entityCopy(string $location, MailMessageObject $so): ?MailMessageObject { - return null; + 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 $location, array $so): ?array { - // extract entity id - $id = $so['id']; + 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($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; + $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); + } + } + }