4 Commits

Author SHA1 Message Date
Sebastian cc1deb4c07 feat: implement entiry read and write
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-10-01 21:31:00 -04:00
Sebastian dbfb5f8b37 feat: implement node list
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-10-01 21:10:42 -04:00
Sebastian de71f95e3d refactor: mailbox resolution
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-09-26 21:49:16 -04:00
Sebastian c8a632d20f refactor(module): register integrations through runtime contexts
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-31 22:55:11 -04:00
6 changed files with 549 additions and 114 deletions
+8 -12
View File
@@ -14,7 +14,8 @@ use KTXC\User\Event\UserCreatedEvent;
use KTXC\User\Event\UserDeletingEvent;
use KTXF\Event\DeliveryMode;
use KTXF\Event\EventListenerRegistrarInterface;
use KTXF\Module\ModuleBrowserInterface;
use KTXF\Module\Configuration\BrowserModuleContextInterface;
use KTXF\Module\Configuration\ModuleContextInterface;
use KTXF\Module\ModuleInstanceAbstract;
use KTXF\Resource\Provider\ProviderInterface;
use KTXM\ProviderJmapc\Listeners\UserEventListener;
@@ -28,7 +29,7 @@ use KTXM\ProviderJmapc\Providers\Document\Provider as DocumentProvider;
*
* Provides mail, calendar, and contacts services via JMAP protocol.
*/
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
class Module extends ModuleInstanceAbstract
{
public function __construct(
private readonly ProviderManager $providerManager,
@@ -83,15 +84,10 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
$this->providerManager->register(ProviderInterface::TYPE_DOCUMENT, 'jmap', DocumentProvider::class);
}
public function registerBI(): array {
return [
'handle' => $this->handle(),
'namespace' => 'ProviderJmapc',
'version' => $this->version(),
'label' => $this->label(),
'author' => $this->author(),
'description' => $this->description(),
'boot' => 'static/module.mjs',
];
public function configure(ModuleContextInterface $context): void
{
if ($context instanceof BrowserModuleContextInterface) {
$context->registerModule($this, 'ProviderJmapc', 'static/module.mjs');
}
}
}
+8 -16
View File
@@ -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;
@@ -52,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;
@@ -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);
+335 -4
View File
@@ -10,22 +10,34 @@ 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;
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 +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
class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface, ServiceEntityMutableInterface, ServiceNodeListInterface
{
public const JSON_TYPE = ServiceBaseInterface::JSON_TYPE;
@@ -76,6 +88,29 @@ 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',
],
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;
@@ -484,19 +519,315 @@ 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<string, array{disposition: string, destination: ?CollectionIdentifierInterface, mutation: EntityIdentifierInterface}>
*/
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)
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;
}
}
+25 -20
View File
@@ -468,21 +468,12 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
$this->initialize();
$deleteMode = $this->auxiliary['deleteMode'] ?? 'soft';
$deleteTarget = $this->auxiliary['deleteTarget'] ?? null;
if ($deleteMode !== 'soft' && $deleteMode !== 'hard') {
throw new \InvalidArgumentException("Invalid delete mode: $deleteMode");
}
$deleteTargetIdentifier = null;
if ($deleteMode === 'soft') {
$deleteTargetIdentifier = $deleteTarget !== null && (string) $deleteTarget !== ''
? new CollectionIdentifier($this->provider(), (string) $this->identifier(), (string) $deleteTarget)
: $this->resolveCollectionByRole(CollectionRoles::Trash->value);
if ($deleteTargetIdentifier === null) {
throw new \RuntimeException('No Trash collection configured or found for deletion');
}
}
$deleteTargetIdentifier = $deleteMode === 'soft' ? $this->resolveDeleteDestination() : null;
if ($deleteMode === 'soft') {
$targetMailbox = $this->mailService->collectionFetch((string) $target->collection());
@@ -737,7 +728,6 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
// determine delete mode and target collection (e.g. Trash) if applicable
$deleteMode = $this->auxiliary['deleteMode'] ?? 'soft';
$deleteTarget = $this->auxiliary['deleteTarget'] ?? null;
if ($deleteMode !== 'soft' && $deleteMode !== 'hard') {
throw new \InvalidArgumentException("Invalid delete mode: $deleteMode");
@@ -746,15 +736,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
// connect to remote store
$this->initialize();
$deleteTargetIdentifier = null;
if ($deleteMode === 'soft') {
$deleteTargetIdentifier = $deleteTarget !== null && (string) $deleteTarget !== ''
? new CollectionIdentifier($this->provider(), (string) $this->identifier(), (string) $deleteTarget)
: $this->resolveCollectionByRole(CollectionRoles::Trash->value);
if ($deleteTargetIdentifier === null) {
throw new \RuntimeException('No Trash collection configured or found for deletion');
}
}
$deleteTargetIdentifier = $deleteMode === 'soft' ? $this->resolveDeleteDestination() : null;
$deleteTargetNative = $deleteTargetIdentifier?->collection();
@@ -1036,6 +1018,29 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
return [$native->toJmap(), $uploads];
}
/**
* Resolve the collection soft-deleted messages and collections are moved to
*/
private function resolveDeleteDestination(): CollectionIdentifier
{
$destination = trim((string) ($this->auxiliary['deleteDestination'] ?? ''));
if ($destination === '') {
return $this->resolveCollectionByRole(CollectionRoles::Trash->value)
?? throw new \RuntimeException('No Trash collection configured or found for deletion');
}
$role = CollectionRoles::tryFrom(strtolower($destination));
if ($role !== null && $role !== CollectionRoles::None) {
$resolved = $this->resolveCollectionByRole($role->value);
if ($resolved !== null) {
return $resolved;
}
}
return new CollectionIdentifier($this->provider(), (string) $this->identifier(), $destination);
}
private function resolveCollectionByRole(string $role): ?CollectionIdentifier
{
$filter = $this->collectionListFilter();
+173 -61
View File
@@ -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<string,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
*
@@ -601,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;
}
@@ -645,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;
}
@@ -672,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);
}
}
}