69 lines
2.6 KiB
JavaScript
69 lines
2.6 KiB
JavaScript
'use strict';
|
|
// Full-text search index in <vault>/.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);
|
|
|
|
function openDb(file) {
|
|
const Database = loadDriver();
|
|
const db = new Database(file);
|
|
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS notes USING fts5(
|
|
id UNINDEXED, title, tags, body, tokenize = 'unicode61 remove_diacritics 2'
|
|
)`);
|
|
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 || ''];
|
|
|
|
return {
|
|
upsert(p) { del.run(p.id); ins.run(...row(p)); },
|
|
remove(id) { delTree.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('COMMIT');
|
|
} catch (err) {
|
|
db.exec('ROLLBACK');
|
|
throw err;
|
|
}
|
|
},
|
|
search(q, limit = 20) {
|
|
const parts = String(q).match(/(?:tag:|in:|)\S+/g) || [];
|
|
const terms = [];
|
|
const fields = [];
|
|
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, '')); }
|
|
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 [];
|
|
try {
|
|
return find.all(match, limit).map((r) => ({ id: r.id, title: r.title, snip: r.snip }));
|
|
} catch {
|
|
return [];
|
|
}
|
|
},
|
|
close() { try { db.close(); } catch { /* already closed */ } }
|
|
};
|
|
}
|
|
|
|
module.exports = { openDb };
|