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.
258 lines
8.3 KiB
TypeScript
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, "&")
|
|
.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,
|
|
};
|