cc1deb4c07
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
834 lines
30 KiB
PHP
834 lines
30 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
*/
|
|
|
|
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;
|
|
use KTXM\ProviderJmapc\Providers\ServiceIdentityBasic;
|
|
use KTXM\ProviderJmapc\Providers\ServiceLocation;
|
|
use KTXM\ProviderJmapc\Service\Remote\RemoteFilesService;
|
|
use KTXM\ProviderJmapc\Service\Remote\RemoteService;
|
|
|
|
class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface, ServiceEntityMutableInterface, ServiceNodeListInterface
|
|
{
|
|
public const JSON_TYPE = ServiceBaseInterface::JSON_TYPE;
|
|
|
|
private const PROVIDER_IDENTIFIER = 'jmap';
|
|
|
|
private ?string $serviceTenantId = null;
|
|
private ?string $serviceUserId = null;
|
|
private ?string $serviceIdentifier = null;
|
|
private ?string $serviceLabel = null;
|
|
private bool $serviceEnabled = false;
|
|
private bool $serviceDebug = false;
|
|
private ?ServiceLocation $location = null;
|
|
private ?ServiceIdentityBasic $identity = null;
|
|
private array $auxiliary = [];
|
|
|
|
private array $serviceAbilities = [
|
|
self::CAPABILITY_COLLECTION_LIST => true,
|
|
self::CAPABILITY_COLLECTION_LIST_FILTER => [
|
|
self::CAPABILITY_COLLECTION_FILTER_LABEL => 's:100:256:256',
|
|
],
|
|
self::CAPABILITY_COLLECTION_LIST_SORT => [
|
|
self::CAPABILITY_COLLECTION_SORT_LABEL,
|
|
],
|
|
self::CAPABILITY_COLLECTION_EXTANT => true,
|
|
self::CAPABILITY_COLLECTION_FETCH => true,
|
|
self::CAPABILITY_COLLECTION_CREATE => true,
|
|
self::CAPABILITY_COLLECTION_UPDATE => true,
|
|
self::CAPABILITY_COLLECTION_DELETE => true,
|
|
self::CAPABILITY_ENTITY_LIST => true,
|
|
self::CAPABILITY_ENTITY_LIST_FILTER => [
|
|
self::CAPABILITY_ENTITY_FILTER_ALL => 's:200:256:256',
|
|
],
|
|
self::CAPABILITY_ENTITY_LIST_SORT => [
|
|
self::CAPABILITY_ENTITY_SORT_LABEL
|
|
],
|
|
self::CAPABILITY_ENTITY_LIST_RANGE => [
|
|
'tally' => ['absolute', 'relative']
|
|
],
|
|
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;
|
|
|
|
public function __construct(
|
|
) {}
|
|
|
|
private function initialize(): void {
|
|
if (!isset($this->remoteService)) {
|
|
$client = RemoteService::freshClient($this);
|
|
$this->remoteService = RemoteService::documentsService($client);
|
|
}
|
|
}
|
|
|
|
public function toStore(): array {
|
|
return array_filter([
|
|
'tid' => $this->serviceTenantId,
|
|
'uid' => $this->serviceUserId,
|
|
'sid' => $this->serviceIdentifier,
|
|
'label' => $this->serviceLabel,
|
|
'enabled' => $this->serviceEnabled,
|
|
'debug' => $this->serviceDebug,
|
|
'location' => $this->location?->toStore(),
|
|
'identity' => $this->identity?->toStore(),
|
|
'auxiliary' => $this->auxiliary,
|
|
], fn($v) => $v !== null);
|
|
}
|
|
|
|
public function fromStore(array $data): static {
|
|
$this->serviceTenantId = $data['tid'] ?? null;
|
|
$this->serviceUserId = $data['uid'] ?? null;
|
|
$this->serviceIdentifier = $data['sid'];
|
|
$this->serviceLabel = $data['label'] ?? '';
|
|
$this->serviceEnabled = $data['enabled'] ?? false;
|
|
$this->serviceDebug = $data['debug'] ?? false;
|
|
|
|
if (isset($data['location'])) {
|
|
$this->location = (new ServiceLocation())->fromStore($data['location']);
|
|
}
|
|
|
|
if (isset($data['identity'])) {
|
|
$this->identity = (new ServiceIdentityBasic())->fromStore($data['identity']);
|
|
}
|
|
if (isset($data['auxiliary']) && is_array($data['auxiliary'])) {
|
|
$this->auxiliary = $data['auxiliary'];
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function jsonSerialize(): array {
|
|
return array_filter([
|
|
self::PROPERTY_TYPE => self::JSON_TYPE,
|
|
self::PROPERTY_PROVIDER => self::PROVIDER_IDENTIFIER,
|
|
self::PROPERTY_IDENTIFIER => $this->serviceIdentifier,
|
|
self::PROPERTY_LABEL => $this->serviceLabel,
|
|
self::PROPERTY_ENABLED => $this->serviceEnabled,
|
|
self::PROPERTY_CAPABILITIES => $this->serviceAbilities,
|
|
self::PROPERTY_LOCATION => $this->location?->jsonSerialize(),
|
|
self::PROPERTY_IDENTITY => $this->identity?->jsonSerialize(),
|
|
self::PROPERTY_AUXILIARY => $this->auxiliary,
|
|
], fn($v) => $v !== null);
|
|
}
|
|
|
|
public function jsonDeserialize(array|string $data): static {
|
|
if (is_string($data)) {
|
|
$data = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
|
|
}
|
|
|
|
if (isset($data[self::PROPERTY_LABEL])) {
|
|
$this->setLabel($data[self::PROPERTY_LABEL]);
|
|
}
|
|
if (isset($data[self::PROPERTY_ENABLED])) {
|
|
$this->setEnabled($data[self::PROPERTY_ENABLED]);
|
|
}
|
|
if (isset($data[self::PROPERTY_LOCATION])) {
|
|
$this->setLocation($this->freshLocation(null, $data[self::PROPERTY_LOCATION]));
|
|
}
|
|
if (isset($data[self::PROPERTY_IDENTITY])) {
|
|
$this->setIdentity($this->freshIdentity(null, $data[self::PROPERTY_IDENTITY]));
|
|
}
|
|
if (isset($data[self::PROPERTY_AUXILIARY]) && is_array($data[self::PROPERTY_AUXILIARY])) {
|
|
$this->setAuxiliary($data[self::PROPERTY_AUXILIARY]);
|
|
}
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function capable(string $value): bool
|
|
{
|
|
return isset($this->serviceAbilities[$value]);
|
|
}
|
|
|
|
public function capabilities(): array {
|
|
return $this->serviceAbilities;
|
|
}
|
|
|
|
public function provider(): string
|
|
{
|
|
return self::PROVIDER_IDENTIFIER;
|
|
}
|
|
|
|
public function identifier(): string|int
|
|
{
|
|
return $this->serviceIdentifier;
|
|
}
|
|
|
|
public function getLabel(): string|null
|
|
{
|
|
return $this->serviceLabel;
|
|
}
|
|
|
|
public function setLabel(string $label): static
|
|
{
|
|
$this->serviceLabel = $label;
|
|
return $this;
|
|
}
|
|
|
|
public function getEnabled(): bool
|
|
{
|
|
return $this->serviceEnabled;
|
|
}
|
|
|
|
public function setEnabled(bool $enabled): static
|
|
{
|
|
$this->serviceEnabled = $enabled;
|
|
return $this;
|
|
}
|
|
|
|
public function getLocation(): ServiceLocation
|
|
{
|
|
return $this->location;
|
|
}
|
|
|
|
public function setLocation(ResourceServiceLocationInterface $location): static
|
|
{
|
|
$this->location = $location;
|
|
return $this;
|
|
}
|
|
|
|
public function freshLocation(string|null $type = null, array $data = []): ServiceLocation
|
|
{
|
|
$location = new ServiceLocation();
|
|
$location->jsonDeserialize($data);
|
|
return $location;
|
|
}
|
|
|
|
public function getIdentity(): ServiceIdentityBasic
|
|
{
|
|
return $this->identity;
|
|
}
|
|
|
|
public function setIdentity(ResourceServiceIdentityInterface $identity): static
|
|
{
|
|
$this->identity = $identity;
|
|
return $this;
|
|
}
|
|
|
|
public function freshIdentity(string|null $type, array $data = []): ServiceIdentityBasic
|
|
{
|
|
$identity = new ServiceIdentityBasic();
|
|
$identity->jsonDeserialize($data);
|
|
return $identity;
|
|
}
|
|
|
|
public function getDebug(): bool
|
|
{
|
|
return $this->serviceDebug;
|
|
}
|
|
|
|
public function setDebug(bool $debug): static
|
|
{
|
|
$this->serviceDebug = $debug;
|
|
return $this;
|
|
}
|
|
|
|
public function getAuxiliary(): array
|
|
{
|
|
return $this->auxiliary;
|
|
}
|
|
|
|
public function setAuxiliary(array $auxiliary): static
|
|
{
|
|
$this->auxiliary = $auxiliary;
|
|
return $this;
|
|
}
|
|
|
|
// Collection operations
|
|
|
|
public function collectionList(string|int|null $location, ?IFilter $filter = null, ?ISort $sort = null): array
|
|
{
|
|
$this->initialize();
|
|
|
|
$collections = $this->remoteService->collectionList($location, $filter, $sort);
|
|
|
|
foreach ($collections as &$collection) {
|
|
if (is_array($collection) && isset($collection['id'])) {
|
|
$object = new CollectionResource(provider: $this->provider(), service: $this->identifier());
|
|
$object->fromJmap($collection);
|
|
$collection = $object;
|
|
}
|
|
}
|
|
|
|
return $collections;
|
|
}
|
|
|
|
public function collectionListFilter(): Filter
|
|
{
|
|
return new Filter($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_FILTER] ?? []);
|
|
}
|
|
|
|
public function collectionListSort(): Sort
|
|
{
|
|
return new Sort($this->serviceAbilities[self::CAPABILITY_COLLECTION_LIST_SORT] ?? []);
|
|
}
|
|
|
|
public function collectionExtant(string|int|null $location, string|int ...$identifiers): array
|
|
{
|
|
$this->initialize();
|
|
|
|
return $this->remoteService->collectionExtant($location, ...$identifiers);
|
|
}
|
|
|
|
public function collectionFetch(string|int|null $identifier): ?CollectionBaseInterface
|
|
{
|
|
$this->initialize();
|
|
|
|
$collection = $this->remoteService->collectionFetch($identifier);
|
|
|
|
if (is_array($collection) && isset($collection['id'])) {
|
|
$object = new CollectionResource(provider: $this->provider(), service: $this->identifier());
|
|
$object->fromJmap($collection);
|
|
$collection = $object;
|
|
}
|
|
|
|
return $collection;
|
|
}
|
|
|
|
public function collectionFresh(): CollectionMutableInterface
|
|
{
|
|
return new CollectionResource(provider: $this->provider(), service: $this->identifier());
|
|
}
|
|
|
|
/**
|
|
* Convert incoming collection properties into a native CollectionResource
|
|
*/
|
|
private function collectionNative(CollectionPropertiesBaseInterface $properties, string|int|null $identifier = null): CollectionResource
|
|
{
|
|
if ($properties instanceof CollectionResource) {
|
|
return clone $properties;
|
|
}
|
|
$object = new CollectionResource(provider: $this->provider(), service: $this->identifier());
|
|
$object->jsonDeserialize(array_filter([
|
|
CollectionResource::PROPERTY_IDENTIFIER => $identifier,
|
|
CollectionResource::PROPERTY_PROPERTIES => $properties->jsonSerialize(),
|
|
], fn($v) => $v !== null));
|
|
return $object;
|
|
}
|
|
|
|
public function collectionCreate(CollectionIdentifierInterface|null $target, CollectionPropertiesBaseInterface $properties, array $options = []): CollectionBaseInterface
|
|
{
|
|
$this->initialize();
|
|
|
|
$collection = $this->collectionNative($properties);
|
|
|
|
$collection = $collection->toJmap();
|
|
$collection = $this->remoteService->collectionCreate($target?->collection(), $collection, $options);
|
|
|
|
$object = new CollectionResource(provider: $this->provider(), service: $this->identifier());
|
|
$object->fromJmap($collection);
|
|
|
|
return $object;
|
|
}
|
|
|
|
public function collectionUpdate(CollectionIdentifierInterface $target, CollectionPropertiesBaseInterface $properties): CollectionBaseInterface
|
|
{
|
|
$this->initialize();
|
|
|
|
$identifier = $target->collection();
|
|
$collection = $this->collectionNative($properties, $identifier);
|
|
|
|
$collection = $collection->toJmap();
|
|
$collection = $this->remoteService->collectionModify($identifier, $collection);
|
|
|
|
$object = new CollectionResource(provider: $this->provider(), service: $this->identifier());
|
|
$object->fromJmap($collection);
|
|
|
|
return $object;
|
|
}
|
|
|
|
public function collectionDelete(CollectionIdentifierInterface $target, bool $force = false): CollectionBaseInterface | true
|
|
{
|
|
$this->initialize();
|
|
|
|
if ($this->remoteService->collectionDestroy($target->collection(), $force, $force) === null) {
|
|
throw new \RuntimeException("Failed to delete collection: {$target->collection()}");
|
|
}
|
|
return true;
|
|
}
|
|
|
|
public function collectionMove(CollectionIdentifierInterface|null $target, CollectionIdentifierInterface $source): CollectionBaseInterface
|
|
{
|
|
// TODO: Implement collection move
|
|
$this->initialize();
|
|
$collection = new CollectionResource(provider: $this->provider(), service: $this->identifier());
|
|
return $collection;
|
|
}
|
|
|
|
public function collectionCopy(CollectionIdentifierInterface|null $target, CollectionIdentifierInterface $source): CollectionBaseInterface
|
|
{
|
|
// TODO: Implement collection copy
|
|
$this->initialize();
|
|
$collection = new CollectionResource(provider: $this->provider(), service: $this->identifier());
|
|
return $collection;
|
|
}
|
|
|
|
// Entity operations
|
|
|
|
public function entityListBulk(string|int|null $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array
|
|
{
|
|
$this->initialize();
|
|
|
|
$result = $this->remoteService->entityList($collection, $filter, $sort, $range, $properties);
|
|
|
|
$list = [];
|
|
foreach ($result['list'] as $index => $entry) {
|
|
if (is_array($entry) && isset($entry['id'])) {
|
|
$object = new EntityResource(provider: $this->provider(), service: $this->identifier());
|
|
$object->fromJmap($entry);
|
|
$list[$object->identifier()] = $object;
|
|
}
|
|
unset($result['list'][$index]);
|
|
}
|
|
|
|
return $list;
|
|
}
|
|
|
|
public function entityListStream(string|int|null $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): Generator
|
|
{
|
|
$this->initialize();
|
|
|
|
$result = $this->remoteService->entityList($collection, $filter, $sort, $range, $properties);
|
|
|
|
foreach ($result['list'] as $index => $entry) {
|
|
if (is_array($entry) && isset($entry['id'])) {
|
|
$object = new EntityResource(provider: $this->provider(), service: $this->identifier());
|
|
$object->fromJmap($entry);
|
|
yield $object;
|
|
}
|
|
unset($result['list'][$index]);
|
|
}
|
|
}
|
|
|
|
public function entityListFilter(): Filter
|
|
{
|
|
return new Filter($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_FILTER] ?? []);
|
|
}
|
|
|
|
public function entityListSort(): Sort
|
|
{
|
|
return new Sort($this->serviceAbilities[self::CAPABILITY_ENTITY_LIST_SORT] ?? []);
|
|
}
|
|
|
|
public function entityListRange(RangeType $type): IRange
|
|
{
|
|
return new Range();
|
|
}
|
|
|
|
public function entityDelta(string|int|null $collection, string $signature): Delta
|
|
{
|
|
$this->initialize();
|
|
|
|
return $this->remoteService->entityDelta($collection, $signature, 'ids');
|
|
}
|
|
|
|
public function entityExtant(string|int|null $collection, string|int ...$identifiers): array
|
|
{
|
|
$this->initialize();
|
|
|
|
return $this->remoteService->entityExtant(...$identifiers);
|
|
}
|
|
|
|
public function entityFetchBulk(EntityIdentifierInterface ...$identifiers): array
|
|
{
|
|
$this->initialize();
|
|
|
|
$entityIds = array_map(fn($identifier) => $identifier->entity(), $identifiers);
|
|
$entities = $this->remoteService->entityFetch(...$entityIds);
|
|
|
|
$list = [];
|
|
foreach ($entities as $key => $entity) {
|
|
if (is_array($entity) && isset($entity['id'])) {
|
|
$object = new EntityResource(provider: $this->provider(), service: $this->identifier());
|
|
$object->fromJmap($entity);
|
|
$list[$object->identifier()] = $object;
|
|
} else {
|
|
$list[$key] = $entity;
|
|
}
|
|
}
|
|
|
|
return $list;
|
|
}
|
|
|
|
public function entityFetchStream(EntityIdentifierInterface ...$identifiers): Generator
|
|
{
|
|
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
|
|
{
|
|
$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)
|
|
{
|
|
$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
|
|
{
|
|
$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;
|
|
}
|
|
|
|
}
|