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

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

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

No files matched your search

+100 -15
View File
@@ -12,51 +12,136 @@ function loadDriver() {
const likeEscape = (s) => s.replace(/[\\%_]/g, (c) => '\\' + c);
// A page's own front matter keys; any other key is a database property (src/lib/database.js).
const NOT_PROPS = new Set(['icon', 'cover', 'tags', 'created', 'ink-database', 'kanban-plugin']);
// meta (or a page's `props`) -> [[key, value]], one pair per list item, values trimmed, empty ones dropped
function propPairs(props) {
const out = [];
for (const [k, v] of Object.entries(props || {})) {
if (NOT_PROPS.has(k)) continue;
for (const x of Array.isArray(v) ? v : [v]) {
const t = String(x ?? '').trim();
if (t) out.push([k.toLowerCase(), t]);
}
}
return out;
}
function openDb(file) {
const Database = loadDriver();
const db = new Database(file);
// An index made before properties were indexed has no `props` column: it's only a cache, so start it again.
try {
const cols = db.prepare('PRAGMA table_info(notes)').all().map((c) => c.name);
if (cols.length && !cols.includes('props')) db.exec('DROP TABLE notes');
} catch { /* no table yet */ }
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS notes USING fts5(
id UNINDEXED, title, tags, body, tokenize = 'unicode61 remove_diacritics 2'
id UNINDEXED, title, tags, body, props, tokenize = 'unicode61 remove_diacritics 2'
)`);
// property values as rows, for key:value searches
db.exec(`CREATE TABLE IF NOT EXISTS props (id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL)`);
db.exec('CREATE INDEX IF NOT EXISTS props_key ON props (key, value)');
db.exec('CREATE INDEX IF NOT EXISTS props_id ON props (id)');
const del = db.prepare('DELETE FROM notes WHERE id = ?');
const delTree = db.prepare("DELETE FROM notes WHERE id = ? OR id LIKE ? ESCAPE '\\'");
const ins = db.prepare('INSERT INTO notes (id, title, tags, body) VALUES (?, ?, ?, ?)');
const find = db.prepare(`SELECT id, title, snippet(notes, 3, char(1), char(2), '…', 14) AS snip
FROM notes WHERE notes MATCH ? ORDER BY bm25(notes, 0.0, 8.0, 4.0, 1.0) LIMIT ?`);
const row = (p) => [p.id, p.title || '', (p.tags || []).join(' '), p.body || ''];
const ins = db.prepare('INSERT INTO notes (id, title, tags, body, props) VALUES (?, ?, ?, ?, ?)');
const find = db.prepare(`SELECT id, title, snippet(notes, -1, char(1), char(2), '…', 14) AS snip
FROM notes WHERE notes MATCH ? ORDER BY bm25(notes, 0.0, 8.0, 4.0, 1.0, 3.0) LIMIT ?`);
const delProps = db.prepare('DELETE FROM props WHERE id = ?');
const delPropsTree = db.prepare("DELETE FROM props WHERE id = ? OR id LIKE ? ESCAPE '\\'");
const insProp = db.prepare('INSERT INTO props (id, key, value) VALUES (?, ?, ?)');
const hasKey = db.prepare('SELECT 1 FROM props WHERE key = ? LIMIT 1');
const propsOfId = db.prepare('SELECT key, value FROM props WHERE id = ? LIMIT 6');
const titleOf = db.prepare('SELECT title FROM notes WHERE id = ?');
const row = (p) => {
const pairs = propPairs(p.props);
return [p.id, p.title || '', (p.tags || []).join(' '), p.body || '', pairs.map(([, v]) => v).join(' ')];
};
const putProps = (p) => { for (const [k, v] of propPairs(p.props)) insProp.run(p.id, k, v.toLowerCase()); };
// ids of pages that satisfy a `key:value` filter ({ key, op, value }); ops: '' (equals or starts with), '!', '>', '<', '>=', '<='
const idsFor = (f) => {
const num = f.op === '>' || f.op === '<' || f.op === '>=' || f.op === '<=';
if (num) {
const n = Number(f.value);
if (!Number.isFinite(n)) return new Set();
const sql = `SELECT DISTINCT id FROM props WHERE key = ? AND value GLOB '*[0-9]*' AND CAST(value AS REAL) ${f.op} ?`;
return new Set(db.prepare(sql).all(f.key, n).map((r) => r.id));
}
const v = f.value.toLowerCase();
const sql = "SELECT DISTINCT id FROM props WHERE key = ? AND (value = ? OR value LIKE ? ESCAPE '\\')";
return new Set(db.prepare(sql).all(f.key, v, likeEscape(v) + '%').map((r) => r.id));
};
return {
upsert(p) { del.run(p.id); ins.run(...row(p)); },
remove(id) { delTree.run(id, likeEscape(id) + '/%'); },
upsert(p) { del.run(p.id); delProps.run(p.id); ins.run(...row(p)); putProps(p); },
remove(id) { delTree.run(id, likeEscape(id) + '/%'); delPropsTree.run(id, likeEscape(id) + '/%'); },
rebuild(pages) {
db.exec('BEGIN');
try {
db.exec('DELETE FROM notes');
for (const p of pages) if (p.exists) ins.run(...row(p));
db.exec('DELETE FROM props');
for (const p of pages) if (p.exists) { ins.run(...row(p)); putProps(p); }
db.exec('COMMIT');
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
},
// Words, `tag:x`, `in:title`, and `property:value` for a database property (`status:Done`, `due:2026-03`,
// `points:>=5`, `owner:!Ana`, `status:"In progress"`, `has:due`). Property filters narrow what the words find;
// with only filters, every matching page is listed.
search(q, limit = 20) {
const parts = String(q).match(/(?:tag:|in:|)\S+/g) || [];
const parts = String(q).match(/[^\s:"]+:"[^"]*"|"[^"]*"|\S+/g) || [];
const terms = [];
const fields = [];
const filters = [];
for (const p of parts) {
if (p.startsWith('tag:')) { fields.push('tags: ' + p.slice(4).replace(/[^\w-]/g, '')); }
else if (p.startsWith('in:')) { fields.push('title: ' + p.slice(3).replace(/[^\w-]/g, '')); }
const m = p.match(/^([^\s:"]+):(.*)$/);
const key = m ? m[1].toLowerCase() : '';
const val = m ? m[2].replace(/^"|"$/g, '').trim() : '';
if (key === 'tag') { fields.push('tags: ' + val.replace(/[^\w-]/g, '')); }
else if (key === 'in') { fields.push('title: ' + val.replace(/[^\w-]/g, '')); }
else if (key === 'has' && val && hasKey.get(val.toLowerCase())) { filters.push({ key: val.toLowerCase(), has: true }); }
else if (m && val && hasKey.get(key)) {
const o = val.match(/^(>=|<=|>|<|!|=)\s*(.*)$/);
filters.push({ key, op: o ? (o[1] === '=' ? '' : o[1]) : '', value: o ? o[2] : val, not: o && o[1] === '!' });
}
else { terms.push(p); }
}
const words = (terms.join(' ').match(/[\p{L}\p{N}_]+/gu) || []).slice(0, 8);
let match = words.map((t) => `"${t}"*`).join(' ');
if (fields.length) {
match = fields.map(f => `${f.split(':')[0]}: "${f.split(':')[1].trim()}"*`).join(' ') + (match ? ' ' + match : '');
}
if (!match) return [];
if (!match && !filters.length) return [];
// property filters -> the ids that pass all of them
let allowed = null;
for (const f of filters) {
let ids;
if (f.has) ids = new Set(db.prepare('SELECT DISTINCT id FROM props WHERE key = ?').all(f.key).map((r) => r.id));
else if (f.not) {
// everything that has properties at all (the records), except those with that value
const hit = idsFor({ ...f, op: '' });
ids = new Set(db.prepare('SELECT DISTINCT id FROM props').all().map((r) => r.id).filter((id) => !hit.has(id)));
} else ids = idsFor(f);
allowed = allowed ? new Set([...allowed].filter((id) => ids.has(id))) : ids;
}
try {
return find.all(match, limit).map((r) => ({ id: r.id, title: r.title, snip: r.snip }));
if (match) {
const rows = find.all(match, allowed ? 1000 : limit).map((r) => ({ id: r.id, title: r.title, snip: r.snip }));
return (allowed ? rows.filter((r) => allowed.has(r.id)) : rows).slice(0, limit);
}
// only property filters: list the matching pages, showing the values that matched
const out = [];
for (const id of allowed) {
const t = titleOf.get(id);
if (!t) continue;
const shown = propsOfId.all(id).map((r) => `${r.key}: ${r.value}`).join(' · ');
out.push({ id, title: t.title, snip: shown });
}
return out.sort((a, b) => a.title.localeCompare(b.title)).slice(0, limit);
} catch {
return [];
}
@@ -65,4 +150,4 @@ function openDb(file) {
};
}
module.exports = { openDb };
module.exports = { openDb, propPairs };