"Archive completed tasks" empties every board of what is finished into a twin of the boards folder - `Boards/work.md` into `Boards/archive/work.md` - itself a Kanban board with the same lanes, so the history opens like any other board and the live one stays short. The same pass lifts a board's native archive (everything below the `***` separator) into that mirror, so finished work is not left sitting in two places. Archived tasks render beside live ones when "Include the archive" and "Show completed" are both on, from the mirrors and from any `***` section still in a board. They carry an Archive badge instead of a project chip, ride with the sphere they came from in the rail, and refuse drops.
240 lines
7.3 KiB
TypeScript
240 lines
7.3 KiB
TypeScript
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();
|
|
}
|
|
}
|