From 9c0e57e29b279ce141e55254ae1cfce376a5e429 Mon Sep 17 00:00:00 2001 From: h Date: Sat, 5 Sep 2026 00:21:36 +0200 Subject: [PATCH] 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. --- README.md | 17 ++++ src/main.ts | 33 ++++--- src/model/archive.ts | 58 ++++++++++++ src/model/board.ts | 19 +++- src/settings.ts | 28 +++--- src/ui/drop.ts | 3 + src/vault/edits.ts | 151 ++++++++++++++++++++++++++------ src/vault/mutate.ts | 95 ++++++++++++++++++-- src/vault/store.svelte.ts | 80 +++++++++++++---- tests/archive.test.ts | 85 ++++++++++++++++++ tests/edits.test.ts | 179 ++++++++++++++++++++++++++++---------- tests/smoke.mjs | 54 +++++++++++- 12 files changed, 680 insertions(+), 122 deletions(-) create mode 100644 src/model/archive.ts create mode 100644 tests/archive.test.ts diff --git a/README.md b/README.md index 30c2b1b..bbd63ff 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,23 @@ Calendar and task list for Obsidian Kanban+Tasks 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 ```bash diff --git a/src/main.ts b/src/main.ts index 5843d5d..38856fd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,9 +4,10 @@ import { DEFAULT_SETTINGS, } from "./settings"; import type { BeaverCalendarSettings } from "./settings"; +import { archiveRoot, toArchivePath } from "./model/archive"; import { BoardStore } from "./vault/store.svelte"; import { transaction } from "./vault/history"; -import { archiveDone } from "./vault/mutate"; +import { archiveBoard } from "./vault/mutate"; import { BoardsView, VIEW_TYPE_BOARDS } from "./view"; export default class BeaverCalendarPlugin extends Plugin { @@ -90,34 +91,44 @@ export default class BeaverCalendarPlugin extends Plugin { private async archiveAll(): Promise { const folder = this.settings.boardsFolder; - if (!folder) { + const archive = this.settings.archiveFolder; + if (!folder || !archive) { new Notice( - "Beaver Calendar: no boards folder is set, archiving cancelled", + "Beaver Calendar: the boards and archive folders must both be set", 6000, ); return; } + // The mirror is under the boards folder, so it would sweep itself. + const root = `${archiveRoot(folder, archive)}/`; const files = this.app.vault .getMarkdownFiles() - .filter((file) => file.path.startsWith(`${folder}/`)); + .filter( + (file) => + file.path.startsWith(`${folder}/`) && !file.path.startsWith(root), + ); if (files.length === 0) { new Notice("Beaver Calendar: no boards found"); return; } let moved = 0; + let migrated = 0; // One undo step for the whole sweep, however many boards it touched. await transaction("the archive", async () => { for (const file of files) { - moved += await archiveDone( - this.app, - file.path, - this.settings.archiveHeading, - ); + const mirror = toArchivePath(file.path, folder, archive); + if (!mirror) continue; + const outcome = await archiveBoard(this.app, file.path, mirror, true); + 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( - moved > 0 - ? `Beaver Calendar: archived ${moved} task(s)` + parts.length > 0 + ? `Beaver Calendar: ${parts.join(", ")}` : "Beaver Calendar: nothing to archive", ); } diff --git a/src/model/archive.ts b/src/model/archive.ts new file mode 100644 index 0000000..ee74897 --- /dev/null +++ b/src/model/archive.ts @@ -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; +} diff --git a/src/model/board.ts b/src/model/board.ts index 1b52244..ab06a85 100644 --- a/src/model/board.ts +++ b/src/model/board.ts @@ -14,7 +14,16 @@ function isCardBody(line: string): boolean { 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 sphere = sphereOf(path); 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; 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 { let end = lane.end; while (end > lane.start && board.lines[end - 1].trim() === "") { diff --git a/src/settings.ts b/src/settings.ts index f8b765c..d6033e0 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -8,7 +8,7 @@ export type SortBy = "manual" | "priority" | "due"; export interface BeaverCalendarSettings { boardsFolder: string; - archiveHeading: string; + archiveFolder: string; view: ViewMode; groupBy: GroupBy; listSort: SortBy; @@ -24,7 +24,7 @@ export interface BeaverCalendarSettings { export const DEFAULT_SETTINGS: BeaverCalendarSettings = { boardsFolder: "Boards", - archiveHeading: "Archive", + archiveFolder: "archive", view: "list", groupBy: "sphere", listSort: "due", @@ -70,25 +70,33 @@ export class BeaverCalendarSettingsTab extends PluginSettingTab { ); new Setting(containerEl) - .setName("Archive heading") - .setDesc("The lane completed tasks are moved to by the archive command.") + .setName("Archive folder") + .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) => text - .setPlaceholder("Archive") - .setValue(this.plugin.settings.archiveHeading) + .setPlaceholder("archive") + .setValue(this.plugin.settings.archiveFolder) .onChange(async (value) => { - this.plugin.settings.archiveHeading = value.trim() || "Archive"; + this.plugin.settings.archiveFolder = + normalizeFolder(value) || "archive"; await this.plugin.saveSettings(); + await this.plugin.store.reloadAll(); }), ); new Setting(containerEl) .setName("Include the archive") .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 " + - "tasks still sitting in their lanes. Archived tasks carry an " + - "Archive badge instead of a project one.", + "tasks still in their lanes. Archived tasks carry an Archive badge " + + "instead of a project one.", ) .addToggle((toggle) => toggle diff --git a/src/ui/drop.ts b/src/ui/drop.ts index 93d8162..8a6d257 100644 --- a/src/ui/drop.ts +++ b/src/ui/drop.ts @@ -65,6 +65,9 @@ export async function handleDrop( if (id === dragged.id) return; const onto = store.all.find((task) => task.id === id); 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) { const board = store.boardFor(onto.path); diff --git a/src/vault/edits.ts b/src/vault/edits.ts index f1b0b40..485aaac 100644 --- a/src/vault/edits.ts +++ b/src/vault/edits.ts @@ -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(); + } } diff --git a/src/vault/mutate.ts b/src/vault/mutate.ts index 9e44bbf..c37a4f3 100644 --- a/src/vault/mutate.ts +++ b/src/vault/mutate.ts @@ -7,15 +7,17 @@ import { tasksApi, warnMissingTasks } from "../tasks-api"; import { today } from "../lib/due"; import { record, transaction } from "./history"; import { - applyArchive, applyReorder, + ARCHIVE_SEED, cutTask, extent, + fillArchive, insertBlock, locate, MissingLaneError, replaceLine, StaleTaskError, + takeDone, } from "./edits"; 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, path: string, - archiveHeading: string, -): Promise { - let moved = 0; - await edit(app, path, (lines) => { - moved = applyArchive(lines, path, archiveHeading); + before: string, + after: string, +): Promise { + const file = app.vault.getAbstractFileByPath(path); + 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 { + 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 { + 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 }; } diff --git a/src/vault/store.svelte.ts b/src/vault/store.svelte.ts index fee732c..aaa8fec 100644 --- a/src/vault/store.svelte.ts +++ b/src/vault/store.svelte.ts @@ -1,5 +1,6 @@ import { TFile } from "obsidian"; import type { App } from "obsidian"; +import { isArchivePath, toBoardPath } from "../model/archive"; import { parseBoard, sphereOf } from "../model/board"; import { anchorDate, isOpen, STATUS_ORDER } 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 { private readonly boards = new Map(); + /** 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(); private reloadTimer: number | null = null; private readonly pending = new Set(); @@ -228,13 +232,31 @@ export class BoardStore { } private inScope(path: string): boolean { + if (!path.endsWith(".md")) return false; const folder = this.settings.boardsFolder; - if (!folder) return path.endsWith(".md"); - return path.startsWith(`${folder}/`) && path.endsWith(".md"); + return !folder || path.startsWith(`${folder}/`); + } + + 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 { this.boards.clear(); + this.archives.clear(); const files = this.app.vault .getMarkdownFiles() .filter((file) => this.inScope(file.path)); @@ -244,7 +266,14 @@ export class BoardStore { private async load(file: TFile): Promise { 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 { @@ -264,20 +293,21 @@ export class BoardStore { const file = this.app.vault.getAbstractFileByPath(path); if (!(file instanceof TFile)) { this.boards.delete(path); + this.archives.delete(path); continue; } - const content = await this.app.vault.cachedRead(file); - this.boards.set(path, parseBoard(path, content)); + this.keep(path, await this.app.vault.cachedRead(file)); } this.version += 1; } 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 { - return this.boards.get(path); + return this.boards.get(path) ?? this.archives.get(path); } all = $derived.by(() => { @@ -288,6 +318,9 @@ export class BoardStore { if (this.includeArchive || !task.inArchive) out.push(task); } } + if (this.includeArchive) { + for (const board of this.archives.values()) out.push(...board.tasks); + } return out; }); @@ -333,8 +366,9 @@ export class BoardStore { const byProject = new Map(); for (const task of this.all) { if (!this.matches(task)) continue; - byPath.set(task.path, (byPath.get(task.path) ?? 0) + 1); - const key = laneKey(task.path, task.project); + const path = this.boardPath(task.path); + byPath.set(path, (byPath.get(path) ?? 0) + 1); + const key = laneKey(path, task.project); byProject.set(key, (byProject.get(key) ?? 0) + 1); } return { byPath, byProject }; @@ -356,12 +390,17 @@ export class BoardStore { ); for (const group of groups) { 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( - (task) => task.path === first.path && task.project === first.project, + (task) => + this.boardPath(task.path) === path && task.project === first.project, ); - group.lane = single - ? { path: first.path, project: first.project } - : null; + group.lane = + single && this.boards.has(path) + ? { path, project: first.project } + : null; } return groups; }); @@ -369,7 +408,7 @@ export class BoardStore { private slotFor(task: Task): Omit { if (this.groupBy === "project") { return { - key: laneKey(task.path, task.project), + key: laneKey(this.boardPath(task.path), task.project), label: task.project, hint: task.sphere, rank: 0, @@ -387,7 +426,12 @@ export class BoardStore { const bucket = dueBucket(anchorDate(task), this.today); 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(() => { @@ -463,7 +507,7 @@ export class BoardStore { } sphereName(path: string): string { - return this.boards.get(path)?.sphere ?? sphereOf(path); + return this.boardFor(path)?.sphere ?? sphereOf(path); } // ── Keyboard cursor ────────────────────────────────────────────────────── @@ -714,7 +758,9 @@ export class BoardStore { throughRail(task: Task): boolean { if (this.lanes === null) return true; // 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)); } diff --git a/tests/archive.test.ts b/tests/archive.test.ts new file mode 100644 index 0000000..b0922bb --- /dev/null +++ b/tests/archive.test.ts @@ -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); + }); +}); diff --git a/tests/edits.test.ts b/tests/edits.test.ts index aac3fb0..a28e135 100644 --- a/tests/edits.test.ts +++ b/tests/edits.test.ts @@ -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" }), + ); +} diff --git a/tests/smoke.mjs b/tests/smoke.mjs index 4bdfdcc..36d2194 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -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();