import { computed, ref } from "vue"; import { defineStore } from "pinia"; import { uuid } from "@/lib/common/utils"; import * as api from "@/lib/backend/api"; import type { TransferTask, TransferTaskConfig, TransferTaskFolder, TransferTaskLibrary } from "@/types/database"; import { resolveTransferStrategy, transferStrategyOptions } from "@/components/transfer/transferStrategy"; export class TransferTaskNameConflictError extends Error { readonly code = "TRANSFER_TASK_NAME_CONFLICT"; constructor(readonly entryName: string) { super(`"${entryName}" already exists in this location.`); this.name = "TransferTaskNameConflictError"; } } function nowIso() { return new Date().toISOString(); } function sortFoldersByOrder(items: TransferTaskFolder[]) { return [...items].sort((a, b) => { const orderDiff = (a.orderIndex ?? 0) - (b.orderIndex ?? 0); if (orderDiff !== 0) return orderDiff; return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }); }); } function sortTasksByOrder(items: TransferTask[]) { return [...items].sort((a, b) => { const orderDiff = (a.orderIndex ?? 0) - (b.orderIndex ?? 0); if (orderDiff !== 0) return orderDiff; return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }); }); } function reindexFolders(items: TransferTaskFolder[]) { return items.map((folder, index) => ({ ...folder, orderIndex: index })); } function reindexTasks(items: TransferTask[], folderId?: string) { return items.map((task, index) => ({ ...task, folderId, orderIndex: index })); } function maxOrderIndex(values: Array<{ orderIndex?: number }>) { return values.reduce((max, item) => Math.max(max, item.orderIndex ?? -1), -1); } function normalizeFolder(raw: unknown): TransferTaskFolder | null { if (!raw || typeof raw !== "object") return null; const candidate = raw as Partial; if (!candidate.id || !candidate.name) return null; return { id: String(candidate.id), parentFolderId: candidate.parentFolderId || undefined, name: String(candidate.name), orderIndex: typeof candidate.orderIndex === "number" ? candidate.orderIndex : 0, createdAt: candidate.createdAt || nowIso(), updatedAt: candidate.updatedAt || nowIso(), }; } function normalizeTask(raw: unknown): TransferTask | null { if (!raw || typeof raw !== "object") return null; const candidate = raw as Partial; const config = candidate.config; if (!candidate.id || !candidate.name || !config || typeof config !== "object") return null; if (!config.sourceConnectionId || !config.sourceDatabase || !config.targetConnectionId || !config.targetDatabase) return null; const objects: TransferTaskConfig["objects"] = {}; if (config.objects && typeof config.objects === "object") { for (const [kind, names] of Object.entries(config.objects)) { if (Array.isArray(names)) objects[kind as keyof typeof objects] = names.filter((name): name is string => typeof name === "string"); } } return { id: String(candidate.id), folderId: candidate.folderId || undefined, name: String(candidate.name), orderIndex: typeof candidate.orderIndex === "number" ? candidate.orderIndex : 0, config: { sourceConnectionId: String(config.sourceConnectionId), sourceCatalog: config.sourceCatalog || undefined, sourceDatabase: String(config.sourceDatabase), sourceSchema: config.sourceSchema || undefined, targetConnectionId: String(config.targetConnectionId), targetCatalog: config.targetCatalog || undefined, targetDatabase: String(config.targetDatabase), targetSchema: config.targetSchema || undefined, objects, content: config.content ?? "structureAndData", ...transferStrategyOptions(resolveTransferStrategy(config)), targetTableNameCase: config.targetTableNameCase ?? "preserve", quoteTargetColumnNames: config.quoteTargetColumnNames ?? true, batchSize: typeof config.batchSize === "number" && config.batchSize > 0 ? config.batchSize : 1000, dropTargetConfirmed: false, }, createdAt: candidate.createdAt || nowIso(), updatedAt: candidate.updatedAt || nowIso(), }; } function normalizeLibrary(raw: unknown): TransferTaskLibrary { if (raw === null || raw === undefined) return { version: 1, folders: [], tasks: [] }; if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Invalid transfer task library"); const candidate = raw as { version?: unknown; folders?: unknown; tasks?: unknown }; if (candidate.version !== undefined && candidate.version !== 1) { throw new Error(`Unsupported transfer task library version: ${String(candidate.version)}`); } if (!Array.isArray(candidate.folders) || !Array.isArray(candidate.tasks)) { throw new Error("Invalid transfer task library"); } const normalizedFolders = candidate.folders.map(normalizeFolder); const normalizedTasks = candidate.tasks.map(normalizeTask); if (normalizedFolders.some((folder) => !folder) || normalizedTasks.some((task) => !task)) { throw new Error("Invalid transfer task library entry"); } return { version: 1, folders: normalizedFolders as TransferTaskFolder[], tasks: normalizedTasks as TransferTask[], }; } /** Copy name for duplicated tasks: "name_copy1", "name_copy2", ... */ export function nextTransferTaskCopyName(sourceName: string, takenNames: ReadonlySet): string { const copyBase = sourceName.replace(/_copy\d+$/i, "") || sourceName; const normalizedTaken = new Set([...takenNames].map((name) => name.toLocaleLowerCase())); let index = 1; while (normalizedTaken.has(`${copyBase}_copy${index}`.toLocaleLowerCase())) index++; return `${copyBase}_copy${index}`; } export const useTransferTaskStore = defineStore("transferTasks", () => { const folders = ref([]); const tasks = ref([]); const isLoaded = ref(false); let initPromise: Promise | null = null; let initError: unknown = null; let mutationQueue: Promise = Promise.resolve(); async function initFromStorage() { if (isLoaded.value) return; if (!initPromise) { initPromise = (async () => { try { const library = normalizeLibrary(await api.loadTransferTaskLibrary()); folders.value = library.folders; tasks.value = library.tasks; initError = null; isLoaded.value = true; } catch (error) { initError = error; // Backend unavailable or persisted data is invalid: keep empty state and retry on next init. } })().finally(() => { initPromise = null; }); } await initPromise; } async function ensureInitialized() { await initFromStorage(); if (!isLoaded.value) { throw initError instanceof Error ? initError : new Error("Failed to load transfer task library"); } } function queueMutation(mutation: (...args: Args) => Promise) { return (...args: Args): Promise => { const result = mutationQueue.then(async () => { await ensureInitialized(); return mutation(...args); }); mutationQueue = result.then( () => undefined, () => undefined, ); return result; }; } /** Whole-library persistence with rollback; public mutations run serially. */ async function persist(nextFolders: TransferTaskFolder[], nextTasks: TransferTask[]) { const previousFolders = folders.value; const previousTasks = tasks.value; folders.value = nextFolders; tasks.value = nextTasks; try { const library: TransferTaskLibrary = { version: 1, folders: nextFolders, tasks: nextTasks }; await api.saveTransferTaskLibrary(JSON.parse(JSON.stringify(library))); } catch (error) { folders.value = previousFolders; tasks.value = previousTasks; throw error; } } function listChildFolders(parentFolderId?: string) { return sortFoldersByOrder(folders.value.filter((folder) => (folder.parentFolderId || "") === (parentFolderId || ""))); } function listTasks(folderId?: string) { return sortTasksByOrder(tasks.value.filter((task) => (task.folderId || "") === (folderId || ""))); } function getTask(id: string) { return tasks.value.find((task) => task.id === id); } function descendantFolderIds(folderId: string) { const ids = new Set([folderId]); let changed = true; while (changed) { changed = false; for (const folder of folders.value) { if (folder.parentFolderId && ids.has(folder.parentFolderId) && !ids.has(folder.id)) { ids.add(folder.id); changed = true; } } } return ids; } function ensureFolderNameAvailable(name: string, parentFolderId: string | undefined, excludeId?: string) { const key = name.trim().toLocaleLowerCase(); const conflict = folders.value.some((folder) => folder.id !== excludeId && (folder.parentFolderId || "") === (parentFolderId || "") && folder.name.trim().toLocaleLowerCase() === key); if (conflict) throw new TransferTaskNameConflictError(name); } function ensureTaskNameAvailable(name: string, folderId: string | undefined, excludeId?: string) { const key = name.trim().toLocaleLowerCase(); const conflict = tasks.value.some((task) => task.id !== excludeId && (task.folderId || "") === (folderId || "") && task.name.trim().toLocaleLowerCase() === key); if (conflict) throw new TransferTaskNameConflictError(name); } async function createFolder(name: string, parentFolderId?: string) { const trimmed = name.trim(); if (!trimmed) throw new TransferTaskNameConflictError(name); ensureFolderNameAvailable(trimmed, parentFolderId); const timestamp = nowIso(); const folder: TransferTaskFolder = { id: uuid(), parentFolderId: parentFolderId || undefined, name: trimmed, orderIndex: maxOrderIndex(folders.value.filter((item) => (item.parentFolderId || "") === (parentFolderId || ""))) + 1, createdAt: timestamp, updatedAt: timestamp, }; await persist([...folders.value, folder], tasks.value); return folder; } async function renameFolder(id: string, name: string) { const existing = folders.value.find((folder) => folder.id === id); if (!existing) return; const trimmed = name.trim(); if (!trimmed || trimmed === existing.name) return; ensureFolderNameAvailable(trimmed, existing.parentFolderId, id); await persist( folders.value.map((folder) => (folder.id === id ? { ...folder, name: trimmed, updatedAt: nowIso() } : folder)), tasks.value, ); } /** Deletes the folder, its descendant folders and all contained tasks. */ async function deleteFolder(id: string) { const removedIds = descendantFolderIds(id); await persist( folders.value.filter((folder) => !removedIds.has(folder.id)), tasks.value.filter((task) => !task.folderId || !removedIds.has(task.folderId)), ); } async function saveTask(input: { id?: string; folderId?: string; name: string; config: TransferTaskConfig }) { const trimmed = input.name.trim(); if (!trimmed) throw new TransferTaskNameConflictError(input.name); const existing = input.id ? getTask(input.id) : undefined; const folderId = Object.prototype.hasOwnProperty.call(input, "folderId") ? input.folderId || undefined : existing?.folderId; ensureTaskNameAvailable(trimmed, folderId, input.id); const timestamp = nowIso(); const config: TransferTaskConfig = { ...input.config, ...transferStrategyOptions(resolveTransferStrategy(input.config)), dropTargetConfirmed: false }; const task: TransferTask = existing ? { ...existing, folderId, name: trimmed, config, updatedAt: timestamp } : { id: uuid(), folderId, name: trimmed, orderIndex: maxOrderIndex(tasks.value.filter((item) => (item.folderId || "") === (folderId || ""))) + 1, config, createdAt: timestamp, updatedAt: timestamp, }; await persist(folders.value, [...tasks.value.filter((item) => item.id !== task.id), task]); return task; } async function renameTask(id: string, name: string) { const existing = getTask(id); if (!existing) return; const trimmed = name.trim(); if (!trimmed || trimmed === existing.name) return; ensureTaskNameAvailable(trimmed, existing.folderId, id); await persist( folders.value, tasks.value.map((task) => (task.id === id ? { ...task, name: trimmed, updatedAt: nowIso() } : task)), ); } async function duplicateTask(id: string) { const source = getTask(id); if (!source) return undefined; const takenNames = new Set(tasks.value.filter((task) => (task.folderId || "") === (source.folderId || "")).map((task) => task.name)); const timestamp = nowIso(); const copy: TransferTask = { ...JSON.parse(JSON.stringify(source)), id: uuid(), name: nextTransferTaskCopyName(source.name, takenNames), orderIndex: maxOrderIndex(tasks.value.filter((item) => (item.folderId || "") === (source.folderId || ""))) + 1, createdAt: timestamp, updatedAt: timestamp, }; await persist(folders.value, [...tasks.value, copy]); return copy; } async function deleteTask(id: string) { await persist( folders.value, tasks.value.filter((task) => task.id !== id), ); } async function moveTaskToFolder(taskId: string, folderId?: string) { const target = getTask(taskId); if (!target) return; const targetFolderId = folderId || undefined; if ((target.folderId || undefined) === targetFolderId) return; const timestamp = nowIso(); const sourceGroup = sortTasksByOrder(tasks.value.filter((task) => (task.folderId || "") === (target.folderId || "") && task.id !== taskId)); const destinationGroup = sortTasksByOrder(tasks.value.filter((task) => task.id !== taskId && (task.folderId || "") === (targetFolderId || ""))); const movedTask: TransferTask = { ...target, folderId: targetFolderId, updatedAt: timestamp }; const nextSource = reindexTasks(sourceGroup, target.folderId || undefined).map((task) => ({ ...task, updatedAt: timestamp })); const nextDestination = reindexTasks([...destinationGroup, movedTask], targetFolderId).map((task) => ({ ...task, updatedAt: timestamp })); const untouched = tasks.value.filter((task) => task.id !== taskId && (task.folderId || "") !== (target.folderId || "") && (task.folderId || "") !== (targetFolderId || "")); await persist(folders.value, [...untouched, ...nextSource, ...nextDestination]); } async function moveFolderToFolder(folderId: string, parentFolderId?: string) { const target = folders.value.find((folder) => folder.id === folderId); if (!target) return; const nextParentFolderId = parentFolderId || undefined; if ((target.parentFolderId || undefined) === nextParentFolderId) return; if (nextParentFolderId && descendantFolderIds(folderId).has(nextParentFolderId)) return; const timestamp = nowIso(); const previousParentFolderId = target.parentFolderId || undefined; const sourceGroup = reindexFolders(sortFoldersByOrder(folders.value.filter((folder) => folder.id !== folderId && (folder.parentFolderId || "") === (previousParentFolderId || "")))).map((folder) => ({ ...folder, updatedAt: timestamp, })); const destinationGroup = reindexFolders([...sortFoldersByOrder(folders.value.filter((folder) => folder.id !== folderId && (folder.parentFolderId || "") === (nextParentFolderId || ""))), { ...target, parentFolderId: nextParentFolderId, updatedAt: timestamp }]).map((folder) => ({ ...folder, parentFolderId: nextParentFolderId, updatedAt: timestamp, })); const untouched = folders.value.filter((folder) => folder.id !== folderId && (folder.parentFolderId || "") !== (previousParentFolderId || "") && (folder.parentFolderId || "") !== (nextParentFolderId || "")); await persist([...untouched, ...sourceGroup, ...destinationGroup], tasks.value); } async function reorderFolders(draggedId: string, targetId: string, position: "before" | "after") { const dragged = folders.value.find((folder) => folder.id === draggedId); const target = folders.value.find((folder) => folder.id === targetId); if (!dragged || !target || dragged.id === target.id) return; if (descendantFolderIds(draggedId).has(targetId)) return; const timestamp = nowIso(); const targetParentFolderId = target.parentFolderId || undefined; const previousParentFolderId = dragged.parentFolderId || undefined; const ordered = sortFoldersByOrder(folders.value.filter((folder) => (folder.parentFolderId || "") === (targetParentFolderId || ""))); const remaining = ordered.filter((folder) => folder.id !== draggedId); const targetIndex = remaining.findIndex((folder) => folder.id === targetId); const insertIndex = position === "before" ? targetIndex : targetIndex + 1; remaining.splice(insertIndex, 0, { ...dragged, parentFolderId: targetParentFolderId, updatedAt: timestamp }); const updatedTargetGroup = reindexFolders(remaining).map((folder) => ({ ...folder, parentFolderId: targetParentFolderId, updatedAt: timestamp })); const updatedSourceGroup = previousParentFolderId === targetParentFolderId ? [] : reindexFolders(sortFoldersByOrder(folders.value.filter((folder) => folder.id !== draggedId && (folder.parentFolderId || "") === (previousParentFolderId || "")))).map((folder) => ({ ...folder, updatedAt: timestamp, })); const untouched = folders.value.filter((folder) => folder.id !== draggedId && (folder.parentFolderId || "") !== (targetParentFolderId || "") && (folder.parentFolderId || "") !== (previousParentFolderId || "")); await persist([...untouched, ...updatedSourceGroup, ...updatedTargetGroup], tasks.value); } async function reorderTasks(draggedId: string, targetId: string, position: "before" | "after") { const dragged = getTask(draggedId); const target = getTask(targetId); if (!dragged || !target || dragged.id === target.id) return; const targetFolderId = target.folderId || undefined; const groupTasks = sortTasksByOrder(tasks.value.filter((task) => (task.folderId || "") === (targetFolderId || ""))); const remainingGroup = groupTasks.filter((task) => task.id !== draggedId); const draggedNext: TransferTask = { ...dragged, folderId: targetFolderId, updatedAt: nowIso() }; const targetIndex = remainingGroup.findIndex((task) => task.id === targetId); const insertIndex = position === "before" ? targetIndex : targetIndex + 1; remainingGroup.splice(insertIndex, 0, draggedNext); const updatedGroup = reindexTasks(remainingGroup, targetFolderId).map((task) => ({ ...task, updatedAt: draggedNext.updatedAt })); const previousGroupId = dragged.folderId || undefined; const sourceGroup = previousGroupId === targetFolderId ? [] : reindexTasks(sortTasksByOrder(tasks.value.filter((task) => task.id !== draggedId && (task.folderId || "") === (previousGroupId || ""))), previousGroupId).map((task) => ({ ...task, updatedAt: draggedNext.updatedAt })); const untouched = tasks.value.filter((task) => task.id !== draggedId && (task.folderId || "") !== (targetFolderId || "") && (task.folderId || "") !== (previousGroupId || "")); await persist(folders.value, [...untouched, ...sourceGroup, ...updatedGroup]); } const allFolders = computed(() => sortFoldersByOrder(folders.value)); const allTasks = computed(() => sortTasksByOrder(tasks.value)); return { folders, tasks, isLoaded, initFromStorage, listChildFolders, listTasks, getTask, createFolder: queueMutation(createFolder), renameFolder: queueMutation(renameFolder), deleteFolder: queueMutation(deleteFolder), saveTask: queueMutation(saveTask), renameTask: queueMutation(renameTask), duplicateTask: queueMutation(duplicateTask), deleteTask: queueMutation(deleteTask), moveTaskToFolder: queueMutation(moveTaskToFolder), moveFolderToFolder: queueMutation(moveFolderToFolder), reorderFolders: queueMutation(reorderFolders), reorderTasks: queueMutation(reorderTasks), allFolders, allTasks, }; });