v0.8.7: add JSON sidecars
pipeline / build (push) Has been cancelled
pipeline / test (push) Has been cancelled

This commit is contained in:
Ein Anderssono
2026-06-15 02:11:02 +02:00
parent 5c40b1d3ba
commit d909d30b87
7 changed files with 160 additions and 22 deletions
+67 -10
View File
@@ -228,9 +228,9 @@ 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.
--sidecar none|xmp|json|xmp,json
Write opt-in metadata sidecars next to each exported file. Default: none.
If sidecar writing fails, the asset is counted as failed.
--xmp-privacy keep|strip-location|strip-address
Control location/address metadata in generated XMP sidecars. Default: keep.
@@ -901,6 +901,20 @@ func sidecarPath(exportedPath string) string {
return strings.TrimSuffix(exportedPath, ext) + ".xmp"
}
func jsonSidecarPath(exportedPath string) string {
ext := filepath.Ext(exportedPath)
return strings.TrimSuffix(exportedPath, ext) + ".json"
}
func sidecarEnabled(sidecar, format string) bool {
for _, part := range strings.Split(sidecar, ",") {
if strings.TrimSpace(part) == format {
return true
}
}
return false
}
func renderXMP(d xmpSidecarData) []byte {
attrs := []struct{ key, val string }{
{"photoscli:xmpSchemaVersion", "2"},
@@ -1074,8 +1088,31 @@ func writeXMPSidecar(path string, data xmpSidecarData) error {
return nil
}
func writeJSONSidecar(path string, data xmpSidecarData) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
f, err := createTempFunc(filepath.Dir(path), ".*.json.tmp")
if err != nil {
return err
}
tmp := f.Name()
_ = f.Close()
payload, _ := json.MarshalIndent(data, "", " ")
payload = append(payload, '\n')
if err := writeFileFunc(tmp, payload, 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, cache *geocodeCache, bridge photos.Bridge) error {
if opts.sidecar != "xmp" {
if opts.sidecar == "none" || opts.sidecar == "" {
return nil
}
mode := "preview"
@@ -1138,7 +1175,7 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
if xmpRating == "none" {
isFavorite = false
}
return writeXMPSidecar(sidecarPath(fullPath), xmpSidecarData{
data := xmpSidecarData{
AssetID: pa.asset.ID,
OriginalFilename: pa.asset.Filename,
ExportedFilename: result.Filename,
@@ -1170,7 +1207,18 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
BurstSelectionTypes: pa.asset.BurstSelectionTypes,
AdjustmentInfo: pa.asset.AdjustmentInfo,
Resources: pa.asset.Resources,
})
}
if sidecarEnabled(opts.sidecar, "xmp") {
if err := writeXMPSidecar(sidecarPath(fullPath), data); err != nil {
return err
}
}
if sidecarEnabled(opts.sidecar, "json") {
if err := writeJSONSidecar(jsonSidecarPath(fullPath), data); err != nil {
return err
}
}
return nil
}
func writeMetadataOnlySidecar(pa pendingAsset, entry manifest.Entry, originals bool, opts exportOptions, cache *geocodeCache, bridge photos.Bridge) error {
@@ -1933,10 +1981,19 @@ 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)
if opts.sidecar != "none" && !sidecarEnabled(opts.sidecar, "xmp") && !sidecarEnabled(opts.sidecar, "json") {
fmt.Fprintf(stderr, "error: --sidecar must be none, xmp, json, or xmp,json, got %q\n", opts.sidecar)
return opts, false
}
if opts.sidecar != "none" {
for _, part := range strings.Split(opts.sidecar, ",") {
part = strings.TrimSpace(part)
if part != "xmp" && part != "json" {
fmt.Fprintf(stderr, "error: --sidecar must be none, xmp, json, or xmp,json, got %q\n", opts.sidecar)
return opts, false
}
}
}
if opts.xmpPrivacy != "keep" && opts.xmpPrivacy != "strip-location" && opts.xmpPrivacy != "strip-address" {
fmt.Fprintf(stderr, "error: --xmp-privacy must be keep, strip-location, or strip-address, got %q\n", opts.xmpPrivacy)
return opts, false
@@ -1949,8 +2006,8 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
fmt.Fprintf(stderr, "error: --xmp-rating must be favorite or none, got %q\n", opts.xmpRating)
return opts, false
}
if opts.metadataOnly && opts.sidecar != "xmp" {
fmt.Fprintln(stderr, "error: --metadata-only requires --sidecar xmp")
if opts.metadataOnly && opts.sidecar == "none" {
fmt.Fprintln(stderr, "error: --metadata-only requires --sidecar xmp, json, or xmp,json")
return opts, false
}
if v := flagVal(args, "--retry"); v != "" {
+69 -4
View File
@@ -4385,6 +4385,52 @@ func TestWriteXMPSidecar(t *testing.T) {
}
}
func TestWriteJSONSidecar(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "photo.json")
if got := jsonSidecarPath(filepath.Join(dir, "photo.jpg")); got != path {
t.Fatalf("json sidecar path=%q", got)
}
if !sidecarEnabled("xmp,json", "json") || sidecarEnabled("xmp", "json") {
t.Fatal("sidecarEnabled mismatch")
}
if err := writeJSONSidecar(path, xmpSidecarData{AssetID: "x1", ExportedFilename: "photo.jpg"}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `"AssetID": "x1"`) {
t.Fatalf("unexpected json sidecar: %s", string(data))
}
badParent := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(badParent, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
if err := writeJSONSidecar(filepath.Join(badParent, "bad.json"), xmpSidecarData{}); err == nil {
t.Fatal("expected mkdir error")
}
oldCreate := createTempFunc
createTempFunc = func(string, string) (*os.File, error) { return nil, fmt.Errorf("create") }
if err := writeJSONSidecar(path, xmpSidecarData{}); err == nil {
t.Fatal("expected create temp error")
}
createTempFunc = oldCreate
oldWrite := writeFileFunc
writeFileFunc = func(string, []byte, os.FileMode) error { return fmt.Errorf("write") }
if err := writeJSONSidecar(path, xmpSidecarData{}); err == nil {
t.Fatal("expected write error")
}
writeFileFunc = oldWrite
oldRename := renameFunc
renameFunc = func(string, string) error { return fmt.Errorf("rename") }
if err := writeJSONSidecar(path, xmpSidecarData{}); err == nil {
t.Fatal("expected rename error")
}
renameFunc = oldRename
}
func TestSidecarExportIntegration(t *testing.T) {
dir := t.TempDir()
date := "2024-01-02T03:04:05Z"
@@ -4395,7 +4441,7 @@ func TestSidecarExportIntegration(t *testing.T) {
}
return photos.ExportResult{Filename: "photo.jpg", Size: 4, Cloud: "local"}, nil
}
exported, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "Album", false, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp"})
exported, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "Album", false, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp,json"})
if exported != 1 || failed != 0 {
t.Fatalf("exported=%d failed=%d", exported, failed)
}
@@ -4412,6 +4458,13 @@ func TestSidecarExportIntegration(t *testing.T) {
if _, err := os.Stat(filepath.Join(dir, "photo.jpg.xmp")); !os.IsNotExist(err) {
t.Fatal("sidecar should use basename, not double extension")
}
jsonData, err := os.ReadFile(filepath.Join(dir, "photo.json"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(jsonData), `"AssetID": "x1"`) {
t.Fatalf("json sidecar missing asset ID: %s", string(jsonData))
}
}
func TestSidecarReverseGeocodeCache(t *testing.T) {
@@ -4585,6 +4638,14 @@ func TestSidecarConfigAndErrors(t *testing.T) {
t.Fatalf("expected sidecar validation error, stderr=%q", stderr.String())
}
stderr.Reset()
if opts, ok := parseExportOptions([]string{"--sidecar", "json"}, &stderr); !ok || opts.sidecar != "json" || stderr.Len() != 0 {
t.Fatalf("expected json sidecar option, opts=%+v ok=%v stderr=%q", opts, ok, stderr.String())
}
stderr.Reset()
if _, ok := parseExportOptions([]string{"--sidecar", "xmp,bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--sidecar") {
t.Fatalf("expected mixed sidecar validation error, stderr=%q", stderr.String())
}
stderr.Reset()
if _, ok := parseExportOptions([]string{"--xmp-privacy", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--xmp-privacy") {
t.Fatalf("expected xmp privacy validation error, stderr=%q", stderr.String())
}
@@ -4603,10 +4664,14 @@ func TestSidecarConfigAndErrors(t *testing.T) {
}
oldRename := renameFunc
renameFunc = func(string, string) error { return fmt.Errorf("sidecar rename") }
exported, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp"})
exported, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{sidecar: "json"})
if exported != 0 || failed != 1 {
t.Fatalf("expected json sidecar failure, exported=%d failed=%d", exported, failed)
}
exported, failed = exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp"})
renameFunc = oldRename
if exported != 0 || failed != 1 {
t.Fatalf("expected sidecar failure, exported=%d failed=%d", exported, failed)
t.Fatalf("expected xmp sidecar failure, exported=%d failed=%d", exported, failed)
}
}
@@ -4725,7 +4790,7 @@ func TestMetadataOnlyExportErrors(t *testing.T) {
dir := t.TempDir()
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "photo.jpg"}}}
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--metadata-only"}, b)
if rc != exitErr || !strings.Contains(stderr, "--metadata-only requires --sidecar xmp") {
if rc != exitErr || !strings.Contains(stderr, "--metadata-only requires --sidecar") {
t.Fatalf("expected sidecar requirement rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"export", "--album-id", "x", "--out", dir, "--sidecar", "xmp", "--metadata-only", "--no-manifest"}, b)