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)