--- /dev/null
+* text=auto eol=lf
+*.png binary
+*.ico binary
--- /dev/null
+# Claude Code per-machine settings
+.claude/settings.local.json
+
+# Card data lives in ../pim-cards; never commit cards or interrupted writes
+*.md.tmp
+*.tmp
+
+# Node
+node_modules/
+npm-debug.log*
+*.log
+
+# Editors and OS
+.vscode/
+.idea/
+*.swp
+*~
+.DS_Store
+Thumbs.db
+desktop.ini
--- /dev/null
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## What this is
+
+A local to-do card app. A dependency-free Node server serves Markdown cards to one browser page. There is no package.json, no build step and no npm dependency.
+
+## Commands
+
+| Task | Command |
+| -------------- | ------------------------------------------- |
+| Run the server | `node server.js` |
+| Run all tests | `node --test` |
+| Run one file | `node --test dates.test.js` |
+| Run one test | `node --test --test-name-pattern="tokenRe"` |
+
+The server listens on `http://127.0.0.1:8717/` (`PORT` overrides) and also on `http://cardhost/` (127.0.0.2 port 80, needs the hosts line `127.0.0.2 cardhost`). The cards are in `../pim-cards/` (`~/lib/pim-cards`), outside this folder. `CARDS_DIR` overrides it. If the folder is missing, the server creates it empty and shows an empty board with no error.
+
+## Architecture
+
+- `server.js`: serves `index.html` with every card injected as JSON in place of `<!--CARDS-->`, serves the static scripts, and takes `PUT`/`DELETE` on `/api/cards/<key>`. Each card is one file `<key>.md` in the card folder, with optional front matter (`title:`, `tags:`). Writes go to a `.tmp` file, then a rename.
+- The home card is `_home.md`. When that file is missing, the server supplies a default home card, and the first save writes the file. The server refuses to delete it.
+- `index.html` loads `md.js`, `dates.js`, `lines.js`, `app.js` as plain scripts, in that order. There are no ES modules.
+- `md.js`: Markdown to HTML (`md2html`), foldable sections (`##+` open, `##-` closed), and `markTasks`, which puts an `<!--L<n>-->` source line number on each checklist line so a ticked box finds its line.
+- `dates.js` (global `CardDates`) and `lines.js` (global `CardLines`): pure logic, shared by browser and tests through a UMD wrapper. Node tests `require` them.
+- `app.js`: all UI in one block scope. Cards are `<article>` elements; the URL hash selects a card. It renders queries, the agenda, search, drag, inline edit, and a serial save queue.
+- `rearman.css` is outside this folder, at `../rearman.css`.
+
+## Card syntax (parsed in several files)
+
+- Card keys: `_home` or lowercase slugs (`validKey`). This pattern is the only path guard, so keep it strict.
+- Tags: card tags in front matter; `#tag` in text. A line inherits tags from its parent list item or heading (`lineTags`).
+- Dates: `!` due, `@` scheduled, `!2026-10-20T09:00`, repeats `+1w` / `.+1y`, ranges joined by `--`. Done stamps: `x2026-10-15T16:57`. `stampRe` is copied in `dates.js` and `lines.js`; change both.
+- Query blocks on a card: `{{tags: next, -waiting, has:tag, -has:tag}}` and `{{agenda: 14d -#done}}`. `has:tag` holds when an open checklist line carries the tag.
+
+## Constraints
+
+- The server has no login. It binds loopback only and rejects unknown `Host` headers (DNS rebinding). `PUT` requires `application/json` so a cross-site form cannot write. Keep all three.
+- The card folder holds the user's real data. Tests must not write to it.
+- Code uses tab indentation and double quotes.
--- /dev/null
+{
+ const HOME = "_home";
+ const keyOf = (id) => id || HOME;
+ const validId = (id) => /^[a-z0-9]+(-[a-z0-9]+)*$/.test(id);
+ const cards = new Map(
+ Object.entries(JSON.parse(document.getElementById("cards").textContent))
+ );
+ for (const c of cards.values()) c.tags ||= [];
+ const TAG_RE = /^[a-z0-9][a-z0-9_-]*$/;
+ const parseTags = (s) =>
+ [...new Set(s.split(/[\s,]+/).map((t) => t.toLowerCase()).filter((t) => TAG_RE.test(t)))];
+ const D = CardDates;
+ const BOOKMARK = "bookmark"; // the card tag the ☆ button sets
+ const L = CardLines;
+ // line tags of a card, worked out again only when its text changes
+ const lineCache = new WeakMap();
+ const linesOf = (card) => {
+ let c = lineCache.get(card);
+ if (!c || c.md != card.md) lineCache.set(card, (c = { md: card.md, lines: L.lineTags(card.md) }));
+ return c.lines;
+ };
+ const lineTagsOf = (card) => [...new Set(linesOf(card).flatMap((e) => e.own))];
+ const union = (a, b) => [...new Set([...a, ...b])];
+ const fits = (tags, want, skip) =>
+ want.every((t) => tags.includes(t)) && !skip.some((t) => tags.includes(t));
+ // has:tag keeps cards with an open checklist line tagged #tag, -has:tag drops them
+ const hasOk = (card, has = [], hasNot = []) =>
+ has.every((t) => L.hasOpenTag(linesOf(card), t)) &&
+ !hasNot.some((t) => L.hasOpenTag(linesOf(card), t));
+ // query terms: tag, -tag, has:tag, -has:tag
+ const splitTerms = (terms) => ({
+ want: terms.filter((t) => !t.startsWith("-") && !t.startsWith("has:")),
+ skip: terms.filter((t) => t.startsWith("-") && !t.startsWith("-has:")).map((t) => t.slice(1)),
+ has: terms.filter((t) => t.startsWith("has:")).map((t) => t.slice(4)),
+ hasNot: terms.filter((t) => t.startsWith("-has:")).map((t) => t.slice(5)),
+ });
+ const renderMd = (md) =>
+ md2html(
+ foldSections(markTasks(md)
+ .replace(
+ /^[ \t]*\{\{agenda(?::([^}\n]*))?\}\}[ \t]*$/gm,
+ (_, args = "") => {
+ // "14d" sets the window, #tag keeps cards with it, -#tag drops them,
+ // has:tag and -has:tag test the card's open checklist lines
+ const terms = args.toLowerCase().split(/[\s,]+/);
+ const days = terms.find((t) => /^\d+d$/.test(t));
+ const tags = (re) => terms.filter((t) => re.test(t)).map((t) => t.replace(/^-?#/, "")).join(" ");
+ const has = (re) => terms.filter((t) => re.test(t)).map((t) => t.replace(/^-?has:/, "")).join(" ");
+ return `<div class="agenda" data-days="${days ? parseInt(days, 10) : 7}" data-want="${tags(/^#[a-z0-9][a-z0-9_-]*$/)}" data-skip="${tags(/^-#[a-z0-9][a-z0-9_-]*$/)}" data-has="${has(/^has:[a-z0-9][a-z0-9_-]*$/)}" data-hasnot="${has(/^-has:[a-z0-9][a-z0-9_-]*$/)}"></div>`;
+ }
+ )
+ .replace(
+ /^[ \t]*\{\{tags:([^}\n]*)\}\}[ \t]*$/gm,
+ (_, q) =>
+ `<div class="query" data-q="${q.split(/[\s,]+/).map((t) => t.toLowerCase()).filter((t) => /^-?(has:)?[a-z0-9][a-z0-9_-]*$/.test(t)).join(" ")}"></div>`
+ )
+ ));
+
+ // due "!" and scheduled "@" date tokens
+ const BADGE_SKIP =
+ "code, pre, a, textarea, .editor, .cardtools, .cardtitle, .cardtags, .when, .agenda, .donestamp";
+ const badgeOf = (d) => {
+ const span = document.createElement("span");
+ span.className = "when " + (d.kind == "!" ? "due" : "sched");
+ span.textContent = d.raw;
+ span.title =
+ (d.kind == "!" ? "deadline " : "scheduled ") +
+ d.date +
+ (d.time ? " " + d.time : "") +
+ (d.endDate
+ ? " to " + d.endDate + (d.endTime ? " " + d.endTime : "")
+ : d.endTime
+ ? " to " + d.endTime
+ : "") +
+ (d.rep ? ", repeats " + d.rep.from + d.rep.n + d.rep.unit : "");
+ return span;
+ };
+ const paintWhen = (span) => {
+ const today = D.todayStr();
+ const box = span.closest("li.task")?.querySelector("input[type=checkbox]");
+ const task = span.dataset.task !== undefined ? span.dataset.task == "1" : !!box;
+ const d = D.parseDates(span.textContent)[0];
+ if (!d) return;
+ span.classList.remove("overdue", "today", "soon", "later", "done", "past");
+ span.classList.add(box?.checked ? "done" : D.badgeStatus({ task }, d, today));
+ };
+ const decorate = (root) => {
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
+ acceptNode: (n) =>
+ n.parentElement.closest(BADGE_SKIP)
+ ? NodeFilter.FILTER_REJECT
+ : NodeFilter.FILTER_ACCEPT,
+ });
+ const nodes = [];
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) nodes.push(n);
+ for (const node of nodes) {
+ const text = node.nodeValue;
+ const re = D.tokenRe();
+ const parts = [];
+ const spans = [];
+ let last = 0;
+ for (let m; (m = re.exec(text)); ) {
+ const lead = m[1] || "";
+ const d = D.parseDates(m[0].slice(lead.length))[0];
+ if (!d) continue; // not a date: leave the text as is
+ parts.push(text.slice(last, m.index) + lead);
+ const span = badgeOf(d);
+ parts.push(span);
+ spans.push(span);
+ last = m.index + m[0].length;
+ }
+ if (!spans.length) continue;
+ parts.push(text.slice(last));
+ node.replaceWith(
+ ...parts.map((p) => (typeof p == "string" ? document.createTextNode(p) : p))
+ );
+ spans.forEach(paintWhen);
+ }
+ tagify(root);
+ stampify(root);
+ };
+ // #tags written in the text become clickable chips, like the card tags
+ // skip: what to leave alone; query rows pass their own, since they live inside .query
+ const tagify = (root, skip = BADGE_SKIP + ", .tag, .query") => {
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
+ acceptNode: (n) =>
+ n.parentElement.closest(skip)
+ ? NodeFilter.FILTER_REJECT
+ : NodeFilter.FILTER_ACCEPT,
+ });
+ const nodes = [];
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) nodes.push(n);
+ for (const node of nodes) {
+ const text = node.nodeValue;
+ const parts = [];
+ let last = 0;
+ for (const m of text.matchAll(L.tagRe())) {
+ parts.push(text.slice(last, m.index) + m[1]);
+ const chip = document.createElement("span");
+ chip.className = "tag linetag";
+ chip.dataset.tag = m[2].toLowerCase();
+ chip.textContent = "#" + m[2];
+ parts.push(chip);
+ last = m.index + m[0].length;
+ }
+ if (!parts.length) continue;
+ parts.push(text.slice(last));
+ node.replaceWith(
+ ...parts.map((p) => (typeof p == "string" ? document.createTextNode(p) : p))
+ );
+ }
+ };
+ // a done stamp chip, e.g. x2026-10-15T14:32
+ const stampChip = (date, time) => {
+ const span = document.createElement("span");
+ span.className = "donestamp";
+ span.textContent = "x" + date + "T" + time;
+ span.title = "done " + date + " " + time;
+ return span;
+ };
+ // stamps written in the text become chips; the lead character stays text
+ const stampify = (root) => {
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
+ acceptNode: (n) =>
+ n.parentElement.closest(BADGE_SKIP + ", .tag, .query")
+ ? NodeFilter.FILTER_REJECT
+ : NodeFilter.FILTER_ACCEPT,
+ });
+ const nodes = [];
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) nodes.push(n);
+ for (const node of nodes) {
+ const text = node.nodeValue;
+ const parts = [];
+ let last = 0;
+ for (const m of text.matchAll(D.stampRe())) {
+ parts.push(text.slice(last, m.index) + m[1]);
+ parts.push(stampChip(m[2], m[3]));
+ last = m.index + m[0].length;
+ }
+ if (!parts.length) continue;
+ parts.push(text.slice(last));
+ node.replaceWith(
+ ...parts.map((p) => (typeof p == "string" ? document.createTextNode(p) : p))
+ );
+ }
+ };
+ const previewEl = document.querySelector(".preview");
+ const statusEl = document.querySelector(".status");
+ const retryBtn = document.querySelector('button[data-act="retry"]');
+ const stack = [];
+ let hoverTimer = 0;
+
+ // a wiki page link only: needs an existing article, so external links
+ // and not-yet-created pages (#new-page) get no preview and no paper
+ // "#slug", or the absolute form a browser "save page" rewrites it into
+ const cardIdOf = (link) => {
+ const id = link?.hash?.substring(1) || "";
+ const file = (path) => path.split("/").pop();
+ return id &&
+ link.protocol == location.protocol &&
+ file(link.pathname) == file(location.pathname)
+ ? id
+ : "";
+ };
+
+ const pageOf = (link) => {
+ const id = cardIdOf(link);
+ const el = id && document.getElementById(id);
+ return el?.tagName == "ARTICLE" ? el : null;
+ };
+
+ // build every card while this script parses, so the #hash target exists
+ for (const [key, card] of cards) {
+ const el = document.createElement("article");
+ if (key == HOME) el.className = "index";
+ else el.id = key;
+ if (card.title) el.dataset.title = card.title;
+ el.innerHTML = renderMd(card.md);
+ decorate(el);
+ document.currentScript.before(el);
+ }
+
+ function render() {
+ document.querySelectorAll("article.paper").forEach((el) => {
+ el.classList.remove("paper", "top");
+ el.removeAttribute("style");
+ });
+ for (let i = stack.length; i--; )
+ if (!document.getElementById(stack[i].id)) stack.splice(i, 1);
+ stack.forEach((card, i) => {
+ const el = document.getElementById(card.id);
+ el.classList.add("paper");
+ el.classList.toggle("top", i == stack.length - 1);
+ el.style.setProperty("--i", i + 1);
+ el.style.setProperty("--x", card.x + "px");
+ el.style.setProperty("--y", card.y + "px");
+ });
+ }
+
+ // new card sits just under the link that opened it, always stepped right
+ // of its parent card so a strip of the parent stays clickable
+ const spotUnder = (link) => {
+ const card = link.closest("article");
+ const box = link.getBoundingClientRect();
+ const cardBox = card.getBoundingClientRect();
+ const pad = parseFloat(getComputedStyle(card).paddingLeft);
+ return {
+ x: Math.round(Math.max(box.left - pad, cardBox.left) + 40 + scrollX),
+ y: Math.round(box.bottom + 4 + scrollY),
+ };
+ };
+
+ // show a page as a card, creating it and opening its editor if it is new.
+ // spotFn is only called when a position is actually needed.
+ const showCard = (id, spotFn, title) => {
+ const fresh = !document.getElementById(id);
+ if (fresh) editCard(id);
+ const card = document.getElementById(id);
+ if (title && !card.dataset.title) card.dataset.title = title;
+ labelCard(card);
+ const at = stack.findIndex((card) => card.id == id);
+ if (at < 0) stack.push({ id, ...spotFn() });
+ else stack.push(stack.splice(at, 1)[0]); // already open: raise it
+ render();
+ front(card);
+ if (fresh) document.querySelector(".editor textarea").focus();
+ };
+
+ // only the last-clicked card carries .front
+ const front = (card) => {
+ document
+ .querySelectorAll("article.front")
+ .forEach((el) => el.classList.remove("front"));
+ card?.classList.add("front");
+ };
+
+ // bring a card to the front. z-order only; no card moves or closes
+ const raiseCard = (card) => {
+ const at = stack.findIndex((c) => c.id == card.id);
+ if (at >= 0) {
+ stack.push(stack.splice(at, 1)[0]);
+ render();
+ }
+ front(card);
+ };
+
+ const openPage = (id, link) => {
+ if (id == location.hash.substring(1)) {
+ stack.length = 0;
+ render();
+ // the link text is the readable name for a page being created
+ } else showCard(id, () => spotUnder(link), link?.textContent.trim());
+ };
+
+ const hidePreview = () => {
+ clearTimeout(hoverTimer);
+ previewEl.hidden = true;
+ previewEl.innerHTML = "";
+ };
+
+ const showPreview = (link) => {
+ const page = pageOf(link);
+ if (!page || page.classList.contains("top")) return;
+ previewEl.innerHTML = page.innerHTML;
+ previewEl.hidden = false;
+ const w = previewEl.offsetWidth;
+ const spot = spotUnder(link);
+ previewEl.style.left =
+ Math.max(8, Math.min(spot.x, innerWidth - w - 8)) + "px";
+ previewEl.style.top = spot.y + "px";
+ };
+
+ document.addEventListener("mouseover", (ev) => {
+ const link = ev.target.closest?.("a");
+ if (link && pageOf(link)) {
+ clearTimeout(hoverTimer);
+ hoverTimer = setTimeout(() => showPreview(link), 250);
+ } else hidePreview();
+ });
+
+ const newCard = () => {
+ const name = prompt("Name for the new page")?.trim();
+ if (!name) return;
+ const id = slugify(name);
+ if (!id) return;
+ if (!document.getElementById(id)) pendingTags.set(id, ["inbox"]);
+ showCard(id, spotOffTop, name);
+ };
+
+ const renameCard = (btn) => {
+ const card = btn.closest("article");
+ const old = card.id;
+ if (!old) return;
+ if (card.querySelector(".editor"))
+ return alert("Finish the edit before renaming.");
+ const entry = cards.get(old);
+ if (!entry) return;
+ const title = prompt(
+ "Title for this card",
+ card.dataset.title || old
+ )?.trim();
+ if (!title) return;
+ const id = slugify(title);
+ if (!id) return;
+ if (id != old && document.getElementById(id))
+ return alert(`A card named "${id}" already exists.`);
+ entry.title = title;
+ card.dataset.title = title;
+ let changed = [id];
+ if (id != old) {
+ card.id = id;
+ cards.delete(old);
+ cards.set(id, entry);
+ // the new file is written first, the old one is deleted last
+ changed = [id, ...relink(old, id), old];
+ const stackEntry = stack.find((c) => c.id == old);
+ if (stackEntry) stackEntry.id = id;
+ if (location.hash.substring(1) == old) location.hash = id;
+ }
+ labelCard(card);
+ render();
+ save(...changed);
+ };
+
+ // close only this card; cards opened from it stay where they are
+ const closeCard = (btn) => {
+ const at = stack.findIndex((c) => c.id == btn.closest("article").id);
+ if (at < 0) return;
+ stack.splice(at, 1);
+ render();
+ };
+
+ // delete the page this button sits in. render() prunes the dead id.
+ const deleteCard = (btn) => {
+ const card = btn.closest("article");
+ const id = card.id;
+ if (!id) return alert("The home card cannot be deleted, only edited.");
+ if (!confirm(`Delete "${id}"? This cannot be undone.`)) return;
+ cards.delete(id);
+ card.remove();
+ render();
+ if (location.hash.substring(1) == id) location.hash = "";
+ save(id);
+ };
+
+ const editTags = (btn) => {
+ const card = btn.closest("article");
+ const key = keyOf(card.id);
+ const entry = cards.get(key);
+ if (!entry) return alert("Save the card first.");
+ const input = prompt("Tags, separated by commas", entry.tags.join(", "));
+ if (input === null) return;
+ entry.tags = parseTags(input);
+ labelCard(card);
+ save(key);
+ };
+
+ const actions = {
+ new: newCard,
+ cards: () => toggleCardList("cards"),
+ bookmarks: () => toggleCardList("bookmarks"),
+ bookmark: (btn) => toggleBookmark(btn),
+ retry: () => save(),
+ rename: renameCard,
+ tags: editTags,
+ delete: deleteCard,
+ close: closeCard,
+ cancel: cancelEdit,
+ done: doneEdit,
+ };
+
+ // the day a date chip points at: a stamp's own day; for a badge, the occurrence
+ // it shows (a repeating appointment's next one, else its start)
+ const dayOfChip = (el) => {
+ if (el.classList.contains("donestamp")) return el.textContent.slice(1, 11);
+ const d = D.parseDates(el.textContent)[0];
+ if (!d) return "";
+ const box = el.closest("li.task")?.querySelector("input[type=checkbox]");
+ const task = el.dataset.task !== undefined ? el.dataset.task == "1" : !!box;
+ return D.when({ task }, d, D.todayStr());
+ };
+
+ document.addEventListener("click", (ev) => {
+ const dateEl = ev.target.closest?.("article .when, article .donestamp");
+ if (dateEl) {
+ // a date inside a checklist label must not also tick the box
+ ev.preventDefault();
+ const day = dayOfChip(dateEl);
+ if (day) {
+ searchEl.value = "on:" + day + " ";
+ searchEl.focus();
+ runSearch();
+ }
+ return;
+ }
+ const tagEl = ev.target.closest?.("article .tag[data-tag]");
+ if (tagEl) {
+ // a chip inside a checklist label must not also tick the box
+ ev.preventDefault();
+ searchEl.value = "#" + tagEl.dataset.tag + " ";
+ searchEl.focus();
+ runSearch();
+ return;
+ }
+ const btn = ev.target.closest?.("button[data-act]");
+ if (btn) {
+ actions[btn.dataset.act]?.(btn);
+ return;
+ }
+ const link = ev.target.closest?.("a");
+ if (link) {
+ const id = cardIdOf(link);
+ if (!id) return; // external link
+ const target = document.getElementById(id);
+ if (target && target.tagName != "ARTICLE") return; // in-page anchor
+ if (!target && !validId(id)) return; // not a slug: let the browser handle it
+ ev.preventDefault();
+ hidePreview();
+ openPage(id, link);
+ return;
+ }
+ if (ev.target.closest?.("label, input, button, .editor")) return;
+ // any card, including the base one, comes forward; clicking the
+ // desk does nothing, so no card is ever closed by a stray click
+ const card = ev.target.closest?.("article:not(.preview)");
+ if (card) raiseCard(card);
+ });
+
+ // is a real glyph under the pointer, or just empty space?
+ const onText = (x, y) => {
+ const pos = document.caretPositionFromPoint(x, y);
+ const node = pos?.offsetNode;
+ if (node?.nodeType != 3 || !node.length) return false;
+ const probe = document.createRange();
+ const at = Math.min(pos.offset, node.length - 1);
+ probe.setStart(node, at);
+ probe.setEnd(node, at + 1);
+ const box = probe.getBoundingClientRect();
+ // the caret snaps to the nearest character, so check the pointer really
+ // sits on that character and not past the end of the line
+ return (
+ x >= box.left - 2 && x <= box.right + 2 && y >= box.top && y <= box.bottom
+ );
+ };
+
+ // drag a card from anywhere on it; interactive bits keep their behavior
+ document.addEventListener("pointerdown", (ev) => {
+ if (ev.button) return; // main button only
+ if (ev.target.closest?.("a, button, input, label, textarea, .editor"))
+ return;
+ if (onText(ev.clientX, ev.clientY)) return; // text: let it select
+ const card = ev.target.closest?.("article.paper");
+ const entry = stack.find((c) => c.id == card?.id);
+ if (!entry) return;
+ ev.preventDefault(); // whitespace: no selection or image drag
+ raiseCard(card);
+ const dx = ev.clientX + scrollX - entry.x;
+ const dy = ev.clientY + scrollY - entry.y;
+ let dragging = false;
+ const move = (e) => {
+ // a few pixels of slack, so a plain click still selects text
+ if (!dragging) {
+ if (
+ Math.abs(e.clientX - ev.clientX) + Math.abs(e.clientY - ev.clientY) <
+ 4
+ )
+ return;
+ dragging = true;
+ }
+ e.preventDefault();
+ entry.x = Math.round(e.clientX + scrollX - dx);
+ entry.y = Math.max(0, Math.round(e.clientY + scrollY - dy));
+ card.style.setProperty("--x", entry.x + "px");
+ card.style.setProperty("--y", entry.y + "px");
+ };
+ // pointer capture keeps the moves coming to this card, so there is
+ // nothing to clean up on the document
+ card.setPointerCapture(ev.pointerId);
+ card.addEventListener("pointermove", move);
+ card.addEventListener(
+ "pointerup",
+ () => card.removeEventListener("pointermove", move),
+ { once: true }
+ );
+ });
+
+ document.addEventListener("keydown", (ev) => {
+ if (ev.key != "Escape") return;
+ const card = document.querySelector("article.front");
+ const at = stack.findIndex((c) => c.id == card?.id);
+ if (at < 0) return; // nothing raised, or the base card is front
+ stack.splice(at, 1);
+ render();
+ front(document.getElementById(stack.at(-1)?.id));
+ });
+
+ // checkbox state lives in two places: the checked attribute, and the
+ // markdown so an edit keeps it too
+ // done entries shown on purpose, as "key:line"; see agendaItems
+ const keptTicks = new Set();
+
+ // the rendered element of a card, or null
+ const cardElOf = (key) =>
+ key == HOME ? document.querySelector("article.index") : document.getElementById(key);
+
+ // tick one checklist line in the markdown; null when it is not one
+ const applyTick = (key, no, checked) => {
+ const entry = cards.get(key);
+ const lines = entry.md.split("\n");
+ if (!/^[ \t]*- ?\[[ xX]\]/.test(lines[no] ?? "")) return null;
+ const r = D.completeLine(lines, no, checked, new Date());
+ entry.md = r.lines.join("\n");
+ if (r.inserted) {
+ // the history line pushes every later line down by one
+ const at = key.length + 1;
+ const moved = [...keptTicks].filter(
+ (t) => t.startsWith(key + ":") && Number(t.slice(at)) > no
+ );
+ for (const t of moved) keptTicks.delete(t);
+ for (const t of moved) keptTicks.add(key + ":" + (Number(t.slice(at)) + 1));
+ }
+ return r;
+ };
+
+ // redraw one card from its markdown, keeping its open panes open
+ const rerenderCard = (key) => {
+ const card = cardElOf(key);
+ if (!card) return;
+ // a re-render drops the open state, so keep it across the rebuild
+ const open = [...card.querySelectorAll("details")].map((d) => d.open);
+ card.innerHTML = renderMd(cards.get(key).md);
+ decorate(card);
+ addTools();
+ labelCard(card);
+ const panes = card.querySelectorAll("details");
+ if (panes.length == open.length) panes.forEach((d, i) => (d.open = open[i]));
+ };
+
+ // a tick in a query list acts like a tick on the card
+ const queryTick = (box) => {
+ const key = box.dataset.key;
+ const no = Number(box.dataset.line);
+ const entry = cards.get(key);
+ const refuse = (why) => {
+ box.checked = !box.checked;
+ statusEl.textContent = why;
+ fillQueries();
+ };
+ if (!entry) return refuse("Card not found");
+ if (entry.md.split("\n")[no] !== box.dataset.text)
+ return refuse("Line changed since the list was drawn");
+ if (cardElOf(key)?.querySelector(".editor")) return refuse("Card is open for editing");
+ const r = applyTick(key, no, box.checked);
+ if (!r) return refuse("Not a checklist line");
+ if (!r.repeat) {
+ if (box.checked) keptTicks.add(key + ":" + no);
+ else keptTicks.delete(key + ":" + no);
+ }
+ rerenderCard(key);
+ save(key);
+ };
+
+ document.addEventListener("change", (ev) => {
+ const box = ev.target;
+ if (box.classList.contains("qbox")) {
+ queryTick(box);
+ return;
+ }
+ if (box.type != "checkbox") return;
+ const card = box.closest("article");
+ if (!card || box.closest(".preview")) return;
+ const key = keyOf(card.id);
+ const entry = cards.get(key);
+ if (!entry) return;
+ box.toggleAttribute("checked", box.checked);
+ box.closest("li")?.querySelectorAll(".when").forEach(paintWhen);
+ // the box's label holds an HTML comment mark, L12, naming its source line
+ const mark = [...(box.closest("label")?.childNodes || [])].find(
+ (n) => n.nodeType == Node.COMMENT_NODE && /^L\d+$/.test(n.data)
+ );
+ if (!mark) return; // a box typed as raw HTML has no line to save
+ const no = Number(mark.data.slice(1));
+ const r = applyTick(key, no, box.checked);
+ if (!r) return;
+ if (r.repeat || r.inserted) {
+ rerenderCard(key);
+ } else {
+ // swap the done stamp chip in place
+ const label = box.closest("label");
+ label?.querySelectorAll(".donestamp").forEach((s) => {
+ const prev = s.previousSibling;
+ if (prev?.nodeType == Node.TEXT_NODE && !prev.nodeValue.trim()) prev.remove();
+ s.remove();
+ });
+ const last = [...r.lines[no].matchAll(D.stampRe())].at(-1);
+ if (label && box.checked && last) label.append(" ", stampChip(last[2], last[3]));
+ }
+ save(key);
+ });
+
+ // stack a card over the current top one, stepped down and to the right
+ const spotOffTop = () => {
+ const top =
+ document.querySelector("article.top") ||
+ document.querySelector("article:target") ||
+ document.querySelector("article.index");
+ const box = top?.getBoundingClientRect() || { left: 0, top: 0 };
+ return {
+ x: Math.round(box.left + 40 + scrollX),
+ y: Math.round(box.top + 40 + scrollY),
+ };
+ };
+
+ const slugify = (name) =>
+ name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/(^-|-$)/g, "");
+
+ // per-card chrome, added at runtime so it never lands in a page's markdown
+ const cardChrome =
+ '<div class="cardtools">' +
+ '<button data-act="bookmark" aria-label="Bookmark" aria-pressed="false">☆</button>' +
+ '<button data-act="delete">Delete</button>' +
+ '<button data-act="rename">Rename</button>' +
+ '<button data-act="tags">Tags</button>' +
+ '<button class="cardclose" data-act="close">×</button>' +
+ "</div>" +
+ '<h1 class="cardtitle"></h1>' +
+ '<div class="cardtags"></div>';
+ // the heading is the readable title; the slug never shows on the card
+ const labelCard = (card) => {
+ const head = card.querySelector("h1.cardtitle");
+ const title =
+ card.dataset.title ||
+ card.id.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
+ if (head && head.textContent != title) head.textContent = title;
+ const tagEl = card.querySelector(".cardtags");
+ const list = cards.get(keyOf(card.id))?.tags || [];
+ const star = card.querySelector('.cardtools [data-act="bookmark"]');
+ if (star) {
+ const on = list.includes(BOOKMARK);
+ star.textContent = on ? "★" : "☆";
+ star.setAttribute("aria-pressed", on);
+ star.title = on ? "Remove bookmark" : "Bookmark";
+ }
+ const tags = list.map((t) => "#" + t).join(" ");
+ if (tagEl && tagEl.textContent != tags)
+ tagEl.replaceChildren(
+ ...list.flatMap((t, i) => {
+ const tag = document.createElement("span");
+ tag.className = "tag";
+ tag.dataset.tag = t;
+ tag.textContent = "#" + t;
+ return i ? [" ", tag] : [tag];
+ })
+ );
+ };
+ function addTools() {
+ document.querySelectorAll("article:not(.preview)").forEach((el) => {
+ if (!el.querySelector(".cardtools"))
+ el.insertAdjacentHTML("afterbegin", cardChrome);
+ // textContent, not interpolation: an id may hold any characters
+ labelCard(el);
+ });
+ }
+
+ const nameOf = (key, card) =>
+ card.title || key.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
+
+ const WD = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
+
+ // dated entries of every card within days of today, sorted by date and time
+ const agendaItems = (today, days, want = [], skip = [], has = [], hasNot = []) => {
+ const items = [];
+ for (const [key, card] of cards) {
+ // a card tagged done is finished, so its dates no longer need attention
+ if (key == HOME || card.tags.includes("done")) continue;
+ if (skip.some((t) => card.tags.includes(t))) continue;
+ if (!hasOk(card, has, hasNot)) continue;
+ const lines = linesOf(card);
+ for (const e of D.entries(card.md)) {
+ if (e.done && !keptTicks.has(key + ":" + e.line)) continue;
+ // an entry carries its card's tags and those of its own line and parents
+ if (!fits(union(card.tags, lines[e.line]?.tags || []), want, skip)) continue;
+ for (const d of e.dates)
+ for (const { date, label } of D.agendaDays(e, d, today, days))
+ items.push({ key, card, e, d, w: date, label });
+ }
+ }
+ return items.sort(
+ (a, b) =>
+ a.w.localeCompare(b.w) ||
+ (a.d.time || "").localeCompare(b.d.time || "") ||
+ nameOf(a.key, a.card).localeCompare(nameOf(b.key, b.card))
+ );
+ };
+
+ // lines whose own or inherited tags, with their card's, meet a query. A card
+ // that matches on its card tags alone is listed as a card, so its lines are
+ // not repeated; under a matching line its children are not listed again.
+ const matchingLines = (want, skip, has, hasNot) => {
+ const hits = [];
+ for (const [key, card] of cards) {
+ if (key == HOME || skip.some((t) => card.tags.includes(t))) continue;
+ if (!hasOk(card, has, hasNot)) continue;
+ if (fits(card.tags, want, skip)) continue;
+ const listed = new Set();
+ for (const e of linesOf(card)) {
+ if (!e.tags.length) continue;
+ if (listed.has(e.parent)) {
+ listed.add(e.line);
+ continue;
+ }
+ if (!fits(union(card.tags, e.tags), want, skip)) continue;
+ listed.add(e.line);
+ hits.push({ key, card, e });
+ }
+ }
+ return hits;
+ };
+
+ const cardLink = (key, card) => {
+ const a = document.createElement("a");
+ a.href = "#" + key;
+ a.textContent = nameOf(key, card);
+ return a;
+ };
+
+ // a checkbox that ticks its source line; dataset.text guards against a changed line
+ const qbox = (key, line, checked, text) => {
+ const box = document.createElement("input");
+ box.type = "checkbox";
+ box.className = "qbox";
+ box.checked = checked;
+ box.dataset.key = key;
+ box.dataset.line = line;
+ box.dataset.text = cards.get(key)?.md.split("\n")[line] ?? "";
+ box.setAttribute("aria-label", "done: " + text);
+ return box;
+ };
+
+ // tag chips inside a query row: every text node except the card link and badges
+ const ROW_SKIP = "a, code, .tag, .when, .card";
+
+ // fill each {{tags:...}} block with the cards, then the lines, that match its tags
+ function fillQueries() {
+ document.querySelectorAll("article:not(.preview) .query").forEach((el) => {
+ const terms = el.dataset.q.split(" ").filter(Boolean);
+ if (!terms.length) {
+ el.replaceChildren();
+ return;
+ }
+ const { want, skip, has, hasNot } = splitTerms(terms);
+ const host = keyOf(el.closest("article").id);
+ const matches = [...cards]
+ .filter(([key, card]) => key != HOME && key != host && fits(card.tags, want, skip) && hasOk(card, has, hasNot))
+ .sort(([a, x], [b, y]) => nameOf(a, x).localeCompare(nameOf(b, y)));
+ const lines = matchingLines(want, skip, has, hasNot).sort(
+ (a, b) => nameOf(a.key, a.card).localeCompare(nameOf(b.key, b.card)) || a.e.line - b.e.line
+ );
+ if (!matches.length && !lines.length) {
+ const p = document.createElement("p");
+ p.className = "empty";
+ p.textContent = "No cards";
+ el.replaceChildren(p);
+ return;
+ }
+ const out = [];
+ if (matches.length) {
+ const ul = document.createElement("ul");
+ for (const [key, card] of matches) {
+ const li = document.createElement("li");
+ li.append(cardLink(key, card));
+ ul.append(li);
+ }
+ out.push(ul);
+ }
+ if (lines.length) {
+ const ul = document.createElement("ul");
+ ul.className = "lines";
+ for (const { key, card, e } of lines) {
+ const li = document.createElement("li");
+ if (e.task) li.className = e.done ? "task done" : "task";
+ const span = document.createElement("span");
+ span.className = "card";
+ span.append(cardLink(key, card));
+ li.append(
+ ...(e.task ? [qbox(key, e.line, e.done, e.text), " "] : []),
+ e.text,
+ span
+ );
+ // the row is built after the page-wide passes: badges first, then tag chips
+ decorate(li);
+ tagify(li, ROW_SKIP);
+ ul.append(li);
+ }
+ out.push(ul);
+ }
+ el.replaceChildren(...out);
+ });
+
+ // fill each {{agenda}} block, grouped by day
+ document.querySelectorAll("article:not(.preview) .agenda").forEach((el) => {
+ const today = D.todayStr();
+ const out = [];
+ let group = null;
+ const list = (s) => (s || "").split(" ").filter(Boolean);
+ for (const { key, card, e, d, w, label } of agendaItems(
+ today, Number(el.dataset.days), list(el.dataset.want), list(el.dataset.skip),
+ list(el.dataset.has), list(el.dataset.hasnot)
+ )) {
+ const g = w < today ? "overdue" : w;
+ if (g != group) {
+ group = g;
+ const head = document.createElement("div");
+ head.className = "day";
+ head.textContent =
+ g == "overdue"
+ ? "Overdue"
+ : g == today
+ ? "Today"
+ : WD[new Date(w + "T00:00").getDay()] + " " + w;
+ out.push(head);
+ }
+ const badge = badgeOf(d);
+ badge.dataset.task = e.task ? "1" : "";
+ paintWhen(badge);
+ const a = document.createElement("a");
+ a.href = "#" + key;
+ a.textContent = nameOf(key, card);
+ const cardEl = document.createElement("span");
+ cardEl.className = "card";
+ cardEl.append(a);
+ const item = document.createElement("div");
+ item.className = e.done ? "item done" : "item";
+ // a card-level date has no text of its own: the card link names it
+ item.append(
+ ...(e.task ? [qbox(key, e.line, e.done, e.text), " "] : []),
+ badge,
+ ...(e.text ? [" " + e.text] : []),
+ ...(label ? [" " + label] : []),
+ cardEl
+ );
+ tagify(item, ROW_SKIP);
+ out.push(item);
+ }
+ if (!out.length) {
+ const p = document.createElement("p");
+ p.className = "empty";
+ p.textContent = "Nothing scheduled";
+ out.push(p);
+ }
+ el.replaceChildren(...out);
+ });
+ }
+
+ // every link that pointed at the old id, in markdown and rendered html
+ const relink = (from, to) => {
+ const changed = [];
+ for (const [key, entry] of cards) {
+ const md = entry.md
+ .split(`](#${from})`)
+ .join(`](#${to})`)
+ .split(`href="#${from}"`)
+ .join(`href="#${to}"`);
+ if (md != entry.md) {
+ entry.md = md;
+ changed.push(key);
+ }
+ }
+ document.querySelectorAll("article a").forEach((a) => {
+ if (a.getAttribute("href") == "#" + from)
+ a.setAttribute("href", "#" + to);
+ });
+ return changed;
+ };
+
+ // one request at a time, in order; a key changed while its request is in
+ // flight is re-added by save() and goes out on the next flush
+ const dirty = new Set();
+ let queue = Promise.resolve();
+ const save = (...keys) => {
+ keys.forEach((k) => dirty.add(k));
+ statusEl.textContent = "Saving";
+ queue = queue.then(flush);
+ fillQueries();
+ if (!resultsEl.hidden) runSearch();
+ };
+ const flush = async () => {
+ for (const key of [...dirty]) {
+ dirty.delete(key);
+ try {
+ const res = cards.has(key)
+ ? await fetch(`api/cards/${key}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(cards.get(key)),
+ })
+ : await fetch(`api/cards/${key}`, { method: "DELETE" });
+ if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
+ } catch (err) {
+ dirty.add(key);
+ statusEl.textContent = `Not saved: ${err.message}`;
+ statusEl.classList.add("failed");
+ retryBtn.hidden = false;
+ return;
+ }
+ }
+ if (dirty.size == 0) {
+ statusEl.textContent = "Saved";
+ statusEl.classList.remove("failed");
+ retryBtn.hidden = true;
+ }
+ };
+ addEventListener("beforeunload", (ev) => {
+ if (dirty.size) ev.preventDefault();
+ });
+
+ window.addEventListener("hashchange", () => {
+ stack.length = 0;
+ hidePreview();
+ render();
+ });
+
+ const editorEl = document.querySelector(".editor");
+ const mdInput = editorEl.querySelector("textarea");
+ // Tab types a tab character. Shift+Tab keeps its normal job of moving focus
+ // out of the editor, so the keyboard is never trapped in the text box.
+ mdInput.addEventListener("keydown", (ev) => {
+ if (ev.key != "Tab" || ev.shiftKey || ev.ctrlKey || ev.altKey || ev.metaKey) return;
+ ev.preventDefault();
+ // insertText keeps the browser's undo history; the fallback edits the value directly
+ if (!document.execCommand("insertText", false, "\t")) {
+ const { selectionStart: a, selectionEnd: b, value } = mdInput;
+ mdInput.value = value.slice(0, a) + "\t" + value.slice(b);
+ mdInput.selectionStart = mdInput.selectionEnd = a + 1;
+ }
+ });
+ const hashEl = editorEl.querySelector(".hash");
+
+ let currentId = "";
+ let createdCard = false;
+ let editing = null; // the card the editor is parked in
+ const pendingTags = new Map(); // tags for a new card, until its first save
+
+ // the rendered card for currentId, created empty if it does not exist
+ const viewCard = () => {
+ let el = currentId
+ ? document.getElementById(currentId)
+ : document.querySelector("article.index");
+ createdCard = !el;
+ if (!el) {
+ el = document.createElement("article");
+ el.id = currentId;
+ document.body.append(el);
+ addTools();
+ }
+ return el;
+ };
+ // back to the body, hidden, so re-rendering a card cannot eat it
+ const park = () => {
+ editorEl.style.display = "none";
+ document.body.append(editorEl);
+ };
+
+ const edit = () => {
+ hashEl.textContent = currentId;
+ mdInput.value = cards.get(keyOf(currentId))?.md || "";
+ editing = viewCard();
+ editing.append(editorEl);
+ editorEl.style.display = "";
+ mdInput.focus();
+ };
+ // open the editor on a given page, creating its card if needed
+ function editCard(id) {
+ if (id && !validId(id)) return;
+ currentId = id;
+ edit();
+ }
+ document.addEventListener("dblclick", (ev) => {
+ if (ev.target.closest?.(".editor")) return;
+ editCard(ev.target.closest?.("article")?.id ?? currentId);
+ });
+
+ const onLoad = () => {
+ currentId = location.hash.substring(1);
+ if ((!currentId || validId(currentId)) && !cards.has(keyOf(currentId)))
+ edit();
+ };
+ window.addEventListener("hashchange", onLoad);
+
+ function cancelEdit() {
+ pendingTags.delete(currentId);
+ park();
+ if (!createdCard) return;
+ // the card only existed for this edit, so drop it again
+ editing.remove();
+ if (location.hash.substring(1) == currentId) location.hash = "";
+ render();
+ }
+ function doneEdit() {
+ const md = mdInput.value;
+ park();
+ const key = keyOf(currentId);
+ // the lines may have moved, so this card's kept ticks go
+ for (const t of [...keptTicks]) if (t.startsWith(key + ":")) keptTicks.delete(t);
+ const view = viewCard();
+ const tags = cards.get(key)?.tags ?? pendingTags.get(key) ?? [];
+ pendingTags.delete(key);
+ if (md.trim() || key == HOME) {
+ cards.set(key, { title: view.dataset.title || "", tags, md });
+ view.innerHTML = renderMd(md);
+ decorate(view);
+ addTools();
+ labelCard(view);
+ // a stacked card stays put; only a base card needs the hash nudge
+ if (!view.classList.contains("paper")) {
+ location.hash = "";
+ location.hash = currentId;
+ }
+ } else {
+ cards.delete(key);
+ view.remove();
+ location.hash = "";
+ render();
+ }
+ save(key);
+ }
+
+ // search box: cards by words, #tags and -#tags; tag counts
+ const searchEl = document.querySelector(".searchbox");
+ const resultsEl = document.querySelector(".results");
+ let active = 0;
+
+ const emptyRow = (text) => {
+ const el = document.createElement("div");
+ el.className = "empty";
+ el.textContent = text;
+ return el;
+ };
+
+ const tagRows = (prefix) => {
+ const counts = new Map();
+ for (const [key, card] of cards)
+ if (key != HOME)
+ for (const t of union(card.tags, lineTagsOf(card))) counts.set(t, (counts.get(t) || 0) + 1);
+ return [...counts]
+ .filter(([t]) => t.startsWith(prefix))
+ .sort(([a, x], [b, y]) => y - x || a.localeCompare(b))
+ .slice(0, 50)
+ .map(([t, n]) => {
+ const row = document.createElement("div");
+ row.className = "row";
+ row.setAttribute("role", "option");
+ row.dataset.tag = t;
+ row.textContent = `#${t} (${n})`;
+ return row;
+ });
+ };
+
+ // text around the first word found in md; its start when none is
+ const snippetOf = (md, words) => {
+ const text = md.replace(/\s+/g, " ");
+ const lower = text.toLowerCase();
+ let at = -1;
+ for (const w of words) if ((at = lower.indexOf(w)) >= 0) break;
+ if (at < 0) return text.slice(0, 100);
+ const start = Math.max(0, at - 40);
+ const end = Math.min(text.length, at + 60);
+ return (start ? "…" : "") + text.slice(start, end) + (end < text.length ? "…" : "");
+ };
+
+ const cardRows = (words, want, skip, has, hasNot) =>
+ [...cards]
+ .filter(
+ ([key, card]) =>
+ key != HOME &&
+ // #tag finds a card that has the tag on the card or on any line
+ fits(union(card.tags, lineTagsOf(card)), want, []) &&
+ !skip.some((t) => card.tags.includes(t)) &&
+ hasOk(card, has, hasNot) &&
+ words.every(
+ (w) =>
+ nameOf(key, card).toLowerCase().includes(w) ||
+ card.md.toLowerCase().includes(w)
+ )
+ )
+ .map(([key, card]) => {
+ const name = nameOf(key, card);
+ const lower = name.toLowerCase();
+ const score = words.filter((w) => lower.includes(w)).length;
+ return { key, card, name, score };
+ })
+ .sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
+ .slice(0, 50)
+ .map(({ key, card, name }) => {
+ const row = document.createElement("div");
+ row.className = "row";
+ row.setAttribute("role", "option");
+ row.dataset.key = key;
+ const nameEl = document.createElement("span");
+ nameEl.className = "name";
+ nameEl.textContent = name;
+ row.append(nameEl);
+ if (card.tags.length) {
+ const tags = document.createElement("span");
+ tags.className = "tags";
+ tags.textContent = card.tags.map((t) => "#" + t).join(" ");
+ row.append(tags);
+ }
+ const snip = document.createElement("span");
+ snip.className = "snip";
+ snip.textContent = snippetOf(card.md, words.length ? words : want.map((t) => "#" + t));
+ row.append(snip);
+ return row;
+ });
+
+ // date terms in a search: !overdue, @today, !3d, @2026-10-20
+ const DATE_RE = /^([!@])(overdue|today|\d+d|\d{4}-\d\d-\d\d)$/;
+
+ const dateMatch = (what, e, d, today) => {
+ return D.matchTerm(what, e, d, today);
+ };
+
+ const resultRow = (key, name, text, snip) => {
+ const row = document.createElement("div");
+ row.className = "row";
+ row.setAttribute("role", "option");
+ row.dataset.key = key;
+ // a card-level line has no text of its own: show the card name once
+ for (const [cls, t] of [["name", text || name], ["tags", text ? name : ""], ["snip", snip]]) {
+ if (!t) continue;
+ const s = document.createElement("span");
+ s.className = cls;
+ s.textContent = t;
+ row.append(s);
+ }
+ return row;
+ };
+
+ // on:day lists everything on that day: open deadlines, schedules and ranges
+ // that cover it, then every line with a completion stamp from it
+ const ON_RE = /^on:(\d{4}-\d\d-\d\d)$/i;
+ const onRows = (words, want, skip, has, hasNot, day) => {
+ const hits = [];
+ for (const [key, card] of cards) {
+ if (key == HOME || skip.some((t) => card.tags.includes(t)) || !hasOk(card, has, hasNot)) continue;
+ const name = nameOf(key, card);
+ const lines = linesOf(card);
+ const tagsOk = (no) => fits(union(card.tags, lines[no]?.tags || []), want, skip);
+ const wordsOk = (text) =>
+ words.every((w) => text.toLowerCase().includes(w) || name.toLowerCase().includes(w));
+ for (const e of D.entries(card.md)) {
+ if (e.done || !tagsOk(e.line) || !wordsOk(e.text || "")) continue;
+ const d = e.dates.find((d) => D.occursOn(e, d, day));
+ if (d) hits.push({ key, name, text: e.text, snip: d.raw, order: 0 });
+ }
+ card.md.split("\n").forEach((line, no) => {
+ const m = [...line.matchAll(D.stampRe())].find((m) => m[2] == day);
+ if (!m) return;
+ // date tokens out, as in the agenda; a history line holds only its stamp,
+ // so it is named after the task above it
+ const clean = (s) => (s || "").replace(D.tokenRe(), "$1").replace(/\s+/g, " ").trim();
+ let text = clean(lines[no]?.text);
+ const parent = lines[no]?.parent ?? -1;
+ if (!text && parent >= 0) text = clean(lines[parent]?.text) + " (earlier)";
+ if (tagsOk(no) && wordsOk(text)) hits.push({ key, name, text, snip: "x" + m[2] + "T" + m[3], order: 1 });
+ });
+ }
+ return hits
+ .sort((a, b) => a.order - b.order || a.snip.localeCompare(b.snip) || a.name.localeCompare(b.name))
+ .slice(0, 50)
+ .map(({ key, name, text, snip }) => resultRow(key, name, text, snip));
+ };
+
+ // one row per open entry that meets every date term and word
+ const entryRows = (words, want, skip, dateTerms, has, hasNot) => {
+ const today = D.todayStr();
+ const hits = [];
+ for (const [key, card] of cards) {
+ if (key == HOME || skip.some((t) => card.tags.includes(t))) continue;
+ if (!hasOk(card, has, hasNot)) continue;
+ const name = nameOf(key, card);
+ const lines = linesOf(card);
+ for (const e of D.entries(card.md)) {
+ if (e.done) continue;
+ if (!fits(union(card.tags, lines[e.line]?.tags || []), want, skip)) continue;
+ const text = (e.text || "").toLowerCase();
+ const lowName = name.toLowerCase();
+ if (!words.every((w) => text.includes(w) || lowName.includes(w))) continue;
+ let first = null;
+ const ok = dateTerms.every((term) => {
+ const hit = e.dates.find(
+ (d) => d.kind == term.kind && dateMatch(term.what, e, d, today)
+ );
+ first ||= hit;
+ return hit;
+ });
+ if (ok) hits.push({ key, name, e, d: first, w: D.when(e, first, today) });
+ }
+ }
+ return hits
+ .sort((a, b) => a.w.localeCompare(b.w))
+ .slice(0, 50)
+ .map(({ key, name, e, d }) => {
+ const row = document.createElement("div");
+ row.className = "row";
+ row.setAttribute("role", "option");
+ row.dataset.key = key;
+ // a card-level date has no text of its own: show the card name once
+ for (const [cls, text] of [
+ ["name", e.text || name],
+ ["tags", e.text ? name : ""],
+ ["snip", d.raw],
+ ]) {
+ if (!text) continue;
+ const s = document.createElement("span");
+ s.className = cls;
+ s.textContent = text;
+ row.append(s);
+ }
+ return row;
+ });
+ };
+
+ const runSearch = () => {
+ const v = searchEl.value;
+ if (!v.trim()) {
+ resultsEl.hidden = true;
+ return;
+ }
+ const tokens = v.trim().split(/\s+/);
+ let rows;
+ // a partial tag as the last word lists tags to complete it; a whole tag
+ // already names cards, so it searches
+ const last = tokens.at(-1);
+ const prefix =
+ !/\s$/.test(v) && /^-?#[a-z0-9_-]*$/i.test(last)
+ ? last.split("#")[1].toLowerCase()
+ : null;
+ const whole = prefix && [...cards.values()].some((c) => c.tags.includes(prefix));
+ if (prefix !== null && !whole) {
+ rows = tagRows(prefix);
+ if (!rows.length) rows = [emptyRow("No tags")];
+ } else {
+ // has:tag and -has:tag test the card's open checklist lines
+ const isHas = (t) => /^-?has:[a-z0-9][a-z0-9_-]*$/i.test(t);
+ const has = tokens.filter((t) => /^has:/i.test(t) && isHas(t)).map((t) => t.slice(4).toLowerCase());
+ const hasNot = tokens.filter((t) => /^-has:/i.test(t) && isHas(t)).map((t) => t.slice(5).toLowerCase());
+ const words = tokens
+ .filter(
+ (t) =>
+ !t.startsWith("#") && !t.startsWith("-#") && !DATE_RE.test(t.toLowerCase()) && !isHas(t) && !ON_RE.test(t)
+ )
+ .map((t) => t.toLowerCase());
+ const onDay = tokens.map((t) => ON_RE.exec(t)?.[1]).find(Boolean);
+ const dateTerms = tokens
+ .map((t) => DATE_RE.exec(t.toLowerCase()))
+ .filter(Boolean)
+ .map((m) => ({ kind: m[1], what: m[2] }));
+ const want = tokens
+ .filter((t) => t.startsWith("#"))
+ .map((t) => t.slice(1).toLowerCase());
+ const skip = tokens
+ .filter((t) => t.startsWith("-#"))
+ .map((t) => t.slice(2).toLowerCase());
+ if (!words.length && !want.length && !skip.length && !dateTerms.length && !has.length && !hasNot.length && !onDay) {
+ resultsEl.hidden = true;
+ return;
+ }
+ rows = onDay
+ ? onRows(words, want, skip, has, hasNot, onDay)
+ : dateTerms.length
+ ? entryRows(words, want, skip, dateTerms, has, hasNot)
+ : cardRows(words, want, skip, has, hasNot);
+ if (!rows.length) rows = [emptyRow(onDay || dateTerms.length ? "No entries" : "No cards")];
+ }
+ resultsEl.replaceChildren(...rows);
+ active = 0;
+ rows[0].classList.add("active");
+ resultsEl.hidden = false;
+ };
+
+ const pick = (row) => {
+ if (row.dataset.tag) {
+ const head = searchEl.value.replace(/\S*$/, "");
+ const sign = searchEl.value.slice(head.length).startsWith("-") ? "-" : "";
+ searchEl.value = head + sign + "#" + row.dataset.tag + " ";
+ searchEl.focus();
+ runSearch();
+ } else if (row.dataset.key) {
+ resultsEl.hidden = true;
+ if (location.hash.substring(1) != row.dataset.key)
+ showCard(row.dataset.key, spotOffTop);
+ }
+ };
+
+ // cards panel: every card by name, narrowed by the filter box
+ const cardListEl = document.querySelector(".cardlist");
+ const cardFilterEl = cardListEl.querySelector(".cardfilter");
+ const cardRowsEl = cardListEl.querySelector(".cardrows");
+ const cardsBtn = document.querySelector('button[data-act="cards"]');
+ const bookmarksBtn = document.querySelector('button[data-act="bookmarks"]');
+ let listMode = ""; // "cards" or "bookmarks" while the panel is open
+
+ // a bookmark is the card tag "bookmark"; the star on each card turns it on or off
+ const toggleBookmark = (btn) => {
+ const card = btn.closest("article");
+ const key = keyOf(card.id);
+ const entry = cards.get(key);
+ if (!entry) return alert("Save the card first.");
+ entry.tags = entry.tags.includes(BOOKMARK)
+ ? entry.tags.filter((t) => t != BOOKMARK)
+ : [...entry.tags, BOOKMARK];
+ labelCard(card);
+ save(key);
+ if (listMode == "bookmarks") openCardList("bookmarks");
+ };
+
+ const cardRow = (key, name, tags) => {
+ const row = document.createElement("div");
+ row.className = "row";
+ row.setAttribute("role", "option");
+ row.dataset.key = key;
+ const nameEl = document.createElement("span");
+ nameEl.className = "name";
+ nameEl.textContent = name;
+ row.append(nameEl);
+ if (tags.length) {
+ const tagEl = document.createElement("span");
+ tagEl.className = "tags";
+ tagEl.textContent = tags.map((t) => "#" + t).join(" ");
+ row.append(tagEl);
+ }
+ return row;
+ };
+ const visibleRows = () =>
+ [...cardRowsEl.querySelectorAll(".row")].filter((r) => !r.hidden);
+ const setActive = (row) => {
+ cardRowsEl.querySelectorAll(".row.active").forEach((r) => r.classList.remove("active"));
+ row?.classList.add("active");
+ };
+ // one panel serves both buttons: every card, or only the bookmarked ones
+ const openCardList = (mode) => {
+ resultsEl.hidden = true;
+ const marks = mode == "bookmarks";
+ const others = [...cards]
+ .filter(([key, card]) => key != HOME && (!marks || card.tags.includes(BOOKMARK)))
+ .sort(([a, x], [b, y]) => nameOf(a, x).localeCompare(nameOf(b, y)));
+ cardRowsEl.replaceChildren(
+ ...(marks ? [] : [cardRow("", "Home", [])]),
+ ...others.map(([key, card]) => cardRow(key, nameOf(key, card), card.tags))
+ );
+ if (marks && !others.length) cardRowsEl.append(emptyRow("No bookmarks yet: use ☆ on a card"));
+ listMode = mode;
+ cardFilterEl.value = "";
+ cardFilterEl.placeholder = marks ? "Filter bookmarks" : "Filter cards";
+ cardFilterEl.setAttribute("aria-label", cardFilterEl.placeholder);
+ cardListEl.hidden = false;
+ cardsBtn.setAttribute("aria-expanded", !marks);
+ bookmarksBtn.setAttribute("aria-expanded", marks);
+ cardFilterEl.focus();
+ setActive(visibleRows()[0]);
+ };
+ const closeCardList = () => {
+ cardListEl.hidden = true;
+ listMode = "";
+ cardsBtn.setAttribute("aria-expanded", "false");
+ bookmarksBtn.setAttribute("aria-expanded", "false");
+ };
+ const toggleCardList = (mode) =>
+ cardListEl.hidden || listMode != mode ? openCardList(mode) : closeCardList();
+ const openRow = (row) => {
+ closeCardList();
+ const key = row.dataset.key;
+ if (key == "") location.hash = "";
+ else if (key != location.hash.substring(1)) showCard(key, spotOffTop);
+ };
+ cardFilterEl.addEventListener("input", () => {
+ const tokens = cardFilterEl.value.toLowerCase().split(/\s+/).filter(Boolean);
+ for (const row of cardRowsEl.querySelectorAll(".row")) {
+ const text = row.textContent.toLowerCase();
+ row.hidden = !tokens.every((t) => text.includes(t));
+ }
+ setActive(visibleRows()[0]);
+ });
+ cardFilterEl.addEventListener("keydown", (ev) => {
+ if (ev.key == "ArrowDown" || ev.key == "ArrowUp") {
+ ev.preventDefault();
+ const rows = visibleRows();
+ const at = rows.findIndex((r) => r.classList.contains("active"));
+ const next = Math.max(
+ 0,
+ Math.min(rows.length - 1, at + (ev.key == "ArrowDown" ? 1 : -1))
+ );
+ setActive(rows[next]);
+ rows[next]?.scrollIntoView({ block: "nearest" });
+ } else if (ev.key == "Enter") {
+ ev.preventDefault();
+ const row = cardRowsEl.querySelector(".row.active");
+ if (row) openRow(row);
+ } else if (ev.key == "Escape") {
+ ev.preventDefault();
+ ev.stopPropagation();
+ closeCardList();
+ }
+ });
+ cardRowsEl.addEventListener("mousedown", (ev) => ev.preventDefault());
+ cardRowsEl.addEventListener("click", (ev) => {
+ const row = ev.target.closest(".row");
+ if (row) openRow(row);
+ ev.stopPropagation();
+ });
+
+ searchEl.addEventListener("input", runSearch);
+ searchEl.addEventListener("focus", runSearch);
+ searchEl.addEventListener("keydown", (ev) => {
+ const rows = [...resultsEl.querySelectorAll(".row")];
+ if (ev.key == "ArrowDown" || ev.key == "ArrowUp") {
+ ev.preventDefault();
+ rows[active]?.classList.remove("active");
+ active = Math.max(
+ 0,
+ Math.min(rows.length - 1, active + (ev.key == "ArrowDown" ? 1 : -1))
+ );
+ rows[active]?.classList.add("active");
+ rows[active]?.scrollIntoView({ block: "nearest" });
+ } else if (ev.key == "Enter") {
+ ev.preventDefault();
+ if (rows[active]) pick(rows[active]);
+ } else if (ev.key == "Tab") {
+ if (rows[active]?.dataset.tag) {
+ ev.preventDefault();
+ pick(rows[active]);
+ }
+ } else if (ev.key == "Escape") {
+ searchEl.value = "";
+ resultsEl.hidden = true;
+ searchEl.blur();
+ ev.stopPropagation();
+ }
+ });
+ resultsEl.addEventListener("mousedown", (ev) => ev.preventDefault());
+ resultsEl.addEventListener("click", (ev) => {
+ const row = ev.target.closest(".row");
+ if (row) pick(row);
+ ev.stopPropagation();
+ });
+ document.addEventListener("pointerdown", (ev) => {
+ if (!ev.target.closest?.(".search")) resultsEl.hidden = true;
+ if (!cardListEl.contains(ev.target) && !cardsBtn.contains(ev.target) && !bookmarksBtn.contains(ev.target))
+ closeCardList();
+ });
+ document.addEventListener("keydown", (ev) => {
+ if (ev.key != "/" || ev.ctrlKey || ev.metaKey || ev.altKey) return;
+ if (ev.target.closest?.("input, textarea, [contenteditable]")) return;
+ ev.preventDefault();
+ searchEl.focus();
+ searchEl.select();
+ });
+
+ let lastDay = D.todayStr();
+ document.addEventListener("visibilitychange", () => {
+ if (document.hidden || D.todayStr() == lastDay) return;
+ lastDay = D.todayStr();
+ keptTicks.clear();
+ fillQueries();
+ document.querySelectorAll("article:not(.preview) .when").forEach(paintWhen);
+ });
+
+ addTools();
+ fillQueries();
+ onLoad();
+}
--- /dev/null
+(function (root) {
+ const DAY = 86400000;
+ const pad = (n) => (n < 10 ? "0" : "") + n;
+
+ // Dates are "YYYY-MM-DD" strings. All date math runs in UTC, so DST never matters.
+ const toUtc = (s) => {
+ const [y, m, d] = s.split("-").map(Number);
+ return new Date(Date.UTC(y, m - 1, d));
+ };
+ const fromUtc = (dt) =>
+ dt.getUTCFullYear() + "-" + pad(dt.getUTCMonth() + 1) + "-" + pad(dt.getUTCDate());
+ const addDays = (date, n) => fromUtc(new Date(toUtc(date).getTime() + n * DAY));
+
+ // Range form: a double dash joins the start and end, for dates and for times.
+ function tokenRe() {
+ return /(^|[\s(])([!@])(\d{4}-\d\d-\d\d)(?:T(\d\d:\d\d)(?:--(\d\d:\d\d))?)?(?:--(\d{4}-\d\d-\d\d)(?:T(\d\d:\d\d))?)?(?:(\.?\+)(\d+)([dwmy]))?(?=$|[\s),.;:])/g;
+ }
+
+ function todayStr(now = new Date()) {
+ return now.getFullYear() + "-" + pad(now.getMonth() + 1) + "-" + pad(now.getDate());
+ }
+
+ function parseDates(text) {
+ const out = [];
+ const re = tokenRe();
+ let m;
+ while ((m = re.exec(text)) !== null) {
+ // A backwards range is ignored.
+ const endDate = m[6] && m[6] >= m[3] ? m[6] : "";
+ out.push({
+ kind: m[2],
+ date: m[3],
+ time: m[4] || "",
+ endDate,
+ endTime: m[7] || m[5] || "",
+ rep: m[8] ? { from: m[8], n: Number(m[9]), unit: m[10] } : null,
+ raw: m[0].slice(m[1].length),
+ index: m.index + m[1].length,
+ });
+ }
+ return out;
+ }
+
+ function addInterval(date, n, unit) {
+ if (unit == "d") return addDays(date, n);
+ if (unit == "w") return addDays(date, 7 * n);
+ const [y, m, d] = date.split("-").map(Number);
+ const total = y * 12 + (m - 1) + (unit == "y" ? 12 * n : n);
+ const ty = Math.floor(total / 12);
+ const tm = total - ty * 12;
+ // Day 0 of the next month is the last day of month tm.
+ const last = new Date(Date.UTC(ty, tm + 1, 0)).getUTCDate();
+ return ty + "-" + pad(tm + 1) + "-" + pad(Math.min(d, last));
+ }
+
+ function daysBetween(a, b) {
+ return Math.round((toUtc(b) - toUtc(a)) / DAY);
+ }
+
+ // Offset (n times the step count) that puts date on or after today.
+ function offsetPast(date, today, n, unit) {
+ if (!(n >= 1)) throw new RangeError("repeat step must be 1 or more");
+ if (unit == "d" || unit == "w") {
+ // Compute the step count directly. Looping would take thousands of steps.
+ const step = unit == "w" ? 7 * n : n;
+ return Math.ceil(daysBetween(date, today) / step) * n;
+ }
+ // Month and year steps clamp the day, so each step starts from the original date.
+ for (let k = 1; ; k++) {
+ if (addInterval(date, k * n, unit) >= today) return k * n;
+ }
+ }
+
+ function nextOccurrence(d, today) {
+ if (!d.rep || d.date >= today) return d.date;
+ return addInterval(d.date, offsetPast(d.date, today, d.rep.n, d.rep.unit), d.rep.unit);
+ }
+
+ // Start and end of a token. A past repeating non-task moves forward by whole steps.
+ function bounds(entry, d, today) {
+ const end = d.endDate || d.date;
+ if (!d.rep || entry.task || end >= today) return { start: d.date, end };
+ const { n, unit } = d.rep;
+ const off = offsetPast(end, today, n, unit);
+ return { start: addInterval(d.date, off, unit), end: addInterval(end, off, unit) };
+ }
+
+ function status(date, today) {
+ if (date < today) return "overdue";
+ if (date == today) return "today";
+ return daysBetween(today, date) <= 7 ? "soon" : "later";
+ }
+
+ function badgeStatus(entry, d, today) {
+ const b = bounds(entry, d, today);
+ if (b.end < today) return entry.task ? "overdue" : "past";
+ if (b.start <= today && today <= b.end) return "today";
+ return status(b.start, today);
+ }
+
+ // One item per day in the window. A single date gives one item with no label.
+ function agendaDays(entry, d, today, days) {
+ const win = addDays(today, days);
+ const b = bounds(entry, d, today);
+ if (entry.task && b.end < today) return [{ date: b.end, label: "" }];
+ if (b.end < today || b.start > win) return [];
+ if (b.start == b.end) return [{ date: b.start, label: "" }];
+ const n = daysBetween(b.start, b.end) + 1;
+ const last = b.end < win ? b.end : win;
+ const out = [];
+ for (let day = b.start > today ? b.start : today; day <= last; day = addDays(day, 1)) {
+ out.push({ date: day, label: `(${daysBetween(b.start, day) + 1}/${n})` });
+ }
+ return out;
+ }
+
+ function matchTerm(what, entry, d, today) {
+ const b = bounds(entry, d, today);
+ if (what == "overdue") return entry.task && b.end < today;
+ if (what == "today") return b.start <= today && today <= b.end;
+ const span = /^(\d+)d$/.exec(what);
+ if (span) {
+ const win = addDays(today, Number(span[1]));
+ return (entry.task && b.end < today) || (b.end >= today && b.start <= win);
+ }
+ return /^\d{4}-\d\d-\d\d$/.test(what) && b.start <= what && what <= b.end;
+ }
+
+ function advance(text, today) {
+ let out = "";
+ let last = 0;
+ for (const d of parseDates(text)) {
+ if (!d.rep) continue;
+ const { from, n, unit } = d.rep;
+ const next = addInterval(from == ".+" ? today : d.date, n, unit);
+ let range = "";
+ if (d.endDate) {
+ const nextEnd =
+ from == ".+"
+ ? addInterval(next, daysBetween(d.date, d.endDate), "d")
+ : addInterval(d.endDate, n, unit);
+ range = "--" + nextEnd + (d.endTime ? "T" + d.endTime : "");
+ } else if (d.endTime) {
+ range = "--" + d.endTime;
+ }
+ // Rebuild the token from its fields. The repeat text stays the same.
+ const time = d.time ? "T" + d.time : "";
+ out += text.slice(last, d.index) + d.kind + next + time + range + from + n + unit;
+ last = d.index + d.raw.length;
+ }
+ return out + text.slice(last);
+ }
+
+ function cleanText(line) {
+ return line
+ .replace(/^\s*- /, "")
+ .replace(/^\[[ xX]\] ?/, "")
+ .replace(stampRe(), "")
+ .replace(tokenRe(), "$1")
+ .replace(/\s+/g, " ")
+ .trim();
+ }
+
+ function entries(md) {
+ const out = [];
+ const lines = md.split("\n");
+ let fenced = false;
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ if (line.startsWith("```")) {
+ fenced = !fenced;
+ continue;
+ }
+ if (fenced) continue;
+ const dates = parseDates(line);
+ if (dates.length == 0) continue;
+ out.push({
+ line: i,
+ task: /^\s*- \[[ xX]\]/.test(line),
+ done: /^\s*- \[[xX]\]/.test(line),
+ text: cleanText(line),
+ dates,
+ });
+ }
+ return out;
+ }
+
+ function when(entry, d, today) {
+ return bounds(entry, d, today).start;
+ }
+
+ // does this date put the entry on day? A repeating appointment counts on each
+ // occurrence; a task or a plain date only on its own date or range
+ function occursOn(entry, d, day) {
+ const end = d.endDate || d.date;
+ if (day < d.date) return false;
+ if (day <= end) return true;
+ if (!d.rep || entry.task) return false;
+ const { n, unit } = d.rep;
+ const len = daysBetween(d.date, end);
+ // day and week steps jump straight to the occurrence just before day
+ let k = 1;
+ if (unit == "d" || unit == "w")
+ k = Math.max(1, Math.floor(daysBetween(d.date, day) / ((unit == "w" ? 7 : 1) * n)));
+ for (let start = addInterval(d.date, k * n, unit); start <= day; start = addInterval(d.date, ++k * n, unit))
+ if (day <= addDays(start, len)) return true;
+ return false;
+ }
+
+ // Completion stamp: "x" + local date + "T" + local time, e.g. "x2026-10-15T14:32".
+ function stampRe() {
+ return /(^|\s)x(\d{4}-\d\d-\d\d)T(\d\d:\d\d)(?=$|\s)/g;
+ }
+
+ function stampOf(now) {
+ return "x" + todayStr(now) + "T" + pad(now.getHours()) + ":" + pad(now.getMinutes());
+ }
+
+ // Returns a new array. A repeating task that had a stamp gets a history line.
+ function completeLine(lines, i, checked, now) {
+ const [, lead, rest] = /^([ \t]*- ?)\[[ xX]\](.*)$/.exec(lines[i]);
+ const first = stampRe().exec(rest);
+ const old = first ? first[2] + "T" + first[3] : null;
+ const bare = rest.replace(stampRe(), "").trimEnd();
+ const out = lines.slice();
+ const result = { lines: out, repeat: false, inserted: 0 };
+ if (!checked) {
+ out[i] = lead + "[ ]" + bare;
+ } else if (parseDates(rest).some((d) => d.rep)) {
+ out[i] = lead + "[ ]" + advance(bare, todayStr(now)) + " " + stampOf(now);
+ result.repeat = true;
+ if (old !== null) {
+ const indent = /^[ \t]*/.exec(lead)[0];
+ out.splice(i + 1, 0, indent + "\t- x" + old);
+ result.inserted = 1;
+ }
+ } else {
+ out[i] = lead + "[x]" + bare + " " + stampOf(now);
+ }
+ return result;
+ }
+
+ const api = {
+ tokenRe,
+ stampRe,
+ stampOf,
+ completeLine,
+ parseDates,
+ todayStr,
+ addInterval,
+ daysBetween,
+ nextOccurrence,
+ bounds,
+ status,
+ badgeStatus,
+ agendaDays,
+ matchTerm,
+ advance,
+ entries,
+ when,
+ occursOn,
+ };
+ if (typeof module == "object" && module.exports) module.exports = api;
+ else root.CardDates = api;
+})(this);
--- /dev/null
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const D = require("./dates.js");
+
+const one = (s) => D.parseDates(s)[0];
+
+test("tokenRe matches each token form and rejects bad ones", () => {
+ const found = (s) => [...s.matchAll(D.tokenRe())].map((m) => m[0].slice(m[1].length));
+ assert.deepEqual(found("!2026-10-20"), ["!2026-10-20"]);
+ assert.deepEqual(found("@2026-10-20T09:00"), ["@2026-10-20T09:00"]);
+ assert.deepEqual(found("!2026-10-20+1w"), ["!2026-10-20+1w"]);
+ assert.deepEqual(found("@1997-10-28.+1y"), ["@1997-10-28.+1y"]);
+ assert.deepEqual(found("(!2026-10-20)"), ["!2026-10-20"]);
+ assert.deepEqual(found("x!2026-10-20"), []);
+ assert.deepEqual(found("!2026-10-2"), []);
+});
+
+test("tokenRe returns a new regex on each call", () => {
+ assert.notEqual(D.tokenRe(), D.tokenRe());
+});
+
+test("parseDates reads fields and index", () => {
+ const text = "Call (@2026-10-20T09:00) and !1997-10-28.+1y";
+ const [a, b] = D.parseDates(text);
+ assert.deepEqual(a, {
+ kind: "@",
+ date: "2026-10-20",
+ time: "09:00",
+ endDate: "",
+ endTime: "",
+ rep: null,
+ raw: "@2026-10-20T09:00",
+ index: text.indexOf("@"),
+ });
+ assert.deepEqual(b, {
+ kind: "!",
+ date: "1997-10-28",
+ time: "",
+ endDate: "",
+ endTime: "",
+ rep: { from: ".+", n: 1, unit: "y" },
+ raw: "!1997-10-28.+1y",
+ index: text.indexOf("!1997"),
+ });
+});
+
+test("addInterval steps days, weeks and months", () => {
+ assert.equal(D.addInterval("2026-10-15", 3, "d"), "2026-10-18");
+ assert.equal(D.addInterval("2026-10-15", 2, "w"), "2026-10-29");
+ assert.equal(D.addInterval("2026-11-15", 3, "m"), "2027-02-15");
+});
+
+test("addInterval clamps month and year ends", () => {
+ assert.equal(D.addInterval("2026-01-31", 1, "m"), "2026-02-28");
+ assert.equal(D.addInterval("2028-02-29", 1, "y"), "2029-02-28");
+});
+
+test("addInterval weekly step across the DST change", () => {
+ assert.equal(D.addInterval("2026-03-05", 1, "w"), "2026-03-12");
+});
+
+test("daysBetween counts across the DST change", () => {
+ assert.equal(D.daysBetween("2026-03-01", "2026-03-31"), 30);
+ assert.equal(D.daysBetween("2026-10-01", "2026-11-01"), 31);
+ assert.equal(D.daysBetween("2026-03-10", "2026-03-01"), -9);
+});
+
+test("nextOccurrence for a yearly birthday", () => {
+ const bday = one("!1997-10-28.+1y");
+ assert.equal(D.nextOccurrence(bday, "2026-10-15"), "2026-10-28");
+ assert.equal(D.nextOccurrence(bday, "2026-10-29"), "2027-10-28");
+});
+
+test("nextOccurrence for a monthly 31st", () => {
+ assert.equal(D.nextOccurrence(one("!2026-01-31+1m"), "2026-03-15"), "2026-03-31");
+});
+
+test("nextOccurrence for weekly and daily steps", () => {
+ assert.equal(D.nextOccurrence(one("!2026-10-01+1w"), "2026-10-15"), "2026-10-15");
+ assert.equal(D.nextOccurrence(one("!2000-01-01+1d"), "2026-10-15"), "2026-10-15");
+});
+
+test("nextOccurrence keeps a future date and a past date with no repeat", () => {
+ assert.equal(D.nextOccurrence(one("!2026-10-20+1w"), "2026-10-15"), "2026-10-20");
+ assert.equal(D.nextOccurrence(one("!2026-01-01"), "2026-10-15"), "2026-01-01");
+});
+
+test("nextOccurrence rejects a zero step", () => {
+ assert.throws(() => D.nextOccurrence(one("!2026-01-01+0d"), "2026-10-15"), RangeError);
+});
+
+test("status returns each of the four states", () => {
+ assert.equal(D.status("2026-10-14", "2026-10-15"), "overdue");
+ assert.equal(D.status("2026-10-15", "2026-10-15"), "today");
+ assert.equal(D.status("2026-10-22", "2026-10-15"), "soon");
+ assert.equal(D.status("2026-10-23", "2026-10-15"), "later");
+});
+
+test("advance steps '+' from the date and '.+' from today", () => {
+ assert.equal(D.advance("Oil !2026-10-01+1m done", "2026-10-15"), "Oil !2026-11-01+1m done");
+ assert.equal(
+ D.advance("Pump @2026-10-01T09:00.+2w end", "2026-10-15"),
+ "Pump @2026-10-29T09:00.+2w end",
+ );
+});
+
+test("advance leaves non-repeating tokens alone", () => {
+ assert.equal(
+ D.advance("Pay !2026-10-20 then !2026-10-01+1d", "2026-10-15"),
+ "Pay !2026-10-20 then !2026-10-02+1d",
+ );
+});
+
+test("entries reads tasks, plain lines and skips fenced lines", () => {
+ const md = [
+ "# Plan",
+ "- [x] Service compressor !2026-10-01+1m",
+ "- [ ] Call vendor @2026-10-20T09:00 (urgent)",
+ "Plain line due !2026-10-30",
+ "```",
+ "- [ ] hidden !2026-10-31",
+ "```",
+ "no date here",
+ ].join("\n");
+ const e = D.entries(md);
+ assert.equal(e.length, 3);
+
+ assert.equal(e[0].line, 1);
+ assert.equal(e[0].task, true);
+ assert.equal(e[0].done, true);
+ assert.equal(e[0].text, "Service compressor");
+ assert.deepEqual(e[0].dates[0].rep, { from: "+", n: 1, unit: "m" });
+
+ assert.equal(e[1].line, 2);
+ assert.equal(e[1].task, true);
+ assert.equal(e[1].done, false);
+ assert.equal(e[1].text, "Call vendor (urgent)");
+
+ assert.equal(e[2].line, 3);
+ assert.equal(e[2].task, false);
+ assert.equal(e[2].done, false);
+ assert.equal(e[2].text, "Plain line due");
+});
+
+test("when uses the next occurrence only for a repeating non-task entry", () => {
+ const d = one("!2026-10-01+1w");
+ assert.equal(D.when({ task: false }, d, "2026-10-15"), "2026-10-15");
+ assert.equal(D.when({ task: true }, d, "2026-10-15"), "2026-10-01");
+});
+
+test("todayStr returns the local date", () => {
+ assert.equal(D.todayStr(new Date(2026, 9, 15, 23, 59)), "2026-10-15");
+});
+
+test("tokenRe matches range and repeat forms whole", () => {
+ const found = (s) => [...s.matchAll(D.tokenRe())].map((m) => m[0].slice(m[1].length));
+ assert.deepEqual(found("@2026-08-18--2026-08-27"), ["@2026-08-18--2026-08-27"]);
+ assert.deepEqual(found("@2026-08-18T08:00--2026-08-19T17:00"), [
+ "@2026-08-18T08:00--2026-08-19T17:00",
+ ]);
+ assert.deepEqual(found("@2026-09-14T09:00--10:00+1w"), ["@2026-09-14T09:00--10:00+1w"]);
+ assert.deepEqual(found("!2026-10-20+1w"), ["!2026-10-20+1w"]);
+});
+
+test("parseDates reads range end date and end time and ignores a backwards range", () => {
+ const a = one("@2026-08-18--2026-08-27");
+ assert.equal(a.endDate, "2026-08-27");
+ assert.equal(a.endTime, "");
+
+ const b = one("@2026-08-18T08:00--2026-08-19T17:00");
+ assert.equal(b.time, "08:00");
+ assert.equal(b.endDate, "2026-08-19");
+ assert.equal(b.endTime, "17:00");
+
+ const c = one("@2026-09-14T09:00--10:00+1w");
+ assert.equal(c.endDate, "");
+ assert.equal(c.endTime, "10:00");
+ assert.deepEqual(c.rep, { from: "+", n: 1, unit: "w" });
+
+ const back = one("!2026-08-27--2026-08-18");
+ assert.equal(back.endDate, "");
+});
+
+test("bounds returns the range for a plain range", () => {
+ assert.deepEqual(D.bounds({ task: false }, one("@2026-08-18--2026-08-27"), "2026-10-15"), {
+ start: "2026-08-18",
+ end: "2026-08-27",
+ });
+});
+
+test("bounds moves a past weekly range forward for a non-task only", () => {
+ const d = one("@2026-09-14T09:00--2026-09-15T17:00+1w");
+ assert.deepEqual(D.bounds({ task: false }, d, "2026-10-15"), {
+ start: "2026-10-19",
+ end: "2026-10-20",
+ });
+ assert.deepEqual(D.bounds({ task: true }, d, "2026-10-15"), {
+ start: "2026-09-14",
+ end: "2026-09-15",
+ });
+});
+
+test("agendaDays lists each day of a 10-day range seen from day 3", () => {
+ const days = D.agendaDays({ task: false }, one("@2026-10-01--2026-10-10"), "2026-10-03", 14);
+ assert.equal(days.length, 8);
+ assert.deepEqual(days[0], { date: "2026-10-03", label: "(3/10)" });
+ assert.deepEqual(days[7], { date: "2026-10-10", label: "(10/10)" });
+});
+
+test("agendaDays is empty for a range that starts after the window", () => {
+ assert.deepEqual(
+ D.agendaDays({ task: false }, one("@2026-11-20--2026-11-25"), "2026-10-15", 14),
+ [],
+ );
+});
+
+test("agendaDays gives one overdue item for a past task range", () => {
+ assert.deepEqual(D.agendaDays({ task: true }, one("!2026-10-01--2026-10-05"), "2026-10-15", 14), [
+ { date: "2026-10-05", label: "" },
+ ]);
+});
+
+test("agendaDays gives one unlabeled item for a single date", () => {
+ assert.deepEqual(D.agendaDays({ task: false }, one("@2026-10-20"), "2026-10-15", 14), [
+ { date: "2026-10-20", label: "" },
+ ]);
+});
+
+test("badgeStatus gives today, past, overdue and soon", () => {
+ const today = "2026-10-15";
+ assert.equal(D.badgeStatus({ task: false }, one("@2026-10-10--2026-10-20"), today), "today");
+ assert.equal(D.badgeStatus({ task: false }, one("@2026-10-01--2026-10-05"), today), "past");
+ assert.equal(D.badgeStatus({ task: true }, one("!2026-10-01--2026-10-05"), today), "overdue");
+ assert.equal(D.badgeStatus({ task: false }, one("@2026-10-20"), today), "soon");
+});
+
+test("matchTerm finds overdue, today, a 7d span and a date inside a range", () => {
+ const today = "2026-10-15";
+ const late = one("!2026-10-01");
+ assert.equal(D.matchTerm("overdue", { task: true }, late, today), true);
+ assert.equal(D.matchTerm("overdue", { task: false }, late, today), false);
+
+ const span = one("@2026-10-10--2026-10-20");
+ assert.equal(D.matchTerm("today", { task: false }, span, today), true);
+ assert.equal(D.matchTerm("2026-10-15", { task: false }, span, today), true);
+ assert.equal(D.matchTerm("2026-10-21", { task: false }, span, today), false);
+
+ assert.equal(
+ D.matchTerm("7d", { task: false }, one("@2026-10-20--2026-10-25"), today),
+ true,
+ );
+ assert.equal(
+ D.matchTerm("7d", { task: false }, one("@2026-11-01--2026-11-05"), today),
+ false,
+ );
+});
+
+test("advance moves a '+' range and keeps its length and a same-day end time", () => {
+ assert.equal(
+ D.advance("Lease @2026-08-18--2026-08-27+1w ok", "2026-10-15"),
+ "Lease @2026-08-25--2026-09-03+1w ok",
+ );
+ assert.equal(
+ D.advance("Pump @2026-09-14T09:00--10:00.+1w end", "2026-10-15"),
+ "Pump @2026-10-22T09:00--10:00.+1w end",
+ );
+});
+
+test("stampOf zero pads the local date and time", () => {
+ assert.equal(D.stampOf(new Date(2026, 0, 5, 7, 3)), "x2026-01-05T07:03");
+});
+
+test("completeLine ticks a task and appends a stamp", () => {
+ const now = new Date(2026, 9, 15, 14, 32);
+ const r = D.completeLine(["- [ ] call vendor"], 0, true, now);
+ assert.deepEqual(r, { lines: ["- [x] call vendor x2026-10-15T14:32"], repeat: false, inserted: 0 });
+});
+
+test("completeLine unticks a stamped task and removes the stamp", () => {
+ const now = new Date(2026, 9, 15, 14, 32);
+ const r = D.completeLine(["- [x] call vendor x2026-10-15T14:32"], 0, false, now);
+ assert.deepEqual(r.lines, ["- [ ] call vendor"]);
+ assert.equal(r.repeat, false);
+});
+
+test("completeLine re-tick replaces the old stamp", () => {
+ const now = new Date(2026, 9, 15, 14, 32);
+ const r = D.completeLine(["- [ ] call vendor x2026-10-12T10:16"], 0, true, now);
+ assert.deepEqual(r.lines, ["- [x] call vendor x2026-10-15T14:32"]);
+});
+
+test("completeLine repeat tick with no old stamp advances the date and adds a stamp", () => {
+ const now = new Date(2026, 9, 15, 14, 32);
+ const r = D.completeLine(["- [ ] water plant !2026-10-01.+1w"], 0, true, now);
+ assert.deepEqual(r, {
+ lines: ["- [ ] water plant !2026-10-22.+1w x2026-10-15T14:32"],
+ repeat: true,
+ inserted: 0,
+ });
+});
+
+test("completeLine repeat tick inserts a history line above the older history", () => {
+ const now = new Date(2026, 9, 15, 14, 32);
+ const lines = ["- [ ] water plant !2026-10-01.+1w x2026-10-12T10:16", "\t- x2026-10-05T08:00"];
+ const r = D.completeLine(lines, 0, true, now);
+ assert.deepEqual(r, {
+ lines: [
+ "- [ ] water plant !2026-10-22.+1w x2026-10-15T14:32",
+ "\t- x2026-10-12T10:16",
+ "\t- x2026-10-05T08:00",
+ ],
+ repeat: true,
+ inserted: 1,
+ });
+});
+
+test("completeLine keeps a tab indent and gives the history line one more tab", () => {
+ const now = new Date(2026, 9, 15, 14, 32);
+ const r = D.completeLine(
+ ["\t- [ ] water plant !2026-10-01.+1w x2026-10-12T10:16"],
+ 0,
+ true,
+ now,
+ );
+ assert.deepEqual(r.lines, [
+ "\t- [ ] water plant !2026-10-22.+1w x2026-10-15T14:32",
+ "\t\t- x2026-10-12T10:16",
+ ]);
+ assert.equal(r.inserted, 1);
+});
+
+test("completeLine does not change its input array", () => {
+ const now = new Date(2026, 9, 15, 14, 32);
+ const lines = ["- [ ] water plant !2026-10-01.+1w x2026-10-12T10:16", "\t- x2026-10-05T08:00"];
+ const copy = lines.slice();
+ D.completeLine(lines, 0, true, now);
+ assert.deepEqual(lines, copy);
+});
+
+test("entries text has no stamp", () => {
+ const [e] = D.entries("- [x] call vendor !2026-10-20 x2026-10-15T14:32");
+ assert.equal(e.text, "call vendor");
+});
+
+test("occursOn: plain dates, ranges and repeating appointments", () => {
+ const D = require("./dates.js");
+ const one = (s, task = false) => [{ task }, D.parseDates(s)[0]];
+ assert.equal(D.occursOn(...one("@2026-10-20"), "2026-10-20"), true);
+ assert.equal(D.occursOn(...one("@2026-10-20"), "2026-10-21"), false);
+ assert.equal(D.occursOn(...one("@2026-10-18--2026-10-22"), "2026-10-20"), true);
+ assert.equal(D.occursOn(...one("@2026-10-18--2026-10-22"), "2026-10-23"), false);
+ // a weekly time slot on later weeks, a birthday decades on, a monthly range
+ assert.equal(D.occursOn(...one("@2026-09-14T09:00--10:00+1w"), "2026-10-19"), true);
+ assert.equal(D.occursOn(...one("@2026-09-14T09:00--10:00+1w"), "2026-10-20"), false);
+ assert.equal(D.occursOn(...one("@1997-10-28+1y"), "2026-10-28"), true);
+ assert.equal(D.occursOn(...one("@1997-10-28+1y"), "2026-10-29"), false);
+ assert.equal(D.occursOn(...one("@2026-01-30--2026-02-01+1m"), "2026-03-01"), true);
+ // a repeating task is only on its current date
+ assert.equal(D.occursOn(...one("!2026-10-20+1w", true), "2026-10-27"), false);
+ assert.equal(D.occursOn(...one("!2026-10-20+1w", true), "2026-10-20"), true);
+});
--- /dev/null
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>To-do cards</title>
+ <link rel="stylesheet" href="rearman.css">
+ <style>
+ article:not(.index, :target, :has(:target), .paper, .preview),
+ :root:has(:target) article.index:not(:has(:target)) {
+ display: none;
+ }
+ * {
+ box-sizing: border-box;
+ }
+ body {
+ margin: 0;
+ padding: 0;
+ max-width: none;
+ background-color: var(--pale-green, Canvas);
+ }
+ article {
+ --w: min(500px, 96vw);
+ padding: 2.25em 1em 1em;
+ margin: 1rem;
+ position: relative;
+ max-width: var(--w);
+ min-height: calc(var(--w) * 5 / 3);
+ font: 1em sans-serif;
+ line-height: 1.75;
+ background-color: inherit;
+ color: var(--text-black, CanvasText);
+ border: 1px solid var(--traffic-white, CanvasText);
+ box-shadow: 10px 10px 30px 10px rgba(0, 0, 0, 0.1);
+ .cardtools {
+ position: absolute;
+ top: 0;
+ right: 0;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.25rem;
+ font: 0.8em sans-serif;
+ }
+ h1.cardtitle {
+ margin: 0 0 0.5em;
+ padding-bottom: 0.25em;
+ border-bottom: 1px solid var(--traffic-white, CanvasText);
+ font-size: 1.5em;
+ overflow-wrap: anywhere;
+ }
+ li.task {
+ list-style: none;
+ }
+ li.task label,
+ li.task input {
+ cursor: pointer;
+ }
+ img {
+ max-width: 100%;
+ height: auto;
+ }
+ }
+ article.paper {
+ position: absolute;
+ top: var(--y);
+ left: clamp(0.5rem, var(--x), calc(100vw - var(--w) - 0.5rem));
+ width: var(--w);
+ max-width: none;
+ margin: 0;
+ box-shadow: 10px 10px 30px 10px rgba(0, 0, 0, 0.2);
+ z-index: calc(90 + var(--i));
+ }
+ article.paper:not(.top) {
+ cursor: pointer;
+ }
+ /* the last card clicked sits above every other card */
+ article.front {
+ z-index: 200;
+ }
+ article.index .cardtools,
+ article.index .cardtitle,
+ article:not(.paper) .cardclose,
+ /* a hover preview is only a look: its buttons appear once the card opens */
+ .preview .cardtools {
+ display: none;
+ }
+ .preview {
+ position: absolute;
+ z-index: 500;
+ width: min(500px, 96vw);
+ max-width: none;
+ margin: 0;
+ box-shadow: 10px 10px 30px 10px rgba(0, 0, 0, 0.2);
+ pointer-events: none;
+ }
+ .editor {
+ display: flex;
+ flex-direction: column;
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ font: 1em sans-serif;
+ background-color: inherit;
+ .menu {
+ display: flex;
+ justify-content: space-between;
+ padding: 0.5rem;
+ gap: 0.5rem;
+ }
+ textarea {
+ padding: 0.5rem;
+ border: 0;
+ outline: none;
+ flex: 1;
+ resize: none;
+ }
+ }
+ .tools {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 0.5rem;
+ position: fixed;
+ right: 0;
+ top: 0;
+ z-index: 300;
+ max-width: calc(100vw - 1rem);
+ margin: 0.5rem;
+ font: 1em sans-serif;
+ }
+ .tools .status {
+ /* below the toolbar, so "Saved" never widens it into the base card */
+ position: absolute;
+ top: 100%;
+ right: 0;
+ white-space: nowrap;
+ font-size: 0.9em;
+ opacity: 0.7;
+ }
+ .tools .status.failed {
+ color: var(--traffic-red, red);
+ opacity: 1;
+ }
+ article .cardtags {
+ margin: -0.25em 0 0.75em;
+ font-size: 0.85em;
+ opacity: 0.7;
+ }
+ article.index .cardtags {
+ display: none;
+ }
+ article .query ul {
+ margin: 0.25em 0;
+ }
+ article .query .empty {
+ margin: 0.25em 0;
+ font-style: italic;
+ opacity: 0.6;
+ }
+ .search {
+ position: relative;
+ }
+ .searchbox {
+ /* 12em lets the toolbar sit beside a 500px card in a half-width window */
+ width: 12em;
+ font: inherit;
+ }
+ .results {
+ position: absolute;
+ top: 100%;
+ right: 0;
+ width: min(26em, 90vw);
+ max-height: 70vh;
+ overflow-y: auto;
+ margin-top: 0.25rem;
+ padding: 0.25rem 0;
+ background-color: var(--pale-green, Canvas);
+ color: var(--text-black, CanvasText);
+ border: 1px solid var(--traffic-white, CanvasText);
+ box-shadow: 10px 10px 30px 10px rgba(0, 0, 0, 0.2);
+ }
+ .results .row {
+ padding: 0.3rem 0.6rem;
+ cursor: pointer;
+ }
+ .results .row.active,
+ .results .row:hover {
+ background-color: rgba(128, 128, 128, 0.2);
+ }
+ .results .name {
+ font-weight: bold;
+ }
+ .results .tags,
+ .results .snip {
+ display: block;
+ font-size: 0.85em;
+ opacity: 0.75;
+ }
+ .results .empty {
+ padding: 0.3rem 0.6rem;
+ font-style: italic;
+ opacity: 0.6;
+ }
+ article .cardtags .tag {
+ margin-right: 0.4em;
+ cursor: pointer;
+ }
+ article .cardtags .tag:hover {
+ text-decoration: underline;
+ }
+ .when {
+ padding: 0 0.3em;
+ border: 1px solid currentColor;
+ border-radius: 3px;
+ font-size: 0.85em;
+ white-space: nowrap;
+ }
+ /* a date chip searches its day: on:2026-10-20 */
+ .when,
+ .donestamp {
+ cursor: pointer;
+ }
+ .when:hover,
+ .donestamp:hover {
+ text-decoration: underline;
+ }
+ .when.overdue {
+ color: var(--fire-red, red);
+ font-weight: bold;
+ }
+ .when.today {
+ color: var(--alert-orange, darkorange);
+ font-weight: bold;
+ }
+ .when.done,
+ .when.past {
+ opacity: 0.5;
+ }
+ .agenda .day {
+ margin: 0.6em 0 0.2em;
+ font-weight: bold;
+ }
+ .agenda .item {
+ margin: 0.15em 0 0.15em 0.5em;
+ }
+ /* a rule under each day: its last item comes before the next heading, or ends the list */
+ .agenda .item:has(+ .day),
+ .agenda .item:last-child {
+ padding-bottom: 0.3em;
+ border-bottom: 1px solid var(--traffic-white, CanvasText);
+ }
+ .agenda .item .card {
+ margin-left: 0.4em;
+ font-size: 0.85em;
+ opacity: 0.7;
+ }
+ article .linetag {
+ cursor: pointer;
+ opacity: 0.75;
+ }
+ article .linetag:hover {
+ text-decoration: underline;
+ }
+ .query .lines .card {
+ margin-left: 0.4em;
+ font-size: 0.85em;
+ opacity: 0.7;
+ }
+ .agenda .item.done,
+ .query .lines .done {
+ text-decoration: line-through;
+ opacity: 0.6;
+ }
+ .qbox {
+ margin: 0 0.35em 0 0;
+ vertical-align: middle;
+ cursor: pointer;
+ }
+ .agenda .empty {
+ font-style: italic;
+ opacity: 0.6;
+ }
+ .cardlist {
+ position: absolute;
+ top: 100%;
+ right: 0;
+ width: min(22em, 90vw);
+ max-height: 70vh;
+ overflow-y: auto;
+ margin-top: 0.25rem;
+ padding: 0.25rem 0;
+ background-color: var(--pale-green, Canvas);
+ color: var(--text-black, CanvasText);
+ border: 1px solid var(--traffic-white, CanvasText);
+ box-shadow: 10px 10px 30px 10px rgba(0, 0, 0, 0.2);
+ }
+ .cardlist[hidden],
+ .cardlist .row[hidden] {
+ display: none;
+ }
+ .cardlist .cardfilter {
+ display: block;
+ box-sizing: border-box;
+ width: calc(100% - 1rem);
+ margin: 0.25rem 0.5rem;
+ font: inherit;
+ }
+ .cardlist .row {
+ padding: 0.3rem 0.6rem;
+ cursor: pointer;
+ }
+ .cardlist .row.active,
+ .cardlist .row:hover {
+ background-color: rgba(128, 128, 128, 0.2);
+ }
+ .cardlist .tags {
+ margin-left: 0.5em;
+ font-size: 0.85em;
+ opacity: 0.7;
+ }
+ .donestamp {
+ font-size: 0.8em;
+ opacity: 0.55;
+ white-space: nowrap;
+ }
+ </style>
+</head>
+<body>
+ <div class="tools">
+ <div class="search">
+ <input type="search" class="searchbox" placeholder="Search ( / )" aria-label="Search cards and tags" autocomplete="off" spellcheck="false">
+ <div class="results" role="listbox" hidden></div>
+ </div>
+ <span class="status" role="status"></span>
+ <button data-act="retry" hidden>Retry</button>
+ <button data-act="bookmarks" aria-expanded="false">Bookmarks</button>
+ <button data-act="cards" aria-expanded="false">Cards</button>
+ <button data-act="new">New</button>
+ <div class="cardlist" hidden>
+ <input type="search" class="cardfilter" placeholder="Filter cards" aria-label="Filter cards" autocomplete="off" spellcheck="false">
+ <div class="cardrows" role="listbox"></div>
+ </div>
+ </div>
+ <article class="preview" hidden></article>
+ <div class="editor" style="display: none">
+ <div class="menu">
+ <button data-act="cancel">Cancel</button>
+ <span class="hash"></span>
+ <button data-act="done">Done</button>
+ </div>
+ <textarea placeholder="Write here..."></textarea>
+ </div>
+ <!--CARDS-->
+ <script src="md.js"></script>
+ <script src="dates.js"></script>
+ <script src="lines.js"></script>
+ <script src="app.js"></script>
+</body>
+</html>
--- /dev/null
+(function (root) {
+ // a #tag starts the text or follows whitespace; links like (#slug) never match
+ const tagRe = () => /(^|\s)#([a-z0-9][a-z0-9_-]*)(?=$|[\s,.;:!?)])/gi;
+
+ const HEADING = /^(#{1,6})[+-]? */;
+ const LIST = /^([ \t]*)(?:-|\d+\.)(?: |$)/;
+ const width = (s) => s.replaceAll("\t", " ").length;
+ // same as stampRe in dates.js; the two files do not import each other
+ const stampRe = () => /(^|\s)x(\d{4}-\d\d-\d\d)T(\d\d:\d\d)(?=$|\s)/g;
+
+ // the tags written on one line; code spans and the heading marker are skipped
+ function ownTags(line) {
+ const text = line.replace(HEADING, "").replace(/`[^`]*`/g, " ");
+ const out = [];
+ for (const m of text.matchAll(tagRe())) {
+ const t = m[2].toLowerCase();
+ if (!out.includes(t)) out.push(t);
+ }
+ return out;
+ }
+
+ const union = (a, b) => [...new Set([...a, ...b])];
+
+ // one entry per line of md: { line, parent, own, tags, text, task, done }.
+ // tags are the line's own plus those of its parent line: the list item above
+ // it at a smaller indent, or else the heading whose section holds it.
+ // Lines inside ``` fences and blank lines carry no tags.
+ function lineTags(md) {
+ const out = [];
+ const heads = []; // { level, line, tags }
+ let items = []; // { indent, line, tags }
+ let fenced = false;
+ md.split("\n").forEach((line, i) => {
+ const entry = { line: i, parent: -1, own: [], tags: [], text: "", task: false, done: false };
+ out.push(entry);
+ if (line.startsWith("```")) {
+ fenced = !fenced;
+ return;
+ }
+ if (fenced || !line.trim()) return;
+ const head = heads.at(-1);
+ const h = /^(#{1,6})[+-]? /.exec(line) || /^(#{1,6})[+-]?$/.exec(line);
+ const li = LIST.exec(line);
+ let parent = null;
+ if (h) {
+ const level = h[1].length;
+ while (heads.length && heads.at(-1).level >= level) heads.pop();
+ items = [];
+ parent = heads.at(-1) || null;
+ } else if (li) {
+ const indent = width(li[1]);
+ while (items.length && items.at(-1).indent >= indent) items.pop();
+ parent = items.at(-1) || head || null;
+ } else if (/^[ \t]/.test(line) && items.length) {
+ parent = items.at(-1); // an indented line under an item belongs to it
+ } else {
+ items = [];
+ parent = head || null;
+ }
+ entry.own = ownTags(line);
+ entry.parent = parent ? parent.line : -1;
+ entry.tags = union(parent ? parent.tags : [], entry.own);
+ entry.task = /^[ \t]*- ?\[[ xX]\]/.test(line);
+ entry.done = /^[ \t]*- ?\[[xX]\]/.test(line);
+ entry.text = line
+ .replace(HEADING, "")
+ .replace(/^[ \t]*(?:-|\d+\.) ?/, "")
+ .replace(/^\[[ xX]\] ?/, "")
+ .replace(stampRe(), "")
+ .trim();
+ if (h) heads.push({ level: h[1].length, line: i, tags: entry.tags });
+ else if (li) items.push({ indent: width(li[1]), line: i, tags: entry.tags });
+ });
+ return out;
+ }
+
+ // a has:tag term holds when an open checklist line carries the tag, on the
+ // line itself or from a parent line or heading; entries come from lineTags
+ const hasOpenTag = (entries, tag) => entries.some((e) => e.task && !e.done && e.tags.includes(tag));
+
+ // every tag used on any line of md
+ const allLineTags = (md) => [...new Set(lineTags(md).flatMap((e) => e.own))];
+
+ const api = { tagRe, ownTags, lineTags, allLineTags, hasOpenTag };
+ if (typeof module == "object" && module.exports) module.exports = api;
+ else root.CardLines = api;
+})(this);
--- /dev/null
+"use strict";
+
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const L = require("./lines.js");
+
+test("ownTags finds #tags and skips links, code and the heading marker", () => {
+ assert.deepEqual(L.ownTags("- [ ] call Ty #phone #Waiting."), ["phone", "waiting"]);
+ assert.deepEqual(L.ownTags("see [Ty](#people) and `#code` #real"), ["real"]);
+ assert.deepEqual(L.ownTags("## Title #proj"), ["proj"]);
+ assert.deepEqual(L.ownTags("x#no #-no #ok"), ["ok"]);
+});
+
+test("nested list items inherit their parent's tags", () => {
+ const t = L.lineTags("- [ ] parent #a\n\t- [ ] child #b\n\t\t- grandchild\n- sibling");
+ assert.deepEqual(t[0].tags, ["a"]);
+ assert.deepEqual(t[1].tags, ["a", "b"]);
+ assert.deepEqual(t[2].tags, ["a", "b"]);
+ assert.equal(t[2].parent, 1);
+ assert.deepEqual(t[3].tags, []);
+});
+
+test("a heading's tags reach every line of its section", () => {
+ const t = L.lineTags("## Calls #phone\n- ring A\ntext\n### Sub\n- ring B\n## Other\n- none");
+ assert.deepEqual(t[1].tags, ["phone"]);
+ assert.deepEqual(t[2].tags, ["phone"]);
+ assert.deepEqual(t[4].tags, ["phone"]);
+ assert.deepEqual(t[6].tags, []);
+});
+
+test("fenced lines carry no tags, and task state is read", () => {
+ const t = L.lineTags("```\n- [ ] #x\n```\n- [x] done #y");
+ assert.deepEqual(t[1].tags, []);
+ assert.equal(t[3].task, true);
+ assert.equal(t[3].done, true);
+ assert.equal(t[3].text, "done #y");
+});
+
+test("allLineTags lists each tag once", () => {
+ assert.deepEqual(L.allLineTags("- a #x\n- b #x #y"), ["x", "y"]);
+});
+
+test("lineTags text has no stamp", () => {
+ const [t] = L.lineTags("- [x] call vendor #phone x2026-10-15T14:32");
+ assert.equal(t.text, "call vendor #phone");
+});
+
+test("hasOpenTag counts only open checklist lines, inherited tags included", () => {
+ const e = L.lineTags("## Next steps #next\n- [ ] call\n- [x] old #later\n- note #later");
+ assert.equal(L.hasOpenTag(e, "next"), true);
+ assert.equal(L.hasOpenTag(e, "later"), false);
+ assert.equal(L.hasOpenTag(L.lineTags("- [x] done #next"), "next"), false);
+});
--- /dev/null
+// Markdown to HTML for the cards. Shared by app.js; copied from wiki.html.
+function md2html(str) {
+ const enchtml = (str) => str.replaceAll("<", "<");
+ const codeBuf = [];
+ const stash = (txt) =>
+ `\u0000${codeBuf.push(`<code>${enchtml(txt)}</code>`) - 1}\u0000`;
+ const inlines = [
+ // stash code spans first: no later rule may see inside them
+ [/<code>([\s\S]*?)<\/code>/g, (_, txt) => stash(txt)],
+ [/`(.+?)`/g, (_, txt) => stash(txt)],
+ [
+ /(\*{1,2}|~~)(.+?)\1/g,
+ (_, c, txt) =>
+ c == "*"
+ ? `<i>${txt}</i>`
+ : c == "**"
+ ? `<b>${txt}</b>`
+ : `<s>${txt}</s>`,
+ ],
+ [
+ /!\[(.*?)\]\((.+?)\)/g,
+ (_, txt, url) => `<img src="${url}" alt="${txt}">`,
+ ],
+ [
+ /\[(.+?)\]\((.+?)\)/g,
+ (_, txt, url) => `<a href="${url}">${txt}</a>`,
+ ],
+ [
+ /(?<!\=["'])https?\:\/\/[^\s]+/gi,
+ (url) => `<a href="${url}">${url}</a>`,
+ ],
+ // and put them back, verbatim
+ [/\u0000(\d+)\u0000/g, (_, i) => codeBuf[i]],
+ ];
+ const replaceInlines = (str) =>
+ inlines.reduce((md, rule) => md.replace(...rule), str);
+ const task = (item) => {
+ const mark = item.match(/^\[([ xX])\] ?/);
+ if (!mark) return `<li>${replaceInlines(item)}</li>`;
+ const on = mark[1] == " " ? "" : " checked";
+ return `<li class="task"><label><input type="checkbox"${on}> ${replaceInlines(
+ item.slice(mark[0].length)
+ )}</label></li>`;
+ };
+ const fenceBuf = [];
+ const fenceMark = "\n<!\ufeff>\n";
+ const fence = (str) => (fenceBuf.push(str), fenceMark);
+ // nested lists from lines like " - item"; indent decides depth
+ const nestList = (txt, marker, tag, item) => {
+ const rows = [];
+ for (const line of txt.split("\n")) {
+ const m = line.match(marker);
+ if (m) rows.push([m[1].replaceAll("\t", " ").length, m[2]]);
+ }
+ let at = 0;
+ const build = (indent) => {
+ let html = "";
+ while (at < rows.length) {
+ const [ind, text] = rows[at];
+ if (ind < indent) break;
+ if (ind > indent) {
+ // deeper line: its list belongs inside the item above it
+ const sub = build(ind);
+ html = html.replace(/<\/li>$/, () => sub + "</li>");
+ continue;
+ }
+ at++;
+ // inline rules run inside item(), after the checkbox test: a link rule run first eats "[ ]"
+ html += item(text);
+ }
+ return `<${tag}>${html}</${tag}>`;
+ };
+ return rows.length ? build(rows[0][0]) : txt;
+ };
+ const blocks = [
+ [
+ /\n```([^\n]*)\n([\s\S]*?)\n```\n/g,
+ (_, arg, code) =>
+ fence(`\n<pre><code>${enchtml(code)}</code></pre>\n`),
+ ],
+ [
+ /\n(#+)([^\n]+)/g,
+ (_, h, txt) => `\n<h${h.length}>${replaceInlines(txt.trim())}</h${h.length}>`,
+ ],
+ [
+ /\n(\n[ \t]*-[^\n]+)+/g,
+ (txt) => "\n" + nestList(txt, /^([ \t]*)- ?(.*)$/, "ul", task),
+ ],
+ [
+ /\n(\n[ \t]*\d+\.[^\n]+)+/g,
+ (txt) =>
+ "\n" +
+ nestList(txt, /^([ \t]*)\d+\. ?(.*)$/, "ol", (t) => `<li>${replaceInlines(t)}</li>`),
+ ],
+ [
+ /\n([^<\n][^\n]*\n)+/g,
+ (txt) =>
+ `\n<p>${replaceInlines(
+ txt.replaceAll(" \n", "<br>\n").trim()
+ )}</p>\n`,
+ ],
+ ];
+ return blocks
+ // the extra leading newline lets a list on the first line match the list rules
+ .reduce((md, rule) => md.replace(...rule), `\n\n${str}\n`)
+ .replaceAll(fenceMark, () => fenceBuf.shift());
+}
+
+// "##+ Title" opens a foldable section, "##- Title" one that starts closed. A
+// section runs to the next heading of the same or a higher level.
+function foldSections(md) {
+ const out = [];
+ const open = []; // levels of the sections not yet closed
+ let fenced = false;
+ const close = (level) => {
+ while (open.length && open.at(-1) >= level) {
+ open.pop();
+ out.push("", "</div></details>", "");
+ }
+ };
+ for (const line of md.split("\n")) {
+ if (line.startsWith("```")) fenced = !fenced;
+ const h = fenced ? null : /^(#{1,6})([+-]?) *(.*)$/.exec(line);
+ if (!h) {
+ out.push(line);
+ continue;
+ }
+ close(h[1].length);
+ if (!h[2]) {
+ out.push(line);
+ continue;
+ }
+ open.push(h[1].length);
+ // the heading stays a Markdown line so md2html gives it links and other inline markup
+ out.push(`<details class="section"${h[2] == "-" ? "" : " open"}><summary>`, `${h[1]} ${h[3]}`, "</summary>", "<div>", "");
+ }
+ close(1);
+ return out.join("\n");
+}
+
+// mark each checklist line with its source line number, so a ticked box finds
+// its own line even where the renderer draws no box for a list-looking line
+function markTasks(md) {
+ let fenced = false;
+ return md
+ .split("\n")
+ .map((line, i) => {
+ if (line.startsWith("```")) fenced = !fenced;
+ return fenced ? line : line.replace(/^([ \t]*- ?\[[ xX]\] ?)/, `$1<!--L${i}-->`);
+ })
+ .join("\n");
+}
--- /dev/null
+"use strict";
+
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const vm = require("node:vm");
+
+// md.js is a browser script: run it in a sandbox to reach its functions
+const ctx = {};
+vm.runInNewContext(fs.readFileSync(__dirname + "/md.js", "utf8"), ctx);
+
+test("##+ open, ##- closed, ## plain", () => {
+ const out = ctx.foldSections("##+ A\ntext\n##- B\nmore\n## C\nend");
+ assert.match(out, /^<details class="section" open><summary>\n## A\n<\/summary>/);
+ assert.match(out, /<details class="section"><summary>\n## B\n<\/summary>/);
+ assert.equal((out.match(/<\/details>/g) || []).length, 2);
+ assert.match(out, /<\/details>\n\n## C\nend$/);
+});
+
+test("a section ends at a heading of the same or higher level", () => {
+ const out = ctx.foldSections("##+ A\n### sub\nx\n## B\ny");
+ const firstEnd = out.indexOf("</details>");
+ assert.ok(firstEnd > out.indexOf("### sub") && firstEnd < out.indexOf("## B"));
+ assert.equal((out.match(/<\/details>/g) || []).length, 1);
+});
+
+test("nested folds close inner first", () => {
+ const out = ctx.foldSections("##+ A\n###- B\nx\n## C");
+ const ends = [...out.matchAll(/<\/details>/g)].map((m) => m.index);
+ assert.ok(ends.length == 2 && ends[1] < out.indexOf("## C"));
+});
+
+test("headings inside code fences are left alone", () => {
+ assert.equal(ctx.foldSections("```\n##+ no\n```"), "```\n##+ no\n```");
+});
+
+test("headings render inline markup", () => {
+ const html = ctx.md2html(ctx.foldSections("##- Go [there](#x) and `code`\nbody"));
+ assert.match(html, /<summary>\s*<h2>Go <a href="#x">there<\/a> and <code>code<\/code><\/h2>\s*<\/summary>/);
+});
+
+test("a checklist line with a link still gets its checkbox", () => {
+ const html = ctx.md2html("\n- [ ] Email [Ty](#people) today\n- [x] done");
+ assert.equal((html.match(/type="checkbox"/g) || []).length, 2);
+ assert.match(html, /type="checkbox"> Email <a href="#people">Ty<\/a> today/);
+});
+
+test("markTasks numbers checklist lines and skips code fences", () => {
+ const out = ctx.markTasks("text\n- [ ] a\n```\n- [ ] code\n```\n - [x] b");
+ assert.equal(out, "text\n- [ ] <!--L1-->a\n```\n- [ ] code\n```\n - [x] <!--L5-->b");
+});
+
+test("tab-indented items nest like space-indented ones", () => {
+ const html = ctx.md2html("\n- [ ] top\n\t- [ ] child\n\t\t- grandchild");
+ assert.match(html, /<ul><li class="task">.*top.*<ul><li class="task">.*child.*<ul><li>grandchild<\/li><\/ul>/s);
+});
--- /dev/null
+"use strict";
+
+const http = require("node:http");
+const fs = require("node:fs/promises");
+const path = require("node:path");
+
+// Local only. There is no login. Never bind a LAN address.
+const PORT = Number(process.env.PORT) || 8717;
+const HOST = "127.0.0.1";
+// http://cardhost/ needs the hosts line "127.0.0.2 cardhost". Port 80 on
+// 127.0.0.1 belongs to WSL, so the name gets its own loopback address.
+const NAME = "cardhost";
+const NAME_HOST = "127.0.0.2";
+const NAME_PORT = 80;
+// the cards live outside the app, beside it in ~/lib
+const CARDS = process.env.CARDS_DIR || path.join(__dirname, "..", "pim-cards");
+const HOME = "_home";
+const MAX_BODY = 1024 * 1024;
+
+const STATIC = new Map([
+ ["/app.js", { file: path.join(__dirname, "app.js"), type: "text/javascript; charset=utf-8" }],
+ ["/md.js", { file: path.join(__dirname, "md.js"), type: "text/javascript; charset=utf-8" }],
+ ["/dates.js", { file: path.join(__dirname, "dates.js"), type: "text/javascript; charset=utf-8" }],
+ ["/lines.js", { file: path.join(__dirname, "lines.js"), type: "text/javascript; charset=utf-8" }],
+ ["/rearman.css", { file: path.join(__dirname, "..", "rearman.css"), type: "text/css; charset=utf-8" }],
+]);
+
+// This pattern is the only thing between a request and a file name.
+function validKey(key) {
+ return key === HOME || /^[a-z0-9]+(-[a-z0-9]+)*$/.test(key);
+}
+
+function fileOf(key) {
+ return path.join(CARDS, key + ".md");
+}
+
+const TAG_RE = /^[a-z0-9][a-z0-9_-]*$/;
+
+// Returns the lowercased, de-duplicated tags, or null when any is invalid.
+function cleanTags(list) {
+ if (!Array.isArray(list) || !list.every((t) => typeof t === "string")) return null;
+ const out = [];
+ for (const raw of list) {
+ const tag = raw.trim().toLowerCase();
+ if (!TAG_RE.test(tag)) return null;
+ if (!out.includes(tag)) out.push(tag);
+ }
+ return out;
+}
+
+function parse(text) {
+ const s = text.replace(/\r\n/g, "\n");
+ const m = /^---\n((?:[a-z]+: ?.*\n)*)---\n/.exec(s);
+ if (!m) return { title: "", tags: [], md: s };
+ const fields = {};
+ for (const [, key, value] of m[1].matchAll(/^([a-z]+): ?(.*)$/gm)) fields[key] = value;
+ return {
+ title: (fields.title || "").trim(),
+ tags: (fields.tags || "").split(",").map((t) => t.trim()).filter(Boolean),
+ md: s.slice(m[0].length),
+ };
+}
+
+function format({ title, tags = [], md }) {
+ const lines = [];
+ if (title) lines.push(`title: ${title}`);
+ if (tags.length) lines.push(`tags: ${tags.join(", ")}`);
+ return (lines.length ? `---\n${lines.join("\n")}\n---\n` : "") + md;
+}
+
+async function loadCards() {
+ await fs.mkdir(CARDS, { recursive: true });
+ const cards = { [HOME]: { title: "", tags: [], md: "Double-click a card to edit it.\n" } };
+ const names = (await fs.readdir(CARDS)).filter((n) => n.endsWith(".md")).sort();
+ for (const name of names) {
+ const key = name.slice(0, -3);
+ if (!validKey(key)) continue;
+ cards[key] = parse(await fs.readFile(path.join(CARDS, name), "utf8"));
+ }
+ return cards;
+}
+
+async function writeCard(key, card) {
+ const file = fileOf(key);
+ await fs.writeFile(file + ".tmp", format(card), "utf8");
+ await fs.rename(file + ".tmp", file);
+}
+
+function readBody(req) {
+ return new Promise((resolve, reject) => {
+ const chunks = [];
+ let size = 0;
+ req.on("data", (chunk) => {
+ size += chunk.length;
+ if (size > MAX_BODY) {
+ // Stop collecting and drain the rest of the request.
+ req.removeAllListeners("data");
+ req.resume();
+ const err = new Error("body too large");
+ err.status = 413;
+ reject(err);
+ return;
+ }
+ chunks.push(chunk);
+ });
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
+ req.on("error", reject);
+ });
+}
+
+function send(res, status, body, type = "text/plain; charset=utf-8") {
+ res.writeHead(status, { "Content-Type": type, "Cache-Control": "no-store" });
+ res.end(body);
+}
+
+async function route(req, res) {
+ // Blocks DNS rebinding from other sites: only our own names get an answer.
+ const host = (req.headers.host || "").toLowerCase().replace(/:\d+$/, "");
+ if (![HOST, NAME_HOST, "localhost", NAME].includes(host)) {
+ return send(res, 421, "wrong host");
+ }
+
+ const { pathname } = new URL(req.url, "http://127.0.0.1");
+
+ if (req.method === "GET" && pathname === "/") {
+ const json = JSON.stringify(await loadCards()).replace(/</g, "\\u003c");
+ const html = (await fs.readFile(path.join(__dirname, "index.html"), "utf8")).replace(
+ "<!--CARDS-->",
+ () => `<script type="application/json" id="cards">${json}</script>`,
+ );
+ return send(res, 200, html, "text/html; charset=utf-8");
+ }
+
+ if (req.method === "GET" && STATIC.has(pathname)) {
+ const { file, type } = STATIC.get(pathname);
+ return send(res, 200, await fs.readFile(file), type);
+ }
+
+ const m = /^\/api\/cards\/([^/]+)$/.exec(pathname);
+ if (m) {
+ const key = m[1];
+ if (!validKey(key)) return send(res, 400, "bad card id");
+
+ if (req.method === "PUT") {
+ // A JSON content type forces a CORS preflight, which this server never grants, so other sites cannot write.
+ if (!(req.headers["content-type"] || "").startsWith("application/json")) {
+ return send(res, 415, "expected application/json");
+ }
+ const body = JSON.parse(await readBody(req));
+ if (typeof body?.md !== "string" || (body.title !== undefined && typeof body.title !== "string")) {
+ return send(res, 400, "expected {title, md}");
+ }
+ const tags = body.tags === undefined ? [] : cleanTags(body.tags);
+ if (tags === null) return send(res, 400, "bad tags");
+ await writeCard(key, {
+ title: (body.title || "").replace(/[\r\n]+/g, " ").trim(),
+ tags,
+ md: body.md.replace(/\r\n/g, "\n"),
+ });
+ return send(res, 204, "");
+ }
+
+ if (req.method === "DELETE") {
+ if (key === HOME) return send(res, 400, "the home card cannot be deleted");
+ await fs.rm(fileOf(key), { force: true });
+ return send(res, 204, "");
+ }
+
+ return send(res, 405, "method not allowed");
+ }
+
+ return send(res, 404, "not found");
+}
+
+async function handler(req, res) {
+ try {
+ await route(req, res);
+ } catch (err) {
+ send(res, err.status || (err instanceof SyntaxError ? 400 : 500), err.message);
+ }
+}
+
+if (require.main === module) {
+ http.createServer(handler).listen(PORT, HOST, () => {
+ console.log(`To-do cards: http://${HOST}:${PORT}/ (cards in ${CARDS})`);
+ });
+ // the named address is extra: if its port is taken, the numbered one still serves
+ http.createServer(handler)
+ .on("error", (err) => console.error(`http://${NAME}/ not served: ${err.message}`))
+ .listen(NAME_PORT, NAME_HOST, () => console.log(`To-do cards: http://${NAME}/`));
+}
+
+module.exports = { validKey, cleanTags, parse, format };
--- /dev/null
+"use strict";
+
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const { validKey, cleanTags, parse, format } = require("./server.js");
+
+test("validKey accepts good keys", () => {
+ for (const key of ["_home", "a", "buy-milk", "x1-2"]) {
+ assert.equal(validKey(key), true, key);
+ }
+});
+
+test("validKey rejects bad keys", () => {
+ // The hex escapes spell two hyphens.
+ const bad = ["", "../x", "a/b", "A", "a\x2d\x2db", "-a", "a-", "home.md", "_x"];
+ for (const key of bad) {
+ assert.equal(validKey(key), false, JSON.stringify(key));
+ }
+});
+
+test("parse and format round trip with a title and tags", () => {
+ const card = { title: "Buy milk", tags: ["shop", "urgent"], md: "- eggs\n" };
+ assert.deepEqual(parse(format(card)), card);
+});
+
+test("format writes a header with tags only", () => {
+ const text = format({ tags: ["a"], md: "x\n" });
+ assert.equal(text, "---\ntags: a\n---\nx\n");
+ assert.deepEqual(parse(text), { title: "", tags: ["a"], md: "x\n" });
+});
+
+test("parse without front matter gives an empty title and no tags", () => {
+ assert.deepEqual(parse("just text\n"), { title: "", tags: [], md: "just text\n" });
+});
+
+test("parse turns CRLF into LF in title and body", () => {
+ const text = "---\r\ntitle: Buy milk\r\n---\r\nline one\r\nline two\r\n";
+ assert.deepEqual(parse(text), { title: "Buy milk", tags: [], md: "line one\nline two\n" });
+});
+
+test("cleanTags lowercases and removes duplicates", () => {
+ assert.deepEqual(cleanTags([" Foo ", "bar", "FOO"]), ["foo", "bar"]);
+});
+
+test("cleanTags rejects bad input", () => {
+ for (const bad of [["-x"], ["a b"], [""], "a", [1]]) {
+ assert.equal(cleanTags(bad), null, JSON.stringify(bad));
+ }
+});