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:
hh committed 2026-09-05 00:21:36 +02:00
1 parent 0cc4655f9f
commit 9c0e57e29b
12 files changed
+690 -132

No files matched your search

+17
View File
@@ -5,6 +5,23 @@ Calendar and task list for Obsidian Kanban+Tasks
Sphere - md-file in Kanban boards directory. Project - h2 inside the file. Sphere - md-file in Kanban boards directory. Project - h2 inside the file.
## Archiving
"Archive completed tasks" empties every board of what is finished with it, into
a mirror of the boards folder: `Boards/work.md` archives into
`Boards/archive/work.md`, itself a Kanban board with the same lanes, so the
history opens normally and the board stays short. The same pass lifts a board's
old native archive - everything below the `***` separator - into that mirror,
headings and all, so nothing is left behind in two places.
Set "Archive folder" before the first run: renaming it later leaves the mirrors
already written behind, where they read as ordinary boards.
Archived tasks render alongside live ones when both "Include the archive" and
"Show completed" are on - from the mirror files and from any `***` section still
sitting in a board. They carry an Archive badge instead of a project chip, ride
with the sphere they came from in the rail, and are not drop targets.
## Build and install ## Build and install
```bash ```bash
+22 -11
View File
@@ -4,9 +4,10 @@ import {
DEFAULT_SETTINGS, DEFAULT_SETTINGS,
} from "./settings"; } from "./settings";
import type { BeaverCalendarSettings } from "./settings"; import type { BeaverCalendarSettings } from "./settings";
import { archiveRoot, toArchivePath } from "./model/archive";
import { BoardStore } from "./vault/store.svelte"; import { BoardStore } from "./vault/store.svelte";
import { transaction } from "./vault/history"; import { transaction } from "./vault/history";
import { archiveDone } from "./vault/mutate"; import { archiveBoard } from "./vault/mutate";
import { BoardsView, VIEW_TYPE_BOARDS } from "./view"; import { BoardsView, VIEW_TYPE_BOARDS } from "./view";
export default class BeaverCalendarPlugin extends Plugin { export default class BeaverCalendarPlugin extends Plugin {
@@ -90,34 +91,44 @@ export default class BeaverCalendarPlugin extends Plugin {
private async archiveAll(): Promise<void> { private async archiveAll(): Promise<void> {
const folder = this.settings.boardsFolder; const folder = this.settings.boardsFolder;
if (!folder) { const archive = this.settings.archiveFolder;
if (!folder || !archive) {
new Notice( new Notice(
"Beaver Calendar: no boards folder is set, archiving cancelled", "Beaver Calendar: the boards and archive folders must both be set",
6000, 6000,
); );
return; return;
} }
// The mirror is under the boards folder, so it would sweep itself.
const root = `${archiveRoot(folder, archive)}/`;
const files = this.app.vault const files = this.app.vault
.getMarkdownFiles() .getMarkdownFiles()
.filter((file) => file.path.startsWith(`${folder}/`)); .filter(
(file) =>
file.path.startsWith(`${folder}/`) && !file.path.startsWith(root),
);
if (files.length === 0) { if (files.length === 0) {
new Notice("Beaver Calendar: no boards found"); new Notice("Beaver Calendar: no boards found");
return; return;
} }
let moved = 0; let moved = 0;
let migrated = 0;
// One undo step for the whole sweep, however many boards it touched. // One undo step for the whole sweep, however many boards it touched.
await transaction("the archive", async () => { await transaction("the archive", async () => {
for (const file of files) { for (const file of files) {
moved += await archiveDone( const mirror = toArchivePath(file.path, folder, archive);
this.app, if (!mirror) continue;
file.path, const outcome = await archiveBoard(this.app, file.path, mirror, true);
this.settings.archiveHeading, moved += outcome.moved;
); if (outcome.migrated) migrated += 1;
} }
}); });
const parts: string[] = [];
if (moved > 0) parts.push(`archived ${moved} task(s)`);
if (migrated > 0) parts.push(`moved the old archive of ${migrated} board(s)`);
new Notice( new Notice(
moved > 0 parts.length > 0
? `Beaver Calendar: archived ${moved} task(s)` ? `Beaver Calendar: ${parts.join(", ")}`
: "Beaver Calendar: nothing to archive", : "Beaver Calendar: nothing to archive",
); );
} }
+58
View File
@@ -0,0 +1,58 @@
/**
* Completed cards leave the board for a mirror of the boards folder: a board
* at `Boards/work.md` archives into `Boards/archive/work.md`, which is itself
* a Kanban board, so the history opens like any other one. The mirror keeps
* the boards folder's shape, so a nested board archives to a nested twin.
*/
function strip(value: string): string {
return value.trim().replace(/^\/+/u, "").replace(/\/+$/u, "");
}
/** The folder the mirror lives in, or `""` when there is no archive folder. */
export function archiveRoot(boardsFolder: string, archiveFolder: string): string {
const name = strip(archiveFolder);
if (!name) return "";
const boards = strip(boardsFolder);
return boards ? `${boards}/${name}` : name;
}
export function isArchivePath(
path: string,
boardsFolder: string,
archiveFolder: string,
): boolean {
const root = archiveRoot(boardsFolder, archiveFolder);
return root !== "" && path.startsWith(`${root}/`);
}
/**
* The archive twin of a board, or `null` when the board is outside the boards
* folder and so has nowhere to mirror to. An archive file is its own twin.
*/
export function toArchivePath(
path: string,
boardsFolder: string,
archiveFolder: string,
): string | null {
const root = archiveRoot(boardsFolder, archiveFolder);
if (!root) return null;
if (path.startsWith(`${root}/`)) return path;
const boards = strip(boardsFolder);
if (!boards) return `${root}/${path}`;
if (!path.startsWith(`${boards}/`)) return null;
return `${root}/${path.slice(boards.length + 1)}`;
}
/** The board an archive file mirrors; any other path is its own board. */
export function toBoardPath(
path: string,
boardsFolder: string,
archiveFolder: string,
): string {
const root = archiveRoot(boardsFolder, archiveFolder);
if (!root || !path.startsWith(`${root}/`)) return path;
const boards = strip(boardsFolder);
const rest = path.slice(root.length + 1);
return boards ? `${boards}/${rest}` : rest;
}
+17 -2
View File
@@ -14,7 +14,16 @@ function isCardBody(line: string): boolean {
return /^\t/u.test(line); return /^\t/u.test(line);
} }
export function parseBoard(path: string, content: string): Board { /**
* `archived` marks a whole file as archive - the mirror files under the
* archive folder hold nothing else, so every lane in them is one, separator
* or no separator.
*/
export function parseBoard(
path: string,
content: string,
archived = false,
): Board {
const lines = content.split("\n"); const lines = content.split("\n");
const sphere = sphereOf(path); const sphere = sphereOf(path);
const lanes: Lane[] = []; const lanes: Lane[] = [];
@@ -35,7 +44,7 @@ export function parseBoard(path: string, content: string): Board {
} }
} }
let inArchive = false; let inArchive = archived;
let current: Lane | null = null; let current: Lane | null = null;
const closeLane = (end: number): void => { const closeLane = (end: number): void => {
@@ -117,6 +126,12 @@ export function findLane(
); );
} }
/** Where a heading-less board ends: before its Kanban settings block. */
export function bodyEnd(lines: string[]): number {
const settingsAt = lines.findIndex((line) => SETTINGS_OPEN.test(line));
return settingsAt === -1 ? lines.length : settingsAt;
}
export function laneInsertPoint(board: Board, lane: Lane): number { export function laneInsertPoint(board: Board, lane: Lane): number {
let end = lane.end; let end = lane.end;
while (end > lane.start && board.lines[end - 1].trim() === "") { while (end > lane.start && board.lines[end - 1].trim() === "") {
+18 -10
View File
@@ -8,7 +8,7 @@ export type SortBy = "manual" | "priority" | "due";
export interface BeaverCalendarSettings { export interface BeaverCalendarSettings {
boardsFolder: string; boardsFolder: string;
archiveHeading: string; archiveFolder: string;
view: ViewMode; view: ViewMode;
groupBy: GroupBy; groupBy: GroupBy;
listSort: SortBy; listSort: SortBy;
@@ -24,7 +24,7 @@ export interface BeaverCalendarSettings {
export const DEFAULT_SETTINGS: BeaverCalendarSettings = { export const DEFAULT_SETTINGS: BeaverCalendarSettings = {
boardsFolder: "Boards", boardsFolder: "Boards",
archiveHeading: "Archive", archiveFolder: "archive",
view: "list", view: "list",
groupBy: "sphere", groupBy: "sphere",
listSort: "due", listSort: "due",
@@ -70,25 +70,33 @@ export class BeaverCalendarSettingsTab extends PluginSettingTab {
); );
new Setting(containerEl) new Setting(containerEl)
.setName("Archive heading") .setName("Archive folder")
.setDesc("The lane completed tasks are moved to by the archive command.") .setDesc(
"A subfolder mirroring the boards folder, where the archive command " +
"puts completed tasks: a board at Boards/work.md archives into " +
"Boards/archive/work.md, itself a Kanban board with the same lanes. " +
"The command also lifts a board's old *** archive section in there.",
)
.addText((text) => .addText((text) =>
text text
.setPlaceholder("Archive") .setPlaceholder("archive")
.setValue(this.plugin.settings.archiveHeading) .setValue(this.plugin.settings.archiveFolder)
.onChange(async (value) => { .onChange(async (value) => {
this.plugin.settings.archiveHeading = value.trim() || "Archive"; this.plugin.settings.archiveFolder =
normalizeFolder(value) || "archive";
await this.plugin.saveSettings(); await this.plugin.saveSettings();
await this.plugin.store.reloadAll();
}), }),
); );
new Setting(containerEl) new Setting(containerEl)
.setName("Include the archive") .setName("Include the archive")
.setDesc( .setDesc(
"Widen the show-completed toggle to reach tasks below the archive " + "Widen the show-completed toggle to reach archived tasks - both the " +
"archive folder and whatever still sits below a board's *** " +
"separator. With this off, show-completed only reveals completed " + "separator. With this off, show-completed only reveals completed " +
"tasks still sitting in their lanes. Archived tasks carry an " + "tasks still in their lanes. Archived tasks carry an Archive badge " +
"Archive badge instead of a project one.", "instead of a project one.",
) )
.addToggle((toggle) => .addToggle((toggle) =>
toggle toggle
+3
View File
@@ -65,6 +65,9 @@ export async function handleDrop(
if (id === dragged.id) return; if (id === dragged.id) return;
const onto = store.all.find((task) => task.id === id); const onto = store.all.find((task) => task.id === id);
if (!onto) return; if (!onto) return;
// Archived cards are read-only ground: they live in the mirror file, and
// dropping onto one has no lane to mean.
if (onto.inArchive) return;
if (onto.path === dragged.path && onto.project === dragged.project) { if (onto.path === dragged.path && onto.project === dragged.project) {
const board = store.boardFor(onto.path); const board = store.boardFor(onto.path);
+119 -26
View File
@@ -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"; import type { Task } from "../model/types";
export class StaleTaskError extends Error { 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[], lines: string[],
path: string, path: string,
archiveHeading: string, migrate: boolean,
): number { ): Harvest {
const board = parseBoard(path, lines.join("\n")); const board = parseBoard(path, lines.join("\n"));
const done = board.tasks.filter( const done = board.tasks.filter(
(task) => !task.inArchive && (task.status === "x" || task.status === "-"), (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()) { for (const task of [...done].reverse()) {
const at = locate(lines, task); 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 legacy: Section[] = [];
let archiveAt = lines.findIndex((line) => line.trim() === heading); if (migrate) {
if (archiveAt === -1) { const from = lines.findIndex((line) => SEPARATOR.test(line));
const settingsAt = lines.findIndex((line) => SETTINGS_OPEN.test(line)); if (from !== -1) {
let tail = settingsAt === -1 ? lines.length : settingsAt; 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; while (tail > 0 && lines[tail - 1].trim() === "") tail -= 1;
lines.splice(tail, 0, "", "***", "", heading, ""); const pad = from < lines.length ? ["", ""] : [""];
archiveAt = tail + 3; lines.splice(tail, from - tail, ...pad);
}
} }
let insertAt = archiveAt + 1; return { cards, legacy };
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; /** 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();
} }
lines.splice(insertAt, 0, ...blocks.flat());
return blocks.length;
} }
+87 -8
View File
@@ -7,15 +7,17 @@ import { tasksApi, warnMissingTasks } from "../tasks-api";
import { today } from "../lib/due"; import { today } from "../lib/due";
import { record, transaction } from "./history"; import { record, transaction } from "./history";
import { import {
applyArchive,
applyReorder, applyReorder,
ARCHIVE_SEED,
cutTask, cutTask,
extent, extent,
fillArchive,
insertBlock, insertBlock,
locate, locate,
MissingLaneError, MissingLaneError,
replaceLine, replaceLine,
StaleTaskError, StaleTaskError,
takeDone,
} from "./edits"; } from "./edits";
async function edit( async function edit(
@@ -266,14 +268,91 @@ export async function appendRawTask(
}); });
} }
export async function archiveDone( /** Replaces a whole file, but only if it still reads the way it was read. */
async function overwrite(
app: App, app: App,
path: string, path: string,
archiveHeading: string, before: string,
): Promise<number> { after: string,
let moved = 0; ): Promise<boolean> {
await edit(app, path, (lines) => { const file = app.vault.getAbstractFileByPath(path);
moved = applyArchive(lines, path, archiveHeading); if (!(file instanceof TFile)) return false;
let ok = true;
await app.vault.process(file, (data) => {
if (data !== before) {
ok = false;
return data;
}
record(path, data, after);
return after;
}); });
return moved; return ok;
}
/** The mirror file, made along with any folder it needs. */
async function openArchive(app: App, path: string): Promise<TFile | null> {
const existing = app.vault.getAbstractFileByPath(path);
if (existing instanceof TFile) return existing;
const parts = path.split("/");
parts.pop();
let folder = "";
for (const part of parts) {
folder = folder ? `${folder}/${part}` : part;
if (app.vault.getAbstractFileByPath(folder)) continue;
try {
await app.vault.createFolder(folder);
} catch {
/* another writer got there first, which is all we wanted */
}
}
try {
return await app.vault.create(path, ARCHIVE_SEED.join("\n"));
} catch {
new Notice(`Beaver Calendar: could not create ${path}`, 6000);
return null;
}
}
export interface ArchiveOutcome {
/** Completed cards moved out of the board. */
moved: number;
/** Whether the board's native `***` archive went with them. */
migrated: boolean;
}
/**
* Empties a board of what is finished with it: the completed cards, and with
* `migrate` the native archive section too. The mirror is written first, so a
* board only loses lines once the file that is to keep them has them.
*/
export async function archiveBoard(
app: App,
path: string,
archivePath: string,
migrate: boolean,
): Promise<ArchiveOutcome> {
const nothing: ArchiveOutcome = { moved: 0, migrated: false };
const source = app.vault.getAbstractFileByPath(path);
if (!(source instanceof TFile)) return nothing;
const before = await app.vault.read(source);
const lines = before.split("\n");
const harvest = takeDone(lines, path, migrate);
if (harvest.cards.length === 0 && harvest.legacy.length === 0) return nothing;
if (!(await openArchive(app, archivePath))) return nothing;
const pasted = await edit(app, archivePath, (into) => {
fillArchive(into, archivePath, harvest);
});
if (!pasted) return nothing;
if (!(await overwrite(app, path, before, lines.join("\n")))) {
new Notice(
`Beaver Calendar: ${path} changed mid-archive, undo to put it back`,
6000,
);
return nothing;
}
return { moved: harvest.cards.length, migrated: harvest.legacy.length > 0 };
} }
+62 -16
View File
@@ -1,5 +1,6 @@
import { TFile } from "obsidian"; import { TFile } from "obsidian";
import type { App } from "obsidian"; import type { App } from "obsidian";
import { isArchivePath, toBoardPath } from "../model/archive";
import { parseBoard, sphereOf } from "../model/board"; import { parseBoard, sphereOf } from "../model/board";
import { anchorDate, isOpen, STATUS_ORDER } from "../model/types"; import { anchorDate, isOpen, STATUS_ORDER } from "../model/types";
import type { Board, Status, Task } from "../model/types"; import type { Board, Status, Task } from "../model/types";
@@ -91,6 +92,9 @@ function compareTasks(sortBy: SortBy, a: Task, b: Task): number {
export class BoardStore { export class BoardStore {
private readonly boards = new Map<string, Board>(); private readonly boards = new Map<string, Board>();
/** The mirror files under the archive folder, kept apart from the boards
* they shadow: they are history, not lanes to write into. */
private readonly archives = new Map<string, Board>();
private reloadTimer: number | null = null; private reloadTimer: number | null = null;
private readonly pending = new Set<string>(); private readonly pending = new Set<string>();
@@ -228,13 +232,31 @@ export class BoardStore {
} }
private inScope(path: string): boolean { private inScope(path: string): boolean {
if (!path.endsWith(".md")) return false;
const folder = this.settings.boardsFolder; const folder = this.settings.boardsFolder;
if (!folder) return path.endsWith(".md"); return !folder || path.startsWith(`${folder}/`);
return path.startsWith(`${folder}/`) && path.endsWith(".md"); }
private isArchive(path: string): boolean {
return isArchivePath(
path,
this.settings.boardsFolder,
this.settings.archiveFolder,
);
}
/** The board a path belongs to: a mirror file answers with what it shadows. */
private boardPath(path: string): string {
return toBoardPath(
path,
this.settings.boardsFolder,
this.settings.archiveFolder,
);
} }
async reloadAll(): Promise<void> { async reloadAll(): Promise<void> {
this.boards.clear(); this.boards.clear();
this.archives.clear();
const files = this.app.vault const files = this.app.vault
.getMarkdownFiles() .getMarkdownFiles()
.filter((file) => this.inScope(file.path)); .filter((file) => this.inScope(file.path));
@@ -244,7 +266,14 @@ export class BoardStore {
private async load(file: TFile): Promise<void> { private async load(file: TFile): Promise<void> {
const content = await this.app.vault.cachedRead(file); const content = await this.app.vault.cachedRead(file);
this.boards.set(file.path, parseBoard(file.path, content)); this.keep(file.path, content);
}
private keep(path: string, content: string): void {
const archived = this.isArchive(path);
const into = archived ? this.archives : this.boards;
(archived ? this.boards : this.archives).delete(path);
into.set(path, parseBoard(path, content, archived));
} }
queueReload(path: string): void { queueReload(path: string): void {
@@ -264,20 +293,21 @@ export class BoardStore {
const file = this.app.vault.getAbstractFileByPath(path); const file = this.app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) { if (!(file instanceof TFile)) {
this.boards.delete(path); this.boards.delete(path);
this.archives.delete(path);
continue; continue;
} }
const content = await this.app.vault.cachedRead(file); this.keep(path, await this.app.vault.cachedRead(file));
this.boards.set(path, parseBoard(path, content));
} }
this.version += 1; this.version += 1;
} }
drop(path: string): void { drop(path: string): void {
if (this.boards.delete(path)) this.version += 1; const gone = this.boards.delete(path) || this.archives.delete(path);
if (gone) this.version += 1;
} }
boardFor(path: string): Board | undefined { boardFor(path: string): Board | undefined {
return this.boards.get(path); return this.boards.get(path) ?? this.archives.get(path);
} }
all = $derived.by<Task[]>(() => { all = $derived.by<Task[]>(() => {
@@ -288,6 +318,9 @@ export class BoardStore {
if (this.includeArchive || !task.inArchive) out.push(task); if (this.includeArchive || !task.inArchive) out.push(task);
} }
} }
if (this.includeArchive) {
for (const board of this.archives.values()) out.push(...board.tasks);
}
return out; return out;
}); });
@@ -333,8 +366,9 @@ export class BoardStore {
const byProject = new Map<string, number>(); const byProject = new Map<string, number>();
for (const task of this.all) { for (const task of this.all) {
if (!this.matches(task)) continue; if (!this.matches(task)) continue;
byPath.set(task.path, (byPath.get(task.path) ?? 0) + 1); const path = this.boardPath(task.path);
const key = laneKey(task.path, task.project); byPath.set(path, (byPath.get(path) ?? 0) + 1);
const key = laneKey(path, task.project);
byProject.set(key, (byProject.get(key) ?? 0) + 1); byProject.set(key, (byProject.get(key) ?? 0) + 1);
} }
return { byPath, byProject }; return { byPath, byProject };
@@ -356,11 +390,16 @@ export class BoardStore {
); );
for (const group of groups) { for (const group of groups) {
const first = group.tasks[0]; const first = group.tasks[0];
const path = this.boardPath(first.path);
// A group only offers a composer when every card in it shares one live
// lane; archived ones ride along, so they are read through their board.
const single = group.tasks.every( const single = group.tasks.every(
(task) => task.path === first.path && task.project === first.project, (task) =>
this.boardPath(task.path) === path && task.project === first.project,
); );
group.lane = single group.lane =
? { path: first.path, project: first.project } single && this.boards.has(path)
? { path, project: first.project }
: null; : null;
} }
return groups; return groups;
@@ -369,7 +408,7 @@ export class BoardStore {
private slotFor(task: Task): Omit<Group, "tasks" | "lane"> { private slotFor(task: Task): Omit<Group, "tasks" | "lane"> {
if (this.groupBy === "project") { if (this.groupBy === "project") {
return { return {
key: laneKey(task.path, task.project), key: laneKey(this.boardPath(task.path), task.project),
label: task.project, label: task.project,
hint: task.sphere, hint: task.sphere,
rank: 0, rank: 0,
@@ -387,7 +426,12 @@ export class BoardStore {
const bucket = dueBucket(anchorDate(task), this.today); const bucket = dueBucket(anchorDate(task), this.today);
return { key: bucket.key, label: bucket.label, hint: "", rank: bucket.rank }; return { key: bucket.key, label: bucket.label, hint: "", rank: bucket.rank };
} }
return { key: task.path, label: task.sphere, hint: "", rank: 0 }; return {
key: this.boardPath(task.path),
label: task.sphere,
hint: "",
rank: 0,
};
} }
byDay = $derived.by(() => { byDay = $derived.by(() => {
@@ -463,7 +507,7 @@ export class BoardStore {
} }
sphereName(path: string): string { sphereName(path: string): string {
return this.boards.get(path)?.sphere ?? sphereOf(path); return this.boardFor(path)?.sphere ?? sphereOf(path);
} }
// ── Keyboard cursor ────────────────────────────────────────────────────── // ── Keyboard cursor ──────────────────────────────────────────────────────
@@ -714,7 +758,9 @@ export class BoardStore {
throughRail(task: Task): boolean { throughRail(task: Task): boolean {
if (this.lanes === null) return true; if (this.lanes === null) return true;
// Archived tasks have no lane of their own; they ride with their sphere. // Archived tasks have no lane of their own; they ride with their sphere.
if (task.inArchive) return this.sphereState(task.path) !== "none"; if (task.inArchive) {
return this.sphereState(this.boardPath(task.path)) !== "none";
}
return this.lanes.has(laneKey(task.path, task.project)); return this.lanes.has(laneKey(task.path, task.project));
} }
+85
View File
@@ -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);
});
});
+149 -58
View File
@@ -5,14 +5,15 @@ import { parseBoard } from "../src/model/board";
import { serializeTaskLine } from "../src/model/serialize"; import { serializeTaskLine } from "../src/model/serialize";
import type { Task } from "../src/model/types"; import type { Task } from "../src/model/types";
import { import {
applyArchive,
applyReorder, applyReorder,
fillArchive,
cutTask, cutTask,
insertBlock, insertBlock,
locate, locate,
MissingLaneError, MissingLaneError,
replaceLine, replaceLine,
StaleTaskError, StaleTaskError,
takeDone,
} from "../src/vault/edits"; } from "../src/vault/edits";
const FIXTURES = join(import.meta.dirname, "fixtures"); const FIXTURES = join(import.meta.dirname, "fixtures");
@@ -189,61 +190,17 @@ describe("applyReorder", () => {
}); });
}); });
describe("applyArchive", () => { /** Runs the archive over one board, mirror file and all. */
it("moves done cards under the archive heading", () => { function archived(name: string, migrate = true) {
const { content, board: parsed } = board("personal.md"); const { content } = board(name);
const lines = content.split("\n"); const lines = content.split("\n");
const open = parsed.tasks.find((item) => !item.inArchive) as Task; const harvest = takeDone(lines, name, migrate);
replaceLine( const mirror: string[] = [];
lines, fillArchive(mirror, `archive/${name}`, harvest);
open, return { harvest, board: lines.join("\n"), mirror: mirror.join("\n") };
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);
});
it("is a no-op when nothing is done", () => {
const { content } = board("home.md");
const lines = content.split("\n");
expect(applyArchive(lines, "home.md", "Archive")).toBe(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");
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);
const next = lines.join("\n"); function markAllDone(name: string): string[] {
expect(next).toContain("***");
expect(next).toContain("## Archive");
expect(next).toContain("%% kanban:settings");
expect(parseBoard("home.md", next).tasks.every((t) => t.inArchive)).toBe(true);
});
});
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`, () => {
const { content, board: parsed } = board(name); const { content, board: parsed } = board(name);
const lines = content.split("\n"); const lines = content.split("\n");
for (const task of parsed.tasks.filter((item) => !item.inArchive)) { for (const task of parsed.tasks.filter((item) => !item.inArchive)) {
@@ -253,20 +210,154 @@ describe("every board survives a full archive pass", () => {
serializeTaskLine({ ...task, status: "x", done: "2026-08-08" }), serializeTaskLine({ ...task, status: "x", done: "2026-08-08" }),
); );
} }
applyArchive(lines, name, "Archive"); 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;
replaceLine(
lines,
open,
serializeTaskLine({ ...open, status: "x", done: "2026-08-08" }),
);
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 and nothing was archived", () => {
const { content } = board("home.md");
const lines = content.split("\n");
const harvest = takeDone(lines, "home.md", true);
expect(harvest.cards).toHaveLength(0);
expect(harvest.legacy).toHaveLength(0);
expect(lines.join("\n")).toBe(content);
});
it("leaves the native archive alone without migrate", () => {
const { content } = board("personal.md");
const lines = content.split("\n");
takeDone(lines, "personal.md", false);
const next = lines.join("\n"); const next = lines.join("\n");
expect(next).toContain("***");
expect(next).toContain("## Archive");
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}: nothing lost, the board keeps its shape`, () => {
const { content, board: parsed } = board(name);
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); const reparsed = parseBoard(name, next);
expect(reparsed.tasks).toHaveLength(parsed.tasks.length); expect(reparsed.tasks).toHaveLength(0);
expect(reparsed.tasks.every((item) => item.inArchive)).toBe(true);
const before = landmarks(content); const before = landmarks(content);
const after = landmarks(next); const after = landmarks(next);
expect(after.settings).toBe(before.settings); expect(after.settings).toBe(before.settings);
expect(after.frontmatter).toBe(before.frontmatter); 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); 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
View File
@@ -9,6 +9,7 @@ import { JSDOM } from "jsdom";
const root = new URL("..", import.meta.url).pathname; const root = new URL("..", import.meta.url).pathname;
const fixtures = join(root, "tests/fixtures"); const fixtures = join(root, "tests/fixtures");
const FOLDER = "Boards"; const FOLDER = "Boards";
const ARCHIVE = "archive";
const out = join(mkdtempSync(join(tmpdir(), "bcal-")), "bundle.mjs"); const out = join(mkdtempSync(join(tmpdir(), "bcal-")), "bundle.mjs");
writeFileSync( writeFileSync(
@@ -96,6 +97,23 @@ const files = new Map(
readFileSync(join(fixtures, name), "utf8"), 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 handles = new Map([...files.keys()].map((path) => [path, new TFile(path)]));
const writes = []; const writes = [];
@@ -122,7 +140,7 @@ const app = {
const settings = { const settings = {
boardsFolder: FOLDER, boardsFolder: FOLDER,
archiveHeading: "Archive", archiveFolder: ARCHIVE,
view: "list", view: "list",
groupBy: "sphere", groupBy: "sphere",
listSort: "due", listSort: "due",
@@ -153,6 +171,11 @@ await store.reloadAll();
const outsideArchive = 15; const outsideArchive = 15;
check("parsed every board", store.spheres.length === names.length, `${store.spheres.length}`); 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(`${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 target = dom.window.document.body;
const instance = mount(App, { const instance = mount(App, {
@@ -181,10 +204,39 @@ flushSync();
await settle(); await settle();
check("archive appears when both toggles are on", text().includes("pick up the parcel")); 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("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; store.includeArchive = false;
flushSync(); flushSync();
check("archive hidden again", !text().includes("pick up the parcel")); 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; store.showDone = false;
flushSync(); flushSync();