47 Commits

Author SHA1 Message Date
Sebastian 71d75737e0 chore(deps): update dependency vue to v3.5.42
Build Test / test (pull_request) Successful in 39s
JS Unit Tests / test (pull_request) Successful in 40s
PHP Integration Tests / Integration Tests (pull_request) Failing after 1m49s
PHP Unit Tests / test (pull_request) Successful in 3m9s
2026-09-07 03:02:14 +00:00
Sebastian 4b37be3e7a feat: use resource identifiers
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-09-05 20:12:27 -04:00
Sebastian 26ed0c8aad feat: use resource identifier
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-09-05 20:10:08 -04:00
Sebastian 41de70da75 refactor: use common validation
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-09-05 20:05:08 -04:00
Sebastian e802c8fc0f feat: use ResourceIdentifiers
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-09-05 17:27:55 -04:00
Sebastian 7d18bcab1a feat: node list back end
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-09-04 22:12:27 -04:00
Sebastian 93aa1b0f24 refactor(module): register browser integration through context
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-31 22:55:11 -04:00
Sebastian cbcf2e5acb fix(preview): return no content when preview is unavailable
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-31 22:55:11 -04:00
Sebastian ce246669d9 feat: use new variant type
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-29 19:11:45 -04:00
Sebastian 6b040ba19e refactor: simplify preview generation
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-29 18:44:30 -04:00
Sebastian 33a06c6509 feat: implement preview mechanism
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-29 12:29:15 -04:00
Sebastian f80e3679b6 feat: add documents selector
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-15 11:20:37 -04:00
Sebastian 26ee6e9c33 refactor: migrate to scoped execution contexts
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-27 00:50:43 -04:00
Sebastian eb78c5149a Merge pull request 'chore: implement js test suites' (#33) from chore/js-test-suites into main
Reviewed-on: #33
2026-07-24 04:25:28 +00:00
Sebastian 861abac4b4 chore: implement js test suites
Build Test / test (pull_request) Successful in 51s
JS Unit Tests / test (pull_request) Successful in 39s
PHP Integration Tests / Integration Tests (pull_request) Successful in 2m0s
PHP Unit Tests / test (pull_request) Successful in 2m18s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-24 00:13:45 -04:00
Sebastian 314294c37b Merge pull request 'chore(deps): update mongo docker tag to v8' (#32) from renovate/mongo-8.x into main
Reviewed-on: #32
2026-07-24 03:28:36 +00:00
Sebastian 6609ad5d8b chore(deps): update mongo docker tag to v8
PHP Unit Tests / test (pull_request) Successful in 1m24s
PHP Integration Tests / Integration Tests (pull_request) Successful in 2m21s
2026-07-24 03:17:13 +00:00
Sebastian 9b701bf2a0 Merge pull request 'chore(deps): update dependency pinia to v4.0.2' (#29) from renovate/pinia-4.x-lockfile into main
Reviewed-on: #29
2026-07-24 02:36:44 +00:00
Sebastian b51941c45e Merge pull request 'chore: implement php test suites' (#30) from chore/php83-test-suites into main
Reviewed-on: #30
2026-07-24 02:36:37 +00:00
Sebastian ed1a21157b fix: install module dependencies before running integration tests
PHP Integration Tests / Integration Tests (pull_request) Successful in 1m21s
PHP Unit Tests / test (pull_request) Successful in 55s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-23 22:32:07 -04:00
Sebastian e9904b8500 chore: implement php test suites
PHP Unit Tests / test (pull_request) Successful in 1m9s
PHP Integration Tests / Integration Tests (pull_request) Failing after 1m38s
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-23 22:20:36 -04:00
Sebastian d81b758a01 chore(deps): update dependency pinia to v4.0.2 2026-07-18 03:02:40 +00:00
Sebastian 39eed88cf5 Merge pull request 'chore(deps): update dependency vite to v8.1.5' (#26) from renovate/vite-8.x-lockfile into main
Reviewed-on: #26
2026-07-17 23:21:04 +00:00
Sebastian e4ce58cdd3 Merge pull request 'fix(deps): update dependency pinia to v4' (#27) from renovate/pinia-4.x into main
Reviewed-on: #27
2026-07-17 23:20:57 +00:00
Sebastian c4e3659fd6 Merge pull request 'chore(deps): update dependency vue to v3.5.40' (#28) from renovate/vue-monorepo into main
Reviewed-on: #28
2026-07-17 23:20:53 +00:00
Sebastian 77ae86562e chore(deps): update dependency vue to v3.5.40 2026-07-17 03:05:29 +00:00
Sebastian 0a133c65bc chore(deps): update dependency vite to v8.1.5 2026-07-17 03:05:26 +00:00
Sebastian 7c314dfcbe fix(deps): update dependency pinia to v4 2026-07-15 03:05:28 +00:00
Sebastian 9636c75429 Track Composer dependencies
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-09 22:56:21 -04:00
Sebastian 16c38c4e99 fix(renovate): install renovate in runner temp
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-09 20:12:07 -04:00
Sebastian bc644613d1 Merge pull request 'chore(deps): update dependency vite to v8.1.3' (#23) from renovate/vite-8.x-lockfile into main
Reviewed-on: #23
2026-07-09 23:44:37 +00:00
Sebastian d6430c9745 Merge pull request 'chore(deps): update dependency @types/node to v24.13.3' (#24) from renovate/node-24.x-lockfile into main
Reviewed-on: #24
2026-07-09 23:44:29 +00:00
Sebastian e9d909c921 Merge pull request 'chore(deps): update dependency typescript to v7' (#25) from renovate/typescript-7.x into main
Reviewed-on: #25
2026-07-09 23:44:23 +00:00
Sebastian 050467919b refactor: documents interfaces
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-07-09 19:32:27 -04:00
Sebastian b7aaff045d chore(deps): update dependency typescript to v7 2026-07-09 03:06:43 +00:00
Sebastian 3cf3e900ca chore(deps): update dependency @types/node to v24.13.3 2026-07-09 03:06:37 +00:00
Sebastian efc1999ed2 chore(deps): update dependency vite to v8.1.3 2026-07-04 03:05:18 +00:00
Sebastian ac393843f1 Merge pull request 'chore(deps): update dependency vue to v3.5.39' (#21) from renovate/vue-monorepo into main
Reviewed-on: #21
2026-06-28 14:38:50 +00:00
Sebastian eeb48ed576 Merge pull request 'chore(deps): update dependency vite to v8.1.0' (#22) from renovate/vite-8.x-lockfile into main
Reviewed-on: #22
2026-06-28 14:38:44 +00:00
Sebastian db0151b0e6 chore(deps): update dependency vite to v8.1.0 2026-06-28 03:06:32 +00:00
Sebastian 9e93310c4c chore(deps): update dependency vue to v3.5.39 2026-06-28 03:06:27 +00:00
Sebastian 623d290638 Merge pull request 'chore(deps): update dependency vite to v8.0.16' (#18) from renovate/vite-8.x-lockfile into main
Reviewed-on: #18
2026-06-16 17:42:49 +00:00
Sebastian 462cc3a0eb Merge pull request 'chore(deps): update dependency @types/node to v24.13.2' (#19) from renovate/node-24.x-lockfile into main
Reviewed-on: #19
2026-06-16 17:42:42 +00:00
Sebastian ab69655d52 Merge pull request 'chore(deps): update vue monorepo to v3.5.38' (#20) from renovate/vue-monorepo into main
Reviewed-on: #20
2026-06-16 17:42:35 +00:00
Sebastian 2ec8dd13ef chore(deps): update vue monorepo to v3.5.38 2026-06-12 03:03:50 +00:00
Sebastian 1520c79c2e chore(deps): update dependency @types/node to v24.13.2 2026-06-11 03:05:31 +00:00
Sebastian d11a96af69 chore(deps): update dependency vite to v8.0.16 2026-06-11 03:05:29 +00:00
48 changed files with 6663 additions and 2163 deletions
+42
View File
@@ -0,0 +1,42 @@
name: Build Test
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Retrieve Server Install Action
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/action-server-install
ref: main
path: action-server-install
github-server-url: https://git.ktrix.dev
- name: Install Server
uses: ./action-server-install
with:
install-php: 'false'
install-node: 'true'
php-version: '8.5'
node-version: '24'
server-path: './server'
- name: Checkout Pull Request
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.repository }}
ref: ${{ github.event.pull_request.head.sha }}
path: server/modules/documents_manager
github-server-url: https://git.ktrix.dev
- name: Install dependencies
run: npm ci
working-directory: server/modules/documents_manager
- name: Build
run: npm run build
working-directory: server/modules/documents_manager
+42
View File
@@ -0,0 +1,42 @@
name: JS Unit Tests
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Retrieve Server Install Action
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/action-server-install
ref: main
path: action-server-install
github-server-url: https://git.ktrix.dev
- name: Install Server
uses: ./action-server-install
with:
install-php: 'false'
install-node: 'true'
php-version: '8.5'
node-version: '24'
server-path: './server'
- name: Checkout Pull Request
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.repository }}
ref: ${{ github.event.pull_request.head.sha }}
path: server/modules/documents_manager
github-server-url: https://git.ktrix.dev
- name: Install dependencies
run: npm ci
working-directory: server/modules/documents_manager
- name: Run tests
run: npm run test:unit
working-directory: server/modules/documents_manager
@@ -0,0 +1,59 @@
name: PHP Integration Tests
on:
pull_request:
workflow_dispatch:
jobs:
test:
name: Integration Tests
runs-on: ubuntu-latest
services:
mongo:
image: mongo:8
options: >-
--health-cmd "mongosh --quiet --eval \"db.adminCommand('ping')\""
--health-interval 5s
--health-timeout 5s
--health-retries 12
steps:
- name: Retrieve Server Install Action
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/action-server-install
ref: main
path: action-server-install
github-server-url: https://git.ktrix.dev
- name: Install server
uses: ./action-server-install
with:
install-php: 'true'
php-version: '8.5'
server-path: './server'
database-uri: 'mongodb://mongo:27017/?tls=false'
database-name: 'ktrix_ci'
app-environment: 'test'
- name: Checkout module under test
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.repository }}
ref: ${{ github.event.pull_request.head.sha || github.sha }}
path: server/modules/documents_manager
github-server-url: https://git.ktrix.dev
- name: Install module dependencies
run: composer install --prefer-dist --no-progress
working-directory: server/modules/documents_manager
- name: Install and enable module
working-directory: server
run: |
php bin/console module:install documents_manager
php bin/console module:enable documents_manager
- name: Run integration tests
working-directory: server/modules/documents_manager
run: composer test:integration
+42
View File
@@ -0,0 +1,42 @@
name: PHP Unit Tests
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Retrieve Server Install Action
uses: actions/checkout@v6.0.2
with:
repository: Nodarx/action-server-install
ref: main
path: action-server-install
github-server-url: https://git.ktrix.dev
- name: Install Server
uses: ./action-server-install
with:
install-php: 'true'
install-node: 'false'
php-version: '8.5'
node-version: '24'
server-path: './server'
- name: Checkout Pull Request
uses: actions/checkout@v6.0.2
with:
repository: ${{ github.repository }}
ref: ${{ github.event.pull_request.head.sha }}
path: server/modules/documents_manager
github-server-url: https://git.ktrix.dev
- name: Install dependencies
run: composer install --prefer-dist --no-progress
working-directory: server/modules/documents_manager
- name: Run tests
run: composer test:unit
working-directory: server/modules/documents_manager
+8 -2
View File
@@ -25,11 +25,17 @@ jobs:
tools: composer:v2
- name: Install Renovate
run: npm install -g renovate
run: |
npm install --global --no-audit --fund=false \
--prefix "${{ runner.temp }}/renovate-npm" \
--cache "${{ runner.temp }}/renovate-npm-cache" \
renovate
"${{ runner.temp }}/renovate-npm/bin/renovate" --version
- name: Run Renovate
env:
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: https://git.ktrix.dev/api/v1
run: renovate ${{ gitea.repository }}
run: |
"${{ runner.temp }}/renovate-npm/bin/renovate" ${{ gitea.repository }}
+2 -4
View File
@@ -12,12 +12,10 @@ node_modules/
/static/
# Backend development
/vendor/
/lib/vendor/
coverage/
phpunit.xml.cache
.phpunit.result.cache
.php-cs-fixer.cache
.phpstan.cache
*.cache
.phpactor/
# Editors
+14 -2
View File
@@ -10,17 +10,29 @@
"config": {
"optimize-autoloader": true,
"platform": {
"php": "8.2"
"php": "8.3"
},
"autoloader-suffix": "DocumentsManager",
"vendor-dir": "lib/vendor"
},
"require": {
"php": ">=8.2 <=8.5"
"php": ">=8.3 <=8.5"
},
"require-dev": {
"phpunit/phpunit": "^12.0"
},
"autoload": {
"psr-4": {
"KTXM\\DocumentsManager\\": "lib/"
}
},
"autoload-dev": {
"psr-4": {
"KTXT\\DocumentsManager\\Tests\\": "tests/php/"
}
},
"scripts": {
"test:unit": "phpunit --configuration tests/php/phpunit.xml --testsuite \"Unit Tests\" --colors=always --testdox",
"test:integration": "phpunit --configuration tests/php/phpunit.xml --testsuite \"Integration Tests\" --colors=always --testdox"
}
}
Generated
+1705
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+110 -114
View File
@@ -12,9 +12,14 @@ namespace KTXM\DocumentsManager\Controllers;
use KTXC\Http\Response\JsonResponse;
use KTXC\Http\Response\Response;
use KTXC\Http\Response\StreamedResponse;
use KTXC\SessionIdentity;
use KTXC\SessionTenant;
use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContextInterface;
use KTXF\Controller\ControllerAbstract;
use KTXF\Documents\Collection\CollectionBaseInterface;
use KTXF\Documents\Entity\EntityBaseInterface;
use KTXF\Resource\Identifier\CollectionIdentifier;
use KTXF\Resource\Identifier\EntityIdentifier;
use KTXF\Resource\Identifier\ResourceIdentifiers;
use KTXF\Routing\Attributes\AuthenticatedRoute;
use KTXM\DocumentsManager\Manager;
use KTXM\DocumentsManager\Transfer\StreamingZip;
@@ -23,7 +28,7 @@ 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)
@@ -34,55 +39,40 @@ use Throwable;
class TransferController extends ControllerAbstract
{
public function __construct(
private readonly SessionTenant $tenantIdentity,
private readonly SessionIdentity $userIdentity,
private readonly TenantContextInterface $tenantContext,
private readonly IdentityContextInterface $identityContext,
private readonly Manager $manager,
private readonly LoggerInterface $logger
) {}
/**
* Download a single file
*
*
* 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->tenantIdentity->identifier();
$userId = $this->userIdentity->identifier();
$tenantId = $this->tenantContext->identifier();
$userId = $this->identityContext->identifier();
try {
// Fetch entity metadata
$entities = $this->manager->entityFetch(
$tenantId,
$userId,
$provider,
$service,
$collection,
[$identifier]
);
$target = new EntityIdentifier($provider, $service, $collection, $identifier);
if (empty($entities) || !isset($entities[$identifier])) {
// Fetch entity metadata
/** @var EntityBaseInterface[] $entities */
$entities = $this->manager->entityFetchBulk($tenantId, $userId, $target);
if (!isset($entities[(string) $target])) {
return new JsonResponse([
'status' => 'error',
'error' => ['code' => 404, 'message' => 'File not found']
], Response::HTTP_NOT_FOUND);
}
$entity = $entities[$identifier];
$entity = $entities[(string) $target];
// Get the stream
$stream = $this->manager->entityReadStream(
$tenantId,
$userId,
$provider,
$service,
$collection,
$identifier
);
$stream = $this->manager->entityReadStream($tenantId, $userId, $target);
if ($stream === null) {
return new JsonResponse([
@@ -114,7 +104,7 @@ class TransferController extends ControllerAbstract
if ($size > 0) {
$response->headers->set('Content-Length', (string) $size);
}
$response->headers->set('Content-Disposition',
$response->headers->set('Content-Disposition',
$response->headers->makeDisposition('attachment', $filename, $this->asciiFallback($filename))
);
$response->headers->set('Cache-Control', 'private, no-cache');
@@ -130,19 +120,44 @@ 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->tenantIdentity->identifier();
$userId = $this->userIdentity->identifier();
$tenantId = $this->tenantContext->identifier();
$userId = $this->identityContext->identifier();
if (empty($ids)) {
return new JsonResponse([
@@ -179,10 +194,7 @@ class TransferController extends ControllerAbstract
$stream = $this->manager->entityReadStream(
$tenantId,
$userId,
$provider,
$service,
$file['collection'],
$file['id']
new EntityIdentifier($provider, $service, (string)$file['collection'], (string)$file['id'])
);
if ($stream !== null) {
@@ -221,17 +233,13 @@ class TransferController extends ControllerAbstract
/**
* Download a collection (folder) as a ZIP archive with structure preserved
*
*
* 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->tenantIdentity->identifier();
$userId = $this->userIdentity->identifier();
$tenantId = $this->tenantContext->identifier();
$userId = $this->identityContext->identifier();
try {
// Fetch collection metadata
@@ -239,9 +247,7 @@ class TransferController extends ControllerAbstract
$collection = $this->manager->collectionFetch(
$tenantId,
$userId,
$provider,
$service,
$identifier
new CollectionIdentifier($provider, $service, $identifier)
);
if ($collection === null) {
@@ -251,7 +257,7 @@ class TransferController extends ControllerAbstract
], Response::HTTP_NOT_FOUND);
}
$folderName = $collection->getLabel() ?? 'folder';
$folderName = $collection->getProperties()->getLabel() ?? 'folder';
$archiveName = $this->sanitizeFilename($folderName) . '.zip';
// Build recursive file list
@@ -275,10 +281,7 @@ class TransferController extends ControllerAbstract
$stream = $this->manager->entityReadStream(
$tenantId,
$userId,
$provider,
$service,
$file['collection'],
$file['id']
new EntityIdentifier($provider, $service, (string)$file['collection'], (string)$file['id'])
);
if ($stream !== null) {
@@ -324,24 +327,22 @@ 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->entityFetch(
$entities = $this->manager->entityFetchBulk(
$tenantId,
$userId,
$provider,
$service,
$collection,
[$id]
$target
);
if (!empty($entities) && isset($entities[$id])) {
$entity = $entities[$id];
if (isset($entities[(string) $target])) {
$entity = $entities[(string) $target];
$files[] = [
'type' => 'file',
'id' => $id,
'collection' => $collection,
'path' => $entity->getLabel() ?? $id,
'modTime' => $entity->modifiedOn()?->getTimestamp(),
'path' => $entity->getProperties()->getLabel() ?? $id,
'modTime' => $entity->modified()?->getTimestamp(),
];
continue;
}
@@ -352,19 +353,17 @@ class TransferController extends ControllerAbstract
$collectionNode = $this->manager->collectionFetch(
$tenantId,
$userId,
$provider,
$service,
$id
new CollectionIdentifier($provider, $service, (string)$id)
);
if ($collectionNode !== null) {
$folderName = $collectionNode->getLabel() ?? $id;
$folderName = $collectionNode->getProperties()->getLabel() ?? $id;
$subFiles = $this->resolveCollectionContents(
$tenantId,
$userId,
$provider,
$service,
$id,
(string)$id,
$folderName
);
$files = array_merge($files, $subFiles);
@@ -407,17 +406,13 @@ class TransferController extends ControllerAbstract
];
}
// Get all nodes in this collection using nodeList with recursive=false
// We handle recursion ourselves to build proper paths
// List immediate child collections and entities of this collection;
// recursion is handled here to build proper archive paths
$sources = new ResourceIdentifiers();
$sources->add(new CollectionIdentifier($provider, $service, $collectionId));
try {
$nodes = $this->manager->nodeList(
$tenantId,
$userId,
$provider,
$service,
$collectionId,
false // Not recursive - we handle it ourselves
);
$collections = $this->manager->collectionList($tenantId, $userId, $sources)[$provider][$service] ?? [];
$entities = $this->manager->entityListBulk($tenantId, $userId, $sources)[$provider][$service][$collectionId] ?? [];
} catch (Throwable $e) {
$this->logger->warning('Failed to list collection contents', [
'collection' => $collectionId,
@@ -426,34 +421,35 @@ class TransferController extends ControllerAbstract
return $files;
}
foreach ($nodes as $node) {
$nodeName = $node->getLabel() ?? (string) $node->id();
/** @var CollectionBaseInterface $node */
foreach ($collections as $node) {
$nodeName = $node->getProperties()->getLabel() ?? (string) $node->identifier();
$nodePath = $basePath !== '' ? $basePath . '/' . $nodeName : $nodeName;
// Recursively get contents of sub-collection
$subFiles = $this->resolveCollectionContents(
$tenantId,
$userId,
$provider,
$service,
(string) $node->identifier(),
$nodePath,
$depth + 1,
$maxDepth
);
$files = array_merge($files, $subFiles);
}
if ($node->isCollection()) {
// Recursively get contents of sub-collection
$subFiles = $this->resolveCollectionContents(
$tenantId,
$userId,
$provider,
$service,
(string) $node->id(),
$nodePath,
$depth + 1,
$maxDepth
);
$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(),
];
}
/** @var EntityBaseInterface $node */
foreach ($entities as $node) {
$nodeName = $node->getProperties()->getLabel() ?? (string) $node->identifier();
$nodePath = $basePath !== '' ? $basePath . '/' . $nodeName : $nodeName;
$files[] = [
'type' => 'file',
'id' => (string) $node->identifier(),
'collection' => $collectionId,
'path' => $nodePath,
'modTime' => $node->modified()?->getTimestamp(),
];
}
return $files;
@@ -467,11 +463,11 @@ class TransferController extends ControllerAbstract
// Remove or replace problematic characters
$filename = preg_replace('/[<>:"\/\\|?*\x00-\x1F]/', '_', $filename);
$filename = trim($filename, '. ');
if ($filename === '') {
$filename = 'download';
}
return $filename;
}
+926 -621
View File
File diff suppressed because it is too large Load Diff
+8 -12
View File
@@ -2,13 +2,14 @@
namespace KTXM\DocumentsManager;
use KTXF\Module\ModuleBrowserInterface;
use KTXF\Module\Configuration\BrowserModuleContextInterface;
use KTXF\Module\Configuration\ModuleContextInterface;
use KTXF\Module\ModuleInstanceAbstract;
/**
* File Manager Module
*/
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
class Module extends ModuleInstanceAbstract
{
public function __construct()
@@ -50,16 +51,11 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
];
}
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',
];
public function configure(ModuleContextInterface $context): void
{
if ($context instanceof BrowserModuleContextInterface) {
$context->registerModule($this, 'DocumentsManager', 'static/module.mjs');
}
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace KTXM\DocumentsManager\Stream;
/**
* Implemented by a stream event that declares, up front, how many data frames
* are expected to follow. When such an event leads a stream generator, the
* envelope writer folds its value into the `control:start` frame's `total`
* (the progress denominator) instead of emitting it as a `data` frame.
*/
interface ExpectedTotal {
public function expectedTotal(): int;
}
+1447 -251
View File
File diff suppressed because it is too large Load Diff
+14 -4
View File
@@ -7,17 +7,27 @@
"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"
"typecheck": "vue-tsc --noEmit",
"test": "vitest run --config tests/js/vitest.config.ts",
"test:unit": "vitest run --config tests/js/vitest.config.ts",
"test:watch": "vitest watch --config tests/js/vitest.config.ts",
"test:coverage": "vitest run --coverage --config tests/js/vitest.config.ts"
},
"dependencies": {
"pinia": "^3.0.0",
"vue": "^3.5.13"
"pinia": "^4.0.0",
"vue": "^3.5.13",
"vuetify": "^4.0.0"
},
"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"
"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
@@ -0,0 +1,228 @@
<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
@@ -0,0 +1,9 @@
/**
* 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
@@ -0,0 +1,349 @@
/**
* 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
@@ -0,0 +1,135 @@
/**
* 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
@@ -0,0 +1,14 @@
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;
+8 -2
View File
@@ -2,14 +2,20 @@
* Documents Manager Module Boot
*/
import integrations from '@/integrations'
console.log('[Documents Manager] Booting module...')
console.log('[Documents Manager] Module booted successfully...')
// CSS will be injected by build process
//export const css = ['__CSS_FILENAME_PLACEHOLDER__']
// CSS filename is injected by the vite plugin at build time
export const css = ['__CSS_FILENAME_PLACEHOLDER__']
export { integrations }
// Export services, stores and models for external use
export * from '@/services'
export * from '@/stores'
export * from '@/models'
export * from '@/composables'
export * from '@/utils/fileHelpers'
+21 -1
View File
@@ -4,6 +4,19 @@
import type { CollectionContentTypes, CollectionInterface, CollectionModelInterface, CollectionPropertiesInterface } from "@/types/collection";
/**
* Reduce a serialized resource identifier ("provider:service:collection")
* to its own (last) segment; plain values pass through unchanged
*/
function plainIdentifier(value: string | number | null | undefined): string | number | null {
if (value === null || value === undefined || value === '') {
return value ?? null;
}
const text = String(value);
const index = text.lastIndexOf(':');
return index >= 0 ? text.slice(index + 1) : value;
}
export class CollectionObject implements CollectionModelInterface {
_data!: CollectionInterface;
@@ -24,7 +37,14 @@ export class CollectionObject implements CollectionModelInterface {
}
fromJson(data: CollectionInterface): CollectionObject {
this._data = data;
// the wire format carries full resource identifiers (provider:service:collection);
// reduce them to plain ids, which is what the stores and UI operate on
this._data = {
...data,
service: plainIdentifier(data.service) ?? '',
collection: plainIdentifier(data.collection),
identifier: plainIdentifier(data.identifier) ?? '',
};
if (data.properties) {
this._data.properties = new CollectionPropertiesObject().fromJson(data.properties as CollectionPropertiesInterface);
}
+21 -1
View File
@@ -5,6 +5,19 @@ import type { EntityInterface, EntityModelInterface } from "@/types/entity";
import type { DocumentInterface, DocumentModelInterface } from "@/types/document";
import { DocumentObject } from "./document";
/**
* Reduce a serialized resource identifier ("provider:service:collection:entity")
* to its own (last) segment; plain values pass through unchanged
*/
function plainIdentifier(value: string | number | null | undefined): string | number | null {
if (value === null || value === undefined || value === '') {
return value ?? null;
}
const text = String(value);
const index = text.lastIndexOf(':');
return index >= 0 ? text.slice(index + 1) : value;
}
export class EntityObject implements EntityModelInterface {
_data!: EntityInterface<DocumentInterface|DocumentModelInterface>;
@@ -25,7 +38,14 @@ export class EntityObject implements EntityModelInterface {
}
fromJson(data: EntityInterface): EntityObject {
this._data = data
// the wire format carries full resource identifiers (provider:service:collection:entity);
// reduce them to plain ids, which is what the stores and UI operate on
this._data = {
...data,
service: String(plainIdentifier(data.service) ?? ''),
collection: plainIdentifier(data.collection) ?? '',
identifier: plainIdentifier(data.identifier) ?? '',
}
if (data.properties) {
this._data.properties = new DocumentObject().fromJson(data.properties as DocumentInterface);
}
+38 -33
View File
@@ -16,28 +16,20 @@ import type {
CollectionUpdateRequest,
CollectionDeleteResponse,
CollectionDeleteRequest,
CollectionCopyRequest,
CollectionCopyResponse,
CollectionMoveRequest,
CollectionMoveResponse,
CollectionInterface,
} from '../types/collection';
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
import { CollectionObject, CollectionPropertiesObject } from '../models/collection';
function isCollectionPayload(value: unknown): value is CollectionInterface {
if (!value || typeof value !== 'object') {
return false;
}
const candidate = value as Record<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
*/
function createCollectionObject(data: CollectionInterface): CollectionObject {
export function createCollectionObject(data: CollectionInterface): CollectionObject {
const integrationStore = useIntegrationStore();
const factoryItem = integrationStore.getItemById('documents_collection_factory', data.provider) as any;
const factory = factoryItem?.factory;
@@ -64,38 +56,27 @@ export const collectionService = {
const serviceList: Record<string, Record<string, CollectionObject>> = {};
Object.entries(providerServices).forEach(([serviceId, serviceCollections]) => {
const collectionList: Record<string, CollectionObject> = {};
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);
}
});
}
Object.entries(serviceCollections).forEach(([collectionId, collectionData]) => {
collectionList[collectionId] = createCollectionObject(collectionData);
});
serviceList[serviceId] = collectionList;
});
providerList[providerId] = serviceList;
});
return providerList;
},
/**
* Retrieve a specific collection by provider and identifier
* Retrieve collections by resource identifier
*
* @param request - fetch request parameters
*
* @returns Promise with collection object
* @returns Promise with collections keyed by resource identifier
*/
async fetch(request: CollectionFetchRequest): Promise<CollectionObject> {
async fetch(request: CollectionFetchRequest): Promise<Record<string, CollectionObject>> {
const response = await transceivePost<CollectionFetchRequest, CollectionFetchResponse>('collection.fetch', request);
return createCollectionObject(response);
return Object.fromEntries(Object.entries(response).map(([target, data]) => [target, createCollectionObject(data)]));
},
/**
@@ -141,15 +122,39 @@ export const collectionService = {
/**
* Delete a collection
*
*
* @param request - delete request parameters
*
*
* @returns Promise with deletion result
*/
async delete(request: CollectionDeleteRequest): Promise<CollectionDeleteResponse> {
return await transceivePost<CollectionDeleteRequest, CollectionDeleteResponse>('collection.delete', request);
},
/**
* Copy a collection to a new parent
*
* @param request - copy request parameters
*
* @returns Promise with copied collection object
*/
async copy(request: CollectionCopyRequest): Promise<CollectionObject> {
const response = await transceivePost<CollectionCopyRequest, CollectionCopyResponse>('collection.copy', request);
return createCollectionObject(response);
},
/**
* Move a collection to a new parent
*
* @param request - move request parameters
*
* @returns Promise with moved collection object
*/
async move(request: CollectionMoveRequest): Promise<CollectionObject> {
const response = await transceivePost<CollectionMoveRequest, CollectionMoveResponse>('collection.move', request);
return createCollectionObject(response);
},
};
export default collectionService;
+53 -13
View File
@@ -2,7 +2,7 @@
* Entity management service
*/
import { transceivePost } from './transceive';
import { transceivePost, transceiveStream } from './transceive';
import type {
EntityListRequest,
EntityListResponse,
@@ -18,6 +18,10 @@ import type {
EntityDeleteResponse,
EntityDeltaRequest,
EntityDeltaResponse,
EntityCopyRequest,
EntityCopyResponse,
EntityMoveRequest,
EntityMoveResponse,
EntityReadRequest,
EntityReadResponse,
EntityWriteRequest,
@@ -31,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
*/
function createEntityObject(data: EntityInterface): EntityObject {
export function createEntityObject(data: EntityInterface): EntityObject {
const integrationStore = useIntegrationStore();
const factoryItem = integrationStore.getItemById('documents_entity_factory', data.provider) as any;
const factory = factoryItem?.factory;
@@ -43,14 +47,14 @@ function createEntityObject(data: EntityInterface): EntityObject {
export const entityService = {
/**
* Retrieve list of entities, optionally filtered by source selector
*
* Retrieve list of entities, optionally filtered by source identifiers
*
* @param request - list request parameters
*
*
* @returns Promise with entity object list grouped by provider, service, collection, and entity identifier
*/
async list(request: EntityListRequest = {}): Promise<Record<string, Record<string, Record<string, Record<string, EntityObject>>>>> {
const response = await transceivePost<EntityListRequest, EntityListResponse>('entity.list', request);
async listBulk(request: EntityListRequest = {}): Promise<Record<string, Record<string, Record<string, Record<string, EntityObject>>>>> {
const response = await transceivePost<EntityListRequest, EntityListResponse>('entity.listBulk', request);
// Convert nested response to EntityObject instances
const providerList: Record<string, Record<string, Record<string, Record<string, EntityObject>>>> = {};
@@ -73,11 +77,25 @@ export const entityService = {
return providerList;
},
/**
* Stream entities one by one, invoking the callback for each entity
*
* @param request - list request parameters
* @param onEntity - callback invoked for each streamed entity
*
* @returns Promise with the total number of streamed entities
*/
async listStream(request: EntityListRequest, onEntity: (entity: EntityObject) => void): Promise<{ total: number }> {
return await transceiveStream<EntityListRequest, EntityInterface>('entity.listStream', request, (entityData) => {
onEntity(createEntityObject(entityData));
});
},
/**
* Retrieve a specific entity by provider and identifier
*
*
* @param request - fetch request parameters
*
*
* @returns Promise with entity objects keyed by identifier
*/
async fetch(request: EntityFetchRequest): Promise<Record<string, EntityObject>> {
@@ -128,16 +146,38 @@ export const entityService = {
},
/**
* Delete an entity
*
* Delete entities
*
* @param request - delete request parameters
*
* @returns Promise with deletion result
*
* @returns Promise with per-entity disposition results
*/
async delete(request: EntityDeleteRequest): Promise<EntityDeleteResponse> {
return await transceivePost<EntityDeleteRequest, EntityDeleteResponse>('entity.delete', request);
},
/**
* Copy entities to another collection
*
* @param request - copy request parameters
*
* @returns Promise with per-entity disposition results
*/
async copy(request: EntityCopyRequest): Promise<EntityCopyResponse> {
return await transceivePost<EntityCopyRequest, EntityCopyResponse>('entity.copy', request);
},
/**
* Move entities to another collection
*
* @param request - move request parameters
*
* @returns Promise with per-entity disposition results
*/
async move(request: EntityMoveRequest): Promise<EntityMoveResponse> {
return await transceivePost<EntityMoveRequest, EntityMoveResponse>('entity.move', request);
},
/**
* Retrieve delta changes for entities
*
-1
View File
@@ -6,4 +6,3 @@ export { providerService } from './providerService';
export { serviceService } from './serviceService';
export { collectionService } from './collectionService';
export { entityService } from './entityService';
export { nodeService } from './nodeService';
+29 -63
View File
@@ -1,68 +1,34 @@
/**
* Node (unified collection/entity) management service
*/
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';
import { transceivePost } from './transceive'
import type { ListFilter, ListSort, ListRange } from '../types/common'
import type { CollectionInterface } from '../types/collection'
import type { EntityInterface } from '../types/entity'
export type NodeItem = CollectionInterface | EntityInterface
export interface NodeListRequest {
provider: string
service: string | number
location?: string | number | null
recursive?: boolean
filter?: ListFilter | null
sort?: ListSort | null
range?: ListRange | null
}
export type NodeListResponse = Record<string, NodeItem>
export interface NodeDeltaRequest {
provider: string
service: string | number
location?: string | number | null
signature: string
recursive?: boolean
detail?: 'ids' | 'full'
}
export interface NodeDeltaResult {
added: Array<string | number | NodeItem>
modified: Array<string | number | NodeItem>
removed: Array<string | number>
signature: string
}
export type NodeListResult = (CollectionObject | EntityObject)[];
export const nodeService = {
async list(request: NodeListRequest): Promise<NodeItem[]> {
const response = await transceivePost<NodeListRequest, NodeListResponse>('node.list', {
provider: request.provider,
service: request.service,
location: request.location ?? null,
recursive: request.recursive ?? false,
filter: request.filter ?? null,
sort: request.sort ?? null,
range: request.range ?? null,
})
return Object.values(response)
/** 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;
},
};
async delta(request: NodeDeltaRequest): Promise<NodeDeltaResult> {
return await transceivePost<NodeDeltaRequest, NodeDeltaResult>('node.delta', {
provider: request.provider,
service: request.service,
location: request.location ?? null,
signature: request.signature,
recursive: request.recursive ?? false,
detail: request.detail ?? 'ids',
})
},
}
export default nodeService
export default nodeService;
+88 -3
View File
@@ -4,7 +4,7 @@
*/
import { createFetchWrapper } from '@KTXC';
import type { ApiRequest, ApiResponse } from '../types/common';
import type { ApiRequest, ApiResponse, ApiStreamResponse } from '../types/common';
const fetchWrapper = createFetchWrapper();
const API_URL = '/m/documents_manager/v1';
@@ -40,11 +40,96 @@ export async function transceivePost<TRequest, TResponse>(
};
const response: ApiResponse<TResponse> = await fetchWrapper.post(API_URL, request);
if (response.status === 'error') {
const errorMessage = `[${operation}] ${response.data.message}${response.data.code ? ` (code: ${response.data.code})` : ''}`;
throw new Error(errorMessage);
}
return response.data;
}
/**
* Stream an NDJSON API response, unwrapping data frames for the caller.
*
* @param operation - Operation name, e.g. 'entity.listStream'
* @param data - Operation-specific request data
* @param onData - Synchronous callback invoked for every unwrapped data payload.
* @param options - Optional `user` override and an `onStart` hook.
* @returns Promise resolving to the final stream total from the control/end frame
*/
export async function transceiveStream<TRequest, TData>(
operation: string,
data: TRequest,
onData: (data: TData) => void,
options?: { user?: string; onStart?: (expected?: number) => void; requireEnd?: boolean }
): Promise<{ total: number }> {
const request: ApiRequest<TRequest> = {
version: API_VERSION,
transaction: generateTransactionId(),
operation,
data,
user: options?.user,
};
let total = 0;
let ended = false;
const dispatch = (line: string): void => {
const message = JSON.parse(line) as ApiStreamResponse<TData>;
if (message.type === 'control') {
if (message.status === 'start') {
options?.onStart?.(message.total);
} else if (message.status === 'end') {
total = message.total;
ended = true;
}
return;
}
if (message.type === 'error') {
throw new Error(`[${operation}] ${message.message}`);
}
onData(message.data);
};
await fetchWrapper.post(API_URL, request, {
headers: { 'Accept': 'application/json' },
onStream: async (response: Response) => {
if (!response.body) {
throw new Error(`[${operation}] Response body is not readable`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop()!;
for (const line of lines) {
if (line.trim()) dispatch(line);
}
}
buffer += decoder.decode();
if (buffer.trim()) dispatch(buffer);
} finally {
reader.releaseLock();
}
},
});
if (options?.requireEnd && !ended) {
throw new Error(`[${operation}] Stream ended without a completion frame`);
}
return { total };
}
+49 -33
View File
@@ -6,11 +6,11 @@ import { ref, computed, readonly } from 'vue'
import { defineStore } from 'pinia'
import { collectionService } from '../services/collectionService'
import type {
SourceSelector,
CollectionIdentifier,
ServiceIdentifier,
ListFilter,
ListSort,
CollectionMutableProperties,
CollectionDeleteResponse,
} from '../types'
import { CollectionObject } from '../models/collection'
@@ -40,25 +40,25 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () =
return `${provider}:${service ?? ''}:${identifier ?? ''}`
}
function collection(provider: string, service: string | number, identifier: string | number, retrieve: boolean = false): CollectionObject | null {
const key = identifierKey(provider, service, identifier)
function collection(target: CollectionIdentifier, retrieve: boolean = false): CollectionObject | null {
const key = target
if (retrieve === true && !_collections.value[key]) {
console.debug(`[Documents Manager][Store] - Force fetching collection "${key}"`)
fetch(provider, service, identifier)
void fetch([target])
}
return _collections.value[key] || null
}
function collectionsForService(provider: string, service: string | number): CollectionObject[] {
const serviceKeyPrefix = `${provider}:${service}:`
function collectionsForService(target: ServiceIdentifier): CollectionObject[] {
const serviceKeyPrefix = `${target}:`
return Object.entries(_collections.value)
.filter(([key]) => key.startsWith(serviceKeyPrefix))
.map(([_, collectionObj]) => collectionObj)
}
function clearService(provider: string, service: string | number): void {
const serviceKeyPrefix = `${provider}:${service}:`
function clearService(target: ServiceIdentifier): void {
const serviceKeyPrefix = `${target}:`
Object.keys(_collections.value)
.filter((key) => key.startsWith(serviceKeyPrefix))
.forEach((key) => {
@@ -70,8 +70,29 @@ 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?: SourceSelector, filter?: ListFilter, sort?: ListSort): Promise<Record<string, CollectionObject>> {
async function list(sources?: (ServiceIdentifier | CollectionIdentifier)[], filter?: ListFilter, sort?: ListSort): Promise<Record<string, CollectionObject>> {
transceiving.value = true
try {
const response = await collectionService.list({ sources, filter, sort })
@@ -98,14 +119,11 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () =
}
}
async function fetch(provider: string, service: string | number, identifier: string | number): Promise<CollectionObject> {
async function fetch(targets: CollectionIdentifier[]): Promise<Record<string, CollectionObject>> {
transceiving.value = true
try {
const response = await collectionService.fetch({ provider, service, collection: identifier })
const key = identifierKey(response.provider, response.service, response.identifier)
_collections.value[key] = response
console.debug('[Documents Manager][Store] - Successfully fetched collection:', key)
const response = await collectionService.fetch({ targets })
_collections.value = { ..._collections.value, ...response }
return response
} catch (error: any) {
console.error('[Documents Manager][Store] - Failed to fetch collection:', error)
@@ -115,10 +133,10 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () =
}
}
async function extant(sources: SourceSelector) {
async function extant(targets: CollectionIdentifier[]) {
transceiving.value = true
try {
const response = await collectionService.extant({ sources })
const response = await collectionService.extant({ targets })
console.debug('[Documents Manager][Store] - Successfully checked collection availability')
return response
} catch (error: any) {
@@ -130,14 +148,12 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () =
}
async function create(
provider: string,
service: string | number,
collection: string | number | null,
target: ServiceIdentifier | CollectionIdentifier,
properties: CollectionMutableProperties,
): Promise<CollectionObject> {
transceiving.value = true
try {
const response = await collectionService.create({ provider, service, collection, properties })
const response = await collectionService.create({ target, properties })
const key = identifierKey(response.provider, response.service, response.identifier)
_collections.value[key] = response
@@ -152,14 +168,12 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () =
}
async function update(
provider: string,
service: string | number,
identifier: string | number,
target: CollectionIdentifier,
properties: CollectionMutableProperties,
): Promise<CollectionObject> {
transceiving.value = true
try {
const response = await collectionService.update({ provider, service, identifier, properties })
const response = await collectionService.update({ target, properties })
const key = identifierKey(response.provider, response.service, response.identifier)
_collections.value[key] = response
@@ -173,17 +187,17 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () =
}
}
async function remove(provider: string, service: string | number, identifier: string | number): Promise<CollectionDeleteResponse> {
async function remove(target: CollectionIdentifier): Promise<{ success: boolean }> {
transceiving.value = true
try {
const response = await collectionService.delete({ provider, service, identifier })
if (response.success) {
const key = identifierKey(provider, service, identifier)
delete _collections.value[key]
const response = await collectionService.delete({ target })
const success = response.disposition === 'deleted' || response.disposition === 'moved'
if (success) {
delete _collections.value[target]
}
console.debug('[Documents Manager][Store] - Successfully deleted collection:', `${provider}:${service}:${identifier}`)
return response
console.debug('[Documents Manager][Store] - Successfully deleted collection:', target)
return { success }
} catch (error: any) {
console.error('[Documents Manager][Store] - Failed to delete collection:', error)
throw error
@@ -202,6 +216,8 @@ export const useCollectionsStore = defineStore('documentsCollectionsStore', () =
collectionsForService,
clearService,
clearAll,
mergeMany,
removeMany,
list,
fetch,
extant,
+58 -68
View File
@@ -7,12 +7,13 @@ import { defineStore } from 'pinia'
import { entityService } from '../services/entityService'
import { EntityObject } from '../models'
import type {
SourceSelector,
CollectionIdentifier,
EntityIdentifier,
ServiceIdentifier,
ListFilter,
ListSort,
ListRange,
DocumentInterface,
EntityDeleteResponse,
EntityDeltaResponse,
} from '../types'
@@ -48,37 +49,34 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => {
}
function entity(
provider: string,
service: string | number,
collection: string | number,
identifier: string | number,
target: EntityIdentifier,
retrieve: boolean = false,
): EntityObject | null {
const key = identifierKey(provider, service, collection, identifier)
const key = target
if (retrieve === true && !_entities.value[key]) {
console.debug(`[Documents Manager][Store] - Force fetching entity "${key}"`)
fetch(provider, service, collection, [identifier])
void fetch([target])
}
return _entities.value[key] || null
}
function entitiesForService(provider: string, service: string | number): EntityObject[] {
const serviceKeyPrefix = `${provider}:${service}:`
function entitiesForService(target: ServiceIdentifier): EntityObject[] {
const serviceKeyPrefix = `${target}:`
return Object.entries(_entities.value)
.filter(([key]) => key.startsWith(serviceKeyPrefix))
.map(([_, entityObj]) => entityObj)
}
function entitiesForCollection(provider: string, service: string | number, collection: string | number): EntityObject[] {
const collectionKeyPrefix = `${provider}:${service}:${collection}:`
function entitiesForCollection(target: CollectionIdentifier): EntityObject[] {
const collectionKeyPrefix = `${target}:`
return Object.entries(_entities.value)
.filter(([key]) => key.startsWith(collectionKeyPrefix))
.map(([_, entityObj]) => entityObj)
}
function clearService(provider: string, service: string | number): void {
const serviceKeyPrefix = `${provider}:${service}:`
function clearService(target: ServiceIdentifier): void {
const serviceKeyPrefix = `${target}:`
Object.keys(_entities.value)
.filter((key) => key.startsWith(serviceKeyPrefix))
.forEach((key) => {
@@ -86,8 +84,8 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => {
})
}
function clearCollection(provider: string, service: string | number, collection: string | number): void {
const collectionKeyPrefix = `${provider}:${service}:${collection}:`
function clearCollection(target: CollectionIdentifier): void {
const collectionKeyPrefix = `${target}:`
Object.keys(_entities.value)
.filter((key) => key.startsWith(collectionKeyPrefix))
.forEach((key) => {
@@ -99,11 +97,23 @@ 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?: SourceSelector, filter?: ListFilter, sort?: ListSort, range?: ListRange): Promise<Record<string, EntityObject>> {
async function list(sources?: (ServiceIdentifier | CollectionIdentifier)[], filter?: ListFilter, sort?: ListSort, range?: ListRange): Promise<Record<string, EntityObject>> {
transceiving.value = true
try {
const response = await entityService.list({ sources, filter, sort, range })
const response = await entityService.listBulk({ sources, filter, sort, range })
const hydrated: Record<string, EntityObject> = {}
Object.entries(response).forEach(([providerId, providerServices]) => {
@@ -130,24 +140,13 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => {
}
async function fetch(
provider: string,
service: string | number,
collection: string | number,
identifiers: (string | number)[],
targets: EntityIdentifier[],
): Promise<Record<string, EntityObject>> {
transceiving.value = true
try {
const response = await entityService.fetch({ provider, service, collection, identifiers })
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
const response = await entityService.fetch({ targets })
_entities.value = { ..._entities.value, ...response }
return response
} catch (error: any) {
console.error('[Documents Manager][Store] - Failed to fetch entities:', error)
throw error
@@ -156,10 +155,10 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => {
}
}
async function extant(sources: SourceSelector) {
async function extant(targets: (CollectionIdentifier | EntityIdentifier)[]) {
transceiving.value = true
try {
const response = await entityService.extant({ sources })
const response = await entityService.extant({ targets })
console.debug('[Documents Manager][Store] - Successfully checked entity availability')
return response
} catch (error: any) {
@@ -171,15 +170,13 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => {
}
async function create(
provider: string,
service: string | number,
collection: string | number,
target: CollectionIdentifier,
properties: DocumentInterface,
options?: Record<string, unknown>,
): Promise<EntityObject> {
transceiving.value = true
try {
const response = await entityService.create({ provider, service, collection, properties, options })
const response = await entityService.create({ target, properties, options })
const key = identifierKey(response.provider, response.service, response.collection, response.identifier)
_entities.value[key] = response
@@ -194,15 +191,12 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => {
}
async function update(
provider: string,
service: string | number,
collection: string | number,
identifier: string | number,
target: EntityIdentifier,
properties: DocumentInterface,
): Promise<EntityObject> {
transceiving.value = true
try {
const response = await entityService.update({ provider, service, collection, identifier, properties })
const response = await entityService.update({ target, properties })
const key = identifierKey(response.provider, response.service, response.collection, response.identifier)
_entities.value[key] = response
@@ -217,21 +211,22 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => {
}
async function remove(
provider: string,
service: string | number,
collection: string | number,
identifier: string | number,
): Promise<EntityDeleteResponse> {
targets: EntityIdentifier[],
): Promise<{ successes: EntityIdentifier[]; failures: EntityIdentifier[] }> {
transceiving.value = true
try {
const response = await entityService.delete({ provider, service, collection, identifier })
if (response.success) {
const key = identifierKey(provider, service, collection, identifier)
delete _entities.value[key]
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)
}
}
console.debug('[Documents Manager][Store] - Successfully deleted entity:', `${provider}:${service}:${collection}:${identifier}`)
return response
return { successes, failures }
} catch (error: any) {
console.error('[Documents Manager][Store] - Failed to delete entity:', error)
throw error
@@ -240,10 +235,10 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => {
}
}
async function delta(sources: SourceSelector): Promise<EntityDeltaResponse> {
async function delta(targets: (CollectionIdentifier | EntityIdentifier)[]): Promise<EntityDeltaResponse> {
transceiving.value = true
try {
const response = await entityService.delta({ sources })
const response = await entityService.delta({ targets })
Object.entries(response).forEach(([provider, providerData]) => {
if (providerData === false) return
@@ -273,14 +268,11 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => {
}
async function read(
provider: string,
service: string | number,
collection: string | number,
identifier: string | number,
target: EntityIdentifier,
): Promise<string | null> {
transceiving.value = true
try {
const response = await entityService.read({ provider, service, collection, identifier })
const response = await entityService.read({ target })
return response.content
} catch (error: any) {
console.error('[Documents Manager][Store] - Failed to read entity:', error)
@@ -291,15 +283,12 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => {
}
async function write(
provider: string,
service: string | number,
collection: string | number,
identifier: string | number,
target: EntityIdentifier,
content: string,
): Promise<number> {
transceiving.value = true
try {
const response = await entityService.write({ provider, service, collection, identifier, content, encoding: 'base64' })
const response = await entityService.write({ target, content, encoding: 'base64' })
return response.bytesWritten
} catch (error: any) {
console.error('[Documents Manager][Store] - Failed to write entity:', error)
@@ -321,6 +310,7 @@ export const useEntitiesStore = defineStore('documentsEntitiesStore', () => {
clearService,
clearCollection,
clearAll,
mergeMany,
list,
fetch,
extant,
+78 -77
View File
@@ -5,7 +5,9 @@
import { computed, ref, readonly } from 'vue'
import { defineStore } from 'pinia'
import type {
SourceSelector,
CollectionIdentifier,
EntityIdentifier,
ServiceIdentifier,
ListFilter,
ListSort,
ListRange,
@@ -15,6 +17,7 @@ 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'
@@ -45,8 +48,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}`),
]
}
@@ -104,24 +107,13 @@ export const useNodesStore = defineStore('documentsNodesStore', () => {
}
async function fetchCollections(
providerId: string,
serviceId: string | number,
collectionId: string | number | null,
sources?: (ServiceIdentifier | CollectionIdentifier)[],
filter?: ListFilter,
sort?: ListSort,
): Promise<CollectionObject[]> {
error.value = null
try {
const sources: SourceSelector = {
[providerId]: {
[String(serviceId)]: collectionId === null
? true
: { [String(collectionId)]: true },
},
}
await collectionsStore.list(sources, filter, sort)
return collectionsStore.collectionsForService(providerId, serviceId)
return Object.values(await collectionsStore.list(sources, filter, sort))
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to fetch collections'
throw e
@@ -129,47 +121,76 @@ export const useNodesStore = defineStore('documentsNodesStore', () => {
}
async function fetchEntities(
providerId: string,
serviceId: string | number,
collectionId: string | number | null,
sources?: (ServiceIdentifier | CollectionIdentifier)[],
filter?: ListFilter,
sort?: ListSort,
range?: ListRange,
): Promise<EntityObject[]> {
error.value = null
try {
const sources: SourceSelector = {
[providerId]: {
[String(serviceId)]: collectionId === null
? true
: { [String(collectionId)]: true },
},
}
await entitiesStore.list(sources, filter, sort, range)
return entitiesStore.entitiesForCollection(providerId, serviceId, collectionId)
return Object.values(await entitiesStore.list(sources, filter, sort, range))
} 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(
providerId: string,
serviceId: string | number,
parentId: string | number | null = ROOT_ID,
sources?: (ServiceIdentifier | CollectionIdentifier)[],
filter?: ListFilter,
sort?: ListSort,
range?: ListRange,
): Promise<NodeRecord[]> {
mode: 'reset' | 'append' = 'reset',
): Promise<{ collections: CollectionObject[]; entities: EntityObject[]; isLastPage: boolean }> {
error.value = null
try {
await Promise.all([
fetchCollections(providerId, serviceId, parentId, filter, sort),
fetchEntities(providerId, serviceId, parentId, filter, sort, range),
])
const result = await nodeService.list({ sources, filter, sort, range })
return getChildren(providerId, serviceId, parentId)
// 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 }
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to fetch nodes'
throw e
@@ -177,14 +198,12 @@ export const useNodesStore = defineStore('documentsNodesStore', () => {
}
async function createCollection(
providerId: string,
serviceId: string | number,
parentCollectionId: string | number | null,
target: ServiceIdentifier | CollectionIdentifier,
properties: CollectionMutableProperties,
): Promise<CollectionObject> {
error.value = null
try {
return await collectionsStore.create(providerId, serviceId, parentCollectionId, properties)
return await collectionsStore.create(target, properties)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to create collection'
throw e
@@ -192,14 +211,12 @@ export const useNodesStore = defineStore('documentsNodesStore', () => {
}
async function updateCollection(
providerId: string,
serviceId: string | number,
identifier: string | number,
target: CollectionIdentifier,
properties: CollectionMutableProperties,
): Promise<CollectionObject> {
error.value = null
try {
return await collectionsStore.update(providerId, serviceId, identifier, properties)
return await collectionsStore.update(target, properties)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to update collection'
throw e
@@ -207,13 +224,11 @@ export const useNodesStore = defineStore('documentsNodesStore', () => {
}
async function deleteCollection(
providerId: string,
serviceId: string | number,
identifier: string | number,
target: CollectionIdentifier,
): Promise<boolean> {
error.value = null
try {
const response = await collectionsStore.delete(providerId, serviceId, identifier)
const response = await collectionsStore.delete(target)
return response.success
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to delete collection'
@@ -222,15 +237,13 @@ export const useNodesStore = defineStore('documentsNodesStore', () => {
}
async function createEntity(
providerId: string,
serviceId: string | number,
collectionId: string | number,
target: CollectionIdentifier,
properties: DocumentInterface,
options?: Record<string, unknown>,
): Promise<EntityObject> {
error.value = null
try {
return await entitiesStore.create(providerId, serviceId, collectionId, properties, options)
return await entitiesStore.create(target, properties, options)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to create entity'
throw e
@@ -238,15 +251,12 @@ export const useNodesStore = defineStore('documentsNodesStore', () => {
}
async function updateEntity(
providerId: string,
serviceId: string | number,
collectionId: string | number,
identifier: string | number,
target: EntityIdentifier,
properties: DocumentInterface,
): Promise<EntityObject> {
error.value = null
try {
return await entitiesStore.update(providerId, serviceId, collectionId, identifier, properties)
return await entitiesStore.update(target, properties)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to update entity'
throw e
@@ -254,15 +264,12 @@ export const useNodesStore = defineStore('documentsNodesStore', () => {
}
async function deleteEntity(
providerId: string,
serviceId: string | number,
collectionId: string | number,
identifier: string | number,
target: EntityIdentifier,
): Promise<boolean> {
error.value = null
try {
const response = await entitiesStore.delete(providerId, serviceId, collectionId, identifier)
return response.success
const response = await entitiesStore.delete([target])
return response.successes.includes(target)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to delete entity'
throw e
@@ -270,14 +277,11 @@ export const useNodesStore = defineStore('documentsNodesStore', () => {
}
async function readEntity(
providerId: string,
serviceId: string | number,
collectionId: string | number,
identifier: string | number,
target: EntityIdentifier,
): Promise<string | null> {
error.value = null
try {
return await entitiesStore.read(providerId, serviceId, collectionId, identifier)
return await entitiesStore.read(target)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to read entity'
throw e
@@ -285,15 +289,12 @@ export const useNodesStore = defineStore('documentsNodesStore', () => {
}
async function writeEntity(
providerId: string,
serviceId: string | number,
collectionId: string | number,
identifier: string | number,
target: EntityIdentifier,
content: string,
): Promise<number> {
error.value = null
try {
return await entitiesStore.write(providerId, serviceId, collectionId, identifier, content)
return await entitiesStore.write(target, content)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to write entity'
throw e
@@ -301,8 +302,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 {
+8 -8
View File
@@ -6,7 +6,7 @@ import { ref, computed, readonly } from 'vue'
import { defineStore } from 'pinia'
import { providerService } from '../services'
import { ProviderObject } from '../models/provider'
import type { SourceSelector } from '../types'
import type { ProviderIdentifier } from '../types'
export const useProvidersStore = defineStore('documentsProvidersStore', () => {
// State
@@ -54,10 +54,10 @@ export const useProvidersStore = defineStore('documentsProvidersStore', () => {
*
* @returns Promise with provider object list keyed by provider identifier
*/
async function list(sources?: SourceSelector): Promise<Record<string, ProviderObject>> {
async function list(targets?: ProviderIdentifier[]): Promise<Record<string, ProviderObject>> {
transceiving.value = true
try {
const providers = await providerService.list({ sources })
const providers = await providerService.list({ targets })
// Merge retrieved providers into state
_providers.value = { ..._providers.value, ...providers }
@@ -82,7 +82,7 @@ export const useProvidersStore = defineStore('documentsProvidersStore', () => {
async function fetch(identifier: string): Promise<ProviderObject> {
transceiving.value = true
try {
const provider = await providerService.fetch({ identifier })
const provider = await providerService.fetch({ target: identifier })
// Merge fetched provider into state
_providers.value[provider.identifier] = provider
@@ -100,14 +100,14 @@ export const useProvidersStore = defineStore('documentsProvidersStore', () => {
/**
* Retrieve provider availability status for a given source selector
*
* @param sources - source selector to check availability for
* @param targets - provider identifiers to check availability for
*
* @returns Promise with provider availability status
*/
async function extant(sources: SourceSelector) {
async function extant(targets: ProviderIdentifier[]) {
transceiving.value = true
try {
const response = await providerService.extant({ sources })
const response = await providerService.extant({ targets })
Object.entries(response).forEach(([providerId, providerStatus]) => {
if (providerStatus === false) {
@@ -115,7 +115,7 @@ export const useProvidersStore = defineStore('documentsProvidersStore', () => {
}
})
console.debug('[Documents Manager][Store] - Successfully checked', sources ? Object.keys(sources).length : 0, 'providers')
console.debug('[Documents Manager][Store] - Successfully checked', targets ? targets.length : 0, 'providers')
return response
} catch (error: any) {
console.error('[Documents Manager][Store] - Failed to check providers:', error)
+8 -8
View File
@@ -7,7 +7,7 @@ import { defineStore } from 'pinia'
import { serviceService } from '../services'
import { ServiceObject } from '../models/service'
import type {
SourceSelector,
ServiceIdentifier,
ServiceInterface,
} from '../types'
@@ -76,14 +76,14 @@ export const useServicesStore = defineStore('documentsServicesStore', () => {
/**
* Retrieve all or specific services, optionally filtered by source selector
*
* @param sources - optional source selector
* @param targets - optional service identifiers
*
* @returns Promise with service object list keyed by provider and service identifier
*/
async function list(sources?: SourceSelector): Promise<Record<string, ServiceObject>> {
async function list(targets?: ServiceIdentifier[]): Promise<Record<string, ServiceObject>> {
transceiving.value = true
try {
const response = await serviceService.list({ sources })
const response = await serviceService.list({ targets })
// Flatten nested structure: provider-id: { service-id: object } -> "provider-id:service-id": object
const services: Record<string, ServiceObject> = {}
@@ -137,16 +137,16 @@ export const useServicesStore = defineStore('documentsServicesStore', () => {
/**
* Retrieve service availability status for a given source selector
*
* @param sources - source selector to check availability for
* @param targets - service identifiers to check availability for
*
* @returns Promise with service availability status
*/
async function extant(sources: SourceSelector) {
async function extant(targets: ServiceIdentifier[]) {
transceiving.value = true
try {
const response = await serviceService.extant({ sources })
const response = await serviceService.extant({ targets })
console.debug('[Documents Manager][Store] - Successfully checked', sources ? Object.keys(sources).length : 0, 'services')
console.debug('[Documents Manager][Store] - Successfully checked', targets ? targets.length : 0, 'services')
return response
} catch (error: any) {
console.error('[Documents Manager][Store] - Failed to check services:', error)
+16 -26
View File
@@ -1,7 +1,7 @@
/**
* Collection type definitions
*/
import type { ListFilter, ListSort, SourceSelector } from './common';
import type { CollectionIdentifier, ListFilter, ListSort, ServiceIdentifier } from './common';
export interface CollectionModelInterface extends Omit<CollectionInterface, '@type' | 'created' | 'modified'> {
@@ -46,7 +46,7 @@ export interface CollectionPropertiesInterface extends CollectionMutableProperti
* Collection list
*/
export interface CollectionListRequest {
sources?: SourceSelector;
sources?: (ServiceIdentifier | CollectionIdentifier)[];
filter?: ListFilter;
sort?: ListSort;
}
@@ -63,18 +63,16 @@ export interface CollectionListResponse {
* Collection fetch
*/
export interface CollectionFetchRequest {
provider: string;
service: string | number;
collection: string | number;
targets: CollectionIdentifier[];
}
export interface CollectionFetchResponse extends CollectionInterface {}
export type CollectionFetchResponse = Record<CollectionIdentifier, CollectionInterface>;
/**
* Collection extant
*/
export interface CollectionExtantRequest {
sources: SourceSelector;
targets: CollectionIdentifier[];
}
export interface CollectionExtantResponse {
@@ -89,10 +87,9 @@ export interface CollectionExtantResponse {
* Collection create
*/
export interface CollectionCreateRequest {
provider: string;
service: string | number;
collection?: string | number | null; // Parent Collection Identifier
target: ServiceIdentifier | CollectionIdentifier; // Service root or parent collection
properties: CollectionMutableProperties;
options?: Record<string, unknown>;
}
export interface CollectionCreateResponse extends CollectionInterface {}
@@ -101,9 +98,7 @@ export interface CollectionCreateResponse extends CollectionInterface {}
* Collection modify
*/
export interface CollectionUpdateRequest {
provider: string;
service: string | number;
identifier: string | number;
target: CollectionIdentifier;
properties: CollectionMutableProperties;
}
@@ -113,26 +108,23 @@ export interface CollectionUpdateResponse extends CollectionInterface {}
* Collection delete
*/
export interface CollectionDeleteRequest {
provider: string;
service: string | number;
identifier: string | number;
target: CollectionIdentifier;
options?: {
force?: boolean; // Whether to force delete even if collection is not empty
};
}
export interface CollectionDeleteResponse {
success: boolean;
disposition: 'deleted' | 'moved';
mutation?: CollectionInterface;
}
/**
* Collection copy
*/
export interface CollectionCopyRequest {
provider: string;
service: string;
identifier: string;
location?: string | null;
source: CollectionIdentifier;
target?: CollectionIdentifier | null; // Destination parent (absent for root)
}
export interface CollectionCopyResponse extends CollectionInterface {}
@@ -141,10 +133,8 @@ export interface CollectionCopyResponse extends CollectionInterface {}
* Collection move
*/
export interface CollectionMoveRequest {
provider: string;
service: string;
identifier: string;
location?: string | null;
source: CollectionIdentifier;
target?: CollectionIdentifier | null; // Destination parent (absent for root)
}
export interface CollectionMoveResponse extends CollectionInterface {}
export interface CollectionMoveResponse extends CollectionInterface {}
+47 -23
View File
@@ -44,33 +44,57 @@ export interface ApiErrorResponse {
export type ApiResponse<T = any> = ApiSuccessResponse<T> | ApiErrorResponse;
/**
* Selector for targeting specific providers, services, collections, or entities in list or extant operations.
*
* Example usage:
* {
* "provider1": true, // Select all services/collections/entities under provider1
* "provider2": {
* "serviceA": true, // Select all collections/entities under serviceA of provider2
* "serviceB": {
* "collectionX": true, // Select all entities under collectionX of serviceB of provider2
* "collectionY": [1, 2, 3] // Select entities with identifiers 1, 2, and 3 under collectionY of serviceB of provider2
* }
* }
* }
* Stream control start line.
*/
export type SourceSelector = {
[provider: string]: boolean | ServiceSelector;
};
export interface ApiStreamStartResponse {
type: 'control';
status: 'start';
version: number;
transaction: string;
total?: number;
}
export type ServiceSelector = {
[service: string]: boolean | CollectionSelector;
};
/**
* Stream control end line
*/
export interface ApiStreamEndResponse {
type: 'control';
status: 'end';
total: number;
}
export type CollectionSelector = {
[collection: string | number]: boolean | EntitySelector;
};
/**
* Stream error line
*/
export interface ApiStreamErrorResponse {
type: 'error';
message: string;
}
export type EntitySelector = (string | number)[];
export interface ApiStreamDataResponse<T = any> {
type: 'data';
data: T;
}
/**
* Shared stream control lines
*/
export type ApiStreamResponse<T = any> =
| ApiStreamStartResponse
| ApiStreamEndResponse
| ApiStreamErrorResponse
| ApiStreamDataResponse<T>;
/**
* Identifiers for targeting specific providers, services, collections, or entities in list or extant operations.
*
* Operations accept flat arrays of colon-separated identifier strings, e.g.
* ["default:personal:00000000-0000-0000-0000-000000000000", "default:personal:folder1:file1"].
*/
export type ProviderIdentifier = `${string}`;
export type ServiceIdentifier = `${string}:${string}`;
export type CollectionIdentifier = `${string}:${string}:${string | number}`;
export type EntityIdentifier = `${string}:${string}:${string}:${string | number}`;
/**
+31 -40
View File
@@ -1,7 +1,7 @@
/**
* Entity type definitions
*/
import type { ListFilter, ListRange, ListSort, SourceSelector } from './common';
import type { CollectionIdentifier, EntityIdentifier, ListFilter, ListRange, ListSort, ServiceIdentifier } from './common';
import type { DocumentInterface, DocumentModelInterface } from './document';
/**
@@ -25,11 +25,21 @@ export interface EntityInterface<T = DocumentInterface> {
properties: T;
}
/**
* Entity mutation result (delete/move/copy operations)
*/
export interface EntityMutationResult {
disposition: 'deleted' | 'moved' | 'copied' | 'error';
destination?: CollectionIdentifier | null;
mutation?: EntityIdentifier;
error?: string;
}
/**
* Entity list
*/
export interface EntityListRequest {
sources?: SourceSelector;
sources?: (ServiceIdentifier | CollectionIdentifier)[];
filter?: ListFilter;
sort?: ListSort;
range?: ListRange;
@@ -49,10 +59,7 @@ export interface EntityListResponse {
* Entity fetch
*/
export interface EntityFetchRequest {
provider: string;
service: string | number;
collection: string | number;
identifiers: (string | number)[];
targets: EntityIdentifier[];
}
export interface EntityFetchResponse {
@@ -63,7 +70,7 @@ export interface EntityFetchResponse {
* Entity extant
*/
export interface EntityExtantRequest {
sources: SourceSelector;
targets: (CollectionIdentifier | EntityIdentifier)[];
}
export interface EntityExtantResponse {
@@ -80,9 +87,7 @@ export interface EntityExtantResponse {
* Entity create
*/
export interface EntityCreateRequest<T = DocumentInterface> {
provider: string;
service: string | number;
collection: string | number;
target: CollectionIdentifier;
properties: T;
options?: Record<string, unknown>;
}
@@ -93,10 +98,7 @@ export interface EntityCreateResponse<T = DocumentInterface> extends EntityInter
* Entity update
*/
export interface EntityUpdateRequest<T = DocumentInterface> {
provider: string;
service: string | number;
collection: string | number;
identifier: string | number;
target: EntityIdentifier;
properties: T;
}
@@ -106,21 +108,18 @@ export interface EntityUpdateResponse<T = DocumentInterface> extends EntityInter
* Entity delete
*/
export interface EntityDeleteRequest {
provider: string;
service: string | number;
collection: string | number;
identifier: string | number;
targets: EntityIdentifier[];
}
export interface EntityDeleteResponse {
success: boolean;
[identifier: string]: EntityMutationResult;
}
/**
* Entity delta
*/
export interface EntityDeltaRequest {
sources: SourceSelector;
targets: (CollectionIdentifier | EntityIdentifier)[];
}
export interface EntityDeltaResponse {
@@ -140,36 +139,31 @@ export interface EntityDeltaResponse {
* Entity copy
*/
export interface EntityCopyRequest {
provider: string;
service: string | number;
collection: string | number;
identifier: string | number;
destination?: string | null;
target: CollectionIdentifier;
sources: EntityIdentifier[];
}
export interface EntityCopyResponse<T = DocumentInterface> extends EntityInterface<T> {}
export interface EntityCopyResponse {
[identifier: string]: EntityMutationResult;
}
/**
* Entity move
*/
export interface EntityMoveRequest {
provider: string;
service: string | number;
collection: string | number;
identifier: string | number;
destination?: string | null;
target: CollectionIdentifier;
sources: EntityIdentifier[];
}
export interface EntityMoveResponse<T = DocumentInterface> extends EntityInterface<T> {}
export interface EntityMoveResponse {
[identifier: string]: EntityMutationResult;
}
/**
* Entity read content
*/
export interface EntityReadRequest {
provider: string;
service: string | number;
collection: string | number;
identifier: string | number;
target: EntityIdentifier;
}
export interface EntityReadResult {
@@ -183,10 +177,7 @@ export type EntityReadResponse = EntityReadResult;
* Entity write content
*/
export interface EntityWriteRequest {
provider: string;
service: string | number;
collection: string | number;
identifier: string | number;
target: EntityIdentifier;
content: string;
encoding?: 'base64';
}
+1 -1
View File
@@ -3,5 +3,5 @@ export type * from './provider';
export type * from './service';
export type * from './collection';
export type * from './entity';
export type * from './document';
export type * from './node';
export type * from './document';
+16 -30
View File
@@ -1,37 +1,23 @@
/**
* Node types for combined operations
* 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';
import type { CollectionInterface } from "./collection";
import type { ApiResponse, ListFilterCondition, ListRange, ListSort } from "./common";
import type { EntityInterface } from "./entity";
/**
* Node list
*/
export interface NodeListRequest {
provider: string;
service: string;
location?: string | null;
recursive?: boolean;
filter?: ListFilterCondition | null;
sort?: ListSort | null;
range?: ListRange | null;
sources?: (ServiceIdentifier | CollectionIdentifier)[];
filter?: ListFilter;
sort?: ListSort;
range?: ListRange;
}
export type NodeListResponse = ApiResponse<CollectionInterface | EntityInterface>;
export type NodeInterface =
| (Omit<CollectionInterface, '@type'> & { '@type': 'document:collection' })
| (Omit<EntityInterface, '@type'> & { '@type': 'document:entity' });
export interface NodeDeltaRequest {
provider: string;
service: string;
location?: string | null;
signature: string;
recursive?: boolean;
detail?: 'ids' | 'full';
}
export interface NodeDeltaResult {
added: string[];
modified: string[];
removed: string[];
signature: string;
}
export type NodeDeltaResponse = ApiResponse<NodeDeltaResult>;
/** Payload of each data frame in the node.list NDJSON stream. */
export type NodeListResponse = NodeInterface;
+6 -6
View File
@@ -1,7 +1,7 @@
/**
* Provider type definitions
*/
import type { SourceSelector } from "./common";
import type { ProviderIdentifier } from "./common";
/**
* Provider capabilities
@@ -32,7 +32,7 @@ export interface ProviderInterface {
* Provider list
*/
export interface ProviderListRequest {
sources?: SourceSelector;
targets?: ProviderIdentifier[];
}
export interface ProviderListResponse {
@@ -40,19 +40,19 @@ export interface ProviderListResponse {
}
/**
* Provider fetch
* Provider fetch
*/
export interface ProviderFetchRequest {
identifier: string;
target: ProviderIdentifier;
}
export interface ProviderFetchResponse extends ProviderInterface {}
/**
* Provider extant
* Provider extant
*/
export interface ProviderExtantRequest {
sources: SourceSelector;
targets: ProviderIdentifier[];
}
export interface ProviderExtantResponse {
+3 -3
View File
@@ -1,7 +1,7 @@
/**
* Service type definitions
*/
import type { SourceSelector, ListFilterComparisonOperator } from './common';
import type { ServiceIdentifier as ServiceIdentifierString, ListFilterComparisonOperator } from './common';
/**
* Service capabilities
@@ -66,7 +66,7 @@ export interface ServiceInterface {
* Service list
*/
export interface ServiceListRequest {
sources?: SourceSelector;
targets?: ServiceIdentifierString[];
}
export interface ServiceListResponse {
@@ -89,7 +89,7 @@ export interface ServiceFetchResponse extends ServiceInterface {}
* Service extant
*/
export interface ServiceExtantRequest {
sources: SourceSelector;
targets: ServiceIdentifierString[];
}
export interface ServiceExtantResponse {
+71
View File
@@ -0,0 +1,71 @@
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'
}
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest'
describe('Basic Tests', () => {
it('should perform basic assertion', () => {
expect(true).toBe(true)
})
it('should test array operations', () => {
const array = ['foo', 'bar', 'baz']
expect(array).toHaveLength(3)
expect(array).toContain('bar')
expect(array[0]).toBe('foo')
})
it('should test string operations', () => {
const string = 'Hello, World!'
expect(string).toContain('World')
expect(string.length).toBe(13)
})
it('should test object operations', () => {
const obj = { foo: 'bar', count: 42 }
expect(obj).toHaveProperty('foo')
expect(obj.foo).toBe('bar')
expect(obj.count).toBeGreaterThan(40)
})
})
+31
View File
@@ -0,0 +1,31 @@
import { fileURLToPath } from 'node:url'
import { defineConfig, configDefaults } from 'vitest/config'
import path from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, '../../src'),
'@KTXC': path.resolve(__dirname, '../../../../core/src'),
},
},
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('../../', import.meta.url)),
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'tests/',
'**/*.d.ts',
'**/*.config.*',
'**/dist/**',
],
},
},
})
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace KTXT\DocumentsManager\Tests\Integration;
use PHPUnit\Framework\TestCase;
class BaseTest extends TestCase
{
public function testBasicAssertion(): void
{
$this->assertTrue(true);
}
public function testArrayOperations(): void
{
$array = ['foo' => 'bar'];
$this->assertArrayHasKey('foo', $array);
$this->assertEquals('bar', $array['foo']);
}
public function testStringOperations(): void
{
$string = 'Hello, World!';
$this->assertStringContainsString('World', $string);
$this->assertEquals(13, strlen($string));
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace KTXT\DocumentsManager\Tests\Unit;
use PHPUnit\Framework\TestCase;
class BaseTest extends TestCase
{
public function testBasicAssertion(): void
{
$this->assertTrue(true);
}
public function testArrayOperations(): void
{
$array = ['foo' => 'bar'];
$this->assertArrayHasKey('foo', $array);
$this->assertEquals('bar', $array['foo']);
}
public function testStringOperations(): void
{
$string = 'Hello, World!';
$this->assertStringContainsString('World', $string);
$this->assertEquals(13, strlen($string));
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
require dirname(__DIR__, 2).'/lib/vendor/autoload.php';
// When this module is checked out inside a full server (server/modules/<handle>,
// as it is in CI and in this monorepo checkout), also load the server's own
// core/shared autoloader so tests can reference framework (KTXC/KTXF) types.
// Standalone module checkouts without a server alongside them skip this.
define('SERVER_ROOT', dirname(__DIR__, 4));
$serverAutoload = SERVER_ROOT . '/vendor/autoload.php';
if (is_file($serverAutoload)) {
require $serverAutoload;
}
if (isset($_SERVER['APP_DEBUG']) && $_SERVER['APP_DEBUG']) {
umask(0000);
}
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="../../lib/vendor/phpunit/phpunit/phpunit.xsd"
colors="true"
failOnDeprecation="true"
failOnNotice="true"
failOnWarning="true"
bootstrap="bootstrap.php"
cacheDirectory="../../.phpunit.cache"
>
<php>
<ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" />
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
</php>
<testsuites>
<testsuite name="Unit Tests">
<directory>Unit</directory>
</testsuite>
<testsuite name="Integration Tests">
<directory>Integration</directory>
</testsuite>
</testsuites>
<source ignoreSuppressionOfDeprecations="true"
ignoreIndirectDeprecations="true"
restrictNotices="true"
restrictWarnings="true"
>
<include>
<directory>../../lib</directory>
</include>
</source>
<extensions>
</extensions>
</phpunit>
+1 -1
View File
@@ -15,5 +15,5 @@
"@KTXC/*": ["../../core/src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}