joplin-plugin-gtd-calendar/src/Gtd/kanbanMoveHandler.ts

133 lines
4.3 KiB
TypeScript

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<string>,
config: ReturnType<typeof parseKanbanConfig>
): 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<MoveCardResult> {
const candidate = intent as Partial<MoveKanbanCardIntent> | 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.");
}
}