feat(archive): completed cards move to a mirror of the boards folder
"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.
This commit is contained in:
1 parent
0cc4655f9f
commit
9c0e57e29b
12 files changed
+680
-122
No files matched your search
+122
-29
@@ -1,4 +1,4 @@
|
||||
import { findLane, laneInsertPoint, parseBoard } from "../model/board";
|
||||
import { bodyEnd, findLane, laneInsertPoint, parseBoard } from "../model/board";
|
||||
import type { Task } from "../model/types";
|
||||
|
||||
export class StaleTaskError extends Error {
|
||||
@@ -101,46 +101,139 @@ export function applyReorder(
|
||||
}
|
||||
}
|
||||
|
||||
const SETTINGS_OPEN = /^%%\s*kanban:settings\s*$/u;
|
||||
// ── The archive ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function applyArchive(
|
||||
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,
|
||||
archiveHeading: string,
|
||||
): number {
|
||||
migrate: boolean,
|
||||
): Harvest {
|
||||
const board = parseBoard(path, lines.join("\n"));
|
||||
const done = board.tasks.filter(
|
||||
(task) => !task.inArchive && (task.status === "x" || task.status === "-"),
|
||||
);
|
||||
if (done.length === 0) return 0;
|
||||
|
||||
const blocks: string[][] = [];
|
||||
const cards: Section[] = [];
|
||||
for (const task of [...done].reverse()) {
|
||||
const at = locate(lines, task);
|
||||
blocks.unshift(lines.splice(at, extent(lines, at, task)));
|
||||
cards.unshift({
|
||||
project: task.project,
|
||||
block: lines.splice(at, extent(lines, at, task)),
|
||||
});
|
||||
}
|
||||
|
||||
const heading = `## ${archiveHeading}`;
|
||||
let archiveAt = lines.findIndex((line) => line.trim() === heading);
|
||||
if (archiveAt === -1) {
|
||||
const settingsAt = lines.findIndex((line) => SETTINGS_OPEN.test(line));
|
||||
let tail = settingsAt === -1 ? lines.length : settingsAt;
|
||||
while (tail > 0 && lines[tail - 1].trim() === "") tail -= 1;
|
||||
lines.splice(tail, 0, "", "***", "", heading, "");
|
||||
archiveAt = tail + 3;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
let insertAt = archiveAt + 1;
|
||||
while (
|
||||
insertAt < lines.length &&
|
||||
!/^##\s/u.test(lines[insertAt]) &&
|
||||
!SETTINGS_OPEN.test(lines[insertAt])
|
||||
) {
|
||||
insertAt += 1;
|
||||
}
|
||||
while (insertAt > archiveAt + 1 && lines[insertAt - 1].trim() === "") {
|
||||
insertAt -= 1;
|
||||
}
|
||||
lines.splice(insertAt, 0, ...blocks.flat());
|
||||
return blocks.length;
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user