From 668d51d6ed6f18401f9721834faf9cc13fca4d92 Mon Sep 17 00:00:00 2001 From: rush Date: Mon, 19 Oct 2026 11:13:20 -0400 Subject: [PATCH] Initial import of todo-cards 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) --- .gitattributes | 3 + .gitignore | 20 + CLAUDE.md | 41 ++ app.js | 1515 ++++++++++++++++++++++++++++++++++++++++++++++++ dates.js | 265 +++++++++ dates.test.js | 361 ++++++++++++ index.html | 361 ++++++++++++ lines.js | 87 +++ lines.test.js | 53 ++ md.js | 152 +++++ md.test.js | 56 ++ server.js | 193 ++++++ server.test.js | 49 ++ 13 files changed, 3156 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 app.js create mode 100644 dates.js create mode 100644 dates.test.js create mode 100644 index.html create mode 100644 lines.js create mode 100644 lines.test.js create mode 100644 md.js create mode 100644 md.test.js create mode 100644 server.js create mode 100644 server.test.js diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e2d7a31 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +*.png binary +*.ico binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c4eed6 --- /dev/null +++ b/.gitignore @@ -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 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 ``, serves the static scripts, and takes `PUT`/`DELETE` on `/api/cards/`. Each card is one file `.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 `` 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 `
` 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 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 `
`; + } + ) + .replace( + /^[ \t]*\{\{tags:([^}\n]*)\}\}[ \t]*$/gm, + (_, q) => + `
` + ) + )); + + // 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 = + '
' + + '' + + '' + + '' + + '' + + '' + + "
" + + '

' + + '
'; + // 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 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 index 0000000..cef31c4 --- /dev/null +++ b/dates.test.js @@ -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 index 0000000..24d459c --- /dev/null +++ b/index.html @@ -0,0 +1,361 @@ + + + + + + To-do cards + + + + +
+ + + + + + + +
+ + + + + + + + + diff --git a/lines.js b/lines.js new file mode 100644 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 index 0000000..6bc0d68 --- /dev/null +++ b/lines.test.js @@ -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 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("<", "<"); + const codeBuf = []; + const stash = (txt) => + `\u0000${codeBuf.push(`${enchtml(txt)}`) - 1}\u0000`; + const inlines = [ + // stash code spans first: no later rule may see inside them + [/([\s\S]*?)<\/code>/g, (_, txt) => stash(txt)], + [/`(.+?)`/g, (_, txt) => stash(txt)], + [ + /(\*{1,2}|~~)(.+?)\1/g, + (_, c, txt) => + c == "*" + ? `${txt}` + : c == "**" + ? `${txt}` + : `${txt}`, + ], + [ + /!\[(.*?)\]\((.+?)\)/g, + (_, txt, url) => `${txt}`, + ], + [ + /\[(.+?)\]\((.+?)\)/g, + (_, txt, url) => `${txt}`, + ], + [ + /(? `${url}`, + ], + // 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 `
  • ${replaceInlines(item)}
  • `; + const on = mark[1] == " " ? "" : " checked"; + return `
  • `; + }; + const fenceBuf = []; + const fenceMark = "\n\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 + ""); + continue; + } + at++; + // inline rules run inside item(), after the checkbox test: a link rule run first eats "[ ]" + html += item(text); + } + return `<${tag}>${html}`; + }; + return rows.length ? build(rows[0][0]) : txt; + }; + const blocks = [ + [ + /\n```([^\n]*)\n([\s\S]*?)\n```\n/g, + (_, arg, code) => + fence(`\n
    ${enchtml(code)}
    \n`), + ], + [ + /\n(#+)([^\n]+)/g, + (_, h, txt) => `\n${replaceInlines(txt.trim())}`, + ], + [ + /\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) => `
  • ${replaceInlines(t)}
  • `), + ], + [ + /\n([^<\n][^\n]*\n)+/g, + (txt) => + `\n

    ${replaceInlines( + txt.replaceAll(" \n", "
    \n").trim() + )}

    \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("", "", ""); + } + }; + 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(`
    `, `${h[1]} ${h[3]}`, "", "
    ", ""); + } + 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`); + }) + .join("\n"); +} diff --git a/md.test.js b/md.test.js new file mode 100644 index 0000000..8393a80 --- /dev/null +++ b/md.test.js @@ -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, /^
    \n## A\n<\/summary>/); + assert.match(out, /
    \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("
    "); + 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, /\s*

    Go there<\/a> and 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 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- [ ] a\n```\n- [ ] code\n```\n - [x] 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, /
    • .*top.*
      • .*child.*
        • grandchild<\/li><\/ul>/s); +}); diff --git a/server.js b/server.js new file mode 100644 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(/", + () => ``, + ); + 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 index 0000000..3f60f21 --- /dev/null +++ b/server.test.js @@ -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)); + } +}); -- 2.39.5