'use strict'; // Full-text search index in /.notes/index.sqlite (SQLite FTS5). // The Markdown files are the source of truth; this file can be deleted and is rebuilt on launch. // Uses better-sqlite3 in the app; falls back to node:sqlite so the tests run on plain Node 22. function loadDriver() { try { return require('better-sqlite3'); } catch { const { DatabaseSync } = require('node:sqlite'); return DatabaseSync; } } 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, 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, 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); 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'); 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(/[^\s:"]+:"[^"]*"|"[^"]*"|\S+/g) || []; const terms = []; const fields = []; const filters = []; for (const p of parts) { 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 && !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 { 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 []; } }, close() { try { db.close(); } catch { /* already closed */ } } }; } module.exports = { openDb, propPairs };