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();