// THE SEARCH SUITE (v1.8, section J): folding, operators, and the vault-wide // replace built on both. // // The three are one feature to the reader — they all live in the same box — so // they are tested together, and the tests that matter most are the ones about // what did NOT happen: a fold that never reaches the write path, a preview that // is the operation, a frontmatter fence the replace cannot see. import assert from "node:assert/strict"; import { readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { after, before, describe, it } from "node:test"; import { findAnyMatches, findMatches, foldKeep, foldTerm } from "../shared/fold.ts"; import { parseSearchQuery, propQuery, SEARCH_OPERATORS, searchScope } from "../shared/searchQuery.ts"; import { applyBulk, clearUndoBundles, undoBulk } from "../server/bulkRewrite.ts"; import { initIndexer, props, queryPaths, replaceCandidates, search, searchMatches, searchTerms, } from "../server/indexer.ts"; import { nearbyNotes } from "../server/nearby.ts"; import { makeBodyTest, previewReplace, replaceTransform, screenTargets, } from "../server/searchReplace.ts"; import { initSite } from "../server/site.ts"; import { initVault } from "../server/vault.ts"; import { makeDir, makeVault, note, removeVault } from "./helpers/vault.ts"; // ================================================================ the fold describe("fold: what counts as the same letter", () => { it("strips harakat so the plain spelling is the pointed one", () => { assert.equal(foldTerm("الْمُقَدِّمَة"), foldTerm("المقدمة")); }); it("folds the alef family, ى/ي, ة/ه and the Persian letters", () => { for (const [a, b] of [ ["أحمد", "احمد"], ["إسلام", "اسلام"], ["آخر", "اخر"], ["علی", "علي"], // Persian yeh ["مصطفى", "مصطفي"], ["مدينة", "مدينه"], ["کتاب", "كتاب"], // Persian keheh ]) { assert.equal(foldTerm(a), foldTerm(b), `${a} should fold onto ${b}`); } }); it("folds Latin accents — résumé is resume, composed OR decomposed", () => { // BOTH normalisations, and the composed one is the case that actually // reaches this code: a filename typed on a Mac, a sentence pasted out of a // browser, anything that has been through NFC carries `é` as ONE code // point, and a fold that only knew the combining block sailed past it. for (const [a, b] of [["résumé", "resume"], ["naïve", "naive"], ["café", "cafe"], ["señor", "senor"]]) { for (const form of ["NFC", "NFD"] as const) { assert.equal(foldTerm(a.normalize(form)), b, `${a} (${form})`); assert.equal(foldTerm(a.normalize(form).toUpperCase()), b, `${a} upper (${form})`); } } }); it("does NOT fold a Hangul syllable onto its initial consonant", () => { // Hangul decomposes under NFD too, into jamo that are letters in their own // right — taking the first one would make every syllable starting with ᄀ // the same word. assert.notEqual(foldTerm("\uac00"), foldTerm("\uac01")); assert.equal(foldKeep("\uac00").length, 1); }); it("finds a composed accent in running text, at the right offset", () => { const line = "a café in Lyon"; const [hit] = findMatches(line, "cafe"); assert.ok(hit); assert.equal(line.slice(hit.start, hit.end), "café"); }); it("drops tatweel, which is a stretch and not a letter", () => { assert.equal(foldTerm("كــــتاب"), foldTerm("كتاب")); }); it("returns nothing for a term that is nothing but marks", () => { // An empty term in the index is a term every query matches, which is why // the caller drops it rather than filing it. assert.equal(foldTerm("ًـ‍"), ""); }); it("foldKeep preserves length, because its caller reports indices", () => { for (const text of ["الْمُقَدِّمَة", "résumé".normalize("NFD"), "Plain ASCII", "مصطفى"]) { assert.equal(foldKeep(text).length, text.length, text); } }); it("foldKeep still folds the letter families it can do in place", () => { assert.equal(foldKeep("مدينة"), foldKeep("مدينه")); }); it("findMatches reports offsets into the UNTOUCHED string", () => { const line = "باب الْمُقَدِّمَة في الكتاب"; const [hit] = findMatches(line, "المقدمة"); assert.ok(hit, "the plain spelling must find the pointed word"); assert.equal(line.slice(hit.start, hit.end), "الْمُقَدِّمَة"); }); it("findAnyMatches merges overlapping needles rather than nesting them", () => { const hits = findAnyMatches("the note and the notebook", ["note", "notebook"]); // Both needles hit at 17. The longer one wins and the scan resumes past // it, so the word is not marked twice with one span inside the other. assert.deepEqual(hits.map((h) => [h.start, h.end]), [[4, 8], [17, 25]]); }); }); // ============================================================== the operators describe("search operators: the grammar", () => { it("peels the operators off and leaves the words", () => { const parsed = parseSearchQuery("tag:recipes cumin -is:published"); assert.equal(parsed.text, "cumin"); assert.deepEqual( parsed.filters.map((f) => [f.kind, f.value, f.negated]), [["tag", "recipes", false], ["is", "published", true]], ); }); it("accepts a quoted value, because folders have spaces in them", () => { const parsed = parseSearchQuery('path:"Reading notes" salt'); assert.equal(parsed.text, "salt"); assert.equal(parsed.filters[0].value, "reading notes"); }); it("takes the # off a tag the reader copied from a pill", () => { assert.equal(parseSearchQuery("tag:#recipes").filters[0].value, "recipes"); }); it("parses the date operators to the start of the named period, in UTC", () => { const [before] = parseSearchQuery("before:2024").filters; assert.equal(before.ms, Date.UTC(2024, 0, 1)); const [after] = parseSearchQuery("after:2024-06-15").filters; assert.equal(after.ms, Date.UTC(2024, 5, 15)); }); it("AN OPERATOR THAT DOES NOT PARSE IS A WORD — never a query matching nothing", () => { for (const raw of ["before:soon", "is:blue", "tag:", "2024-06-15", "http://x.test/a"]) { const parsed = parseSearchQuery(raw); assert.deepEqual(parsed.filters, [], raw); assert.equal(parsed.text, raw, raw); } }); it("refuses a date that rolled forward into the next month", () => { assert.deepEqual(parseSearchQuery("before:2024-02-31").filters, []); }); it("leaves a minus in front of a plain word alone", () => { assert.equal(parseSearchQuery("-draft").text, "-draft"); }); it("names every operator it parses, for the help popover", () => { for (const op of SEARCH_OPERATORS) { const value = op === "is" ? "page" : op === "in" ? "books" : op === "before" || op === "after" ? "2024" : "x"; assert.equal(parseSearchQuery(`${op}:${value}`).filters.length, 1, op); } }); // `in:` picks an INDEX rather than narrowing one, so it is read off the // filters as a scope (shared/searchQuery.ts::searchScope) by both sides. it("in:books and in:notes choose a scope, and a value it does not know is a word", () => { assert.equal(searchScope(parseSearchQuery("cumin").filters), "all"); assert.equal(searchScope(parseSearchQuery("in:books cumin").filters), "books"); assert.equal(searchScope(parseSearchQuery("in:notes cumin").filters), "notes"); assert.equal(searchScope(parseSearchQuery("-in:books cumin").filters), "notes"); assert.equal(searchScope(parseSearchQuery("-in:notes cumin").filters), "books"); // AND, like everything else: nothing is both. assert.equal(searchScope(parseSearchQuery("in:books in:notes").filters), "none"); const word = parseSearchQuery("in:drawer cumin"); assert.deepEqual(word.filters, []); assert.equal(word.text, "in:drawer cumin"); // Case does not matter to the operator, as with is:. assert.equal(searchScope(parseSearchQuery("IN:Books").filters), "books"); }); }); // ================================================== the index, on a real vault const data = makeDir(); const root = makeVault({ "Books/Muqaddima.md": note( { title: "المقدمة", date: "2021-03-04" }, "كتاب الْمُقَدِّمَة لابن خلدون، وفيه الْعُمْران.\n\nSee also [[Ledger]].\n", ), "Recipes/Dal.md": note( { tags: "[recipes, dinner]", date: "2024-05-01" }, "A pot of dal with cumin and salt.\nMore cumin than you think.\n", ), "Recipes/Soup.md": note( { tags: "recipes", publish: "true", date: "2023-01-02" }, "Soup with cumin.\n", ), "Ledger.md": note({ page: "true" }, "The ledger links to [[Dal]] and nothing else.\n"), "Resume.md": note({}, "My résumé, and a café in Lyon.\n"), "Notes/Quiet.md": "No frontmatter at all. Cumin is capitalised nowhere here.\n", }); before(async () => { initSite({ ASTROLABE_DATA: data }); initVault(root); await initIndexer(); }); after(() => { clearUndoBundles(); removeVault(root); removeVault(data); }); const paths = (hits: { path: string }[]): string[] => hits.map((h) => h.path).sort(); describe("search: the fold reaches the index", () => { it('"المقدمة" finds the note that spells it with every haraka', () => { assert.ok(paths(search("المقدمة", false, null)).includes("Books/Muqaddima.md")); }); it("the pointed spelling finds the plain one too — the fold runs both ways", () => { assert.ok(paths(search("الْعُمْران", false, null)).includes("Books/Muqaddima.md")); }); it("resume finds résumé, and cafe finds café", () => { assert.ok(paths(search("resume", false, null)).includes("Resume.md")); assert.ok(paths(search("cafe", false, null)).includes("Resume.md")); }); it("marks the pointed word in the line it quotes back", () => { const [line] = searchMatches("Books/Muqaddima.md", "المقدمة", false, null); assert.ok(line, "the expansion under a hit must find the line too"); assert.match(line.text, /الْمُقَدِّمَة<\/mark>/); }); }); describe("search: the operators, against real notes", () => { it("a query of nothing but operators lists every note it narrows to", () => { assert.deepEqual(paths(search("tag:recipes", false, null)), [ "Recipes/Dal.md", "Recipes/Soup.md", ]); }); it("narrows a term search", () => { assert.deepEqual(paths(search("cumin path:Recipes", false, null)), [ "Recipes/Dal.md", "Recipes/Soup.md", ]); }); it("is:published and is:page read the frontmatter flags", () => { assert.deepEqual(paths(search("is:published", false, null)), ["Recipes/Soup.md"]); assert.deepEqual(paths(search("is:page", false, null)), ["Ledger.md"]); }); it("negation removes rather than narrowing to nothing", () => { assert.deepEqual(paths(search("tag:recipes -is:published", false, null)), ["Recipes/Dal.md"]); }); it("the date operators bracket a year", () => { assert.deepEqual(paths(search("tag:recipes after:2024", false, null)), ["Recipes/Dal.md"]); assert.deepEqual(paths(search("tag:recipes before:2024", false, null)), ["Recipes/Soup.md"]); }); it("linkto: is the notes that point AT it; linkfrom: is what it points to", () => { assert.deepEqual(paths(search("linkto:Ledger", false, null)), ["Books/Muqaddima.md"]); assert.deepEqual(paths(search("linkfrom:Ledger", false, null)), ["Recipes/Dal.md"]); }); it("a link operator naming nothing answers with nothing, not with everything", () => { assert.deepEqual(search("linkto:NoSuchNote", false, null), []); }); it("AND, not OR: two filters both have to hold", () => { assert.deepEqual(search("tag:recipes is:page", false, null), []); }); it("keeps the operators out of the line scanner's needles", () => { assert.deepEqual(searchTerms("tag:recipes cumin"), ["cumin"]); }); it("a visitor's operator search is still a visitor's search", () => { // `is:page` matches Ledger, which is not published — an anonymous caller // must not be able to enumerate it through the operator layer. assert.deepEqual(search("is:page", true, null), []); }); }); describe("the properties shelf: propQuery and /api/props", () => { it("builds the token the parser reads back, quoting a value with a space", () => { assert.equal(propQuery("status"), "prop:status"); assert.equal(propQuery("status", "reading"), "prop:status=reading"); assert.equal(propQuery("status", 'in "progress"'), 'prop:status="in progress"'); const parsed = parseSearchQuery(propQuery("status", "in progress")); assert.deepEqual([parsed.filters[0].key, parsed.filters[0].value, parsed.text], ["status", "in progress", ""]); }); it("lists every key with a count and its values, tags left to their own shelf", () => { const rows = props(false, null); const keys = rows.map((r) => r.key); assert.ok(!keys.includes("tags"), keys.join(",")); const date = rows.find((r) => r.key === "date"); assert.ok(date); assert.equal(date.count, 3); assert.equal(date.values.length, 3); // A list value counts once per item, the way `prop:key=value` matches. const publish = rows.find((r) => r.key === "publish"); assert.deepEqual(publish?.values, [{ value: "true", count: 1 }]); // The keys with the most notes come first. assert.equal(keys[0], "date"); }); it("a visitor's shelf is the published notes' shelf", () => { const rows = props(true, null); assert.deepEqual( rows.map((r) => r.key).sort(), ["date", "publish"], ); }); it("queryPaths answers a query with every path and nothing for no query", () => { assert.deepEqual(queryPaths("tag:recipes", false, null).sort(), ["Recipes/Dal.md", "Recipes/Soup.md"]); assert.deepEqual(queryPaths("cumin", false, null).sort(), ["Notes/Quiet.md", "Recipes/Dal.md", "Recipes/Soup.md"]); assert.deepEqual(queryPaths("", false, null), []); assert.deepEqual(queryPaths(" ", false, null), []); assert.deepEqual(queryPaths("is:page", true, null), []); }); }); describe("nearby: the corpus around shared/nearby.ts", () => { it("finds the other recipe from cumin and the shared tag, naming both", () => { const hits = nearbyNotes("Recipes/Dal.md"); assert.ok(hits.length > 0); assert.equal(hits[0].path, "Recipes/Soup.md"); assert.ok(hits[0].terms.includes("#recipes") || hits[0].terms.includes("cumin"), hits[0].terms.join(",")); assert.ok(hits.every((h) => h.path !== "Recipes/Dal.md")); }); it("an unknown path is nobody's neighbour", () => { assert.deepEqual(nearbyNotes("Nowhere.md"), []); }); }); // ================================================== the vault-wide replace const spec = (find: string, replace: string, regex = false) => ({ find, replace, regex }); describe("replace: the transform", () => { it("rewrites the body and never the frontmatter", () => { const src = note({ title: "cumin study", tags: "[cumin]" }, "cumin here\n"); const out = replaceTransform(spec("cumin", "coriander"), null)("N.md", src); assert.equal(out?.count, 1); assert.equal(out?.text, note({ title: "cumin study", tags: "[cumin]" }, "coriander here\n")); }); it("is case-sensitive, because a replace is a write", () => { assert.equal(replaceTransform(spec("cumin", "x"), null)("N.md", "Cumin\n"), null); }); it("does NOT fold — the one matcher in v1.8 that must not", () => { // Finding is a question and folding widens it kindly. Replacing would be // stripping harakat the reader never typed and never saw. assert.equal(replaceTransform(spec("المقدمة", "x"), null)("N.md", "الْمُقَدِّمَة\n"), null); }); it("treats $ in a literal replacement as a dollar sign", () => { const out = replaceTransform(spec("PRICE", "$5"), null)("N.md", "PRICE\n"); assert.equal(out?.text, "$5\n"); }); it("regex mode honours capture groups and per-line anchors", () => { const out = replaceTransform(spec("^(\\w+): (\\d+)", "$2 — $1", true), null)( "N.md", "alpha: 1\nbeta: 2\nnot a match\n", ); assert.equal(out?.text, "1 — alpha\n2 — beta\nnot a match\n"); assert.equal(out?.count, 2); }); it("refuses a pattern that matches the empty string", () => { assert.throws(() => replaceTransform(spec("a*", "x", true), null)("N.md", "b"), /everywhere/); }); it("refuses an invalid pattern, a newline, and an empty needle", () => { assert.throws(() => replaceTransform(spec("([", "x", true), null)("N.md", ""), /valid/); assert.throws(() => replaceTransform(spec("a\nb", "x"), null)("N.md", ""), /one line/); assert.throws(() => replaceTransform(spec("", "x"), null)("N.md", ""), /find/i); }); it("touches only the lines the reader ticked", () => { const src = note({ a: "1" }, "cumin one\ncumin two\ncumin three\n"); // The fence is three lines, so the body starts at line 4. const selection = new Map([["N.md", new Set([5])]]); const out = replaceTransform(spec("cumin", "salt"), selection)("N.md", src); assert.equal(out?.count, 1); assert.equal(out?.text, note({ a: "1" }, "cumin one\nsalt two\ncumin three\n")); }); it("a file absent from the selection is not touched at all", () => { const selection = new Map | null>([["Other.md", null]]); assert.equal(replaceTransform(spec("cumin", "salt"), selection)("N.md", "cumin\n"), null); }); }); describe("replace: nomination and preview", () => { it("nominates only the notes whose BODY holds the needle, within the scope", () => { assert.deepEqual(replaceCandidates("tag:recipes", makeBodyTest(spec("cumin", "x"))), [ "Recipes/Dal.md", "Recipes/Soup.md", ]); }); it("without operators the scope is the whole vault", () => { assert.deepEqual(replaceCandidates("", makeBodyTest(spec("cumin", "x"))), [ "Recipes/Dal.md", "Recipes/Soup.md", ]); }); it("previews the exact lines, numbered as the editor counts them", async () => { const preview = await previewReplace(["Recipes/Dal.md"], spec("cumin", "coriander")); assert.equal(preview.notes, 1); assert.equal(preview.edits, 2); const [file] = preview.files; assert.equal(file.path, "Recipes/Dal.md"); assert.ok(file.mtimeMs > 0, "every previewed file carries the mtime it was read at"); assert.deepEqual(file.lines.map((l) => l.line), [5, 6]); assert.equal(file.lines[0].before, "A pot of dal with cumin and salt."); assert.equal(file.lines[0].after, "A pot of dal with coriander and salt."); }); }); describe("replace: apply, conflict, undo", () => { const dalAbs = path.join(root, "Recipes/Dal.md"); it("applies what was previewed and takes it back", async () => { const before = readFileSync(dalAbs, "utf8"); const preview = await previewReplace(["Recipes/Dal.md"], spec("cumin", "coriander")); const { paths: ok, selection, conflicts } = await screenTargets( preview.files.map((f) => ({ path: f.path, mtimeMs: f.mtimeMs, lines: null })), ); assert.deepEqual(conflicts, []); const result = await applyBulk(ok, replaceTransform(spec("cumin", "coriander"), selection)); assert.equal(result.notes, 1); assert.equal(result.edits, 2); assert.match(readFileSync(dalAbs, "utf8"), /coriander/); assert.ok(result.undoId, "a replace this size must be undoable"); await undoBulk(result.undoId); assert.equal(readFileSync(dalAbs, "utf8"), before); }); it("REFUSES A FILE THAT MOVED since the preview, and names it", async () => { const preview = await previewReplace(["Recipes/Dal.md"], spec("cumin", "coriander")); // Somebody else saves the note while the reader is reading the preview. const stale = readFileSync(dalAbs, "utf8"); writeFileSync(dalAbs, `${stale}An extra line.\n`); const screened = await screenTargets( preview.files.map((f) => ({ path: f.path, mtimeMs: f.mtimeMs, lines: null })), ); assert.deepEqual(screened.conflicts, ["Recipes/Dal.md"]); assert.deepEqual(screened.paths, []); writeFileSync(dalAbs, stale); }); it("refuses a path that climbs out of the vault", async () => { await assert.rejects( screenTargets([{ path: "../../etc/passwd", mtimeMs: 1, lines: null }]), /outside|invalid|escape/i, ); }); }); describe("presentation forms fold to the letters a reader types", () => { it("finds a shaped text layer by its plain spelling, with real offsets", () => { // What a Chromium-made PDF's text layer carries: shaped glyphs, not letters. const shaped = "\u0627\u0645\u0631\u0624 \ufe8d\ufedf\ufed8\ufef4\ufeb2 — \ufee1\ufeeb"; const hits = findMatches(shaped, "القيس"); assert.equal(hits.length, 1); assert.equal(shaped.slice(hits[0].start, hits[0].end), "\ufe8d\ufedf\ufed8\ufef4\ufeb2"); }); it("a lam-alef ligature is the two letters, a shaped vowel is nothing, a NUL is nothing", () => { assert.equal(findMatches("\ufefb \u0628\u064f\u062f\ufe73", "لا").length, 1); assert.equal(findMatches("\u0628\u0000\u062f", "بد").length, 1); assert.equal(foldTerm("\ufefb\ufe73"), "لا"); }); it("foldKeep keeps its length across a ligature and a shaped vowel", () => { const text = "\ufefb\ufe73\ufeb2"; assert.equal(foldKeep(text).length, text.length); }); });