Compare commits

5 Commits

Author SHA1 Message Date
Ein Anderssono 5c40b1d3ba v0.8.6: add XMP keyword and rating controls
pipeline / build (push) Has been cancelled
pipeline / test (push) Has been cancelled
2026-06-15 02:05:14 +02:00
Ein Anderssono 700d8ef05a v0.8.5: add XMP privacy controls
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled
2026-06-15 02:02:26 +02:00
Ein Anderssono fbc37b8d8d v0.8.4: add strict sidecar verification
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled
2026-06-15 01:58:38 +02:00
Ein Anderssono 32a5819c86 v0.8.3: add sidecar inspection
pipeline / build (push) Has been cancelled
pipeline / test (push) Has been cancelled
2026-06-15 01:55:31 +02:00
Ein Anderssono a51db37fdb v0.8.2: add metadata-only sidecars
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled
2026-06-15 01:48:32 +02:00
7 changed files with 676 additions and 24 deletions
+41
View File
@@ -2,6 +2,47 @@
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.
- 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
Strict XMP sidecar verification release.
- Add `verify --sidecar --strict` to require photoscli XMP schema metadata, sidecar generator metadata, and matching exported filename metadata.
- Keep existing `verify --sidecar` behavior unchanged for backup-wide existence, readability, and asset-ID checks.
## v0.8.3
XMP sidecar inspection release.
- Add `sidecar inspect <file.xmp>` to print key photoscli metadata from generated XMP sidecars.
- Add `sidecar inspect <file.xmp> --json` for scriptable inspection output.
## v0.8.2
Metadata-only XMP refresh release.
- Add `--metadata-only` for manifest-based XMP sidecar generation without re-exporting media files.
- Support metadata-only refresh for both `export` and `backup-all` when used with `--sidecar xmp`.
- Require a manifest for metadata-only mode so existing media paths are resolved safely.
- Keep media files untouched while overwriting/regenerating generated XMP sidecars.
- Support `--reverse-geocode` during metadata-only sidecar refresh.
## v0.8.1
XMP standards and sidecar verification release.
+1 -1
View File
@@ -1,6 +1,6 @@
BINARY := ./bin/photoscli
MODULE := gitea.k3s.k0.nu/tools/photocli
VERSION := 0.8.1
VERSION := 0.8.6
RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip
RELEASE_NOTES := RELEASE_NOTES.md
BRIDGE_DIR := bridge
+25
View File
@@ -21,6 +21,7 @@ For a practical step-by-step manual with recommended backup workflows, recovery
- Deduplicated failure management with `failures list`, `failures clear`, and `retry-failed --clear-on-success`.
- Verification, reporting, and diff commands for backup integrity.
- Optional XMP sidecar verification with `verify --sidecar`.
- Metadata-only XMP refresh for manifest-backed exports with `--metadata-only`.
- Status command for quick backup summaries.
- Opt-in rich XMP sidecar metadata with `--sidecar xmp`.
- Optional Apple MapKit reverse geocoding for GPS assets on macOS 26+ with `--reverse-geocode`.
@@ -283,6 +284,7 @@ Common flags for `export` and `backup-all`:
- `--max-size <n>`: filter by estimated pixel count.
- `--format jpeg|heic|png`: preview format hint. Current bridge output is still the existing preview path; non-JPEG bridge output is future work.
- `--sidecar none|xmp`: write opt-in XMP metadata sidecars next to exported files.
- `--metadata-only`: with `--sidecar xmp`, refresh XMP sidecars for manifest-backed files without exporting media.
- `--reverse-geocode`: with `--sidecar xmp`, add cached Apple MapKit address metadata for GPS assets on macOS 26+.
- `--date-template <template>`: append date folders based on creation date, for example `YYYY/MM/DD`.
@@ -355,12 +357,35 @@ 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.
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
photoscli verify --out ./backup --sidecar
```
For stricter checks against recent photoscli-generated XMP sidecars:
```bash
photoscli verify --out ./backup --sidecar --strict
```
Inspect one generated sidecar with:
```bash
photoscli sidecar inspect ./backup/IMG_0001.xmp
photoscli sidecar inspect ./backup/IMG_0001.xmp --json
```
Refresh sidecars for files already present in a manifest-backed export without rewriting media files:
```bash
photoscli backup-all --out ./backup --sidecar xmp --metadata-only
```
## Failure Tracking
Failed exports are deduplicated by asset ID and stored in:
+7 -9
View File
@@ -1,20 +1,18 @@
# v0.8.1
# v0.8.6
This release improves XMP sidecar standards compatibility and adds sidecar verification.
This release adds keyword and rating controls for generated XMP sidecars.
## Highlights
- Add `photoscli:xmpSchemaVersion="2"` to generated XMP sidecars.
- Add standard XMP mappings for favorite rating, metadata date, Photoshop date created, and EXIF GPS coordinates.
- Add `dc:subject` keywords from album/folder context.
- Add sidecar generator and generated timestamp metadata.
- Add `verify --sidecar` for missing, zero-byte, unreadable, and asset-ID mismatched XMP sidecars.
- Keep Vision/Core ML people, animal, object, and scene analysis out of this release.
- 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.1-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.
+41
View File
@@ -557,6 +557,24 @@ 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.
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.
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
@@ -573,6 +591,29 @@ Verify sidecars after an export:
This reports missing, zero-byte, unreadable, or asset-ID mismatched `.xmp` files.
Use strict verification for sidecars generated by recent photoscli versions:
```bash
./bin/photoscli verify --out ./PhotosBackup --sidecar --strict
```
Strict mode also checks photoscli schema metadata, generator metadata, and the exported filename recorded inside the sidecar.
Inspect one generated sidecar when troubleshooting or scripting:
```bash
./bin/photoscli sidecar inspect ./PhotosBackup/IMG_0001.xmp
./bin/photoscli sidecar inspect ./PhotosBackup/IMG_0001.xmp --json
```
Refresh metadata only for an existing manifest-backed backup:
```bash
./bin/photoscli backup-all --out ./PhotosBackup --sidecar xmp --metadata-only
```
Metadata-only mode does not re-export media files. It uses manifest paths to find existing files and rewrites generated `.xmp` sidecars next to them. It requires `--sidecar xmp` and an enabled manifest.
If you explicitly request `--sidecar xmp` and the XMP file cannot be written, the asset is counted as failed.
## Configuration File
+261 -12
View File
@@ -1,6 +1,7 @@
package main
import (
"bytes"
"encoding/json"
"encoding/xml"
"fmt"
@@ -41,6 +42,10 @@ type exportOptions struct {
verify bool
format string
sidecar string
xmpPrivacy string
xmpKeywords string
xmpRating string
metadataOnly bool
reverseGeocode bool
minSize int64
maxSize int64
@@ -90,6 +95,8 @@ func run(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
return cmdFailures(args[1:], stdout, stderr)
case "status":
return cmdStatus(args[1:], stdout, stderr)
case "sidecar":
return cmdSidecar(args[1:], stdout, stderr)
case "version", "--version", "-v":
fmt.Fprintln(stdout, version)
return exitOK
@@ -131,6 +138,7 @@ USAGE
photoscli retry-failed --out <dir> --clear-on-success
photoscli failures list --out <dir>
photoscli failures clear --out <dir>
photoscli sidecar inspect <file.xmp> [--json]
photoscli status --out <dir> [--json]
photoscli version
photoscli help
@@ -167,7 +175,8 @@ COMMANDS
verify --out <dir> [--manifest jsonl|sqlite]
Verify that manifest entries point to files that exist on disk. Missing
files are printed as <asset-id><TAB><filename>. Exits 2 on missing files.
Add --sidecar to verify expected XMP sidecars too.
Add --sidecar to verify expected XMP sidecars too. Add --strict with
--sidecar to require photoscli schema/generator and exported filename metadata.
retry-failed --out <dir>
Retry assets previously written to failures.jsonl.
@@ -175,6 +184,9 @@ COMMANDS
failures list|clear --out <dir>
List or clear deduplicated failure records.
sidecar inspect <file.xmp> [--json]
Read a generated XMP sidecar and print key photoscli metadata.
status --out <dir> [--manifest jsonl|sqlite] [--json]
Show manifest type, entry count, and failure count for a backup.
@@ -220,6 +232,19 @@ COMMON EXPORT FLAGS
Write opt-in XMP sidecar metadata next to each exported file. Default:
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.
--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.
--reverse-geocode
With --sidecar xmp, use Apple MapKit on macOS 26+ to add address metadata
for assets with GPS coordinates. Results are cached under .photoscli.
@@ -444,6 +469,10 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
fmt.Fprintln(stderr, "error: --out is required")
return exitErr
}
if opts.metadataOnly && noManifest {
fmt.Fprintln(stderr, "error: --metadata-only requires a manifest")
return exitErr
}
mf, mfErr := manifest.ParseFormat(manifestFmt)
if mfErr != nil {
@@ -546,8 +575,17 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
fmt.Fprintf(stderr, "dry-run: %d assets would be exported to %s\n", total, outDir)
return exitOK
}
var exported, failed int
if opts.metadataOnly {
m, _ := manifest.Open(outDir, mf)
defer m.Close()
entries := manifestEntries(m)
fmt.Fprintf(stderr, "writing metadata for %d assets to %s...\n", total, outDir)
exported, failed = metadataOnlyAssets(assets, outDir, originals, "", entries, opts, bridge)
} else {
fmt.Fprintf(stderr, "exporting %d assets (%s) to %s...\n", total, exportMode(originals), outDir)
exported, failed := exportAssets(assets, outDir, size, quality, concurrency, originals, total, stderr, bridge, "", noManifest, mf, enableLog, opts)
exported, failed = exportAssets(assets, outDir, size, quality, concurrency, originals, total, stderr, bridge, "", noManifest, mf, enableLog, opts)
}
if opts.jsonOut {
writeJSONSummary(stdout, commandSummary{Exported: exported, Failed: failed, Total: total})
}
@@ -562,7 +600,9 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
return exitErr
}
if originals {
if opts.metadataOnly {
fmt.Fprintf(stderr, "\nwrote %d metadata sidecars to %s", exported, outDir)
} else if originals {
fmt.Fprintf(stderr, "\nexported %d original files to %s", exported, outDir)
} else {
fmt.Fprintf(stderr, "\nexported %d photos to %s", exported, outDir)
@@ -602,6 +642,10 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge)
fmt.Fprintln(stderr, "error: --out is required")
return exitErr
}
if opts.metadataOnly && noManifest {
fmt.Fprintln(stderr, "error: --metadata-only requires a manifest")
return exitErr
}
mf, mfErr := manifest.ParseFormat(manifestFmt)
if mfErr != nil {
@@ -675,13 +719,25 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge)
fmt.Fprintf(stderr, "dry-run: %d assets would be exported (%d skipped)\n", len(pending), skipped)
return exitOK
}
totalAssets, failed, err := backupTree(nodes, outDir, size, quality, concurrency, originals, skipVideos, stderr, bridge, noManifest, mf, sortNewest, excludeAlbums, sinceTime, enableLog, opts)
var totalAssets, failed int
if opts.metadataOnly {
m, _ := manifest.Open(outDir, mf)
entries := manifestEntries(m)
m.Close()
pending, skipped := collectPendingAssets(nodes, outDir, bridge, skipVideos, originals, nil, nil, sortNewest, excludeAlbums, sinceTime, opts)
fmt.Fprintf(stderr, " indexed %d metadata entries (%d skipped), writing sidecars to %s...\n", len(pending), skipped, outDir)
totalAssets, failed = metadataOnlyPending(pending, entries, originals, opts, bridge)
} else {
totalAssets, failed, err = backupTree(nodes, outDir, size, quality, concurrency, originals, skipVideos, stderr, bridge, noManifest, mf, sortNewest, excludeAlbums, sinceTime, enableLog, opts)
}
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
if originals {
if opts.metadataOnly {
fmt.Fprintf(stderr, "\nwrote %d metadata sidecars across %d albums to %s", totalAssets, albumCount, outDir)
} else if originals {
fmt.Fprintf(stderr, "\nexported %d original files across %d albums to %s", totalAssets, albumCount, outDir)
} else {
fmt.Fprintf(stderr, "\nexported %d preview files across %d albums to %s", totalAssets, albumCount, outDir)
@@ -1047,17 +1103,48 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
if pa.asset.ModificationDate != nil {
modifyDate = *pa.asset.ModificationDate
}
location := pa.asset.Location
xmpPrivacy := opts.xmpPrivacy
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 && 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)
}
if xmpPrivacy == "strip-location" {
location = nil
placemark = nil
}
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,
@@ -1066,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,
@@ -1076,7 +1163,7 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
Size: result.Size,
CreateDate: createDate,
ModifyDate: modifyDate,
Location: pa.asset.Location,
Location: location,
Placemark: placemark,
BurstIdentifier: pa.asset.BurstIdentifier,
RepresentsBurst: pa.asset.RepresentsBurst,
@@ -1086,6 +1173,46 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
})
}
func writeMetadataOnlySidecar(pa pendingAsset, entry manifest.Entry, originals bool, opts exportOptions, cache *geocodeCache, bridge photos.Bridge) error {
checkPath := entry.Path
if checkPath == "" {
checkPath = entry.Filename
}
if checkPath == "" {
return fmt.Errorf("manifest entry has no path")
}
root := pa.root
if root == "" {
root = pa.path
}
fullPath := filepath.Join(root, checkPath)
info, err := statFunc(fullPath)
if err != nil {
return fmt.Errorf("metadata target missing: %s", checkPath)
}
if info.Size() == 0 {
return fmt.Errorf("metadata target zero-byte: %s", checkPath)
}
pa.path = filepath.Dir(fullPath)
result := photos.ExportResult{Filename: filepath.Base(fullPath), Size: info.Size(), Cloud: entry.Cloud}
return writeSidecarIfNeeded(pa, result, originals, opts, cache, bridge)
}
func manifestEntries(m manifest.Manifest) map[string]manifest.Entry {
if r, ok := m.(manifest.EntryReader); ok {
return r.Entries()
}
return nil
}
func metadataOnlyAssets(assets []photos.Asset, outDir string, originals bool, dirPrefix string, entries map[string]manifest.Entry, opts exportOptions, bridge photos.Bridge) (int, int) {
pending := make([]pendingAsset, 0, len(assets))
for _, a := range assets {
pending = append(pending, pendingAsset{asset: a, root: outDir, path: outDir, album: dirPrefix})
}
return metadataOnlyPending(pending, entries, originals, opts, bridge)
}
func collectPendingAssets(nodes []photos.CollectionNode, outDir string, bridge photos.Bridge, skipVideos bool, originals bool, onProgress func(collectProgress), m manifest.Manifest, sortNewest bool, exclude []string, since time.Time, opts exportOptions) ([]pendingAsset, int) {
var items []pendingAsset
var skipped int
@@ -1123,6 +1250,30 @@ func collectPendingAssets(nodes []photos.CollectionNode, outDir string, bridge p
return items, skipped
}
func metadataOnlyPending(pending []pendingAsset, entries map[string]manifest.Entry, originals bool, opts exportOptions, bridge photos.Bridge) (int, int) {
var cache *geocodeCache
if opts.reverseGeocode && len(pending) > 0 {
root := pending[0].root
if root == "" {
root = pending[0].path
}
cache = newGeocodeCache(root)
}
written, failed := 0, 0
for _, pa := range pending {
entry, ok := entries[pa.asset.ID]
if !ok {
continue
}
if err := writeMetadataOnlySidecar(pa, entry, originals, opts, cache, bridge); err != nil {
failed++
continue
}
written++
}
return written, failed
}
func collectNodes(nodes []photos.CollectionNode, outDir string, bridge photos.Bridge, skipVideos bool, originals bool, items *[]pendingAsset, skipped *int, onProgress func(collectProgress), m manifest.Manifest, exclude []string, opts exportOptions) {
names := make(map[string]int)
for _, node := range nodes {
@@ -1160,7 +1311,7 @@ func collectNodes(nodes []photos.CollectionNode, outDir string, bridge photos.Br
}
assets = applyAssetFilters(assets, opts)
for _, a := range assets {
if m != nil && m.Has(a.ID) {
if m != nil && m.Has(a.ID) && !opts.metadataOnly {
*skipped++
continue
}
@@ -1767,6 +1918,10 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
verify: hasFlag(args, "--verify"),
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"),
}
@@ -1782,6 +1937,22 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
fmt.Fprintf(stderr, "error: --sidecar must be none or xmp, got %q\n", opts.sidecar)
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.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
}
if v := flagVal(args, "--retry"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
@@ -2005,6 +2176,7 @@ func cmdDiff(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int
func cmdVerify(args []string, stdout, stderr io.Writer) int {
outDir := flagVal(args, "--out")
checkSidecar := hasFlag(args, "--sidecar")
strictSidecar := hasFlag(args, "--strict")
if outDir == "" {
fmt.Fprintln(stderr, "error: --out is required")
return exitErr
@@ -2044,7 +2216,7 @@ func cmdVerify(args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stdout, "%s\t%s\tsize-mismatch\tmanifest=%d\tdisk=%d\n", id, checkPath, e.Size, info.Size())
}
if checkSidecar {
bad += verifySidecar(stdout, outDir, id, checkPath)
bad += verifySidecar(stdout, outDir, id, checkPath, strictSidecar)
}
}
if bad > 0 {
@@ -2054,7 +2226,7 @@ func cmdVerify(args []string, stdout, stderr io.Writer) int {
return exitOK
}
func verifySidecar(stdout io.Writer, outDir, id, checkPath string) int {
func verifySidecar(stdout io.Writer, outDir, id, checkPath string, strict bool) int {
xmpPath := sidecarPath(filepath.Join(outDir, checkPath))
rel, err := filepath.Rel(outDir, xmpPath)
if err != nil || strings.HasPrefix(rel, "..") {
@@ -2079,9 +2251,86 @@ func verifySidecar(stdout io.Writer, outDir, id, checkPath string) int {
fmt.Fprintf(stdout, "%s\t%s\tsidecar-asset-mismatch\n", id, rel)
return 1
}
if strict {
meta := inspectXMP(data)
if meta["xmpSchemaVersion"] != "2" {
fmt.Fprintf(stdout, "%s\t%s\tsidecar-schema-missing\n", id, rel)
return 1
}
if meta["sidecarGenerator"] == "" {
fmt.Fprintf(stdout, "%s\t%s\tsidecar-generator-missing\n", id, rel)
return 1
}
if meta["exportedFilename"] != filepath.Base(checkPath) {
fmt.Fprintf(stdout, "%s\t%s\tsidecar-filename-mismatch\n", id, rel)
return 1
}
}
return 0
}
func cmdSidecar(args []string, stdout, stderr io.Writer) int {
if len(args) < 1 || args[0] != "inspect" {
fmt.Fprintln(stderr, "error: expected sidecar inspect <file.xmp>")
return exitErr
}
if len(args) < 2 {
fmt.Fprintln(stderr, "error: sidecar inspect requires <file.xmp>")
return exitErr
}
path := args[1]
data, err := readFileFunc(path)
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
meta := inspectXMP(data)
if len(meta) == 0 {
fmt.Fprintln(stderr, "error: no photoscli metadata found")
return exitErr
}
if hasFlag(args[2:], "--json") {
if err := json.NewEncoder(stdout).Encode(meta); err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
return exitOK
}
keys := make([]string, 0, len(meta))
for k := range meta {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(stdout, "%s\t%s\n", k, meta[k])
}
return exitOK
}
func inspectXMP(data []byte) map[string]string {
attrs := map[string]string{}
dec := xml.NewDecoder(bytes.NewReader(data))
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return attrs
}
start, ok := tok.(xml.StartElement)
if !ok {
continue
}
for _, a := range start.Attr {
if a.Name.Space == "photoscli" || a.Name.Space == "https://gitea.k3s.k0.nu/tools/photocli/ns/1.0/" {
attrs[a.Name.Local] = a.Value
}
}
}
return attrs
}
func cmdRetryFailed(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
outDir := flagVal(args, "--out")
clearOnSuccess := hasFlag(args, "--clear-on-success")
+299 -1
View File
@@ -35,6 +35,19 @@ type mockBridge struct {
cancelled atomic.Bool
}
type errWriter struct{}
func (errWriter) Write([]byte) (int, error) { return 0, fmt.Errorf("write") }
type noEntryManifest struct{}
func (noEntryManifest) Has(string) bool { return false }
func (noEntryManifest) Add(string, string, int64, string) {}
func (noEntryManifest) AddEntry(manifest.Entry) {}
func (noEntryManifest) Save() error { return nil }
func (noEntryManifest) Close() {}
func (noEntryManifest) OpenAppend() error { return nil }
func (m *mockBridge) RequestAccess() error { return m.accessErr }
func (m *mockBridge) ListAlbums() ([]photos.Album, error) { return m.albums, m.albumsErr }
func (m *mockBridge) ListAssets(albumID string) ([]photos.Asset, int, error) {
@@ -4204,12 +4217,92 @@ func TestVerifySidecarBranches(t *testing.T) {
oldRead := readFileFunc
readFileFunc = func(string) ([]byte, error) { return nil, fmt.Errorf("read") }
var out bytes.Buffer
if got := verifySidecar(&out, subdir, "x1", "../asset.jpg"); got != 1 || !strings.Contains(out.String(), "sidecar-unreadable") {
if got := verifySidecar(&out, subdir, "x1", "../asset.jpg", false); got != 1 || !strings.Contains(out.String(), "sidecar-unreadable") {
t.Fatalf("expected unreadable with rel fallback, got=%d out=%q", got, out.String())
}
readFileFunc = oldRead
}
func TestVerifySidecarStrict(t *testing.T) {
dir := t.TempDir()
media := filepath.Join(dir, "photo.jpg")
if err := os.WriteFile(media, []byte("data"), 0644); err != nil {
t.Fatal(err)
}
if err := writeXMPSidecar(sidecarPath(media), xmpSidecarData{AssetID: "x1", ExportedFilename: "photo.jpg"}); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
if got := verifySidecar(&out, dir, "x1", "photo.jpg", true); got != 0 || out.Len() != 0 {
t.Fatalf("strict valid got=%d out=%q", got, out.String())
}
if err := os.WriteFile(sidecarPath(media), []byte(`photoscli:assetID="x1"`), 0644); err != nil {
t.Fatal(err)
}
out.Reset()
if got := verifySidecar(&out, dir, "x1", "photo.jpg", true); got != 1 || !strings.Contains(out.String(), "sidecar-schema-missing") {
t.Fatalf("strict schema got=%d out=%q", got, out.String())
}
if err := os.WriteFile(sidecarPath(media), []byte(`<?xpacket begin=""?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:photoscli="https://gitea.k3s.k0.nu/tools/photocli/ns/1.0/" photoscli:assetID="x1" photoscli:xmpSchemaVersion="2" photoscli:exportedFilename="photo.jpg" /></rdf:RDF></x:xmpmeta>`), 0644); err != nil {
t.Fatal(err)
}
out.Reset()
if got := verifySidecar(&out, dir, "x1", "photo.jpg", true); got != 1 || !strings.Contains(out.String(), "sidecar-generator-missing") {
t.Fatalf("strict generator got=%d out=%q", got, out.String())
}
if err := os.WriteFile(sidecarPath(media), []byte(`<?xpacket begin=""?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:photoscli="https://gitea.k3s.k0.nu/tools/photocli/ns/1.0/" photoscli:assetID="x1" photoscli:xmpSchemaVersion="2" photoscli:sidecarGenerator="photoscli" photoscli:exportedFilename="other.jpg" /></rdf:RDF></x:xmpmeta>`), 0644); err != nil {
t.Fatal(err)
}
out.Reset()
if got := verifySidecar(&out, dir, "x1", "photo.jpg", true); got != 1 || !strings.Contains(out.String(), "sidecar-filename-mismatch") {
t.Fatalf("strict filename got=%d out=%q", got, out.String())
}
}
func TestSidecarInspect(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "photo.xmp")
if err := writeXMPSidecar(path, xmpSidecarData{AssetID: "x1", ExportedFilename: "photo.jpg", Album: "Trips"}); err != nil {
t.Fatal(err)
}
out, stderr, rc := runWith([]string{"sidecar", "inspect", path}, &mockBridge{})
if rc != exitOK || stderr != "" || !strings.Contains(out, "assetID\tx1") || !strings.Contains(out, "album\tTrips") {
t.Fatalf("inspect rc=%d out=%q stderr=%q", rc, out, stderr)
}
out, stderr, rc = runWith([]string{"sidecar", "inspect", path, "--json"}, &mockBridge{})
if rc != exitOK || stderr != "" || !strings.Contains(out, `"assetID":"x1"`) || !strings.Contains(out, `"exportedFilename":"photo.jpg"`) {
t.Fatalf("inspect json rc=%d out=%q stderr=%q", rc, out, stderr)
}
_, stderr, rc = runWith([]string{"sidecar"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "expected sidecar inspect") {
t.Fatalf("inspect missing subcommand rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"sidecar", "inspect"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "requires <file.xmp>") {
t.Fatalf("inspect missing path rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"sidecar", "inspect", filepath.Join(dir, "missing.xmp")}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "error:") {
t.Fatalf("inspect missing file rc=%d stderr=%q", rc, stderr)
}
plain := filepath.Join(dir, "plain.xmp")
if err := os.WriteFile(plain, []byte(`<x:xmpmeta xmlns:x="adobe:ns:meta/"></x:xmpmeta>`), 0644); err != nil {
t.Fatal(err)
}
_, stderr, rc = runWith([]string{"sidecar", "inspect", plain}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "no photoscli metadata") {
t.Fatalf("inspect no metadata rc=%d stderr=%q", rc, stderr)
}
bad := inspectXMP([]byte(`<x:xmpmeta><rdf:RDF>`))
if len(bad) != 0 {
t.Fatalf("expected empty metadata on malformed XML, got %#v", bad)
}
stderrBuf := &bytes.Buffer{}
if rc := cmdSidecar([]string{"inspect", path, "--json"}, errWriter{}, stderrBuf); rc != exitErr || !strings.Contains(stderrBuf.String(), "error:") {
t.Fatalf("expected json encoder error rc=%d stderr=%q", rc, stderrBuf.String())
}
}
func TestXMPSidecarHelpers(t *testing.T) {
if got := sidecarPath("/tmp/IMG_0001.HEIC"); got != "/tmp/IMG_0001.xmp" {
t.Fatalf("sidecar path = %q", got)
@@ -4491,6 +4584,18 @@ func TestSidecarConfigAndErrors(t *testing.T) {
if _, ok := parseExportOptions([]string{"--sidecar", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--sidecar") {
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())
}
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) {
@@ -4505,6 +4610,199 @@ 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 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) {
dir := t.TempDir()
m := manifest.LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "photo.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
m.Close()
if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "orig.heic", MediaType: "image", IsFavorite: true}}}
b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) {
t.Fatal("metadata-only must not export media")
return photos.ExportResult{}, nil
}
out, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--sidecar", "xmp", "--metadata-only"}, b)
if rc != exitOK || out != "" || !strings.Contains(stderr, "wrote 1 metadata sidecars") {
t.Fatalf("metadata-only rc=%d out=%q stderr=%q", rc, out, stderr)
}
data, err := os.ReadFile(filepath.Join(dir, "photo.xmp"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "photoscli:assetID=\"x1\"") || !strings.Contains(string(data), "xmp:Rating=\"5\"") {
t.Fatalf("unexpected metadata sidecar: %s", string(data))
}
}
func TestMetadataOnlyExportErrors(t *testing.T) {
dir := t.TempDir()
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "photo.jpg"}}}
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--metadata-only"}, b)
if rc != exitErr || !strings.Contains(stderr, "--metadata-only requires --sidecar xmp") {
t.Fatalf("expected sidecar requirement rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"export", "--album-id", "x", "--out", dir, "--sidecar", "xmp", "--metadata-only", "--no-manifest"}, b)
if rc != exitErr || !strings.Contains(stderr, "requires a manifest") {
t.Fatalf("expected manifest requirement rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"backup-all", "--out", dir, "--sidecar", "xmp", "--metadata-only", "--no-manifest"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "requires a manifest") {
t.Fatalf("expected backup manifest requirement rc=%d stderr=%q", rc, stderr)
}
}
func TestMetadataOnlyHelperBranches(t *testing.T) {
dir := t.TempDir()
pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "photo.jpg"}, path: dir}
if err := writeMetadataOnlySidecar(pa, manifest.Entry{ID: "x1"}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err == nil || !strings.Contains(err.Error(), "no path") {
t.Fatalf("expected no path error, got %v", err)
}
if err := writeMetadataOnlySidecar(pa, manifest.Entry{ID: "x1", Path: "missing.jpg"}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err == nil || !strings.Contains(err.Error(), "missing") {
t.Fatalf("expected missing error, got %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "zero.jpg"), nil, 0644); err != nil {
t.Fatal(err)
}
if err := writeMetadataOnlySidecar(pa, manifest.Entry{ID: "x1", Path: "zero.jpg"}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err == nil || !strings.Contains(err.Error(), "zero-byte") {
t.Fatalf("expected zero-byte error, got %v", err)
}
if entries := manifestEntries(noEntryManifest{}); entries != nil {
t.Fatalf("expected nil entries, got %#v", entries)
}
written, failed := metadataOnlyPending([]pendingAsset{{asset: photos.Asset{ID: "x1"}, path: dir}}, map[string]manifest.Entry{"x1": {ID: "x1", Path: "missing.jpg"}}, false, exportOptions{sidecar: "xmp"}, &mockBridge{})
if written != 0 || failed != 1 {
t.Fatalf("expected failed metadata pending, written=%d failed=%d", written, failed)
}
if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
written, failed = metadataOnlyPending([]pendingAsset{{asset: photos.Asset{ID: "x2", Location: &photos.AssetLocation{Latitude: 1, Longitude: 2}}, path: dir}}, map[string]manifest.Entry{"x2": {ID: "x2", Path: "photo.jpg"}}, false, exportOptions{sidecar: "xmp", reverseGeocode: true}, &mockBridge{reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) {
return photos.Placemark{Country: "Sweden"}, nil
}})
if written != 1 || failed != 0 {
t.Fatalf("expected reverse geocode metadata success, written=%d failed=%d", written, failed)
}
}
func TestMetadataOnlyBackupAll(t *testing.T) {
dir := t.TempDir()
m := manifest.LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "Album/photo.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
m.Close()
albumDir := filepath.Join(dir, "Album")
if err := os.Mkdir(albumDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(albumDir, "photo.jpg"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
b := &mockBridge{
tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}},
assetsByAlbum: map[string][]photos.Asset{"a1": {{ID: "x1", Filename: "orig.heic", MediaType: "image"}, {ID: "x2", Filename: "missing.heic", MediaType: "image"}}},
}
b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) {
t.Fatal("metadata-only backup-all must not export media")
return photos.ExportResult{}, nil
}
_, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--sidecar", "xmp", "--metadata-only"}, b)
if rc != exitOK || !strings.Contains(stderr, "wrote 1 metadata sidecars") {
t.Fatalf("metadata-only backup rc=%d stderr=%q", rc, stderr)
}
if _, err := os.Stat(filepath.Join(albumDir, "photo.xmp")); err != nil {
t.Fatalf("expected metadata-only sidecar: %v", err)
}
}
func TestSidecarAdditionalBranches(t *testing.T) {
dir := t.TempDir()
oldCreateTemp := createTempFunc