From: rush Date: Mon, 19 Oct 2026 15:13:20 +0000 (-0400) Subject: Initial import of todo-cards X-Git-Url: https://git.earman.xyz/?a=commitdiff_plain;h=668d51d6ed6f18401f9721834faf9cc13fca4d92;p=pim-card-server.git 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) --- 668d51d6ed6f18401f9721834faf9cc13fca4d92 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)); + } +});