feat(electron): main process, vault, search index, links, import, capture
Vault on plain .md files with FTS5 index, link rewriting, canvas files, history/trash/backup, link previews, image fetch, tasks and unlinked mentions, Notion/Obsidian importer, quick-capture window and shortcut. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0b5f8bfa8b
commit
313c41f867
16 files changed
+2706
-15
No files matched your search
+100
-15
@@ -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 };
|
||||
Reference in new issue
Block a user