Files
bookhoard/web/src/annotations-banner.ts
T
John O'Keefe 89665fb2bc feat(annotations + player): bookmark rows in the banner, stacked volume panel, badge parity for comics/manga
Annotations banner: audio/text bookmarks join notes and highlights —
third row kind (bookmark glyph indicator, H:MM:SS / Page N / Chapter N
friendly labels), edit-note/delete actions branch by kind, and the
banner's export now covers all annotations. The archived ledger
(ListDeletedAnnotationsForBook) already tombstoned bookmarks equally
and Restore/Purge take annotation_type=bookmark — verified live:
delete → restore brings the row back with its note intact.

Volume: the dragger sits BELOW the mute icon in the panel (stacked
flyout shape) with the horizontal slider from the last round.

Badges: the real fix for comics/manga — format_group only knows
'comic_archive' (manga_type is unreliable across sources), so the
comic/manga split is resolved from the LIBRARY type server-side.
handlers.MediaBadgeKind + BookInfo.Badge at every construction site
(dashboard SSR + sections API, collections, series, and the bookshelf
search grid — which was also missing FormatGroup entirely, so no card
ever badged there). Both renderers (templ + dashboard.ts) switch on
the resolved badge. Verified: All-Books grid renders 2 Comic + 3
Manga badges, dashboard the audiobook one.

Infra: header.templ main.js src gains ?v=<stamp> — browsers keyed an
intermediate bundle by URL and served it across rebuilds (the stale
'Add note' the user saw); bump the stamp when JS stale-shadows.
2026-10-15 19:33:18 -04:00

258 lines
8.3 KiB
TypeScript

// 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: "# <friendly label>" header + note source as body
// highlight: "# <highlight text>" 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 | bookmark */
kind: "note" | "highlight" | "bookmark";
/** 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;
}
/** Bookmark position labels: audio:<s> renders H:MM:SS; page:N /
* comic:page:N renders Page N; chapter:N renders Chapter N (0-based in
* storage); else the bookmark title's own words. */
function bookmarkLabel(position: string | null | undefined, title: string): string {
const raw = (position ?? "").trim();
if (raw.startsWith("audio:")) {
const sec = parseInt(raw.slice(6), 10);
if (Number.isFinite(sec) && sec >= 0) {
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const sec2 = sec % 60;
const two = (n: number) => String(n).padStart(2, "0");
return h > 0 ? `${h}:${two(m)}:${two(sec2)}` : `${m}:${two(sec2)}`;
}
}
const pageM = /^(?:comic:)?page:(\d+)$/.exec(raw);
if (pageM) return `Page ${pageM[1]}`;
const chapM = /^chapter:(\d+)$/.exec(raw);
if (chapM) return `Chapter ${Number(chapM[1]) + 1}`;
return title || "Bookmark";
}
function buildAnnotationRows(
notes: any[],
highlights: any[],
bookmarks: 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 });
}
for (const b of bookmarks) {
if (b.deleted) continue;
const notesrc = String(b.notes ?? "");
const title = String(b.title ?? "");
const label = bookmarkLabel(b.position, title);
const loc = String(b.position ?? "");
const row: AnnotationRow = {
key: `bookmark-${b.id}`,
kind: "bookmark",
label,
preview: title || label,
richSelection: "",
richNote: renderNoteHtml(notesrc),
loc,
color: "var(--accent)",
hasNote: notesrc.trim().length > 0,
expanded: false,
};
rows.push(row);
sources.push({
row,
source: notesrc,
highlightText: title || label,
});
}
return { rows, sources };
}
function escapeHtmlForRich(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
/** 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,
};