v0.7.0: add XMP sidecars
This commit is contained in:
+158
-4
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -22,6 +23,8 @@ var (
|
||||
configValues map[string]string
|
||||
configLoaded bool
|
||||
mkdirTempFunc = os.MkdirTemp
|
||||
createTempFunc = os.CreateTemp
|
||||
writeFileFunc = os.WriteFile
|
||||
renameFunc = os.Rename
|
||||
openFileFunc = os.OpenFile
|
||||
removeFunc = os.Remove
|
||||
@@ -35,6 +38,7 @@ type exportOptions struct {
|
||||
jsonOut bool
|
||||
verify bool
|
||||
format string
|
||||
sidecar string
|
||||
minSize int64
|
||||
maxSize int64
|
||||
dateTemplate string
|
||||
@@ -208,6 +212,10 @@ COMMON EXPORT FLAGS
|
||||
--verify
|
||||
Run manifest/file verification after export or backup-all.
|
||||
|
||||
--sidecar none|xmp
|
||||
Write opt-in XMP sidecar metadata next to each exported file. Default:
|
||||
none. If XMP writing fails, the asset is counted as failed.
|
||||
|
||||
FILTERING AND SELECTION
|
||||
--since <date>
|
||||
Include only assets on or after a date. Accepts YYYY-MM-DD or RFC3339.
|
||||
@@ -731,6 +739,133 @@ func addManifestEntry(m manifest.Manifest, pa pendingAsset, result photos.Export
|
||||
m.AddEntry(manifest.NewEntry(pa.asset.ID, result.Filename, relPath, result.Size, result.Cloud))
|
||||
}
|
||||
|
||||
type xmpSidecarData struct {
|
||||
AssetID string
|
||||
OriginalFilename string
|
||||
ExportedFilename string
|
||||
Album string
|
||||
AlbumPath string
|
||||
ManifestPath string
|
||||
MediaType string
|
||||
PixelWidth int
|
||||
PixelHeight int
|
||||
IsFavorite bool
|
||||
Cloud string
|
||||
ExportMode string
|
||||
PhotoscliVersion string
|
||||
ExportedAt string
|
||||
Size int64
|
||||
CreateDate string
|
||||
}
|
||||
|
||||
func sidecarPath(exportedPath string) string {
|
||||
ext := filepath.Ext(exportedPath)
|
||||
return strings.TrimSuffix(exportedPath, ext) + ".xmp"
|
||||
}
|
||||
|
||||
func renderXMP(d xmpSidecarData) []byte {
|
||||
attrs := []struct{ key, val string }{
|
||||
{"photoscli:assetID", d.AssetID},
|
||||
{"photoscli:originalFilename", d.OriginalFilename},
|
||||
{"photoscli:exportedFilename", d.ExportedFilename},
|
||||
{"photoscli:album", d.Album},
|
||||
{"photoscli:albumPath", d.AlbumPath},
|
||||
{"photoscli:manifestPath", d.ManifestPath},
|
||||
{"photoscli:mediaType", d.MediaType},
|
||||
{"photoscli:pixelWidth", fmt.Sprintf("%d", d.PixelWidth)},
|
||||
{"photoscli:pixelHeight", fmt.Sprintf("%d", d.PixelHeight)},
|
||||
{"photoscli:isFavorite", fmt.Sprintf("%t", d.IsFavorite)},
|
||||
{"photoscli:cloud", d.Cloud},
|
||||
{"photoscli:exportMode", d.ExportMode},
|
||||
{"photoscli:photoscliVersion", d.PhotoscliVersion},
|
||||
{"photoscli:exportedAt", d.ExportedAt},
|
||||
{"photoscli:size", fmt.Sprintf("%d", d.Size)},
|
||||
{"dc:title", d.ExportedFilename},
|
||||
}
|
||||
if d.CreateDate != "" {
|
||||
attrs = append(attrs, struct{ key, val string }{"xmp:CreateDate", d.CreateDate})
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n")
|
||||
sb.WriteString("<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n")
|
||||
sb.WriteString(" <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n")
|
||||
sb.WriteString(" <rdf:Description xmlns:photoscli=\"https://gitea.k3s.k0.nu/tools/photocli/ns/1.0/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\"")
|
||||
for _, a := range attrs {
|
||||
sb.WriteString("\n ")
|
||||
sb.WriteString(a.key)
|
||||
sb.WriteString("=\"")
|
||||
xml.EscapeText(&sb, []byte(a.val))
|
||||
sb.WriteString("\"")
|
||||
}
|
||||
sb.WriteString(" />\n")
|
||||
sb.WriteString(" </rdf:RDF>\n")
|
||||
sb.WriteString("</x:xmpmeta>\n")
|
||||
sb.WriteString("<?xpacket end=\"w\"?>\n")
|
||||
return []byte(sb.String())
|
||||
}
|
||||
|
||||
func writeXMPSidecar(path string, data xmpSidecarData) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := createTempFunc(filepath.Dir(path), ".*.xmp.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := f.Name()
|
||||
_ = f.Close()
|
||||
if err := writeFileFunc(tmp, renderXMP(data), 0644); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := renameFunc(tmp, path); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals bool, opts exportOptions) error {
|
||||
if opts.sidecar != "xmp" {
|
||||
return nil
|
||||
}
|
||||
mode := "preview"
|
||||
if originals {
|
||||
mode = "original"
|
||||
}
|
||||
root := pa.root
|
||||
if root == "" {
|
||||
root = pa.path
|
||||
}
|
||||
fullPath := filepath.Join(pa.path, result.Filename)
|
||||
relPath, err := filepath.Rel(root, fullPath)
|
||||
if err != nil || strings.HasPrefix(relPath, "..") {
|
||||
relPath = result.Filename
|
||||
}
|
||||
createDate := ""
|
||||
if pa.asset.CreationDate != nil {
|
||||
createDate = *pa.asset.CreationDate
|
||||
}
|
||||
return writeXMPSidecar(sidecarPath(fullPath), xmpSidecarData{
|
||||
AssetID: pa.asset.ID,
|
||||
OriginalFilename: pa.asset.Filename,
|
||||
ExportedFilename: result.Filename,
|
||||
Album: pa.album,
|
||||
AlbumPath: pa.path,
|
||||
ManifestPath: relPath,
|
||||
MediaType: pa.asset.MediaType,
|
||||
PixelWidth: pa.asset.PixelWidth,
|
||||
PixelHeight: pa.asset.PixelHeight,
|
||||
IsFavorite: pa.asset.IsFavorite,
|
||||
Cloud: result.Cloud,
|
||||
ExportMode: mode,
|
||||
PhotoscliVersion: version,
|
||||
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Size: result.Size,
|
||||
CreateDate: createDate,
|
||||
})
|
||||
}
|
||||
|
||||
func collectPendingAssets(nodes []photos.CollectionNode, outDir string, bridge photos.Bridge, skipVideos bool, originals bool, onProgress func(collectProgress), m manifest.Manifest, sortNewest bool, exclude []string, since time.Time, opts exportOptions) ([]pendingAsset, int) {
|
||||
var items []pendingAsset
|
||||
var skipped int
|
||||
@@ -904,8 +1039,15 @@ func exportPendingSerial(pending []pendingAsset, targetSize, quality int, origin
|
||||
} else if isSkipped {
|
||||
addManifestEntry(m, pa, result)
|
||||
} else {
|
||||
done++
|
||||
addManifestEntry(m, pa, result)
|
||||
if sidecarErr := writeSidecarIfNeeded(pa, result, originals, opts); sidecarErr != nil {
|
||||
failed++
|
||||
exportErr = sidecarErr
|
||||
isErr = true
|
||||
appendFailure(pa.path, pa, sidecarErr)
|
||||
} else {
|
||||
done++
|
||||
addManifestEntry(m, pa, result)
|
||||
}
|
||||
}
|
||||
avgSpeed := float64(0)
|
||||
if totalDur > 0 {
|
||||
@@ -1033,8 +1175,15 @@ func exportPendingParallel(pending []pendingAsset, targetSize, quality int, orig
|
||||
} else if isSkipped {
|
||||
addManifestEntry(m, entry.pa, entry.result)
|
||||
} else {
|
||||
done++
|
||||
addManifestEntry(m, entry.pa, entry.result)
|
||||
if sidecarErr := writeSidecarIfNeeded(entry.pa, entry.result, originals, opts); sidecarErr != nil {
|
||||
failed++
|
||||
entry.err = sidecarErr
|
||||
isErr = true
|
||||
appendFailure(entry.pa.path, entry.pa, sidecarErr)
|
||||
} else {
|
||||
done++
|
||||
addManifestEntry(m, entry.pa, entry.result)
|
||||
}
|
||||
}
|
||||
avgSpeed := float64(0)
|
||||
if totalDur > 0 {
|
||||
@@ -1381,6 +1530,7 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
|
||||
jsonOut: hasFlag(args, "--json"),
|
||||
verify: hasFlag(args, "--verify"),
|
||||
format: flagValWithDefault(args, "--format", "jpeg"),
|
||||
sidecar: flagValWithDefault(args, "--sidecar", "none"),
|
||||
dateTemplate: flagVal(args, "--date-template"),
|
||||
}
|
||||
if opts.media != "photos" && opts.media != "videos" && opts.media != "all" {
|
||||
@@ -1391,6 +1541,10 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
|
||||
fmt.Fprintf(stderr, "error: --format must be jpeg, heic, or png, got %q\n", opts.format)
|
||||
return opts, false
|
||||
}
|
||||
if opts.sidecar != "none" && opts.sidecar != "xmp" {
|
||||
fmt.Fprintf(stderr, "error: --sidecar must be none or xmp, got %q\n", opts.sidecar)
|
||||
return opts, false
|
||||
}
|
||||
if v := flagVal(args, "--retry"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 0 {
|
||||
|
||||
Reference in New Issue
Block a user