133 lines
4.3 KiB
TypeScript
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.");
|
|
}
|
|
}
|