export type DocumentMoveOrderDocument = { id: string; parent_id?: string | null; sort_order?: number | null; created_at?: string | null; }; export type DocumentMoveOrderPatch = { documentId: string; parentId: string | null; sortOrder: number; moved: boolean; }; export type DocumentMoveOrderPlan = { documentId: string; fromParentId: string | null; toParentId: string | null; requestedSortOrder: number; normalizedSortOrder: number; patches: DocumentMoveOrderPatch[]; }; export type DocumentMoveWriteOperation = DocumentMoveOrderPlan & { family: "tree"; schema: "mnote.tree.write_operation"; schemaVersion: 1; operation: "tree.subtree.move.write"; workspaceId: string; }; function normalizeParentId(value: string | null | undefined): string | null { const trimmed = typeof value === "string" ? value.trim() : ""; return trimmed.length > 0 ? trimmed : null; } function normalizeSortOrder(value: number | null | undefined): number { if (typeof value !== "number" || !Number.isFinite(value)) { return Number.MAX_SAFE_INTEGER; } return Math.floor(value); } function compareDocumentMoveOrder(a: DocumentMoveOrderDocument, b: DocumentMoveOrderDocument): number { const orderA = normalizeSortOrder(a.sort_order); const orderB = normalizeSortOrder(b.sort_order); if (orderA !== orderB) return orderA - orderB; const createdA = String(a.created_at ?? ""); const createdB = String(b.created_at ?? ""); if (createdA !== createdB) return createdA.localeCompare(createdB); return a.id.localeCompare(b.id); } function clampMoveIndex(raw: number, max: number): number { const value = Number.isFinite(raw) ? Math.floor(raw) : 0; if (value < 0) return 0; if (value > max) return max; return value; } function appendOrderPatches( patches: DocumentMoveOrderPatch[], ordered: DocumentMoveOrderDocument[], parentId: string | null, movedDocumentId: string, ) { ordered.forEach((document, index) => { const moved = document.id === movedDocumentId; if (normalizeParentId(document.parent_id) === parentId && normalizeSortOrder(document.sort_order) === index && !moved) { return; } patches.push({ documentId: document.id, parentId, sortOrder: index, moved, }); }); } export function buildDocumentMoveOrderPlanFromDocuments(input: { documents: readonly DocumentMoveOrderDocument[]; documentId: string; parentId: string | null; sortOrder: number; }): DocumentMoveOrderPlan { const source = input.documents.find((document) => document.id === input.documentId); if (!source) { throw new Error("源页面不存在或无权限"); } const fromParentId = normalizeParentId(source.parent_id); const toParentId = normalizeParentId(input.parentId); const siblingsByParent = new Map(); input.documents.forEach((document) => { const parentId = normalizeParentId(document.parent_id); const bucket = siblingsByParent.get(parentId); if (bucket) bucket.push(document); else siblingsByParent.set(parentId, [document]); }); siblingsByParent.forEach((siblings) => siblings.sort(compareDocumentMoveOrder)); const patches: DocumentMoveOrderPatch[] = []; let normalizedSortOrder = 0; if (fromParentId === toParentId) { const siblings = [...(siblingsByParent.get(toParentId) ?? [])].filter((document) => document.id !== input.documentId); normalizedSortOrder = clampMoveIndex(input.sortOrder, siblings.length); siblings.splice(normalizedSortOrder, 0, source); appendOrderPatches(patches, siblings, toParentId, input.documentId); } else { const oldSiblings = [...(siblingsByParent.get(fromParentId) ?? [])].filter((document) => document.id !== input.documentId); appendOrderPatches(patches, oldSiblings, fromParentId, input.documentId); const newSiblings = [...(siblingsByParent.get(toParentId) ?? [])].filter((document) => document.id !== input.documentId); normalizedSortOrder = clampMoveIndex(input.sortOrder, newSiblings.length); newSiblings.splice(normalizedSortOrder, 0, source); appendOrderPatches(patches, newSiblings, toParentId, input.documentId); } return { documentId: input.documentId, fromParentId, toParentId, requestedSortOrder: input.sortOrder, normalizedSortOrder, patches, }; } export function normalizeDocumentMoveOrderPlan(value: unknown): DocumentMoveOrderPlan | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } const record = value as Partial; if ( typeof record.documentId !== "string" || typeof record.requestedSortOrder !== "number" || typeof record.normalizedSortOrder !== "number" || !Array.isArray(record.patches) ) { return null; } return { documentId: record.documentId, fromParentId: normalizeParentId(record.fromParentId), toParentId: normalizeParentId(record.toParentId), requestedSortOrder: record.requestedSortOrder, normalizedSortOrder: record.normalizedSortOrder, patches: record.patches.map((patch) => { const item = patch as Partial; return { documentId: String(item.documentId ?? ""), parentId: normalizeParentId(item.parentId), sortOrder: typeof item.sortOrder === "number" && Number.isFinite(item.sortOrder) ? Math.floor(item.sortOrder) : -1, moved: Boolean(item.moved), }; }), }; } export function normalizeDocumentMoveWriteOperation(value: unknown): DocumentMoveWriteOperation | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } const record = value as Partial; if ( record.family !== "tree" || record.schema !== "mnote.tree.write_operation" || record.schemaVersion !== 1 || record.operation !== "tree.subtree.move.write" || typeof record.workspaceId !== "string" || !record.workspaceId.trim() ) { return null; } const normalizedPlan = normalizeDocumentMoveOrderPlan(value); if (!normalizedPlan) { return null; } return { family: "tree", schema: "mnote.tree.write_operation", schemaVersion: 1, operation: "tree.subtree.move.write", workspaceId: record.workspaceId.trim(), ...normalizedPlan, }; } export function assertDocumentMoveWriteOperationMatches(expected: unknown, actual: DocumentMoveOrderPlan): DocumentMoveOrderPlan { const normalizedExpected = normalizeDocumentMoveWriteOperation(expected); if (!normalizedExpected) { throw new Error("Rust move write operation 与 Convex 当前排序状态不一致"); } const { family: _family, schema: _schema, schemaVersion: _schemaVersion, operation: _operation, workspaceId: _workspaceId, ...expectedPlan } = normalizedExpected; if (JSON.stringify(expectedPlan) !== JSON.stringify(actual)) { throw new Error("Rust move write operation 与 Convex 当前排序状态不一致"); } return expectedPlan; }