feat(web): book-detail annotations banner — ?loc= reader jumps, single/zip markdown export
Release / build-and-push (push) Successful in 3m9s
Release / build-and-push (push) Successful in 3m9s
Objective 7 (web half). The 'Annotations' button slides a right-hand
banner (annotations_banner.templ + annotations-banner.ts) fed by the
existing per-book GET endpoints: friendly labels (Section N parsed from
the CFI spine / Page N from the PDF envelope / NN% + first-words
fallbacks), capped highlight previews, rich x-html expansion through the
objective-6 markdown pipeline, and per-row 'Open reader here' jumps
riding /readers/:uuid?loc= (consumeLocJump: jump outranks stored
progress for that open; URL stripped before sync can echo it). WEB-ONLY
export: single .md or one-file-per-item zip via fflate — item format
identical both modes ('# friendly label' / '# selection text' headers,
raw markdown bodies, no raw CFIs anywhere).
Verified live in Brave: 12 restored highlights + a markdown note
labeled/expanded correctly; the jump navigated, landed on the right
chapter and consumed ?loc=; export produced the 13-item .md (friendly
headers only) and a valid 13-entry zip. Book-detail's existing
recently-deleted modal stays untouched (banner is additive).
This commit is contained in:
1 parent
4b5e193d9f
commit
0b83db8fce
10 files changed
+1044
-483
No files matched your search
@@ -0,0 +1,197 @@
|
||||
// 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 */
|
||||
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;
|
||||
/** rich body for the expanded view (pre-sanitized) */
|
||||
rich: string;
|
||||
/** the ?loc= handoff value for the jump */
|
||||
loc: string;
|
||||
/** row accent color (swatch) */
|
||||
color: string;
|
||||
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)",
|
||||
rich: renderNoteHtml(content),
|
||||
loc,
|
||||
color: "var(--accent)",
|
||||
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: the full highlight text followed by its rich note.
|
||||
const rich = note
|
||||
? `<p style="color: var(--text-secondary); margin: 0 0 .5rem;">${escapeHtmlForRich(selection)}</p>` + renderNoteHtml(note)
|
||||
: `<p style="color: var(--text-secondary);">${escapeHtmlForRich(selection) || "…"}</p>`;
|
||||
|
||||
const row: AnnotationRow = {
|
||||
key: `hl-${h.id}`,
|
||||
kind: "highlight",
|
||||
label,
|
||||
preview: capped || "(empty highlight)",
|
||||
rich,
|
||||
loc,
|
||||
color,
|
||||
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, "<")
|
||||
.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,
|
||||
};
|
||||
Reference in new issue
Block a user