1 Commits

Author SHA1 Message Date
Sebastian 745aff1be3 chore(deps): update dependency phpunit/phpunit to v12.5.33
JS Unit Tests / test (pull_request) Successful in 59s
Build Test / test (pull_request) Successful in 1m9s
PHP Integration Tests / Integration Tests (pull_request) Failing after 1m43s
PHP Unit Tests / test (pull_request) Successful in 2m24s
2026-07-29 03:02:47 +00:00
25 changed files with 764 additions and 1965 deletions
Generated
+7 -7
View File
@@ -589,16 +589,16 @@
},
{
"name": "phpunit/phpunit",
"version": "12.5.31",
"version": "12.5.33",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "0608d157a284f15cc73b99a3327eff06b66a176d"
"reference": "b98e028a26c5c5ba7e4a54be96ccf35f2914d184"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0608d157a284f15cc73b99a3327eff06b66a176d",
"reference": "0608d157a284f15cc73b99a3327eff06b66a176d",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b98e028a26c5c5ba7e4a54be96ccf35f2914d184",
"reference": "b98e028a26c5c5ba7e4a54be96ccf35f2914d184",
"shasum": ""
},
"require": {
@@ -667,7 +667,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.31"
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.33"
},
"funding": [
{
@@ -675,7 +675,7 @@
"type": "other"
}
],
"time": "2026-07-06T14:54:16+00:00"
"time": "2026-07-28T13:58:09+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -1701,5 +1701,5 @@
"platform-overrides": {
"php": "8.3"
},
"plugin-api-version": "2.6.0"
"plugin-api-version": "2.9.0"
}
File diff suppressed because it is too large Load Diff
+20 -38
View File
@@ -50,7 +50,11 @@ class TransferController extends ControllerAbstract
*
* GET /download/entity/{provider}/{service}/{collection}/{identifier}
*/
#[AuthenticatedRoute('/download/entity/{provider}/{service}/{collection}/{identifier}', name: 'document_manager.download.entity', methods: ['GET'])]
#[AuthenticatedRoute(
'/download/entity/{provider}/{service}/{collection}/{identifier}',
name: 'document_manager.download.entity',
methods: ['GET']
)]
public function downloadEntity(string $provider, string $service, string $collection, string $identifier): Response {
$tenantId = $this->tenantContext->identifier();
$userId = $this->identityContext->identifier();
@@ -62,14 +66,14 @@ class TransferController extends ControllerAbstract
/** @var EntityBaseInterface[] $entities */
$entities = $this->manager->entityFetchBulk($tenantId, $userId, $target);
if (!isset($entities[(string) $target])) {
if (empty($entities) || !isset($entities[$identifier])) {
return new JsonResponse([
'status' => 'error',
'error' => ['code' => 404, 'message' => 'File not found']
], Response::HTTP_NOT_FOUND);
}
$entity = $entities[(string) $target];
$entity = $entities[$identifier];
// Get the stream
$stream = $this->manager->entityReadStream($tenantId, $userId, $target);
@@ -120,41 +124,16 @@ class TransferController extends ControllerAbstract
}
}
#[AuthenticatedRoute('/preview/entity/{provider}/{service}/{collection}/{identifier}', name: 'document_manager.preview.entity', methods: ['GET'])]
public function previewEntity(string $provider, string $service, string $collection, string $identifier, string $variant = 'inline'): Response {
try {
$preview = $this->manager->entityPreview(
$this->tenantContext->identifier(),
$this->identityContext->identifier(),
new EntityIdentifier($provider, $service, $collection, $identifier),
$variant,
);
if ($preview === null) {
return new Response('', Response::HTTP_NO_CONTENT);
}
$response = new StreamedResponse($preview->resource->stream());
$response->headers->set('Content-Type', $preview->resource->mimeType());
$response->headers->set('Content-Length', (string) $preview->size);
$response->headers->set('ETag', '"' . trim($preview->etag, '"') . '"');
$response->headers->set('Cache-Control', 'private, max-age=3600');
$response->headers->set('X-Content-Type-Options', 'nosniff');
return $response;
} catch (Throwable $throwable) {
$this->logger->error('Document preview failed', ['exception' => $throwable]);
return new JsonResponse([
'status' => 'error',
'error' => ['code' => 500, 'message' => 'Preview unavailable'],
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Download multiple files as a ZIP archive
*
* GET /download/archive?provider=...&service=...&ids[]=...&ids[]=...
*/
#[AuthenticatedRoute('/download/archive', name: 'manager.download.archive', methods: ['GET'])]
#[AuthenticatedRoute(
'/download/archive',
name: 'manager.download.archive',
methods: ['GET']
)]
public function downloadArchive(string $provider, string $service, array $ids = [], ?string $collection = null, string $name = 'download'): Response {
$tenantId = $this->tenantContext->identifier();
$userId = $this->identityContext->identifier();
@@ -236,7 +215,11 @@ class TransferController extends ControllerAbstract
*
* GET /download/collection/{provider}/{service}/{identifier}
*/
#[AuthenticatedRoute('/download/collection/{provider}/{service}/{identifier}', name: 'manager.download.collection', methods: ['GET'])]
#[AuthenticatedRoute(
'/download/collection/{provider}/{service}/{identifier}',
name: 'manager.download.collection',
methods: ['GET']
)]
public function downloadCollection(string $provider, string $service, string $identifier): Response {
$tenantId = $this->tenantContext->identifier();
$userId = $this->identityContext->identifier();
@@ -327,16 +310,15 @@ class TransferController extends ControllerAbstract
foreach ($ids as $id) {
// Try as entity first
if ($collection !== null) {
$target = new EntityIdentifier($provider, $service, $collection, (string) $id);
/** @var EntityBaseInterface[] $entities */
$entities = $this->manager->entityFetchBulk(
$tenantId,
$userId,
$target
new EntityIdentifier($provider, $service, $collection, (string)$id)
);
if (isset($entities[(string) $target])) {
$entity = $entities[(string) $target];
if (!empty($entities) && isset($entities[$id])) {
$entity = $entities[$id];
$files[] = [
'type' => 'file',
'id' => $id,
+11 -161
View File
@@ -5,9 +5,6 @@ declare(strict_types=1);
namespace KTXM\DocumentsManager;
use InvalidArgumentException;
use RuntimeException;
use KTXC\Preview\Preview;
use KTXC\Preview\PreviewManager;
use KTXC\Resource\ProviderManager;
use KTXF\Documents\Collection\CollectionBaseInterface;
use KTXF\Documents\Collection\CollectionPropertiesMutableInterface;
@@ -20,12 +17,8 @@ use KTXF\Documents\Service\ServiceBaseInterface;
use KTXF\Documents\Service\ServiceCollectionMutableInterface;
use KTXF\Documents\Service\ServiceEntityMutableInterface;
use KTXF\Documents\Service\ServiceMutableInterface;
use KTXF\Documents\Service\ServiceNodeListInterface;
use KTXF\Resource\Filter\IFilter;
use KTXF\Preview\PreviewSource;
use KTXF\Resource\BinaryResource;
use KTXF\Resource\Identifier\CollectionIdentifier;
use KTXF\Resource\Identifier\ServiceIdentifier;
use KTXF\Resource\Identifier\EntityIdentifier;
use KTXF\Resource\Identifier\ResourceIdentifiers;
use KTXF\Resource\Provider\ResourceServiceIdentityInterface;
@@ -44,7 +37,6 @@ class Manager {
public function __construct(
private LoggerInterface $logger,
private ProviderManager $providerManager,
private PreviewManager $previewManager,
) { }
/**
@@ -508,16 +500,18 @@ class Manager {
*
* @param string $tenantId tenant identifier
* @param string $userId user identifier
* @param ServiceIdentifier|CollectionIdentifier $target service root or parent collection
* @param string $provider provider identifier
* @param string|int $service service identifier
* @param CollectionIdentifier|null $target target parent collection identifier (null for root)
* @param CollectionPropertiesMutableInterface|array $properties properties for the new collection
* @param array $options additional options for creation
*
* @return CollectionBaseInterface
* @throws InvalidArgumentException
*/
public function collectionCreate(string $tenantId, string $userId, ServiceIdentifier|CollectionIdentifier $target, CollectionPropertiesMutableInterface|array $properties, array $options = []): CollectionBaseInterface {
public function collectionCreate(string $tenantId, string $userId, string $provider, string|int $service, CollectionIdentifier|null $target, CollectionPropertiesMutableInterface|array $properties, array $options = []): CollectionBaseInterface {
// retrieve service
$service = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service());
$service = $this->serviceFetch($tenantId, $userId, $provider, $service);
// Check if service supports collection creation
if ($service->getEnabled() === false) {
throw new InvalidArgumentException("Service '{$service->identifier()}' not found or is disabled");
@@ -533,7 +527,7 @@ class Manager {
$properties = $service->collectionFresh()->getProperties()->jsonDeserialize($properties);
}
// Create collection
return $service->collectionCreate($target instanceof CollectionIdentifier ? $target : null, $properties, $options);
return $service->collectionCreate($target, $properties, $options);
}
/**
@@ -811,28 +805,21 @@ class Manager {
* @return array<string,EntityBaseInterface>
*/
public function entityFetchBulk(string $tenantId, ?string $userId, EntityIdentifier ...$identifiers): array {
// Keep locations separate: providers may return maps keyed by local ID.
// group identifiers by provider/service
$groupedIdentifiers = [];
foreach ($identifiers as $identifier) {
$groupedIdentifiers[$identifier->provider()][$identifier->service()][$identifier->collection()][] = $identifier;
$groupedIdentifiers[$identifier->provider()][$identifier->service()][] = $identifier;
}
// retrieve each service and fetch entities
$list = [];
foreach ($groupedIdentifiers as $providerId => $services) {
foreach ($services as $serviceId => $collections) {
foreach ($services as $serviceId => $entities) {
$service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId);
if ($service->getEnabled() === false) {
throw new InvalidArgumentException("Service '{$providerId}:{$serviceId}' not found or is disabled");
}
foreach ($collections as $entities) {
$fetched = $service->entityFetchBulk(...$entities);
foreach ($entities as $identifier) {
$entity = $fetched[(string) $identifier] ?? $fetched[$identifier->entity()] ?? null;
if ($entity !== null) {
$list[(string) $identifier] = $entity;
}
}
}
// retrieve entities and merge into list
$list = array_merge($list, $service->entityFetchBulk(...$entities));
}
}
return $list;
@@ -1355,141 +1342,4 @@ class Manager {
return $this->serviceForWrite($tenantId, $userId, $target)->entityWriteChunk($target, $offset, $data);
}
/**
* Resolve an authorized entity and lazily generate or retrieve its preview.
*/
public function entityPreview(string $tenantId, string $userId, EntityIdentifier $target, string $variant = 'inline'): ?Preview {
$entities = $this->entityFetchBulk($tenantId, $userId, $target);
$entity = $entities[(string) $target] ?? null;
if (!$entity instanceof EntityBaseInterface) {
return null;
}
$properties = $entity->getProperties();
$mimeType = $properties->getMime();
$size = $properties->size();
$signature = $entity->signature() ?? hash('sha256', implode('|', [
(string) $target,
$entity->modified()?->format('U.u') ?? '',
(string) $size,
$mimeType,
]));
$source = new PreviewSource(
sourceType: 'document',
identity: (string) $target,
signature: $signature,
mimeType: $mimeType,
size: $size,
resourceFactory: function () use ($tenantId, $userId, $target, $properties, $mimeType): BinaryResource {
$stream = $this->entityReadStream($tenantId, $userId, $target);
if (!is_resource($stream)) {
throw new RuntimeException('Document content is unavailable');
}
return new BinaryResource(
$properties->getLabel(),
$mimeType,
(static function () use ($stream): \Generator {
try {
while (!feof($stream)) {
$chunk = fread($stream, 65536);
if ($chunk === false) {
throw new RuntimeException('Failed to read document content');
}
if ($chunk !== '') {
yield $chunk;
}
}
} finally {
fclose($stream);
}
})(),
);
},
);
return $this->previewManager->fetch($tenantId, $source, $variant);
}
/**
* List collections and entities within a location as one unified, paginated set
*
* Folders are ordered before files within the returned range. Unlike
* {@see collectionList} and {@see entityListBulk}, this requires the target
* service to natively implement {@see ServiceNodeListInterface} — there is
* intentionally no fallback for services that only support the separate
* collection/entity listing operations, since every current caller targets a
* single provider/service/location per request.
*
* @since 2026.09.01
*
* @param string $tenantId Tenant identifier
* @param string $userId User identifier
* @param ResourceIdentifiers|null $targets Node sources with collection identifiers
* @param array|null $filter Node filter
* @param array|null $sort Node sort
* @param array|null $range Node range/pagination
*
* @return \Generator<int,CollectionBaseInterface|EntityBaseInterface> Nodes in provider order
*
* @throws InvalidArgumentException If the resolved service does not support unified node listing
*/
public function nodeList(string $tenantId, string $userId, ?ResourceIdentifiers $targets = null, array|null $filter = null, array|null $sort = null, array|null $range = null): \Generator {
// confirm that sources are provided
if ($targets === null) {
$targets = new ResourceIdentifiers([]);
}
// retrieve services for each provider
$aggregateServices = $this->serviceList($tenantId, $userId, $targets);
// retrieve nodes for each service
foreach ($aggregateServices as $services) {
/** @var ServiceBaseInterface $service */
foreach ($services as $service) {
// omit disabled services
if ($service->getEnabled() === false) {
continue;
}
if ($service instanceof ServiceNodeListInterface === false) {
throw new InvalidArgumentException("Service '{$service->identifier()}' does not support paginated node listing");
}
// retrieve collections for each service
$collectionSelected = $targets->byProvider($service->provider())->byService($service->identifier())->collections();
if ($collectionSelected === []) {
// documents are hierarchical: service level selection lists the root collection
$collectionSelected = [''];
}
// construct filter for nodes
$nodeFilter = null;
if ($filter !== null && $filter !== []) {
$nodeFilter = $service->nodeListFilter();
foreach ($filter as $attribute => $value) {
$nodeFilter->condition($attribute, $value);
}
}
// construct sort for nodes
$nodeSort = null;
if ($sort !== null && $sort !== []) {
$nodeSort = $service->nodeListSort();
foreach ($sort as $attribute => $direction) {
$nodeSort->condition($attribute, $direction);
}
}
// construct range for nodes
$nodeRange = null;
if ($range !== null && $range !== [] && isset($range['type'])) {
$nodeRange = $service->nodeListRange(RangeType::from($range['type']))->jsonDeserialize($range);
}
// Preserve provider order without grouping or buffering nodes.
foreach ($collectionSelected as $collectionId) {
$nodes = $service->nodeList($collectionId, $nodeFilter, $nodeSort, $nodeRange);
foreach ($nodes as $node) {
yield $node;
}
}
}
}
}
}
+12 -8
View File
@@ -2,14 +2,13 @@
namespace KTXM\DocumentsManager;
use KTXF\Module\Configuration\BrowserModuleContextInterface;
use KTXF\Module\Configuration\ModuleContextInterface;
use KTXF\Module\ModuleBrowserInterface;
use KTXF\Module\ModuleInstanceAbstract;
/**
* File Manager Module
*/
class Module extends ModuleInstanceAbstract
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
{
public function __construct()
@@ -51,11 +50,16 @@ class Module extends ModuleInstanceAbstract
];
}
public function configure(ModuleContextInterface $context): void
{
if ($context instanceof BrowserModuleContextInterface) {
$context->registerModule($this, 'DocumentsManager', 'static/module.mjs');
}
public function registerBI(): array {
return [
'handle' => $this->handle(),
'namespace' => 'DocumentsManager',
'version' => $this->version(),
'label' => $this->label(),
'author' => $this->author(),
'description' => $this->description(),
'boot' => 'static/module.mjs',
];
}
}
+5 -142
View File
@@ -9,20 +9,17 @@
"version": "1.0.0",
"dependencies": {
"pinia": "^4.0.0",
"vue": "^3.5.13",
"vuetify": "^4.0.0"
"vue": "^3.5.13"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.0",
"@types/node": "^24.0.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vitest/coverage-v8": "^4.1.6",
"@vue/tsconfig": "^0.9.0",
"jsdom": "^29.1.1",
"typescript": "~6.0.0",
"vite": "^8.0.0",
"vitest": "^4.1.6",
"vue-tsc": "^3.0.5"
"vitest": "^4.1.6"
}
},
"node_modules/@asamuzakjp/css-color": {
@@ -717,23 +714,6 @@
"undici-types": "~7.18.0"
}
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.8",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz",
"integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"vue": "^3.2.25"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz",
@@ -888,35 +868,6 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@volar/language-core": {
"version": "2.4.28",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz",
"integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/source-map": "2.4.28"
}
},
"node_modules/@volar/source-map": {
"version": "2.4.28",
"resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz",
"integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@volar/typescript": {
"version": "2.4.28",
"resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz",
"integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/language-core": "2.4.28",
"path-browserify": "^1.0.1",
"vscode-uri": "^3.0.8"
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz",
@@ -997,22 +948,6 @@
"license": "MIT",
"peer": true
},
"node_modules/@vue/language-core": {
"version": "3.3.9",
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.9.tgz",
"integrity": "sha512-in/68oAa4BCtVY6n/nkuhLIkV8DHYd2UivedJ6cMZ6UYtlq9jaoaSNUBHYCVO44z3nKg7MdE5OBoHKt5SxeBKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/language-core": "2.4.28",
"@vue/compiler-dom": "^3.5.0",
"@vue/shared": "^3.5.0",
"alien-signals": "^3.2.1",
"muggle-string": "^0.4.1",
"path-browserify": "^1.0.1",
"picomatch": "^4.0.4"
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.40",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz",
@@ -1080,13 +1015,6 @@
}
}
},
"node_modules/alien-signals": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz",
"integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==",
"dev": true,
"license": "MIT"
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1721,13 +1649,6 @@
"dev": true,
"license": "CC0-1.0"
},
"node_modules/muggle-string": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
@@ -1792,13 +1713,6 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"dev": true,
"license": "MIT"
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -1833,9 +1747,9 @@
}
},
"node_modules/pinia": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-4.0.3.tgz",
"integrity": "sha512-XMQqpvjgG7LMqVhhFUzKLT4KEbsYbfZZ0CZU9PgdFm1O2VKBmIkykL8+SfNhOaT7sR0wT6BEgKT0YNDYWgmVRA==",
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-4.0.2.tgz",
"integrity": "sha512-yKVVA7bSj5oRZFp/Ab9wLlmyb5gPUYEiIm4ryiWTe/xe7PtkRdMVOp1X1ggvq0c6Uj7Q0Du1HnV2mtAwM0Ks1g==",
"license": "MIT",
"dependencies": {
"nostics": "^1.1.4"
@@ -2312,13 +2226,6 @@
}
}
},
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
"dev": true,
"license": "MIT"
},
"node_modules/vue": {
"version": "3.5.40",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.40.tgz",
@@ -2340,50 +2247,6 @@
}
}
},
"node_modules/vue-tsc": {
"version": "3.3.9",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.9.tgz",
"integrity": "sha512-TS3Y1ux/IRoE8OCP2PpACAeOseuIs0UvWrcr7u+w3PmfY+SlCfEf8zjrBgnQksHUgLpthi5vHlffcQTQTdPBZA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/typescript": "2.4.28",
"@vue/language-core": "3.3.9"
},
"bin": {
"vue-tsc": "bin/vue-tsc.js"
},
"peerDependencies": {
"typescript": ">=5.0.0"
}
},
"node_modules/vuetify": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.1.9.tgz",
"integrity": "sha512-2NlpUiSwkUAGxKmkKwoRCst3IJF6EPP7msNJd+VxFjcAN9xCG+RFODzsfXfO2ypl9aSj3T/PJ65BBjQeTGgFKA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/johnleider"
},
"peerDependencies": {
"typescript": ">=4.7",
"vite-plugin-vuetify": ">=2.1.0",
"vue": "^3.5.0 || ^3.6.0-0",
"webpack-plugin-vuetify": ">=3.1.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
},
"vite-plugin-vuetify": {
"optional": true
},
"webpack-plugin-vuetify": {
"optional": true
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+1 -4
View File
@@ -15,17 +15,14 @@
},
"dependencies": {
"pinia": "^4.0.0",
"vue": "^3.5.13",
"vuetify": "^4.0.0"
"vue": "^3.5.13"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.0",
"@types/node": "^24.0.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.9.0",
"typescript": "~6.0.0",
"vite": "^8.0.0",
"vue-tsc": "^3.0.5",
"@vitest/coverage-v8": "^4.1.6",
"jsdom": "^29.1.1",
"vitest": "^4.1.6"
-228
View File
@@ -1,228 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useDocumentManager, useDocumentSelection } from '@/composables'
import { CollectionObject } from '@/models/collection'
import { EntityObject } from '@/models/entity'
import { formatSize, getFileIcon } from '@/utils/fileHelpers'
export interface DocumentSelection {
kind: 'entity' | 'collection'
identifier: string
name: string
mimeType?: string
size?: number
}
const PROVIDER_ID = 'default'
const SERVICE_ID = 'personal'
const LARGE_FILE_WARNING_BYTES = 25 * 1024 * 1024
const props = withDefaults(defineProps<{
modelValue: boolean
selectable?: ('entity' | 'collection')[]
}>(), {
selectable: () => ['entity'],
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
select: [items: DocumentSelection[]]
}>()
const allowEntities = computed(() => props.selectable.includes('entity'))
const allowCollections = computed(() => props.selectable.includes('collection'))
const documentManager = useDocumentManager({
providerId: PROVIDER_ID,
serviceId: SERVICE_ID,
})
const selection = useDocumentSelection({
multiple: true,
allowFolders: allowCollections.value,
allowFiles: allowEntities.value,
})
const initialized = ref(false)
watch(() => props.modelValue, async (isOpen) => {
if (!isOpen) {
return
}
selection.clear()
if (!initialized.value) {
await documentManager.initialize()
initialized.value = true
}
await documentManager.navigateToRoot()
})
function nodeId(node: CollectionObject | EntityObject): string {
return String(node.identifier ?? '')
}
function nodeLabel(node: CollectionObject | EntityObject): string {
return node.properties.label || nodeId(node)
}
const breadcrumbs = computed(() => [
{ id: documentManager.ROOT_ID, label: 'Home' },
...documentManager.breadcrumbs.value.map(node => ({ id: nodeId(node), label: nodeLabel(node) })),
])
async function navigateToBreadcrumb(id: string) {
selection.clear()
await documentManager.navigateTo(id === documentManager.ROOT_ID ? null : id)
}
async function openFolder(folder: CollectionObject) {
selection.clear()
await documentManager.navigateTo(nodeId(folder))
}
function toggleFolderSelection(folder: CollectionObject, event: Event) {
event.stopPropagation()
selection.toggle(folder)
}
function toggleEntity(entity: EntityObject) {
selection.toggle(entity)
}
const hasLargeSelection = computed(() =>
selection.selectedEntities.value.some(entity => entity.properties.size > LARGE_FILE_WARNING_BYTES),
)
function entityIdentifier(entity: EntityObject): string {
return `${entity.provider}:${entity.service}:${entity.collection}:${entity.identifier}`
}
function collectionIdentifier(collection: CollectionObject): string {
return `${collection.provider}:${collection.service}:${collection.identifier}`
}
function toSelection(node: CollectionObject | EntityObject): DocumentSelection {
if (node instanceof EntityObject) {
return {
kind: 'entity',
identifier: entityIdentifier(node),
name: nodeLabel(node),
mimeType: node.properties.mime || 'application/octet-stream',
size: node.properties.size,
}
}
return {
kind: 'collection',
identifier: collectionIdentifier(node),
name: nodeLabel(node),
}
}
function handleClose() {
emit('update:modelValue', false)
}
function handleConfirm() {
const items = selection.selectedNodeArray.value.map(toSelection)
if (items.length === 0) {
return
}
emit('select', items)
selection.clear()
emit('update:modelValue', false)
}
</script>
<template>
<v-dialog :model-value="modelValue" @update:model-value="emit('update:modelValue', $event)" max-width="560">
<v-card>
<v-card-title class="d-flex align-center">
<v-icon class="mr-2">mdi-folder-search-outline</v-icon>
Select Documents
</v-card-title>
<v-card-text>
<v-breadcrumbs :items="breadcrumbs" density="compact" class="px-0 mb-2">
<template #item="{ item }">
<v-breadcrumbs-item :disabled="item.id === breadcrumbs[breadcrumbs.length - 1]?.id" @click="navigateToBreadcrumb(item.id)">
{{ item.label }}
</v-breadcrumbs-item>
</template>
</v-breadcrumbs>
<v-progress-linear v-if="documentManager.isLoading.value" indeterminate color="primary" class="mb-2" />
<div class="selector-list">
<v-list v-if="documentManager.currentCollections.value.length > 0 || documentManager.currentEntities.value.length > 0" density="compact">
<v-list-item
v-for="folder in documentManager.currentCollections.value"
:key="nodeId(folder)"
:title="nodeLabel(folder)"
@click="openFolder(folder)"
>
<template #prepend>
<v-checkbox-btn
v-if="allowCollections"
:model-value="selection.isSelected(nodeId(folder))"
class="mr-2"
@click="toggleFolderSelection(folder, $event)"
/>
<v-icon>mdi-folder</v-icon>
</template>
<template #append>
<v-icon size="small">mdi-chevron-right</v-icon>
</template>
</v-list-item>
<v-list-item
v-for="entity in documentManager.currentEntities.value"
:key="nodeId(entity)"
:title="nodeLabel(entity)"
:subtitle="formatSize(entity.properties.size)"
:disabled="!allowEntities"
@click="allowEntities && toggleEntity(entity)"
>
<template #prepend>
<v-checkbox-btn v-if="allowEntities" :model-value="selection.isSelected(nodeId(entity))" class="mr-2" />
<v-icon>{{ getFileIcon(entity) }}</v-icon>
</template>
</v-list-item>
</v-list>
<div v-else-if="!documentManager.isLoading.value" class="text-center py-8 text-medium-emphasis">
<v-icon size="40" class="mb-2">mdi-folder-open-outline</v-icon>
<div>This folder is empty</div>
</div>
</div>
<v-alert v-if="hasLargeSelection" type="warning" variant="tonal" density="compact" class="mt-3">
One or more selected files are larger than 25 MB and may take a moment to process.
</v-alert>
</v-card-text>
<v-card-actions>
<span class="text-caption text-medium-emphasis ml-2">
{{ selection.count.value }} selected
</span>
<v-spacer />
<v-btn variant="text" @click="handleClose">Cancel</v-btn>
<v-btn color="primary" variant="elevated" :disabled="!selection.hasSelection.value" @click="handleConfirm">
Select
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.selector-list {
max-height: 360px;
overflow-y: auto;
border: 1px solid rgb(var(--v-border-color));
border-radius: 8px;
}
</style>
-9
View File
@@ -1,9 +0,0 @@
/**
* Central export point for Documents Manager composables
*/
export { useDocumentManager } from './useDocumentManager'
export { useDocumentSelection } from './useDocumentSelection'
export type { UseDocumentManagerOptions } from './useDocumentManager'
export type { UseDocumentSelectionOptions } from './useDocumentSelection'
-349
View File
@@ -1,349 +0,0 @@
/**
* File Manager composable for convenient file/folder operations
* Provides reactive access to file manager state and actions
*/
import { computed, ref, unref } from 'vue'
import type { Ref, ComputedRef } from 'vue'
import { useProvidersStore } from '@/stores/providersStore'
import { useServicesStore } from '@/stores/servicesStore'
import { useNodesStore, ROOT_ID } from '@/stores/nodesStore'
import type { ListFilter, ListSort, ListRange, ServiceIdentifier, CollectionIdentifier } from '@/types/common'
import type { DocumentInterface } from '@/types/document'
import { CollectionObject } from '@/models/collection'
import { EntityObject } from '@/models/entity'
// Base URL for file manager transfer endpoints
const TRANSFER_BASE_URL = '/m/documents_manager'
// Number of collections+entities requested per page
const DEFAULT_PAGE_TALLY = 64
export interface UseDocumentManagerOptions {
providerId: string | Ref<string> | ComputedRef<string>
serviceId: string | Ref<string> | ComputedRef<string>
autoFetch?: boolean
}
export function useDocumentManager(options: UseDocumentManagerOptions) {
const providersStore = useProvidersStore()
const servicesStore = useServicesStore()
const nodesStore = useNodesStore()
const { providerId, serviceId, autoFetch = false } = options
const currentProviderId = () => unref(providerId)
const currentServiceId = () => unref(serviceId)
const currentSources = (): (ServiceIdentifier | CollectionIdentifier)[] => [
currentLocation.value === ROOT_ID
? `${currentProviderId()}:${currentServiceId()}`
: `${currentProviderId()}:${currentServiceId()}:${currentLocation.value}`,
]
// Current location (folder being viewed)
const currentLocation: Ref<string> = ref(ROOT_ID)
// Loading/error state
const isLoading = computed(() => nodesStore.transceiving)
const error = computed(() => nodesStore.error)
// Pagination state for the current location + filter + sort
const currentFilter: Ref<ListFilter | undefined> = ref(undefined)
const currentSort: Ref<ListSort | undefined> = ref(undefined)
const loadedCount = ref(0)
const hasMore = ref(false)
const isLoadingMore = ref(false)
// Provider and service
const provider = computed(() => providersStore.provider(currentProviderId()))
const service = computed(() => servicesStore.service(currentProviderId(), currentServiceId()))
const rootId = computed(() => ROOT_ID)
// Current children
const currentChildren = computed(() =>
nodesStore.getChildren(currentProviderId(), currentServiceId(), currentLocation.value)
)
const currentCollections: ComputedRef<CollectionObject[]> = computed(() =>
nodesStore.getChildCollections(currentProviderId(), currentServiceId(), currentLocation.value)
)
const currentEntities: ComputedRef<EntityObject[]> = computed(() =>
nodesStore.getChildEntities(currentProviderId(), currentServiceId(), currentLocation.value)
)
// Breadcrumb path
const breadcrumbs = computed(() => {
if (currentLocation.value === ROOT_ID) {
return []
}
return nodesStore.getPath(currentProviderId(), currentServiceId(), currentLocation.value)
})
// Is at root?
const isAtRoot = computed(() => currentLocation.value === ROOT_ID)
// Navigate to a folder
const navigateTo = async (collectionId: string | null) => {
currentLocation.value = collectionId || ROOT_ID
await resetAndLoad(currentFilter.value, currentSort.value)
}
// Navigate up one level
const navigateUp = async () => {
if (currentLocation.value === ROOT_ID) {
return
}
const currentNode = nodesStore.getNode(currentProviderId(), currentServiceId(), currentLocation.value)
if (currentNode) {
await navigateTo(currentNode.collection ? String(currentNode.collection) : ROOT_ID)
}
}
// Navigate to root
const navigateToRoot = async () => {
await navigateTo(ROOT_ID)
}
// Reset the current location's loaded items and (re)fetch the first page,
// optionally with a new filter/sort (e.g. search query or sort field change)
const resetAndLoad = async (filter?: ListFilter, sort?: ListSort) => {
currentFilter.value = filter
currentSort.value = sort
loadedCount.value = 0
hasMore.value = false
const range: ListRange = { type: 'tally', anchor: 'absolute', position: 0, tally: DEFAULT_PAGE_TALLY }
const result = await nodesStore.fetchNodes(
currentSources(),
filter,
sort,
range,
'reset'
)
loadedCount.value = result.collections.length + result.entities.length
hasMore.value = !result.isLastPage
}
// Fetch the next page of the current location (infinite scroll "load more")
const loadMore = async () => {
if (!hasMore.value || isLoadingMore.value) {
return
}
isLoadingMore.value = true
try {
const range: ListRange = { type: 'tally', anchor: 'absolute', position: loadedCount.value, tally: DEFAULT_PAGE_TALLY }
const result = await nodesStore.fetchNodes(
currentSources(),
currentFilter.value,
currentSort.value,
range,
'append'
)
loadedCount.value += result.collections.length + result.entities.length
hasMore.value = !result.isLastPage
} finally {
isLoadingMore.value = false
}
}
// Create a new folder
const createFolder = async (label: string): Promise<CollectionObject> => {
return await nodesStore.createCollection(
`${currentProviderId()}:${currentServiceId()}:${currentLocation.value}`,
{ label, owner: '' }
)
}
// Create a new file
const createFile = async (
label: string,
mime: string = 'application/octet-stream'
): Promise<EntityObject> => {
const properties: DocumentInterface = {
'@type': 'documents.properties',
urid: null,
size: 0,
label,
mime,
format: null,
encoding: null,
}
return await nodesStore.createEntity(
`${currentProviderId()}:${currentServiceId()}:${currentLocation.value}`,
properties
)
}
// Rename a node
const renameNode = async (nodeId: string, newLabel: string) => {
const node = nodesStore.getNode(currentProviderId(), currentServiceId(), nodeId)
if (!node) {
throw new Error('Node not found')
}
if (node instanceof CollectionObject) {
return await nodesStore.updateCollection(`${node.provider}:${node.service}:${nodeId}`, {
label: newLabel,
owner: node.properties.owner,
})
} else {
const properties: DocumentInterface = {
'@type': 'documents.properties',
urid: node.properties.urid,
size: node.properties.size,
label: newLabel,
mime: node.properties.mime,
format: node.properties.format,
encoding: node.properties.encoding,
}
return await nodesStore.updateEntity(`${node.provider}:${node.service}:${node.collection || ROOT_ID}:${nodeId}`, properties)
}
}
// Delete a node
const deleteNode = async (nodeId: string): Promise<boolean> => {
const node = nodesStore.getNode(currentProviderId(), currentServiceId(), nodeId)
if (!node) {
throw new Error('Node not found')
}
if (node instanceof CollectionObject) {
return await nodesStore.deleteCollection(`${node.provider}:${node.service}:${nodeId}`)
} else {
return await nodesStore.deleteEntity(`${node.provider}:${node.service}:${node.collection || ROOT_ID}:${nodeId}`)
}
}
// Read file content
const readFile = async (entityId: string): Promise<string | null> => {
const node = nodesStore.getNode(currentProviderId(), currentServiceId(), entityId)
if (!node || !(node instanceof EntityObject)) {
throw new Error('Entity not found')
}
return await nodesStore.readEntity(`${node.provider}:${node.service}:${node.collection || ROOT_ID}:${entityId}`)
}
// Write file content
const writeFile = async (entityId: string, content: string): Promise<number> => {
const node = nodesStore.getNode(currentProviderId(), currentServiceId(), entityId)
if (!node || !(node instanceof EntityObject)) {
throw new Error('Entity not found')
}
return await nodesStore.writeEntity(`${node.provider}:${node.service}:${node.collection || ROOT_ID}:${entityId}`, content)
}
// Get a URL suitable for inline viewing (img src / video src)
const getEntityUrl = (entityId: string, collectionId?: string | null): string => {
const collection = collectionId ?? currentLocation.value
return `${TRANSFER_BASE_URL}/download/entity/${encodeURIComponent(currentProviderId())}/${encodeURIComponent(currentServiceId())}/${encodeURIComponent(collection)}/${encodeURIComponent(entityId)}`
}
const getEntityPreviewUrl = (
entityId: string,
collectionId?: string | null,
variant: 'thumbnail' | 'inline' | 'fullscreen' = 'inline',
): string => {
const collection = collectionId ?? currentLocation.value
return `${TRANSFER_BASE_URL}/preview/entity/${encodeURIComponent(currentProviderId())}/${encodeURIComponent(currentServiceId())}/${encodeURIComponent(collection)}/${encodeURIComponent(entityId)}?variant=${variant}`
}
// Download a single file
const downloadEntity = (entityId: string, collectionId?: string | null): void => {
const collection = collectionId ?? currentLocation.value
// Use path parameters: /download/entity/{provider}/{service}/{collection}/{identifier}
const url = `${TRANSFER_BASE_URL}/download/entity/${encodeURIComponent(currentProviderId())}/${encodeURIComponent(currentServiceId())}/${encodeURIComponent(collection)}/${encodeURIComponent(entityId)}`
// Trigger download by opening URL (browser handles it)
window.open(url, '_blank')
}
const downloadCollection = (collectionId: string): void => {
// Use path parameters: /download/collection/{provider}/{service}/{identifier}
const url = `${TRANSFER_BASE_URL}/download/collection/${encodeURIComponent(currentProviderId())}/${encodeURIComponent(currentServiceId())}/${encodeURIComponent(collectionId)}`
window.open(url, '_blank')
}
const downloadArchive = (ids: string[], name: string = 'download', collectionId?: string | null): void => {
const collection = collectionId ?? currentLocation.value
const params = new URLSearchParams({
provider: currentProviderId(),
service: currentServiceId(),
})
ids.forEach(id => params.append('ids[]', id))
if (name) {
params.append('name', name)
}
if (collection && collection !== ROOT_ID) {
params.append('collection', collection)
}
const url = `${TRANSFER_BASE_URL}/download/archive?${params.toString()}`
window.open(url, '_blank')
}
// Initialize - fetch providers, services, and initial nodes if autoFetch
const initialize = async () => {
await providersStore.list([currentProviderId()])
await servicesStore.list()
if (autoFetch) {
await resetAndLoad()
}
}
return {
// State
currentLocation,
isLoading,
error,
hasMore,
isLoadingMore,
// Provider/Service
provider,
service,
rootId,
// Current view
currentChildren,
currentCollections,
currentEntities,
breadcrumbs,
isAtRoot,
// Navigation
navigateTo,
navigateUp,
navigateToRoot,
resetAndLoad,
loadMore,
// Operations
createFolder,
createFile,
renameNode,
deleteNode,
readFile,
writeFile,
getEntityUrl,
getEntityPreviewUrl,
downloadEntity,
downloadCollection,
downloadArchive,
// Initialize
initialize,
// Constants
ROOT_ID,
}
}
export default useDocumentManager
-135
View File
@@ -1,135 +0,0 @@
/**
* File selection composable
* Provides reactive selection management for file manager
*/
import { ref, computed } from 'vue'
import type { Ref, ComputedRef } from 'vue'
import { CollectionObject } from '@/models/collection'
import { EntityObject } from '@/models/entity'
type NodeRecord = CollectionObject | EntityObject
export interface UseDocumentSelectionOptions {
multiple?: boolean
allowFolders?: boolean
allowFiles?: boolean
}
export function useDocumentSelection(options: UseDocumentSelectionOptions = {}) {
const {
multiple = true,
allowFolders = true,
allowFiles = true,
} = options
const selectedIds: Ref<Set<string>> = ref(new Set())
const selectedNodes: Ref<Map<string, NodeRecord>> = ref(new Map())
const count: ComputedRef<number> = computed(() => selectedIds.value.size)
const hasSelection: ComputedRef<boolean> = computed(() => selectedIds.value.size > 0)
const selectedIdArray: ComputedRef<string[]> = computed(() => Array.from(selectedIds.value))
const selectedNodeArray: ComputedRef<NodeRecord[]> = computed(() => Array.from(selectedNodes.value.values()))
const selectedCollections: ComputedRef<CollectionObject[]> = computed(() =>
selectedNodeArray.value.filter((node): node is CollectionObject => node instanceof CollectionObject),
)
const selectedEntities: ComputedRef<EntityObject[]> = computed(() =>
selectedNodeArray.value.filter((node): node is EntityObject => node instanceof EntityObject),
)
const isSelected = (nodeId: string): boolean => selectedIds.value.has(nodeId)
const isTypeAllowed = (node: NodeRecord): boolean => {
if (node instanceof CollectionObject && !allowFolders) return false
if (node instanceof EntityObject && !allowFiles) return false
return true
}
const select = (node: NodeRecord) => {
if (!isTypeAllowed(node)) return
if (!multiple) {
selectedIds.value.clear()
selectedNodes.value.clear()
}
const nodeId = String(node.identifier)
selectedIds.value.add(nodeId)
selectedNodes.value.set(nodeId, node)
}
const deselect = (nodeId: string) => {
selectedIds.value.delete(nodeId)
selectedNodes.value.delete(nodeId)
}
const toggle = (node: NodeRecord) => {
const nodeId = String(node.identifier)
if (isSelected(nodeId)) {
deselect(nodeId)
return
}
select(node)
}
const selectMultiple = (nodes: NodeRecord[]) => {
if (!multiple) {
const lastNode = nodes[nodes.length - 1]
if (lastNode && isTypeAllowed(lastNode)) {
selectedIds.value.clear()
selectedNodes.value.clear()
const nodeId = String(lastNode.identifier)
selectedIds.value.add(nodeId)
selectedNodes.value.set(nodeId, lastNode)
}
return
}
for (const node of nodes) {
if (isTypeAllowed(node)) {
const nodeId = String(node.identifier)
selectedIds.value.add(nodeId)
selectedNodes.value.set(nodeId, node)
}
}
}
const selectAll = (nodes: NodeRecord[]) => {
if (!multiple) return
selectMultiple(nodes)
}
const clear = () => {
selectedIds.value.clear()
selectedNodes.value.clear()
}
const setSelection = (nodes: NodeRecord[]) => {
clear()
selectMultiple(nodes)
}
return {
selectedIds,
selectedNodes,
count,
hasSelection,
selectedIdArray,
selectedNodeArray,
selectedCollections,
selectedEntities,
isSelected,
select,
deselect,
toggle,
selectMultiple,
selectAll,
clear,
setSelection,
}
}
export default useDocumentSelection
-14
View File
@@ -1,14 +0,0 @@
import type { ModuleIntegrations } from "@KTXC/types/moduleTypes";
const integrations: ModuleIntegrations = {
documents_selector: [
{
id: 'dialog',
label: 'Document Selector Dialog',
priority: 10,
component: () => import('./components/DocumentSelectorDialog.vue'),
},
],
};
export default integrations;
+2 -8
View File
@@ -2,20 +2,14 @@
* Documents Manager Module Boot
*/
import integrations from '@/integrations'
console.log('[Documents Manager] Booting module...')
console.log('[Documents Manager] Module booted successfully...')
// CSS filename is injected by the vite plugin at build time
export const css = ['__CSS_FILENAME_PLACEHOLDER__']
export { integrations }
// CSS will be injected by build process
//export const css = ['__CSS_FILENAME_PLACEHOLDER__']
// Export services, stores and models for external use
export * from '@/services'
export * from '@/stores'
export * from '@/models'
export * from '@/composables'
export * from '@/utils/fileHelpers'
+31 -8
View File
@@ -25,11 +25,23 @@ import type {
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
import { CollectionObject, CollectionPropertiesObject } from '../models/collection';
function isCollectionPayload(value: unknown): value is CollectionInterface {
if (!value || typeof value !== 'object') {
return false;
}
const candidate = value as Record<string, unknown>;
return (
('identifier' in candidate || 'provider' in candidate || 'service' in candidate)
&& 'properties' in candidate
);
}
/**
* Helper to create the right collection model class based on provider identifier
* Uses provider-specific factory if available, otherwise returns base CollectionObject
*/
export function createCollectionObject(data: CollectionInterface): CollectionObject {
function createCollectionObject(data: CollectionInterface): CollectionObject {
const integrationStore = useIntegrationStore();
const factoryItem = integrationStore.getItemById('documents_collection_factory', data.provider) as any;
const factory = factoryItem?.factory;
@@ -56,27 +68,38 @@ export const collectionService = {
const serviceList: Record<string, Record<string, CollectionObject>> = {};
Object.entries(providerServices).forEach(([serviceId, serviceCollections]) => {
const collectionList: Record<string, CollectionObject> = {};
Object.entries(serviceCollections).forEach(([collectionId, collectionData]) => {
collectionList[collectionId] = createCollectionObject(collectionData);
Object.entries(serviceCollections as Record<string, unknown>).forEach(([collectionId, collectionData]) => {
if (isCollectionPayload(collectionData)) {
collectionList[collectionId] = createCollectionObject(collectionData);
return;
}
if (collectionData && typeof collectionData === 'object') {
Object.entries(collectionData as Record<string, unknown>).forEach(([nestedCollectionId, nestedCollectionData]) => {
if (isCollectionPayload(nestedCollectionData)) {
collectionList[nestedCollectionId] = createCollectionObject(nestedCollectionData);
}
});
}
});
serviceList[serviceId] = collectionList;
});
providerList[providerId] = serviceList;
});
return providerList;
},
/**
* Retrieve collections by resource identifier
* Retrieve a specific collection by provider and identifier
*
* @param request - fetch request parameters
*
* @returns Promise with collections keyed by resource identifier
* @returns Promise with collection object
*/
async fetch(request: CollectionFetchRequest): Promise<Record<string, CollectionObject>> {
async fetch(request: CollectionFetchRequest): Promise<CollectionObject> {
const response = await transceivePost<CollectionFetchRequest, CollectionFetchResponse>('collection.fetch', request);
return Object.fromEntries(Object.entries(response).map(([target, data]) => [target, createCollectionObject(data)]));
return createCollectionObject(response);
},
/**
+1 -1
View File
@@ -35,7 +35,7 @@ import { EntityObject } from '../models';
* Helper to create the right entity model class based on provider identifier
* Uses provider-specific factory if available, otherwise returns base EntityObject
*/
export function createEntityObject(data: EntityInterface): EntityObject {
function createEntityObject(data: EntityInterface): EntityObject {
const integrationStore = useIntegrationStore();
const factoryItem = integrationStore.getItemById('documents_entity_factory', data.provider) as any;
const factory = factoryItem?.factory;
-34
View File
@@ -1,34 +0,0 @@
import { transceiveStream } from './transceive';
import type { NodeListRequest, NodeInterface } from '../types/node';
import { createCollectionObject } from './collectionService';
import { createEntityObject } from './entityService';
import type { CollectionObject } from '../models/collection';
import type { EntityObject } from '../models/entity';
export type NodeListResult = (CollectionObject | EntityObject)[];
export const nodeService = {
/** Hydrate a mixed page in the exact order returned by the provider. */
async list(request: NodeListRequest = {}): Promise<NodeListResult> {
const nodes: NodeListResult = [];
const { total } = await transceiveStream<NodeListRequest, NodeInterface>('node.list', request, node => {
switch (node['@type']) {
case 'document:collection':
nodes.push(createCollectionObject(node));
break;
case 'document:entity':
nodes.push(createEntityObject(node));
break;
default:
throw new Error('Unsupported node type in node.list response');
}
}, { requireEnd: true });
if (total !== nodes.length) {
throw new Error('Incomplete node.list stream');
}
// Publish only complete pages so a retry uses the same offset after failure.
return nodes;
},
};
export default nodeService;
+1 -7
View File
@@ -62,7 +62,7 @@ export async function transceiveStream<TRequest, TData>(
operation: string,
data: TRequest,
onData: (data: TData) => void,
options?: { user?: string; onStart?: (expected?: number) => void; requireEnd?: boolean }
options?: { user?: string; onStart?: (expected?: number) => void }
): Promise<{ total: number }> {
const request: ApiRequest<TRequest> = {
version: API_VERSION,
@@ -73,7 +73,6 @@ export async function transceiveStream<TRequest, TData>(
};
let total = 0;
let ended = false;
const dispatch = (line: string): void => {
const message = JSON.parse(line) as ApiStreamResponse<TData>;
@@ -83,7 +82,6 @@ export async function transceiveStream<TRequest, TData>(
options?.onStart?.(message.total);
} else if (message.status === 'end') {
total = message.total;
ended = true;
}
return;
}
@@ -120,7 +118,6 @@ export async function transceiveStream<TRequest, TData>(
}
}
buffer += decoder.decode();
if (buffer.trim()) dispatch(buffer);
} finally {
reader.releaseLock();
@@ -128,8 +125,5 @@ export async function transceiveStream<TRequest, TData>(
},
});
if (options?.requireEnd && !ended) {
throw new Error(`[${operation}] Stream ended without a completion frame`);
}
return { total };
}
+29 -41
View File
@@ -40,25 +40,25 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () =
return `${provider}:${service ?? ''}:${identifier ?? ''}`
}
function collection(target: CollectionIdentifier, retrieve: boolean = false): CollectionObject | null {
const key = target
function collection(provider: string, service: string | number, identifier: string | number, retrieve: boolean = false): CollectionObject | null {
const key = identifierKey(provider, service, identifier)
if (retrieve === true && !_collections.value[key]) {
console.debug(`[Documents Manager][Store] - Force fetching collection "${key}"`)
void fetch([target])
fetch(provider, service, identifier)
}
return _collections.value[key] || null
}
function collectionsForService(target: ServiceIdentifier): CollectionObject[] {
const serviceKeyPrefix = `${target}:`
function collectionsForService(provider: string, service: string | number): CollectionObject[] {
const serviceKeyPrefix = `${provider}:${service}:`
return Object.entries(_collections.value)
.filter(([key]) => key.startsWith(serviceKeyPrefix))
.map(([_, collectionObj]) => collectionObj)
}
function clearService(target: ServiceIdentifier): void {
const serviceKeyPrefix = `${target}:`
function clearService(provider: string, service: string | number): void {
const serviceKeyPrefix = `${provider}:${service}:`
Object.keys(_collections.value)
.filter((key) => key.startsWith(serviceKeyPrefix))
.forEach((key) => {
@@ -70,27 +70,6 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () =
_collections.value = {}
}
/**
* Merge externally-hydrated collections into the store (used by paginated node fetches)
*/
function mergeMany(collections: CollectionObject[]): void {
const hydrated: Record<string, CollectionObject> = {}
collections.forEach((collectionObj) => {
const key = identifierKey(collectionObj.provider, collectionObj.service, collectionObj.identifier)
hydrated[key] = collectionObj
})
_collections.value = { ..._collections.value, ...hydrated }
}
/**
* Remove specific collections by their own identifiers (used to evict a stale page before a reset)
*/
function removeMany(targets: CollectionIdentifier[]): void {
targets.forEach((target) => {
delete _collections.value[target]
})
}
// Actions
async function list(sources?: (ServiceIdentifier | CollectionIdentifier)[], filter?: ListFilter, sort?: ListSort): Promise<Record<string, CollectionObject>> {
transceiving.value = true
@@ -119,11 +98,14 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () =
}
}
async function fetch(targets: CollectionIdentifier[]): Promise<Record<string, CollectionObject>> {
async function fetch(provider: string, service: string | number, identifier: string | number): Promise<CollectionObject> {
transceiving.value = true
try {
const response = await collectionService.fetch({ targets })
_collections.value = { ..._collections.value, ...response }
const response = await collectionService.fetch({ targets: [`${provider}:${service}:${identifier}`] })
const key = identifierKey(response.provider, response.service, response.identifier)
_collections.value[key] = response
console.debug('[Documents Manager][Store] - Successfully fetched collection:', key)
return response
} catch (error: any) {
console.error('[Documents Manager][Store] - Failed to fetch collection:', error)
@@ -148,12 +130,17 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () =
}
async function create(
target: ServiceIdentifier | CollectionIdentifier,
provider: string,
service: string | number,
collection: string | number | null,
properties: CollectionMutableProperties,
): Promise<CollectionObject> {
transceiving.value = true
try {
const response = await collectionService.create({ target, properties })
const target: CollectionIdentifier | undefined = collection !== null && collection !== undefined && collection !== ''
? `${provider}:${service}:${collection}`
: undefined
const response = await collectionService.create({ provider, service: String(service), target, properties })
const key = identifierKey(response.provider, response.service, response.identifier)
_collections.value[key] = response
@@ -168,12 +155,14 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () =
}
async function update(
target: CollectionIdentifier,
provider: string,
service: string | number,
identifier: string | number,
properties: CollectionMutableProperties,
): Promise<CollectionObject> {
transceiving.value = true
try {
const response = await collectionService.update({ target, properties })
const response = await collectionService.update({ target: `${provider}:${service}:${identifier}`, properties })
const key = identifierKey(response.provider, response.service, response.identifier)
_collections.value[key] = response
@@ -187,16 +176,17 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () =
}
}
async function remove(target: CollectionIdentifier): Promise<{ success: boolean }> {
async function remove(provider: string, service: string | number, identifier: string | number): Promise<{ success: boolean }> {
transceiving.value = true
try {
const response = await collectionService.delete({ target })
const response = await collectionService.delete({ target: `${provider}:${service}:${identifier}` })
const success = response.disposition === 'deleted' || response.disposition === 'moved'
if (success) {
delete _collections.value[target]
const key = identifierKey(provider, service, identifier)
delete _collections.value[key]
}
console.debug('[Documents Manager][Store] - Successfully deleted collection:', target)
console.debug('[Documents Manager][Store] - Successfully deleted collection:', `${provider}:${service}:${identifier}`)
return { success }
} catch (error: any) {
console.error('[Documents Manager][Store] - Failed to delete collection:', error)
@@ -216,8 +206,6 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () =
collectionsForService,
clearService,
clearAll,
mergeMany,
removeMany,
list,
fetch,
extant,
+62 -48
View File
@@ -49,34 +49,37 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => {
}
function entity(
target: EntityIdentifier,
provider: string,
service: string | number,
collection: string | number,
identifier: string | number,
retrieve: boolean = false,
): EntityObject | null {
const key = target
const key = identifierKey(provider, service, collection, identifier)
if (retrieve === true && !_entities.value[key]) {
console.debug(`[Documents Manager][Store] - Force fetching entity "${key}"`)
void fetch([target])
fetch(provider, service, collection, [identifier])
}
return _entities.value[key] || null
}
function entitiesForService(target: ServiceIdentifier): EntityObject[] {
const serviceKeyPrefix = `${target}:`
function entitiesForService(provider: string, service: string | number): EntityObject[] {
const serviceKeyPrefix = `${provider}:${service}:`
return Object.entries(_entities.value)
.filter(([key]) => key.startsWith(serviceKeyPrefix))
.map(([_, entityObj]) => entityObj)
}
function entitiesForCollection(target: CollectionIdentifier): EntityObject[] {
const collectionKeyPrefix = `${target}:`
function entitiesForCollection(provider: string, service: string | number, collection: string | number): EntityObject[] {
const collectionKeyPrefix = `${provider}:${service}:${collection}:`
return Object.entries(_entities.value)
.filter(([key]) => key.startsWith(collectionKeyPrefix))
.map(([_, entityObj]) => entityObj)
}
function clearService(target: ServiceIdentifier): void {
const serviceKeyPrefix = `${target}:`
function clearService(provider: string, service: string | number): void {
const serviceKeyPrefix = `${provider}:${service}:`
Object.keys(_entities.value)
.filter((key) => key.startsWith(serviceKeyPrefix))
.forEach((key) => {
@@ -84,8 +87,8 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => {
})
}
function clearCollection(target: CollectionIdentifier): void {
const collectionKeyPrefix = `${target}:`
function clearCollection(provider: string, service: string | number, collection: string | number): void {
const collectionKeyPrefix = `${provider}:${service}:${collection}:`
Object.keys(_entities.value)
.filter((key) => key.startsWith(collectionKeyPrefix))
.forEach((key) => {
@@ -97,18 +100,6 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => {
_entities.value = {}
}
/**
* Merge externally-hydrated entities into the store (used by paginated node fetches)
*/
function mergeMany(entities: EntityObject[]): void {
const hydrated: Record<string, EntityObject> = {}
entities.forEach((entityObj) => {
const key = identifierKey(entityObj.provider, entityObj.service, entityObj.collection, entityObj.identifier)
hydrated[key] = entityObj
})
_entities.value = { ..._entities.value, ...hydrated }
}
// Actions
async function list(sources?: (ServiceIdentifier | CollectionIdentifier)[], filter?: ListFilter, sort?: ListSort, range?: ListRange): Promise<Record<string, EntityObject>> {
transceiving.value = true
@@ -140,13 +131,25 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => {
}
async function fetch(
targets: EntityIdentifier[],
provider: string,
service: string | number,
collection: string | number,
identifiers: (string | number)[],
): Promise<Record<string, EntityObject>> {
transceiving.value = true
try {
const targets: EntityIdentifier[] = identifiers.map((identifier) => `${provider}:${service}:${collection}:${identifier}` as EntityIdentifier)
const response = await entityService.fetch({ targets })
_entities.value = { ..._entities.value, ...response }
return response
const hydrated: Record<string, EntityObject> = {}
Object.entries(response).forEach(([identifier, entityObj]) => {
const key = identifierKey(provider, service, collection, identifier)
hydrated[key] = entityObj
_entities.value[key] = entityObj
})
console.debug('[Documents Manager][Store] - Successfully fetched', Object.keys(hydrated).length, 'entities')
return hydrated
} catch (error: any) {
console.error('[Documents Manager][Store] - Failed to fetch entities:', error)
throw error
@@ -170,13 +173,15 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => {
}
async function create(
target: CollectionIdentifier,
provider: string,
service: string | number,
collection: string | number,
properties: DocumentInterface,
options?: Record<string, unknown>,
): Promise<EntityObject> {
transceiving.value = true
try {
const response = await entityService.create({ target, properties, options })
const response = await entityService.create({ target: `${provider}:${service}:${collection}`, properties, options })
const key = identifierKey(response.provider, response.service, response.collection, response.identifier)
_entities.value[key] = response
@@ -191,12 +196,15 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => {
}
async function update(
target: EntityIdentifier,
provider: string,
service: string | number,
collection: string | number,
identifier: string | number,
properties: DocumentInterface,
): Promise<EntityObject> {
transceiving.value = true
try {
const response = await entityService.update({ target, properties })
const response = await entityService.update({ target: `${provider}:${service}:${collection}:${identifier}`, properties })
const key = identifierKey(response.provider, response.service, response.collection, response.identifier)
_entities.value[key] = response
@@ -211,22 +219,23 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => {
}
async function remove(
targets: EntityIdentifier[],
): Promise<{ successes: EntityIdentifier[]; failures: EntityIdentifier[] }> {
provider: string,
service: string | number,
collection: string | number,
identifier: string | number,
): Promise<{ success: boolean }> {
transceiving.value = true
try {
const response = await entityService.delete({ targets })
const successes: EntityIdentifier[] = []
const failures: EntityIdentifier[] = []
for (const target of targets) {
if (response[target]?.disposition === 'deleted') {
delete _entities.value[target]
successes.push(target)
} else {
failures.push(target)
}
const target: EntityIdentifier = `${provider}:${service}:${collection}:${identifier}`
const response = await entityService.delete({ targets: [target] })
const success = response[target]?.disposition === 'deleted'
if (success) {
const key = identifierKey(provider, service, collection, identifier)
delete _entities.value[key]
}
return { successes, failures }
console.debug('[Documents Manager][Store] - Successfully deleted entity:', target)
return { success }
} catch (error: any) {
console.error('[Documents Manager][Store] - Failed to delete entity:', error)
throw error
@@ -268,11 +277,14 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => {
}
async function read(
target: EntityIdentifier,
provider: string,
service: string | number,
collection: string | number,
identifier: string | number,
): Promise<string | null> {
transceiving.value = true
try {
const response = await entityService.read({ target })
const response = await entityService.read({ target: `${provider}:${service}:${collection}:${identifier}` })
return response.content
} catch (error: any) {
console.error('[Documents Manager][Store] - Failed to read entity:', error)
@@ -283,12 +295,15 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => {
}
async function write(
target: EntityIdentifier,
provider: string,
service: string | number,
collection: string | number,
identifier: string | number,
content: string,
): Promise<number> {
transceiving.value = true
try {
const response = await entityService.write({ target, content, encoding: 'base64' })
const response = await entityService.write({ target: `${provider}:${service}:${collection}:${identifier}`, content, encoding: 'base64' })
return response.bytesWritten
} catch (error: any) {
console.error('[Documents Manager][Store] - Failed to write entity:', error)
@@ -310,7 +325,6 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => {
clearService,
clearCollection,
clearAll,
mergeMany,
list,
fetch,
extant,
+68 -76
View File
@@ -6,7 +6,6 @@ import { computed, ref, readonly } from 'vue'
import { defineStore } from 'pinia'
import type {
CollectionIdentifier,
EntityIdentifier,
ServiceIdentifier,
ListFilter,
ListSort,
@@ -17,7 +16,6 @@ import type {
import { CollectionObject, EntityObject } from '../models'
import { useCollectionsStore } from './collectionsStore'
import { useEntitiesStore } from './entitiesStore'
import { nodeService } from '../services/nodeService'
export const ROOT_ID = '00000000-0000-0000-0000-000000000000'
@@ -48,8 +46,8 @@ export const useNodesStore = defineStore('documentsNodesStore', () => {
function getServiceNodes(providerId: string, serviceId: string | number): NodeRecord[] {
return [
...collectionsStore.collectionsForService(`${providerId}:${serviceId}`),
...entitiesStore.entitiesForService(`${providerId}:${serviceId}`),
...collectionsStore.collectionsForService(providerId, serviceId),
...entitiesStore.entitiesForService(providerId, serviceId),
]
}
@@ -107,13 +105,20 @@ export const useNodesStore = defineStore('documentsNodesStore', () => {
}
async function fetchCollections(
sources?: (ServiceIdentifier | CollectionIdentifier)[],
providerId: string,
serviceId: string | number,
collectionId: string | number | null,
filter?: ListFilter,
sort?: ListSort,
): Promise<CollectionObject[]> {
error.value = null
try {
return Object.values(await collectionsStore.list(sources, filter, sort))
const sources: (ServiceIdentifier | CollectionIdentifier)[] = collectionId === null
? [`${providerId}:${serviceId}`]
: [`${providerId}:${serviceId}:${collectionId}`]
await collectionsStore.list(sources, filter, sort)
return collectionsStore.collectionsForService(providerId, serviceId)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to fetch collections'
throw e
@@ -121,76 +126,43 @@ export const useNodesStore = defineStore('documentsNodesStore', () => {
}
async function fetchEntities(
sources?: (ServiceIdentifier | CollectionIdentifier)[],
providerId: string,
serviceId: string | number,
collectionId: string | number | null,
filter?: ListFilter,
sort?: ListSort,
range?: ListRange,
): Promise<EntityObject[]> {
error.value = null
try {
return Object.values(await entitiesStore.list(sources, filter, sort, range))
const sources: (ServiceIdentifier | CollectionIdentifier)[] = collectionId === null
? [`${providerId}:${serviceId}`]
: [`${providerId}:${serviceId}:${collectionId}`]
await entitiesStore.list(sources, filter, sort, range)
return entitiesStore.entitiesForCollection(providerId, serviceId, collectionId)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to fetch entities'
throw e
}
}
/**
* Fetch a unified node listing, optionally limited to a page by range.
*/
async function fetchNodes(
sources?: (ServiceIdentifier | CollectionIdentifier)[],
providerId: string,
serviceId: string | number,
parentId: string | number | null = ROOT_ID,
filter?: ListFilter,
sort?: ListSort,
range?: ListRange,
mode: 'reset' | 'append' = 'reset',
): Promise<{ collections: CollectionObject[]; entities: EntityObject[]; isLastPage: boolean }> {
): Promise<NodeRecord[]> {
error.value = null
try {
const result = await nodeService.list({ sources, filter, sort, range })
await Promise.all([
fetchCollections(providerId, serviceId, parentId, filter, sort),
fetchEntities(providerId, serviceId, parentId, filter, sort, range),
])
// Service selectors list the root; collection selectors list that folder.
// Omitted sources list roots across services, not every cached descendant.
const locations = new Map<string, { provider: string; service: string; parent: string }>()
if (sources?.length) {
for (const source of sources) {
const [provider, service, parent] = source.split(':')
const location = { provider: provider!, service: service!, parent: isRoot(parent) ? ROOT_ID : parent! }
locations.set(`${location.provider}:${location.service}:${location.parent}`, location)
}
} else {
for (const node of [...nodeList.value, ...result]) {
const location = { provider: node.provider, service: String(node.service), parent: ROOT_ID }
locations.set(`${location.provider}:${location.service}:${ROOT_ID}`, location)
}
}
// Replace only the selected locations, and only after the stream succeeds.
if (mode === 'reset') {
for (const { provider, service, parent } of locations.values()) {
const staleIds = getChildCollections(provider, service, parent)
.map((node): CollectionIdentifier => `${node.provider}:${node.service}:${node.identifier}`)
collectionsStore.removeMany(staleIds)
entitiesStore.clearCollection(`${provider}:${service}:${parent}`)
}
}
const pageCollections = result.filter((node): node is CollectionObject => node instanceof CollectionObject)
const pageEntities = result.filter((node): node is EntityObject => node instanceof EntityObject)
collectionsStore.mergeMany(pageCollections)
entitiesStore.mergeMany(pageEntities)
// The backend applies the range independently to each selected location.
const counts = new Map<string, number>()
for (const node of result) {
const parent = isRoot(node.collection) ? ROOT_ID : String(node.collection)
const key = `${node.provider}:${node.service}:${parent}`
counts.set(key, (counts.get(key) ?? 0) + 1)
}
const isLastPage = range === undefined || [...counts.values()].every(count => count < range.tally)
return { collections: pageCollections, entities: pageEntities, isLastPage }
return getChildren(providerId, serviceId, parentId)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to fetch nodes'
throw e
@@ -198,12 +170,14 @@ export const useNodesStore = defineStore('documentsNodesStore', () => {
}
async function createCollection(
target: ServiceIdentifier | CollectionIdentifier,
providerId: string,
serviceId: string | number,
parentCollectionId: string | number | null,
properties: CollectionMutableProperties,
): Promise<CollectionObject> {
error.value = null
try {
return await collectionsStore.create(target, properties)
return await collectionsStore.create(providerId, serviceId, parentCollectionId, properties)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to create collection'
throw e
@@ -211,12 +185,14 @@ export const useNodesStore = defineStore('documentsNodesStore', () => {
}
async function updateCollection(
target: CollectionIdentifier,
providerId: string,
serviceId: string | number,
identifier: string | number,
properties: CollectionMutableProperties,
): Promise<CollectionObject> {
error.value = null
try {
return await collectionsStore.update(target, properties)
return await collectionsStore.update(providerId, serviceId, identifier, properties)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to update collection'
throw e
@@ -224,11 +200,13 @@ export const useNodesStore = defineStore('documentsNodesStore', () => {
}
async function deleteCollection(
target: CollectionIdentifier,
providerId: string,
serviceId: string | number,
identifier: string | number,
): Promise<boolean> {
error.value = null
try {
const response = await collectionsStore.delete(target)
const response = await collectionsStore.delete(providerId, serviceId, identifier)
return response.success
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to delete collection'
@@ -237,13 +215,15 @@ export const useNodesStore = defineStore('documentsNodesStore', () => {
}
async function createEntity(
target: CollectionIdentifier,
providerId: string,
serviceId: string | number,
collectionId: string | number,
properties: DocumentInterface,
options?: Record<string, unknown>,
): Promise<EntityObject> {
error.value = null
try {
return await entitiesStore.create(target, properties, options)
return await entitiesStore.create(providerId, serviceId, collectionId, properties, options)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to create entity'
throw e
@@ -251,12 +231,15 @@ export const useNodesStore = defineStore('documentsNodesStore', () => {
}
async function updateEntity(
target: EntityIdentifier,
providerId: string,
serviceId: string | number,
collectionId: string | number,
identifier: string | number,
properties: DocumentInterface,
): Promise<EntityObject> {
error.value = null
try {
return await entitiesStore.update(target, properties)
return await entitiesStore.update(providerId, serviceId, collectionId, identifier, properties)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to update entity'
throw e
@@ -264,12 +247,15 @@ export const useNodesStore = defineStore('documentsNodesStore', () => {
}
async function deleteEntity(
target: EntityIdentifier,
providerId: string,
serviceId: string | number,
collectionId: string | number,
identifier: string | number,
): Promise<boolean> {
error.value = null
try {
const response = await entitiesStore.delete([target])
return response.successes.includes(target)
const response = await entitiesStore.delete(providerId, serviceId, collectionId, identifier)
return response.success
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to delete entity'
throw e
@@ -277,11 +263,14 @@ export const useNodesStore = defineStore('documentsNodesStore', () => {
}
async function readEntity(
target: EntityIdentifier,
providerId: string,
serviceId: string | number,
collectionId: string | number,
identifier: string | number,
): Promise<string | null> {
error.value = null
try {
return await entitiesStore.read(target)
return await entitiesStore.read(providerId, serviceId, collectionId, identifier)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to read entity'
throw e
@@ -289,12 +278,15 @@ export const useNodesStore = defineStore('documentsNodesStore', () => {
}
async function writeEntity(
target: EntityIdentifier,
providerId: string,
serviceId: string | number,
collectionId: string | number,
identifier: string | number,
content: string,
): Promise<number> {
error.value = null
try {
return await entitiesStore.write(target, content)
return await entitiesStore.write(providerId, serviceId, collectionId, identifier, content)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to write entity'
throw e
@@ -302,8 +294,8 @@ export const useNodesStore = defineStore('documentsNodesStore', () => {
}
function clearServiceNodes(providerId: string, serviceId: string | number): void {
collectionsStore.clearService(`${providerId}:${serviceId}`)
entitiesStore.clearService(`${providerId}:${serviceId}`)
collectionsStore.clearService(providerId, serviceId)
entitiesStore.clearService(providerId, serviceId)
}
function clearNodes(): void {
+5 -3
View File
@@ -66,7 +66,7 @@ export interface CollectionFetchRequest {
targets: CollectionIdentifier[];
}
export type CollectionFetchResponse = Record<CollectionIdentifier, CollectionInterface>;
export interface CollectionFetchResponse extends CollectionInterface {}
/**
* Collection extant
@@ -87,7 +87,9 @@ export interface CollectionExtantResponse {
* Collection create
*/
export interface CollectionCreateRequest {
target: ServiceIdentifier | CollectionIdentifier; // Service root or parent collection
provider: string;
service: string | number;
target?: CollectionIdentifier | null; // Parent collection identifier (absent for root)
properties: CollectionMutableProperties;
options?: Record<string, unknown>;
}
@@ -137,4 +139,4 @@ export interface CollectionMoveRequest {
target?: CollectionIdentifier | null; // Destination parent (absent for root)
}
export interface CollectionMoveResponse extends CollectionInterface {}
export interface CollectionMoveResponse extends CollectionInterface {}
-1
View File
@@ -3,5 +3,4 @@ export type * from './provider';
export type * from './service';
export type * from './collection';
export type * from './entity';
export type * from './node';
export type * from './document';
-23
View File
@@ -1,23 +0,0 @@
/**
* Node (unified collections + entities) type definitions
*/
import type { CollectionIdentifier, ListFilter, ListRange, ListSort, ServiceIdentifier } from './common';
import type { CollectionInterface } from './collection';
import type { EntityInterface } from './entity';
/**
* Node list
*/
export interface NodeListRequest {
sources?: (ServiceIdentifier | CollectionIdentifier)[];
filter?: ListFilter;
sort?: ListSort;
range?: ListRange;
}
export type NodeInterface =
| (Omit<CollectionInterface, '@type'> & { '@type': 'document:collection' })
| (Omit<EntityInterface, '@type'> & { '@type': 'document:entity' });
/** Payload of each data frame in the node.list NDJSON stream. */
export type NodeListResponse = NodeInterface;
-71
View File
@@ -1,71 +0,0 @@
import { EntityObject } from '@/models/entity'
/**
* Get the appropriate icon for a file based on its MIME type
*/
export function getFileIcon(entity: EntityObject): string {
const mime = entity.properties.mime || ''
if (!mime) return 'mdi-file'
if (mime.startsWith('image/')) return 'mdi-file-image'
if (mime.startsWith('video/')) return 'mdi-file-video'
if (mime.startsWith('audio/')) return 'mdi-file-music'
if (mime.startsWith('text/')) return 'mdi-file-document'
if (mime === 'application/pdf') return 'mdi-file-pdf-box'
if (mime.includes('zip') || mime.includes('tar') || mime.includes('compressed')) return 'mdi-folder-zip'
if (mime.includes('word') || mime.includes('document')) return 'mdi-file-word'
if (mime.includes('excel') || mime.includes('spreadsheet')) return 'mdi-file-excel'
if (mime.includes('powerpoint') || mime.includes('presentation')) return 'mdi-file-powerpoint'
return 'mdi-file'
}
/**
* Format file size in human readable format
*/
export function formatSize(bytes: number): string {
const safeBytes = Number.isFinite(bytes) ? bytes : 0
if (safeBytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(safeBytes) / Math.log(k))
return parseFloat((safeBytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
/**
* Format date string to localized format
*/
export function formatDate(value: Date | string | null | undefined): string {
if (!value) return '—'
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
/**
* Get upload status icon
*/
export function getUploadStatusIcon(status: string): string {
switch (status) {
case 'pending': return 'mdi-clock-outline'
case 'uploading': return 'mdi-loading mdi-spin'
case 'completed': return 'mdi-check-circle'
case 'error': return 'mdi-alert-circle'
default: return 'mdi-file'
}
}
/**
* Get upload status color
*/
export function getUploadStatusColor(status: string): string {
switch (status) {
case 'pending': return 'grey'
case 'uploading': return 'primary'
case 'completed': return 'success'
case 'error': return 'error'
default: return 'grey'
}
}
+1 -1
View File
@@ -15,5 +15,5 @@
"@KTXC/*": ["../../core/src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
"include": ["src/**/*.ts", "src/**/*.tsx"]
}