From 5c40b1d3baa8dff93e86d87cdcf7972f185b60cd Mon Sep 17 00:00:00 2001 From: Ein Anderssono Date: Mon, 15 Jun 2026 02:05:14 +0200 Subject: [PATCH] v0.8.6: add XMP keyword and rating controls --- CHANGELOG.md | 8 ++++++ Makefile | 2 +- README.md | 2 ++ RELEASE_NOTES.md | 14 +++++----- USERGUIDE.md | 9 +++++++ cmd/photoscli/main.go | 41 ++++++++++++++++++++++++++++-- cmd/photoscli/main_test.go | 52 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 118 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22c6306..4f183d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. +## 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 XMP privacy controls release. diff --git a/Makefile b/Makefile index dd43600..4ae345a 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ BINARY := ./bin/photoscli MODULE := gitea.k3s.k0.nu/tools/photocli -VERSION := 0.8.5 +VERSION := 0.8.6 RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip RELEASE_NOTES := RELEASE_NOTES.md BRIDGE_DIR := bridge diff --git a/README.md b/README.md index 30d198f..7f889a1 100644 --- a/README.md +++ b/README.md @@ -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 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: ```bash diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 3ec1f51..b4e4ad4 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -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 -- Add `--xmp-privacy keep|strip-location|strip-address`. -- Keep existing behavior by default with `keep`. -- Use `strip-address` to omit reverse-geocoded address fields while keeping GPS coordinates. -- Use `strip-location` to omit both GPS coordinates and address fields. +- Add `--xmp-keywords album-path|album|none`. +- Add `--xmp-rating favorite|none`. +- Keep existing behavior by default with album-path keywords and favorite-to-rating mapping. +- Use these flags when another DAM or metadata tool should own keywords or ratings. ## Assets - `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. Intel Macs are not currently a supported release target. diff --git a/USERGUIDE.md b/USERGUIDE.md index fbbf316..630f2ff 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -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. +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: ```bash diff --git a/cmd/photoscli/main.go b/cmd/photoscli/main.go index 4e41752..391ce9c 100644 --- a/cmd/photoscli/main.go +++ b/cmd/photoscli/main.go @@ -43,6 +43,8 @@ type exportOptions struct { format string sidecar string xmpPrivacy string + xmpKeywords string + xmpRating string metadataOnly bool reverseGeocode bool minSize int64 @@ -233,6 +235,12 @@ COMMON EXPORT FLAGS --xmp-privacy keep|strip-location|strip-address 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 With --sidecar xmp, write or refresh XMP sidecars for files already in the manifest without exporting media files. Requires a manifest. @@ -1100,6 +1108,14 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals if xmpPrivacy == "" { xmpPrivacy = "keep" } + xmpKeywords := opts.xmpKeywords + if xmpKeywords == "" { + xmpKeywords = "album-path" + } + xmpRating := opts.xmpRating + if xmpRating == "" { + xmpRating = "favorite" + } var placemark *photos.Placemark if opts.reverseGeocode && location != nil && cache != nil && xmpPrivacy == "keep" { 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" { 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{ AssetID: pa.asset.ID, OriginalFilename: pa.asset.Filename, ExportedFilename: result.Filename, Album: pa.album, AlbumPath: pa.path, - Keywords: keywordsFromAlbumPath(pa.album, relDir), + Keywords: keywords, ManifestPath: relPath, MediaType: pa.asset.MediaType, MediaSubtypes: pa.asset.MediaSubtypes, @@ -1126,7 +1153,7 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals PixelWidth: pa.asset.PixelWidth, PixelHeight: pa.asset.PixelHeight, Duration: pa.asset.Duration, - IsFavorite: pa.asset.IsFavorite, + IsFavorite: isFavorite, IsHidden: pa.asset.IsHidden, HasAdjustments: pa.asset.HasAdjustments, Cloud: result.Cloud, @@ -1892,6 +1919,8 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) { format: flagValWithDefault(args, "--format", "jpeg"), sidecar: flagValWithDefault(args, "--sidecar", "none"), xmpPrivacy: flagValWithDefault(args, "--xmp-privacy", "keep"), + xmpKeywords: flagValWithDefault(args, "--xmp-keywords", "album-path"), + xmpRating: flagValWithDefault(args, "--xmp-rating", "favorite"), metadataOnly: hasFlag(args, "--metadata-only"), reverseGeocode: hasFlag(args, "--reverse-geocode"), 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) 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" { fmt.Fprintln(stderr, "error: --metadata-only requires --sidecar xmp") return opts, false diff --git a/cmd/photoscli/main_test.go b/cmd/photoscli/main_test.go index 4c5da85..a002d75 100644 --- a/cmd/photoscli/main_test.go +++ b/cmd/photoscli/main_test.go @@ -4588,6 +4588,14 @@ func TestSidecarConfigAndErrors(t *testing.T) { 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()) } + 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.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, "Trips") != tc.wantTrip { + t.Fatalf("%s Trips keyword mismatch in %s", tc.name, content) + } + if strings.Contains(content, "Beach") != 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) { dir := t.TempDir() m := manifest.LoadJSONL(dir)