]> git.earman.xyz Git - pim-card-server.git/commitdiff
Initial import of todo-cards
authorrush <rearman@r717.net>
Mon, 19 Oct 2026 15:13:20 +0000 (11:13 -0400)
committerrush <rearman@r717.net>
Mon, 19 Oct 2026 15:17:56 +0000 (11:17 -0400)
Local to-do card app: a dependency-free Node server that serves
Markdown cards to one browser page, with tests run by node --test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
13 files changed:
.gitattributes [new file with mode: 0644]
.gitignore [new file with mode: 0644]
CLAUDE.md [new file with mode: 0644]
app.js [new file with mode: 0644]
dates.js [new file with mode: 0644]
dates.test.js [new file with mode: 0644]
index.html [new file with mode: 0644]
lines.js [new file with mode: 0644]
lines.test.js [new file with mode: 0644]
md.js [new file with mode: 0644]
md.test.js [new file with mode: 0644]
server.js [new file with mode: 0644]
server.test.js [new file with mode: 0644]

diff --git a/.gitattributes b/.gitattributes
new file mode 100644 (file)
index 0000000..e2d7a31
--- /dev/null
@@ -0,0 +1,3 @@
+* text=auto eol=lf
+*.png binary
+*.ico binary
diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..3c4eed6
--- /dev/null
@@ -0,0 +1,20 @@
+# 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
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644 (file)
index 0000000..95bb631
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,41 @@
+# 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.
diff --git a/app.js b/app.js
new file mode 100644 (file)
index 0000000..ed099b1
--- /dev/null
+++ b/app.js
@@ -0,0 +1,1515 @@
+{
+       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();
+}
diff --git a/dates.js b/dates.js
new file mode 100644 (file)
index 0000000..4338fa2
--- /dev/null
+++ b/dates.js
@@ -0,0 +1,265 @@
+(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);
diff --git a/dates.test.js b/dates.test.js
new file mode 100644 (file)
index 0000000..cef31c4
--- /dev/null
@@ -0,0 +1,361 @@
+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);
+});
diff --git a/index.html b/index.html
new file mode 100644 (file)
index 0000000..24d459c
--- /dev/null
@@ -0,0 +1,361 @@
+<!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>
diff --git a/lines.js b/lines.js
new file mode 100644 (file)
index 0000000..2f9b1cf
--- /dev/null
+++ b/lines.js
@@ -0,0 +1,87 @@
+(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);
diff --git a/lines.test.js b/lines.test.js
new file mode 100644 (file)
index 0000000..6bc0d68
--- /dev/null
@@ -0,0 +1,53 @@
+"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);
+});
diff --git a/md.js b/md.js
new file mode 100644 (file)
index 0000000..222a216
--- /dev/null
+++ b/md.js
@@ -0,0 +1,152 @@
+// Markdown to HTML for the cards. Shared by app.js; copied from wiki.html.
+function md2html(str) {
+       const enchtml = (str) => str.replaceAll("<", "&lt;");
+       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");
+}
diff --git a/md.test.js b/md.test.js
new file mode 100644 (file)
index 0000000..8393a80
--- /dev/null
@@ -0,0 +1,56 @@
+"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);
+});
diff --git a/server.js b/server.js
new file mode 100644 (file)
index 0000000..e0e344e
--- /dev/null
+++ b/server.js
@@ -0,0 +1,193 @@
+"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 };
diff --git a/server.test.js b/server.test.js
new file mode 100644 (file)
index 0000000..3f60f21
--- /dev/null
@@ -0,0 +1,49 @@
+"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));
+       }
+});