Three defects from real-world use: - The m4b rewrite assumed every stream survives -map 0 -c copy; files carrying text-ish tracks (the movenc 'text' fourcc) fail the ipod muxer with "Tag text incompatible with output codec id" — exactly the error a write attempt hit in the field. The rewrite now attempts full fidelity first, then retries mapping audio + attached pictures only (-dn -sn): chapters live in the container header (-map_chapters 0) so they survive; every attempt still ffprobe-validates before the atomic rename and the original is never touched on total failure. - The browser confirm() is replaced by an in-app dialog (WriteToFileConfirmDialog, Alpine + templ — no native dialogs). - "Write to file" now SAVES the pending editor edits first (persisted through the same PUT the Save button uses), then writes the file from those DB values, and closes the editor + refreshes the page exactly like Save — previously the endpoint wrote the LAST-SAVED DB values, so edits sitting in the modal never reached the file, and the page didn't visibly update. Test coverage per the owner's bar: EVERY single-file container (MP3, FLAC, M4A, M4B) gets its own runtime-generated fixture, gets written, and the FILE's metadata is then verified independently — ffprobe reads the rewritten container and every mapped key is asserted per format, including the subtitle round-trip split (MP3/FLAC yes, M4A/B no) — and the EPUB/CBZ/sidecar assertions now PARSE the written XML (dc titles/ creators, ComicInfo fields, calibre meta) instead of substring matching. A smart-genre unit test also pins the mixed-blob contract: a recognized shelf wins the genre column even when demoted format words ride along in the same blob.
1107 lines
40 KiB
TypeScript
1107 lines
40 KiB
TypeScript
import { Alpine } from "./alpine";
|
|
import { showToast } from "./toast";
|
|
import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
|
|
import { renderNoteHtml } from "./markdown";
|
|
import { strToU8, zipSync } from "fflate";
|
|
import {
|
|
buildAnnotationRows,
|
|
annotationItemMarkdown,
|
|
triggerDownload,
|
|
bannerBookTitle,
|
|
type AnnotationRow,
|
|
} from "./annotations-banner";
|
|
|
|
function getMediaId(): string {
|
|
const parts = window.location.pathname.split("/");
|
|
return parts[parts.length - 1] || "";
|
|
}
|
|
|
|
function getAuthHeader(): string {
|
|
const token = localStorage.getItem("token");
|
|
return token ? `Bearer ${token}` : "";
|
|
}
|
|
|
|
function showProgressSyncModal(): void {
|
|
const modal = document.getElementById("progress-sync-modal");
|
|
if (modal) modal.classList.remove("hidden");
|
|
}
|
|
|
|
function showNotesModal(): void {
|
|
const modal = document.getElementById("notes-modal");
|
|
if (modal) modal.classList.remove("hidden");
|
|
}
|
|
|
|
function hideProgressSyncModal(): void {
|
|
const modal = document.getElementById("progress-sync-modal");
|
|
if (modal) modal.classList.add("hidden");
|
|
}
|
|
|
|
function hideNotesModal(): void {
|
|
const modal = document.getElementById("notes-modal");
|
|
if (modal) modal.classList.add("hidden");
|
|
}
|
|
|
|
function showReaderPlaceholder(): void {
|
|
showToast("Ebook reader coming soon!", "info");
|
|
}
|
|
|
|
function showMetadataEditor(): void {
|
|
const modal = document.getElementById("metadata-editor-modal");
|
|
if (modal) modal.classList.remove("hidden");
|
|
}
|
|
|
|
function hideMetadataEditor(): void {
|
|
const modal = document.getElementById("metadata-editor-modal");
|
|
if (modal) modal.classList.add("hidden");
|
|
}
|
|
|
|
function triggerCoverUpload(): void {
|
|
document.getElementById("cover-upload-input")?.click();
|
|
}
|
|
|
|
function collectFormData(editorTags: string[]): Record<string, unknown> {
|
|
const modal = document.getElementById("metadata-editor-modal");
|
|
if (!modal) return {};
|
|
|
|
const data: Record<string, unknown> = {};
|
|
const inputs = modal.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
|
|
"input[name], textarea[name], select[name]",
|
|
);
|
|
for (const el of inputs) {
|
|
if (el.name === "tags") continue;
|
|
if (el.type === "number") {
|
|
const val = parseFloat(el.value);
|
|
data[el.name] = isNaN(val) ? 0 : val;
|
|
} else if (el instanceof HTMLInputElement && el.type === "checkbox") {
|
|
data[el.name] = el.checked;
|
|
} else {
|
|
data[el.name] = el.value;
|
|
}
|
|
}
|
|
|
|
data.tags = editorTags;
|
|
|
|
const contribStr = data.contributors as string;
|
|
if (typeof contribStr === "string") {
|
|
data.contributors = contribStr
|
|
.split(",")
|
|
.map((c: string) => c.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
// Narrators arrive as one comma-separated form field (mirrors
|
|
// contributors); empty → drop the key so non-audio saves don't ship [].
|
|
const narrStr = data.narrators as string;
|
|
if (typeof narrStr === "string") {
|
|
const narrators = narrStr
|
|
.split(",")
|
|
.map((n: string) => n.trim())
|
|
.filter(Boolean);
|
|
if (narrators.length) data.narrators = narrators;
|
|
else delete data.narrators;
|
|
}
|
|
|
|
// A2b: authors/publishers are arrays server-side (write truth); the form
|
|
// fields are comma-separated strings exactly like narrators.
|
|
for (const key of ["authors", "publishers"] as const) {
|
|
const raw = data[key] as string;
|
|
if (typeof raw === "string") {
|
|
const list = raw
|
|
.split(",")
|
|
.map((v: string) => v.trim())
|
|
.filter(Boolean);
|
|
if (list.length) data[key] = list;
|
|
else delete data[key];
|
|
}
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
interface MetadataEditorState {
|
|
openSections: Record<string, boolean>;
|
|
hasExistingCover: boolean;
|
|
newCoverPreview: string;
|
|
coverFile: Blob | null;
|
|
coverAction: string;
|
|
saving: boolean;
|
|
rescanning: boolean;
|
|
resetting: boolean;
|
|
writingFile: boolean;
|
|
writeConfirmOpen: boolean;
|
|
fileWritePath: string;
|
|
userRating: number;
|
|
ratingHover: number;
|
|
ratingSaving: boolean;
|
|
conflictId: string;
|
|
conflictWinner: string;
|
|
readSaving: boolean;
|
|
toggleSection(section: string): void;
|
|
showMetadataEditor(): void;
|
|
hideMetadataEditor(): void;
|
|
handleCoverUpload(event: Event): void;
|
|
removeCover(): void;
|
|
saveMetadata(): Promise<void>;
|
|
rescanBook(): Promise<void>;
|
|
resetMetadata(): Promise<void>;
|
|
writeMetadataToFile(): void;
|
|
writeConfirmMessage(): string;
|
|
performWriteToFile(): Promise<void>;
|
|
persistMetadata(): Promise<boolean>;
|
|
metadataWriteTarget(): string;
|
|
metadataWriteHint(): string;
|
|
starFill(i: number): string;
|
|
ratingText(): string;
|
|
setRating(value: number): Promise<void>;
|
|
clearRating(): Promise<void>;
|
|
hasReview: boolean;
|
|
reviewHtml: string;
|
|
reviewOpen: boolean;
|
|
reviewDraft: string;
|
|
reviewSaving: boolean;
|
|
openReviewEditor(): void;
|
|
saveReview(): Promise<void>;
|
|
deleteReview(): Promise<void>;
|
|
toggleRead(read: boolean): Promise<void>;
|
|
resolveConflict(conflictId: string, winner: string): Promise<void>;
|
|
restoreDeletedAnnotation(annotationType: string, annotationId: string): Promise<void>;
|
|
purgeDeletedAnnotation(annotationType: string, annotationId: string): Promise<void>;
|
|
}
|
|
|
|
Alpine.data("bookDetail", () => {
|
|
const coverImg = document.querySelector(
|
|
"#metadata-editor-modal img#metadata-cover-preview",
|
|
) as HTMLImageElement | null;
|
|
const hasCover = coverImg?.src && !coverImg.src.includes("placeholder-book.svg");
|
|
|
|
const initialTags: string[] = [];
|
|
const tagBadges = document.querySelectorAll("#metadata-editor-modal [data-editor-tag]");
|
|
tagBadges.forEach((el) => {
|
|
const tag = el.getAttribute("data-editor-tag");
|
|
if (tag) initialTags.push(tag);
|
|
});
|
|
|
|
return {
|
|
openSections: { basic: true } as Record<string, boolean>,
|
|
hasExistingCover: !!hasCover,
|
|
newCoverPreview: "",
|
|
coverFile: null as Blob | null,
|
|
coverAction: "keep",
|
|
saving: false,
|
|
rescanning: false,
|
|
resetting: false,
|
|
// Write-to-file (2026-10-27): the modal root carries the item's
|
|
// library-relative file path as a data attribute (same hydration
|
|
// pattern as the editor tags above); the capability mirrors
|
|
// services.MetadataWriteTargetFor.
|
|
writingFile: false,
|
|
writeConfirmOpen: false,
|
|
fileWritePath: (() => {
|
|
const modal = document.getElementById("metadata-editor-modal");
|
|
return modal?.getAttribute("data-write-file-path") || "";
|
|
})(),
|
|
userRating: 0,
|
|
ratingHover: 0,
|
|
ratingSaving: false,
|
|
// Review state: hasReview/reviewHtml hydrate from the server-rendered
|
|
// raw markdown in #review-raw; reviewDraft is the editor's working copy.
|
|
hasReview: false,
|
|
reviewHtml: "",
|
|
reviewOpen: false,
|
|
reviewDraft: "",
|
|
reviewSaving: false,
|
|
conflictId: "",
|
|
conflictWinner: "",
|
|
readSaving: false,
|
|
editorTags: initialTags,
|
|
tagSearch: "",
|
|
tagSuggestions: [] as TagSuggestion[],
|
|
showTagDropdown: false,
|
|
highlightedTagIndex: -1,
|
|
|
|
// ----- annotations banner (objective 7) -----
|
|
annotationsBannerOpen: false,
|
|
annotationsBannerLoading: false,
|
|
annotationsExportOpen: false,
|
|
bannerMediaId: getMediaId(),
|
|
bannerRows: [] as AnnotationRow[],
|
|
bannerLoaded: false,
|
|
bannerError: "",
|
|
bannerEditOpen: false,
|
|
bannerEditRow: null as AnnotationRow | null,
|
|
bannerEditText: "",
|
|
// raw source kept for the export builder (not exposed to the template)
|
|
bannerSources: [] as { row: AnnotationRow; source: string; highlightText: string }[],
|
|
|
|
showReaderPlaceholder,
|
|
showMetadataEditor,
|
|
hideMetadataEditor,
|
|
showNotesModal,
|
|
showProgressSyncModal,
|
|
hideProgressSyncModal,
|
|
hideNotesModal,
|
|
triggerCoverUpload,
|
|
readerLocHref(row: { loc?: string }): string {
|
|
return `/readers/${this.bannerMediaId}?loc=${encodeURIComponent(row.loc ?? "")}`;
|
|
},
|
|
bannerEditHasText(): boolean {
|
|
return (this.bannerEditText || "").trim().length > 0;
|
|
},
|
|
|
|
async toggleAnnotationsBanner() {
|
|
this.annotationsBannerOpen = !this.annotationsBannerOpen;
|
|
if (this.annotationsBannerOpen && !this.bannerLoaded) {
|
|
await this.fetchBannerAnnotations();
|
|
}
|
|
},
|
|
hideAnnotationsBanner(): void {
|
|
this.annotationsBannerOpen = false;
|
|
this.hideExportDialog();
|
|
},
|
|
async fetchBannerAnnotations() {
|
|
const mediaId = getMediaId();
|
|
this.annotationsBannerLoading = true;
|
|
try {
|
|
const [noteResp, hlResp, bmResp] = await Promise.all([
|
|
fetch(`/api/media-items/${mediaId}/notes`, {
|
|
headers: { Authorization: getAuthHeader() },
|
|
}),
|
|
fetch(`/api/media-items/${mediaId}/highlights`, {
|
|
headers: { Authorization: getAuthHeader() },
|
|
}),
|
|
fetch(`/api/media-items/${mediaId}/bookmarks`, {
|
|
headers: { Authorization: getAuthHeader() },
|
|
}),
|
|
]);
|
|
// A partial failure must not read as "no annotations": a failed
|
|
// endpoint used to be published as an empty list, silently wiping
|
|
// that kind from the banner while the other kind stayed (found
|
|
// auditing the app's fetch-wipe fix, bookhoard-app 07bbb4c).
|
|
// Keep the previous rows; the next banner open retries.
|
|
if (!noteResp.ok || !hlResp.ok || !bmResp.ok) {
|
|
console.warn(
|
|
`banner annotation load failed ` +
|
|
`(notes ${noteResp.status}, highlights ${hlResp.status}, bookmarks ${bmResp.status}) — keeping previous`,
|
|
);
|
|
return;
|
|
}
|
|
const notes = await noteResp.json();
|
|
const highlights = await hlResp.json();
|
|
const bookmarks = await bmResp.json();
|
|
const built = buildAnnotationRows(
|
|
notes,
|
|
highlights,
|
|
Array.isArray(bookmarks) ? bookmarks : [],
|
|
);
|
|
this.bannerRows = built.rows;
|
|
this.bannerSources = built.sources;
|
|
this.bannerLoaded = true;
|
|
} catch (e) {
|
|
this.bannerError = String(e);
|
|
showToast("Could not load annotations", "error");
|
|
} finally {
|
|
this.annotationsBannerLoading = false;
|
|
}
|
|
},
|
|
// ----- note edit/delete at the banner (objective 8a) -----
|
|
startBannerEdit(row: AnnotationRow): void {
|
|
// Both kinds carry the raw note body on their source: highlights
|
|
// hold the ATTACHED note (never the highlight itself), location
|
|
// notes hold their content. The old code parsed row.rich (rendered
|
|
// HTML — no \n\n separators in it) and came up empty every time.
|
|
this.bannerEditRow = row;
|
|
this.bannerEditText = String(
|
|
this.bannerSources.find((s) => s.row.key === row.key)?.source ?? "",
|
|
);
|
|
this.bannerEditOpen = true;
|
|
},
|
|
async saveBannerNote() {
|
|
const row = this.bannerEditRow;
|
|
const content = String(this.bannerEditText || "").trim();
|
|
this.bannerEditOpen = false;
|
|
if (!row || (!content && row.kind === "note")) return;
|
|
const mediaId = getMediaId();
|
|
try {
|
|
if (row.kind === "bookmark") {
|
|
// Full-replace PUT: title + position ride along from the row's
|
|
// stored values; the note is the editable part.
|
|
const bmId = row.key.slice("bookmark-".length);
|
|
const src = this.bannerSources.find((s) => s.row.key === row.key);
|
|
const resp = await fetch(`/api/media-items/${mediaId}/bookmarks/${bmId}`, {
|
|
method: "PUT",
|
|
headers: { Authorization: getAuthHeader(), "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
title: String(src?.highlightText || "Bookmark"),
|
|
notes: content,
|
|
position: row.loc || "",
|
|
}),
|
|
});
|
|
if (!resp.ok) throw new Error(String(resp.status));
|
|
} else if (row.kind === "note") {
|
|
const noteId = row.key.slice("note-".length);
|
|
// The stored position rides along: the server's PUT hard-sets the
|
|
// position column.
|
|
const position =
|
|
(row.loc || "").startsWith("cfi:") || (row.loc || "").startsWith("page:")
|
|
? row.loc
|
|
: undefined;
|
|
const resp = await fetch(`/api/media-items/${mediaId}/notes/${noteId}`, {
|
|
method: "PUT",
|
|
headers: { Authorization: getAuthHeader(), "Content-Type": "application/json" },
|
|
body: JSON.stringify({ content, position }),
|
|
});
|
|
if (!resp.ok) throw new Error(String(resp.status));
|
|
} else {
|
|
// Highlight-attached note: the full-replace highlight PUT; empty
|
|
// content clears the note (allowed by the validator).
|
|
const hlId = row.key.slice("hl-".length);
|
|
const src = this.bannerSources.find((s) => s.row.key === row.key);
|
|
if (content === String(src?.source ?? "")) return;
|
|
// The banner only ever changes note_text: read the highlight list,
|
|
// then full-replace the row with the new note text.
|
|
const hlListResp = await fetch(`/api/media-items/${mediaId}/highlights`, {
|
|
headers: { Authorization: getAuthHeader() },
|
|
});
|
|
if (!hlListResp.ok) throw new Error(String(hlListResp.status));
|
|
const all = await hlListResp.json();
|
|
const hl = all.find((h: any) => h.id === hlId);
|
|
if (!hl) throw new Error("highlight gone");
|
|
hl.note_text = content;
|
|
const resp = await fetch(`/api/media-items/${mediaId}/highlights/${hlId}`, {
|
|
method: "PUT",
|
|
headers: { Authorization: getAuthHeader(), "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
selection_text: typeof hl.selection_text === "string" ? hl.selection_text : "",
|
|
start_position:
|
|
typeof (hl.start_position ?? "") === "string" ? hl.start_position : "",
|
|
end_position: typeof (hl.end_position ?? "") === "string" ? hl.end_position : "",
|
|
epubcfi_start: hl.epubcfi_start ?? "",
|
|
epubcfi_end: hl.epubcfi_end ?? "",
|
|
color: hl.color || undefined,
|
|
note_text: content,
|
|
note_id: hl.note_id ?? "",
|
|
percentage_start: hl.percentage_start ?? 0,
|
|
percentage_end: hl.percentage_end ?? 0,
|
|
chapter_reference: hl.chapter_reference ?? 0,
|
|
}),
|
|
});
|
|
if (!resp.ok) throw new Error(String(resp.status));
|
|
}
|
|
await this.fetchBannerAnnotations();
|
|
showToast("Note saved", "success");
|
|
} catch {
|
|
showToast("Could not save the note", "error");
|
|
}
|
|
},
|
|
async deleteBannerNote(row: AnnotationRow) {
|
|
if (row.kind !== "note") return;
|
|
const mediaId = getMediaId();
|
|
const noteId = row.key.slice("note-".length);
|
|
try {
|
|
const resp = await fetch(`/api/media-items/${mediaId}/notes/${noteId}`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: getAuthHeader() },
|
|
});
|
|
if (!resp.ok) throw new Error(String(resp.status));
|
|
// Tombstoned server-side: restorable from the Deleted modal.
|
|
showToast("Note deleted — restorable from Deleted", "success");
|
|
await this.fetchBannerAnnotations();
|
|
} catch {
|
|
showToast("Could not delete the note", "error");
|
|
}
|
|
},
|
|
async deleteBannerBookmark(row: AnnotationRow) {
|
|
if (row.kind !== "bookmark") return;
|
|
const mediaId = getMediaId();
|
|
const bmId = row.key.slice("bookmark-".length);
|
|
try {
|
|
const resp = await fetch(`/api/media-items/${mediaId}/bookmarks/${bmId}`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: getAuthHeader() },
|
|
});
|
|
if (!resp.ok) throw new Error(String(resp.status));
|
|
// Tombstoned server-side: restorable from the Deleted modal.
|
|
showToast("Bookmark deleted — restorable from Deleted", "success");
|
|
await this.fetchBannerAnnotations();
|
|
} catch {
|
|
showToast("Could not delete the bookmark", "error");
|
|
}
|
|
},
|
|
async deleteBannerHighlight(row: AnnotationRow) {
|
|
if (row.kind !== "highlight") return;
|
|
const mediaId = getMediaId();
|
|
const hlId = row.key.slice("hl-".length);
|
|
try {
|
|
const resp = await fetch(`/api/media-items/${mediaId}/highlights/${hlId}`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: getAuthHeader() },
|
|
});
|
|
if (!resp.ok) throw new Error(String(resp.status));
|
|
// Tombstoned server-side: restorable from the Deleted modal.
|
|
showToast("Highlight deleted — restorable from Deleted", "success");
|
|
await this.fetchBannerAnnotations();
|
|
} catch {
|
|
showToast("Could not delete the highlight", "error");
|
|
}
|
|
},
|
|
showExportDialog(): void {
|
|
if (this.bannerRows.length === 0) return;
|
|
this.annotationsExportOpen = true;
|
|
},
|
|
hideExportDialog(): void {
|
|
this.annotationsExportOpen = false;
|
|
},
|
|
async exportAnnotations(mode: "single" | "multi") {
|
|
this.hideExportDialog();
|
|
if (this.bannerRows.length === 0) return;
|
|
const items = this.bannerSources.map(annotationItemMarkdown);
|
|
const title = bannerBookTitle();
|
|
try {
|
|
if (mode === "single") {
|
|
const blob = new Blob([items.join("\n")], {
|
|
type: "text/markdown",
|
|
});
|
|
triggerDownload(blob, `${title}-annotations.md`);
|
|
} else {
|
|
// One file per item: 1.md … N.md, zipped client-side.
|
|
const files: Record<string, Uint8Array> = {};
|
|
items.forEach((text, i) => {
|
|
files[`${i + 1}.md`] = strToU8(text);
|
|
});
|
|
const zipped = zipSync(files);
|
|
triggerDownload(
|
|
new Blob([zipped], { type: "application/zip" }),
|
|
`${title}-annotations.zip`,
|
|
);
|
|
}
|
|
showToast("Annotations exported", "success");
|
|
} catch {
|
|
showToast("Export failed", "error");
|
|
}
|
|
},
|
|
|
|
async resolveConflict(conflictId: string, winner: string) {
|
|
try {
|
|
const resp = await fetch(`/api/conflicts/${conflictId}/resolve`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: getAuthHeader(),
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ winner }),
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || err.message || "Failed to resolve conflict");
|
|
}
|
|
|
|
hideProgressSyncModal();
|
|
showToast("Conflict resolved successfully", "success");
|
|
setTimeout(() => window.location.reload(), 500);
|
|
} catch (e) {
|
|
showToast(e instanceof Error ? e.message : "Failed to resolve conflict", "error");
|
|
}
|
|
},
|
|
|
|
async restoreDeletedAnnotation(annotationType: string, annotationId: string) {
|
|
const mediaId = getMediaId();
|
|
try {
|
|
const resp = await fetch(
|
|
`/api/media-items/${mediaId}/annotations/${annotationId}/restore`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: getAuthHeader(),
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ annotation_type: annotationType }),
|
|
},
|
|
);
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to restore annotation");
|
|
}
|
|
showToast("Annotation restored", "success");
|
|
setTimeout(() => window.location.reload(), 500);
|
|
} catch (e) {
|
|
showToast(e instanceof Error ? e.message : "Failed to restore annotation", "error");
|
|
}
|
|
},
|
|
|
|
async purgeDeletedAnnotation(annotationType: string, annotationId: string) {
|
|
if (
|
|
!confirm(
|
|
"Permanently delete this annotation? This cannot be undone and it will not reappear on any device.",
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
const mediaId = getMediaId();
|
|
try {
|
|
const resp = await fetch(
|
|
`/api/media-items/${mediaId}/annotations/${annotationId}?annotation_type=${encodeURIComponent(annotationType)}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: { Authorization: getAuthHeader() },
|
|
},
|
|
);
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to delete annotation");
|
|
}
|
|
showToast("Annotation permanently deleted", "success");
|
|
setTimeout(() => window.location.reload(), 500);
|
|
} catch (e) {
|
|
showToast(e instanceof Error ? e.message : "Failed to delete annotation", "error");
|
|
}
|
|
},
|
|
|
|
init() {
|
|
const ratingAttr = document.body.getAttribute("data-rating");
|
|
this.userRating = ratingAttr ? parseInt(ratingAttr, 10) || 0 : 0;
|
|
|
|
// Hydrate the review card from the server-rendered raw markdown.
|
|
const reviewRaw = document.getElementById("review-raw");
|
|
if (reviewRaw) {
|
|
const source = reviewRaw.textContent || "";
|
|
this.hasReview = source.trim().length > 0;
|
|
this.reviewHtml = renderNoteHtml(source);
|
|
}
|
|
|
|
const conflictId = document.body.getAttribute("data-conflict-id");
|
|
const conflictWinner = document.body.getAttribute("data-conflict-winner");
|
|
this.conflictId = conflictId && conflictId !== "null" ? conflictId : "";
|
|
this.conflictWinner = conflictWinner && conflictWinner !== "null" ? conflictWinner : "";
|
|
|
|
const link = document.getElementById("back-link");
|
|
if (!link) return;
|
|
const storageKey = "book_detail_back";
|
|
if (document.referrer) {
|
|
try {
|
|
const ref = new URL(document.referrer);
|
|
if (
|
|
ref.origin === window.location.origin &&
|
|
!ref.pathname.startsWith("/readers/") &&
|
|
ref.pathname !== window.location.pathname
|
|
) {
|
|
sessionStorage.setItem(storageKey, ref.pathname + ref.search);
|
|
}
|
|
} catch {}
|
|
} else {
|
|
sessionStorage.removeItem(storageKey);
|
|
}
|
|
const backUrl = sessionStorage.getItem(storageKey) || "/dashboard";
|
|
link.addEventListener("click", (e: Event) => {
|
|
e.preventDefault();
|
|
sessionStorage.removeItem(storageKey);
|
|
window.location.href = backUrl;
|
|
});
|
|
},
|
|
|
|
toggleSection(section: string) {
|
|
this.openSections[section] = !this.openSections[section];
|
|
},
|
|
|
|
starFill(i: number): string {
|
|
const display = this.ratingHover || this.userRating;
|
|
if (i * 2 <= display) {
|
|
return "color: var(--accent);";
|
|
} else if (i * 2 - 1 === display) {
|
|
return "background: linear-gradient(90deg, var(--accent) 50%, var(--text-secondary) 50%); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;";
|
|
}
|
|
return "color: var(--text-secondary);";
|
|
},
|
|
|
|
ratingText(): string {
|
|
if (this.userRating === 0) return "(not rated)";
|
|
return `(${(this.userRating / 2).toFixed(1)} / 5)`;
|
|
},
|
|
|
|
async setRating(value: number) {
|
|
if (this.ratingSaving) return;
|
|
this.ratingSaving = true;
|
|
const mediaId = getMediaId();
|
|
try {
|
|
const resp = await fetch(`/api/media-items/${mediaId}/rating`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: getAuthHeader(),
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ rating: value }),
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to save rating");
|
|
}
|
|
this.userRating = value;
|
|
this.ratingHover = 0;
|
|
showToast("Rating saved", "success");
|
|
} catch (e) {
|
|
showToast(e instanceof Error ? e.message : "Failed to save rating", "error");
|
|
} finally {
|
|
this.ratingSaving = false;
|
|
}
|
|
},
|
|
|
|
async clearRating() {
|
|
if (this.ratingSaving) return;
|
|
this.ratingSaving = true;
|
|
const mediaId = getMediaId();
|
|
try {
|
|
const resp = await fetch(`/api/media-items/${mediaId}/rating`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: getAuthHeader() },
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to clear rating");
|
|
}
|
|
this.userRating = 0;
|
|
this.ratingHover = 0;
|
|
showToast("Rating cleared", "success");
|
|
} catch (e) {
|
|
showToast(e instanceof Error ? e.message : "Failed to clear rating", "error");
|
|
} finally {
|
|
this.ratingSaving = false;
|
|
}
|
|
},
|
|
|
|
openReviewEditor() {
|
|
// Seed the draft from the CURRENT rendered source: the raw markdown
|
|
// lives in #review-raw when a review exists, otherwise start empty.
|
|
const reviewRaw = document.getElementById("review-raw");
|
|
this.reviewDraft = reviewRaw ? reviewRaw.textContent || "" : "";
|
|
this.reviewOpen = true;
|
|
},
|
|
|
|
async saveReview() {
|
|
if (this.reviewSaving) return;
|
|
this.reviewSaving = true;
|
|
const mediaId = getMediaId();
|
|
try {
|
|
const resp = await fetch(`/api/media-items/${mediaId}/review`, {
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: getAuthHeader(),
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ review: this.reviewDraft }),
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to save review");
|
|
}
|
|
// Reload so the server-rendered card (or its absence) matches.
|
|
window.location.reload();
|
|
} catch (e) {
|
|
showToast(e instanceof Error ? e.message : "Failed to save review", "error");
|
|
this.reviewSaving = false;
|
|
}
|
|
},
|
|
|
|
async deleteReview() {
|
|
if (this.reviewSaving || !this.hasReview) return;
|
|
this.reviewSaving = true;
|
|
const mediaId = getMediaId();
|
|
try {
|
|
const resp = await fetch(`/api/media-items/${mediaId}/review`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: getAuthHeader() },
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to delete review");
|
|
}
|
|
window.location.reload();
|
|
} catch (e) {
|
|
showToast(e instanceof Error ? e.message : "Failed to delete review", "error");
|
|
this.reviewSaving = false;
|
|
}
|
|
},
|
|
|
|
async toggleRead(read: boolean) {
|
|
if (this.readSaving) return;
|
|
this.readSaving = true;
|
|
const mediaId = getMediaId();
|
|
try {
|
|
// Clear any active sync conflict first. Resolving arms a 10-minute
|
|
// suppression window so the progress write below does not spawn a new
|
|
// conflict. The winner only needs to be a valid source key; the final
|
|
// read/unread state is set by the progress write that follows.
|
|
if (this.conflictId && this.conflictWinner) {
|
|
const cr = await fetch(`/api/conflicts/${this.conflictId}/resolve`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: getAuthHeader(),
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ winner: this.conflictWinner }),
|
|
});
|
|
// 400 means it was already resolved - treat as no conflict.
|
|
if (!cr.ok && cr.status !== 400) {
|
|
const err = await cr.json().catch(() => ({}));
|
|
throw new Error(err.error || err.message || "Failed to clear sync conflict");
|
|
}
|
|
}
|
|
|
|
if (read) {
|
|
// Mark as Read: PUT percentage 1.0. (Cannot PUT 0 to unread - the
|
|
// server silently ignores percentage < 0.005 when progress > 0.01.)
|
|
const resp = await fetch(`/api/media-items/${mediaId}/progress`, {
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: getAuthHeader(),
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ percentage: 1.0 }),
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to mark as read");
|
|
}
|
|
} else {
|
|
// Mark as Unread: DELETE the progress row. Notes, highlights and
|
|
// ratings are independent and are NOT affected.
|
|
const resp = await fetch(`/api/media-items/${mediaId}/progress`, {
|
|
method: "DELETE",
|
|
headers: { Authorization: getAuthHeader() },
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to mark as unread");
|
|
}
|
|
}
|
|
|
|
showToast(read ? "Marked as read" : "Marked as unread", "success");
|
|
setTimeout(() => window.location.reload(), 500);
|
|
} catch (e) {
|
|
showToast(e instanceof Error ? e.message : "Failed to update read state", "error");
|
|
} finally {
|
|
this.readSaving = false;
|
|
}
|
|
},
|
|
|
|
handleCoverUpload(event: Event) {
|
|
const input = event.target as HTMLInputElement;
|
|
const file = input.files?.[0];
|
|
if (!file) return;
|
|
|
|
this.coverFile = file;
|
|
this.coverAction = "upload";
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
const preview = document.getElementById("metadata-cover-preview") as HTMLImageElement;
|
|
if (preview && e.target?.result) {
|
|
preview.src = e.target.result as string;
|
|
}
|
|
this.newCoverPreview = e.target?.result as string;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
},
|
|
|
|
removeCover() {
|
|
this.coverAction = "remove";
|
|
this.coverFile = null;
|
|
this.newCoverPreview = "";
|
|
const preview = document.getElementById("metadata-cover-preview") as HTMLImageElement;
|
|
if (preview) {
|
|
preview.src = "/static/placeholder-book.svg";
|
|
}
|
|
},
|
|
|
|
async saveMetadata() {
|
|
if (this.saving) return;
|
|
this.saving = true;
|
|
try {
|
|
if (await this.persistMetadata()) {
|
|
hideMetadataEditor();
|
|
showToast("Metadata saved successfully", "success");
|
|
setTimeout(() => window.location.reload(), 500);
|
|
}
|
|
} finally {
|
|
this.saving = false;
|
|
}
|
|
},
|
|
|
|
// persistMetadata PUTs the editor's current fields (with cover, when
|
|
// one was staged) — no toast, no reload. saveMetadata and the
|
|
// write-to-file flow share it, so "Write to file" always persists
|
|
// the user's PENDING edits before touching the file.
|
|
async persistMetadata(): Promise<boolean> {
|
|
try {
|
|
const mediaId = getMediaId();
|
|
const formData = collectFormData(this.editorTags);
|
|
formData.cover_action = this.coverAction;
|
|
|
|
if (this.coverFile) {
|
|
const fd = new FormData();
|
|
for (const [key, value] of Object.entries(formData)) {
|
|
if (Array.isArray(value)) {
|
|
fd.append(key, JSON.stringify(value));
|
|
} else if (typeof value === "boolean") {
|
|
fd.append(key, value ? "true" : "false");
|
|
} else {
|
|
fd.append(key, String(value));
|
|
}
|
|
}
|
|
fd.append("cover_file", this.coverFile, "cover.jpg");
|
|
|
|
const resp = await fetch(`/api/media-items/${mediaId}`, {
|
|
method: "PUT",
|
|
headers: { Authorization: getAuthHeader() },
|
|
body: fd,
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to save metadata");
|
|
}
|
|
} else {
|
|
const resp = await fetch(`/api/media-items/${mediaId}`, {
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: getAuthHeader(),
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(formData),
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to save metadata");
|
|
}
|
|
}
|
|
return true;
|
|
} catch (e) {
|
|
showToast(e instanceof Error ? e.message : "Failed to save metadata", "error");
|
|
return false;
|
|
}
|
|
},
|
|
|
|
async rescanBook() {
|
|
if (this.rescanning) return;
|
|
this.rescanning = true;
|
|
const mediaId = getMediaId();
|
|
try {
|
|
const resp = await fetch(`/api/media-items/${mediaId}/rescan`, {
|
|
method: "POST",
|
|
headers: { Authorization: getAuthHeader() },
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to rescan book");
|
|
}
|
|
showToast("Book rescanned successfully", "success");
|
|
setTimeout(() => window.location.reload(), 500);
|
|
} catch (e) {
|
|
showToast(e instanceof Error ? e.message : "Failed to rescan book", "error");
|
|
} finally {
|
|
this.rescanning = false;
|
|
}
|
|
},
|
|
|
|
// metadataWriteTarget mirrors services.MetadataWriteTargetFor: EPUB
|
|
// embeds its OPF, single-file audio (mp3/flac/m4a/m4b) rewrites its
|
|
// container tags, CBZ gets ComicInfo.xml injected, and everything
|
|
// else takes a sidecar.
|
|
metadataWriteTarget(): string {
|
|
const ext = (this.fileWritePath.split(".").pop() || "").toLowerCase();
|
|
if (ext === "epub" || ext === "kepub") return "embedded-opf";
|
|
if (["mp3", "flac", "m4a", "m4b"].includes(ext)) return "audio-tags";
|
|
if (ext === "cbz") return "comicinfo";
|
|
return "sidecar-opf";
|
|
},
|
|
|
|
metadataWriteHint(): string {
|
|
const target = this.metadataWriteTarget();
|
|
if (target === "embedded-opf") {
|
|
return "Rewrite the metadata inside the EPUB, then rescan";
|
|
}
|
|
if (target === "audio-tags") {
|
|
return "Rewrite the audiobook's embedded tags, then rescan";
|
|
}
|
|
if (target === "comicinfo") {
|
|
return "Write ComicInfo.xml inside the comic archive, then rescan";
|
|
}
|
|
return "Write a Calibre-style metadata sidecar next to the file, then rescan";
|
|
},
|
|
|
|
async writeMetadataToFile() {
|
|
if (this.writingFile) return;
|
|
// In-app dialog (not window.confirm): shows the target-specific
|
|
// copy; the dialog's confirm button calls performWriteToFile().
|
|
this.writeConfirmOpen = true;
|
|
},
|
|
|
|
writeConfirmMessage(): string {
|
|
switch (this.metadataWriteTarget()) {
|
|
case "embedded-opf":
|
|
return "The metadata inside this EPUB will be rewritten with your current edits. The file's checksum changes, so device syncs re-key this book.";
|
|
case "audio-tags":
|
|
return "This audiobook's embedded tags will be rewritten with your current edits. Audio streams are copied untouched, but the file's checksum changes, so device syncs re-key this book.";
|
|
case "comicinfo":
|
|
return "A ComicInfo.xml with your current edits will be written inside this comic archive. The page images are not modified.";
|
|
default:
|
|
return "A Calibre-style metadata sidecar with your current edits will be written next to this file. The media file itself is not modified.";
|
|
}
|
|
},
|
|
|
|
async performWriteToFile() {
|
|
if (this.writingFile) return;
|
|
this.writeConfirmOpen = false;
|
|
this.writingFile = true;
|
|
try {
|
|
// Save the pending editor edits FIRST: the endpoint writes the
|
|
// item's DB values, so what the user sees in the modal must be
|
|
// in the database before the file is rewritten.
|
|
if (!(await this.persistMetadata())) return;
|
|
|
|
const mediaId = getMediaId();
|
|
const resp = await fetch(`/api/media-items/${mediaId}/write-metadata`, {
|
|
method: "POST",
|
|
headers: { Authorization: getAuthHeader() },
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to write metadata to file");
|
|
}
|
|
const payload = (await resp.json()) as {
|
|
rescan?: string;
|
|
metadata_target?: string;
|
|
};
|
|
if (payload.rescan && payload.rescan !== "ok") {
|
|
showToast(
|
|
`Written, but rescan failed: ${payload.rescan.replace(/^error: /, "")}`,
|
|
"error",
|
|
);
|
|
} else {
|
|
const label =
|
|
payload.metadata_target === "embedded-opf"
|
|
? "Metadata written into the EPUB and re-extracted"
|
|
: payload.metadata_target === "audio-tags"
|
|
? "Audio tags written and re-extracted"
|
|
: payload.metadata_target === "comicinfo"
|
|
? "ComicInfo.xml written and re-extracted"
|
|
: "Metadata sidecar written and re-extracted";
|
|
showToast(label, "success");
|
|
}
|
|
// Same page refresh as Save: the editor closes and the page
|
|
// reflects the written + re-extracted state.
|
|
hideMetadataEditor();
|
|
setTimeout(() => window.location.reload(), 800);
|
|
} catch (e) {
|
|
showToast(e instanceof Error ? e.message : "Failed to write metadata to file", "error");
|
|
} finally {
|
|
this.writingFile = false;
|
|
}
|
|
},
|
|
|
|
async resetMetadata() {
|
|
if (this.resetting) return;
|
|
if (
|
|
!window.confirm(
|
|
"Reset all metadata to scanned defaults? Manual edits and custom covers will be discarded.",
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
this.resetting = true;
|
|
const mediaId = getMediaId();
|
|
try {
|
|
const resp = await fetch(`/api/media-items/${mediaId}/rescan?reset_overrides=true`, {
|
|
method: "POST",
|
|
headers: { Authorization: getAuthHeader() },
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.error || "Failed to reset metadata");
|
|
}
|
|
showToast("Metadata reset to scanned defaults", "success");
|
|
setTimeout(() => window.location.reload(), 500);
|
|
} catch (e) {
|
|
showToast(e instanceof Error ? e.message : "Failed to reset metadata", "error");
|
|
} finally {
|
|
this.resetting = false;
|
|
}
|
|
},
|
|
|
|
async searchEditorTags() {
|
|
const libraryId = document.body.getAttribute("data-library-id") || "";
|
|
if (!this.tagSearch || this.tagSearch.length < 2 || !libraryId) {
|
|
this.tagSuggestions = [];
|
|
this.showTagDropdown = false;
|
|
return;
|
|
}
|
|
const results = await searchTagSuggestions(this.tagSearch, libraryId);
|
|
const current = this.editorTags.map((t: string) => t.toLowerCase());
|
|
this.tagSuggestions = results.filter(
|
|
(r: TagSuggestion) => !current.includes(r.value.toLowerCase()),
|
|
);
|
|
this.showTagDropdown = this.tagSuggestions.length > 0;
|
|
this.highlightedTagIndex = -1;
|
|
},
|
|
|
|
addEditorTag(tag: string) {
|
|
const trimmed = tag.trim();
|
|
if (!trimmed) return;
|
|
const lower = trimmed.toLowerCase();
|
|
if (this.editorTags.some((t: string) => t.toLowerCase() === lower)) return;
|
|
this.editorTags.push(trimmed);
|
|
this.tagSearch = "";
|
|
this.showTagDropdown = false;
|
|
this.tagSuggestions = [];
|
|
this.highlightedTagIndex = -1;
|
|
},
|
|
|
|
removeEditorTag(index: number) {
|
|
this.editorTags.splice(index, 1);
|
|
},
|
|
|
|
selectEditorTagSuggestion(tag: string) {
|
|
this.addEditorTag(tag);
|
|
},
|
|
|
|
hideEditorTagDropdown() {
|
|
setTimeout(() => {
|
|
this.showTagDropdown = false;
|
|
}, 200);
|
|
},
|
|
|
|
onTagKeydown(event: KeyboardEvent) {
|
|
if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
if (this.showTagDropdown && this.tagSuggestions.length > 0) {
|
|
this.highlightedTagIndex = (this.highlightedTagIndex + 1) % this.tagSuggestions.length;
|
|
}
|
|
} else if (event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
if (this.showTagDropdown && this.tagSuggestions.length > 0) {
|
|
this.highlightedTagIndex =
|
|
this.highlightedTagIndex <= 0
|
|
? this.tagSuggestions.length - 1
|
|
: this.highlightedTagIndex - 1;
|
|
}
|
|
} else if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
if (this.highlightedTagIndex >= 0 && this.showTagDropdown) {
|
|
this.addEditorTag(this.tagSuggestions[this.highlightedTagIndex].value);
|
|
} else if (this.tagSearch.trim()) {
|
|
this.addEditorTag(this.tagSearch);
|
|
}
|
|
} else if (event.key === ",") {
|
|
event.preventDefault();
|
|
if (this.tagSearch.trim()) {
|
|
this.addEditorTag(this.tagSearch);
|
|
}
|
|
} else if (event.key === "Escape") {
|
|
this.showTagDropdown = false;
|
|
this.tagSuggestions = [];
|
|
this.highlightedTagIndex = -1;
|
|
}
|
|
},
|
|
} as MetadataEditorState;
|
|
});
|