Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7555b561bd | |||
| d909d30b87 | |||
| 5c40b1d3ba | |||
| 700d8ef05a | |||
| fbc37b8d8d | |||
| 32a5819c86 | |||
| a51db37fdb | |||
| 9cd702628d | |||
| fffb30023b |
@@ -12,4 +12,9 @@
|
|||||||
- Manifest backends: JSONL default, SQLite optional via `modernc.org/sqlite`.
|
- Manifest backends: JSONL default, SQLite optional via `modernc.org/sqlite`.
|
||||||
- Preserve manifest compatibility and migration behavior.
|
- Preserve manifest compatibility and migration behavior.
|
||||||
- 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.
|
||||||
|
- 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,83 @@
|
|||||||
|
|
||||||
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.9.0
|
||||||
|
|
||||||
|
Manifest checksum release.
|
||||||
|
|
||||||
|
- Add opt-in `--checksum sha256` to store SHA-256 file checksums in JSONL and SQLite manifests.
|
||||||
|
- Add SQLite migration support for the manifest `checksum` column.
|
||||||
|
- Keep checksum collection disabled by default with `--checksum none`.
|
||||||
|
|
||||||
|
## v0.8.7
|
||||||
|
|
||||||
|
JSON sidecar release.
|
||||||
|
|
||||||
|
- Add `--sidecar json` for structured JSON metadata sidecars.
|
||||||
|
- Add `--sidecar xmp,json` to write both XMP and JSON sidecars from the same metadata.
|
||||||
|
- Keep `--sidecar none` as the default.
|
||||||
|
|
||||||
|
## v0.8.6
|
||||||
|
|
||||||
|
XMP keyword and rating controls release.
|
||||||
|
|
||||||
|
- Add `--xmp-keywords album-path|album|none` for generated sidecars.
|
||||||
|
- Add `--xmp-rating favorite|none` for generated sidecars.
|
||||||
|
- Keep existing keyword/rating behavior as defaults with `album-path` and `favorite`.
|
||||||
|
|
||||||
|
## v0.8.5
|
||||||
|
|
||||||
|
XMP privacy controls release.
|
||||||
|
|
||||||
|
- Add `--xmp-privacy keep|strip-location|strip-address` for generated sidecars.
|
||||||
|
- Keep existing XMP location/address behavior as the default with `keep`.
|
||||||
|
- Allow GPS coordinates to be kept while reverse-geocoded address fields are omitted with `strip-address`.
|
||||||
|
- Allow both GPS coordinates and address fields to be omitted with `strip-location`.
|
||||||
|
|
||||||
|
## v0.8.4
|
||||||
|
|
||||||
|
Strict XMP sidecar verification release.
|
||||||
|
|
||||||
|
- Add `verify --sidecar --strict` to require photoscli XMP schema metadata, sidecar generator metadata, and matching exported filename metadata.
|
||||||
|
- Keep existing `verify --sidecar` behavior unchanged for backup-wide existence, readability, and asset-ID checks.
|
||||||
|
|
||||||
|
## v0.8.3
|
||||||
|
|
||||||
|
XMP sidecar inspection release.
|
||||||
|
|
||||||
|
- Add `sidecar inspect <file.xmp>` to print key photoscli metadata from generated XMP sidecars.
|
||||||
|
- Add `sidecar inspect <file.xmp> --json` for scriptable inspection output.
|
||||||
|
|
||||||
|
## v0.8.2
|
||||||
|
|
||||||
|
Metadata-only XMP refresh release.
|
||||||
|
|
||||||
|
- Add `--metadata-only` for manifest-based XMP sidecar generation without re-exporting media files.
|
||||||
|
- Support metadata-only refresh for both `export` and `backup-all` when used with `--sidecar xmp`.
|
||||||
|
- Require a manifest for metadata-only mode so existing media paths are resolved safely.
|
||||||
|
- Keep media files untouched while overwriting/regenerating generated XMP sidecars.
|
||||||
|
- Support `--reverse-geocode` during metadata-only sidecar refresh.
|
||||||
|
|
||||||
|
## v0.8.1
|
||||||
|
|
||||||
|
XMP standards and sidecar verification release.
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
- Expand asset metadata from public PhotoKit fields: modification date, hidden state, media subtypes, source type, playback style, burst data, GPS coordinates, adjustment state, adjustment info, and richer resource metadata.
|
||||||
|
- Expand XMP sidecars with duration, hidden/adjustment state, modification date, structured media subtype/burst/resource lists, GPS coordinates, and adjustment metadata.
|
||||||
|
- Add opt-in `--reverse-geocode` using Apple reverse geocoding APIs to add address metadata for GPS assets.
|
||||||
|
- Cache reverse-geocode results under `.photoscli/geocode-cache.jsonl` so repeated exports do not repeatedly query Apple geocoding.
|
||||||
|
- Keep Vision/Core ML people, animal, object, and scene analysis out of this release; that remains future work.
|
||||||
|
|
||||||
## v0.7.0
|
## v0.7.0
|
||||||
|
|
||||||
XMP sidecar metadata release.
|
XMP sidecar metadata 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.7.0
|
VERSION := 0.9.0
|
||||||
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,7 +20,7 @@ $(LIB): $(OBJ)
|
|||||||
ar rcs $@ $<
|
ar rcs $@ $<
|
||||||
|
|
||||||
$(OBJ): $(BRIDGE_DIR)/photokit_bridge.m $(BRIDGE_DIR)/photokit_bridge.h
|
$(OBJ): $(BRIDGE_DIR)/photokit_bridge.m $(BRIDGE_DIR)/photokit_bridge.h
|
||||||
cc -c -x objective-c -fobjc-arc -framework Photos -framework Foundation -o $@ $<
|
cc -c -x objective-c -fobjc-arc -o $@ $<
|
||||||
|
|
||||||
$(STUB_LIB): $(STUB_OBJ)
|
$(STUB_LIB): $(STUB_OBJ)
|
||||||
ar rcs $@ $<
|
ar rcs $@ $<
|
||||||
|
|||||||
@@ -20,8 +20,12 @@ 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`.
|
||||||
|
- Optional SHA-256 manifest checksums with `--checksum sha256`.
|
||||||
|
- 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 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`.
|
||||||
- Script-friendly exit codes and optional JSON summaries.
|
- Script-friendly exit codes and optional JSON summaries.
|
||||||
- 100% test coverage for the Go CLI and parsing layers.
|
- 100% test coverage for the Go CLI and parsing layers.
|
||||||
|
|
||||||
@@ -166,6 +170,7 @@ photoscli export --album-id "Favorites" --out ./favorites --only-favorites
|
|||||||
photoscli export --album-id "Videos" --out ./videos --media videos --include-videos
|
photoscli export --album-id "Videos" --out ./videos --media videos --include-videos
|
||||||
photoscli export --album-id "Archive" --out ./archive --originals --retry 3
|
photoscli export --album-id "Archive" --out ./archive --originals --retry 3
|
||||||
photoscli export --album-id "Vacation" --out ./vacation --date-template YYYY/MM/DD
|
photoscli export --album-id "Vacation" --out ./vacation --date-template YYYY/MM/DD
|
||||||
|
photoscli export --album-id "Vacation" --out ./vacation --sidecar xmp --reverse-geocode
|
||||||
```
|
```
|
||||||
|
|
||||||
### `backup-all`
|
### `backup-all`
|
||||||
@@ -182,10 +187,12 @@ Useful examples:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
photoscli backup-all --out ./backup --manifest sqlite --log
|
photoscli backup-all --out ./backup --manifest sqlite --log
|
||||||
|
photoscli backup-all --out ./backup --checksum sha256
|
||||||
photoscli backup-all --out ./backup --exclude-album "Recently Deleted" --exclude-album "Temp*"
|
photoscli backup-all --out ./backup --exclude-album "Recently Deleted" --exclude-album "Temp*"
|
||||||
photoscli backup-all --out ./backup --since 2024-01-01 --sort newest
|
photoscli backup-all --out ./backup --since 2024-01-01 --sort newest
|
||||||
photoscli backup-all --out ./backup --concurrency 8 --retry 3
|
photoscli backup-all --out ./backup --concurrency 8 --retry 3
|
||||||
photoscli backup-all --out ./backup --dry-run --json
|
photoscli backup-all --out ./backup --dry-run --json
|
||||||
|
photoscli backup-all --out ./backup --sidecar xmp --reverse-geocode
|
||||||
```
|
```
|
||||||
|
|
||||||
### `report`
|
### `report`
|
||||||
@@ -266,6 +273,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.
|
||||||
@@ -278,6 +286,8 @@ 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+.
|
||||||
- `--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`.
|
||||||
|
|
||||||
`backup-all` also supports:
|
`backup-all` also supports:
|
||||||
@@ -336,6 +346,8 @@ Write archival metadata sidecars with:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp
|
photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp
|
||||||
|
photoscli export --album-id "Vacation" --out ./Vacation --sidecar json
|
||||||
|
photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp,json
|
||||||
```
|
```
|
||||||
|
|
||||||
Sidecars are opt-in and use the exported file basename:
|
Sidecars are opt-in and use the exported file basename:
|
||||||
@@ -343,10 +355,42 @@ Sidecars are opt-in and use the exported file basename:
|
|||||||
```text
|
```text
|
||||||
IMG_0001.jpg -> IMG_0001.xmp
|
IMG_0001.jpg -> IMG_0001.xmp
|
||||||
IMG_0001.HEIC -> IMG_0001.xmp
|
IMG_0001.HEIC -> IMG_0001.xmp
|
||||||
|
IMG_0001.jpg -> IMG_0001.json
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
Sidecars also include richer public PhotoKit metadata where available: modification date, duration, hidden state, adjustment state, media subtypes, source type, playback style, burst data, GPS coordinates, adjustment info, structured asset resources, standard XMP dates, EXIF GPS coordinates, favorite rating, and album/folder keywords. Add `--reverse-geocode` to include cached address fields from Apple MapKit for assets with GPS coordinates. Reverse geocoding requires macOS 26 or newer; on older macOS versions the export continues and XMP still includes GPS coordinates.
|
||||||
|
|
||||||
|
Control XMP location metadata with `--xmp-privacy keep|strip-location|strip-address`. The default is `keep`. Use `strip-address` to omit reverse-geocoded address fields while keeping GPS coordinates, or `strip-location` to omit both GPS and address fields.
|
||||||
|
|
||||||
|
Control generated XMP keywords and ratings with `--xmp-keywords album-path|album|none` and `--xmp-rating favorite|none`. Defaults preserve existing behavior: album/folder keywords and favorite assets mapped to `xmp:Rating="5"`.
|
||||||
|
|
||||||
|
Verify generated sidecars with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
photoscli verify --out ./backup --sidecar
|
||||||
|
```
|
||||||
|
|
||||||
|
For stricter checks against recent photoscli-generated XMP sidecars:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
photoscli verify --out ./backup --sidecar --strict
|
||||||
|
```
|
||||||
|
|
||||||
|
Inspect one generated sidecar with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
photoscli sidecar inspect ./backup/IMG_0001.xmp
|
||||||
|
photoscli sidecar inspect ./backup/IMG_0001.xmp --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Refresh sidecars for files already present in a manifest-backed export without rewriting media files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
photoscli backup-all --out ./backup --sidecar xmp --metadata-only
|
||||||
|
```
|
||||||
|
|
||||||
## Failure Tracking
|
## Failure Tracking
|
||||||
|
|
||||||
Failed exports are deduplicated by asset ID and stored in:
|
Failed exports are deduplicated by asset ID and stored in:
|
||||||
@@ -458,3 +502,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.
|
||||||
|
|||||||
+7
-9
@@ -1,20 +1,18 @@
|
|||||||
# v0.7.0
|
# v0.9.0
|
||||||
|
|
||||||
This release adds opt-in XMP sidecar metadata for archival exports.
|
This release starts the backup-integrity series with manifest checksums.
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
- Add `--sidecar none|xmp` with default `none`.
|
- Add opt-in `--checksum sha256` to store SHA-256 checksums in manifests.
|
||||||
- Write XMP sidecars next to exported files when `--sidecar xmp` is selected.
|
- Support checksums in both JSONL and SQLite manifests.
|
||||||
- XMP files use the exported file basename, for example `IMG_0001.jpg` -> `IMG_0001.xmp`.
|
- Add SQLite migration support for the new checksum column.
|
||||||
- Sidecars include 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.
|
- Keep checksum collection disabled by default with `--checksum none`.
|
||||||
- XMP writes are atomic and fail the asset when explicitly requested sidecar output cannot be written.
|
|
||||||
- Config files can set `sidecar = "xmp"`.
|
|
||||||
|
|
||||||
## Assets
|
## Assets
|
||||||
|
|
||||||
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
|
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
|
||||||
- `photoscli-0.7.0-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG.
|
- `photoscli-0.9.0-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.
|
||||||
|
|||||||
+74
-1
@@ -21,8 +21,10 @@ It is especially useful when you want to:
|
|||||||
- Retry transient iCloud failures.
|
- Retry transient iCloud failures.
|
||||||
- Verify that files referenced by a manifest exist on disk.
|
- Verify that files referenced by a manifest exist on disk.
|
||||||
- Detect missing, zero-byte, and size-mismatched manifest files.
|
- Detect missing, zero-byte, and size-mismatched manifest files.
|
||||||
|
- Store optional SHA-256 checksums in manifests.
|
||||||
- Inspect or clear deduplicated failure records.
|
- Inspect or clear deduplicated failure records.
|
||||||
- Write optional XMP sidecar metadata for archival workflows.
|
- Write optional XMP sidecar metadata for archival workflows.
|
||||||
|
- Add optional reverse-geocoded address metadata to XMP sidecars for GPS assets.
|
||||||
- Script Photos exports with stable exit codes.
|
- Script Photos exports with stable exit codes.
|
||||||
|
|
||||||
It is not intended to replace Apple Photos, iCloud Photos, or Time Machine. Think of it as an additional file-based export and backup tool.
|
It is not intended to replace Apple Photos, iCloud Photos, or Time Machine. Think of it as an additional file-based export and backup tool.
|
||||||
@@ -545,6 +547,8 @@ Use XMP sidecars when you want portable metadata next to exported files:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
./bin/photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp
|
./bin/photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp
|
||||||
|
./bin/photoscli export --album-id "Vacation" --out ./Vacation --sidecar json
|
||||||
|
./bin/photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp,json
|
||||||
```
|
```
|
||||||
|
|
||||||
Sidecars are disabled by default. When enabled, they use the exported file basename:
|
Sidecars are disabled by default. When enabled, they use the exported file basename:
|
||||||
@@ -552,9 +556,69 @@ Sidecars are disabled by default. When enabled, they use the exported file basen
|
|||||||
```text
|
```text
|
||||||
IMG_0001.jpg -> IMG_0001.xmp
|
IMG_0001.jpg -> IMG_0001.xmp
|
||||||
IMG_0001.HEIC -> IMG_0001.xmp
|
IMG_0001.HEIC -> IMG_0001.xmp
|
||||||
|
IMG_0001.jpg -> IMG_0001.json
|
||||||
```
|
```
|
||||||
|
|
||||||
The XMP includes photoscli archive metadata such as asset ID, original filename, exported filename, album, manifest path, media type, dimensions, favorite state, cloud state, export mode, version, exported time, size, and creation date when available.
|
Use XMP for standards-oriented metadata workflows and JSON when you want structured photoscli metadata for scripts or audits.
|
||||||
|
|
||||||
|
The XMP includes photoscli archive metadata such as asset ID, original filename, exported filename, album, manifest path, media type, dimensions, favorite state, hidden state, cloud state, export mode, version, exported time, size, creation date, modification date, duration, adjustment state, media subtypes, source type, playback style, burst data, GPS coordinates, adjustment info, structured asset resources, standard XMP date fields, EXIF GPS fields, favorite rating, and album/folder keywords when PhotoKit exposes them.
|
||||||
|
|
||||||
|
Control location privacy in generated sidecars:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp --xmp-privacy strip-address
|
||||||
|
./bin/photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp --xmp-privacy strip-location
|
||||||
|
```
|
||||||
|
|
||||||
|
`keep` is the default. `strip-address` omits reverse-geocoded address fields while preserving GPS coordinates. `strip-location` omits both GPS coordinates and address fields.
|
||||||
|
|
||||||
|
Control generated keywords and ratings:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp --xmp-keywords album
|
||||||
|
./bin/photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp --xmp-keywords none --xmp-rating none
|
||||||
|
```
|
||||||
|
|
||||||
|
`--xmp-keywords album-path` is the default and writes album/folder keywords. `album` writes only the album name. `none` omits generated `dc:subject` keywords. `--xmp-rating favorite` is the default and maps favorite assets to `xmp:Rating="5"`; `none` omits that generated rating.
|
||||||
|
|
||||||
|
For address metadata from GPS coordinates, opt in to Apple's reverse geocoder:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/photoscli export --album-id "Vacation" --out ./Vacation --sidecar xmp --reverse-geocode
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
@@ -578,6 +642,7 @@ media = "photos"
|
|||||||
retry = 3
|
retry = 3
|
||||||
log = true
|
log = true
|
||||||
sidecar = "xmp"
|
sidecar = "xmp"
|
||||||
|
reverse-geocode = true
|
||||||
```
|
```
|
||||||
|
|
||||||
Use a custom config path:
|
Use a custom config path:
|
||||||
@@ -757,6 +822,14 @@ Use structured logs:
|
|||||||
./bin/photoscli backup-all --out ./PhotosBackup --manifest sqlite --log
|
./bin/photoscli backup-all --out ./PhotosBackup --manifest sqlite --log
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Store SHA-256 checksums in the manifest when exporting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/photoscli backup-all --out ./PhotosBackup --checksum sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
Checksums are opt-in and stored in JSONL or SQLite manifests. The default is `--checksum none`.
|
||||||
|
|
||||||
## Safe Operating Practices
|
## Safe Operating Practices
|
||||||
|
|
||||||
- Run `--dry-run` before the first large backup.
|
- Run `--dry-run` before the first large backup.
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ char *photos_list_albums_json(void);
|
|||||||
|
|
||||||
char *photos_list_assets_json(const char *album_id);
|
char *photos_list_assets_json(const char *album_id);
|
||||||
|
|
||||||
|
char *photos_reverse_geocode_json(double latitude, double longitude);
|
||||||
|
|
||||||
char *photos_export_preview_json(
|
char *photos_export_preview_json(
|
||||||
const char *asset_id,
|
const char *asset_id,
|
||||||
const char *output_dir,
|
const char *output_dir,
|
||||||
|
|||||||
+139
-2
@@ -1,6 +1,8 @@
|
|||||||
#import <Foundation/Foundation.h>
|
#import <Foundation/Foundation.h>
|
||||||
#import <AppKit/AppKit.h>
|
#import <AppKit/AppKit.h>
|
||||||
#import <Photos/Photos.h>
|
#import <Photos/Photos.h>
|
||||||
|
#import <CoreLocation/CoreLocation.h>
|
||||||
|
#import <MapKit/MapKit.h>
|
||||||
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
|
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
|
||||||
#import <objc/message.h>
|
#import <objc/message.h>
|
||||||
#import "photokit_bridge.h"
|
#import "photokit_bridge.h"
|
||||||
@@ -311,6 +313,87 @@ static NSString *media_type_string(PHAssetMediaType type) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static NSString *source_type_string(PHAssetSourceType type) {
|
||||||
|
NSMutableArray *parts = [NSMutableArray array];
|
||||||
|
if (type & PHAssetSourceTypeUserLibrary) [parts addObject:@"userLibrary"];
|
||||||
|
if (type & PHAssetSourceTypeCloudShared) [parts addObject:@"cloudShared"];
|
||||||
|
if (type & PHAssetSourceTypeiTunesSynced) [parts addObject:@"iTunesSynced"];
|
||||||
|
return parts.count > 0 ? [parts componentsJoinedByString:@","] : @"unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSString *playback_style_string(PHAssetPlaybackStyle style) {
|
||||||
|
switch (style) {
|
||||||
|
case PHAssetPlaybackStyleImage: return @"image";
|
||||||
|
case PHAssetPlaybackStyleImageAnimated: return @"imageAnimated";
|
||||||
|
case PHAssetPlaybackStyleLivePhoto: return @"livePhoto";
|
||||||
|
case PHAssetPlaybackStyleVideo: return @"video";
|
||||||
|
case PHAssetPlaybackStyleVideoLooping: return @"videoLooping";
|
||||||
|
default: return @"unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSArray<NSString *> *media_subtype_strings(PHAssetMediaSubtype subtypes) {
|
||||||
|
NSMutableArray *parts = [NSMutableArray array];
|
||||||
|
if (subtypes & PHAssetMediaSubtypePhotoPanorama) [parts addObject:@"photoPanorama"];
|
||||||
|
if (subtypes & PHAssetMediaSubtypePhotoHDR) [parts addObject:@"photoHDR"];
|
||||||
|
if (subtypes & PHAssetMediaSubtypePhotoScreenshot) [parts addObject:@"photoScreenshot"];
|
||||||
|
if (subtypes & PHAssetMediaSubtypePhotoLive) [parts addObject:@"photoLive"];
|
||||||
|
if (subtypes & PHAssetMediaSubtypePhotoDepthEffect) [parts addObject:@"photoDepthEffect"];
|
||||||
|
if (subtypes & PHAssetMediaSubtypeVideoStreamed) [parts addObject:@"videoStreamed"];
|
||||||
|
if (subtypes & PHAssetMediaSubtypeVideoHighFrameRate) [parts addObject:@"videoHighFrameRate"];
|
||||||
|
if (subtypes & PHAssetMediaSubtypeVideoTimelapse) [parts addObject:@"videoTimelapse"];
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSArray<NSString *> *burst_selection_type_strings(PHAssetBurstSelectionType types) {
|
||||||
|
NSMutableArray *parts = [NSMutableArray array];
|
||||||
|
if (types & PHAssetBurstSelectionTypeAutoPick) [parts addObject:@"autoPick"];
|
||||||
|
if (types & PHAssetBurstSelectionTypeUserPick) [parts addObject:@"userPick"];
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSNumber *resource_file_size(PHAssetResource *res) {
|
||||||
|
@try {
|
||||||
|
id value = [res valueForKey:@"fileSize"];
|
||||||
|
if ([value respondsToSelector:@selector(longLongValue)]) {
|
||||||
|
return @([value longLongValue]);
|
||||||
|
}
|
||||||
|
} @catch (NSException *e) {}
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSDictionary *location_dict(CLLocation *location) {
|
||||||
|
if (!location) return nil;
|
||||||
|
NSMutableDictionary *dict = [NSMutableDictionary dictionary];
|
||||||
|
dict[@"latitude"] = @(location.coordinate.latitude);
|
||||||
|
dict[@"longitude"] = @(location.coordinate.longitude);
|
||||||
|
dict[@"altitude"] = @(location.altitude);
|
||||||
|
dict[@"horizontalAccuracy"] = @(location.horizontalAccuracy);
|
||||||
|
return dict;
|
||||||
|
}
|
||||||
|
|
||||||
|
static NSDictionary *editing_input_info(PHAsset *asset) {
|
||||||
|
PHContentEditingInputRequestOptions *opts = [[PHContentEditingInputRequestOptions alloc] init];
|
||||||
|
opts.networkAccessAllowed = NO;
|
||||||
|
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
|
||||||
|
__block NSMutableDictionary *dict = [NSMutableDictionary dictionary];
|
||||||
|
[asset requestContentEditingInputWithOptions:opts completionHandler:^(PHContentEditingInput *input, NSDictionary *info) {
|
||||||
|
if (input.adjustmentData) {
|
||||||
|
dict[@"formatIdentifier"] = input.adjustmentData.formatIdentifier ?: @"";
|
||||||
|
dict[@"formatVersion"] = input.adjustmentData.formatVersion ?: @"";
|
||||||
|
}
|
||||||
|
NSURL *url = input.fullSizeImageURL;
|
||||||
|
if (url.lastPathComponent.length > 0) {
|
||||||
|
dict[@"baseFilename"] = url.lastPathComponent;
|
||||||
|
}
|
||||||
|
dispatch_semaphore_signal(sem);
|
||||||
|
}];
|
||||||
|
if (!semaphore_wait_with_timeout(sem, 5)) {
|
||||||
|
return nil;
|
||||||
|
}
|
||||||
|
return dict.count > 0 ? dict : nil;
|
||||||
|
}
|
||||||
|
|
||||||
static NSString *iso8601_string(NSDate *date) {
|
static NSString *iso8601_string(NSDate *date) {
|
||||||
if (!date) return nil;
|
if (!date) return nil;
|
||||||
static NSDateFormatter *fmt = nil;
|
static NSDateFormatter *fmt = nil;
|
||||||
@@ -355,18 +438,22 @@ char *photos_list_assets_json(const char *album_id) {
|
|||||||
NSString *mediaTypeStr = media_type_string(asset.mediaType);
|
NSString *mediaTypeStr = media_type_string(asset.mediaType);
|
||||||
|
|
||||||
NSString *creationDateStr = iso8601_string(asset.creationDate);
|
NSString *creationDateStr = iso8601_string(asset.creationDate);
|
||||||
|
NSString *modificationDateStr = iso8601_string(asset.modificationDate);
|
||||||
|
|
||||||
NSMutableArray *resourcesList = [NSMutableArray arrayWithCapacity:resources.count];
|
NSMutableArray *resourcesList = [NSMutableArray arrayWithCapacity:resources.count];
|
||||||
for (PHAssetResource *res in resources) {
|
for (PHAssetResource *res in resources) {
|
||||||
NSString *resTypeStr = resource_type_string(res.type);
|
NSString *resTypeStr = resource_type_string(res.type);
|
||||||
NSString *uti = res.uniformTypeIdentifier ?: @"";
|
NSString *uti = res.uniformTypeIdentifier ?: @"";
|
||||||
BOOL isLocal = resource_is_locally_available(res);
|
BOOL isLocal = resource_is_locally_available(res);
|
||||||
[resourcesList addObject:@{
|
NSMutableDictionary *resourceDict = [NSMutableDictionary dictionaryWithDictionary:@{
|
||||||
@"type": resTypeStr,
|
@"type": resTypeStr,
|
||||||
@"filename": res.originalFilename ?: @"",
|
@"filename": res.originalFilename ?: @"",
|
||||||
@"uti": uti,
|
@"uti": uti,
|
||||||
@"local": @(isLocal)
|
@"local": @(isLocal)
|
||||||
}];
|
}];
|
||||||
|
NSNumber *size = resource_file_size(res);
|
||||||
|
if (size) resourceDict[@"size"] = size;
|
||||||
|
[resourcesList addObject:resourceDict];
|
||||||
}
|
}
|
||||||
|
|
||||||
NSMutableDictionary *dict = [NSMutableDictionary dictionaryWithDictionary:@{
|
NSMutableDictionary *dict = [NSMutableDictionary dictionaryWithDictionary:@{
|
||||||
@@ -374,17 +461,37 @@ char *photos_list_assets_json(const char *album_id) {
|
|||||||
@"filename": filename ?: @"",
|
@"filename": filename ?: @"",
|
||||||
@"cloud": cloudStatus,
|
@"cloud": cloudStatus,
|
||||||
@"mediaType": mediaTypeStr,
|
@"mediaType": mediaTypeStr,
|
||||||
|
@"mediaSubtypes": media_subtype_strings(asset.mediaSubtypes),
|
||||||
|
@"sourceType": source_type_string(asset.sourceType),
|
||||||
|
@"playbackStyle": playback_style_string(asset.playbackStyle),
|
||||||
@"pixelWidth": @(asset.pixelWidth),
|
@"pixelWidth": @(asset.pixelWidth),
|
||||||
@"pixelHeight": @(asset.pixelHeight),
|
@"pixelHeight": @(asset.pixelHeight),
|
||||||
@"duration": @(asset.duration),
|
@"duration": @(asset.duration),
|
||||||
@"isFavorite": @(asset.isFavorite)
|
@"isFavorite": @(asset.isFavorite),
|
||||||
|
@"isHidden": @(asset.isHidden),
|
||||||
|
@"representsBurst": @(asset.representsBurst),
|
||||||
|
@"burstSelectionTypes": burst_selection_type_strings(asset.burstSelectionTypes)
|
||||||
}];
|
}];
|
||||||
if (creationDateStr) {
|
if (creationDateStr) {
|
||||||
dict[@"creationDate"] = creationDateStr;
|
dict[@"creationDate"] = creationDateStr;
|
||||||
}
|
}
|
||||||
|
if (modificationDateStr) {
|
||||||
|
dict[@"modificationDate"] = modificationDateStr;
|
||||||
|
}
|
||||||
|
NSDictionary *loc = location_dict(asset.location);
|
||||||
|
if (loc) {
|
||||||
|
dict[@"location"] = loc;
|
||||||
|
}
|
||||||
|
if (asset.burstIdentifier.length > 0) {
|
||||||
|
dict[@"burstIdentifier"] = asset.burstIdentifier;
|
||||||
|
}
|
||||||
if (@available(macOS 12, *)) {
|
if (@available(macOS 12, *)) {
|
||||||
dict[@"hasAdjustments"] = @(asset.hasAdjustments);
|
dict[@"hasAdjustments"] = @(asset.hasAdjustments);
|
||||||
}
|
}
|
||||||
|
NSDictionary *adjustmentInfo = editing_input_info(asset);
|
||||||
|
if (adjustmentInfo) {
|
||||||
|
dict[@"adjustmentInfo"] = adjustmentInfo;
|
||||||
|
}
|
||||||
if (resourcesList.count > 0) {
|
if (resourcesList.count > 0) {
|
||||||
dict[@"resources"] = resourcesList;
|
dict[@"resources"] = resourcesList;
|
||||||
}
|
}
|
||||||
@@ -394,6 +501,36 @@ char *photos_list_assets_json(const char *album_id) {
|
|||||||
return json_from_object(@{@"assets": list, @"total": @(assets.count)});
|
return json_from_object(@{@"assets": list, @"total": @(assets.count)});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
char *photos_reverse_geocode_json(double latitude, double longitude) {
|
||||||
|
if (@available(macOS 26.0, *)) {
|
||||||
|
CLLocation *location = [[CLLocation alloc] initWithLatitude:latitude longitude:longitude];
|
||||||
|
MKReverseGeocodingRequest *request = [[MKReverseGeocodingRequest alloc] initWithLocation:location];
|
||||||
|
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
|
||||||
|
__block MKMapItem *item = nil;
|
||||||
|
__block NSError *mapErr = nil;
|
||||||
|
[request getMapItemsWithCompletionHandler:^(NSArray<MKMapItem *> *mapItems, NSError *error) {
|
||||||
|
mapErr = error;
|
||||||
|
item = mapItems.firstObject;
|
||||||
|
dispatch_semaphore_signal(sem);
|
||||||
|
}];
|
||||||
|
if (!semaphore_wait_with_timeout(sem, 10)) {
|
||||||
|
[request cancel];
|
||||||
|
return json_from_object(@{@"error": @"timeout waiting for reverse geocode"});
|
||||||
|
}
|
||||||
|
if (mapErr || !item) {
|
||||||
|
NSString *msg = mapErr.localizedDescription ?: @"reverse geocode failed";
|
||||||
|
return json_from_object(@{@"error": msg});
|
||||||
|
}
|
||||||
|
NSMutableDictionary *dict = [NSMutableDictionary dictionary];
|
||||||
|
if (item.name) dict[@"name"] = item.name;
|
||||||
|
if (item.address.fullAddress) dict[@"formattedAddress"] = item.address.fullAddress;
|
||||||
|
if (item.address.shortAddress) dict[@"thoroughfare"] = item.address.shortAddress;
|
||||||
|
return json_from_object(@{@"placemark": dict});
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_from_object(@{@"error": @"reverse geocoding requires macOS 26 or newer"});
|
||||||
|
}
|
||||||
|
|
||||||
char *photos_list_tree_json(void) {
|
char *photos_list_tree_json(void) {
|
||||||
PHFetchResult<PHCollection *> *collections = [PHCollectionList fetchTopLevelUserCollectionsWithOptions:nil];
|
PHFetchResult<PHCollection *> *collections = [PHCollectionList fetchTopLevelUserCollectionsWithOptions:nil];
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ static int stub_access_rc = 0;
|
|||||||
static const char *stub_albums_json = "{\"albums\":[]}";
|
static const char *stub_albums_json = "{\"albums\":[]}";
|
||||||
static const char *stub_assets_json = "{\"assets\":[]}";
|
static const char *stub_assets_json = "{\"assets\":[]}";
|
||||||
static const char *stub_tree_json = "{\"collections\":[]}";
|
static const char *stub_tree_json = "{\"collections\":[]}";
|
||||||
|
static const char *stub_geocode_json = "{\"placemark\":{}}";
|
||||||
static int stub_albums_null = 0;
|
static int stub_albums_null = 0;
|
||||||
static int stub_assets_null = 0;
|
static int stub_assets_null = 0;
|
||||||
static int stub_tree_null = 0;
|
static int stub_tree_null = 0;
|
||||||
|
static int stub_geocode_null = 0;
|
||||||
static int stub_cancelled = 0;
|
static int stub_cancelled = 0;
|
||||||
static const char *stub_export_preview_json = NULL;
|
static const char *stub_export_preview_json = NULL;
|
||||||
static const char *stub_export_original_json = NULL;
|
static const char *stub_export_original_json = NULL;
|
||||||
@@ -34,6 +36,8 @@ void photos_test_set_access(int rc) { stub_access_rc = rc; }
|
|||||||
void photos_test_set_albums(const char *json) { stub_albums_json = json; stub_albums_null = 0; }
|
void photos_test_set_albums(const char *json) { stub_albums_json = json; stub_albums_null = 0; }
|
||||||
void photos_test_set_assets(const char *json) { stub_assets_json = json; stub_assets_null = 0; }
|
void photos_test_set_assets(const char *json) { stub_assets_json = json; stub_assets_null = 0; }
|
||||||
void photos_test_set_tree(const char *json) { stub_tree_json = json; stub_tree_null = 0; }
|
void photos_test_set_tree(const char *json) { stub_tree_json = json; stub_tree_null = 0; }
|
||||||
|
void photos_test_set_geocode(const char *json) { stub_geocode_json = json; stub_geocode_null = 0; }
|
||||||
|
void photos_test_set_geocode_null(void) { stub_geocode_null = 1; }
|
||||||
void photos_test_set_albums_null(void) { stub_albums_null = 1; }
|
void photos_test_set_albums_null(void) { stub_albums_null = 1; }
|
||||||
void photos_test_set_assets_null(void) { stub_assets_null = 1; }
|
void photos_test_set_assets_null(void) { stub_assets_null = 1; }
|
||||||
void photos_test_set_tree_null(void) { stub_tree_null = 1; }
|
void photos_test_set_tree_null(void) { stub_tree_null = 1; }
|
||||||
@@ -51,6 +55,13 @@ char *photos_list_assets_json(const char *album_id) {
|
|||||||
return alloc_json(stub_assets_json);
|
return alloc_json(stub_assets_json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
char *photos_reverse_geocode_json(double latitude, double longitude) {
|
||||||
|
(void)latitude;
|
||||||
|
(void)longitude;
|
||||||
|
if (stub_geocode_null) return NULL;
|
||||||
|
return alloc_json(stub_geocode_json);
|
||||||
|
}
|
||||||
|
|
||||||
char *photos_export_preview_json(const char *asset_id, const char *output_dir, int target_size, int quality, int index, int slot_index) {
|
char *photos_export_preview_json(const char *asset_id, const char *output_dir, int target_size, int quality, int index, int slot_index) {
|
||||||
(void)asset_id;
|
(void)asset_id;
|
||||||
(void)output_dir;
|
(void)output_dir;
|
||||||
|
|||||||
+651
-31
@@ -1,6 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -25,6 +28,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 +44,12 @@ type exportOptions struct {
|
|||||||
verify bool
|
verify bool
|
||||||
format string
|
format string
|
||||||
sidecar string
|
sidecar string
|
||||||
|
checksum string
|
||||||
|
xmpPrivacy string
|
||||||
|
xmpKeywords string
|
||||||
|
xmpRating string
|
||||||
|
metadataOnly bool
|
||||||
|
reverseGeocode bool
|
||||||
minSize int64
|
minSize int64
|
||||||
maxSize int64
|
maxSize int64
|
||||||
dateTemplate string
|
dateTemplate string
|
||||||
@@ -87,6 +98,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
|
||||||
@@ -128,6 +141,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
|
||||||
@@ -164,6 +178,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.
|
||||||
@@ -171,6 +187,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.
|
||||||
|
|
||||||
@@ -212,9 +231,29 @@ COMMON EXPORT FLAGS
|
|||||||
--verify
|
--verify
|
||||||
Run manifest/file verification after export or backup-all.
|
Run manifest/file verification after export or backup-all.
|
||||||
|
|
||||||
--sidecar none|xmp
|
--checksum none|sha256
|
||||||
Write opt-in XMP sidecar metadata next to each exported file. Default:
|
Store optional file checksum metadata in the manifest. Default: none.
|
||||||
none. If XMP writing fails, the asset is counted as failed.
|
|
||||||
|
--sidecar none|xmp|json|xmp,json
|
||||||
|
Write opt-in metadata sidecars next to each exported file. Default: none.
|
||||||
|
If sidecar writing fails, the asset is counted as failed.
|
||||||
|
|
||||||
|
--xmp-privacy keep|strip-location|strip-address
|
||||||
|
Control location/address metadata in generated XMP sidecars. Default: keep.
|
||||||
|
|
||||||
|
--xmp-keywords album-path|album|none
|
||||||
|
Control dc:subject keywords in generated XMP sidecars. Default: album-path.
|
||||||
|
|
||||||
|
--xmp-rating favorite|none
|
||||||
|
Control favorite-to-rating mapping in generated XMP sidecars. Default: favorite.
|
||||||
|
|
||||||
|
--metadata-only
|
||||||
|
With --sidecar xmp, write or refresh XMP sidecars for files already in
|
||||||
|
the manifest without exporting media files. Requires a manifest.
|
||||||
|
|
||||||
|
--reverse-geocode
|
||||||
|
With --sidecar xmp, use Apple MapKit on macOS 26+ to add address metadata
|
||||||
|
for assets with GPS coordinates. Results are cached under .photoscli.
|
||||||
|
|
||||||
FILTERING AND SELECTION
|
FILTERING AND SELECTION
|
||||||
--since <date>
|
--since <date>
|
||||||
@@ -284,6 +323,8 @@ CONFIGURATION
|
|||||||
sort = "newest"
|
sort = "newest"
|
||||||
retry = 3
|
retry = 3
|
||||||
log = true
|
log = true
|
||||||
|
sidecar = "xmp"
|
||||||
|
reverse-geocode = true
|
||||||
|
|
||||||
Command-line flags override config values.
|
Command-line flags override config values.
|
||||||
|
|
||||||
@@ -434,6 +475,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 {
|
||||||
@@ -536,8 +581,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
|
||||||
}
|
}
|
||||||
|
var exported, failed int
|
||||||
|
if opts.metadataOnly {
|
||||||
|
m, _ := manifest.Open(outDir, mf)
|
||||||
|
defer m.Close()
|
||||||
|
entries := manifestEntries(m)
|
||||||
|
fmt.Fprintf(stderr, "writing metadata for %d assets to %s...\n", total, outDir)
|
||||||
|
exported, failed = metadataOnlyAssets(assets, outDir, originals, "", entries, opts, bridge)
|
||||||
|
} else {
|
||||||
fmt.Fprintf(stderr, "exporting %d assets (%s) to %s...\n", total, exportMode(originals), outDir)
|
fmt.Fprintf(stderr, "exporting %d assets (%s) to %s...\n", total, exportMode(originals), outDir)
|
||||||
exported, failed := exportAssets(assets, outDir, size, quality, concurrency, originals, total, stderr, bridge, "", noManifest, mf, enableLog, opts)
|
exported, failed = exportAssets(assets, outDir, size, quality, concurrency, originals, total, stderr, bridge, "", noManifest, mf, enableLog, opts)
|
||||||
|
}
|
||||||
if opts.jsonOut {
|
if opts.jsonOut {
|
||||||
writeJSONSummary(stdout, commandSummary{Exported: exported, Failed: failed, Total: total})
|
writeJSONSummary(stdout, commandSummary{Exported: exported, Failed: failed, Total: total})
|
||||||
}
|
}
|
||||||
@@ -552,7 +606,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)
|
||||||
@@ -592,6 +648,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 {
|
||||||
@@ -665,13 +725,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)
|
||||||
@@ -723,7 +795,7 @@ func logEntry(event, level, assetID, album, filename, cloud string, size int64,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func addManifestEntry(m manifest.Manifest, pa pendingAsset, result photos.ExportResult) {
|
func addManifestEntry(m manifest.Manifest, pa pendingAsset, result photos.ExportResult, checksum string) {
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -736,7 +808,33 @@ func addManifestEntry(m manifest.Manifest, pa pendingAsset, result photos.Export
|
|||||||
if err != nil || strings.HasPrefix(relPath, "..") {
|
if err != nil || strings.HasPrefix(relPath, "..") {
|
||||||
relPath = result.Filename
|
relPath = result.Filename
|
||||||
}
|
}
|
||||||
m.AddEntry(manifest.NewEntry(pa.asset.ID, result.Filename, relPath, result.Size, result.Cloud))
|
m.AddEntry(manifest.NewEntryWithChecksum(pa.asset.ID, result.Filename, relPath, result.Size, result.Cloud, checksum))
|
||||||
|
}
|
||||||
|
|
||||||
|
func addManifestEntryForResult(m manifest.Manifest, pa pendingAsset, result photos.ExportResult, opts exportOptions) error {
|
||||||
|
checksum := ""
|
||||||
|
if opts.checksum == "sha256" && !result.Skipped {
|
||||||
|
var err error
|
||||||
|
checksum, err = fileSHA256(filepath.Join(pa.path, result.Filename))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addManifestEntry(m, pa, result, checksum)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileSHA256(path string) (string, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := io.Copy(h, f); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type xmpSidecarData struct {
|
type xmpSidecarData struct {
|
||||||
@@ -745,17 +843,89 @@ 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
|
||||||
|
SourceType string
|
||||||
|
PlaybackStyle string
|
||||||
PixelWidth int
|
PixelWidth int
|
||||||
PixelHeight int
|
PixelHeight int
|
||||||
|
Duration float64
|
||||||
IsFavorite bool
|
IsFavorite bool
|
||||||
|
IsHidden bool
|
||||||
|
HasAdjustments bool
|
||||||
Cloud string
|
Cloud string
|
||||||
ExportMode string
|
ExportMode string
|
||||||
PhotoscliVersion string
|
PhotoscliVersion string
|
||||||
ExportedAt string
|
ExportedAt string
|
||||||
Size int64
|
Size int64
|
||||||
CreateDate string
|
CreateDate string
|
||||||
|
ModifyDate string
|
||||||
|
Location *photos.AssetLocation
|
||||||
|
Placemark *photos.Placemark
|
||||||
|
BurstIdentifier string
|
||||||
|
RepresentsBurst bool
|
||||||
|
BurstSelectionTypes []string
|
||||||
|
AdjustmentInfo *photos.AdjustmentInfo
|
||||||
|
Resources []photos.AssetResource
|
||||||
|
}
|
||||||
|
|
||||||
|
type geocodeCache struct {
|
||||||
|
path string
|
||||||
|
mu sync.Mutex
|
||||||
|
items map[string]photos.Placemark
|
||||||
|
}
|
||||||
|
|
||||||
|
type geocodeCacheEntry struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Placemark photos.Placemark `json:"placemark"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGeocodeCache(root string) *geocodeCache {
|
||||||
|
c := &geocodeCache{path: filepath.Join(root, ".photoscli", "geocode-cache.jsonl"), items: map[string]photos.Placemark{}}
|
||||||
|
data, err := os.ReadFile(c.path)
|
||||||
|
if err != nil {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var e geocodeCacheEntry
|
||||||
|
if json.Unmarshal([]byte(line), &e) == nil && e.Key != "" {
|
||||||
|
c.items[e.Key] = e.Placemark
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func geocodeKey(lat, lon float64) string { return fmt.Sprintf("%.5f,%.5f", lat, lon) }
|
||||||
|
|
||||||
|
func (c *geocodeCache) lookup(lat, lon float64, bridge photos.Bridge) *photos.Placemark {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
key := geocodeKey(lat, lon)
|
||||||
|
c.mu.Lock()
|
||||||
|
if p, ok := c.items[key]; ok {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return &p
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
p, err := bridge.ReverseGeocode(lat, lon)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.items[key] = p
|
||||||
|
_ = os.MkdirAll(filepath.Dir(c.path), 0755)
|
||||||
|
if f, err := openFileFunc(c.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
|
||||||
|
_ = json.NewEncoder(f).Encode(geocodeCacheEntry{Key: key, Placemark: p})
|
||||||
|
_ = f.Close()
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
return &p
|
||||||
}
|
}
|
||||||
|
|
||||||
func sidecarPath(exportedPath string) string {
|
func sidecarPath(exportedPath string) string {
|
||||||
@@ -763,8 +933,23 @@ func sidecarPath(exportedPath string) string {
|
|||||||
return strings.TrimSuffix(exportedPath, ext) + ".xmp"
|
return strings.TrimSuffix(exportedPath, ext) + ".xmp"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func jsonSidecarPath(exportedPath string) string {
|
||||||
|
ext := filepath.Ext(exportedPath)
|
||||||
|
return strings.TrimSuffix(exportedPath, ext) + ".json"
|
||||||
|
}
|
||||||
|
|
||||||
|
func sidecarEnabled(sidecar, format string) bool {
|
||||||
|
for _, part := range strings.Split(sidecar, ",") {
|
||||||
|
if strings.TrimSpace(part) == format {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
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},
|
||||||
@@ -772,24 +957,75 @@ func renderXMP(d xmpSidecarData) []byte {
|
|||||||
{"photoscli:albumPath", d.AlbumPath},
|
{"photoscli:albumPath", d.AlbumPath},
|
||||||
{"photoscli:manifestPath", d.ManifestPath},
|
{"photoscli:manifestPath", d.ManifestPath},
|
||||||
{"photoscli:mediaType", d.MediaType},
|
{"photoscli:mediaType", d.MediaType},
|
||||||
|
{"photoscli:sourceType", d.SourceType},
|
||||||
|
{"photoscli:playbackStyle", d.PlaybackStyle},
|
||||||
{"photoscli:pixelWidth", fmt.Sprintf("%d", d.PixelWidth)},
|
{"photoscli:pixelWidth", fmt.Sprintf("%d", d.PixelWidth)},
|
||||||
{"photoscli:pixelHeight", fmt.Sprintf("%d", d.PixelHeight)},
|
{"photoscli:pixelHeight", fmt.Sprintf("%d", d.PixelHeight)},
|
||||||
|
{"photoscli:duration", fmt.Sprintf("%.3f", d.Duration)},
|
||||||
{"photoscli:isFavorite", fmt.Sprintf("%t", d.IsFavorite)},
|
{"photoscli:isFavorite", fmt.Sprintf("%t", d.IsFavorite)},
|
||||||
|
{"photoscli:isHidden", fmt.Sprintf("%t", d.IsHidden)},
|
||||||
|
{"photoscli:hasAdjustments", fmt.Sprintf("%t", d.HasAdjustments)},
|
||||||
{"photoscli:cloud", d.Cloud},
|
{"photoscli:cloud", d.Cloud},
|
||||||
|
{"photoscli:burstIdentifier", d.BurstIdentifier},
|
||||||
|
{"photoscli:representsBurst", fmt.Sprintf("%t", d.RepresentsBurst)},
|
||||||
{"photoscli:exportMode", d.ExportMode},
|
{"photoscli:exportMode", d.ExportMode},
|
||||||
{"photoscli:photoscliVersion", d.PhotoscliVersion},
|
{"photoscli:photoscliVersion", d.PhotoscliVersion},
|
||||||
{"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 != "" {
|
||||||
|
attrs = append(attrs, struct{ key, val string }{"xmp:ModifyDate", d.ModifyDate})
|
||||||
|
}
|
||||||
|
if d.Location != nil {
|
||||||
|
attrs = append(attrs,
|
||||||
|
struct{ key, val string }{"photoscli:latitude", fmt.Sprintf("%.8f", d.Location.Latitude)},
|
||||||
|
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 {
|
||||||
|
attrs = append(attrs,
|
||||||
|
struct{ key, val string }{"photoscli:addressName", d.Placemark.Name},
|
||||||
|
struct{ key, val string }{"photoscli:addressCountry", d.Placemark.Country},
|
||||||
|
struct{ key, val string }{"photoscli:addressCountryCode", d.Placemark.CountryCode},
|
||||||
|
struct{ key, val string }{"photoscli:addressRegion", d.Placemark.AdministrativeArea},
|
||||||
|
struct{ key, val string }{"photoscli:addressCity", d.Placemark.Locality},
|
||||||
|
struct{ key, val string }{"photoscli:addressSubLocality", d.Placemark.SubLocality},
|
||||||
|
struct{ key, val string }{"photoscli:addressStreet", strings.TrimSpace(d.Placemark.Thoroughfare + " " + d.Placemark.SubThoroughfare)},
|
||||||
|
struct{ key, val string }{"photoscli:addressPostalCode", d.Placemark.PostalCode},
|
||||||
|
struct{ key, val string }{"photoscli:addressFormatted", d.Placemark.FormattedAddress},
|
||||||
|
struct{ key, val string }{"photoscli:reverseGeocoder", "MapKit"},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if d.AdjustmentInfo != nil {
|
||||||
|
attrs = append(attrs,
|
||||||
|
struct{ key, val string }{"photoscli:adjustmentFormatIdentifier", d.AdjustmentInfo.FormatIdentifier},
|
||||||
|
struct{ key, val string }{"photoscli:adjustmentFormatVersion", d.AdjustmentInfo.FormatVersion},
|
||||||
|
struct{ key, val string }{"photoscli:adjustmentBaseFilename", d.AdjustmentInfo.BaseFilename},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
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)
|
||||||
@@ -797,13 +1033,72 @@ func renderXMP(d xmpSidecarData) []byte {
|
|||||||
xml.EscapeText(&sb, []byte(a.val))
|
xml.EscapeText(&sb, []byte(a.val))
|
||||||
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:burstSelectionTypes", d.BurstSelectionTypes)
|
||||||
|
writeStringSeq(&sb, "photoscli:areasOfInterest", func() []string {
|
||||||
|
if d.Placemark == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return d.Placemark.AreasOfInterest
|
||||||
|
}())
|
||||||
|
writeResourceSeq(&sb, d.Resources)
|
||||||
|
sb.WriteString(" </rdf:Description>\n")
|
||||||
sb.WriteString(" </rdf:RDF>\n")
|
sb.WriteString(" </rdf:RDF>\n")
|
||||||
sb.WriteString("</x:xmpmeta>\n")
|
sb.WriteString("</x:xmpmeta>\n")
|
||||||
sb.WriteString("<?xpacket end=\"w\"?>\n")
|
sb.WriteString("<?xpacket end=\"w\"?>\n")
|
||||||
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) {
|
||||||
|
if len(vals) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sb.WriteString("\n <" + name + "><rdf:Seq>")
|
||||||
|
for _, v := range vals {
|
||||||
|
sb.WriteString("<rdf:li>")
|
||||||
|
xml.EscapeText(sb, []byte(v))
|
||||||
|
sb.WriteString("</rdf:li>")
|
||||||
|
}
|
||||||
|
sb.WriteString("</rdf:Seq></" + name + ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeResourceSeq(sb *strings.Builder, resources []photos.AssetResource) {
|
||||||
|
if len(resources) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sb.WriteString("\n <photoscli:resources><rdf:Seq>")
|
||||||
|
for _, r := range resources {
|
||||||
|
sb.WriteString("<rdf:li><rdf:Description")
|
||||||
|
for _, a := range []struct{ key, val string }{{"photoscli:resourceType", r.Type}, {"photoscli:resourceFilename", r.Filename}, {"photoscli:resourceUTI", r.UTI}, {"photoscli:resourceLocal", fmt.Sprintf("%t", r.Local)}, {"photoscli:resourceSize", fmt.Sprintf("%d", r.Size)}} {
|
||||||
|
sb.WriteString(" " + a.key + "=\"")
|
||||||
|
xml.EscapeText(sb, []byte(a.val))
|
||||||
|
sb.WriteString("\"")
|
||||||
|
}
|
||||||
|
sb.WriteString(" /></rdf:li>")
|
||||||
|
}
|
||||||
|
sb.WriteString("</rdf:Seq></photoscli:resources>")
|
||||||
|
}
|
||||||
|
|
||||||
func writeXMPSidecar(path string, data xmpSidecarData) error {
|
func writeXMPSidecar(path string, data xmpSidecarData) error {
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -825,8 +1120,31 @@ func writeXMPSidecar(path string, data xmpSidecarData) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals bool, opts exportOptions) error {
|
func writeJSONSidecar(path string, data xmpSidecarData) error {
|
||||||
if opts.sidecar != "xmp" {
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := createTempFunc(filepath.Dir(path), ".*.json.tmp")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := f.Name()
|
||||||
|
_ = f.Close()
|
||||||
|
payload, _ := json.MarshalIndent(data, "", " ")
|
||||||
|
payload = append(payload, '\n')
|
||||||
|
if err := writeFileFunc(tmp, payload, 0644); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := renameFunc(tmp, path); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals bool, opts exportOptions, cache *geocodeCache, bridge photos.Bridge) error {
|
||||||
|
if opts.sidecar == "none" || opts.sidecar == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
mode := "preview"
|
mode := "preview"
|
||||||
@@ -842,28 +1160,137 @@ 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
|
||||||
}
|
}
|
||||||
return writeXMPSidecar(sidecarPath(fullPath), xmpSidecarData{
|
modifyDate := ""
|
||||||
|
if pa.asset.ModificationDate != nil {
|
||||||
|
modifyDate = *pa.asset.ModificationDate
|
||||||
|
}
|
||||||
|
location := pa.asset.Location
|
||||||
|
xmpPrivacy := opts.xmpPrivacy
|
||||||
|
if xmpPrivacy == "" {
|
||||||
|
xmpPrivacy = "keep"
|
||||||
|
}
|
||||||
|
xmpKeywords := opts.xmpKeywords
|
||||||
|
if xmpKeywords == "" {
|
||||||
|
xmpKeywords = "album-path"
|
||||||
|
}
|
||||||
|
xmpRating := opts.xmpRating
|
||||||
|
if xmpRating == "" {
|
||||||
|
xmpRating = "favorite"
|
||||||
|
}
|
||||||
|
var placemark *photos.Placemark
|
||||||
|
if opts.reverseGeocode && location != nil && cache != nil && xmpPrivacy == "keep" {
|
||||||
|
placemark = cache.lookup(pa.asset.Location.Latitude, pa.asset.Location.Longitude, bridge)
|
||||||
|
}
|
||||||
|
if xmpPrivacy == "strip-location" {
|
||||||
|
location = nil
|
||||||
|
placemark = nil
|
||||||
|
}
|
||||||
|
if xmpPrivacy == "strip-address" {
|
||||||
|
placemark = nil
|
||||||
|
}
|
||||||
|
keywords := keywordsFromAlbumPath(pa.album, relDir)
|
||||||
|
if xmpKeywords == "album" {
|
||||||
|
keywords = keywordsFromAlbumPath(pa.album, "")
|
||||||
|
}
|
||||||
|
if xmpKeywords == "none" {
|
||||||
|
keywords = nil
|
||||||
|
}
|
||||||
|
isFavorite := pa.asset.IsFavorite
|
||||||
|
if xmpRating == "none" {
|
||||||
|
isFavorite = false
|
||||||
|
}
|
||||||
|
data := xmpSidecarData{
|
||||||
AssetID: pa.asset.ID,
|
AssetID: pa.asset.ID,
|
||||||
OriginalFilename: pa.asset.Filename,
|
OriginalFilename: pa.asset.Filename,
|
||||||
ExportedFilename: result.Filename,
|
ExportedFilename: result.Filename,
|
||||||
Album: pa.album,
|
Album: pa.album,
|
||||||
AlbumPath: pa.path,
|
AlbumPath: pa.path,
|
||||||
|
Keywords: keywords,
|
||||||
ManifestPath: relPath,
|
ManifestPath: relPath,
|
||||||
MediaType: pa.asset.MediaType,
|
MediaType: pa.asset.MediaType,
|
||||||
|
MediaSubtypes: pa.asset.MediaSubtypes,
|
||||||
|
SourceType: pa.asset.SourceType,
|
||||||
|
PlaybackStyle: pa.asset.PlaybackStyle,
|
||||||
PixelWidth: pa.asset.PixelWidth,
|
PixelWidth: pa.asset.PixelWidth,
|
||||||
PixelHeight: pa.asset.PixelHeight,
|
PixelHeight: pa.asset.PixelHeight,
|
||||||
IsFavorite: pa.asset.IsFavorite,
|
Duration: pa.asset.Duration,
|
||||||
|
IsFavorite: isFavorite,
|
||||||
|
IsHidden: pa.asset.IsHidden,
|
||||||
|
HasAdjustments: pa.asset.HasAdjustments,
|
||||||
Cloud: result.Cloud,
|
Cloud: result.Cloud,
|
||||||
ExportMode: mode,
|
ExportMode: mode,
|
||||||
PhotoscliVersion: version,
|
PhotoscliVersion: version,
|
||||||
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
Size: result.Size,
|
Size: result.Size,
|
||||||
CreateDate: createDate,
|
CreateDate: createDate,
|
||||||
})
|
ModifyDate: modifyDate,
|
||||||
|
Location: location,
|
||||||
|
Placemark: placemark,
|
||||||
|
BurstIdentifier: pa.asset.BurstIdentifier,
|
||||||
|
RepresentsBurst: pa.asset.RepresentsBurst,
|
||||||
|
BurstSelectionTypes: pa.asset.BurstSelectionTypes,
|
||||||
|
AdjustmentInfo: pa.asset.AdjustmentInfo,
|
||||||
|
Resources: pa.asset.Resources,
|
||||||
|
}
|
||||||
|
if sidecarEnabled(opts.sidecar, "xmp") {
|
||||||
|
if err := writeXMPSidecar(sidecarPath(fullPath), data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sidecarEnabled(opts.sidecar, "json") {
|
||||||
|
if err := writeJSONSidecar(jsonSidecarPath(fullPath), data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
@@ -903,6 +1330,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 {
|
||||||
@@ -940,7 +1391,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
|
||||||
}
|
}
|
||||||
@@ -1004,13 +1455,25 @@ func backupTree(nodes []photos.CollectionNode, outDir string, targetSize, qualit
|
|||||||
}
|
}
|
||||||
|
|
||||||
func exportPending(pending []pendingAsset, targetSize, quality int, originals bool, total int, bar *progressBar, bridge photos.Bridge, m manifest.Manifest, concurrency int, lw manifest.LogWriter, opts exportOptions) (int, int) {
|
func exportPending(pending []pendingAsset, targetSize, quality int, originals bool, total int, bar *progressBar, bridge photos.Bridge, m manifest.Manifest, concurrency int, lw manifest.LogWriter, opts exportOptions) (int, int) {
|
||||||
if len(pending) < 4 {
|
var cache *geocodeCache
|
||||||
return exportPendingSerial(pending, targetSize, quality, originals, total, bar, bridge, m, lw, opts)
|
if opts.reverseGeocode && len(pending) > 0 {
|
||||||
|
root := pending[0].root
|
||||||
|
if root == "" {
|
||||||
|
root = pending[0].path
|
||||||
}
|
}
|
||||||
return exportPendingParallel(pending, targetSize, quality, originals, total, bar, bridge, concurrency, m, lw, opts)
|
cache = newGeocodeCache(root)
|
||||||
|
}
|
||||||
|
if len(pending) < 4 {
|
||||||
|
return exportPendingSerial(pending, targetSize, quality, originals, total, bar, bridge, m, lw, opts, cache)
|
||||||
|
}
|
||||||
|
return exportPendingParallel(pending, targetSize, quality, originals, total, bar, bridge, concurrency, m, lw, opts, cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
func exportPendingSerial(pending []pendingAsset, targetSize, quality int, originals bool, total int, bar *progressBar, bridge photos.Bridge, m manifest.Manifest, lw manifest.LogWriter, opts exportOptions) (int, int) {
|
func exportPendingSerial(pending []pendingAsset, targetSize, quality int, originals bool, total int, bar *progressBar, bridge photos.Bridge, m manifest.Manifest, lw manifest.LogWriter, opts exportOptions, cache ...*geocodeCache) (int, int) {
|
||||||
|
var geo *geocodeCache
|
||||||
|
if len(cache) > 0 {
|
||||||
|
geo = cache[0]
|
||||||
|
}
|
||||||
done := 0
|
done := 0
|
||||||
failed := 0
|
failed := 0
|
||||||
var totalBytes int64
|
var totalBytes int64
|
||||||
@@ -1037,16 +1500,20 @@ func exportPendingSerial(pending []pendingAsset, targetSize, quality int, origin
|
|||||||
failed++
|
failed++
|
||||||
appendFailure(pa.path, pa, exportErr)
|
appendFailure(pa.path, pa, exportErr)
|
||||||
} else if isSkipped {
|
} else if isSkipped {
|
||||||
addManifestEntry(m, pa, result)
|
_ = addManifestEntryForResult(m, pa, result, opts)
|
||||||
} else {
|
} else {
|
||||||
if sidecarErr := writeSidecarIfNeeded(pa, result, originals, opts); sidecarErr != nil {
|
if sidecarErr := writeSidecarIfNeeded(pa, result, originals, opts, geo, bridge); sidecarErr != nil {
|
||||||
failed++
|
failed++
|
||||||
exportErr = sidecarErr
|
exportErr = sidecarErr
|
||||||
isErr = true
|
isErr = true
|
||||||
appendFailure(pa.path, pa, sidecarErr)
|
appendFailure(pa.path, pa, sidecarErr)
|
||||||
|
} else if checksumErr := addManifestEntryForResult(m, pa, result, opts); checksumErr != nil {
|
||||||
|
failed++
|
||||||
|
exportErr = checksumErr
|
||||||
|
isErr = true
|
||||||
|
appendFailure(pa.path, pa, checksumErr)
|
||||||
} else {
|
} else {
|
||||||
done++
|
done++
|
||||||
addManifestEntry(m, pa, result)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
avgSpeed := float64(0)
|
avgSpeed := float64(0)
|
||||||
@@ -1075,7 +1542,11 @@ func exportPendingSerial(pending []pendingAsset, targetSize, quality int, origin
|
|||||||
return done, failed
|
return done, failed
|
||||||
}
|
}
|
||||||
|
|
||||||
func exportPendingParallel(pending []pendingAsset, targetSize, quality int, originals bool, total int, bar *progressBar, bridge photos.Bridge, workers int, m manifest.Manifest, lw manifest.LogWriter, opts exportOptions) (int, int) {
|
func exportPendingParallel(pending []pendingAsset, targetSize, quality int, originals bool, total int, bar *progressBar, bridge photos.Bridge, workers int, m manifest.Manifest, lw manifest.LogWriter, opts exportOptions, cache ...*geocodeCache) (int, int) {
|
||||||
|
var geo *geocodeCache
|
||||||
|
if len(cache) > 0 {
|
||||||
|
geo = cache[0]
|
||||||
|
}
|
||||||
type resultEntry struct {
|
type resultEntry struct {
|
||||||
result photos.ExportResult
|
result photos.ExportResult
|
||||||
err error
|
err error
|
||||||
@@ -1173,16 +1644,20 @@ func exportPendingParallel(pending []pendingAsset, targetSize, quality int, orig
|
|||||||
failed++
|
failed++
|
||||||
appendFailure(entry.pa.path, entry.pa, entry.err)
|
appendFailure(entry.pa.path, entry.pa, entry.err)
|
||||||
} else if isSkipped {
|
} else if isSkipped {
|
||||||
addManifestEntry(m, entry.pa, entry.result)
|
_ = addManifestEntryForResult(m, entry.pa, entry.result, opts)
|
||||||
} else {
|
} else {
|
||||||
if sidecarErr := writeSidecarIfNeeded(entry.pa, entry.result, originals, opts); sidecarErr != nil {
|
if sidecarErr := writeSidecarIfNeeded(entry.pa, entry.result, originals, opts, geo, bridge); sidecarErr != nil {
|
||||||
failed++
|
failed++
|
||||||
entry.err = sidecarErr
|
entry.err = sidecarErr
|
||||||
isErr = true
|
isErr = true
|
||||||
appendFailure(entry.pa.path, entry.pa, sidecarErr)
|
appendFailure(entry.pa.path, entry.pa, sidecarErr)
|
||||||
|
} else if checksumErr := addManifestEntryForResult(m, entry.pa, entry.result, opts); checksumErr != nil {
|
||||||
|
failed++
|
||||||
|
entry.err = checksumErr
|
||||||
|
isErr = true
|
||||||
|
appendFailure(entry.pa.path, entry.pa, checksumErr)
|
||||||
} else {
|
} else {
|
||||||
done++
|
done++
|
||||||
addManifestEntry(m, entry.pa, entry.result)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
avgSpeed := float64(0)
|
avgSpeed := float64(0)
|
||||||
@@ -1531,6 +2006,12 @@ 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"),
|
||||||
|
checksum: flagValWithDefault(args, "--checksum", "none"),
|
||||||
|
xmpPrivacy: flagValWithDefault(args, "--xmp-privacy", "keep"),
|
||||||
|
xmpKeywords: flagValWithDefault(args, "--xmp-keywords", "album-path"),
|
||||||
|
xmpRating: flagValWithDefault(args, "--xmp-rating", "favorite"),
|
||||||
|
metadataOnly: hasFlag(args, "--metadata-only"),
|
||||||
|
reverseGeocode: hasFlag(args, "--reverse-geocode"),
|
||||||
dateTemplate: flagVal(args, "--date-template"),
|
dateTemplate: flagVal(args, "--date-template"),
|
||||||
}
|
}
|
||||||
if opts.media != "photos" && opts.media != "videos" && opts.media != "all" {
|
if opts.media != "photos" && opts.media != "videos" && opts.media != "all" {
|
||||||
@@ -1541,8 +2022,37 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
|
|||||||
fmt.Fprintf(stderr, "error: --format must be jpeg, heic, or png, got %q\n", opts.format)
|
fmt.Fprintf(stderr, "error: --format must be jpeg, heic, or png, got %q\n", opts.format)
|
||||||
return opts, false
|
return opts, false
|
||||||
}
|
}
|
||||||
if opts.sidecar != "none" && opts.sidecar != "xmp" {
|
if opts.sidecar != "none" && !sidecarEnabled(opts.sidecar, "xmp") && !sidecarEnabled(opts.sidecar, "json") {
|
||||||
fmt.Fprintf(stderr, "error: --sidecar must be none or xmp, got %q\n", opts.sidecar)
|
fmt.Fprintf(stderr, "error: --sidecar must be none, xmp, json, or xmp,json, got %q\n", opts.sidecar)
|
||||||
|
return opts, false
|
||||||
|
}
|
||||||
|
if opts.sidecar != "none" {
|
||||||
|
for _, part := range strings.Split(opts.sidecar, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part != "xmp" && part != "json" {
|
||||||
|
fmt.Fprintf(stderr, "error: --sidecar must be none, xmp, json, or xmp,json, got %q\n", opts.sidecar)
|
||||||
|
return opts, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if opts.checksum != "none" && opts.checksum != "sha256" {
|
||||||
|
fmt.Fprintf(stderr, "error: --checksum must be none or sha256, got %q\n", opts.checksum)
|
||||||
|
return opts, false
|
||||||
|
}
|
||||||
|
if opts.xmpPrivacy != "keep" && opts.xmpPrivacy != "strip-location" && opts.xmpPrivacy != "strip-address" {
|
||||||
|
fmt.Fprintf(stderr, "error: --xmp-privacy must be keep, strip-location, or strip-address, got %q\n", opts.xmpPrivacy)
|
||||||
|
return opts, false
|
||||||
|
}
|
||||||
|
if opts.xmpKeywords != "album-path" && opts.xmpKeywords != "album" && opts.xmpKeywords != "none" {
|
||||||
|
fmt.Fprintf(stderr, "error: --xmp-keywords must be album-path, album, or none, got %q\n", opts.xmpKeywords)
|
||||||
|
return opts, false
|
||||||
|
}
|
||||||
|
if opts.xmpRating != "favorite" && opts.xmpRating != "none" {
|
||||||
|
fmt.Fprintf(stderr, "error: --xmp-rating must be favorite or none, got %q\n", opts.xmpRating)
|
||||||
|
return opts, false
|
||||||
|
}
|
||||||
|
if opts.metadataOnly && opts.sidecar == "none" {
|
||||||
|
fmt.Fprintln(stderr, "error: --metadata-only requires --sidecar xmp, json, or xmp,json")
|
||||||
return opts, false
|
return opts, false
|
||||||
}
|
}
|
||||||
if v := flagVal(args, "--retry"); v != "" {
|
if v := flagVal(args, "--retry"); v != "" {
|
||||||
@@ -1767,6 +2277,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
|
||||||
@@ -1805,6 +2317,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
|
||||||
@@ -1813,6 +2328,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")
|
||||||
|
|||||||
+681
-7
@@ -31,9 +31,23 @@ type mockBridge struct {
|
|||||||
treeErr error
|
treeErr error
|
||||||
exportPreviewFn func(string, string, int, int, int) (photos.ExportResult, error)
|
exportPreviewFn func(string, string, int, int, int) (photos.ExportResult, error)
|
||||||
exportOrigFn func(string, string, int) (photos.ExportResult, error)
|
exportOrigFn func(string, string, int) (photos.ExportResult, error)
|
||||||
|
reverseGeocodeFn func(float64, float64) (photos.Placemark, error)
|
||||||
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) {
|
||||||
@@ -57,6 +71,12 @@ func (m *mockBridge) ListAssets(albumID string) ([]photos.Asset, int, error) {
|
|||||||
return m.assets, len(m.assets), nil
|
return m.assets, len(m.assets), nil
|
||||||
}
|
}
|
||||||
func (m *mockBridge) ListTree() ([]photos.CollectionNode, error) { return m.tree, m.treeErr }
|
func (m *mockBridge) ListTree() ([]photos.CollectionNode, error) { return m.tree, m.treeErr }
|
||||||
|
func (m *mockBridge) ReverseGeocode(lat, lon float64) (photos.Placemark, error) {
|
||||||
|
if m.reverseGeocodeFn != nil {
|
||||||
|
return m.reverseGeocodeFn(lat, lon)
|
||||||
|
}
|
||||||
|
return photos.Placemark{}, nil
|
||||||
|
}
|
||||||
func (m *mockBridge) ExportPreview(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
|
func (m *mockBridge) ExportPreview(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
|
||||||
if m.exportPreviewFn != nil {
|
if m.exportPreviewFn != nil {
|
||||||
return m.exportPreviewFn(assetID, out, targetSize, quality, index)
|
return m.exportPreviewFn(assetID, out, targetSize, quality, index)
|
||||||
@@ -2642,6 +2662,23 @@ func TestExportPendingParallelManifestAdd(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExportPendingParallelChecksumError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
mf := manifest.LoadJSONL(dir)
|
||||||
|
if err := mf.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}}
|
||||||
|
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
|
||||||
|
return photos.ExportResult{Filename: "missing.jpg", Size: 3}, nil
|
||||||
|
}
|
||||||
|
bar := newProgressBar(io.Discard, 1)
|
||||||
|
done, failed := exportPendingParallel([]pendingAsset{{asset: b.assets[0], path: dir}}, 1024, 85, false, 1, bar, b, 1, mf, manifest.NoopLogWriter, exportOptions{checksum: "sha256"})
|
||||||
|
if done != 0 || failed != 1 {
|
||||||
|
t.Fatalf("done=%d failed=%d", done, failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExportPendingParallelCancel(t *testing.T) {
|
func TestExportPendingParallelCancel(t *testing.T) {
|
||||||
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}}
|
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}}
|
||||||
b.Cancel()
|
b.Cancel()
|
||||||
@@ -2784,6 +2821,58 @@ func TestExportAssetsManifestWrite(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExportAssetsChecksum(t *testing.T) {
|
||||||
|
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}}
|
||||||
|
dir := t.TempDir()
|
||||||
|
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
|
||||||
|
if err := os.WriteFile(filepath.Join(out, "img.jpg"), []byte("abc"), 0644); err != nil {
|
||||||
|
return photos.ExportResult{}, err
|
||||||
|
}
|
||||||
|
return photos.ExportResult{Filename: "img.jpg", Size: 3, Cloud: "local"}, nil
|
||||||
|
}
|
||||||
|
done, failed := exportAssets(b.assets, dir, 1024, 85, 1, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{checksum: "sha256"})
|
||||||
|
if done != 1 || failed != 0 {
|
||||||
|
t.Fatalf("done=%d failed=%d", done, failed)
|
||||||
|
}
|
||||||
|
entry := manifest.LoadJSONL(dir).Entries()["x1"]
|
||||||
|
if entry.Checksum != "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" {
|
||||||
|
t.Fatalf("checksum=%q", entry.Checksum)
|
||||||
|
}
|
||||||
|
got, err := fileSHA256(filepath.Join(dir, "img.jpg"))
|
||||||
|
if err != nil || got != entry.Checksum {
|
||||||
|
t.Fatalf("fileSHA256 got=%q err=%v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := fileSHA256(filepath.Join(dir, "missing.jpg")); err == nil {
|
||||||
|
t.Fatal("expected missing checksum error")
|
||||||
|
}
|
||||||
|
if _, err := fileSHA256(dir); err == nil {
|
||||||
|
t.Fatal("expected directory checksum error")
|
||||||
|
}
|
||||||
|
if err := addManifestEntryForResult(nil, pendingAsset{}, photos.ExportResult{}, exportOptions{}); err != nil {
|
||||||
|
t.Fatalf("nil manifest add error: %v", err)
|
||||||
|
}
|
||||||
|
if err := addManifestEntryForResult(manifest.LoadJSONL(dir), pendingAsset{asset: photos.Asset{ID: "skip"}, path: dir}, photos.ExportResult{Filename: "missing.jpg", Skipped: true}, exportOptions{checksum: "sha256"}); err != nil {
|
||||||
|
t.Fatalf("skipped checksum add error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportAssetsChecksumErrors(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "img.jpg"}}}
|
||||||
|
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
|
||||||
|
return photos.ExportResult{Filename: "missing.jpg", Size: 3}, nil
|
||||||
|
}
|
||||||
|
done, failed := exportAssets(b.assets, dir, 1024, 85, 1, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{checksum: "sha256"})
|
||||||
|
if done != 0 || failed != 1 {
|
||||||
|
t.Fatalf("serial checksum error done=%d failed=%d", done, failed)
|
||||||
|
}
|
||||||
|
dir = t.TempDir()
|
||||||
|
done, failed = exportAssets(b.assets, dir, 1024, 85, 2, false, 2, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{checksum: "sha256"})
|
||||||
|
if done != 0 || failed != 1 {
|
||||||
|
t.Fatalf("parallel checksum error done=%d failed=%d", done, failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBackupTreeManifestOpenErr(t *testing.T) {
|
func TestBackupTreeManifestOpenErr(t *testing.T) {
|
||||||
b := &mockBridge{
|
b := &mockBridge{
|
||||||
tree: []photos.CollectionNode{
|
tree: []photos.CollectionNode{
|
||||||
@@ -3828,6 +3917,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)
|
||||||
@@ -4126,6 +4222,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)
|
||||||
@@ -4133,10 +4231,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)
|
||||||
@@ -4155,6 +4273,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)
|
||||||
@@ -4165,25 +4382,56 @@ 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&<>"`},
|
||||||
|
SourceType: "userLibrary",
|
||||||
|
PlaybackStyle: "livePhoto",
|
||||||
PixelWidth: 10,
|
PixelWidth: 10,
|
||||||
PixelHeight: 20,
|
PixelHeight: 20,
|
||||||
|
Duration: 1.25,
|
||||||
IsFavorite: true,
|
IsFavorite: true,
|
||||||
|
IsHidden: true,
|
||||||
|
HasAdjustments: true,
|
||||||
Cloud: "local",
|
Cloud: "local",
|
||||||
ExportMode: "preview",
|
ExportMode: "preview",
|
||||||
PhotoscliVersion: "test",
|
PhotoscliVersion: "test",
|
||||||
ExportedAt: "2026-01-01T00:00:00Z",
|
ExportedAt: "2026-01-01T00:00:00Z",
|
||||||
Size: 123,
|
Size: 123,
|
||||||
CreateDate: "2024-01-01T00:00:00Z",
|
CreateDate: "2024-01-01T00:00:00Z",
|
||||||
|
ModifyDate: "2024-02-01T00:00:00Z",
|
||||||
|
Location: &photos.AssetLocation{Latitude: 59.3293, Longitude: 18.0686, Altitude: 10, HorizontalAccuracy: 5},
|
||||||
|
Placemark: &photos.Placemark{Country: "Sweden", CountryCode: "SE", Locality: "Stockholm", FormattedAddress: "Stockholm, Sweden", AreasOfInterest: []string{"Gamla stan"}},
|
||||||
|
BurstIdentifier: "burst1",
|
||||||
|
RepresentsBurst: true,
|
||||||
|
BurstSelectionTypes: []string{"autoPick"},
|
||||||
|
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&<>"\"", "photoscli:isFavorite=\"true\"", "xmp:CreateDate=\"2024-01-01T00:00:00Z\""} {
|
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")
|
||||||
@@ -4206,6 +4454,52 @@ func TestWriteXMPSidecar(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWriteJSONSidecar(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "photo.json")
|
||||||
|
if got := jsonSidecarPath(filepath.Join(dir, "photo.jpg")); got != path {
|
||||||
|
t.Fatalf("json sidecar path=%q", got)
|
||||||
|
}
|
||||||
|
if !sidecarEnabled("xmp,json", "json") || sidecarEnabled("xmp", "json") {
|
||||||
|
t.Fatal("sidecarEnabled mismatch")
|
||||||
|
}
|
||||||
|
if err := writeJSONSidecar(path, xmpSidecarData{AssetID: "x1", ExportedFilename: "photo.jpg"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), `"AssetID": "x1"`) {
|
||||||
|
t.Fatalf("unexpected json sidecar: %s", string(data))
|
||||||
|
}
|
||||||
|
badParent := filepath.Join(t.TempDir(), "file")
|
||||||
|
if err := os.WriteFile(badParent, []byte("x"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := writeJSONSidecar(filepath.Join(badParent, "bad.json"), xmpSidecarData{}); err == nil {
|
||||||
|
t.Fatal("expected mkdir error")
|
||||||
|
}
|
||||||
|
oldCreate := createTempFunc
|
||||||
|
createTempFunc = func(string, string) (*os.File, error) { return nil, fmt.Errorf("create") }
|
||||||
|
if err := writeJSONSidecar(path, xmpSidecarData{}); err == nil {
|
||||||
|
t.Fatal("expected create temp error")
|
||||||
|
}
|
||||||
|
createTempFunc = oldCreate
|
||||||
|
oldWrite := writeFileFunc
|
||||||
|
writeFileFunc = func(string, []byte, os.FileMode) error { return fmt.Errorf("write") }
|
||||||
|
if err := writeJSONSidecar(path, xmpSidecarData{}); err == nil {
|
||||||
|
t.Fatal("expected write error")
|
||||||
|
}
|
||||||
|
writeFileFunc = oldWrite
|
||||||
|
oldRename := renameFunc
|
||||||
|
renameFunc = func(string, string) error { return fmt.Errorf("rename") }
|
||||||
|
if err := writeJSONSidecar(path, xmpSidecarData{}); err == nil {
|
||||||
|
t.Fatal("expected rename error")
|
||||||
|
}
|
||||||
|
renameFunc = oldRename
|
||||||
|
}
|
||||||
|
|
||||||
func TestSidecarExportIntegration(t *testing.T) {
|
func TestSidecarExportIntegration(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
date := "2024-01-02T03:04:05Z"
|
date := "2024-01-02T03:04:05Z"
|
||||||
@@ -4216,7 +4510,7 @@ func TestSidecarExportIntegration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
return photos.ExportResult{Filename: "photo.jpg", Size: 4, Cloud: "local"}, nil
|
return photos.ExportResult{Filename: "photo.jpg", Size: 4, Cloud: "local"}, nil
|
||||||
}
|
}
|
||||||
exported, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "Album", false, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp"})
|
exported, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "Album", false, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp,json"})
|
||||||
if exported != 1 || failed != 0 {
|
if exported != 1 || failed != 0 {
|
||||||
t.Fatalf("exported=%d failed=%d", exported, failed)
|
t.Fatalf("exported=%d failed=%d", exported, failed)
|
||||||
}
|
}
|
||||||
@@ -4233,6 +4527,165 @@ func TestSidecarExportIntegration(t *testing.T) {
|
|||||||
if _, err := os.Stat(filepath.Join(dir, "photo.jpg.xmp")); !os.IsNotExist(err) {
|
if _, err := os.Stat(filepath.Join(dir, "photo.jpg.xmp")); !os.IsNotExist(err) {
|
||||||
t.Fatal("sidecar should use basename, not double extension")
|
t.Fatal("sidecar should use basename, not double extension")
|
||||||
}
|
}
|
||||||
|
jsonData, err := os.ReadFile(filepath.Join(dir, "photo.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(jsonData), `"AssetID": "x1"`) {
|
||||||
|
t.Fatalf("json sidecar missing asset ID: %s", string(jsonData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSidecarReverseGeocodeCache(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
geoCalls := 0
|
||||||
|
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "geo.jpg", Location: &photos.AssetLocation{Latitude: 59.3293, Longitude: 18.0686}}}}
|
||||||
|
b.reverseGeocodeFn = func(lat, lon float64) (photos.Placemark, error) {
|
||||||
|
geoCalls++
|
||||||
|
return photos.Placemark{Country: "Sweden", Locality: "Stockholm", FormattedAddress: "Stockholm, Sweden"}, nil
|
||||||
|
}
|
||||||
|
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
|
||||||
|
name := fmt.Sprintf("photo%d.jpg", geoCalls)
|
||||||
|
if err := os.WriteFile(filepath.Join(out, name), []byte("data"), 0644); err != nil {
|
||||||
|
return photos.ExportResult{}, err
|
||||||
|
}
|
||||||
|
return photos.ExportResult{Filename: name, Size: 4, Cloud: "local"}, nil
|
||||||
|
}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
exported, failed := exportAssets(b.assets, dir, 1024, 85, 1, false, 1, io.Discard, b, "Album", true, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp", reverseGeocode: true})
|
||||||
|
if exported != 1 || failed != 0 {
|
||||||
|
t.Fatalf("run %d exported=%d failed=%d", i, exported, failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if geoCalls != 1 {
|
||||||
|
t.Fatalf("expected cached geocode after first call, got %d", geoCalls)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, "photo1.xmp"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "photoscli:addressCity=\"Stockholm\"") || !strings.Contains(string(data), "photoscli:reverseGeocoder=\"MapKit\"") {
|
||||||
|
t.Fatalf("missing geocode fields: %s", string(data))
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, ".photoscli", "geocode-cache.jsonl")); err != nil {
|
||||||
|
t.Fatalf("missing geocode cache: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeocodeCacheBranches(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cache := newGeocodeCache(dir)
|
||||||
|
if got := cache.lookup(1, 2, &mockBridge{reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) {
|
||||||
|
return photos.Placemark{}, fmt.Errorf("offline")
|
||||||
|
}}); got != nil {
|
||||||
|
t.Fatalf("expected nil on geocode error, got %+v", got)
|
||||||
|
}
|
||||||
|
if got := (*geocodeCache)(nil).lookup(1, 2, &mockBridge{}); got != nil {
|
||||||
|
t.Fatalf("expected nil cache lookup, got %+v", got)
|
||||||
|
}
|
||||||
|
oldOpen := openFileFunc
|
||||||
|
openFileFunc = func(string, int, os.FileMode) (*os.File, error) { return nil, fmt.Errorf("open") }
|
||||||
|
got := cache.lookup(3, 4, &mockBridge{reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) {
|
||||||
|
return photos.Placemark{Country: "Nowhere"}, nil
|
||||||
|
}})
|
||||||
|
openFileFunc = oldOpen
|
||||||
|
if got == nil || got.Country != "Nowhere" {
|
||||||
|
t.Fatalf("expected placemark despite cache write error, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSidecarReverseGeocodeWithoutCache(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "geo.jpg", Location: &photos.AssetLocation{Latitude: 1, Longitude: 2}}, root: dir, path: dir}
|
||||||
|
if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: "geo.jpg", Size: 1}, false, exportOptions{sidecar: "xmp", reverseGeocode: true}, nil, &mockBridge{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, "geo.xmp"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content := string(data)
|
||||||
|
if !strings.Contains(content, "photoscli:latitude=\"1.00000000\"") || strings.Contains(content, "photoscli:reverseGeocoder") {
|
||||||
|
t.Fatalf("unexpected reverse geocode content: %s", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSidecarModificationDate(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
modified := "2024-03-04T05:06:07Z"
|
||||||
|
pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "mod.jpg", ModificationDate: &modified}, path: dir}
|
||||||
|
if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: "mod.jpg", Size: 1}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, "mod.xmp"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "xmp:ModifyDate=\"2024-03-04T05:06:07Z\"") {
|
||||||
|
t.Fatalf("missing modify date: %s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportPendingReverseGeocodeNoPending(t *testing.T) {
|
||||||
|
bar := newProgressBar(io.Discard, 1)
|
||||||
|
done, failed := exportPending(nil, 1024, 85, false, 0, bar, &mockBridge{}, nil, 1, manifest.NoopLogWriter, exportOptions{sidecar: "xmp", reverseGeocode: true})
|
||||||
|
if done != 0 || failed != 0 {
|
||||||
|
t.Fatalf("done=%d failed=%d", done, failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportPendingGeocodeCacheRootFallback(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
a := photos.Asset{ID: "g1", Filename: "geo.jpg", Location: &photos.AssetLocation{Latitude: 1, Longitude: 2}}
|
||||||
|
b := &mockBridge{assets: []photos.Asset{a}, reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) {
|
||||||
|
return photos.Placemark{Country: "Sweden"}, nil
|
||||||
|
}}
|
||||||
|
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
|
||||||
|
if err := os.WriteFile(filepath.Join(out, "geo.jpg"), []byte("data"), 0644); err != nil {
|
||||||
|
return photos.ExportResult{}, err
|
||||||
|
}
|
||||||
|
return photos.ExportResult{Filename: "geo.jpg", Size: 4}, nil
|
||||||
|
}
|
||||||
|
bar := newProgressBar(io.Discard, 1)
|
||||||
|
done, failed := exportPending([]pendingAsset{{asset: a, path: dir}}, 1024, 85, false, 1, bar, b, nil, 1, manifest.NoopLogWriter, exportOptions{sidecar: "xmp", reverseGeocode: true})
|
||||||
|
if done != 1 || failed != 0 {
|
||||||
|
t.Fatalf("done=%d failed=%d", done, failed)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, ".photoscli", "geocode-cache.jsonl")); err != nil {
|
||||||
|
t.Fatalf("expected fallback-root cache: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportPendingCreatesGeocodeCacheForParallel(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
assets := []photos.Asset{
|
||||||
|
{ID: "g1", Filename: "one.jpg", Location: &photos.AssetLocation{Latitude: 1, Longitude: 2}},
|
||||||
|
{ID: "g2", Filename: "two.jpg", Location: &photos.AssetLocation{Latitude: 3, Longitude: 4}},
|
||||||
|
{ID: "g3", Filename: "three.jpg", Location: &photos.AssetLocation{Latitude: 5, Longitude: 6}},
|
||||||
|
{ID: "g4", Filename: "four.jpg", Location: &photos.AssetLocation{Latitude: 7, Longitude: 8}},
|
||||||
|
}
|
||||||
|
pending := make([]pendingAsset, len(assets))
|
||||||
|
for i, a := range assets {
|
||||||
|
pending[i] = pendingAsset{asset: a, root: dir, path: dir, album: "Geo"}
|
||||||
|
}
|
||||||
|
b := &mockBridge{assets: assets, reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) {
|
||||||
|
return photos.Placemark{Country: "Sweden"}, nil
|
||||||
|
}}
|
||||||
|
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
|
||||||
|
name := assetID + ".jpg"
|
||||||
|
if err := os.WriteFile(filepath.Join(out, name), []byte("data"), 0644); err != nil {
|
||||||
|
return photos.ExportResult{}, err
|
||||||
|
}
|
||||||
|
return photos.ExportResult{Filename: name, Size: 4}, nil
|
||||||
|
}
|
||||||
|
bar := newProgressBar(io.Discard, 4)
|
||||||
|
done, failed := exportPending(pending, 1024, 85, false, len(pending), bar, b, nil, 4, manifest.NoopLogWriter, exportOptions{sidecar: "xmp", reverseGeocode: true})
|
||||||
|
if done != len(pending) || failed != 0 {
|
||||||
|
t.Fatalf("done=%d failed=%d", done, failed)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, ".photoscli", "geocode-cache.jsonl")); err != nil {
|
||||||
|
t.Fatalf("expected geocode cache: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSidecarConfigAndErrors(t *testing.T) {
|
func TestSidecarConfigAndErrors(t *testing.T) {
|
||||||
@@ -4253,6 +4706,30 @@ func TestSidecarConfigAndErrors(t *testing.T) {
|
|||||||
if _, ok := parseExportOptions([]string{"--sidecar", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--sidecar") {
|
if _, ok := parseExportOptions([]string{"--sidecar", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--sidecar") {
|
||||||
t.Fatalf("expected sidecar validation error, stderr=%q", stderr.String())
|
t.Fatalf("expected sidecar validation error, stderr=%q", stderr.String())
|
||||||
}
|
}
|
||||||
|
stderr.Reset()
|
||||||
|
if opts, ok := parseExportOptions([]string{"--sidecar", "json"}, &stderr); !ok || opts.sidecar != "json" || stderr.Len() != 0 {
|
||||||
|
t.Fatalf("expected json sidecar option, opts=%+v ok=%v stderr=%q", opts, ok, stderr.String())
|
||||||
|
}
|
||||||
|
stderr.Reset()
|
||||||
|
if _, ok := parseExportOptions([]string{"--sidecar", "xmp,bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--sidecar") {
|
||||||
|
t.Fatalf("expected mixed sidecar validation error, stderr=%q", stderr.String())
|
||||||
|
}
|
||||||
|
stderr.Reset()
|
||||||
|
if _, ok := parseExportOptions([]string{"--xmp-privacy", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--xmp-privacy") {
|
||||||
|
t.Fatalf("expected xmp privacy validation error, stderr=%q", stderr.String())
|
||||||
|
}
|
||||||
|
stderr.Reset()
|
||||||
|
if _, ok := parseExportOptions([]string{"--xmp-keywords", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--xmp-keywords") {
|
||||||
|
t.Fatalf("expected xmp keywords validation error, stderr=%q", stderr.String())
|
||||||
|
}
|
||||||
|
stderr.Reset()
|
||||||
|
if _, ok := parseExportOptions([]string{"--xmp-rating", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--xmp-rating") {
|
||||||
|
t.Fatalf("expected xmp rating validation error, stderr=%q", stderr.String())
|
||||||
|
}
|
||||||
|
stderr.Reset()
|
||||||
|
if _, ok := parseExportOptions([]string{"--checksum", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--checksum") {
|
||||||
|
t.Fatalf("expected checksum validation error, stderr=%q", stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "photo.jpg"}}}
|
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "photo.jpg"}}}
|
||||||
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
|
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
|
||||||
@@ -4260,10 +4737,207 @@ func TestSidecarConfigAndErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
oldRename := renameFunc
|
oldRename := renameFunc
|
||||||
renameFunc = func(string, string) error { return fmt.Errorf("sidecar rename") }
|
renameFunc = func(string, string) error { return fmt.Errorf("sidecar rename") }
|
||||||
exported, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp"})
|
exported, failed := exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{sidecar: "json"})
|
||||||
|
if exported != 0 || failed != 1 {
|
||||||
|
t.Fatalf("expected json sidecar failure, exported=%d failed=%d", exported, failed)
|
||||||
|
}
|
||||||
|
exported, failed = exportAssets(b.assets, dir, 1024, 85, 3, false, 1, io.Discard, b, "", false, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp"})
|
||||||
renameFunc = oldRename
|
renameFunc = oldRename
|
||||||
if exported != 0 || failed != 1 {
|
if exported != 0 || failed != 1 {
|
||||||
t.Fatalf("expected sidecar failure, exported=%d failed=%d", exported, failed)
|
t.Fatalf("expected xmp sidecar failure, exported=%d failed=%d", exported, failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestXMPSidecarPrivacy(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
asset := photos.Asset{ID: "x1", Filename: "geo.jpg", Location: &photos.AssetLocation{Latitude: 59.3293, Longitude: 18.0686}}
|
||||||
|
bridge := &mockBridge{}
|
||||||
|
bridge.reverseGeocodeFn = func(float64, float64) (photos.Placemark, error) {
|
||||||
|
return photos.Placemark{Country: "Sweden", Locality: "Stockholm"}, nil
|
||||||
|
}
|
||||||
|
pa := pendingAsset{asset: asset, root: dir, path: dir, album: "Album"}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
privacy string
|
||||||
|
wantGPS bool
|
||||||
|
wantAddress bool
|
||||||
|
}{
|
||||||
|
{privacy: "keep", wantGPS: true, wantAddress: true},
|
||||||
|
{privacy: "strip-address", wantGPS: true, wantAddress: false},
|
||||||
|
{privacy: "strip-location", wantGPS: false, wantAddress: false},
|
||||||
|
} {
|
||||||
|
path := filepath.Join(dir, tc.privacy+".jpg")
|
||||||
|
if err := os.WriteFile(path, []byte("data"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: filepath.Base(path), Size: 4}, false, exportOptions{sidecar: "xmp", reverseGeocode: true, xmpPrivacy: tc.privacy}, newGeocodeCache(dir), bridge); err != nil {
|
||||||
|
t.Fatalf("%s write sidecar: %v", tc.privacy, err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(sidecarPath(path))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content := string(data)
|
||||||
|
if strings.Contains(content, "photoscli:latitude") != tc.wantGPS {
|
||||||
|
t.Fatalf("%s GPS presence mismatch in %s", tc.privacy, content)
|
||||||
|
}
|
||||||
|
if strings.Contains(content, "photoscli:addressCountry") != tc.wantAddress {
|
||||||
|
t.Fatalf("%s address presence mismatch in %s", tc.privacy, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestXMPSidecarKeywordAndRatingOptions(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
asset := photos.Asset{ID: "x1", Filename: "photo.jpg", IsFavorite: true}
|
||||||
|
pa := pendingAsset{asset: asset, root: dir, path: filepath.Join(dir, "Trips", "Beach"), album: "Beach"}
|
||||||
|
if err := os.MkdirAll(pa.path, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
keywords string
|
||||||
|
rating string
|
||||||
|
wantTrip bool
|
||||||
|
wantBeach bool
|
||||||
|
wantRate bool
|
||||||
|
}{
|
||||||
|
{name: "default", wantTrip: true, wantBeach: true, wantRate: true},
|
||||||
|
{name: "album", keywords: "album", wantTrip: false, wantBeach: true, wantRate: true},
|
||||||
|
{name: "none", keywords: "none", rating: "none", wantTrip: false, wantBeach: false, wantRate: false},
|
||||||
|
} {
|
||||||
|
filename := tc.name + ".jpg"
|
||||||
|
path := filepath.Join(pa.path, filename)
|
||||||
|
if err := os.WriteFile(path, []byte("data"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: filename, Size: 4}, false, exportOptions{sidecar: "xmp", xmpKeywords: tc.keywords, xmpRating: tc.rating}, nil, &mockBridge{}); err != nil {
|
||||||
|
t.Fatalf("%s write sidecar: %v", tc.name, err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(sidecarPath(path))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content := string(data)
|
||||||
|
if strings.Contains(content, "<rdf:li>Trips</rdf:li>") != tc.wantTrip {
|
||||||
|
t.Fatalf("%s Trips keyword mismatch in %s", tc.name, content)
|
||||||
|
}
|
||||||
|
if strings.Contains(content, "<rdf:li>Beach</rdf:li>") != tc.wantBeach {
|
||||||
|
t.Fatalf("%s Beach keyword mismatch in %s", tc.name, content)
|
||||||
|
}
|
||||||
|
if strings.Contains(content, "xmp:Rating=\"5\"") != tc.wantRate {
|
||||||
|
t.Fatalf("%s rating mismatch in %s", tc.name, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetadataOnlyExport(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
m := manifest.LoadJSONL(dir)
|
||||||
|
if err := m.OpenAppend(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "photo.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
|
||||||
|
m.Close()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("data"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "orig.heic", MediaType: "image", IsFavorite: true}}}
|
||||||
|
b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) {
|
||||||
|
t.Fatal("metadata-only must not export media")
|
||||||
|
return photos.ExportResult{}, nil
|
||||||
|
}
|
||||||
|
out, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--sidecar", "xmp", "--metadata-only"}, b)
|
||||||
|
if rc != exitOK || out != "" || !strings.Contains(stderr, "wrote 1 metadata sidecars") {
|
||||||
|
t.Fatalf("metadata-only rc=%d out=%q stderr=%q", rc, out, stderr)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, "photo.xmp"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "photoscli:assetID=\"x1\"") || !strings.Contains(string(data), "xmp:Rating=\"5\"") {
|
||||||
|
t.Fatalf("unexpected metadata sidecar: %s", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetadataOnlyExportErrors(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "photo.jpg"}}}
|
||||||
|
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--metadata-only"}, b)
|
||||||
|
if rc != exitErr || !strings.Contains(stderr, "--metadata-only requires --sidecar") {
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4283,7 +4957,7 @@ func TestSidecarAdditionalBranches(t *testing.T) {
|
|||||||
writeFileFunc = oldWriteFile
|
writeFileFunc = oldWriteFile
|
||||||
|
|
||||||
pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "orig.HEIC"}, path: dir, album: "Album"}
|
pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "orig.HEIC"}, path: dir, album: "Album"}
|
||||||
if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: "out.jpg", Size: 1, Cloud: "local"}, true, exportOptions{sidecar: "xmp"}); err != nil {
|
if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: "out.jpg", Size: 1, Cloud: "local"}, true, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
data, err := os.ReadFile(filepath.Join(dir, "out.xmp"))
|
data, err := os.ReadFile(filepath.Join(dir, "out.xmp"))
|
||||||
@@ -4295,7 +4969,7 @@ func TestSidecarAdditionalBranches(t *testing.T) {
|
|||||||
t.Fatalf("unexpected sidecar: %s", content)
|
t.Fatalf("unexpected sidecar: %s", content)
|
||||||
}
|
}
|
||||||
otherRoot := filepath.Join(dir, "other")
|
otherRoot := filepath.Join(dir, "other")
|
||||||
if err := writeSidecarIfNeeded(pendingAsset{asset: photos.Asset{ID: "x2"}, root: otherRoot, path: dir}, photos.ExportResult{Filename: "fallback.jpg"}, false, exportOptions{sidecar: "xmp"}); err != nil {
|
if err := writeSidecarIfNeeded(pendingAsset{asset: photos.Asset{ID: "x2"}, root: otherRoot, path: dir}, photos.ExportResult{Filename: "fallback.jpg"}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
data, err = os.ReadFile(filepath.Join(dir, "fallback.xmp"))
|
data, err = os.ReadFile(filepath.Join(dir, "fallback.xmp"))
|
||||||
@@ -4417,7 +5091,7 @@ func TestInjectedErrorBranchesForCoverage(t *testing.T) {
|
|||||||
removeFunc = oldRemove
|
removeFunc = oldRemove
|
||||||
|
|
||||||
mf := &mockManifest{}
|
mf := &mockManifest{}
|
||||||
addManifestEntry(mf, pendingAsset{asset: photos.Asset{ID: "x"}, root: filepath.Join(dir, "other"), path: dir}, photos.ExportResult{Filename: "file.jpg", Size: 1})
|
addManifestEntry(mf, pendingAsset{asset: photos.Asset{ID: "x"}, root: filepath.Join(dir, "other"), path: dir}, photos.ExportResult{Filename: "file.jpg", Size: 1}, "")
|
||||||
if mf.last.Path != "file.jpg" {
|
if mf.last.Path != "file.jpg" {
|
||||||
t.Fatalf("expected fallback rel path, got %+v", mf.last)
|
t.Fatalf("expected fallback rel path, got %+v", mf.last)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ func TestNewEntryPath(t *testing.T) {
|
|||||||
if e.Path != "Album/file2.jpg" || e.Filename != "file2.jpg" || e.Size != 456 || e.Cloud != "cloud" {
|
if e.Path != "Album/file2.jpg" || e.Filename != "file2.jpg" || e.Size != 456 || e.Cloud != "cloud" {
|
||||||
t.Fatalf("unexpected entry with path: %+v", e)
|
t.Fatalf("unexpected entry with path: %+v", e)
|
||||||
}
|
}
|
||||||
|
e = NewEntryWithChecksum("id3", "file3.jpg", "Album/file3.jpg", 789, "local", "sha256:abc")
|
||||||
|
if e.Checksum != "sha256:abc" || e.Path != "Album/file3.jpg" {
|
||||||
|
t.Fatalf("unexpected checksum entry: %+v", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAddEntryDefaultsPath(t *testing.T) {
|
func TestAddEntryDefaultsPath(t *testing.T) {
|
||||||
@@ -19,11 +23,15 @@ func TestAddEntryDefaultsPath(t *testing.T) {
|
|||||||
if err := jm.OpenAppend(); err != nil {
|
if err := jm.OpenAppend(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
jm.AddEntry(Entry{ID: "x1", Filename: "file.jpg", Size: 3, Cloud: "local"})
|
jm.AddEntry(Entry{ID: "x1", Filename: "file.jpg", Size: 3, Cloud: "local", Checksum: "sha256:abc"})
|
||||||
jm.Close()
|
jm.Close()
|
||||||
if got := LoadJSONL(dir).Entries()["x1"].Path; got != "file.jpg" {
|
loaded := LoadJSONL(dir).Entries()["x1"]
|
||||||
|
if got := loaded.Path; got != "file.jpg" {
|
||||||
t.Fatalf("jsonl path = %q", got)
|
t.Fatalf("jsonl path = %q", got)
|
||||||
}
|
}
|
||||||
|
if loaded.Checksum != "sha256:abc" {
|
||||||
|
t.Fatalf("jsonl checksum = %q", loaded.Checksum)
|
||||||
|
}
|
||||||
|
|
||||||
sdir := t.TempDir()
|
sdir := t.TempDir()
|
||||||
sm, err := LoadSQLite(sdir)
|
sm, err := LoadSQLite(sdir)
|
||||||
@@ -33,12 +41,16 @@ func TestAddEntryDefaultsPath(t *testing.T) {
|
|||||||
if err := sm.OpenAppend(); err != nil {
|
if err := sm.OpenAppend(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
sm.AddEntry(Entry{ID: "x1", Filename: "file.jpg", Size: 3, Cloud: "local"})
|
sm.AddEntry(Entry{ID: "x1", Filename: "file.jpg", Size: 3, Cloud: "local", Checksum: "sha256:def"})
|
||||||
if _, err := sm.db.Exec(`UPDATE downloads SET path = '' WHERE id = 'x1'`); err != nil {
|
if _, err := sm.db.Exec(`UPDATE downloads SET path = '' WHERE id = 'x1'`); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := sm.Entries()["x1"].Path; got != "file.jpg" {
|
sloaded := sm.Entries()["x1"]
|
||||||
|
if got := sloaded.Path; got != "file.jpg" {
|
||||||
t.Fatalf("sqlite path = %q", got)
|
t.Fatalf("sqlite path = %q", got)
|
||||||
}
|
}
|
||||||
|
if sloaded.Checksum != "sha256:def" {
|
||||||
|
t.Fatalf("sqlite checksum = %q", sloaded.Checksum)
|
||||||
|
}
|
||||||
sm.Close()
|
sm.Close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ func LoadJSONL(dir string) *jsonlManifest {
|
|||||||
Path string `json:"path,omitempty"`
|
Path string `json:"path,omitempty"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
Cloud string `json:"cloud"`
|
Cloud string `json:"cloud"`
|
||||||
|
Checksum string `json:"checksum,omitempty"`
|
||||||
Exported int64 `json:"exported"`
|
Exported int64 `json:"exported"`
|
||||||
}
|
}
|
||||||
for _, line := range strings.Split(string(data), "\n") {
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
@@ -61,6 +62,7 @@ func LoadJSONL(dir string) *jsonlManifest {
|
|||||||
Path: raw.Path,
|
Path: raw.Path,
|
||||||
Size: raw.Size,
|
Size: raw.Size,
|
||||||
Cloud: raw.Cloud,
|
Cloud: raw.Cloud,
|
||||||
|
Checksum: raw.Checksum,
|
||||||
Exported: raw.Exported,
|
Exported: raw.Exported,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,8 +95,9 @@ func (m *jsonlManifest) AddEntry(entry Entry) {
|
|||||||
Path string `json:"path,omitempty"`
|
Path string `json:"path,omitempty"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
Cloud string `json:"cloud"`
|
Cloud string `json:"cloud"`
|
||||||
|
Checksum string `json:"checksum,omitempty"`
|
||||||
Exported int64 `json:"exported"`
|
Exported int64 `json:"exported"`
|
||||||
}{ID: entry.ID, Filename: entry.Filename, Path: entry.Path, Size: entry.Size, Cloud: entry.Cloud, Exported: entry.Exported})
|
}{ID: entry.ID, Filename: entry.Filename, Path: entry.Path, Size: entry.Size, Cloud: entry.Cloud, Checksum: entry.Checksum, Exported: entry.Exported})
|
||||||
m.file.Write(data)
|
m.file.Write(data)
|
||||||
m.file.Write([]byte("\n"))
|
m.file.Write([]byte("\n"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type Entry struct {
|
|||||||
Path string
|
Path string
|
||||||
Size int64
|
Size int64
|
||||||
Cloud string
|
Cloud string
|
||||||
|
Checksum string
|
||||||
Exported int64
|
Exported int64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,3 +43,9 @@ func NewEntry(id, filename, path string, size int64, cloud string) Entry {
|
|||||||
}
|
}
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewEntryWithChecksum(id, filename, path string, size int64, cloud, checksum string) Entry {
|
||||||
|
e := NewEntry(id, filename, path, size, cloud)
|
||||||
|
e.Checksum = checksum
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ func (m *sqliteManifest) OpenAppend() error {
|
|||||||
path TEXT NOT NULL DEFAULT '',
|
path TEXT NOT NULL DEFAULT '',
|
||||||
size INTEGER NOT NULL DEFAULT 0,
|
size INTEGER NOT NULL DEFAULT 0,
|
||||||
cloud TEXT NOT NULL DEFAULT '',
|
cloud TEXT NOT NULL DEFAULT '',
|
||||||
|
checksum TEXT NOT NULL DEFAULT '',
|
||||||
exported INTEGER NOT NULL DEFAULT 0
|
exported INTEGER NOT NULL DEFAULT 0
|
||||||
)`)
|
)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -71,6 +72,7 @@ func (m *sqliteManifest) OpenAppend() error {
|
|||||||
return fmt.Errorf("create table: %w", err)
|
return fmt.Errorf("create table: %w", err)
|
||||||
}
|
}
|
||||||
_, _ = execFn(`ALTER TABLE downloads ADD COLUMN path TEXT NOT NULL DEFAULT ''`)
|
_, _ = execFn(`ALTER TABLE downloads ADD COLUMN path TEXT NOT NULL DEFAULT ''`)
|
||||||
|
_, _ = execFn(`ALTER TABLE downloads ADD COLUMN checksum TEXT NOT NULL DEFAULT ''`)
|
||||||
_, err = execFn(`CREATE INDEX IF NOT EXISTS idx_downloads_id ON downloads(id)`)
|
_, err = execFn(`CREATE INDEX IF NOT EXISTS idx_downloads_id ON downloads(id)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
db.Close()
|
db.Close()
|
||||||
@@ -103,8 +105,8 @@ func (m *sqliteManifest) AddEntry(entry Entry) {
|
|||||||
if entry.Path == "" {
|
if entry.Path == "" {
|
||||||
entry.Path = entry.Filename
|
entry.Path = entry.Filename
|
||||||
}
|
}
|
||||||
m.db.Exec(`INSERT OR REPLACE INTO downloads (id, filename, path, size, cloud, exported) VALUES (?, ?, ?, ?, ?, ?)`,
|
m.db.Exec(`INSERT OR REPLACE INTO downloads (id, filename, path, size, cloud, checksum, exported) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
entry.ID, entry.Filename, entry.Path, entry.Size, entry.Cloud, entry.Exported)
|
entry.ID, entry.Filename, entry.Path, entry.Size, entry.Cloud, entry.Checksum, entry.Exported)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *sqliteManifest) Save() error {
|
func (m *sqliteManifest) Save() error {
|
||||||
@@ -127,14 +129,14 @@ func (m *sqliteManifest) Entries() map[string]Entry {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := make(map[string]Entry)
|
out := make(map[string]Entry)
|
||||||
rows, err := m.db.Query(`SELECT id, filename, path, size, cloud, exported FROM downloads`)
|
rows, err := m.db.Query(`SELECT id, filename, path, size, cloud, checksum, exported FROM downloads`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var e Entry
|
var e Entry
|
||||||
if err := rows.Scan(&e.ID, &e.Filename, &e.Path, &e.Size, &e.Cloud, &e.Exported); err == nil {
|
if err := rows.Scan(&e.ID, &e.Filename, &e.Path, &e.Size, &e.Cloud, &e.Checksum, &e.Exported); err == nil {
|
||||||
if e.Path == "" {
|
if e.Path == "" {
|
||||||
e.Path = e.Filename
|
e.Path = e.Filename
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type Bridge interface {
|
|||||||
RequestAccess() error
|
RequestAccess() error
|
||||||
ListAlbums() ([]Album, error)
|
ListAlbums() ([]Album, error)
|
||||||
ListAssets(albumID string) ([]Asset, int, error)
|
ListAssets(albumID string) ([]Asset, int, error)
|
||||||
|
ReverseGeocode(latitude, longitude float64) (Placemark, error)
|
||||||
ListTree() ([]CollectionNode, error)
|
ListTree() ([]CollectionNode, error)
|
||||||
ExportPreview(assetID, outputDir string, targetSize, quality, index int) (ExportResult, error)
|
ExportPreview(assetID, outputDir string, targetSize, quality, index int) (ExportResult, error)
|
||||||
ExportOriginal(assetID, outputDir string, index int) (ExportResult, error)
|
ExportOriginal(assetID, outputDir string, index int) (ExportResult, error)
|
||||||
@@ -39,6 +40,17 @@ func ParseAssetsJSON(jsonStr string) ([]Asset, int, error) {
|
|||||||
return resp.Assets, resp.Total, nil
|
return resp.Assets, resp.Total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ParsePlacemarkJSON(jsonStr string) (Placemark, error) {
|
||||||
|
var resp PlacemarkResponse
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &resp); err != nil {
|
||||||
|
return Placemark{}, err
|
||||||
|
}
|
||||||
|
if resp.Error != "" {
|
||||||
|
return Placemark{}, fmt.Errorf("%s", resp.Error)
|
||||||
|
}
|
||||||
|
return resp.Placemark, nil
|
||||||
|
}
|
||||||
|
|
||||||
func ParseTreeJSON(jsonStr string) ([]CollectionNode, error) {
|
func ParseTreeJSON(jsonStr string) ([]CollectionNode, error) {
|
||||||
var errResp ErrorResponse
|
var errResp ErrorResponse
|
||||||
if err := json.Unmarshal([]byte(jsonStr), &errResp); err == nil && errResp.Error != "" {
|
if err := json.Unmarshal([]byte(jsonStr), &errResp); err == nil && errResp.Error != "" {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ package photos
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
#cgo CFLAGS: -I${SRCDIR}/../../bridge
|
#cgo CFLAGS: -I${SRCDIR}/../../bridge
|
||||||
#cgo LDFLAGS: -L${SRCDIR}/../../bridge -lphotokit_bridge -framework Photos -framework Foundation -framework AppKit -framework UniformTypeIdentifiers
|
#cgo LDFLAGS: -L${SRCDIR}/../../bridge -lphotokit_bridge -framework Photos -framework Foundation -framework AppKit -framework UniformTypeIdentifiers -framework CoreLocation -framework MapKit
|
||||||
#include "photokit_bridge.h"
|
#include "photokit_bridge.h"
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
*/
|
*/
|
||||||
@@ -45,6 +45,15 @@ func (*CgoBridge) ListAssets(albumID string) ([]Asset, int, error) {
|
|||||||
return ParseAssetsJSON(C.GoString(cs))
|
return ParseAssetsJSON(C.GoString(cs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (*CgoBridge) ReverseGeocode(latitude, longitude float64) (Placemark, error) {
|
||||||
|
cs := C.photos_reverse_geocode_json(C.double(latitude), C.double(longitude))
|
||||||
|
if cs == nil {
|
||||||
|
return Placemark{}, errBridgeNil
|
||||||
|
}
|
||||||
|
defer C.photos_free_string(cs)
|
||||||
|
return ParsePlacemarkJSON(C.GoString(cs))
|
||||||
|
}
|
||||||
|
|
||||||
func (*CgoBridge) ListTree() ([]CollectionNode, error) {
|
func (*CgoBridge) ListTree() ([]CollectionNode, error) {
|
||||||
cs := C.photos_list_tree_json()
|
cs := C.photos_list_tree_json()
|
||||||
if cs == nil {
|
if cs == nil {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ void photos_test_set_access(int rc);
|
|||||||
void photos_test_set_albums(const char *json);
|
void photos_test_set_albums(const char *json);
|
||||||
void photos_test_set_assets(const char *json);
|
void photos_test_set_assets(const char *json);
|
||||||
void photos_test_set_tree(const char *json);
|
void photos_test_set_tree(const char *json);
|
||||||
|
void photos_test_set_geocode(const char *json);
|
||||||
|
void photos_test_set_geocode_null(void);
|
||||||
void photos_test_set_albums_null(void);
|
void photos_test_set_albums_null(void);
|
||||||
void photos_test_set_assets_null(void);
|
void photos_test_set_assets_null(void);
|
||||||
void photos_test_set_tree_null(void);
|
void photos_test_set_tree_null(void);
|
||||||
@@ -35,6 +37,8 @@ func SetTestAccessRC(rc int) { C.photos_test_set_access(C.int(rc)
|
|||||||
func SetTestAlbumsJSON(json string) { C.photos_test_set_albums(C.CString(json)) }
|
func SetTestAlbumsJSON(json string) { C.photos_test_set_albums(C.CString(json)) }
|
||||||
func SetTestAssetsJSON(json string) { C.photos_test_set_assets(C.CString(json)) }
|
func SetTestAssetsJSON(json string) { C.photos_test_set_assets(C.CString(json)) }
|
||||||
func SetTestTreeJSON(json string) { C.photos_test_set_tree(C.CString(json)) }
|
func SetTestTreeJSON(json string) { C.photos_test_set_tree(C.CString(json)) }
|
||||||
|
func SetTestGeocodeJSON(json string) { C.photos_test_set_geocode(C.CString(json)) }
|
||||||
|
func SetTestGeocodeNull() { C.photos_test_set_geocode_null() }
|
||||||
func SetTestAlbumsNull() { C.photos_test_set_albums_null() }
|
func SetTestAlbumsNull() { C.photos_test_set_albums_null() }
|
||||||
func SetTestAssetsNull() { C.photos_test_set_assets_null() }
|
func SetTestAssetsNull() { C.photos_test_set_assets_null() }
|
||||||
func SetTestTreeNull() { C.photos_test_set_tree_null() }
|
func SetTestTreeNull() { C.photos_test_set_tree_null() }
|
||||||
@@ -73,6 +77,15 @@ func (*CgoBridge) ListAssets(albumID string) ([]Asset, int, error) {
|
|||||||
return ParseAssetsJSON(C.GoString(cs))
|
return ParseAssetsJSON(C.GoString(cs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (*CgoBridge) ReverseGeocode(latitude, longitude float64) (Placemark, error) {
|
||||||
|
cs := C.photos_reverse_geocode_json(C.double(latitude), C.double(longitude))
|
||||||
|
if cs == nil {
|
||||||
|
return Placemark{}, errBridgeNil
|
||||||
|
}
|
||||||
|
defer C.photos_free_string(cs)
|
||||||
|
return ParsePlacemarkJSON(C.GoString(cs))
|
||||||
|
}
|
||||||
|
|
||||||
func (*CgoBridge) ListTree() ([]CollectionNode, error) {
|
func (*CgoBridge) ListTree() ([]CollectionNode, error) {
|
||||||
cs := C.photos_list_tree_json()
|
cs := C.photos_list_tree_json()
|
||||||
if cs == nil {
|
if cs == nil {
|
||||||
|
|||||||
@@ -168,6 +168,63 @@ func TestParseAssetsJSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseAssetsJSONExtendedMetadata(t *testing.T) {
|
||||||
|
created := "2024-01-01T00:00:00Z"
|
||||||
|
modified := "2024-01-02T00:00:00Z"
|
||||||
|
assets, total, err := ParseAssetsJSON(`{"assets":[{"id":"x1","filename":"IMG.HEIC","mediaType":"image","mediaSubtypes":["photoLive","photoHDR"],"sourceType":"userLibrary","playbackStyle":"livePhoto","pixelWidth":1,"pixelHeight":2,"creationDate":"2024-01-01T00:00:00Z","modificationDate":"2024-01-02T00:00:00Z","duration":3.5,"isFavorite":true,"isHidden":true,"hasAdjustments":true,"location":{"latitude":59.1,"longitude":18.2,"altitude":10,"horizontalAccuracy":5},"burstIdentifier":"burst","representsBurst":true,"burstSelectionTypes":["autoPick"],"adjustmentInfo":{"formatIdentifier":"fmt","formatVersion":"1","baseFilename":"base.heic"},"resources":[{"type":"adjustmentData","filename":"adj.plist","uti":"public.plist","local":true,"size":42}]}],"total":1}`)
|
||||||
|
if err != nil || total != 1 || len(assets) != 1 {
|
||||||
|
t.Fatalf("ParseAssetsJSON err=%v total=%d len=%d", err, total, len(assets))
|
||||||
|
}
|
||||||
|
a := assets[0]
|
||||||
|
if a.CreationDate == nil || *a.CreationDate != created || a.ModificationDate == nil || *a.ModificationDate != modified {
|
||||||
|
t.Fatalf("unexpected dates: %+v", a)
|
||||||
|
}
|
||||||
|
if a.Location == nil || a.Location.Latitude != 59.1 || a.Location.Longitude != 18.2 || !a.IsHidden || !a.HasAdjustments || !a.RepresentsBurst {
|
||||||
|
t.Fatalf("unexpected extended metadata: %+v", a)
|
||||||
|
}
|
||||||
|
if len(a.MediaSubtypes) != 2 || a.SourceType != "userLibrary" || a.PlaybackStyle != "livePhoto" || len(a.BurstSelectionTypes) != 1 {
|
||||||
|
t.Fatalf("unexpected type metadata: %+v", a)
|
||||||
|
}
|
||||||
|
if a.AdjustmentInfo == nil || a.AdjustmentInfo.FormatIdentifier != "fmt" || a.Resources[0].Size != 42 {
|
||||||
|
t.Fatalf("unexpected adjustment/resource metadata: %+v", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePlacemarkJSON(t *testing.T) {
|
||||||
|
p, err := ParsePlacemarkJSON(`{"placemark":{"country":"Sweden","countryCode":"SE","locality":"Stockholm","formattedAddress":"Stockholm, Sweden","areasOfInterest":["Gamla stan"]}}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p.Country != "Sweden" || p.CountryCode != "SE" || p.Locality != "Stockholm" || len(p.AreasOfInterest) != 1 {
|
||||||
|
t.Fatalf("unexpected placemark: %+v", p)
|
||||||
|
}
|
||||||
|
if _, err := ParsePlacemarkJSON(`{"error":"geocode failed"}`); err == nil || err.Error() != "geocode failed" {
|
||||||
|
t.Fatalf("expected geocode error, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := ParsePlacemarkJSON(`bad`); err == nil {
|
||||||
|
t.Fatal("expected invalid JSON error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCgoBridgeReverseGeocode(t *testing.T) {
|
||||||
|
SetTestGeocodeJSON(`{"placemark":{"country":"Sweden","locality":"Stockholm"}}`)
|
||||||
|
p, err := (&CgoBridge{}).ReverseGeocode(59.3293, 18.0686)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p.Country != "Sweden" || p.Locality != "Stockholm" {
|
||||||
|
t.Fatalf("unexpected placemark: %+v", p)
|
||||||
|
}
|
||||||
|
SetTestGeocodeJSON(`{"error":"no network"}`)
|
||||||
|
if _, err := (&CgoBridge{}).ReverseGeocode(0, 0); err == nil || err.Error() != "no network" {
|
||||||
|
t.Fatalf("expected geocode error, got %v", err)
|
||||||
|
}
|
||||||
|
SetTestGeocodeNull()
|
||||||
|
if _, err := (&CgoBridge{}).ReverseGeocode(0, 0); err != errBridgeNil {
|
||||||
|
t.Fatalf("expected nil bridge error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseTreeJSON(t *testing.T) {
|
func TestParseTreeJSON(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -10,20 +10,66 @@ type Asset struct {
|
|||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
Cloud string `json:"cloud"`
|
Cloud string `json:"cloud"`
|
||||||
MediaType string `json:"mediaType"`
|
MediaType string `json:"mediaType"`
|
||||||
|
MediaSubtypes []string `json:"mediaSubtypes,omitempty"`
|
||||||
|
SourceType string `json:"sourceType,omitempty"`
|
||||||
|
PlaybackStyle string `json:"playbackStyle,omitempty"`
|
||||||
PixelWidth int `json:"pixelWidth"`
|
PixelWidth int `json:"pixelWidth"`
|
||||||
PixelHeight int `json:"pixelHeight"`
|
PixelHeight int `json:"pixelHeight"`
|
||||||
CreationDate *string `json:"creationDate,omitempty"`
|
CreationDate *string `json:"creationDate,omitempty"`
|
||||||
|
ModificationDate *string `json:"modificationDate,omitempty"`
|
||||||
Duration float64 `json:"duration,omitempty"`
|
Duration float64 `json:"duration,omitempty"`
|
||||||
IsFavorite bool `json:"isFavorite,omitempty"`
|
IsFavorite bool `json:"isFavorite,omitempty"`
|
||||||
|
IsHidden bool `json:"isHidden,omitempty"`
|
||||||
HasAdjustments bool `json:"hasAdjustments,omitempty"`
|
HasAdjustments bool `json:"hasAdjustments,omitempty"`
|
||||||
|
Location *AssetLocation `json:"location,omitempty"`
|
||||||
|
BurstIdentifier string `json:"burstIdentifier,omitempty"`
|
||||||
|
RepresentsBurst bool `json:"representsBurst,omitempty"`
|
||||||
|
BurstSelectionTypes []string `json:"burstSelectionTypes,omitempty"`
|
||||||
|
AdjustmentInfo *AdjustmentInfo `json:"adjustmentInfo,omitempty"`
|
||||||
Resources []AssetResource `json:"resources,omitempty"`
|
Resources []AssetResource `json:"resources,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AssetLocation struct {
|
||||||
|
Latitude float64 `json:"latitude"`
|
||||||
|
Longitude float64 `json:"longitude"`
|
||||||
|
Altitude float64 `json:"altitude,omitempty"`
|
||||||
|
HorizontalAccuracy float64 `json:"horizontalAccuracy,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdjustmentInfo struct {
|
||||||
|
FormatIdentifier string `json:"formatIdentifier,omitempty"`
|
||||||
|
FormatVersion string `json:"formatVersion,omitempty"`
|
||||||
|
BaseFilename string `json:"baseFilename,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type AssetResource struct {
|
type AssetResource struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
UTI string `json:"uti"`
|
UTI string `json:"uti"`
|
||||||
Local bool `json:"local"`
|
Local bool `json:"local"`
|
||||||
|
Size int64 `json:"size,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Placemark struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Country string `json:"country,omitempty"`
|
||||||
|
CountryCode string `json:"countryCode,omitempty"`
|
||||||
|
AdministrativeArea string `json:"administrativeArea,omitempty"`
|
||||||
|
SubAdministrativeArea string `json:"subAdministrativeArea,omitempty"`
|
||||||
|
Locality string `json:"locality,omitempty"`
|
||||||
|
SubLocality string `json:"subLocality,omitempty"`
|
||||||
|
Thoroughfare string `json:"thoroughfare,omitempty"`
|
||||||
|
SubThoroughfare string `json:"subThoroughfare,omitempty"`
|
||||||
|
PostalCode string `json:"postalCode,omitempty"`
|
||||||
|
FormattedAddress string `json:"formattedAddress,omitempty"`
|
||||||
|
InlandWater string `json:"inlandWater,omitempty"`
|
||||||
|
Ocean string `json:"ocean,omitempty"`
|
||||||
|
AreasOfInterest []string `json:"areasOfInterest,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlacemarkResponse struct {
|
||||||
|
Placemark Placemark `json:"placemark"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExportResult struct {
|
type ExportResult struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user