/** * 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(); /** 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 { 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 ? "…" : ""}`; }