Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbc37b8d8d | |||
| 32a5819c86 | |||
| a51db37fdb | |||
| 9cd702628d |
@@ -14,4 +14,7 @@
|
|||||||
- XMP sidecars are opt-in via `--sidecar xmp`; default must remain `none`.
|
- 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.
|
- 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.
|
- 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.
|
- Do not commit generated artifacts from `bin/` or coverage files.
|
||||||
|
|||||||
@@ -2,6 +2,40 @@
|
|||||||
|
|
||||||
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.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.
|
||||||
|
|
||||||
|
- 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
|
## v0.8.0
|
||||||
|
|
||||||
Rich PhotoKit metadata and reverse-geocoded XMP release.
|
Rich PhotoKit metadata and reverse-geocoded XMP release.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
BINARY := ./bin/photoscli
|
BINARY := ./bin/photoscli
|
||||||
MODULE := gitea.k3s.k0.nu/tools/photocli
|
MODULE := gitea.k3s.k0.nu/tools/photocli
|
||||||
VERSION := 0.8.0
|
VERSION := 0.8.4
|
||||||
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
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ For a practical step-by-step manual with recommended backup workflows, recovery
|
|||||||
- Failure tracking with `failures.jsonl` and `retry-failed`.
|
- Failure tracking with `failures.jsonl` and `retry-failed`.
|
||||||
- Deduplicated failure management with `failures list`, `failures clear`, and `retry-failed --clear-on-success`.
|
- Deduplicated failure management with `failures list`, `failures clear`, and `retry-failed --clear-on-success`.
|
||||||
- Verification, reporting, and diff commands for backup integrity.
|
- 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.
|
- Status command for quick backup summaries.
|
||||||
- Opt-in rich XMP sidecar metadata with `--sidecar xmp`.
|
- Opt-in rich XMP sidecar metadata with `--sidecar xmp`.
|
||||||
- Optional Apple MapKit reverse geocoding for GPS assets on macOS 26+ with `--reverse-geocode`.
|
- Optional Apple MapKit reverse geocoding for GPS assets on macOS 26+ with `--reverse-geocode`.
|
||||||
@@ -269,6 +271,7 @@ Common flags for `export` and `backup-all`:
|
|||||||
- `--dry-run`: print planned exports without writing files, manifests, or logs.
|
- `--dry-run`: print planned exports without writing files, manifests, or logs.
|
||||||
- `--json`: print a machine-readable summary to stdout.
|
- `--json`: print a machine-readable summary to stdout.
|
||||||
- `--verify`: run manifest/file verification after export.
|
- `--verify`: run manifest/file verification after export.
|
||||||
|
- `--sidecar` with `verify`: also verify expected `.xmp` sidecars.
|
||||||
- `--log`: enable structured export logging.
|
- `--log`: enable structured export logging.
|
||||||
- `--manifest jsonl|sqlite`: choose manifest backend, default `jsonl`.
|
- `--manifest jsonl|sqlite`: choose manifest backend, default `jsonl`.
|
||||||
- `--no-manifest`: disable manifest reads/writes.
|
- `--no-manifest`: disable manifest reads/writes.
|
||||||
@@ -281,6 +284,7 @@ Common flags for `export` and `backup-all`:
|
|||||||
- `--max-size <n>`: filter by estimated pixel count.
|
- `--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.
|
- `--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.
|
- `--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+.
|
- `--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`.
|
- `--date-template <template>`: append date folders based on creation date, for example `YYYY/MM/DD`.
|
||||||
|
|
||||||
@@ -351,7 +355,32 @@ 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.
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
## Failure Tracking
|
||||||
|
|
||||||
@@ -464,3 +493,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.
|
- 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.
|
- 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.
|
- `--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.
|
||||||
|
|||||||
+6
-9
@@ -1,20 +1,17 @@
|
|||||||
# v0.8.0
|
# v0.8.4
|
||||||
|
|
||||||
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 adds strict XMP sidecar verification.
|
||||||
|
|
||||||
## Highlights
|
## 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 `verify --sidecar --strict` to require photoscli schema metadata, sidecar generator metadata, and matching exported filename metadata.
|
||||||
- Add `--reverse-geocode` to enrich GPS assets with cached address metadata from Apple MapKit on macOS 26+.
|
- Keep existing `verify --sidecar` behavior unchanged for basic sidecar checks.
|
||||||
- On older macOS versions, reverse geocoding is skipped safely; export continues and GPS coordinates remain in XMP.
|
- Use strict mode when validating sidecars generated by recent photoscli versions.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Assets
|
## Assets
|
||||||
|
|
||||||
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
|
- `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.4-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.
|
||||||
|
|||||||
+32
-1
@@ -555,7 +555,7 @@ IMG_0001.jpg -> IMG_0001.xmp
|
|||||||
IMG_0001.HEIC -> 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:
|
For address metadata from GPS coordinates, opt in to Apple's reverse geocoder:
|
||||||
|
|
||||||
@@ -565,6 +565,37 @@ 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.
|
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.
|
||||||
|
|
||||||
|
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.
|
If you explicitly request `--sidecar xmp` and the XMP file cannot be written, the asset is counted as failed.
|
||||||
|
|
||||||
## Configuration File
|
## Configuration File
|
||||||
|
|||||||
+272
-8
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -25,6 +26,8 @@ var (
|
|||||||
mkdirTempFunc = os.MkdirTemp
|
mkdirTempFunc = os.MkdirTemp
|
||||||
createTempFunc = os.CreateTemp
|
createTempFunc = os.CreateTemp
|
||||||
writeFileFunc = os.WriteFile
|
writeFileFunc = os.WriteFile
|
||||||
|
readFileFunc = os.ReadFile
|
||||||
|
statFunc = os.Stat
|
||||||
renameFunc = os.Rename
|
renameFunc = os.Rename
|
||||||
openFileFunc = os.OpenFile
|
openFileFunc = os.OpenFile
|
||||||
removeFunc = os.Remove
|
removeFunc = os.Remove
|
||||||
@@ -39,6 +42,7 @@ type exportOptions struct {
|
|||||||
verify bool
|
verify bool
|
||||||
format string
|
format string
|
||||||
sidecar string
|
sidecar string
|
||||||
|
metadataOnly bool
|
||||||
reverseGeocode bool
|
reverseGeocode bool
|
||||||
minSize int64
|
minSize int64
|
||||||
maxSize int64
|
maxSize int64
|
||||||
@@ -88,6 +92,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
|
||||||
@@ -129,6 +135,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
|
||||||
@@ -165,6 +172,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 --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.
|
||||||
@@ -172,6 +181,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.
|
||||||
|
|
||||||
@@ -217,6 +229,10 @@ 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.
|
||||||
|
|
||||||
|
--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
|
--reverse-geocode
|
||||||
With --sidecar xmp, use Apple MapKit on macOS 26+ to add address metadata
|
With --sidecar xmp, use Apple MapKit on macOS 26+ to add address metadata
|
||||||
for assets with GPS coordinates. Results are cached under .photoscli.
|
for assets with GPS coordinates. Results are cached under .photoscli.
|
||||||
@@ -441,6 +457,10 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
|
|||||||
fmt.Fprintln(stderr, "error: --out is required")
|
fmt.Fprintln(stderr, "error: --out is required")
|
||||||
return exitErr
|
return exitErr
|
||||||
}
|
}
|
||||||
|
if opts.metadataOnly && noManifest {
|
||||||
|
fmt.Fprintln(stderr, "error: --metadata-only requires a manifest")
|
||||||
|
return exitErr
|
||||||
|
}
|
||||||
|
|
||||||
mf, mfErr := manifest.ParseFormat(manifestFmt)
|
mf, mfErr := manifest.ParseFormat(manifestFmt)
|
||||||
if mfErr != nil {
|
if mfErr != nil {
|
||||||
@@ -543,8 +563,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)
|
fmt.Fprintf(stderr, "dry-run: %d assets would be exported to %s\n", total, outDir)
|
||||||
return exitOK
|
return exitOK
|
||||||
}
|
}
|
||||||
fmt.Fprintf(stderr, "exporting %d assets (%s) to %s...\n", total, exportMode(originals), outDir)
|
var exported, failed int
|
||||||
exported, failed := exportAssets(assets, outDir, size, quality, concurrency, originals, total, stderr, bridge, "", noManifest, mf, enableLog, opts)
|
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)
|
||||||
|
}
|
||||||
if opts.jsonOut {
|
if opts.jsonOut {
|
||||||
writeJSONSummary(stdout, commandSummary{Exported: exported, Failed: failed, Total: total})
|
writeJSONSummary(stdout, commandSummary{Exported: exported, Failed: failed, Total: total})
|
||||||
}
|
}
|
||||||
@@ -559,7 +588,9 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
|
|||||||
return exitErr
|
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)
|
fmt.Fprintf(stderr, "\nexported %d original files to %s", exported, outDir)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(stderr, "\nexported %d photos to %s", exported, outDir)
|
fmt.Fprintf(stderr, "\nexported %d photos to %s", exported, outDir)
|
||||||
@@ -599,6 +630,10 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge)
|
|||||||
fmt.Fprintln(stderr, "error: --out is required")
|
fmt.Fprintln(stderr, "error: --out is required")
|
||||||
return exitErr
|
return exitErr
|
||||||
}
|
}
|
||||||
|
if opts.metadataOnly && noManifest {
|
||||||
|
fmt.Fprintln(stderr, "error: --metadata-only requires a manifest")
|
||||||
|
return exitErr
|
||||||
|
}
|
||||||
|
|
||||||
mf, mfErr := manifest.ParseFormat(manifestFmt)
|
mf, mfErr := manifest.ParseFormat(manifestFmt)
|
||||||
if mfErr != nil {
|
if mfErr != nil {
|
||||||
@@ -672,13 +707,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)
|
fmt.Fprintf(stderr, "dry-run: %d assets would be exported (%d skipped)\n", len(pending), skipped)
|
||||||
return exitOK
|
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 {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||||
return exitErr
|
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)
|
fmt.Fprintf(stderr, "\nexported %d original files across %d albums to %s", totalAssets, albumCount, outDir)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(stderr, "\nexported %d preview files across %d albums to %s", totalAssets, albumCount, outDir)
|
fmt.Fprintf(stderr, "\nexported %d preview files across %d albums to %s", totalAssets, albumCount, outDir)
|
||||||
@@ -752,6 +799,7 @@ type xmpSidecarData struct {
|
|||||||
ExportedFilename string
|
ExportedFilename string
|
||||||
Album string
|
Album string
|
||||||
AlbumPath string
|
AlbumPath string
|
||||||
|
Keywords []string
|
||||||
ManifestPath string
|
ManifestPath string
|
||||||
MediaType string
|
MediaType string
|
||||||
MediaSubtypes []string
|
MediaSubtypes []string
|
||||||
@@ -843,6 +891,7 @@ func sidecarPath(exportedPath string) string {
|
|||||||
|
|
||||||
func renderXMP(d xmpSidecarData) []byte {
|
func renderXMP(d xmpSidecarData) []byte {
|
||||||
attrs := []struct{ key, val string }{
|
attrs := []struct{ key, val string }{
|
||||||
|
{"photoscli:xmpSchemaVersion", "2"},
|
||||||
{"photoscli:assetID", d.AssetID},
|
{"photoscli:assetID", d.AssetID},
|
||||||
{"photoscli:originalFilename", d.OriginalFilename},
|
{"photoscli:originalFilename", d.OriginalFilename},
|
||||||
{"photoscli:exportedFilename", d.ExportedFilename},
|
{"photoscli:exportedFilename", d.ExportedFilename},
|
||||||
@@ -866,9 +915,18 @@ func renderXMP(d xmpSidecarData) []byte {
|
|||||||
{"photoscli:exportedAt", d.ExportedAt},
|
{"photoscli:exportedAt", d.ExportedAt},
|
||||||
{"photoscli:size", fmt.Sprintf("%d", d.Size)},
|
{"photoscli:size", fmt.Sprintf("%d", d.Size)},
|
||||||
{"dc:title", d.ExportedFilename},
|
{"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 != "" {
|
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 != "" {
|
if d.ModifyDate != "" {
|
||||||
attrs = append(attrs, struct{ key, val string }{"xmp:ModifyDate", d.ModifyDate})
|
attrs = append(attrs, struct{ key, val string }{"xmp:ModifyDate", d.ModifyDate})
|
||||||
@@ -879,6 +937,9 @@ func renderXMP(d xmpSidecarData) []byte {
|
|||||||
struct{ key, val string }{"photoscli:longitude", fmt.Sprintf("%.8f", d.Location.Longitude)},
|
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:altitude", fmt.Sprintf("%.3f", d.Location.Altitude)},
|
||||||
struct{ key, val string }{"photoscli:horizontalAccuracy", fmt.Sprintf("%.3f", d.Location.HorizontalAccuracy)},
|
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 {
|
if d.Placemark != nil {
|
||||||
@@ -906,7 +967,7 @@ func renderXMP(d xmpSidecarData) []byte {
|
|||||||
sb.WriteString("<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n")
|
sb.WriteString("<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n")
|
||||||
sb.WriteString("<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\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: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 {
|
for _, a := range attrs {
|
||||||
sb.WriteString("\n ")
|
sb.WriteString("\n ")
|
||||||
sb.WriteString(a.key)
|
sb.WriteString(a.key)
|
||||||
@@ -915,6 +976,7 @@ func renderXMP(d xmpSidecarData) []byte {
|
|||||||
sb.WriteString("\"")
|
sb.WriteString("\"")
|
||||||
}
|
}
|
||||||
sb.WriteString(" >\n")
|
sb.WriteString(" >\n")
|
||||||
|
writeStringSeq(&sb, "dc:subject", d.Keywords)
|
||||||
writeStringSeq(&sb, "photoscli:mediaSubtypes", d.MediaSubtypes)
|
writeStringSeq(&sb, "photoscli:mediaSubtypes", d.MediaSubtypes)
|
||||||
writeStringSeq(&sb, "photoscli:burstSelectionTypes", d.BurstSelectionTypes)
|
writeStringSeq(&sb, "photoscli:burstSelectionTypes", d.BurstSelectionTypes)
|
||||||
writeStringSeq(&sb, "photoscli:areasOfInterest", func() []string {
|
writeStringSeq(&sb, "photoscli:areasOfInterest", func() []string {
|
||||||
@@ -931,6 +993,24 @@ func renderXMP(d xmpSidecarData) []byte {
|
|||||||
return []byte(sb.String())
|
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) {
|
func writeStringSeq(sb *strings.Builder, name string, vals []string) {
|
||||||
if len(vals) == 0 {
|
if len(vals) == 0 {
|
||||||
return
|
return
|
||||||
@@ -999,6 +1079,10 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
|
|||||||
if err != nil || strings.HasPrefix(relPath, "..") {
|
if err != nil || strings.HasPrefix(relPath, "..") {
|
||||||
relPath = result.Filename
|
relPath = result.Filename
|
||||||
}
|
}
|
||||||
|
relDir, err := filepath.Rel(root, pa.path)
|
||||||
|
if err != nil || strings.HasPrefix(relDir, "..") || relDir == "." {
|
||||||
|
relDir = ""
|
||||||
|
}
|
||||||
createDate := ""
|
createDate := ""
|
||||||
if pa.asset.CreationDate != nil {
|
if pa.asset.CreationDate != nil {
|
||||||
createDate = *pa.asset.CreationDate
|
createDate = *pa.asset.CreationDate
|
||||||
@@ -1017,6 +1101,7 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
|
|||||||
ExportedFilename: result.Filename,
|
ExportedFilename: result.Filename,
|
||||||
Album: pa.album,
|
Album: pa.album,
|
||||||
AlbumPath: pa.path,
|
AlbumPath: pa.path,
|
||||||
|
Keywords: keywordsFromAlbumPath(pa.album, relDir),
|
||||||
ManifestPath: relPath,
|
ManifestPath: relPath,
|
||||||
MediaType: pa.asset.MediaType,
|
MediaType: pa.asset.MediaType,
|
||||||
MediaSubtypes: pa.asset.MediaSubtypes,
|
MediaSubtypes: pa.asset.MediaSubtypes,
|
||||||
@@ -1045,6 +1130,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) {
|
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 items []pendingAsset
|
||||||
var skipped int
|
var skipped int
|
||||||
@@ -1082,6 +1207,30 @@ func collectPendingAssets(nodes []photos.CollectionNode, outDir string, bridge p
|
|||||||
return items, skipped
|
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) {
|
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)
|
names := make(map[string]int)
|
||||||
for _, node := range nodes {
|
for _, node := range nodes {
|
||||||
@@ -1119,7 +1268,7 @@ func collectNodes(nodes []photos.CollectionNode, outDir string, bridge photos.Br
|
|||||||
}
|
}
|
||||||
assets = applyAssetFilters(assets, opts)
|
assets = applyAssetFilters(assets, opts)
|
||||||
for _, a := range assets {
|
for _, a := range assets {
|
||||||
if m != nil && m.Has(a.ID) {
|
if m != nil && m.Has(a.ID) && !opts.metadataOnly {
|
||||||
*skipped++
|
*skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1726,6 +1875,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"),
|
||||||
|
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"),
|
||||||
}
|
}
|
||||||
@@ -1741,6 +1891,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.metadataOnly && opts.sidecar != "xmp" {
|
||||||
|
fmt.Fprintln(stderr, "error: --metadata-only requires --sidecar xmp")
|
||||||
|
return opts, false
|
||||||
|
}
|
||||||
if v := flagVal(args, "--retry"); v != "" {
|
if v := flagVal(args, "--retry"); v != "" {
|
||||||
n, err := strconv.Atoi(v)
|
n, err := strconv.Atoi(v)
|
||||||
if err != nil || n < 0 {
|
if err != nil || n < 0 {
|
||||||
@@ -1963,6 +2117,8 @@ 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")
|
||||||
|
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
|
||||||
@@ -2001,6 +2157,9 @@ func cmdVerify(args []string, stdout, stderr io.Writer) int {
|
|||||||
bad++
|
bad++
|
||||||
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 {
|
||||||
|
bad += verifySidecar(stdout, outDir, id, checkPath, strictSidecar)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if bad > 0 {
|
if bad > 0 {
|
||||||
return exitPartial
|
return exitPartial
|
||||||
@@ -2009,6 +2168,111 @@ func cmdVerify(args []string, stdout, stderr io.Writer) int {
|
|||||||
return exitOK
|
return exitOK
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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, "..") {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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 {
|
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")
|
||||||
|
|||||||
+270
-1
@@ -35,6 +35,19 @@ 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{}
|
||||||
|
|
||||||
|
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) RequestAccess() error { return m.accessErr }
|
||||||
func (m *mockBridge) ListAlbums() ([]photos.Album, error) { return m.albums, m.albumsErr }
|
func (m *mockBridge) ListAlbums() ([]photos.Album, error) { return m.albums, m.albumsErr }
|
||||||
func (m *mockBridge) ListAssets(albumID string) ([]photos.Asset, int, error) {
|
func (m *mockBridge) ListAssets(albumID string) ([]photos.Asset, int, error) {
|
||||||
@@ -3835,6 +3848,13 @@ func TestReportDiffVerifyRetryFailedAndConfig(t *testing.T) {
|
|||||||
if rc != exitOK || !strings.Contains(out, "verified") || stderr != "" {
|
if rc != exitOK || !strings.Contains(out, "verified") || stderr != "" {
|
||||||
t.Fatalf("verify rc=%d out=%q stderr=%q", rc, out, 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"}}}
|
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)
|
out, _, rc = runWith([]string{"diff", "--album-id", "Album", "--out", dir}, b)
|
||||||
@@ -4133,6 +4153,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: "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: "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()
|
m.Close()
|
||||||
if err := os.WriteFile(filepath.Join(dir, "zero.jpg"), nil, 0644); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, "zero.jpg"), nil, 0644); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -4140,10 +4162,30 @@ func TestMoreIntegrityBranches(t *testing.T) {
|
|||||||
if err := os.WriteFile(filepath.Join(dir, "mismatch.jpg"), []byte("bad"), 0644); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, "mismatch.jpg"), []byte("bad"), 0644); err != nil {
|
||||||
t.Fatal(err)
|
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{})
|
out, _, rc := runWith([]string{"verify", "--out", dir}, &mockBridge{})
|
||||||
if rc != exitPartial || !strings.Contains(out, "zero-byte") || !strings.Contains(out, "size-mismatch") {
|
if rc != exitPartial || !strings.Contains(out, "zero-byte") || !strings.Contains(out, "size-mismatch") {
|
||||||
t.Fatalf("verify rc=%d out=%q", rc, out)
|
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{})
|
_, stderr, rc := runWith([]string{"status"}, &mockBridge{})
|
||||||
if rc != exitErr || !strings.Contains(stderr, "--out") {
|
if rc != exitErr || !strings.Contains(stderr, "--out") {
|
||||||
t.Fatalf("status missing out rc=%d stderr=%q", rc, stderr)
|
t.Fatalf("status missing out rc=%d stderr=%q", rc, stderr)
|
||||||
@@ -4162,6 +4204,105 @@ 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", 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) {
|
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)
|
||||||
@@ -4172,6 +4313,7 @@ func TestXMPSidecarHelpers(t *testing.T) {
|
|||||||
ExportedFilename: "IMG_0001.jpg",
|
ExportedFilename: "IMG_0001.jpg",
|
||||||
Album: "A&B",
|
Album: "A&B",
|
||||||
AlbumPath: "/tmp/A&B",
|
AlbumPath: "/tmp/A&B",
|
||||||
|
Keywords: []string{"A&B", "Trips"},
|
||||||
ManifestPath: "A&B/IMG_0001.jpg",
|
ManifestPath: "A&B/IMG_0001.jpg",
|
||||||
MediaType: "image",
|
MediaType: "image",
|
||||||
MediaSubtypes: []string{"photoLive", `hdr&<>"`},
|
MediaSubtypes: []string{"photoLive", `hdr&<>"`},
|
||||||
@@ -4198,13 +4340,29 @@ func TestXMPSidecarHelpers(t *testing.T) {
|
|||||||
AdjustmentInfo: &photos.AdjustmentInfo{FormatIdentifier: "com.apple", FormatVersion: "1.0", BaseFilename: "base.heic"},
|
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}},
|
Resources: []photos.AssetResource{{Type: "photo", Filename: `res&.heic`, UTI: "public.heic", Local: true, Size: 99}},
|
||||||
}))
|
}))
|
||||||
for _, want := range []string{"photoscli:assetID=\"id&<>"\"", "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&.heic\"", "<photoscli:mediaSubtypes><rdf:Seq>"} {
|
for _, want := range []string{"photoscli:xmpSchemaVersion=\"2\"", "photoscli:assetID=\"id&<>"\"", "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&B</rdf:li>", "<photoscli:resources><rdf:Seq>", "photoscli:resourceFilename=\"res&.heic\"", "<photoscli:mediaSubtypes><rdf:Seq>"} {
|
||||||
if !strings.Contains(xmp, want) {
|
if !strings.Contains(xmp, want) {
|
||||||
t.Fatalf("XMP missing %q in %s", want, xmp)
|
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) {
|
func TestWriteXMPSidecar(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "photo.xmp")
|
path := filepath.Join(dir, "photo.xmp")
|
||||||
@@ -4440,6 +4598,117 @@ func TestSidecarConfigAndErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestSidecarAdditionalBranches(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
oldCreateTemp := createTempFunc
|
oldCreateTemp := createTempFunc
|
||||||
|
|||||||
Reference in New Issue
Block a user