From 313c41f8678142935e611776d5f4453664536bdd Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 15 Oct 2026 11:57:09 +0200 Subject: [PATCH] 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 --- electron/backup.cjs | 134 ++++++ electron/canvas.cjs | 91 ++++ electron/capture-preload.cjs | 9 + electron/capture.html | 52 +++ electron/db.cjs | 115 +++++- electron/frontmatter.cjs | 68 +++ electron/imagefetch.cjs | 79 ++++ electron/importer.cjs | 148 +++++++ electron/linkpreview.cjs | 166 ++++++++ electron/links.cjs | 290 +++++++++++++ electron/main.cjs | 781 +++++++++++++++++++++++++++++++++++ electron/preload.cjs | 109 +++++ electron/seed.cjs | 50 +++ electron/settings.cjs | 39 ++ electron/tasks.cjs | 55 +++ electron/vault.cjs | 535 ++++++++++++++++++++++++ 16 files changed, 2706 insertions(+), 15 deletions(-) create mode 100644 electron/backup.cjs create mode 100644 electron/canvas.cjs create mode 100644 electron/capture-preload.cjs create mode 100644 electron/capture.html create mode 100644 electron/frontmatter.cjs create mode 100644 electron/imagefetch.cjs create mode 100644 electron/importer.cjs create mode 100644 electron/linkpreview.cjs create mode 100644 electron/links.cjs create mode 100644 electron/main.cjs create mode 100644 electron/preload.cjs create mode 100644 electron/seed.cjs create mode 100644 electron/settings.cjs create mode 100644 electron/tasks.cjs create mode 100644 electron/vault.cjs diff --git a/electron/backup.cjs b/electron/backup.cjs new file mode 100644 index 0000000..76bf40e --- /dev/null +++ b/electron/backup.cjs @@ -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 " .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 }; diff --git a/electron/canvas.cjs b/electron/canvas.cjs new file mode 100644 index 0000000..5f27d0f --- /dev/null +++ b/electron/canvas.cjs @@ -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 }; diff --git a/electron/capture-preload.cjs b/electron/capture-preload.cjs new file mode 100644 index 0000000..47e4223 --- /dev/null +++ b/electron/capture-preload.cjs @@ -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') +}); diff --git a/electron/capture.html b/electron/capture.html new file mode 100644 index 0000000..724a43c --- /dev/null +++ b/electron/capture.html @@ -0,0 +1,52 @@ + + + + + +Quick capture + + + + +
+
+ + +
+ Enter save · Shift+Enter new line · Esc close + +
+ + + diff --git a/electron/db.cjs b/electron/db.cjs index 6734913..a1d9dbc 100644 --- a/electron/db.cjs +++ b/electron/db.cjs @@ -12,51 +12,136 @@ function loadDriver() { const likeEscape = (s) => s.replace(/[\\%_]/g, (c) => '\\' + c); +// A page's own front matter keys; any other key is a database property (src/lib/database.js). +const NOT_PROPS = new Set(['icon', 'cover', 'tags', 'created', 'ink-database', 'kanban-plugin']); +// meta (or a page's `props`) -> [[key, value]], one pair per list item, values trimmed, empty ones dropped +function propPairs(props) { + const out = []; + for (const [k, v] of Object.entries(props || {})) { + if (NOT_PROPS.has(k)) continue; + for (const x of Array.isArray(v) ? v : [v]) { + const t = String(x ?? '').trim(); + if (t) out.push([k.toLowerCase(), t]); + } + } + return out; +} + function openDb(file) { const Database = loadDriver(); const db = new Database(file); + // An index made before properties were indexed has no `props` column: it's only a cache, so start it again. + try { + const cols = db.prepare('PRAGMA table_info(notes)').all().map((c) => c.name); + if (cols.length && !cols.includes('props')) db.exec('DROP TABLE notes'); + } catch { /* no table yet */ } db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS notes USING fts5( - id UNINDEXED, title, tags, body, tokenize = 'unicode61 remove_diacritics 2' + id UNINDEXED, title, tags, body, props, tokenize = 'unicode61 remove_diacritics 2' )`); + // property values as rows, for key:value searches + db.exec(`CREATE TABLE IF NOT EXISTS props (id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL)`); + db.exec('CREATE INDEX IF NOT EXISTS props_key ON props (key, value)'); + db.exec('CREATE INDEX IF NOT EXISTS props_id ON props (id)'); const del = db.prepare('DELETE FROM notes WHERE id = ?'); const delTree = db.prepare("DELETE FROM notes WHERE id = ? OR id LIKE ? ESCAPE '\\'"); - const ins = db.prepare('INSERT INTO notes (id, title, tags, body) VALUES (?, ?, ?, ?)'); - const find = db.prepare(`SELECT id, title, snippet(notes, 3, char(1), char(2), '…', 14) AS snip - FROM notes WHERE notes MATCH ? ORDER BY bm25(notes, 0.0, 8.0, 4.0, 1.0) LIMIT ?`); - const row = (p) => [p.id, p.title || '', (p.tags || []).join(' '), p.body || '']; + const ins = db.prepare('INSERT INTO notes (id, title, tags, body, props) VALUES (?, ?, ?, ?, ?)'); + const find = db.prepare(`SELECT id, title, snippet(notes, -1, char(1), char(2), '…', 14) AS snip + FROM notes WHERE notes MATCH ? ORDER BY bm25(notes, 0.0, 8.0, 4.0, 1.0, 3.0) LIMIT ?`); + const delProps = db.prepare('DELETE FROM props WHERE id = ?'); + const delPropsTree = db.prepare("DELETE FROM props WHERE id = ? OR id LIKE ? ESCAPE '\\'"); + const insProp = db.prepare('INSERT INTO props (id, key, value) VALUES (?, ?, ?)'); + const hasKey = db.prepare('SELECT 1 FROM props WHERE key = ? LIMIT 1'); + const propsOfId = db.prepare('SELECT key, value FROM props WHERE id = ? LIMIT 6'); + const titleOf = db.prepare('SELECT title FROM notes WHERE id = ?'); + const row = (p) => { + const pairs = propPairs(p.props); + return [p.id, p.title || '', (p.tags || []).join(' '), p.body || '', pairs.map(([, v]) => v).join(' ')]; + }; + const putProps = (p) => { for (const [k, v] of propPairs(p.props)) insProp.run(p.id, k, v.toLowerCase()); }; + // ids of pages that satisfy a `key:value` filter ({ key, op, value }); ops: '' (equals or starts with), '!', '>', '<', '>=', '<=' + const idsFor = (f) => { + const num = f.op === '>' || f.op === '<' || f.op === '>=' || f.op === '<='; + if (num) { + const n = Number(f.value); + if (!Number.isFinite(n)) return new Set(); + const sql = `SELECT DISTINCT id FROM props WHERE key = ? AND value GLOB '*[0-9]*' AND CAST(value AS REAL) ${f.op} ?`; + return new Set(db.prepare(sql).all(f.key, n).map((r) => r.id)); + } + const v = f.value.toLowerCase(); + const sql = "SELECT DISTINCT id FROM props WHERE key = ? AND (value = ? OR value LIKE ? ESCAPE '\\')"; + return new Set(db.prepare(sql).all(f.key, v, likeEscape(v) + '%').map((r) => r.id)); + }; return { - upsert(p) { del.run(p.id); ins.run(...row(p)); }, - remove(id) { delTree.run(id, likeEscape(id) + '/%'); }, + upsert(p) { del.run(p.id); delProps.run(p.id); ins.run(...row(p)); putProps(p); }, + remove(id) { delTree.run(id, likeEscape(id) + '/%'); delPropsTree.run(id, likeEscape(id) + '/%'); }, rebuild(pages) { db.exec('BEGIN'); try { db.exec('DELETE FROM notes'); - for (const p of pages) if (p.exists) ins.run(...row(p)); + db.exec('DELETE FROM props'); + for (const p of pages) if (p.exists) { ins.run(...row(p)); putProps(p); } db.exec('COMMIT'); } catch (err) { db.exec('ROLLBACK'); throw err; } }, + // Words, `tag:x`, `in:title`, and `property:value` for a database property (`status:Done`, `due:2026-03`, + // `points:>=5`, `owner:!Ana`, `status:"In progress"`, `has:due`). Property filters narrow what the words find; + // with only filters, every matching page is listed. search(q, limit = 20) { - const parts = String(q).match(/(?:tag:|in:|)\S+/g) || []; + const parts = String(q).match(/[^\s:"]+:"[^"]*"|"[^"]*"|\S+/g) || []; const terms = []; const fields = []; + const filters = []; for (const p of parts) { - if (p.startsWith('tag:')) { fields.push('tags: ' + p.slice(4).replace(/[^\w-]/g, '')); } - else if (p.startsWith('in:')) { fields.push('title: ' + p.slice(3).replace(/[^\w-]/g, '')); } + const m = p.match(/^([^\s:"]+):(.*)$/); + const key = m ? m[1].toLowerCase() : ''; + const val = m ? m[2].replace(/^"|"$/g, '').trim() : ''; + if (key === 'tag') { fields.push('tags: ' + val.replace(/[^\w-]/g, '')); } + else if (key === 'in') { fields.push('title: ' + val.replace(/[^\w-]/g, '')); } + else if (key === 'has' && val && hasKey.get(val.toLowerCase())) { filters.push({ key: val.toLowerCase(), has: true }); } + else if (m && val && hasKey.get(key)) { + const o = val.match(/^(>=|<=|>|<|!|=)\s*(.*)$/); + filters.push({ key, op: o ? (o[1] === '=' ? '' : o[1]) : '', value: o ? o[2] : val, not: o && o[1] === '!' }); + } else { terms.push(p); } } - + const words = (terms.join(' ').match(/[\p{L}\p{N}_]+/gu) || []).slice(0, 8); let match = words.map((t) => `"${t}"*`).join(' '); if (fields.length) { match = fields.map(f => `${f.split(':')[0]}: "${f.split(':')[1].trim()}"*`).join(' ') + (match ? ' ' + match : ''); } - if (!match) return []; + if (!match && !filters.length) return []; + + // property filters -> the ids that pass all of them + let allowed = null; + for (const f of filters) { + let ids; + if (f.has) ids = new Set(db.prepare('SELECT DISTINCT id FROM props WHERE key = ?').all(f.key).map((r) => r.id)); + else if (f.not) { + // everything that has properties at all (the records), except those with that value + const hit = idsFor({ ...f, op: '' }); + ids = new Set(db.prepare('SELECT DISTINCT id FROM props').all().map((r) => r.id).filter((id) => !hit.has(id))); + } else ids = idsFor(f); + allowed = allowed ? new Set([...allowed].filter((id) => ids.has(id))) : ids; + } try { - return find.all(match, limit).map((r) => ({ id: r.id, title: r.title, snip: r.snip })); + if (match) { + const rows = find.all(match, allowed ? 1000 : limit).map((r) => ({ id: r.id, title: r.title, snip: r.snip })); + return (allowed ? rows.filter((r) => allowed.has(r.id)) : rows).slice(0, limit); + } + // only property filters: list the matching pages, showing the values that matched + const out = []; + for (const id of allowed) { + const t = titleOf.get(id); + if (!t) continue; + const shown = propsOfId.all(id).map((r) => `${r.key}: ${r.value}`).join(' · '); + out.push({ id, title: t.title, snip: shown }); + } + return out.sort((a, b) => a.title.localeCompare(b.title)).slice(0, limit); } catch { return []; } @@ -65,4 +150,4 @@ function openDb(file) { }; } -module.exports = { openDb }; +module.exports = { openDb, propPairs }; diff --git a/electron/frontmatter.cjs b/electron/frontmatter.cjs new file mode 100644 index 0000000..489bc59 --- /dev/null +++ b/electron/frontmatter.cjs @@ -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 }; diff --git a/electron/imagefetch.cjs b/electron/imagefetch.cjs new file mode 100644 index 0000000..87701e0 --- /dev/null +++ b/electron/imagefetch.cjs @@ -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 }; diff --git a/electron/importer.cjs b/electron/importer.cjs new file mode 100644 index 0000000..55007e8 --- /dev/null +++ b/electron/importer.cjs @@ -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 /. 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 }; diff --git a/electron/linkpreview.cjs b/electron/linkpreview.cjs new file mode 100644 index 0000000..c13c425 --- /dev/null +++ b/electron/linkpreview.cjs @@ -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(/]*>/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(/]*>([\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 = /]+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 }; diff --git a/electron/links.cjs b/electron/links.cjs new file mode 100644 index 0000000..8c40422 --- /dev/null +++ b/electron/links.cjs @@ -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.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 }; diff --git a/electron/main.cjs b/electron/main.cjs new file mode 100644 index 0000000..92045c3 --- /dev/null +++ b/electron/main.cjs @@ -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/?src= + { 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(); +}); diff --git a/electron/preload.cjs b/electron/preload.cjs new file mode 100644 index 0000000..35e7ff7 --- /dev/null +++ b/electron/preload.cjs @@ -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); + } +}); diff --git a/electron/seed.cjs b/electron/seed.cjs new file mode 100644 index 0000000..fa8825a --- /dev/null +++ b/electron/seed.cjs @@ -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 Ctrl K 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 + +
+Where do my files actually live? + +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. + +
+ +> 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 }; diff --git a/electron/settings.cjs b/electron/settings.cjs new file mode 100644 index 0000000..1c5792a --- /dev/null +++ b/electron/settings.cjs @@ -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 }; diff --git a/electron/tasks.cjs b/electron/tasks.cjs new file mode 100644 index 0000000..5bd6d79 --- /dev/null +++ b/electron/tasks.cjs @@ -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 }; diff --git a/electron/vault.cjs b/electron/vault.cjs new file mode 100644 index 0000000..be33142 --- /dev/null +++ b/electron/vault.cjs @@ -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//