// Passages of text a note is tied to, kept as a W3C TextQuoteSelector keeps // them: the exact text and a little of what comes before and after, so the // passage is found again when the chat or the page draws it anew, and the // right one of several alike. /** How much text either side of a passage is kept to tell it from others like it. */ export const CONTEXT = 32; /** The longest passage a note keeps. */ export const LONGEST = 2000; export type Quote = { quote: string; prefix: string; suffix: string }; /** The passage between two offsets of a text, with its context. */ export function quoteAt(text: string, start: number, end: number): Quote { const stop = Math.min(end, start + LONGEST); return { quote: text.slice(start, stop), prefix: text.slice(Math.max(0, start - CONTEXT), start), suffix: text.slice(stop, stop + CONTEXT) }; } function sharedEnd(a: string, b: string): number { let n = 0; while (n < a.length && n < b.length && a[a.length - 1 - n] === b[b.length - 1 - n]) n++; return n; } function sharedStart(a: string, b: string): number { let n = 0; while (n < a.length && n < b.length && a[n] === b[n]) n++; return n; } /** * Where a passage is in a text: of every place its words appear, the one * whose surroundings match the kept context best (the first on a tie), or * null when they appear nowhere. */ export function locate(text: string, { quote, prefix, suffix }: Quote): { start: number; end: number } | null { if (!quote) return null; let best = -1; let score = -1; for (let at = text.indexOf(quote), seen = 0; at >= 0 && seen < 1000; at = text.indexOf(quote, at + 1), seen++) { const around = sharedEnd(text.slice(Math.max(0, at - prefix.length), at), prefix) + sharedStart(text.slice(at + quote.length, at + quote.length + suffix.length), suffix); if (around > score) [best, score] = [at, around]; } return best < 0 ? null : { start: best, end: best + quote.length }; } /** * A passage as it reads, for a message or a label: blank lines and the * space around it dropped, each line's indent kept, so code stays code. */ export function tidy(quote: string): string { return quote .split('\n') .map((line) => line.trimEnd()) .filter(Boolean) .join('\n') .trimStart(); }