import { bodyEnd, findLane, laneInsertPoint, parseBoard } from "../model/board"; import type { Task } from "../model/types"; export class StaleTaskError extends Error { constructor(public readonly task: Task) { super(`line no longer present in ${task.path}`); this.name = "StaleTaskError"; } } export class MissingLaneError extends Error { constructor( public readonly path: string, public readonly project: string, ) { super(`no lane "${project}" in ${path}`); this.name = "MissingLaneError"; } } export function locate(lines: string[], task: Task): number { if (lines[task.line] === task.raw) return task.line; let best = -1; let bestDistance = Number.POSITIVE_INFINITY; for (let i = 0; i < lines.length; i += 1) { if (lines[i] !== task.raw) continue; const distance = Math.abs(i - task.line); if (distance < bestDistance) { best = i; bestDistance = distance; } } if (best === -1) throw new StaleTaskError(task); return best; } export function extent(lines: string[], at: number, task: Task): number { let count = 1; while (count <= task.body.length && lines[at + count] === task.body[count - 1]) { count += 1; } return count; } export function replaceLine(lines: string[], task: Task, text: string): void { const at = locate(lines, task); lines.splice(at, 1, ...text.split("\n")); } export function cutTask(lines: string[], task: Task): string[] { const at = locate(lines, task); const block = lines.splice(at, extent(lines, at, task)); while (lines[at] === "" && lines[at - 1] === "" && lines[at + 1] !== undefined) { lines.splice(at, 1); } return block; } export interface InsertTarget { project: string; index?: number; } export function insertBlock( lines: string[], path: string, target: InsertTarget, block: string[], ): void { const board = parseBoard(path, lines.join("\n")); const lane = findLane(board, target.project); if (!lane) throw new MissingLaneError(path, target.project); const siblings = board.tasks.filter( (task) => task.project === target.project && !task.inArchive, ); const index = target.index ?? siblings.length; const at = index < siblings.length ? siblings[index].line : laneInsertPoint(board, lane); lines.splice(at, 0, ...block); } export function applyReorder( lines: string[], inLane: Task[], ordered: Task[], ): void { if (inLane.length < 2 || ordered.length !== inLane.length) return; const slots = inLane.map((task) => { const at = locate(lines, task); return { at, size: extent(lines, at, task) }; }); const blocks = ordered.map((task) => { const at = locate(lines, task); return lines.slice(at, at + extent(lines, at, task)); }); for (let i = slots.length - 1; i >= 0; i -= 1) { lines.splice(slots[i].at, slots[i].size); } for (let i = 0; i < slots.length; i += 1) { lines.splice(slots[i].at, 0, ...blocks[i]); } } // ── The archive ────────────────────────────────────────────────────────────── const SEPARATOR = /^\*\*\*\s*$/u; /** Where loose cards land when a native archive section carries no heading. */ const LOOSE_LANE = "Archive"; /** A fresh mirror file: enough front matter for Kanban to open it as a board. */ export const ARCHIVE_SEED = ["---", "", "kanban-plugin: board", "", "---", ""]; export interface Section { project: string; block: string[]; } export interface Harvest { /** Completed cards taken off the board, each with the lane it sat in. */ cards: Section[]; /** The board's native `***` archive, lifted whole, lane by lane. */ legacy: Section[]; } function trimBlanks(block: string[]): string[] { let from = 0; let to = block.length; while (from < to && block[from].trim() === "") from += 1; while (to > from && block[to - 1].trim() === "") to -= 1; return block.slice(from, to); } /** Splits a run of board lines into its `## lane` sections, content and all. */ function sections(block: string[]): Section[] { const out: Section[] = []; let current: Section | null = null; for (const line of block) { const heading = /^##\s+(.*?)\s*$/u.exec(line); if (heading) { current = { project: heading[1], block: [] }; out.push(current); continue; } if (!current) { if (line.trim() === "") continue; current = { project: LOOSE_LANE, block: [] }; out.push(current); } current.block.push(line); } return out .map((section) => ({ ...section, block: trimBlanks(section.block) })) .filter((section) => section.block.length > 0); } /** * Takes the completed cards off a board, and with `migrate` the native archive * below the `***` separator as well - the mirror file is where history lives * now, so the separator goes with it rather than growing forever in place. * Mutates `lines`; what it returns is what the mirror has to be given. */ export function takeDone( lines: string[], path: string, migrate: boolean, ): Harvest { const board = parseBoard(path, lines.join("\n")); const done = board.tasks.filter( (task) => !task.inArchive && (task.status === "x" || task.status === "-"), ); const cards: Section[] = []; for (const task of [...done].reverse()) { const at = locate(lines, task); cards.unshift({ project: task.project, block: lines.splice(at, extent(lines, at, task)), }); } let legacy: Section[] = []; if (migrate) { const from = lines.findIndex((line) => SEPARATOR.test(line)); if (from !== -1) { const taken = lines.splice(from, bodyEnd(lines) - from); legacy = sections(taken.slice(1)); // Close the gap the section left, in the spacing the boards already use. let tail = from; while (tail > 0 && lines[tail - 1].trim() === "") tail -= 1; const pad = from < lines.length ? ["", ""] : [""]; lines.splice(tail, from - tail, ...pad); } } return { cards, legacy }; } /** Appends a block to a lane of a mirror file, opening the lane if it is new. */ function appendToLane( lines: string[], path: string, project: string, block: string[], ): void { const board = parseBoard(path, lines.join("\n"), true); const lane = findLane(board, project, true); if (lane) { lines.splice(laneInsertPoint(board, lane), 0, ...block); return; } let tail = bodyEnd(lines); while (tail > 0 && lines[tail - 1].trim() === "") tail -= 1; // The boards leave one blank line under the front matter and two between // lanes; a mirror nobody has to squint at reads the same way. const lead = board.lanes.length > 0 ? ["", ""] : [""]; lines.splice(tail, 0, ...lead, `## ${project}`, "", ...block, ""); } /** * Writes a harvest into its mirror file. Cards join the lane they came from, * so the mirror reads like the board it shadows; the old native archive keeps * its own headings, whatever they were called. */ export function fillArchive( lines: string[], path: string, harvest: Harvest, ): void { if (lines.every((line) => line.trim() === "")) { lines.splice(0, lines.length, ...ARCHIVE_SEED); } for (const { project, block } of [...harvest.cards, ...harvest.legacy]) { appendToLane(lines, path, project, block); } while (lines.length > 1 && lines.at(-1) === "" && lines.at(-2) === "") { lines.pop(); } }