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,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(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
`"`, """,
|
||||
"'", "'",
|
||||
).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)
|
||||
}
|
||||
Reference in new issue
Block a user