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

+16 -15
View File
@@ -1773,23 +1773,24 @@ func applyTitleDerivedSeries(metadata *MediaMetadata) {
// extractCalibreSidecar checks for and parses a Calibre metadata.opf sidecar file
func (s *MediaScanner) extractCalibreSidecar(path string) *MediaMetadata {
// Get directory of media file
// Per-file sidecar FIRST (<basename>.opf, the writer's multi-book
// safe name), then Calibre's dir-scoped metadata.opf.
dir := filepath.Dir(path)
opfPath := filepath.Join(dir, "metadata.opf")
// Check if sidecar exists
if _, err := os.Stat(opfPath); os.IsNotExist(err) {
return nil // No sidecar, not an error
base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
for _, name := range []string{base + ".opf", "metadata.opf"} {
opfPath := filepath.Join(dir, name)
if _, err := os.Stat(opfPath); err != nil {
continue
}
// Parse sidecar
metadata, err := s.parseCalibreMetadataOPF(opfPath)
if err != nil {
fmt.Printf("Warning: failed to parse Calibre metadata.opf: %v\n", err)
continue // fall back to the next candidate / embedded
}
return metadata
}
// Parse sidecar
metadata, err := s.parseCalibreMetadataOPF(opfPath)
if err != nil {
fmt.Printf("Warning: failed to parse Calibre metadata.opf: %v\n", err)
return nil // Parsing failed, fall back to embedded
}
return metadata
return nil
}
// applyEmbeddedCoverFallback extracts a cover from the media file itself when
+517
View File
@@ -0,0 +1,517 @@
package services
import (
"archive/zip"
"bytes"
"context"
"encoding/xml"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
// MetadataWriter persists the item's CURRENT database metadata back into
// the artifact on disk, so the library stays portable (Calibre, phone
// players, other servers). 2026-10-27 feature (user request): embed into
// formats that carry metadata, write a Calibre-style sidecar for those
// that don't.
//
// Safety contract:
// - temp file + atomic rename; the original is NEVER truncated on
// failure (a failed write must not destroy a book)
// - the write mirrors what the scanner's read chain parses
// (parseOPFContent), so a write + rescan round-trips
// - metadata_overrides are left untouched: after a successful write
// the file and the DB agree, and the overrides keep stale sidecars
// from clobbering later rescans
//
// Consequences that are inherent to editing files in place (documented
// in docs/user/calibre-integration.md): the mtime change makes the
// watcher re-extract (wanted), and the SHA-256 changes (dedup/move
// detection re-keys; Kobo device sidecars re-key on next sync).
// MetadataWriteTarget names where a format's metadata landed.
type MetadataWriteTarget string
const (
// MetadataTargetEmbeddedOPF: the OPF inside the EPUB was rewritten.
MetadataTargetEmbeddedOPF MetadataWriteTarget = "embedded-opf"
// MetadataTargetSidecarOPF: a Calibre-style sidecar was written next
// to the file (formats that can't carry metadata: PDF, comics,
// folder books; audio until phase 5b).
MetadataTargetSidecarOPF MetadataWriteTarget = "sidecar-opf"
)
// MetadataWriteResult reports what the write did, for the UI toast and
// the response contract.
type MetadataWriteResult struct {
Target MetadataWriteTarget `json:"target"`
Path string `json:"path"` // absolute path of the artifact written
Sidecar bool `json:"sidecar"`
FieldsWritten []string `json:"fields_written"`
}
// MetadataWriteTargetFor maps a file extension to the write target.
// The UI mirrors this to label the button; keep both in lockstep.
func MetadataWriteTargetFor(ext string) MetadataWriteTarget {
switch strings.ToLower(strings.TrimSpace(ext)) {
case ".epub", ".kepub":
return MetadataTargetEmbeddedOPF
default:
// 5b (audio tags via ffmpeg) and 5c (CBZ ComicInfo.xml) will
// claim their formats here; everything else takes the sidecar.
return MetadataTargetSidecarOPF
}
}
// MetadataWriter carries the dependencies for file-writing operations.
type MetadataWriter struct {
db *database.Queries
libraries *LibraryService
}
func NewMetadataWriter(db *database.Queries) *MetadataWriter {
return &MetadataWriter{db: db, libraries: NewLibraryService(db)}
}
// WriteMetadataToFile dispatches on the item's format and persists the
// current DB values into the artifact on disk.
func (w *MetadataWriter) WriteMetadataToFile(ctx context.Context, itemID uuid.UUID) (*MetadataWriteResult, error) {
item, err := w.db.GetMediaItem(ctx, pgtype.UUID{Bytes: itemID, Valid: true})
if err != nil {
return nil, fmt.Errorf("media item not found: %w", err)
}
absPath, err := w.libraries.ResolveMediaPath(ctx, item.LibraryID, item.FilePath)
if err != nil {
return nil, fmt.Errorf("cannot resolve library folder: %w", err)
}
if _, err := os.Stat(absPath); err != nil {
return nil, fmt.Errorf("media file missing on disk: %w", err)
}
switch MetadataWriteTargetFor(filepath.Ext(absPath)) {
case MetadataTargetEmbeddedOPF:
path, err := w.writeEPUBMetadata(absPath, &item)
if err != nil {
return nil, err
}
return &MetadataWriteResult{
Target: MetadataTargetEmbeddedOPF,
Path: path,
FieldsWritten: metadataFieldNames(&item),
}, nil
default:
path, err := w.writeSidecarOPF(ctx, absPath, &item)
if err != nil {
return nil, err
}
return &MetadataWriteResult{
Target: MetadataTargetSidecarOPF,
Path: path,
Sidecar: true,
FieldsWritten: metadataFieldNames(&item),
}, nil
}
}
// writeSidecarOPF writes a Calibre-style OPF next to the media file.
// Naming: `metadata.opf` (Calibre's own convention) when the item is
// the only book in its directory; otherwise `<basename>.opf` so one
// book's sidecar can never poison its dir-mates' scans. The scanner
// reads both (basename first).
func (w *MetadataWriter) writeSidecarOPF(ctx context.Context, absPath string, item *database.MediaItems) (string, error) {
dir := filepath.Dir(absPath)
base := strings.TrimSuffix(filepath.Base(absPath), filepath.Ext(absPath))
others, err := w.countSiblingBooks(ctx, item)
if err != nil {
return "", fmt.Errorf("cannot check sibling books: %w", err)
}
name := "metadata.opf"
if others > 0 {
name = base + ".opf"
}
sidecarPath := filepath.Join(dir, name)
doc := buildSidecarOPFXML(item)
if err := atomicWrite(sidecarPath, []byte(doc)); err != nil {
return "", fmt.Errorf("failed to write sidecar: %w", err)
}
return sidecarPath, nil
}
// countSiblingBooks counts OTHER non-archived items that live in the
// same directory of the same library — the guard against writing a
// dir-scoped metadata.opf over a multi-book folder.
func (w *MetadataWriter) countSiblingBooks(ctx context.Context, item *database.MediaItems) (int, error) {
dir := filepath.ToSlash(filepath.Dir(item.FilePath))
siblings, err := w.db.CountOtherMediaItemsInDir(ctx, database.CountOtherMediaItemsInDirParams{
LibraryID: item.LibraryID,
ID: item.ID,
DirPrefix: pgtype.Text{String: dir + "/", Valid: true},
})
if err != nil {
return 0, err
}
return int(siblings), nil
}
// writeEPUBMetadata splices a freshly generated <metadata> block into
// the EPUB's existing OPF (manifest/spine/guide preserved untouched)
// and rewrites the zip atomically: every other entry is copied
// byte-identical, in original order with original compression — the
// mimetype entry must stay first and STORED.
func (w *MetadataWriter) writeEPUBMetadata(absPath string, item *database.MediaItems) (string, error) {
reader, err := zip.OpenReader(absPath)
if err != nil {
return "", fmt.Errorf("failed to open EPUB: %w", err)
}
defer reader.Close()
opfName := findOPFPathInZip(reader.File)
if opfName == "" {
return "", fmt.Errorf("EPUB has no OPF (container.xml missing rootfile)")
}
newOPF, err := spliceOPFMetadata(reader.File, opfName, item)
if err != nil {
return "", err
}
tmpPath := absPath + ".tmp-write"
if err := rewriteEPUBZip(reader.File, opfName, newOPF, tmpPath); err != nil {
os.Remove(tmpPath)
return "", err
}
if err := os.Rename(tmpPath, absPath); err != nil {
os.Remove(tmpPath)
return "", fmt.Errorf("failed to replace EPUB: %w", err)
}
return absPath, nil
}
// spliceOPFMetadata returns the OPF with its <metadata> inner XML
// replaced. The ORIGINAL opening tag (with its namespaces and
// unique-identifier) is preserved so manifest/spine id references keep
// resolving.
func spliceOPFMetadata(entries []*zip.File, opfName string, item *database.MediaItems) ([]byte, error) {
var opfEntry *zip.File
for _, f := range entries {
if f.Name == opfName {
opfEntry = f
break
}
}
if opfEntry == nil {
return nil, fmt.Errorf("OPF %q not found in EPUB", opfName)
}
rc, err := opfEntry.Open()
if err != nil {
return nil, fmt.Errorf("failed to open OPF: %w", err)
}
original, err := io.ReadAll(rc)
closeErr := rc.Close()
if err != nil {
return nil, fmt.Errorf("failed to read OPF: %w", err)
}
if closeErr != nil {
return nil, fmt.Errorf("failed to close OPF: %w", closeErr)
}
metaStart := bytes.Index(original, []byte("<metadata"))
if metaStart == -1 {
return nil, fmt.Errorf("OPF has no <metadata> element")
}
openEnd := bytes.Index(original[metaStart:], []byte(">"))
if openEnd == -1 {
return nil, fmt.Errorf("OPF <metadata> tag malformed")
}
metaClose := bytes.Index(original[metaStart:], []byte("</metadata>"))
if metaClose == -1 {
return nil, fmt.Errorf("OPF has no </metadata> close")
}
openTag := original[metaStart : metaStart+openEnd+1]
inner := buildOPFMetadataInner(item) + originalIdentifiers(original[metaStart:metaStart+metaClose])
out := bytes.NewBuffer(nil)
out.Write(original[:metaStart])
out.Write(openTag)
out.WriteString("\n")
out.WriteString(inner)
out.Write(original[metaStart+metaClose:])
return out.Bytes(), nil
}
// originalIdentifiers extracts the old metadata's dc:identifier
// elements verbatim. The package's unique-identifier attribute
// references one of them, so replacing the metadata block must keep
// them (generated fields come FIRST, so the parser's first-wins reads
// the fresh ISBN; the originals remain for identifier stability).
func originalIdentifiers(oldMetadata []byte) string {
var b strings.Builder
rest := oldMetadata
for {
start := bytes.Index(rest, []byte("<dc:identifier"))
if start == -1 {
break
}
end := bytes.Index(rest[start:], []byte("</dc:identifier>"))
if end == -1 {
break
}
end += start + len("</dc:identifier>")
b.Write(rest[start:end])
b.WriteString("\n")
rest = rest[end:]
}
return b.String()
}
// rewriteEPUBZip copies every entry to dst, substituting opfName's
// content. Order, compression method and modes are preserved.
func rewriteEPUBZip(entries []*zip.File, opfName string, newOPF []byte, dst string) error {
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return fmt.Errorf("failed to create temp EPUB: %w", err)
}
writer := zip.NewWriter(out)
for _, f := range entries {
header := f.FileHeader
header.Flags &= ^uint16(0x8) // drop data-descriptor flag; sizes are known upfront
var target io.Writer
if f.Name == opfName {
// Sizes/CRC recomputed by the writer for the new content.
header.CompressedSize64 = 0
header.UncompressedSize64 = 0
header.CompressedSize = 0
header.UncompressedSize = 0
header.CRC32 = 0
target, err = writer.CreateHeader(&header)
if err != nil {
writer.Close()
out.Close()
return fmt.Errorf("failed to stage OPF entry: %w", err)
}
if _, err := target.Write(newOPF); err != nil {
writer.Close()
out.Close()
return fmt.Errorf("failed to write OPF entry: %w", err)
}
continue
}
target, err = writer.CreateHeader(&header)
if err != nil {
writer.Close()
out.Close()
return fmt.Errorf("failed to stage entry %s: %w", f.Name, err)
}
rc, openErr := f.Open()
if openErr != nil {
writer.Close()
out.Close()
return fmt.Errorf("failed to read entry %s: %w", f.Name, openErr)
}
if _, err := io.Copy(target, rc); err != nil {
rc.Close()
writer.Close()
out.Close()
return fmt.Errorf("failed to copy entry %s: %w", f.Name, err)
}
if err := rc.Close(); err != nil {
writer.Close()
out.Close()
return fmt.Errorf("failed to finish entry %s: %w", f.Name, err)
}
}
if err := writer.Close(); err != nil {
out.Close()
return fmt.Errorf("failed to finalize EPUB: %w", err)
}
if err := out.Sync(); err != nil {
out.Close()
return fmt.Errorf("failed to sync EPUB: %w", err)
}
return out.Close()
}
// atomicWrite writes via temp file + rename in the SAME directory so a
// crash or a full disk can never leave a truncated book (or sidecar)
// behind.
func atomicWrite(path string, data []byte) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".bookhoard-write-*")
if err != nil {
return err
}
tmpPath := tmp.Name()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmpPath)
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
os.Remove(tmpPath)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return err
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
return err
}
return nil
}
// ---- OPF generation (mirrors parseOPFContent's read set) ----
// escXML escapes the five XML entities for element and attribute text.
func escXML(s string) string {
return strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&apos;",
).Replace(s)
}
// buildSidecarOPFXML renders a complete Calibre-compatible package
// document for a sidecar.
func buildSidecarOPFXML(item *database.MediaItems) string {
var b strings.Builder
b.WriteString(xml.Header)
b.WriteString(`<package version="2.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="bookhoard-uuid">` + "\n")
b.WriteString(`<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">` + "\n")
b.WriteString(buildOPFMetadataInner(item))
b.WriteString("</metadata>\n</package>\n")
return b.String()
}
// buildOPFMetadataInner renders the dc:* elements for the item's
// current values — exactly the fields parseOPFContent reads back
// (title/subtitle via title-type, creators, subjects, description,
// publisher, date, language, isbn identifier, calibre:series metas).
// Empty DB values simply omit their element: a sidecar absent-field
// means "no data", never "delete this from the file".
func buildOPFMetadataInner(item *database.MediaItems) string {
var b strings.Builder
title := strings.TrimSpace(item.Title)
if title != "" {
b.WriteString("\t<dc:title id=\"tmain\">" + escXML(title) + "</dc:title>\n")
b.WriteString("\t<meta refines=\"#tmain\" property=\"title-type\">main</meta>\n")
}
if item.Subtitle.Valid && strings.TrimSpace(item.Subtitle.String) != "" {
b.WriteString("\t<dc:title id=\"tsub\">" + escXML(strings.TrimSpace(item.Subtitle.String)) + "</dc:title>\n")
b.WriteString("\t<meta refines=\"#tsub\" property=\"title-type\">subtitle</meta>\n")
}
for _, a := range item.Authors {
a = strings.TrimSpace(a)
if a == "" {
continue
}
b.WriteString("\t<dc:creator opf:role=\"aut\">" + escXML(a) + "</dc:creator>\n")
}
if len(item.Authors) == 0 && item.Author.Valid && strings.TrimSpace(item.Author.String) != "" {
b.WriteString("\t<dc:creator opf:role=\"aut\">" + escXML(strings.TrimSpace(item.Author.String)) + "</dc:creator>\n")
}
if item.Description.Valid && strings.TrimSpace(item.Description.String) != "" {
b.WriteString("\t<dc:description>" + escXML(item.Description.String) + "</dc:description>\n")
}
for _, p := range item.Publishers {
p = strings.TrimSpace(p)
if p == "" {
continue
}
b.WriteString("\t<dc:publisher>" + escXML(p) + "</dc:publisher>\n")
break // parser reads the first
}
if item.DatePublished.Valid {
b.WriteString("\t<dc:date>" + item.DatePublished.Time.Format("2006-01-02") + "</dc:date>\n")
}
if item.Language.Valid && strings.TrimSpace(item.Language.String) != "" {
b.WriteString("\t<dc:language>" + escXML(strings.TrimSpace(item.Language.String)) + "</dc:language>\n")
}
if item.Isbn.Valid && strings.TrimSpace(item.Isbn.String) != "" {
b.WriteString("\t<dc:identifier opf:scheme=\"ISBN\">" + escXML(strings.TrimSpace(item.Isbn.String)) + "</dc:identifier>\n")
}
// The package's unique-identifier needs a matching element: the
// scan's OPF uuid when known, else the item id (the parser files
// urn:uuid under hash info, never metadata).
uid := ""
if item.OpfUuid.Valid {
uid = strings.TrimSpace(item.OpfUuid.String)
}
if uid == "" && item.ID.Valid {
uid = uuid.UUID(item.ID.Bytes).String()
}
if uid != "" {
b.WriteString("\t<dc:identifier id=\"bookhoard-uuid\">urn:uuid:" + escXML(uid) + "</dc:identifier>\n")
}
for _, t := range item.Tags {
t = strings.TrimSpace(t)
if t == "" {
continue
}
b.WriteString("\t<dc:subject>" + escXML(t) + "</dc:subject>\n")
}
if item.Series.Valid && strings.TrimSpace(item.Series.String) != "" {
b.WriteString("\t<meta name=\"calibre:series\" content=\"" + escXML(item.Series.String) + "\"/>\n")
if item.SeriesNumber.Valid && item.SeriesNumber.Float64 > 0 {
b.WriteString("\t<meta name=\"calibre:series_index\" content=\"" + strconvFormatFloat(item.SeriesNumber.Float64) + "\"/>\n")
}
}
return b.String()
}
// metadataFieldNames lists the DB fields a write mirrors — response
// contract for the UI ("what got written").
func metadataFieldNames(item *database.MediaItems) []string {
fields := []string{"title"}
if item.Subtitle.Valid && item.Subtitle.String != "" {
fields = append(fields, "subtitle")
}
if (len(item.Authors) > 0) || (item.Author.Valid && item.Author.String != "") {
fields = append(fields, "authors")
}
if item.Description.Valid && item.Description.String != "" {
fields = append(fields, "description")
}
if len(item.Publishers) > 0 || (item.Publisher.Valid && item.Publisher.String != "") {
fields = append(fields, "publisher")
}
if item.DatePublished.Valid {
fields = append(fields, "date_published")
}
if item.Language.Valid && item.Language.String != "" {
fields = append(fields, "language")
}
if item.Isbn.Valid && item.Isbn.String != "" {
fields = append(fields, "isbn")
}
if len(item.Tags) > 0 {
fields = append(fields, "tags")
}
if item.Series.Valid && item.Series.String != "" {
fields = append(fields, "series")
}
return fields
}
// strconvFormatFloat renders series indexes without trailing noise
// (3 → "3", 3.5 → "3.5").
func strconvFormatFloat(f float64) string {
return fmt.Sprintf("%g", f)
}
+208
View File
@@ -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 &amp; &quot;Quotes&quot; &lt;etc&gt;`)
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)
}