From fbc8ac2d7e8dbf3a774b4ebd9b130eb0d337851c Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Wed, 6 May 2026 12:03:05 -0400 Subject: [PATCH] feat: collection move Signed-off-by: Sebastian Krupinski --- lib/Controllers/DefaultController.php | 15 ++++---- lib/Manager.php | 36 +++++++++++++++++++ src/services/collectionService.ts | 14 ++++++++ src/stores/collectionsStore.ts | 50 +++++++++++++++++++++++++-- src/types/collection.ts | 13 +++++-- 5 files changed, 117 insertions(+), 11 deletions(-) diff --git a/lib/Controllers/DefaultController.php b/lib/Controllers/DefaultController.php index 2e20ae0..0e6bfb6 100644 --- a/lib/Controllers/DefaultController.php +++ b/lib/Controllers/DefaultController.php @@ -152,7 +152,7 @@ class DefaultController extends ControllerAbstract { 'collection.update' => $this->collectionUpdate($tenantId, $userId, $data), 'collection.delete' => $this->collectionDelete($tenantId, $userId, $data), 'collection.delta' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), - 'collection.move' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), + 'collection.move' => $this->collectionMove($tenantId, $userId, $data), // Entity operations 'entity.list' => $this->entityList($tenantId, $userId, $data), @@ -581,11 +581,11 @@ class DefaultController extends ControllerAbstract { if (!is_string($data['target'])) { throw new InvalidArgumentException(self::ERR_INVALID_TARGET); } - if (!isset($data['sources'])) { - throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); + if (!isset($data['source'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SOURCE); } - if (!is_array($data['sources'])) { - throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); + if (!is_string($data['source'])) { + throw new InvalidArgumentException(self::ERR_INVALID_SOURCE); } $target = ResourceIdentifier::fromString($data['target']); @@ -593,9 +593,10 @@ class DefaultController extends ControllerAbstract { throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection'); } - $source = ResourceIdentifier::fromArray($data['source']); + $source = ResourceIdentifier::fromString($data['source']); if (!$source instanceof CollectionIdentifier) { - throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service:collection identifiers'); + throw new InvalidArgumentException('Invalid parameter: source must be provider:service:collection'); + } return $this->mailManager->collectionMove($tenantId, $userId, $target, $source); diff --git a/lib/Manager.php b/lib/Manager.php index 43276d1..249236e 100644 --- a/lib/Manager.php +++ b/lib/Manager.php @@ -671,6 +671,42 @@ class Manager { return $service->collectionDelete($collectionId, $force); } + /** + * Move a specific collection to a new parent collection + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param CollectionIdentifier $target Target collection identifier (new parent) + * @param CollectionIdentifier $source Source collection identifier (collection to move) + * + * @return CollectionBaseInterface Moved collection + */ + public function collectionMove(string $tenantId, ?string $userId, CollectionIdentifier $target, CollectionIdentifier $source): CollectionBaseInterface { + // validate that source and target are the same provider and service + if ($source->provider() !== $target->provider() || $source->service() !== $target->service()) { + throw new InvalidArgumentException("Source and target collections must belong to the same provider and service"); + } + // Validate that source and target are not the same + if ($source->collection() === $target->collection()) { + throw new InvalidArgumentException("Source and target collections are the same"); + } + // retrieve service + $service = $this->serviceFetch($tenantId, $userId, $source->provider(), $source->service()); + + // Check if service supports collection move + if (!($service instanceof ServiceCollectionMutableInterface)) { + throw new InvalidArgumentException("Service does not support collection mutations"); + } + if (!$service->capable(ServiceCollectionMutableInterface::CAPABILITY_COLLECTION_MOVE)) { + throw new InvalidArgumentException("Service is not capable of moving collections"); + } + + // move collection + return $service->collectionMove($source->collection(), $target->collection()); + } + // ==================== Message Operations ==================== /** diff --git a/src/services/collectionService.ts b/src/services/collectionService.ts index aabb2b7..c9f0d24 100644 --- a/src/services/collectionService.ts +++ b/src/services/collectionService.ts @@ -17,6 +17,8 @@ import type { CollectionDeleteResponse, CollectionDeleteRequest, CollectionInterface, + CollectionMoveRequest, + CollectionMoveResponse, } from '../types/collection'; import { useIntegrationStore } from '@KTXC/stores/integrationStore'; import { CollectionObject, CollectionPropertiesObject } from '../models/collection'; @@ -132,6 +134,18 @@ export const collectionService = { return true; }, + + /** + * Move a collection to a new target collection + * + * @param request - move request parameters + * + * @returns Promise with moved collection object + */ + async move(request: CollectionMoveRequest): Promise { + const response = await transceivePost('collection.move', request); + return createCollectionObject(response); + } }; export default collectionService; diff --git a/src/stores/collectionsStore.ts b/src/stores/collectionsStore.ts index 3246144..66711e5 100644 --- a/src/stores/collectionsStore.ts +++ b/src/stores/collectionsStore.ts @@ -4,9 +4,9 @@ import { ref, computed, readonly } from 'vue' import { defineStore } from 'pinia' -import { collectionService } from '../services' +import { collectionService, entityService } from '../services' import { CollectionObject, CollectionPropertiesObject } from '../models/collection' -import type { SourceSelector, ListFilter, ListSort } from '../types' +import type { SourceSelector, ListFilter, ListSort, CollectionIdentifier, CollectionMoveResponse } from '../types' export const useCollectionsStore = defineStore('mailCollectionsStore', () => { const ROOT_IDENTIFIER = '__root__' @@ -425,6 +425,51 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => { } } + /** + * Move collections to another target collection. + * + * Updates local store keys for successfully moved collections when they are + * already present in cache. + * + * @param target - target collection identifier + * @param source - source collection identifier + * + * @returns Promise with move results keyed by source identifier + */ + async function move(target: CollectionIdentifier, source: CollectionIdentifier): Promise { + transceiving.value = true + try { + const response = await collectionService.move({ target, source }) + + if (!(response instanceof CollectionObject)) { + console.warn('[Mail Manager][Store] - Move failed. Received unexpected response from move operation:', response) + throw new Error('Failed to move collection: unexpected response from move operation') + } + + const sourceCollection = _collections.value[source] + + if (sourceCollection) { + deindexCollection(sourceCollection) + } + + delete _collections.value[source] + + const movedCollection = response + const movedKey = identifierKey(movedCollection.provider, movedCollection.service, movedCollection.identifier) + + _collections.value[movedKey] = movedCollection + indexCollection(movedCollection) + + console.debug('[Mail Manager][Store] - Successfully moved collection:', source, ' to ', movedKey) + return response + } catch (error: any) { + console.error('[Mail Manager][Store] - Failed to move collection:', error) + throw error + } finally { + transceiving.value = false + } + } + // Return public API return { // State (readonly) @@ -445,5 +490,6 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => { create, update, delete: remove, + move, } }) diff --git a/src/types/collection.ts b/src/types/collection.ts index bc6ec19..fcb9a7b 100644 --- a/src/types/collection.ts +++ b/src/types/collection.ts @@ -1,7 +1,7 @@ /** * Collection type definitions */ -import type { ListFilter, ListSort, SourceSelector } from './common'; +import type { CollectionIdentifier, ListFilter, ListSort, SourceSelector } from './common'; /** * Collection information @@ -119,7 +119,6 @@ export interface CollectionDeleteRequest { identifier: string | number; options?: { force?: boolean; // Whether to force delete even if collection is not empty - recursive?: boolean; // Whether to delete child collections/items as well }; } @@ -127,3 +126,13 @@ export interface CollectionDeleteResponse { outcome: 'deleted' | 'moved'; data?: CollectionInterface | null; // If moved, the new location of the collection } + +/** + * Collection move + */ +export interface CollectionMoveRequest { + target: CollectionIdentifier; + source: CollectionIdentifier; +} + +export interface CollectionMoveResponse extends CollectionInterface {};