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:
1 parent
0b5f8bfa8b
commit
313c41f867
16 files changed
+2706
-15
No files matched your search
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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')
|
||||||
|
});
|
||||||
@@ -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
@@ -12,51 +12,136 @@ function loadDriver() {
|
|||||||
|
|
||||||
const likeEscape = (s) => s.replace(/[\\%_]/g, (c) => '\\' + c);
|
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) {
|
function openDb(file) {
|
||||||
const Database = loadDriver();
|
const Database = loadDriver();
|
||||||
const db = new Database(file);
|
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(
|
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 del = db.prepare('DELETE FROM notes WHERE id = ?');
|
||||||
const delTree = db.prepare("DELETE FROM notes WHERE id = ? OR id LIKE ? ESCAPE '\\'");
|
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 ins = db.prepare('INSERT INTO notes (id, title, tags, body, props) VALUES (?, ?, ?, ?, ?)');
|
||||||
const find = db.prepare(`SELECT id, title, snippet(notes, 3, char(1), char(2), '…', 14) AS snip
|
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) LIMIT ?`);
|
FROM notes WHERE notes MATCH ? ORDER BY bm25(notes, 0.0, 8.0, 4.0, 1.0, 3.0) LIMIT ?`);
|
||||||
const row = (p) => [p.id, p.title || '', (p.tags || []).join(' '), p.body || ''];
|
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 {
|
return {
|
||||||
upsert(p) { del.run(p.id); ins.run(...row(p)); },
|
upsert(p) { del.run(p.id); delProps.run(p.id); ins.run(...row(p)); putProps(p); },
|
||||||
remove(id) { delTree.run(id, likeEscape(id) + '/%'); },
|
remove(id) { delTree.run(id, likeEscape(id) + '/%'); delPropsTree.run(id, likeEscape(id) + '/%'); },
|
||||||
rebuild(pages) {
|
rebuild(pages) {
|
||||||
db.exec('BEGIN');
|
db.exec('BEGIN');
|
||||||
try {
|
try {
|
||||||
db.exec('DELETE FROM notes');
|
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');
|
db.exec('COMMIT');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
db.exec('ROLLBACK');
|
db.exec('ROLLBACK');
|
||||||
throw err;
|
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) {
|
search(q, limit = 20) {
|
||||||
const parts = String(q).match(/(?:tag:|in:|)\S+/g) || [];
|
const parts = String(q).match(/[^\s:"]+:"[^"]*"|"[^"]*"|\S+/g) || [];
|
||||||
const terms = [];
|
const terms = [];
|
||||||
const fields = [];
|
const fields = [];
|
||||||
|
const filters = [];
|
||||||
for (const p of parts) {
|
for (const p of parts) {
|
||||||
if (p.startsWith('tag:')) { fields.push('tags: ' + p.slice(4).replace(/[^\w-]/g, '')); }
|
const m = p.match(/^([^\s:"]+):(.*)$/);
|
||||||
else if (p.startsWith('in:')) { fields.push('title: ' + p.slice(3).replace(/[^\w-]/g, '')); }
|
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); }
|
else { terms.push(p); }
|
||||||
}
|
}
|
||||||
|
|
||||||
const words = (terms.join(' ').match(/[\p{L}\p{N}_]+/gu) || []).slice(0, 8);
|
const words = (terms.join(' ').match(/[\p{L}\p{N}_]+/gu) || []).slice(0, 8);
|
||||||
let match = words.map((t) => `"${t}"*`).join(' ');
|
let match = words.map((t) => `"${t}"*`).join(' ');
|
||||||
if (fields.length) {
|
if (fields.length) {
|
||||||
match = fields.map(f => `${f.split(':')[0]}: "${f.split(':')[1].trim()}"*`).join(' ') + (match ? ' ' + match : '');
|
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 {
|
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 {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -65,4 +150,4 @@ function openDb(file) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { openDb };
|
module.exports = { openDb, propPairs };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 .
|
||||||
|
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 };
|
||||||
Reference in new issue
Block a user