feat(metadata): write-to-file — embedded EPUB OPF + Calibre sidecars

Phase 5a of the metadata-writer feature: the book editor gains a
"Write to file" action (admin) that persists the item's CURRENT
database metadata into the artifact on disk, so the library stays
portable to Calibre and other apps — the thing Save alone can't do.

Dispatch (services/metadata_writer.go): EPUB/KEPUB gets its embedded
OPF spliced in place (fresh dc:* block generated inside the ORIGINAL
metadata open tag; manifest/spine/guide untouched; every other zip
entry copied byte-identical in original order so mimetype stays first
and STORED; original dc:identifiers preserved — the package's
unique-identifier must keep resolving). Everything else takes a
Calibre-style sidecar: metadata.opf when the book is ALONE in its
directory (Calibre's own convention — round-trips through Calibre
too), <basename>.opf when siblings share the folder, so one book's
sidecar can never poison dir-mates' scans (the scanner now reads both,
basename first).

Safety contract: temp file + atomic rename — the original is never
truncated on failure; the generated OPF is unit-tested to ROUND-TRIP
through the scanner's own parser (write side and read side can never
disagree); metadata_overrides are deliberately kept (file and DB agree
after the write; overrides still guard against stale sidecars). The
endpoint re-extracts inline (RescanMediaItem) and reports rescan
failure separately — a failed rescan doesn't undo the write. SHA-256
changes are inherent and documented for users (Kobo/KOReader re-key).

UI: the metadata editor modal carries the file path as a data
attribute; the button confirms before mutating, labels the target
(embedded vs sidecar) and toasts the outcome. Integration tests pin
the 403, missing-file error, EPUB embed + post-write rescan title, the
lone-book metadata.opf case, and the shared-dir <basename>.opf case
with no dir-scoped litter.
This commit is contained in:
John O'Keefe committed 2026-10-27 15:36:43 -04:00
1 parent 61e5f47ec0
commit eaf1b2c8dd
13 files changed
+1591 -413

No files matched your search

+77
View File
@@ -127,6 +127,8 @@ interface MetadataEditorState {
saving: boolean;
rescanning: boolean;
resetting: boolean;
writingFile: boolean;
fileWritePath: string;
userRating: number;
ratingHover: number;
ratingSaving: boolean;
@@ -141,6 +143,9 @@ interface MetadataEditorState {
saveMetadata(): Promise<void>;
rescanBook(): Promise<void>;
resetMetadata(): Promise<void>;
writeMetadataToFile(): Promise<void>;
metadataWriteTarget(): string;
metadataWriteHint(): string;
starFill(i: number): string;
ratingText(): string;
setRating(value: number): Promise<void>;
@@ -181,6 +186,15 @@ Alpine.data("bookDetail", () => {
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,
fileWritePath: (() => {
const modal = document.getElementById("metadata-editor-modal");
return modal?.getAttribute("data-write-file-path") || "";
})(),
userRating: 0,
ratingHover: 0,
ratingSaving: false,
@@ -873,6 +887,69 @@ Alpine.data("bookDetail", () => {
}
},
// metadataWriteTarget mirrors services.MetadataWriteTargetFor: EPUB
// embeds its OPF, everything else takes a sidecar until the audio
// (5b) and CBZ (5c) writers land.
metadataWriteTarget(): string {
const ext = (this.fileWritePath.split(".").pop() || "").toLowerCase();
if (ext === "epub" || ext === "kepub") return "embedded-opf";
return "sidecar-opf";
},
metadataWriteHint(): string {
if (this.metadataWriteTarget() === "embedded-opf") {
return "Rewrite the metadata inside the EPUB, then rescan";
}
return "Write a Calibre-style metadata sidecar next to the file, then rescan";
},
async writeMetadataToFile() {
if (this.writingFile) return;
if (
!window.confirm(
this.metadataWriteTarget() === "embedded-opf"
? "Rewrite the metadata inside this EPUB with the current saved values? The original file will be modified (the SHA-256 changes, so device syncs re-key this book)."
: "Write a metadata sidecar next to this file with the current saved values? The media file itself is not modified.",
)
) {
return;
}
this.writingFile = true;
const mediaId = getMediaId();
try {
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 {
showToast(
payload.metadata_target === "embedded-opf"
? "Metadata written into the EPUB and re-extracted"
: "Metadata sidecar written and re-extracted",
"success",
);
}
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 (