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
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
archiveRoot,
|
||||
isArchivePath,
|
||||
toArchivePath,
|
||||
toBoardPath,
|
||||
} from "../src/model/archive";
|
||||
import { parseBoard, sphereOf } from "../src/model/board";
|
||||
|
||||
describe("archive paths", () => {
|
||||
it("mirrors a board into the archive folder", () => {
|
||||
expect(toArchivePath("Boards/work.md", "Boards", "archive")).toBe(
|
||||
"Boards/archive/work.md",
|
||||
);
|
||||
expect(toArchivePath("📆 доски/работа.md", "📆 доски", "архив")).toBe(
|
||||
"📆 доски/архив/работа.md",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the shape of a nested board", () => {
|
||||
expect(toArchivePath("Boards/team/ops.md", "Boards", "archive")).toBe(
|
||||
"Boards/archive/team/ops.md",
|
||||
);
|
||||
expect(toBoardPath("Boards/archive/team/ops.md", "Boards", "archive")).toBe(
|
||||
"Boards/team/ops.md",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a mirror file where it is", () => {
|
||||
const mirror = "Boards/archive/work.md";
|
||||
expect(toArchivePath(mirror, "Boards", "archive")).toBe(mirror);
|
||||
expect(isArchivePath(mirror, "Boards", "archive")).toBe(true);
|
||||
expect(isArchivePath("Boards/work.md", "Boards", "archive")).toBe(false);
|
||||
});
|
||||
|
||||
it("has nowhere to put a board outside the boards folder", () => {
|
||||
expect(toArchivePath("Notes/work.md", "Boards", "archive")).toBeNull();
|
||||
expect(toArchivePath("Boards/work.md", "Boards", "")).toBeNull();
|
||||
expect(archiveRoot("Boards", " ")).toBe("");
|
||||
});
|
||||
|
||||
it("works with no boards folder at all", () => {
|
||||
expect(archiveRoot("", "archive")).toBe("archive");
|
||||
expect(toArchivePath("work.md", "", "archive")).toBe("archive/work.md");
|
||||
expect(toBoardPath("archive/work.md", "", "archive")).toBe("work.md");
|
||||
});
|
||||
|
||||
it("shrugs at slashes around the folder names", () => {
|
||||
expect(toArchivePath("Boards/work.md", "/Boards/", "/archive/")).toBe(
|
||||
"Boards/archive/work.md",
|
||||
);
|
||||
});
|
||||
|
||||
it("a mirror file keeps the sphere of the board it shadows", () => {
|
||||
expect(sphereOf("Boards/archive/работа.md")).toBe("работа");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsing a mirror file", () => {
|
||||
const content = [
|
||||
"---",
|
||||
"",
|
||||
"kanban-plugin: board",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"## errands",
|
||||
"",
|
||||
"- [x] pick up the parcel ✅ 2026-07-30",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
it("marks everything archive, separator or no separator", () => {
|
||||
const board = parseBoard("Boards/archive/personal.md", content, true);
|
||||
expect(board.tasks).toHaveLength(1);
|
||||
expect(board.tasks[0].inArchive).toBe(true);
|
||||
expect(board.tasks[0].project).toBe("errands");
|
||||
expect(board.lanes.every((lane) => lane.inArchive)).toBe(true);
|
||||
});
|
||||
|
||||
it("reads as a plain board when nobody says it is a mirror", () => {
|
||||
const board = parseBoard("Boards/personal.md", content);
|
||||
expect(board.tasks[0].inArchive).toBe(false);
|
||||
});
|
||||
});
|
||||
+135
-44
@@ -5,14 +5,15 @@ import { parseBoard } from "../src/model/board";
|
||||
import { serializeTaskLine } from "../src/model/serialize";
|
||||
import type { Task } from "../src/model/types";
|
||||
import {
|
||||
applyArchive,
|
||||
applyReorder,
|
||||
fillArchive,
|
||||
cutTask,
|
||||
insertBlock,
|
||||
locate,
|
||||
MissingLaneError,
|
||||
replaceLine,
|
||||
StaleTaskError,
|
||||
takeDone,
|
||||
} from "../src/vault/edits";
|
||||
|
||||
const FIXTURES = join(import.meta.dirname, "fixtures");
|
||||
@@ -189,8 +190,31 @@ describe("applyReorder", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyArchive", () => {
|
||||
it("moves done cards under the archive heading", () => {
|
||||
/** Runs the archive over one board, mirror file and all. */
|
||||
function archived(name: string, migrate = true) {
|
||||
const { content } = board(name);
|
||||
const lines = content.split("\n");
|
||||
const harvest = takeDone(lines, name, migrate);
|
||||
const mirror: string[] = [];
|
||||
fillArchive(mirror, `archive/${name}`, harvest);
|
||||
return { harvest, board: lines.join("\n"), mirror: mirror.join("\n") };
|
||||
}
|
||||
|
||||
function markAllDone(name: string): string[] {
|
||||
const { content, board: parsed } = board(name);
|
||||
const lines = content.split("\n");
|
||||
for (const task of parsed.tasks.filter((item) => !item.inArchive)) {
|
||||
replaceLine(
|
||||
lines,
|
||||
task,
|
||||
serializeTaskLine({ ...task, status: "x", done: "2026-08-08" }),
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
describe("takeDone", () => {
|
||||
it("lifts a completed card out with the lane it sat in", () => {
|
||||
const { content, board: parsed } = board("personal.md");
|
||||
const lines = content.split("\n");
|
||||
const open = parsed.tasks.find((item) => !item.inArchive) as Task;
|
||||
@@ -200,73 +224,140 @@ describe("applyArchive", () => {
|
||||
serializeTaskLine({ ...open, status: "x", done: "2026-08-08" }),
|
||||
);
|
||||
|
||||
expect(applyArchive(lines, "personal.md", "Archive")).toBe(1);
|
||||
|
||||
const next = lines.join("\n");
|
||||
const reparsed = parseBoard("personal.md", next);
|
||||
const archived = reparsed.tasks.find((item) => item.text === open.text) as Task;
|
||||
expect(archived.inArchive).toBe(true);
|
||||
expect(archived.done).toBe("2026-08-08");
|
||||
expect(reparsed.tasks.filter((item) => item.text === open.text)).toHaveLength(1);
|
||||
expect(landmarks(next).settings).toBe(1);
|
||||
expect(landmarks(next).fences).toBe(1);
|
||||
const harvest = takeDone(lines, "personal.md", false);
|
||||
expect(harvest.cards).toHaveLength(1);
|
||||
expect(harvest.cards[0].project).toBe("errands");
|
||||
expect(harvest.cards[0].block[0]).toContain(open.text);
|
||||
expect(lines.join("\n")).not.toContain(open.text);
|
||||
});
|
||||
|
||||
it("is a no-op when nothing is done", () => {
|
||||
it("is a no-op when nothing is done and nothing was archived", () => {
|
||||
const { content } = board("home.md");
|
||||
const lines = content.split("\n");
|
||||
expect(applyArchive(lines, "home.md", "Archive")).toBe(0);
|
||||
const harvest = takeDone(lines, "home.md", true);
|
||||
expect(harvest.cards).toHaveLength(0);
|
||||
expect(harvest.legacy).toHaveLength(0);
|
||||
expect(lines.join("\n")).toBe(content);
|
||||
});
|
||||
|
||||
it("creates the fence and heading on a board without one", () => {
|
||||
const { content } = board("home.md");
|
||||
expect(content).not.toContain("## Archive");
|
||||
it("leaves the native archive alone without migrate", () => {
|
||||
const { content } = board("personal.md");
|
||||
const lines = content.split("\n");
|
||||
const parsed = parseBoard("home.md", content);
|
||||
for (const task of parsed.tasks) {
|
||||
replaceLine(
|
||||
lines,
|
||||
task,
|
||||
serializeTaskLine({ ...task, status: "x", done: "2026-08-08" }),
|
||||
);
|
||||
}
|
||||
expect(applyArchive(lines, "home.md", "Archive")).toBe(parsed.tasks.length);
|
||||
|
||||
takeDone(lines, "personal.md", false);
|
||||
const next = lines.join("\n");
|
||||
expect(next).toContain("***");
|
||||
expect(next).toContain("## Archive");
|
||||
expect(next).toContain("%% kanban:settings");
|
||||
expect(parseBoard("home.md", next).tasks.every((t) => t.inArchive)).toBe(true);
|
||||
expect(next).toContain("pick up the parcel");
|
||||
});
|
||||
|
||||
it("takes the native archive whole, separator and all", () => {
|
||||
const { board: parsed } = board("personal.md");
|
||||
const { harvest, board: next } = archived("personal.md");
|
||||
|
||||
expect(harvest.legacy).toHaveLength(1);
|
||||
expect(harvest.legacy[0].project).toBe("Archive");
|
||||
const wasArchived = parsed.tasks.filter((item) => item.inArchive);
|
||||
expect(harvest.legacy[0].block).toHaveLength(wasArchived.length);
|
||||
|
||||
expect(next).not.toContain("***");
|
||||
expect(next).not.toContain("## Archive");
|
||||
expect(next).not.toContain("pick up the parcel");
|
||||
// The lanes that were live stay untouched, settings block included.
|
||||
expect(landmarks(next).settings).toBe(1);
|
||||
expect(landmarks(next).headings).toEqual(["## errands", "## someday"]);
|
||||
expect(next).toContain("- [ ] renew the gym membership");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fillArchive", () => {
|
||||
it("writes a Kanban board with the lanes the cards came from", () => {
|
||||
const lines = markAllDone("work.md");
|
||||
const harvest = takeDone(lines, "work.md", true);
|
||||
const mirror: string[] = [];
|
||||
fillArchive(mirror, "archive/work.md", harvest);
|
||||
const next = mirror.join("\n");
|
||||
|
||||
expect(next.startsWith("---\n\nkanban-plugin: board\n\n---\n")).toBe(true);
|
||||
expect(next).toContain("## website");
|
||||
expect(next).toContain("## notes");
|
||||
// The old native archive keeps its own heading.
|
||||
expect(next).toContain("## Archive");
|
||||
expect(next).toContain("- [x] ship the pricing page 📅 2026-07-20 ✅ 2026-07-21");
|
||||
|
||||
const parsed = parseBoard("archive/work.md", next, true);
|
||||
expect(parsed.tasks.every((task) => task.inArchive)).toBe(true);
|
||||
expect(parsed.tasks.map((task) => task.project)).toContain("website");
|
||||
});
|
||||
|
||||
it("keeps a card's indented body with it", () => {
|
||||
const lines = markAllDone("work.md");
|
||||
const harvest = takeDone(lines, "work.md", false);
|
||||
const mirror: string[] = [];
|
||||
fillArchive(mirror, "archive/work.md", harvest);
|
||||
const next = mirror.join("\n");
|
||||
expect(next).toContain("\t- shorter intro");
|
||||
expect(next).toContain("\tfollow-up: check the analytics bundle size");
|
||||
});
|
||||
|
||||
it("appends to a mirror that already holds history", () => {
|
||||
const first = markAllDone("personal.md");
|
||||
const harvest = takeDone(first, "personal.md", true);
|
||||
const mirror: string[] = [];
|
||||
fillArchive(mirror, "archive/personal.md", harvest);
|
||||
const before = parseBoard("archive/personal.md", mirror.join("\n"), true);
|
||||
|
||||
fillArchive(mirror, "archive/personal.md", {
|
||||
cards: [{ project: "errands", block: ["- [x] a later one ✅ 2026-09-01"] }],
|
||||
legacy: [],
|
||||
});
|
||||
const after = parseBoard("archive/personal.md", mirror.join("\n"), true);
|
||||
|
||||
expect(after.tasks).toHaveLength(before.tasks.length + 1);
|
||||
expect(after.lanes).toHaveLength(before.lanes.length);
|
||||
const errands = after.tasks.filter((task) => task.project === "errands");
|
||||
expect(errands.at(-1)?.text).toBe("a later one");
|
||||
});
|
||||
});
|
||||
|
||||
describe("every board survives a full archive pass", () => {
|
||||
for (const name of readdirSync(FIXTURES).filter((n) => n.endsWith(".md"))) {
|
||||
it(`${name}: landmarks intact, no task lost`, () => {
|
||||
it(`${name}: nothing lost, the board keeps its shape`, () => {
|
||||
const { content, board: parsed } = board(name);
|
||||
const lines = content.split("\n");
|
||||
for (const task of parsed.tasks.filter((item) => !item.inArchive)) {
|
||||
replaceLine(
|
||||
lines,
|
||||
task,
|
||||
serializeTaskLine({ ...task, status: "x", done: "2026-08-08" }),
|
||||
);
|
||||
}
|
||||
applyArchive(lines, name, "Archive");
|
||||
const lines = markAllDone(name);
|
||||
const harvest = takeDone(lines, name, true);
|
||||
const next = lines.join("\n");
|
||||
const mirror: string[] = [];
|
||||
fillArchive(mirror, `archive/${name}`, harvest);
|
||||
|
||||
// The board keeps its front matter, its settings and its live lanes,
|
||||
// and holds no task at all any more.
|
||||
const reparsed = parseBoard(name, next);
|
||||
expect(reparsed.tasks).toHaveLength(parsed.tasks.length);
|
||||
expect(reparsed.tasks.every((item) => item.inArchive)).toBe(true);
|
||||
|
||||
expect(reparsed.tasks).toHaveLength(0);
|
||||
const before = landmarks(content);
|
||||
const after = landmarks(next);
|
||||
expect(after.settings).toBe(before.settings);
|
||||
expect(after.frontmatter).toBe(before.frontmatter);
|
||||
for (const heading of before.headings) {
|
||||
expect(after.fences).toBe(0);
|
||||
for (const heading of before.headings.filter((h) => h !== "## Archive")) {
|
||||
expect(after.headings, `${heading} must survive`).toContain(heading);
|
||||
}
|
||||
|
||||
// Every task that was on the board is now in the mirror, once.
|
||||
const mirrored = parseBoard(`archive/${name}`, mirror.join("\n"), true);
|
||||
expect(mirrored.tasks).toHaveLength(parsed.tasks.length);
|
||||
expect(mirrored.tasks.every((task) => task.inArchive)).toBe(true);
|
||||
expect(mirrored.tasks.map((task) => task.raw).sort()).toEqual(
|
||||
[
|
||||
...parsed.tasks.filter((task) => task.inArchive).map((t) => t.raw),
|
||||
...markedDone(parsed.tasks.filter((task) => !task.inArchive)),
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function markedDone(tasks: Task[]): string[] {
|
||||
return tasks.map((task) =>
|
||||
serializeTaskLine({ ...task, status: "x", done: "2026-08-08" }),
|
||||
);
|
||||
}
|
||||
+53
-1
@@ -9,6 +9,7 @@ import { JSDOM } from "jsdom";
|
||||
const root = new URL("..", import.meta.url).pathname;
|
||||
const fixtures = join(root, "tests/fixtures");
|
||||
const FOLDER = "Boards";
|
||||
const ARCHIVE = "archive";
|
||||
|
||||
const out = join(mkdtempSync(join(tmpdir(), "bcal-")), "bundle.mjs");
|
||||
writeFileSync(
|
||||
@@ -96,6 +97,23 @@ const files = new Map(
|
||||
readFileSync(join(fixtures, name), "utf8"),
|
||||
]),
|
||||
);
|
||||
// A mirror file, the way the archive command leaves one behind.
|
||||
const MIRROR = `${FOLDER}/${ARCHIVE}/personal.md`;
|
||||
files.set(
|
||||
MIRROR,
|
||||
[
|
||||
"---",
|
||||
"",
|
||||
"kanban-plugin: board",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"## errands",
|
||||
"",
|
||||
"- [x] hang the shelf 📅 2026-07-02 ✅ 2026-07-03",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
const handles = new Map([...files.keys()].map((path) => [path, new TFile(path)]));
|
||||
|
||||
const writes = [];
|
||||
@@ -122,7 +140,7 @@ const app = {
|
||||
|
||||
const settings = {
|
||||
boardsFolder: FOLDER,
|
||||
archiveHeading: "Archive",
|
||||
archiveFolder: ARCHIVE,
|
||||
view: "list",
|
||||
groupBy: "sphere",
|
||||
listSort: "due",
|
||||
@@ -153,6 +171,11 @@ await store.reloadAll();
|
||||
const outsideArchive = 15;
|
||||
check("parsed every board", store.spheres.length === names.length, `${store.spheres.length}`);
|
||||
check(`${outsideArchive} tasks outside the archive`, store.all.length === outsideArchive, `got ${store.all.length}`);
|
||||
check(
|
||||
"the mirror file is not a sphere of its own",
|
||||
store.spheres.every((sphere) => !sphere.path.includes(`/${ARCHIVE}/`)),
|
||||
store.spheres.map((sphere) => sphere.path).join(", "),
|
||||
);
|
||||
|
||||
const target = dom.window.document.body;
|
||||
const instance = mount(App, {
|
||||
@@ -181,10 +204,39 @@ flushSync();
|
||||
await settle();
|
||||
check("archive appears when both toggles are on", text().includes("pick up the parcel"));
|
||||
check("archived task carries the Archive badge", text().includes("Archive"));
|
||||
check("the archive folder is rendered too", text().includes("hang the shelf"));
|
||||
|
||||
const mirrored = store.all.find((task) => task.text === "hang the shelf");
|
||||
check("a mirrored task is archive", mirrored?.inArchive === true);
|
||||
check(
|
||||
"it keeps the lane it was archived from",
|
||||
mirrored?.project === "errands",
|
||||
`${mirrored?.project}`,
|
||||
);
|
||||
check(
|
||||
"it groups under the board it mirrors, not beside it",
|
||||
store.groups.filter((group) => group.label === "personal").length === 1,
|
||||
store.groups.map((group) => group.label).join(", "),
|
||||
);
|
||||
|
||||
// The rail knows nothing of the mirror file, so archived cards have to ride
|
||||
// with the sphere they came from.
|
||||
store.soloSphere(`${FOLDER}/personal.md`);
|
||||
flushSync();
|
||||
await settle();
|
||||
check("the rail keeps mirrored cards with their sphere", text().includes("hang the shelf"));
|
||||
store.soloSphere(`${FOLDER}/work.md`);
|
||||
flushSync();
|
||||
await settle();
|
||||
check("another sphere leaves them out", !text().includes("hang the shelf"));
|
||||
store.filterAll();
|
||||
flushSync();
|
||||
await settle();
|
||||
|
||||
store.includeArchive = false;
|
||||
flushSync();
|
||||
check("archive hidden again", !text().includes("pick up the parcel"));
|
||||
check("the archive folder is hidden with it", !text().includes("hang the shelf"));
|
||||
store.showDone = false;
|
||||
flushSync();
|
||||
|
||||
|
||||
Reference in new issue
Block a user