Initial commit
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal 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
26
composer.json
Normal 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/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
720
lib/Controllers/DefaultController.php
Normal file
720
lib/Controllers/DefaultController.php
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
488
lib/Controllers/TransferController.php
Normal file
488
lib/Controllers/TransferController.php
Normal 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
748
lib/Manager.php
Normal 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
65
lib/Module.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
285
lib/Transfer/StreamingZip.php
Normal file
285
lib/Transfer/StreamingZip.php
Normal 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
1385
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal 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
17
src/main.ts
Normal 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
132
src/models/collection.ts
Normal 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
200
src/models/entity.ts
Normal 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
8
src/models/index.ts
Normal 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
63
src/models/provider.ts
Normal 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
57
src/models/service.ts
Normal 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
72
src/services/api.ts
Normal 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;
|
||||||
195
src/services/collectionService.ts
Normal file
195
src/services/collectionService.ts
Normal 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;
|
||||||
293
src/services/entityService.ts
Normal file
293
src/services/entityService.ts
Normal 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
10
src/services/index.ts
Normal 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';
|
||||||
74
src/services/nodeService.ts
Normal file
74
src/services/nodeService.ts
Normal 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;
|
||||||
34
src/services/providerService.ts
Normal file
34
src/services/providerService.ts
Normal 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;
|
||||||
48
src/services/serviceService.ts
Normal file
48
src/services/serviceService.ts
Normal 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
687
src/stores/nodesStore.ts
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
104
src/stores/providersStore.ts
Normal file
104
src/stores/providersStore.ts
Normal 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
131
src/stores/servicesStore.ts
Normal 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
262
src/types/api.ts
Normal 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
85
src/types/common.ts
Normal 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
9
src/types/index.ts
Normal 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
65
src/types/node.ts
Normal 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
49
src/types/provider.ts
Normal 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
13
src/types/service.ts
Normal 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
19
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
25
tsconfig.node.json
Normal file
25
tsconfig.node.json
Normal 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
30
vite.config.ts
Normal 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\//,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user