Initial commit

This commit is contained in:
root
2025-12-21 09:57:43 -05:00
committed by Sebastian Krupinski
commit db42b6699c
35 changed files with 6458 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@@ -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

26
composer.json Normal file
View File

@@ -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/"
}
}
}

View File

@@ -0,0 +1,720 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* 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
);
}
}

View File

@@ -0,0 +1,488 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* 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);
}
}

748
lib/Manager.php Normal file
View File

@@ -0,0 +1,748 @@
<?php
declare(strict_types=1);
namespace KTXM\FileManager;
use InvalidArgumentException;
use KTXC\Resource\ProviderManager;
use KTXF\Files\Node\INodeBase;
use KTXF\Files\Node\INodeCollectionBase;
use KTXF\Files\Node\INodeCollectionMutable;
use KTXF\Files\Node\INodeEntityBase;
use KTXF\Files\Node\INodeEntityMutable;
use KTXF\Files\Provider\IProviderBase;
use KTXF\Files\Service\IServiceBase;
use KTXF\Files\Service\IServiceCollectionMutable;
use KTXF\Files\Service\IServiceEntityMutable;
use KTXF\Resource\Provider\ProviderInterface;
use KTXF\Resource\Range\IRangeTally;
use KTXF\Resource\Range\RangeAnchorType;
use KTXF\Resource\Range\RangeType;
use KTXF\Resource\Selector\ServiceSelector;
use KTXF\Resource\Selector\SourceSelector;
use Psr\Log\LoggerInterface;
class Manager {
public function __construct(
private LoggerInterface $logger,
private ProviderManager $providerManager,
) { }
// ==================== Provider Operations ====================
/**
* Retrieve available providers
*
* @param SourceSelector|null $sources collection of provider identifiers
*
* @return array<string,IProviderBase> 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<string,bool> 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<string,array<string,IServiceBase>> 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<string,bool> 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<string|int,INodeCollectionBase>
*/
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<string|int,INodeEntityBase>
*/
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<string|int,bool>
*/
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<string|int,INodeEntityBase>
*/
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<string|int,INodeBase>
*/
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);
}
}

65
lib/Module.php Normal file
View File

@@ -0,0 +1,65 @@
<?php
namespace KTXM\FileManager;
use KTXF\Module\ModuleBrowserInterface;
use KTXF\Module\ModuleInstanceAbstract;
/**
* File Manager Module
*/
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
{
public function __construct()
{ }
public function handle(): string
{
return 'file_manager';
}
public function label(): string
{
return 'File Manager';
}
public function author(): string
{
return 'Ktrix';
}
public function description(): string
{
return 'File management module for Ktrix - provides file and folder management functionalities';
}
public function version(): string
{
return '0.0.1';
}
public function permissions(): array
{
return [
'file_manager' => [
'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',
];
}
}

View File

@@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* 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;
}
}

1385
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@@ -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"
}
}

17
src/main.ts Normal file
View File

@@ -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 }

132
src/models/collection.ts Normal file
View File

@@ -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;
}
}

200
src/models/entity.ts Normal file
View File

@@ -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';
}
}

8
src/models/index.ts Normal file
View File

@@ -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';

63
src/models/provider.ts Normal file
View File

@@ -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<string, string> | Record<string, string[]> | 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;
}
}

57
src/models/service.ts Normal file
View File

@@ -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;
}
}

72
src/services/api.ts Normal file
View File

@@ -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<string, unknown>;
}
interface ApiSuccessResponse<T> {
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<T> = ApiSuccessResponse<T> | 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<T>(operation: string, data: Record<string, unknown> = {}): Promise<T> {
const request: ApiRequest = {
version: 1,
transaction: generateTransactionId(),
operation,
data,
};
const response: ApiResponseRaw<T> = 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;

View File

@@ -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<FileCollection[]> {
return await fileManagerApi.execute<FileCollection[]>('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<boolean> {
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<FileCollection> {
return await fileManagerApi.execute<FileCollection>('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<FileCollection>,
options?: Record<string, unknown>
): Promise<FileCollection> {
return await fileManagerApi.execute<FileCollection>('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<FileCollection>
): Promise<FileCollection> {
return await fileManagerApi.execute<FileCollection>('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<boolean> {
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<FileCollection> {
return await fileManagerApi.execute<FileCollection>('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<FileCollection> {
return await fileManagerApi.execute<FileCollection>('collection.move', {
provider,
service,
identifier,
location: location ?? null,
});
},
};
export default collectionService;

View File

@@ -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<FileEntity[]> {
return await fileManagerApi.execute<FileEntity[]>('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<EntityDeltaResult> {
return await fileManagerApi.execute<EntityDeltaResult>('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<Record<string, boolean>> {
return await fileManagerApi.execute<Record<string, boolean>>('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<FileEntity[]> {
return await fileManagerApi.execute<FileEntity[]>('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<FileEntity>,
options?: Record<string, unknown>
): Promise<FileEntity> {
return await fileManagerApi.execute<FileEntity>('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<FileEntity>
): Promise<FileEntity> {
return await fileManagerApi.execute<FileEntity>('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<boolean> {
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<FileEntity> {
return await fileManagerApi.execute<FileEntity>('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<FileEntity> {
return await fileManagerApi.execute<FileEntity>('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<number> {
const result = await fileManagerApi.execute<{ bytesWritten: number }>('entity.write', {
provider,
service,
collection,
identifier,
content,
encoding: 'base64',
});
return result.bytesWritten;
},
};
export default entityService;

10
src/services/index.ts Normal file
View File

@@ -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';

View File

@@ -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<FileNode[]> {
return await fileManagerApi.execute<FileNode[]>('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<NodeDeltaResult> {
return await fileManagerApi.execute<NodeDeltaResult>('node.delta', {
provider,
service,
location,
signature,
recursive,
detail,
});
},
};
export default nodeService;

View File

@@ -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<ProviderRecord> {
return await fileManagerApi.execute<ProviderRecord>('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<Record<string, boolean>> {
return await fileManagerApi.execute<Record<string, boolean>>('provider.extant', { sources });
},
};
export default providerService;

View File

@@ -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<ServiceRecord> {
return await fileManagerApi.execute<ServiceRecord>('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<Record<string, boolean>> {
return await fileManagerApi.execute<Record<string, boolean>>('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<ServiceInterface> {
return await fileManagerApi.execute<ServiceInterface>('service.fetch', {
provider,
identifier
});
},
};
export default serviceService;

687
src/stores/nodesStore.ts Normal file
View File

@@ -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<string, NodeRecord>
type ProviderNodeStore = Record<string, ServiceNodeStore>
type NodeStore = Record<string, ProviderNodeStore>
export const useNodesStore = defineStore('fileNodes', () => {
const nodes: Ref<NodeStore> = ref({})
const syncTokens: Ref<Record<string, Record<string, string>>> = ref({}) // provider -> service -> token
const loading = ref(false)
const error: Ref<string | null> = ref(null)
// Computed: flat list of all nodes
const nodeList: ComputedRef<NodeRecord[]> = 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<FileCollectionObject[]> = computed(() => {
return nodeList.value.filter(
(node): node is FileCollectionObject => node['@type'] === 'files.collection'
)
})
// Computed: all entities (files)
const entityList: ComputedRef<FileEntityObject[]> = 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<string, FileNode>
) => {
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<NodeRecord[]> => {
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<FileCollectionObject[]> => {
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<FileEntityObject[]> => {
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<FileCollection>,
options?: Record<string, unknown>
): Promise<FileCollectionObject> => {
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<FileEntity>,
options?: Record<string, unknown>
): Promise<FileEntityObject> => {
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<FileCollection>
): Promise<FileCollectionObject> => {
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<FileEntity>
): Promise<FileEntityObject> => {
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<boolean> => {
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<boolean> => {
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<FileCollectionObject> => {
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<FileEntityObject> => {
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<FileCollectionObject> => {
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<FileEntityObject> => {
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<string | null> => {
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<number> => {
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<void> => {
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,
}
})

View File

@@ -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<Record<string, ProviderObject>> = ref({})
const loading = ref(false)
const error: Ref<string | null> = ref(null)
const initialized = ref(false)
const providerList: ComputedRef<ProviderObject[]> = computed(() =>
Object.values(providers.value)
)
const providerIds: ComputedRef<string[]> = 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<string, ProviderObject> = {}
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<void> => {
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<Record<string, boolean>> => {
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,
}
})

131
src/stores/servicesStore.ts Normal file
View File

@@ -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<string, Record<string, ServiceObject>>
export const useServicesStore = defineStore('fileServices', () => {
const services: Ref<ServiceStore> = ref({})
const loading = ref(false)
const error: Ref<string | null> = ref(null)
const initialized = ref(false)
const serviceList: ComputedRef<ServiceObject[]> = 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<void> => {
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<Record<string, boolean>> => {
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<ServiceObject> => {
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,
}
})

262
src/types/api.ts Normal file
View File

@@ -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<ProviderRecord>;
export interface ProviderExtantRequest {
sources: SourceSelector;
}
export type ProviderExtantResponse = ApiResponse<Record<string, boolean>>;
// ==================== Service Types ====================
export type ServiceListResponse = ApiResponse<ServiceRecord>;
export interface ServiceExtantRequest {
sources: SourceSelector;
}
export type ServiceExtantResponse = ApiResponse<Record<string, boolean>>;
export interface ServiceFetchRequest {
provider: string;
identifier: string;
}
export type ServiceFetchResponse = ApiResponse<ServiceInterface>;
// ==================== Collection Types ====================
export interface CollectionListRequest {
provider: string;
service: string;
location?: string | null;
filter?: FilterCondition[] | null;
sort?: SortCondition[] | null;
}
export type CollectionListResponse = ApiResponse<FileCollection[]>;
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<FileCollection>;
export interface CollectionCreateRequest {
provider: string;
service: string;
location?: string | null;
data: Partial<FileCollection>;
options?: Record<string, unknown>;
}
export type CollectionCreateResponse = ApiResponse<FileCollection>;
export interface CollectionModifyRequest {
provider: string;
service: string;
identifier: string;
data: Partial<FileCollection>;
}
export type CollectionModifyResponse = ApiResponse<FileCollection>;
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<FileCollection>;
export interface CollectionMoveRequest {
provider: string;
service: string;
identifier: string;
location?: string | null;
}
export type CollectionMoveResponse = ApiResponse<FileCollection>;
// ==================== Entity Types ====================
export interface EntityListRequest {
provider: string;
service: string;
collection: string;
filter?: FilterCondition[] | null;
sort?: SortCondition[] | null;
range?: RangeCondition | null;
}
export type EntityListResponse = ApiResponse<FileEntity[]>;
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<EntityDeltaResult>;
export interface EntityExtantRequest {
provider: string;
service: string;
collection: string;
identifiers: string[];
}
export type EntityExtantResponse = ApiResponse<Record<string, boolean>>;
export interface EntityFetchRequest {
provider: string;
service: string;
collection: string;
identifiers: string[];
}
export type EntityFetchResponse = ApiResponse<FileEntity[]>;
export interface EntityReadRequest {
provider: string;
service: string;
collection: string;
identifier: string;
}
export interface EntityReadResult {
content: string | null;
encoding: 'base64';
}
export type EntityReadResponse = ApiResponse<EntityReadResult>;
export interface EntityCreateRequest {
provider: string;
service: string;
collection?: string | null;
data: Partial<FileEntity>;
options?: Record<string, unknown>;
}
export type EntityCreateResponse = ApiResponse<FileEntity>;
export interface EntityModifyRequest {
provider: string;
service: string;
collection?: string | null;
identifier: string;
data: Partial<FileEntity>;
}
export type EntityModifyResponse = ApiResponse<FileEntity>;
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<FileEntity>;
export interface EntityMoveRequest {
provider: string;
service: string;
collection?: string | null;
identifier: string;
destination?: string | null;
}
export type EntityMoveResponse = ApiResponse<FileEntity>;
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<FileNode[]>;
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<NodeDeltaResult>;

85
src/types/common.ts Normal file
View File

@@ -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<string, unknown>;
}
export interface ApiResponse<T = unknown> {
version: number;
transaction: string;
operation: string;
status: 'success' | 'error';
data?: T;
error?: {
code: number;
message: string;
};
}

9
src/types/index.ts Normal file
View File

@@ -0,0 +1,9 @@
/**
* File manager types barrel export
*/
export * from './common';
export * from './provider';
export * from './service';
export * from './node';
export * from './api';

65
src/types/node.ts Normal file
View File

@@ -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';
}

49
src/types/provider.ts Normal file
View File

@@ -0,0 +1,49 @@
/**
* Provider types for file manager
*/
export interface ProviderCapabilitiesInterface {
CollectionList?: boolean;
CollectionListFilter?: boolean | Record<string, string>;
CollectionListSort?: boolean | string[];
CollectionExtant?: boolean;
CollectionFetch?: boolean;
CollectionCreate?: boolean;
CollectionModify?: boolean;
CollectionDestroy?: boolean;
CollectionCopy?: boolean;
CollectionMove?: boolean;
EntityList?: boolean;
EntityListFilter?: boolean | Record<string, string>;
EntityListSort?: boolean | string[];
EntityListRange?: boolean | Record<string, string[]>;
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<string, string>;
NodeListSort?: boolean | string[];
NodeListRange?: boolean | Record<string, string[]>;
NodeDelta?: boolean;
[key: string]: boolean | string[] | Record<string, string> | Record<string, string[]> | undefined;
}
export interface ProviderInterface {
'@type': string;
id: string;
label: string;
capabilities: ProviderCapabilitiesInterface;
}
export type ProviderRecord = Record<string, ProviderInterface>;

13
src/types/service.ts Normal file
View File

@@ -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<string, ServiceInterface>;

19
tsconfig.app.json Normal file
View File

@@ -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"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

25
tsconfig.node.json Normal file
View File

@@ -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"]
}

30
vite.config.ts Normal file
View File

@@ -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\//,
],
},
},
})