const YAML = require("yaml"); import extractGtdBlock from "./gtdBlock"; import resolveScopedFolderIds from "./folderScope"; import { moveKanbanItem } from "./kanbanMutation"; import { isKanbanDestination } from "./moveProtocol"; import parseKanbanConfig from "./parseKanbanConfig"; import resolveNotebook from "./resolveNotebook"; import { DataAdapter, MoveCardResult, MoveKanbanCardIntent, MutationAdapter, RawFolder, RawNote, } from "./types"; export interface KanbanMoveHost { id: string; parentId: string; } function stale(viewInstanceId: string, message: string): MoveCardResult { return { status: "stale", viewInstanceId, message }; } function error(viewInstanceId: string, message: string): MoveCardResult { return { status: "error", viewInstanceId, message }; } function validId(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } class StaleMoveError extends Error {} function validateFreshEligibility( note: RawNote, host: KanbanMoveHost, folderIds: Set, config: ReturnType ): void { if (note.id === host.id) throw new StaleMoveError("The dashboard note cannot be moved."); if (!folderIds.has(note.parent_id)) throw new StaleMoveError("The card is no longer in scope."); if (!note.is_todo && config.notes === "none") { throw new StaleMoveError("The card is no longer admitted by notes."); } const block = extractGtdBlock(note.body); if (note.is_todo) { if (config.todos === "none") throw new StaleMoveError("The card is no longer admitted by todos."); if (config.todos === "gtd-only" && !block.found) { throw new StaleMoveError("The to-do no longer contains a gtd block."); } } else if (!block.found) { throw new StaleMoveError("The note no longer contains a gtd block."); } } /** Validate untrusted webview intent against fresh host, scope, and item state. */ export async function validateAndMoveKanbanCard( intent: unknown, host: KanbanMoveHost | null, readAdapter: DataAdapter, mutationAdapter: MutationAdapter, now: () => number = Date.now, logDiagnostic: (error: unknown) => void = () => undefined ): Promise { const candidate = intent as Partial | null; const viewInstanceId = validId(candidate?.viewInstanceId) ? candidate.viewInstanceId : "unknown"; const allowedKeys = new Set([ "type", "view", "hostNoteId", "cardId", "destination", "rawConfig", "viewInstanceId", ]); if ( !candidate || Array.isArray(candidate) || Object.keys(candidate).some((key) => !allowedKeys.has(key)) || candidate.type !== "moveKanbanCard" || candidate.view !== "kanban" || !validId(candidate.hostNoteId) || !validId(candidate.cardId) || !validId(candidate.viewInstanceId) || typeof candidate.rawConfig !== "string" || !isKanbanDestination(candidate.destination) ) { return error(viewInstanceId, "Invalid kanban move request."); } if (!host || candidate.hostNoteId !== host.id) { return stale(viewInstanceId, "The rendered dashboard is no longer current."); } let parsed: unknown; try { parsed = YAML.parse(candidate.rawConfig || "") || {}; } catch (_parseError) { return stale(viewInstanceId, "The kanban configuration is no longer valid."); } const config = parseKanbanConfig(parsed); if (!config.editable) { return error(viewInstanceId, "This kanban is read-only."); } try { const folders = await readAdapter.getFolders(); let scanFolderId = host.parentId; if (!config.scopeAll && config.notebook) { scanFolderId = resolveNotebook(folders, config.notebook, host.parentId).folderId; } const folderIds = new Set( resolveScopedFolderIds(folders, scanFolderId, config.scopeDepth, config.scopeAll) ); const outcome = await moveKanbanItem( mutationAdapter, candidate.cardId, candidate.destination, config, now, (note) => validateFreshEligibility(note, host, folderIds, config) ); return { status: "success", viewInstanceId, changed: outcome.changed }; } catch (moveError) { if (moveError instanceof StaleMoveError) { return stale(viewInstanceId, moveError.message); } if (moveError instanceof Error && moveError.message === "The card no longer exists.") { return stale(viewInstanceId, moveError.message); } logDiagnostic(moveError); return error(viewInstanceId, "Could not apply the move. Refresh and try again."); } }