feat: Add search operators tag: and in:
This commit is contained in:
1 parent
81925934e3
commit
b94693ddce
2 files changed
+326
No files matched your search
@@ -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, {}, `\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, '\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 });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user