// THE WRITING DESK (shared/desk.ts, server/desk.ts, server/deskWords.ts, // docs/writing-desk.md): the frontmatter a piece is read from, the board and // the series lanes drawn from it, the outline that becomes headings, the // words-per-day log — and THE PRIVACY GUARD: a piece that is not published is // invisible to a visitor on every surface a visitor has. import assert from "node:assert/strict"; import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { after, before, describe, it } from "node:test"; import { Hono } from "hono"; import { ALL, boardColumns, comparePieces, DESK_STATUSES, deskMetaOf, filterPieces, findOutline, isOverdue, logWords, newPieceContent, nextStepOf, NO_SERIES, outlineToHeadings, parseDeskStatus, pieceNotePath, seriesLanes, stageOf, WORD_LOG_DAYS, wordPoints, type DeskData, type DeskPiece, } from "../shared/desk.ts"; import { initAuth } from "../server/auth.ts"; import { api } from "../server/api.ts"; import { renderFeed, renderSitemap } from "../server/blog.ts"; import { indexFile, initIndexer, notes, posts } from "../server/indexer.ts"; import { deskData, deskPiece } from "../server/desk.ts"; import { deskWordPoints, flushDeskWords, moveDeskWords, noteDeskWords, resetDeskWordsForTests } from "../server/deskWords.ts"; import { initSite } from "../server/site.ts"; import { initVault } from "../server/vault.ts"; import { makeDir, makeVault, removeVault } from "./helpers/vault.ts"; function piece(over: Partial = {}): DeskPiece { return { path: "Writing/A.md", title: "A", status: "idea", stage: "idea", published: false, series: null, seriesRef: null, lang: null, angle: "", priority: null, due: null, next: "", words: 0, goal: null, mtimeMs: 0, ...over, }; } describe("the frontmatter", () => { it("reads a piece: kind, status, angle, series, lang, priority, due, next", () => { const meta = deskMetaOf({ kind: "essay", status: "Drafting", angle: " A history of the astrolabe, told through one instrument. ", series: [["Instruments of the sky"]], lang: "ar", priority: "high", due: new Date(Date.UTC(2026, 10, 1)), next: "Find the 1820 survey", }); assert.deepEqual(meta, { kind: "essay", status: "drafting", series: "Instruments of the sky", lang: "ar", angle: "A history of the astrolabe, told through one instrument.", priority: "high", due: "2026-11-01", next: "Find the 1820 survey", description: "", }); }); it("is not a piece without kind, and a bare kind is an idea", () => { assert.equal(deskMetaOf({ status: "drafting" }), null); assert.equal(deskMetaOf({ kind: "book" }), null); const bare = deskMetaOf({ kind: "Essay" })!; assert.equal(bare.status, "idea"); assert.equal(bare.series, null); assert.equal(bare.due, null); }); it("folds the spellings a hand reaches for, and nothing unknown escapes", () => { assert.equal(parseDeskStatus("draft"), "drafting"); assert.equal(parseDeskStatus("research"), "researching"); assert.equal(parseDeskStatus("outline"), "outlining"); assert.equal(parseDeskStatus("on hold"), "parked"); assert.equal(parseDeskStatus("PUBLISHED"), "published"); assert.equal(parseDeskStatus("whenever"), "idea"); assert.equal(parseDeskStatus(""), "idea"); for (const s of DESK_STATUSES) assert.equal(parseDeskStatus(s), s); }); it("drops a due date that is not a calendar day", () => { assert.equal(deskMetaOf({ kind: "essay", due: "2026-02-30" })!.due, null); assert.equal(deskMetaOf({ kind: "essay", due: "soon" })!.due, null); assert.equal(deskMetaOf({ kind: "essay", due: "2026-02-28" })!.due, "2026-02-28"); }); it("reads a series: its description, and no membership of its own", () => { const meta = deskMetaOf({ kind: "series", description: "Nine bridges, nine centuries.", series: ["[[Stone]]", "[[Iron]]"] })!; assert.equal(meta.kind, "series"); assert.equal(meta.description, "Nine bridges, nine centuries."); assert.equal(meta.series, null); }); it("the flag decides published, never the status", () => { assert.equal(stageOf("published", false), "editing"); assert.equal(stageOf("drafting", true), "published"); assert.equal(stageOf("parked", false), "parked"); }); }); describe("capture", () => { it("files a new piece under Writing/, with a safe name", () => { assert.equal(pieceNotePath("A history of the astrolabe"), "Writing/A history of the astrolabe.md"); assert.equal(pieceNotePath("Why: bridges / arches?"), "Writing/Why bridges arches.md"); assert.equal(pieceNotePath("..hidden"), "Writing/hidden.md"); assert.equal(pieceNotePath(" "), "Writing/Untitled.md"); }); it("writes a private note: no publish key, the angle quoted, an outline to grow", () => { const text = newPieceContent({ kind: "essay", angle: 'The "first" bridge', series: "Bridges", lang: "en" }); assert.ok(!/publish/i.test(text)); assert.match(text, /^---\nkind: essay\nstatus: idea\nangle: "The \\"first\\" bridge"\nseries: "\[\[Bridges\]\]"\nlang: en\n---\n/); assert.match(text, /## Outline\n\n- \n/); const meta = deskMetaOf({ kind: "essay", series: "[[Bridges]]" })!; assert.equal(meta.series, "Bridges"); const series = newPieceContent({ kind: "series", angle: "Nine bridges" }); assert.ok(!/publish/i.test(series)); assert.match(series, /kind: series\nstatus: idea\ndescription: "Nine bridges"\nseries: \[\]\n/); }); }); describe("the board", () => { it("has every column in road order, published last but one, parked last", () => { const cols = boardColumns([]); assert.deepEqual( cols.map((c) => c.status), ["idea", "researching", "outlining", "drafting", "editing", "published", "parked"], ); assert.ok(cols.every((c) => c.pieces.length === 0)); }); it("puts each piece in its stage's column, sorted priority → due → last touched", () => { const a = piece({ path: "a", title: "a", stage: "drafting", priority: "low", mtimeMs: 9 }); const b = piece({ path: "b", title: "b", stage: "drafting", priority: "high", mtimeMs: 1 }); const c = piece({ path: "c", title: "c", stage: "drafting", due: "2026-01-01", mtimeMs: 1 }); const d = piece({ path: "d", title: "d", stage: "drafting", due: "2026-03-01", mtimeMs: 1 }); const e = piece({ path: "e", title: "e", stage: "drafting", mtimeMs: 5 }); const f = piece({ path: "f", title: "f", stage: "published", published: true }); const cols = boardColumns([a, b, c, d, e, f]); assert.deepEqual(cols.find((x) => x.status === "drafting")!.pieces.map((p) => p.path), ["b", "c", "d", "e", "a"]); assert.deepEqual(cols.find((x) => x.status === "published")!.pieces.map((p) => p.path), ["f"]); assert.ok(comparePieces(b, a) < 0); }); it("filters by series, language and status", () => { const list = [ piece({ path: "1", series: "S.md", seriesRef: "S", lang: "en", stage: "idea" }), piece({ path: "2", lang: "ar", stage: "drafting" }), piece({ path: "3", seriesRef: "Nowhere", stage: "idea" }), ]; assert.deepEqual(filterPieces(list, ALL).map((p) => p.path), ["1", "2", "3"]); assert.deepEqual(filterPieces(list, { ...ALL, series: "S.md" }).map((p) => p.path), ["1"]); assert.deepEqual(filterPieces(list, { ...ALL, series: NO_SERIES }).map((p) => p.path), ["2"]); assert.deepEqual(filterPieces(list, { ...ALL, lang: "ar" }).map((p) => p.path), ["2"]); assert.deepEqual(filterPieces(list, { ...ALL, status: "idea" }).map((p) => p.path), ["1", "3"]); }); it("draws a series as a lane: its list in order, planned slots, then the pieces that name it", () => { const data: DeskData = { pieces: [ piece({ path: "Stone.md", title: "Stone", series: "Bridges.md", seriesRef: "Bridges", stage: "published", published: true }), piece({ path: "Iron.md", title: "Iron", series: "Bridges.md", seriesRef: "Bridges", stage: "drafting" }), piece({ path: "Rope.md", title: "Rope", series: "Bridges.md", seriesRef: "Bridges", stage: "idea" }), piece({ path: "Loose.md", title: "Loose", seriesRef: "Clocks", stage: "editing" }), ], series: [ { path: "Bridges.md", title: "Bridges", status: "drafting", description: "Nine bridges", lang: null, angle: "", published: false, mtimeMs: 0, slots: [ { ref: "Stone", path: "Stone.md" }, { ref: "Steel", path: null }, { ref: "Iron", path: "Iron.md" }, { ref: "Iron", path: "Iron.md" }, { ref: "Notes on arches", path: "Arches.md" }, ], }, ], }; const lanes = seriesLanes(data); assert.equal(lanes.length, 2); const bridges = lanes[0]; assert.deepEqual( bridges.entries.map((e) => (e.kind === "piece" ? e.piece.path : e.kind === "note" ? `note:${e.path}` : `planned:${e.ref}`)), ["Stone.md", "planned:Steel", "Iron.md", "note:Arches.md", "Rope.md"], ); assert.equal(bridges.total, 5); assert.equal(bridges.drafted, 2, "Stone (published) and Iron (drafting)"); assert.equal(bridges.published, 1); // A series named only by its pieces still gets a lane, under that name. assert.equal(lanes[1].title, "Clocks"); assert.equal(lanes[1].series, null); assert.equal(lanes[1].drafted, 1); }); it("says what to do next, the writer's words first", () => { assert.deepEqual(nextStepOf(piece({ next: "Call the archive" })), { kind: "own", text: "Call the archive" }); assert.deepEqual(nextStepOf(piece()), { kind: "angle" }); assert.deepEqual(nextStepOf(piece({ angle: "x" })), { kind: "research" }); assert.deepEqual(nextStepOf(piece({ stage: "outlining" })), { kind: "headings" }); assert.deepEqual(nextStepOf(piece({ stage: "drafting", words: 1200, goal: 3000 })), { kind: "words", n: 1800 }); assert.deepEqual(nextStepOf(piece({ stage: "drafting", words: 3100, goal: 3000 })), { kind: "finish" }); assert.deepEqual(nextStepOf(piece({ stage: "editing", status: "published" })), { kind: "publish" }); assert.deepEqual(nextStepOf(piece({ stage: "published" })), { kind: "live" }); assert.equal(isOverdue(piece({ due: "2026-01-01", stage: "drafting" }), "2026-02-01"), true); assert.equal(isOverdue(piece({ due: "2026-01-01", stage: "published" }), "2026-02-01"), false); }); }); describe("outline → headings", () => { const SRC = [ "---", "kind: essay", "---", "", "## Outline", "", "- The first crossing", " - Rope and timber", "- [ ] Stone", "1. Iron", "", "```md", "- not an item", "## Not a heading", "```", "", "## Notes", "", "Loose thoughts.", "", ].join("\n"); it("finds the outline and its items, outside code", () => { const o = findOutline(SRC)!; assert.equal(o.level, 2); assert.deepEqual(o.items, [ { depth: 0, text: "The first crossing" }, { depth: 1, text: "Rope and timber" }, { depth: 0, text: "Stone" }, { depth: 0, text: "Iron" }, ]); }); it("writes the headings after the outline, keeping the outline and everything else", () => { const out = outlineToHeadings(SRC)!; assert.equal(out.added, 4); const lines = out.text.split("\n"); const at = lines.indexOf("## The first crossing"); assert.ok(at > lines.indexOf("```"), "after the outline section, not inside its fence"); assert.deepEqual(lines.slice(at, at + 8), ["## The first crossing", "", "### Rope and timber", "", "## Stone", "", "## Iron", ""]); assert.ok(out.text.includes("- The first crossing"), "the outline stays"); assert.ok(out.text.includes("## Notes\n\nLoose thoughts.")); // Every original line survives, in order. const kept = SRC.split("\n").filter((l) => l !== ""); let i = 0; for (const l of lines) if (l === kept[i]) i++; assert.equal(i, kept.length); }); it("adds nothing the second time, and nothing to a note with no outline", () => { const once = outlineToHeadings(SRC)!.text; const twice = outlineToHeadings(once)!; assert.equal(twice.added, 0); assert.equal(twice.text, once); assert.equal(outlineToHeadings("# Title\n\nNo plan here.\n"), null); }); it("keeps CRLF line endings", () => { const out = outlineToHeadings(SRC.replace(/\n/g, "\r\n"))!; assert.ok(!/[^\r]\n/.test(out.text)); assert.ok(out.text.includes("## Stone\r\n")); }); it("reads المخطط as an outline", () => { const out = outlineToHeadings("## المخطط\n\n- الجسر الأول\n")!; assert.equal(out.added, 1); assert.ok(out.text.includes("## الجسر الأول")); }); }); describe("words over time", () => { it("keeps the last count of a day, returns the same log when nothing changed, and caps its length", () => { const a = logWords({}, "2026-01-01", 10); const b = logWords(a, "2026-01-01", 25); assert.deepEqual(b, { "2026-01-01": 25 }); assert.equal(logWords(b, "2026-01-01", 25), b); let log = {}; for (let i = 0; i < WORD_LOG_DAYS + 20; i++) { const d = new Date(Date.UTC(2024, 0, 1 + i)).toISOString().slice(0, 10); log = logWords(log, d, i); } const points = wordPoints(log); assert.equal(points.length, WORD_LOG_DAYS); assert.equal(points[0].words, 20); assert.ok(points.every((p, i) => i === 0 || points[i - 1].day < p.day)); }); }); // ─────────────────────────────────────────────────── the index and the guard const HASH = "$argon2id$v=19$m=65536,t=3,p=4$c2FsdHNhbHQ$aGFzaGhhc2hoYXNoaGFzaA"; const SECRET = "desksecret0123456789abcdef0123456789"; const ORIGIN = "https://astrolabe.example"; const OFF = { mode: "off", lang: null, fallbackFrom: null } as const; /** A word that appears in the private pieces and nowhere else. */ const SECRET_WORD = "quillwort"; const data = makeDir(); const root = makeVault({ "Writing/Bridges.md": [ "---", "kind: series", "status: drafting", `description: "A series on bridges, ${SECRET_WORD}"`, "series:", ' - "[[Stone bridges]]"', ' - "[[Steel bridges]]"', "---", "", ].join("\n"), "Writing/Stone bridges.md": [ "---", "kind: essay", "status: drafting", `angle: "Why stone outlasted ${SECRET_WORD}"`, 'series: "[[Bridges]]"', "goal: 2000", "tags: [bridges]", "---", "", `The first ${SECRET_WORD} crossing was rope; [[Arch notes]] has the rest, and ![[survey.pdf]] the numbers.`, "", ].join("\n"), "Writing/A history of the astrolabe.md": [ "---", "kind: essay", "status: published", `angle: "One instrument, ${SECRET_WORD}"`, "---", "", `Called finished, but not published: ${SECRET_WORD}.`, "", ].join("\n"), "Writing/Out.md": ["---", "kind: essay", "status: editing", "publish: true", "---", "", "This one is on the site.", ""].join("\n"), "Arch notes.md": "Notes on arches, linking back to [[Stone bridges]].\n", "Clip.md": ['---', 'source: "https://example.org/bridges"', "---", "", "A clipped page.", ""].join("\n"), "Reading.md": "See [[Clip]] and [[Stone bridges]].\n", "survey.pdf": "%PDF-1.4\n", "Templates/Essay.md": ["---", "kind: essay", "status: idea", "---", ""].join("\n"), }); const app = new Hono(); app.route("/api", api); before(async () => { initAuth({}); initSite({ ASTROLABE_DATA: data, SITE_LANG: "en" }); initVault(root); resetDeskWordsForTests(); await initIndexer(); }); after(() => { initAuth({}); resetDeskWordsForTests(); removeVault(root); removeVault(data); }); describe("the index", () => { it("reads every piece and series, leaving templates out", () => { const d = deskData(); assert.deepEqual( d.pieces.map((p) => p.path), ["Writing/A history of the astrolabe.md", "Writing/Out.md", "Writing/Stone bridges.md"], ); assert.deepEqual(d.series.map((s) => s.path), ["Writing/Bridges.md"]); const stone = d.pieces.find((p) => p.path === "Writing/Stone bridges.md")!; assert.equal(stone.series, "Writing/Bridges.md"); assert.equal(stone.stage, "drafting"); assert.equal(stone.goal, 2000); assert.ok(stone.words > 5); assert.equal(d.pieces.find((p) => p.path === "Writing/A history of the astrolabe.md")!.stage, "editing"); assert.equal(d.pieces.find((p) => p.path === "Writing/Out.md")!.stage, "published"); assert.deepEqual(d.series[0].slots, [ { ref: "Stone bridges", path: "Writing/Stone bridges.md" }, { ref: "Steel bridges", path: null }, ]); }); it("a piece's series: is membership, not a blog series of one", () => { assert.equal(notes.get("Writing/Stone bridges.md")!.seriesRefs, null); assert.equal(notes.get("Writing/Stone bridges.md")!.desk!.series, "Bridges"); assert.deepEqual(notes.get("Writing/Bridges.md")!.seriesRefs, ["Stone bridges", "Steel bridges"]); }); it("gathers a piece's sources: linked notes, books, clips and backlinks", () => { const detail = deskPiece("Writing/Stone bridges.md")!; assert.equal(detail.seriesTitle, "Bridges"); assert.deepEqual(detail.linked.map((r) => r.path), ["Arch notes.md"]); assert.deepEqual(detail.books.map((r) => r.path), ["survey.pdf"]); assert.deepEqual(detail.backlinks.map((r) => r.path).sort(), ["Arch notes.md", "Reading.md"]); assert.equal(deskPiece("Arch notes.md"), null, "an ordinary note is not a piece"); }); it("logs a piece's words once a day, follows a rename, and writes the ledger owner-only", () => { noteDeskWords("Writing/Stone bridges.md", 120, "2026-01-01"); noteDeskWords("Writing/Stone bridges.md", 340, "2026-01-02"); noteDeskWords("Writing/Stone bridges.md", 360, "2026-01-02"); const points = deskWordPoints("Writing/Stone bridges.md").filter((p) => p.day.startsWith("2026-01-0")); assert.deepEqual(points, [ { day: "2026-01-01", words: 120 }, { day: "2026-01-02", words: 360 }, ]); moveDeskWords("Writing/Stone bridges.md", "Writing/Stone.md"); assert.equal(deskWordPoints("Writing/Stone bridges.md").length, 0); assert.ok(deskWordPoints("Writing/Stone.md").some((p) => p.day === "2026-01-02")); moveDeskWords("Writing/Stone.md", "Writing/Stone bridges.md"); flushDeskWords(); const file = path.join(data, "desk-words.json"); assert.ok(existsSync(file)); assert.ok(readFileSync(file, "utf8").includes("2026-01-02")); }); }); describe("THE PRIVACY GUARD: a piece in progress is invisible to a visitor", () => { before(() => initAuth({ ADMIN_PASSWORD_HASH: HASH, SESSION_SECRET: SECRET })); after(() => initAuth({})); const get = async (url: string): Promise<{ status: number; text: string }> => { const res = await app.request(url); return { status: res.status, text: await res.text() }; }; it("the desk itself answers a visitor with a 401 and nothing else", async () => { for (const url of ["/api/desk", "/api/desk/piece?path=Writing%2FStone%20bridges.md", "/api/desk/piece?path=nothing.md"]) { const res = await get(url); assert.equal(res.status, 401, url); assert.ok(!res.text.includes(SECRET_WORD) && !res.text.includes("Stone bridges"), url); } }); it("a drafting essay with no publish flag is on no visitor surface", async () => { const urls = [ "/api/posts", "/api/tree", `/api/search?q=${SECRET_WORD}`, "/api/search?q=Stone%20bridges", "/api/props", "/api/query?q=prop%3Akind%3Dessay", "/api/query?q=prop%3Astatus%3Ddrafting", "/api/tags", "/api/graph", "/api/aliases", "/api/backlinks?path=Arch%20notes.md", ]; for (const url of urls) { const res = await get(url); assert.ok(res.status === 200 || res.status === 401 || res.status === 404, `${url} → ${res.status}`); assert.ok(!res.text.includes(SECRET_WORD), `${url} leaked the private text`); assert.ok(!res.text.includes("Stone bridges"), `${url} leaked the piece's name`); assert.ok(!res.text.includes("Bridges.md"), `${url} leaked the series`); assert.ok(!res.text.includes("astrolabe.md"), `${url} leaked a piece called finished`); assert.ok(!/"(drafting|researching|outlining)"/.test(res.text), `${url} leaked a status`); } const note = await get("/api/note?path=Writing%2FStone%20bridges.md"); assert.equal(note.status, 404); assert.ok(!note.text.includes(SECRET_WORD)); }); it("nor in the feed, the sitemap, or the visitor's post list", () => { for (const text of [renderFeed(ORIGIN, OFF), renderSitemap(ORIGIN, OFF)]) { assert.ok(!text.includes(SECRET_WORD)); assert.ok(!text.includes("Stone")); assert.ok(!text.includes("Bridges")); assert.ok(!text.includes("history of the astrolabe") && !text.includes("history%20of%20the%20astrolabe")); assert.ok(text.includes("Out"), "the published piece IS there: the guard is a filter, not a wall"); } const visible = posts(true, null).map((p) => p.path); assert.deepEqual(visible, ["Writing/Out.md"]); }); it("status: published is not publishing: only the flag puts a piece on the site", async () => { // The piece the writer called finished, without the flag, is still dark. assert.equal((await get("/api/note?path=Writing%2FA%20history%20of%20the%20astrolabe.md")).status, 404); const out = await get("/api/note?path=Writing%2FOut.md"); assert.equal(out.status, 200); }); it("a reindex after an edit keeps it dark", async () => { await indexFile("Writing/Stone bridges.md"); const res = await get(`/api/search?q=${SECRET_WORD}`); assert.ok(!res.text.includes(SECRET_WORD)); }); });