Files
InkNote/frontend/src/search.ts
T
marcandClaude Opus 5 6438648313 Pin notes to passages in a PDF, and put the notes control with the rest
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>
2026-08-10 13:08:20 +02:00

206 lines
6.9 KiB
TypeScript

/**
* Local full-text search.
*
* The server stores note bodies encrypted and deliberately keeps no plaintext
* index — so searching has to happen on the client, over the copy already in
* IndexedDB. That also means search keeps working offline.
*
* The index is a plain in-memory map. For a personal note collection that is
* both fast enough and far simpler than a real inverted index: matching a few
* thousand strings costs well under a frame.
*/
import * as db from "./offlineStore";
import { docToText } from "./editor/content";
import type { SearchHit } from "./types";
interface IndexEntry {
/** Normalised title, for matching. */
title: string;
/** Normalised body text, for matching. */
text: string;
/** Original body text, for building the snippet shown to the user. */
raw: string;
/** Text from an attached document, kept apart from what the user typed. */
attachedRaw: string;
/** Notes pinned to spots in an attached document. */
pinnedRaw: string;
}
const _index = new Map<string, IndexEntry>();
/** Lowercases and strips diacritics, so `kuhlschrank` matches `Kühlschrank`. */
const normalize = (s: unknown): string =>
String(s ?? "")
.toLowerCase()
.normalize("NFKD")
.replace(/\p{Diacritic}/gu, "");
/** Plain text of a stored note body, whatever format it is in. */
export function contentToText(raw: string | null | undefined): string {
if (!raw) return "";
let parsed: unknown = null;
try {
parsed = JSON.parse(raw);
} catch {
parsed = null;
}
const obj = parsed as { v?: number; doc?: unknown; html?: unknown; tasks?: unknown } | null;
if (obj && obj.v === 2 && obj.doc) return docToText(obj.doc, 20000);
// v1 — HTML plus a detached task list.
const html = typeof obj?.html === "string" ? obj.html : raw;
const el = document.createElement("div");
el.innerHTML = html;
const tasks = Array.isArray(obj?.tasks)
? (obj.tasks as Array<{ text?: string }>).map((t) => t.text ?? "").join(" ")
: "";
return `${el.textContent || ""} ${tasks}`.replace(/\s+/g, " ").trim();
}
/**
* Adds or replaces a note in the index.
*
* A PDF note has two sources of text — what the reader wrote and what the
* document says — updated at different moments by different code. `attached`
* is only replaced when given, so typing a note does not drop the document's
* text and re-reading the document does not drop the note. Passing one and
* forgetting the other used to leave whichever ran last as the only searchable
* half.
*/
export function setEntry(id: string, title: string, content?: string | null, attached?: string | null): void {
const existing = _index.get(id);
const raw = contentToText(content);
const attachedRaw = attached === undefined ? (existing?.attachedRaw ?? "") : (attached ?? "");
const pinnedRaw = existing?.pinnedRaw ?? "";
_index.set(id, {
title: normalize(title),
text: normalize(`${raw} ${attachedRaw} ${pinnedRaw}`),
raw,
attachedRaw,
pinnedRaw,
});
}
/** Replaces one of the three halves, leaving the other two alone. */
function setPart(id: string, title: string, part: "attachedRaw" | "pinnedRaw", value: string): void {
const existing = _index.get(id) ?? { raw: "", attachedRaw: "", pinnedRaw: "", title: "", text: "" };
const next = { ...existing, [part]: value };
_index.set(id, {
title: normalize(title),
text: normalize(`${next.raw} ${next.attachedRaw} ${next.pinnedRaw}`),
raw: next.raw,
attachedRaw: next.attachedRaw,
pinnedRaw: next.pinnedRaw,
});
}
/** Replaces only the attached document's text, leaving what the user wrote. */
export const setAttachedText = (id: string, title: string, attached: string): void =>
setPart(id, title, "attachedRaw", attached);
/**
* Replaces only the notes pinned into an attached document.
*
* These live in the page overlay rather than in the note body, so nothing else
* puts them in the index — and a note you wrote onto page 14 that cannot be
* searched for is a note you will not find again.
*/
export const setPinnedText = (id: string, title: string, pinned: string): void =>
setPart(id, title, "pinnedRaw", pinned);
export function removeEntry(id: string): void {
_index.delete(id);
}
export function size(): number {
return _index.size;
}
/** Builds the index from whatever is cached locally. */
export async function rebuild(): Promise<number> {
const notes = await db.getAllNotes();
_index.clear();
for (const n of notes) {
const c = await db.getNoteContent(n.id);
setEntry(n.id, n.title, c?.content);
}
return _index.size;
}
/**
* Ranked matches for a query.
*
* All terms must appear (AND). Title matches outrank body matches so that
* searching for a note by name still puts that note first. Returns `null` for
* an empty query, which callers read as "no search, show the plain list".
*/
export function search(query: string, { limit = 200 }: { limit?: number } = {}): SearchHit[] | null {
const terms = normalize(query).split(/\s+/).filter(Boolean);
if (terms.length === 0) return null;
const hits: Array<{ id: string; score: number; entry: IndexEntry }> = [];
for (const [id, entry] of _index) {
let score = 0;
let matchedAll = true;
for (const term of terms) {
const inTitle = entry.title.includes(term);
const inText = entry.text.includes(term);
if (!inTitle && !inText) {
matchedAll = false;
break;
}
if (inTitle) score += entry.title.startsWith(term) ? 12 : 8;
if (inText) score += 1;
}
if (matchedAll) hits.push({ id, score, entry });
}
hits.sort((a, b) => b.score - a.score);
return hits.slice(0, limit).map(({ id, score, entry }) => ({
id,
score,
snippet: snippetFor(entry, terms),
}));
}
/**
* A short excerpt around the first matching term, for the results list.
*
* Looks in what the user wrote first, then in an attached document: a hit inside
* a PDF should still show the sentence it was found in, rather than an excerpt
* of unrelated notes or nothing at all.
*/
function snippetFor(entry: IndexEntry, terms: string[], radius = 60): string {
const firstHit = (source: string) => {
if (!source) return -1;
const haystack = normalize(source);
let at = -1;
for (const term of terms) {
const i = haystack.indexOf(term);
if (i !== -1 && (at === -1 || i < at)) at = i;
}
return at;
};
let text = entry.raw;
let at = firstHit(text);
// A pinned note is short and was written deliberately, so it outranks the
// document's own text as the excerpt to show.
if (at === -1 && entry.pinnedRaw) {
text = entry.pinnedRaw;
at = firstHit(text);
}
if (at === -1 && entry.attachedRaw) {
text = entry.attachedRaw;
at = firstHit(text);
}
if (!text) return "";
if (at === -1) return text.slice(0, radius * 2).trim();
const start = Math.max(0, at - radius);
const end = Math.min(text.length, at + radius);
return `${start > 0 ? "…" : ""}${text.slice(start, end).trim()}${end < text.length ? "…" : ""}`;
}