Merge pull request 'feat: collection move' (#16) from feat/collection-move into main

Reviewed-on: #16
This commit was merged in pull request #16.
This commit is contained in:
2026-05-06 16:03:26 +00:00
5 changed files with 117 additions and 11 deletions

View File

@@ -152,7 +152,7 @@ class DefaultController extends ControllerAbstract {
'collection.update' => $this->collectionUpdate($tenantId, $userId, $data), 'collection.update' => $this->collectionUpdate($tenantId, $userId, $data),
'collection.delete' => $this->collectionDelete($tenantId, $userId, $data), 'collection.delete' => $this->collectionDelete($tenantId, $userId, $data),
'collection.delta' => throw new InvalidArgumentException('Operation not implemented: ' . $operation), '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 operations
'entity.list' => $this->entityList($tenantId, $userId, $data), 'entity.list' => $this->entityList($tenantId, $userId, $data),
@@ -581,11 +581,11 @@ class DefaultController extends ControllerAbstract {
if (!is_string($data['target'])) { if (!is_string($data['target'])) {
throw new InvalidArgumentException(self::ERR_INVALID_TARGET); throw new InvalidArgumentException(self::ERR_INVALID_TARGET);
} }
if (!isset($data['sources'])) { if (!isset($data['source'])) {
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); throw new InvalidArgumentException(self::ERR_MISSING_SOURCE);
} }
if (!is_array($data['sources'])) { if (!is_string($data['source'])) {
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); throw new InvalidArgumentException(self::ERR_INVALID_SOURCE);
} }
$target = ResourceIdentifier::fromString($data['target']); $target = ResourceIdentifier::fromString($data['target']);
@@ -593,9 +593,10 @@ class DefaultController extends ControllerAbstract {
throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection'); 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) { 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); return $this->mailManager->collectionMove($tenantId, $userId, $target, $source);

View File

@@ -671,6 +671,42 @@ class Manager {
return $service->collectionDelete($collectionId, $force); 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 ==================== // ==================== Message Operations ====================
/** /**

View File

@@ -17,6 +17,8 @@ import type {
CollectionDeleteResponse, CollectionDeleteResponse,
CollectionDeleteRequest, CollectionDeleteRequest,
CollectionInterface, CollectionInterface,
CollectionMoveRequest,
CollectionMoveResponse,
} from '../types/collection'; } from '../types/collection';
import { useIntegrationStore } from '@KTXC/stores/integrationStore'; import { useIntegrationStore } from '@KTXC/stores/integrationStore';
import { CollectionObject, CollectionPropertiesObject } from '../models/collection'; import { CollectionObject, CollectionPropertiesObject } from '../models/collection';
@@ -132,6 +134,18 @@ export const collectionService = {
return true; 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<CollectionObject> {
const response = await transceivePost<CollectionMoveRequest, CollectionMoveResponse>('collection.move', request);
return createCollectionObject(response);
}
}; };
export default collectionService; export default collectionService;

View File

@@ -4,9 +4,9 @@
import { ref, computed, readonly } from 'vue' import { ref, computed, readonly } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { collectionService } from '../services' import { collectionService, entityService } from '../services'
import { CollectionObject, CollectionPropertiesObject } from '../models/collection' 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', () => { export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
const ROOT_IDENTIFIER = '__root__' 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<CollectionObject> {
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 public API
return { return {
// State (readonly) // State (readonly)
@@ -445,5 +490,6 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
create, create,
update, update,
delete: remove, delete: remove,
move,
} }
}) })

View File

@@ -1,7 +1,7 @@
/** /**
* Collection type definitions * Collection type definitions
*/ */
import type { ListFilter, ListSort, SourceSelector } from './common'; import type { CollectionIdentifier, ListFilter, ListSort, SourceSelector } from './common';
/** /**
* Collection information * Collection information
@@ -119,7 +119,6 @@ export interface CollectionDeleteRequest {
identifier: string | number; identifier: string | number;
options?: { options?: {
force?: boolean; // Whether to force delete even if collection is not empty 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'; outcome: 'deleted' | 'moved';
data?: CollectionInterface | null; // If moved, the new location of the collection 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 {};