v0.8.5: add XMP privacy controls
This commit is contained in:
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
This changelog is maintained from git history plus the published Gitea release series. Future releases should update this file and publish matching release notes on the release page.
|
This changelog is maintained from git history plus the published Gitea release series. Future releases should update this file and publish matching release notes on the release page.
|
||||||
|
|
||||||
|
## v0.8.5
|
||||||
|
|
||||||
|
XMP privacy controls release.
|
||||||
|
|
||||||
|
- Add `--xmp-privacy keep|strip-location|strip-address` for generated sidecars.
|
||||||
|
- Keep existing XMP location/address behavior as the default with `keep`.
|
||||||
|
- Allow GPS coordinates to be kept while reverse-geocoded address fields are omitted with `strip-address`.
|
||||||
|
- Allow both GPS coordinates and address fields to be omitted with `strip-location`.
|
||||||
|
|
||||||
## v0.8.4
|
## v0.8.4
|
||||||
|
|
||||||
Strict XMP sidecar verification release.
|
Strict XMP sidecar verification release.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
BINARY := ./bin/photoscli
|
BINARY := ./bin/photoscli
|
||||||
MODULE := gitea.k3s.k0.nu/tools/photocli
|
MODULE := gitea.k3s.k0.nu/tools/photocli
|
||||||
VERSION := 0.8.4
|
VERSION := 0.8.5
|
||||||
RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip
|
RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip
|
||||||
RELEASE_NOTES := RELEASE_NOTES.md
|
RELEASE_NOTES := RELEASE_NOTES.md
|
||||||
BRIDGE_DIR := bridge
|
BRIDGE_DIR := bridge
|
||||||
|
|||||||
@@ -357,6 +357,8 @@ The XMP contains photoscli metadata such as asset ID, filenames, album, manifest
|
|||||||
|
|
||||||
Sidecars also include richer public PhotoKit metadata where available: modification date, duration, hidden state, adjustment state, media subtypes, source type, playback style, burst data, GPS coordinates, adjustment info, structured asset resources, standard XMP dates, EXIF GPS coordinates, favorite rating, and album/folder keywords. Add `--reverse-geocode` to include cached address fields from Apple MapKit for assets with GPS coordinates. Reverse geocoding requires macOS 26 or newer; on older macOS versions the export continues and XMP still includes GPS coordinates.
|
Sidecars also include richer public PhotoKit metadata where available: modification date, duration, hidden state, adjustment state, media subtypes, source type, playback style, burst data, GPS coordinates, adjustment info, structured asset resources, standard XMP dates, EXIF GPS coordinates, favorite rating, and album/folder keywords. Add `--reverse-geocode` to include cached address fields from Apple MapKit for assets with GPS coordinates. Reverse geocoding requires macOS 26 or newer; on older macOS versions the export continues and XMP still includes GPS coordinates.
|
||||||
|
|
||||||
|
Control XMP location metadata with `--xmp-privacy keep|strip-location|strip-address`. The default is `keep`. Use `strip-address` to omit reverse-geocoded address fields while keeping GPS coordinates, or `strip-location` to omit both GPS and address fields.
|
||||||
|
|
||||||
Verify generated sidecars with:
|
Verify generated sidecars with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+7
-6
@@ -1,17 +1,18 @@
|
|||||||
# v0.8.4
|
# v0.8.5
|
||||||
|
|
||||||
This release adds strict XMP sidecar verification.
|
This release adds privacy controls for generated XMP sidecars.
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
- Add `verify --sidecar --strict` to require photoscli schema metadata, sidecar generator metadata, and matching exported filename metadata.
|
- Add `--xmp-privacy keep|strip-location|strip-address`.
|
||||||
- Keep existing `verify --sidecar` behavior unchanged for basic sidecar checks.
|
- Keep existing behavior by default with `keep`.
|
||||||
- Use strict mode when validating sidecars generated by recent photoscli versions.
|
- Use `strip-address` to omit reverse-geocoded address fields while keeping GPS coordinates.
|
||||||
|
- Use `strip-location` to omit both GPS coordinates and address fields.
|
||||||
|
|
||||||
## Assets
|
## Assets
|
||||||
|
|
||||||
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
|
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
|
||||||
- `photoscli-0.8.4-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG.
|
- `photoscli-0.8.5-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG.
|
||||||
- `USERGUIDE.md`: standalone user guide.
|
- `USERGUIDE.md`: standalone user guide.
|
||||||
|
|
||||||
Intel Macs are not currently a supported release target.
|
Intel Macs are not currently a supported release target.
|
||||||
|
|||||||
@@ -557,6 +557,15 @@ IMG_0001.HEIC -> IMG_0001.xmp
|
|||||||
|
|
||||||
The XMP includes photoscli archive metadata such as asset ID, original filename, exported filename, album, manifest path, media type, dimensions, favorite state, hidden state, cloud state, export mode, version, exported time, size, creation date, modification date, duration, adjustment state, media subtypes, source type, playback style, burst data, GPS coordinates, adjustment info, structured asset resources, standard XMP date fields, EXIF GPS fields, favorite rating, and album/folder keywords when PhotoKit exposes them.
|
The XMP includes photoscli archive metadata such as asset ID, original filename, exported filename, album, manifest path, media type, dimensions, favorite state, hidden state, cloud state, export mode, version, exported time, size, creation date, modification date, duration, adjustment state, media subtypes, source type, playback style, burst data, GPS coordinates, adjustment info, structured asset resources, standard XMP date fields, EXIF GPS fields, favorite rating, and album/folder keywords when PhotoKit exposes them.
|
||||||
|
|
||||||
|
Control location privacy in generated sidecars:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp --xmp-privacy strip-address
|
||||||
|
./bin/photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp --xmp-privacy strip-location
|
||||||
|
```
|
||||||
|
|
||||||
|
`keep` is the default. `strip-address` omits reverse-geocoded address fields while preserving GPS coordinates. `strip-location` omits both GPS coordinates and address fields.
|
||||||
|
|
||||||
For address metadata from GPS coordinates, opt in to Apple's reverse geocoder:
|
For address metadata from GPS coordinates, opt in to Apple's reverse geocoder:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+23
-2
@@ -42,6 +42,7 @@ type exportOptions struct {
|
|||||||
verify bool
|
verify bool
|
||||||
format string
|
format string
|
||||||
sidecar string
|
sidecar string
|
||||||
|
xmpPrivacy string
|
||||||
metadataOnly bool
|
metadataOnly bool
|
||||||
reverseGeocode bool
|
reverseGeocode bool
|
||||||
minSize int64
|
minSize int64
|
||||||
@@ -229,6 +230,9 @@ COMMON EXPORT FLAGS
|
|||||||
Write opt-in XMP sidecar metadata next to each exported file. Default:
|
Write opt-in XMP sidecar metadata next to each exported file. Default:
|
||||||
none. If XMP writing fails, the asset is counted as failed.
|
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
|
--metadata-only
|
||||||
With --sidecar xmp, write or refresh XMP sidecars for files already in
|
With --sidecar xmp, write or refresh XMP sidecars for files already in
|
||||||
the manifest without exporting media files. Requires a manifest.
|
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 {
|
if pa.asset.ModificationDate != nil {
|
||||||
modifyDate = *pa.asset.ModificationDate
|
modifyDate = *pa.asset.ModificationDate
|
||||||
}
|
}
|
||||||
|
location := pa.asset.Location
|
||||||
|
xmpPrivacy := opts.xmpPrivacy
|
||||||
|
if xmpPrivacy == "" {
|
||||||
|
xmpPrivacy = "keep"
|
||||||
|
}
|
||||||
var placemark *photos.Placemark
|
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)
|
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{
|
return writeXMPSidecar(sidecarPath(fullPath), xmpSidecarData{
|
||||||
AssetID: pa.asset.ID,
|
AssetID: pa.asset.ID,
|
||||||
OriginalFilename: pa.asset.Filename,
|
OriginalFilename: pa.asset.Filename,
|
||||||
@@ -1120,7 +1136,7 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
|
|||||||
Size: result.Size,
|
Size: result.Size,
|
||||||
CreateDate: createDate,
|
CreateDate: createDate,
|
||||||
ModifyDate: modifyDate,
|
ModifyDate: modifyDate,
|
||||||
Location: pa.asset.Location,
|
Location: location,
|
||||||
Placemark: placemark,
|
Placemark: placemark,
|
||||||
BurstIdentifier: pa.asset.BurstIdentifier,
|
BurstIdentifier: pa.asset.BurstIdentifier,
|
||||||
RepresentsBurst: pa.asset.RepresentsBurst,
|
RepresentsBurst: pa.asset.RepresentsBurst,
|
||||||
@@ -1875,6 +1891,7 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
|
|||||||
verify: hasFlag(args, "--verify"),
|
verify: hasFlag(args, "--verify"),
|
||||||
format: flagValWithDefault(args, "--format", "jpeg"),
|
format: flagValWithDefault(args, "--format", "jpeg"),
|
||||||
sidecar: flagValWithDefault(args, "--sidecar", "none"),
|
sidecar: flagValWithDefault(args, "--sidecar", "none"),
|
||||||
|
xmpPrivacy: flagValWithDefault(args, "--xmp-privacy", "keep"),
|
||||||
metadataOnly: hasFlag(args, "--metadata-only"),
|
metadataOnly: hasFlag(args, "--metadata-only"),
|
||||||
reverseGeocode: hasFlag(args, "--reverse-geocode"),
|
reverseGeocode: hasFlag(args, "--reverse-geocode"),
|
||||||
dateTemplate: flagVal(args, "--date-template"),
|
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)
|
fmt.Fprintf(stderr, "error: --sidecar must be none or xmp, got %q\n", opts.sidecar)
|
||||||
return opts, false
|
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" {
|
if opts.metadataOnly && opts.sidecar != "xmp" {
|
||||||
fmt.Fprintln(stderr, "error: --metadata-only requires --sidecar xmp")
|
fmt.Fprintln(stderr, "error: --metadata-only requires --sidecar xmp")
|
||||||
return opts, false
|
return opts, false
|
||||||
|
|||||||
@@ -4584,6 +4584,10 @@ func TestSidecarConfigAndErrors(t *testing.T) {
|
|||||||
if _, ok := parseExportOptions([]string{"--sidecar", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--sidecar") {
|
if _, ok := parseExportOptions([]string{"--sidecar", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--sidecar") {
|
||||||
t.Fatalf("expected sidecar validation error, stderr=%q", stderr.String())
|
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 := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "photo.jpg"}}}
|
||||||
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
|
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) {
|
func TestMetadataOnlyExport(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
m := manifest.LoadJSONL(dir)
|
m := manifest.LoadJSONL(dir)
|
||||||
|
|||||||
Reference in New Issue
Block a user