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 }); } }); test('version history works for canvases: the file text comes back exactly, and it follows a rename', async () => { const root = await tmp(); try { const v = new Vault(root); const id = await v.createCanvas(null, 'Map'); const first = '{\n\t"nodes":[\n\t\t{"id":"aaaaaaaaaaaaaaaa","type":"text","text":"---\\nnot front matter","x":0,"y":0,"width":250,"height":60}\n\t],\n\t"edges":[]\n}'; // making the canvas kept its empty state as the first version; age that so the next save makes a new one const dir = path.join(root, '.notes', 'history', id); const [t0] = await v.listHistory(id); await writeFile(path.join(dir, `${t0 - 10 * 60 * 1000}.md`), await readFile(path.join(dir, `${t0}.md`), 'utf8')); await rm(path.join(dir, `${t0}.md`)); await v.write(id, {}, first); const [ts] = await v.listHistory(id); assert.equal((await v.readHistory(id, ts)).body, first, 'the file text comes back as saved, not read as front matter'); const second = '{\n\t"nodes":[],\n\t"edges":[]\n}'; await v.write(id, {}, second.replace('[]', '[ ]')); // within the gap: no version of its own await v.restoreHistory(id, ts); assert.equal(await readFile(v.abs(id), 'utf8'), first, 'written back byte for byte, without front matter added'); const kept = await v.listHistory(id); assert.ok((await Promise.all(kept.map((t) => v.readHistory(id, t)))).some((x) => x.body === second.replace('[]', '[ ]')), 'the canvas before the restore was kept'); const next = await v.rename(id, 'Roadmap'); assert.equal(next, 'Roadmap.canvas'); assert.deepEqual(await v.listHistory(next), kept); assert.deepEqual(await v.listHistory(id), []); } finally { await rm(root, { recursive: true, force: true }); } }); test('duplicate: a page (with its sub-pages), a board and a canvas, next to the original', async () => { const root = await tmp(); try { const v = new Vault(root); const id = await v.create(null, 'Plan', { icon: 'star' }, 'Hello\n'); const child = await v.create(id, 'Child', {}, 'inside\n'); const pic = await v.saveImage(id, 'pic.png', Buffer.from([1, 2, 3])); const copy = await v.duplicate(id); assert.equal(copy, 'Plan copy'); assert.deepEqual(await readFile(v.abs(copy), 'utf8'), await readFile(v.abs(id), 'utf8'), 'the file is copied as it is'); assert.equal((await v.read(`${copy}/Child`)).body, 'inside\n', 'sub-pages come along'); assert.ok(v.imagePath(copy, pic) && (await readFile(v.imagePath(copy, pic))).length === 3, 'pictures (kept beside the page) are still found by the copy'); // the copy is its own page await v.write(copy, {}, 'changed\n'); assert.equal((await v.read(id)).body, 'Hello\n'); assert.equal((await v.read(child)).body, 'inside\n'); // again: a free name assert.equal(await v.duplicate(id), 'Plan copy 2'); // the copy of a copy assert.equal(await v.duplicate(copy), 'Plan copy copy'); // inside a parent: stays there assert.equal(await v.duplicate(child), `${id}/Child copy`); // a canvas keeps its text exactly const cv = await v.createCanvas(null, 'Map'); const text = '{\n\t"nodes":[\n\t\t{"id":"aaaaaaaaaaaaaaaa","type":"text","text":"hi","x":0,"y":0,"width":250,"height":60}\n\t],\n\t"edges":[]\n}'; await v.write(cv, {}, text); const cvCopy = await v.duplicate(cv); assert.equal(cvCopy, 'Map copy.canvas'); assert.equal(await readFile(v.abs(cvCopy), 'utf8'), text); assert.equal((await v.listHistory(cvCopy)).length, 1, 'the copy starts its own history'); // nothing to copy await assert.rejects(v.duplicate('Nope'), /no file to copy/); } finally { await rm(root, { recursive: true, force: true }); } }); test('a database record keeps its property values in front matter, and the list reports them', async () => { const root = await tmp(); const v = new Vault(root); const db = await v.create(null, 'Tasks', { 'ink-database': 'basic' }, '%% ink-database\n{}\n%%\n'); const rec = await v.create(db, 'Write', { status: 'Doing', due: '2026-03-01', owner: ['Ana', 'Ben'], done: 'true', 'due-date': '2026-04-02' }); const pages = await v.list(); const d = pages.find((p) => p.id === db), r = pages.find((p) => p.id === rec); assert.equal(d.db, true); assert.equal(r.db, undefined); assert.deepEqual(r.props, { status: 'Doing', due: '2026-03-01', owner: ['Ana', 'Ben'], done: 'true', 'due-date': '2026-04-02' }); assert.equal(r.parent, 'Tasks'); assert.equal(r.tags.length, 0); // page keys are not properties // a value change keeps every other key and the body const before = await v.read(rec); await v.write(rec, { ...before.meta, status: 'Done' }, 'Notes\n'); const after = (await v.list()).find((p) => p.id === rec); assert.equal(after.props.status, 'Done'); assert.deepEqual(after.props.owner, ['Ana', 'Ben']); assert.equal((await v.read(rec)).body, 'Notes\n'); await rm(root, { recursive: true, force: true }); }); test('property values are found as words and by key:value, with the same operators as tag: and in:', async () => { const dir = await tmp(); const db = openDb(path.join(dir, 'i.sqlite')); db.upsert({ id: 'T/Write docs', title: 'Write docs', tags: ['work'], body: 'Some text', props: { status: 'Doing', points: '5', owner: ['Ana', 'Ben'], due: '2026-03-01' } }); db.upsert({ id: 'T/Ship it', title: 'Ship it', tags: [], body: 'Release', props: { status: 'In progress', points: '12', owner: ['Ben'] } }); db.upsert({ id: 'T/Plan', title: 'Plan', tags: [], body: 'Roadmap', props: {} }); const ids = (q) => db.search(q).map((r) => r.id).sort(); assert.deepEqual(ids('Ana'), ['T/Write docs']); // a value, as a plain word assert.deepEqual(ids('status:doing'), ['T/Write docs']); assert.deepEqual(ids('status:do'), ['T/Write docs']); // like tag:, a prefix matches assert.deepEqual(ids('status:"In progress"'), ['T/Ship it']); assert.deepEqual(ids('owner:ben'), ['T/Ship it', 'T/Write docs']); assert.deepEqual(ids('owner:!ana'), ['T/Ship it']); assert.deepEqual(ids('points:>5'), ['T/Ship it']); assert.deepEqual(ids('points:<=5'), ['T/Write docs']); assert.deepEqual(ids('has:due'), ['T/Write docs']); assert.deepEqual(ids('owner:ben release'), ['T/Ship it']); // words narrow it too assert.deepEqual(ids('tag:work status:doing'), ['T/Write docs']); assert.deepEqual(ids('status:done'), []); assert.deepEqual(ids('nokey:value'), []); // not a property: searched as words, which match nothing assert.match(db.search('status:doing')[0].snip, /status: doing/); // a changed value replaces the old one; a removed page leaves no values behind db.upsert({ id: 'T/Write docs', title: 'Write docs', tags: [], body: '', props: { status: 'Done' } }); assert.deepEqual(ids('status:doing'), []); assert.deepEqual(ids('status:done'), ['T/Write docs']); db.remove('T'); assert.deepEqual(ids('status:done'), []); db.close(); await rm(dir, { recursive: true, force: true }); }); test('an index made before properties were indexed is started again', async () => { const dir = await tmp(); const file = path.join(dir, 'old.sqlite'); const { DatabaseSync } = require('node:sqlite'); const old = new DatabaseSync(file); old.exec("CREATE VIRTUAL TABLE notes USING fts5(id UNINDEXED, title, tags, body, tokenize = 'unicode61 remove_diacritics 2')"); old.exec("INSERT INTO notes (id, title, tags, body) VALUES ('A', 'Old', '', 'old words')"); old.close(); const db = openDb(file); assert.deepEqual(db.search('old'), []); // emptied; the app rebuilds it on launch db.rebuild([{ id: 'A', title: 'Old', tags: [], body: 'old words', exists: true, props: { status: 'Done' } }]); assert.deepEqual(db.search('status:done').map((r) => r.id), ['A']); db.close(); await rm(dir, { recursive: true, force: true }); }); test('links in property values and relation properties are links', () => { const links = require('../electron/links.cjs'); const schema = '%% ink-database\n{"properties":[{"key":"after","name":"After","type":"relation"},{"key":"ref","name":"Ref","type":"text"}],"views":[]}\n%%\n'; const pages = [ { id: 'Tasks', title: 'Tasks', exists: true, parent: null, body: schema, props: {} }, { id: 'Tasks/A', title: 'A', exists: true, parent: 'Tasks', body: '', props: { after: ['B'], ref: 'see [[Spec]]' } }, { id: 'Tasks/B', title: 'B', exists: true, parent: 'Tasks', body: '', props: {} }, { id: 'Spec', title: 'Spec', exists: true, parent: null, body: '', props: {} } ]; const g = links.graph(pages); assert.ok(g.edges.some(([a, b]) => a === 'Tasks/A' && b === 'Spec'), 'link in a property value'); assert.ok(g.edges.some(([a, b]) => a === 'Tasks/A' && b === 'Tasks/B'), 'relation'); assert.deepEqual(links.backlinks('Spec', pages).map((b) => b.id), ['Tasks/A']); assert.deepEqual(links.backlinks('Tasks/B', pages).map((b) => [b.id, b.snip]), [['Tasks/A', 'After']]); const after = pages.map((p) => (p.id === 'Spec' ? { ...p, id: 'Specs', title: 'Specs' } : p)); const patch = links.rewriteProps(pages[1].props, pages, after, (id) => (id === 'Spec' ? 'Specs' : null)); assert.deepEqual(patch, { ref: 'see [[Specs]]' }); assert.equal(links.rewriteProps({ after: ['B'] }, pages, after, () => null), null); }); test('the watcher reports changes made outside, also after the app has saved and moved things', async () => { const root = await tmp(); const v = new Vault(root); await v.create(null, 'A', {}, 'a\n'); await mkdir(path.join(root, 'Sub')); await writeFile(path.join(root, 'Sub', 'Inner.md'), 'inner\n'); const seen = []; const stop = v.watch((ids) => seen.push(...ids)); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const expectId = async (id, label) => { for (let i = 0; i < 30 && !seen.includes(id); i++) await sleep(100); assert.ok(seen.includes(id), `${label}: expected ${id}, saw ${JSON.stringify(seen)}`); seen.length = 0; }; await sleep(300); await writeFile(path.join(root, 'Outside.md'), 'x'); await expectId('Outside', 'a new file'); await writeFile(path.join(root, 'Sub', 'Inner.md'), 'changed\n'); await expectId('Sub/Inner', 'a file in a folder'); // the app saves (which makes .notes/history): outside changes must still come through await sleep(1700); // past the "just written by us" window await v.write('A', {}, 'mine\n'); await sleep(1700); seen.length = 0; await writeFile(path.join(root, 'Outside.md'), 'again'); await expectId('Outside', 'after the app saved'); // a folder made later is watched too await mkdir(path.join(root, 'Later')); await sleep(500); await writeFile(path.join(root, 'Later', 'New.md'), 'n'); await expectId('Later/New', 'a folder created after the watch began'); // a renamed folder reports under its new name await rm(path.join(root, 'Sub', 'Inner.md')); await sleep(300); seen.length = 0; await (await import('node:fs/promises')).rename(path.join(root, 'Later'), path.join(root, 'Moved')); await sleep(900); seen.length = 0; await writeFile(path.join(root, 'Moved', 'New.md'), 'edited'); await expectId('Moved/New', 'a renamed folder'); // own writes and dot folders stay quiet seen.length = 0; await sleep(1700); await v.write('A', {}, 'again\n'); await sleep(900); assert.deepEqual(seen, [], 'our own write is not reported'); stop(); await rm(root, { recursive: true, force: true }); });