v0.8.1: improve XMP sidecars
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled

This commit is contained in:
Ein Anderssono
2026-06-15 01:36:04 +02:00
parent fffb30023b
commit 9cd702628d
8 changed files with 188 additions and 15 deletions
+3
View File
@@ -14,4 +14,7 @@
- XMP sidecars are opt-in via `--sidecar xmp`; default must remain `none`.
- Reverse geocoding is opt-in via `--reverse-geocode`, uses MapKit on macOS 26+, and must fail safely on older macOS without failing export.
- Do not claim Photos people/animal/object labels are exported unless Vision/Core ML analysis is explicitly implemented.
- Roadmap: keep `0.8.x` focused on making XMP sidecars as rich, standard-compatible, and complete as possible from public PhotoKit metadata.
- Roadmap: use `0.9.0` through `0.9.5` for checksums, deep verification, manifest repair, cleanup, doctor, and backup-integrity hardening.
- Roadmap: start Vision/Core ML analysis only after `0.9.5`; keep it opt-in and label it as photoscli-generated analysis, not Apple Photos metadata.
- Do not commit generated artifacts from `bin/` or coverage files.
+10
View File
@@ -2,6 +2,16 @@
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.1
XMP standards and sidecar verification release.
- Add XMP schema version metadata for generated sidecars.
- Add standard XMP fields for rating, metadata date, creation 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` to check missing, zero-byte, unreadable, and asset-ID mismatched XMP sidecars.
## v0.8.0
Rich PhotoKit metadata and reverse-geocoded XMP release.
+1 -1
View File
@@ -1,6 +1,6 @@
BINARY := ./bin/photoscli
MODULE := gitea.k3s.k0.nu/tools/photocli
VERSION := 0.8.0
VERSION := 0.8.1
RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip
RELEASE_NOTES := RELEASE_NOTES.md
BRIDGE_DIR := bridge
+15 -1
View File
@@ -20,6 +20,7 @@ For a practical step-by-step manual with recommended backup workflows, recovery
- Failure tracking with `failures.jsonl` and `retry-failed`.
- 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`.
- 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`.
@@ -269,6 +270,7 @@ Common flags for `export` and `backup-all`:
- `--dry-run`: print planned exports without writing files, manifests, or logs.
- `--json`: print a machine-readable summary to stdout.
- `--verify`: run manifest/file verification after export.
- `--sidecar` with `verify`: also verify expected `.xmp` sidecars.
- `--log`: enable structured export logging.
- `--manifest jsonl|sqlite`: choose manifest backend, default `jsonl`.
- `--no-manifest`: disable manifest reads/writes.
@@ -351,7 +353,13 @@ IMG_0001.HEIC -> IMG_0001.xmp
The XMP contains photoscli metadata such as asset ID, filenames, album, manifest path, media type, dimensions, favorite state, cloud state, export mode, version, exported timestamp, size, and creation date when available. If `--sidecar xmp` is explicitly selected and the sidecar cannot be written, that asset is treated as failed.
In v0.8.0, 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, and structured asset resources. 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.
Verify generated sidecars with:
```bash
photoscli verify --out ./backup --sidecar
```
## Failure Tracking
@@ -464,3 +472,9 @@ Objective-C returns JSON to Go. Tests use the stub bridge and do not require rea
- Album title lookup uses exact title matching. Use PhotoKit local identifiers when names are ambiguous.
- iCloud-backed assets may trigger downloads and can fail due to network or account state.
- `--min-size` and `--max-size` currently use estimated pixel count from dimensions, not encoded file size.
## Roadmap
- `0.8.x`: make XMP sidecars as rich, standard-compatible, and complete as possible from public PhotoKit metadata.
- `0.9.0` through `0.9.5`: checksums, deep verification, manifest repair, cleanup, doctor, and backup-integrity hardening.
- After `0.9.5`: opt-in Vision/Core ML analysis for photoscli-generated face/object/animal/scene metadata.
+9 -9
View File
@@ -1,20 +1,20 @@
# v0.8.0
# v0.8.1
This release expands XMP sidecars with richer public PhotoKit metadata and adds optional reverse geocoding through Apple MapKit on macOS 26 or newer.
This release improves XMP sidecar standards compatibility and adds sidecar verification.
## Highlights
- XMP sidecars now include richer public PhotoKit metadata: modification date, duration, hidden state, adjustment state, media subtypes, source type, playback style, burst data, GPS coordinates, adjustment info, and structured asset resources when available.
- Add `--reverse-geocode` to enrich GPS assets with cached address metadata from Apple MapKit on macOS 26+.
- On older macOS versions, reverse geocoding is skipped safely; export continues and GPS coordinates remain in XMP.
- Reverse-geocode cache is stored under `.photoscli/geocode-cache.jsonl` in the backup root.
- Existing `--sidecar xmp` behavior remains opt-in and atomic; sidecar write failure still fails that asset.
- Vision/Core ML people, animal, object, and scene analysis is not included in this release.
- 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.
## Assets
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
- `photoscli-0.8.0-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG.
- `photoscli-0.8.1-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.
+9 -1
View File
@@ -555,7 +555,7 @@ IMG_0001.jpg -> IMG_0001.xmp
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, and structured asset resources 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.
For address metadata from GPS coordinates, opt in to Apple's reverse geocoder:
@@ -565,6 +565,14 @@ For address metadata from GPS coordinates, opt in to Apple's reverse geocoder:
Reverse geocoding uses Apple MapKit and requires macOS 26 or newer. It can require network access and may be rate-limited by Apple, so results are cached in `.photoscli/geocode-cache.jsonl` under the backup root. On older macOS versions, `--reverse-geocode` is treated as unavailable: the export continues, no address fields are added, and the XMP still contains GPS coordinates.
Verify sidecars after an export:
```bash
./bin/photoscli verify --out ./PhotosBackup --sidecar
```
This reports missing, zero-byte, unreadable, or asset-ID mismatched `.xmp` files.
If you explicitly request `--sidecar xmp` and the XMP file cannot be written, the asset is counted as failed.
## Configuration File
+75 -2
View File
@@ -25,6 +25,8 @@ var (
mkdirTempFunc = os.MkdirTemp
createTempFunc = os.CreateTemp
writeFileFunc = os.WriteFile
readFileFunc = os.ReadFile
statFunc = os.Stat
renameFunc = os.Rename
openFileFunc = os.OpenFile
removeFunc = os.Remove
@@ -165,6 +167,7 @@ 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.
retry-failed --out <dir>
Retry assets previously written to failures.jsonl.
@@ -752,6 +755,7 @@ type xmpSidecarData struct {
ExportedFilename string
Album string
AlbumPath string
Keywords []string
ManifestPath string
MediaType string
MediaSubtypes []string
@@ -843,6 +847,7 @@ func sidecarPath(exportedPath string) string {
func renderXMP(d xmpSidecarData) []byte {
attrs := []struct{ key, val string }{
{"photoscli:xmpSchemaVersion", "2"},
{"photoscli:assetID", d.AssetID},
{"photoscli:originalFilename", d.OriginalFilename},
{"photoscli:exportedFilename", d.ExportedFilename},
@@ -866,9 +871,18 @@ func renderXMP(d xmpSidecarData) []byte {
{"photoscli:exportedAt", d.ExportedAt},
{"photoscli:size", fmt.Sprintf("%d", d.Size)},
{"dc:title", d.ExportedFilename},
{"xmp:MetadataDate", d.ExportedAt},
{"photoscli:sidecarGeneratedAt", d.ExportedAt},
{"photoscli:sidecarGenerator", "photoscli " + d.PhotoscliVersion},
}
if d.IsFavorite {
attrs = append(attrs, struct{ key, val string }{"xmp:Rating", "5"})
}
if d.CreateDate != "" {
attrs = append(attrs, struct{ key, val string }{"xmp:CreateDate", d.CreateDate})
attrs = append(attrs,
struct{ key, val string }{"xmp:CreateDate", d.CreateDate},
struct{ key, val string }{"photoshop:DateCreated", d.CreateDate},
)
}
if d.ModifyDate != "" {
attrs = append(attrs, struct{ key, val string }{"xmp:ModifyDate", d.ModifyDate})
@@ -879,6 +893,9 @@ func renderXMP(d xmpSidecarData) []byte {
struct{ key, val string }{"photoscli:longitude", fmt.Sprintf("%.8f", d.Location.Longitude)},
struct{ key, val string }{"photoscli:altitude", fmt.Sprintf("%.3f", d.Location.Altitude)},
struct{ key, val string }{"photoscli:horizontalAccuracy", fmt.Sprintf("%.3f", d.Location.HorizontalAccuracy)},
struct{ key, val string }{"exif:GPSLatitude", fmt.Sprintf("%.8f", d.Location.Latitude)},
struct{ key, val string }{"exif:GPSLongitude", fmt.Sprintf("%.8f", d.Location.Longitude)},
struct{ key, val string }{"exif:GPSAltitude", fmt.Sprintf("%.3f", d.Location.Altitude)},
)
}
if d.Placemark != nil {
@@ -906,7 +923,7 @@ func renderXMP(d xmpSidecarData) []byte {
sb.WriteString("<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n")
sb.WriteString("<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n")
sb.WriteString(" <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n")
sb.WriteString(" <rdf:Description xmlns:photoscli=\"https://gitea.k3s.k0.nu/tools/photocli/ns/1.0/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\"")
sb.WriteString(" <rdf:Description xmlns:photoscli=\"https://gitea.k3s.k0.nu/tools/photocli/ns/1.0/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\" xmlns:exif=\"http://ns.adobe.com/exif/1.0/\" xmlns:photoshop=\"http://ns.adobe.com/photoshop/1.0/\"")
for _, a := range attrs {
sb.WriteString("\n ")
sb.WriteString(a.key)
@@ -915,6 +932,7 @@ func renderXMP(d xmpSidecarData) []byte {
sb.WriteString("\"")
}
sb.WriteString(" >\n")
writeStringSeq(&sb, "dc:subject", d.Keywords)
writeStringSeq(&sb, "photoscli:mediaSubtypes", d.MediaSubtypes)
writeStringSeq(&sb, "photoscli:burstSelectionTypes", d.BurstSelectionTypes)
writeStringSeq(&sb, "photoscli:areasOfInterest", func() []string {
@@ -931,6 +949,24 @@ func renderXMP(d xmpSidecarData) []byte {
return []byte(sb.String())
}
func keywordsFromAlbumPath(album, albumPath string) []string {
seen := map[string]bool{}
var out []string
add := func(v string) {
v = strings.TrimSpace(v)
if v == "" || v == "." || seen[v] {
return
}
seen[v] = true
out = append(out, v)
}
add(album)
for _, part := range strings.Split(filepath.ToSlash(albumPath), "/") {
add(part)
}
return out
}
func writeStringSeq(sb *strings.Builder, name string, vals []string) {
if len(vals) == 0 {
return
@@ -999,6 +1035,10 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
if err != nil || strings.HasPrefix(relPath, "..") {
relPath = result.Filename
}
relDir, err := filepath.Rel(root, pa.path)
if err != nil || strings.HasPrefix(relDir, "..") || relDir == "." {
relDir = ""
}
createDate := ""
if pa.asset.CreationDate != nil {
createDate = *pa.asset.CreationDate
@@ -1017,6 +1057,7 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
ExportedFilename: result.Filename,
Album: pa.album,
AlbumPath: pa.path,
Keywords: keywordsFromAlbumPath(pa.album, relDir),
ManifestPath: relPath,
MediaType: pa.asset.MediaType,
MediaSubtypes: pa.asset.MediaSubtypes,
@@ -1963,6 +2004,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")
if outDir == "" {
fmt.Fprintln(stderr, "error: --out is required")
return exitErr
@@ -2001,6 +2043,9 @@ func cmdVerify(args []string, stdout, stderr io.Writer) int {
bad++
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)
}
}
if bad > 0 {
return exitPartial
@@ -2009,6 +2054,34 @@ func cmdVerify(args []string, stdout, stderr io.Writer) int {
return exitOK
}
func verifySidecar(stdout io.Writer, outDir, id, checkPath string) int {
xmpPath := sidecarPath(filepath.Join(outDir, checkPath))
rel, err := filepath.Rel(outDir, xmpPath)
if err != nil || strings.HasPrefix(rel, "..") {
rel = xmpPath
}
info, err := statFunc(xmpPath)
if err != nil {
fmt.Fprintf(stdout, "%s\t%s\tsidecar-missing\n", id, rel)
return 1
}
if info.Size() == 0 {
fmt.Fprintf(stdout, "%s\t%s\tsidecar-zero-byte\n", id, rel)
return 1
}
data, err := readFileFunc(xmpPath)
if err != nil {
fmt.Fprintf(stdout, "%s\t%s\tsidecar-unreadable\n", id, rel)
return 1
}
needle := `photoscli:assetID="` + id + `"`
if !strings.Contains(string(data), needle) {
fmt.Fprintf(stdout, "%s\t%s\tsidecar-asset-mismatch\n", id, rel)
return 1
}
return 0
}
func cmdRetryFailed(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
outDir := flagVal(args, "--out")
clearOnSuccess := hasFlag(args, "--clear-on-success")
+66 -1
View File
@@ -3835,6 +3835,13 @@ func TestReportDiffVerifyRetryFailedAndConfig(t *testing.T) {
if rc != exitOK || !strings.Contains(out, "verified") || stderr != "" {
t.Fatalf("verify rc=%d out=%q stderr=%q", rc, out, stderr)
}
if err := os.WriteFile(filepath.Join(dir, "file.xmp"), renderXMP(xmpSidecarData{AssetID: "x1", ExportedFilename: "file.jpg"}), 0644); err != nil {
t.Fatal(err)
}
out, stderr, rc = runWith([]string{"verify", "--out", dir, "--sidecar"}, &mockBridge{})
if rc != exitOK || !strings.Contains(out, "verified") || stderr != "" {
t.Fatalf("verify sidecar rc=%d out=%q stderr=%q", rc, out, stderr)
}
b := &mockBridge{albums: []photos.Album{{ID: "a1", Title: "Album"}}, assets: []photos.Asset{{ID: "x1", Filename: "file.jpg"}, {ID: "x2", Filename: "missing.jpg"}}}
out, _, rc = runWith([]string{"diff", "--album-id", "Album", "--out", dir}, b)
@@ -4133,6 +4140,8 @@ func TestMoreIntegrityBranches(t *testing.T) {
}
m.AddEntry(manifest.Entry{ID: "zero", Filename: "zero.jpg", Path: "zero.jpg", Size: 1, Cloud: "local", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "mismatch", Filename: "mismatch.jpg", Path: "mismatch.jpg", Size: 10, Cloud: "local", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "nosidecar", Filename: "nosidecar.jpg", Path: "nosidecar.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "zerosidecar", Filename: "zerosidecar.jpg", Path: "zerosidecar.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
m.Close()
if err := os.WriteFile(filepath.Join(dir, "zero.jpg"), nil, 0644); err != nil {
t.Fatal(err)
@@ -4140,10 +4149,30 @@ func TestMoreIntegrityBranches(t *testing.T) {
if err := os.WriteFile(filepath.Join(dir, "mismatch.jpg"), []byte("bad"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "nosidecar.jpg"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "zerosidecar.jpg"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
out, _, rc := runWith([]string{"verify", "--out", dir}, &mockBridge{})
if rc != exitPartial || !strings.Contains(out, "zero-byte") || !strings.Contains(out, "size-mismatch") {
t.Fatalf("verify rc=%d out=%q", rc, out)
}
if err := os.WriteFile(filepath.Join(dir, "mismatch.xmp"), []byte("wrong asset"), 0644); err != nil {
t.Fatal(err)
}
out, _, rc = runWith([]string{"verify", "--out", dir, "--sidecar"}, &mockBridge{})
if rc != exitPartial || !strings.Contains(out, "sidecar-missing") || !strings.Contains(out, "sidecar-asset-mismatch") {
t.Fatalf("verify sidecar failures rc=%d out=%q", rc, out)
}
if err := os.WriteFile(filepath.Join(dir, "zerosidecar.xmp"), nil, 0644); err != nil {
t.Fatal(err)
}
out, _, rc = runWith([]string{"verify", "--out", dir, "--sidecar"}, &mockBridge{})
if rc != exitPartial || !strings.Contains(out, "sidecar-zero-byte") {
t.Fatalf("verify zero sidecar rc=%d out=%q", rc, out)
}
_, stderr, rc := runWith([]string{"status"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "--out") {
t.Fatalf("status missing out rc=%d stderr=%q", rc, stderr)
@@ -4162,6 +4191,25 @@ func TestMoreIntegrityBranches(t *testing.T) {
}
}
func TestVerifySidecarBranches(t *testing.T) {
dir := t.TempDir()
subdir := filepath.Join(dir, "sub")
if err := os.Mkdir(subdir, 0755); err != nil {
t.Fatal(err)
}
xmp := filepath.Join(dir, "asset.xmp")
if err := os.WriteFile(xmp, []byte(`photoscli:assetID="x1"`), 0644); err != nil {
t.Fatal(err)
}
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") {
t.Fatalf("expected unreadable with rel fallback, got=%d out=%q", got, out.String())
}
readFileFunc = oldRead
}
func TestXMPSidecarHelpers(t *testing.T) {
if got := sidecarPath("/tmp/IMG_0001.HEIC"); got != "/tmp/IMG_0001.xmp" {
t.Fatalf("sidecar path = %q", got)
@@ -4172,6 +4220,7 @@ func TestXMPSidecarHelpers(t *testing.T) {
ExportedFilename: "IMG_0001.jpg",
Album: "A&B",
AlbumPath: "/tmp/A&B",
Keywords: []string{"A&B", "Trips"},
ManifestPath: "A&B/IMG_0001.jpg",
MediaType: "image",
MediaSubtypes: []string{"photoLive", `hdr&<>"`},
@@ -4198,13 +4247,29 @@ func TestXMPSidecarHelpers(t *testing.T) {
AdjustmentInfo: &photos.AdjustmentInfo{FormatIdentifier: "com.apple", FormatVersion: "1.0", BaseFilename: "base.heic"},
Resources: []photos.AssetResource{{Type: "photo", Filename: `res&.heic`, UTI: "public.heic", Local: true, Size: 99}},
}))
for _, want := range []string{"photoscli:assetID=\"id&amp;&lt;&gt;&#34;\"", "photoscli:isFavorite=\"true\"", "photoscli:hasAdjustments=\"true\"", "xmp:ModifyDate=\"2024-02-01T00:00:00Z\"", "photoscli:latitude=\"59.32930000\"", "photoscli:addressCountry=\"Sweden\"", "photoscli:adjustmentFormatIdentifier=\"com.apple\"", "<photoscli:resources><rdf:Seq>", "photoscli:resourceFilename=\"res&amp;.heic\"", "<photoscli:mediaSubtypes><rdf:Seq>"} {
for _, want := range []string{"photoscli:xmpSchemaVersion=\"2\"", "photoscli:assetID=\"id&amp;&lt;&gt;&#34;\"", "photoscli:isFavorite=\"true\"", "xmp:Rating=\"5\"", "photoscli:hasAdjustments=\"true\"", "xmp:ModifyDate=\"2024-02-01T00:00:00Z\"", "photoshop:DateCreated=\"2024-01-01T00:00:00Z\"", "exif:GPSLatitude=\"59.32930000\"", "photoscli:latitude=\"59.32930000\"", "photoscli:addressCountry=\"Sweden\"", "photoscli:adjustmentFormatIdentifier=\"com.apple\"", "<dc:subject><rdf:Seq>", "<rdf:li>A&amp;B</rdf:li>", "<photoscli:resources><rdf:Seq>", "photoscli:resourceFilename=\"res&amp;.heic\"", "<photoscli:mediaSubtypes><rdf:Seq>"} {
if !strings.Contains(xmp, want) {
t.Fatalf("XMP missing %q in %s", want, xmp)
}
}
}
func TestKeywordsFromAlbumPath(t *testing.T) {
got := keywordsFromAlbumPath("Album", "Trips/Album/2024")
want := []string{"Album", "Trips", "2024"}
if len(got) != len(want) {
t.Fatalf("keywords len=%d want=%d: %#v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("keywords[%d]=%q want %q in %#v", i, got[i], want[i], got)
}
}
if got := keywordsFromAlbumPath("", "."); len(got) != 0 {
t.Fatalf("expected no dot keyword, got %#v", got)
}
}
func TestWriteXMPSidecar(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "photo.xmp")