) are numbered by first
+// appearance; footnote texts (elements with data-fid) get the number of their id, or show the id if nothing
+// references them. `inScope(el)` leaves out parts of `root` that number on their own (embeds).
+export function paintFootnotes(root, inScope = () => true) {
+ const texts = new Set([...root.querySelectorAll('[data-fid]')].filter(inScope).map((el) => el.dataset.fid));
+ const order = new Map();
+ let n = 0;
+ for (const s of root.querySelectorAll('sup.fn')) {
+ if (!inScope(s)) continue;
+ if ('inline' in s.dataset) { s.textContent = String(++n); continue; }
+ const id = s.dataset.fn;
+ if (!order.has(id)) order.set(id, ++n);
+ s.textContent = String(order.get(id));
+ s.classList.toggle('missing', !texts.has(id));
+ }
+ for (const el of root.querySelectorAll('[data-fid]')) {
+ if (!inScope(el)) continue;
+ const k = order.get(el.dataset.fid);
+ const num = el.querySelector('.fn-num');
+ if (num) num.textContent = k ? String(k) : el.dataset.fid;
+ el.classList.toggle('orphan', !k);
+ }
+ return order;
+}
diff --git a/src/lib/formula.js b/src/lib/formula.js
new file mode 100644
index 0000000..713162f
--- /dev/null
+++ b/src/lib/formula.js
@@ -0,0 +1,341 @@
+// A small formula language for database properties. Pure functions (no DOM), so they can be tested on their own.
+//
+// prop("Price") * prop("Quantity") a property by name ({Price} is the same)
+// if(prop("Opened"), dateBetween(prop("Expiry"), today(), "days"), "")
+// concat(prop("Brand"), " – ", prop("Name"))
+//
+// Values are numbers, text, true / false, dates (text like 2026-03-01) and lists. An empty value counts as 0 in
+// arithmetic, "" in text and false in conditions. Nothing here can touch anything but the values it is given.
+
+export class FormulaError extends Error {}
+
+/* tokens */
+const isDigit = (c) => c >= '0' && c <= '9';
+const isIdentStart = (c) => /[A-Za-z_]/.test(c);
+const isIdent = (c) => /[A-Za-z0-9_]/.test(c);
+
+function tokenize(src) {
+ const out = [];
+ let i = 0;
+ while (i < src.length) {
+ const c = src[i];
+ if (/\s/.test(c)) { i++; continue; }
+ const at = i;
+ if (isDigit(c) || (c === '.' && isDigit(src[i + 1] || ''))) {
+ let j = i;
+ while (j < src.length && (isDigit(src[j]) || src[j] === '.')) j++;
+ const text = src.slice(i, j);
+ if ((text.match(/\./g) || []).length > 1) throw new FormulaError(`“${text}” is not a number (at ${at + 1})`);
+ out.push({ t: 'num', v: Number(text), at });
+ i = j;
+ } else if (c === '"' || c === "'") {
+ let j = i + 1, s = '';
+ while (j < src.length && src[j] !== c) {
+ if (src[j] === '\\' && j + 1 < src.length) {
+ const n = src[j + 1];
+ s += n === 'n' ? '\n' : n === 't' ? '\t' : n;
+ j += 2;
+ } else s += src[j++];
+ }
+ if (j >= src.length) throw new FormulaError(`The text starting at ${at + 1} is not closed`);
+ out.push({ t: 'str', v: s, at });
+ i = j + 1;
+ } else if (c === '{') {
+ const j = src.indexOf('}', i);
+ if (j < 0) throw new FormulaError(`The “{” at ${at + 1} is not closed`);
+ out.push({ t: 'prop', v: src.slice(i + 1, j).trim(), at });
+ i = j + 1;
+ } else if (isIdentStart(c)) {
+ let j = i;
+ while (j < src.length && isIdent(src[j])) j++;
+ out.push({ t: 'id', v: src.slice(i, j), at });
+ i = j;
+ } else {
+ const two = src.slice(i, i + 2);
+ if (['==', '!=', '<=', '>=', '&&', '||', '<>'].includes(two)) { out.push({ t: 'op', v: two === '<>' ? '!=' : two, at }); i += 2; }
+ else if ('+-*/%^<>!(),='.includes(c)) { out.push({ t: 'op', v: c === '=' ? '==' : c, at }); i++; }
+ else throw new FormulaError(`Unexpected “${c}” at ${at + 1}`);
+ }
+ }
+ return out;
+}
+
+/* parser: precedence climbing */
+export function parseFormula(src) {
+ const toks = tokenize(String(src));
+ let p = 0;
+ const peek = () => toks[p];
+ const next = () => toks[p++];
+ const isOp = (v) => peek() && peek().t === 'op' && peek().v === v;
+ const isWord = (v) => peek() && peek().t === 'id' && peek().v.toLowerCase() === v;
+ const expect = (v) => {
+ if (!isOp(v)) throw new FormulaError(peek() ? `Expected “${v}” at ${peek().at + 1}` : `Expected “${v}” at the end`);
+ p++;
+ };
+
+ function or() {
+ let a = and();
+ while (isOp('||') || isWord('or')) { p++; a = { t: 'or', a, b: and() }; }
+ return a;
+ }
+ function and() {
+ let a = eq();
+ while (isOp('&&') || isWord('and')) { p++; a = { t: 'and', a, b: eq() }; }
+ return a;
+ }
+ function eq() {
+ let a = cmp();
+ while (isOp('==') || isOp('!=')) { const op = next().v; a = { t: 'bin', op, a, b: cmp() }; }
+ return a;
+ }
+ function cmp() {
+ let a = add();
+ while (isOp('<') || isOp('>') || isOp('<=') || isOp('>=')) { const op = next().v; a = { t: 'bin', op, a, b: add() }; }
+ return a;
+ }
+ function add() {
+ let a = mul();
+ while (isOp('+') || isOp('-')) { const op = next().v; a = { t: 'bin', op, a, b: mul() }; }
+ return a;
+ }
+ function mul() {
+ let a = unary();
+ while (isOp('*') || isOp('/') || isOp('%')) { const op = next().v; a = { t: 'bin', op, a, b: unary() }; }
+ return a;
+ }
+ function unary() {
+ if (isOp('-')) { p++; return { t: 'un', op: '-', a: unary() }; }
+ if (isOp('!') || isWord('not')) { p++; return { t: 'un', op: '!', a: unary() }; }
+ return power();
+ }
+ function power() {
+ const a = primary();
+ if (isOp('^')) { p++; return { t: 'bin', op: '^', a, b: unary() }; } // right-associative
+ return a;
+ }
+ function primary() {
+ const t = next();
+ if (!t) throw new FormulaError('The formula ends too soon');
+ if (t.t === 'num') return { t: 'lit', v: t.v };
+ if (t.t === 'str') return { t: 'lit', v: t.v };
+ if (t.t === 'prop') return { t: 'prop', name: t.v };
+ if (t.t === 'op' && t.v === '(') {
+ const e = or();
+ expect(')');
+ return e;
+ }
+ if (t.t === 'id') {
+ const low = t.v.toLowerCase();
+ if (isOp('(')) {
+ p++;
+ const args = [];
+ if (!isOp(')')) {
+ for (;;) {
+ args.push(or());
+ if (isOp(',')) { p++; continue; }
+ break;
+ }
+ }
+ expect(')');
+ return { t: 'call', name: low, args, at: t.at };
+ }
+ if (low === 'true') return { t: 'lit', v: true };
+ if (low === 'false') return { t: 'lit', v: false };
+ if (low === 'empty' || low === 'null') return { t: 'lit', v: null };
+ throw new FormulaError(`“${t.v}” is not a function or a value. Property names go in prop("…") or {…}`);
+ }
+ throw new FormulaError(`Unexpected “${t.v}” at ${t.at + 1}`);
+ }
+
+ if (!toks.length) throw new FormulaError('The formula is empty');
+ const ast = or();
+ if (p < toks.length) throw new FormulaError(`Unexpected “${toks[p].v}” at ${toks[p].at + 1}`);
+ return ast;
+}
+
+/* values */
+export const isDateText = (v) => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}(?:$|[T ])/.test(v);
+const dayNum = (s) => Math.round(Date.UTC(+s.slice(0, 4), +s.slice(5, 7) - 1, +s.slice(8, 10)) / 864e5);
+const isoDay = (n) => new Date(n * 864e5).toISOString().slice(0, 10);
+const blank = (v) => v === null || v === undefined || v === '' || (Array.isArray(v) && !v.length);
+export const truthy = (v) => (Array.isArray(v) ? v.length > 0 : typeof v === 'string' ? v.trim() !== '' && v.toLowerCase() !== 'false' : !!v);
+
+function num(v, what = 'a number') {
+ if (blank(v)) return 0;
+ if (typeof v === 'number') return v;
+ if (typeof v === 'boolean') return v ? 1 : 0;
+ if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v);
+ throw new FormulaError(`“${Array.isArray(v) ? v.join(', ') : v}” can't be used as ${what}`);
+}
+const numeric = (v) => typeof v === 'number' || (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)) && !isDateText(v));
+
+export function show(v) {
+ if (blank(v)) return '';
+ if (typeof v === 'number') return Number.isFinite(v) ? String(Number(v.toFixed(10))) : '';
+ if (typeof v === 'boolean') return v ? 'true' : 'false';
+ if (Array.isArray(v)) return v.map(show).join(', ');
+ return String(v);
+}
+
+const UNITS = { day: 'days', days: 'days', week: 'weeks', weeks: 'weeks', month: 'months', months: 'months', year: 'years', years: 'years' };
+const unitOf = (u) => UNITS[String(u ?? 'days').toLowerCase()] || (() => { throw new FormulaError(`Unknown unit “${u}” (use days, weeks, months or years)`); })();
+function dateOf(v, what = 'a date') {
+ if (isDateText(v)) return v.slice(0, 10);
+ throw new FormulaError(`“${show(v)}” can't be used as ${what}`);
+}
+function addMonths(iso, n) {
+ const y = +iso.slice(0, 4), m = +iso.slice(5, 7) - 1, d = +iso.slice(8, 10);
+ const t = new Date(Date.UTC(y, m + n, 1));
+ const last = new Date(Date.UTC(t.getUTCFullYear(), t.getUTCMonth() + 1, 0)).getUTCDate();
+ return new Date(Date.UTC(t.getUTCFullYear(), t.getUTCMonth(), Math.min(d, last))).toISOString().slice(0, 10);
+}
+function dateAdd(iso, n, unit) {
+ const u = unitOf(unit);
+ if (u === 'days') return isoDay(dayNum(iso) + n);
+ if (u === 'weeks') return isoDay(dayNum(iso) + n * 7);
+ return addMonths(iso, u === 'years' ? n * 12 : n);
+}
+function dateBetween(a, b, unit) {
+ const u = unitOf(unit);
+ const x = dayNum(a), y = dayNum(b);
+ if (u === 'days') return x - y;
+ if (u === 'weeks') return Math.trunc((x - y) / 7);
+ // whole months between two dates (negative if a is before b)
+ let months = (+a.slice(0, 4) - +b.slice(0, 4)) * 12 + (+a.slice(5, 7) - +b.slice(5, 7));
+ const da = +a.slice(8, 10), db = +b.slice(8, 10);
+ if (months > 0 && da < db) months--;
+ else if (months < 0 && da > db) months++;
+ return u === 'years' ? Math.trunc(months / 12) : months;
+}
+const MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
+
+function formatDate(iso, fmt = 'MMM D, YYYY') {
+ const y = iso.slice(0, 4), m = +iso.slice(5, 7), d = +iso.slice(8, 10);
+ return String(fmt)
+ .replace(/YYYY/g, y).replace(/YY/g, y.slice(2))
+ .replace(/MMMM/g, MONTH_NAMES[m - 1]).replace(/MMM/g, MONTH_NAMES[m - 1].slice(0, 3))
+ .replace(/MM/g, String(m).padStart(2, '0')).replace(/DD/g, String(d).padStart(2, '0')).replace(/\bD\b/g, String(d));
+}
+
+/* evaluation */
+const flat = (args) => args.flatMap((a) => (Array.isArray(a) ? a : [a]));
+const nums = (args) => flat(args).filter((v) => !blank(v)).map((v) => num(v));
+const FUNCS = {
+ // logic
+ if: null, and: null, or: null, // lazily, below
+ not: (c) => !truthy(c),
+ empty: (v) => blank(v),
+ // numbers
+ abs: (x) => Math.abs(num(x)),
+ round: (x, d = 0) => { const k = 10 ** num(d); return Math.round((num(x) + Number.EPSILON) * k) / k; },
+ floor: (x) => Math.floor(num(x)),
+ ceil: (x) => Math.ceil(num(x)),
+ sqrt: (x) => { const n = num(x); if (n < 0) throw new FormulaError('The square root of a negative number'); return Math.sqrt(n); },
+ pow: (x, y) => num(x) ** num(y),
+ min: (...a) => { const n = nums(a); return n.length ? Math.min(...n) : null; },
+ max: (...a) => { const n = nums(a); return n.length ? Math.max(...n) : null; },
+ sum: (...a) => nums(a).reduce((s, x) => s + x, 0),
+ average: (...a) => { const n = nums(a); return n.length ? n.reduce((s, x) => s + x, 0) / n.length : null; },
+ tonumber: (v) => { if (blank(v)) return 0; const n = Number(String(v).replace(/[^0-9.+\-eE]/g, '')); if (!Number.isFinite(n)) throw new FormulaError(`“${show(v)}” is not a number`); return n; },
+ // text
+ concat: (...a) => flat(a).map(show).join(''),
+ join: (list, sep = ', ') => (Array.isArray(list) ? list : [list]).map(show).join(show(sep)),
+ length: (v) => (Array.isArray(v) ? v.length : show(v).length),
+ lower: (v) => show(v).toLowerCase(),
+ upper: (v) => show(v).toUpperCase(),
+ trim: (v) => show(v).trim(),
+ contains: (a, b) => (Array.isArray(a) ? a.map((x) => show(x).toLowerCase()).includes(show(b).toLowerCase()) : show(a).toLowerCase().includes(show(b).toLowerCase())),
+ replace: (s, from, to) => show(s).split(show(from)).join(show(to)),
+ format: (v) => show(v),
+ // dates
+ today: () => null, // filled in by the evaluator (it knows the day)
+ now: () => null,
+ datebetween: (a, b, unit) => dateBetween(dateOf(a), dateOf(b), unit),
+ dateadd: (d, n, unit) => dateAdd(dateOf(d), num(n), unit),
+ year: (d) => +dateOf(d).slice(0, 4),
+ month: (d) => +dateOf(d).slice(5, 7),
+ day: (d) => +dateOf(d).slice(8, 10),
+ formatdate: (d, f) => formatDate(dateOf(d), f)
+};
+export const FUNCTION_NAMES = Object.keys(FUNCS).filter((n) => n !== 'now' && n !== 'tonumber').concat('toNumber');
+
+function compare(op, a, b) {
+ const bothNum = numeric(a) && numeric(b);
+ if (bothNum) {
+ const x = Number(a), y = Number(b);
+ return { '<': x < y, '>': x > y, '<=': x <= y, '>=': x >= y, '==': x === y, '!=': x !== y }[op];
+ }
+ if (blank(a) || blank(b)) {
+ const eq = blank(a) && blank(b);
+ if (op === '==') return eq;
+ if (op === '!=') return !eq;
+ return false;
+ }
+ const x = show(a), y = show(b);
+ const c = x < y ? -1 : x > y ? 1 : 0;
+ return { '<': c < 0, '>': c > 0, '<=': c <= 0, '>=': c >= 0, '==': c === 0, '!=': c !== 0 }[op];
+}
+
+function evalNode(n, ctx) {
+ switch (n.t) {
+ case 'lit': return n.v;
+ case 'prop': return ctx.prop(n.name);
+ case 'or': return truthy(evalNode(n.a, ctx)) || truthy(evalNode(n.b, ctx));
+ case 'and': return truthy(evalNode(n.a, ctx)) && truthy(evalNode(n.b, ctx));
+ case 'un': {
+ const v = evalNode(n.a, ctx);
+ return n.op === '-' ? -num(v) : !truthy(v);
+ }
+ case 'bin': {
+ const a = evalNode(n.a, ctx), b = evalNode(n.b, ctx);
+ switch (n.op) {
+ case '+':
+ if (isDateText(a) && !blank(b) && numeric(b)) return dateAdd(a.slice(0, 10), Number(b), 'days');
+ if ((typeof a === 'string' && !numeric(a)) || (typeof b === 'string' && !numeric(b)) || Array.isArray(a) || Array.isArray(b)) return show(a) + show(b);
+ return num(a) + num(b);
+ case '-':
+ if (isDateText(a) && isDateText(b)) return dateBetween(a.slice(0, 10), b.slice(0, 10), 'days');
+ if (isDateText(a) && !blank(b) && numeric(b)) return dateAdd(a.slice(0, 10), -Number(b), 'days');
+ return num(a) - num(b);
+ case '*': return num(a) * num(b);
+ case '/': { const d = num(b); if (d === 0) throw new FormulaError('Division by zero'); return num(a) / d; }
+ case '%': { const d = num(b); if (d === 0) throw new FormulaError('Division by zero'); return num(a) % d; }
+ case '^': return num(a) ** num(b);
+ default: return compare(n.op, a, b);
+ }
+ }
+ case 'call': {
+ const name = n.name;
+ if (name === 'if') {
+ if (n.args.length < 2 || n.args.length > 3) throw new FormulaError('if() takes a condition, a value for true and optionally one for false');
+ return truthy(evalNode(n.args[0], ctx)) ? evalNode(n.args[1], ctx) : n.args[2] ? evalNode(n.args[2], ctx) : null;
+ }
+ if (name === 'and') return n.args.every((a) => truthy(evalNode(a, ctx)));
+ if (name === 'or') return n.args.some((a) => truthy(evalNode(a, ctx)));
+ if (name === 'prop') {
+ if (n.args.length !== 1) throw new FormulaError('prop() takes one property name');
+ return ctx.prop(show(evalNode(n.args[0], ctx)));
+ }
+ if (name === 'today' || name === 'now') return ctx.today;
+ const f = FUNCS[name];
+ if (typeof f !== 'function') throw new FormulaError(`There is no function called “${name}”`);
+ return f(...n.args.map((a) => evalNode(a, ctx)));
+ }
+ default: throw new FormulaError('Unsupported formula');
+ }
+}
+
+const cache = new Map();
+// Evaluate `src`. ctx: { prop(name) -> value (throws FormulaError for an unknown name), today: 'YYYY-MM-DD' }.
+// Returns the value; throws FormulaError.
+export function evaluateFormula(src, ctx) {
+ let ast = cache.get(src);
+ if (!ast) {
+ ast = parseFormula(src);
+ if (cache.size > 500) cache.clear();
+ cache.set(src, ast);
+ }
+ const v = evalNode(ast, ctx);
+ if (typeof v === 'number' && !Number.isFinite(v)) throw new FormulaError('The result is not a number');
+ return v;
+}
diff --git a/src/lib/graph.js b/src/lib/graph.js
index a01c8dd..f38f420 100644
--- a/src/lib/graph.js
+++ b/src/lib/graph.js
@@ -7,7 +7,7 @@
// Like d3-force, every push is scaled by `alpha`, which cools from 1 towards 0 so the layout settles;
// dragging a node or changing the graph warms it up again.
-const THETA2 = 0.81; // Barnes–Hut accuracy (theta = 0.9): bigger = faster, rougher
+let THETA2 = 0.81; // Barnes–Hut accuracy (theta = 0.9): bigger = faster, rougher; a big graph sets it per tick
export function createSimulation({ repel = 420, linkDistance = 96, gravity = 0.04, velocityDecay = 0.42 } = {}) {
let nodes = [];
@@ -54,7 +54,18 @@ export function createSimulation({ repel = 420, linkDistance = 96, gravity = 0.0
reheat(to = 0.6) { sim.alpha = Math.max(sim.alpha, to); return sim; },
get running() { return sim.alpha > sim.alphaMin || sim.alphaTarget > 0; },
+ // Ticks until `times` are done or `ms` have passed (at least one): a big graph must not freeze the page.
+ tickFor(times, ms) {
+ const end = performance.now() + ms;
+ for (let t = 0; t < times; t++) {
+ sim.tick(1);
+ if (performance.now() >= end) break;
+ }
+ return sim;
+ },
+
tick(times = 1) {
+ THETA2 = nodes.length > 3000 ? 1.8 : 0.81; // 10k notes: coarser far-field, about twice as fast
for (let t = 0; t < times; t++) {
sim.alpha += (sim.alphaTarget - sim.alpha) * sim.alphaDecay;
const alpha = sim.alpha;
diff --git a/src/lib/graphColors.js b/src/lib/graphColors.js
new file mode 100644
index 0000000..4459111
--- /dev/null
+++ b/src/lib/graphColors.js
@@ -0,0 +1,11 @@
+import { tagHex } from './tagColors.js';
+
+// The colour of a dot in the graph: the colour of the first of its tags that has one (set in the sidebar's tag
+// menu), otherwise `plain`. (There is no automatic colour per tag: it would look just like a chosen one.)
+export function nodeColor(tags, tagColors, plain) {
+ for (const t of tags || []) {
+ const hex = tagHex(tagColors && tagColors[t]);
+ if (hex) return hex;
+ }
+ return plain;
+}
diff --git a/src/lib/highlight.js b/src/lib/highlight.js
new file mode 100644
index 0000000..214d0a2
--- /dev/null
+++ b/src/lib/highlight.js
@@ -0,0 +1,20 @@
+import { esc } from './text.js';
+
+// Small single-pass highlighter: comments, strings, keywords, numbers, function calls.
+const RE =
+ /(\/\/[^\n]*|#[^\n]*)|("(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`)|\b(const|let|var|function|return|if|else|for|while|import|from|export|default|async|await|new|class|def|in|of|true|false|null|undefined|None|True|False|npm|npx|cd)\b|\b(\d+(?:\.\d+)?)\b|([A-Za-z_$][\w$]*)(?=\()/g;
+
+export function highlight(src) {
+ let out = '', last = 0, m;
+ RE.lastIndex = 0;
+ while ((m = RE.exec(src))) {
+ out += esc(src.slice(last, m.index));
+ const cls = m[1] ? 'tc' : m[2] ? 'ts' : m[3] ? 'tk' : m[4] ? 'tn' : 'tf';
+ out += `${esc(m[0])}`;
+ last = RE.lastIndex;
+ }
+ out += esc(src.slice(last));
+ // A trailing newline needs a
to get its own visible line in a contenteditable .
+ if (src.endsWith('\n')) out += '
';
+ return out;
+}
diff --git a/src/lib/htmlDoc.js b/src/lib/htmlDoc.js
new file mode 100644
index 0000000..4b30725
--- /dev/null
+++ b/src/lib/htmlDoc.js
@@ -0,0 +1,136 @@
+// HTML -> Markdown for whole documents and clipboard content (web pages, Notion/Word/Docs copies).
+// Runs in the page (DOMParser). `base` makes relative links and pictures absolute.
+// htmlToMarkdown(html, { base }) -> Markdown text
+// articleOf(html) -> { title, html } the main content of a web page
+
+const BLOCKS = new Set(['P', 'DIV', 'SECTION', 'ARTICLE', 'MAIN', 'HEADER', 'FOOTER', 'ASIDE', 'FIGURE', 'FIGCAPTION', 'DETAILS', 'SUMMARY', 'ADDRESS', 'BODY', 'HTML']);
+const DROP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'IFRAME', 'OBJECT', 'EMBED', 'SVG', 'CANVAS', 'FORM', 'BUTTON', 'SELECT', 'TEXTAREA', 'HEAD', 'LINK', 'META']);
+const esc = (s) => s.replace(/([\\`*_[\]])/g, '\\$1');
+
+function absolute(href, base) {
+ if (!href) return '';
+ try { return new URL(href, base || undefined).href; } catch { return href; }
+}
+const safeHref = (h) => (/^(https?:|mailto:)/i.test(h) ? h : '');
+
+export function htmlToMarkdown(html, { base = '' } = {}) {
+ const doc = new DOMParser().parseFromString(String(html || ''), 'text/html');
+ const out = walkBlocks(doc.body, { base, depth: 0 });
+ return out.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
+}
+
+// inline content of an element
+function inline(node, cx) {
+ let s = '';
+ for (const n of node.childNodes) {
+ if (n.nodeType === 3) { s += esc(n.data.replace(/\s+/g, ' ')); continue; }
+ if (n.nodeType !== 1 || DROP.has(n.tagName.toUpperCase())) continue;
+ const tag = n.tagName.toUpperCase();
+ const inner = () => inline(n, cx);
+ const wrap = (m) => { const t = inner(); const lead = t.match(/^\s*/)[0], trail = t.match(/\s*$/)[0]; const core = t.trim(); return core ? lead + m + core + m + trail : t; };
+ switch (tag) {
+ case 'STRONG': case 'B': s += wrap('**'); break;
+ case 'EM': case 'I': s += wrap('*'); break;
+ case 'DEL': case 'S': case 'STRIKE': s += wrap('~~'); break;
+ case 'MARK': s += wrap('=='); break;
+ case 'CODE': s += '`' + n.textContent.replace(/`/g, "'") + '`'; break;
+ case 'BR': s += '\n'; break;
+ case 'IMG': {
+ const src = absolute(n.getAttribute('src') || '', cx.base);
+ if (/^https?:/i.test(src)) s += ``;
+ break;
+ }
+ case 'A': {
+ const href = safeHref(absolute(n.getAttribute('href') || '', cx.base));
+ const t = inner().trim();
+ s += href && t ? `[${t}](${href.replace(/\)/g, '%29')})` : t;
+ break;
+ }
+ case 'INPUT': break;
+ default: s += inner();
+ }
+ }
+ return s;
+}
+
+const hasBlock = (el) => [...el.children].some((c) => BLOCKS.has(c.tagName) || /^(H[1-6]|UL|OL|LI|TABLE|BLOCKQUOTE|PRE|HR)$/.test(c.tagName));
+
+// block content of an element, as Markdown with blank lines between blocks
+function walkBlocks(el, cx) {
+ const parts = [];
+ let run = '';
+ const flush = () => { const t = run.replace(/[ \t]*\n[ \t]*/g, '\n').trim(); if (t) parts.push(t.replace(/\n/g, ' \n')); run = ''; };
+ for (const n of el.childNodes) {
+ if (n.nodeType === 3) { run += esc(n.data.replace(/\s+/g, ' ')); continue; }
+ if (n.nodeType !== 1) continue;
+ const tag = n.tagName.toUpperCase();
+ if (DROP.has(tag)) continue;
+ const m = /^H([1-6])$/.exec(tag);
+ if (m) { flush(); const t = inline(n, cx).replace(/\s+/g, ' ').trim(); if (t) parts.push('#'.repeat(+m[1]) + ' ' + t); continue; }
+ if (tag === 'P' || ((tag === 'DIV' || BLOCKS.has(tag)) && !hasBlock(n))) { flush(); const t = inline(n, cx).replace(/[ \t]*\n[ \t]*/g, '\n').trim(); if (t) parts.push(t.replace(/\n/g, ' \n')); continue; }
+ if (BLOCKS.has(tag)) { flush(); const t = walkBlocks(n, cx); if (t) parts.push(t); continue; }
+ if (tag === 'UL' || tag === 'OL') { flush(); parts.push(list(n, cx, 0)); continue; }
+ if (tag === 'BLOCKQUOTE') { flush(); const t = walkBlocks(n, cx); if (t) parts.push(t.split('\n').map((l) => (l ? '> ' + l : '>')).join('\n')); continue; }
+ if (tag === 'PRE') {
+ flush();
+ const code = n.querySelector('code');
+ const lang = ((code || n).className.match(/(?:language|lang)-([\w+#-]+)/) || [])[1] || '';
+ parts.push('```' + lang + '\n' + n.textContent.replace(/\n+$/, '') + '\n```');
+ continue;
+ }
+ if (tag === 'HR') { flush(); parts.push('---'); continue; }
+ if (tag === 'TABLE') { flush(); const t = table(n, cx); if (t) parts.push(t); continue; }
+ if (tag === 'LI') { flush(); parts.push(list({ children: [n], tagName: 'UL', getAttribute: () => null }, cx, 0)); continue; }
+ run += inline({ childNodes: [n] }, cx);
+ }
+ flush();
+ return parts.join('\n\n');
+}
+
+function list(el, cx, depth) {
+ const ordered = el.tagName === 'OL';
+ let i = Number(el.getAttribute && el.getAttribute('start')) || 1;
+ const lines = [];
+ for (const li of el.children) {
+ if (li.tagName !== 'LI') continue;
+ const sub = [];
+ let own = '';
+ for (const n of li.childNodes) {
+ if (n.nodeType === 1 && (n.tagName === 'UL' || n.tagName === 'OL')) sub.push(list(n, cx, depth + 1));
+ else if (n.nodeType === 1 && (n.tagName === 'P' || n.tagName === 'DIV')) own += ' ' + inline(n, cx);
+ else if (n.nodeType === 1 && DROP.has(n.tagName)) continue;
+ else own += inline({ childNodes: [n] }, cx);
+ }
+ const box = li.querySelector(':scope > input[type=checkbox], :scope > p > input[type=checkbox]');
+ const text = own.replace(/\s+/g, ' ').trim();
+ const mark = ordered ? `${i++}. ` : '- ';
+ lines.push(' '.repeat(depth) + mark + (box ? (box.hasAttribute('checked') ? '[x] ' : '[ ] ') : '') + text);
+ for (const s of sub) lines.push(s);
+ }
+ return lines.join('\n');
+}
+
+function table(el, cx) {
+ const rows = [...el.querySelectorAll('tr')].map((tr) => [...tr.children].filter((c) => /^T[DH]$/.test(c.tagName)).map((c) => inline(c, cx).replace(/\s+/g, ' ').replace(/\|/g, '\\|').trim()));
+ if (!rows.length || !rows[0].length) return '';
+ const w = Math.max(...rows.map((r) => r.length));
+ const pad = (r) => '| ' + Array.from({ length: w }, (_, i) => r[i] || '').join(' | ') + ' |';
+ return [pad(rows[0]), '|' + ' --- |'.repeat(w), ...rows.slice(1).map(pad)].join('\n');
+}
+
+// A web page's main content: , or the biggest block of text, with page chrome removed.
+export function articleOf(html, { base = '' } = {}) {
+ const doc = new DOMParser().parseFromString(String(html || ''), 'text/html');
+ for (const bad of doc.querySelectorAll('script,style,noscript,nav,footer,aside,form,header nav,[role=navigation],[role=banner],[aria-hidden=true],.advert,.ads,.cookie,.share,.social')) bad.remove();
+ const title = (doc.querySelector('meta[property="og:title"]')?.getAttribute('content') || doc.querySelector('h1')?.textContent || doc.title || '').replace(/\s+/g, ' ').trim();
+ let root = doc.querySelector('article') || doc.querySelector('main,[role=main]');
+ if (!root) {
+ let best = null, score = 0;
+ for (const el of doc.querySelectorAll('div,section')) {
+ const own = [...el.children].filter((c) => c.tagName === 'P').reduce((n, p) => n + p.textContent.length, 0);
+ if (own > score) { score = own; best = el; }
+ }
+ root = best || doc.body;
+ }
+ return { title, markdown: htmlToMarkdown(root.outerHTML, { base }) };
+}
diff --git a/src/lib/icons.js b/src/lib/icons.js
index fc85fd0..9fdc1d0 100644
--- a/src/lib/icons.js
+++ b/src/lib/icons.js
@@ -2,6 +2,8 @@
export const PATHS = {
plus: 'M12 5v14|M5 12h14',
chev: 'M9 6l6 6-6 6',
+ chevLeft: 'M15 18l-6-6 6-6',
+ chevRight: 'M9 6l6 6-6 6',
search: 'M4 11a7 7 0 1 0 14 0a7 7 0 1 0-14 0|M16.5 16.5L21 21',
panel: 'M4 5h16v14H4z|M9.5 5v14',
dots: 'M6 12h.01|M12 12h.01|M18 12h.01',
@@ -10,6 +12,10 @@ export const PATHS = {
minimize: 'M6 12h12',
maximize: 'M6 6h12v12H6z',
restore: 'M8 9h10v10H8z|M6 14V5h11',
+ card: 'M4 5h16v14H4z|M8 10h8|M8 14h5',
+ group: 'M3 7V4h3|M18 4h3v3|M21 17v3h-3|M6 20H3v-3|M10 4h4|M10 20h4|M8 9h8v6H8z',
+ fit: 'M4 9V4h5|M15 4h5v5|M20 15v5h-5|M9 20H4v-5',
+ canvas: 'M4 4h6v5H4z|M14 15h6v5h-6z|M7 9v4.5a1.5 1.5 0 0 0 1.5 1.5H14|M14 4h6v5h-6z',
board: 'M4 4h4.5v16H4z|M9.75 4h4.5v11h-4.5z|M15.5 4H20v7h-4.5z',
star: 'M12 3.8l2.5 5.2 5.7.8-4.1 4 1 5.6L12 16.7l-5.1 2.7 1-5.6-4.1-4 5.7-.8z',
undo: 'M9 14L4 9l5-5|M4 9h10.5a5.5 5.5 0 0 1 0 11H11',
@@ -44,6 +50,17 @@ export const PATHS = {
link: 'M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1|M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1',
unlink: 'M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1|M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1|M4 4l16 16',
alignLeft: 'M4 6h16|M4 10h10|M4 14h16|M4 18h10',
+ layoutV: 'M4 6h2|M9 6h11|M4 12h2|M9 12h11|M4 18h2|M9 18h11',
+ layoutH: 'M4 8h4v8H4z|M10 8h4v8h-4z|M16 8h4v8h-4z',
+ // lining up and spreading out boxes (canvas selection bar)
+ boxLeft: 'M4 3v18|M8 7h12v4H8z|M8 14h7v4H8z',
+ boxCenterX: 'M12 3v18|M6 7h12v4H6z|M8.5 14h7v4h-7z',
+ boxRight: 'M20 3v18|M4 7h12v4H4z|M9 14h7v4H9z',
+ boxTop: 'M3 4h18|M7 8v12h4V8z|M14 8v7h4V8z',
+ boxCenterY: 'M3 12h18|M7 6v12h4V6z|M14 8.5v7h4v-7z',
+ boxBottom: 'M3 20h18|M7 4v12h4V4z|M14 9v7h4V9z',
+ spreadX: 'M4 3v18|M20 3v18|M10 7h4v10h-4z',
+ spreadY: 'M3 4h18|M3 20h18|M7 10h10v4H7z',
alignCenter: 'M4 6h16|M7 10h10|M4 14h16|M7 18h10',
alignRight: 'M4 6h16|M10 10h10|M4 14h16|M10 18h10',
minus: 'M5 12h14',
@@ -89,7 +106,70 @@ export const PATHS = {
tag: 'M3 12V4h8l10 10-8 8z|M7.5 8h.01',
inbox: 'M3 13l3-8h12l3 8v6H3z|M3 13h5l1 2h6l1-2h5',
layers: 'M12 3l9 5-9 5-9-5z|M3 13l9 5 9-5',
- check2: 'M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0|M8 12.5l2.5 2.5L16 9.5'
+ check2: 'M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0|M8 12.5l2.5 2.5L16 9.5',
+ // more page icons
+ shield: 'M12 3l7 3v5c0 5-3.2 8.5-7 10-3.8-1.5-7-5-7-10V6z',
+ eye: 'M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z|M9.5 12a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0-5 0',
+ wifi: 'M2.5 9a14 14 0 0 1 19 0|M5.5 12.5a9.5 9.5 0 0 1 13 0|M8.5 16a5 5 0 0 1 7 0|M12 19.5h.01',
+ phone: 'M7 3h10v18H7z|M11 18h2',
+ headphones: 'M4 14v-2a8 8 0 0 1 16 0v2|M4 14h3v6H4z|M17 14h3v6h-3z',
+ mic: 'M9 6a3 3 0 0 1 6 0v5a3 3 0 0 1-6 0z|M5 11a7 7 0 0 0 14 0|M12 18v3|M9 21h6',
+ video: 'M3 6h12v12H3z|M15 10l6-3v10l-6-3',
+ film: 'M4 4h16v16H4z|M8 4v16|M16 4v16|M4 8h4|M4 12h4|M4 16h4|M16 8h4|M16 12h4|M16 16h4',
+ printer: 'M6 9V3h12v6|M6 18H4V9h16v9h-2|M6 14h12v7H6z',
+ download: 'M12 4v11|M7 11l5 5 5-5|M5 20h14',
+ upload: 'M12 16V5|M7 9l5-5 5 5|M5 20h14',
+ cloud: 'M7 18a4 4 0 0 1-.5-8A6 6 0 0 1 18 9.5 4 4 0 0 1 17 18z',
+ umbrella: 'M3 12a9 9 0 0 1 18 0z|M12 12v6a2 2 0 0 0 4 0|M12 3v1',
+ snowflake: 'M12 3v18|M4.2 7.5l15.6 9|M4.2 16.5l15.6-9',
+ droplet: 'M12 3s6 6.2 6 11a6 6 0 0 1-12 0c0-4.8 6-11 6-11z',
+ fire: 'M12 3c1 3 5 5 5 10a5 5 0 0 1-10 0c0-2 1-3 2-4 .3 1.2 1 2 2 2 0-3-1-5 1-8z',
+ anchor: 'M9.5 5.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0-5 0|M12 8v13|M5 12H3a9 9 0 0 0 18 0h-2|M8 12h8',
+ flask: 'M9 3h6|M10 3v6l-5 9a2 2 0 0 0 1.8 3h10.4a2 2 0 0 0 1.8-3l-5-9V3|M8 15h8',
+ magnet: 'M5 4h4v9a3 3 0 0 0 6 0V4h4v9a7 7 0 0 1-14 0z|M5 8h4|M15 8h4',
+ scissors: 'M3 6a3 3 0 1 0 6 0a3 3 0 1 0-6 0|M3 18a3 3 0 1 0 6 0a3 3 0 1 0-6 0|M8.5 7.5L21 19|M8.5 16.5L21 5',
+ paperclip: 'M20 11l-8.5 8.5a5 5 0 0 1-7-7l9-9a3.5 3.5 0 0 1 5 5l-9 9a2 2 0 0 1-3-3l8-8',
+ pin: 'M9 4h6l-1 6 3 3H7l3-3z|M12 13v8',
+ grid: 'M4 4h7v7H4z|M13 4h7v7h-7z|M4 13h7v7H4z|M13 13h7v7h-7z',
+ columns: 'M4 4h16v16H4z|M10 4v16|M15 4v16',
+ crown: 'M3 8l4.5 4L12 5l4.5 7L21 8l-2 11H5z',
+ trophy: 'M8 4h8v6a4 4 0 0 1-8 0z|M8 6H4v2a3 3 0 0 0 4 3|M16 6h4v2a3 3 0 0 1-4 3|M12 14v4|M8 21h8|M10 18h4',
+ medal: 'M7.5 9a4.5 4.5 0 1 0 9 0a4.5 4.5 0 1 0-9 0|M8.5 13L7 21l5-3 5 3-1.5-8',
+ diamond: 'M6 4h12l4 6-10 11L2 10z|M2 10h20|M9 4l-2 6 5 11 5-11-2-6',
+ skull: 'M12 3a8 8 0 0 0-5 14v3h10v-3a8 8 0 0 0-5-14z|M9 12h.01|M15 12h.01|M10 20v-2|M14 20v-2',
+ ghost: 'M5 21V11a7 7 0 0 1 14 0v10l-3-2-2 2-2-2-2 2-2-2z|M9.5 11h.01|M14.5 11h.01',
+ bug: 'M9 8a3 3 0 0 1 6 0v1H9z|M7 9h10v6a5 5 0 0 1-10 0z|M12 9v11|M3 12h4|M17 12h4|M4 6l3 2|M20 6l-3 2|M4 19l3-2|M20 19l-3-2',
+ infinity: 'M7 8a4 4 0 1 0 0 8c4 0 6-8 10-8a4 4 0 1 1 0 8c-4 0-6-8-10-8z',
+ building: 'M5 21V4h9v17|M14 9h5v12|M8 8h2|M8 12h2|M8 16h2|M16 13h.01|M16 17h.01',
+ bank: 'M3 9l9-5 9 5z|M5 10v8|M9.5 10v8|M14.5 10v8|M19 10v8|M3 21h18',
+ cross: 'M9 3h6v6h6v6h-6v6H9v-6H3V9h6z',
+ pill: 'M10.5 20.5a5 5 0 0 1-7-7l10-10a5 5 0 0 1 7 7z|M8.5 8.5l7 7',
+ dumbbell: 'M6.5 6.5v11|M17.5 6.5v11|M3 9v6|M21 9v6|M6.5 12h11',
+ bike: 'M2 16.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0|M15 16.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0|M5.5 16.5l4-8h6l3 8|M9.5 8.5L12 16.5H5.5|M15.5 8.5l-1-3h2',
+ car: 'M5 16v-5l2-5h10l2 5v5|M3 16h18|M7 19v-3|M17 19v-3|M7.5 13h.01|M16.5 13h.01',
+ plane: 'M2 16l8-3V5.5a2 2 0 0 1 4 0V13l8 3v2l-8-2v3l2 1.5V22l-4-1-4 1v-1.5L10 19v-3l-8 2z',
+ wine: 'M8 3h8l-1 7a3 3 0 0 1-6 0z|M12 13v8|M8 21h8',
+ cake: 'M4 12h16v8H4z|M4 15c2 1.5 4 1.5 6 0s4-1.5 6 0 2.5 1 4 0|M12 12V8|M12 5.5c.8 0 1.2-1 0-2.5-1.2 1.5-.8 2.5 0 2.5z',
+ apple: 'M12 7c-3-2-7-1-7 4 0 5 3 10 5 10 1 0 1.5-.5 2-.5s1 .5 2 .5c2 0 5-5 5-10 0-5-4-6-7-4z|M12 7c0-2 1-3.5 3-4',
+ feather: 'M20 4c-8 0-14 4-14 12l-2 4|M20 4c0 8-4 12-12 12|M9 15l6-6',
+ mountain: 'M3 19l6-11 4 7 3-4 5 8z',
+ waves: 'M3 9c2-2 4-2 6 0s4 2 6 0 4-2 6 0|M3 14c2-2 4-2 6 0s4 2 6 0 4-2 6 0|M3 19c2-2 4-2 6 0s4 2 6 0 4-2 6 0',
+ text: 'M5 6V4h14v2|M12 4v16|M9 20h6',
+ quote: 'M5 8h5v5c0 3-1.5 4.5-4 5|M14 8h5v5c0 3-1.5 4.5-4 5',
+ smile: 'M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0|M8 14.5a5 5 0 0 0 8 0|M9 9.5h.01|M15 9.5h.01',
+ thumbsUp: 'M7 11v9H4v-9z|M7 11l4-8c1.7 0 2.5 1 2.5 2.5V9H19a2 2 0 0 1 2 2.3l-1 6a2.5 2.5 0 0 1-2.5 2.2H7',
+ dice: 'M4 4h16v16H4z|M8.5 8.5h.01|M15.5 8.5h.01|M12 12h.01|M8.5 15.5h.01|M15.5 15.5h.01',
+ wrench: 'M14.5 6.5a4 4 0 0 0-5 5L3 18l3 3 6.5-6.5a4 4 0 0 0 5-5l-2.5 2.5-2.5-.5-.5-2.5z',
+ ruler: 'M3 17L17 3l4 4L7 21z|M7 13l2 2|M10 10l2 2|M13 7l2 2',
+ brush: 'M18 3l3 3-9 9-3-3z|M9 12c-3 0-4 2-4 4s-1 3-2 4c3 .5 7 0 7-4',
+ archive: 'M3 4h18v4H3z|M5 8v12h14V8|M10 12h4',
+ package: 'M12 3l9 4.5v9L12 21l-9-4.5v-9z|M3 7.5L12 12l9-4.5|M12 12v9',
+ hourglass: 'M6 3h12|M6 21h12|M7 3c0 5 5 6 5 9s-5 4-5 9|M17 3c0 5-5 6-5 9s5 4 5 9',
+ tv: 'M3 7h18v12H3z|M8 3l4 4 4-4',
+ bookOpen: 'M12 6c-2-1.5-5-2-8-2v14c3 0 6 .5 8 2 2-1.5 5-2 8-2V4c-3 0-6 .5-8 2z|M12 6v14',
+ newspaper: 'M4 4h13v16H6a2 2 0 0 1-2-2z|M17 8h3v10a2 2 0 0 1-2 2|M8 8h5|M8 12h5|M8 16h3',
+ coins: 'M4 8a6 3 0 1 0 12 0a6 3 0 1 0-12 0|M4 8v4c0 1.7 2.7 3 6 3s6-1.3 6-3V8|M16 11c2.5.3 4 1.3 4 2.5v4c0 1.7-2.7 3-6 3-2.5 0-4.6-.8-5.5-1.8',
+ at: 'M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0z|M16 8v5a2 2 0 0 0 4 0v-1a8 8 0 1 0-3 6.2'
};
// The stroke icons offered in the icon picker, with words to find them by.
@@ -107,7 +187,26 @@ export const PICKER_ICONS = [
['user', 'person me profile'], ['users', 'people team group'], ['mail', 'email letter'], ['chat', 'message talk'],
['globe', 'world web internet'], ['map', 'travel place'], ['send', 'share plane'], ['tree', 'nature forest'],
['sun', 'day light summer'], ['pulse', 'health fitness activity'], ['food', 'recipe cooking eat'], ['cart', 'shopping buy'],
- ['gift', 'present birthday'], ['lock', 'private secure'], ['key', 'password access'], ['trash', 'delete bin']
+ ['gift', 'present birthday'], ['lock', 'private secure'], ['key', 'password access'], ['trash', 'delete bin'],
+ ['canvas', 'whiteboard map diagram'], ['graph', 'network connections links'], ['table', 'spreadsheet grid data'], ['list', 'bullets outline'],
+ ['link', 'url connect'], ['search', 'find look'], ['gear', 'settings tools config'], ['save', 'disk store'],
+ ['export', 'share out'], ['copy', 'duplicate clone'], ['monitor', 'screen display desktop'],
+ ['shield', 'security protect safe'], ['eye', 'view watch look'], ['wifi', 'network wireless internet'], ['phone', 'mobile call'],
+ ['headphones', 'audio listen music'], ['mic', 'record podcast voice'], ['video', 'film movie record'], ['film', 'movie cinema'],
+ ['printer', 'print paper'], ['download', 'get save'], ['upload', 'send put'], ['cloud', 'weather sky online'],
+ ['umbrella', 'rain weather'], ['snowflake', 'winter cold snow'], ['droplet', 'water rain'], ['fire', 'hot flame trending'],
+ ['anchor', 'ship sea fixed'], ['flask', 'science chemistry lab test'], ['magnet', 'attract'], ['scissors', 'cut craft'],
+ ['paperclip', 'attach file'], ['pin', 'location keep'], ['grid', 'tiles layout overview'], ['columns', 'layout split'],
+ ['crown', 'king queen best'], ['trophy', 'win award prize'], ['medal', 'award achievement'], ['diamond', 'gem premium'],
+ ['skull', 'danger dead hacker'], ['ghost', 'spooky halloween'], ['bug', 'insect error issue'], ['infinity', 'loop forever'],
+ ['building', 'office company city'], ['bank', 'finance institution money'], ['cross', 'medical health plus'], ['pill', 'medicine health'],
+ ['dumbbell', 'gym workout fitness'], ['bike', 'cycling ride'], ['car', 'drive vehicle'], ['plane', 'flight travel trip'],
+ ['wine', 'drink glass'], ['cake', 'birthday party dessert'], ['apple', 'fruit food health'], ['feather', 'light write'],
+ ['mountain', 'hike outdoors peak'], ['waves', 'sea ocean water'], ['text', 'type font writing'], ['quote', 'citation saying'],
+ ['smile', 'happy mood face'], ['thumbsUp', 'like good approve'], ['dice', 'game random chance'], ['wrench', 'fix repair tool'],
+ ['ruler', 'measure design'], ['brush', 'paint art draw'], ['archive', 'old storage box'], ['package', 'box delivery release'],
+ ['hourglass', 'wait time later'], ['tv', 'television watch'], ['bookOpen', 'read library story'], ['newspaper', 'news article press'],
+ ['coins', 'money cash savings'], ['at', 'mention email']
];
// Emoji offered in the picker, as "emoji words" pairs.
@@ -127,7 +226,20 @@ export const PICKER_EMOJI = [
'❤️ heart love', '💪 strong fitness', '🏃 run exercise', '🧘 yoga calm', '😴 sleep', '🩺 doctor health', '💊 pill medicine', '🏋️ gym',
'🔒 lock private', '🔑 key password', '🛠️ tools build', '🔧 wrench fix', '⚙️ gear settings', '🧰 toolbox', '🐛 bug', '🧹 cleanup',
'📦 package box', '🎁 gift', '🛒 shopping cart', '🏷️ tag label', '🔔 bell reminder', '❓ question', '❗ important', '⚠️ warning',
- '🐶 dog', '🐱 cat', '🦊 fox', '🐼 panda', '🦉 owl', '🐢 turtle', '🦋 butterfly', '🐙 octopus'
+ '🐶 dog', '🐱 cat', '🦊 fox', '🐼 panda', '🦉 owl', '🐢 turtle', '🦋 butterfly', '🐙 octopus',
+ '🧭 compass direction', '🔭 telescope space', '🪐 planet space', '🌟 glowing star', '💫 dizzy star', '🌠 shooting star', '🌌 galaxy night', '🛰️ satellite',
+ '🛸 ufo', '🧬 dna biology', '⚗️ alembic chemistry', '🧲 magnet', '🔋 battery', '💾 floppy save', '💿 disc', '📀 dvd',
+ '🖨️ printer', '📱 phone mobile', '☎️ telephone', '📡 antenna signal', '🔌 plug power', '🪙 coin', '💎 gem diamond',
+ '👑 crown', '🏆 trophy win', '🥇 gold medal first', '🎖️ medal', '🎓 graduation cap', '🏛️ classical building', '⚖️ scales law', '🧮 abacus math',
+ '✏️ pencil', '🖊️ pen', '🖋️ fountain pen', '🖌️ paintbrush', '📐 triangle ruler', '📏 ruler', '✂️ scissors', '📋 clipboard',
+ '📰 newspaper news', '🗞️ rolled newspaper', '📜 scroll', '📃 page', '📄 document', '📑 bookmark tabs', '📓 notebook', '📔 notebook decorated',
+ '🍇 grapes', '🍊 orange fruit', '🍋 lemon', '🍓 strawberry', '🥑 avocado', '🥕 carrot', '🌶️ chili pepper', '🍞 bread',
+ '🧀 cheese', '🍜 noodles ramen', '🍣 sushi', '🍪 cookie', '🍫 chocolate', '🍷 wine', '🍺 beer', '🥂 cheers',
+ '⚽ football soccer', '🏀 basketball', '🎾 tennis', '🏓 ping pong', '⛳ golf', '🎳 bowling', '🥊 boxing', '🛹 skateboard',
+ '🚂 train', '🚌 bus', '🚢 ship', '⛵ sailboat', '🚁 helicopter', '🏍️ motorcycle', '🛴 scooter', '⛽ fuel',
+ '🦄 unicorn', '🐧 penguin', '🦁 lion', '🐯 tiger', '🐻 bear', '🐨 koala', '🐝 bee', '🐞 ladybug',
+ '😀 smile happy', '😎 cool sunglasses', '🤔 thinking', '😂 laugh', '🥳 party', '😇 angel', '🤯 mind blown', '🫡 salute',
+ '👍 thumbs up like', '👏 clap', '🙏 thanks pray', '✌️ peace victory', '💯 hundred perfect', '🎉 party celebrate', '🎊 confetti', '🔮 crystal ball'
].map((s) => { const i = s.indexOf(' '); return [s.slice(0, i), s.slice(i + 1)]; });
export const isEmoji = (s) => typeof s === 'string' && /\p{Extended_Pictographic}/u.test(s) && s.length <= 16;
@@ -140,6 +252,9 @@ export const ic = (name, cls = '') =>
export const PAGE_ICONS = ['sparkle', 'pen', 'leaf', 'book', 'code', 'moon', 'compass', 'cup'];
+// A cover can be a gradient (a key of COVERS) or a picture saved next to the page ("header.png" in its front matter).
+export const IMAGE_COVER = /\.(png|jpe?g|gif|webp|avif|svg|bmp)$/i;
+export const isImageCover = (v) => typeof v === 'string' && !!v && !(v in COVERS) && IMAGE_COVER.test(v) && !/^[a-z][a-z0-9+.-]*:/i.test(v);
export const COVERS = {
blush: ['#d99a91', '#caa07c'],
sage: ['#97b096', '#d1b48e'],
diff --git a/src/lib/kanban.js b/src/lib/kanban.js
new file mode 100644
index 0000000..d3908f7
--- /dev/null
+++ b/src/lib/kanban.js
@@ -0,0 +1,91 @@
+// Kanban boards stored as plain Markdown, in the same shape as the Obsidian Kanban plugin:
+//
+// ---
+// kanban-plugin: basic <- front matter marks the page as a board
+// ---
+//
+// ## To do <- a column
+//
+// - [ ] Write the intro <- a card (text keeps inline Markdown: **bold**, [[links]] …)
+// with a second line <- indented lines belong to the card above
+// - [x] Pick a title <- a checked card
+//
+// %% kanban:settings ... %% <- anything from a *** or %% line on is kept as is
+//
+// Lines in a column that aren't cards (e.g. the plugin's **Complete** marker) are kept too.
+import { parseMarkdown, htmlToMd } from './markdown.js';
+
+export const BOARD_KEY = 'kanban-plugin';
+export const isBoard = (meta) => !!(meta && meta[BOARD_KEY]);
+
+let seq = 0;
+const uid = (p) => p + (++seq).toString(36) + Math.random().toString(36).slice(2, 6);
+export const card = (text = '', done = false) => ({ id: uid('k'), text, done });
+export const column = (title = 'Untitled', cards = []) => ({ id: uid('c'), title, extra: [], cards });
+
+export function parseBoard(src) {
+ const lines = String(src || '').replace(/\r\n?/g, '\n').split('\n');
+ const board = { head: '', columns: [], tail: '' };
+ const head = [];
+ let col = null;
+ let last = null; // card that indented lines continue
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const t = line.trim();
+ if (t === '***' || t.startsWith('%% kanban')) {
+ board.tail = lines.slice(i).join('\n').replace(/\s+$/, '');
+ break;
+ }
+ let m;
+ if ((m = line.match(/^##\s+(.*?)\s*$/))) {
+ col = column(m[1]);
+ board.columns.push(col);
+ last = null;
+ continue;
+ }
+ if (!col) { head.push(line); continue; }
+ if ((m = line.match(/^[-*+]\s+(?:\[([ xX])\]\s?)?(.*)$/))) {
+ last = card(m[2], !!m[1] && m[1] !== ' ');
+ col.cards.push(last);
+ continue;
+ }
+ if (last && /^(\s{2,}|\t)\S/.test(line)) { last.text += '\n' + line.replace(/^(\s{2}|\t)/, ''); continue; }
+ if (line.trim()) { col.extra.push(line); last = null; }
+ }
+ board.head = head.join('\n').trim();
+ return board;
+}
+
+export function boardToMd(board) {
+ const parts = [];
+ if (board.head) parts.push(board.head);
+ for (const c of board.columns) {
+ const body = [...c.extra, ...c.cards.map((k) => `- [${k.done ? 'x' : ' '}] ` + k.text.replace(/\n/g, '\n '))];
+ parts.push(`## ${c.title}` + (body.length ? '\n\n' + body.join('\n') : ''));
+ }
+ if (board.tail) parts.push(board.tail);
+ return parts.length ? parts.join('\n\n\n') + '\n' : '';
+}
+
+export const defaultBoard = () => ({ head: '', tail: '', columns: [column('To do'), column('Doing'), column('Done')] });
+
+// Turn an ordinary page into a board: each ## heading becomes a column, list items and paragraphs become cards.
+// Anything before the first heading lands in a "To do" column.
+export function boardFromMarkdown(body) {
+ const blocks = parseMarkdown(body);
+ const columns = [];
+ let col = null;
+ for (const b of blocks) {
+ if (b.type === 'h1' || b.type === 'h2' || b.type === 'h3') {
+ col = column(htmlToMd(b.html).trim() || 'Untitled');
+ columns.push(col);
+ continue;
+ }
+ const text = b.type === 'code' ? '`' + (b.text || '').split('\n')[0] + '`' : b.type === 'toggle' ? htmlToMd(b.html) : htmlToMd(b.html);
+ if (b.type === 'divider' || b.type === 'image' || !text.trim()) continue;
+ if (!col) { col = column('To do'); columns.unshift(col); }
+ col.cards.push(card(text.trim().replace(/\n+/g, '\n'), !!b.checked));
+ }
+ if (!columns.length) return defaultBoard();
+ return { head: '', tail: '', columns };
+}
diff --git a/src/lib/links.js b/src/lib/links.js
new file mode 100644
index 0000000..bf67b1e
--- /dev/null
+++ b/src/lib/links.js
@@ -0,0 +1,57 @@
+// [[Wiki links]] between pages. Kept in step with electron/links.cjs, which does the same on disk.
+//
+// [[Roadmap]] -> the page titled "Roadmap" (shortest path wins when titles repeat)
+// [[Projects/Roadmap]] -> that exact page
+// [[Roadmap|the plan]] -> shown as "the plan"
+// [[Roadmap#Goals]] -> that heading of the page; shown as "Roadmap › Goals"
+// [[#Goals]] -> a heading of the page you're on
+// ![[Roadmap]] -> the page's content shown inline (an "embed"); ![[Roadmap#Goals]] just that section
+
+export const refPage = (ref) => String(ref || '').split('#')[0].replace(/\.md$/i, '').trim();
+
+// The heading a link points at ('' if none). Only the last #part counts, and #^blockid isn't a heading.
+export const headingOf = (ref) => {
+ const s = String(ref || '');
+ const i = s.lastIndexOf('#');
+ if (i < 0) return '';
+ const h = s.slice(i + 1).trim();
+ return h.startsWith('^') ? '' : h;
+};
+// Headings are matched ignoring case and extra spaces.
+export const normHeading = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase();
+
+// Heading text as it is written inside a link: # | ^ [ ] would break the link syntax, so they become spaces.
+export const linkHeading = (s) => String(s || '').replace(/[#|^\[\]]/g, ' ').replace(/\s+/g, ' ').trim();
+// Does a link's heading part point at this heading? (Either as the heading is written or in its link-safe form.)
+export const matchesHeading = (linkText, headingText) => {
+ const a = normHeading(linkText);
+ return !!a && (a === normHeading(headingText) || a === normHeading(linkHeading(headingText)));
+};
+
+// Files that ![[...]] shows as a picture, and other files (not notes) that it can't show.
+export const IMAGE_FILE = /\.(png|jpe?g|gif|webp|avif|svg|bmp)$/i;
+export const OTHER_FILE = /\.(pdf|mp3|mp4|wav|m4a|ogg|webm|mov|mkv|zip|docx?|xlsx?|pptx?|csv)$/i;
+
+export const linkLabel = (ref) => {
+ const p = refPage(ref);
+ const name = p ? p.slice(p.lastIndexOf('/') + 1) : '';
+ const h = headingOf(ref);
+ return name && h ? `${name} › ${h}` : name || h || String(ref || '');
+};
+
+export function resolveLink(ref, pages) {
+ const want = refPage(ref).toLowerCase();
+ if (!want) return null;
+ const exact = pages.find((p) => p.id.toLowerCase() === want);
+ if (exact) return exact.id;
+ // by name: a note's title, or a canvas's file name ("Map.canvas"), so [[Map]] never means a canvas
+ const byTitle = pages.filter((p) => p.id.slice(p.id.lastIndexOf('/') + 1).toLowerCase() === want).sort((a, b) => a.id.length - b.id.length);
+ return byTitle.length ? byTitle[0].id : null;
+}
+
+// The shortest text that links to `id`: its title when that's unique, else the full path.
+export function refFor(id, pages) {
+ const title = id.slice(id.lastIndexOf('/') + 1);
+ const same = pages.filter((p) => p.id.slice(p.id.lastIndexOf('/') + 1).toLowerCase() === title.toLowerCase());
+ return same.length <= 1 ? title : id;
+}
diff --git a/src/lib/markdown.js b/src/lib/markdown.js
index a81c05c..e22f52a 100644
--- a/src/lib/markdown.js
+++ b/src/lib/markdown.js
@@ -241,6 +241,34 @@ export function parseMarkdown(src) {
out.push(block('quote', mdToHtml(buf.join('\n'))));
continue;
}
+ // :::: columns / ::: column ... ::: / :::: : side-by-side blocks (fenced divs; plain text anywhere else)
+ if (/^:{4,}\s*columns\s*$/.test(line)) {
+ let j = i + 1;
+ while (j < lines.length && !/^:{4,}\s*$/.test(lines[j])) j++;
+ if (j < lines.length) {
+ const cols = [];
+ let cur = null;
+ for (const l of lines.slice(i + 1, j)) {
+ const open = l.match(/^:{3}\s*(?:column|\{\s*\.column(?:\s+width\s*=\s*"?([\d.]+)%"?)?\s*\})\s*$/);
+ if (open) { cur = { lines: [], w: open[1] ? +open[1] : null }; cols.push(cur); }
+ else if (/^:{3}\s*$/.test(l)) cur = null;
+ else if (cur) cur.lines.push(l);
+ }
+ if (cols.length) {
+ flush();
+ const g = blockId();
+ // widths count only if every column has one
+ const widths = cols.every((c) => c.w > 0) ? cols.map((c) => c.w) : null;
+ cols.forEach((col, k) => {
+ let inner = parseMarkdown(col.lines.join('\n'));
+ if (!inner.length) inner = [block('p')];
+ for (const b of inner) { if (b.col) delete b.col; b.col = widths ? { g, i: k, w: widths[k] } : { g, i: k }; out.push(b); }
+ });
+ i = j + 1;
+ continue;
+ }
+ }
+ }
if (/^]*)?>\s*$/i.test(line.trim())) {
flush();
i++;
@@ -341,12 +369,38 @@ export function blockToMd(b, n = 1) {
}
}
+// A run of blocks that share a column group, as fenced columns.
+function columnsToMd(run) {
+ const byCol = new Map();
+ for (const b of run) {
+ if (!byCol.has(b.col.i)) byCol.set(b.col.i, []);
+ byCol.get(b.col.i).push(b);
+ }
+ const keys = [...byCol.keys()].sort((a, b) => a - b);
+ const widths = keys.every((k) => byCol.get(k)[0].col.w > 0) ? keys.map((k) => byCol.get(k)[0].col.w) : null;
+ const parts = keys.map((k, n) => `${widths ? `::: {.column width="${+widths[n].toFixed(1)}%"}` : '::: column'}\n${toMarkdown(byCol.get(k).map((b) => ({ ...b, col: undefined }))).trimEnd()}\n:::`);
+ return `:::: columns\n${parts.join('\n')}\n::::`;
+}
+
export function toMarkdown(blocks) {
let out = '';
let prevList = false;
let prevType = null;
let n = 0; // position in a run of numbered items
- for (const b of blocks) {
+ for (let idx = 0; idx < blocks.length; idx++) {
+ const b = blocks[idx];
+ if (b.col) {
+ let end = idx;
+ while (end + 1 < blocks.length && blocks[end + 1].col && blocks[end + 1].col.g === b.col.g) end++;
+ const md = columnsToMd(blocks.slice(idx, end + 1));
+ out += out ? '\n\n' : '';
+ out += md;
+ idx = end;
+ prevList = false;
+ prevType = 'columns';
+ n = 0;
+ continue;
+ }
if (b.type === 'p' && !htmlToMd(b.html).trim()) continue;
const isList = LISTS.has(b.type);
n = b.type === 'number' ? n + 1 : 0;
@@ -391,7 +445,18 @@ export function toHtmlDocument(title, raw) {
const blocks = raw.map((b) => ({ ...b, html: fns.html(b.html), body: b.body && fns.html(b.body), rows: b.rows && b.rows.map((r) => r.map(fns.html)) }));
const body = [];
let list = null; // 'ul' or 'ol' while inside a list
+ let curKey = null, curG = null; // the column (group:index) and group the output is in
for (const b of blocks) {
+ const key = b.col ? `${b.col.g}:${b.col.i}` : null;
+ const g = b.col ? b.col.g : null;
+ if (key !== curKey) {
+ if (list) { body.push(`${list}>`); list = null; }
+ if (curG !== null && g !== curG) body.push('');
+ else if (curKey !== null) body.push('');
+ if (g !== null && g !== curG) body.push('');
+ else if (g !== null) body.push('
');
+ curKey = key; curG = g;
+ }
const want = b.type === 'number' ? 'ol' : LISTS.has(b.type) ? 'ul' : null;
if (list && list !== want) { body.push(`${list}>`); list = null; }
if (want && !list) { body.push(`<${want}>`); list = want; }
@@ -418,7 +483,8 @@ export function toHtmlDocument(title, raw) {
default: if (b.html) body.push(`
${b.html}
`);
}
}
- if (list) body.push(`${list}>`);
+ if (list) { body.push(`${list}>`); list = null; }
+ if (curG !== null) body.push('
');
return `
@@ -438,6 +504,7 @@ export function toHtmlDocument(title, raw) {
hr { border: 0; border-top: 1px solid #e3e3e6; margin: 24px 0; }
img { max-width: 100%; border-radius: 12px; }
table { border-collapse: collapse; margin: 12px 0; } th, td { border: 1px solid #e3e3e6; padding: 6px 10px; } th { background: #f3f3f4; text-align: left; }
+ .cols { display: flex; gap: 28px; } .col { flex: 1; min-width: 0; }
a { color: #a8564c; }
.wl { color: #a8564c; text-decoration: underline; text-decoration-color: #e3c2bb; }
diff --git a/src/lib/mock.js b/src/lib/mock.js
new file mode 100644
index 0000000..b725c28
--- /dev/null
+++ b/src/lib/mock.js
@@ -0,0 +1,307 @@
+// Browser-only stand-in for the Electron bridge. Same method names and shapes as preload.cjs.
+// Keeps notes in memory (and localStorage when available) so `npm run dev:web` is usable.
+
+import { resolveLink } from './links.js';
+
+const KEY = 'notes-web-mock-v1';
+const FILE = /\.(png|jpe?g|gif|webp|avif|svg|bmp|pdf|mp3|mp4|wav|m4a|ogg|webm|mov|mkv|zip|docx?|xlsx?|pptx?|csv)$/i;
+const today = () => new Date().toISOString().slice(0, 10);
+
+const SEED = {
+ 'Getting started': {
+ meta: { icon: 'sparkle', cover: 'blush', tags: ['guide'], created: today() },
+ body: `> [!note]
+> This is the browser preview. In the desktop app every page is a .md file in your notes folder.
+
+## Write the way you think
+
+- Type \`/\` on any line to add a heading, to-do, quote, toggle or code block.
+- Start a line with \`#\`, \`-\`, \`[]\` or \`>\` and it turns into that block as you type.
+- Press
Ctrl K to search every note.
+
+- [x] Pick a folder for your notes
+- [ ] Write one page, however short
+`
+ },
+ Projects: { meta: { icon: 'compass', cover: 'sage', created: today() }, body: 'Things in motion.\n' },
+ 'Projects/Note app roadmap': {
+ meta: { icon: 'code', cover: 'dusk', tags: ['dev', 'todo'], created: today() },
+ body: `An offline notes app built on Electron and SvelteKit.
+
+### v0.1
+
+- [x] Block editor with a slash menu
+- [ ] Full-text search with SQLite FTS5
+
+\`\`\`js
+const id = await window.vault.create(null, 'Untitled')
+\`\`\`
+`
+ }
+};
+
+export function createMock() {
+ let files = null;
+ let settings = { theme: 'dark' };
+ try {
+ const saved = JSON.parse(localStorage.getItem(KEY) || 'null');
+ if (saved) { files = saved.files; settings = saved.settings || settings; }
+ } catch { /* storage unavailable */ }
+ let opened = !!files;
+ files = files || structuredClone(SEED);
+
+ const save = () => { try { localStorage.setItem(KEY, JSON.stringify({ files, settings })); } catch { /* ignore */ } };
+ const parentOf = (id) => (id.includes('/') ? id.slice(0, id.lastIndexOf('/')) : null);
+ const titleOf = (id) => id.slice(id.lastIndexOf('/') + 1);
+ const pages = () => {
+ const ids = new Set(Object.keys(files));
+ for (const id of [...ids]) { let p = parentOf(id); while (p) { ids.add(p); p = parentOf(p); } }
+ return [...ids].map((id) => {
+ const f = files[id];
+ return {
+ id, parent: parentOf(id), title: titleOf(id), exists: !!f, mtime: 0,
+ icon: f?.meta.icon || null, cover: f?.meta.cover || null, tags: f?.meta.tags || [], created: f?.meta.created || null,
+ ...(f?.meta['ink-database'] ? { db: true } : {}),
+ props: Object.fromEntries(Object.entries(f?.meta || {}).filter(([k]) => !['icon', 'cover', 'tags', 'created'].includes(k)))
+ };
+ });
+ };
+ const snap = () => ({ path: '~/Notes', name: 'Notes', pages: pages() });
+ const free = (parent, base) => {
+ for (let n = 1; ; n++) {
+ const id = (parent ? parent + '/' : '') + (n === 1 ? base : `${base} ${n}`);
+ if (!files[id] && !pages().some((p) => p.id === id)) return id;
+ }
+ };
+ const listeners = new Set();
+ const images = new Map();
+ const trash = []; // [{ entry, id, title, icon, pages, deletedAt, gone: { id: file } }], kept for this session
+
+ return {
+ platform: 'web',
+ async listTemplates() {
+ return ['Daily Note', 'Meeting'];
+ },
+ async duplicate(id) {
+ const f = files[id];
+ if (!f) throw new Error('That page has no file to copy.');
+ let next = id + ' copy';
+ for (let n = 2; files[next]; n++) next = `${id} copy ${n}`;
+ files[next] = structuredClone(f);
+ save();
+ return next;
+ },
+ async saveImageFromUrl() { throw new Error('Adding a picture from a link needs the desktop app.'); },
+ async pickFile() { throw new Error('Attaching files needs the desktop app.'); },
+ async openFile() { throw new Error('Opening files needs the desktop app.'); },
+ async createMany(parent, items) {
+ const ids = [];
+ for (const it of items || []) ids.push(await this.create(parent, it.title, it.meta, ''));
+ return ids;
+ },
+ async createCanvas() { throw new Error('Canvases need the desktop app.'); },
+ async openTemplatesFolder() { return 'The templates folder needs the desktop app.'; },
+ async getTemplate(name) {
+ if (name === 'Daily Note') return { meta: { tags: ['daily'] }, body: '# Daily Note\n\n' };
+ if (name === 'Meeting') return { meta: { tags: ['meeting'] }, body: '# Meeting\n\n' };
+ return null;
+ },
+ async get() { return opened ? snap() : null; },
+ async list() { return snap(); },
+ async pick() { opened = true; save(); return snap(); },
+ async read(id) {
+ const f = files[id];
+ return { id, title: titleOf(id), meta: f ? structuredClone(f.meta) : {}, body: f ? f.body : '', exists: !!f, mtime: 0 };
+ },
+ async write(id, meta, body) { files[id] = { meta: structuredClone(meta || {}), body: body || '' }; save(); return Date.now(); },
+ async create(parent, title, meta, body) {
+ const id = free(parent, (title || 'Untitled').replace(/[\\/:*?"<>|]/g, '-').trim() || 'Untitled');
+ files[id] = { meta: { created: today(), ...(meta || {}) }, body: body || '' };
+ save();
+ return id;
+ },
+ async rename(id, title) {
+ const clean = (title || 'Untitled').replace(/[\\/:*?"<>|]/g, '-').trim() || 'Untitled';
+ if (clean === titleOf(id)) return id;
+ const next = free(parentOf(id), clean);
+ for (const k of Object.keys(files)) {
+ if (k === id || k.startsWith(id + '/')) { files[next + k.slice(id.length)] = files[k]; delete files[k]; }
+ }
+ save();
+ return next;
+ },
+ async move(id, parent) {
+ if ((parent || null) === parentOf(id)) return id;
+ if (parent && (parent === id || parent.startsWith(id + '/'))) throw new Error('A page cannot go inside itself.');
+ const next = free(parent || null, titleOf(id));
+ for (const k of Object.keys(files)) {
+ if (k === id || k.startsWith(id + '/')) { files[next + k.slice(id.length)] = files[k]; delete files[k]; }
+ }
+ save();
+ return next;
+ },
+ // No version history in the browser preview.
+ async keepVersion() {},
+ async listHistory() { return []; },
+ async readHistory() { throw new Error('Version history needs the desktop app.'); },
+ async restoreHistory() { throw new Error('Version history needs the desktop app.'); },
+ async graph() {
+ const all = pages();
+ const nodes = all.filter((p) => p.exists).map((p) => ({ id: p.id, title: p.title, icon: p.icon, parent: p.parent, tags: p.tags }));
+ const edges = [], ghosts = new Map(), seen = new Set();
+ for (const [id, f] of Object.entries(files)) {
+ for (const m of f.body.matchAll(/(!?)\[\[([^\[\]\n|#]*)/g)) {
+ // ![[Page]] embeds count as links; ![[photo.png]] is a file, and [[#Heading]] points at its own page
+ if ((m[1] && FILE.test(m[2].trim())) || !m[2].trim()) continue;
+ let to = resolveLink(m[2], all);
+ if (!to) { to = '?' + m[2].trim().toLowerCase(); ghosts.set(to, { id: to, title: m[2].trim() }); }
+ if (to !== id && !seen.has(id + to)) { seen.add(id + to); edges.push([id, to]); }
+ }
+ }
+ return { nodes, edges, ghosts: [...ghosts.values()] };
+ },
+ // A heading of page `id` was renamed: update [[Page#from]] links and ![[Page#from]] embeds elsewhere (browser preview).
+ async renameHeading(id, from, to) {
+ const all = pages();
+ const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase();
+ const safe = String(to).replace(/[#|^\[\]]/g, ' ').replace(/\s+/g, ' ').trim();
+ const touched = [];
+ let count = 0;
+ if (!safe) return { pages: touched, count };
+ for (const [k, f] of Object.entries(files)) {
+ if (k === id) continue;
+ let n = 0;
+ const body = f.body.replace(/(!?)\[\[([^\[\]\n|]+)(\|[^\[\]\n]*)?\]\]/g, (m, bang, ref, alias) => {
+ const at = ref.lastIndexOf('#');
+ if (at < 0 || !ref.slice(0, at).replace(/#.*/, '').trim() || resolveLink(ref, all) !== id || norm(ref.slice(at + 1)) !== norm(from)) return m;
+ n++;
+ return `${bang}[[${ref.slice(0, at + 1)}${safe}${alias || ''}]]`;
+ });
+ if (n) { f.body = body; touched.push(k); count += n; }
+ }
+ if (touched.length) save();
+ return { pages: touched, count };
+ },
+ async tasks() {
+ const out = [];
+ for (const [id, f] of Object.entries(files)) {
+ if (f.meta['kanban-plugin'] || id.endsWith('.canvas')) continue;
+ f.body.split('\n').forEach((l, line) => {
+ const m = l.match(/^\s*[-*+]\s+\[([ xX])\]\s+(.+)$/);
+ if (!m) return;
+ const due = m[2].match(/(?:📅|\bdue:)\s*(\d{4}-\d{2}-\d{2})/);
+ const tags = new Set(f.meta.tags || []);
+ for (const t of m[2].matchAll(/(?:^|\s)#([\w/-]+)/g)) tags.add(t[1]);
+ out.push({ id, title: titleOf(id), icon: f.meta.icon || null, line, done: m[1] !== ' ', text: m[2].trim(), due: due ? due[1] : '', tags: [...tags], depth: 0 });
+ });
+ }
+ return out;
+ },
+ async setTask(id, line, text, done) {
+ const rows = files[id].body.split('\n');
+ const m = rows[line] && rows[line].match(/^(\s*[-*+]\s+)\[[ xX]\](\s+)(.*)$/);
+ if (!m || m[3].trim() !== text.trim()) throw new Error('That task changed. Reopen the list.');
+ rows[line] = m[1] + (done ? '[x]' : '[ ]') + m[2] + m[3];
+ files[id].body = rows.join('\n');
+ save();
+ },
+ async unlinked(id) {
+ const title = titleOf(id);
+ if (title.length < 3) return [];
+ const all = pages();
+ const re = new RegExp('(? k !== id && re.test(f.body.replace(/\[\[[^\]]*\]\]/g, '')) && ![...f.body.matchAll(/\[\[([^\[\]\n|#]+)/g)].some((m) => resolveLink(m[1], all) === id))
+ .map(([k, f]) => ({ id: k, title: titleOf(k), icon: f.meta.icon || null, snip: (f.body.split('\n').find((l) => re.test(l)) || '').trim() }));
+ },
+ async linkMention(id, target) {
+ const title = titleOf(target);
+ const re = new RegExp('(? (m === title ? `[[${title}]]` : `[[${title}|${m}]]`));
+ save();
+ },
+ async openCapture() {},
+ async takeCaptures() { return []; },
+ onCapture() { return () => {}; },
+ async backlinks(id) {
+ const all = pages();
+ return Object.entries(files)
+ .filter(([k, f]) => k !== id && [...f.body.matchAll(/(!?)\[\[([^\[\]\n|]+)/g)].some((m) => !(m[1] && FILE.test(m[2].trim())) && resolveLink(m[2], all) === id))
+ .map(([k, f]) => ({ id: k, title: titleOf(k), icon: f.meta.icon || null, snip: (f.body.split('\n').find((l) => l.includes('[[')) || '').replace(/[[\]]/g, '') }));
+ },
+ // Images live in memory only here; the desktop app writes them next to the note.
+ async saveImage(_id, name, bytes) {
+ const key = `${Date.now().toString(36)}-${name}`;
+ images.set(key, URL.createObjectURL(new Blob([bytes])));
+ return key;
+ },
+ async pickImage(id) {
+ const input = Object.assign(document.createElement('input'), { type: 'file', accept: 'image/*' });
+ const file = await new Promise((done) => { input.onchange = () => done(input.files[0] || null); input.click(); });
+ return file ? this.saveImage(id, file.name, new Uint8Array(await file.arrayBuffer())) : null;
+ },
+ mediaUrl: (_id, src) => images.get(src) || src,
+ async remove(id) {
+ const gone = {};
+ for (const k of Object.keys(files)) if (k === id || k.startsWith(id + '/')) { gone[k] = files[k]; delete files[k]; }
+ const entry = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
+ trash.unshift({ entry, id, title: titleOf(id), icon: gone[id]?.meta.icon || null, pages: Object.keys(gone).length, deletedAt: Date.now(), gone });
+ save();
+ return entry;
+ },
+ async trash() { return trash.map(({ gone, ...t }) => t); },
+ async restore(entry) {
+ const t = trash.find((x) => x.entry === entry);
+ if (!t) throw new Error('Unknown trash item.');
+ const next = free(parentOf(t.id), titleOf(t.id));
+ for (const [k, f] of Object.entries(t.gone)) files[next + k.slice(t.id.length)] = f;
+ trash.splice(trash.indexOf(t), 1);
+ save();
+ return next;
+ },
+ async purge(entry) {
+ const keep = entry ? trash.filter((x) => x.entry !== entry) : [];
+ trash.splice(0, trash.length, ...keep);
+ },
+ async search(q) {
+ const terms = String(q).toLowerCase().match(/[\p{L}\p{N}_]+/gu) || [];
+ if (!terms.length) return [];
+ return Object.entries(files)
+ .filter(([id, f]) => terms.every((t) => (id + ' ' + f.body).toLowerCase().includes(t)))
+ .slice(0, 20)
+ .map(([id, f]) => {
+ const lo = f.body.toLowerCase();
+ const at = lo.indexOf(terms[0]);
+ const start = Math.max(0, at - 30);
+ const snip = at < 0 ? f.body.slice(0, 90)
+ : (start ? '…' : '') + f.body.slice(start, at) + '\u0001' + f.body.slice(at, at + terms[0].length) + '\u0002' + f.body.slice(at + terms[0].length, at + 70);
+ return { id, title: titleOf(id), snip };
+ });
+ },
+ async reveal() {},
+ async filePath(id) { return `~/Notes/${id}.md`; },
+ async saveExport(name, content) {
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(new Blob([content], { type: 'text/plain' }));
+ a.download = name;
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
+ return name;
+ },
+ async linkPreview() { return null; },
+ async getSettings() { return { ...settings }; },
+ async setSettings(patch) { settings = { ...settings, ...patch }; save(); return settings; },
+ async getSpellcheck() { return { enabled: settings.spellcheck !== false, languages: [], available: [], system: true }; },
+ async setSpellcheck(enabled) { settings = { ...settings, spellcheck: !!enabled }; save(); return true; },
+ async spellWords() { return Array.isArray(settings.words) ? settings.words : []; },
+ async addSpellWord(word) { settings = { ...settings, words: [...new Set([...(settings.words || []), word])] }; save(); },
+ async removeSpellWord(word) { settings = { ...settings, words: (settings.words || []).filter((w) => w !== word) }; save(); },
+ async backupNow() { throw new Error('Backups need the desktop app.'); },
+ async backupInfo() { return { folder: '', last: 0 }; },
+ async pickBackupFolder() { return null; },
+ async revealBackups() {},
+ onNav(cb) { return () => {}; },
+ onChanged(cb) { listeners.add(cb); return () => listeners.delete(cb); },
+ onFlush() { return () => {}; }
+ };
+}
diff --git a/src/lib/prefs.js b/src/lib/prefs.js
new file mode 100644
index 0000000..5548c85
--- /dev/null
+++ b/src/lib/prefs.js
@@ -0,0 +1,115 @@
+// Settings beyond the few that have their own field on the store (theme, text size, ...). One object, saved as `prefs`.
+// Every key has a default; cleanPrefs() fills gaps and drops anything of the wrong kind from a file written by hand.
+export const DEFAULTS = {
+ // appearance
+ uiFont: 'inter', // 'inter' | 'system' | 'mono'
+ density: 'default', // sidebar rows and spacing: 'compact' | 'default' | 'comfortable'
+ sidebarWidth: 268, // px
+ showCover: true,
+ showIcon: true,
+ showBacklinks: true,
+ codeSize: 100, // % of the page's code size
+ codeWrap: false, // wrap long lines in code blocks instead of scrolling
+ customPageFont: '', // an installed font's name for page text; '' uses the Page font choice
+ customCodeFont: '', // same, for code
+ // editor
+ autoPair: true, // ( [ { ` close themselves
+ smartTypography: false, // -- to an em dash, ... to an ellipsis, curly quotes
+ pasteRich: true, // pasted web / rich text keeps headings, lists, links and bold as Markdown
+ mdShortcuts: true, // "# " and "- " turn a line into a heading or a list
+ typewriter: false, // keep the line being typed in the middle of the screen
+ hiddenKinds: [], // block kinds left out of the slash menu
+ autosaveDelay: 600, // ms after the last keystroke
+ // notes
+ dateFormat: 'YYYY-MM-DD', // daily note names
+ dailyTemplate: '', // text a new daily note starts with; {{date}} and {{weekday}} are filled in
+ newPageIcon: 'random', // 'random' | 'none'
+ newPageCover: 'random', // 'random' | 'none'
+ weekStart: 'monday', // calendar views: 'monday' | 'sunday'
+ locale: '', // date language; '' follows the system
+ exportLinks: 'wiki', // [[Page]] links in Markdown exports: 'wiki' | 'markdown'
+ confirmTrash: false, // ask before moving a page to the trash
+ trashDays: 30, // days in the trash before it is emptied; 0 never
+ historyKeep: 100, // versions kept per page
+ historyGapMin: 5, // at most one version per this many minutes of editing
+ // canvas
+ snapToGrid: true,
+ cardColor: '', // color of new canvas cards: '' (none) or a preset 1-6
+ // window
+ alwaysOnTop: false,
+ startMinimized: false,
+ // keyboard
+ shortcuts: {}, // { action id: 'Mod+Shift+K' } for the shortcuts that were changed (src/lib/shortcuts.js)
+ // quick capture
+ captureShortcut: 'CommandOrControl+Alt+N', // global shortcut for the capture window; '' turns it off
+ captureTo: 'daily', // 'daily' (today's note) | 'inbox'
+ // backups
+ backupEvery: 'off', // 'off' | 'daily' | 'weekly'
+ backupKeep: 5, // zips kept
+ backupFolder: '' // '' =
/backups
+};
+
+export const DATE_FORMATS = ['YYYY-MM-DD', 'DD-MM-YYYY', 'MM-DD-YYYY', 'YYYY.MM.DD', 'YYYY-MM-DD dddd', 'MMMM D, YYYY'];
+
+const CHOICES = {
+ uiFont: ['inter', 'system', 'mono'],
+ density: ['compact', 'default', 'comfortable'],
+ newPageIcon: ['random', 'none'],
+ newPageCover: ['random', 'none'],
+ weekStart: ['monday', 'sunday'],
+ exportLinks: ['wiki', 'markdown'],
+ backupEvery: ['off', 'daily', 'weekly'],
+ captureTo: ['daily', 'inbox'],
+ dateFormat: DATE_FORMATS,
+ cardColor: ['', '1', '2', '3', '4', '5', '6']
+};
+const RANGES = {
+ sidebarWidth: [220, 420], codeSize: [80, 140], autosaveDelay: [200, 5000],
+ trashDays: [0, 365], historyKeep: [5, 1000], historyGapMin: [1, 120], backupKeep: [1, 50]
+};
+
+export function cleanPrefs(raw) {
+ const src = raw && typeof raw === 'object' ? raw : {};
+ const out = {};
+ for (const [k, def] of Object.entries(DEFAULTS)) {
+ const v = src[k];
+ if (k === 'shortcuts') {
+ out[k] = {};
+ if (v && typeof v === 'object' && !Array.isArray(v)) for (const [id, c] of Object.entries(v)) if (typeof c === 'string' && /^Mod(\+(Alt|Shift))*\+.+$/.test(c)) out[k][id] = c;
+ continue;
+ }
+ if (CHOICES[k]) out[k] = CHOICES[k].includes(v) ? v : def;
+ else if (RANGES[k]) {
+ const n = Math.round(Number(v));
+ out[k] = Number.isFinite(n) && v !== '' && v !== null ? Math.min(RANGES[k][1], Math.max(RANGES[k][0], n)) : def;
+ } else if (typeof def === 'boolean') out[k] = typeof v === 'boolean' ? v : def;
+ else if (Array.isArray(def)) out[k] = Array.isArray(v) ? v.filter((x) => typeof x === 'string').slice(0, 64) : [];
+ else out[k] = typeof v === 'string' ? v.slice(0, 4000) : def;
+ }
+ return out;
+}
+
+// [[Page]] -> [Page](Page.md), [[Page|label]] -> [label](Page.md), ![[photo.png]] -> ; a #heading goes
+// into the address. Links inside code are left alone.
+export function wikiToMarkdownLinks(md) {
+ const enc = (t) => encodeURI(t).replace(/\(/g, '%28').replace(/\)/g, '%29');
+ const link = (embed, target, alias) => {
+ const [name, heading] = target.split('#');
+ const file = /\.[a-z0-9]{2,5}$/i.test(name) ? name : name + '.md';
+ const label = alias || target;
+ if (embed && /\.(png|jpe?g|gif|webp|svg|avif)$/i.test(name)) return `})`;
+ return `${embed ? '!' : ''}[${label}](${enc(file)}${heading ? '#' + enc(heading.toLowerCase().replace(/\s+/g, '-')) : ''})`;
+ };
+ let fenced = false;
+ return md
+ .split('\n')
+ .map((line) => {
+ if (/^\s*(```|~~~)/.test(line)) { fenced = !fenced; return line; }
+ if (fenced) return line;
+ return line
+ .split(/(`[^`]*`)/)
+ .map((part, i) => (i % 2 ? part : part.replace(/(!?)\[\[([^\]|]+)(?:\|([^\]]*))?\]\]/g, (_, bang, target, alias) => link(!!bang, target.trim(), alias && alias.trim()))))
+ .join('');
+ })
+ .join('\n');
+}
diff --git a/src/lib/scroll.js b/src/lib/scroll.js
new file mode 100644
index 0000000..18a3d7b
--- /dev/null
+++ b/src/lib/scroll.js
@@ -0,0 +1,12 @@
+// Scroll a page's block into view and flash it, so you can see where you landed.
+// Used by the outline and by [[Page#Heading]] links. Returns how long (ms) smooth scrolling will keep running.
+export function scrollToBlock(scroller, el) {
+ const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
+ const top = el.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop - 24;
+ scroller.scrollTo({ top: Math.max(0, top), behavior: reduce ? 'auto' : 'smooth' });
+ el.classList.remove('flash');
+ void el.offsetWidth;
+ el.classList.add('flash');
+ el.addEventListener('animationend', () => el.classList.remove('flash'), { once: true });
+ return reduce ? 50 : 700;
+}
diff --git a/src/lib/shortcuts.js b/src/lib/shortcuts.js
new file mode 100644
index 0000000..8e04ae9
--- /dev/null
+++ b/src/lib/shortcuts.js
@@ -0,0 +1,52 @@
+// The app-level keyboard shortcuts, and the ones a user has changed (prefs.shortcuts: { id: 'Mod+Shift+K' }).
+// A combo is written "Mod+Shift+K": Mod is Ctrl (⌘ on a Mac), then Alt / Shift, then one key.
+export const ACTIONS = [
+ { id: 'palette', label: 'Search and commands', combo: 'Mod+K' },
+ { id: 'newPage', label: 'New page', combo: 'Mod+N' },
+ { id: 'daily', label: "Today's note", combo: 'Mod+Shift+D' },
+ { id: 'tasks', label: 'Tasks', combo: 'Mod+Shift+T' },
+ { id: 'graph', label: 'Graph view', combo: 'Mod+G' },
+ { id: 'outline', label: 'Outline', combo: 'Mod+Shift+O' },
+ { id: 'split', label: 'Split view', combo: 'Mod+Shift+\\' },
+ { id: 'sidebar', label: 'Show or hide the sidebar', combo: 'Mod+\\' },
+ { id: 'find', label: 'Find in page', combo: 'Mod+F' },
+ { id: 'save', label: 'Save now', combo: 'Mod+S' },
+ { id: 'settings', label: 'Settings', combo: 'Mod+,' }
+];
+
+export const comboOf = (id, custom = {}) => {
+ const mine = custom && typeof custom[id] === 'string' ? custom[id] : '';
+ return mine || (ACTIONS.find((a) => a.id === id) || {}).combo || '';
+};
+
+const MODS = ['Mod', 'Alt', 'Shift'];
+// The combo a keydown event is, or '' when it has no Ctrl/⌘ (the app's shortcuts all have one).
+export function eventCombo(e) {
+ if (!(e.ctrlKey || e.metaKey)) return '';
+ const k = e.key;
+ if (!k || ['Control', 'Meta', 'Shift', 'Alt', 'AltGraph', 'Dead'].includes(k)) return '';
+ // Shift turns "," into "<" on some layouts and "\\" into "|": judge the physical key for punctuation
+ const key = k.length === 1 ? (/[a-z0-9]/i.test(k) ? k.toUpperCase() : e.code === 'Backslash' ? '\\' : e.code === 'Comma' ? ',' : k) : k;
+ return ['Mod', e.altKey && 'Alt', e.shiftKey && 'Shift', key].filter(Boolean).join('+');
+}
+
+export function actionFor(e, custom = {}) {
+ const c = eventCombo(e);
+ if (!c) return null;
+ const hit = ACTIONS.find((a) => comboOf(a.id, custom) === c);
+ return hit ? hit.id : null;
+}
+
+// "Mod+Shift+K" -> "Ctrl ⇧ K" / "⌘ ⇧ K"
+export function format(combo, mac = false) {
+ return String(combo || '')
+ .split('+')
+ .map((p) => (p === 'Mod' ? (mac ? '⌘' : 'Ctrl') : p === 'Shift' ? '⇧' : p === 'Alt' ? (mac ? '⌥' : 'Alt') : p))
+ .join(' ');
+}
+
+// Whether a combo may be assigned: needs Mod and one non-modifier key.
+export const valid = (combo) => {
+ const parts = String(combo || '').split('+');
+ return parts[0] === 'Mod' && parts.length >= 2 && !MODS.includes(parts[parts.length - 1]);
+};
diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js
index f5e48c7..0796abc 100644
--- a/src/lib/state.svelte.js
+++ b/src/lib/state.svelte.js
@@ -7,17 +7,37 @@ import { parseMarkdown, toMarkdown, blockText, toHtmlDocument } from './markdown
import { PAGE_ICONS, COVERS, PATHS, isEmoji } from './icons.js';
import { countWords, htmlText } from './text.js';
import { toast } from './toast.svelte.js';
+import { cleanFolder, autoFolder, dailyId, dailyBody, formatDate } from './daily.js';
+import { articleOf } from './htmlDoc.js';
+import { DEFAULTS, cleanPrefs, wikiToMarkdownLinks } from './prefs.js';
import { resolveLink, refFor, refPage, headingOf, normHeading, matchesHeading } from './links.js';
import { sectionOf } from './embed.js';
import { scrollToBlock } from './scroll.js';
import { BOARD_KEY, isBoard, parseBoard, boardToMd, defaultBoard, boardFromMarkdown } from './kanban.js';
+import { isCanvasId } from './canvas.js';
+import { DB_KEY, isDatabase, parseDatabase, databaseToMd, defaultDatabase, view as newView, withFormulas } from './database.js';
+import { databaseCsv, parseCsv } from './csv.js';
+const CORE_KEYS = ['icon', 'cover', 'tags', 'created'];
+// A canvas, board or database keeps its file text as is (#raw); other pages are blocks.
+const rawOf = (id, r) => (isCanvasId(id) || isBoard(r.meta) || isDatabase(r.meta) ? r.body : null);
+
+// A page's content as one comparable string: front matter without empty values and in a fixed order, then the text.
+// (Used to tell whether the file on disk is still what this window last read or wrote.)
+const canon = (meta, body) =>
+ JSON.stringify([
+ Object.keys(meta || {})
+ .filter((k) => { const v = meta[k]; return !(v === undefined || v === null || v === '' || (Array.isArray(v) && !v.length)); })
+ .sort()
+ .map((k) => [k, Array.isArray(meta[k]) ? meta[k].map(String) : String(meta[k])]),
+ String(body ?? '').replace(/\r\n/g, '\n')
+ ]);
const hash = (s) => { let h = 7; for (const ch of String(s)) h = (h * 31 + ch.charCodeAt(0)) | 0; return Math.abs(h); };
const byTitle = (a, b) => a.title.localeCompare(b.title, undefined, { numeric: true, sensitivity: 'base' });
const lastSegment = (id) => id.slice(id.lastIndexOf('/') + 1);
// A page's icon: a stroke icon name or an emoji from its front matter, else one picked from its title.
-export const iconOf = (p) => (p && p.icon && (PATHS[p.icon] || isEmoji(p.icon)) ? p.icon : PAGE_ICONS[hash(p ? p.title : '') % PAGE_ICONS.length]);
+export const iconOf = (p) => (p && p.icon && (PATHS[p.icon] || isEmoji(p.icon)) ? p.icon : p && isCanvasId(p.id) ? 'canvas' : PAGE_ICONS[hash(p ? p.title : '') % PAGE_ICONS.length]);
export const coverOf = (p) => (p && COVERS[p.cover] ? p.cover : Object.keys(COVERS)[hash(p ? p.title : '') % 4]);
export const isMobile = () => typeof window !== 'undefined' && window.innerWidth <= 760;
@@ -33,11 +53,22 @@ class Store {
themePref = $state('dark'); // what was chosen: 'dark' | 'light' | 'system'
fontSize = $state(16); // page text, px
pageWidth = $state('default'); // 'narrow' | 'default' | 'wide' | 'full'
+ quickLayout = $state('vertical'); // the sidebar's Search / New page / Today / Graph view: 'vertical' list or 'horizontal' icons
+ pageFont = $state('sans'); // 'sans' | 'serif' | 'mono'
+ lineSpacing = $state('default'); // 'compact' | 'default' | 'relaxed'
+ palette = $state('default'); // color theme: 'default' or a name from Store.PALETTES
+ accent = $state('blush'); // 'blush' (default) or a name from Store.ACCENTS
+ reduceMotion = $state(false);
+ startPage = $state('last'); // which page opens at launch: 'last' | 'today'
+ prefs = $state({ ...DEFAULTS }); // see prefs.js
settingsOpen = $state(false);
view = $state('page'); // 'page' | 'graph'
- graph = $state({ local: false, depth: 1, orphans: true, ghosts: false, folders: false, spacing: 1 });
+ graph = $state({ local: false, depth: 1, orphans: true, ghosts: false, folders: false, tags: false, spacing: 1 });
collapsed = $state(false);
+ peek = $state(false); // the hidden sidebar is floated in while the mouse is at the window's left edge
+ tagsOpen = $state(true); // the sidebar's tag list is folded away when false
focusMode = $state(false);
+ slashRecent = []; // block kinds last picked from the slash menu, newest first
drawer = $state(false);
saving = $state(false);
justSaved = $state(false);
@@ -52,13 +83,33 @@ class Store {
paletteQuery = ''; // text to start the search palette with
renaming = $state(null); // page id being renamed inline in the sidebar
pins = $state([]); // favorite page ids, in the order they were added
+ tagColors = $state({}); // tag -> colour name (see tagColors.js), per notes folder
+ dailyFolder = $state(null); // where daily notes go (a page path); null = find it (see daily.js), per notes folder
recent = $state([]); // recently opened page ids, newest first
trashOpen = $state(false);
+ tasksOpen = $state(false);
+ tagManagerOpen = $state(false); // the tag manager dialog
+ calendarOpen = $state(false);
+ clipOpen = $state(false);
+ searches = $state([]); // saved searches: [{ q }], per notes folder
historyOpen = $state(null);
templatesOpen = $state(null); // null, or { parent }
+ csvImport = $state(null); // the CSV import dialog: { target: database id or null (a new database), name, text }
findOpen = $state(0); // find-in-page bar: 0 closed, otherwise bumped on every Ctrl+F to refocus it
+ navHistory = $state([]); // [{ id, view }]
+ navIndex = $state(-1);
+ #isNavigating = false;
+
+ get canGoBack() {
+ return this.navIndex > 0;
+ }
+ get canGoForward() {
+ return this.navIndex >= 0 && this.navIndex < this.navHistory.length - 1;
+ }
+
editor = null; // set by , not reactive
+ canvasView = null; // set by (what the menus need from the open canvas), not reactive
#vaults = {}; // per notes folder: { [path]: { pins, recent } }, kept in settings
#blocks = [];
headings = $state.raw([]); // outline of the open page: [{ id (block id), level 1-3, text }]
@@ -66,20 +117,36 @@ class Store {
#headingTimer = null;
#headingChain = Promise.resolve();
outlineOpen = $state(false);
+ split = $state(null); // id of the page shown in the side pane (an ordinary page, never the one on the left)
+ splitHint = $state(null); // 'left' | 'right' while a sidebar drag is over that half of the page
+ splitSave = null; // set by : writes its pending edit, not reactive
#raw = null; // a board's Markdown body; null for ordinary pages, which save from #blocks
+ #base = null; // canon() of the open page as this window last read or wrote it, to notice edits made elsewhere
#dirty = false;
#timer = null;
#chain = Promise.resolve();
#version = 0;
get current() { return this.pages.find((p) => p.id === this.currentId) || null; }
- children(parent) { return this.pages.filter((p) => p.parent === parent).sort(byTitle); }
+ // parent id -> its sub-pages by title, built once per page list (the sidebar asks for every folder's children)
+ #childMap = $derived.by(() => {
+ const map = new Map();
+ for (const p of this.pages) {
+ const k = p.parent || null;
+ const list = map.get(k);
+ if (list) list.push(p); else map.set(k, [p]);
+ }
+ for (const list of map.values()) list.sort(byTitle);
+ return map;
+ });
+ children(parent) { return this.#childMap.get(parent || null) || []; }
+ #byId = $derived.by(() => new Map(this.pages.map((p) => [p.id, p])));
ancestors(id) {
const out = [];
- let p = this.pages.find((x) => x.id === id);
+ let p = this.#byId.get(id);
let parent = p && p.parent;
while (parent) {
- const a = this.pages.find((x) => x.id === parent);
+ const a = this.#byId.get(parent);
if (!a) break;
out.unshift(a);
parent = a.parent;
@@ -102,19 +169,35 @@ class Store {
this.#systemDark.addEventListener('change', () => { if (this.themePref === 'system') this.#transition(this.#resolve('system')); });
this.setFontSize(s.fontSize, false);
this.setPageWidth(s.pageWidth, false);
+ this.quickLayout = s.quickLayout === 'horizontal' ? 'horizontal' : 'vertical';
+ this.setPageFont(s.pageFont, false);
+ this.setLineSpacing(s.lineSpacing, false);
+ this.setPalette(s.palette, false);
+ this.setAccent(s.accent, false);
+ this.setReduceMotion(!!s.reduceMotion, false);
+ this.prefs = cleanPrefs(s.prefs);
+ this.#applyPrefs();
+ this.startPage = s.startPage === 'today' ? 'today' : 'last';
if (s.graph && typeof s.graph === 'object') this.graph = { ...this.graph, ...s.graph };
this.collapsed = !!s.sidebarCollapsed;
+ this.tagsOpen = s.tagsOpen !== false;
this.focusMode = !!s.focusMode;
+ this.slashRecent = Array.isArray(s.slashRecent) ? s.slashRecent.filter((t) => typeof t === 'string').slice(0, 5) : [];
this.outlineOpen = !!s.outline;
this.open = s.open && typeof s.open === 'object' ? s.open : {};
this.#vaults = s.vaults && typeof s.vaults === 'object' ? s.vaults : {};
const v = await api.get();
- if (v) await this.#load(v, s.lastPage);
+ if (v) {
+ await this.#load(v, s.lastPage);
+ if (this.startPage === 'today') await this.openDaily();
+ }
} catch (err) {
toast(errorText(err), 'x');
}
this.ready = true;
api.onChanged((ids) => this.#external(ids));
+ api.onCapture?.(() => this.takeCaptures());
+ this.takeCaptures();
api.onFlush(() => this.settle());
window.addEventListener('blur', () => this.settle());
}
@@ -124,6 +207,9 @@ class Store {
this.pages = v.pages;
const saved = this.#vaults[v.path] || {};
this.pins = Array.isArray(saved.pins) ? saved.pins : [];
+ this.searches = Array.isArray(saved.searches) ? saved.searches.filter((x) => x && typeof x.q === 'string' && x.q.trim()).map((x) => ({ q: x.q.trim() })) : [];
+ this.dailyFolder = typeof saved.dailyFolder === 'string' && saved.dailyFolder ? saved.dailyFolder : null;
+ this.tagColors = saved.tagColors && typeof saved.tagColors === 'object' && !Array.isArray(saved.tagColors) ? { ...saved.tagColors } : {};
this.recent = Array.isArray(saved.recent) ? saved.recent : [];
const first = this.pages.find((p) => p.id === last) || this.children(null).find((p) => p.exists) || this.pages[0];
if (first) await this.openPage(first.id);
@@ -141,20 +227,86 @@ class Store {
async refresh() {
const v = await api.list();
- if (v) this.pages = v.pages;
+ if (!v) return;
+ // The open page may have edits that aren't on disk yet (a cover just chosen, a property just set): the list
+ // read from disk would take them back out, so what the page itself holds wins until it is saved.
+ if (this.doc && this.#dirty) {
+ const p = v.pages.find((x) => x.id === this.doc.id);
+ if (p) this.#syncListEntry(p, this.doc.meta);
+ }
+ this.pages = v.pages;
+ }
+
+ // The fields of a page's list entry that come from its front matter, taken from `meta`.
+ #syncListEntry(p, meta) {
+ p.icon = meta.icon || null;
+ p.cover = meta.cover || null;
+ p.tags = Array.isArray(meta.tags) ? meta.tags : [];
+ const props = {};
+ for (const [k, v] of Object.entries(meta)) if (!CORE_KEYS.includes(k)) props[k] = v;
+ p.props = props;
+ }
+
+ /* navigation history */
+ #pushNav(entry) {
+ const cur = this.navHistory[this.navIndex];
+ if (cur && cur.view === entry.view && cur.id === entry.id) return;
+ if (this.navIndex < this.navHistory.length - 1) {
+ this.navHistory = this.navHistory.slice(0, this.navIndex + 1);
+ }
+ this.navHistory.push(entry);
+ if (this.navHistory.length > 80) {
+ this.navHistory.shift();
+ }
+ this.navIndex = this.navHistory.length - 1;
+ }
+
+ back() { return this.#go(-1); }
+ forward() { return this.#go(1); }
+
+ // Moves through the history one step at a time; presses made while a move is still loading are queued
+ // rather than dropped, so rapid mouse-button clicks walk the history exactly as many steps.
+ #goQueue = Promise.resolve();
+ #go(delta) {
+ this.#goQueue = this.#goQueue.then(() => this.#step(delta)).catch(() => {});
+ return this.#goQueue;
+ }
+ async #step(delta) {
+ let i = this.navIndex + delta;
+ while (i >= 0 && i < this.navHistory.length) {
+ const entry = this.navHistory[i];
+ this.#isNavigating = true;
+ try {
+ if (entry.view === 'graph') {
+ if (this.view !== 'graph') this.openGraph();
+ this.navIndex = i;
+ return;
+ }
+ if (this.view === 'graph') this.closeGraph();
+ await this.openPage(entry.id, { push: false });
+ if (this.currentId === entry.id) { this.navIndex = i; return; }
+ } finally {
+ this.#isNavigating = false;
+ }
+ // The page could not be opened (deleted?): drop the entry and try the next one in that direction.
+ this.navHistory.splice(i, 1);
+ if (delta < 0) { i--; this.navIndex--; }
+ }
}
/* pages */
- async openPage(id) {
+ async openPage(id, { push = true } = {}) {
this.view = 'page';
await this.settle();
let r;
try { r = await api.read(id); } catch (err) { toast(errorText(err), 'x'); return; }
for (const a of this.ancestors(id)) this.open[a.id] = true;
this.currentId = id;
+ if (this.split === id) this.split = null; // the side pane would show the same file twice
this.titleDraft = null;
- this.#blocks = parseMarkdown(r.body);
- this.#raw = isBoard(r.meta) ? r.body : null;
+ this.#raw = rawOf(id, r);
+ this.#base = canon(r.meta, r.body);
+ this.#blocks = isCanvasId(id) ? [] : parseMarkdown(r.body);
this.#dirty = false;
this.doc = { id, title: r.title, meta: r.meta || {}, blocks: this.#blocks, body: r.body, exists: r.exists, version: ++this.#version };
this.#derive(true);
@@ -162,16 +314,18 @@ class Store {
this.recent = [id, ...this.recent.filter((x) => x !== id)].slice(0, 30);
this.#persist({ lastPage: id, open: $state.snapshot(this.open) });
this.#saveLists();
+ if (push && !this.#isNavigating) {
+ this.#pushNav({ id, view: 'page' });
+ }
}
async newPage(parent = null, templateName = null) {
await this.flush();
const covers = Object.keys(COVERS);
try {
- let meta = {
- icon: PAGE_ICONS[Math.floor(Math.random() * PAGE_ICONS.length)],
- cover: covers[Math.floor(Math.random() * covers.length)]
- };
+ let meta = {};
+ if (this.prefs.newPageIcon === 'random') meta.icon = PAGE_ICONS[Math.floor(Math.random() * PAGE_ICONS.length)];
+ if (this.prefs.newPageCover === 'random') meta.cover = covers[Math.floor(Math.random() * covers.length)];
let body = '';
let title = 'Untitled';
@@ -199,6 +353,7 @@ class Store {
async removePage(id = this.currentId) {
if (!id) return;
const page = this.pages.find((p) => p.id === id);
+ if (this.prefs.confirmTrash && !confirm(`Move “${page ? page.title : id}” to the trash?`)) return;
const parent = page ? page.parent : null;
const within = (x) => x === id || x.startsWith(id + '/');
const count = this.pages.filter((p) => within(p.id)).length;
@@ -220,36 +375,119 @@ class Store {
else { this.doc = null; this.currentId = null; }
}
- async renameTag(oldTag, newTag) {
- if (!oldTag || !newTag || oldTag === newTag) return;
+ // Several pages at once (a selection of database records), as one step: one toast, one Undo.
+ async removePages(ids) {
+ ids = [...new Set(ids)].filter((id) => !this.currentId || id !== this.currentId);
+ if (!ids.length) return;
+ if (this.prefs.confirmTrash && !confirm(ids.length === 1 ? 'Move this page to the trash?' : `Move ${ids.length} pages to the trash?`)) return;
await this.flush();
- let updated = 0;
+ const entries = [];
try {
- for (const page of this.pages) {
- if (page.tags && page.tags.includes(oldTag)) {
- const r = await api.read(page.id);
- const meta = r.meta || {};
- const tags = meta.tags || [];
- const idx = tags.indexOf(oldTag);
- if (idx !== -1) {
- tags[idx] = newTag;
- meta.tags = tags;
- await api.write(page.id, meta, r.body);
- updated++;
- }
- }
- }
- if (updated > 0) {
- if (this.filterTag === oldTag) this.filterTag = newTag;
- await this.refresh();
- if (this.currentId) {
- const r = await api.read(this.currentId);
- this.doc = { ...this.doc, meta: r.meta || {}, version: ++this.#version };
- }
- toast(`Renamed tag across ${updated} page${updated === 1 ? '' : 's'}`);
- }
+ for (const id of ids) { const e = await api.remove(id); if (e) entries.push(e); }
} catch (err) {
- toast('Could not rename tag: ' + errorText(err), 'x');
+ toast(errorText(err), 'x');
+ }
+ await this.refresh();
+ if (!entries.length) return;
+ const undo = {
+ label: 'Undo',
+ run: async () => {
+ try { for (const e of entries) await api.restore(e); await this.refresh(); toast('Restored', 'check'); } catch (err) { toast('Could not restore: ' + errorText(err), 'x'); }
+ }
+ };
+ toast(entries.length === 1 ? 'Moved to the trash' : `Moved ${entries.length} pages to the trash`, 'trash', undo);
+ }
+
+ // The colour of a tag (null removes it).
+ setTagColor(tag, color) {
+ const next = { ...this.tagColors };
+ if (color) next[tag] = color;
+ else delete next[tag];
+ this.tagColors = next;
+ this.#saveLists();
+ }
+
+ // Rename a tag in every page; into a tag that exists, it merges. An empty new name removes the tag.
+ async renameTag(oldTag, newTag = '') {
+ const to = String(newTag || '').trim().toLowerCase().replace(/^#/, '').replace(/\s+/g, '-');
+ if (!oldTag || to === oldTag) return;
+ await this.flush();
+ try {
+ const n = await api.renameTag(oldTag, to);
+ if (this.filterTag === oldTag) this.filterTag = to || null;
+ if (this.tagColors[oldTag]) {
+ if (to && !this.tagColors[to]) this.setTagColor(to, this.tagColors[oldTag]);
+ this.setTagColor(oldTag, null);
+ }
+ await this.refresh();
+ if (this.currentId) {
+ const r = await api.read(this.currentId);
+ this.#base = canon(r.meta, r.body);
+ this.doc = { ...this.doc, meta: r.meta || {}, version: ++this.#version };
+ }
+ toast(`${to ? 'Renamed' : 'Removed'} #${oldTag} in ${n} page${n === 1 ? '' : 's'}`);
+ } catch (err) {
+ toast('Could not change the tag: ' + errorText(err), 'x');
+ }
+ }
+ deleteTag(tag) { return this.renameTag(tag, ''); }
+
+ /* bringing notes in and out */
+ async importNotes(kind) {
+ try {
+ await this.flush();
+ const r = await api.importNotes(kind);
+ if (!r) return;
+ await this.refresh();
+ this.open[r.folder] = true;
+ toast(`Imported ${r.notes} note${r.notes === 1 ? '' : 's'}${r.files ? ` and ${r.files} file${r.files === 1 ? '' : 's'}` : ''} into “${r.folder}”`, 'download');
+ if (this.pages.some((p) => p.id === r.folder)) await this.openPage(r.folder);
+ } catch (err) {
+ toast('Could not import: ' + errorText(err), 'x');
+ }
+ }
+ async exportVaultZip() {
+ try {
+ await this.flush();
+ const r = await api.exportVaultZip();
+ if (r) toast(`Saved ${r.count} files to a zip`, 'save');
+ } catch (err) {
+ toast('Could not export: ' + errorText(err), 'x');
+ }
+ }
+ // A web page saved as a note: its main text as Markdown, with the address in front matter.
+ async clipWeb(address) {
+ const url = String(address || '').trim();
+ if (!url) return;
+ try {
+ await this.flush();
+ const page = await api.clipPage(/^[a-z]+:\/\//i.test(url) ? url : 'https://' + url);
+ const { title, markdown } = articleOf(page.html, { base: page.url });
+ const name = (title || new URL(page.url).hostname).slice(0, 120);
+ const id = await api.create(null, name, { icon: 'globe', source: page.url }, markdown);
+ await this.refresh();
+ await this.openPage(id);
+ toast('Clipped “' + name + '”', 'check');
+ } catch (err) {
+ toast('Could not clip that page: ' + errorText(err), 'x');
+ }
+ }
+
+ // A copy of a page, board or canvas beside it (with its sub-pages), opened.
+ async duplicatePage(id) {
+ await this.flush();
+ try {
+ const next = await api.duplicate(id);
+ await this.refresh();
+ this.fresh = next;
+ await this.openPage(next);
+ setTimeout(() => { if (this.fresh === next) this.fresh = null; }, 1500);
+ toast('Duplicated', 'copy', {
+ label: 'Undo',
+ run: async () => { await this.removePage(next); await this.openPage(id); }
+ });
+ } catch (err) {
+ toast('Could not duplicate: ' + errorText(err), 'x');
}
}
@@ -260,9 +498,12 @@ class Store {
if (title === page.title) return;
await this.settle();
try {
- await this.#relocated(id, await api.rename(id, title));
+ const next = await api.rename(id, title);
+ await this.#relocated(id, next);
+ return next; // the new id (the file name may differ from `title`)
} catch (err) {
toast('Could not rename: ' + errorText(err), 'x');
+ return null;
}
}
@@ -295,28 +536,114 @@ class Store {
#saveLists() {
if (!this.vault) return;
- this.#vaults = { ...this.#vaults, [this.vault.path]: { pins: $state.snapshot(this.pins), recent: $state.snapshot(this.recent) } };
+ this.#vaults = { ...this.#vaults, [this.vault.path]: { pins: $state.snapshot(this.pins), recent: $state.snapshot(this.recent), tagColors: $state.snapshot(this.tagColors), dailyFolder: this.dailyFolder, searches: $state.snapshot(this.searches) } };
this.#persist({ vaults: this.#vaults });
}
- /* daily note: Daily/YYYY-MM-DD */
- async openDaily() {
- const d = new Date();
- const z = (n) => String(n).padStart(2, '0');
- const id = `Daily/${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}`;
+ /* saved searches: a query kept in the sidebar; opening one runs it in the search palette */
+ isSearchSaved(q) { const t = String(q || '').trim().toLowerCase(); return this.searches.some((s) => s.q.toLowerCase() === t); }
+ saveSearch(q) {
+ const t = String(q || '').trim();
+ if (!t || this.isSearchSaved(t)) return;
+ this.searches = [...this.searches, { q: t }];
+ this.#saveLists();
+ toast('Search saved to the sidebar', 'search');
+ }
+ removeSearch(q) {
+ this.searches = this.searches.filter((s) => s.q !== q);
+ this.#saveLists();
+ }
+ runSearch(q) {
+ this.paletteQuery = q;
+ this.paletteOpen = true;
+ }
+
+ /* quick capture (see electron/main.cjs): text from the capture window, added to today's note or to Inbox */
+ async takeCaptures() {
+ try {
+ for (const c of (await api.takeCaptures()) || []) await this.#capture(c);
+ } catch (err) {
+ toast('Could not capture: ' + errorText(err), 'x');
+ }
+ }
+ async #capture({ text, to }) {
+ await this.flush();
+ await this.refresh();
+ let id, label;
+ if (to === 'inbox') {
+ id = 'Inbox';
+ label = 'Inbox';
+ if (!this.pages.some((p) => p.id === id && p.exists)) await api.write(id, { icon: 'inbox', created: dailyId('', new Date()).split('/').pop() }, '');
+ } else {
+ id = (await this.#ensureDaily()).id;
+ label = "today's note";
+ }
+ const r = await api.read(id);
+ const line = text.includes('\n') ? text : '- ' + text;
+ const base = String(r.body || '').replace(/\s+$/, '');
+ const tight = !text.includes('\n') && /^\s*[-*+] /.test(base.slice(base.lastIndexOf('\n') + 1));
+ await api.write(id, r.meta, (base ? base + (tight ? '\n' : '\n\n') : '') + line + '\n');
+ await this.refresh();
+ if (this.doc && this.doc.id === id) await this.#external([id]);
+ toast('Added to ' + label, 'check');
+ }
+
+ /* daily note: /YYYY-MM-DD */
+ // The folder daily notes go in: the one chosen in Settings, else found (see daily.js).
+ get dailyFolderNow() { return this.dailyFolder || autoFolder(this.pages); }
+ setDailyFolder(text) {
+ const folder = cleanFolder(text);
+ this.dailyFolder = folder || null;
+ this.#saveLists();
+ }
+ // Today's note, made (with its folder) if it isn't there yet: { id, folder, fresh }.
+ async #ensureDaily(date = null) {
+ const folder = this.dailyFolderNow;
+ const now = date || new Date();
+ const id = dailyId(folder, now, this.prefs.dateFormat, this.prefs.locale);
+ const day = dailyId('', now).slice(dailyId('', now).lastIndexOf('/') + 1); // always YYYY-MM-DD: it is the page's created date
+ if (!this.pages.some((p) => p.id === folder && p.exists)) {
+ await api.write(folder, { icon: 'book', cover: 'sand', created: day }, '');
+ }
+ const fresh = !this.pages.some((p) => p.id === id && p.exists);
+ if (fresh) {
+ const covers = Object.keys(COVERS);
+ await api.write(id, { icon: 'cup', cover: covers[Math.floor(Math.random() * covers.length)], created: day }, dailyBody(this.prefs.dailyTemplate, now, this.prefs.dateFormat, this.prefs.locale));
+ await this.refresh();
+ }
+ return { id, folder, fresh };
+ }
+ // The daily notes that exist: { 'YYYY-MM-DD' -> page id }, for the calendar and the previous / next buttons.
+ get dailyDays() {
+ const folder = this.dailyFolderNow;
+ const out = new Map();
+ for (const p of this.pages) {
+ if (!p.exists || p.parent !== folder || !/^\d{4}-\d{2}-\d{2}$/.test(p.created || '')) continue;
+ const d = new Date(p.created + 'T12:00:00');
+ if (formatDate(d, this.prefs.dateFormat, this.prefs.locale) === p.title) out.set(p.created, p.id);
+ }
+ return out;
+ }
+ get currentDay() {
+ for (const [day, id] of this.dailyDays) if (id === this.currentId) return day;
+ return null;
+ }
+ // The nearest daily note before (-1) or after (+1) the one open, or today's when you go past the last one.
+ stepDaily(dir) {
+ const days = [...this.dailyDays.keys()].sort();
+ const cur = this.currentDay;
+ if (!cur) return;
+ const next = dir < 0 ? days.filter((d) => d < cur).pop() : days.find((d) => d > cur);
+ if (next) return this.openPage(this.dailyDays.get(next));
+ if (dir > 0) return this.openDaily();
+ toast('That is the first daily note', 'calendar');
+ }
+ async openDaily(date = null) {
try {
await this.flush();
await this.refresh();
- if (!this.pages.some((p) => p.id === 'Daily' && p.exists)) {
- await api.write('Daily', { icon: 'book', cover: 'sand', created: id.slice(6) }, '');
- }
- const fresh = !this.pages.some((p) => p.id === id && p.exists);
- if (fresh) {
- const covers = Object.keys(COVERS);
- await api.write(id, { icon: 'cup', cover: covers[Math.floor(Math.random() * covers.length)], created: id.slice(6) }, '');
- await this.refresh();
- }
- this.open.Daily = true;
+ const { id, folder, fresh } = await this.#ensureDaily(date);
+ this.open[folder] = true;
this.filterTag = null;
await this.openPage(id);
if (fresh) requestAnimationFrame(() => this.editor?.focusFirst());
@@ -331,6 +658,19 @@ class Store {
const page = this.pages.find((p) => p.id === id);
if (!page || page.parent === parent || parent === id || (parent && parent.startsWith(id + '/'))) return;
await this.settle();
+ // A record that leaves its database keeps its property values in the file: say so, and offer to clean up.
+ const from = page.parent && this.pages.find((p) => p.id === page.parent && p.db);
+ const into = parent && this.pages.find((p) => p.id === parent && p.db);
+ let left = []; // names and keys of the old database's properties that this record has values for
+ if (from) {
+ try {
+ const has = new Set(Object.keys(page.props || {}));
+ const old = await this.readSchema(from.id);
+ const now = into ? await this.readSchema(into.id) : null;
+ const kept = new Set(((now && now.properties) || []).map((p) => p.key));
+ left = ((old && old.properties) || []).filter((p) => has.has(p.key) && !kept.has(p.key)).map((p) => ({ key: p.key, name: p.name }));
+ } catch { /* not a database after all */ }
+ }
try {
const next = await api.move(id, parent);
if (parent) this.open[parent] = true;
@@ -338,12 +678,37 @@ class Store {
this.fresh = next;
setTimeout(() => { if (this.fresh === next) this.fresh = null; }, 1500);
const to = parent && this.pages.find((p) => p.id === parent);
- toast(to ? `Moved into ${to.title}` : 'Moved to the top level', 'folder');
+ const base = to ? `Moved into ${to.title}` : 'Moved to the top level';
+ const undo = { label: 'Undo', run: () => this.movePage(next, page.parent) };
+ if (!left.length) { toast(base, 'folder', undo); return; }
+ const n = left.length;
+ const what = `${n} propert${n === 1 ? 'y' : 'ies'} from ${from.title} (${left.map((p) => p.name).slice(0, 3).join(', ')}${n > 3 ? '…' : ''})`;
+ toast(`${base}. ${into ? `${what} ${n === 1 ? 'isn’t' : 'aren’t'} in ${into.title}` : `${what} stayed in the file`}`, 'folder', {
+ label: 'Remove',
+ run: () => this.dropProps(next, left.map((p) => p.key))
+ });
} catch (err) {
toast('Could not move: ' + errorText(err), 'x');
}
}
+ // Take property values (front matter keys) off a page.
+ async dropProps(id, keys) {
+ try {
+ if (this.currentId === id) await this.flush();
+ const r = await api.read(id);
+ if (!r.exists) return;
+ const meta = { ...(r.meta || {}) };
+ for (const k of keys) delete meta[k];
+ await api.write(id, meta, r.body);
+ await this.refresh();
+ if (this.currentId === id) await this.openPage(id);
+ toast(`Removed ${keys.length} propert${keys.length === 1 ? 'y' : 'ies'}`, 'check');
+ } catch (err) {
+ toast('Could not remove: ' + errorText(err), 'x');
+ }
+ }
+
// Bookkeeping after page `id` became `next`, together with everything under it.
async #relocated(id, next) {
if (next === id) return;
@@ -353,6 +718,7 @@ class Store {
this.open = open;
this.pins = this.pins.map(move);
this.recent = this.recent.map(move);
+ this.navHistory = this.navHistory.map((e) => ({ ...e, id: move(e.id) }));
this.#saveLists();
await this.refresh();
this.#persist({ open: $state.snapshot(this.open) });
@@ -416,7 +782,9 @@ class Store {
if (id === this.currentId) return { state: 'self', id, title };
const r = await api.read(id);
if (!r.exists) return { state: 'missing', name: title };
+ if (isDatabase(r.meta)) return { state: 'database', id, title, view: heading, body: r.body };
if (isBoard(r.meta)) return { state: 'board', id, title };
+ if (isCanvasId(id)) return { state: 'canvas', id, title };
let blocks = parseMarkdown(r.body);
if (heading) {
const section = sectionOf(blocks, heading);
@@ -427,13 +795,16 @@ class Store {
}
// Make a top-level page for a link to something that doesn't exist yet. Returns its id.
- async createLinked(title) {
+ createLinked(title) { return this.createNote(null, title); }
+
+ // Make a page (inside `parent`, or at the top) with some text in it, without opening it. Returns its id.
+ async createNote(parent, title, body = '') {
const covers = Object.keys(COVERS);
try {
- const id = await api.create(null, title || 'Untitled', {
+ const id = await api.create(parent || null, title || 'Untitled', {
icon: PAGE_ICONS[Math.floor(Math.random() * PAGE_ICONS.length)],
cover: covers[Math.floor(Math.random() * covers.length)]
- });
+ }, body);
await this.refresh();
return id;
} catch (err) {
@@ -449,6 +820,32 @@ class Store {
await this.renamePage(d.id, text);
}
+ /* canvases */
+ get isCanvas() { return isCanvasId(this.doc?.id); }
+
+ // Canvas `id` changed: save its file text. (Ignored if another page is open by now.)
+ editCanvas(id, text) {
+ if (!this.isCanvas || this.doc.id !== id) return;
+ this.#raw = text;
+ this.#dirty = true;
+ this.#schedule();
+ }
+
+ async newCanvas(parent = null) {
+ await this.settle();
+ try {
+ const id = await api.createCanvas(parent, 'Untitled canvas');
+ if (parent) this.open[parent] = true;
+ this.filterTag = null;
+ await this.refresh();
+ this.fresh = id;
+ await this.openPage(id);
+ setTimeout(() => { if (this.fresh === id) this.fresh = null; }, 1500);
+ } catch (err) {
+ toast(errorText(err), 'x');
+ }
+ }
+
/* boards */
get isBoard() { return isBoard(this.doc?.meta); }
@@ -485,6 +882,152 @@ class Store {
toast(on ? 'Showing as a board' : 'Showing as a document', on ? 'board' : 'file');
}
+ /* databases
+ A database is a page whose sub-pages are its records; the schema (properties, views) is in its body and each
+ record keeps its property values in its own front matter. See database.js. */
+ get isDatabase() { return isDatabase(this.doc?.meta); }
+
+ // The schema changed: save the database page's body.
+ editDatabase(body) {
+ this.#raw = body;
+ this.#dirty = true;
+ this.#schedule();
+ }
+
+ async newDatabase(parent = null) {
+ await this.flush();
+ try {
+ const id = await api.create(parent, 'Database', { icon: 'table', cover: Object.keys(COVERS)[Math.floor(Math.random() * 4)], [DB_KEY]: 'basic' }, databaseToMd(defaultDatabase()));
+ if (parent) this.open[parent] = true;
+ this.filterTag = null;
+ await this.refresh();
+ this.fresh = id;
+ await this.openPage(id);
+ setTimeout(() => { if (this.fresh === id) this.fresh = null; }, 1500);
+ } catch (err) {
+ toast(errorText(err), 'x');
+ }
+ }
+
+ // The records of database `id`: its sub-pages that are files (not canvases, not folders without a page).
+ records(id) { return this.pages.filter((p) => p.parent === id && p.exists && p.kind !== 'canvas'); }
+
+ // A new record in database `id`; `props` are stored values (see database.write). Returns the record's id.
+ async addRecord(id, title = 'Untitled', props = {}) {
+ try {
+ await this.flush();
+ const rid = await api.create(id, title, { ...props }, '');
+ await this.refresh();
+ return rid;
+ } catch (err) {
+ toast(errorText(err), 'x');
+ return null;
+ }
+ }
+
+ #recordChain = Promise.resolve();
+ // Set property values on a record (a patch of key -> stored value; '' or [] removes the key). Edits are
+ // applied one after another so two quick changes to the same record don't undo each other.
+ setRecordProps(id, patch) {
+ const p = this.pages.find((x) => x.id === id);
+ if (p) {
+ const props = { ...(p.props || {}) };
+ for (const [k, v] of Object.entries(patch)) {
+ if (v === '' || v === undefined || (Array.isArray(v) && !v.length)) delete props[k];
+ else props[k] = v;
+ }
+ p.props = props;
+ }
+ this.#recordChain = this.#recordChain.then(async () => {
+ try {
+ const r = await api.read(id);
+ if (!r.exists) return;
+ const meta = { ...(r.meta || {}), ...patch };
+ for (const k of Object.keys(patch)) if (patch[k] === '' || patch[k] === undefined) delete meta[k];
+ await api.write(id, meta, r.body);
+ } catch (err) {
+ toast('Could not save: ' + errorText(err), 'x');
+ }
+ });
+ return this.#recordChain;
+ }
+
+ // Another page's database schema (read from disk): for a record's page, which shows its parent's properties.
+ async readSchema(id) {
+ const r = await api.read(id);
+ return isDatabase(r.meta) ? parseDatabase(r.body) : null;
+ }
+ async saveSchema(id, db) {
+ const r = await api.read(id);
+ await api.write(id, r.meta || {}, databaseToMd(db));
+ }
+
+ /* CSV in and out */
+ // The open database's records as a CSV file (all of them, every property).
+ async exportCsv() {
+ const d = this.doc;
+ if (!d || !this.isDatabase) return;
+ await this.flush();
+ try {
+ const schema = parseDatabase(this.#raw ?? d.body);
+ const csv = databaseCsv(schema, withFormulas(this.records(d.id), schema.properties));
+ const where = await api.saveExport(`${d.title}.csv`, csv);
+ if (where) toast('Exported ' + this.records(d.id).length + ' records', 'export');
+ } catch (err) {
+ toast('Could not export: ' + errorText(err), 'x');
+ }
+ }
+
+ // Choose a CSV file and open the import dialog for it. target: a database id, or null for a new database.
+ pickCsv(target = null) {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = '.csv,.tsv,.txt,text/csv,text/tab-separated-values';
+ input.onchange = async () => {
+ const f = input.files && input.files[0];
+ if (!f) return;
+ try {
+ const text = await f.text();
+ if (!parseCsv(text).rows.length) { toast('That file has no rows', 'x'); return; }
+ this.csvImport = { target, name: f.name.replace(/\.[^.]+$/, ''), text };
+ } catch (err) {
+ toast('Could not read that file: ' + errorText(err), 'x');
+ }
+ };
+ input.click();
+ }
+
+ // Create what a planned import describes (see csv.planImport). Returns the database's id.
+ async importCsv(plan, { target, name }) {
+ await this.flush();
+ try {
+ let dbId = target;
+ if (target) {
+ const s = await this.readSchema(target);
+ if (!s) throw new Error('That page is not a database.');
+ for (const p of plan.properties) s.properties.push(p);
+ for (const [key, add] of Object.entries(plan.options)) {
+ const p = s.properties.find((x) => x.key === key);
+ if (p) p.options = [...new Set([...(p.options || []), ...add])];
+ }
+ await this.saveSchema(target, s);
+ } else {
+ const views = [newView('table', 'Table')];
+ const date = plan.properties.find((p) => p.type === 'date');
+ if (date) views.push({ ...newView('calendar', 'Calendar'), dateProp: date.key });
+ dbId = await api.create(null, name || 'Imported', { icon: 'table', cover: Object.keys(COVERS)[Math.floor(Math.random() * 4)], [DB_KEY]: 'basic' }, databaseToMd({ properties: plan.properties, views, rest: '' }));
+ }
+ await api.createMany(dbId, plan.items);
+ await this.refresh();
+ if (this.currentId === dbId || !target) await this.openPage(dbId);
+ toast(`Imported ${plan.items.length} record${plan.items.length === 1 ? '' : 's'}`, 'check');
+ return dbId;
+ } catch (err) {
+ toast('Could not import: ' + errorText(err), 'x');
+ return null;
+ }
+ }
+
async newBoard(parent = null) {
await this.flush();
try {
@@ -533,6 +1076,7 @@ class Store {
// Save, and bring links to renamed headings up to date. Used when you leave the page or the window.
async settle() {
+ await this.splitSave?.();
await this.flush();
await this.#commitHeadingRenames();
}
@@ -585,6 +1129,49 @@ class Store {
}
}
+ /* split view */
+ openSplit(id) {
+ if (!id) return;
+ if (id === this.currentId) { toast('That page is already open on the left', 'columns'); return; }
+ this.view = 'page';
+ this.split = id;
+ }
+ async closeSplit() {
+ await this.splitSave?.();
+ this.split = null;
+ }
+ // The side page moves to the main pane and the main page takes its place.
+ async swapSplit() {
+ const side = this.split, main = this.currentId;
+ if (!side || !main) return;
+ await this.closeSplit();
+ await this.openPage(side);
+ this.split = main;
+ }
+ // A page dragged from the sidebar onto the right half goes to the side pane; onto the left half it takes the
+ // main pane and the page that was there moves to the side.
+ async dropToSide(id, side) {
+ if (id === this.currentId) return;
+ if (side === 'right') { this.openSplit(id); return; }
+ const was = this.currentId;
+ await this.splitSave?.();
+ this.split = null;
+ await this.openPage(id);
+ if (was) this.split = was;
+ }
+ // Ctrl/⌘ \ with a side pane open closes it; otherwise the page you visited before this one opens there.
+ toggleSplit() {
+ if (this.split) { this.closeSplit(); return; }
+ const other = this.recent.find((id) => id !== this.currentId && this.pages.find((p) => p.id === id && p.exists));
+ if (other) this.openSplit(other);
+ else toast('Right-click a page in the sidebar and choose “Open in split”', 'columns');
+ }
+
+ rememberSlash(kind) {
+ this.slashRecent = [kind, ...this.slashRecent.filter((t) => t !== kind)].slice(0, 5);
+ this.#persist({ slashRecent: this.slashRecent });
+ }
+
toggleFocusMode() {
this.focusMode = !this.focusMode;
this.#persist({ focusMode: this.focusMode });
@@ -600,7 +1187,20 @@ class Store {
if (!d) return;
this.doc = { ...d, meta: { ...d.meta, ...patch } };
const p = this.current;
- if (p) for (const k of ['icon', 'cover', 'tags']) if (k in patch) p[k] = patch[k];
+ if (p) {
+ for (const k of ['icon', 'cover', 'tags']) if (k in patch) p[k] = patch[k];
+ // other front matter keys are a record's property values, as the database shows them
+ const rest = Object.keys(patch).filter((k) => !CORE_KEYS.includes(k));
+ if (rest.length) {
+ const props = { ...(p.props || {}) };
+ for (const k of rest) {
+ const v = patch[k];
+ if (v === '' || v === undefined || (Array.isArray(v) && !v.length)) delete props[k];
+ else props[k] = v;
+ }
+ p.props = props;
+ }
+ }
this.#dirty = true;
this.#schedule();
}
@@ -608,7 +1208,7 @@ class Store {
#schedule() {
this.saving = true;
clearTimeout(this.#timer);
- this.#timer = setTimeout(() => this.flush(), 600);
+ this.#timer = setTimeout(() => this.flush(), this.prefs.autosaveDelay);
}
flush() {
@@ -623,9 +1223,28 @@ class Store {
this.#dirty = false;
const body = this.#raw ?? toMarkdown(this.#blocks);
try {
+ // Someone else (another editor, a sync tool) may have changed the file since this window read it. Saving
+ // would silently replace their text, so their version is put in version history first and you are told.
+ const mine = canon(d.meta, body);
+ if (this.#base !== null) {
+ const disk = await api.read(d.id).catch(() => null);
+ if (disk && disk.exists) {
+ const there = canon(disk.meta, disk.body);
+ if (there !== this.#base && there !== mine) {
+ await api.keepVersion(d.id).catch(() => {});
+ toast('This note was also changed outside Ink. Your version was saved; the other one is in version history.', 'clock', {
+ label: 'Show versions',
+ run: () => { this.historyOpen = d.id; }
+ });
+ }
+ }
+ }
await api.write(d.id, { ...d.meta }, body);
+ this.#base = mine;
const p = this.pages.find((x) => x.id === d.id);
if (p && !p.exists) p.exists = true;
+ // what was just saved is what the list should show (a refresh that raced the save may have read the old file)
+ if (p) this.#syncListEntry(p, d.meta);
this.justSaved = true;
clearTimeout(this.#justSavedTimer);
this.#justSavedTimer = setTimeout(() => { this.justSaved = false; }, 3000);
@@ -647,9 +1266,10 @@ class Store {
else { this.doc = null; this.currentId = null; }
return;
}
- if (r.body === (this.#raw ?? toMarkdown(this.#blocks)) && isBoard(r.meta) === this.isBoard) return;
- this.#blocks = parseMarkdown(r.body);
- this.#raw = isBoard(r.meta) ? r.body : null;
+ if (r.body === (this.#raw ?? toMarkdown(this.#blocks)) && isBoard(r.meta) === this.isBoard && isDatabase(r.meta) === this.isDatabase) return;
+ this.#raw = rawOf(d.id, r);
+ this.#base = canon(r.meta, r.body);
+ this.#blocks = isCanvasId(d.id) ? [] : parseMarkdown(r.body);
this.#derive(true);
this.doc = { ...d, meta: r.meta || {}, blocks: this.#blocks, body: r.body, version: ++this.#version };
toast('Updated from disk', 'file');
@@ -658,7 +1278,9 @@ class Store {
exportText(format) {
const d = this.doc;
if (!d) return '';
- return format === 'html' ? toHtmlDocument(d.title, this.#blocks) : `# ${d.title}\n\n${this.#raw ?? toMarkdown(this.#blocks)}`;
+ if (format === 'html') return toHtmlDocument(d.title, this.#blocks);
+ const md = `# ${d.title}\n\n${this.#raw ?? toMarkdown(this.#blocks)}`;
+ return this.prefs.exportLinks === 'markdown' ? wikiToMarkdownLinks(md) : md;
}
/* ui */
@@ -667,8 +1289,93 @@ class Store {
this.#persist({ open: $state.snapshot(this.open) });
}
+ setQuickLayout(layout) {
+ this.quickLayout = layout === 'horizontal' ? 'horizontal' : 'vertical';
+ this.#persist({ quickLayout: this.quickLayout });
+ }
+
+ setTagsOpen(on) {
+ this.tagsOpen = on;
+ this.#persist({ tagsOpen: on });
+ }
+
+ // Hover reveal for a hidden sidebar, driven by where the mouse is:
+ // - touching the thin edge strip opens it at once;
+ // - it stays while the mouse is over it (and a few pixels around it, so slipping off its border or into the gap
+ // beside it is fine);
+ // - once the mouse is clearly away it closes: quickly if it never got into the sidebar (just brushed the edge),
+ // after a short grace if it did. Coming back before that cancels it.
+ // It also stays while something of the sidebar is in use (a menu, a drag, a rename).
+ static PEEK_EDGE = 7; // trigger strip width (px)
+ static PEEK_LEFT = 10; // where the floating sidebar starts, and
+ static PEEK_RIGHT = 10 + 268; // where it ends
+ static PEEK_SLACK = 14; // a little beyond its right edge still counts as on it
+ static PEEK_TOP = 44; // below the window's drag bar
+ #closeTimer = null;
+ #peekEntered = false; // the mouse has been in the sidebar since it opened
+ #peekLocked = false; // closed with Escape while the mouse is still on the edge: stays closed until it moves off
+ #leftWindow = false; // the pointer went out of the window with the sidebar floating in
+ #canPeek() { return this.collapsed && !this.focusMode && !isMobile(); }
+ #peekBusy() {
+ return !!(this.menu || this.textMenu || this.renaming || document.body.classList.contains('page-dragging') ||
+ document.querySelector('.sidebar input:focus'));
+ }
+ #cancelClose() { clearTimeout(this.#closeTimer); this.#closeTimer = null; }
+ // The pointer is in the trigger strip (pointerenter on it, or a move that lands there): open at once.
+ peekOn() {
+ if (this.#peekLocked || !this.#canPeek()) return;
+ this.#cancelClose();
+ if (!this.peek) { this.peek = true; this.#peekEntered = false; }
+ }
+ // The pointer left the window (or the window lost focus) with the sidebar floating in: no more pointer moves will
+ // arrive to close it, so it goes after a short wait (long enough to find your way back, not to hang around).
+ peekWindowLeft() {
+ if (!this.peek) return;
+ this.#cancelClose();
+ this.#leftWindow = true;
+ const tryClose = () => {
+ if (this.#peekBusy()) { this.#closeTimer = setTimeout(tryClose, 300); return; }
+ this.#closeTimer = null;
+ this.peek = false;
+ };
+ this.#closeTimer = setTimeout(tryClose, 1500);
+ }
+ // Every pointer move.
+ peekMoved(x, y) {
+ if (this.#leftWindow) { this.#leftWindow = false; this.#cancelClose(); } // back in the window: judged by position again
+ if (this.#peekLocked) { if (x > 48) this.#peekLocked = false; return; }
+ if (!this.#canPeek()) return;
+ if (!this.peek) {
+ if (x <= Store.PEEK_EDGE && y >= Store.PEEK_TOP) this.peekOn();
+ return;
+ }
+ if (x >= Store.PEEK_LEFT && x <= Store.PEEK_RIGHT) this.#peekEntered = true;
+ if (x <= Store.PEEK_RIGHT + Store.PEEK_SLACK) { this.#cancelClose(); return; }
+ if (this.#closeTimer) return;
+ const tryClose = () => {
+ if (this.#peekBusy()) { this.#closeTimer = setTimeout(tryClose, 300); return; }
+ this.#closeTimer = null;
+ this.peek = false;
+ };
+ this.#closeTimer = setTimeout(tryClose, this.#peekEntered ? 400 : 150);
+ }
+ // Escape: closed now, and not opened again until the mouse has moved off the edge.
+ peekEscape() {
+ this.#cancelClose();
+ this.peek = false;
+ this.#peekLocked = true;
+ }
+
toggleSidebar() {
+ this.#cancelClose();
+ this.peek = false;
if (isMobile()) { this.drawer = !this.drawer; return; }
+ if (this.focusMode) {
+ this.toggleFocusMode();
+ this.collapsed = false;
+ this.#persist({ sidebarCollapsed: this.collapsed });
+ return;
+ }
this.collapsed = !this.collapsed;
this.#persist({ sidebarCollapsed: this.collapsed });
}
@@ -694,11 +1401,17 @@ class Store {
this.paletteOpen = false;
this.findOpen = 0;
this.drawer = false;
+ if (!this.#isNavigating) {
+ this.#pushNav({ id: this.currentId, view: 'graph' });
+ }
}
closeGraph() {
// The page editor was unmounted meanwhile: remount it from the latest content, not what was first loaded.
if (this.doc) this.doc = { ...this.doc, blocks: this.#blocks, body: this.#raw ?? this.doc.body, version: ++this.#version };
this.view = 'page';
+ if (!this.#isNavigating && this.currentId) {
+ this.#pushNav({ id: this.currentId, view: 'page' });
+ }
}
setGraph(patch) {
this.graph = { ...this.graph, ...patch };
@@ -713,6 +1426,7 @@ class Store {
this.theme = t;
document.documentElement.dataset.theme = t;
if (persist) { this.themePref = t; this.#persist({ theme: t }); }
+ this.#rememberGround();
}
// Choose 'dark', 'light' or 'system' (follows the desktop). `origin`: element the theme spreads out from.
@@ -759,6 +1473,118 @@ class Store {
if (persist) this.#persist({ pageWidth: this.pageWidth });
}
+ static FONTS = {
+ sans: 'var(--f-ui)',
+ serif: "'Iowan Old Style','Palatino Linotype',Palatino,Georgia,'Noto Serif',serif",
+ mono: 'var(--f-mono)'
+ };
+ static SPACING = { compact: '1.5', default: '1.7', relaxed: '1.95' };
+
+ setPageFont(f, persist = true) {
+ this.pageFont = Store.FONTS[f] ? f : 'sans';
+ this.#applyFonts();
+ if (persist) this.#persist({ pageFont: this.pageFont });
+ }
+
+ setLineSpacing(v, persist = true) {
+ this.lineSpacing = Store.SPACING[v] ? v : 'default';
+ document.documentElement.style.setProperty('--lh', Store.SPACING[this.lineSpacing]);
+ if (persist) this.#persist({ lineSpacing: this.lineSpacing });
+ }
+
+ static PALETTES = ['default', 'midnight', 'nord', 'forest', 'sepia', 'catppuccin', 'catppuccin-dark', 'amoled'];
+
+ setPalette(name, persist = true) {
+ this.palette = Store.PALETTES.includes(name) ? name : 'default';
+ if (this.palette === 'default') document.documentElement.removeAttribute('data-palette');
+ else document.documentElement.dataset.palette = this.palette;
+ if (persist) { this.#persist({ palette: this.palette }); this.#rememberGround(); }
+ }
+
+ // The window's background before the page loads, so launch doesn't flash the wrong color.
+ #rememberGround() {
+ requestAnimationFrame(() => {
+ const bg = getComputedStyle(document.documentElement).getPropertyValue('--ground').trim();
+ if (bg) this.#persist({ ground: bg });
+ });
+ }
+
+ // Change one or more of the settings in prefs.js.
+ setPrefs(patch) {
+ this.prefs = cleanPrefs({ ...$state.snapshot(this.prefs), ...patch });
+ this.#applyPrefs();
+ this.#persist({ prefs: $state.snapshot(this.prefs) });
+ }
+
+ // Everything in Settings back to how it came (the notes folder and page contents are not touched).
+ resetSettings() {
+ this.setThemePref('dark');
+ this.setFontSize(Store.FONT.base);
+ this.setPageWidth('default');
+ this.setQuickLayout('vertical');
+ this.setPageFont('sans');
+ this.setLineSpacing('default');
+ this.setPalette('default');
+ this.setAccent('blush');
+ this.setReduceMotion(false);
+ this.setStartPage('last');
+ this.setGraph({ local: false, depth: 1, orphans: true, ghosts: false, folders: false, tags: false, spacing: 1 });
+ this.prefs = { ...DEFAULTS };
+ this.#applyPrefs();
+ this.#persist({ prefs: { ...DEFAULTS } });
+ }
+
+ // The prefs that are only CSS: variables and classes on the root element.
+ // Page and code fonts: the choice in Settings, with an installed font typed by name in front of it.
+ #applyFonts() {
+ const root = document.documentElement;
+ const clean = (t) => String(t || '').replace(/[^\p{L}\p{N} _.-]/gu, '').trim();
+ const page = clean(this.prefs?.customPageFont);
+ root.style.setProperty('--f-page', page ? `"${page}", ${Store.FONTS[this.pageFont]}` : Store.FONTS[this.pageFont]);
+ const code = clean(this.prefs?.customCodeFont);
+ if (code) root.style.setProperty('--f-mono', `"${code}", 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace`);
+ else root.style.removeProperty('--f-mono');
+ }
+
+ #applyPrefs() {
+ const p = this.prefs, root = document.documentElement;
+ this.#applyFonts();
+ const fonts = {
+ inter: "'Inter Variable','Inter',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif",
+ system: "system-ui,-apple-system,'Segoe UI',Roboto,'Noto Sans',sans-serif",
+ mono: "'JetBrains Mono',ui-monospace,'SF Mono',Menlo,Consolas,monospace"
+ };
+ root.style.setProperty('--f-ui', fonts[p.uiFont]);
+ root.style.setProperty('--side-w', p.sidebarWidth + 'px');
+ root.style.setProperty('--code-scale', String(p.codeSize / 100));
+ root.dataset.density = p.density;
+ root.classList.toggle('hide-cover', !p.showCover);
+ root.classList.toggle('hide-icon', !p.showIcon);
+ root.classList.toggle('hide-backlinks', !p.showBacklinks);
+ root.classList.toggle('code-wrap', p.codeWrap);
+ root.classList.toggle('typewriter', p.typewriter);
+ }
+
+ static ACCENTS = ['blush', 'amber', 'sage', 'sky', 'violet'];
+
+ setAccent(name, persist = true) {
+ this.accent = Store.ACCENTS.includes(name) ? name : 'blush';
+ if (this.accent === 'blush') document.documentElement.removeAttribute('data-accent');
+ else document.documentElement.dataset.accent = this.accent;
+ if (persist) this.#persist({ accent: this.accent });
+ }
+
+ setReduceMotion(on, persist = true) {
+ this.reduceMotion = !!on;
+ document.documentElement.classList.toggle('reduce-motion', this.reduceMotion);
+ if (persist) this.#persist({ reduceMotion: this.reduceMotion });
+ }
+
+ setStartPage(v) {
+ this.startPage = v === 'today' ? 'today' : 'last';
+ this.#persist({ startPage: this.startPage });
+ }
+
#persist(patch) {
Promise.resolve(api.setSettings(patch)).catch(() => {});
}
diff --git a/src/lib/tagColors.js b/src/lib/tagColors.js
new file mode 100644
index 0000000..882f067
--- /dev/null
+++ b/src/lib/tagColors.js
@@ -0,0 +1,7 @@
+// Colours a tag can have (kept by name, so the file stays valid if the set ever changes).
+export const TAG_COLORS = [
+ ['red', '#e5534b'], ['orange', '#e8883a'], ['yellow', '#d9b130'], ['green', '#44ad6e'],
+ ['teal', '#3aa6c4'], ['blue', '#5b8def'], ['purple', '#9a6de0'], ['pink', '#e06fa6']
+];
+const HEX = Object.fromEntries(TAG_COLORS);
+export const tagHex = (key) => HEX[key] || null;
diff --git a/src/lib/text.js b/src/lib/text.js
new file mode 100644
index 0000000..d9cb06e
--- /dev/null
+++ b/src/lib/text.js
@@ -0,0 +1,28 @@
+export const esc = (s) =>
+ String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
+
+export const unesc = (s) =>
+ s.replace(/ /g, ' ')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/&/g, '&');
+
+// Plain text of an HTML fragment without a DOM (works in tests too).
+export const htmlText = (h) => unesc(String(h || '').replace(/
/gi, '\n').replace(/<[^>]+>/g, ''));
+
+// Highlight every case-insensitive occurrence of q in s, HTML-escaped.
+export function mark(s, q) {
+ s = String(s || '');
+ if (!q) return esc(s);
+ const lo = s.toLowerCase();
+ let out = '', i = 0, j;
+ while ((j = lo.indexOf(q, i)) >= 0) {
+ out += esc(s.slice(i, j)) + '' + esc(s.slice(j, j + q.length)) + '';
+ i = j + q.length;
+ }
+ return out + esc(s.slice(i));
+}
+
+export const countWords = (t) => (String(t).trim().match(/\S+/g) || []).length;
diff --git a/src/lib/toast.svelte.js b/src/lib/toast.svelte.js
new file mode 100644
index 0000000..c2ffd01
--- /dev/null
+++ b/src/lib/toast.svelte.js
@@ -0,0 +1,20 @@
+export const toastState = $state({ msg: '', icon: 'check', show: false, action: null });
+
+let timer;
+// action: optional { label, run } shown as a button; the toast then stays up longer.
+export function toast(msg, icon = 'check', action = null) {
+ toastState.msg = msg;
+ toastState.icon = icon;
+ toastState.action = action;
+ toastState.show = true;
+ clearTimeout(timer);
+ timer = setTimeout(() => (toastState.show = false), action ? 6000 : 2200);
+}
+
+export function runToastAction() {
+ const a = toastState.action;
+ toastState.action = null;
+ toastState.show = false;
+ clearTimeout(timer);
+ if (a) a.run();
+}