diff --git a/composer.json b/composer.json index 7a7b90a..ce7ae76 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "ktxm/file-manager", + "name": "ktxm/documents-manager", "type": "project", "authors": [ { @@ -12,7 +12,7 @@ "platform": { "php": "8.2" }, - "autoloader-suffix": "FileManager", + "autoloader-suffix": "DocumentsManager", "vendor-dir": "lib/vendor" }, "require": { @@ -20,7 +20,7 @@ }, "autoload": { "psr-4": { - "KTXM\\FileManager\\": "lib/" + "KTXM\\DocumentsManager\\": "lib/" } } } diff --git a/lib/Controllers/DefaultController.php b/lib/Controllers/DefaultController.php index 6d3ec5b..577e26e 100644 --- a/lib/Controllers/DefaultController.php +++ b/lib/Controllers/DefaultController.php @@ -7,7 +7,7 @@ declare(strict_types=1); * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace KTXM\FileManager\Controllers; +namespace KTXM\DocumentsManager\Controllers; use InvalidArgumentException; use KTXC\Http\Response\JsonResponse; @@ -16,49 +16,71 @@ use KTXC\SessionTenant; use KTXF\Controller\ControllerAbstract; use KTXF\Resource\Selector\SourceSelector; use KTXF\Routing\Attributes\AuthenticatedRoute; -use KTXM\FileManager\Manager; +use KTXM\DocumentsManager\Manager; use Psr\Log\LoggerInterface; use Throwable; class DefaultController extends ControllerAbstract { + private const ERR_MISSING_PROVIDER = 'Missing parameter: provider'; + private const ERR_MISSING_IDENTIFIER = 'Missing parameter: identifier'; + private const ERR_MISSING_SERVICE = 'Missing parameter: service'; + private const ERR_MISSING_COLLECTION = 'Missing parameter: collection'; + private const ERR_MISSING_DATA = 'Missing parameter: data'; + private const ERR_MISSING_SOURCES = 'Missing parameter: sources'; + private const ERR_MISSING_IDENTIFIERS = 'Missing parameter: identifiers'; + private const ERR_INVALID_OPERATION = 'Invalid operation: '; + private const ERR_INVALID_PROVIDER = 'Invalid parameter: provider must be a string'; + private const ERR_INVALID_SERVICE = 'Invalid parameter: service must be a string'; + private const ERR_INVALID_IDENTIFIER = 'Invalid parameter: identifier must be a string'; + private const ERR_INVALID_COLLECTION = 'Invalid parameter: collection must be a string or integer'; + private const ERR_INVALID_SOURCES = 'Invalid parameter: sources must be an array'; + private const ERR_INVALID_IDENTIFIERS = 'Invalid parameter: identifiers must be an array'; + private const ERR_INVALID_DATA = 'Invalid parameter: data must be an array'; + public function __construct( private readonly SessionTenant $tenantIdentity, private readonly SessionIdentity $userIdentity, - private Manager $fileManager, + private readonly Manager $manager, private readonly LoggerInterface $logger ) {} - /** - * Main API endpoint for file operations - * - * @return JsonResponse - */ - #[AuthenticatedRoute('/v1', name: 'filemanager.v1', methods: ['POST'])] - - public function index(int $version, string $transaction, string $operation, array $data = [], string|null $user = null): JsonResponse { + #[AuthenticatedRoute('/v1', name: 'documents.manager.v1', methods: ['POST'])] + public function index( + int $version, + string $transaction, + string|null $operation = null, + array|null $data = null, + string|null $user = null + ): JsonResponse { // authorize request $tenantId = $this->tenantIdentity->identifier(); $userId = $this->userIdentity->identifier(); try { - $data = $this->process($tenantId, $userId, $operation, $data); - return new JsonResponse([ - 'version' => $version, - 'transaction' => $transaction, - 'operation' => $operation, - 'status' => 'success', - 'data' => $data - ], JsonResponse::HTTP_OK); + + if ($operation !== null) { + $result = $this->process($tenantId, $userId, $operation, $data ?? [], []); + return new JsonResponse([ + 'version' => $version, + 'transaction' => $transaction, + 'operation' => $operation, + 'status' => 'success', + 'data' => $result + ], JsonResponse::HTTP_OK); + } + + throw new InvalidArgumentException('Operation must be provided'); + } catch (Throwable $t) { - $this->logger->error('Error processing file manager request', ['exception' => $t]); + $this->logger->error('Error processing request', ['exception' => $t]); return new JsonResponse([ 'version' => $version, 'transaction' => $transaction, 'operation' => $operation, 'status' => 'error', - 'error' => [ + 'data' => [ 'code' => $t->getCode(), 'message' => $t->getMessage() ] @@ -66,37 +88,50 @@ class DefaultController extends ControllerAbstract { } } + /** + * Process a single operation + */ private function process(string $tenantId, string $userId, string $operation, array $data): mixed { - return match ($operation) { // Provider operations 'provider.list' => $this->providerList($tenantId, $userId, $data), + 'provider.fetch' => $this->providerFetch($tenantId, $userId, $data), 'provider.extant' => $this->providerExtant($tenantId, $userId, $data), + // Service operations 'service.list' => $this->serviceList($tenantId, $userId, $data), 'service.extant' => $this->serviceExtant($tenantId, $userId, $data), 'service.fetch' => $this->serviceFetch($tenantId, $userId, $data), + 'service.create' => $this->serviceCreate($tenantId, $userId, $data), + 'service.update' => $this->serviceUpdate($tenantId, $userId, $data), + 'service.delete' => $this->serviceDelete($tenantId, $userId, $data), + 'service.test' => $this->serviceTest($tenantId, $userId, $data), + // Collection operations 'collection.list' => $this->collectionList($tenantId, $userId, $data), 'collection.extant' => $this->collectionExtant($tenantId, $userId, $data), 'collection.fetch' => $this->collectionFetch($tenantId, $userId, $data), 'collection.create' => $this->collectionCreate($tenantId, $userId, $data), - 'collection.modify' => $this->collectionModify($tenantId, $userId, $data), - 'collection.destroy' => $this->collectionDestroy($tenantId, $userId, $data), + 'collection.update' => $this->collectionUpdate($tenantId, $userId, $data), + 'collection.delete' => $this->collectionDelete($tenantId, $userId, $data), 'collection.copy' => $this->collectionCopy($tenantId, $userId, $data), 'collection.move' => $this->collectionMove($tenantId, $userId, $data), + // Entity operations 'entity.list' => $this->entityList($tenantId, $userId, $data), 'entity.delta' => $this->entityDelta($tenantId, $userId, $data), 'entity.extant' => $this->entityExtant($tenantId, $userId, $data), 'entity.fetch' => $this->entityFetch($tenantId, $userId, $data), - 'entity.read' => $this->entityRead($tenantId, $userId, $data), 'entity.create' => $this->entityCreate($tenantId, $userId, $data), - 'entity.modify' => $this->entityModify($tenantId, $userId, $data), - 'entity.destroy' => $this->entityDestroy($tenantId, $userId, $data), + 'entity.update' => $this->entityUpdate($tenantId, $userId, $data), + 'entity.delete' => $this->entityDelete($tenantId, $userId, $data), 'entity.copy' => $this->entityCopy($tenantId, $userId, $data), 'entity.move' => $this->entityMove($tenantId, $userId, $data), + 'entity.read' => $this->entityRead($tenantId, $userId, $data), + 'entity.read.chunk' => $this->entityReadChunk($tenantId, $userId, $data), 'entity.write' => $this->entityWrite($tenantId, $userId, $data), + 'entity.write.chunk' => $this->entityWriteChunk($tenantId, $userId, $data), + // Node operations (unified recursive) 'node.list' => $this->nodeList($tenantId, $userId, $data), 'node.delta' => $this->nodeDelta($tenantId, $userId, $data), @@ -107,207 +142,341 @@ class DefaultController extends ControllerAbstract { // ==================== Provider Operations ==================== - private function providerList(string $tenantId, string $userId, array $data = []): mixed { + private function providerList(string $tenantId, string $userId, array $data): mixed { $sources = null; - if (isset($data['sources']) && is_array($data['sources'])) { - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); - } - return $this->fileManager->providerList($tenantId, $userId, $sources); - + if (isset($data['sources']) && is_array($data['sources'])) { + $sources = new SourceSelector(); + $sources->jsonDeserialize($data['sources']); + } + + return $this->manager->providerList($tenantId, $userId, $sources); + } - private function providerExtant(string $tenantId, string $userId, array $data = []): mixed { - - if (!isset($data['sources']) || !is_array($data['sources'])) { - throw new InvalidArgumentException('Invalid sources selector provided'); - } - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); - // retrieve providers - return $this->fileManager->providerExtant($tenantId, $userId, $sources); - - } - - // ==================== Service Operations ==================== - - private function serviceList(string $tenantId, string $userId, array $data = []): mixed { - - $sources = null; - if (isset($data['sources']) && is_array($data['sources'])) { - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); - } - // retrieve services - return $this->fileManager->serviceList($tenantId, $userId, $sources); - - } - - private function serviceExtant(string $tenantId, string $userId, array $data = []): mixed { + private function providerFetch(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['sources']) || !is_array($data['sources'])) { - throw new InvalidArgumentException('Invalid sources selector provided'); - } - $sources = new SourceSelector(); - $sources->jsonDeserialize($data['sources']); - // retrieve services - return $this->fileManager->serviceExtant($tenantId, $userId, $sources); - - } - - private function serviceFetch(string $tenantId, string $userId, array $data = []): mixed { - - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException('Invalid provider identifier provided'); - } if (!isset($data['identifier'])) { - throw new InvalidArgumentException('Invalid service identifier provided'); + throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); } - // retrieve service - return $this->fileManager->serviceFetch($tenantId, $userId, $data['provider'], $data['identifier']); - + if (!is_string($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); + } + + return $this->manager->providerFetch($tenantId, $userId, $data['identifier']); } - // ==================== Collection Operations ==================== - - private function collectionList(string $tenantId, string $userId, array $data = []): mixed { + private function providerExtant(string $tenantId, string $userId, array $data): mixed { - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException('Invalid provider identifier provided'); - } - if (!isset($data['service'])) { - throw new InvalidArgumentException('Invalid service identifier provided'); + if (!isset($data['sources'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); + } + if (!is_array($data['sources'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); + } + $sources = new SourceSelector(); + $sources->jsonDeserialize($data['sources']); + + return $this->manager->providerExtant($tenantId, $userId, $sources); + + } + + // ==================== Service Operations ===================== + + private function serviceList(string $tenantId, string $userId, array $data): mixed { + + $sources = null; + if (isset($data['sources']) && is_array($data['sources'])) { + $sources = new SourceSelector(); + $sources->jsonDeserialize($data['sources']); } - $provider = $data['provider']; - $service = $data['service']; - $location = $data['location'] ?? null; - $filter = $data['filter'] ?? null; - $sort = $data['sort'] ?? null; + return $this->manager->serviceList($tenantId, $userId, $sources); + } + + private function serviceFetch(string $tenantId, string $userId, array $data): mixed { + + if (!isset($data['provider'])) { + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + } + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); + } + if (!is_string($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); + } - return $this->fileManager->collectionList( - $tenantId, + return $this->manager->serviceFetch($tenantId, $userId, $data['provider'], $data['identifier']); + } + + private function serviceExtant(string $tenantId, string $userId, array $data): mixed { + + if (!isset($data['sources'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); + } + if (!is_array($data['sources'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); + } + $sources = new SourceSelector(); + $sources->jsonDeserialize($data['sources']); + + return $this->manager->serviceExtant($tenantId, $userId, $sources); + } + + private function serviceCreate(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['provider'])) { + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + } + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['data'])) { + throw new InvalidArgumentException(self::ERR_MISSING_DATA); + } + if (!is_array($data['data'])) { + throw new InvalidArgumentException(self::ERR_INVALID_DATA); + } + + return $this->manager->serviceCreate( + $tenantId, $userId, - $provider, - $service, - $location, - $filter, - $sort - ); - } - - private function collectionExtant(string $tenantId, string $userId, array $data = []): mixed { - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException('Missing required parameter: provider'); - } - if (!isset($data['service'])) { - throw new InvalidArgumentException('Missing required parameter: service'); - } - if (!isset($data['identifier'])) { - throw new InvalidArgumentException('Missing required parameter: identifier'); - } - - return [ - 'extant' => $this->fileManager->collectionExtant( - $tenantId, - $userId, - $data['provider'], - $data['service'], - $data['identifier'] - ) - ]; - } - - private function collectionFetch(string $tenantId, string $userId, array $data = []): mixed { - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException('Missing required parameter: provider'); - } - if (!isset($data['service'])) { - throw new InvalidArgumentException('Missing required parameter: service'); - } - if (!isset($data['identifier'])) { - throw new InvalidArgumentException('Missing required parameter: identifier'); - } - - return $this->fileManager->collectionFetch( - $tenantId, - $userId, - $data['provider'], - $data['service'], - $data['identifier'] - ); - } - - private function collectionCreate(string $tenantId, string $userId, array $data = []): mixed { - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException('Missing required parameter: provider'); - } - if (!isset($data['service'])) { - throw new InvalidArgumentException('Missing required parameter: service'); - } - if (!isset($data['data'])) { - throw new InvalidArgumentException('Missing required parameter: data'); - } - - $location = $data['location'] ?? null; - $options = $data['options'] ?? []; - - return $this->fileManager->collectionCreate( - $tenantId, - $userId, - $data['provider'], - $data['service'], - $location, - $data['data'], - $options - ); - } - - private function collectionModify(string $tenantId, string $userId, array $data = []): mixed { - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException('Missing required parameter: provider'); - } - if (!isset($data['service'])) { - throw new InvalidArgumentException('Missing required parameter: service'); - } - if (!isset($data['identifier'])) { - throw new InvalidArgumentException('Missing required parameter: identifier'); - } - if (!isset($data['data'])) { - throw new InvalidArgumentException('Missing required parameter: data'); - } - - return $this->fileManager->collectionModify( - $tenantId, - $userId, - $data['provider'], - $data['service'], - $data['identifier'], + $data['provider'], $data['data'] ); } - private function collectionDestroy(string $tenantId, string $userId, array $data = []): mixed { - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException('Missing required parameter: provider'); + private function serviceUpdate(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['provider'])) { + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); } - if (!isset($data['service'])) { - throw new InvalidArgumentException('Missing required parameter: service'); + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); } if (!isset($data['identifier'])) { - throw new InvalidArgumentException('Missing required parameter: identifier'); + throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); + } + if (!is_string($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); + } + if (!isset($data['data'])) { + throw new InvalidArgumentException(self::ERR_MISSING_DATA); + } + if (!is_array($data['data'])) { + throw new InvalidArgumentException(self::ERR_INVALID_DATA); } - return [ - 'success' => $this->fileManager->collectionDestroy( - $tenantId, - $userId, - $data['provider'], - $data['service'], - $data['identifier'] - ) - ]; + return $this->manager->serviceUpdate( + $tenantId, + $userId, + $data['provider'], + $data['identifier'], + $data['data'] + ); + } + + private function serviceDelete(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['provider'])) { + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + } + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); + } + if (!is_string($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); + } + + return $this->manager->serviceDelete( + $tenantId, + $userId, + $data['provider'], + $data['identifier'] + ); + } + + private function serviceTest(string $tenantId, string $userId, array $data): mixed { + + if (!isset($data['provider'])) { + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + } + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + + if (!isset($data['identifier']) && !isset($data['location']) && !isset($data['identity'])) { + throw new InvalidArgumentException('Either a service identifier or location and identity must be provided for service test'); + } + + return $this->manager->serviceTest( + $tenantId, + $userId, + $data['provider'], + $data['identifier'] ?? null, + $data['location'] ?? null, + $data['identity'] ?? null, + ); + } + + // ==================== Collection Operations ==================== + + private function collectionList(string $tenantId, string $userId, array $data): mixed { + $sources = null; + if (isset($data['sources']) && is_array($data['sources'])) { + $sources = new SourceSelector(); + $sources->jsonDeserialize($data['sources']); + } + + $filter = $data['filter'] ?? null; + $sort = $data['sort'] ?? null; + + return $this->manager->collectionList($tenantId, $userId, $sources, $filter, $sort); + } + + private function collectionExtant(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['sources'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); + } + if (!is_array($data['sources'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); + } + + $sources = new SourceSelector(); + $sources->jsonDeserialize($data['sources']); + + return $this->manager->collectionExtant($tenantId, $userId, $sources); + } + + private function collectionFetch(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['provider'])) { + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + } + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['service'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); + } + if (!is_string($data['service'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); + } + if (!isset($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); + } + if (!is_string($data['identifier']) && !is_int($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); + } + + return $this->manager->collectionFetch( + $tenantId, + $userId, + $data['provider'], + $data['service'], + $data['identifier'] + ); + } + + private function collectionCreate(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['provider'])) { + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + } + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['service'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); + } + if (!is_string($data['service'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); + } + if (isset($data['collection']) && !is_string($data['collection']) && !is_int($data['collection'])) { + throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); + } + if (!isset($data['properties'])) { + throw new InvalidArgumentException(self::ERR_MISSING_DATA); + } + if (!is_array($data['properties'])) { + throw new InvalidArgumentException(self::ERR_INVALID_DATA); + } + + return $this->manager->collectionCreate( + $tenantId, + $userId, + $data['provider'], + $data['service'], + $data['collection'] ?? null, + $data['properties'] + ); + } + + private function collectionUpdate(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['provider'])) { + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + } + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['service'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); + } + if (!is_string($data['service'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); + } + if (!isset($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); + } + if (!is_string($data['identifier']) && !is_int($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); + } + if (!isset($data['properties'])) { + throw new InvalidArgumentException(self::ERR_MISSING_DATA); + } + if (!is_array($data['properties'])) { + throw new InvalidArgumentException(self::ERR_INVALID_DATA); + } + + return $this->manager->collectionUpdate( + $tenantId, + $userId, + $data['provider'], + $data['service'], + $data['identifier'], + $data['properties'] + ); + } + + private function collectionDelete(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['provider'])) { + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + } + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['service'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); + } + if (!is_string($data['service'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); + } + if (!isset($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); + } + if (!is_string($data['identifier']) && !is_int($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); + } + + return $this->manager->collectionDelete( + $tenantId, + $userId, + $data['provider'], + $data['service'], + $data['identifier'], + $data['options'] ?? [] + ); } private function collectionCopy(string $tenantId, string $userId, array $data = []): mixed { @@ -323,7 +492,7 @@ class DefaultController extends ControllerAbstract { $location = $data['location'] ?? null; - return $this->fileManager->collectionCopy( + return $this->manager->collectionCopy( $tenantId, $userId, $data['provider'], @@ -346,7 +515,7 @@ class DefaultController extends ControllerAbstract { $location = $data['location'] ?? null; - return $this->fileManager->collectionMove( + return $this->manager->collectionMove( $tenantId, $userId, $data['provider'], @@ -358,216 +527,149 @@ class DefaultController extends ControllerAbstract { // ==================== Entity Operations ==================== - private function entityList(string $tenantId, string $userId, array $data = []): mixed { + private function entityList(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['sources'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); + } + if (!is_array($data['sources'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); + } - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException('Invalid provider identifier provided'); + $sources = new SourceSelector(); + $sources->jsonDeserialize($data['sources']); + + $filter = $data['filter'] ?? null; + $sort = $data['sort'] ?? null; + $range = $data['range'] ?? null; + + return $this->manager->entityList($tenantId, $userId, $sources, $filter, $sort, $range); + } + + private function entityFetch(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['provider'])) { + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + } + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); } if (!isset($data['service'])) { - throw new InvalidArgumentException('Invalid service identifier provided'); + throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); + } + if (!is_string($data['service'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); } if (!isset($data['collection'])) { - throw new InvalidArgumentException('Missing required parameter: collection'); + throw new InvalidArgumentException(self::ERR_MISSING_COLLECTION); + } + if (!is_string($data['collection']) && !is_int($data['collection'])) { + throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); + } + if (!isset($data['identifiers'])) { + throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIERS); + } + if (!is_array($data['identifiers'])) { + throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIERS); } - - $provider = $data['provider']; - $service = $data['service']; - $collection = $data['collection']; - $filter = $data['filter'] ?? null; - $sort = $data['sort'] ?? null; - $range = $data['range'] ?? null; - return $this->fileManager->entityList( - $tenantId, + return $this->manager->entityFetch( + $tenantId, $userId, - $provider, - $service, - $collection, - $filter, - $sort, - $range - ); - } - - private function entityDelta(string $tenantId, string $userId, array $data = []): mixed { - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException('Missing required parameter: provider'); - } - if (!isset($data['service'])) { - throw new InvalidArgumentException('Missing required parameter: service'); - } - if (!isset($data['collection'])) { - throw new InvalidArgumentException('Missing required parameter: collection'); - } - if (!isset($data['signature']) || !is_string($data['signature'])) { - throw new InvalidArgumentException('Missing required parameter: signature'); - } - - $detail = $data['detail'] ?? 'ids'; - - return $this->fileManager->entityDelta( - $tenantId, - $userId, - $data['provider'], - $data['service'], - $data['collection'], - $data['signature'], - $detail - ); - } - - private function entityExtant(string $tenantId, string $userId, array $data = []): mixed { - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException('Missing required parameter: provider'); - } - if (!isset($data['service'])) { - throw new InvalidArgumentException('Missing required parameter: service'); - } - if (!isset($data['collection'])) { - throw new InvalidArgumentException('Missing required parameter: collection'); - } - if (!isset($data['identifiers']) || !is_array($data['identifiers'])) { - throw new InvalidArgumentException('Missing required parameter: identifiers'); - } - - return $this->fileManager->entityExtant( - $tenantId, - $userId, - $data['provider'], - $data['service'], + $data['provider'], + $data['service'], $data['collection'], $data['identifiers'] ); } - private function entityFetch(string $tenantId, string $userId, array $data = []): mixed { - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException('Missing required parameter: provider'); + private function entityExtant(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['sources'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); } - if (!isset($data['service'])) { - throw new InvalidArgumentException('Missing required parameter: service'); - } - if (!isset($data['collection'])) { - throw new InvalidArgumentException('Missing required parameter: collection'); - } - if (!isset($data['identifiers']) || !is_array($data['identifiers'])) { - throw new InvalidArgumentException('Missing required parameter: identifiers'); + if (!is_array($data['sources'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); } - return $this->fileManager->entityFetch( - $tenantId, - $userId, - $data['provider'], - $data['service'], - $data['collection'], - $data['identifiers'] - ); + $sources = new SourceSelector(); + $sources->jsonDeserialize($data['sources']); + + return $this->manager->entityExtant($tenantId, $userId, $sources); } - private function entityRead(string $tenantId, string $userId, array $data = []): mixed { - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException('Missing required parameter: provider'); - } - if (!isset($data['service'])) { - throw new InvalidArgumentException('Missing required parameter: service'); - } - if (!isset($data['collection'])) { - throw new InvalidArgumentException('Missing required parameter: collection'); - } - if (!isset($data['identifier'])) { - throw new InvalidArgumentException('Missing required parameter: identifier'); - } - - $content = $this->fileManager->entityRead( - $tenantId, - $userId, - $data['provider'], - $data['service'], - $data['collection'], - $data['identifier'] - ); - - return [ - 'content' => $content !== null ? base64_encode($content) : null, - 'encoding' => 'base64' - ]; - } + private function entityCreate(string $tenantId, string $userId, array $data = []): mixed { - private function entityCreate(string $tenantId, string $userId, array $data = []): mixed { - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException('Missing required parameter: provider'); + if (!isset($data['provider']) || !is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['service']) || !is_string($data['service'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); + } + if (!isset($data['collection'])) { + throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); + } + $properties = $data['properties'] ?? $data['data'] ?? null; + if (!is_array($properties)) { + throw new InvalidArgumentException('Invalid parameter: properties must be an array'); } - if (!isset($data['service'])) { - throw new InvalidArgumentException('Missing required parameter: service'); - } - if (!isset($data['data'])) { - throw new InvalidArgumentException('Missing required parameter: data'); - } - - $collection = $data['collection'] ?? null; - $options = $data['options'] ?? []; - - return $this->fileManager->entityCreate( - $tenantId, - $userId, - $data['provider'], - $data['service'], - $collection, - $data['data'], - $options - ); - } + $options = $data['options'] ?? []; - private function entityModify(string $tenantId, string $userId, array $data = []): mixed { - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException('Missing required parameter: provider'); - } - if (!isset($data['service'])) { - throw new InvalidArgumentException('Missing required parameter: service'); - } - if (!isset($data['identifier'])) { - throw new InvalidArgumentException('Missing required parameter: identifier'); - } - if (!isset($data['data'])) { - throw new InvalidArgumentException('Missing required parameter: data'); - } - - $collection = $data['collection'] ?? null; - - return $this->fileManager->entityModify( - $tenantId, - $userId, - $data['provider'], - $data['service'], - $collection, - $data['identifier'], - $data['data'] - ); - } + return $this->manager->entityCreate($tenantId, $userId, $data['provider'], $data['service'], $data['collection'], $properties, $options); - private function entityDestroy(string $tenantId, string $userId, array $data = []): mixed { - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException('Missing required parameter: provider'); + } + + private function entityUpdate(string $tenantId, string $userId, array $data = []): mixed { + + if (!isset($data['provider']) || !is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['service']) || !is_string($data['service'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); + } + if (!isset($data['collection'])) { + throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); + } + if (!isset($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); + } + $properties = $data['properties'] ?? $data['data'] ?? null; + if (!is_array($properties)) { + throw new InvalidArgumentException('Invalid parameter: properties must be an array'); } - if (!isset($data['service'])) { - throw new InvalidArgumentException('Missing required parameter: service'); + + return $this->manager->entityUpdate($tenantId, $userId, $data['provider'], $data['service'], $data['collection'], $data['identifier'], $properties); + + } + + private function entityDelete(string $tenantId, string $userId, array $data = []): mixed { + + if (!isset($data['provider']) || !is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['service']) || !is_string($data['service'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); + } + if (!isset($data['collection'])) { + throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); + } + if (!isset($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); + } + + return $this->manager->entityDelete($tenantId, $userId, $data['provider'], $data['service'], $data['collection'], $data['identifier']); + + } + + private function entityDelta(string $tenantId, string $userId, array $data): mixed { + if (!isset($data['sources'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); } - if (!isset($data['identifier'])) { - throw new InvalidArgumentException('Missing required parameter: identifier'); + if (!is_array($data['sources'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); } - $collection = $data['collection'] ?? null; + $sources = new SourceSelector(); + $sources->jsonDeserialize($data['sources']); - return [ - 'success' => $this->fileManager->entityDestroy( - $tenantId, - $userId, - $data['provider'], - $data['service'], - $collection, - $data['identifier'] - ) - ]; + return $this->manager->entityDelta($tenantId, $userId, $sources); } private function entityCopy(string $tenantId, string $userId, array $data = []): mixed { @@ -584,7 +686,7 @@ class DefaultController extends ControllerAbstract { $collection = $data['collection'] ?? null; $destination = $data['destination'] ?? null; - return $this->fileManager->entityCopy( + return $this->manager->entityCopy( $tenantId, $userId, $data['provider'], @@ -609,7 +711,7 @@ class DefaultController extends ControllerAbstract { $collection = $data['collection'] ?? null; $destination = $data['destination'] ?? null; - return $this->fileManager->entityMove( + return $this->manager->entityMove( $tenantId, $userId, $data['provider'], @@ -620,18 +722,74 @@ class DefaultController extends ControllerAbstract { ); } - private function entityWrite(string $tenantId, string $userId, array $data = []): mixed { - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException('Missing required parameter: provider'); + private function entityRead(string $tenantId, string $userId, array $data = []): mixed { + if (!isset($data['provider'])) { + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + } + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); } if (!isset($data['service'])) { - throw new InvalidArgumentException('Missing required parameter: service'); + throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); + } + if (!is_string($data['service'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); + } + if (!isset($data['collection'])) { + throw new InvalidArgumentException(self::ERR_MISSING_COLLECTION); + } + if (!is_string($data['collection']) && !is_int($data['collection'])) { + throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); } if (!isset($data['identifier'])) { - throw new InvalidArgumentException('Missing required parameter: identifier'); + throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); + } + if (!is_string($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); + } + + $content = $this->manager->entityRead( + $tenantId, + $userId, + $data['provider'], + $data['service'], + $data['collection'], + $data['identifier'] + ); + + return [ + 'content' => $content !== null ? base64_encode($content) : null, + 'encoding' => 'base64' + ]; + } + + private function entityWrite(string $tenantId, string $userId, array $data = []): mixed { + if (!isset($data['provider'])) { + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + } + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['service'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); + } + if (!is_string($data['service'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); + } + if (!isset($data['collection'])) { + throw new InvalidArgumentException(self::ERR_MISSING_COLLECTION); + } + if (!is_string($data['collection']) && !is_int($data['collection'])) { + throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); + } + if (!isset($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); + } + if (!is_string($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); } if (!isset($data['content'])) { - throw new InvalidArgumentException('Missing required parameter: content'); + throw new InvalidArgumentException(self::ERR_MISSING_DATA); } // Decode content if base64 encoded @@ -643,7 +801,7 @@ class DefaultController extends ControllerAbstract { } } - $bytesWritten = $this->fileManager->entityWrite( + $bytesWritten = $this->manager->entityWrite( $tenantId, $userId, $data['provider'], @@ -658,31 +816,145 @@ class DefaultController extends ControllerAbstract { ]; } + private function entityReadChunk(string $tenantId, string $userId, array $data = []): mixed { + if (!isset($data['provider'])) { + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + } + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['service'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); + } + if (!is_string($data['service'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); + } + if (!isset($data['collection'])) { + throw new InvalidArgumentException(self::ERR_MISSING_COLLECTION); + } + if (!is_string($data['collection']) && !is_int($data['collection'])) { + throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); + } + if (!isset($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); + } + if (!is_string($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); + } + if (!isset($data['offset']) || !is_int($data['offset'])) { + throw new InvalidArgumentException('Missing parameter: offset'); + } + if (!isset($data['length']) || !is_int($data['length'])) { + throw new InvalidArgumentException('Missing parameter: length'); + } + + $chunk = $this->manager->entityReadChunk( + $tenantId, + $userId, + $data['provider'], + $data['service'], + $data['collection'], + $data['identifier'], + $data['offset'], + $data['length'] + ); + + return [ + 'content' => $chunk !== null ? base64_encode($chunk) : null, + 'encoding' => 'base64', + 'offset' => $data['offset'], + 'length' => $chunk !== null ? strlen($chunk) : 0, + ]; + } + + private function entityWriteChunk(string $tenantId, string $userId, array $data = []): mixed { + if (!isset($data['provider'])) { + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + } + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); + } + if (!isset($data['service'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); + } + if (!is_string($data['service'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); + } + if (!isset($data['collection'])) { + throw new InvalidArgumentException(self::ERR_MISSING_COLLECTION); + } + if (!is_string($data['collection']) && !is_int($data['collection'])) { + throw new InvalidArgumentException(self::ERR_INVALID_COLLECTION); + } + if (!isset($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER); + } + if (!is_string($data['identifier'])) { + throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER); + } + if (!isset($data['offset']) || !is_int($data['offset'])) { + throw new InvalidArgumentException('Missing parameter: offset'); + } + if (!isset($data['content'])) { + throw new InvalidArgumentException(self::ERR_MISSING_DATA); + } + + // Decode content if base64 encoded + $content = $data['content']; + if (isset($data['encoding']) && $data['encoding'] === 'base64') { + $content = base64_decode($content); + if ($content === false) { + throw new InvalidArgumentException('Invalid base64 encoded content'); + } + } + + $bytesWritten = $this->manager->entityWriteChunk( + $tenantId, + $userId, + $data['provider'], + $data['service'], + $data['collection'], + $data['identifier'], + $data['offset'], + $content + ); + + return [ + 'bytesWritten' => $bytesWritten, + 'offset' => $data['offset'], + ]; + } + // ==================== Node Operations (Unified/Recursive) ==================== private function nodeList(string $tenantId, string $userId, array $data = []): mixed { - - if (!isset($data['provider']) || !is_string($data['provider'])) { - throw new InvalidArgumentException('Invalid provider identifier provided'); + if (!isset($data['provider'])) { + throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER); + } + if (!is_string($data['provider'])) { + throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER); } if (!isset($data['service'])) { - throw new InvalidArgumentException('Invalid service identifier provided'); + throw new InvalidArgumentException(self::ERR_MISSING_SERVICE); + } + if (!is_string($data['service'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SERVICE); } $provider = $data['provider']; $service = $data['service']; - $location = $data['location'] ?? null; + $collection = $data['collection'] ?? null; $recursive = $data['recursive'] ?? false; $filter = $data['filter'] ?? null; $sort = $data['sort'] ?? null; $range = $data['range'] ?? null; - return $this->fileManager->nodeList( + return $this->manager->nodeList( $tenantId, $userId, $provider, $service, - $location, + $collection, $recursive, $filter, $sort, @@ -705,7 +977,7 @@ class DefaultController extends ControllerAbstract { $recursive = $data['recursive'] ?? false; $detail = $data['detail'] ?? 'ids'; - return $this->fileManager->nodeDelta( + return $this->manager->nodeDelta( $tenantId, $userId, $data['provider'], diff --git a/lib/Controllers/TransferController.php b/lib/Controllers/TransferController.php index 840ddde..4e9277a 100644 --- a/lib/Controllers/TransferController.php +++ b/lib/Controllers/TransferController.php @@ -7,20 +7,17 @@ declare(strict_types=1); * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace KTXM\FileManager\Controllers; +namespace KTXM\DocumentsManager\Controllers; -use InvalidArgumentException; use KTXC\Http\Response\JsonResponse; use KTXC\Http\Response\Response; use KTXC\Http\Response\StreamedResponse; use KTXC\SessionIdentity; use KTXC\SessionTenant; use KTXF\Controller\ControllerAbstract; -use KTXF\Files\Node\INodeCollectionBase; -use KTXF\Files\Node\INodeEntityBase; use KTXF\Routing\Attributes\AuthenticatedRoute; -use KTXM\FileManager\Manager; -use KTXM\FileManager\Transfer\StreamingZip; +use KTXM\DocumentsManager\Manager; +use KTXM\DocumentsManager\Transfer\StreamingZip; use Psr\Log\LoggerInterface; use Throwable; @@ -39,7 +36,7 @@ class TransferController extends ControllerAbstract public function __construct( private readonly SessionTenant $tenantIdentity, private readonly SessionIdentity $userIdentity, - private Manager $fileManager, + private readonly Manager $manager, private readonly LoggerInterface $logger ) {} @@ -50,21 +47,16 @@ class TransferController extends ControllerAbstract */ #[AuthenticatedRoute( '/download/entity/{provider}/{service}/{collection}/{identifier}', - name: 'filemanager.download.entity', + name: 'document_manager.download.entity', methods: ['GET'] )] - public function downloadEntity( - string $provider, - string $service, - string $collection, - string $identifier - ): Response { + public function downloadEntity(string $provider, string $service, string $collection, string $identifier): Response { $tenantId = $this->tenantIdentity->identifier(); $userId = $this->userIdentity->identifier(); try { // Fetch entity metadata - $entities = $this->fileManager->entityFetch( + $entities = $this->manager->entityFetch( $tenantId, $userId, $provider, @@ -80,11 +72,10 @@ class TransferController extends ControllerAbstract ], Response::HTTP_NOT_FOUND); } - /** @var INodeEntityBase $entity */ $entity = $entities[$identifier]; // Get the stream - $stream = $this->fileManager->entityReadStream( + $stream = $this->manager->entityReadStream( $tenantId, $userId, $provider, @@ -100,22 +91,29 @@ class TransferController extends ControllerAbstract ], Response::HTTP_NOT_FOUND); } - $filename = $entity->getLabel() ?? 'download'; - $mime = $entity->getMime() ?? 'application/octet-stream'; - $size = $entity->size(); + $filename = $entity->getProperties()->getLabel() ?? 'download'; + $mime = $entity->getProperties()->getMime() ?? 'application/octet-stream'; + $size = $entity->getProperties()->size(); // Create streamed response $response = new StreamedResponse(function () use ($stream) { - while (!feof($stream)) { - echo fread($stream, 65536); - @ob_flush(); - flush(); + try { + while (!feof($stream)) { + echo fread($stream, 65536); + @ob_flush(); + flush(); + } + } finally { + fclose($stream); } - fclose($stream); }); $response->headers->set('Content-Type', $mime); - $response->headers->set('Content-Length', (string) $size); + // Only advertise Content-Length when metadata is non-zero; a zero value + // would cause clients to believe the file is empty and discard the body. + if ($size > 0) { + $response->headers->set('Content-Length', (string) $size); + } $response->headers->set('Content-Disposition', $response->headers->makeDisposition('attachment', $filename, $this->asciiFallback($filename)) ); @@ -139,16 +137,10 @@ class TransferController extends ControllerAbstract */ #[AuthenticatedRoute( '/download/archive', - name: 'filemanager.download.archive', + name: 'manager.download.archive', methods: ['GET'] )] - public function downloadArchive( - string $provider, - string $service, - array $ids = [], - string $collection = null, - string $name = 'download' - ): Response { + public function downloadArchive(string $provider, string $service, array $ids = [], ?string $collection = null, string $name = 'download'): Response { $tenantId = $this->tenantIdentity->identifier(); $userId = $this->userIdentity->identifier(); @@ -184,7 +176,7 @@ class TransferController extends ControllerAbstract $zip = new StreamingZip(null, false); // No compression for speed foreach ($files as $file) { - $stream = $this->fileManager->entityReadStream( + $stream = $this->manager->entityReadStream( $tenantId, $userId, $provider, @@ -194,12 +186,15 @@ class TransferController extends ControllerAbstract ); if ($stream !== null) { - $zip->addFileFromStream( - $file['path'], - $stream, - $file['modTime'] ?? null - ); - fclose($stream); + try { + $zip->addFileFromStream( + $file['path'], + $stream, + $file['modTime'] ?? null + ); + } finally { + fclose($stream); + } } } @@ -231,20 +226,17 @@ class TransferController extends ControllerAbstract */ #[AuthenticatedRoute( '/download/collection/{provider}/{service}/{identifier}', - name: 'filemanager.download.collection', + name: 'manager.download.collection', methods: ['GET'] )] - public function downloadCollection( - string $provider, - string $service, - string $identifier - ): Response { + public function downloadCollection(string $provider, string $service, string $identifier): Response { $tenantId = $this->tenantIdentity->identifier(); $userId = $this->userIdentity->identifier(); try { // Fetch collection metadata - $collection = $this->fileManager->collectionFetch( + /** @var CollectionBaseInterface|null $collection */ + $collection = $this->manager->collectionFetch( $tenantId, $userId, $provider, @@ -280,7 +272,7 @@ class TransferController extends ControllerAbstract if ($file['type'] === 'directory') { $zip->addDirectory($file['path'], $file['modTime'] ?? null); } else { - $stream = $this->fileManager->entityReadStream( + $stream = $this->manager->entityReadStream( $tenantId, $userId, $provider, @@ -290,12 +282,15 @@ class TransferController extends ControllerAbstract ); if ($stream !== null) { - $zip->addFileFromStream( - $file['path'], - $stream, - $file['modTime'] ?? null - ); - fclose($stream); + try { + $zip->addFileFromStream( + $file['path'], + $stream, + $file['modTime'] ?? null + ); + } finally { + fclose($stream); + } } } } @@ -323,20 +318,14 @@ class TransferController extends ControllerAbstract /** * Resolve a list of entity/collection IDs into a flat file list for archiving */ - private function resolveFilesForArchive( - string $tenantId, - string $userId, - string $provider, - string $service, - ?string $collection, - array $ids - ): array { + private function resolveFilesForArchive(string $tenantId, string $userId, string $provider, string $service, ?string $collection, array $ids): array { $files = []; foreach ($ids as $id) { // Try as entity first if ($collection !== null) { - $entities = $this->fileManager->entityFetch( + /** @var EntityBaseInterface[] $entities */ + $entities = $this->manager->entityFetch( $tenantId, $userId, $provider, @@ -346,7 +335,6 @@ class TransferController extends ControllerAbstract ); if (!empty($entities) && isset($entities[$id])) { - /** @var INodeEntityBase $entity */ $entity = $entities[$id]; $files[] = [ 'type' => 'file', @@ -360,7 +348,8 @@ class TransferController extends ControllerAbstract } // Try as collection (folder) - $collectionNode = $this->fileManager->collectionFetch( + /** @var CollectionBaseInterface|null $collectionNode */ + $collectionNode = $this->manager->collectionFetch( $tenantId, $userId, $provider, @@ -394,10 +383,21 @@ class TransferController extends ControllerAbstract string $provider, string $service, string $collectionId, - string $basePath + string $basePath, + int $depth = 0, + int $maxDepth = 20 ): array { $files = []; + // Guard against runaway recursion on pathologically deep trees + if ($depth > $maxDepth) { + $this->logger->warning('Max recursion depth reached, skipping deeper contents', [ + 'collection' => $collectionId, + 'depth' => $depth, + ]); + return $files; + } + // Add directory entry if we have a path if ($basePath !== '') { $files[] = [ @@ -410,7 +410,7 @@ class TransferController extends ControllerAbstract // Get all nodes in this collection using nodeList with recursive=false // We handle recursion ourselves to build proper paths try { - $nodes = $this->fileManager->nodeList( + $nodes = $this->manager->nodeList( $tenantId, $userId, $provider, @@ -438,7 +438,9 @@ class TransferController extends ControllerAbstract $provider, $service, (string) $node->id(), - $nodePath + $nodePath, + $depth + 1, + $maxDepth ); $files = array_merge($files, $subFiles); } else { diff --git a/lib/Manager.php b/lib/Manager.php index 9abc677..133cd80 100644 --- a/lib/Manager.php +++ b/lib/Manager.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace KTXM\FileManager; +namespace KTXM\DocumentsManager; use InvalidArgumentException; use KTXC\Resource\ProviderManager; @@ -11,16 +11,30 @@ use KTXF\Files\Node\INodeCollectionBase; use KTXF\Files\Node\INodeCollectionMutable; use KTXF\Files\Node\INodeEntityBase; use KTXF\Files\Node\INodeEntityMutable; -use KTXF\Files\Provider\IProviderBase; -use KTXF\Files\Service\IServiceBase; use KTXF\Files\Service\IServiceCollectionMutable; use KTXF\Files\Service\IServiceEntityMutable; -use KTXF\Resource\Provider\ProviderInterface; +use KTXF\Resource\Documents\Collection\CollectionBaseInterface; +use KTXF\Resource\Documents\Collection\CollectionMutableInterface; +use KTXF\Resource\Documents\Entity\EntityBaseInterface; +use KTXF\Resource\Documents\Entity\EntityMutableInterface; +use KTXF\Resource\Documents\Provider\ProviderBaseInterface; +use KTXF\Resource\Documents\Provider\ProviderServiceMutateInterface; +use KTXF\Resource\Documents\Provider\ProviderServiceTestInterface; +use KTXF\Resource\Documents\Service\ServiceBaseInterface; +use KTXF\Resource\Documents\Service\ServiceCollectionMutableInterface; +use KTXF\Resource\Documents\Service\ServiceEntityMutableInterface; +use KTXF\Resource\Filter\IFilter; +use KTXF\Resource\Provider\ResourceServiceIdentityInterface; +use KTXF\Resource\Provider\ResourceServiceLocationInterface; use KTXF\Resource\Range\IRangeTally; use KTXF\Resource\Range\RangeAnchorType; use KTXF\Resource\Range\RangeType; +use KTXF\Resource\Selector\CollectionSelector; +use KTXF\Resource\Selector\EntitySelector; use KTXF\Resource\Selector\ServiceSelector; use KTXF\Resource\Selector\SourceSelector; +use KTXF\Resource\Sort\ISort; +use KTXM\ProviderMailSystem\Providers\Service; use Psr\Log\LoggerInterface; class Manager { @@ -30,39 +44,18 @@ class Manager { private ProviderManager $providerManager, ) { } - // ==================== Provider Operations ==================== - /** * Retrieve available providers * * @param SourceSelector|null $sources collection of provider identifiers * - * @return array collection of available providers + * @return array collection of available providers e.g. ['provider1' => IProvider, 'provider2' => IProvider] */ public function providerList(string $tenantId, string $userId, ?SourceSelector $sources = null): array { // determine filter from sources $filter = ($sources !== null && $sources->identifiers() !== []) ? $sources->identifiers() : null; // retrieve providers from provider manager - return $this->providerManager->providers(ProviderInterface::TYPE_FILES, $filter); - } - - /** - * Confirm which providers are available - * - * @param SourceSelector $sources collection of provider identifiers to confirm - * - * @return array collection of providers and their availability status - */ - public function providerExtant(string $tenantId, string $userId, SourceSelector $sources): array { - $providerFilter = $sources?->identifiers() ?? []; - $providersResolved = $this->providerManager->providers(ProviderInterface::TYPE_FILES, $providerFilter); - $providersAvailable = array_keys($providersResolved); - $providersUnavailable = array_diff($providerFilter, $providersAvailable); - $responseData = array_merge( - array_fill_keys($providersAvailable, true), - array_fill_keys($providersUnavailable, false) - ); - return $responseData; + return $this->providerManager->providers(ProviderBaseInterface::TYPE_DOCUMENT, $filter); } /** @@ -72,10 +65,10 @@ class Manager { * @param string $userId user identifier * @param string $provider provider identifier * - * @return IProviderBase + * @return ProviderBaseInterface * @throws InvalidArgumentException */ - public function providerFetch(string $tenantId, string $userId, string $provider): IProviderBase { + public function providerFetch(string $tenantId, string $userId, string $provider): ProviderBaseInterface { // retrieve provider $providers = $this->providerList($tenantId, $userId, new SourceSelector([$provider => true])); if (!isset($providers[$provider])) { @@ -84,7 +77,25 @@ class Manager { return $providers[$provider]; } - // ==================== Service Operations ==================== + /** + * Confirm which providers are available + * + * @param SourceSelector|null $sources collection of provider identifiers to confirm + * + * @return array collection of providers and their availability status e.g. ['provider1' => true, 'provider2' => false] + */ + public function providerExtant(string $tenantId, string $userId, SourceSelector $sources): array { + // determine which providers are available + $providersResolved = $this->providerList($tenantId, $userId, $sources); + $providersAvailable = array_keys($providersResolved); + $providersUnavailable = array_diff($sources->identifiers(), $providersAvailable); + // construct response data + $responseData = array_merge( + array_fill_keys($providersAvailable, true), + array_fill_keys($providersUnavailable, false) + ); + return $responseData; + } /** * Retrieve available services for specific user @@ -93,7 +104,7 @@ class Manager { * @param string $userId user identifier * @param SourceSelector|null $sources list of provider and service identifiers * - * @return array> collections of available services + * @return array> collections of available services e.g. ['provider1' => ['service1' => IServiceBase], 'provider2' => ['service2' => IServiceBase]] */ public function serviceList(string $tenantId, string $userId, ?SourceSelector $sources = null): array { // retrieve providers @@ -101,36 +112,9 @@ class Manager { // retrieve services for each provider $responseData = []; foreach ($providers as $provider) { - $serviceFilter = $sources[$provider->id()] instanceof ServiceSelector ? $sources[$provider->id()]->identifiers() : []; + $serviceFilter = $sources[$provider->identifier()] instanceof ServiceSelector ? $sources[$provider->identifier()]->identifiers() : []; $services = $provider->serviceList($tenantId, $userId, $serviceFilter); - $responseData[$provider->id()] = $services; - } - return $responseData; - } - - /** - * Confirm which services are available - * - * @param string $tenantId tenant identifier - * @param string $userId user identifier - * @param SourceSelector|null $sources collection of provider and service identifiers to confirm - * - * @return array collection of providers and their availability status - */ - public function serviceExtant(string $tenantId, string $userId, SourceSelector $sources): array { - // retrieve providers - $providers = $this->providerList($tenantId, $userId, $sources); - $providersRequested = $sources->identifiers(); - $providersUnavailable = array_diff($providersRequested, array_keys($providers)); - - // initialize response with unavailable providers - $responseData = array_fill_keys($providersUnavailable, false); - - // retrieve services for each available provider - foreach ($providers as $provider) { - $serviceSelector = $sources[$provider->id()]; - $serviceAvailability = $provider->serviceExtant($tenantId, $userId, ...$serviceSelector->identifiers()); - $responseData[$provider->id()] = $serviceAvailability; + $responseData[$provider->identifier()] = $services; } return $responseData; } @@ -143,155 +127,475 @@ class Manager { * @param string $providerId provider identifier * @param string|int $serviceId service identifier * - * @return IServiceBase + * @return ServiceBaseInterface * @throws InvalidArgumentException */ - public function serviceFetch(string $tenantId, string $userId, string $providerId, string|int $serviceId): IServiceBase { + public function serviceFetch(string $tenantId, string $userId, string $providerId, string|int $serviceId): ServiceBaseInterface { // retrieve provider and service $service = $this->providerFetch($tenantId, $userId, $providerId)->serviceFetch($tenantId, $userId, $serviceId); if ($service === null) { throw new InvalidArgumentException("Service '$serviceId' not found for provider '$providerId'"); } + // retrieve services return $service; } + /** + * Confirm which services are available + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param SourceSelector|null $sources collection of provider and service identifiers to confirm + * + * @return array collection of providers and their availability status e.g. ['provider1' => ['service1' => false], 'provider2' => ['service2' => true, 'service3' => true]] + */ + public function serviceExtant(string $tenantId, string $userId, SourceSelector $sources): array { + // retrieve providers + $providers = $this->providerList($tenantId, $userId, $sources); + $providersRequested = $sources->identifiers(); + $providersUnavailable = array_diff($providersRequested, array_keys($providers)); + + // initialize response with unavailable providers + $responseData = array_fill_keys($providersUnavailable, false); + + // retrieve services for each available provider + foreach ($providers as $provider) { + $serviceSelector = $sources[$provider->identifier()]; + $serviceAvailability = $provider->serviceExtant($tenantId, $userId, ...$serviceSelector->identifiers()); + $responseData[$provider->identifier()] = $serviceAvailability; + } + return $responseData; + } + + /** + * Create a new service + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param string $providerId Provider identifier + * @param array $data Service configuration data + * + * @return ServiceBaseInterface Created service + * + * @throws InvalidArgumentException If provider doesn't support service creation + */ + public function serviceCreate(string $tenantId, ?string $userId, string $providerId, array $data): ServiceBaseInterface { + // retrieve provider and service + $provider = $this->providerFetch($tenantId, $userId, $providerId); + if ($provider instanceof ProviderServiceMutateInterface === false) { + throw new InvalidArgumentException("Provider '$providerId' does not support service creation"); + } + + // Create a fresh service instance + $service = $provider->serviceFresh(); + + // Deserialize the data into the service + $service->jsonDeserialize($data); + + // Create the service + $serviceId = $provider->serviceCreate($tenantId, $userId, $service); + + // Fetch and return the created service + return $provider->serviceFetch($tenantId, $userId, $serviceId); + } + + /** + * Update an existing service + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string $userId User identifier for context + * @param string $providerId Provider identifier + * @param string|int $serviceId Service identifier + * @param array $data Updated service configuration data + * + * @return ServiceBaseInterface Updated service + * + * @throws InvalidArgumentException If provider doesn't support service modification or service not found + */ + public function serviceUpdate(string $tenantId, string $userId, string $providerId, string|int $serviceId, array $data): ServiceBaseInterface { + // retrieve provider and service + $provider = $this->providerFetch($tenantId, $userId, $providerId); + if ($provider instanceof ProviderServiceMutateInterface === false) { + throw new InvalidArgumentException("Provider '$providerId' does not support service modification"); + } + + // Fetch existing service + $service = $provider->serviceFetch($tenantId, $userId, $serviceId); + if ($service === null) { + throw new InvalidArgumentException("Service '$serviceId' not found"); + } + + // Update with new data + $service->jsonDeserialize($data); + + // Modify the service + $provider->serviceModify($tenantId, $userId, $service); + + // Fetch and return the updated service + return $provider->serviceFetch($tenantId, $userId, $serviceId); + } + + /** + * Delete a service + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string $userId User identifier for context + * @param string $providerId Provider identifier + * @param string|int $serviceId Service identifier + * + * @return bool True if service was deleted + * + * @throws InvalidArgumentException If provider doesn't support service deletion or service not found + */ + public function serviceDelete(string $tenantId, string $userId, string $providerId, string|int $serviceId): bool { + // retrieve provider and service + $provider = $this->providerFetch($tenantId, $userId, $providerId); + if ($provider instanceof ProviderServiceMutateInterface === false) { + throw new InvalidArgumentException("Provider '$providerId' does not support service creation"); + } + + // Fetch existing service + $service = $provider->serviceFetch($tenantId, $userId, $serviceId); + if ($service === null) { + throw new InvalidArgumentException("Service '$serviceId' not found"); + } + + // Delete the service + return $provider->serviceDestroy($tenantId, $userId, $service); + } + + + /** + * Test a mail service connection + * + * Tests connectivity and authentication for either an existing service + * or a fresh configuration. Delegates to the appropriate provider. + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string $userId User identifier for context + * @param string $providerId Provider ID (for existing service or targeted test) + * @param string|int|null $serviceId Service ID (for existing service test) + * @param ResourceServiceLocationInterface|array|null $location Service location (for fresh config test) + * @param ResourceServiceIdentityInterface|array|null $identity Service credentials (for fresh config test) + * + * @return array Test results + * + * @throws InvalidArgumentException If invalid parameters + */ + public function serviceTest( + string $tenantId, + string $userId, + string $providerId, + string|int|null $serviceId = null, + ResourceServiceLocationInterface|array|null $location = null, + ResourceServiceIdentityInterface|array|null $identity = null + ): array { + // retrieve provider + $provider = $this->providerFetch($tenantId, $userId, $providerId); + if ($provider instanceof ProviderServiceTestInterface === false) { + throw new InvalidArgumentException("Provider '$providerId' does not support service testing"); + } + + // Testing existing service + if ($providerId !== null && $serviceId !== null) { + // retrieve service + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + if ($service === null) { + throw new InvalidArgumentException("Service not found: $providerId/$serviceId"); + } + + try { + return $provider->serviceTest($service); + } catch (\Throwable $e) { + throw new InvalidArgumentException('Service test failed: ' . $e->getMessage()); + } + } + + // Testing fresh configuration + if ($location !== null && $identity !== null) { + + if ($provider instanceof ProviderServiceMutateInterface === false) { + throw new InvalidArgumentException("Provider '$providerId' does not support fresh service configuration testing"); + } + + if (empty($location['type'])) { + throw new InvalidArgumentException('Service location not valid'); + } + + if (empty($identity['type'])) { + throw new InvalidArgumentException('Service identity not valid'); + } + + /** @var ServiceMutableInterface $service */ + $service = $provider->serviceFresh(); + if ($location instanceof ResourceServiceLocationInterface === false) { + $location = $service->freshLocation($location['type'], (array)$location); + $service->setLocation($location); + } + if ($identity instanceof ResourceServiceIdentityInterface === false) { + $identity = $service->freshIdentity($identity['type'], (array)$identity); + $service->setIdentity($identity); + } + + return $provider->serviceTest($service); + } + + throw new InvalidArgumentException( + 'Either (provider + service) or (provider + location + identity) must be provided' + ); + } + // ==================== Collection Operations ==================== /** - * List collections at a location - * - * @return array + * List collections across services + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param SourceSelector|null $sources Provider/service sources + * @param IFilter|null $filter Collection filter + * @param ISort|null $sort Collection sort + * + * @return array>> Collections grouped by provider/service */ - public function collectionList( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int|null $location = null, - ?array $filter = null, - ?array $sort = null - ): array { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - - // construct filter - $collectionFilter = null; - if ($filter !== null && $filter !== []) { - $collectionFilter = $service->collectionListFilter(); - foreach ($filter as $attribute => $value) { - $collectionFilter->condition($attribute, $value); + public function collectionList(string $tenantId, ?string $userId, ?SourceSelector $sources = null, ?IFilter $filter = null, ?ISort $sort = null): array { + // confirm that sources are provided + if ($sources === null) { + $sources = new SourceSelector([]); + } + // retrieve providers + $providers = $this->providerList($tenantId, $userId, $sources); + // retrieve services for each provider + $responseData = []; + foreach ($providers as $provider) { + // extract required required service identifiers for this provider from sources + $serviceSelector = $sources[$provider->identifier()] ?? null; + $serviceSelected = $serviceSelector instanceof ServiceSelector ? $serviceSelector->identifiers() : []; + /** @var ServiceBaseInterface[] $services */ + $services = $provider->serviceList($tenantId, $userId, $serviceSelected); + // retrieve collections for each service + foreach ($services as $service) { + // extract required collection identifiers for this service from sources + $collectionSelector = $serviceSelector[$service->identifier()] ?? null; + $collectionSelected = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : [null]; + // construct filter for collections + $collectionFilter = null; + if ($filter !== null && $filter !== []) { + $collectionFilter = $service->collectionListFilter(); + foreach ($filter as $attribute => $value) { + $collectionFilter->condition($attribute, $value); + } + } + // construct sort for collections + $collectionSort = null; + if ($sort !== null && $sort !== []) { + $collectionSort = $service->collectionListSort(); + foreach ($sort as $attribute => $direction) { + $collectionSort->condition($attribute, $direction); + } + } + // retrieve collections + foreach ($collectionSelected as $collectionId) { + $collections = $service->collectionList($collectionId, $collectionFilter, $collectionSort); + if ($collections !== []) { + $responseData[$provider->identifier()][$service->identifier()][$collectionId] = $collections; + } + } } } - - // construct sort - $collectionSort = null; - if ($sort !== null && $sort !== []) { - $collectionSort = $service->collectionListSort(); - foreach ($sort as $attribute => $direction) { - $collectionSort->condition($attribute, $direction); - } - } - - return $service->collectionList($location, $collectionFilter, $collectionSort); + return $responseData; } /** - * Check if collection exists + * Check if collections exist + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param SourceSelector $sources Collection sources with identifiers + * + * @return array>> Existence map grouped by provider/service */ - public function collectionExtant( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int $identifier - ): bool { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - return $service->collectionExtant($identifier); + public function collectionExtant(string $tenantId, ?string $userId, SourceSelector $sources): array { + // retrieve available providers + $providers = $this->providerList($tenantId, $userId, $sources); + $providersRequested = $sources->identifiers(); + $providersUnavailable = array_diff($providersRequested, array_keys($providers)); + + // initialize response with unavailable providers + $responseData = array_fill_keys($providersUnavailable, false); + + // check services and collections for each available provider + foreach ($providers as $provider) { + $serviceSelector = $sources[$provider->identifier()]; + $servicesRequested = $serviceSelector->identifiers(); + /** @var ServiceBaseInterface[] $servicesAvailable */ + $servicesAvailable = $provider->serviceList($tenantId, $userId, $servicesRequested); + $servicesUnavailable = array_diff($servicesRequested, array_keys($servicesAvailable)); + + // mark unavailable services as false + if ($servicesUnavailable !== []) { + $responseData[$provider->identifier()] = array_fill_keys($servicesUnavailable, false); + } + + // confirm collections for each available service + foreach ($servicesAvailable as $service) { + $collectionSelector = $serviceSelector[$service->identifier()]; + $collectionsRequested = $collectionSelector->identifiers(); + + if ($collectionsRequested === []) { + continue; + } + + // check each requested collection + $collectionsAvailable = $service->collectionExtant(...$collectionsRequested); + $collectionsUnavailable = array_diff($collectionsRequested, array_keys($collectionsAvailable)); + $responseData[$provider->identifier()][$service->identifier()] = array_merge( + $collectionsAvailable, + array_fill_keys($collectionsUnavailable, false) + ); + } + } + return $responseData; } /** * Fetch a specific collection + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param string $providerId Provider identifier + * @param string|int $serviceId Service identifier + * @param string|int $collectionId Collection identifier + * + * @return CollectionBaseInterface|null */ - public function collectionFetch( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int $identifier - ): ?INodeCollectionBase { + public function collectionFetch(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId): ?CollectionBaseInterface { + // retrieve service $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - return $service->collectionFetch($identifier); + if ($service === null) { + return null; + } + // retrieve collection + return $service->collectionFetch($collectionId); } /** - * Create a new collection + * Create a new collection for a specific user + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param string $providerId provider identifier + * @param string|int $serviceId service identifier + * @param string|int|null $collectionId collection identifier (parent collection) + * @param CollectionMutableInterface|array $object collection to create + * @param array $options additional options for creation + * + * @return CollectionBaseInterface + * @throws InvalidArgumentException */ - public function collectionCreate( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int|null $location, - INodeCollectionMutable|array $collection, - array $options = [] - ): INodeCollectionBase { + public function collectionCreate(string $tenantId, string $userId, string $providerId, string|int $serviceId, string|int|null $collectionId, CollectionMutableInterface|array $object, array $options = []): CollectionBaseInterface { + // retrieve service $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - - if (!$service instanceof IServiceCollectionMutable) { - throw new InvalidArgumentException('Service does not support collection creation'); + + // Check if service supports collection creation + if (!($service instanceof ServiceCollectionMutableInterface)) { + throw new InvalidArgumentException("Service does not support collection mutations"); + } + if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_CREATE)) { + throw new InvalidArgumentException("Service is not capable of creating collections"); + } + + if (is_array($object)) { + $collection = $service->collectionFresh(); + $collection->getProperties()->jsonDeserialize($object); + } else { + $collection = $object; } - if (is_array($collection)) { - $collectionObject = $service->collectionFresh(); - $collectionObject->jsonDeserialize($collection); - $collection = $collectionObject; - } - - return $service->collectionCreate($location, $collection, $options); + // Create collection + return $service->collectionCreate($collectionId, $collection, $options); } /** - * Modify an existing collection + * Modify an existing collection for a specific user + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param string $providerId provider identifier + * @param string|int $serviceId service identifier + * @param string|int $collectionId collection identifier + * @param CollectionMutableInterface|array $object collection to modify + * + * @return CollectionBaseInterface + * @throws InvalidArgumentException */ - public function collectionModify( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int $identifier, - INodeCollectionMutable|array $collection - ): INodeCollectionBase { + public function collectionUpdate(string $tenantId, string $userId, string $providerId, string|int $serviceId, string|int $collectionId, CollectionMutableInterface|array $object): CollectionBaseInterface { + // retrieve service $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - - if (!$service instanceof IServiceCollectionMutable) { - throw new InvalidArgumentException('Service does not support collection modification'); + + // Check if service supports collection creation + if (!($service instanceof ServiceCollectionMutableInterface)) { + throw new InvalidArgumentException("Service does not support collection mutations"); + } + if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_UPDATE)) { + throw new InvalidArgumentException("Service is not capable of updating collections"); + } + + if (is_array($object)) { + $collection = $service->collectionFresh(); + $collection->getProperties()->jsonDeserialize($object); + } else { + $collection = $object; } - if (is_array($collection)) { - $collectionObject = $service->collectionFresh(); - $collectionObject->jsonDeserialize($collection); - $collection = $collectionObject; - } - - return $service->collectionModify($identifier, $collection); + // Update collection + return $service->collectionUpdate($collectionId, $collection); } /** - * Destroy a collection + * Delete a specific collection + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param string $providerId Provider identifier + * @param string|int $serviceId Service identifier + * @param string|int $collectionId Collection identifier + * + * @return CollectionBaseInterface|null */ - public function collectionDestroy( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int $identifier - ): bool { + public function collectionDelete(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId, array $options = []): bool { + // retrieve service $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - - if (!$service instanceof IServiceCollectionMutable) { - throw new InvalidArgumentException('Service does not support collection deletion'); + + // Check if service supports collection deletion + if (!($service instanceof ServiceCollectionMutableInterface)) { + throw new InvalidArgumentException("Service does not support collection mutations"); } - - return $service->collectionDestroy($identifier); + if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_DELETE)) { + throw new InvalidArgumentException("Service is not capable of deleting collections"); + } + + $force = $options['force'] ?? false; + $recursive = $options['recursive'] ?? false; + + // delete collection + return $service->collectionDelete($collectionId, $force, $recursive); } /** @@ -338,107 +642,362 @@ class Manager { /** * List entities in a collection - * - * @return array + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string $userId User identifier + * @param SourceSelector $sources Entity sources with collection identifiers + * @param array|null $filter Entity filter + * @param array|null $sort Entity sort + * @param array|null $range Entity range/pagination + * + * @return array>>> Entities grouped by provider/service/collection */ - public function entityList( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int $collection, - ?array $filter = null, - ?array $sort = null, - ?array $range = null - ): array { + public function entityList(string $tenantId, string $userId, SourceSelector $sources, array|null $filter = null, array|null $sort = null, array|null $range = null): array { + // retrieve providers + $providers = $this->providerList($tenantId, $userId, $sources); + // retrieve services for each provider + $responseData = []; + foreach ($providers as $provider) { + // retrieve services for each provider + $serviceSelector = $sources[$provider->identifier()]; + $servicesSelected = $provider->serviceList($tenantId,$userId, $serviceSelector->identifiers()); + /** @var ServiceBaseInterface $service */ + foreach ($servicesSelected as $service) { + // extract required collection identifiers for this service from sources + $collectionSelector = $serviceSelector[$service->identifier()] ?? null; + $collectionSelected = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : [null]; + // construct filter for entities + $entityFilter = null; + if ($filter !== null && $filter !== []) { + $entityFilter = $service->entityListFilter(); + foreach ($filter as $attribute => $value) { + $entityFilter->condition($attribute, $value); + } + } + // construct sort for entities + $entitySort = null; + if ($sort !== null && $sort !== []) { + $entitySort = $service->entityListSort(); + foreach ($sort as $attribute => $direction) { + $entitySort->condition($attribute, $direction); + } + } + // construct range for entities + $entityRange = null; + if ($range !== null && $range !== [] && isset($range['type'])) { + $entityRange = $service->entityListRange(RangeType::from($range['type'])); + // Cast to IRangeTally if the range type is TALLY + if ($entityRange->type() === RangeType::TALLY) { + /** @var IRangeTally $entityRange */ + if (isset($range['anchor'])) { + $entityRange->setAnchor(RangeAnchorType::from($range['anchor'])); + } + if (isset($range['position'])) { + $entityRange->setPosition($range['position']); + } + if (isset($range['tally'])) { + $entityRange->setTally($range['tally']); + } + } + } + // retrieve entities for each collection + foreach ($collectionSelected as $collectionId) { + $entities = $service->entityList($collectionId, $entityFilter, $entitySort, $entityRange, null); + // skip collections with no entities + if ($entities === []) { + continue; + } + $responseData[$provider->identifier()][$service->identifier()][$collectionId] = $entities; + } + } + } + + return $responseData; + } + + /** + * Fetch specific messages + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param string $providerId Provider identifier + * @param string|int $serviceId Service identifier + * @param string|int $collectionId Collection identifier + * @param array $identifiers Message identifiers + * + * @return array Messages indexed by ID + */ + public function entityFetch(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId, array $identifiers): array { $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - // construct filter - $entityFilter = null; - if ($filter !== null && $filter !== []) { - $entityFilter = $service->entityListFilter(); - foreach ($filter as $attribute => $value) { - $entityFilter->condition($attribute, $value); - } - } - - // construct sort - $entitySort = null; - if ($sort !== null && $sort !== []) { - $entitySort = $service->entityListSort(); - foreach ($sort as $attribute => $direction) { - $entitySort->condition($attribute, $direction); - } - } - - // construct range - $entityRange = null; - if ($range !== null && $range !== [] && isset($range['type'])) { - $entityRange = $service->entityListRange(RangeType::from($range['type'])); - if ($entityRange instanceof IRangeTally) { - if (isset($range['anchor'])) { - $entityRange->setAnchor(RangeAnchorType::from($range['anchor'])); - } - if (isset($range['position'])) { - $entityRange->setPosition($range['position']); - } - if (isset($range['tally'])) { - $entityRange->setTally($range['tally']); - } - } - } - - return $service->entityList($collection, $entityFilter, $entitySort, $entityRange); + // retrieve collection + return $service->entityFetch($collectionId, ...$identifiers); } /** - * Get entity delta/changes since a signature + * Check if messages exist + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param SourceSelector $sources Message sources with identifiers + * + * @return array>>> Existence map grouped by provider/service/collection */ - public function entityDelta( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int $collection, - string $signature, - string $detail = 'ids' - ): array { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - return $service->entityDelta($collection, $signature, $detail); + public function entityExtant(string $tenantId, string $userId, SourceSelector $sources): array { + // confirm that sources are provided + if ($sources === null) { + $sources = new SourceSelector([]); + } + // retrieve available providers + $providers = $this->providerList($tenantId, $userId, $sources); + $providersRequested = $sources->identifiers(); + $providersUnavailable = array_diff($providersRequested, array_keys($providers)); + + // initialize response with unavailable providers + $responseData = array_fill_keys($providersUnavailable, false); + + // check services, collections, and entities for each available provider + foreach ($providers as $provider) { + $serviceSelector = $sources[$provider->identifier()]; + $servicesRequested = $serviceSelector->identifiers(); + /** @var ServiceBaseInterface[] $servicesAvailable */ + $servicesAvailable = $provider->serviceList($tenantId, $userId, $servicesRequested); + $servicesUnavailable = array_diff($servicesRequested, array_keys($servicesAvailable)); + + // mark unavailable services as false + if ($servicesUnavailable !== []) { + $responseData[$provider->identifier()] = array_fill_keys($servicesUnavailable, false); + } + + // check collections and entities for each available service + foreach ($servicesAvailable as $service) { + $collectionSelector = $serviceSelector[$service->identifier()]; + $collectionsRequested = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : []; + + if ($collectionsRequested === []) { + continue; + } + + // check entities for each requested collection + foreach ($collectionsRequested as $collectionId) { + // first check if collection exists + $collectionExists = $service->collectionExtant((string)$collectionId); + + if (!$collectionExists) { + // collection doesn't exist, mark as false + $responseData[$provider->identifier()][$service->identifier()][$collectionId] = false; + continue; + } + + // extract entity identifiers from collection selector + $entitySelector = $collectionSelector[$collectionId]; + + // handle both array of entity IDs and boolean true (meaning check if collection exists) + if ($entitySelector instanceof EntitySelector) { + // check specific entities within the collection + $responseData[$provider->identifier()][$service->identifier()][$collectionId] = $service->entityExtant($collectionId, ...$entitySelector->identifiers()); + } elseif ($entitySelector === true) { + // just checking if collection exists (already confirmed above) + $responseData[$provider->identifier()][$service->identifier()][$collectionId] = true; + } + } + } + } + return $responseData; } /** - * Check if entities exist - * - * @return array + * Get message delta/changes + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param SourceSelector $sources Message sources with signatures + * + * @return array>> Delta grouped by provider/service/collection */ - public function entityExtant( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int $collection, - array $identifiers - ): array { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - return $service->entityExtant($collection, ...$identifiers); + public function entityDelta(string $tenantId, string $userId, SourceSelector $sources): array { + // confirm that sources are provided + if ($sources === null) { + $sources = new SourceSelector([]); + } + // retrieve providers + $providers = $this->providerList($tenantId, $userId, $sources); + $providersRequested = $sources->identifiers(); + $providersUnavailable = array_diff($providersRequested, array_keys($providers)); + // initialize response with unavailable providers + $responseData = array_fill_keys($providersUnavailable, false); + // iterate through available providers + foreach ($providers as $provider) { + $serviceSelector = $sources[$provider->identifier()]; + $servicesRequested = $serviceSelector instanceof ServiceSelector ? $serviceSelector->identifiers() : []; + /** @var ServiceBaseInterface[] $services */ + $services = $provider->serviceList($tenantId, $userId, $servicesRequested); + $servicesUnavailable = array_diff($servicesRequested, array_keys($services)); + if ($servicesUnavailable !== []) { + $responseData[$provider->identifier()] = array_fill_keys($servicesUnavailable, false); + } + // iterate through available services + foreach ($services as $service) { + $collectionSelector = $serviceSelector[$service->identifier()]; + $collectionsRequested = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : []; + if ($collectionsRequested === []) { + $responseData[$provider->identifier()][$service->identifier()] = false; + continue; + } + foreach ($collectionsRequested as $collection) { + $entitySelector = $collectionSelector[$collection] ?? null; + $responseData[$provider->identifier()][$service->identifier()][$collection] = $service->entityDelta($collection, $entitySelector); + } + } + } + return $responseData; } /** - * Fetch specific entities + * Create a new entity in a collection + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param string $providerId provider identifier + * @param string|int $serviceId service identifier + * @param string|int $collectionId collection identifier + * @param EntityMutableInterface|array $entity entity to create + * @param array $options additional options * - * @return array + * @return EntityBaseInterface + * @throws InvalidArgumentException */ - public function entityFetch( + public function entityCreate(string $tenantId, string $userId, string $providerId, string|int $serviceId, string|int $collectionId, EntityMutableInterface|array $object, array $options = []): EntityBaseInterface { + // retrieve service + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + + // Check if service supports entity creation + if (!($service instanceof ServiceEntityMutableInterface)) { + throw new InvalidArgumentException("Service does not support entity mutations"); + } + if (!$service->capable(ServiceEntityMutableInterface::CAPABILITY_ENTITY_CREATE)) { + throw new InvalidArgumentException("Service is not capable of creating entities"); + } + + if (is_array($object)) { + $entity = $service->entityFresh(); + $entity->getProperties()->jsonDeserialize($object); + } else { + $entity = $object; + } + + return $service->entityCreate($collectionId, $entity, $options); + } + + /** + * Modify an existing entity in a collection + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param string|int $providerId provider identifier + * @param string|int $serviceId service identifier + * @param string|int $collectionId collection identifier + * @param string|int $identifier entity identifier + * @param EntityBaseInterface|array $entity entity with modifications + * + * @return EntityBaseInterface + * @throws InvalidArgumentException + */ + public function entityUpdate(string $tenantId, string $userId, string|int $providerId, string|int $serviceId, string|int $collectionId, string|int $identifier, EntityBaseInterface|array $object): EntityBaseInterface { + // retrieve service + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + + // Check if service supports entity creation + if (!($service instanceof ServiceEntityMutableInterface)) { + throw new InvalidArgumentException("Service does not support entity mutations"); + } + if (!$service->capable(ServiceEntityMutableInterface::CAPABILITY_ENTITY_CREATE)) { + throw new InvalidArgumentException("Service is not capable of creating entities"); + } + + if (is_array($object)) { + $entity = $service->entityFresh(); + $entity->getProperties()->jsonDeserialize($object); + } else { + $entity = $object; + } + + return $service->entityUpdate($collectionId, $identifier, $entity); + } + + /** + * Destroy an entity from a collection + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param string|int $providerId provider identifier + * @param string|int $serviceId service identifier + * @param string|int $collectionId collection identifier + * @param string|int $identifier entity identifier + * + * @return bool + * @throws InvalidArgumentException + */ + public function entityDelete(string $tenantId, string $userId, string|int $providerId, string|int $serviceId, string|int $collectionId, string|int $identifier): bool { + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + + if (!($service instanceof ServiceEntityMutableInterface)) { + throw new InvalidArgumentException('Service does not support entity destruction'); + } + + $entity = $service->entityDelete($collectionId, $identifier); + + return $entity !== null; + } + + /** + * Copy an entity + */ + public function entityCopy( string $tenantId, string $userId, string $providerId, string|int $serviceId, - string|int $collection, - array $identifiers - ): array { + string|int|null $collection, + string|int $identifier, + string|int|null $destination + ): INodeEntityBase { $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - return $service->entityFetch($collection, ...$identifiers); + + if (!$service instanceof ServiceEntityMutableInterface) { + throw new InvalidArgumentException('Service does not support entity copy'); + } + + return $service->entityCopy($collection, $identifier, $destination); + } + + /** + * Move an entity + */ + public function entityMove( + string $tenantId, + string $userId, + string $providerId, + string|int $serviceId, + string|int|null $collection, + string|int $identifier, + string|int|null $destination + ): INodeEntityBase { + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + + if (!$service instanceof ServiceEntityMutableInterface) { + throw new InvalidArgumentException('Service does not support entity move'); + } + + return $service->entityMove($collection, $identifier, $destination); } /** @@ -490,122 +1049,6 @@ class Manager { return $service->entityReadChunk($collection, $identifier, $offset, $length); } - /** - * Create a new entity - */ - public function entityCreate( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int|null $collection, - INodeEntityMutable|array $entity, - array $options = [] - ): INodeEntityBase { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - - if (!$service instanceof IServiceEntityMutable) { - throw new InvalidArgumentException('Service does not support entity creation'); - } - - if (is_array($entity)) { - $entityObject = $service->entityFresh(); - $entityObject->jsonDeserialize($entity); - $entity = $entityObject; - } - - return $service->entityCreate($collection, $entity, $options); - } - - /** - * Modify an existing entity - */ - public function entityModify( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int|null $collection, - string|int $identifier, - INodeEntityMutable|array $entity - ): INodeEntityBase { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - - if (!$service instanceof IServiceEntityMutable) { - throw new InvalidArgumentException('Service does not support entity modification'); - } - - if (is_array($entity)) { - $entityObject = $service->entityFresh(); - $entityObject->jsonDeserialize($entity); - $entity = $entityObject; - } - - return $service->entityModify($collection, $identifier, $entity); - } - - /** - * Destroy an entity - */ - public function entityDestroy( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int|null $collection, - string|int $identifier - ): bool { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - - if (!$service instanceof IServiceEntityMutable) { - throw new InvalidArgumentException('Service does not support entity deletion'); - } - - return $service->entityDestroy($collection, $identifier); - } - - /** - * Copy an entity - */ - public function entityCopy( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int|null $collection, - string|int $identifier, - string|int|null $destination - ): INodeEntityBase { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - - if (!$service instanceof IServiceEntityMutable) { - throw new InvalidArgumentException('Service does not support entity copy'); - } - - return $service->entityCopy($collection, $identifier, $destination); - } - - /** - * Move an entity - */ - public function entityMove( - string $tenantId, - string $userId, - string $providerId, - string|int $serviceId, - string|int|null $collection, - string|int $identifier, - string|int|null $destination - ): INodeEntityBase { - $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - - if (!$service instanceof IServiceEntityMutable) { - throw new InvalidArgumentException('Service does not support entity move'); - } - - return $service->entityMove($collection, $identifier, $destination); - } - /** * Write entity content */ @@ -620,7 +1063,7 @@ class Manager { ): int { $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - if (!$service instanceof IServiceEntityMutable) { + if (!$service instanceof ServiceEntityMutableInterface) { throw new InvalidArgumentException('Service does not support entity write'); } @@ -642,7 +1085,7 @@ class Manager { ) { $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - if (!$service instanceof IServiceEntityMutable) { + if (!$service instanceof ServiceEntityMutableInterface) { throw new InvalidArgumentException('Service does not support entity write stream'); } @@ -664,7 +1107,7 @@ class Manager { ): int { $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - if (!$service instanceof IServiceEntityMutable) { + if (!$service instanceof ServiceEntityMutableInterface) { throw new InvalidArgumentException('Service does not support entity write chunk'); } @@ -691,7 +1134,7 @@ class Manager { ): array { $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); - // construct filter + // construct filter for collections $nodeFilter = null; if ($filter !== null && $filter !== []) { $nodeFilter = $service->nodeListFilter(); @@ -699,8 +1142,7 @@ class Manager { $nodeFilter->condition($attribute, $value); } } - - // construct sort + // construct sort for collections $nodeSort = null; if ($sort !== null && $sort !== []) { $nodeSort = $service->nodeListSort(); @@ -708,7 +1150,7 @@ class Manager { $nodeSort->condition($attribute, $direction); } } - + // construct range $nodeRange = null; if ($range !== null && $range !== [] && isset($range['type'])) { diff --git a/lib/Module.php b/lib/Module.php index 14e11b9..1502eec 100644 --- a/lib/Module.php +++ b/lib/Module.php @@ -1,6 +1,6 @@ [ - 'label' => 'Access File Manager', - 'description' => 'View and access the file manager module', - 'group' => 'File Management' + 'documents_manager' => [ + 'label' => 'Access Documents Manager', + 'description' => 'View and access the documents manager module', + 'group' => 'Document Management' ], ]; } @@ -53,7 +53,7 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface public function registerBI(): array { return [ 'handle' => $this->handle(), - 'namespace' => 'FileManager', + 'namespace' => 'DocumentsManager', 'version' => $this->version(), 'label' => $this->label(), 'author' => $this->author(), diff --git a/lib/Transfer/StreamingZip.php b/lib/Transfer/StreamingZip.php index b8fe59d..005bc5e 100644 --- a/lib/Transfer/StreamingZip.php +++ b/lib/Transfer/StreamingZip.php @@ -7,7 +7,7 @@ declare(strict_types=1); * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace KTXM\FileManager\Transfer; +namespace KTXM\DocumentsManager\Transfer; /** * Native PHP streaming ZIP archive generator diff --git a/src/main.ts b/src/main.ts index 848c011..a1b43ac 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,13 +1,10 @@ /** - * File Manager Module Boot Script - * - * This script is executed when the file_manager module is loaded. - * It initializes the stores which manage file nodes (files and folders) state. + * Documents Manager Module Boot */ -console.log('[FileManager] Booting File Manager module...') +console.log('[Documents Manager] Booting module...') -console.log('[FileManager] File Manager module booted successfully') +console.log('[Documents Manager] Module booted successfully...') // CSS will be injected by build process //export const css = ['__CSS_FILENAME_PLACEHOLDER__'] diff --git a/src/models/collection.ts b/src/models/collection.ts index 1908754..9777b3c 100644 --- a/src/models/collection.ts +++ b/src/models/collection.ts @@ -1,95 +1,144 @@ /** - * Class model for FileCollection Interface + * Class model for Collection Interface */ -import type { FileCollection } from "@/types/node"; -export class FileCollectionObject implements FileCollection { +import type { CollectionContentTypes, CollectionInterface, CollectionModelInterface, CollectionPropertiesInterface } from "@/types/collection"; - _data!: FileCollection; +export class CollectionObject implements CollectionModelInterface { + + _data!: CollectionInterface; constructor() { this._data = { - '@type': 'files.collection', - in: null, - id: '', - createdBy: '', - createdOn: '', - modifiedBy: '', - modifiedOn: '', + '@type': 'documents:collection', + schema: 1, + provider: '', + service: '', + collection: null, + identifier: '', + signature: null, + created: null, + modified: null, + properties: new CollectionPropertiesObject(), + }; + } + + fromJson(data: CollectionInterface): CollectionObject { + this._data = data; + if (data.properties) { + this._data.properties = new CollectionPropertiesObject().fromJson(data.properties as CollectionPropertiesInterface); + } + return this; + } + + toJson(): CollectionInterface { + const json = { ...this._data }; + if (this._data.properties instanceof CollectionPropertiesObject) { + json.properties = this._data.properties.toJson(); + } + return json; + } + + clone(): CollectionObject { + const cloned = new CollectionObject(); + cloned._data = { ...this._data }; + cloned._data.properties = this.properties.clone(); + return cloned; + } + + /** Immutable Properties */ + + get schema(): number { + return this._data.schema; + } + + get provider(): string { + return this._data.provider; + } + + get service(): string | number { + return this._data.service; + } + + get collection(): string | number | null { + return this._data.collection; + } + + get identifier(): string | number { + return this._data.identifier; + } + + get signature(): string | null { + return this._data.signature || null; + } + + get created(): Date | null { + return this._data.created ? new Date(this._data.created) : null; + } + + get modified(): Date | null { + return this._data.modified ? new Date(this._data.modified) : null; + } + + get properties(): CollectionPropertiesObject { + if (this._data.properties instanceof CollectionPropertiesObject) { + return this._data.properties; + } + + if (this._data.properties) { + const hydrated = new CollectionPropertiesObject().fromJson(this._data.properties as CollectionPropertiesInterface); + this._data.properties = hydrated; + return hydrated; + } + + const defaultProperties = new CollectionPropertiesObject(); + this._data.properties = defaultProperties; + return defaultProperties; + } + + set properties(value: CollectionPropertiesObject) { + if (value instanceof CollectionPropertiesObject) { + this._data.properties = value as any; + } else { + this._data.properties = value; + } + } +} + +export class CollectionPropertiesObject implements CollectionPropertiesInterface { + + _data!: CollectionPropertiesInterface; + + constructor() { + this._data = { + content: [], owner: '', - signature: '', label: '', }; } - fromJson(data: FileCollection): FileCollectionObject { + fromJson(data: CollectionPropertiesInterface): CollectionPropertiesObject { this._data = data; return this; } - toJson(): FileCollection { + toJson(): CollectionPropertiesInterface { return this._data; } - clone(): FileCollectionObject { - const cloned = new FileCollectionObject(); - cloned._data = JSON.parse(JSON.stringify(this._data)); + clone(): CollectionPropertiesObject { + const cloned = new CollectionPropertiesObject(); + cloned._data = { ...this._data }; return cloned; } - /** Properties */ + /** Immutable Properties */ - get '@type'(): 'files.collection' { - return this._data['@type']; + get content(): CollectionContentTypes[] { + return this._data.content || []; } - get in(): string | null { - return this._data.in; - } - - set in(value: string | null) { - this._data.in = value; - } - - get id(): string { - return this._data.id; - } - - set id(value: string) { - this._data.id = value; - } - - get createdBy(): string { - return this._data.createdBy; - } - - set createdBy(value: string) { - this._data.createdBy = value; - } - - get createdOn(): string { - return this._data.createdOn; - } - - set createdOn(value: string) { - this._data.createdOn = value; - } - - get modifiedBy(): string { - return this._data.modifiedBy; - } - - set modifiedBy(value: string) { - this._data.modifiedBy = value; - } - - get modifiedOn(): string { - return this._data.modifiedOn; - } - - set modifiedOn(value: string) { - this._data.modifiedOn = value; - } + /** Mutable Properties */ get owner(): string { return this._data.owner; @@ -99,34 +148,12 @@ export class FileCollectionObject implements FileCollection { this._data.owner = value; } - get signature(): string { - return this._data.signature; - } - - set signature(value: string) { - this._data.signature = value; - } - get label(): string { - return this._data.label; + return this._data.label || ''; } set label(value: string) { this._data.label = value; } - - /** Helper methods */ - - get isRoot(): boolean { - return this._data.id === '00000000-0000-0000-0000-000000000000'; - } - - get createdOnDate(): Date | null { - return this._data.createdOn ? new Date(this._data.createdOn) : null; - } - - get modifiedOnDate(): Date | null { - return this._data.modifiedOn ? new Date(this._data.modifiedOn) : null; - } - -} + +} \ No newline at end of file diff --git a/src/models/document.ts b/src/models/document.ts new file mode 100644 index 0000000..321cfce --- /dev/null +++ b/src/models/document.ts @@ -0,0 +1,79 @@ +import type { DocumentInterface, DocumentModelInterface } from "@/types/document"; + +export class DocumentObject implements DocumentModelInterface { + + _data!: DocumentInterface; + + constructor() { + this._data = { + '@type': 'documents:document', + urid: null, + size: 0, + label: '', + mime: null, + format: null, + encoding: null, + }; + } + + fromJson(data: DocumentInterface): DocumentObject { + this._data = data; + return this; + } + + toJson(): DocumentInterface { + return this._data; + } + + clone(): DocumentObject { + const cloned = new DocumentObject(); + cloned._data = { ...this._data }; + return cloned; + } + + /** Immutable Properties */ + + get urid(): string | null { + return this._data.urid; + } + + get size(): number { + const parsed = typeof this._data.size === 'number' ? this._data.size : Number(this._data.size); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; + } + + /** Mutable Properties */ + + get label(): string | null { + return this._data.label || null; + } + + set label(value: string) { + this._data.label = value; + } + + get mime(): string | null { + return this._data.mime; + } + + set mime(value: string) { + this._data.mime = value; + } + + get format(): string | null { + return this._data.format; + } + + set format(value: string) { + this._data.format = value; + } + + get encoding(): string | null { + return this._data.encoding; + } + + set encoding(value: string) { + this._data.encoding = value; + } + +} \ No newline at end of file diff --git a/src/models/entity.ts b/src/models/entity.ts index 071002d..31689e9 100644 --- a/src/models/entity.ts +++ b/src/models/entity.ts @@ -1,200 +1,104 @@ /** - * Class model for FileEntity Interface + * Class model for Entity Interface */ -import type { FileEntity } from "@/types/node"; +import type { EntityInterface, EntityModelInterface } from "@/types/entity"; +import type { DocumentInterface, DocumentModelInterface } from "@/types/document"; +import { DocumentObject } from "./document"; -export class FileEntityObject implements FileEntity { +export class EntityObject implements EntityModelInterface { - _data!: FileEntity; + _data!: EntityInterface; constructor() { this._data = { - '@type': 'files.entity', - in: null, - id: '', - createdBy: '', - createdOn: '', - modifiedBy: '', - modifiedOn: '', - owner: '', - signature: '', - label: '', - size: 0, - mime: '', - format: '', - encoding: '', + '@type': '', + schema: 1, + provider: '', + service: '', + collection: '', + identifier: '', + signature: null, + created: null, + modified: null, + properties: new DocumentObject(), }; } - fromJson(data: FileEntity): FileEntityObject { - this._data = data; + fromJson(data: EntityInterface): EntityObject { + this._data = data + if (data.properties) { + this._data.properties = new DocumentObject().fromJson(data.properties as DocumentInterface); + } return this; } - toJson(): FileEntity { - return this._data; + toJson(): EntityInterface { + const json = { ...this._data } + if (this._data.properties instanceof DocumentObject) { + json.properties = this._data.properties.toJson(); + } + return json as EntityInterface } - clone(): FileEntityObject { - const cloned = new FileEntityObject(); - cloned._data = JSON.parse(JSON.stringify(this._data)); - return cloned; + clone(): EntityObject { + const cloned = new EntityObject() + cloned._data = { ...this._data } + cloned._data.properties = this.properties.clone(); + return cloned } - /** Properties */ + /** Immutable Properties */ - get '@type'(): 'files.entity' { - return this._data['@type']; + get provider(): string { + return this._data.provider } - get in(): string | null { - return this._data.in; + get schema(): number { + return this._data.schema } - set in(value: string | null) { - this._data.in = value; + get service(): string { + return this._data.service } - get id(): string { - return this._data.id; + get collection(): string | number { + return this._data.collection } - set id(value: string) { - this._data.id = value; + get identifier(): string | number { + return this._data.identifier } - get createdBy(): string { - return this._data.createdBy; + get signature(): string | null { + return this._data.signature } - set createdBy(value: string) { - this._data.createdBy = value; + get created(): Date | null { + return this._data.created ? new Date(this._data.created) : null } - get createdOn(): string { - return this._data.createdOn; + get modified(): Date | null { + return this._data.modified ? new Date(this._data.modified) : null } - set createdOn(value: string) { - this._data.createdOn = value; + get properties(): DocumentObject { + if (this._data.properties instanceof DocumentObject) { + return this._data.properties + } + + if (this._data.properties) { + const hydrated = new DocumentObject().fromJson(this._data.properties as DocumentInterface) + this._data.properties = hydrated + return hydrated + } + + const defaultProperties = new DocumentObject() + this._data.properties = defaultProperties + return defaultProperties } - get modifiedBy(): string { - return this._data.modifiedBy; - } - - set modifiedBy(value: string) { - this._data.modifiedBy = value; - } - - get modifiedOn(): string { - return this._data.modifiedOn; - } - - set modifiedOn(value: string) { - this._data.modifiedOn = value; - } - - get owner(): string { - return this._data.owner; - } - - set owner(value: string) { - this._data.owner = value; - } - - get signature(): string { - return this._data.signature; - } - - set signature(value: string) { - this._data.signature = value; - } - - get label(): string { - return this._data.label; - } - - set label(value: string) { - this._data.label = value; - } - - get size(): number { - return this._data.size; - } - - set size(value: number) { - this._data.size = value; - } - - get mime(): string { - return this._data.mime; - } - - set mime(value: string) { - this._data.mime = value; - } - - get format(): string { - return this._data.format; - } - - set format(value: string) { - this._data.format = value; - } - - get encoding(): string { - return this._data.encoding; - } - - set encoding(value: string) { - this._data.encoding = value; - } - - /** Helper methods */ - - get createdOnDate(): Date | null { - return this._data.createdOn ? new Date(this._data.createdOn) : null; - } - - get modifiedOnDate(): Date | null { - return this._data.modifiedOn ? new Date(this._data.modifiedOn) : null; - } - - get extension(): string { - const parts = this._data.label.split('.'); - return parts.length > 1 ? parts[parts.length - 1] : ''; - } - - get sizeFormatted(): string { - const bytes = this._data.size; - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; - } - - get isImage(): boolean { - return this._data.mime.startsWith('image/'); - } - - get isVideo(): boolean { - return this._data.mime.startsWith('video/'); - } - - get isAudio(): boolean { - return this._data.mime.startsWith('audio/'); - } - - get isText(): boolean { - return this._data.mime.startsWith('text/') || - this._data.mime === 'application/json' || - this._data.mime === 'application/xml'; - } - - get isPdf(): boolean { - return this._data.mime === 'application/pdf'; + set properties(value: DocumentObject) { + this._data.properties = value } } diff --git a/src/models/identity.ts b/src/models/identity.ts new file mode 100644 index 0000000..3291090 --- /dev/null +++ b/src/models/identity.ts @@ -0,0 +1,196 @@ +/** + * Identity implementation classes for Mail Manager services + */ + +import type { + ServiceIdentity, + ServiceIdentityNone, + ServiceIdentityBasic, + ServiceIdentityToken, + ServiceIdentityOAuth, + ServiceIdentityCertificate +} from '@/types/service'; + +/** + * Base Identity class + */ +export abstract class Identity { + abstract toJson(): ServiceIdentity; + + static fromJson(data: ServiceIdentity): Identity { + switch (data.type) { + case 'NA': + return IdentityNone.fromJson(data); + case 'BA': + return IdentityBasic.fromJson(data); + case 'TA': + return IdentityToken.fromJson(data); + case 'OA': + return IdentityOAuth.fromJson(data); + case 'CC': + return IdentityCertificate.fromJson(data); + default: + throw new Error(`Unknown identity type: ${(data as any).type}`); + } + } +} + +/** + * No authentication + */ +export class IdentityNone extends Identity { + readonly type = 'NA' as const; + + static fromJson(_data: ServiceIdentityNone): IdentityNone { + return new IdentityNone(); + } + + toJson(): ServiceIdentityNone { + return { + type: this.type + }; + } +} + +/** + * Basic authentication (username/password) + */ +export class IdentityBasic extends Identity { + readonly type = 'BA' as const; + identity: string; + secret: string; + + constructor(identity: string = '', secret: string = '') { + super(); + this.identity = identity; + this.secret = secret; + } + + static fromJson(data: ServiceIdentityBasic): IdentityBasic { + return new IdentityBasic(data.identity, data.secret); + } + + toJson(): ServiceIdentityBasic { + return { + type: this.type, + identity: this.identity, + secret: this.secret + }; + } +} + +/** + * Token authentication (API key, static token) + */ +export class IdentityToken extends Identity { + readonly type = 'TA' as const; + token: string; + + constructor(token: string = '') { + super(); + this.token = token; + } + + static fromJson(data: ServiceIdentityToken): IdentityToken { + return new IdentityToken(data.token); + } + + toJson(): ServiceIdentityToken { + return { + type: this.type, + token: this.token + }; + } +} + +/** + * OAuth authentication + */ +export class IdentityOAuth extends Identity { + readonly type = 'OA' as const; + accessToken: string; + accessScope?: string[]; + accessExpiry?: number; + refreshToken?: string; + refreshLocation?: string; + + constructor( + accessToken: string = '', + accessScope?: string[], + accessExpiry?: number, + refreshToken?: string, + refreshLocation?: string + ) { + super(); + this.accessToken = accessToken; + this.accessScope = accessScope; + this.accessExpiry = accessExpiry; + this.refreshToken = refreshToken; + this.refreshLocation = refreshLocation; + } + + static fromJson(data: ServiceIdentityOAuth): IdentityOAuth { + return new IdentityOAuth( + data.accessToken, + data.accessScope, + data.accessExpiry, + data.refreshToken, + data.refreshLocation + ); + } + + toJson(): ServiceIdentityOAuth { + return { + type: this.type, + accessToken: this.accessToken, + ...(this.accessScope && { accessScope: this.accessScope }), + ...(this.accessExpiry && { accessExpiry: this.accessExpiry }), + ...(this.refreshToken && { refreshToken: this.refreshToken }), + ...(this.refreshLocation && { refreshLocation: this.refreshLocation }) + }; + } + + isExpired(): boolean { + if (!this.accessExpiry) return false; + return Date.now() / 1000 >= this.accessExpiry; + } + + expiresIn(): number { + if (!this.accessExpiry) return Infinity; + return Math.max(0, this.accessExpiry - Date.now() / 1000); + } +} + +/** + * Client certificate authentication (mTLS) + */ +export class IdentityCertificate extends Identity { + readonly type = 'CC' as const; + certificate: string; + privateKey: string; + passphrase?: string; + + constructor(certificate: string = '', privateKey: string = '', passphrase?: string) { + super(); + this.certificate = certificate; + this.privateKey = privateKey; + this.passphrase = passphrase; + } + + static fromJson(data: ServiceIdentityCertificate): IdentityCertificate { + return new IdentityCertificate( + data.certificate, + data.privateKey, + data.passphrase + ); + } + + toJson(): ServiceIdentityCertificate { + return { + type: this.type, + certificate: this.certificate, + privateKey: this.privateKey, + ...(this.passphrase && { passphrase: this.passphrase }) + }; + } +} diff --git a/src/models/index.ts b/src/models/index.ts index 02ebfe4..2c5fe89 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -1,8 +1,4 @@ -/** - * Central export point for all File Manager models - */ - -export { FileCollectionObject } from './collection'; -export { FileEntityObject } from './entity'; +export { CollectionObject } from './collection'; +export { EntityObject } from './entity'; export { ProviderObject } from './provider'; export { ServiceObject } from './service'; diff --git a/src/models/location.ts b/src/models/location.ts new file mode 100644 index 0000000..7e19cce --- /dev/null +++ b/src/models/location.ts @@ -0,0 +1,240 @@ +/** + * Location implementation classes for Mail Manager services + */ + +import type { + ServiceLocation, + ServiceLocationUri, + ServiceLocationSocketSole, + ServiceLocationSocketSplit, + ServiceLocationFile +} from '@/types/service'; + +/** + * Base Location class + */ +export abstract class Location { + abstract toJson(): ServiceLocation; + + static fromJson(data: ServiceLocation): Location { + switch (data.type) { + case 'URI': + return LocationUri.fromJson(data); + case 'SOCKET_SOLE': + return LocationSocketSole.fromJson(data); + case 'SOCKET_SPLIT': + return LocationSocketSplit.fromJson(data); + case 'FILE': + return LocationFile.fromJson(data); + default: + throw new Error(`Unknown location type: ${(data as any).type}`); + } + } +} + +/** + * URI-based service location for API and web services + * Used by: JMAP, Gmail API, etc. + */ +export class LocationUri extends Location { + readonly type = 'URI' as const; + scheme: string; + host: string; + port: number; + path?: string; + verifyPeer: boolean; + verifyHost: boolean; + + constructor( + scheme: string = 'https', + host: string = '', + port: number = 443, + path?: string, + verifyPeer: boolean = true, + verifyHost: boolean = true + ) { + super(); + this.scheme = scheme; + this.host = host; + this.port = port; + this.path = path; + this.verifyPeer = verifyPeer; + this.verifyHost = verifyHost; + } + + static fromJson(data: ServiceLocationUri): LocationUri { + return new LocationUri( + data.scheme, + data.host, + data.port, + data.path, + data.verifyPeer ?? true, + data.verifyHost ?? true + ); + } + + toJson(): ServiceLocationUri { + return { + type: this.type, + scheme: this.scheme, + host: this.host, + port: this.port, + ...(this.path && { path: this.path }), + ...(this.verifyPeer !== undefined && { verifyPeer: this.verifyPeer }), + ...(this.verifyHost !== undefined && { verifyHost: this.verifyHost }) + }; + } + + getUrl(): string { + const path = this.path || ''; + return `${this.scheme}://${this.host}:${this.port}${path}`; + } +} + +/** + * Single socket-based service location + * Used by: services using a single host/port combination + */ +export class LocationSocketSole extends Location { + readonly type = 'SOCKET_SOLE' as const; + host: string; + port: number; + encryption: 'none' | 'ssl' | 'tls' | 'starttls'; + verifyPeer: boolean; + verifyHost: boolean; + + constructor( + host: string = '', + port: number = 993, + encryption: 'none' | 'ssl' | 'tls' | 'starttls' = 'ssl', + verifyPeer: boolean = true, + verifyHost: boolean = true + ) { + super(); + this.host = host; + this.port = port; + this.encryption = encryption; + this.verifyPeer = verifyPeer; + this.verifyHost = verifyHost; + } + + static fromJson(data: ServiceLocationSocketSole): LocationSocketSole { + return new LocationSocketSole( + data.host, + data.port, + data.encryption, + data.verifyPeer ?? true, + data.verifyHost ?? true + ); + } + + toJson(): ServiceLocationSocketSole { + return { + type: this.type, + host: this.host, + port: this.port, + encryption: this.encryption, + ...(this.verifyPeer !== undefined && { verifyPeer: this.verifyPeer }), + ...(this.verifyHost !== undefined && { verifyHost: this.verifyHost }) + }; + } +} + +/** + * Split socket-based service location + * Used by: traditional IMAP/SMTP configurations + */ +export class LocationSocketSplit extends Location { + readonly type = 'SOCKET_SPLIT' as const; + inboundHost: string; + inboundPort: number; + inboundEncryption: 'none' | 'ssl' | 'tls' | 'starttls'; + outboundHost: string; + outboundPort: number; + outboundEncryption: 'none' | 'ssl' | 'tls' | 'starttls'; + inboundVerifyPeer: boolean; + inboundVerifyHost: boolean; + outboundVerifyPeer: boolean; + outboundVerifyHost: boolean; + + constructor( + inboundHost: string = '', + inboundPort: number = 993, + inboundEncryption: 'none' | 'ssl' | 'tls' | 'starttls' = 'ssl', + outboundHost: string = '', + outboundPort: number = 465, + outboundEncryption: 'none' | 'ssl' | 'tls' | 'starttls' = 'ssl', + inboundVerifyPeer: boolean = true, + inboundVerifyHost: boolean = true, + outboundVerifyPeer: boolean = true, + outboundVerifyHost: boolean = true + ) { + super(); + this.inboundHost = inboundHost; + this.inboundPort = inboundPort; + this.inboundEncryption = inboundEncryption; + this.outboundHost = outboundHost; + this.outboundPort = outboundPort; + this.outboundEncryption = outboundEncryption; + this.inboundVerifyPeer = inboundVerifyPeer; + this.inboundVerifyHost = inboundVerifyHost; + this.outboundVerifyPeer = outboundVerifyPeer; + this.outboundVerifyHost = outboundVerifyHost; + } + + static fromJson(data: ServiceLocationSocketSplit): LocationSocketSplit { + return new LocationSocketSplit( + data.inboundHost, + data.inboundPort, + data.inboundEncryption, + data.outboundHost, + data.outboundPort, + data.outboundEncryption, + data.inboundVerifyPeer ?? true, + data.inboundVerifyHost ?? true, + data.outboundVerifyPeer ?? true, + data.outboundVerifyHost ?? true + ); + } + + toJson(): ServiceLocationSocketSplit { + return { + type: this.type, + inboundHost: this.inboundHost, + inboundPort: this.inboundPort, + inboundEncryption: this.inboundEncryption, + outboundHost: this.outboundHost, + outboundPort: this.outboundPort, + outboundEncryption: this.outboundEncryption, + ...(this.inboundVerifyPeer !== undefined && { inboundVerifyPeer: this.inboundVerifyPeer }), + ...(this.inboundVerifyHost !== undefined && { inboundVerifyHost: this.inboundVerifyHost }), + ...(this.outboundVerifyPeer !== undefined && { outboundVerifyPeer: this.outboundVerifyPeer }), + ...(this.outboundVerifyHost !== undefined && { outboundVerifyHost: this.outboundVerifyHost }) + }; + } +} + +/** + * File-based service location + * Used by: local file system providers + */ +export class LocationFile extends Location { + readonly type = 'FILE' as const; + path: string; + + constructor(path: string = '') { + super(); + this.path = path; + } + + static fromJson(data: ServiceLocationFile): LocationFile { + return new LocationFile(data.path); + } + + toJson(): ServiceLocationFile { + return { + type: this.type, + path: this.path + }; + } +} diff --git a/src/models/provider.ts b/src/models/provider.ts index 4afd4a4..683382a 100644 --- a/src/models/provider.ts +++ b/src/models/provider.ts @@ -1,7 +1,11 @@ /** * Class model for Provider Interface */ -import type { ProviderCapabilitiesInterface, ProviderInterface } from "@/types/provider"; + +import type { + ProviderInterface, + ProviderCapabilitiesInterface +} from "@/types/provider"; export class ProviderObject implements ProviderInterface { @@ -9,8 +13,8 @@ export class ProviderObject implements ProviderInterface { constructor() { this._data = { - '@type': 'files:provider', - id: '', + '@type': 'documents:provider', + identifier: '', label: '', capabilities: {}, }; @@ -25,21 +29,16 @@ export class ProviderObject implements ProviderInterface { return this._data; } - clone(): ProviderObject { - const cloned = new ProviderObject(); - cloned._data = JSON.parse(JSON.stringify(this._data)); - return cloned; + capable(capability: keyof ProviderCapabilitiesInterface): boolean { + const value = this._data.capabilities?.[capability]; + return value !== undefined && value !== false; } - capable(capability: keyof ProviderCapabilitiesInterface): boolean { - return !!(this._data.capabilities && this._data.capabilities[capability]); - } - - capability(capability: keyof ProviderCapabilitiesInterface): boolean | string[] | Record | Record | undefined { + capability(capability: keyof ProviderCapabilitiesInterface): any | null { if (this._data.capabilities) { return this._data.capabilities[capability]; } - return undefined; + return null; } /** Immutable Properties */ @@ -48,8 +47,8 @@ export class ProviderObject implements ProviderInterface { return this._data['@type']; } - get id(): string { - return this._data.id; + get identifier(): string { + return this._data.identifier; } get label(): string { diff --git a/src/models/service.ts b/src/models/service.ts index 7b9bf92..9d11194 100644 --- a/src/models/service.ts +++ b/src/models/service.ts @@ -1,7 +1,15 @@ /** * Class model for Service Interface */ -import type { ServiceInterface } from "@/types/service"; + +import type { + ServiceInterface, + ServiceCapabilitiesInterface, + ServiceIdentity, + ServiceLocation +} from "@/types/service"; +import { Identity } from './identity'; +import { Location } from './location'; export class ServiceObject implements ServiceInterface { @@ -9,11 +17,12 @@ export class ServiceObject implements ServiceInterface { constructor() { this._data = { - '@type': 'files:service', - id: '', + '@type': 'documents:service', provider: '', - label: '', - rootId: '', + identifier: null, + label: null, + enabled: false, + capabilities: {} }; } @@ -26,10 +35,16 @@ export class ServiceObject implements ServiceInterface { return this._data; } - clone(): ServiceObject { - const cloned = new ServiceObject(); - cloned._data = JSON.parse(JSON.stringify(this._data)); - return cloned; + capable(capability: keyof ServiceCapabilitiesInterface): boolean { + const value = this._data.capabilities?.[capability]; + return value !== undefined && value !== false; + } + + capability(capability: keyof ServiceCapabilitiesInterface): any | null { + if (this._data.capabilities) { + return this._data.capabilities[capability]; + } + return null; } /** Immutable Properties */ @@ -38,20 +53,76 @@ export class ServiceObject implements ServiceInterface { return this._data['@type']; } - get id(): string { - return this._data.id; - } - get provider(): string { return this._data.provider; } - get label(): string { + get identifier(): string | number | null { + return this._data.identifier; + } + + get capabilities(): ServiceCapabilitiesInterface | undefined { + return this._data.capabilities; + } + + /** Mutable Properties */ + + get label(): string | null { return this._data.label; } - get rootId(): string { - return this._data.rootId; + set label(value: string | null) { + this._data.label = value; + } + + get enabled(): boolean { + return this._data.enabled; + } + + set enabled(value: boolean) { + this._data.enabled = value; + } + + get location(): ServiceLocation | null { + return this._data.location ?? null; + } + + set location(value: ServiceLocation | null) { + this._data.location = value; + } + + get identity(): ServiceIdentity | null { + return this._data.identity ?? null; + } + + set identity(value: ServiceIdentity | null) { + this._data.identity = value; + } + + get auxiliary(): Record { + return this._data.auxiliary ?? {}; + } + + set auxiliary(value: Record) { + this._data.auxiliary = value; + } + + /** Helper Methods */ + + /** + * Get identity as a class instance for easier manipulation + */ + getIdentity(): Identity | null { + if (!this._data.identity) return null; + return Identity.fromJson(this._data.identity); + } + + /** + * Get location as a class instance for easier manipulation + */ + getLocation(): Location | null { + if (!this._data.location) return null; + return Location.fromJson(this._data.location); } } diff --git a/src/services/api.ts b/src/services/api.ts deleted file mode 100644 index 6fcaf19..0000000 --- a/src/services/api.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * File Manager API Service - * Central service for making API calls to the file manager backend - */ - -import { createFetchWrapper } from '@KTXC'; - -const fetchWrapper = createFetchWrapper(); - -const BASE_URL = '/m/file_manager/v1'; - -interface ApiRequest { - version: number; - transaction: string; - operation: string; - data?: Record; -} - -interface ApiSuccessResponse { - version: number; - transaction: string; - operation: string; - status: 'success'; - data: T; -} - -interface ApiErrorResponse { - version: number; - transaction: string; - operation: string; - status: 'error'; - error: { - code: number; - message: string; - }; -} - -type ApiResponseRaw = ApiSuccessResponse | ApiErrorResponse; - -/** - * Generate a unique transaction ID - */ -function generateTransactionId(): string { - return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; -} - -/** - * Execute an API operation - */ -async function execute(operation: string, data: Record = {}): Promise { - const request: ApiRequest = { - version: 1, - transaction: generateTransactionId(), - operation, - data, - }; - - const response: ApiResponseRaw = await fetchWrapper.post(BASE_URL, request); - - if (response.status === 'error') { - throw new Error(response.error.message); - } - - return response.data; -} - -export const fileManagerApi = { - execute, - generateTransactionId, -}; - -export default fileManagerApi; diff --git a/src/services/collectionService.ts b/src/services/collectionService.ts index 9ee4095..c677469 100644 --- a/src/services/collectionService.ts +++ b/src/services/collectionService.ts @@ -2,194 +2,154 @@ * Collection management service */ -import { fileManagerApi } from './api'; -import type { FilterCondition, SortCondition } from '@/types/common'; -import type { FileCollection } from '@/types/node'; +import { transceivePost } from './transceive'; +import type { + CollectionListRequest, + CollectionListResponse, + CollectionExtantRequest, + CollectionExtantResponse, + CollectionFetchRequest, + CollectionFetchResponse, + CollectionCreateRequest, + CollectionCreateResponse, + CollectionUpdateResponse, + CollectionUpdateRequest, + CollectionDeleteResponse, + CollectionDeleteRequest, + CollectionInterface, +} from '../types/collection'; +import { useIntegrationStore } from '@KTXC/stores/integrationStore'; +import { CollectionObject, CollectionPropertiesObject } from '../models/collection'; + +function isCollectionPayload(value: unknown): value is CollectionInterface { + if (!value || typeof value !== 'object') { + return false; + } + + const candidate = value as Record; + return ( + ('identifier' in candidate || 'provider' in candidate || 'service' in candidate) + && 'properties' in candidate + ); +} + +/** + * Helper to create the right collection model class based on provider identifier + * Uses provider-specific factory if available, otherwise returns base CollectionObject + */ +function createCollectionObject(data: CollectionInterface): CollectionObject { + const integrationStore = useIntegrationStore(); + const factoryItem = integrationStore.getItemById('documents_collection_factory', data.provider) as any; + const factory = factoryItem?.factory; + + // Use provider factory if available, otherwise base class + return factory ? factory(data) : new CollectionObject().fromJson(data); +} export const collectionService = { /** - * List collections within a location + * Retrieve list of collections, optionally filtered by source selector * - * @param provider - Provider identifier - * @param service - Service identifier - * @param location - Parent collection ID (null for root) - * @param filter - Optional filter conditions - * @param sort - Optional sort conditions - * @returns Promise with collection list + * @param request - list request parameters + * + * @returns Promise with collection object list grouped by provider, service, and collection identifier */ - async list( - provider: string, - service: string, - location?: string | null, - filter?: FilterCondition[] | null, - sort?: SortCondition[] | null - ): Promise { - return await fileManagerApi.execute('collection.list', { - provider, - service, - location: location ?? null, - filter: filter ?? null, - sort: sort ?? null, + async list(request: CollectionListRequest = {}): Promise>>> { + const response = await transceivePost('collection.list', request); + + // Convert nested response to CollectionObject instances + const providerList: Record>> = {}; + Object.entries(response).forEach(([providerId, providerServices]) => { + const serviceList: Record> = {}; + Object.entries(providerServices).forEach(([serviceId, serviceCollections]) => { + const collectionList: Record = {}; + Object.entries(serviceCollections as Record).forEach(([collectionId, collectionData]) => { + if (isCollectionPayload(collectionData)) { + collectionList[collectionId] = createCollectionObject(collectionData); + return; + } + + if (collectionData && typeof collectionData === 'object') { + Object.entries(collectionData as Record).forEach(([nestedCollectionId, nestedCollectionData]) => { + if (isCollectionPayload(nestedCollectionData)) { + collectionList[nestedCollectionId] = createCollectionObject(nestedCollectionData); + } + }); + } + }); + serviceList[serviceId] = collectionList; + }); + providerList[providerId] = serviceList; }); + + return providerList; }, /** - * Check if a collection exists + * Retrieve a specific collection by provider and identifier * - * @param provider - Provider identifier - * @param service - Service identifier - * @param identifier - Collection identifier - * @returns Promise with extant status + * @param request - fetch request parameters + * + * @returns Promise with collection object */ - async extant( - provider: string, - service: string, - identifier: string - ): Promise { - const result = await fileManagerApi.execute<{ extant: boolean }>('collection.extant', { - provider, - service, - identifier, - }); - return result.extant; + async fetch(request: CollectionFetchRequest): Promise { + const response = await transceivePost('collection.fetch', request); + return createCollectionObject(response); }, /** - * Fetch a specific collection + * Retrieve collection availability status for a given source selector * - * @param provider - Provider identifier - * @param service - Service identifier - * @param identifier - Collection identifier - * @returns Promise with collection details + * @param request - extant request parameters + * + * @returns Promise with collection availability status */ - async fetch( - provider: string, - service: string, - identifier: string - ): Promise { - return await fileManagerApi.execute('collection.fetch', { - provider, - service, - identifier, - }); + async extant(request: CollectionExtantRequest): Promise { + return await transceivePost('collection.extant', request); }, /** - * Create a new collection (folder) + * Create a new collection * - * @param provider - Provider identifier - * @param service - Service identifier - * @param location - Parent collection ID (null for root) - * @param data - Collection data (label, etc.) - * @param options - Additional options - * @returns Promise with created collection + * @param request - create request parameters + * + * @returns Promise with created collection object */ - async create( - provider: string, - service: string, - location: string | null, - data: Partial, - options?: Record - ): Promise { - return await fileManagerApi.execute('collection.create', { - provider, - service, - location, - data, - options: options ?? {}, - }); + async create(request: CollectionCreateRequest): Promise { + if (request.properties instanceof CollectionPropertiesObject) { + request.properties = request.properties.toJson(); + } + const response = await transceivePost('collection.create', request); + return createCollectionObject(response); }, /** - * Modify an existing collection + * Update an existing collection * - * @param provider - Provider identifier - * @param service - Service identifier - * @param identifier - Collection identifier - * @param data - Data to modify - * @returns Promise with modified collection + * @param request - update request parameters + * + * @returns Promise with updated collection object */ - async modify( - provider: string, - service: string, - identifier: string, - data: Partial - ): Promise { - return await fileManagerApi.execute('collection.modify', { - provider, - service, - identifier, - data, - }); + async update(request: CollectionUpdateRequest): Promise { + if (request.properties instanceof CollectionPropertiesObject) { + request.properties = request.properties.toJson(); + } + const response = await transceivePost('collection.update', request); + return createCollectionObject(response); }, /** * Delete a collection * - * @param provider - Provider identifier - * @param service - Service identifier - * @param identifier - Collection identifier - * @returns Promise with success status + * @param request - delete request parameters + * + * @returns Promise with deletion result */ - async destroy( - provider: string, - service: string, - identifier: string - ): Promise { - const result = await fileManagerApi.execute<{ success: boolean }>('collection.destroy', { - provider, - service, - identifier, - }); - return result.success; + async delete(request: CollectionDeleteRequest): Promise { + return await transceivePost('collection.delete', request); }, - /** - * Copy a collection to a new location - * - * @param provider - Provider identifier - * @param service - Service identifier - * @param identifier - Collection identifier to copy - * @param location - Destination parent collection ID (null for root) - * @returns Promise with copied collection - */ - async copy( - provider: string, - service: string, - identifier: string, - location?: string | null - ): Promise { - return await fileManagerApi.execute('collection.copy', { - provider, - service, - identifier, - location: location ?? null, - }); - }, - - /** - * Move a collection to a new location - * - * @param provider - Provider identifier - * @param service - Service identifier - * @param identifier - Collection identifier to move - * @param location - Destination parent collection ID (null for root) - * @returns Promise with moved collection - */ - async move( - provider: string, - service: string, - identifier: string, - location?: string | null - ): Promise { - return await fileManagerApi.execute('collection.move', { - provider, - service, - identifier, - location: location ?? null, - }); - }, }; export default collectionService; diff --git a/src/services/entityService.ts b/src/services/entityService.ts index c72cc82..c0711a9 100644 --- a/src/services/entityService.ts +++ b/src/services/entityService.ts @@ -1,292 +1,175 @@ /** - * Entity (file) management service + * Entity management service */ -import { fileManagerApi } from './api'; -import type { FilterCondition, SortCondition, RangeCondition } from '@/types/common'; -import type { FileEntity } from '@/types/node'; -import type { EntityDeltaResult } from '@/types/api'; +import { transceivePost } from './transceive'; +import type { + EntityListRequest, + EntityListResponse, + EntityFetchRequest, + EntityFetchResponse, + EntityExtantRequest, + EntityExtantResponse, + EntityCreateRequest, + EntityCreateResponse, + EntityUpdateRequest, + EntityUpdateResponse, + EntityDeleteRequest, + EntityDeleteResponse, + EntityDeltaRequest, + EntityDeltaResponse, + EntityReadRequest, + EntityReadResponse, + EntityWriteRequest, + EntityWriteResponse, + EntityInterface, +} from '../types/entity'; +import { useIntegrationStore } from '@KTXC/stores/integrationStore'; +import { EntityObject } from '../models'; + +/** + * Helper to create the right entity model class based on provider identifier + * Uses provider-specific factory if available, otherwise returns base EntityObject + */ +function createEntityObject(data: EntityInterface): EntityObject { + const integrationStore = useIntegrationStore(); + const factoryItem = integrationStore.getItemById('documents_entity_factory', data.provider) as any; + const factory = factoryItem?.factory; + + // Use provider factory if available, otherwise base class + return factory ? factory(data) : new EntityObject().fromJson(data); +} export const entityService = { /** - * List entities within a collection + * Retrieve list of entities, optionally filtered by source selector * - * @param provider - Provider identifier - * @param service - Service identifier - * @param collection - Collection identifier - * @param filter - Optional filter conditions - * @param sort - Optional sort conditions - * @param range - Optional range/pagination conditions - * @returns Promise with entity list + * @param request - list request parameters + * + * @returns Promise with entity object list grouped by provider, service, collection, and entity identifier */ - async list( - provider: string, - service: string, - collection: string, - filter?: FilterCondition[] | null, - sort?: SortCondition[] | null, - range?: RangeCondition | null - ): Promise { - return await fileManagerApi.execute('entity.list', { - provider, - service, - collection, - filter: filter ?? null, - sort: sort ?? null, - range: range ?? null, + async list(request: EntityListRequest = {}): Promise>>>> { + const response = await transceivePost('entity.list', request); + + // Convert nested response to EntityObject instances + const providerList: Record>>> = {}; + Object.entries(response).forEach(([providerId, providerServices]) => { + const serviceList: Record>> = {}; + Object.entries(providerServices).forEach(([serviceId, serviceCollections]) => { + const collectionList: Record> = {}; + Object.entries(serviceCollections).forEach(([collectionId, collectionEntities]) => { + const entityList: Record = {}; + Object.entries(collectionEntities).forEach(([entityId, entityData]) => { + entityList[entityId] = createEntityObject(entityData); + }); + collectionList[collectionId] = entityList; + }); + serviceList[serviceId] = collectionList; + }); + providerList[providerId] = serviceList; }); + + return providerList; }, /** - * Get delta changes for entities since a signature + * Retrieve a specific entity by provider and identifier * - * @param provider - Provider identifier - * @param service - Service identifier - * @param collection - Collection identifier - * @param signature - Previous sync signature - * @param detail - Detail level ('ids' or 'full') - * @returns Promise with delta changes + * @param request - fetch request parameters + * + * @returns Promise with entity objects keyed by identifier */ - async delta( - provider: string, - service: string, - collection: string, - signature: string, - detail: 'ids' | 'full' = 'ids' - ): Promise { - return await fileManagerApi.execute('entity.delta', { - provider, - service, - collection, - signature, - detail, + async fetch(request: EntityFetchRequest): Promise> { + const response = await transceivePost('entity.fetch', request); + + // Convert response to EntityObject instances + const list: Record = {}; + Object.entries(response).forEach(([identifier, entityData]) => { + list[identifier] = createEntityObject(entityData); }); + + return list; }, /** - * Check which entities exist + * Retrieve entity availability status for a given source selector * - * @param provider - Provider identifier - * @param service - Service identifier - * @param collection - Collection identifier - * @param identifiers - Entity identifiers to check - * @returns Promise with existence map + * @param request - extant request parameters + * + * @returns Promise with entity availability status */ - async extant( - provider: string, - service: string, - collection: string, - identifiers: string[] - ): Promise> { - return await fileManagerApi.execute>('entity.extant', { - provider, - service, - collection, - identifiers, - }); + async extant(request: EntityExtantRequest): Promise { + return await transceivePost('entity.extant', request); }, /** - * Fetch specific entities + * Create a new entity * - * @param provider - Provider identifier - * @param service - Service identifier - * @param collection - Collection identifier - * @param identifiers - Entity identifiers to fetch - * @returns Promise with entity list + * @param request - create request parameters + * + * @returns Promise with created entity object */ - async fetch( - provider: string, - service: string, - collection: string, - identifiers: string[] - ): Promise { - return await fileManagerApi.execute('entity.fetch', { - provider, - service, - collection, - identifiers, - }); + async create(request: EntityCreateRequest): Promise { + const response = await transceivePost('entity.create', request); + return createEntityObject(response); }, /** - * Read entity content + * Update an existing entity * - * @param provider - Provider identifier - * @param service - Service identifier - * @param collection - Collection identifier - * @param identifier - Entity identifier - * @returns Promise with base64 encoded content - */ - async read( - provider: string, - service: string, - collection: string, - identifier: string - ): Promise<{ content: string | null; encoding: 'base64' }> { - return await fileManagerApi.execute<{ content: string | null; encoding: 'base64' }>('entity.read', { - provider, - service, - collection, - identifier, - }); - }, - - /** - * Create a new entity (file) + * @param request - update request parameters * - * @param provider - Provider identifier - * @param service - Service identifier - * @param collection - Collection identifier (null for root) - * @param data - Entity data (label, mime, etc.) - * @param options - Additional options - * @returns Promise with created entity + * @returns Promise with updated entity object */ - async create( - provider: string, - service: string, - collection: string | null, - data: Partial, - options?: Record - ): Promise { - return await fileManagerApi.execute('entity.create', { - provider, - service, - collection, - data, - options: options ?? {}, - }); - }, - - /** - * Modify an existing entity - * - * @param provider - Provider identifier - * @param service - Service identifier - * @param collection - Collection identifier (can be null) - * @param identifier - Entity identifier - * @param data - Data to modify - * @returns Promise with modified entity - */ - async modify( - provider: string, - service: string, - collection: string | null, - identifier: string, - data: Partial - ): Promise { - return await fileManagerApi.execute('entity.modify', { - provider, - service, - collection, - identifier, - data, - }); + async update(request: EntityUpdateRequest): Promise { + const response = await transceivePost('entity.update', request); + return createEntityObject(response); }, /** * Delete an entity * - * @param provider - Provider identifier - * @param service - Service identifier - * @param collection - Collection identifier (can be null) - * @param identifier - Entity identifier - * @returns Promise with success status + * @param request - delete request parameters + * + * @returns Promise with deletion result */ - async destroy( - provider: string, - service: string, - collection: string | null, - identifier: string - ): Promise { - const result = await fileManagerApi.execute<{ success: boolean }>('entity.destroy', { - provider, - service, - collection, - identifier, - }); - return result.success; + async delete(request: EntityDeleteRequest): Promise { + return await transceivePost('entity.delete', request); }, /** - * Copy an entity to a new location + * Retrieve delta changes for entities * - * @param provider - Provider identifier - * @param service - Service identifier - * @param collection - Source collection identifier (can be null) - * @param identifier - Entity identifier to copy - * @param destination - Destination collection ID (null for root) - * @returns Promise with copied entity + * @param request - delta request parameters + * + * @returns Promise with delta changes (created, modified, deleted) */ - async copy( - provider: string, - service: string, - collection: string | null, - identifier: string, - destination?: string | null - ): Promise { - return await fileManagerApi.execute('entity.copy', { - provider, - service, - collection, - identifier, - destination: destination ?? null, - }); + async delta(request: EntityDeltaRequest): Promise { + return await transceivePost('entity.delta', request); }, /** - * Move an entity to a new location + * Read entity content * - * @param provider - Provider identifier - * @param service - Service identifier - * @param collection - Source collection identifier (can be null) - * @param identifier - Entity identifier to move - * @param destination - Destination collection ID (null for root) - * @returns Promise with moved entity + * @param request - read request parameters + * @returns Promise with base64 encoded content */ - async move( - provider: string, - service: string, - collection: string | null, - identifier: string, - destination?: string | null - ): Promise { - return await fileManagerApi.execute('entity.move', { - provider, - service, - collection, - identifier, - destination: destination ?? null, - }); + async read(request: EntityReadRequest): Promise { + return await transceivePost('entity.read', request); }, /** * Write content to an entity * - * @param provider - Provider identifier - * @param service - Service identifier - * @param collection - Collection identifier (can be null) - * @param identifier - Entity identifier - * @param content - Content to write (base64 encoded) - * @returns Promise with bytes written + * @param request - write request parameters + * @returns Promise with write result */ - async write( - provider: string, - service: string, - collection: string | null, - identifier: string, - content: string - ): Promise { - const result = await fileManagerApi.execute<{ bytesWritten: number }>('entity.write', { - provider, - service, - collection, - identifier, - content, - encoding: 'base64', + async write(request: EntityWriteRequest): Promise { + return await transceivePost('entity.write', { + ...request, + encoding: request.encoding ?? 'base64', }); - return result.bytesWritten; }, }; diff --git a/src/services/index.ts b/src/services/index.ts index ba39971..abf6c61 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -2,7 +2,6 @@ * Central export point for all File Manager services */ -export { fileManagerApi } from './api'; export { providerService } from './providerService'; export { serviceService } from './serviceService'; export { collectionService } from './collectionService'; diff --git a/src/services/nodeService.ts b/src/services/nodeService.ts index 247cd64..9a929f6 100644 --- a/src/services/nodeService.ts +++ b/src/services/nodeService.ts @@ -2,73 +2,67 @@ * Node (unified collection/entity) management service */ -import { fileManagerApi } from './api'; -import type { FilterCondition, SortCondition, RangeCondition } from '@/types/common'; -import type { FileNode } from '@/types/node'; -import type { NodeDeltaResult } from '@/types/api'; +import { transceivePost } from './transceive' +import type { ListFilter, ListSort, ListRange } from '../types/common' +import type { CollectionInterface } from '../types/collection' +import type { EntityInterface } from '../types/entity' + +export type NodeItem = CollectionInterface | EntityInterface + +export interface NodeListRequest { + provider: string + service: string | number + location?: string | number | null + recursive?: boolean + filter?: ListFilter | null + sort?: ListSort | null + range?: ListRange | null +} + +export type NodeListResponse = Record + +export interface NodeDeltaRequest { + provider: string + service: string | number + location?: string | number | null + signature: string + recursive?: boolean + detail?: 'ids' | 'full' +} + +export interface NodeDeltaResult { + added: Array + modified: Array + removed: Array + signature: string +} export const nodeService = { - /** - * List all nodes (collections and entities) within a location - * - * @param provider - Provider identifier - * @param service - Service identifier - * @param location - Parent collection ID (null for root) - * @param recursive - Whether to list recursively - * @param filter - Optional filter conditions - * @param sort - Optional sort conditions - * @param range - Optional range/pagination conditions - * @returns Promise with node list - */ - async list( - provider: string, - service: string, - location?: string | null, - recursive: boolean = false, - filter?: FilterCondition[] | null, - sort?: SortCondition[] | null, - range?: RangeCondition | null - ): Promise { - return await fileManagerApi.execute('node.list', { - provider, - service, - location: location ?? null, - recursive, - filter: filter ?? null, - sort: sort ?? null, - range: range ?? null, - }); + async list(request: NodeListRequest): Promise { + const response = await transceivePost('node.list', { + provider: request.provider, + service: request.service, + location: request.location ?? null, + recursive: request.recursive ?? false, + filter: request.filter ?? null, + sort: request.sort ?? null, + range: request.range ?? null, + }) + + return Object.values(response) }, - /** - * Get delta changes for nodes since a signature - * - * @param provider - Provider identifier - * @param service - Service identifier - * @param location - Parent collection ID (null for root) - * @param signature - Previous sync signature - * @param recursive - Whether to get delta recursively - * @param detail - Detail level ('ids' or 'full') - * @returns Promise with delta changes - */ - async delta( - provider: string, - service: string, - location: string | null, - signature: string, - recursive: boolean = false, - detail: 'ids' | 'full' = 'ids' - ): Promise { - return await fileManagerApi.execute('node.delta', { - provider, - service, - location, - signature, - recursive, - detail, - }); + async delta(request: NodeDeltaRequest): Promise { + return await transceivePost('node.delta', { + provider: request.provider, + service: request.service, + location: request.location ?? null, + signature: request.signature, + recursive: request.recursive ?? false, + detail: request.detail ?? 'ids', + }) }, -}; +} -export default nodeService; +export default nodeService diff --git a/src/services/providerService.ts b/src/services/providerService.ts index 371b0c8..f282e77 100644 --- a/src/services/providerService.ts +++ b/src/services/providerService.ts @@ -2,32 +2,74 @@ * Provider management service */ -import { fileManagerApi } from './api'; -import type { SourceSelector } from '@/types/common'; -import type { ProviderRecord } from '@/types/provider'; +import type { + ProviderListRequest, + ProviderListResponse, + ProviderExtantRequest, + ProviderExtantResponse, + ProviderFetchRequest, + ProviderFetchResponse, + ProviderInterface, +} from '../types/provider'; +import { useIntegrationStore } from '@KTXC/stores/integrationStore'; +import { transceivePost } from './transceive'; +import { ProviderObject } from '../models/provider'; + +/** + * Helper to create the right provider model class based on provider identifier + * Uses provider-specific factory if available, otherwise returns base ProviderObject + */ +function createProviderObject(data: ProviderInterface): ProviderObject { + const integrationStore = useIntegrationStore(); + const factoryItem = integrationStore.getItemById('documents_provider_factory', data.identifier) as any; + const factory = factoryItem?.factory; + + // Use provider factory if available, otherwise base class + return factory ? factory(data) : new ProviderObject().fromJson(data); +} export const providerService = { /** - * List all available providers + * Retrieve list of providers, optionally filtered by source selector * - * @param sources - Optional source selector to filter providers - * @returns Promise with provider list keyed by provider ID + * @param request - list request parameters + * + * @returns Promise with provider object list keyed by provider identifier */ - async list(sources?: SourceSelector): Promise { - return await fileManagerApi.execute('provider.list', { - sources: sources || null + async list(request: ProviderListRequest = {}): Promise> { + const response = await transceivePost('provider.list', request); + + // Convert response to ProviderObject instances + const list: Record = {}; + Object.entries(response).forEach(([providerId, providerData]) => { + list[providerId] = createProviderObject(providerData); }); + + return list; }, /** - * Check which providers exist/are available + * Retrieve specific provider by identifier + * + * @param request - fetch request parameters + * + * @returns Promise with provider object + */ + async fetch(request: ProviderFetchRequest): Promise { + const response = await transceivePost('provider.fetch', request); + return createProviderObject(response); + }, + + /** + * Retrieve provider availability status for a given source selector + * + * @param request - extant request parameters * - * @param sources - Source selector with provider IDs to check * @returns Promise with provider availability status */ - async extant(sources: SourceSelector): Promise> { - return await fileManagerApi.execute>('provider.extant', { sources }); + async extant(request: ProviderExtantRequest): Promise { + return await transceivePost('provider.extant', request); }, }; diff --git a/src/services/serviceService.ts b/src/services/serviceService.ts index 7b3ca87..c5b2131 100644 --- a/src/services/serviceService.ts +++ b/src/services/serviceService.ts @@ -2,46 +2,161 @@ * Service management service */ -import { fileManagerApi } from './api'; -import type { SourceSelector } from '@/types/common'; -import type { ServiceInterface, ServiceRecord } from '@/types/service'; +import type { + ServiceListRequest, + ServiceListResponse, + ServiceFetchRequest, + ServiceFetchResponse, + ServiceExtantRequest, + ServiceExtantResponse, + ServiceCreateResponse, + ServiceCreateRequest, + ServiceUpdateResponse, + ServiceUpdateRequest, + ServiceDeleteResponse, + ServiceDeleteRequest, + ServiceDiscoverRequest, + ServiceDiscoverResponse, + ServiceTestRequest, + ServiceTestResponse, + ServiceInterface, +} from '../types/service'; +import { useIntegrationStore } from '@KTXC/stores/integrationStore'; +import { transceivePost } from './transceive'; +import { ServiceObject } from '../models/service'; + +/** + * Helper to create the right service model class based on provider identifier + * Uses provider-specific factory if available, otherwise returns base ServiceObject + */ +function createServiceObject(data: ServiceInterface): ServiceObject { + const integrationStore = useIntegrationStore(); + const factoryItem = integrationStore.getItemById('documents_service_factory', data.provider) as any; + const factory = factoryItem?.factory; + + // Use provider factory if available, otherwise base class + return factory ? factory(data) : new ServiceObject().fromJson(data); +} export const serviceService = { /** - * List all available services + * Retrieve list of services, optionally filtered by source selector * - * @param sources - Optional source selector to filter services - * @returns Promise with service list grouped by provider + * @param request - list request parameters + * + * @returns Promise with service object list grouped by provider and keyed by service identifier */ - async list(sources?: SourceSelector): Promise { - return await fileManagerApi.execute('service.list', { - sources: sources || null + async list(request: ServiceListRequest = {}): Promise>> { + const response = await transceivePost('service.list', request); + + // Convert nested response to ServiceObject instances + const providerList: Record> = {}; + Object.entries(response).forEach(([providerId, providerServices]) => { + const serviceList: Record = {}; + Object.entries(providerServices).forEach(([serviceId, serviceData]) => { + serviceList[serviceId] = createServiceObject(serviceData); + }); + providerList[providerId] = serviceList; }); + + return providerList; }, /** - * Check which services exist/are available + * Retrieve a specific service by provider and identifier + * + * @param request - fetch request parameters + * + * @returns Promise with service object + */ + async fetch(request: ServiceFetchRequest): Promise { + const response = await transceivePost('service.fetch', request); + return createServiceObject(response); + }, + + /** + * Retrieve service availability status for a given source selector + * + * @param request - extant request parameters * - * @param sources - Source selector with service IDs to check * @returns Promise with service availability status */ - async extant(sources: SourceSelector): Promise> { - return await fileManagerApi.execute>('service.extant', { sources }); + async extant(request: ServiceExtantRequest): Promise { + return await transceivePost('service.extant', request); }, /** - * Fetch a specific service + * Retrieve discoverable services for a given source selector, sorted by provider * - * @param provider - Provider identifier - * @param identifier - Service identifier - * @returns Promise with service details + * @param request - discover request parameters + * + * @returns Promise with array of discovered services sorted by provider */ - async fetch(provider: string, identifier: string): Promise { - return await fileManagerApi.execute('service.fetch', { - provider, - identifier + async discover(request: ServiceDiscoverRequest): Promise { + const response = await transceivePost('service.discover', request); + + // Convert discovery results to ServiceObjects + const services: ServiceObject[] = []; + Object.entries(response).forEach(([providerId, location]) => { + const serviceData: ServiceInterface = { + '@type': 'documents:service', + provider: providerId, + identifier: null, + label: null, + enabled: false, + location: location, + }; + services.push(createServiceObject(serviceData)); }); + + // Sort by provider + return services.sort((a, b) => a.provider.localeCompare(b.provider)); + }, + + /** + * Test service connectivity and configuration + * + * @param request - Service test request + * @returns Promise with test results + */ + async test(request: ServiceTestRequest): Promise { + return await transceivePost('service.test', request); + }, + + /** + * Create a new service + * + * @param request - create request parameters + * + * @returns Promise with created service object + */ + async create(request: ServiceCreateRequest): Promise { + const response = await transceivePost('service.create', request); + return createServiceObject(response); + }, + + /** + * Update a existing service + * + * @param request - update request parameters + * + * @returns Promise with updated service object + */ + async update(request: ServiceUpdateRequest): Promise { + const response = await transceivePost('service.update', request); + return createServiceObject(response); + }, + + /** + * Delete a service + * + * @param request - delete request parameters + * + * @returns Promise with deletion result + */ + async delete(request: { provider: string; identifier: string | number }): Promise { + return await transceivePost('service.delete', request); }, }; diff --git a/src/services/transceive.ts b/src/services/transceive.ts new file mode 100644 index 0000000..2f9f47d --- /dev/null +++ b/src/services/transceive.ts @@ -0,0 +1,50 @@ +/** + * API Client for Documents Manager + * Provides a centralized way to make API calls with envelope wrapping/unwrapping + */ + +import { createFetchWrapper } from '@KTXC'; +import type { ApiRequest, ApiResponse } from '../types/common'; + +const fetchWrapper = createFetchWrapper(); +const API_URL = '/m/documents_manager/v1'; +const API_VERSION = 1; + +/** + * Generate a unique transaction ID + */ +export function generateTransactionId(): string { + return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; +} + +/** + * Make an API call with automatic envelope wrapping and unwrapping + * + * @param operation - Operation name (e.g., 'provider.list', 'service.autodiscover') + * @param data - Operation-specific request data + * @param user - Optional user identifier override + * @returns Promise with unwrapped response data + * @throws Error if the API returns an error status + */ +export async function transceivePost( + operation: string, + data: TRequest, + user?: string +): Promise { + const request: ApiRequest = { + version: API_VERSION, + transaction: generateTransactionId(), + operation, + data, + user + }; + + const response: ApiResponse = await fetchWrapper.post(API_URL, request); + + if (response.status === 'error') { + const errorMessage = `[${operation}] ${response.data.message}${response.data.code ? ` (code: ${response.data.code})` : ''}`; + throw new Error(errorMessage); + } + + return response.data; +} diff --git a/src/stores/collectionsStore.ts b/src/stores/collectionsStore.ts new file mode 100644 index 0000000..699c9a2 --- /dev/null +++ b/src/stores/collectionsStore.ts @@ -0,0 +1,212 @@ +/** + * Collections Store + */ + +import { ref, computed, readonly } from 'vue' +import { defineStore } from 'pinia' +import { collectionService } from '../services/collectionService' +import type { + SourceSelector, + ListFilter, + ListSort, + CollectionMutableProperties, + CollectionDeleteResponse, +} from '../types' +import { CollectionObject } from '../models/collection' + +export const useCollectionsStore = defineStore('documentsCollectionsStore', () => { + // State + const _collections = ref>({}) + const transceiving = ref(false) + + // Getters + const count = computed(() => Object.keys(_collections.value).length) + const has = computed(() => count.value > 0) + const collections = computed(() => Object.values(_collections.value)) + + const collectionsByService = computed(() => { + const groups: Record = {} + + Object.values(_collections.value).forEach((collection) => { + const serviceKey = `${collection.provider}:${collection.service}` + const serviceCollections = (groups[serviceKey] ??= []) + serviceCollections.push(collection) + }) + + return groups + }) + + function identifierKey(provider: string, service: string | number | null, identifier: string | number | null): string { + return `${provider}:${service ?? ''}:${identifier ?? ''}` + } + + function collection(provider: string, service: string | number, identifier: string | number, retrieve: boolean = false): CollectionObject | null { + const key = identifierKey(provider, service, identifier) + if (retrieve === true && !_collections.value[key]) { + console.debug(`[Documents Manager][Store] - Force fetching collection "${key}"`) + fetch(provider, service, identifier) + } + + return _collections.value[key] || null + } + + function collectionsForService(provider: string, service: string | number): CollectionObject[] { + const serviceKeyPrefix = `${provider}:${service}:` + return Object.entries(_collections.value) + .filter(([key]) => key.startsWith(serviceKeyPrefix)) + .map(([_, collectionObj]) => collectionObj) + } + + function clearService(provider: string, service: string | number): void { + const serviceKeyPrefix = `${provider}:${service}:` + Object.keys(_collections.value) + .filter((key) => key.startsWith(serviceKeyPrefix)) + .forEach((key) => { + delete _collections.value[key] + }) + } + + function clearAll(): void { + _collections.value = {} + } + + // Actions + async function list(sources?: SourceSelector, filter?: ListFilter, sort?: ListSort): Promise> { + transceiving.value = true + try { + const response = await collectionService.list({ sources, filter, sort }) + + const hydrated: Record = {} + Object.entries(response).forEach(([providerId, providerServices]) => { + Object.entries(providerServices).forEach(([serviceId, serviceCollections]) => { + Object.entries(serviceCollections).forEach(([collectionId, collectionObj]) => { + const key = identifierKey(providerId, serviceId, collectionId) + hydrated[key] = collectionObj + }) + }) + }) + + _collections.value = { ..._collections.value, ...hydrated } + + console.debug('[Documents Manager][Store] - Successfully retrieved', Object.keys(hydrated).length, 'collections') + return hydrated + } catch (error: any) { + console.error('[Documents Manager][Store] - Failed to retrieve collections:', error) + throw error + } finally { + transceiving.value = false + } + } + + async function fetch(provider: string, service: string | number, identifier: string | number): Promise { + transceiving.value = true + try { + const response = await collectionService.fetch({ provider, service, collection: identifier }) + const key = identifierKey(response.provider, response.service, response.identifier) + _collections.value[key] = response + + console.debug('[Documents Manager][Store] - Successfully fetched collection:', key) + return response + } catch (error: any) { + console.error('[Documents Manager][Store] - Failed to fetch collection:', error) + throw error + } finally { + transceiving.value = false + } + } + + async function extant(sources: SourceSelector) { + transceiving.value = true + try { + const response = await collectionService.extant({ sources }) + console.debug('[Documents Manager][Store] - Successfully checked collection availability') + return response + } catch (error: any) { + console.error('[Documents Manager][Store] - Failed to check collection availability:', error) + throw error + } finally { + transceiving.value = false + } + } + + async function create( + provider: string, + service: string | number, + collection: string | number | null, + properties: CollectionMutableProperties, + ): Promise { + transceiving.value = true + try { + const response = await collectionService.create({ provider, service, collection, properties }) + const key = identifierKey(response.provider, response.service, response.identifier) + _collections.value[key] = response + + console.debug('[Documents Manager][Store] - Successfully created collection:', key) + return response + } catch (error: any) { + console.error('[Documents Manager][Store] - Failed to create collection:', error) + throw error + } finally { + transceiving.value = false + } + } + + async function update( + provider: string, + service: string | number, + identifier: string | number, + properties: CollectionMutableProperties, + ): Promise { + transceiving.value = true + try { + const response = await collectionService.update({ provider, service, identifier, properties }) + const key = identifierKey(response.provider, response.service, response.identifier) + _collections.value[key] = response + + console.debug('[Documents Manager][Store] - Successfully updated collection:', key) + return response + } catch (error: any) { + console.error('[Documents Manager][Store] - Failed to update collection:', error) + throw error + } finally { + transceiving.value = false + } + } + + async function remove(provider: string, service: string | number, identifier: string | number): Promise { + transceiving.value = true + try { + const response = await collectionService.delete({ provider, service, identifier }) + if (response.success) { + const key = identifierKey(provider, service, identifier) + delete _collections.value[key] + } + + console.debug('[Documents Manager][Store] - Successfully deleted collection:', `${provider}:${service}:${identifier}`) + return response + } catch (error: any) { + console.error('[Documents Manager][Store] - Failed to delete collection:', error) + throw error + } finally { + transceiving.value = false + } + } + + return { + transceiving: readonly(transceiving), + count, + has, + collections, + collectionsByService, + collection, + collectionsForService, + clearService, + clearAll, + list, + fetch, + extant, + create, + update, + delete: remove, + } +}) diff --git a/src/stores/entitiesStore.ts b/src/stores/entitiesStore.ts new file mode 100644 index 0000000..967607e --- /dev/null +++ b/src/stores/entitiesStore.ts @@ -0,0 +1,334 @@ +/** + * Entities Store + */ + +import { ref, computed, readonly } from 'vue' +import { defineStore } from 'pinia' +import { entityService } from '../services/entityService' +import { EntityObject } from '../models' +import type { + SourceSelector, + ListFilter, + ListSort, + ListRange, + DocumentInterface, + EntityDeleteResponse, + EntityDeltaResponse, +} from '../types' + +export const useEntitiesStore = defineStore('documentsEntitiesStore', () => { + // State + const _entities = ref>({}) + const transceiving = ref(false) + + // Getters + const count = computed(() => Object.keys(_entities.value).length) + const has = computed(() => count.value > 0) + const entities = computed(() => Object.values(_entities.value)) + + const entitiesByService = computed(() => { + const groups: Record = {} + + Object.values(_entities.value).forEach((entity) => { + const serviceKey = `${entity.provider}:${entity.service}` + const serviceEntities = (groups[serviceKey] ??= []) + serviceEntities.push(entity) + }) + + return groups + }) + + function identifierKey( + provider: string, + service: string | number, + collection: string | number, + identifier: string | number, + ): string { + return `${provider}:${service}:${collection}:${identifier}` + } + + function entity( + provider: string, + service: string | number, + collection: string | number, + identifier: string | number, + retrieve: boolean = false, + ): EntityObject | null { + const key = identifierKey(provider, service, collection, identifier) + if (retrieve === true && !_entities.value[key]) { + console.debug(`[Documents Manager][Store] - Force fetching entity "${key}"`) + fetch(provider, service, collection, [identifier]) + } + + return _entities.value[key] || null + } + + function entitiesForService(provider: string, service: string | number): EntityObject[] { + const serviceKeyPrefix = `${provider}:${service}:` + return Object.entries(_entities.value) + .filter(([key]) => key.startsWith(serviceKeyPrefix)) + .map(([_, entityObj]) => entityObj) + } + + function entitiesForCollection(provider: string, service: string | number, collection: string | number): EntityObject[] { + const collectionKeyPrefix = `${provider}:${service}:${collection}:` + return Object.entries(_entities.value) + .filter(([key]) => key.startsWith(collectionKeyPrefix)) + .map(([_, entityObj]) => entityObj) + } + + function clearService(provider: string, service: string | number): void { + const serviceKeyPrefix = `${provider}:${service}:` + Object.keys(_entities.value) + .filter((key) => key.startsWith(serviceKeyPrefix)) + .forEach((key) => { + delete _entities.value[key] + }) + } + + function clearCollection(provider: string, service: string | number, collection: string | number): void { + const collectionKeyPrefix = `${provider}:${service}:${collection}:` + Object.keys(_entities.value) + .filter((key) => key.startsWith(collectionKeyPrefix)) + .forEach((key) => { + delete _entities.value[key] + }) + } + + function clearAll(): void { + _entities.value = {} + } + + // Actions + async function list(sources?: SourceSelector, filter?: ListFilter, sort?: ListSort, range?: ListRange): Promise> { + transceiving.value = true + try { + const response = await entityService.list({ sources, filter, sort, range }) + + const hydrated: Record = {} + Object.entries(response).forEach(([providerId, providerServices]) => { + Object.entries(providerServices).forEach(([serviceId, serviceCollections]) => { + Object.entries(serviceCollections).forEach(([collectionId, collectionEntities]) => { + Object.entries(collectionEntities).forEach(([entityId, entityObj]) => { + const key = identifierKey(providerId, serviceId, collectionId, entityId) + hydrated[key] = entityObj + }) + }) + }) + }) + + _entities.value = { ..._entities.value, ...hydrated } + + console.debug('[Documents Manager][Store] - Successfully retrieved', Object.keys(hydrated).length, 'entities') + return hydrated + } catch (error: any) { + console.error('[Documents Manager][Store] - Failed to retrieve entities:', error) + throw error + } finally { + transceiving.value = false + } + } + + async function fetch( + provider: string, + service: string | number, + collection: string | number, + identifiers: (string | number)[], + ): Promise> { + transceiving.value = true + try { + const response = await entityService.fetch({ provider, service, collection, identifiers }) + + const hydrated: Record = {} + Object.entries(response).forEach(([identifier, entityObj]) => { + const key = identifierKey(provider, service, collection, identifier) + hydrated[key] = entityObj + _entities.value[key] = entityObj + }) + + console.debug('[Documents Manager][Store] - Successfully fetched', Object.keys(hydrated).length, 'entities') + return hydrated + } catch (error: any) { + console.error('[Documents Manager][Store] - Failed to fetch entities:', error) + throw error + } finally { + transceiving.value = false + } + } + + async function extant(sources: SourceSelector) { + transceiving.value = true + try { + const response = await entityService.extant({ sources }) + console.debug('[Documents Manager][Store] - Successfully checked entity availability') + return response + } catch (error: any) { + console.error('[Documents Manager][Store] - Failed to check entity availability:', error) + throw error + } finally { + transceiving.value = false + } + } + + async function create( + provider: string, + service: string | number, + collection: string | number, + properties: DocumentInterface, + options?: Record, + ): Promise { + transceiving.value = true + try { + const response = await entityService.create({ provider, service, collection, properties, options }) + const key = identifierKey(response.provider, response.service, response.collection, response.identifier) + _entities.value[key] = response + + console.debug('[Documents Manager][Store] - Successfully created entity:', key) + return response + } catch (error: any) { + console.error('[Documents Manager][Store] - Failed to create entity:', error) + throw error + } finally { + transceiving.value = false + } + } + + async function update( + provider: string, + service: string | number, + collection: string | number, + identifier: string | number, + properties: DocumentInterface, + ): Promise { + transceiving.value = true + try { + const response = await entityService.update({ provider, service, collection, identifier, properties }) + const key = identifierKey(response.provider, response.service, response.collection, response.identifier) + _entities.value[key] = response + + console.debug('[Documents Manager][Store] - Successfully updated entity:', key) + return response + } catch (error: any) { + console.error('[Documents Manager][Store] - Failed to update entity:', error) + throw error + } finally { + transceiving.value = false + } + } + + async function remove( + provider: string, + service: string | number, + collection: string | number, + identifier: string | number, + ): Promise { + transceiving.value = true + try { + const response = await entityService.delete({ provider, service, collection, identifier }) + if (response.success) { + const key = identifierKey(provider, service, collection, identifier) + delete _entities.value[key] + } + + console.debug('[Documents Manager][Store] - Successfully deleted entity:', `${provider}:${service}:${collection}:${identifier}`) + return response + } catch (error: any) { + console.error('[Documents Manager][Store] - Failed to delete entity:', error) + throw error + } finally { + transceiving.value = false + } + } + + async function delta(sources: SourceSelector): Promise { + transceiving.value = true + try { + const response = await entityService.delta({ sources }) + + Object.entries(response).forEach(([provider, providerData]) => { + if (providerData === false) return + + Object.entries(providerData).forEach(([service, serviceData]) => { + if (serviceData === false) return + + Object.entries(serviceData).forEach(([collection, collectionData]) => { + if (collectionData === false) return + + collectionData.deletions.forEach((identifier) => { + const key = identifierKey(provider, service, collection, identifier) + delete _entities.value[key] + }) + }) + }) + }) + + console.debug('[Documents Manager][Store] - Successfully processed entity delta') + return response + } catch (error: any) { + console.error('[Documents Manager][Store] - Failed to process entity delta:', error) + throw error + } finally { + transceiving.value = false + } + } + + async function read( + provider: string, + service: string | number, + collection: string | number, + identifier: string | number, + ): Promise { + transceiving.value = true + try { + const response = await entityService.read({ provider, service, collection, identifier }) + return response.content + } catch (error: any) { + console.error('[Documents Manager][Store] - Failed to read entity:', error) + throw error + } finally { + transceiving.value = false + } + } + + async function write( + provider: string, + service: string | number, + collection: string | number, + identifier: string | number, + content: string, + ): Promise { + transceiving.value = true + try { + const response = await entityService.write({ provider, service, collection, identifier, content, encoding: 'base64' }) + return response.bytesWritten + } catch (error: any) { + console.error('[Documents Manager][Store] - Failed to write entity:', error) + throw error + } finally { + transceiving.value = false + } + } + + return { + transceiving: readonly(transceiving), + count, + has, + entities, + entitiesByService, + entity, + entitiesForService, + entitiesForCollection, + clearService, + clearCollection, + clearAll, + list, + fetch, + extant, + create, + update, + delete: remove, + delta, + read, + write, + } +}) diff --git a/src/stores/index.ts b/src/stores/index.ts index 373c9ad..0781340 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -1,3 +1,5 @@ export { useProvidersStore } from './providersStore'; export { useServicesStore } from './servicesStore'; -export { useNodesStore, ROOT_ID } from './nodesStore'; \ No newline at end of file +export { useCollectionsStore } from './collectionsStore'; +export { useEntitiesStore } from './entitiesStore'; +export { useNodesStore, ROOT_ID } from './nodesStore.ts'; \ No newline at end of file diff --git a/src/stores/nodesStore.ts b/src/stores/nodesStore.ts index aa13297..b495e4e 100644 --- a/src/stores/nodesStore.ts +++ b/src/stores/nodesStore.ts @@ -1,646 +1,322 @@ -import { defineStore } from 'pinia' -import { ref, computed } from 'vue' -import type { Ref, ComputedRef } from 'vue' -import type { FileNode, FileCollection, FileEntity } from '../types/node' -import type { FilterCondition, SortCondition, RangeCondition } from '../types/common' -import { isFileCollection } from '../types/node' -import { collectionService } from '../services/collectionService' -import { entityService } from '../services/entityService' -import { nodeService } from '../services/nodeService' -import { FileCollectionObject } from '../models/collection' -import { FileEntityObject } from '../models/entity' +/** + * Nodes Store (thin wrapper over collections/entities stores) + */ + +import { computed, ref, readonly } from 'vue' +import { defineStore } from 'pinia' +import type { + SourceSelector, + ListFilter, + ListSort, + ListRange, + CollectionMutableProperties, + DocumentInterface, +} from '../types' +import { CollectionObject, EntityObject } from '../models' +import { useCollectionsStore } from './collectionsStore' +import { useEntitiesStore } from './entitiesStore' -// Root collection constant export const ROOT_ID = '00000000-0000-0000-0000-000000000000' -// Store structure: provider -> service -> nodeId -> node (either collection or entity object) -type NodeRecord = FileCollectionObject | FileEntityObject -type ServiceNodeStore = Record -type ProviderNodeStore = Record -type NodeStore = Record +type NodeRecord = CollectionObject | EntityObject -export const useNodesStore = defineStore('fileNodes', () => { - const nodes: Ref = ref({}) - const syncTokens: Ref>> = ref({}) // provider -> service -> token - const loading = ref(false) - const error: Ref = ref(null) +export const useNodesStore = defineStore('documentsNodesStore', () => { + const collectionsStore = useCollectionsStore() + const entitiesStore = useEntitiesStore() - // Computed: flat list of all nodes - const nodeList: ComputedRef = computed(() => { - const result: NodeRecord[] = [] - Object.values(nodes.value).forEach(providerNodes => { - Object.values(providerNodes).forEach(serviceNodes => { - result.push(...Object.values(serviceNodes)) + const error = ref(null) + + const transceiving = computed(() => collectionsStore.transceiving || entitiesStore.transceiving) + + const nodeList = computed(() => { + return [...collectionsStore.collections, ...entitiesStore.entities] + }) + + const collectionList = computed(() => collectionsStore.collections) + const entityList = computed(() => entitiesStore.entities) + + function toId(value: string | number | null | undefined): string | null { + if (value === null || value === undefined || value === '') { + return null + } + + return String(value) + } + + function getServiceNodes(providerId: string, serviceId: string | number): NodeRecord[] { + return [ + ...collectionsStore.collectionsForService(providerId, serviceId), + ...entitiesStore.entitiesForService(providerId, serviceId), + ] + } + + function getNode(providerId: string, serviceId: string | number, nodeId: string | number): NodeRecord | undefined { + const targetId = String(nodeId) + return getServiceNodes(providerId, serviceId) + .find((node) => String(node.identifier) === targetId) + } + + function isRoot(nodeId: string | number | null | undefined): boolean { + const normalized = toId(nodeId) + return normalized === null || normalized === ROOT_ID + } + + function getChildren(providerId: string, serviceId: string | number, parentId: string | number | null): NodeRecord[] { + const serviceNodes = getServiceNodes(providerId, serviceId) + + if (isRoot(parentId)) { + return serviceNodes.filter((node) => { + const parent = toId(node.collection) + return parent === null || parent === ROOT_ID }) - }) - return result - }) + } - // Computed: all collections (folders) - const collectionList: ComputedRef = computed(() => { - return nodeList.value.filter( - (node): node is FileCollectionObject => node['@type'] === 'files.collection' - ) - }) - - // Computed: all entities (files) - const entityList: ComputedRef = computed(() => { - return nodeList.value.filter( - (node): node is FileEntityObject => node['@type'] === 'files.entity' - ) - }) - - // Get a specific node - const getNode = ( - providerId: string, - serviceId: string, - nodeId: string - ): NodeRecord | undefined => { - return nodes.value[providerId]?.[serviceId]?.[nodeId] + const targetParent = String(parentId) + return serviceNodes.filter((node) => toId(node.collection) === targetParent) } - // Get all nodes for a service - const getServiceNodes = ( - providerId: string, - serviceId: string - ): NodeRecord[] => { - return Object.values(nodes.value[providerId]?.[serviceId] || {}) + function getChildCollections(providerId: string, serviceId: string | number, parentId: string | number | null): CollectionObject[] { + return getChildren(providerId, serviceId, parentId) + .filter((node): node is CollectionObject => node instanceof CollectionObject) } - // Get children of a parent node (or root nodes if parentId is null/ROOT_ID) - const getChildren = ( - providerId: string, - serviceId: string, - parentId: string | null - ): NodeRecord[] => { - const serviceNodes = nodes.value[providerId]?.[serviceId] || {} - const targetParent = parentId === ROOT_ID ? ROOT_ID : parentId - return Object.values(serviceNodes).filter(node => node.in === targetParent) + function getChildEntities(providerId: string, serviceId: string | number, parentId: string | number | null): EntityObject[] { + return getChildren(providerId, serviceId, parentId) + .filter((node): node is EntityObject => node instanceof EntityObject) } - // Get child collections (folders) - const getChildCollections = ( - providerId: string, - serviceId: string, - parentId: string | null - ): FileCollectionObject[] => { - return getChildren(providerId, serviceId, parentId).filter( - (node): node is FileCollectionObject => node['@type'] === 'files.collection' - ) - } - - // Get child entities (files) - const getChildEntities = ( - providerId: string, - serviceId: string, - parentId: string | null - ): FileEntityObject[] => { - return getChildren(providerId, serviceId, parentId).filter( - (node): node is FileEntityObject => node['@type'] === 'files.entity' - ) - } - - // Get path to root (ancestors) - const getPath = ( - providerId: string, - serviceId: string, - nodeId: string - ): NodeRecord[] => { + function getPath(providerId: string, serviceId: string | number, nodeId: string | number): NodeRecord[] { const path: NodeRecord[] = [] - let currentNode = getNode(providerId, serviceId, nodeId) - - while (currentNode) { - path.unshift(currentNode) - if (currentNode.in === null || currentNode.in === ROOT_ID || currentNode.id === ROOT_ID) { + let current = getNode(providerId, serviceId, nodeId) + + while (current) { + path.unshift(current) + const parentId = toId(current.collection) + + if (parentId === null || parentId === ROOT_ID) { break } - currentNode = getNode(providerId, serviceId, currentNode.in) + + current = getNode(providerId, serviceId, parentId) } - + return path } - // Check if a node is the root - const isRoot = (nodeId: string): boolean => { - return nodeId === ROOT_ID - } - - // Helper to hydrate a node based on its type - const hydrateNode = (data: FileNode): NodeRecord => { - if (isFileCollection(data)) { - return new FileCollectionObject().fromJson(data) - } else { - return new FileEntityObject().fromJson(data as FileEntity) - } - } - - // Set all nodes for a provider/service - const setNodes = ( + async function fetchCollections( providerId: string, - serviceId: string, - data: FileNode[] - ) => { - if (!nodes.value[providerId]) { - nodes.value[providerId] = {} - } - const hydrated: ServiceNodeStore = {} - for (const nodeData of data) { - hydrated[nodeData.id] = hydrateNode(nodeData) - } - nodes.value[providerId][serviceId] = hydrated - } - - // Add/update a single node - const addNode = ( - providerId: string, - serviceId: string, - node: FileNode - ) => { - if (!nodes.value[providerId]) { - nodes.value[providerId] = {} - } - if (!nodes.value[providerId][serviceId]) { - nodes.value[providerId][serviceId] = {} - } - nodes.value[providerId][serviceId][node.id] = hydrateNode(node) - } - - // Add multiple nodes (handles both array and object formats from API) - const addNodes = ( - providerId: string, - serviceId: string, - data: FileNode[] | Record - ) => { - if (!nodes.value[providerId]) { - nodes.value[providerId] = {} - } - if (!nodes.value[providerId][serviceId]) { - nodes.value[providerId][serviceId] = {} - } - // Handle both array and object (keyed by ID) formats - const nodeArray = Array.isArray(data) ? data : Object.values(data) - for (const nodeData of nodeArray) { - nodes.value[providerId][serviceId][nodeData.id] = hydrateNode(nodeData) - } - } - - // Remove a node - const removeNode = ( - providerId: string, - serviceId: string, - nodeId: string - ) => { - if (nodes.value[providerId]?.[serviceId]) { - delete nodes.value[providerId][serviceId][nodeId] - } - } - - // Remove multiple nodes - const removeNodes = ( - providerId: string, - serviceId: string, - nodeIds: string[] - ) => { - if (nodes.value[providerId]?.[serviceId]) { - for (const id of nodeIds) { - delete nodes.value[providerId][serviceId][id] + serviceId: string | number, + collectionId: string | number | null, + filter?: ListFilter, + sort?: ListSort, + ): Promise { + error.value = null + try { + const sources: SourceSelector = { + [providerId]: { + [String(serviceId)]: collectionId === null + ? true + : { [String(collectionId)]: true }, + }, } - } - } - // Clear all nodes for a service - const clearServiceNodes = ( - providerId: string, - serviceId: string - ) => { - if (nodes.value[providerId]) { - delete nodes.value[providerId][serviceId] - } - } - - // Clear all nodes - const clearNodes = () => { - nodes.value = {} - } - - // Sync token management - const getSyncToken = ( - providerId: string, - serviceId: string - ): string | undefined => { - return syncTokens.value[providerId]?.[serviceId] - } - - const setSyncToken = ( - providerId: string, - serviceId: string, - token: string - ) => { - if (!syncTokens.value[providerId]) { - syncTokens.value[providerId] = {} - } - syncTokens.value[providerId][serviceId] = token - } - - // ==================== API Actions ==================== - - // Fetch nodes (collections and entities) for a location - const fetchNodes = async ( - providerId: string, - serviceId: string, - location?: string | null, - recursive: boolean = false, - filter?: FilterCondition[] | null, - sort?: SortCondition[] | null, - range?: RangeCondition | null - ): Promise => { - loading.value = true - error.value = null - try { - const data = await nodeService.list( - providerId, - serviceId, - location, - recursive, - filter, - sort, - range - ) - // API returns object keyed by ID, convert to array - const nodeArray = Array.isArray(data) ? data : Object.values(data) - addNodes(providerId, serviceId, nodeArray) - return nodeArray.map(hydrateNode) - } catch (e) { - error.value = e instanceof Error ? e.message : 'Failed to fetch nodes' - throw e - } finally { - loading.value = false - } - } - - // Fetch collections (folders) for a location - const fetchCollections = async ( - providerId: string, - serviceId: string, - location?: string | null, - filter?: FilterCondition[] | null, - sort?: SortCondition[] | null - ): Promise => { - loading.value = true - error.value = null - try { - const data = await collectionService.list(providerId, serviceId, location, filter, sort) - // API returns object keyed by ID, convert to array - const collectionArray = Array.isArray(data) ? data : Object.values(data) - addNodes(providerId, serviceId, collectionArray) - return collectionArray.map(c => new FileCollectionObject().fromJson(c)) + await collectionsStore.list(sources, filter, sort) + return collectionsStore.collectionsForService(providerId, serviceId) } catch (e) { error.value = e instanceof Error ? e.message : 'Failed to fetch collections' throw e - } finally { - loading.value = false } } - // Fetch entities (files) for a collection - const fetchEntities = async ( + async function fetchEntities( providerId: string, - serviceId: string, - collection: string, - filter?: FilterCondition[] | null, - sort?: SortCondition[] | null, - range?: RangeCondition | null - ): Promise => { - loading.value = true + serviceId: string | number, + collectionId: string | number | null, + filter?: ListFilter, + sort?: ListSort, + range?: ListRange, + ): Promise { error.value = null try { - const data = await entityService.list(providerId, serviceId, collection, filter, sort, range) - // API returns object keyed by ID, convert to array - const entityArray = Array.isArray(data) ? data : Object.values(data) - addNodes(providerId, serviceId, entityArray) - return entityArray.map(e => new FileEntityObject().fromJson(e)) + const sources: SourceSelector = { + [providerId]: { + [String(serviceId)]: collectionId === null + ? true + : { [String(collectionId)]: true }, + }, + } + + await entitiesStore.list(sources, filter, sort, range) + return entitiesStore.entitiesForCollection(providerId, serviceId, collectionId) } catch (e) { error.value = e instanceof Error ? e.message : 'Failed to fetch entities' throw e - } finally { - loading.value = false } } - // Create a collection (folder) - const createCollection = async ( + async function fetchNodes( providerId: string, - serviceId: string, - location: string | null, - data: Partial, - options?: Record - ): Promise => { - loading.value = true + serviceId: string | number, + parentId: string | number | null = ROOT_ID, + filter?: ListFilter, + sort?: ListSort, + range?: ListRange, + ): Promise { error.value = null try { - const created = await collectionService.create(providerId, serviceId, location, data, options) - addNode(providerId, serviceId, created) - return new FileCollectionObject().fromJson(created) + await Promise.all([ + fetchCollections(providerId, serviceId, parentId, filter, sort), + fetchEntities(providerId, serviceId, parentId, filter, sort, range), + ]) + + return getChildren(providerId, serviceId, parentId) + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to fetch nodes' + throw e + } + } + + async function createCollection( + providerId: string, + serviceId: string | number, + parentCollectionId: string | number | null, + properties: CollectionMutableProperties, + ): Promise { + error.value = null + try { + return await collectionsStore.create(providerId, serviceId, parentCollectionId, properties) } catch (e) { error.value = e instanceof Error ? e.message : 'Failed to create collection' throw e - } finally { - loading.value = false } } - // Create an entity (file) - const createEntity = async ( + async function updateCollection( providerId: string, - serviceId: string, - collection: string | null, - data: Partial, - options?: Record - ): Promise => { - loading.value = true + serviceId: string | number, + identifier: string | number, + properties: CollectionMutableProperties, + ): Promise { error.value = null try { - const created = await entityService.create(providerId, serviceId, collection, data, options) - addNode(providerId, serviceId, created) - return new FileEntityObject().fromJson(created) + return await collectionsStore.update(providerId, serviceId, identifier, properties) + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to update collection' + throw e + } + } + + async function deleteCollection( + providerId: string, + serviceId: string | number, + identifier: string | number, + ): Promise { + error.value = null + try { + const response = await collectionsStore.delete(providerId, serviceId, identifier) + return response.success + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to delete collection' + throw e + } + } + + async function createEntity( + providerId: string, + serviceId: string | number, + collectionId: string | number, + properties: DocumentInterface, + options?: Record, + ): Promise { + error.value = null + try { + return await entitiesStore.create(providerId, serviceId, collectionId, properties, options) } catch (e) { error.value = e instanceof Error ? e.message : 'Failed to create entity' throw e - } finally { - loading.value = false } } - // Modify a collection - const modifyCollection = async ( + async function updateEntity( providerId: string, - serviceId: string, - identifier: string, - data: Partial - ): Promise => { - loading.value = true + serviceId: string | number, + collectionId: string | number, + identifier: string | number, + properties: DocumentInterface, + ): Promise { error.value = null try { - const modified = await collectionService.modify(providerId, serviceId, identifier, data) - addNode(providerId, serviceId, modified) - return new FileCollectionObject().fromJson(modified) + return await entitiesStore.update(providerId, serviceId, collectionId, identifier, properties) } catch (e) { - error.value = e instanceof Error ? e.message : 'Failed to modify collection' + error.value = e instanceof Error ? e.message : 'Failed to update entity' throw e - } finally { - loading.value = false } } - // Modify an entity - const modifyEntity = async ( + async function deleteEntity( providerId: string, - serviceId: string, - collection: string | null, - identifier: string, - data: Partial - ): Promise => { - loading.value = true + serviceId: string | number, + collectionId: string | number, + identifier: string | number, + ): Promise { error.value = null try { - const modified = await entityService.modify(providerId, serviceId, collection, identifier, data) - addNode(providerId, serviceId, modified) - return new FileEntityObject().fromJson(modified) + const response = await entitiesStore.delete(providerId, serviceId, collectionId, identifier) + return response.success } catch (e) { - error.value = e instanceof Error ? e.message : 'Failed to modify entity' + error.value = e instanceof Error ? e.message : 'Failed to delete entity' throw e - } finally { - loading.value = false } } - // Destroy a collection - const destroyCollection = async ( + async function readEntity( providerId: string, - serviceId: string, - identifier: string - ): Promise => { - loading.value = true + serviceId: string | number, + collectionId: string | number, + identifier: string | number, + ): Promise { error.value = null try { - const success = await collectionService.destroy(providerId, serviceId, identifier) - if (success) { - removeNode(providerId, serviceId, identifier) - } - return success - } catch (e) { - error.value = e instanceof Error ? e.message : 'Failed to destroy collection' - throw e - } finally { - loading.value = false - } - } - - // Destroy an entity - const destroyEntity = async ( - providerId: string, - serviceId: string, - collection: string | null, - identifier: string - ): Promise => { - loading.value = true - error.value = null - try { - const success = await entityService.destroy(providerId, serviceId, collection, identifier) - if (success) { - removeNode(providerId, serviceId, identifier) - } - return success - } catch (e) { - error.value = e instanceof Error ? e.message : 'Failed to destroy entity' - throw e - } finally { - loading.value = false - } - } - - // Copy a collection - const copyCollection = async ( - providerId: string, - serviceId: string, - identifier: string, - location?: string | null - ): Promise => { - loading.value = true - error.value = null - try { - const copied = await collectionService.copy(providerId, serviceId, identifier, location) - addNode(providerId, serviceId, copied) - return new FileCollectionObject().fromJson(copied) - } catch (e) { - error.value = e instanceof Error ? e.message : 'Failed to copy collection' - throw e - } finally { - loading.value = false - } - } - - // Copy an entity - const copyEntity = async ( - providerId: string, - serviceId: string, - collection: string | null, - identifier: string, - destination?: string | null - ): Promise => { - loading.value = true - error.value = null - try { - const copied = await entityService.copy(providerId, serviceId, collection, identifier, destination) - addNode(providerId, serviceId, copied) - return new FileEntityObject().fromJson(copied) - } catch (e) { - error.value = e instanceof Error ? e.message : 'Failed to copy entity' - throw e - } finally { - loading.value = false - } - } - - // Move a collection - const moveCollection = async ( - providerId: string, - serviceId: string, - identifier: string, - location?: string | null - ): Promise => { - loading.value = true - error.value = null - try { - const moved = await collectionService.move(providerId, serviceId, identifier, location) - addNode(providerId, serviceId, moved) - return new FileCollectionObject().fromJson(moved) - } catch (e) { - error.value = e instanceof Error ? e.message : 'Failed to move collection' - throw e - } finally { - loading.value = false - } - } - - // Move an entity - const moveEntity = async ( - providerId: string, - serviceId: string, - collection: string | null, - identifier: string, - destination?: string | null - ): Promise => { - loading.value = true - error.value = null - try { - const moved = await entityService.move(providerId, serviceId, collection, identifier, destination) - addNode(providerId, serviceId, moved) - return new FileEntityObject().fromJson(moved) - } catch (e) { - error.value = e instanceof Error ? e.message : 'Failed to move entity' - throw e - } finally { - loading.value = false - } - } - - // Read entity content - const readEntity = async ( - providerId: string, - serviceId: string, - collection: string, - identifier: string - ): Promise => { - loading.value = true - error.value = null - try { - const result = await entityService.read(providerId, serviceId, collection, identifier) - return result.content + return await entitiesStore.read(providerId, serviceId, collectionId, identifier) } catch (e) { error.value = e instanceof Error ? e.message : 'Failed to read entity' throw e - } finally { - loading.value = false } } - // Write entity content - const writeEntity = async ( + async function writeEntity( providerId: string, - serviceId: string, - collection: string | null, - identifier: string, - content: string - ): Promise => { - loading.value = true + serviceId: string | number, + collectionId: string | number, + identifier: string | number, + content: string, + ): Promise { error.value = null try { - return await entityService.write(providerId, serviceId, collection, identifier, content) + return await entitiesStore.write(providerId, serviceId, collectionId, identifier, content) } catch (e) { error.value = e instanceof Error ? e.message : 'Failed to write entity' throw e - } finally { - loading.value = false } } - // Sync delta changes - const syncDelta = async ( - providerId: string, - serviceId: string, - location: string | null, - signature: string, - recursive: boolean = false, - detail: 'ids' | 'full' = 'full' - ): Promise => { - loading.value = true - error.value = null - try { - const delta = await nodeService.delta(providerId, serviceId, location, signature, recursive, detail) - - // Handle removed nodes - if (delta.removed.length > 0) { - removeNodes(providerId, serviceId, delta.removed) - } - - // Handle added/modified nodes - if (detail === 'full') { - const addedNodes = delta.added as FileNode[] - const modifiedNodes = delta.modified as FileNode[] - if (addedNodes.length > 0) { - addNodes(providerId, serviceId, addedNodes) - } - if (modifiedNodes.length > 0) { - addNodes(providerId, serviceId, modifiedNodes) - } - } - - // Update sync token - if (delta.signature) { - setSyncToken(providerId, serviceId, delta.signature) - } - } catch (e) { - error.value = e instanceof Error ? e.message : 'Failed to sync delta' - throw e - } finally { - loading.value = false - } + function clearServiceNodes(providerId: string, serviceId: string | number): void { + collectionsStore.clearService(providerId, serviceId) + entitiesStore.clearService(providerId, serviceId) + } + + function clearNodes(): void { + collectionsStore.clearAll() + entitiesStore.clearAll() } return { - // State - nodes, - syncTokens, - loading, - error, - // Constants + transceiving: readonly(transceiving), + error: readonly(error), ROOT_ID, - // Computed nodeList, collectionList, entityList, - // Getters getNode, getServiceNodes, getChildren, @@ -648,40 +324,18 @@ export const useNodesStore = defineStore('fileNodes', () => { getChildEntities, getPath, isRoot, - // Setters - setNodes, - addNode, - addNodes, - removeNode, - removeNodes, - clearServiceNodes, - clearNodes, - // Sync - getSyncToken, - setSyncToken, - // API Actions - Fetch fetchNodes, fetchCollections, fetchEntities, - // API Actions - Create createCollection, + updateCollection, + deleteCollection, createEntity, - // API Actions - Modify - modifyCollection, - modifyEntity, - // API Actions - Destroy - destroyCollection, - destroyEntity, - // API Actions - Copy - copyCollection, - copyEntity, - // API Actions - Move - moveCollection, - moveEntity, - // API Actions - Content + updateEntity, + deleteEntity, readEntity, writeEntity, - // API Actions - Sync - syncDelta, + clearServiceNodes, + clearNodes, } }) diff --git a/src/stores/providersStore.ts b/src/stores/providersStore.ts index 7042578..dde4c2e 100644 --- a/src/stores/providersStore.ts +++ b/src/stores/providersStore.ts @@ -1,104 +1,142 @@ +/** + * Providers Store + */ + +import { ref, computed, readonly } from 'vue' import { defineStore } from 'pinia' -import { ref, computed } from 'vue' -import type { Ref, ComputedRef } from 'vue' -import type { ProviderInterface, ProviderRecord, ProviderCapabilitiesInterface } from '../types/provider' -import type { SourceSelector } from '../types/common' -import { providerService } from '../services/providerService' +import { providerService } from '../services' import { ProviderObject } from '../models/provider' +import type { SourceSelector } from '../types' -export const useProvidersStore = defineStore('fileProviders', () => { - const providers: Ref> = ref({}) - const loading = ref(false) - const error: Ref = ref(null) - const initialized = ref(false) +export const useProvidersStore = defineStore('documentsProvidersStore', () => { + // State + const _providers = ref>({}) + const transceiving = ref(false) - const providerList: ComputedRef = computed(() => - Object.values(providers.value) - ) + /** + * Get count of providers in store + */ + const count = computed(() => Object.keys(_providers.value).length) - const providerIds: ComputedRef = computed(() => - Object.keys(providers.value) - ) + /** + * Check if any providers are present in store + */ + const has = computed(() => count.value > 0) - const getProvider = (id: string): ProviderObject | undefined => { - return providers.value[id] - } + /** + * Get all providers present in store + */ + const providers = computed(() => Object.values(_providers.value)) - const hasProvider = (id: string): boolean => { - return id in providers.value - } - - const isCapable = (providerId: string, capability: keyof ProviderCapabilitiesInterface): boolean => { - const provider = providers.value[providerId] - return provider ? provider.capable(capability) : false - } - - const setProviders = (data: ProviderRecord) => { - const hydrated: Record = {} - for (const [id, providerData] of Object.entries(data)) { - hydrated[id] = new ProviderObject().fromJson(providerData) + /** + * Get a specific provider from store, with optional retrieval + * + * @param identifier - Provider identifier + * @param retrieve - Retrieve behavior: true = fetch if missing or refresh, false = cache only + * + * @returns Provider object or null + */ + function provider(identifier: string, retrieve: boolean = false): ProviderObject | null { + if (retrieve === true && !_providers.value[identifier]) { + console.debug(`[Documents Manager][Store] - Force fetching provider "${identifier}"`) + fetch(identifier) } - providers.value = hydrated - initialized.value = true + + return _providers.value[identifier] || null } - const addProvider = (id: string, provider: ProviderInterface) => { - providers.value[id] = new ProviderObject().fromJson(provider) - } + // Actions - const removeProvider = (id: string) => { - delete providers.value[id] - } - - const clearProviders = () => { - providers.value = {} - initialized.value = false - } - - // API actions - const fetchProviders = async (sources?: SourceSelector): Promise => { - loading.value = true - error.value = null + /** + * Retrieve all or specific providers, optionally filtered by source selector + * + * @param request - list request parameters + * + * @returns Promise with provider object list keyed by provider identifier + */ + async function list(sources?: SourceSelector): Promise> { + transceiving.value = true try { - const data = await providerService.list(sources) - setProviders(data) - } catch (e) { - error.value = e instanceof Error ? e.message : 'Failed to fetch providers' - throw e + const providers = await providerService.list({ sources }) + + // Merge retrieved providers into state + _providers.value = { ..._providers.value, ...providers } + + console.debug('[Documents Manager][Store] - Successfully retrieved', Object.keys(providers).length, 'providers') + return providers + } catch (error: any) { + console.error('[Documents Manager][Store] - Failed to retrieve providers:', error) + throw error } finally { - loading.value = false + transceiving.value = false } } - const checkProviderExtant = async (sources: SourceSelector): Promise> => { + /** + * Retrieve a specific provider by identifier + * + * @param identifier - provider identifier + * + * @returns Promise with provider object + */ + async function fetch(identifier: string): Promise { + transceiving.value = true try { - return await providerService.extant(sources) - } catch (e) { - error.value = e instanceof Error ? e.message : 'Failed to check providers' - throw e + const provider = await providerService.fetch({ identifier }) + + // Merge fetched provider into state + _providers.value[provider.identifier] = provider + + console.debug('[Documents Manager][Store] - Successfully fetched provider:', provider.identifier) + return provider + } catch (error: any) { + console.error('[Documents Manager][Store] - Failed to fetch provider:', error) + throw error + } finally { + transceiving.value = false } } + /** + * Retrieve provider availability status for a given source selector + * + * @param sources - source selector to check availability for + * + * @returns Promise with provider availability status + */ + async function extant(sources: SourceSelector) { + transceiving.value = true + try { + const response = await providerService.extant({ sources }) + + Object.entries(response).forEach(([providerId, providerStatus]) => { + if (providerStatus === false) { + delete _providers.value[providerId] + } + }) + + console.debug('[Documents Manager][Store] - Successfully checked', sources ? Object.keys(sources).length : 0, 'providers') + return response + } catch (error: any) { + console.error('[Documents Manager][Store] - Failed to check providers:', error) + throw error + } finally { + transceiving.value = false + } + } + + // Return public API return { // State + transceiving: readonly(transceiving), + // computed + count, + has, providers, - loading, - error, - initialized, - // Computed - providerList, - providerIds, - // Getters - getProvider, - hasProvider, - isCapable, - // Setters - setProviders, - addProvider, - removeProvider, - clearProviders, - // Actions - fetchProviders, - checkProviderExtant, + provider, + // functions + list, + fetch, + extant, } }) diff --git a/src/stores/servicesStore.ts b/src/stores/servicesStore.ts index de1d8d0..27e9b78 100644 --- a/src/stores/servicesStore.ts +++ b/src/stores/servicesStore.ts @@ -1,131 +1,259 @@ +/** + * Services Store + */ + +import { ref, computed, readonly } from 'vue' import { defineStore } from 'pinia' -import { ref, computed } from 'vue' -import type { Ref, ComputedRef } from 'vue' -import type { ServiceInterface, ServiceRecord } from '../types/service' -import type { SourceSelector } from '../types/common' -import { serviceService } from '../services/serviceService' +import { serviceService } from '../services' import { ServiceObject } from '../models/service' +import type { + SourceSelector, + ServiceInterface, +} from '../types' -// Nested structure: provider -> service -> ServiceObject -type ServiceStore = Record> +export const useServicesStore = defineStore('documentsServicesStore', () => { + // State + const _services = ref>({}) + const transceiving = ref(false) -export const useServicesStore = defineStore('fileServices', () => { - const services: Ref = ref({}) - const loading = ref(false) - const error: Ref = ref(null) - const initialized = ref(false) + /** + * Get count of services in store + */ + const count = computed(() => Object.keys(_services.value).length) - const serviceList: ComputedRef = computed(() => { - const result: ServiceObject[] = [] - Object.values(services.value).forEach(providerServices => { - result.push(...Object.values(providerServices)) + /** + * Check if any services are present in store + */ + const has = computed(() => count.value > 0) + + /** + * Get all services present in store + */ + const services = computed(() => Object.values(_services.value)) + + /** + * Get all services present in store grouped by provider + */ + const servicesByProvider = computed(() => { + const groups: Record = {} + + Object.values(_services.value).forEach((service) => { + const providerServices = (groups[service.provider] ??= []) + providerServices.push(service) }) - return result + + return groups }) - const getService = (providerId: string, serviceId: string): ServiceObject | undefined => { - return services.value[providerId]?.[serviceId] - } - - const hasService = (providerId: string, serviceId: string): boolean => { - return !!services.value[providerId]?.[serviceId] - } - - const getProviderServices = (providerId: string): ServiceObject[] => { - return Object.values(services.value[providerId] || {}) - } - - const getRootId = (providerId: string, serviceId: string): string | undefined => { - return services.value[providerId]?.[serviceId]?.rootId - } - - const setServices = (data: ServiceRecord) => { - const hydrated: ServiceStore = {} - for (const [id, serviceData] of Object.entries(data)) { - const providerId = serviceData.provider - if (!hydrated[providerId]) { - hydrated[providerId] = {} - } - hydrated[providerId][id] = new ServiceObject().fromJson(serviceData) + /** + * Get a specific service from store, with optional retrieval + * + * @param provider - provider identifier + * @param identifier - service identifier + * @param retrieve - Retrieve behavior: true = fetch if missing or refresh, false = cache only + * + * @returns Service object or null + */ + function service(provider: string, identifier: string | number, retrieve: boolean = false): ServiceObject | null { + const key = identifierKey(provider, identifier) + if (retrieve === true && !_services.value[key]) { + console.debug(`[Documents Manager][Store] - Force fetching service "${key}"`) + fetch(provider, identifier) } - services.value = hydrated - initialized.value = true + + return _services.value[key] || null } - const addService = (providerId: string, serviceId: string, service: ServiceInterface) => { - if (!services.value[providerId]) { - services.value[providerId] = {} - } - services.value[providerId][serviceId] = new ServiceObject().fromJson(service) + /** + * Unique key for a service + */ + function identifierKey(provider: string, identifier: string | number | null): string { + return `${provider}:${identifier ?? ''}` } - const removeService = (providerId: string, serviceId: string) => { - if (services.value[providerId]) { - delete services.value[providerId][serviceId] - } - } - - const clearServices = () => { - services.value = {} - initialized.value = false - } - - // API actions - const fetchServices = async (sources?: SourceSelector): Promise => { - loading.value = true - error.value = null + // Actions + + /** + * Retrieve all or specific services, optionally filtered by source selector + * + * @param sources - optional source selector + * + * @returns Promise with service object list keyed by provider and service identifier + */ + async function list(sources?: SourceSelector): Promise> { + transceiving.value = true try { - const data = await serviceService.list(sources) - setServices(data) - } catch (e) { - error.value = e instanceof Error ? e.message : 'Failed to fetch services' - throw e + const response = await serviceService.list({ sources }) + + // Flatten nested structure: provider-id: { service-id: object } -> "provider-id:service-id": object + const services: Record = {} + Object.entries(response).forEach(([_providerId, providerServices]) => { + Object.entries(providerServices).forEach(([_serviceId, serviceObj]) => { + const key = identifierKey(serviceObj.provider, serviceObj.identifier) + services[key] = serviceObj + }) + }) + + // Merge retrieved services into state + _services.value = { ..._services.value, ...services } + + console.debug('[Documents Manager][Store] - Successfully retrieved', Object.keys(services).length, 'services') + return services + } catch (error: any) { + console.error('[Documents Manager][Store] - Failed to retrieve services:', error) + throw error } finally { - loading.value = false + transceiving.value = false } } - - const checkServiceExtant = async (sources: SourceSelector): Promise> => { + + /** + * Retrieve a specific service by provider and identifier + * + * @param provider - provider identifier + * @param identifier - service identifier + * + * @returns Promise with service object + */ + async function fetch(provider: string, identifier: string | number): Promise { + transceiving.value = true try { - return await serviceService.extant(sources) - } catch (e) { - error.value = e instanceof Error ? e.message : 'Failed to check services' - throw e + const service = await serviceService.fetch({ provider, identifier }) + + // Merge fetched service into state + const key = identifierKey(service.provider, service.identifier) + _services.value[key] = service + + console.debug('[Documents Manager][Store] - Successfully fetched service:', key) + return service + } catch (error: any) { + console.error('[Documents Manager][Store] - Failed to fetch service:', error) + throw error + } finally { + transceiving.value = false } } - const fetchService = async (providerId: string, serviceId: string): Promise => { + /** + * Retrieve service availability status for a given source selector + * + * @param sources - source selector to check availability for + * + * @returns Promise with service availability status + */ + async function extant(sources: SourceSelector) { + transceiving.value = true try { - const data = await serviceService.fetch(providerId, serviceId) - addService(providerId, serviceId, data) - return services.value[providerId][serviceId] - } catch (e) { - error.value = e instanceof Error ? e.message : 'Failed to fetch service' - throw e + const response = await serviceService.extant({ sources }) + + console.debug('[Documents Manager][Store] - Successfully checked', sources ? Object.keys(sources).length : 0, 'services') + return response + } catch (error: any) { + console.error('[Documents Manager][Store] - Failed to check services:', error) + throw error + } finally { + transceiving.value = false } } + /** + * Create a new service with given provider and data + * + * @param provider - provider identifier for the new service + * @param data - partial service data for creation + * + * @returns Promise with created service object + */ + async function create(provider: string, data: Partial): Promise { + transceiving.value = true + try { + const service = await serviceService.create({ provider, data }) + + // Merge created service into state + const key = identifierKey(service.provider, service.identifier) + _services.value[key] = service + + console.debug('[Documents Manager][Store] - Successfully created service:', key) + return service + } catch (error: any) { + console.error('[Documents Manager][Store] - Failed to create service:', error) + throw error + } finally { + transceiving.value = false + } + } + + /** + * Update an existing service with given provider, identifier, and data + * + * @param provider - provider identifier for the service to update + * @param identifier - service identifier for the service to update + * @param data - partial service data for update + * + * @returns Promise with updated service object + */ + async function update(provider: string, identifier: string | number, data: Partial): Promise { + transceiving.value = true + try { + const service = await serviceService.update({ provider, identifier, data }) + + // Merge updated service into state + const key = identifierKey(service.provider, service.identifier) + _services.value[key] = service + + console.debug('[Documents Manager][Store] - Successfully updated service:', key) + return service + } catch (error: any) { + console.error('[Documents Manager][Store] - Failed to update service:', error) + throw error + } finally { + transceiving.value = false + } + } + + /** + * Delete a service by provider and identifier + * + * @param provider - provider identifier for the service to delete + * @param identifier - service identifier for the service to delete + * + * @returns Promise with deletion result + */ + async function remove(provider: string, identifier: string | number): Promise { + transceiving.value = true + try { + await serviceService.delete({ provider, identifier }) + + // Remove deleted service from state + const key = identifierKey(provider, identifier) + delete _services.value[key] + + console.debug('[Documents Manager][Store] - Successfully deleted service:', key) + } catch (error: any) { + console.error('[Documents Manager][Store] - Failed to delete service:', error) + throw error + } finally { + transceiving.value = false + } + } + + // Return public API return { - // State - services, - loading, - error, - initialized, - // Computed - serviceList, + // State (readonly) + transceiving: readonly(transceiving), // Getters - getService, - hasService, - getProviderServices, - getRootId, - // Setters - setServices, - addService, - removeService, - clearServices, + count, + has, + services, + servicesByProvider, + // Actions - fetchServices, - checkServiceExtant, - fetchService, + service, + list, + fetch, + extant, + create, + update, + delete: remove, } }) diff --git a/src/types/api.ts b/src/types/api.ts deleted file mode 100644 index 45ad71c..0000000 --- a/src/types/api.ts +++ /dev/null @@ -1,262 +0,0 @@ -/** - * File Manager API Types - Request and Response interfaces - */ - -import type { SourceSelector, FilterCondition, SortCondition, RangeCondition, ApiResponse } from '@/types/common'; -import type { ProviderRecord } from '@/types/provider'; -import type { ServiceInterface, ServiceRecord } from '@/types/service'; -import type { FileCollection, FileEntity, FileNode } from '@/types/node'; - -// ==================== Provider Types ==================== - -export type ProviderListResponse = ApiResponse; - -export interface ProviderExtantRequest { - sources: SourceSelector; -} - -export type ProviderExtantResponse = ApiResponse>; - -// ==================== Service Types ==================== - -export type ServiceListResponse = ApiResponse; - -export interface ServiceExtantRequest { - sources: SourceSelector; -} - -export type ServiceExtantResponse = ApiResponse>; - -export interface ServiceFetchRequest { - provider: string; - identifier: string; -} - -export type ServiceFetchResponse = ApiResponse; - -// ==================== Collection Types ==================== - -export interface CollectionListRequest { - provider: string; - service: string; - location?: string | null; - filter?: FilterCondition[] | null; - sort?: SortCondition[] | null; -} - -export type CollectionListResponse = ApiResponse; - -export interface CollectionExtantRequest { - provider: string; - service: string; - identifier: string; -} - -export type CollectionExtantResponse = ApiResponse<{ extant: boolean }>; - -export interface CollectionFetchRequest { - provider: string; - service: string; - identifier: string; -} - -export type CollectionFetchResponse = ApiResponse; - -export interface CollectionCreateRequest { - provider: string; - service: string; - location?: string | null; - data: Partial; - options?: Record; -} - -export type CollectionCreateResponse = ApiResponse; - -export interface CollectionModifyRequest { - provider: string; - service: string; - identifier: string; - data: Partial; -} - -export type CollectionModifyResponse = ApiResponse; - -export interface CollectionDestroyRequest { - provider: string; - service: string; - identifier: string; -} - -export type CollectionDestroyResponse = ApiResponse<{ success: boolean }>; - -export interface CollectionCopyRequest { - provider: string; - service: string; - identifier: string; - location?: string | null; -} - -export type CollectionCopyResponse = ApiResponse; - -export interface CollectionMoveRequest { - provider: string; - service: string; - identifier: string; - location?: string | null; -} - -export type CollectionMoveResponse = ApiResponse; - -// ==================== Entity Types ==================== - -export interface EntityListRequest { - provider: string; - service: string; - collection: string; - filter?: FilterCondition[] | null; - sort?: SortCondition[] | null; - range?: RangeCondition | null; -} - -export type EntityListResponse = ApiResponse; - -export interface EntityDeltaRequest { - provider: string; - service: string; - collection: string; - signature: string; - detail?: 'ids' | 'full'; -} - -export interface EntityDeltaResult { - added: string[] | FileEntity[]; - modified: string[] | FileEntity[]; - removed: string[]; - signature: string; -} - -export type EntityDeltaResponse = ApiResponse; - -export interface EntityExtantRequest { - provider: string; - service: string; - collection: string; - identifiers: string[]; -} - -export type EntityExtantResponse = ApiResponse>; - -export interface EntityFetchRequest { - provider: string; - service: string; - collection: string; - identifiers: string[]; -} - -export type EntityFetchResponse = ApiResponse; - -export interface EntityReadRequest { - provider: string; - service: string; - collection: string; - identifier: string; -} - -export interface EntityReadResult { - content: string | null; - encoding: 'base64'; -} - -export type EntityReadResponse = ApiResponse; - -export interface EntityCreateRequest { - provider: string; - service: string; - collection?: string | null; - data: Partial; - options?: Record; -} - -export type EntityCreateResponse = ApiResponse; - -export interface EntityModifyRequest { - provider: string; - service: string; - collection?: string | null; - identifier: string; - data: Partial; -} - -export type EntityModifyResponse = ApiResponse; - -export interface EntityDestroyRequest { - provider: string; - service: string; - collection?: string | null; - identifier: string; -} - -export type EntityDestroyResponse = ApiResponse<{ success: boolean }>; - -export interface EntityCopyRequest { - provider: string; - service: string; - collection?: string | null; - identifier: string; - destination?: string | null; -} - -export type EntityCopyResponse = ApiResponse; - -export interface EntityMoveRequest { - provider: string; - service: string; - collection?: string | null; - identifier: string; - destination?: string | null; -} - -export type EntityMoveResponse = ApiResponse; - -export interface EntityWriteRequest { - provider: string; - service: string; - collection?: string | null; - identifier: string; - content: string; - encoding?: 'base64'; -} - -export type EntityWriteResponse = ApiResponse<{ bytesWritten: number }>; - -// ==================== Node Types (Unified/Recursive) ==================== - -export interface NodeListRequest { - provider: string; - service: string; - location?: string | null; - recursive?: boolean; - filter?: FilterCondition[] | null; - sort?: SortCondition[] | null; - range?: RangeCondition | null; -} - -export type NodeListResponse = ApiResponse; - -export interface NodeDeltaRequest { - provider: string; - service: string; - location?: string | null; - signature: string; - recursive?: boolean; - detail?: 'ids' | 'full'; -} - -export interface NodeDeltaResult { - added: string[] | FileNode[]; - modified: string[] | FileNode[]; - removed: string[]; - signature: string; -} - -export type NodeDeltaResponse = ApiResponse; diff --git a/src/types/collection.ts b/src/types/collection.ts new file mode 100644 index 0000000..4645ce3 --- /dev/null +++ b/src/types/collection.ts @@ -0,0 +1,150 @@ +/** + * Collection type definitions + */ +import type { ListFilter, ListSort, SourceSelector } from './common'; + + +export interface CollectionModelInterface extends Omit { + created: Date | null; + modified: Date | null; +} + +/** + * Collection information + */ +export interface CollectionInterface { + '@type': string; + schema: number; + provider: string; + service: string | number; + collection: string | number | null; + identifier: string | number; + signature: string | null; + created: string | null; + modified: string | null; + properties: CollectionPropertiesInterface; +} + +export type CollectionContentTypes = 'file' | 'folder'; + +export interface CollectionBaseProperties { + +} + +export interface CollectionImmutableProperties extends CollectionBaseProperties { + content: CollectionContentTypes[]; +} + +export interface CollectionMutableProperties extends CollectionBaseProperties { + owner: string; + label: string; +} + +export interface CollectionPropertiesInterface extends CollectionMutableProperties, CollectionImmutableProperties {} + +/** + * Collection list + */ +export interface CollectionListRequest { + sources?: SourceSelector; + filter?: ListFilter; + sort?: ListSort; +} + +export interface CollectionListResponse { + [providerId: string]: { + [serviceId: string]: { + [collectionId: string]: CollectionInterface; + }; + }; +} + +/** + * Collection fetch + */ +export interface CollectionFetchRequest { + provider: string; + service: string | number; + collection: string | number; +} + +export interface CollectionFetchResponse extends CollectionInterface {} + +/** + * Collection extant + */ +export interface CollectionExtantRequest { + sources: SourceSelector; +} + +export interface CollectionExtantResponse { + [providerId: string]: { + [serviceId: string]: { + [collectionId: string]: boolean; + }; + }; +} + +/** + * Collection create + */ +export interface CollectionCreateRequest { + provider: string; + service: string | number; + collection?: string | number | null; // Parent Collection Identifier + properties: CollectionMutableProperties; +} + +export interface CollectionCreateResponse extends CollectionInterface {} + +/** + * Collection modify + */ +export interface CollectionUpdateRequest { + provider: string; + service: string | number; + identifier: string | number; + properties: CollectionMutableProperties; +} + +export interface CollectionUpdateResponse extends CollectionInterface {} + +/** + * Collection delete + */ +export interface CollectionDeleteRequest { + provider: string; + service: string | number; + identifier: string | number; + options?: { + force?: boolean; // Whether to force delete even if collection is not empty + }; +} + +export interface CollectionDeleteResponse { + success: boolean; +} + +/** + * Collection copy + */ +export interface CollectionCopyRequest { + provider: string; + service: string; + identifier: string; + location?: string | null; +} + +export interface CollectionCopyResponse extends CollectionInterface {} + +/** + * Collection move + */ +export interface CollectionMoveRequest { + provider: string; + service: string; + identifier: string; + location?: string | null; +} + +export interface CollectionMoveResponse extends CollectionInterface {} \ No newline at end of file diff --git a/src/types/common.ts b/src/types/common.ts index a30c1b4..ab912d4 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -1,85 +1,156 @@ /** - * Common types for file manager + * Common types shared across provider, service, collection, and entity request and responses. */ +/** + * Base API request envelope + */ +export interface ApiRequest { + version: number; + transaction: string; + operation: string; + data: T; + user?: string; +} + +/** + * Success response envelope + */ +export interface ApiSuccessResponse { + version: number; + transaction: string; + operation: string; + status: 'success'; + data: T; +} + +/** + * Error response envelope + */ +export interface ApiErrorResponse { + version: number; + transaction: string; + operation: string; + status: 'error'; + data: { + code: number; + message: string; + }; +} + +/** + * Combined response type + */ +export type ApiResponse = ApiSuccessResponse | ApiErrorResponse; + +/** + * Selector for targeting specific providers, services, collections, or entities in list or extant operations. + * + * Example usage: + * { + * "provider1": true, // Select all services/collections/entities under provider1 + * "provider2": { + * "serviceA": true, // Select all collections/entities under serviceA of provider2 + * "serviceB": { + * "collectionX": true, // Select all entities under collectionX of serviceB of provider2 + * "collectionY": [1, 2, 3] // Select entities with identifiers 1, 2, and 3 under collectionY of serviceB of provider2 + * } + * } + * } + */ export type SourceSelector = { [provider: string]: boolean | ServiceSelector; }; export type ServiceSelector = { - [service: string]: boolean; + [service: string]: boolean | CollectionSelector; }; -export const SortDirection = { - Ascending: 'asc', - Descending: 'desc' +export type CollectionSelector = { + [collection: string | number]: boolean | EntitySelector; +}; + +export type EntitySelector = (string | number)[]; + + +/** + * Filter comparison for list operations + */ +export const ListFilterComparisonOperator = { + EQ: 1, // Equal + NEQ: 2, // Not Equal + GT: 4, // Greater Than + LT: 8, // Less Than + GTE: 16, // Greater Than or Equal + LTE: 32, // Less Than or Equal + IN: 64, // In Array + NIN: 128, // Not In Array + LIKE: 256, // Like + NLIKE: 512, // Not Like } as const; -export type SortDirection = typeof SortDirection[keyof typeof SortDirection]; +export type ListFilterComparisonOperator = typeof ListFilterComparisonOperator[keyof typeof ListFilterComparisonOperator]; -export const RangeType = { - Tally: 'tally', - Date: 'date' +/** + * Filter conjunction for list operations + */ +export const ListFilterConjunctionOperator = { + NONE: '', + AND: 'AND', + OR: 'OR', } as const; -export type RangeType = typeof RangeType[keyof typeof RangeType]; +export type ListFilterConjunctionOperator = typeof ListFilterConjunctionOperator[keyof typeof ListFilterConjunctionOperator]; -export const RangeAnchorType = { - Absolute: 'absolute', - Relative: 'relative' -} as const; +/** + * Filter condition for list operations + * + * Tuple format: [value, comparator?, conjunction?] + */ +export type ListFilterCondition = [ + string | number | boolean | string[] | number[], + ListFilterComparisonOperator?, + ListFilterConjunctionOperator? +]; -export type RangeAnchorType = typeof RangeAnchorType[keyof typeof RangeAnchorType]; - -export const FilterOperator = { - Equals: 'eq', - NotEquals: 'ne', - GreaterThan: 'gt', - LessThan: 'lt', - GreaterThanOrEquals: 'gte', - LessThanOrEquals: 'lte', - Contains: 'contains', - StartsWith: 'startsWith', - EndsWith: 'endsWith', - In: 'in', - NotIn: 'notIn' -} as const; - -export type FilterOperator = typeof FilterOperator[keyof typeof FilterOperator]; - -export interface FilterCondition { - attribute: string; - value: unknown; - operator?: FilterOperator; +/** + * Filter for list operations + * + * Values can be: + * - Simple primitives (string | number | boolean) for default equality comparison + * - ListFilterCondition tuple for explicit comparator/conjunction + * + * Examples: + * - Simple usage: { name: "John" } + * - With comparator: { age: [25, ListFilterComparisonOperator.GT] } + * - With conjunction: { age: [25, ListFilterComparisonOperator.GT, ListFilterConjunctionOperator.AND] } + * - With array value for IN operator: { status: [["active", "pending"], ListFilterComparisonOperator.IN] } + */ +export interface ListFilter { + [attribute: string]: string | number | boolean | ListFilterCondition; } -export interface SortCondition { - attribute: string; - direction: SortDirection; +/** + * Sort for list operations + * + * Values can be: + * - true for ascending + * - false for descending + */ +export interface ListSort { + [attribute: string]: boolean; } -export interface RangeCondition { - type: RangeType; - anchor?: RangeAnchorType; - position?: string | number; - tally?: number; -} - -export interface ApiRequest { - version: number; - transaction: string; - operation: string; - data?: Record; -} - -export interface ApiResponse { - version: number; - transaction: string; - operation: string; - status: 'success' | 'error'; - data?: T; - error?: { - code: number; - message: string; - }; -} +/** + * Range for list operations + * + * Values can be: + * - relative based on item identifier + * - absolute based on item count + */ +export interface ListRange { + type: 'tally'; + anchor: 'relative' | 'absolute'; + position: string | number; + tally: number; +} \ No newline at end of file diff --git a/src/types/document.ts b/src/types/document.ts new file mode 100644 index 0000000..de0c02d --- /dev/null +++ b/src/types/document.ts @@ -0,0 +1,20 @@ +/** + * Document-related type definitions + */ + +/** + * Document interface + */ +export interface DocumentModelInterface extends Omit { + label: string | null; +} + +export interface DocumentInterface { + '@type': string; + urid: string | null; + size: number; + label: string; + mime: string | null; + format: string | null; + encoding: string | null; +} diff --git a/src/types/entity.ts b/src/types/entity.ts new file mode 100644 index 0000000..dd66e2f --- /dev/null +++ b/src/types/entity.ts @@ -0,0 +1,194 @@ +/** + * Entity type definitions + */ +import type { ListFilter, ListRange, ListSort, SourceSelector } from './common'; +import type { DocumentInterface, DocumentModelInterface } from './document'; + +/** + * Entity definition + */ +export interface EntityModelInterface extends Omit, '@type' | 'created' | 'modified'> { + created: Date | null; + modified: Date | null; +} + +export interface EntityInterface { + '@type': string; + schema: number; + provider: string; + service: string; + collection: string | number; + identifier: string | number; + signature: string | null; + created: string | null; + modified: string | null; + properties: T; +} + +/** + * Entity list + */ +export interface EntityListRequest { + sources?: SourceSelector; + filter?: ListFilter; + sort?: ListSort; + range?: ListRange; +} + +export interface EntityListResponse { + [providerId: string]: { + [serviceId: string]: { + [collectionId: string]: { + [identifier: string]: EntityInterface; + }; + }; + }; +} + +/** + * Entity fetch + */ +export interface EntityFetchRequest { + provider: string; + service: string | number; + collection: string | number; + identifiers: (string | number)[]; +} + +export interface EntityFetchResponse { + [identifier: string]: EntityInterface; +} + +/** + * Entity extant + */ +export interface EntityExtantRequest { + sources: SourceSelector; +} + +export interface EntityExtantResponse { + [providerId: string]: { + [serviceId: string]: { + [collectionId: string]: { + [identifier: string]: boolean; + }; + }; + }; +} + +/** + * Entity create + */ +export interface EntityCreateRequest { + provider: string; + service: string | number; + collection: string | number; + properties: T; + options?: Record; +} + +export interface EntityCreateResponse extends EntityInterface {} + +/** + * Entity update + */ +export interface EntityUpdateRequest { + provider: string; + service: string | number; + collection: string | number; + identifier: string | number; + properties: T; +} + +export interface EntityUpdateResponse extends EntityInterface {} + +/** + * Entity delete + */ +export interface EntityDeleteRequest { + provider: string; + service: string | number; + collection: string | number; + identifier: string | number; +} + +export interface EntityDeleteResponse { + success: boolean; +} + +/** + * Entity delta + */ +export interface EntityDeltaRequest { + sources: SourceSelector; +} + +export interface EntityDeltaResponse { + [providerId: string]: false | { + [serviceId: string]: false | { + [collectionId: string]: false | { + signature: string; + additions: (string | number)[]; + modifications: (string | number)[]; + deletions: (string | number)[]; + }; + }; + }; +} + +/** + * Entity copy + */ +export interface EntityCopyRequest { + provider: string; + service: string | number; + collection: string | number; + identifier: string | number; + destination?: string | null; +} + +export interface EntityCopyResponse extends EntityInterface {} + +/** + * Entity move + */ +export interface EntityMoveRequest { + provider: string; + service: string | number; + collection: string | number; + identifier: string | number; + destination?: string | null; +} + +export interface EntityMoveResponse extends EntityInterface {} + +/** + * Entity read content + */ +export interface EntityReadRequest { + provider: string; + service: string | number; + collection: string | number; + identifier: string | number; +} + +export interface EntityReadResult { + content: string | null; + encoding: 'base64'; +} + +export type EntityReadResponse = EntityReadResult; + +/** + * Entity write content + */ +export interface EntityWriteRequest { + provider: string; + service: string | number; + collection: string | number; + identifier: string | number; + content: string; + encoding?: 'base64'; +} + +export type EntityWriteResponse = { bytesWritten: number }; \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index d259f0f..c065da1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,9 +1,7 @@ -/** - * File manager types barrel export - */ - -export * from './common'; -export * from './provider'; -export * from './service'; -export * from './node'; -export * from './api'; +export type * from './common'; +export type * from './provider'; +export type * from './service'; +export type * from './collection'; +export type * from './entity'; +export type * from './document'; +export type * from './node'; diff --git a/src/types/node.ts b/src/types/node.ts index 6263b69..68c220c 100644 --- a/src/types/node.ts +++ b/src/types/node.ts @@ -1,65 +1,37 @@ /** - * Node types for file manager (collections and entities) + * Node types for combined operations */ -export type NodeType = 'files.collection' | 'files.entity'; +import type { CollectionInterface } from "./collection"; +import type { ApiResponse, ListFilterCondition, ListRange, ListSort } from "./common"; +import type { EntityInterface } from "./entity"; -export interface NodeBase { - '@type': NodeType; - in: string | null; - id: string; - createdBy: string; - createdOn: string; - modifiedBy: string; - modifiedOn: string; - owner: string; +export interface NodeListRequest { + provider: string; + service: string; + location?: string | null; + recursive?: boolean; + filter?: ListFilterCondition | null; + sort?: ListSort | null; + range?: ListRange | null; +} + +export type NodeListResponse = ApiResponse; + +export interface NodeDeltaRequest { + provider: string; + service: string; + location?: string | null; signature: string; - label: string; + recursive?: boolean; + detail?: 'ids' | 'full'; } -export interface FileCollection extends NodeBase { - '@type': 'files.collection'; -} - -export interface FileEntity extends NodeBase { - '@type': 'files.entity'; - size: number; - mime: string; - format: string; - encoding: string; -} - -export type FileNode = FileCollection | FileEntity; - -export interface NodeListResult { - items: FileNode[]; - total: number; - hasMore?: boolean; -} - -export interface EntityListResult { - items: FileEntity[]; - total: number; - hasMore?: boolean; -} - -export interface CollectionListResult { - items: FileCollection[]; - total: number; - hasMore?: boolean; -} - -export interface DeltaResult { - added: FileNode[]; - modified: FileNode[]; +export interface NodeDeltaResult { + added: string[]; + modified: string[]; removed: string[]; signature: string; } -export function isFileCollection(node: FileNode): node is FileCollection { - return node['@type'] === 'files.collection'; -} - -export function isFileEntity(node: FileNode): node is FileEntity { - return node['@type'] === 'files.entity'; -} +export type NodeDeltaResponse = ApiResponse; diff --git a/src/types/provider.ts b/src/types/provider.ts index 00aa8d4..b9632c8 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -1,49 +1,60 @@ /** - * Provider types for file manager + * Provider type definitions */ +import type { SourceSelector } from "./common"; +/** + * Provider capabilities + */ export interface ProviderCapabilitiesInterface { - CollectionList?: boolean; - CollectionListFilter?: boolean | Record; - CollectionListSort?: boolean | string[]; - CollectionExtant?: boolean; - CollectionFetch?: boolean; - CollectionCreate?: boolean; - CollectionModify?: boolean; - CollectionDestroy?: boolean; - CollectionCopy?: boolean; - CollectionMove?: boolean; - EntityList?: boolean; - EntityListFilter?: boolean | Record; - EntityListSort?: boolean | string[]; - EntityListRange?: boolean | Record; - EntityDelta?: boolean; - EntityExtant?: boolean; - EntityFetch?: boolean; - EntityRead?: boolean; - EntityReadStream?: boolean; - EntityReadChunk?: boolean; - EntityCreate?: boolean; - EntityModify?: boolean; - EntityDestroy?: boolean; - EntityCopy?: boolean; - EntityMove?: boolean; - EntityWrite?: boolean; - EntityWriteStream?: boolean; - EntityWriteChunk?: boolean; - NodeList?: boolean; - NodeListFilter?: boolean | Record; - NodeListSort?: boolean | string[]; - NodeListRange?: boolean | Record; - NodeDelta?: boolean; - [key: string]: boolean | string[] | Record | Record | undefined; + ServiceList?: boolean; + ServiceFetch?: boolean; + ServiceExtant?: boolean; + ServiceCreate?: boolean; + ServiceUpdate?: boolean; + ServiceDelete?: boolean; + ServiceDiscover?: boolean; + ServiceTest?: boolean; + [key: string]: boolean | object | string[] | undefined; } +/** + * Provider information + */ export interface ProviderInterface { '@type': string; - id: string; + identifier: string; label: string; capabilities: ProviderCapabilitiesInterface; } -export type ProviderRecord = Record; +/** + * Provider list + */ +export interface ProviderListRequest { + sources?: SourceSelector; +} + +export interface ProviderListResponse { + [identifier: string]: ProviderInterface; +} + +/** + * Provider fetch + */ +export interface ProviderFetchRequest { + identifier: string; +} + +export interface ProviderFetchResponse extends ProviderInterface {} + +/** + * Provider extant + */ +export interface ProviderExtantRequest { + sources: SourceSelector; +} + +export interface ProviderExtantResponse { + [identifier: string]: boolean; +} diff --git a/src/types/service.ts b/src/types/service.ts index cba2444..b5562b3 100644 --- a/src/types/service.ts +++ b/src/types/service.ts @@ -1,13 +1,307 @@ /** - * Service types for file manager + * Service type definitions */ +import type { SourceSelector, ListFilterComparisonOperator } from './common'; -export interface ServiceInterface { - '@type': string; - id: string; - provider: string; - label: string; - rootId: string; +/** + * Service capabilities + */ +export interface ServiceCapabilitiesInterface { + // Collection capabilities + CollectionList?: boolean; + CollectionListFilter?: ServiceListFilterCollection; + CollectionListSort?: ServiceListSortCollection; + CollectionExtant?: boolean; + CollectionFetch?: boolean; + CollectionCreate?: boolean; + CollectionUpdate?: boolean; + CollectionDelete?: boolean; + CollectionCopy?: boolean; + CollectionMove?: boolean; + // Entity capabilities + EntityList?: boolean; + EntityListFilter?: ServiceListFilterEntity; + EntityListSort?: ServiceListSortEntity; + EntityListRange?: ServiceListRange; + EntityDelta?: boolean; + EntityExtant?: boolean; + EntityFetch?: boolean; + EntityCreate?: boolean; + EntityUpdate?: boolean; + EntityDelete?: boolean; + EntityMove?: boolean; + EntityCopy?: boolean; + EntityRead?: boolean; + EntityReadStream?: boolean; + EntityReadChunk?: boolean; + EntityWrite?: boolean; + EntityWriteStream?: boolean; + EntityWriteChunk?: boolean; + // Node capabilities + NodeList?: boolean; + NodeListFilter?: boolean | Record; + NodeListSort?: boolean | string[]; + NodeListRange?: boolean | Record; + NodeDelta?: boolean; + + [key: string]: boolean | object | string | string[] | undefined; } -export type ServiceRecord = Record; +/** + * Service information + */ +export interface ServiceInterface { + '@type': string; + provider: string; + identifier: string | number | null; + label: string | null; + enabled: boolean; + capabilities?: ServiceCapabilitiesInterface; + location?: ServiceLocation | null; + identity?: ServiceIdentity | null; + auxiliary?: Record; // Provider-specific extension data +} + +/** + * Service list + */ +export interface ServiceListRequest { + sources?: SourceSelector; +} + +export interface ServiceListResponse { + [provider: string]: { + [identifier: string]: ServiceInterface; + }; +} + +/** + * Service fetch + */ +export interface ServiceFetchRequest { + provider: string; + identifier: string | number; +} + +export interface ServiceFetchResponse extends ServiceInterface {} + +/** + * Service extant + */ +export interface ServiceExtantRequest { + sources: SourceSelector; +} + +export interface ServiceExtantResponse { + [provider: string]: { + [identifier: string]: boolean; + }; +} + +/** + * Service create + */ +export interface ServiceCreateRequest { + provider: string; + data: Partial; +} + +export interface ServiceCreateResponse extends ServiceInterface {} + +/** + * Service update + */ +export interface ServiceUpdateRequest { + provider: string; + identifier: string | number; + data: Partial; +} + +export interface ServiceUpdateResponse extends ServiceInterface {} + +/** + * Service delete + */ +export interface ServiceDeleteRequest { + provider: string; + identifier: string | number; +} + +export interface ServiceDeleteResponse {} + +/** + * Service discovery + */ +export interface ServiceDiscoverRequest { + identity: string; // Email address or domain + provider?: string; // Optional: specific provider ('jmap', 'smtp', etc.) or null for all + location?: string; // Optional: known hostname (bypasses DNS lookup) + secret?: string; // Optional: password/token for credential validation +} + +export interface ServiceDiscoverResponse { + [provider: string]: ServiceLocation; // Uses existing ServiceLocation discriminated union +} + +/** + * Service connection test + */ +export interface ServiceTestRequest { + provider: string; + // For existing service + identifier?: string | number | null; + // For fresh configuration + location?: ServiceLocation | null; + identity?: ServiceIdentity | null; +} + +export interface ServiceTestResponse { + success: boolean; + message: string; +} + +/** + * Service location - Base + */ +export interface ServiceLocationBase { + type: 'URI' | 'FILE'; +} + +/** + * Service location - URI-based type + */ +export interface ServiceLocationUri extends ServiceLocationBase { + type: 'URI'; + scheme: string; // e.g., 'https', 'http' + host: string; // e.g., 'api.example.com' + port: number; // e.g., 443 + path?: string; // e.g., '/v1/api' + verifyPeer?: boolean; // Verify SSL/TLS peer certificate + verifyHost?: boolean; // Verify SSL/TLS certificate host +} + +/** + * Service location - File-based type (e.g., for local mail delivery or Unix socket) + */ +export interface ServiceLocationFile extends ServiceLocationBase { + type: 'FILE'; + path: string; // File system path +} + +/** + * Service location types + */ +export type ServiceLocation = + | ServiceLocationUri + | ServiceLocationFile; + +/** + * Service identity - base + */ +export interface ServiceIdentityBase { + type: 'NA' | 'BA' | 'TA' | 'OA' | 'CC'; +} + +/** + * Service identity - No authentication + */ +export interface ServiceIdentityNone extends ServiceIdentityBase { + type: 'NA'; +} + +/** + * Service identity - Basic authentication type + */ +export interface ServiceIdentityBasic extends ServiceIdentityBase { + type: 'BA'; + identity: string; // Username/email + secret: string; // Password +} + +/** + * Token authentication (API key, static token) + */ +export interface ServiceIdentityToken extends ServiceIdentityBase { + type: 'TA'; + token: string; // Authentication token/API key +} + +/** + * OAuth authentication + */ +export interface ServiceIdentityOAuth extends ServiceIdentityBase { + type: 'OA'; + accessToken: string; // Current access token + accessScope?: string[]; // Token scopes + accessExpiry?: number; // Unix timestamp when token expires + refreshToken?: string; // Refresh token for getting new access tokens + refreshLocation?: string; // Token refresh endpoint URL +} + +/** + * Client certificate authentication (mTLS) + */ +export interface ServiceIdentityCertificate extends ServiceIdentityBase { + type: 'CC'; + certificate: string; // X.509 certificate (PEM format or file path) + privateKey: string; // Private key (PEM format or file path) + passphrase?: string; // Optional passphrase for encrypted private key +} + +/** + * Service identity configuration + * Discriminated union of all identity types + */ +export type ServiceIdentity = + | ServiceIdentityNone + | ServiceIdentityBasic + | ServiceIdentityToken + | ServiceIdentityOAuth + | ServiceIdentityCertificate; + +/** + * List filter specification format + * + * Format: "type:length:defaultComparator:supportedComparators" + * + * Examples: + * - "s:200:256:771" = String field, max 200 chars, default LIKE, supports EQ|NEQ|LIKE|NLIKE + * - "a:10:64:192" = Array field, max 10 items, default IN, supports IN|NIN + * - "i:0:1:31" = Integer field, default EQ, supports EQ|NEQ|GT|LT|GTE|LTE + * + * Type codes: + * - s = string + * - i = integer + * - b = boolean + * - a = array + * + * Comparator values are bitmasks that can be combined + */ +export type ServiceListFilterCollection = { + 'label'?: string; + [attribute: string]: string | undefined; +}; + +export type ServiceListFilterEntity = { + 'text'?: string; + 'label'?: string; + [attribute: string]: string | undefined; +} + +/** + * Service list sort specification + */ +export type ServiceListSortCollection = ("label" | string)[]; +export type ServiceListSortEntity = ( "label" | string)[]; + +export type ServiceListRange = { + 'tally'?: string[]; +}; + + +export interface ServiceListFilterDefinition { + type: 'string' | 'integer' | 'date' | 'boolean' | 'array'; + length: number; + defaultComparator: ListFilterComparisonOperator; + supportedComparators: ListFilterComparisonOperator[]; +}