feat(electron): main process, vault, search index, links, import, capture

Vault on plain .md files with FTS5 index, link rewriting, canvas files,
history/trash/backup, link previews, image fetch, tasks and unlinked
mentions, Notion/Obsidian importer, quick-capture window and shortcut.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Your NameandClaude Sonnet 5.5 committed 2026-10-15 11:57:09 +02:00
1 parent 0b5f8bfa8b
commit 313c41f867
16 files changed
+2706 -15

No files matched your search

+134
View File
@@ -0,0 +1,134 @@
'use strict';
// Backups: the notes folder as one .zip, written without any dependency (deflate from zlib, a small zip writer).
// Version history and the trash are left out (they are large and can be rebuilt or are meant to be gone),
// and so is the search index.
const fs = require('node:fs');
const fsp = require('node:fs/promises');
const path = require('node:path');
const zlib = require('node:zlib');
const SKIP = ['.notes/history', '.notes/trash', '.notes/index.sqlite', '.notes/index.sqlite-wal', '.notes/index.sqlite-shm'];
let table = null;
function crc32(buf) {
if (!table) {
table = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
}
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) c = table[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function dosTime(d) {
const year = Math.max(1980, d.getFullYear());
return {
time: (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1),
date: ((year - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()
};
}
async function* walk(root, rel = '') {
let entries;
try { entries = await fsp.readdir(path.join(root, rel), { withFileTypes: true }); } catch { return; }
for (const e of entries) {
const r = rel ? `${rel}/${e.name}` : e.name;
if (SKIP.includes(r)) continue;
if (e.isDirectory()) yield* walk(root, r);
else if (e.isFile()) yield r;
}
}
// Zip `root` into `out`. Returns the number of files.
async function zipFolder(root, out) {
await fsp.mkdir(path.dirname(out), { recursive: true });
const tmp = out + '.part';
const fd = await fsp.open(tmp, 'w');
const central = [];
let offset = 0, count = 0;
try {
for await (const rel of walk(root)) {
const file = path.join(root, rel);
let data, st;
try { data = await fsp.readFile(file); st = await fsp.stat(file); } catch { continue; } // changed while we ran
const packed = zlib.deflateRawSync(data);
const useDeflate = packed.length < data.length;
const body = useDeflate ? packed : data;
const name = Buffer.from(rel, 'utf8');
const { time, date } = dosTime(st.mtime);
const crc = crc32(data);
const head = Buffer.alloc(30);
head.writeUInt32LE(0x04034b50, 0);
head.writeUInt16LE(20, 4);
head.writeUInt16LE(0x0800, 6); // UTF-8 names
head.writeUInt16LE(useDeflate ? 8 : 0, 8);
head.writeUInt16LE(time, 10);
head.writeUInt16LE(date, 12);
head.writeUInt32LE(crc, 14);
head.writeUInt32LE(body.length, 18);
head.writeUInt32LE(data.length, 22);
head.writeUInt16LE(name.length, 26);
await fd.write(head);
await fd.write(name);
await fd.write(body);
central.push({ name, crc, size: data.length, packed: body.length, method: useDeflate ? 8 : 0, time, date, offset });
offset += 30 + name.length + body.length;
count++;
}
let size = 0;
for (const c of central) {
const h = Buffer.alloc(46);
h.writeUInt32LE(0x02014b50, 0);
h.writeUInt16LE(20, 4);
h.writeUInt16LE(20, 6);
h.writeUInt16LE(0x0800, 8);
h.writeUInt16LE(c.method, 10);
h.writeUInt16LE(c.time, 12);
h.writeUInt16LE(c.date, 14);
h.writeUInt32LE(c.crc, 16);
h.writeUInt32LE(c.packed, 20);
h.writeUInt32LE(c.size, 24);
h.writeUInt16LE(c.name.length, 28);
h.writeUInt32LE(c.offset, 42);
await fd.write(h);
await fd.write(c.name);
size += 46 + c.name.length;
}
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(count, 8);
end.writeUInt16LE(count, 10);
end.writeUInt32LE(size, 12);
end.writeUInt32LE(offset, 16);
await fd.write(end);
} finally {
await fd.close();
}
await fsp.rename(tmp, out);
return count;
}
const pad = (n) => String(n).padStart(2, '0');
function stamp(d = new Date()) {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}${pad(d.getMinutes())}`;
}
// Back up the folder into `dir` as "<folder name> <date time>.zip", then keep only the newest `keep` of this folder's.
async function backup(root, dir, keep) {
const base = path.basename(root);
const out = path.join(dir, `${base} ${stamp()}.zip`);
const count = await zipFolder(root, out);
const mine = (await fsp.readdir(dir)).filter((f) => f.startsWith(base + ' ') && /\d{4}-\d{2}-\d{2} \d{4}\.zip$/.test(f)).sort().reverse();
for (const old of mine.slice(Math.max(1, keep))) await fsp.unlink(path.join(dir, old)).catch(() => {});
return { file: out, count };
}
const EVERY = { daily: 20 * 3600e3, weekly: 6.5 * 24 * 3600e3 };
// Whether a backup is due: every = 'off' | 'daily' | 'weekly'; last = time of the previous one (ms) or 0.
const due = (every, last, now = Date.now()) => !!EVERY[every] && now - (last || 0) >= EVERY[every];
module.exports = { zipFolder, backup, due, crc32, stamp };
+91
View File
@@ -0,0 +1,91 @@
'use strict';
// Canvas files (".canvas") on disk, in the JSON Canvas format Obsidian uses (https://jsoncanvas.org):
// { "nodes": [{ id, type: text|file|link|group, x, y, width, height, text|file|url|label, color? }],
// "edges": [{ id, fromNode, fromSide?, toNode, toSide?, label?, color? }] }
// Written the way Obsidian writes it (one node per line, tabs), so files changed by both apps diff cleanly.
// The page-side twin is src/lib/canvas.js; keep parse/serialize in step.
const { rewriteLinks, renameHeadingRefs, resolveLink, matchesHeading, linkHeading } = require('./links.cjs');
const isCanvasId = (id) => /\.canvas$/i.test(String(id || ''));
// { nodes, edges, extra } from the file's text. Empty text is an empty canvas; anything unreadable throws.
function parseCanvas(text) {
const s = String(text || '').trim();
if (!s) return { nodes: [], edges: [], extra: {} };
const data = JSON.parse(s);
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('This is not a canvas file.');
const { nodes, edges, ...extra } = data;
return { nodes: Array.isArray(nodes) ? nodes : [], edges: Array.isArray(edges) ? edges : [], extra };
}
function serializeCanvas({ nodes = [], edges = [], extra = {} }) {
const list = (arr) => (arr.length ? '[\n' + arr.map((o) => '\t\t' + JSON.stringify(o)).join(',\n') + '\n\t]' : '[]');
const parts = [`\t"nodes":${list(nodes)}`, `\t"edges":${list(edges)}`];
for (const [k, v] of Object.entries(extra)) parts.push(`\t${JSON.stringify(k)}:${JSON.stringify(v)}`);
return '{\n' + parts.join(',\n') + '\n}';
}
const EMPTY_CANVAS = serializeCanvas({});
// A note's file node points at "Folder/Note.md"; its page id has no extension. Other files keep theirs.
const fileToId = (file) => String(file || '').replace(/\.md$/i, '');
const isNoteFile = (file) => /\.md$/i.test(String(file || ''));
const isCanvasFile = (file) => isCanvasId(file);
// The canvas as Markdown-ish text: what search indexes, and where backlinks and the graph find its links
// (a note or canvas on it counts as a [[link]]).
function canvasText(text) {
let c;
try { c = parseCanvas(text); } catch { return ''; }
const out = [];
for (const n of c.nodes) {
if (n.type === 'text' && n.text) out.push(String(n.text));
else if (n.type === 'group' && n.label) out.push(String(n.label));
else if (n.type === 'link' && n.url) out.push(String(n.url));
else if (n.type === 'file' && (isNoteFile(n.file) || isCanvasFile(n.file))) out.push(`[[${fileToId(n.file)}${n.subpath || ''}]]`);
}
for (const e of c.edges) if (e.label) out.push(String(e.label));
return out.join('\n\n');
}
// Pages moved or renamed (see main.cjs relink): point file nodes and [[links]] in text nodes at the new places.
// Returns the new file text, or null when nothing changed.
function rewriteCanvas(text, before, after, moved) {
let c;
try { c = parseCanvas(text); } catch { return null; }
let changed = false;
for (const n of c.nodes) {
if (n.type === 'file' && n.file) {
const id = fileToId(n.file);
const now = moved(id);
if (now && now !== id) { n.file = isNoteFile(n.file) ? now + '.md' : now; changed = true; }
} else if (n.type === 'text' && n.text) {
const t = rewriteLinks(n.text, before, after, moved);
if (t !== null) { n.text = t; changed = true; }
}
}
return changed ? serializeCanvas(c) : null;
}
// A heading of page `pageId` was renamed: notes shown at that heading ("subpath") and [[Page#Heading]] links in
// text nodes follow. Returns { body, count } or null.
function renameHeadingInCanvas(text, pages, pageId, from, to) {
let c;
try { c = parseCanvas(text); } catch { return null; }
const safe = linkHeading(to);
if (!safe) return null;
let count = 0;
for (const n of c.nodes) {
if (n.type === 'file' && isNoteFile(n.file) && n.subpath && resolveLink(fileToId(n.file), pages) === pageId && matchesHeading(String(n.subpath).replace(/^#/, ''), from)) {
n.subpath = '#' + safe;
count++;
} else if (n.type === 'text' && n.text) {
const r = renameHeadingRefs(n.text, pages, pageId, from, to);
if (r) { n.text = r.body; count += r.count; }
}
}
return count ? { body: serializeCanvas(c), count } : null;
}
module.exports = { isCanvasId, parseCanvas, serializeCanvas, EMPTY_CANVAS, canvasText, rewriteCanvas, renameHeadingInCanvas, fileToId };
+9
View File
@@ -0,0 +1,9 @@
'use strict';
// The quick-capture window's only bridge (electron/capture.html).
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('capture', {
info: () => ipcRenderer.invoke('capture:info'),
save: (text, to) => ipcRenderer.invoke('capture:save', text, to),
close: () => ipcRenderer.send('capture:close')
});
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'" />
<title>Quick capture</title>
<style>
:root { --bg:#161617; --raise:#222224; --line:rgba(255,255,255,.13); --ink:#ececed; --ink-2:#a1a1a6; --ink-3:#6c6c71; --blush:#e4ada5; --blush-ink:#2a1714; color-scheme:dark; }
:root[data-theme="light"] { --bg:#fbfbfb; --raise:#f3f3f4; --line:rgba(0,0,0,.14); --ink:#1c1c1e; --ink-2:#5c5c61; --ink-3:#9a9a9f; --blush:#b8695f; --blush-ink:#fff8f5; color-scheme:light; }
* { box-sizing:border-box; }
html, body { height:100%; margin:0; }
body { background:var(--bg); color:var(--ink); font:14px/1.5 'Inter Variable', Inter, ui-sans-serif, system-ui, sans-serif; -webkit-font-smoothing:antialiased; display:flex; flex-direction:column; padding:14px; gap:10px; border:1px solid var(--line); border-radius:10px; }
textarea { flex:1; resize:none; border:0; outline:0; background:transparent; color:inherit; font:inherit; font-size:15px; }
textarea::placeholder { color:var(--ink-3); }
.bar { display:flex; align-items:center; gap:8px; color:var(--ink-3); font-size:12px; }
.bar span { flex:1; }
.seg { display:flex; background:var(--raise); border-radius:7px; padding:2px; }
.seg button { border:0; background:none; color:var(--ink-2); font:inherit; font-size:12px; padding:3px 10px; border-radius:5px; cursor:pointer; }
.seg button.on { background:var(--bg); color:var(--ink); box-shadow:0 0 0 1px var(--line); }
.go { border:0; background:var(--blush); color:var(--blush-ink); font:inherit; font-size:12.5px; font-weight:600; padding:5px 12px; border-radius:7px; cursor:pointer; }
kbd { font:inherit; color:var(--ink-2); }
</style>
</head>
<body>
<textarea id="t" placeholder="Capture a thought…" spellcheck="true" autofocus></textarea>
<div class="bar">
<div class="seg" role="radiogroup" aria-label="Where it goes">
<button data-to="daily" role="radio">Today's note</button>
<button data-to="inbox" role="radio">Inbox</button>
</div>
<span><kbd>Enter</kbd> save · <kbd>Shift</kbd>+<kbd>Enter</kbd> new line · <kbd>Esc</kbd> close</span>
<button class="go" id="go">Save</button>
</div>
<script>
let to = 'daily';
const t = document.getElementById('t');
const mark = () => document.querySelectorAll('.seg button').forEach((b) => { b.classList.toggle('on', b.dataset.to === to); b.setAttribute('aria-checked', String(b.dataset.to === to)); });
document.querySelectorAll('.seg button').forEach((b) => b.addEventListener('click', () => { to = b.dataset.to; mark(); t.focus(); }));
async function save() {
if (!t.value.trim()) return window.capture.close();
await window.capture.save(t.value, to);
}
document.getElementById('go').addEventListener('click', save);
t.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); save(); }
if (e.key === 'Escape') { e.preventDefault(); window.capture.close(); }
});
window.capture.info().then((i) => { document.documentElement.dataset.theme = i.theme; to = i.to; mark(); t.focus(); });
mark();
</script>
</body>
</html>
+100 -15
View File
@@ -12,51 +12,136 @@ function loadDriver() {
const likeEscape = (s) => s.replace(/[\\%_]/g, (c) => '\\' + c);
// A page's own front matter keys; any other key is a database property (src/lib/database.js).
const NOT_PROPS = new Set(['icon', 'cover', 'tags', 'created', 'ink-database', 'kanban-plugin']);
// meta (or a page's `props`) -> [[key, value]], one pair per list item, values trimmed, empty ones dropped
function propPairs(props) {
const out = [];
for (const [k, v] of Object.entries(props || {})) {
if (NOT_PROPS.has(k)) continue;
for (const x of Array.isArray(v) ? v : [v]) {
const t = String(x ?? '').trim();
if (t) out.push([k.toLowerCase(), t]);
}
}
return out;
}
function openDb(file) {
const Database = loadDriver();
const db = new Database(file);
// An index made before properties were indexed has no `props` column: it's only a cache, so start it again.
try {
const cols = db.prepare('PRAGMA table_info(notes)').all().map((c) => c.name);
if (cols.length && !cols.includes('props')) db.exec('DROP TABLE notes');
} catch { /* no table yet */ }
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS notes USING fts5(
id UNINDEXED, title, tags, body, tokenize = 'unicode61 remove_diacritics 2'
id UNINDEXED, title, tags, body, props, tokenize = 'unicode61 remove_diacritics 2'
)`);
// property values as rows, for key:value searches
db.exec(`CREATE TABLE IF NOT EXISTS props (id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL)`);
db.exec('CREATE INDEX IF NOT EXISTS props_key ON props (key, value)');
db.exec('CREATE INDEX IF NOT EXISTS props_id ON props (id)');
const del = db.prepare('DELETE FROM notes WHERE id = ?');
const delTree = db.prepare("DELETE FROM notes WHERE id = ? OR id LIKE ? ESCAPE '\\'");
const ins = db.prepare('INSERT INTO notes (id, title, tags, body) VALUES (?, ?, ?, ?)');
const find = db.prepare(`SELECT id, title, snippet(notes, 3, char(1), char(2), '…', 14) AS snip
FROM notes WHERE notes MATCH ? ORDER BY bm25(notes, 0.0, 8.0, 4.0, 1.0) LIMIT ?`);
const row = (p) => [p.id, p.title || '', (p.tags || []).join(' '), p.body || ''];
const ins = db.prepare('INSERT INTO notes (id, title, tags, body, props) VALUES (?, ?, ?, ?, ?)');
const find = db.prepare(`SELECT id, title, snippet(notes, -1, char(1), char(2), '…', 14) AS snip
FROM notes WHERE notes MATCH ? ORDER BY bm25(notes, 0.0, 8.0, 4.0, 1.0, 3.0) LIMIT ?`);
const delProps = db.prepare('DELETE FROM props WHERE id = ?');
const delPropsTree = db.prepare("DELETE FROM props WHERE id = ? OR id LIKE ? ESCAPE '\\'");
const insProp = db.prepare('INSERT INTO props (id, key, value) VALUES (?, ?, ?)');
const hasKey = db.prepare('SELECT 1 FROM props WHERE key = ? LIMIT 1');
const propsOfId = db.prepare('SELECT key, value FROM props WHERE id = ? LIMIT 6');
const titleOf = db.prepare('SELECT title FROM notes WHERE id = ?');
const row = (p) => {
const pairs = propPairs(p.props);
return [p.id, p.title || '', (p.tags || []).join(' '), p.body || '', pairs.map(([, v]) => v).join(' ')];
};
const putProps = (p) => { for (const [k, v] of propPairs(p.props)) insProp.run(p.id, k, v.toLowerCase()); };
// ids of pages that satisfy a `key:value` filter ({ key, op, value }); ops: '' (equals or starts with), '!', '>', '<', '>=', '<='
const idsFor = (f) => {
const num = f.op === '>' || f.op === '<' || f.op === '>=' || f.op === '<=';
if (num) {
const n = Number(f.value);
if (!Number.isFinite(n)) return new Set();
const sql = `SELECT DISTINCT id FROM props WHERE key = ? AND value GLOB '*[0-9]*' AND CAST(value AS REAL) ${f.op} ?`;
return new Set(db.prepare(sql).all(f.key, n).map((r) => r.id));
}
const v = f.value.toLowerCase();
const sql = "SELECT DISTINCT id FROM props WHERE key = ? AND (value = ? OR value LIKE ? ESCAPE '\\')";
return new Set(db.prepare(sql).all(f.key, v, likeEscape(v) + '%').map((r) => r.id));
};
return {
upsert(p) { del.run(p.id); ins.run(...row(p)); },
remove(id) { delTree.run(id, likeEscape(id) + '/%'); },
upsert(p) { del.run(p.id); delProps.run(p.id); ins.run(...row(p)); putProps(p); },
remove(id) { delTree.run(id, likeEscape(id) + '/%'); delPropsTree.run(id, likeEscape(id) + '/%'); },
rebuild(pages) {
db.exec('BEGIN');
try {
db.exec('DELETE FROM notes');
for (const p of pages) if (p.exists) ins.run(...row(p));
db.exec('DELETE FROM props');
for (const p of pages) if (p.exists) { ins.run(...row(p)); putProps(p); }
db.exec('COMMIT');
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
},
// Words, `tag:x`, `in:title`, and `property:value` for a database property (`status:Done`, `due:2026-03`,
// `points:>=5`, `owner:!Ana`, `status:"In progress"`, `has:due`). Property filters narrow what the words find;
// with only filters, every matching page is listed.
search(q, limit = 20) {
const parts = String(q).match(/(?:tag:|in:|)\S+/g) || [];
const parts = String(q).match(/[^\s:"]+:"[^"]*"|"[^"]*"|\S+/g) || [];
const terms = [];
const fields = [];
const filters = [];
for (const p of parts) {
if (p.startsWith('tag:')) { fields.push('tags: ' + p.slice(4).replace(/[^\w-]/g, '')); }
else if (p.startsWith('in:')) { fields.push('title: ' + p.slice(3).replace(/[^\w-]/g, '')); }
const m = p.match(/^([^\s:"]+):(.*)$/);
const key = m ? m[1].toLowerCase() : '';
const val = m ? m[2].replace(/^"|"$/g, '').trim() : '';
if (key === 'tag') { fields.push('tags: ' + val.replace(/[^\w-]/g, '')); }
else if (key === 'in') { fields.push('title: ' + val.replace(/[^\w-]/g, '')); }
else if (key === 'has' && val && hasKey.get(val.toLowerCase())) { filters.push({ key: val.toLowerCase(), has: true }); }
else if (m && val && hasKey.get(key)) {
const o = val.match(/^(>=|<=|>|<|!|=)\s*(.*)$/);
filters.push({ key, op: o ? (o[1] === '=' ? '' : o[1]) : '', value: o ? o[2] : val, not: o && o[1] === '!' });
}
else { terms.push(p); }
}
const words = (terms.join(' ').match(/[\p{L}\p{N}_]+/gu) || []).slice(0, 8);
let match = words.map((t) => `"${t}"*`).join(' ');
if (fields.length) {
match = fields.map(f => `${f.split(':')[0]}: "${f.split(':')[1].trim()}"*`).join(' ') + (match ? ' ' + match : '');
}
if (!match) return [];
if (!match && !filters.length) return [];
// property filters -> the ids that pass all of them
let allowed = null;
for (const f of filters) {
let ids;
if (f.has) ids = new Set(db.prepare('SELECT DISTINCT id FROM props WHERE key = ?').all(f.key).map((r) => r.id));
else if (f.not) {
// everything that has properties at all (the records), except those with that value
const hit = idsFor({ ...f, op: '' });
ids = new Set(db.prepare('SELECT DISTINCT id FROM props').all().map((r) => r.id).filter((id) => !hit.has(id)));
} else ids = idsFor(f);
allowed = allowed ? new Set([...allowed].filter((id) => ids.has(id))) : ids;
}
try {
return find.all(match, limit).map((r) => ({ id: r.id, title: r.title, snip: r.snip }));
if (match) {
const rows = find.all(match, allowed ? 1000 : limit).map((r) => ({ id: r.id, title: r.title, snip: r.snip }));
return (allowed ? rows.filter((r) => allowed.has(r.id)) : rows).slice(0, limit);
}
// only property filters: list the matching pages, showing the values that matched
const out = [];
for (const id of allowed) {
const t = titleOf.get(id);
if (!t) continue;
const shown = propsOfId.all(id).map((r) => `${r.key}: ${r.value}`).join(' · ');
out.push({ id, title: t.title, snip: shown });
}
return out.sort((a, b) => a.title.localeCompare(b.title)).slice(0, limit);
} catch {
return [];
}
@@ -65,4 +150,4 @@ function openDb(file) {
};
}
module.exports = { openDb };
module.exports = { openDb, propPairs };
+68
View File
@@ -0,0 +1,68 @@
'use strict';
// Minimal YAML front matter: flat `key: value`, inline `[a, b]` lists and `- item` lists.
// Unknown keys are kept and written back, so notes from Obsidian survive a round trip.
const FM = /^?---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
function unquote(v) {
v = v.trim();
if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) {
try { return JSON.parse(v); } catch { return v.slice(1, -1); }
}
if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) return v.slice(1, -1).replace(/''/g, "'");
return v;
}
function parseFrontMatter(raw) {
const m = raw.match(FM);
if (!m) return { meta: {}, body: raw };
const meta = {};
let last = null;
for (const line of m[1].split(/\r?\n/)) {
const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
if (kv) {
last = kv[1];
const v = kv[2].trim();
if (v.startsWith('[') && v.endsWith(']')) {
meta[last] = v.slice(1, -1).split(',').map(unquote).filter(Boolean);
} else {
meta[last] = v === '' ? [] : unquote(v);
}
continue;
}
const item = line.match(/^\s*-\s+(.*)$/);
if (item && last) {
if (!Array.isArray(meta[last])) meta[last] = [];
meta[last].push(unquote(item[1]));
}
}
// An empty `key:` with no list items below it is just an empty string.
for (const k of Object.keys(meta)) if (Array.isArray(meta[k]) && !meta[k].length && k !== 'tags') meta[k] = '';
return { meta, body: raw.slice(m[0].length).replace(/^\r?\n/, '') };
}
function quote(s) {
s = String(s);
return /^[\p{L}\p{N}_ .\/@#+-]+$/u.test(s) && s.trim() === s && !/^[-#@]/.test(s) ? s : JSON.stringify(s);
}
const ORDER = ['icon', 'cover', 'tags', 'created'];
function serializeFrontMatter(meta = {}) {
const keys = Object.keys(meta).filter((k) => {
const v = meta[k];
return v !== undefined && v !== null && v !== '' && !(Array.isArray(v) && !v.length);
});
if (!keys.length) return '';
keys.sort((a, b) => {
const ia = ORDER.indexOf(a), ib = ORDER.indexOf(b);
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib);
});
const lines = keys.map((k) => {
const v = meta[k];
return Array.isArray(v) ? `${k}: [${v.map(quote).join(', ')}]` : `${k}: ${quote(v)}`;
});
return `---\n${lines.join('\n')}\n---\n\n`;
}
module.exports = { parseFrontMatter, serializeFrontMatter };
+79
View File
@@ -0,0 +1,79 @@
'use strict';
// Download a picture from a web address (for a page's cover). Runs in the main process, and has the same rule as
// link previews: only public http(s) addresses, every redirect checked again (see linkpreview.cjs).
const path = require('node:path');
const { allowed, hooks } = require('./linkpreview.cjs');
const MAX_BYTES = 50 * 1024 * 1024;
const TIMEOUT = 25000;
const MAX_REDIRECTS = 5;
const EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'image/avif': 'avif', 'image/svg+xml': 'svg', 'image/bmp': 'bmp' };
const KNOWN = /\.(png|jpe?g|gif|webp|avif|svg|bmp)$/i;
// A file name for it: the address's own (when it looks like one), else "cover" with the right extension.
function nameFor(u, type) {
let base = '';
try { base = decodeURIComponent(path.posix.basename(u.pathname)); } catch { base = path.posix.basename(u.pathname); }
base = base.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '-').trim().slice(0, 80);
if (KNOWN.test(base)) return base;
const ext = EXT[type] || 'jpg';
return `${base && !base.startsWith('.') ? base.replace(/\.[a-z0-9]{1,5}$/i, '') : 'cover'}.${ext}`;
}
async function readLimited(res, max) {
const chunks = [];
let size = 0;
const reader = res.body.getReader();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.length;
if (size > max) throw new Error('That image is too big (50 MB at most).');
chunks.push(Buffer.from(value));
}
} finally {
reader.cancel().catch(() => {});
}
return Buffer.concat(chunks);
}
// -> { bytes: Buffer, name, type }. Throws an Error whose message is meant for the person.
async function fetchImage(url, { fetch: doFetch, lookup } = {}) {
const get = doFetch || hooks.fetch || globalThis.fetch;
const look = lookup || hooks.lookup;
const signal = AbortSignal.timeout(TIMEOUT);
let u = await allowed(String(url || '').trim(), look);
if (!u) throw new Error('That is not a public web address (http or https).');
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
let res;
try {
res = await get(u.href, {
redirect: 'manual',
signal,
headers: { 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36', accept: 'image/*,*/*;q=0.1' }
});
} catch {
throw new Error('Could not reach that address.');
}
if (res.status >= 300 && res.status < 400) {
const loc = res.headers.get('location');
const next = loc ? await allowed(new URL(loc, u).href, look) : null;
if (!next) throw new Error('That address redirects somewhere that is not allowed.');
u = next;
continue;
}
if (!res.ok) throw new Error(`The server answered ${res.status}.`);
const type = String(res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
if (!type.startsWith('image/')) throw new Error('That address is not a picture.');
const len = Number(res.headers.get('content-length'));
if (len > MAX_BYTES) throw new Error('That image is too big (50 MB at most).');
if (!res.body) throw new Error('That address sent nothing.');
const bytes = await readLimited(res, MAX_BYTES);
if (!bytes.length) throw new Error('That address sent nothing.');
return { bytes, name: nameFor(u, type), type };
}
throw new Error('Too many redirects.');
}
module.exports = { fetchImage, nameFor };
+148
View File
@@ -0,0 +1,148 @@
'use strict';
// Bringing notes in from another app: an Obsidian vault or a Notion "Markdown & CSV" export, as a folder or a .zip.
// Everything goes into one new top-level folder of the notes, so nothing existing is touched.
// - Notion's 32-character ids are taken out of file and folder names, and its relative Markdown links
// ([Title](Folder/Page%20abc….md)) become [[wiki links]] to the imported pages.
// - Obsidian's .obsidian folder and Notion's _all.csv files are left out; other files (pictures, PDFs) are copied.
const fs = require('node:fs');
const fsp = fs.promises;
const path = require('node:path');
const zlib = require('node:zlib');
const SKIP_DIR = /^(\.obsidian|\.trash|\.git|\.notes|__MACOSX|node_modules)$/;
const MAX_FILES = 50000;
const MAX_FILE = 200 * 1024 * 1024;
/* zip reading: central directory, stored and deflated entries */
function readZip(file) {
const buf = fs.readFileSync(file);
let end = -1;
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--) if (buf.readUInt32LE(i) === 0x06054b50) { end = i; break; }
if (end < 0) throw new Error('That file is not a zip archive.');
const count = buf.readUInt16LE(end + 10);
let at = buf.readUInt32LE(end + 16);
const out = [];
for (let n = 0; n < count && n < MAX_FILES; n++) {
if (buf.readUInt32LE(at) !== 0x02014b50) break;
const method = buf.readUInt16LE(at + 10);
const csize = buf.readUInt32LE(at + 20);
const size = buf.readUInt32LE(at + 24);
const nameLen = buf.readUInt16LE(at + 28), extraLen = buf.readUInt16LE(at + 30), commentLen = buf.readUInt16LE(at + 32);
const local = buf.readUInt32LE(at + 42);
const name = buf.toString('utf8', at + 46, at + 46 + nameLen);
at += 46 + nameLen + extraLen + commentLen;
if (name.endsWith('/') || size > MAX_FILE) continue;
const dataAt = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
out.push({
rel: name,
read: () => {
const raw = buf.subarray(dataAt, dataAt + csize);
return method === 0 ? raw : zlib.inflateRawSync(raw);
}
});
}
return out;
}
async function* walkDir(root, rel = '') {
let entries;
try { entries = await fsp.readdir(path.join(root, rel), { withFileTypes: true }); } catch { return; }
for (const e of entries) {
if (e.isSymbolicLink()) continue;
const r = rel ? `${rel}/${e.name}` : e.name;
if (e.isDirectory()) { if (!SKIP_DIR.test(e.name)) yield* walkDir(root, r); }
else if (e.isFile()) yield r;
}
}
// "Page 0a1b2c3d4e5f60718293a4b5c6d7e8f9.md" -> "Page.md"; a plain name is left alone.
const NOTION_ID = /\s+[0-9a-f]{32}(?=\.[^.]+$|$)/i;
const stripId = (seg) => seg.replace(NOTION_ID, '');
const badChars = /[<>:"|?*\u0000-\u001f]/g;
const safeSeg = (s) => s.replace(badChars, '-').replace(/^\.+/, '').trim() || 'Untitled';
// Where each source path ends up: { 'a/b abc.md': 'a/b.md' }, names made unique.
function plan(files) {
const map = new Map();
const taken = new Set();
for (const rel of files.slice().sort()) {
const parts = rel.split('/').map((p) => safeSeg(stripId(p)));
let target = parts.join('/');
const ext = path.posix.extname(target);
const stem = target.slice(0, target.length - ext.length);
for (let n = 2; taken.has(target.toLowerCase()); n++) target = `${stem} ${n}${ext}`;
taken.add(target.toLowerCase());
map.set(rel, target);
}
return map;
}
// Rewrite relative links in one note: to other notes -> [[wiki links]], to files -> the new relative path.
function rewriteBody(body, fromRel, map, rootName) {
const dir = path.posix.dirname(fromRel);
const resolve = (href) => {
let h = href.split('#')[0];
try { h = decodeURIComponent(h); } catch { /* leave as written */ }
if (!h || /^[a-z][a-z0-9+.-]*:/i.test(h) || h.startsWith('/') || h.startsWith('#')) return null;
const joined = path.posix.normalize(dir === '.' ? h : `${dir}/${h}`);
return joined.startsWith('..') ? null : joined;
};
return body.replace(/(!?)\[([^\]\n]*)\]\(([^)\n]+)\)/g, (all, bang, text, href) => {
const target = resolve(href.trim().replace(/^<|>$/g, ''));
if (!target || !map.has(target)) return all;
const to = map.get(target);
if (!bang && /\.md$/i.test(to)) {
const id = `${rootName}/${to.replace(/\.md$/i, '')}`;
const title = id.split('/').pop();
return text && text !== title ? `[[${id}|${text}]]` : `[[${id}]]`;
}
// a picture or file: its path from this note's new folder
const fromDir = path.posix.dirname(map.get(fromRel));
const rel = path.posix.relative(fromDir === '.' ? '' : fromDir, to);
return `${bang}[${text}](${encodeURI(rel).replace(/\(/g, '%28').replace(/\)/g, '%29')})`;
});
}
// files: [{ rel, read() -> Buffer }]. Writes them under <vaultRoot>/<name>. Returns { folder, notes, files }.
async function importFiles(vaultRoot, name, files) {
const kept = files.filter((f) => !f.rel.split('/').some((p) => SKIP_DIR.test(p)) && !/_all\.csv$/i.test(f.rel) && !/(^|\/)\.DS_Store$/.test(f.rel));
// a zip often has one folder around everything: look inside it
const first = new Set(kept.map((f) => f.rel.split('/')[0]));
const strip = first.size === 1 && kept.every((f) => f.rel.includes('/')) ? [...first][0] + '/' : '';
const items = kept.map((f) => ({ ...f, rel: f.rel.slice(strip.length) })).filter((f) => f.rel);
if (!items.length) throw new Error('There was nothing to import in that.');
const map = plan(items.map((f) => f.rel));
let folder = safeSeg(name);
for (let n = 2; fs.existsSync(path.join(vaultRoot, folder)) || fs.existsSync(path.join(vaultRoot, folder + '.md')); n++) folder = `${safeSeg(name)} ${n}`;
let notes = 0, others = 0;
for (const f of items) {
const to = path.join(vaultRoot, folder, ...map.get(f.rel).split('/'));
await fsp.mkdir(path.dirname(to), { recursive: true });
let data = f.read();
if (/\.md$/i.test(f.rel)) {
data = Buffer.from(rewriteBody(data.toString('utf8'), f.rel, map, folder), 'utf8');
notes++;
} else others++;
await fsp.writeFile(to, data);
}
return { folder, notes, files: others };
}
async function importFolder(vaultRoot, dir) {
const files = [];
for await (const rel of walkDir(dir)) {
if (files.length >= MAX_FILES) break;
const full = path.join(dir, rel);
files.push({ rel, read: () => fs.readFileSync(full) });
}
return importFiles(vaultRoot, path.basename(dir), files);
}
async function importZip(vaultRoot, file) {
const entries = readZip(file).filter((e) => !e.rel.includes('..'));
return importFiles(vaultRoot, path.basename(file).replace(/\.zip$/i, ''), entries);
}
module.exports = { importFolder, importZip, importFiles, readZip, stripId, plan, rewriteBody };
+166
View File
@@ -0,0 +1,166 @@
'use strict';
// What a web link card shows: the page's title and description, read from the start of its HTML.
// Runs in the main process (no CORS there). Only public http(s) addresses are fetched: a canvas file that
// someone else made must not be able to make this app read an address on your own network.
const dns = require('node:dns/promises');
const net = require('node:net');
const MAX_BYTES = 512 * 1024; // of HTML read; the tags we want are at the top
const TIMEOUT = 8000;
const MAX_REDIRECTS = 5;
// Replaceable for tests: { fetch, lookup }
const hooks = { fetch: null, lookup: null };
// Addresses that are not on the public internet (this machine, the local network, link-local, multicast, …).
const PRIVATE = new net.BlockList();
for (const [ip, bits] of [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12],
['192.0.0.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['224.0.0.0', 3]]) PRIVATE.addSubnet(ip, bits, 'ipv4');
for (const [ip, bits] of [['::', 128], ['::1', 128], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8]]) PRIVATE.addSubnet(ip, bits, 'ipv6');
function privateAddress(ip) {
const v = net.isIP(ip);
if (!v) return true;
const x = ip.toLowerCase();
if (v === 6) {
// IPv4 inside IPv6 (::ffff:a.b.c.d, which URL parsing turns into ::ffff:aabb:ccdd, or ::a.b.c.d): judge the IPv4 part
const dotted = x.match(/^::(?:ffff:)?(\d+\.\d+\.\d+\.\d+)$/);
const hex = x.match(/^::(?:ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
let v4 = dotted ? dotted[1] : null;
if (hex) {
const hi = parseInt(hex[1], 16), lo = parseInt(hex[2], 16);
v4 = `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
}
if (v4 && PRIVATE.check(v4, 'ipv4')) return true;
}
return PRIVATE.check(x, v === 4 ? 'ipv4' : 'ipv6');
}
// The URL if it may be fetched, else null.
async function allowed(raw, lookup) {
let u;
try { u = new URL(raw); } catch { return null; }
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
if (u.username || u.password) return null;
const host = u.hostname.replace(/^\[|\]$/g, '');
if (!host) return null;
if (net.isIP(host)) return privateAddress(host) ? null : u;
if (/(^|\.)localhost$/i.test(host)) return null;
try {
const addrs = await (lookup || ((h) => dns.lookup(h, { all: true })))(host);
if (!addrs.length || addrs.some((a) => privateAddress(a.address))) return null;
} catch { return null; }
return u;
}
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', ndash: '–', mdash: '—', hellip: '…', rsquo: '’', lsquo: '‘', rdquo: '”', ldquo: '“', copy: '©' };
const decode = (s) =>
s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
if (e[0] === '#') {
const n = e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
try { return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : m; } catch { return m; }
}
return ENTITIES[e.toLowerCase()] ?? m;
});
const tidy = (s, max) => {
const t = decode(String(s || '')).replace(/\s+/g, ' ').trim();
return t.length > max ? t.slice(0, max - 1).trimEnd() + '…' : t;
};
// { title, description, site } from a page's HTML (empty strings where it says nothing)
function parsePreview(html) {
const head = String(html).slice(0, MAX_BYTES);
const meta = {};
for (const tag of head.match(/<meta\b[^>]*>/gi) || []) {
const at = {};
for (const m of tag.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g)) at[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? '';
const key = (at.property || at.name || '').toLowerCase();
if (key && at.content !== undefined && !(key in meta)) meta[key] = at.content;
}
const t = head.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i);
return {
title: tidy(meta['og:title'] || meta['twitter:title'] || (t ? t[1] : ''), 200),
description: tidy(meta['og:description'] || meta['twitter:description'] || meta.description, 400),
site: tidy(meta['og:site_name'], 80)
};
}
function charsetOf(contentType, bytes) {
const h = /charset\s*=\s*["']?([\w-]+)/i.exec(contentType || '');
if (h) return h[1];
const m = /<meta[^>]+charset\s*=\s*["']?([\w-]+)/i.exec(Buffer.from(bytes.subarray(0, 4096)).toString('latin1'));
return m ? m[1] : 'utf-8';
}
async function readLimited(res, limit = MAX_BYTES) {
const chunks = [];
let size = 0;
const reader = res.body.getReader();
try {
while (size < limit) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
size += value.length;
}
} finally {
reader.cancel().catch(() => {});
}
return Buffer.concat(chunks.map((c) => Buffer.from(c))).subarray(0, limit);
}
// { title, description, site } for a web address, or null when there is nothing to show.
async function fetchPreview(url, { fetch: doFetch, lookup } = {}) {
const get = doFetch || hooks.fetch || globalThis.fetch;
const look = lookup || hooks.lookup;
const signal = AbortSignal.timeout(TIMEOUT);
let u = await allowed(url, look);
for (let hop = 0; u && hop <= MAX_REDIRECTS; hop++) {
let res;
try {
res = await get(u.href, {
redirect: 'manual',
signal,
headers: { 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36', accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.1', 'accept-language': 'en;q=0.8' }
});
} catch { return null; }
if (res.status >= 300 && res.status < 400) {
const loc = res.headers.get('location');
u = loc ? await allowed(new URL(loc, u).href, look) : null; // every hop is checked again
continue;
}
if (!res.ok || !/html/i.test(res.headers.get('content-type') || '') || !res.body) return null;
const bytes = await readLimited(res);
let text;
try { text = new TextDecoder(charsetOf(res.headers.get('content-type'), bytes)).decode(bytes); } catch { text = bytes.toString('utf8'); }
const p = parsePreview(text);
return p.title || p.description ? p : null;
}
return null;
}
// { html, url } for a whole web page (up to 3 MB), under the same rules as previews; null if it can't be had.
async function fetchHtml(url, { fetch: doFetch, lookup } = {}) {
const get = doFetch || hooks.fetch || globalThis.fetch;
const look = lookup || hooks.lookup;
const signal = AbortSignal.timeout(15000);
let u = await allowed(url, look);
for (let hop = 0; u && hop <= MAX_REDIRECTS; hop++) {
let res;
try {
res = await get(u.href, { redirect: 'manual', signal, headers: { 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36', accept: 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.1' } });
} catch { return null; }
if (res.status >= 300 && res.status < 400) {
const loc = res.headers.get('location');
u = loc ? await allowed(new URL(loc, u).href, look) : null;
continue;
}
if (!res.ok || !/html/i.test(res.headers.get('content-type') || '') || !res.body) return null;
const bytes = await readLimited(res, 3 * 1024 * 1024);
let text;
try { text = new TextDecoder(charsetOf(res.headers.get('content-type'), bytes)).decode(bytes); } catch { text = bytes.toString('utf8'); }
return { html: text, url: u.href };
}
return null;
}
module.exports = { fetchHtml, fetchPreview, parsePreview, allowed, hooks };
+290
View File
@@ -0,0 +1,290 @@
'use strict';
// [[Wiki links]] on disk: find them, resolve them, rewrite them after a rename or move,
// and list backlinks. Lookup rules match src/lib/links.js.
const refPage = (ref) => String(ref || '').split('#')[0].replace(/\.md$/i, '').trim();
// Lookups over a page list, built once per list (a vault of thousands of pages is searched for every link).
const indexes = new WeakMap();
function indexOf(pages) {
let ix = indexes.get(pages);
if (ix && ix.size === pages.length) return ix;
const exact = new Map();
const byName = new Map();
for (const p of pages) {
const id = p.id.toLowerCase();
if (!exact.has(id)) exact.set(id, p.id);
const name = id.slice(id.lastIndexOf('/') + 1);
const list = byName.get(name);
if (list) list.push(p); else byName.set(name, [p]);
}
// by name: a note's title, or a canvas's file name ("Map.canvas"), so [[Map]] never means a canvas; shortest path wins
for (const list of byName.values()) if (list.length > 1) list.sort((a, b) => a.id.length - b.id.length);
ix = { size: pages.length, exact, byName };
indexes.set(pages, ix);
return ix;
}
function resolveLink(ref, pages) {
const want = refPage(ref).toLowerCase();
if (!want) return null;
const ix = indexOf(pages);
const hit = ix.exact.get(want);
if (hit) return hit;
const named = ix.byName.get(want);
return named ? named[0].id : null;
}
const normHeading = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase();
const linkHeading = (s) => String(s || '').replace(/[#|^\[\]]/g, ' ').replace(/\s+/g, ' ').trim();
const matchesHeading = (linkText, headingText) => {
const a = normHeading(linkText);
return !!a && (a === normHeading(headingText) || a === normHeading(linkHeading(headingText)));
};
// The heading a link points at ('' if none): the last #part, and #^blockid isn't a heading.
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;
};
function refFor(id, pages) {
const title = id.slice(id.lastIndexOf('/') + 1);
const same = indexOf(pages).byName.get(title.toLowerCase()) || [];
return same.length <= 1 ? title : id;
}
// Blank out code (fenced and inline) so links inside it are left alone; offsets stay the same.
function maskCode(body) {
const blank = (s) => s.replace(/[^\n]/g, ' ');
let fence = false;
return body
.split('\n')
.map((line) => {
if (/^```/.test(line)) { fence = !fence; return blank(line); }
return fence ? blank(line) : line.replace(/`[^`\n]+`/g, blank);
})
.join('\n');
}
// [[Page]] links and ![[Page]] embeds (groups: 1 = "!" for an embed, 2 = target, 3 = |alias).
const LINK = /(!?)\[\[([^\[\]\n|]+)(\|[^\[\]\n]*)?\]\]/g;
// ![[photo.png]] and ![[slides.pdf]] embed files, not pages. (Same lists as src/lib/links.js.)
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;
// [{ index, length, ref, alias, embed }] for every page link and page embed in a Markdown body.
function findLinks(body) {
const masked = maskCode(String(body || ''));
const out = [];
for (const m of masked.matchAll(LINK)) {
if (m[1] && FILE.test(m[2].trim())) continue;
out.push({ index: m.index, length: m[0].length, embed: !!m[1], ref: body.substr(m.index + m[1].length + 2, m[2].length), alias: m[3] || '' });
}
return out;
}
/* database properties
A database record's property values are front matter (page.props). Links written in them ([[Page]]) count as
links from the record, and a relation property (src/lib/database.js) points at sibling records by title. */
const NOT_PROPS = new Set(['icon', 'cover', 'tags', 'created', 'ink-database', 'kanban-plugin']);
const valuesOf = (v) => (Array.isArray(v) ? v : [v]).filter((x) => typeof x === 'string');
// The property values of a page that hold [[links]], one per line.
function propText(p) {
const out = [];
for (const [k, v] of Object.entries(p.props || {})) {
if (NOT_PROPS.has(k)) continue;
for (const x of valuesOf(v)) if (x.includes('[[')) out.push(x);
}
return out.join('\n');
}
// [{ key, name }] of the relation properties in database page `db` (read from the schema comment in its body).
function relationKeys(db) {
const m = String((db && db.body) || '').match(/^[ \t]*%%[ \t]*ink-database[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*%%[ \t]*$/m);
if (!m) return [];
try {
return (JSON.parse(m[1]).properties || []).filter((p) => p && p.type === 'relation' && p.key).map((p) => ({ key: p.key, name: p.name || p.key }));
} catch { return []; }
}
// [{ id, name }]: the sibling records that record `rec` names in its relation properties.
function relatedIds(rec, pages) {
if (!rec.parent || !rec.props) return [];
const db = pages.find((x) => x.id === rec.parent);
const keys = db ? relationKeys(db) : [];
if (!keys.length) return [];
const siblings = pages.filter((x) => x.parent === rec.parent && x.exists && x.id !== rec.id);
const out = [];
for (const { key, name } of keys) {
for (const title of valuesOf(rec.props[key])) {
const hit = siblings.find((x) => x.title.toLowerCase() === title.trim().toLowerCase());
if (hit) out.push({ id: hit.id, name });
}
}
return out;
}
// { key: newValue } for the property values whose [[links]] moved, or null.
function rewriteProps(props, before, after, moved) {
const patch = {};
for (const [k, v] of Object.entries(props || {})) {
if (NOT_PROPS.has(k)) continue;
const next = (x) => (typeof x === 'string' && x.includes('[[') ? rewriteLinks(x, before, after, moved) : null);
if (Array.isArray(v)) {
const out = v.map((x) => next(x));
if (out.some((x) => x !== null)) patch[k] = v.map((x, i) => (out[i] === null ? x : out[i]));
} else {
const n = next(v);
if (n !== null) patch[k] = n;
}
}
return Object.keys(patch).length ? patch : null;
}
// Rewrite links whose target moved. `before` is the page list before the change, `after` the list after,
// `moved(oldId)` returns the new id or null. Returns the new body, or null when nothing changed.
function rewriteLinks(body, before, after, moved) {
const links = findLinks(body);
let out = '', at = 0, changed = false;
for (const l of links) {
const was = resolveLink(l.ref, before);
const now = was && moved(was);
if (!now || now === was) continue;
const hash = l.ref.includes('#') ? l.ref.slice(l.ref.indexOf('#')) : '';
out += body.slice(at, l.index) + (l.embed ? '!' : '') + '[[' + refFor(now, after) + hash + l.alias + ']]';
at = l.index + l.length;
changed = true;
}
return changed ? out + body.slice(at) : null;
}
// A heading of page `pageId` was renamed from `from` to `to`: point the [[Page#from]] links and ![[Page#from]]
// embeds in `body` at the new name (page part and |alias stay). Returns { body, count }, or null if none matched.
// ([[#from]] with no page part points inside its own page, so it belongs to that page and is left alone.)
function renameHeadingRefs(body, pages, pageId, from, to) {
const safe = linkHeading(to);
if (!safe) return null;
let out = '', at = 0, count = 0;
for (const l of findLinks(body)) {
if (!refPage(l.ref) || resolveLink(l.ref, pages) !== pageId || !matchesHeading(headingOf(l.ref), from)) continue;
const ref = l.ref.slice(0, l.ref.lastIndexOf('#') + 1) + safe;
out += body.slice(at, l.index) + (l.embed ? '!' : '') + '[[' + ref + l.alias + ']]';
at = l.index + l.length;
count++;
}
return count ? { body: out + body.slice(at), count } : null;
}
// Pages that link to `id`, each with the line the first link sits on.
function backlinks(id, pages) {
const out = [];
for (const p of pages) {
if (!p.exists || p.id === id) continue;
const text = p.body + '\n' + propText(p); // links in property values count too
const hit = findLinks(text).find((l) => resolveLink(l.ref, pages) === id);
if (!hit) {
const rel = relatedIds(p, pages).find((r) => r.id === id);
if (rel) out.push({ id: p.id, title: p.title, icon: p.icon, snip: rel.name });
continue;
}
const start = text.lastIndexOf('\n', hit.index) + 1;
let end = text.indexOf('\n', hit.index);
if (end < 0) end = text.length;
const line = text.slice(start, end)
.replace(LINK, (_, _bang, ref, alias) => (alias ? alias.slice(1) : refPage(ref).split('/').pop()))
.replace(/^\s*(?:[-*+]\s+(?:\[[ xX]\]\s*)?|#{1,6}\s+|>\s?(?:\[!\w+\]\s*)?)/, '')
.replace(/\*\*|__|[*_`]/g, '')
.trim();
out.push({ id: p.id, title: p.title, icon: p.icon, snip: line.length > 160 ? line.slice(0, 159) + '…' : line });
}
return out.sort((a, b) => a.title.localeCompare(b.title));
}
/* unlinked mentions: pages that say a page's title in plain text without linking to it */
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Blank out what is not plain prose (code, [[links]], [text](url), bare URLs); offsets stay the same.
function maskProse(body) {
const blank = (s) => s.replace(/[^\n]/g, ' ');
return maskCode(body)
.replace(/!?\[\[[^\]\n]*\]\]/g, blank)
.replace(/!?\[[^\]\n]*\]\([^)\n]*\)/g, blank)
.replace(/\b(?:https?:\/\/|mailto:)\S+/g, blank);
}
const mentionRe = (title) => new RegExp('(?<![\\p{L}\\p{N}_])' + esc(title) + '(?![\\p{L}\\p{N}_])', 'iu');
const MIN_MENTION = 3;
function firstMention(body, title) {
if (String(title || '').trim().length < MIN_MENTION) return null;
const m = mentionRe(title.trim()).exec(maskProse(String(body || '')));
return m ? { index: m.index, length: m[0].length } : null;
}
// Pages that mention the title of `id` without linking to it, each with the line of the first mention.
function unlinked(id, pages) {
const me = pages.find((p) => p.id === id);
if (!me || !me.exists || /\.canvas$/i.test(id)) return [];
const out = [];
for (const p of pages) {
if (!p.exists || p.id === id || p.kind === 'canvas' || /\.canvas$/i.test(p.id)) continue;
if (p.props && p.props['kanban-plugin']) continue;
const hit = firstMention(p.body, me.title);
if (!hit) continue;
if (findLinks(p.body + '\n' + propText(p)).some((l) => resolveLink(l.ref, pages) === id)) continue;
const start = p.body.lastIndexOf('\n', hit.index) + 1;
let end = p.body.indexOf('\n', hit.index);
if (end < 0) end = p.body.length;
const line = p.body.slice(start, end)
.replace(/^\s*(?:[-*+]\s+(?:\[[ xX]\]\s*)?|#{1,6}\s+|>\s?(?:\[!\w+\]\s*)?)/, '')
.replace(/\*\*|__|[*_`]/g, '').trim();
out.push({ id: p.id, title: p.title, icon: p.icon, snip: line.length > 160 ? line.slice(0, 159) + '…' : line });
}
return out.sort((a, b) => a.title.localeCompare(b.title)).slice(0, 200);
}
// `body` with the first plain mention of page `id`'s title turned into a link; null if there is none.
function linkFirstMention(body, id, pages) {
const me = pages.find((p) => p.id === id);
if (!me) return null;
const hit = firstMention(body, me.title);
if (!hit) return null;
const said = body.substr(hit.index, hit.length);
const ref = refFor(id, pages);
const link = said === me.title.trim() && ref.toLowerCase() === said.toLowerCase() ? `[[${ref}]]` : `[[${ref}|${said}]]`;
return body.slice(0, hit.index) + link + body.slice(hit.index + hit.length);
}
// Everything the graph view draws: pages, links between them, and links to pages that don't exist yet.
// nodes: [{ id, title, icon, parent }] edges: [[from, to]] (each pair once) ghosts: [{ id: '?name', title }]
function graph(pages) {
const nodes = pages.filter((p) => p.exists).map((p) => ({ id: p.id, title: p.title, icon: p.icon || null, parent: p.parent || null, tags: p.tags || [] }));
const seen = new Set();
const edges = [];
const ghosts = new Map();
const add = (from, to) => {
const key = from + '\u0000' + to;
if (to === from || seen.has(key)) return;
seen.add(key);
edges.push([from, to]);
};
for (const p of pages) {
if (!p.exists) continue;
for (const l of findLinks(p.body + '\n' + propText(p))) {
let to = resolveLink(l.ref, pages);
if (!to) {
const name = refPage(l.ref);
if (!name) continue;
to = '?' + name.toLowerCase();
if (!ghosts.has(to)) ghosts.set(to, { id: to, title: name.split('/').pop() });
}
add(p.id, to);
}
for (const r of relatedIds(p, pages)) add(p.id, r.id);
}
return { nodes, edges, ghosts: [...ghosts.values()] };
}
module.exports = { unlinked, linkFirstMention, refPage, resolveLink, refFor, findLinks, rewriteLinks, rewriteProps, propText, relatedIds, renameHeadingRefs, backlinks, graph, headingOf, linkHeading, matchesHeading };
+781
View File
@@ -0,0 +1,781 @@
'use strict';
const { app, BrowserWindow, ipcMain, dialog, shell, protocol, net, Menu, session, nativeTheme, globalShortcut, screen } = require('electron');
const fs = require('node:fs');
const fsp = fs.promises;
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const { Vault, titleOf, setLimits } = require('./vault.cjs');
const { backup, due: backupDue } = require('./backup.cjs');
const links = require('./links.cjs');
const tasks = require('./tasks.cjs');
const importer = require('./importer.cjs');
const canvas = require('./canvas.cjs');
const linkpreview = require('./linkpreview.cjs');
const imagefetch = require('./imagefetch.cjs');
// What search indexes for a page: a canvas's text, not its JSON.
const indexed = (id, body) => (canvas.isCanvasId(id) ? canvas.canvasText(body) : body);
const { openDb } = require('./db.cjs');
const { isEmpty, seedVault } = require('./seed.cjs');
const settings = require('./settings.cjs');
const DEV = process.argv.includes('--dev');
const DEV_URL = process.env.VITE_DEV_SERVER_URL || 'http://localhost:5173';
const BUILD = path.join(__dirname, '..', 'build');
const MAC = process.platform === 'darwin';
protocol.registerSchemesAsPrivileged([
{ scheme: 'app', privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true } },
// Images inside the notes folder: notes-media://vault/<page id>?src=<path as written in the page>
{ scheme: 'notes-media', privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true, stream: true } }
]);
// The app used to be called "Notes"; its settings (notes folder, favorites, window size) lived under that name.
(function moveOldSettings() {
const now = path.join(app.getPath('userData'), 'settings.json');
const old = path.join(app.getPath('appData'), 'Notes', 'settings.json');
try {
if (!fs.existsSync(now) && fs.existsSync(old)) {
fs.mkdirSync(path.dirname(now), { recursive: true });
fs.copyFileSync(old, now);
}
} catch (err) { console.error('Could not bring over old settings', err); }
})();
if (process.platform === 'win32') app.setAppUserModelId('app.ink.desktop');
if (!app.requestSingleInstanceLock()) app.quit();
let win = null;
let vault = null;
let db = null;
let stopWatch = null;
let allowClose = false;
let quitting = false;
/* ---------- vault ---------- */
const strip = (pages) => pages.map(({ body, ...p }) => p);
async function snapshot(pages) {
if (!vault) return null;
return { path: vault.root, name: path.basename(vault.root), pages: strip(pages || (await vault.list())) };
}
async function openVault(dir) {
if (stopWatch) stopWatch();
if (db) db.close();
await fsp.mkdir(path.join(dir, '.notes'), { recursive: true });
vault = new Vault(dir, { trash: (p) => shell.trashItem(p) });
db = openDb(path.join(dir, '.notes', 'index.sqlite'));
const pages = await vault.list();
db.rebuild(pages);
const trashDays = prefs().trashDays;
if (trashDays > 0) vault.purgeOlderThan(trashDays).catch((err) => console.error('Could not clear old trash', err));
stopWatch = vault.watch(async (ids) => {
for (const id of ids) {
try {
const r = await vault.read(id);
if (r.exists) db.upsert({ id, title: r.title, tags: r.meta.tags, props: r.meta, body: indexed(id, r.body) });
else db.remove(id);
} catch { /* folder events and the like */ }
}
if (win && !win.isDestroyed()) win.webContents.send('vault:changed', ids);
});
settings.set({ vault: vault.root });
return snapshot(pages);
}
async function reindexTree(id) {
const all = await vault.list();
for (const p of all) if (p.exists && (p.id === id || p.id.startsWith(id + '/'))) db.upsert(p);
}
// After page `from` became `to` (rename or move), point every [[link]] to it, or to a page under it, at the new place.
async function relink(before, from, to) {
const moved = (id) => (id === from ? to : id.startsWith(from + '/') ? to + id.slice(from.length) : null);
const after = await vault.list();
const touched = [];
for (const p of after) {
if (!p.exists) continue;
if (canvas.isCanvasId(p.id)) {
// (p.body is the canvas's text; the file itself is JSON)
const next = canvas.rewriteCanvas((await vault.read(p.id)).body, before, after, moved);
if (next === null) continue;
await vault.write(p.id, {}, next);
db.upsert({ id: p.id, title: p.title, tags: [], body: canvas.canvasText(next) });
touched.push(p.id);
continue;
}
const rewritten = links.rewriteLinks(p.body, before, after, moved);
const patch = links.rewriteProps(p.props, before, after, moved); // [[links]] in a record's property values
if (rewritten === null && !patch) continue;
const { meta, body: raw } = await vault.read(p.id);
const body = rewritten === null ? raw : rewritten;
await vault.write(p.id, patch ? { ...meta, ...patch } : meta, body);
db.upsert({ id: p.id, title: p.title, tags: p.tags, props: { ...(p.props || {}), ...(patch || {}) }, body });
touched.push(p.id);
}
if (touched.length && win && !win.isDestroyed()) win.webContents.send('vault:changed', touched);
}
// A heading of page `id` was renamed: point other pages' [[Page#from]] links and ![[Page#from]] embeds at `to`.
// (The page itself is left to the editor, which has its unsaved text.) Returns { pages: [ids changed], count: links changed }.
async function renameHeading(id, from, to) {
const pages = await need().list();
const touched = [];
let count = 0;
for (const p of pages) {
if (!p.exists || p.id === id) continue;
const isCanvas = canvas.isCanvasId(p.id);
const r = isCanvas
? canvas.renameHeadingInCanvas((await vault.read(p.id)).body, pages, id, String(from), String(to))
: links.renameHeadingRefs(p.body, pages, id, String(from), String(to));
if (!r) continue;
const { meta } = await vault.read(p.id);
await vault.write(p.id, meta, r.body);
db.upsert({ id: p.id, title: p.title, tags: p.tags, props: p.props, body: indexed(p.id, r.body) });
touched.push(p.id);
count += r.count;
}
if (touched.length && win && !win.isDestroyed()) win.webContents.send('vault:changed', touched);
return { pages: touched, count };
}
async function relocate(id, op) {
const before = await need().list();
const next = await op();
if (next !== id) {
db.remove(id);
await reindexTree(next);
await relink(before, id, next);
}
return next;
}
const IMAGE_TYPES = [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'bmp'] }];
function need() {
if (!vault) throw new Error('No notes folder is open.');
return vault;
}
/* ---------- IPC ---------- */
const handle = (channel, fn) => ipcMain.handle(channel, (_event, ...args) => fn(...args));
handle('vault:get', () => snapshot());
handle('vault:list', () => snapshot());
handle('vault:pick', async (kind) => {
let dir;
if (kind === 'default') {
dir = path.join(app.getPath('home'), 'Notes');
} else {
const r = await dialog.showOpenDialog(win, {
title: 'Choose a folder for your notes',
buttonLabel: 'Use this folder',
properties: ['openDirectory', 'createDirectory']
});
if (r.canceled || !r.filePaths[0]) return null;
dir = r.filePaths[0];
}
if (await isEmpty(dir)) await seedVault(dir);
return openVault(dir);
});
handle('vault:read', (id) => need().read(id));
handle('vault:keepVersion', (id) => need().keepCurrentVersion(id));
handle('vault:listHistory', (id) => need().listHistory(id));
handle('vault:readHistory', (id, ts) => need().readHistory(id, ts));
handle('vault:listTemplates', () => need().listTemplates());
handle('vault:getTemplate', (name) => need().getTemplate(name));
handle('vault:openTemplates', async () => shell.openPath(await need().ensureTemplatesDir())); // '' on success, else an error text
handle('vault:restoreHistory', async (id, ts) => {
const mtime = await need().restoreHistory(id, ts);
const r = await vault.read(id);
db.upsert({ id, title: titleOf(id), tags: r.meta.tags || [], props: r.meta, body: indexed(id, r.body) });
return mtime;
});
handle('vault:write', async (id, meta, body) => {
const mtime = await need().write(id, meta || {}, body || '');
db.upsert({ id, title: titleOf(id), tags: (meta && meta.tags) || [], props: meta, body: indexed(id, body) });
return mtime;
});
handle('vault:create', async (parent, title, meta, body = '') => {
const id = await need().create(parent || null, title || 'Untitled', meta || {}, body);
db.upsert({ id, title: titleOf(id), tags: (meta && meta.tags) || [], props: meta, body: indexed(id, body) });
return id;
});
// Many records at once (a CSV import): [{ title, meta }] -> their ids, in order.
handle('vault:createMany', async (parent, items) => {
const v = need();
const ids = [];
for (const it of Array.isArray(items) ? items.slice(0, 20000) : []) {
const id = await v.create(parent || null, String((it && it.title) || 'Untitled'), (it && it.meta) || {}, '');
db.upsert({ id, title: titleOf(id), tags: [], props: it && it.meta, body: '' });
ids.push(id);
}
return ids;
});
handle('vault:createCanvas', async (parent, title) => {
const id = await need().createCanvas(parent || null, title || 'Untitled canvas');
db.upsert({ id, title: titleOf(id), tags: [], body: '' });
return id;
});
handle('vault:duplicate', async (id) => {
const next = await need().duplicate(id);
await reindexTree(next);
return next;
});
handle('vault:rename', (id, title) => relocate(id, () => vault.rename(id, title)));
handle('vault:move', (id, parent) => relocate(id, () => vault.move(id, parent)));
handle('vault:renameHeading', (id, from, to) => renameHeading(id, from, to));
handle('vault:backlinks', async (id) => links.backlinks(id, await need().list()));
// Every checklist item in the notes; ticking one edits the line in its file (refused if the line has changed).
handle('vault:tasks', async () => tasks.findTasks(await need().list()));
handle('vault:setTask', async (id, line, text, done) => {
const v = need();
const r = await v.read(id);
const body = tasks.setTask(r.body, line, text, !!done);
if (body === null) throw new Error('That task changed on disk. Reopen the list.');
await v.write(id, r.meta, body);
db.upsert({ id, title: titleOf(id), tags: r.meta.tags || [], props: r.meta, body });
if (win && !win.isDestroyed()) win.webContents.send('vault:changed', [id]);
});
// Pages that say this page's title without linking to it, and turning one such mention into a link.
handle('vault:unlinked', async (id) => links.unlinked(id, await need().list()));
handle('vault:linkMention', async (id, target) => {
const v = need();
const pages = await v.list();
const r = await v.read(id);
const body = links.linkFirstMention(r.body, target, pages);
if (body === null) throw new Error('That mention is gone.');
await v.write(id, r.meta, body);
db.upsert({ id, title: titleOf(id), tags: r.meta.tags || [], props: r.meta, body });
if (win && !win.isDestroyed()) win.webContents.send('vault:changed', [id]);
});
// Tags live in front matter: rename (or, into an existing tag, merge) or remove (to === '') one in every page.
handle('vault:renameTag', async (from, to) => {
const v = need();
const norm = (t) => String(t || '').trim().toLowerCase().replace(/^#/, '').replace(/\s+/g, '-');
const a = norm(from), b = norm(to);
if (!a) throw new Error('Which tag?');
const touched = [];
for (const p of await v.list()) {
if (!p.exists || !(p.tags || []).some((t) => norm(t) === a)) continue;
const r = await v.read(p.id);
const next = [];
for (const t of Array.isArray(r.meta.tags) ? r.meta.tags : []) {
const x = norm(t) === a ? b : String(t);
if (x && !next.some((y) => norm(y) === norm(x))) next.push(x);
}
const meta = { ...r.meta, tags: next };
if (!next.length) delete meta.tags;
await v.write(p.id, meta, r.body);
db.upsert({ id: p.id, title: p.title, tags: next, props: meta, body: indexed(p.id, r.body) });
touched.push(p.id);
}
if (touched.length && win && !win.isDestroyed()) win.webContents.send('vault:changed', touched);
return touched.length;
});
// The whole notes folder as one .zip (the same archive the backups make).
handle('export:vaultZip', async () => {
const v = need();
const stamp = new Date().toISOString().slice(0, 10);
const r = await dialog.showSaveDialog(win, { defaultPath: path.join(app.getPath('documents'), `${path.basename(v.root)} ${stamp}.zip`), filters: [{ name: 'Zip archive', extensions: ['zip'] }] });
if (r.canceled || !r.filePath) return null;
const count = await require('./backup.cjs').zipFolder(v.root, r.filePath);
return { path: r.filePath, count };
});
// Import an Obsidian vault or a Notion export (a folder or a .zip) into a new folder of the notes.
handle('vault:import', async (kind) => {
const v = need();
const zip = kind === 'zip';
const r = await dialog.showOpenDialog(win, zip
? { title: 'Import a .zip export', properties: ['openFile'], filters: [{ name: 'Zip archive', extensions: ['zip'] }] }
: { title: 'Import a folder of notes', properties: ['openDirectory'] });
if (r.canceled || !r.filePaths[0]) return null;
const res = zip ? await importer.importZip(v.root, r.filePaths[0]) : await importer.importFolder(v.root, r.filePaths[0]);
db.rebuild(await v.list());
return res;
});
// A web page's HTML for the clipper (public addresses only).
handle('web:clip', async (url) => {
const page = await linkpreview.fetchHtml(String(url || '').trim(), { fetch: linkpreview.hooks.fetch || net.fetch.bind(net) });
if (!page) throw new Error('Could not read that page.');
return page;
});
handle('vault:graph', async () => links.graph(await need().list()));
handle('vault:saveImage', async (id, name, bytes) => {
if (!(bytes instanceof Uint8Array) || bytes.length > 50 * 1024 * 1024) throw new Error('That image is too big (50 MB at most).');
return need().saveImage(id, name, Buffer.from(bytes));
});
// A picture from a web address, saved next to page `id`; returns its file name.
handle('vault:saveImageFromUrl', async (id, url) => {
const v = need();
const img = await imagefetch.fetchImage(url, { fetch: linkpreview.hooks.fetch || net.fetch.bind(net) });
return v.saveImage(id, img.name, img.bytes);
});
handle('vault:pickImage', async (id) => {
const v = need();
const r = await dialog.showOpenDialog(win, { title: 'Add an image', properties: ['openFile'], filters: IMAGE_TYPES });
if (r.canceled || !r.filePaths[0]) return null;
return v.saveImage(id, path.basename(r.filePaths[0]), await fsp.readFile(r.filePaths[0]));
});
// Any file, for a database record's Files property: copied next to the record, its name returned.
handle('vault:pickFile', async (id) => {
const v = need();
const r = await dialog.showOpenDialog(win, { title: 'Attach files', properties: ['openFile', 'multiSelections'] });
if (r.canceled || !r.filePaths.length) return [];
const names = [];
for (const f of r.filePaths) {
if ((await fsp.stat(f)).size > 50 * 1024 * 1024) throw new Error(`${path.basename(f)} is too big (50 MB at most).`);
names.push(await v.saveImage(id, path.basename(f), await fsp.readFile(f)));
}
return names;
});
// Open a file attached to a record with the system's app for it (only files inside the notes folder).
handle('vault:openFile', async (id, src) => {
const file = need().imagePath(id, src);
if (!file) throw new Error('That file is not in the notes folder.');
const err = await shell.openPath(file); // '' on success
if (err) throw new Error(err);
});
handle('vault:remove', async (id) => {
const entry = await need().remove(id);
db.remove(id);
return entry;
});
handle('vault:trash', () => need().listTrash());
handle('vault:restore', async (entry) => {
const id = await need().restore(entry);
await reindexTree(id);
return id;
});
handle('vault:purge', (entry) => need().purge(entry || null));
handle('vault:search', (q) => (db ? db.search(q) : []));
handle('vault:reveal', async (id) => {
const v = need();
if (!id) return shell.openPath(v.root);
const file = v.abs(id);
shell.showItemInFolder(fs.existsSync(file) ? file : v.dir(id));
});
handle('vault:filePath', (id) => need().abs(id));
handle('export:save', async (name, content) => {
const r = await dialog.showSaveDialog(win, { defaultPath: path.join(app.getPath('documents'), name) });
if (r.canceled || !r.filePath) return null;
await fsp.writeFile(r.filePath, content, 'utf8');
return r.filePath;
});
handle('export:pdf', async (name, title, htmlContent) => {
const r = await dialog.showSaveDialog(win, { defaultPath: path.join(app.getPath('documents'), name) });
if (r.canceled || !r.filePath) return null;
// We create a hidden offscreen window to render the pristine HTML document
const pdfWin = new BrowserWindow({ show: false, webPreferences: { nodeIntegration: false } });
await pdfWin.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(htmlContent));
const data = await pdfWin.webContents.printToPDF({
printBackground: true,
margins: { marginType: 'default' }
});
pdfWin.destroy();
await fsp.writeFile(r.filePath, data);
return r.filePath;
});
// The title and description of a web page, for link cards on a canvas ({ off: true } when the user turned that off).
const previewCache = new Map(); // url -> { at, value }
handle('web:preview', async (url) => {
if (settings.get().linkPreviews === false) return { off: true };
const key = String(url || '');
const hit = previewCache.get(key);
if (hit && Date.now() - hit.at < (hit.value ? 3600e3 : 300e3)) return hit.value;
const value = await linkpreview.fetchPreview(key, { fetch: linkpreview.hooks.fetch || net.fetch.bind(net) });
previewCache.set(key, { at: Date.now(), value });
return value;
});
// A canvas as a picture or a PDF: `html` is a self-contained page (see src/lib/canvasExport.js), `width` × `height` its size.
handle('export:canvas', async (name, kind, html, width, height) => {
const ext = kind === 'pdf' ? 'pdf' : 'png';
const r = await dialog.showSaveDialog(win, {
defaultPath: path.join(app.getPath('documents'), name),
filters: [{ name: ext.toUpperCase(), extensions: [ext] }]
});
if (r.canceled || !r.filePath) return null;
const page = path.join(app.getPath('temp'), `ink-canvas-${process.pid}-${Date.now()}.html`);
await fsp.writeFile(page, html, 'utf8');
const w = Math.max(1, Math.ceil(width)), h = Math.max(1, Math.ceil(height));
const out = new BrowserWindow({
show: false, width: w, height: h, useContentSize: true, frame: false, enableLargerThanScreen: true,
webPreferences: { sandbox: true, backgroundThrottling: false }
});
try {
await out.loadFile(page);
// fonts and pictures in place before it is taken
await out.webContents.executeJavaScript(
'document.fonts.ready.then(() => Promise.all([...document.images].map((i) => i.decode().catch(() => null))))'
);
const data = ext === 'pdf'
? await out.webContents.printToPDF({ printBackground: true, preferCSSPageSize: true, margins: { marginType: 'none' } })
: (await out.webContents.capturePage()).toPNG();
await fsp.writeFile(r.filePath, data);
return r.filePath;
} finally {
out.destroy();
fsp.rm(page, { force: true }).catch(() => {});
}
});
ipcMain.on('export:print', () => {
win.webContents.print({
printBackground: true,
margins: { marginType: 'default' }
});
});
handle('settings:get', () => settings.get());
handle('settings:set', (patch) => {
const next = settings.set(patch || {});
if (patch && patch.prefs) applyPrefs();
return next;
});
/* prefs (see src/lib/prefs.js): the ones only this side can act on */
function prefs() {
const p = settings.get().prefs;
return {
trashDays: 30, historyKeep: 100, historyGapMin: 5, alwaysOnTop: false, startMinimized: false,
backupEvery: 'off', backupKeep: 5, backupFolder: '',
captureShortcut: 'CommandOrControl+Alt+N', captureTo: 'daily',
...(p && typeof p === 'object' ? p : {})
};
}
function applyPrefs() {
const p = prefs();
setLimits(p);
if (win && !win.isDestroyed()) win.setAlwaysOnTop(!!p.alwaysOnTop);
applyCaptureShortcut(p.captureShortcut);
}
/* quick capture: a global shortcut opens a small window; what is typed is added to today's note (or an Inbox page)
by the main window, which knows the daily-note settings. Captures wait in a queue until it takes them. */
let captureWin = null;
let captureKey = '';
const captures = [];
function applyCaptureShortcut(accel) {
const want = String(accel || '').trim();
if (want === captureKey) return;
if (captureKey) { try { globalShortcut.unregister(captureKey); } catch { /* not registered */ } }
captureKey = '';
if (!want) return;
try {
if (globalShortcut.register(want, showCapture)) captureKey = want;
else console.error('Quick capture shortcut not available:', want);
} catch (err) { console.error('Quick capture shortcut not valid:', want, err.message); }
}
function showCapture() {
if (!vault) return;
if (captureWin && !captureWin.isDestroyed()) { captureWin.show(); captureWin.focus(); return; }
const at = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()).workArea;
const w = 560, h = 190;
captureWin = new BrowserWindow({
width: w, height: h, x: Math.round(at.x + (at.width - w) / 2), y: Math.round(at.y + at.height * 0.22),
frame: false, resizable: false, minimizable: false, maximizable: false, skipTaskbar: true, alwaysOnTop: true,
show: false, title: 'Quick capture', backgroundColor: '#161617',
webPreferences: { preload: path.join(__dirname, 'capture-preload.cjs'), contextIsolation: true, sandbox: true, nodeIntegration: false }
});
captureWin.loadFile(path.join(__dirname, 'capture.html'));
captureWin.once('ready-to-show', () => captureWin.show());
captureWin.on('blur', () => { if (captureWin && !captureWin.isDestroyed() && !captureWin.webContents.isDevToolsOpened()) captureWin.close(); });
captureWin.on('closed', () => { captureWin = null; });
}
ipcMain.handle('capture:info', () => {
const s = settings.get();
const dark = s.theme === 'dark' || (s.theme !== 'light' && nativeTheme.shouldUseDarkColors);
return { theme: dark ? 'dark' : 'light', to: prefs().captureTo === 'inbox' ? 'inbox' : 'daily', vault: vault ? path.basename(vault.root) : '' };
});
ipcMain.handle('capture:save', (_e, text, to) => {
const t = String(text || '').trim().slice(0, 20000);
if (!t) return false;
captures.push({ text: t, to: to === 'inbox' ? 'inbox' : 'daily' });
if (win && !win.isDestroyed()) {
win.webContents.send('capture:ping');
} else if (MAC && app.isReady()) {
allowClose = false;
createWindow(); // the page takes the queue when it starts
}
if (captureWin && !captureWin.isDestroyed()) captureWin.close();
return true;
});
ipcMain.on('capture:close', () => { if (captureWin && !captureWin.isDestroyed()) captureWin.close(); });
handle('capture:take', () => captures.splice(0));
handle('capture:open', () => showCapture());
/* backups */
const backupDir = () => prefs().backupFolder || path.join(app.getPath('userData'), 'backups');
async function runBackup() {
if (!vault) throw new Error('Open a notes folder first.');
const r = await backup(vault.root, backupDir(), prefs().backupKeep);
settings.set({ lastBackup: Date.now() });
return r;
}
async function backupIfDue() {
const p = prefs();
if (!vault || !backupDue(p.backupEvery, settings.get().lastBackup)) return;
try { await runBackup(); } catch (err) { console.error('Could not back up', err); }
}
handle('backup:now', async () => {
const r = await runBackup();
return { file: r.file, count: r.count };
});
handle('backup:info', () => ({ folder: backupDir(), last: settings.get().lastBackup || 0 }));
handle('backup:pickFolder', async () => {
const r = await dialog.showOpenDialog(win, { title: 'Folder for backups', properties: ['openDirectory', 'createDirectory'] });
return r.canceled || !r.filePaths[0] ? null : r.filePaths[0];
});
handle('backup:reveal', async () => {
const dir = backupDir();
await fsp.mkdir(dir, { recursive: true });
shell.openPath(dir);
return true;
});
/* custom dictionary */
handle('spell:words', async () => (await session.defaultSession.listWordsInSpellCheckerDictionary()).sort((a, b) => a.localeCompare(b)));
handle('spell:addWord', (word) => session.defaultSession.addWordToSpellCheckerDictionary(String(word).trim().slice(0, 80)));
handle('spell:removeWord', (word) => session.defaultSession.removeWordFromSpellCheckerDictionary(String(word)));
/* spellcheck: on/off and languages (macOS always uses the system's own languages) */
function applySpellcheck() {
const s = settings.get();
const ses = session.defaultSession;
if (!MAC) {
const langs = (Array.isArray(s.spellLangs) ? s.spellLangs : []).filter((l) => ses.availableSpellCheckerLanguages.includes(l));
if (langs.length) ses.setSpellCheckerLanguages(langs);
}
// After the languages: setting them switches spellcheck back on.
ses.setSpellCheckerEnabled(s.spellcheck !== false);
}
handle('spell:get', () => {
const ses = session.defaultSession;
return {
enabled: ses.isSpellCheckerEnabled(),
languages: MAC ? [] : ses.getSpellCheckerLanguages(),
available: MAC ? [] : ses.availableSpellCheckerLanguages,
system: MAC
};
});
handle('spell:set', (enabled, langs) => {
settings.set({ spellcheck: !!enabled, spellLangs: Array.isArray(langs) ? langs.slice(0, 3) : [] });
applySpellcheck();
return true;
});
handle('win:isMaximized', () => !!win && win.isMaximized());
ipcMain.on('win:minimize', () => win && win.minimize());
ipcMain.on('win:toggleMaximize', () => win && (win.isMaximized() ? win.unmaximize() : win.maximize()));
ipcMain.on('win:close', () => win && win.close());
// What the custom right-click menu asks for.
const EDIT_ACTIONS = new Set(['cut', 'copy', 'paste', 'selectAll']);
ipcMain.on('edit:do', (e, action) => { if (EDIT_ACTIONS.has(action)) e.sender[action](); });
ipcMain.on('edit:copyImageAt', (e, x, y) => e.sender.copyImageAt(Math.round(x), Math.round(y)));
ipcMain.on('edit:replaceMisspelling', (e, word) => e.sender.replaceMisspelling(String(word)));
ipcMain.on('edit:addToDictionary', (e, word) => e.sender.session.addWordToSpellCheckerDictionary(String(word)));
ipcMain.on('app:flushed', () => {
allowClose = true;
if (quitting) app.quit();
else if (win && !win.isDestroyed()) win.close();
});
/* ---------- window ---------- */
function createWindow() {
const s = settings.get();
const b = s.bounds || {};
win = new BrowserWindow({
width: b.width || 1280,
height: b.height || 840,
x: b.x,
y: b.y,
minWidth: 720,
minHeight: 480,
show: false,
title: 'Ink',
icon: MAC ? undefined : path.join(__dirname, '..', 'resources', 'icons', '512x512.png'),
backgroundColor: /^#[0-9a-f]{6}$/i.test(s.ground) ? s.ground : (s.theme === 'system' ? !nativeTheme.shouldUseDarkColors : s.theme === 'light') ? '#e7e7e9' : '#0e0e0f',
// macOS keeps its traffic lights; elsewhere the page draws its own controls (WindowControls.svelte).
frame: MAC,
titleBarStyle: MAC ? 'hiddenInset' : 'default',
trafficLightPosition: { x: 22, y: 24 },
autoHideMenuBar: !MAC,
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
spellcheck: true
}
});
win.once('ready-to-show', () => {
win.show();
if (prefs().startMinimized) win.minimize();
});
win.on('maximize', () => win.webContents.send('win:maximized', true));
win.on('unmaximize', () => win.webContents.send('win:maximized', false));
win.on('app-command', (e, cmd) => {
if (cmd === 'browser-backward') {
e.preventDefault();
win.webContents.send('nav:go', 'back');
} else if (cmd === 'browser-forward') {
e.preventDefault();
win.webContents.send('nav:go', 'forward');
}
});
// Let the page save pending edits before the window goes away.
win.on('close', (e) => {
settings.set({ bounds: win.getBounds() });
if (allowClose) return;
e.preventDefault();
win.webContents.send('app:flush');
setTimeout(() => {
allowClose = true;
if (quitting) app.quit();
else if (!win.isDestroyed()) win.close();
}, 1500);
});
win.webContents.setWindowOpenHandler(({ url }) => {
if (/^(https?|mailto):/i.test(url)) shell.openExternal(url);
return { action: 'deny' };
});
win.webContents.on('will-navigate', (e, url) => {
if (!url.startsWith(DEV ? DEV_URL : 'app://')) {
e.preventDefault();
if (/^https?:/i.test(url)) shell.openExternal(url);
}
});
// Spelling suggestions and copy/paste on right click.
// Right-click: the page draws its own menu (TextMenu.svelte). Only this side knows the spelling suggestions,
// so pass along what was clicked; the page's choice comes back through 'edit:*' below.
win.webContents.on('context-menu', (_e, p) => {
win.webContents.send('app:context-menu', {
x: p.x, y: p.y,
isEditable: p.isEditable,
selectionText: p.selectionText || '',
misspelledWord: p.misspelledWord || '',
suggestions: (p.dictionarySuggestions || []).slice(0, 5),
linkURL: p.linkURL || '',
mediaType: p.mediaType || 'none',
can: { cut: p.editFlags.canCut, copy: p.editFlags.canCopy, paste: p.editFlags.canPaste, selectAll: p.editFlags.canSelectAll }
});
});
if (DEV) {
win.loadURL(DEV_URL);
win.webContents.openDevTools({ mode: 'detach' });
} else {
win.loadURL('app://notes/');
}
}
/* ---------- Linux desktop integration ---------- */
// Docks and launchers (GNOME, KDE…) find a window's icon through an installed .desktop file whose name matches
// the window's app id ("ink", from desktopName in package.json). An AppImage installs nothing by itself, so when
// we run from one, put a launcher and the icons in the user's data folder, and keep the launcher pointing at
// wherever the AppImage is now.
function integrateAppImage() {
const image = process.env.APPIMAGE;
if (process.platform !== 'linux' || !image) return;
const data = process.env.XDG_DATA_HOME || path.join(app.getPath('home'), '.local', 'share');
const launcher = path.join(data, 'applications', 'ink.desktop');
const icons = path.join(__dirname, '..', 'resources', 'icons');
// A fingerprint of the icon, so a new icon gets copied even when nothing else in the launcher changed.
let iconId = '';
try { iconId = require('node:crypto').createHash('sha1').update(fs.readFileSync(path.join(icons, '512x512.png'))).digest('hex').slice(0, 12); } catch { /* no icon */ }
const entry = [
'[Desktop Entry]',
'Type=Application',
'Name=Ink',
'Comment=A calm, offline Markdown notes app',
`Exec="${image.replace(/(["`$\\])/g, '\\$1')}" %U`,
'Icon=ink',
'Terminal=false',
'Categories=Office;',
'Keywords=notes;markdown;writing;kanban;journal;',
'StartupWMClass=ink',
`X-AppImage-Version=${app.getVersion()}`,
`X-Ink-Icon=${iconId}`,
''
].join('\n');
try {
if (fs.existsSync(launcher) && fs.readFileSync(launcher, 'utf8') === entry) return;
for (const f of fs.readdirSync(icons)) {
const size = f.replace(/\.png$/, '');
if (!/^\d+x\d+$/.test(size)) continue;
const dir = path.join(data, 'icons', 'hicolor', size, 'apps');
fs.mkdirSync(dir, { recursive: true });
fs.copyFileSync(path.join(icons, f), path.join(dir, 'ink.png'));
}
fs.mkdirSync(path.dirname(launcher), { recursive: true });
fs.writeFileSync(launcher, entry);
} catch (err) {
console.error('Could not add the desktop launcher', err);
}
}
app.whenReady().then(async () => {
integrateAppImage();
applySpellcheck();
protocol.handle('app', (req) => {
const { pathname } = new URL(req.url);
const rel = decodeURIComponent(pathname).replace(/^\/+/, '');
let file = path.normalize(path.join(BUILD, rel));
if (file !== BUILD && !file.startsWith(BUILD + path.sep)) return new Response('Not found', { status: 404 });
let isFile = false;
try { isFile = fs.statSync(file).isFile(); } catch { /* missing */ }
if (!isFile) {
if (rel.startsWith('_app/')) return new Response('Not found', { status: 404 });
file = path.join(BUILD, 'index.html'); // SPA fallback
}
return net.fetch(pathToFileURL(file).toString());
});
protocol.handle('notes-media', (req) => {
const u = new URL(req.url);
const id = decodeURIComponent(u.pathname.replace(/^\/+/, ''));
const file = vault && id && vault.imagePath(id, u.searchParams.get('src') || '');
if (!file || !fs.existsSync(file)) return new Response('Not found', { status: 404 });
return net.fetch(pathToFileURL(file).toString());
});
const s = settings.get();
if (s.vault && fs.existsSync(s.vault)) {
try { await openVault(s.vault); } catch (err) { console.error('Could not open notes folder', err); }
}
createWindow();
applyPrefs();
backupIfDue();
setInterval(backupIfDue, 3600e3).unref();
app.on('activate', () => { if (!BrowserWindow.getAllWindows().length) { allowClose = false; createWindow(); } });
});
app.on('second-instance', () => {
if (!win) return;
if (win.isMinimized()) win.restore();
win.focus();
});
app.on('window-all-closed', () => { if (!MAC) app.quit(); });
app.on('before-quit', () => { quitting = true; });
app.on('will-quit', () => {
globalShortcut.unregisterAll();
if (stopWatch) stopWatch();
if (db) db.close();
settings.flush();
});
+109
View File
@@ -0,0 +1,109 @@
'use strict';
// The only bridge between the page and the file system. Everything goes through ipcMain handlers in main.cjs.
const { contextBridge, ipcRenderer } = require('electron');
const invoke = (channel, ...args) => ipcRenderer.invoke(channel, ...args);
contextBridge.exposeInMainWorld('vault', {
platform: process.platform,
get: () => invoke('vault:get'),
list: () => invoke('vault:list'),
pick: (kind) => invoke('vault:pick', kind),
read: (id) => invoke('vault:read', id),
write: (id, meta, body) => invoke('vault:write', id, meta, body),
duplicate: (id) => invoke('vault:duplicate', id),
create: (parent, title, meta, body) => invoke('vault:create', parent, title, meta, body),
createMany: (parent, items) => invoke('vault:createMany', parent, items),
createCanvas: (parent, title) => invoke('vault:createCanvas', parent, title),
listTemplates: () => invoke('vault:listTemplates'),
getTemplate: (name) => invoke('vault:getTemplate', name),
openTemplatesFolder: () => invoke('vault:openTemplates'),
rename: (id, title) => invoke('vault:rename', id, title),
move: (id, parent) => invoke('vault:move', id, parent),
renameHeading: (id, from, to) => invoke('vault:renameHeading', id, from, to),
tasks: () => invoke('vault:tasks'),
setTask: (id, line, text, done) => invoke('vault:setTask', id, line, text, done),
unlinked: (id) => invoke('vault:unlinked', id),
linkMention: (id, target) => invoke('vault:linkMention', id, target),
openCapture: () => invoke('capture:open'),
takeCaptures: () => invoke('capture:take'),
onCapture(cb) {
const listener = () => cb();
ipcRenderer.on('capture:ping', listener);
return () => ipcRenderer.removeListener('capture:ping', listener);
},
renameTag: (from, to) => invoke('vault:renameTag', from, to),
exportVaultZip: () => invoke('export:vaultZip'),
importNotes: (kind) => invoke('vault:import', kind),
clipPage: (url) => invoke('web:clip', url),
backlinks: (id) => invoke('vault:backlinks', id),
graph: () => invoke('vault:graph'),
keepVersion: (id) => invoke('vault:keepVersion', id),
listHistory: (id) => invoke('vault:listHistory', id),
readHistory: (id, ts) => invoke('vault:readHistory', id, ts),
restoreHistory: (id, ts) => invoke('vault:restoreHistory', id, ts),
saveImage: (id, name, bytes) => invoke('vault:saveImage', id, name, bytes),
saveImageFromUrl: (id, url) => invoke('vault:saveImageFromUrl', id, url),
pickImage: (id) => invoke('vault:pickImage', id),
pickFile: (id) => invoke('vault:pickFile', id),
openFile: (id, src) => invoke('vault:openFile', id, src),
mediaUrl: (id, src) => `notes-media://vault/${encodeURIComponent(id)}?src=${encodeURIComponent(src)}`,
remove: (id) => invoke('vault:remove', id),
trash: () => invoke('vault:trash'),
restore: (entry) => invoke('vault:restore', entry),
purge: (entry) => invoke('vault:purge', entry),
search: (q) => invoke('vault:search', q),
reveal: (id) => invoke('vault:reveal', id),
filePath: (id) => invoke('vault:filePath', id),
saveExport: (name, content) => invoke('export:save', name, content),
savePdf: (name, title, html) => invoke('export:pdf', name, title, html),
print: () => ipcRenderer.send('export:print'),
linkPreview: (url) => invoke('web:preview', url),
saveCanvasImage: (name, kind, html, width, height) => invoke('export:canvas', name, kind, html, width, height),
getSettings: () => invoke('settings:get'),
setSettings: (patch) => invoke('settings:set', patch),
getSpellcheck: () => invoke('spell:get'),
setSpellcheck: (enabled, langs) => invoke('spell:set', enabled, langs),
spellWords: () => invoke('spell:words'),
addSpellWord: (word) => invoke('spell:addWord', word),
removeSpellWord: (word) => invoke('spell:removeWord', word),
backupNow: () => invoke('backup:now'),
backupInfo: () => invoke('backup:info'),
pickBackupFolder: () => invoke('backup:pickFolder'),
revealBackups: () => invoke('backup:reveal'),
onNav(cb) {
const listener = (_e, dir) => cb(dir);
ipcRenderer.on('nav:go', listener);
return () => ipcRenderer.removeListener('nav:go', listener);
},
onChanged(cb) {
const listener = (_e, ids) => cb(ids);
ipcRenderer.on('vault:changed', listener);
return () => ipcRenderer.removeListener('vault:changed', listener);
},
minimize: () => ipcRenderer.send('win:minimize'),
toggleMaximize: () => ipcRenderer.send('win:toggleMaximize'),
close: () => ipcRenderer.send('win:close'),
isMaximized: () => invoke('win:isMaximized'),
onMaximized(cb) {
const listener = (_e, v) => cb(v);
ipcRenderer.on('win:maximized', listener);
return () => ipcRenderer.removeListener('win:maximized', listener);
},
onContextMenu(cb) {
const listener = (_e, info) => cb(info);
ipcRenderer.on('app:context-menu', listener);
return () => ipcRenderer.removeListener('app:context-menu', listener);
},
editAction: (action) => ipcRenderer.send('edit:do', action),
copyImageAt: (x, y) => ipcRenderer.send('edit:copyImageAt', x, y),
replaceMisspelling: (word) => ipcRenderer.send('edit:replaceMisspelling', word),
addToDictionary: (word) => ipcRenderer.send('edit:addToDictionary', word),
onFlush(cb) {
const listener = async () => {
try { await cb(); } finally { ipcRenderer.send('app:flushed'); }
};
ipcRenderer.on('app:flush', listener);
return () => ipcRenderer.removeListener('app:flush', listener);
}
});
+50
View File
@@ -0,0 +1,50 @@
'use strict';
// First-run content for an empty notes folder.
const fsp = require('node:fs').promises;
const path = require('node:path');
const { today } = require('./vault.cjs');
const GETTING_STARTED = `Everything here is plain Markdown in this folder. No account, no sync, nothing leaves this computer.
A quiet place to write. Pages nest like folders, tags live in each file's front matter, and search runs against a local index.
## 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 three backticks and it turns into that block as you type.
- Hover a block and drag the handle on its left to move it.
- Press <kbd>Ctrl</kbd> <kbd>K</kbd> to search every note or run a command.
## First steps
- [x] Pick a folder for your notes
- [ ] Write one page, however short
- [ ] Try the light theme from the switch in the sidebar
<details>
<summary>Where do my files actually live?</summary>
Each page is a \`.md\` file in this folder. A page with sub-pages gets a folder of the same name. The search index sits in \`.notes/index.sqlite\`; delete it any time and it rebuilds on launch.
</details>
> Notes are for thinking, not for filing.
`;
async function isEmpty(dir) {
try {
const entries = await fsp.readdir(dir);
return entries.filter((n) => !n.startsWith('.')).length === 0;
} catch (err) {
if (err.code === 'ENOENT') return true;
throw err;
}
}
async function seedVault(dir) {
await fsp.mkdir(dir, { recursive: true });
const fm = `---\nicon: sparkle\ncover: blush\ntags: [guide]\ncreated: ${today()}\n---\n\n`;
await fsp.writeFile(path.join(dir, 'Getting started.md'), fm + GETTING_STARTED, 'utf8');
}
module.exports = { isEmpty, seedVault, GETTING_STARTED };
+39
View File
@@ -0,0 +1,39 @@
'use strict';
// App settings (not notes): last vault, theme, window size, folded pages.
const fs = require('node:fs');
const path = require('node:path');
let file = null;
let cache = null;
function location() {
if (!file) file = path.join(require('electron').app.getPath('userData'), 'settings.json');
return file;
}
function get() {
if (cache) return cache;
try { cache = JSON.parse(fs.readFileSync(location(), 'utf8')); } catch { cache = {}; }
return cache;
}
let timer = null;
function set(patch) {
cache = { ...get(), ...patch };
clearTimeout(timer);
timer = setTimeout(flush, 200);
return cache;
}
function flush() {
clearTimeout(timer);
if (!cache) return;
try {
fs.mkdirSync(path.dirname(location()), { recursive: true });
fs.writeFileSync(location(), JSON.stringify(cache, null, 2));
} catch (err) {
console.error('Could not write settings', err);
}
}
module.exports = { get, set, flush };
+55
View File
@@ -0,0 +1,55 @@
'use strict';
// Checklist items (`- [ ] text`) across the notes, and ticking one from outside its page.
// A task is identified by its page and line number in the page's body (what vault.read returns), plus its text,
// so a file that changed since the list was made is never edited blindly.
const TASK = /^(\s*(?:>\s*)*[-*+]\s+)\[([ xX])\](\s+)(.*)$/;
const DUE = /(?:📅|\bdue:|\[due::)\s*(\d{4}-\d{2}-\d{2})/;
const TAG = /(?:^|\s)#([\p{L}\p{N}_/-]*[\p{L}_/-][\p{L}\p{N}_/-]*)/gu;
// Line indexes inside ``` / ~~~ fences are not tasks.
function* lines(body) {
let fence = null;
const all = String(body || '').split('\n');
for (let i = 0; i < all.length; i++) {
const f = all[i].match(/^\s*(```|~~~)/);
if (f) { fence = fence === f[1] ? null : fence || f[1]; continue; }
if (!fence) yield [i, all[i]];
}
}
// [{ id, title, icon, line, done, text, due, tags, depth }] for every task on every page (boards and canvases excluded:
// a board's cards are its own thing).
function findTasks(pages) {
const out = [];
for (const p of pages) {
if (!p.exists || p.kind === 'canvas' || /\.canvas$/i.test(p.id)) continue;
if (p.props && p.props['kanban-plugin']) continue;
for (const [line, text] of lines(p.body)) {
const m = text.match(TASK);
if (!m) continue;
const body = m[4].trim();
if (!body) continue;
const tags = new Set((p.tags || []).map(String));
for (const t of body.matchAll(TAG)) tags.add(t[1]);
const due = body.match(DUE);
out.push({
id: p.id, title: p.title, icon: p.icon || null, line,
done: m[2] !== ' ', text: body, due: due ? due[1] : '', tags: [...tags],
depth: Math.floor((m[1].match(/^\s*/)[0].replace(/\t/g, ' ').length) / 2)
});
}
}
return out;
}
// The body with the task at `line` ticked or unticked; null if the line is no longer that task.
function setTask(body, line, text, done) {
const all = String(body || '').split('\n');
const m = all[line] && all[line].match(TASK);
if (!m || m[4].trim() !== String(text).trim()) return null;
all[line] = m[1] + (done ? '[x]' : '[ ]') + m[3] + m[4];
return all.join('\n');
}
module.exports = { findTasks, setTask };
+535
View File
@@ -0,0 +1,535 @@
'use strict';
// The vault is a plain folder of Markdown files.
// "Projects.md" -> page "Projects"
// "Projects/Note app roadmap.md" -> child page of "Projects"
// A folder without a matching .md file still shows up as an (empty) page.
// Page ids are paths relative to the vault, without ".md", always with "/".
// Canvases ("Ideas/Map.canvas", see canvas.cjs) are pages too; their id keeps the ".canvas", as links to them do.
const fs = require('node:fs');
const fsp = fs.promises;
const path = require('node:path');
const { parseFrontMatter, serializeFrontMatter } = require('./frontmatter.cjs');
const { isCanvasId, canvasText, EMPTY_CANVAS } = require('./canvas.cjs');
const ILLEGAL = /[\\/:*?"<>|\u0000-\u001f]/g;
const SKIP_DIRS = new Set(['node_modules']);
function cleanTitle(t) {
const s = String(t || '').replace(ILLEGAL, '-').replace(/\s+/g, ' ').trim().replace(/^\.+/, '').slice(0, 120).trim();
return s || 'Untitled';
}
const today = (d = new Date()) => {
const z = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}`;
};
const parentOf = (id) => (id.includes('/') ? id.slice(0, id.lastIndexOf('/')) : null);
const PAGE_KEYS = new Set(['icon', 'cover', 'tags', 'created']);
const titleOf = (id) => id.slice(id.lastIndexOf('/') + 1).replace(/\.canvas$/i, '');
const extOf = (id) => (isCanvasId(id) ? '.canvas' : '');
const noCanvasParent = (parent) => { if (parent && isCanvasId(parent)) throw new Error('A canvas cannot hold pages.'); };
const asArray = (v) => (Array.isArray(v) ? v : typeof v === 'string' && v ? [v] : []).map(String);
async function exists(p) {
try { await fsp.access(p); return true; } catch { return false; }
}
// Version history limits (Settings): at most one version per `gap` of editing, the newest `keep` per page.
const limits = { gap: 5 * 60 * 1000, keep: 100 };
function setLimits({ historyKeep, historyGapMin } = {}) {
if (Number.isFinite(historyKeep) && historyKeep >= 1) limits.keep = Math.floor(historyKeep);
if (Number.isFinite(historyGapMin) && historyGapMin >= 1) limits.gap = Math.floor(historyGapMin) * 60 * 1000;
}
const isLocalPath = (src) => !!src && !/^[a-z][a-z0-9+.-]*:/i.test(src) && !src.startsWith('/') && !src.startsWith('\\');
const cleanFileName = (name) => {
const ext = (path.extname(String(name)).toLowerCase().match(/^\.[a-z0-9]{1,5}$/) || ['.png'])[0];
const base = path.basename(String(name), path.extname(String(name))).replace(ILLEGAL, '-').replace(/\s+/g, ' ').trim().replace(/^\.+/, '').slice(0, 80);
return (base || 'image') + ext;
};
async function freeFile(dir, name) {
const ext = path.extname(name), base = name.slice(0, name.length - ext.length);
for (let n = 1; n < 1000; n++) {
const f = n === 1 ? name : `${base} ${n}${ext}`;
if (!(await exists(path.join(dir, f)))) return f;
}
throw new Error('Could not find a free file name.');
}
class Vault {
constructor(root, { trash } = {}) {
this.root = path.resolve(root);
this.trash = trash;
this.recent = new Map(); // id -> time of our own write, to ignore watcher echoes
this.cache = new Map(); // fullPath -> { mtime, page }
}
#inside(p) {
if (p !== this.root && !p.startsWith(this.root + path.sep)) throw new Error('That path is outside the notes folder.');
return p;
}
abs(id) { return this.#inside(path.resolve(this.root, isCanvasId(id) ? id : id + '.md')); }
dir(id) { return this.#inside(path.resolve(this.root, id || '.')); }
#touch(id) { this.recent.set(id, Date.now()); }
isRecent(id) { const t = this.recent.get(id); return t !== undefined && Date.now() - t < 1500; }
async list() {
const pages = new Map();
const walk = async (dir, parent) => {
let entries;
try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
if (e.name.startsWith('.') || !e.isFile() || !/\.(md|canvas)$/i.test(e.name)) continue;
const canvas = /\.canvas$/i.test(e.name);
const title = canvas ? e.name.slice(0, -7) : e.name.slice(0, -3);
const id = parent ? `${parent}/${canvas ? e.name : title}` : canvas ? e.name : title;
const full = path.join(dir, e.name);
try {
const st = await fsp.stat(full);
const cached = this.cache.get(full);
let page;
if (cached && cached.mtime === st.mtimeMs) {
page = cached.page;
// update ID and parent in case it moved/renamed but mtime stayed the same
page.id = id;
page.parent = parent || null;
page.title = title;
} else {
const raw = await fsp.readFile(full, 'utf8');
// A canvas's "body" is its text and the notes on it as [[links]], for search, backlinks and the graph.
const { meta, body } = canvas ? { meta: {}, body: canvasText(raw) } : parseFrontMatter(raw);
// Front matter beyond the page's own keys: a database record's property values (see src/lib/database.js).
const props = {};
for (const k of Object.keys(meta)) if (!PAGE_KEYS.has(k)) props[k] = meta[k];
page = {
...(canvas ? { kind: 'canvas' } : {}),
props,
...(meta['ink-database'] ? { db: true } : {}),
id, parent: parent || null, title, exists: true, mtime: st.mtimeMs,
icon: meta.icon || null, cover: meta.cover || null, tags: asArray(meta.tags),
created: meta.created || today(st.birthtime && st.birthtime.getTime() ? st.birthtime : st.mtime),
body
};
this.cache.set(full, { mtime: st.mtimeMs, page });
}
pages.set(id, page);
} catch { /* unreadable file: skip */ }
}
for (const e of entries) {
if (e.name.startsWith('.') || !e.isDirectory() || SKIP_DIRS.has(e.name)) continue;
const id = parent ? `${parent}/${e.name}` : e.name;
if (!pages.has(id)) {
pages.set(id, { id, parent: parent || null, title: e.name, exists: false, mtime: 0, icon: null, cover: null, tags: [], created: null, body: '' });
}
await walk(path.join(dir, e.name), id);
}
};
await walk(this.root, '');
const active = new Set([...pages.values()].filter(p => p.exists).map(p => this.abs(p.id)));
for (const k of this.cache.keys()) {
if (!active.has(k)) this.cache.delete(k);
}
return [...pages.values()];
}
async read(id) {
const file = this.abs(id);
try {
const [raw, st] = await Promise.all([fsp.readFile(file, 'utf8'), fsp.stat(file)]);
const { meta, body } = isCanvasId(id) ? { meta: {}, body: raw } : parseFrontMatter(raw);
if (meta.tags !== undefined) meta.tags = asArray(meta.tags);
return { id, title: titleOf(id), meta, body, exists: true, mtime: st.mtimeMs };
} catch (err) {
if (err.code !== 'ENOENT') throw err;
return { id, title: titleOf(id), meta: {}, body: '', exists: false, mtime: 0 };
}
}
async write(id, meta, body) {
const file = this.abs(id);
await fsp.mkdir(path.dirname(file), { recursive: true });
const text = isCanvasId(id) ? String(body || '') : serializeFrontMatter(meta) + String(body || '');
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.tmp`);
this.#touch(id);
await fsp.writeFile(tmp, text, 'utf8');
await fsp.rename(tmp, file);
this.#touch(id);
await this.snapshot(id, text);
return (await fsp.stat(file)).mtimeMs;
}
/* version history
.notes/history/<page id>/<time>.md holds copies of a page as it was. Saves happen every few
hundred ms while typing, so a copy is kept at most every limits.gap (and never twice the same),
the newest limits.keep per page. */
get historyRoot() { return path.join(this.root, '.notes', 'history'); }
#historyDir(id) {
const dir = path.resolve(this.historyRoot, id);
if (!dir.startsWith(this.historyRoot + path.sep)) throw new Error('That path is outside the notes folder.');
return dir;
}
async #versions(id) {
try {
return (await fsp.readdir(this.#historyDir(id))).filter((v) => /^\d+\.md$/.test(v)).map((v) => parseInt(v, 10)).sort((a, b) => b - a);
} catch { return []; }
}
// Keep `text` (the page's full file) as a version. force: skip the time gap (used before a restore).
async snapshot(id, text, { force = false } = {}) {
try {
const dir = this.#historyDir(id);
const [newest] = await this.#versions(id);
if (newest && !force) {
if (Date.now() - newest < limits.gap) return;
}
if (newest && (await fsp.readFile(path.join(dir, `${newest}.md`), 'utf8').catch(() => null)) === text) return;
await fsp.mkdir(dir, { recursive: true });
await fsp.writeFile(path.join(dir, `${Math.max(Date.now(), (newest || 0) + 1)}.md`), text, 'utf8');
const all = await this.#versions(id);
for (const v of all.slice(limits.keep)) await fsp.unlink(path.join(dir, `${v}.md`)).catch(() => {});
} catch (err) {
console.error('Could not save a version', err);
}
}
// Keep the file as it is on disk right now as a version (before something else overwrites it).
async keepCurrentVersion(id) {
const text = await fsp.readFile(this.abs(id), 'utf8');
await this.snapshot(id, text, { force: true });
}
listHistory(id) { return this.#versions(id); }
async readHistory(id, ts) {
if (!/^\d+$/.test(String(ts))) throw new Error('Unknown version.');
const text = await fsp.readFile(path.join(this.#historyDir(id), `${ts}.md`), 'utf8');
return isCanvasId(id) ? { meta: {}, body: text } : parseFrontMatter(text); // a canvas has no front matter
}
// Put version `ts` back. What's there now is kept as a version first, so a restore can itself be undone.
async restoreHistory(id, ts) {
const { meta, body } = await this.readHistory(id, ts);
try { await this.snapshot(id, await fsp.readFile(this.abs(id), 'utf8'), { force: true }); } catch { /* page file gone */ }
return this.write(id, meta, body);
}
// History follows a page (and its sub-pages) when it's renamed or moved.
async #moveHistory(id, next) {
const from = this.#historyDir(id), to = this.#historyDir(next);
if (!(await exists(from)) || (await exists(to))) return;
await fsp.mkdir(path.dirname(to), { recursive: true });
await fsp.rename(from, to).catch((err) => console.error('Could not move history', err));
}
async #freeName(parent, wanted, except, ext = '') {
const base = cleanTitle(wanted);
for (let n = 1; n < 1000; n++) {
const title = (n === 1 ? base : `${base} ${n}`) + ext;
const id = parent ? `${parent}/${title}` : title;
if (except && id.toLowerCase() === except.toLowerCase()) return id;
if (!(await exists(this.abs(id))) && !(await exists(this.dir(id)))) return id;
}
throw new Error('Could not find a free file name.');
}
async create(parent, title, meta = {}, body = '') {
noCanvasParent(parent);
if (parent) await fsp.mkdir(this.dir(parent), { recursive: true });
const id = await this.#freeName(parent, title || 'Untitled');
await this.write(id, { created: today(), ...meta }, body);
return id;
}
async createCanvas(parent, title) {
noCanvasParent(parent);
if (parent) await fsp.mkdir(this.dir(parent), { recursive: true });
const id = await this.#freeName(parent, title || 'Untitled canvas', undefined, '.canvas');
await this.write(id, {}, EMPTY_CANVAS);
return id;
}
// A copy of a page, board or canvas next to the original ("Name copy", "Name copy 2", …), with its sub-pages and
// pictures (the page's folder) when it has any. Returns the copy's id.
async duplicate(id) {
const src = this.abs(id);
if (!(await exists(src))) throw new Error('That page has no file to copy.');
const next = await this.#freeName(parentOf(id), titleOf(id) + ' copy', undefined, extOf(id));
await fsp.mkdir(path.dirname(this.abs(next)), { recursive: true });
if (!isCanvasId(id) && (await exists(this.dir(id)))) await fsp.cp(this.dir(id), this.dir(next), { recursive: true });
this.#touch(next);
await fsp.copyFile(src, this.abs(next));
this.#touch(next);
await this.snapshot(next, await fsp.readFile(this.abs(next), 'utf8'));
return next;
}
// The folder templates live in (made on demand, so there's something to open).
async ensureTemplatesDir() {
const dir = path.join(this.root, '.notes', 'templates');
await fsp.mkdir(dir, { recursive: true });
return dir;
}
async listTemplates() {
try {
const tplDir = path.join(this.root, '.notes', 'templates');
if (!(await exists(tplDir))) return [];
const files = await fsp.readdir(tplDir);
return files.filter(f => f.endsWith('.md')).map(f => f.slice(0, -3));
} catch {
return [];
}
}
async getTemplate(name) {
try {
const tplFile = path.join(this.root, '.notes', 'templates', name + '.md');
if (!(await exists(tplFile))) return null;
const raw = await fsp.readFile(tplFile, 'utf8');
return parseFrontMatter(raw);
} catch {
return null;
}
}
async rename(id, title) {
const parent = parentOf(id);
if (cleanTitle(title) === titleOf(id)) return id;
const next = await this.#freeName(parent, title, id, extOf(id));
if (next === id) return id;
this.#touch(id); this.#touch(next);
if (await exists(this.abs(id))) await fsp.rename(this.abs(id), this.abs(next));
if (!isCanvasId(id) && (await exists(this.dir(id)))) await fsp.rename(this.dir(id), this.dir(next));
await this.#moveHistory(id, next);
return next;
}
// Move a page (and its sub-pages) under another page, or to the top level when newParent is null.
// Images the page shows from its own folder go with it, so its relative links keep working.
async move(id, newParent) {
newParent = newParent || null;
if (newParent === parentOf(id)) return id;
if (newParent && (newParent === id || newParent.startsWith(id + '/'))) throw new Error('A page cannot go inside itself.');
noCanvasParent(newParent);
if (newParent) await fsp.mkdir(this.dir(newParent), { recursive: true });
const next = await this.#freeName(newParent, titleOf(id), undefined, extOf(id));
const from = path.dirname(this.abs(id)), to = path.dirname(this.abs(next));
this.#touch(id); this.#touch(next);
if (await exists(this.abs(id))) {
await fsp.rename(this.abs(id), this.abs(next));
if (!isCanvasId(next)) await this.#moveImages(next, from, to);
}
if (!isCanvasId(id) && (await exists(this.dir(id)))) await fsp.rename(this.dir(id), this.dir(next));
await this.#moveHistory(id, next);
return next;
}
async #moveImages(id, from, to) {
const { meta, body } = await this.read(id);
let changed = false;
const done = new Map(); // old path -> new name, for images shown more than once
const relocate = async (src) => {
if (!isLocalPath(src)) return src;
const old = path.resolve(from, src);
if (path.dirname(old) !== from) return src;
let name = done.get(old);
if (!name) {
if (!(await exists(old))) return src;
name = await freeFile(to, path.basename(old));
await fsp.rename(old, path.join(to, name));
done.set(old, name);
}
if (name !== path.basename(old)) changed = true;
return name;
};
// a picture used as the page's cover goes with it too
let nextMeta = meta;
if (typeof meta.cover === 'string' && /\.(png|jpe?g|gif|webp|avif|svg|bmp)$/i.test(meta.cover)) {
const name = await relocate(meta.cover);
if (name !== meta.cover) nextMeta = { ...meta, cover: name };
}
const out = [];
for (const line of body.split('\n')) {
let m = line.match(/^(\s*!\[[^\]]*\]\(\s*)(<[^>]+>|[^)\s]+)(.*)$/);
if (m) {
const raw = m[2].replace(/^<|>$/g, '');
let src = raw;
try { src = decodeURI(raw); } catch { /* as written */ }
const name = await relocate(src);
out.push(name === src ? line : m[1] + name.replace(/[ ()<>]/g, encodeURIComponent) + m[3]);
continue;
}
m = line.match(/^(\s*!\[\[)([^\]|]+)(.*)$/);
if (m) {
const name = await relocate(m[2].trim());
out.push(name === m[2].trim() ? line : m[1] + name + m[3]);
continue;
}
out.push(line);
}
if (changed) await this.write(id, nextMeta, out.join('\n'));
}
// Store image bytes next to page `id`; returns the file name to use in ![](name).
async saveImage(id, name, bytes) {
const dir = path.dirname(this.abs(id));
await fsp.mkdir(dir, { recursive: true });
const file = await freeFile(dir, cleanFileName(name));
await fsp.writeFile(path.join(dir, file), bytes);
return file;
}
// Absolute path of an image `src` as written in page `id`, or null if it isn't a file in the vault.
imagePath(id, src) {
if (!isLocalPath(src)) return null;
try { return this.#inside(path.resolve(path.dirname(this.abs(id)), src)); } catch { return null; }
}
/* trash
Deleted pages go to .notes/trash/<entry>/ so the app can list and restore them:
info.json { id, title, icon, pages, deletedAt }
files/ the page's .md and its folder of sub-pages, as they were
Deleting from there (or after 30 days) hands the entry to the system trash. */
get trashRoot() { return path.join(this.root, '.notes', 'trash'); }
// Move a page and its sub-pages to the trash. Returns the trash entry, or null if there was nothing to move.
async remove(id) {
const targets = isCanvasId(id) ? [this.abs(id)] : [this.abs(id), this.dir(id)]; // a canvas has no folder of sub-pages
const found = [];
for (const t of targets) if (await exists(t)) found.push(t);
if (!found.length) return null;
const page = await this.read(id);
const below = (await this.list()).filter((p) => p.exists && p.id.startsWith(id + '/')).length;
const entry = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
const files = path.join(this.trashRoot, entry, 'files');
await fsp.mkdir(files, { recursive: true });
this.#touch(id);
for (const t of found) await fsp.rename(t, path.join(files, path.basename(t)));
const info = { id, title: titleOf(id), icon: page.meta.icon || null, pages: below + (page.exists ? 1 : 0), deletedAt: Date.now() };
await fsp.writeFile(path.join(this.trashRoot, entry, 'info.json'), JSON.stringify(info, null, 2));
return entry;
}
async listTrash() {
let names;
try { names = await fsp.readdir(this.trashRoot); } catch { return []; }
const out = [];
for (const entry of names) {
try {
const info = JSON.parse(await fsp.readFile(path.join(this.trashRoot, entry, 'info.json'), 'utf8'));
out.push({ entry, ...info });
} catch { /* half-written entry: skip */ }
}
return out.sort((a, b) => b.deletedAt - a.deletedAt);
}
#entry(entry) {
if (!/^[a-z0-9-]+$/i.test(String(entry))) throw new Error('Unknown trash item.');
return path.join(this.trashRoot, entry);
}
// Put a trashed page back where it was (with a number added if that name is taken now). Returns its id.
async restore(entry) {
const base = this.#entry(entry);
const info = JSON.parse(await fsp.readFile(path.join(base, 'info.json'), 'utf8'));
const parent = parentOf(info.id);
if (parent) await fsp.mkdir(this.dir(parent), { recursive: true });
const next = await this.#freeName(parent, titleOf(info.id), undefined, extOf(info.id));
const title = titleOf(info.id);
this.#touch(next);
const md = path.join(base, 'files', path.basename(this.abs(info.id))), folder = path.join(base, 'files', title);
if (await exists(md)) await fsp.rename(md, this.abs(next));
if (await exists(folder)) await fsp.rename(folder, this.dir(next));
await fsp.rm(base, { recursive: true, force: true });
return next;
}
// Delete for good: to the system trash when there is one. No entry = everything.
async purge(entry) {
const entries = entry ? [entry] : (await this.listTrash()).map((t) => t.entry);
for (const e of entries) {
const base = this.#entry(e);
if (!(await exists(base))) continue;
if (this.trash) await this.trash(base);
else await fsp.rm(base, { recursive: true, force: true });
}
}
async purgeOlderThan(days) {
const cutoff = Date.now() - days * 864e5;
for (const t of await this.listTrash()) if (t.deletedAt < cutoff) await this.purge(t.entry);
}
// Calls back with the ids of pages changed outside the app.
// Calls onChange(ids) (debounced) when pages change on disk behind the app's back.
// One non-recursive watcher per note folder: Node's recursive watcher on Linux stopped delivering events once
// the app had created folders of its own (.notes/history), and it would also report all the churn inside .notes.
// Dot folders and the app's own are never watched.
watch(onChange) {
const pending = new Set();
const watchers = new Map(); // absolute folder -> FSWatcher
let timer = null;
let resyncTimer = null;
let closed = false;
const flush = () => {
const ids = [...pending].filter((id) => !this.isRecent(id));
pending.clear();
if (ids.length) onChange(ids);
};
const note = (id) => {
pending.add(id);
clearTimeout(timer);
timer = setTimeout(flush, 250);
};
const add = async (dir) => {
if (closed || watchers.has(dir)) return;
let w;
try {
w = fs.watch(dir, (evt, name) => onEvent(dir, evt, name));
} catch {
return;
}
w.on('error', () => { try { w.close(); } catch { /* gone */ } watchers.delete(dir); });
watchers.set(dir, w);
let entries = [];
try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
if (e.isDirectory() && !e.name.startsWith('.') && !SKIP_DIRS.has(e.name)) await add(path.join(dir, e.name));
}
};
// Folders that appeared, moved or vanished: look at the whole tree again (a watched folder that is renamed
// keeps reporting under its old name).
const resync = () => {
clearTimeout(resyncTimer);
resyncTimer = setTimeout(async () => {
if (closed) return;
for (const w of watchers.values()) { try { w.close(); } catch { /* gone */ } }
watchers.clear();
await add(this.root);
}, 200);
};
const onEvent = (dir, evt, name) => {
if (!name) return;
name = String(name);
if (name.startsWith('.')) return; // temp files, .notes, .obsidian, .git
const full = path.join(dir, name);
const rel = path.relative(this.root, full).split(path.sep).join('/');
if (SKIP_DIRS.has(name)) return;
note(rel.replace(/\.md$/i, ''));
if (evt === 'rename') {
fsp.stat(full).then((st) => { if (st.isDirectory()) resync(); }, () => { if (watchers.has(full) || !/\.[a-z0-9]+$/i.test(name)) resync(); });
}
};
add(this.root);
return () => {
closed = true;
clearTimeout(timer);
clearTimeout(resyncTimer);
for (const w of watchers.values()) { try { w.close(); } catch { /* gone */ } }
watchers.clear();
};
}
}
module.exports = { setLimits, Vault, cleanTitle, today, titleOf, parentOf, isCanvasId };