v0.8.7: add JSON sidecars
This commit is contained in:
+67
-10
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user