// Book-detail annotations banner (objective 7): every note and highlight // on the book, friendly location labels (never a raw cfi:epubcfi(…) string // in UI or export headers), per-row reader jumps via ?loc=, and the // WEB-ONLY export (single .md or one-file-per-item zip). Item formatting // is identical in both modes: // location note: "# " header + note source as body // highlight: "# " header + note source as body // (no note → header only) // Rows expand to the rich markup through the same markdown pipeline the // reader uses (renderNoteHtml — sanitized at render time). import { renderNoteHtml, stripNote } from "./markdown"; interface AnnotationRow { /** stable key for x-for */ key: string; /** note | highlight */ kind: "note" | "highlight"; /** human location label (Section N / Page N / NN% / source words) */ label: string; /** compact preview line: stripped markdown (note) or capped selection */ preview: string; /** expanded view, split so the highlight→note boundary is explicit: * the escaped selection text, then the note's markdown-rendered HTML * (empty when the row has no note body). */ richSelection: string; richNote: string; /** the ?loc= handoff value for the jump */ loc: string; /** row accent color (swatch) */ color: string; /** highlight rows: whether a note rides on the highlight (Edit-note vs * Add-note label). Note rows always true. */ hasNote: boolean; expanded: boolean; } interface AnnotationSource { row: AnnotationRow; /** full markdown body under the export header ("" when none) */ source: string; /** highlight selection text (the export header for highlight rows) */ highlightText: string; } /** Friendly human location label; mirrors the app's positionHint(). */ function friendlyAnnotationLabel(position: string | null | undefined, fallbackText: string): string { const raw = (position ?? "").trim(); if (raw) { // PDF/comic anchors: "page:N" if (raw.startsWith("page:")) { const n = parseInt(raw.slice(5), 10); if (Number.isFinite(n) && n > 0) return `Page ${n}`; } // Reflowable location notes: "cfi:epubcfi(...)" const cfi = raw.startsWith("cfi:") ? raw.slice(4).trim() : raw; const m = /epubcfi\(\s*\/6\/(\d+)/.exec(cfi); if (m) { // The spine step counts 1-based element pairs; /6/N!/ → section N/2. const spine = Math.floor(parseInt(m[1], 10) / 2) - 1; if (spine >= 0) return `Section ${spine + 1}`; } } // Spec fallback: the text's first few words (never a raw locator). const words = stripNote(fallbackText || "").replace(/\s+/g, " ").trim(); if (words) { const cut = words.length > 42 ? words.slice(0, words.lastIndexOf(" ", 42)) || words.slice(0, 42) : words; return cut; } return "Location"; } /** PDF highlights store their page inside the epubcfi_start column as a * JSON descriptor {"v":1,"page":N,"rects":[…]} — parse it off. */ function pdfEnvelopePage(cfi: string): number | null { if (cfi.startsWith("{")) { try { const a = JSON.parse(cfi); if (a && typeof a.page === "number") return a.page; } catch { /* not ours */ } } return null; } function buildAnnotationRows( notes: any[], highlights: any[], ): { rows: AnnotationRow[]; sources: AnnotationSource[] } { const rows: AnnotationRow[] = []; const sources: AnnotationSource[] = []; for (const n of notes) { const content = n.content ?? ""; const label = friendlyAnnotationLabel(n.position, content); const loc = n.position ?? ""; const row: AnnotationRow = { key: `note-${n.id}`, kind: "note", label, preview: stripNote(content) || "(empty note)", richSelection: "", richNote: renderNoteHtml(content), loc, color: "var(--accent)", hasNote: true, expanded: false, }; rows.push(row); sources.push({ row, source: content, highlightText: "", }); } for (const h of highlights) { const selection = (h.selection_text ?? "").trim(); const note = (h.note_text ?? "").trim(); const color = h.color || "#ffff00"; const envelopePage = pdfEnvelopePage(h.epubcfi_start ?? ""); const cfi = (h.epubcfi_start ?? "").trim(); const pct = typeof h.percentage_start === "number" ? h.percentage_start : null; let label = "Location"; let loc = ""; if (envelopePage != null) { label = `Page ${envelopePage + 1}`; // ?loc= speaks 1-based pages loc = `page:${envelopePage + 1}`; } else if (cfi || pct != null) { label = friendlyAnnotationLabel(cfi || null, selection); } if (envelopePage == null && cfi.startsWith("epubcfi(")) { loc = `cfi:${cfi}`; } if (!loc && pct != null) { label = `${Math.round(pct * 100)}%`; } const capped = selection.length > 80 ? selection.slice(0, 80) + "…" : selection; // Expanded body, split at the highlight→note boundary: the escaped // selection, then the note's rendered markdown under a labeled divider // (rendered by the banner template). const richSelection = escapeHtmlForRich(selection); const richNote = note ? renderNoteHtml(note) : ""; const row: AnnotationRow = { key: `hl-${h.id}`, kind: "highlight", label, preview: capped || "(empty highlight)", richSelection, richNote, loc, color, hasNote: !!note, expanded: false, }; rows.push(row); sources.push({ row, source: note, highlightText: selection }); } return { rows, sources }; } function escapeHtmlForRich(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">"); } /** The export item text — identical in single and multi modes. */ function annotationItemMarkdown(source: AnnotationSource): string { if (source.row.kind === "highlight") { const body = source.source ? "\n\n" + source.source.trim() : ""; return `# ${source.highlightText || "(highlight)"}${body}\n`; } const body = source.source.trim(); return `# ${source.row.label}\n${body ? "\n" + body : ""}\n`; } function triggerDownload(blob: Blob, filename: string): void { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 5000); } function bannerBookTitle(): string { const h1 = document.querySelector("h1")?.textContent?.trim(); const clean = (h1 || "").replace(/[\\/:*?"<>|]/g, "").slice(0, 80); return clean || "book"; } export { buildAnnotationRows, annotationItemMarkdown, triggerDownload, bannerBookTitle, type AnnotationRow, type AnnotationSource, };