commit db42b6699c4c4b7949022e8e5dac161b4e6a6f4b Author: root Date: Sun Dec 21 09:57:43 2025 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..812e4cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Frontend development +node_modules/ +*.local +.env.local +.env.*.local +.cache/ +.vite/ +.temp/ +.tmp/ + +# Frontend build +/static/ + +# Backend development +/lib/vendor/ +coverage/ +phpunit.xml.cache +.phpunit.result.cache +.php-cs-fixer.cache +.phpstan.cache +.phpactor/ + +# Editors +.DS_Store +.vscode/ +.idea/ + +# Logs +*.log diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..7a7b90a --- /dev/null +++ b/composer.json @@ -0,0 +1,26 @@ +{ + "name": "ktxm/file-manager", + "type": "project", + "authors": [ + { + "name": "Sebastian Krupinski", + "email": "krupinski01@gmail.com" + } + ], + "config": { + "optimize-autoloader": true, + "platform": { + "php": "8.2" + }, + "autoloader-suffix": "FileManager", + "vendor-dir": "lib/vendor" + }, + "require": { + "php": ">=8.2 <=8.5" + }, + "autoload": { + "psr-4": { + "KTXM\\FileManager\\": "lib/" + } + } +} diff --git a/lib/Controllers/DefaultController.php b/lib/Controllers/DefaultController.php new file mode 100644 index 0000000..6d3ec5b --- /dev/null +++ b/lib/Controllers/DefaultController.php @@ -0,0 +1,720 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\FileManager\Controllers; + +use InvalidArgumentException; +use KTXC\Http\Response\JsonResponse; +use KTXC\SessionIdentity; +use KTXC\SessionTenant; +use KTXF\Controller\ControllerAbstract; +use KTXF\Resource\Selector\SourceSelector; +use KTXF\Routing\Attributes\AuthenticatedRoute; +use KTXM\FileManager\Manager; +use Psr\Log\LoggerInterface; +use Throwable; + +class DefaultController extends ControllerAbstract { + + public function __construct( + private readonly SessionTenant $tenantIdentity, + private readonly SessionIdentity $userIdentity, + private Manager $fileManager, + 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 { + + // 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); + } catch (Throwable $t) { + $this->logger->error('Error processing file manager request', ['exception' => $t]); + return new JsonResponse([ + 'version' => $version, + 'transaction' => $transaction, + 'operation' => $operation, + 'status' => 'error', + 'error' => [ + 'code' => $t->getCode(), + 'message' => $t->getMessage() + ] + ], JsonResponse::HTTP_INTERNAL_SERVER_ERROR); + } + } + + 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.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), + // 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.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.copy' => $this->entityCopy($tenantId, $userId, $data), + 'entity.move' => $this->entityMove($tenantId, $userId, $data), + 'entity.write' => $this->entityWrite($tenantId, $userId, $data), + // Node operations (unified recursive) + 'node.list' => $this->nodeList($tenantId, $userId, $data), + 'node.delta' => $this->nodeDelta($tenantId, $userId, $data), + default => throw new InvalidArgumentException('Unknown operation: ' . $operation) + }; + + } + + // ==================== Provider Operations ==================== + + 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); + + } + + 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 { + + 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'); + } + // retrieve service + return $this->fileManager->serviceFetch($tenantId, $userId, $data['provider'], $data['identifier']); + + } + + // ==================== Collection Operations ==================== + + private function collectionList(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'); + } + + $provider = $data['provider']; + $service = $data['service']; + $location = $data['location'] ?? null; + $filter = $data['filter'] ?? null; + $sort = $data['sort'] ?? null; + + return $this->fileManager->collectionList( + $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['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'); + } + if (!isset($data['service'])) { + throw new InvalidArgumentException('Missing required parameter: service'); + } + if (!isset($data['identifier'])) { + throw new InvalidArgumentException('Missing required parameter: identifier'); + } + + return [ + 'success' => $this->fileManager->collectionDestroy( + $tenantId, + $userId, + $data['provider'], + $data['service'], + $data['identifier'] + ) + ]; + } + + private function collectionCopy(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'); + } + + $location = $data['location'] ?? null; + + return $this->fileManager->collectionCopy( + $tenantId, + $userId, + $data['provider'], + $data['service'], + $data['identifier'], + $location + ); + } + + private function collectionMove(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'); + } + + $location = $data['location'] ?? null; + + return $this->fileManager->collectionMove( + $tenantId, + $userId, + $data['provider'], + $data['service'], + $data['identifier'], + $location + ); + } + + // ==================== Entity Operations ==================== + + private function entityList(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['collection'])) { + throw new InvalidArgumentException('Missing required parameter: collection'); + } + + $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, + $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['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'); + } + 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->entityFetch( + $tenantId, + $userId, + $data['provider'], + $data['service'], + $data['collection'], + $data['identifiers'] + ); + } + + 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 { + 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'); + } + + $collection = $data['collection'] ?? null; + $options = $data['options'] ?? []; + + return $this->fileManager->entityCreate( + $tenantId, + $userId, + $data['provider'], + $data['service'], + $collection, + $data['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'] + ); + } + + 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'); + } + if (!isset($data['service'])) { + throw new InvalidArgumentException('Missing required parameter: service'); + } + if (!isset($data['identifier'])) { + throw new InvalidArgumentException('Missing required parameter: identifier'); + } + + $collection = $data['collection'] ?? null; + + return [ + 'success' => $this->fileManager->entityDestroy( + $tenantId, + $userId, + $data['provider'], + $data['service'], + $collection, + $data['identifier'] + ) + ]; + } + + private function entityCopy(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'); + } + + $collection = $data['collection'] ?? null; + $destination = $data['destination'] ?? null; + + return $this->fileManager->entityCopy( + $tenantId, + $userId, + $data['provider'], + $data['service'], + $collection, + $data['identifier'], + $destination + ); + } + + private function entityMove(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'); + } + + $collection = $data['collection'] ?? null; + $destination = $data['destination'] ?? null; + + return $this->fileManager->entityMove( + $tenantId, + $userId, + $data['provider'], + $data['service'], + $collection, + $data['identifier'], + $destination + ); + } + + 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'); + } + 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['content'])) { + throw new InvalidArgumentException('Missing required parameter: content'); + } + + // 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->fileManager->entityWrite( + $tenantId, + $userId, + $data['provider'], + $data['service'], + $data['collection'], + $data['identifier'], + $content + ); + + return [ + 'bytesWritten' => $bytesWritten + ]; + } + + // ==================== 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['service'])) { + throw new InvalidArgumentException('Invalid service identifier provided'); + } + + $provider = $data['provider']; + $service = $data['service']; + $location = $data['location'] ?? null; + $recursive = $data['recursive'] ?? false; + $filter = $data['filter'] ?? null; + $sort = $data['sort'] ?? null; + $range = $data['range'] ?? null; + + return $this->fileManager->nodeList( + $tenantId, + $userId, + $provider, + $service, + $location, + $recursive, + $filter, + $sort, + $range + ); + } + + private function nodeDelta(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['signature']) || !is_string($data['signature'])) { + throw new InvalidArgumentException('Missing required parameter: signature'); + } + + $location = $data['location'] ?? null; + $recursive = $data['recursive'] ?? false; + $detail = $data['detail'] ?? 'ids'; + + return $this->fileManager->nodeDelta( + $tenantId, + $userId, + $data['provider'], + $data['service'], + $location, + $data['signature'], + $recursive, + $detail + ); + } + +} diff --git a/lib/Controllers/TransferController.php b/lib/Controllers/TransferController.php new file mode 100644 index 0000000..840ddde --- /dev/null +++ b/lib/Controllers/TransferController.php @@ -0,0 +1,488 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\FileManager\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 Psr\Log\LoggerInterface; +use Throwable; + +/** + * Controller for file transfers (downloads and uploads) + * + * Handles binary file transfers that don't fit the JSON API pattern: + * - Single file downloads (streamed) + * - Multi-file downloads as ZIP (streamed) + * - Folder downloads as ZIP with structure preserved (streamed) + * - Large file uploads (future) + * - ZIP file uploads with extraction (future) + */ +class TransferController extends ControllerAbstract +{ + public function __construct( + private readonly SessionTenant $tenantIdentity, + private readonly SessionIdentity $userIdentity, + private Manager $fileManager, + private readonly LoggerInterface $logger + ) {} + + /** + * Download a single file + * + * GET /download/entity/{provider}/{service}/{collection}/{identifier} + */ + #[AuthenticatedRoute( + '/download/entity/{provider}/{service}/{collection}/{identifier}', + name: 'filemanager.download.entity', + methods: ['GET'] + )] + 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( + $tenantId, + $userId, + $provider, + $service, + $collection, + [$identifier] + ); + + if (empty($entities) || !isset($entities[$identifier])) { + return new JsonResponse([ + 'status' => 'error', + 'error' => ['code' => 404, 'message' => 'File not found'] + ], Response::HTTP_NOT_FOUND); + } + + /** @var INodeEntityBase $entity */ + $entity = $entities[$identifier]; + + // Get the stream + $stream = $this->fileManager->entityReadStream( + $tenantId, + $userId, + $provider, + $service, + $collection, + $identifier + ); + + if ($stream === null) { + return new JsonResponse([ + 'status' => 'error', + 'error' => ['code' => 404, 'message' => 'File content not available'] + ], Response::HTTP_NOT_FOUND); + } + + $filename = $entity->getLabel() ?? 'download'; + $mime = $entity->getMime() ?? 'application/octet-stream'; + $size = $entity->size(); + + // Create streamed response + $response = new StreamedResponse(function () use ($stream) { + while (!feof($stream)) { + echo fread($stream, 65536); + @ob_flush(); + flush(); + } + fclose($stream); + }); + + $response->headers->set('Content-Type', $mime); + $response->headers->set('Content-Length', (string) $size); + $response->headers->set('Content-Disposition', + $response->headers->makeDisposition('attachment', $filename, $this->asciiFallback($filename)) + ); + $response->headers->set('Cache-Control', 'private, no-cache'); + + return $response; + + } catch (Throwable $t) { + $this->logger->error('Download failed', ['exception' => $t]); + return new JsonResponse([ + 'status' => 'error', + 'error' => ['code' => $t->getCode(), 'message' => $t->getMessage()] + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + /** + * Download multiple files as a ZIP archive + * + * GET /download/archive?provider=...&service=...&ids[]=...&ids[]=... + */ + #[AuthenticatedRoute( + '/download/archive', + name: 'filemanager.download.archive', + methods: ['GET'] + )] + public function downloadArchive( + string $provider, + string $service, + array $ids = [], + string $collection = null, + string $name = 'download' + ): Response { + $tenantId = $this->tenantIdentity->identifier(); + $userId = $this->userIdentity->identifier(); + + if (empty($ids)) { + return new JsonResponse([ + 'status' => 'error', + 'error' => ['code' => 400, 'message' => 'No file IDs provided'] + ], Response::HTTP_BAD_REQUEST); + } + + try { + // Build list of files to include + $files = $this->resolveFilesForArchive( + $tenantId, + $userId, + $provider, + $service, + $collection, + $ids + ); + + if (empty($files)) { + return new JsonResponse([ + 'status' => 'error', + 'error' => ['code' => 404, 'message' => 'No files found'] + ], Response::HTTP_NOT_FOUND); + } + + $archiveName = $this->sanitizeFilename($name) . '.zip'; + + // Create streamed ZIP response + $response = new StreamedResponse(function () use ($files, $tenantId, $userId, $provider, $service) { + $zip = new StreamingZip(null, false); // No compression for speed + + foreach ($files as $file) { + $stream = $this->fileManager->entityReadStream( + $tenantId, + $userId, + $provider, + $service, + $file['collection'], + $file['id'] + ); + + if ($stream !== null) { + $zip->addFileFromStream( + $file['path'], + $stream, + $file['modTime'] ?? null + ); + fclose($stream); + } + } + + $zip->finish(); + }); + + $response->headers->set('Content-Type', 'application/zip'); + $response->headers->set('Content-Disposition', + $response->headers->makeDisposition('attachment', $archiveName, $this->asciiFallback($archiveName)) + ); + $response->headers->set('Cache-Control', 'private, no-cache'); + // No Content-Length since we're streaming + + return $response; + + } catch (Throwable $t) { + $this->logger->error('Archive download failed', ['exception' => $t]); + return new JsonResponse([ + 'status' => 'error', + 'error' => ['code' => $t->getCode(), 'message' => $t->getMessage()] + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + /** + * Download a collection (folder) as a ZIP archive with structure preserved + * + * GET /download/collection/{provider}/{service}/{identifier} + */ + #[AuthenticatedRoute( + '/download/collection/{provider}/{service}/{identifier}', + name: 'filemanager.download.collection', + methods: ['GET'] + )] + 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( + $tenantId, + $userId, + $provider, + $service, + $identifier + ); + + if ($collection === null) { + return new JsonResponse([ + 'status' => 'error', + 'error' => ['code' => 404, 'message' => 'Folder not found'] + ], Response::HTTP_NOT_FOUND); + } + + $folderName = $collection->getLabel() ?? 'folder'; + $archiveName = $this->sanitizeFilename($folderName) . '.zip'; + + // Build recursive file list + $files = $this->resolveCollectionContents( + $tenantId, + $userId, + $provider, + $service, + $identifier, + '' // Base path (root of archive) + ); + + // Create streamed ZIP response + $response = new StreamedResponse(function () use ($files, $tenantId, $userId, $provider, $service) { + $zip = new StreamingZip(null, false); + + foreach ($files as $file) { + if ($file['type'] === 'directory') { + $zip->addDirectory($file['path'], $file['modTime'] ?? null); + } else { + $stream = $this->fileManager->entityReadStream( + $tenantId, + $userId, + $provider, + $service, + $file['collection'], + $file['id'] + ); + + if ($stream !== null) { + $zip->addFileFromStream( + $file['path'], + $stream, + $file['modTime'] ?? null + ); + fclose($stream); + } + } + } + + $zip->finish(); + }); + + $response->headers->set('Content-Type', 'application/zip'); + $response->headers->set('Content-Disposition', + $response->headers->makeDisposition('attachment', $archiveName, $this->asciiFallback($archiveName)) + ); + $response->headers->set('Cache-Control', 'private, no-cache'); + + return $response; + + } catch (Throwable $t) { + $this->logger->error('Collection download failed', ['exception' => $t]); + return new JsonResponse([ + 'status' => 'error', + 'error' => ['code' => $t->getCode(), 'message' => $t->getMessage()] + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + + /** + * 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 { + $files = []; + + foreach ($ids as $id) { + // Try as entity first + if ($collection !== null) { + $entities = $this->fileManager->entityFetch( + $tenantId, + $userId, + $provider, + $service, + $collection, + [$id] + ); + + if (!empty($entities) && isset($entities[$id])) { + /** @var INodeEntityBase $entity */ + $entity = $entities[$id]; + $files[] = [ + 'type' => 'file', + 'id' => $id, + 'collection' => $collection, + 'path' => $entity->getLabel() ?? $id, + 'modTime' => $entity->modifiedOn()?->getTimestamp(), + ]; + continue; + } + } + + // Try as collection (folder) + $collectionNode = $this->fileManager->collectionFetch( + $tenantId, + $userId, + $provider, + $service, + $id + ); + + if ($collectionNode !== null) { + $folderName = $collectionNode->getLabel() ?? $id; + $subFiles = $this->resolveCollectionContents( + $tenantId, + $userId, + $provider, + $service, + $id, + $folderName + ); + $files = array_merge($files, $subFiles); + } + } + + return $files; + } + + /** + * Recursively resolve all contents of a collection into a flat file list + */ + private function resolveCollectionContents( + string $tenantId, + string $userId, + string $provider, + string $service, + string $collectionId, + string $basePath + ): array { + $files = []; + + // Add directory entry if we have a path + if ($basePath !== '') { + $files[] = [ + 'type' => 'directory', + 'path' => $basePath, + 'modTime' => null, + ]; + } + + // Get all nodes in this collection using nodeList with recursive=false + // We handle recursion ourselves to build proper paths + try { + $nodes = $this->fileManager->nodeList( + $tenantId, + $userId, + $provider, + $service, + $collectionId, + false // Not recursive - we handle it ourselves + ); + } catch (Throwable $e) { + $this->logger->warning('Failed to list collection contents', [ + 'collection' => $collectionId, + 'exception' => $e + ]); + return $files; + } + + foreach ($nodes as $node) { + $nodeName = $node->getLabel() ?? (string) $node->id(); + $nodePath = $basePath !== '' ? $basePath . '/' . $nodeName : $nodeName; + + if ($node->isCollection()) { + // Recursively get contents of sub-collection + $subFiles = $this->resolveCollectionContents( + $tenantId, + $userId, + $provider, + $service, + (string) $node->id(), + $nodePath + ); + $files = array_merge($files, $subFiles); + } else { + // It's an entity (file) + /** @var INodeEntityBase $node */ + $files[] = [ + 'type' => 'file', + 'id' => (string) $node->id(), + 'collection' => $collectionId, + 'path' => $nodePath, + 'modTime' => $node->modifiedOn()?->getTimestamp(), + ]; + } + } + + return $files; + } + + /** + * Sanitize a filename for use in Content-Disposition header + */ + private function sanitizeFilename(string $filename): string + { + // Remove or replace problematic characters + $filename = preg_replace('/[<>:"\/\\|?*\x00-\x1F]/', '_', $filename); + $filename = trim($filename, '. '); + + if ($filename === '') { + $filename = 'download'; + } + + return $filename; + } + + /** + * Create an ASCII-safe fallback filename + */ + private function asciiFallback(string $filename): string + { + // Transliterate to ASCII + $fallback = transliterator_transliterate('Any-Latin; Latin-ASCII', $filename); + if ($fallback === false) { + $fallback = preg_replace('/[^\x20-\x7E]/', '_', $filename); + } + return $this->sanitizeFilename($fallback); + } +} diff --git a/lib/Manager.php b/lib/Manager.php new file mode 100644 index 0000000..9abc677 --- /dev/null +++ b/lib/Manager.php @@ -0,0 +1,748 @@ + collection of available providers + */ + 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; + } + + /** + * Retrieve specific provider for specific user + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param string $provider provider identifier + * + * @return IProviderBase + * @throws InvalidArgumentException + */ + public function providerFetch(string $tenantId, string $userId, string $provider): IProviderBase { + // retrieve provider + $providers = $this->providerList($tenantId, $userId, new SourceSelector([$provider => true])); + if (!isset($providers[$provider])) { + throw new InvalidArgumentException("Provider '$provider' not found"); + } + return $providers[$provider]; + } + + // ==================== Service Operations ==================== + + /** + * Retrieve available services for specific user + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param SourceSelector|null $sources list of provider and service identifiers + * + * @return array> collections of available services + */ + public function serviceList(string $tenantId, string $userId, ?SourceSelector $sources = null): array { + // retrieve providers + $providers = $this->providerList($tenantId, $userId, $sources); + // retrieve services for each provider + $responseData = []; + foreach ($providers as $provider) { + $serviceFilter = $sources[$provider->id()] instanceof ServiceSelector ? $sources[$provider->id()]->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; + } + return $responseData; + } + + /** + * Retrieve service for specific user + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param string $providerId provider identifier + * @param string|int $serviceId service identifier + * + * @return IServiceBase + * @throws InvalidArgumentException + */ + public function serviceFetch(string $tenantId, string $userId, string $providerId, string|int $serviceId): IServiceBase { + // 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'"); + } + return $service; + } + + // ==================== Collection Operations ==================== + + /** + * List collections at a location + * + * @return array + */ + 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); + } + } + + // 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); + } + + /** + * Check if collection exists + */ + 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); + } + + /** + * Fetch a specific collection + */ + public function collectionFetch( + string $tenantId, + string $userId, + string $providerId, + string|int $serviceId, + string|int $identifier + ): ?INodeCollectionBase { + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + return $service->collectionFetch($identifier); + } + + /** + * Create a new collection + */ + public function collectionCreate( + string $tenantId, + string $userId, + string $providerId, + string|int $serviceId, + string|int|null $location, + INodeCollectionMutable|array $collection, + array $options = [] + ): INodeCollectionBase { + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + + if (!$service instanceof IServiceCollectionMutable) { + throw new InvalidArgumentException('Service does not support collection creation'); + } + + if (is_array($collection)) { + $collectionObject = $service->collectionFresh(); + $collectionObject->jsonDeserialize($collection); + $collection = $collectionObject; + } + + return $service->collectionCreate($location, $collection, $options); + } + + /** + * Modify an existing collection + */ + public function collectionModify( + string $tenantId, + string $userId, + string $providerId, + string|int $serviceId, + string|int $identifier, + INodeCollectionMutable|array $collection + ): INodeCollectionBase { + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + + if (!$service instanceof IServiceCollectionMutable) { + throw new InvalidArgumentException('Service does not support collection modification'); + } + + if (is_array($collection)) { + $collectionObject = $service->collectionFresh(); + $collectionObject->jsonDeserialize($collection); + $collection = $collectionObject; + } + + return $service->collectionModify($identifier, $collection); + } + + /** + * Destroy a collection + */ + public function collectionDestroy( + string $tenantId, + string $userId, + string $providerId, + string|int $serviceId, + string|int $identifier + ): bool { + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + + if (!$service instanceof IServiceCollectionMutable) { + throw new InvalidArgumentException('Service does not support collection deletion'); + } + + return $service->collectionDestroy($identifier); + } + + /** + * Copy a collection + */ + public function collectionCopy( + string $tenantId, + string $userId, + string $providerId, + string|int $serviceId, + string|int $identifier, + string|int|null $location + ): INodeCollectionBase { + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + + if (!$service instanceof IServiceCollectionMutable) { + throw new InvalidArgumentException('Service does not support collection copy'); + } + + return $service->collectionCopy($identifier, $location); + } + + /** + * Move a collection + */ + public function collectionMove( + string $tenantId, + string $userId, + string $providerId, + string|int $serviceId, + string|int $identifier, + string|int|null $location + ): INodeCollectionBase { + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + + if (!$service instanceof IServiceCollectionMutable) { + throw new InvalidArgumentException('Service does not support collection move'); + } + + return $service->collectionMove($identifier, $location); + } + + // ==================== Entity Operations ==================== + + /** + * List entities in a collection + * + * @return array + */ + 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 { + $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); + } + + /** + * Get entity delta/changes since a signature + */ + 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); + } + + /** + * Check if entities exist + * + * @return array + */ + 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); + } + + /** + * Fetch specific entities + * + * @return array + */ + public function entityFetch( + string $tenantId, + string $userId, + string $providerId, + string|int $serviceId, + string|int $collection, + array $identifiers + ): array { + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + return $service->entityFetch($collection, ...$identifiers); + } + + /** + * Read entity content + */ + public function entityRead( + string $tenantId, + string $userId, + string $providerId, + string|int $serviceId, + string|int $collection, + string|int $identifier + ): ?string { + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + return $service->entityRead($collection, $identifier); + } + + /** + * Read entity content as stream + * + * @return resource|null + */ + public function entityReadStream( + string $tenantId, + string $userId, + string $providerId, + string|int $serviceId, + string|int $collection, + string|int $identifier + ) { + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + return $service->entityReadStream($collection, $identifier); + } + + /** + * Read entity content chunk + */ + public function entityReadChunk( + string $tenantId, + string $userId, + string $providerId, + string|int $serviceId, + string|int $collection, + string|int $identifier, + int $offset, + int $length + ): ?string { + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + 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 + */ + public function entityWrite( + string $tenantId, + string $userId, + string $providerId, + string|int $serviceId, + string|int|null $collection, + string|int $identifier, + string $data + ): int { + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + + if (!$service instanceof IServiceEntityMutable) { + throw new InvalidArgumentException('Service does not support entity write'); + } + + return $service->entityWrite($collection, $identifier, $data); + } + + /** + * Write entity content from stream + * + * @return resource|null + */ + public function entityWriteStream( + string $tenantId, + string $userId, + string $providerId, + string|int $serviceId, + string|int $collection, + string|int $identifier + ) { + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + + if (!$service instanceof IServiceEntityMutable) { + throw new InvalidArgumentException('Service does not support entity write stream'); + } + + return $service->entityWriteStream($collection, $identifier); + } + + /** + * Write entity content chunk + */ + public function entityWriteChunk( + string $tenantId, + string $userId, + string $providerId, + string|int $serviceId, + string|int $collection, + string|int $identifier, + int $offset, + string $data + ): int { + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + + if (!$service instanceof IServiceEntityMutable) { + throw new InvalidArgumentException('Service does not support entity write chunk'); + } + + return $service->entityWriteChunk($collection, $identifier, $offset, $data); + } + + // ==================== Node Operations (Unified/Recursive) ==================== + + /** + * List nodes (collections and entities) at a location + * + * @return array + */ + public function nodeList( + string $tenantId, + string $userId, + string $providerId, + string|int $serviceId, + string|int|null $location = null, + bool $recursive = false, + ?array $filter = null, + ?array $sort = null, + ?array $range = null + ): array { + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + + // construct filter + $nodeFilter = null; + if ($filter !== null && $filter !== []) { + $nodeFilter = $service->nodeListFilter(); + foreach ($filter as $attribute => $value) { + $nodeFilter->condition($attribute, $value); + } + } + + // construct sort + $nodeSort = null; + if ($sort !== null && $sort !== []) { + $nodeSort = $service->nodeListSort(); + foreach ($sort as $attribute => $direction) { + $nodeSort->condition($attribute, $direction); + } + } + + // construct range + $nodeRange = null; + if ($range !== null && $range !== [] && isset($range['type'])) { + $nodeRange = $service->nodeListRange(RangeType::from($range['type'])); + if ($nodeRange instanceof IRangeTally) { + if (isset($range['anchor'])) { + $nodeRange->setAnchor(RangeAnchorType::from($range['anchor'])); + } + if (isset($range['position'])) { + $nodeRange->setPosition($range['position']); + } + if (isset($range['tally'])) { + $nodeRange->setTally($range['tally']); + } + } + } + + return $service->nodeList($location, $recursive, $nodeFilter, $nodeSort, $nodeRange); + } + + /** + * Get node delta/changes since a signature + */ + public function nodeDelta( + string $tenantId, + string $userId, + string $providerId, + string|int $serviceId, + string|int|null $location, + string $signature, + bool $recursive = false, + string $detail = 'ids' + ): array { + $service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId); + return $service->nodeDelta($location, $signature, $recursive, $detail); + } +} diff --git a/lib/Module.php b/lib/Module.php new file mode 100644 index 0000000..14e11b9 --- /dev/null +++ b/lib/Module.php @@ -0,0 +1,65 @@ + [ + 'label' => 'Access File Manager', + 'description' => 'View and access the file manager module', + 'group' => 'File Management' + ], + ]; + } + + public function registerBI(): array { + return [ + 'handle' => $this->handle(), + 'namespace' => 'FileManager', + 'version' => $this->version(), + 'label' => $this->label(), + 'author' => $this->author(), + 'description' => $this->description(), + 'boot' => 'static/module.mjs', + ]; + } + +} diff --git a/lib/Transfer/StreamingZip.php b/lib/Transfer/StreamingZip.php new file mode 100644 index 0000000..b8fe59d --- /dev/null +++ b/lib/Transfer/StreamingZip.php @@ -0,0 +1,285 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\FileManager\Transfer; + +/** + * Native PHP streaming ZIP archive generator + * + * Creates ZIP files on-the-fly without requiring external libraries. + * Streams directly to output, minimizing memory usage. + * + * Supports: + * - Store method (no compression) for maximum streaming efficiency + * - Deflate compression for smaller archives + * - Data descriptors for streaming without knowing file size upfront + * - Files up to 4GB (ZIP64 not implemented) + */ +class StreamingZip +{ + /** @var resource Output stream */ + private $output; + + /** @var array Central directory entries */ + private array $centralDirectory = []; + + /** @var int Current byte offset in the archive */ + private int $offset = 0; + + /** @var bool Whether to use deflate compression */ + private bool $compress; + + /** @var int Compression level (1-9) */ + private int $compressionLevel; + + /** + * @param resource|null $output Output stream (defaults to php://output) + * @param bool $compress Whether to compress files (deflate) + * @param int $compressionLevel Compression level 1-9 (only used if compress=true) + */ + public function __construct($output = null, bool $compress = false, int $compressionLevel = 6) + { + $this->output = $output ?? fopen('php://output', 'wb'); + $this->compress = $compress; + $this->compressionLevel = $compressionLevel; + } + + /** + * Add a file from a stream resource + * + * @param string $path Path/name within the ZIP archive + * @param resource $stream File content stream + * @param int|null $modTime Unix timestamp for modification time (null = now) + */ + public function addFileFromStream(string $path, $stream, ?int $modTime = null): void + { + $modTime = $modTime ?? time(); + $dosTime = $this->unixToDosTime($modTime); + + // Read entire file content first (needed for accurate CRC) + $content = ''; + while (!feof($stream)) { + $chunk = fread($stream, 65536); + if ($chunk === false || $chunk === '') { + break; + } + $content .= $chunk; + } + + $uncompressedSize = strlen($content); + $crcValue = crc32($content); + + // Compress if needed + if ($this->compress && $uncompressedSize > 0) { + $compressed = gzdeflate($content, $this->compressionLevel); + $compressedSize = strlen($compressed); + $method = 0x0008; // Deflate + } else { + $compressed = $content; + $compressedSize = $uncompressedSize; + $method = 0x0000; // Store + } + + // General purpose flags + $gpFlags = 0x0000; + + // UTF-8 filename flag + if (preg_match('//u', $path)) { + $gpFlags |= 0x0800; // Bit 11: UTF-8 filename + } + + $pathBytes = $path; + + // Local file header + $localHeader = pack('V', 0x04034b50); // Local file header signature + $localHeader .= pack('v', 20); // Version needed to extract (2.0) + $localHeader .= pack('v', $gpFlags); // General purpose bit flag + $localHeader .= pack('v', $method); // Compression method + $localHeader .= pack('V', $dosTime); // Last mod time & date + $localHeader .= pack('V', $crcValue); // CRC-32 + $localHeader .= pack('V', $compressedSize); // Compressed size + $localHeader .= pack('V', $uncompressedSize); // Uncompressed size + $localHeader .= pack('v', strlen($pathBytes)); // File name length + $localHeader .= pack('v', 0); // Extra field length + $localHeader .= $pathBytes; // File name + + fwrite($this->output, $localHeader); + fwrite($this->output, $compressed); + + $headerLen = strlen($localHeader); + + // Store entry for central directory + $this->centralDirectory[] = [ + 'path' => $pathBytes, + 'crc' => $crcValue, + 'compressedSize' => $compressedSize, + 'uncompressedSize' => $uncompressedSize, + 'method' => $method, + 'dosTime' => $dosTime, + 'gpFlags' => $gpFlags, + 'offset' => $this->offset, + ]; + + $this->offset += $headerLen + $compressedSize; + + // Free memory + unset($content, $compressed); + + @flush(); + } + + /** + * Add a file from a string + * + * @param string $path Path/name within the ZIP archive + * @param string $content File content + * @param int|null $modTime Unix timestamp for modification time + */ + public function addFileFromString(string $path, string $content, ?int $modTime = null): void + { + $stream = fopen('php://temp', 'r+b'); + fwrite($stream, $content); + rewind($stream); + + $this->addFileFromStream($path, $stream, $modTime); + + fclose($stream); + } + + /** + * Add an empty directory entry + * + * @param string $path Directory path (will have / appended if missing) + * @param int|null $modTime Unix timestamp + */ + public function addDirectory(string $path, ?int $modTime = null): void + { + if (!str_ends_with($path, '/')) { + $path .= '/'; + } + + $modTime = $modTime ?? time(); + $dosTime = $this->unixToDosTime($modTime); + + $gpFlags = 0x0000; + if (preg_match('//u', $path)) { + $gpFlags |= 0x0800; + } + + $pathBytes = $path; + + // Local file header for directory + $localHeader = pack('V', 0x04034b50); + $localHeader .= pack('v', 20); + $localHeader .= pack('v', $gpFlags); + $localHeader .= pack('v', 0); // Store method + $localHeader .= pack('V', $dosTime); + $localHeader .= pack('V', 0); // CRC + $localHeader .= pack('V', 0); // Compressed size + $localHeader .= pack('V', 0); // Uncompressed size + $localHeader .= pack('v', strlen($pathBytes)); + $localHeader .= pack('v', 0); + $localHeader .= $pathBytes; + + fwrite($this->output, $localHeader); + $headerLen = strlen($localHeader); + + $this->centralDirectory[] = [ + 'path' => $pathBytes, + 'crc' => 0, + 'compressedSize' => 0, + 'uncompressedSize' => 0, + 'method' => 0, + 'dosTime' => $dosTime, + 'gpFlags' => $gpFlags, + 'offset' => $this->offset, + 'externalAttr' => 0x10, // Directory attribute + ]; + + $this->offset += $headerLen; + + @flush(); + } + + /** + * Finalize and close the ZIP archive + */ + public function finish(): void + { + $cdOffset = $this->offset; + $cdSize = 0; + + // Write central directory + foreach ($this->centralDirectory as $entry) { + $record = $this->buildCentralDirectoryEntry($entry); + fwrite($this->output, $record); + $cdSize += strlen($record); + } + + // End of central directory record + $eocd = pack('V', 0x06054b50); // EOCD signature + $eocd .= pack('v', 0); // Disk number + $eocd .= pack('v', 0); // Disk with CD + $eocd .= pack('v', count($this->centralDirectory)); // Entries on this disk + $eocd .= pack('v', count($this->centralDirectory)); // Total entries + $eocd .= pack('V', $cdSize); // Central directory size + $eocd .= pack('V', $cdOffset); // CD offset + $eocd .= pack('v', 0); // Comment length + + fwrite($this->output, $eocd); + + @flush(); + } + + /** + * Build a central directory file header entry + */ + private function buildCentralDirectoryEntry(array $entry): string + { + $externalAttr = $entry['externalAttr'] ?? 0; + + $record = pack('V', 0x02014b50); // Central directory signature + $record .= pack('v', 0x033F); // Version made by (Unix, 6.3) + $record .= pack('v', 20); // Version needed + $record .= pack('v', $entry['gpFlags']); // General purpose flags + $record .= pack('v', $entry['method']); // Compression method + $record .= pack('V', $entry['dosTime']); // Last mod time & date + $record .= pack('V', $entry['crc']); // CRC-32 + $record .= pack('V', $entry['compressedSize']); // Compressed size + $record .= pack('V', $entry['uncompressedSize']); // Uncompressed size + $record .= pack('v', strlen($entry['path'])); // File name length + $record .= pack('v', 0); // Extra field length + $record .= pack('v', 0); // Comment length + $record .= pack('v', 0); // Disk number start + $record .= pack('v', 0); // Internal file attributes + $record .= pack('V', $externalAttr); // External file attributes + $record .= pack('V', $entry['offset']); // Relative offset of local header + $record .= $entry['path']; // File name + + return $record; + } + + /** + * Convert Unix timestamp to DOS date/time format + */ + private function unixToDosTime(int $timestamp): int + { + $date = getdate($timestamp); + + $year = max(0, $date['year'] - 1980); + if ($year > 127) { + $year = 127; + } + + $dosDate = ($year << 9) | ($date['mon'] << 5) | $date['mday']; + $dosTime = ($date['hours'] << 11) | ($date['minutes'] << 5) | (int)($date['seconds'] / 2); + + return ($dosDate << 16) | $dosTime; + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d013577 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1385 @@ +{ + "name": "@ktxm/chrono-manager", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@ktxm/chrono-manager", + "version": "1.0.0", + "dependencies": { + "pinia": "^2.3.0", + "vue": "^3.5.13" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.0", + "@types/node": "^22.10.1", + "@vue/tsconfig": "^0.8.0", + "typescript": "~5.7.2", + "vite": "^6.0.3" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tsconfig/node22": { + "version": "22.0.5", + "resolved": "https://registry.npmjs.org/@tsconfig/node22/-/node22-22.0.5.tgz", + "integrity": "sha512-hLf2ld+sYN/BtOJjHUWOk568dvjFQkHnLNa6zce25GIH+vxKfvTgm3qpaH6ToF5tu/NN0IH66s+Bb5wElHrLcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz", + "integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.25", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.25.tgz", + "integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.25.tgz", + "integrity": "sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.25", + "@vue/compiler-dom": "3.5.25", + "@vue/compiler-ssr": "3.5.25", + "@vue/shared": "3.5.25", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.25.tgz", + "integrity": "sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.25.tgz", + "integrity": "sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.25.tgz", + "integrity": "sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.25.tgz", + "integrity": "sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.25", + "@vue/runtime-core": "3.5.25", + "@vue/shared": "3.5.25", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.25.tgz", + "integrity": "sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.25", + "@vue/shared": "3.5.25" + }, + "peerDependencies": { + "vue": "3.5.25" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.25.tgz", + "integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.8.1.tgz", + "integrity": "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "5.x", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz", + "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.25", + "@vue/compiler-sfc": "3.5.25", + "@vue/runtime-dom": "3.5.25", + "@vue/server-renderer": "3.5.25", + "@vue/shared": "3.5.25" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..850dc8d --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "@ktxm/chrono-manager", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build --mode production --config vite.config.ts", + "dev": "vite build --mode development --config vite.config.ts", + "watch": "vite build --mode development --watch --config vite.config.ts", + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "pinia": "^2.3.0", + "vue": "^3.5.13" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.0", + "@types/node": "^22.10.1", + "@vue/tsconfig": "^0.8.0", + "typescript": "~5.7.2", + "vite": "^6.0.3" + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..3fa5e7b --- /dev/null +++ b/src/main.ts @@ -0,0 +1,17 @@ +import { useNodesStore } from '@/stores/nodesStore' +import { useProvidersStore } from '@/stores/providersStore' +import { useServicesStore } from '@/stores/servicesStore' + +/** + * 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. + */ + +console.log('[FileManager] Booting File Manager module...') + +console.log('[FileManager] File Manager module booted successfully') + +// Export stores for external use if needed +export { useNodesStore, useProvidersStore, useServicesStore } diff --git a/src/models/collection.ts b/src/models/collection.ts new file mode 100644 index 0000000..1908754 --- /dev/null +++ b/src/models/collection.ts @@ -0,0 +1,132 @@ +/** + * Class model for FileCollection Interface + */ +import type { FileCollection } from "@/types/node"; + +export class FileCollectionObject implements FileCollection { + + _data!: FileCollection; + + constructor() { + this._data = { + '@type': 'files.collection', + in: null, + id: '', + createdBy: '', + createdOn: '', + modifiedBy: '', + modifiedOn: '', + owner: '', + signature: '', + label: '', + }; + } + + fromJson(data: FileCollection): FileCollectionObject { + this._data = data; + return this; + } + + toJson(): FileCollection { + return this._data; + } + + clone(): FileCollectionObject { + const cloned = new FileCollectionObject(); + cloned._data = JSON.parse(JSON.stringify(this._data)); + return cloned; + } + + /** Properties */ + + get '@type'(): 'files.collection' { + return this._data['@type']; + } + + 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; + } + + 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; + } + + /** 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; + } + +} diff --git a/src/models/entity.ts b/src/models/entity.ts new file mode 100644 index 0000000..071002d --- /dev/null +++ b/src/models/entity.ts @@ -0,0 +1,200 @@ +/** + * Class model for FileEntity Interface + */ +import type { FileEntity } from "@/types/node"; + +export class FileEntityObject implements FileEntity { + + _data!: FileEntity; + + constructor() { + this._data = { + '@type': 'files.entity', + in: null, + id: '', + createdBy: '', + createdOn: '', + modifiedBy: '', + modifiedOn: '', + owner: '', + signature: '', + label: '', + size: 0, + mime: '', + format: '', + encoding: '', + }; + } + + fromJson(data: FileEntity): FileEntityObject { + this._data = data; + return this; + } + + toJson(): FileEntity { + return this._data; + } + + clone(): FileEntityObject { + const cloned = new FileEntityObject(); + cloned._data = JSON.parse(JSON.stringify(this._data)); + return cloned; + } + + /** Properties */ + + get '@type'(): 'files.entity' { + return this._data['@type']; + } + + 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; + } + + 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'; + } + +} diff --git a/src/models/index.ts b/src/models/index.ts new file mode 100644 index 0000000..02ebfe4 --- /dev/null +++ b/src/models/index.ts @@ -0,0 +1,8 @@ +/** + * Central export point for all File Manager models + */ + +export { FileCollectionObject } from './collection'; +export { FileEntityObject } from './entity'; +export { ProviderObject } from './provider'; +export { ServiceObject } from './service'; diff --git a/src/models/provider.ts b/src/models/provider.ts new file mode 100644 index 0000000..4afd4a4 --- /dev/null +++ b/src/models/provider.ts @@ -0,0 +1,63 @@ +/** + * Class model for Provider Interface + */ +import type { ProviderCapabilitiesInterface, ProviderInterface } from "@/types/provider"; + +export class ProviderObject implements ProviderInterface { + + _data!: ProviderInterface; + + constructor() { + this._data = { + '@type': 'files:provider', + id: '', + label: '', + capabilities: {}, + }; + } + + fromJson(data: ProviderInterface): ProviderObject { + this._data = data; + return this; + } + + toJson(): 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 { + return !!(this._data.capabilities && this._data.capabilities[capability]); + } + + capability(capability: keyof ProviderCapabilitiesInterface): boolean | string[] | Record | Record | undefined { + if (this._data.capabilities) { + return this._data.capabilities[capability]; + } + return undefined; + } + + /** Immutable Properties */ + + get '@type'(): string { + return this._data['@type']; + } + + get id(): string { + return this._data.id; + } + + get label(): string { + return this._data.label; + } + + get capabilities(): ProviderCapabilitiesInterface { + return this._data.capabilities; + } + +} diff --git a/src/models/service.ts b/src/models/service.ts new file mode 100644 index 0000000..7b9bf92 --- /dev/null +++ b/src/models/service.ts @@ -0,0 +1,57 @@ +/** + * Class model for Service Interface + */ +import type { ServiceInterface } from "@/types/service"; + +export class ServiceObject implements ServiceInterface { + + _data!: ServiceInterface; + + constructor() { + this._data = { + '@type': 'files:service', + id: '', + provider: '', + label: '', + rootId: '', + }; + } + + fromJson(data: ServiceInterface): ServiceObject { + this._data = data; + return this; + } + + toJson(): ServiceInterface { + return this._data; + } + + clone(): ServiceObject { + const cloned = new ServiceObject(); + cloned._data = JSON.parse(JSON.stringify(this._data)); + return cloned; + } + + /** Immutable Properties */ + + get '@type'(): string { + return this._data['@type']; + } + + get id(): string { + return this._data.id; + } + + get provider(): string { + return this._data.provider; + } + + get label(): string { + return this._data.label; + } + + get rootId(): string { + return this._data.rootId; + } + +} diff --git a/src/services/api.ts b/src/services/api.ts new file mode 100644 index 0000000..fcf60f0 --- /dev/null +++ b/src/services/api.ts @@ -0,0 +1,72 @@ +/** + * File Manager API Service + * Central service for making API calls to the file manager backend + */ + +import { createFetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper-core'; + +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 new file mode 100644 index 0000000..9ee4095 --- /dev/null +++ b/src/services/collectionService.ts @@ -0,0 +1,195 @@ +/** + * Collection management service + */ + +import { fileManagerApi } from './api'; +import type { FilterCondition, SortCondition } from '@/types/common'; +import type { FileCollection } from '@/types/node'; + +export const collectionService = { + + /** + * List collections within a location + * + * @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 + */ + 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, + }); + }, + + /** + * Check if a collection exists + * + * @param provider - Provider identifier + * @param service - Service identifier + * @param identifier - Collection identifier + * @returns Promise with extant status + */ + async extant( + provider: string, + service: string, + identifier: string + ): Promise { + const result = await fileManagerApi.execute<{ extant: boolean }>('collection.extant', { + provider, + service, + identifier, + }); + return result.extant; + }, + + /** + * Fetch a specific collection + * + * @param provider - Provider identifier + * @param service - Service identifier + * @param identifier - Collection identifier + * @returns Promise with collection details + */ + async fetch( + provider: string, + service: string, + identifier: string + ): Promise { + return await fileManagerApi.execute('collection.fetch', { + provider, + service, + identifier, + }); + }, + + /** + * Create a new collection (folder) + * + * @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 + */ + 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 ?? {}, + }); + }, + + /** + * Modify 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 + */ + async modify( + provider: string, + service: string, + identifier: string, + data: Partial + ): Promise { + return await fileManagerApi.execute('collection.modify', { + provider, + service, + identifier, + data, + }); + }, + + /** + * Delete a collection + * + * @param provider - Provider identifier + * @param service - Service identifier + * @param identifier - Collection identifier + * @returns Promise with success status + */ + async destroy( + provider: string, + service: string, + identifier: string + ): Promise { + const result = await fileManagerApi.execute<{ success: boolean }>('collection.destroy', { + provider, + service, + identifier, + }); + return result.success; + }, + + /** + * 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 new file mode 100644 index 0000000..c72cc82 --- /dev/null +++ b/src/services/entityService.ts @@ -0,0 +1,293 @@ +/** + * Entity (file) 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'; + +export const entityService = { + + /** + * List entities within a collection + * + * @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 + */ + 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, + }); + }, + + /** + * Get delta changes for entities since a signature + * + * @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 + */ + 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, + }); + }, + + /** + * Check which entities exist + * + * @param provider - Provider identifier + * @param service - Service identifier + * @param collection - Collection identifier + * @param identifiers - Entity identifiers to check + * @returns Promise with existence map + */ + async extant( + provider: string, + service: string, + collection: string, + identifiers: string[] + ): Promise> { + return await fileManagerApi.execute>('entity.extant', { + provider, + service, + collection, + identifiers, + }); + }, + + /** + * Fetch specific entities + * + * @param provider - Provider identifier + * @param service - Service identifier + * @param collection - Collection identifier + * @param identifiers - Entity identifiers to fetch + * @returns Promise with entity list + */ + async fetch( + provider: string, + service: string, + collection: string, + identifiers: string[] + ): Promise { + return await fileManagerApi.execute('entity.fetch', { + provider, + service, + collection, + identifiers, + }); + }, + + /** + * Read entity content + * + * @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 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 + */ + 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, + }); + }, + + /** + * 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 + */ + 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; + }, + + /** + * Copy an entity to a new location + * + * @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 + */ + 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, + }); + }, + + /** + * Move an entity to a new location + * + * @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 + */ + 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, + }); + }, + + /** + * 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 + */ + 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', + }); + return result.bytesWritten; + }, +}; + +export default entityService; diff --git a/src/services/index.ts b/src/services/index.ts new file mode 100644 index 0000000..ba39971 --- /dev/null +++ b/src/services/index.ts @@ -0,0 +1,10 @@ +/** + * Central export point for all File Manager services + */ + +export { fileManagerApi } from './api'; +export { providerService } from './providerService'; +export { serviceService } from './serviceService'; +export { collectionService } from './collectionService'; +export { entityService } from './entityService'; +export { nodeService } from './nodeService'; diff --git a/src/services/nodeService.ts b/src/services/nodeService.ts new file mode 100644 index 0000000..247cd64 --- /dev/null +++ b/src/services/nodeService.ts @@ -0,0 +1,74 @@ +/** + * 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'; + +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, + }); + }, + + /** + * 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, + }); + }, +}; + +export default nodeService; diff --git a/src/services/providerService.ts b/src/services/providerService.ts new file mode 100644 index 0000000..371b0c8 --- /dev/null +++ b/src/services/providerService.ts @@ -0,0 +1,34 @@ +/** + * Provider management service + */ + +import { fileManagerApi } from './api'; +import type { SourceSelector } from '@/types/common'; +import type { ProviderRecord } from '@/types/provider'; + +export const providerService = { + + /** + * List all available providers + * + * @param sources - Optional source selector to filter providers + * @returns Promise with provider list keyed by provider ID + */ + async list(sources?: SourceSelector): Promise { + return await fileManagerApi.execute('provider.list', { + sources: sources || null + }); + }, + + /** + * Check which providers exist/are available + * + * @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 }); + }, +}; + +export default providerService; diff --git a/src/services/serviceService.ts b/src/services/serviceService.ts new file mode 100644 index 0000000..7b3ca87 --- /dev/null +++ b/src/services/serviceService.ts @@ -0,0 +1,48 @@ +/** + * Service management service + */ + +import { fileManagerApi } from './api'; +import type { SourceSelector } from '@/types/common'; +import type { ServiceInterface, ServiceRecord } from '@/types/service'; + +export const serviceService = { + + /** + * List all available services + * + * @param sources - Optional source selector to filter services + * @returns Promise with service list grouped by provider + */ + async list(sources?: SourceSelector): Promise { + return await fileManagerApi.execute('service.list', { + sources: sources || null + }); + }, + + /** + * Check which services exist/are available + * + * @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 }); + }, + + /** + * Fetch a specific service + * + * @param provider - Provider identifier + * @param identifier - Service identifier + * @returns Promise with service details + */ + async fetch(provider: string, identifier: string): Promise { + return await fileManagerApi.execute('service.fetch', { + provider, + identifier + }); + }, +}; + +export default serviceService; diff --git a/src/stores/nodesStore.ts b/src/stores/nodesStore.ts new file mode 100644 index 0000000..aa13297 --- /dev/null +++ b/src/stores/nodesStore.ts @@ -0,0 +1,687 @@ +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' + +// 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 + +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) + + // 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)) + }) + }) + 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] + } + + // Get all nodes for a service + const getServiceNodes = ( + providerId: string, + serviceId: string + ): NodeRecord[] => { + return Object.values(nodes.value[providerId]?.[serviceId] || {}) + } + + // 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) + } + + // 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[] => { + 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) { + break + } + currentNode = getNode(providerId, serviceId, currentNode.in) + } + + 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 = ( + 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] + } + } + } + + // 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)) + } 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 ( + providerId: string, + serviceId: string, + collection: string, + filter?: FilterCondition[] | null, + sort?: SortCondition[] | null, + range?: RangeCondition | null + ): Promise => { + loading.value = true + 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)) + } 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 ( + providerId: string, + serviceId: string, + location: string | null, + data: Partial, + options?: Record + ): Promise => { + loading.value = true + error.value = null + try { + const created = await collectionService.create(providerId, serviceId, location, data, options) + addNode(providerId, serviceId, created) + return new FileCollectionObject().fromJson(created) + } 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 ( + providerId: string, + serviceId: string, + collection: string | null, + data: Partial, + options?: Record + ): Promise => { + loading.value = true + error.value = null + try { + const created = await entityService.create(providerId, serviceId, collection, data, options) + addNode(providerId, serviceId, created) + return new FileEntityObject().fromJson(created) + } 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 ( + providerId: string, + serviceId: string, + identifier: string, + data: Partial + ): Promise => { + loading.value = true + error.value = null + try { + const modified = await collectionService.modify(providerId, serviceId, identifier, data) + addNode(providerId, serviceId, modified) + return new FileCollectionObject().fromJson(modified) + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to modify collection' + throw e + } finally { + loading.value = false + } + } + + // Modify an entity + const modifyEntity = async ( + providerId: string, + serviceId: string, + collection: string | null, + identifier: string, + data: Partial + ): Promise => { + loading.value = true + error.value = null + try { + const modified = await entityService.modify(providerId, serviceId, collection, identifier, data) + addNode(providerId, serviceId, modified) + return new FileEntityObject().fromJson(modified) + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to modify entity' + throw e + } finally { + loading.value = false + } + } + + // Destroy a collection + const destroyCollection = async ( + providerId: string, + serviceId: string, + identifier: string + ): Promise => { + loading.value = true + 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 + } 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 ( + providerId: string, + serviceId: string, + collection: string | null, + identifier: string, + content: string + ): Promise => { + loading.value = true + error.value = null + try { + return await entityService.write(providerId, serviceId, collection, 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 + } + } + + return { + // State + nodes, + syncTokens, + loading, + error, + // Constants + ROOT_ID, + // Computed + nodeList, + collectionList, + entityList, + // Getters + getNode, + getServiceNodes, + getChildren, + getChildCollections, + getChildEntities, + getPath, + isRoot, + // Setters + setNodes, + addNode, + addNodes, + removeNode, + removeNodes, + clearServiceNodes, + clearNodes, + // Sync + getSyncToken, + setSyncToken, + // API Actions - Fetch + fetchNodes, + fetchCollections, + fetchEntities, + // API Actions - Create + createCollection, + createEntity, + // API Actions - Modify + modifyCollection, + modifyEntity, + // API Actions - Destroy + destroyCollection, + destroyEntity, + // API Actions - Copy + copyCollection, + copyEntity, + // API Actions - Move + moveCollection, + moveEntity, + // API Actions - Content + readEntity, + writeEntity, + // API Actions - Sync + syncDelta, + } +}) diff --git a/src/stores/providersStore.ts b/src/stores/providersStore.ts new file mode 100644 index 0000000..7042578 --- /dev/null +++ b/src/stores/providersStore.ts @@ -0,0 +1,104 @@ +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 { ProviderObject } from '../models/provider' + +export const useProvidersStore = defineStore('fileProviders', () => { + const providers: Ref> = ref({}) + const loading = ref(false) + const error: Ref = ref(null) + const initialized = ref(false) + + const providerList: ComputedRef = computed(() => + Object.values(providers.value) + ) + + const providerIds: ComputedRef = computed(() => + Object.keys(providers.value) + ) + + const getProvider = (id: string): ProviderObject | undefined => { + return providers.value[id] + } + + 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) + } + providers.value = hydrated + initialized.value = true + } + + const addProvider = (id: string, provider: ProviderInterface) => { + providers.value[id] = new ProviderObject().fromJson(provider) + } + + 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 + try { + const data = await providerService.list(sources) + setProviders(data) + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to fetch providers' + throw e + } finally { + loading.value = false + } + } + + const checkProviderExtant = async (sources: SourceSelector): Promise> => { + try { + return await providerService.extant(sources) + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to check providers' + throw e + } + } + + return { + // State + providers, + loading, + error, + initialized, + // Computed + providerList, + providerIds, + // Getters + getProvider, + hasProvider, + isCapable, + // Setters + setProviders, + addProvider, + removeProvider, + clearProviders, + // Actions + fetchProviders, + checkProviderExtant, + } +}) diff --git a/src/stores/servicesStore.ts b/src/stores/servicesStore.ts new file mode 100644 index 0000000..de1d8d0 --- /dev/null +++ b/src/stores/servicesStore.ts @@ -0,0 +1,131 @@ +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 { ServiceObject } from '../models/service' + +// Nested structure: provider -> service -> ServiceObject +type ServiceStore = Record> + +export const useServicesStore = defineStore('fileServices', () => { + const services: Ref = ref({}) + const loading = ref(false) + const error: Ref = ref(null) + const initialized = ref(false) + + const serviceList: ComputedRef = computed(() => { + const result: ServiceObject[] = [] + Object.values(services.value).forEach(providerServices => { + result.push(...Object.values(providerServices)) + }) + return result + }) + + 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) + } + services.value = hydrated + initialized.value = true + } + + const addService = (providerId: string, serviceId: string, service: ServiceInterface) => { + if (!services.value[providerId]) { + services.value[providerId] = {} + } + services.value[providerId][serviceId] = new ServiceObject().fromJson(service) + } + + 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 + try { + const data = await serviceService.list(sources) + setServices(data) + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to fetch services' + throw e + } finally { + loading.value = false + } + } + + const checkServiceExtant = async (sources: SourceSelector): Promise> => { + try { + return await serviceService.extant(sources) + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to check services' + throw e + } + } + + const fetchService = async (providerId: string, serviceId: string): Promise => { + 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 + } + } + + return { + // State + services, + loading, + error, + initialized, + // Computed + serviceList, + // Getters + getService, + hasService, + getProviderServices, + getRootId, + // Setters + setServices, + addService, + removeService, + clearServices, + // Actions + fetchServices, + checkServiceExtant, + fetchService, + } +}) diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..45ad71c --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,262 @@ +/** + * 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/common.ts b/src/types/common.ts new file mode 100644 index 0000000..a30c1b4 --- /dev/null +++ b/src/types/common.ts @@ -0,0 +1,85 @@ +/** + * Common types for file manager + */ + +export type SourceSelector = { + [provider: string]: boolean | ServiceSelector; +}; + +export type ServiceSelector = { + [service: string]: boolean; +}; + +export const SortDirection = { + Ascending: 'asc', + Descending: 'desc' +} as const; + +export type SortDirection = typeof SortDirection[keyof typeof SortDirection]; + +export const RangeType = { + Tally: 'tally', + Date: 'date' +} as const; + +export type RangeType = typeof RangeType[keyof typeof RangeType]; + +export const RangeAnchorType = { + Absolute: 'absolute', + Relative: 'relative' +} as const; + +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; +} + +export interface SortCondition { + attribute: string; + direction: SortDirection; +} + +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; + }; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..d259f0f --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,9 @@ +/** + * File manager types barrel export + */ + +export * from './common'; +export * from './provider'; +export * from './service'; +export * from './node'; +export * from './api'; diff --git a/src/types/node.ts b/src/types/node.ts new file mode 100644 index 0000000..6263b69 --- /dev/null +++ b/src/types/node.ts @@ -0,0 +1,65 @@ +/** + * Node types for file manager (collections and entities) + */ + +export type NodeType = 'files.collection' | 'files.entity'; + +export interface NodeBase { + '@type': NodeType; + in: string | null; + id: string; + createdBy: string; + createdOn: string; + modifiedBy: string; + modifiedOn: string; + owner: string; + signature: string; + label: string; +} + +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[]; + 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'; +} diff --git a/src/types/provider.ts b/src/types/provider.ts new file mode 100644 index 0000000..00aa8d4 --- /dev/null +++ b/src/types/provider.ts @@ -0,0 +1,49 @@ +/** + * Provider types for file manager + */ + +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; +} + +export interface ProviderInterface { + '@type': string; + id: string; + label: string; + capabilities: ProviderCapabilitiesInterface; +} + +export type ProviderRecord = Record; diff --git a/src/types/service.ts b/src/types/service.ts new file mode 100644 index 0000000..cba2444 --- /dev/null +++ b/src/types/service.ts @@ -0,0 +1,13 @@ +/** + * Service types for file manager + */ + +export interface ServiceInterface { + '@type': string; + id: string; + provider: string; + label: string; + rootId: string; +} + +export type ServiceRecord = Record; diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..88d256c --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,19 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "paths": { + "@/*": ["./src/*"], + "@KTXC/*": ["../../core/src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..f85a399 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..43a7d67 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'vite' +import path from 'path' + +// https://vite.dev/config/ +export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + '@KTXC': path.resolve(__dirname, '../../core/src') + }, + }, + build: { + outDir: 'static', + sourcemap: true, + lib: { + entry: path.resolve(__dirname, 'src/main.ts'), + formats: ['es'], + fileName: () => 'module.mjs', + }, + rollupOptions: { + external: [ + 'pinia', + 'vue', + 'vue-router', + // Externalize shared utilities from core to avoid duplication + /^@KTXC\/utils\//, + ], + }, + }, +})