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
@@ -0,0 +1,208 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Unit tests for the metadata writer's pure parts (2026-10-27): OPF
|
||||
// generation round-trips through the scanner's OWN parser
|
||||
// (parseOPFContent — write side and read side can never disagree), the
|
||||
// EPUB splice preserves manifest/spine, and the zip rewrite keeps
|
||||
// mimetype first + stored. The ffmpeg audio writer (5b) will extend
|
||||
// this file.
|
||||
|
||||
func fixtureItem() *database.MediaItems {
|
||||
pubDate, _ := time.Parse("2006-01-02", "1853-07-14")
|
||||
return &database.MediaItems{
|
||||
Title: `Progress & "Quotes" <etc>`,
|
||||
Subtitle: pgtype.Text{String: "A Subtitle", Valid: true},
|
||||
Authors: []string{"Ada Lovelace", "Grace Hopper"},
|
||||
Description: pgtype.Text{
|
||||
String: "A <b>description</b> with entities & such", Valid: true,
|
||||
},
|
||||
Publishers: []string{"Analytical Engine Press"},
|
||||
DatePublished: pgtype.Date{Time: pubDate, Valid: true},
|
||||
Language: pgtype.Text{String: "en", Valid: true},
|
||||
Isbn: pgtype.Text{String: "978-0-13-235088-4", Valid: true},
|
||||
Tags: []string{"Science", "Computing"},
|
||||
Series: pgtype.Text{String: "Scientific Memoirs", Valid: true},
|
||||
SeriesNumber: pgtype.Float8{Float64: 3, Valid: true},
|
||||
}
|
||||
}
|
||||
|
||||
// buildTestEPUB creates a minimal EPUB (mimetype + container + OPF with
|
||||
// manifest/spine) in memory.
|
||||
func buildTestEPUB(t *testing.T, opfContent string) []*zip.File {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
w := zip.NewWriter(&buf)
|
||||
|
||||
mimetype, _ := w.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store})
|
||||
_, _ = mimetype.Write([]byte("application/epub+zip"))
|
||||
|
||||
container, _ := w.Create("META-INF/container.xml")
|
||||
_, _ = container.Write([]byte(`<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`))
|
||||
|
||||
opf, _ := w.Create("OEBPS/content.opf")
|
||||
_, _ = opf.Write([]byte(opfContent))
|
||||
|
||||
chapter, _ := w.Create("OEBPS/chapter1.xhtml")
|
||||
_, _ = chapter.Write([]byte("<html><body>Chapter</body></html>"))
|
||||
|
||||
require.NoError(t, w.Close())
|
||||
reader, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
||||
require.NoError(t, err)
|
||||
return reader.File
|
||||
}
|
||||
|
||||
const spliceTestOPF = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<package version="3.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="pub-id">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title id="t1">Old Title</dc:title>
|
||||
<dc:creator>Old Author</dc:creator>
|
||||
<dc:identifier id="pub-id">urn:uuid:1234</dc:identifier>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="ch1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>
|
||||
</manifest>
|
||||
<spine>
|
||||
<itemref idref="ch1"/>
|
||||
</spine>
|
||||
</package>
|
||||
`
|
||||
|
||||
func TestMetadataWriteTargetFor(t *testing.T) {
|
||||
assert.Equal(t, MetadataTargetEmbeddedOPF, MetadataWriteTargetFor(".epub"))
|
||||
assert.Equal(t, MetadataTargetEmbeddedOPF, MetadataWriteTargetFor(".KEPUB"))
|
||||
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".pdf"))
|
||||
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".m4b"))
|
||||
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".cbz"))
|
||||
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(""))
|
||||
}
|
||||
|
||||
func TestBuildOPFMetadataInnerRoundTrips(t *testing.T) {
|
||||
item := fixtureItem()
|
||||
xmlInner := buildOPFMetadataInner(item)
|
||||
|
||||
// Wrap in a full package and parse with the SCANNER's parser: the
|
||||
// write must survive the read.
|
||||
doc := `<?xml version="1.0"?><package xmlns="http://www.idpf.org/2007/opf"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">` +
|
||||
xmlInner + `</metadata></package>`
|
||||
md, err := parseOPFContent([]byte(doc))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "Progress & \"Quotes\" <etc>: A Subtitle", md.Title, "main title + calibre-style subtitle join")
|
||||
assert.Equal(t, "Ada Lovelace", md.Author, "first creator")
|
||||
assert.Equal(t, "A <b>description</b> with entities & such", md.Description, "entities unescaped on read")
|
||||
assert.Equal(t, "Analytical Engine Press", md.Publisher)
|
||||
assert.Equal(t, "en", md.Language)
|
||||
assert.Equal(t, "9780132350884", md.ISBN, "normalized on read")
|
||||
assert.Equal(t, "Scientific Memoirs", md.Series)
|
||||
assert.Equal(t, float64(3), md.SeriesNumber)
|
||||
assert.ElementsMatch(t, []string{"Science", "Computing"}, md.Tags)
|
||||
require.NotNil(t, md.PublishDate)
|
||||
assert.Equal(t, "1853-07-14", md.PublishDate.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
func TestBuildOPFMetadataInnerOmitsEmptyFields(t *testing.T) {
|
||||
item := &database.MediaItems{Title: "Only A Title"}
|
||||
xmlInner := buildOPFMetadataInner(item)
|
||||
assert.NotContains(t, xmlInner, "dc:creator")
|
||||
assert.NotContains(t, xmlInner, "dc:description")
|
||||
assert.NotContains(t, xmlInner, "calibre:series")
|
||||
assert.NotContains(t, xmlInner, "dc:title id=\"tsub\"", "no subtitle element when unset")
|
||||
|
||||
// Singular-author fallback when the authors array is empty.
|
||||
item = &database.MediaItems{
|
||||
Title: "Fallback",
|
||||
Author: pgtype.Text{String: "Solo Author", Valid: true},
|
||||
}
|
||||
assert.Contains(t, buildOPFMetadataInner(item), "Solo Author")
|
||||
}
|
||||
|
||||
func TestSpliceOPFMetadataPreservesStructure(t *testing.T) {
|
||||
entries := buildTestEPUB(t, spliceTestOPF)
|
||||
item := fixtureItem()
|
||||
|
||||
newOPF, err := spliceOPFMetadata(entries, "OEBPS/content.opf", item)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The old metadata values are gone…
|
||||
out := string(newOPF)
|
||||
assert.NotContains(t, out, "Old Title")
|
||||
assert.NotContains(t, out, "Old Author")
|
||||
// …the new values are in…
|
||||
assert.Contains(t, out, `Progress & "Quotes" <etc>`)
|
||||
assert.Contains(t, out, "Ada Lovelace")
|
||||
// …and manifest/spine/identifier survive untouched.
|
||||
assert.Contains(t, out, `<item id="ch1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>`)
|
||||
assert.Contains(t, out, `<itemref idref="ch1"/>`)
|
||||
assert.Contains(t, out, `urn:uuid:1234`)
|
||||
assert.Contains(t, out, `unique-identifier="pub-id"`, "original opening tag preserved")
|
||||
|
||||
// The spliced OPF parses back with the scanner's parser.
|
||||
md, err := parseOPFContent(newOPF)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, item.Title+": A Subtitle", md.Title)
|
||||
}
|
||||
|
||||
func TestRewriteEPUBZipKeepsMimetypeFirstAndStored(t *testing.T) {
|
||||
entries := buildTestEPUB(t, spliceTestOPF)
|
||||
item := fixtureItem()
|
||||
newOPF, err := spliceOPFMetadata(entries, "OEBPS/content.opf", item)
|
||||
require.NoError(t, err)
|
||||
|
||||
tmp := filepath.Join(t.TempDir(), "book.epub")
|
||||
require.NoError(t, rewriteEPUBZip(entries, "OEBPS/content.opf", newOPF, tmp))
|
||||
|
||||
reader, err := zip.OpenReader(tmp)
|
||||
require.NoError(t, err)
|
||||
defer reader.Close()
|
||||
|
||||
require.NotEmpty(t, reader.File)
|
||||
assert.Equal(t, "mimetype", reader.File[0].Name, "mimetype must be the first entry")
|
||||
assert.Equal(t, zip.Store, reader.File[0].FileHeader.Method, "mimetype must be uncompressed")
|
||||
|
||||
var opfEntry *zip.File
|
||||
names := []string{}
|
||||
for _, f := range reader.File {
|
||||
names = append(names, f.Name)
|
||||
if f.Name == "OEBPS/content.opf" {
|
||||
opfEntry = f
|
||||
}
|
||||
}
|
||||
assert.ElementsMatch(t, []string{"mimetype", "META-INF/container.xml", "OEBPS/content.opf", "OEBPS/chapter1.xhtml"}, names)
|
||||
require.NotNil(t, opfEntry)
|
||||
rc, err := opfEntry.Open()
|
||||
require.NoError(t, err)
|
||||
content, err := io.ReadAll(rc)
|
||||
require.NoError(t, rc.Close())
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(content), "Ada Lovelace")
|
||||
}
|
||||
|
||||
func TestAtomicWriteReplacesContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "metadata.opf")
|
||||
require.NoError(t, os.WriteFile(target, []byte("old"), 0o644))
|
||||
require.NoError(t, atomicWrite(target, []byte("new content")))
|
||||
got, err := os.ReadFile(target)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "new content", string(got))
|
||||
// No temp litter left behind.
|
||||
entries, err := os.ReadDir(dir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, entries, 1)
|
||||
}
|
||||
Reference in new issue
Block a user