The panel beside a PDF is for notes about the document; a note that belongs to one paragraph is a different thing, and one that has to begin "on page 14, third paragraph" has already lost its place. Click Randnotiz, click the passage, write. Pins ride in the same per-page overlay as the drawings, in PDF points, so one stays on its sentence at every zoom and in the exported file. Living outside the note body has consequences that each needed handling: nothing else would index them for search, so the viewer feeds them in with their page numbers; clearing a page's ink must not take them; and "is this overlay empty?" was answered by counting strokes — in the client *and* independently in the server, so an overlay holding only pinned notes was stored as NULL and lost every note on it. They are stamped into the annotated export as a numbered marker plus a box of text, on whichever side of the pin has room. Standard PDF fonts are Latin-1, so characters outside it are replaced rather than failing the whole export. The Notizen control moves into the viewer's toolbar. A second row for one button cost reading height on every screen and put one of the document's controls somewhere none of the others were. On a phone, where the viewer is not on screen while the notes are, the way back sits in the panel. Renaming a separator no longer shifts the row: the two rules always share what is left over equally and the middle keeps one width in both states. 215 unit tests, 206 browser checks across eleven suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
210 lines
8.4 KiB
TypeScript
210 lines
8.4 KiB
TypeScript
import { describe, it, expect, beforeEach } from "vitest";
|
|
|
|
import * as search from "./search";
|
|
import { serializeNoteContent } from "./editor/content";
|
|
|
|
const body = (text: string) =>
|
|
serializeNoteContent({
|
|
type: "doc",
|
|
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
|
});
|
|
|
|
const seed = () => {
|
|
search.removeEntry("a");
|
|
search.removeEntry("b");
|
|
search.removeEntry("c");
|
|
search.setEntry("a", "Haushalt", body("Der Kühlschrank braucht eine neue Dichtung."));
|
|
search.setEntry("b", "Finanzen", body("Steuererklärung abgeben, Belege liegen im Ordner."));
|
|
search.setEntry("c", "Dichtung", body("Nur der Titel enthält das Wort."));
|
|
};
|
|
|
|
beforeEach(seed);
|
|
|
|
describe("search", () => {
|
|
it("returns null for an empty query, meaning 'no search'", () => {
|
|
expect(search.search("")).toBeNull();
|
|
expect(search.search(" ")).toBeNull();
|
|
});
|
|
|
|
it("finds a word that appears only in the body", () => {
|
|
const hits = search.search("Belege");
|
|
expect(hits?.map((h) => h.id)).toEqual(["b"]);
|
|
});
|
|
|
|
it("ranks a title match above a body match", () => {
|
|
const hits = search.search("Dichtung");
|
|
expect(hits?.map((h) => h.id)).toEqual(["c", "a"]);
|
|
});
|
|
|
|
it("requires every term to match", () => {
|
|
expect(search.search("Belege Ordner")?.map((h) => h.id)).toEqual(["b"]);
|
|
expect(search.search("Belege Kühlschrank")).toEqual([]);
|
|
});
|
|
|
|
it("ignores case", () => {
|
|
expect(search.search("BELEGE")?.map((h) => h.id)).toEqual(["b"]);
|
|
});
|
|
|
|
it("ignores diacritics, so an ASCII keyboard still finds umlauts", () => {
|
|
expect(search.search("kuhlschrank")?.map((h) => h.id)).toEqual(["a"]);
|
|
expect(search.search("Steuererklarung")?.map((h) => h.id)).toEqual(["b"]);
|
|
});
|
|
|
|
it("returns a snippet around the match", () => {
|
|
const hit = search.search("Dichtung")?.find((h) => h.id === "a");
|
|
expect(hit?.snippet).toMatch(/Dichtung/);
|
|
});
|
|
|
|
it("respects the result limit", () => {
|
|
for (let i = 0; i < 30; i++) search.setEntry(`x${i}`, `Note ${i}`, body("common word"));
|
|
expect(search.search("common", { limit: 5 })?.length).toBe(5);
|
|
for (let i = 0; i < 30; i++) search.removeEntry(`x${i}`);
|
|
});
|
|
|
|
it("stops matching a note once it is removed from the index", () => {
|
|
expect(search.search("Belege")?.length).toBe(1);
|
|
search.removeEntry("b");
|
|
expect(search.search("Belege")).toEqual([]);
|
|
});
|
|
|
|
it("reflects an updated body immediately", () => {
|
|
search.setEntry("a", "Haushalt", body("Völlig anderer Inhalt jetzt."));
|
|
expect(search.search("Kühlschrank")).toEqual([]);
|
|
expect(search.search("anderer")?.map((h) => h.id)).toEqual(["a"]);
|
|
});
|
|
});
|
|
|
|
describe("attached document text", () => {
|
|
/* A PDF note has two sources of text, written at different moments by
|
|
* different code. Before these, whichever ran last was the only searchable
|
|
* half — typing a note dropped the document, re-reading it dropped the note. */
|
|
it("finds a note by a word from its attached document", () => {
|
|
search.setEntry("p1", "Handbuch", body("Meine eigene Notiz."));
|
|
search.setAttachedText("p1", "Handbuch", "Der Motor hat eine Zündkerze.");
|
|
expect(search.search("Zündkerze")?.map((h) => h.id)).toEqual(["p1"]);
|
|
search.removeEntry("p1");
|
|
});
|
|
|
|
it("and by a word the user typed beside it", () => {
|
|
search.setEntry("p1", "Handbuch", body("Meine eigene Notiz."));
|
|
search.setAttachedText("p1", "Handbuch", "Der Motor hat eine Zündkerze.");
|
|
expect(search.search("eigene")?.map((h) => h.id)).toEqual(["p1"]);
|
|
search.removeEntry("p1");
|
|
});
|
|
|
|
it("keeps the document text when the note is edited afterwards", () => {
|
|
search.setAttachedText("p1", "Handbuch", "Der Motor hat eine Zündkerze.");
|
|
search.setEntry("p1", "Handbuch", body("Ganz neuer Notiztext."));
|
|
expect(search.search("Zündkerze")?.map((h) => h.id)).toEqual(["p1"]);
|
|
expect(search.search("neuer")?.map((h) => h.id)).toEqual(["p1"]);
|
|
search.removeEntry("p1");
|
|
});
|
|
|
|
it("keeps the note when the document is re-read afterwards", () => {
|
|
search.setEntry("p1", "Handbuch", body("Meine eigene Notiz."));
|
|
search.setAttachedText("p1", "Handbuch", "Der Motor hat eine Zündkerze.");
|
|
search.setAttachedText("p1", "Handbuch", "Der Motor hat eine Zündkerze und einen Riemen.");
|
|
expect(search.search("eigene")?.map((h) => h.id)).toEqual(["p1"]);
|
|
expect(search.search("Riemen")?.map((h) => h.id)).toEqual(["p1"]);
|
|
search.removeEntry("p1");
|
|
});
|
|
|
|
it("shows the sentence a document hit was found in, not an unrelated excerpt", () => {
|
|
search.setEntry("p1", "Handbuch", body("Kurze Notiz."));
|
|
search.setAttachedText("p1", "Handbuch", "Vorher. Der Motor hat eine Zündkerze. Nachher.");
|
|
const hit = search.search("Zündkerze")?.[0];
|
|
expect(hit?.snippet).toMatch(/Zündkerze/);
|
|
search.removeEntry("p1");
|
|
});
|
|
|
|
it("clears the attached text when it is set to empty", () => {
|
|
search.setEntry("p1", "Handbuch", body("Notiz."));
|
|
search.setAttachedText("p1", "Handbuch", "Zündkerze");
|
|
search.setEntry("p1", "Handbuch", body("Notiz."), "");
|
|
expect(search.search("Zündkerze")).toEqual([]);
|
|
search.removeEntry("p1");
|
|
});
|
|
});
|
|
|
|
describe("contentToText", () => {
|
|
it("reads a v2 document", () => {
|
|
expect(search.contentToText(body("hello world"))).toBe("hello world");
|
|
});
|
|
|
|
it("reads a v1 body, HTML and task list alike", () => {
|
|
const v1 = JSON.stringify({
|
|
html: "<p>legacy <b>text</b></p>",
|
|
tasks: [{ id: "t", text: "buy milk", done: false }],
|
|
});
|
|
const text = search.contentToText(v1);
|
|
expect(text).toContain("legacy");
|
|
expect(text).toContain("buy milk");
|
|
});
|
|
|
|
it("does not execute or leak markup from a v1 body", () => {
|
|
const text = search.contentToText(JSON.stringify({ html: "<p>safe</p><script>bad()</script>" }));
|
|
expect(text).toContain("safe");
|
|
expect(text).not.toContain("<script>");
|
|
});
|
|
|
|
it("survives content that is not valid JSON", () => {
|
|
expect(() => search.contentToText("<p>bare html</p>")).not.toThrow();
|
|
expect(search.contentToText("<p>bare html</p>")).toContain("bare html");
|
|
});
|
|
|
|
it("returns an empty string for empty input", () => {
|
|
expect(search.contentToText(null)).toBe("");
|
|
expect(search.contentToText(undefined)).toBe("");
|
|
expect(search.contentToText("")).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("notes pinned into a PDF", () => {
|
|
beforeEach(() => {
|
|
seed();
|
|
search.removeEntry("pdf");
|
|
});
|
|
|
|
it("are searchable, even though they live outside the note body", () => {
|
|
// They are stored in the page overlay, so nothing else puts them in the
|
|
// index — and a note written onto page 14 that cannot be found again is
|
|
// not much of a note.
|
|
search.setEntry("pdf", "Handbuch", body("Meine Anmerkungen."));
|
|
search.setPinnedText("pdf", "Handbuch", "S. 14: Achtung, Drehmoment beachten");
|
|
expect(search.search("drehmoment")?.map((h) => h.id)).toEqual(["pdf"]);
|
|
});
|
|
|
|
it("show the pinned text as the excerpt, with its page", () => {
|
|
search.setEntry("pdf", "Handbuch", body("Meine Anmerkungen."));
|
|
search.setPinnedText("pdf", "Handbuch", "S. 14: Achtung, Drehmoment beachten");
|
|
expect(search.search("drehmoment")?.[0]?.snippet).toContain("S. 14");
|
|
});
|
|
|
|
it("do not displace what the reader typed beside the document", () => {
|
|
search.setEntry("pdf", "Handbuch", body("Meine Anmerkungen."));
|
|
search.setPinnedText("pdf", "Handbuch", "Drehmoment");
|
|
expect(search.search("anmerkungen")?.map((h) => h.id)).toEqual(["pdf"]);
|
|
expect(search.search("drehmoment")?.map((h) => h.id)).toEqual(["pdf"]);
|
|
});
|
|
|
|
it("survive the document's own text being indexed afterwards", () => {
|
|
search.setPinnedText("pdf", "Handbuch", "Drehmoment");
|
|
search.setAttachedText("pdf", "Handbuch", "Der Hersteller empfiehlt regelmäßige Wartung.");
|
|
expect(search.search("drehmoment")?.map((h) => h.id)).toEqual(["pdf"]);
|
|
expect(search.search("wartung")?.map((h) => h.id)).toEqual(["pdf"]);
|
|
});
|
|
|
|
it("survive the note body being saved afterwards", () => {
|
|
// Typing in the panel beside the PDF must not wipe what is pinned into it.
|
|
search.setPinnedText("pdf", "Handbuch", "Drehmoment");
|
|
search.setEntry("pdf", "Handbuch", body("Später weiterlesen."));
|
|
expect(search.search("drehmoment")?.map((h) => h.id)).toEqual(["pdf"]);
|
|
});
|
|
|
|
it("are replaced wholesale, so a deleted pin stops being findable", () => {
|
|
search.setPinnedText("pdf", "Handbuch", "Drehmoment");
|
|
search.setPinnedText("pdf", "Handbuch", "");
|
|
expect(search.search("drehmoment")).toEqual([]);
|
|
});
|
|
});
|