v0.8.6: add XMP keyword and rating controls
pipeline / build (push) Has been cancelled
pipeline / test (push) Has been cancelled

This commit is contained in:
Ein Anderssono
2026-06-15 02:05:14 +02:00
parent 700d8ef05a
commit 5c40b1d3ba
7 changed files with 118 additions and 10 deletions
+8
View File
@@ -2,6 +2,14 @@
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.6
XMP keyword and rating controls release.
- Add `--xmp-keywords album-path|album|none` for generated sidecars.
- Add `--xmp-rating favorite|none` for generated sidecars.
- Keep existing keyword/rating behavior as defaults with `album-path` and `favorite`.
## v0.8.5 ## v0.8.5
XMP privacy controls release. XMP privacy controls release.
+1 -1
View File
@@ -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.5 VERSION := 0.8.6
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
+2
View File
@@ -359,6 +359,8 @@ Sidecars also include richer public PhotoKit metadata where available: modificat
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. 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.
Control generated XMP keywords and ratings with `--xmp-keywords album-path|album|none` and `--xmp-rating favorite|none`. Defaults preserve existing behavior: album/folder keywords and favorite assets mapped to `xmp:Rating="5"`.
Verify generated sidecars with: Verify generated sidecars with:
```bash ```bash
+7 -7
View File
@@ -1,18 +1,18 @@
# v0.8.5 # v0.8.6
This release adds privacy controls for generated XMP sidecars. This release adds keyword and rating controls for generated XMP sidecars.
## Highlights ## Highlights
- Add `--xmp-privacy keep|strip-location|strip-address`. - Add `--xmp-keywords album-path|album|none`.
- Keep existing behavior by default with `keep`. - Add `--xmp-rating favorite|none`.
- Use `strip-address` to omit reverse-geocoded address fields while keeping GPS coordinates. - Keep existing behavior by default with album-path keywords and favorite-to-rating mapping.
- Use `strip-location` to omit both GPS coordinates and address fields. - Use these flags when another DAM or metadata tool should own keywords or ratings.
## Assets ## Assets
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`). - `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
- `photoscli-0.8.5-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG. - `photoscli-0.8.6-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.
+9
View File
@@ -566,6 +566,15 @@ Control location privacy in generated sidecars:
`keep` is the default. `strip-address` omits reverse-geocoded address fields while preserving GPS coordinates. `strip-location` omits both GPS coordinates and address fields. `keep` is the default. `strip-address` omits reverse-geocoded address fields while preserving GPS coordinates. `strip-location` omits both GPS coordinates and address fields.
Control generated keywords and ratings:
```bash
./bin/photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp --xmp-keywords album
./bin/photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp --xmp-keywords none --xmp-rating none
```
`--xmp-keywords album-path` is the default and writes album/folder keywords. `album` writes only the album name. `none` omits generated `dc:subject` keywords. `--xmp-rating favorite` is the default and maps favorite assets to `xmp:Rating="5"`; `none` omits that generated rating.
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
+39 -2
View File
@@ -43,6 +43,8 @@ type exportOptions struct {
format string format string
sidecar string sidecar string
xmpPrivacy string xmpPrivacy string
xmpKeywords string
xmpRating string
metadataOnly bool metadataOnly bool
reverseGeocode bool reverseGeocode bool
minSize int64 minSize int64
@@ -233,6 +235,12 @@ COMMON EXPORT FLAGS
--xmp-privacy keep|strip-location|strip-address --xmp-privacy keep|strip-location|strip-address
Control location/address metadata in generated XMP sidecars. Default: keep. Control location/address metadata in generated XMP sidecars. Default: keep.
--xmp-keywords album-path|album|none
Control dc:subject keywords in generated XMP sidecars. Default: album-path.
--xmp-rating favorite|none
Control favorite-to-rating mapping in generated XMP sidecars. Default: favorite.
--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.
@@ -1100,6 +1108,14 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
if xmpPrivacy == "" { if xmpPrivacy == "" {
xmpPrivacy = "keep" xmpPrivacy = "keep"
} }
xmpKeywords := opts.xmpKeywords
if xmpKeywords == "" {
xmpKeywords = "album-path"
}
xmpRating := opts.xmpRating
if xmpRating == "" {
xmpRating = "favorite"
}
var placemark *photos.Placemark var placemark *photos.Placemark
if opts.reverseGeocode && location != nil && cache != nil && xmpPrivacy == "keep" { 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)
@@ -1111,13 +1127,24 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
if xmpPrivacy == "strip-address" { if xmpPrivacy == "strip-address" {
placemark = nil placemark = nil
} }
keywords := keywordsFromAlbumPath(pa.album, relDir)
if xmpKeywords == "album" {
keywords = keywordsFromAlbumPath(pa.album, "")
}
if xmpKeywords == "none" {
keywords = nil
}
isFavorite := pa.asset.IsFavorite
if xmpRating == "none" {
isFavorite = false
}
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,
ExportedFilename: result.Filename, ExportedFilename: result.Filename,
Album: pa.album, Album: pa.album,
AlbumPath: pa.path, AlbumPath: pa.path,
Keywords: keywordsFromAlbumPath(pa.album, relDir), Keywords: keywords,
ManifestPath: relPath, ManifestPath: relPath,
MediaType: pa.asset.MediaType, MediaType: pa.asset.MediaType,
MediaSubtypes: pa.asset.MediaSubtypes, MediaSubtypes: pa.asset.MediaSubtypes,
@@ -1126,7 +1153,7 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
PixelWidth: pa.asset.PixelWidth, PixelWidth: pa.asset.PixelWidth,
PixelHeight: pa.asset.PixelHeight, PixelHeight: pa.asset.PixelHeight,
Duration: pa.asset.Duration, Duration: pa.asset.Duration,
IsFavorite: pa.asset.IsFavorite, IsFavorite: isFavorite,
IsHidden: pa.asset.IsHidden, IsHidden: pa.asset.IsHidden,
HasAdjustments: pa.asset.HasAdjustments, HasAdjustments: pa.asset.HasAdjustments,
Cloud: result.Cloud, Cloud: result.Cloud,
@@ -1892,6 +1919,8 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
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"), xmpPrivacy: flagValWithDefault(args, "--xmp-privacy", "keep"),
xmpKeywords: flagValWithDefault(args, "--xmp-keywords", "album-path"),
xmpRating: flagValWithDefault(args, "--xmp-rating", "favorite"),
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"),
@@ -1912,6 +1941,14 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
fmt.Fprintf(stderr, "error: --xmp-privacy must be keep, strip-location, or strip-address, got %q\n", opts.xmpPrivacy) fmt.Fprintf(stderr, "error: --xmp-privacy must be keep, strip-location, or strip-address, got %q\n", opts.xmpPrivacy)
return opts, false return opts, false
} }
if opts.xmpKeywords != "album-path" && opts.xmpKeywords != "album" && opts.xmpKeywords != "none" {
fmt.Fprintf(stderr, "error: --xmp-keywords must be album-path, album, or none, got %q\n", opts.xmpKeywords)
return opts, false
}
if opts.xmpRating != "favorite" && opts.xmpRating != "none" {
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" { 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
+52
View File
@@ -4588,6 +4588,14 @@ func TestSidecarConfigAndErrors(t *testing.T) {
if _, ok := parseExportOptions([]string{"--xmp-privacy", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--xmp-privacy") { 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()) t.Fatalf("expected xmp privacy validation error, stderr=%q", stderr.String())
} }
stderr.Reset()
if _, ok := parseExportOptions([]string{"--xmp-keywords", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--xmp-keywords") {
t.Fatalf("expected xmp keywords validation error, stderr=%q", stderr.String())
}
stderr.Reset()
if _, ok := parseExportOptions([]string{"--xmp-rating", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--xmp-rating") {
t.Fatalf("expected xmp rating 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) {
@@ -4640,6 +4648,50 @@ func TestXMPSidecarPrivacy(t *testing.T) {
} }
} }
func TestXMPSidecarKeywordAndRatingOptions(t *testing.T) {
dir := t.TempDir()
asset := photos.Asset{ID: "x1", Filename: "photo.jpg", IsFavorite: true}
pa := pendingAsset{asset: asset, root: dir, path: filepath.Join(dir, "Trips", "Beach"), album: "Beach"}
if err := os.MkdirAll(pa.path, 0755); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
keywords string
rating string
wantTrip bool
wantBeach bool
wantRate bool
}{
{name: "default", wantTrip: true, wantBeach: true, wantRate: true},
{name: "album", keywords: "album", wantTrip: false, wantBeach: true, wantRate: true},
{name: "none", keywords: "none", rating: "none", wantTrip: false, wantBeach: false, wantRate: false},
} {
filename := tc.name + ".jpg"
path := filepath.Join(pa.path, filename)
if err := os.WriteFile(path, []byte("data"), 0644); err != nil {
t.Fatal(err)
}
if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: filename, Size: 4}, false, exportOptions{sidecar: "xmp", xmpKeywords: tc.keywords, xmpRating: tc.rating}, nil, &mockBridge{}); err != nil {
t.Fatalf("%s write sidecar: %v", tc.name, err)
}
data, err := os.ReadFile(sidecarPath(path))
if err != nil {
t.Fatal(err)
}
content := string(data)
if strings.Contains(content, "<rdf:li>Trips</rdf:li>") != tc.wantTrip {
t.Fatalf("%s Trips keyword mismatch in %s", tc.name, content)
}
if strings.Contains(content, "<rdf:li>Beach</rdf:li>") != tc.wantBeach {
t.Fatalf("%s Beach keyword mismatch in %s", tc.name, content)
}
if strings.Contains(content, "xmp:Rating=\"5\"") != tc.wantRate {
t.Fatalf("%s rating mismatch in %s", tc.name, 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)