From b94693ddcea326a831a6f5218f47f82fa73b1796 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 9 Oct 2026 23:05:56 +0200 Subject: [PATCH] feat: Add search operators tag: and in: --- electron/db.cjs | 68 ++++++++++++ test/vault.test.js | 258 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 electron/db.cjs create mode 100644 test/vault.test.js diff --git a/electron/db.cjs b/electron/db.cjs new file mode 100644 index 0000000..6734913 --- /dev/null +++ b/electron/db.cjs @@ -0,0 +1,68 @@ +'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); + +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 }; diff --git a/test/vault.test.js b/test/vault.test.js new file mode 100644 index 0000000..4068417 --- /dev/null +++ b/test/vault.test.js @@ -0,0 +1,258 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { mkdtemp, readFile, writeFile, mkdir, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +const require = createRequire(import.meta.url); +const { Vault, cleanTitle } = require('../electron/vault.cjs'); +const { parseFrontMatter, serializeFrontMatter } = require('../electron/frontmatter.cjs'); +const { openDb } = require('../electron/db.cjs'); +const { seedVault, isEmpty } = require('../electron/seed.cjs'); + +const tmp = () => mkdtemp(path.join(tmpdir(), 'notes-test-')); + +test('front matter parses and serializes, keeping unknown keys', () => { + const raw = '---\ntitle: "Hello: world"\ntags: [dev, todo]\naliases:\n - one\n - two\nicon: pen\n---\n\nBody here\n'; + const { meta, body } = parseFrontMatter(raw); + assert.deepEqual(meta, { title: 'Hello: world', tags: ['dev', 'todo'], aliases: ['one', 'two'], icon: 'pen' }); + assert.equal(body, 'Body here\n'); + const out = serializeFrontMatter(meta); + assert.equal(out, '---\nicon: pen\ntags: [dev, todo]\ntitle: "Hello: world"\naliases: [one, two]\n---\n\n'); + assert.deepEqual(parseFrontMatter(out + 'x').meta, meta); + assert.deepEqual(parseFrontMatter('no front matter').meta, {}); + assert.equal(serializeFrontMatter({ tags: [], icon: '' }), ''); +}); + +test('titles are made safe for file names', () => { + assert.equal(cleanTitle('a/b:c*d?'), 'a-b-c-d-'); + assert.equal(cleanTitle(' '), 'Untitled'); + assert.equal(cleanTitle('..hidden'), 'hidden'); +}); + +test('vault creates, writes, nests, renames and removes pages', async () => { + const root = await tmp(); + try { + const v = new Vault(root); + const a = await v.create(null, 'Projects', { icon: 'compass' }); + assert.equal(a, 'Projects'); + const b = await v.create('Projects', 'Roadmap'); + assert.equal(b, 'Projects/Roadmap'); + const b2 = await v.create('Projects', 'Roadmap'); + assert.equal(b2, 'Projects/Roadmap 2'); + + await v.write(b, { tags: ['dev'], created: '2026-10-07' }, '- [ ] ship\n'); + const raw = await readFile(path.join(root, 'Projects', 'Roadmap.md'), 'utf8'); + assert.equal(raw, '---\ntags: [dev]\ncreated: 2026-10-07\n---\n\n- [ ] ship\n'); + + const read = await v.read(b); + assert.equal(read.body, '- [ ] ship\n'); + assert.deepEqual(read.meta.tags, ['dev']); + + // a loose folder shows up as a page that has no file yet + await mkdir(path.join(root, 'Loose')); + await writeFile(path.join(root, 'Loose', 'Inner.md'), 'hi'); + await writeFile(path.join(root, '.hidden.md'), 'skip me'); + const list = await v.list(); + const ids = list.map((p) => p.id).sort(); + assert.deepEqual(ids, ['Loose', 'Loose/Inner', 'Projects', 'Projects/Roadmap', 'Projects/Roadmap 2']); + assert.equal(list.find((p) => p.id === 'Loose').exists, false); + assert.equal(list.find((p) => p.id === 'Projects/Roadmap').parent, 'Projects'); + + // renaming a parent moves its folder too + const renamed = await v.rename('Projects', 'Work'); + assert.equal(renamed, 'Work'); + assert.deepEqual((await readdir(root)).filter((n) => !n.startsWith('.')).sort(), ['Loose', 'Work', 'Work.md']); + assert.equal((await v.read('Work/Roadmap')).body, '- [ ] ship\n'); + assert.equal(await v.rename('Work', 'Work'), 'Work'); + + await v.remove('Work'); + assert.deepEqual((await readdir(root)).filter((n) => !n.startsWith('.')), ['Loose']); + + assert.throws(() => v.abs('../escape'), /outside the notes folder/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('search index finds words in titles, tags and bodies', async () => { + const root = await tmp(); + try { + const db = openDb(path.join(root, 'index.sqlite')); + db.rebuild([ + { id: 'Reading list', title: 'Reading list', tags: ['books'], body: 'The Design of Everyday Things', exists: true }, + { id: 'Journal/October 7', title: 'October 7', tags: ['journal'], body: 'Rainy morning. Sketched the layout.', exists: true }, + { id: 'Ghost', title: 'Ghost', tags: [], body: '', exists: false } + ]); + assert.equal(db.search('everyday')[0].id, 'Reading list'); + assert.equal(db.search('rain')[0].id, 'Journal/October 7'); + assert.equal(db.search('tag:journal')[0].id, 'Journal/October 7'); + assert.equal(db.search('in:read')[0].id, 'Reading list'); + assert.match(db.search('rain')[0].snip, /\u0001Rainy\u0002/); + assert.equal(db.search('books')[0].id, 'Reading list'); + assert.deepEqual(db.search(' '), []); + assert.deepEqual(db.search('"*)('), []); + + db.upsert({ id: 'Journal/October 7', title: 'October 7', tags: [], body: 'Sunny now.' }); + assert.equal(db.search('rain').length, 0); + assert.equal(db.search('sunny').length, 1); + + db.remove('Journal'); + assert.equal(db.search('sunny').length, 0); + db.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('first run seeds an empty folder only', async () => { + const root = await tmp(); + try { + assert.equal(await isEmpty(root), true); + await seedVault(root); + assert.equal(await isEmpty(root), false); + const v = new Vault(root); + const page = await v.read('Getting started'); + assert.equal(page.meta.icon, 'sparkle'); + assert.match(page.body, /## First steps/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('watcher reports outside edits and ignores our own writes', async () => { + const root = await tmp(); + try { + const v = new Vault(root); + const seen = []; + const stop = v.watch((ids) => seen.push(...ids)); + await v.write('Mine', {}, 'by the app'); + await writeFile(path.join(root, 'Theirs.md'), 'by another editor'); + await new Promise((r) => setTimeout(r, 700)); + stop(); + assert.ok(seen.includes('Theirs'), `saw ${seen}`); + assert.ok(!seen.includes('Mine'), `saw ${seen}`); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('moving a page takes its sub-pages and images along', async () => { + const root = await tmp(); + try { + const v = new Vault(root); + await v.create(null, 'Inbox'); + await v.create(null, 'Projects'); + const id = await v.create('Inbox', 'Trip'); + await v.create('Inbox/Trip', 'Packing'); + const pic = await v.saveImage(id, 'beach photo.png', Buffer.from('png')); + assert.equal(pic, 'beach photo.png'); + await v.write(id, {}, `![](beach%20photo.png)\n\n![[beach photo.png]]\n`); + const next = await v.move(id, 'Projects'); + assert.equal(next, 'Projects/Trip'); + assert.equal(await readFile(path.join(root, 'Projects', 'beach photo.png'), 'utf8'), 'png'); + assert.equal((await v.read('Projects/Trip/Packing')).exists, true); + assert.equal((await v.read(next)).body, '![](beach%20photo.png)\n\n![[beach photo.png]]\n'); + assert.equal(v.imagePath(next, 'beach photo.png'), path.join(root, 'Projects', 'beach photo.png')); + assert.equal(v.imagePath(next, '../../etc/passwd'), null); + assert.equal(v.imagePath(next, 'https://example.com/a.png'), null); + // back to the top level, where a clash renames the image and rewrites the link + await writeFile(path.join(root, 'beach photo.png'), 'other'); + const top = await v.move(next, null); + assert.equal(top, 'Trip'); + assert.match((await v.read(top)).body, /^!\[\]\(beach%20photo%202\.png\)\n\n!\[\[beach photo 2\.png\]\]/); + await assert.rejects(v.move('Projects', 'Projects'), /inside itself/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('links are rewritten after a move and listed as backlinks', () => { + const { rewriteLinks, backlinks, findLinks } = require('../electron/links.cjs'); + const page = (id, body = '') => ({ id, title: id.split('/').pop(), parent: null, exists: true, body }); + const before = [page('Plan'), page('Projects'), page('Notes', 'See [[Plan]] and [[Plan#Goals|goals]].\n```\n[[Plan]]\n```\n![[Plan.png]]')]; + const after = [page('Projects/Plan'), page('Projects'), page('Notes', before[2].body), page('Other/Plan')]; + const moved = (id) => (id === 'Plan' ? 'Projects/Plan' : null); + assert.equal( + rewriteLinks(before[2].body, before, after, moved), + 'See [[Projects/Plan]] and [[Projects/Plan#Goals|goals]].\n```\n[[Plan]]\n```\n![[Plan.png]]' + ); + assert.equal(rewriteLinks('nothing here', before, after, moved), null); + assert.equal(findLinks(before[2].body).length, 2); + const bl = backlinks('Plan', before); + assert.deepEqual(bl.map((b) => [b.id, b.snip]), [['Notes', 'See Plan and goals.']]); +}); + +test('trash keeps deleted pages and puts them back', async () => { + const root = await tmp(); + try { + const sent = []; + const v = new Vault(root, { trash: async (p) => { sent.push(path.basename(p)); await rm(p, { recursive: true, force: true }); } }); + await v.create(null, 'Projects', { icon: 'compass' }); + await v.create('Projects', 'Plan'); + await v.write('Projects/Plan', {}, 'the plan\n'); + assert.equal(await v.remove('Nope'), null); + const entry = await v.remove('Projects'); + assert.deepEqual((await v.list()).map((p) => p.id), []); + const [t] = await v.listTrash(); + assert.equal(t.entry, entry); + assert.equal(t.title, 'Projects'); + assert.equal(t.icon, 'compass'); + assert.equal(t.pages, 2); + // the name was taken again meanwhile, so it comes back numbered + await v.create(null, 'Projects'); + const back = await v.restore(entry); + assert.equal(back, 'Projects 2'); + assert.equal((await v.read('Projects 2/Plan')).body, 'the plan\n'); + assert.deepEqual(await v.listTrash(), []); + // purge hands entries to the system trash; old ones go on their own + const e1 = await v.remove('Projects'); + await v.remove('Projects 2'); + await v.purge(e1); + assert.deepEqual(sent, [e1]); + assert.equal((await v.listTrash()).length, 1); + await v.purgeOlderThan(30); + assert.equal((await v.listTrash()).length, 1); + await v.purge(null); + assert.deepEqual(await v.listTrash(), []); + await assert.rejects(v.restore('../../etc'), /Unknown trash item/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('version history: throttled copies, restore keeps the current text, follows renames', async () => { + const root = await tmp(); + try { + const v = new Vault(root); + const id = await v.create(null, 'Plan'); + await v.write(id, {}, 'one\n'); + await v.write(id, {}, 'two\n'); // within 5 minutes of the first copy: no new version + let versions = await v.listHistory(id); + assert.equal(versions.length, 1); + // pretend that copy is old, so the next save makes a new one + const dir = path.join(root, '.notes', 'history', 'Plan'); + const old = versions[0] - 10 * 60 * 1000; + await writeFile(path.join(dir, `${old}.md`), (await readFile(path.join(dir, `${versions[0]}.md`), 'utf8'))); + await rm(path.join(dir, `${versions[0]}.md`)); + await v.write(id, {}, 'three\n'); + versions = await v.listHistory(id); + assert.equal(versions.length, 2); + assert.equal((await v.readHistory(id, versions[0])).body, 'three\n'); + // restore the oldest: what's there now ("three") stays restorable + await v.write(id, {}, 'four\n'); + await v.restoreHistory(id, old); + assert.equal((await v.read(id)).body, ''); + const after = await v.listHistory(id); + assert.ok((await Promise.all(after.map((t) => v.readHistory(id, t)))).some((x) => x.body === 'four\n'), 'the text before the restore was kept'); + // history moves with the page + const next = await v.rename(id, 'Roadmap'); + assert.deepEqual(await v.listHistory(next), after); + assert.deepEqual(await v.listHistory(id), []); + await assert.rejects(v.readHistory(next, '../../x'), /Unknown version/); + await assert.rejects(v.listHistory('../../etc').then((x) => { if (x.length) throw new Error('listed'); throw new Error('outside'); }), /outside/); + } finally { + await rm(root, { recursive: true, force: true }); + } +});