v0.8.5: add XMP privacy controls
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled

This commit is contained in:
Ein Anderssono
2026-06-15 02:02:26 +02:00
parent fbc37b8d8d
commit 700d8ef05a
7 changed files with 93 additions and 9 deletions
+23 -2
View File
@@ -42,6 +42,7 @@ type exportOptions struct {
verify bool
format string
sidecar string
xmpPrivacy string
metadataOnly bool
reverseGeocode bool
minSize int64
@@ -229,6 +230,9 @@ COMMON EXPORT FLAGS
Write opt-in XMP sidecar metadata next to each exported file. Default:
none. If XMP 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.
--metadata-only
With --sidecar xmp, write or refresh XMP sidecars for files already in
the manifest without exporting media files. Requires a manifest.
@@ -1091,10 +1095,22 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
if pa.asset.ModificationDate != nil {
modifyDate = *pa.asset.ModificationDate
}
location := pa.asset.Location
xmpPrivacy := opts.xmpPrivacy
if xmpPrivacy == "" {
xmpPrivacy = "keep"
}
var placemark *photos.Placemark
if opts.reverseGeocode && pa.asset.Location != nil && cache != nil {
if opts.reverseGeocode && location != nil && cache != nil && xmpPrivacy == "keep" {
placemark = cache.lookup(pa.asset.Location.Latitude, pa.asset.Location.Longitude, bridge)
}
if xmpPrivacy == "strip-location" {
location = nil
placemark = nil
}
if xmpPrivacy == "strip-address" {
placemark = nil
}
return writeXMPSidecar(sidecarPath(fullPath), xmpSidecarData{
AssetID: pa.asset.ID,
OriginalFilename: pa.asset.Filename,
@@ -1120,7 +1136,7 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
Size: result.Size,
CreateDate: createDate,
ModifyDate: modifyDate,
Location: pa.asset.Location,
Location: location,
Placemark: placemark,
BurstIdentifier: pa.asset.BurstIdentifier,
RepresentsBurst: pa.asset.RepresentsBurst,
@@ -1875,6 +1891,7 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
verify: hasFlag(args, "--verify"),
format: flagValWithDefault(args, "--format", "jpeg"),
sidecar: flagValWithDefault(args, "--sidecar", "none"),
xmpPrivacy: flagValWithDefault(args, "--xmp-privacy", "keep"),
metadataOnly: hasFlag(args, "--metadata-only"),
reverseGeocode: hasFlag(args, "--reverse-geocode"),
dateTemplate: flagVal(args, "--date-template"),
@@ -1891,6 +1908,10 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
fmt.Fprintf(stderr, "error: --sidecar must be none or xmp, 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
}
if opts.metadataOnly && opts.sidecar != "xmp" {
fmt.Fprintln(stderr, "error: --metadata-only requires --sidecar xmp")
return opts, false
+42
View File
@@ -4584,6 +4584,10 @@ func TestSidecarConfigAndErrors(t *testing.T) {
if _, ok := parseExportOptions([]string{"--sidecar", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--sidecar") {
t.Fatalf("expected 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())
}
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "photo.jpg"}}}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
@@ -4598,6 +4602,44 @@ func TestSidecarConfigAndErrors(t *testing.T) {
}
}
func TestXMPSidecarPrivacy(t *testing.T) {
dir := t.TempDir()
asset := photos.Asset{ID: "x1", Filename: "geo.jpg", Location: &photos.AssetLocation{Latitude: 59.3293, Longitude: 18.0686}}
bridge := &mockBridge{}
bridge.reverseGeocodeFn = func(float64, float64) (photos.Placemark, error) {
return photos.Placemark{Country: "Sweden", Locality: "Stockholm"}, nil
}
pa := pendingAsset{asset: asset, root: dir, path: dir, album: "Album"}
for _, tc := range []struct {
privacy string
wantGPS bool
wantAddress bool
}{
{privacy: "keep", wantGPS: true, wantAddress: true},
{privacy: "strip-address", wantGPS: true, wantAddress: false},
{privacy: "strip-location", wantGPS: false, wantAddress: false},
} {
path := filepath.Join(dir, tc.privacy+".jpg")
if err := os.WriteFile(path, []byte("data"), 0644); err != nil {
t.Fatal(err)
}
if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: filepath.Base(path), Size: 4}, false, exportOptions{sidecar: "xmp", reverseGeocode: true, xmpPrivacy: tc.privacy}, newGeocodeCache(dir), bridge); err != nil {
t.Fatalf("%s write sidecar: %v", tc.privacy, err)
}
data, err := os.ReadFile(sidecarPath(path))
if err != nil {
t.Fatal(err)
}
content := string(data)
if strings.Contains(content, "photoscli:latitude") != tc.wantGPS {
t.Fatalf("%s GPS presence mismatch in %s", tc.privacy, content)
}
if strings.Contains(content, "photoscli:addressCountry") != tc.wantAddress {
t.Fatalf("%s address presence mismatch in %s", tc.privacy, content)
}
}
}
func TestMetadataOnlyExport(t *testing.T) {
dir := t.TempDir()
m := manifest.LoadJSONL(dir)