Compare commits

3 Commits

Author SHA1 Message Date
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
7 changed files with 309 additions and 15 deletions
+23
View File
@@ -2,6 +2,29 @@
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.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 ## v0.8.2
Metadata-only XMP refresh release. Metadata-only XMP refresh 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.2 VERSION := 0.8.5
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
+15
View File
@@ -357,12 +357,27 @@ 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. 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.
Verify generated sidecars with: Verify generated sidecars with:
```bash ```bash
photoscli verify --out ./backup --sidecar 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: Refresh sidecars for files already present in a manifest-backed export without rewriting media files:
```bash ```bash
+7 -8
View File
@@ -1,19 +1,18 @@
# v0.8.2 # v0.8.5
This release adds metadata-only XMP sidecar refresh for existing manifest-backed exports. This release adds privacy controls for generated XMP sidecars.
## Highlights ## Highlights
- Add `--metadata-only` for `export` and `backup-all`. - Add `--xmp-privacy keep|strip-location|strip-address`.
- Use manifest paths to find existing media files and write or refresh `.xmp` sidecars without re-exporting media. - Keep existing behavior by default with `keep`.
- Require `--sidecar xmp` and an enabled manifest for metadata-only mode. - Use `strip-address` to omit reverse-geocoded address fields while keeping GPS coordinates.
- Leave media files untouched; only generated XMP sidecars are written. - Use `strip-location` to omit both GPS coordinates and address fields.
- Support `--reverse-geocode` during metadata-only refresh.
## Assets ## Assets
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`). - `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
- `photoscli-0.8.2-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG. - `photoscli-0.8.5-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.
+24
View File
@@ -557,6 +557,15 @@ 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. 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.
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
@@ -573,6 +582,21 @@ Verify sidecars after an export:
This reports missing, zero-byte, unreadable, or asset-ID mismatched `.xmp` files. 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: Refresh metadata only for an existing manifest-backed backup:
```bash ```bash
+112 -5
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"bytes"
"encoding/json" "encoding/json"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
@@ -41,6 +42,7 @@ type exportOptions struct {
verify bool verify bool
format string format string
sidecar string sidecar string
xmpPrivacy string
metadataOnly bool metadataOnly bool
reverseGeocode bool reverseGeocode bool
minSize int64 minSize int64
@@ -91,6 +93,8 @@ func run(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
return cmdFailures(args[1:], stdout, stderr) return cmdFailures(args[1:], stdout, stderr)
case "status": case "status":
return cmdStatus(args[1:], stdout, stderr) return cmdStatus(args[1:], stdout, stderr)
case "sidecar":
return cmdSidecar(args[1:], stdout, stderr)
case "version", "--version", "-v": case "version", "--version", "-v":
fmt.Fprintln(stdout, version) fmt.Fprintln(stdout, version)
return exitOK return exitOK
@@ -132,6 +136,7 @@ USAGE
photoscli retry-failed --out <dir> --clear-on-success photoscli retry-failed --out <dir> --clear-on-success
photoscli failures list --out <dir> photoscli failures list --out <dir>
photoscli failures clear --out <dir> photoscli failures clear --out <dir>
photoscli sidecar inspect <file.xmp> [--json]
photoscli status --out <dir> [--json] photoscli status --out <dir> [--json]
photoscli version photoscli version
photoscli help photoscli help
@@ -168,7 +173,8 @@ COMMANDS
verify --out <dir> [--manifest jsonl|sqlite] verify --out <dir> [--manifest jsonl|sqlite]
Verify that manifest entries point to files that exist on disk. Missing 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. 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-failed --out <dir>
Retry assets previously written to failures.jsonl. Retry assets previously written to failures.jsonl.
@@ -176,6 +182,9 @@ COMMANDS
failures list|clear --out <dir> failures list|clear --out <dir>
List or clear deduplicated failure records. 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] status --out <dir> [--manifest jsonl|sqlite] [--json]
Show manifest type, entry count, and failure count for a backup. Show manifest type, entry count, and failure count for a backup.
@@ -221,6 +230,9 @@ COMMON EXPORT FLAGS
Write opt-in XMP sidecar metadata next to each exported file. Default: Write opt-in XMP sidecar metadata next to each exported file. Default:
none. If XMP writing fails, the asset is counted as failed. 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.
--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.
@@ -1083,10 +1095,22 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
if pa.asset.ModificationDate != nil { if pa.asset.ModificationDate != nil {
modifyDate = *pa.asset.ModificationDate modifyDate = *pa.asset.ModificationDate
} }
location := pa.asset.Location
xmpPrivacy := opts.xmpPrivacy
if xmpPrivacy == "" {
xmpPrivacy = "keep"
}
var placemark *photos.Placemark 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) 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
}
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,
@@ -1112,7 +1136,7 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
Size: result.Size, Size: result.Size,
CreateDate: createDate, CreateDate: createDate,
ModifyDate: modifyDate, ModifyDate: modifyDate,
Location: pa.asset.Location, Location: location,
Placemark: placemark, Placemark: placemark,
BurstIdentifier: pa.asset.BurstIdentifier, BurstIdentifier: pa.asset.BurstIdentifier,
RepresentsBurst: pa.asset.RepresentsBurst, RepresentsBurst: pa.asset.RepresentsBurst,
@@ -1867,6 +1891,7 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
verify: hasFlag(args, "--verify"), verify: hasFlag(args, "--verify"),
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"),
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"),
@@ -1883,6 +1908,10 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
fmt.Fprintf(stderr, "error: --sidecar must be none or xmp, got %q\n", opts.sidecar) fmt.Fprintf(stderr, "error: --sidecar must be none or xmp, got %q\n", opts.sidecar)
return opts, false 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.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
@@ -2110,6 +2139,7 @@ func cmdDiff(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int
func cmdVerify(args []string, stdout, stderr io.Writer) int { func cmdVerify(args []string, stdout, stderr io.Writer) int {
outDir := flagVal(args, "--out") outDir := flagVal(args, "--out")
checkSidecar := hasFlag(args, "--sidecar") checkSidecar := hasFlag(args, "--sidecar")
strictSidecar := hasFlag(args, "--strict")
if outDir == "" { if outDir == "" {
fmt.Fprintln(stderr, "error: --out is required") fmt.Fprintln(stderr, "error: --out is required")
return exitErr return exitErr
@@ -2149,7 +2179,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()) fmt.Fprintf(stdout, "%s\t%s\tsize-mismatch\tmanifest=%d\tdisk=%d\n", id, checkPath, e.Size, info.Size())
} }
if checkSidecar { if checkSidecar {
bad += verifySidecar(stdout, outDir, id, checkPath) bad += verifySidecar(stdout, outDir, id, checkPath, strictSidecar)
} }
} }
if bad > 0 { if bad > 0 {
@@ -2159,7 +2189,7 @@ func cmdVerify(args []string, stdout, stderr io.Writer) int {
return exitOK 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)) xmpPath := sidecarPath(filepath.Join(outDir, checkPath))
rel, err := filepath.Rel(outDir, xmpPath) rel, err := filepath.Rel(outDir, xmpPath)
if err != nil || strings.HasPrefix(rel, "..") { if err != nil || strings.HasPrefix(rel, "..") {
@@ -2184,9 +2214,86 @@ func verifySidecar(stdout io.Writer, outDir, id, checkPath string) int {
fmt.Fprintf(stdout, "%s\t%s\tsidecar-asset-mismatch\n", id, rel) fmt.Fprintf(stdout, "%s\t%s\tsidecar-asset-mismatch\n", id, rel)
return 1 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 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 { func cmdRetryFailed(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
outDir := flagVal(args, "--out") outDir := flagVal(args, "--out")
clearOnSuccess := hasFlag(args, "--clear-on-success") clearOnSuccess := hasFlag(args, "--clear-on-success")
+127 -1
View File
@@ -35,6 +35,10 @@ type mockBridge struct {
cancelled atomic.Bool cancelled atomic.Bool
} }
type errWriter struct{}
func (errWriter) Write([]byte) (int, error) { return 0, fmt.Errorf("write") }
type noEntryManifest struct{} type noEntryManifest struct{}
func (noEntryManifest) Has(string) bool { return false } func (noEntryManifest) Has(string) bool { return false }
@@ -4213,12 +4217,92 @@ func TestVerifySidecarBranches(t *testing.T) {
oldRead := readFileFunc oldRead := readFileFunc
readFileFunc = func(string) ([]byte, error) { return nil, fmt.Errorf("read") } readFileFunc = func(string) ([]byte, error) { return nil, fmt.Errorf("read") }
var out bytes.Buffer 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()) t.Fatalf("expected unreadable with rel fallback, got=%d out=%q", got, out.String())
} }
readFileFunc = oldRead 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) { func TestXMPSidecarHelpers(t *testing.T) {
if got := sidecarPath("/tmp/IMG_0001.HEIC"); got != "/tmp/IMG_0001.xmp" { if got := sidecarPath("/tmp/IMG_0001.HEIC"); got != "/tmp/IMG_0001.xmp" {
t.Fatalf("sidecar path = %q", got) t.Fatalf("sidecar path = %q", got)
@@ -4500,6 +4584,10 @@ func TestSidecarConfigAndErrors(t *testing.T) {
if _, ok := parseExportOptions([]string{"--sidecar", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--sidecar") { if _, ok := parseExportOptions([]string{"--sidecar", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--sidecar") {
t.Fatalf("expected sidecar validation error, stderr=%q", stderr.String()) 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())
}
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) {
@@ -4514,6 +4602,44 @@ 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 TestMetadataOnlyExport(t *testing.T) { func TestMetadataOnlyExport(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
m := manifest.LoadJSONL(dir) m := manifest.LoadJSONL(dir)