v0.8.6: add XMP keyword and rating controls
This commit is contained in:
@@ -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,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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user