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:
1 parent
61e5f47ec0
commit
eaf1b2c8dd
13 files changed
+1591
-413
No files matched your search
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user