Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bed3ea7bd0 | |||
| 7555b561bd | |||
| d909d30b87 | |||
| 5c40b1d3ba | |||
| 700d8ef05a | |||
| fbc37b8d8d | |||
| 32a5819c86 | |||
| a51db37fdb | |||
| 9cd702628d | |||
| fffb30023b | |||
| 4fe4c15adf | |||
| 36832060d0 | |||
| 05188e5451 | |||
| 0a905758cc |
@@ -0,0 +1,20 @@
|
||||
# AGENT.md
|
||||
|
||||
- Project: `photoscli`, macOS Apple Photos exporter, Go + cgo + Objective-C PhotoKit bridge.
|
||||
- Release binary target: Apple Silicon only (`darwin/arm64`). Make this explicit in docs/assets.
|
||||
- Module: `gitea.k3s.k0.nu/tools/photocli`.
|
||||
- Run tests with `-tags=test`; tests use the C stub bridge, not real PhotoKit.
|
||||
- Required before any release: `go test -tags=test -race -count=1 -coverprofile=coverage.out ./...` must show 100% for all packages, then `make pipeline`, then `make package`.
|
||||
- Never release below 100% coverage.
|
||||
- Release assets must include: binary, `USERGUIDE.md`, and `photoscli-<version>-macos-arm64.zip`.
|
||||
- Release page must use clear notes from `RELEASE_NOTES.md`; `CHANGELOG.md` must be updated too.
|
||||
- Keep README concise; put practical user workflows in `USERGUIDE.md`.
|
||||
- Manifest backends: JSONL default, SQLite optional via `modernc.org/sqlite`.
|
||||
- Preserve manifest compatibility and migration behavior.
|
||||
- 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.
|
||||
+196
-23
@@ -1,48 +1,221 @@
|
||||
# Changelog
|
||||
|
||||
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.1
|
||||
|
||||
Deep checksum verification release.
|
||||
|
||||
- Add `verify --deep` to recompute SHA-256 checksums for manifest entries that have checksum metadata.
|
||||
- Report `checksum-mismatch` when disk contents differ from the manifest checksum.
|
||||
- Keep normal `verify` unchanged unless `--deep` is selected.
|
||||
|
||||
## 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
|
||||
|
||||
XMP sidecar metadata release.
|
||||
|
||||
- Add `--sidecar none|xmp` with default `none`.
|
||||
- Add config support for `sidecar = "xmp"`.
|
||||
- Write XMP sidecars next to exported files using basename `.xmp` filenames.
|
||||
- Include asset ID, filenames, album, manifest path, media type, dimensions, favorite state, cloud state, export mode, photoscli version, exported timestamp, size, and creation date in XMP.
|
||||
- Write XMP files atomically.
|
||||
- Treat sidecar write failure as asset failure when XMP sidecars are explicitly requested.
|
||||
|
||||
## v0.6.0
|
||||
|
||||
Backup integrity and recovery release.
|
||||
|
||||
- Add manifest-relative paths for exported files so tree backups can be verified accurately.
|
||||
- Add SQLite manifest migration for the new `path` column.
|
||||
- Upgrade `verify` to report missing files, zero-byte files, and size mismatches by default.
|
||||
- Add atomic staging export writes with per-asset temporary directories before final rename.
|
||||
- Deduplicate `failures.jsonl` entries by asset ID and track attempts plus latest failure time.
|
||||
- Add `failures list` and `failures clear` commands.
|
||||
- Add `retry-failed --clear-on-success`.
|
||||
- Add `status` command with text and JSON output.
|
||||
- Update release workflow to publish release notes from `RELEASE_NOTES.md`.
|
||||
- Rename release zip assets to include `macos-arm64` and document Apple Silicon-only releases.
|
||||
- Add `AGENT.md` with the minimal project/release rules for future agents.
|
||||
- Keep `CHANGELOG.md` as the canonical release history.
|
||||
|
||||
## v0.5.0
|
||||
|
||||
- Add JSONL and SQLite manifests with resumable skip tracking
|
||||
- Add structured export logging to export.log or SQLite logs table
|
||||
- Add --dry-run, --retry, --only-favorites, --media, --json, --verify, --date-template, and size filters
|
||||
- Add report, diff, verify, and retry-failed commands
|
||||
- Add failure tracking in failures.jsonl
|
||||
- Add configurable preview quality and export concurrency
|
||||
- Add album exclusion, since-date filtering, album collision handling, and config-file defaults
|
||||
- Expand README and CLI help output
|
||||
- Maintain 100% test coverage in the release pipeline
|
||||
Manifest, filtering, logging, and documentation release.
|
||||
|
||||
- Add JSONL and SQLite manifests with resumable skip tracking.
|
||||
- Add automatic conversion between JSONL and SQLite manifests.
|
||||
- Add structured export logging to `export.log` or SQLite `logs` table.
|
||||
- Add explicit exit codes: success, error, partial failure, and access denied.
|
||||
- Add `--quality` for JPEG preview compression.
|
||||
- Add `--concurrency` with progress slot cap.
|
||||
- Add `--exclude-album` with exact/glob matching.
|
||||
- Add `--since` date filtering.
|
||||
- Add `--dry-run`.
|
||||
- Add `--retry`.
|
||||
- Add `--only-favorites`.
|
||||
- Add `--media photos|videos|all`.
|
||||
- Add `--json` command summaries.
|
||||
- Add `--verify` integration.
|
||||
- Add `--format jpeg|heic|png` validation/hint.
|
||||
- Add `--min-size` and `--max-size` estimated pixel-count filters.
|
||||
- Add `--date-template` folder layout support.
|
||||
- Add `report`, `diff`, `verify`, and `retry-failed` commands.
|
||||
- Add failure tracking in `failures.jsonl`.
|
||||
- Add config-file defaults via `~/.photoscli.toml` or `PHOTOSCLI_CONFIG`.
|
||||
- Add album name collision handling for duplicate sibling album names.
|
||||
- Expand `README.md` and verbose CLI help.
|
||||
- Add 100% test coverage for the CLI and manifest/photos packages.
|
||||
|
||||
## v0.4.1
|
||||
|
||||
Documentation and release asset follow-up after v0.5.0.
|
||||
|
||||
- Add `USERGUIDE.md` as a practical end-user manual.
|
||||
- Link the user guide from `README.md`.
|
||||
- Add release zip packaging with binary, README, USERGUIDE, and CHANGELOG.
|
||||
- Attach `USERGUIDE.md` and the macOS zip package to the `v0.5.0` release.
|
||||
|
||||
## v0.4.0
|
||||
|
||||
- Scroll log for completed exports (copied, downloaded, skipped, failed)
|
||||
- Worker status lines with live download progress bars for cloud files
|
||||
- Color gradient progress bar (red to yellow to green)
|
||||
- Disk skip: files already on disk are skipped during backup-all
|
||||
- Parallel export with 3 workers for backup-all
|
||||
Progress and backup-all usability release.
|
||||
|
||||
- Add scroll log for completed exports: copied, downloaded, skipped, and failed.
|
||||
- Add worker status lines with live download progress bars for cloud files.
|
||||
- Add color-gradient progress bars from red to yellow to green.
|
||||
- Add disk skip for files already present during `backup-all`.
|
||||
- Add parallel export with multiple workers for `backup-all`.
|
||||
- Increase progress slot support in the bridge.
|
||||
- Improve progress rendering for large export sessions.
|
||||
|
||||
## v0.3.x
|
||||
|
||||
Published on Gitea as `v0.3.0` through `v0.3.5`. These releases are not represented by local git tags in the current clone, so the exact per-release commit mapping is unavailable here.
|
||||
|
||||
Known purpose of this series:
|
||||
|
||||
- Iterative release packaging and Gitea release workflow improvements.
|
||||
- Continued progress display and backup/export polish between `v0.2.5` and `v0.4.0`.
|
||||
- Preparation for the larger `v0.4.0` progress/backup release.
|
||||
|
||||
## v0.2.6
|
||||
|
||||
Published on Gitea but not represented by a local git tag in the current clone.
|
||||
|
||||
- Post-`v0.2.5` maintenance release in the progress/export series.
|
||||
|
||||
## v0.2.5
|
||||
|
||||
- Unicode progress bar with cloud download speed display
|
||||
- Add Unicode progress bar rendering.
|
||||
- Add cloud download speed display.
|
||||
|
||||
## v0.2.4
|
||||
|
||||
- Stop export loop on Ctrl+C instead of flooding failures
|
||||
- Stop export loop on Ctrl+C instead of flooding failures.
|
||||
- Improve graceful cancellation behavior after interrupt.
|
||||
|
||||
## v0.2.3
|
||||
|
||||
- Fix export write failures and Ctrl+C cancellation
|
||||
- Fix export write failures.
|
||||
- Fix Ctrl+C cancellation behavior.
|
||||
|
||||
## v0.2.2
|
||||
|
||||
No local tag is present for this version in the current clone.
|
||||
|
||||
## v0.2.1
|
||||
|
||||
- Add status messages during export
|
||||
- Fix parallel export progress display
|
||||
- Add status messages during export.
|
||||
- Fix parallel export progress display.
|
||||
|
||||
## v0.2.0
|
||||
|
||||
- Semaphore timeouts, error logging, dead code removal
|
||||
- Parallel exports
|
||||
- Add semaphore timeouts.
|
||||
- Add export error logging.
|
||||
- Remove dead code.
|
||||
- Add parallel exports.
|
||||
|
||||
## Pre-release
|
||||
|
||||
- Initial applephotos CLI with progress, cloud status, per-asset export
|
||||
- Renamed from applephotos to photoscli
|
||||
- Initial `applephotos` CLI with progress, cloud status, and per-asset export.
|
||||
- Add `.gitignore` and remove build artifacts from tracking.
|
||||
- Rename `applephotos` to `photoscli`.
|
||||
- Update module path to `gitea.k3s.k0.nu/tools/photocli`.
|
||||
- Add version flag.
|
||||
- Add Gitea release targets and release pipeline.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
BINARY := ./bin/photoscli
|
||||
MODULE := gitea.k3s.k0.nu/tools/photocli
|
||||
VERSION := 0.5.0
|
||||
VERSION := 0.9.1
|
||||
RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip
|
||||
RELEASE_NOTES := RELEASE_NOTES.md
|
||||
BRIDGE_DIR := bridge
|
||||
LDFLAGS := -X main.version=$(VERSION)
|
||||
OBJ := $(BRIDGE_DIR)/photokit_bridge.o
|
||||
@@ -10,7 +12,7 @@ STUB_LIB := $(BRIDGE_DIR)/libphotokit_bridge_stub.a
|
||||
GITEA_HOST := gitea-1.tail82444.ts.net
|
||||
GITEA_REPO := tools/photocli
|
||||
|
||||
.PHONY: all build clean test coverage tag release pipeline
|
||||
.PHONY: all build clean test coverage tag package release pipeline
|
||||
|
||||
all: build
|
||||
|
||||
@@ -18,7 +20,7 @@ $(LIB): $(OBJ)
|
||||
ar rcs $@ $<
|
||||
|
||||
$(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)
|
||||
ar rcs $@ $<
|
||||
@@ -43,14 +45,17 @@ coverage: $(STUB_LIB)
|
||||
go tool cover -func=coverage.out
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY) $(OBJ) $(LIB) $(STUB_OBJ) $(STUB_LIB) coverage.out
|
||||
rm -f $(BINARY) $(RELEASE_ZIP) $(OBJ) $(LIB) $(STUB_OBJ) $(STUB_LIB) coverage.out
|
||||
|
||||
package: build
|
||||
zip -j $(RELEASE_ZIP) $(BINARY) README.md USERGUIDE.md CHANGELOG.md
|
||||
|
||||
tag:
|
||||
git tag v$(VERSION)
|
||||
git push origin v$(VERSION)
|
||||
|
||||
release:
|
||||
tea releases create --repo $(GITEA_REPO) --tag v$(VERSION) --title "v$(VERSION)" --asset $(BINARY)
|
||||
release: package
|
||||
tea releases create --repo $(GITEA_REPO) --tag v$(VERSION) --title "v$(VERSION)" --note-file $(RELEASE_NOTES) --asset $(BINARY) --asset USERGUIDE.md --asset $(RELEASE_ZIP)
|
||||
|
||||
pipeline: clean test build
|
||||
@echo "--- verifying version ---"
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
The tool is designed for repeatable, resumable Photos backups rather than one-off drag-and-drop exports.
|
||||
|
||||
For a practical step-by-step manual with recommended backup workflows, recovery steps, automation examples, and troubleshooting, see `USERGUIDE.md`.
|
||||
|
||||
## Highlights
|
||||
|
||||
- List albums, photos, and the Photos folder/album tree.
|
||||
@@ -16,18 +18,36 @@ The tool is designed for repeatable, resumable Photos backups rather than one-of
|
||||
- Dry-run mode for planning large backups.
|
||||
- Filters for favorites, media type, date, estimated size, and excluded albums.
|
||||
- Failure tracking with `failures.jsonl` and `retry-failed`.
|
||||
- Deduplicated failure management with `failures list`, `failures clear`, and `retry-failed --clear-on-success`.
|
||||
- Verification, reporting, and diff commands for backup integrity.
|
||||
- Optional XMP sidecar verification with `verify --sidecar`.
|
||||
- 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.
|
||||
- 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.
|
||||
- 100% test coverage for the Go CLI and parsing layers.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Apple Silicon Mac, M1/M2/M3/M4 or newer (`darwin/arm64`).
|
||||
- macOS with Apple Photos.
|
||||
- Go 1.25 or newer.
|
||||
- Xcode command-line tools.
|
||||
- Photos privacy permission for the built binary or terminal app.
|
||||
|
||||
The production build uses cgo and links against Apple frameworks through the Objective-C bridge in `bridge/`.
|
||||
The provided release binary is Apple Silicon only. Intel Macs are not currently a supported release target. The production build uses cgo and links against Apple frameworks through the Objective-C bridge in `bridge/`.
|
||||
|
||||
## Release Assets
|
||||
|
||||
Release zip files are named with the supported architecture:
|
||||
|
||||
```text
|
||||
photoscli-<version>-macos-arm64.zip
|
||||
```
|
||||
|
||||
The zip contains the Apple Silicon binary plus README, USERGUIDE, and CHANGELOG.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -97,6 +117,7 @@ Verify a backup later:
|
||||
|
||||
```bash
|
||||
./bin/photoscli verify --out ./photos-backup --manifest sqlite
|
||||
./bin/photoscli verify --out ./photos-backup --deep
|
||||
```
|
||||
|
||||
## Commands
|
||||
@@ -150,6 +171,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 "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 --sidecar xmp --reverse-geocode
|
||||
```
|
||||
|
||||
### `backup-all`
|
||||
@@ -166,10 +188,12 @@ Useful examples:
|
||||
|
||||
```bash
|
||||
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 --since 2024-01-01 --sort newest
|
||||
photoscli backup-all --out ./backup --concurrency 8 --retry 3
|
||||
photoscli backup-all --out ./backup --dry-run --json
|
||||
photoscli backup-all --out ./backup --sidecar xmp --reverse-geocode
|
||||
```
|
||||
|
||||
### `report`
|
||||
@@ -217,6 +241,26 @@ photoscli retry-failed --out ./backup
|
||||
|
||||
This is useful after transient iCloud or network failures.
|
||||
|
||||
Use `--clear-on-success` to remove successfully retried assets from `failures.jsonl`.
|
||||
|
||||
### `failures`
|
||||
|
||||
Lists or clears deduplicated failure records.
|
||||
|
||||
```bash
|
||||
photoscli failures list --out ./backup
|
||||
photoscli failures clear --out ./backup
|
||||
```
|
||||
|
||||
### `status`
|
||||
|
||||
Shows a quick backup summary.
|
||||
|
||||
```bash
|
||||
photoscli status --out ./backup --manifest sqlite
|
||||
photoscli status --out ./backup --manifest sqlite --json
|
||||
```
|
||||
|
||||
## Export Flags
|
||||
|
||||
Common flags for `export` and `backup-all`:
|
||||
@@ -230,6 +274,7 @@ Common flags for `export` and `backup-all`:
|
||||
- `--dry-run`: print planned exports without writing files, manifests, or logs.
|
||||
- `--json`: print a machine-readable summary to stdout.
|
||||
- `--verify`: run manifest/file verification after export.
|
||||
- `--sidecar` with `verify`: also verify expected `.xmp` sidecars.
|
||||
- `--log`: enable structured export logging.
|
||||
- `--manifest jsonl|sqlite`: choose manifest backend, default `jsonl`.
|
||||
- `--no-manifest`: disable manifest reads/writes.
|
||||
@@ -241,6 +286,9 @@ Common flags for `export` and `backup-all`:
|
||||
- `--min-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.
|
||||
- `--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`.
|
||||
|
||||
`backup-all` also supports:
|
||||
@@ -293,9 +341,60 @@ With SQLite manifest mode, logs are written to the `logs` table in `downloads.db
|
||||
|
||||
Logged events include session start/end, completed exports, skipped assets, and failures. Each event includes timestamp, level, event name, asset ID, album, filename, size, cloud state, duration, and message where available.
|
||||
|
||||
## XMP Sidecars
|
||||
|
||||
Write archival metadata sidecars with:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```text
|
||||
IMG_0001.jpg -> 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.
|
||||
|
||||
Sidecars also include richer public PhotoKit metadata where available: modification date, duration, hidden state, adjustment state, media subtypes, source type, playback style, burst data, GPS coordinates, adjustment info, structured asset resources, standard XMP dates, EXIF GPS coordinates, favorite rating, and album/folder keywords. Add `--reverse-geocode` to include cached address fields from Apple MapKit for assets with GPS coordinates. Reverse geocoding requires macOS 26 or newer; on older macOS versions the export continues and XMP still includes GPS coordinates.
|
||||
|
||||
Control XMP location metadata with `--xmp-privacy keep|strip-location|strip-address`. The default is `keep`. Use `strip-address` to omit reverse-geocoded address fields while keeping GPS coordinates, or `strip-location` to omit both GPS and address fields.
|
||||
|
||||
Control generated XMP keywords and ratings with `--xmp-keywords album-path|album|none` and `--xmp-rating favorite|none`. Defaults preserve existing behavior: album/folder keywords and favorite assets mapped to `xmp:Rating="5"`.
|
||||
|
||||
Verify generated sidecars with:
|
||||
|
||||
```bash
|
||||
photoscli verify --out ./backup --sidecar
|
||||
```
|
||||
|
||||
For stricter checks against recent photoscli-generated XMP sidecars:
|
||||
|
||||
```bash
|
||||
photoscli verify --out ./backup --sidecar --strict
|
||||
```
|
||||
|
||||
Inspect one generated sidecar with:
|
||||
|
||||
```bash
|
||||
photoscli sidecar inspect ./backup/IMG_0001.xmp
|
||||
photoscli sidecar inspect ./backup/IMG_0001.xmp --json
|
||||
```
|
||||
|
||||
Refresh sidecars for files already present in a manifest-backed export without rewriting media files:
|
||||
|
||||
```bash
|
||||
photoscli backup-all --out ./backup --sidecar xmp --metadata-only
|
||||
```
|
||||
|
||||
## Failure Tracking
|
||||
|
||||
Failed exports are appended to:
|
||||
Failed exports are deduplicated by asset ID and stored in:
|
||||
|
||||
```text
|
||||
failures.jsonl
|
||||
@@ -307,6 +406,12 @@ Retry them with:
|
||||
photoscli retry-failed --out ./backup
|
||||
```
|
||||
|
||||
Clear successful retries automatically:
|
||||
|
||||
```bash
|
||||
photoscli retry-failed --out ./backup --clear-on-success
|
||||
```
|
||||
|
||||
Use `--retry N` during normal exports to handle transient iCloud or network failures automatically.
|
||||
|
||||
## Configuration File
|
||||
@@ -398,3 +503,9 @@ Objective-C returns JSON to Go. Tests use the stub bridge and do not require rea
|
||||
- Album title lookup uses exact title matching. Use PhotoKit local identifiers when names are ambiguous.
|
||||
- iCloud-backed assets may trigger downloads and can fail due to network or account state.
|
||||
- `--min-size` and `--max-size` currently use estimated pixel count from dimensions, not encoded file size.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- `0.8.x`: make XMP sidecars as rich, standard-compatible, and complete as possible from public PhotoKit metadata.
|
||||
- `0.9.0` through `0.9.5`: checksums, deep verification, manifest repair, cleanup, doctor, and backup-integrity hardening.
|
||||
- After `0.9.5`: opt-in Vision/Core ML analysis for photoscli-generated face/object/animal/scene metadata.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# v0.9.1
|
||||
|
||||
This release adds deep checksum verification for manifest-backed backups.
|
||||
|
||||
## Highlights
|
||||
|
||||
- Add `verify --deep` to recompute SHA-256 checksums when manifest entries include checksums.
|
||||
- Report `checksum-mismatch` when file contents differ from the manifest checksum.
|
||||
- Keep normal `verify` unchanged unless `--deep` is selected.
|
||||
- `--deep` works with JSONL and SQLite manifests.
|
||||
|
||||
## Assets
|
||||
|
||||
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
|
||||
- `photoscli-0.9.1-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG.
|
||||
- `USERGUIDE.md`: standalone user guide.
|
||||
|
||||
Intel Macs are not currently a supported release target.
|
||||
+860
@@ -0,0 +1,860 @@
|
||||
# photoscli User Guide
|
||||
|
||||
This guide explains how to use `photoscli` safely for Apple Photos exports and backups. It is written for day-to-day users who want reliable commands, clear recovery steps, and predictable backup behavior.
|
||||
|
||||
For a compact command reference, see `README.md` or run:
|
||||
|
||||
```bash
|
||||
photoscli help
|
||||
```
|
||||
|
||||
## What photoscli Is For
|
||||
|
||||
`photoscli` exports assets from Apple Photos on macOS.
|
||||
|
||||
It is especially useful when you want to:
|
||||
|
||||
- Back up the whole Photos album tree to normal folders.
|
||||
- Export one album for sharing or archiving.
|
||||
- Resume a large export without starting over.
|
||||
- Keep a manifest of already-exported assets.
|
||||
- Retry transient iCloud failures.
|
||||
- Verify that files referenced by a manifest exist on disk.
|
||||
- Detect missing, zero-byte, and size-mismatched manifest files.
|
||||
- Store optional SHA-256 checksums in manifests.
|
||||
- Inspect or clear deduplicated failure records.
|
||||
- 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.
|
||||
|
||||
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.
|
||||
|
||||
## Before You Start
|
||||
|
||||
You need:
|
||||
|
||||
- An Apple Silicon Mac, M1/M2/M3/M4 or newer. The prebuilt release binary is `darwin/arm64` only.
|
||||
- macOS.
|
||||
- Apple Photos library available on the machine.
|
||||
- Photos permission granted to the terminal app or binary.
|
||||
- Enough disk space for the export.
|
||||
- A built `photoscli` binary.
|
||||
|
||||
Build it from the repo:
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
Run the binary directly:
|
||||
|
||||
```bash
|
||||
./bin/photoscli version
|
||||
```
|
||||
|
||||
If you install or copy it somewhere else, replace `./bin/photoscli` in examples with `photoscli`.
|
||||
|
||||
Intel Macs are not currently a supported release target. If Intel support is added later, release assets will include a separate architecture-specific package.
|
||||
|
||||
## First Run And Permissions
|
||||
|
||||
Start with a harmless command:
|
||||
|
||||
```bash
|
||||
./bin/photoscli albums
|
||||
```
|
||||
|
||||
macOS may ask for Photos access. Allow it.
|
||||
|
||||
If access is denied, open:
|
||||
|
||||
```text
|
||||
System Settings > Privacy & Security > Photos
|
||||
```
|
||||
|
||||
Then enable access for the terminal application you use, such as Terminal, iTerm, or another launcher. Depending on how you start the binary, macOS may associate the permission with the terminal app rather than the binary itself.
|
||||
|
||||
Verify access:
|
||||
|
||||
```bash
|
||||
./bin/photoscli tree
|
||||
```
|
||||
|
||||
## Recommended Backup Strategy
|
||||
|
||||
For most users, the safest full-library backup command is:
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all \
|
||||
--out /Volumes/BackupDrive/PhotosExport \
|
||||
--manifest sqlite \
|
||||
--log \
|
||||
--retry 3 \
|
||||
--concurrency 4
|
||||
```
|
||||
|
||||
This gives you:
|
||||
|
||||
- Full album-tree export.
|
||||
- SQLite manifest for fast resumable backups.
|
||||
- Structured logs in the same database.
|
||||
- Automatic retries for transient failures.
|
||||
- Moderate concurrency that is usually safe for large libraries.
|
||||
|
||||
Before running it for real, run a dry-run:
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all \
|
||||
--out /Volumes/BackupDrive/PhotosExport \
|
||||
--manifest sqlite \
|
||||
--dry-run \
|
||||
--json
|
||||
```
|
||||
|
||||
## Preview Export vs Original Export
|
||||
|
||||
By default, `photoscli` exports optimized preview images.
|
||||
|
||||
Preview export:
|
||||
|
||||
- Produces smaller files.
|
||||
- Uses `--size` and `--quality`.
|
||||
- Is good for browsing, sharing, and lightweight archives.
|
||||
- May not preserve original file bytes or all original metadata.
|
||||
|
||||
Original export:
|
||||
|
||||
- Uses `--originals`.
|
||||
- Exports original asset resources where PhotoKit exposes them.
|
||||
- Ignores `--size` and `--quality`.
|
||||
- Uses more disk space.
|
||||
- Is usually the better choice for archival backups.
|
||||
|
||||
Preview example:
|
||||
|
||||
```bash
|
||||
./bin/photoscli export --album-id "Vacation" --out ./VacationPreview --size 2048 --quality 90
|
||||
```
|
||||
|
||||
Original example:
|
||||
|
||||
```bash
|
||||
./bin/photoscli export --album-id "Vacation" --out ./VacationOriginals --originals
|
||||
```
|
||||
|
||||
## Export One Album
|
||||
|
||||
List albums first:
|
||||
|
||||
```bash
|
||||
./bin/photoscli albums
|
||||
```
|
||||
|
||||
Export by title:
|
||||
|
||||
```bash
|
||||
./bin/photoscli export --album-id "Vacation" --out ./Vacation
|
||||
```
|
||||
|
||||
Export by PhotoKit local identifier:
|
||||
|
||||
```bash
|
||||
./bin/photoscli export --album-id "5E9F.../L0/001" --out ./Vacation
|
||||
```
|
||||
|
||||
Use the local identifier when album names are duplicated or ambiguous.
|
||||
|
||||
Recommended one-album archival export:
|
||||
|
||||
```bash
|
||||
./bin/photoscli export \
|
||||
--album-id "Vacation" \
|
||||
--out ./VacationOriginals \
|
||||
--originals \
|
||||
--manifest sqlite \
|
||||
--log \
|
||||
--retry 3
|
||||
```
|
||||
|
||||
## Back Up The Whole Library
|
||||
|
||||
Use `backup-all` to export every album into a folder tree matching Photos folders and albums.
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all --out ./PhotosBackup
|
||||
```
|
||||
|
||||
Recommended full backup:
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all \
|
||||
--out ./PhotosBackup \
|
||||
--manifest sqlite \
|
||||
--log \
|
||||
--retry 3 \
|
||||
--concurrency 4
|
||||
```
|
||||
|
||||
Recommended full original backup:
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all \
|
||||
--out ./PhotosOriginals \
|
||||
--originals \
|
||||
--manifest sqlite \
|
||||
--log \
|
||||
--retry 3 \
|
||||
--concurrency 4
|
||||
```
|
||||
|
||||
If two sibling albums have the same name, `photoscli` adds the album ID to the generated folder name so the folders do not collide.
|
||||
|
||||
## Dry Runs
|
||||
|
||||
Dry-runs are strongly recommended before large exports.
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all --out ./PhotosBackup --dry-run
|
||||
```
|
||||
|
||||
With JSON output:
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all --out ./PhotosBackup --dry-run --json
|
||||
```
|
||||
|
||||
Dry-run mode does not write exported files, manifests, logs, or failure files. It prints what would be exported.
|
||||
|
||||
Use dry-run when:
|
||||
|
||||
- You are testing filters.
|
||||
- You are checking output paths.
|
||||
- You are preparing a large first-time backup.
|
||||
- You are validating an exclude list.
|
||||
|
||||
## Incremental Backups
|
||||
|
||||
Incremental backups are enabled by manifests.
|
||||
|
||||
Run the same backup command repeatedly:
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all \
|
||||
--out ./PhotosBackup \
|
||||
--manifest sqlite \
|
||||
--log \
|
||||
--retry 3
|
||||
```
|
||||
|
||||
Already-exported assets are skipped based on the manifest.
|
||||
|
||||
For new assets since a date:
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all \
|
||||
--out ./PhotosBackup \
|
||||
--manifest sqlite \
|
||||
--since 2024-01-01
|
||||
```
|
||||
|
||||
The `--since` flag accepts:
|
||||
|
||||
- `YYYY-MM-DD`
|
||||
- RFC3339, for example `2024-01-01T00:00:00Z`
|
||||
|
||||
## Manifests
|
||||
|
||||
Manifests track exported asset IDs and allow safe resume behavior.
|
||||
|
||||
JSONL manifest:
|
||||
|
||||
```text
|
||||
downloads.jsonl
|
||||
```
|
||||
|
||||
SQLite manifest:
|
||||
|
||||
```text
|
||||
downloads.db
|
||||
```
|
||||
|
||||
Use JSONL when:
|
||||
|
||||
- You want a simple append-friendly text file.
|
||||
- Your library is small or medium-sized.
|
||||
- You want easy inspection with normal text tools.
|
||||
|
||||
Use SQLite when:
|
||||
|
||||
- Your library is large.
|
||||
- You want faster lookup behavior.
|
||||
- You want logs stored in the same database.
|
||||
- You plan to run repeated backups over time.
|
||||
|
||||
Recommended for serious backups:
|
||||
|
||||
```bash
|
||||
--manifest sqlite
|
||||
```
|
||||
|
||||
Disable manifests only for temporary exports:
|
||||
|
||||
```bash
|
||||
./bin/photoscli export --album-id "Scratch" --out ./Scratch --no-manifest
|
||||
```
|
||||
|
||||
Without a manifest, `photoscli` cannot use manifest-based resume behavior.
|
||||
|
||||
## Logging
|
||||
|
||||
Enable logs with:
|
||||
|
||||
```bash
|
||||
--log
|
||||
```
|
||||
|
||||
For JSONL or no-manifest exports, logs go to:
|
||||
|
||||
```text
|
||||
export.log
|
||||
```
|
||||
|
||||
For SQLite manifests, logs go to the `logs` table in:
|
||||
|
||||
```text
|
||||
downloads.db
|
||||
```
|
||||
|
||||
Logged events include:
|
||||
|
||||
- Session start.
|
||||
- Session end.
|
||||
- Export completed.
|
||||
- Export skipped.
|
||||
- Export failed.
|
||||
|
||||
Use logs when:
|
||||
|
||||
- You are running unattended backups.
|
||||
- You need auditability.
|
||||
- You want details about failed or skipped assets.
|
||||
|
||||
## Failed Exports And Retries
|
||||
|
||||
Failed exports are written to:
|
||||
|
||||
```text
|
||||
failures.jsonl
|
||||
```
|
||||
|
||||
Failure records are deduplicated by asset ID. Repeated failures update the existing record and increment the attempt count.
|
||||
|
||||
For transient failures, first use retries during normal export:
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all --out ./PhotosBackup --retry 3
|
||||
```
|
||||
|
||||
If failures remain, retry later:
|
||||
|
||||
```bash
|
||||
./bin/photoscli retry-failed --out ./PhotosBackup
|
||||
```
|
||||
|
||||
Clear successful retry records automatically:
|
||||
|
||||
```bash
|
||||
./bin/photoscli retry-failed --out ./PhotosBackup --clear-on-success
|
||||
```
|
||||
|
||||
List or clear failure records:
|
||||
|
||||
```bash
|
||||
./bin/photoscli failures list --out ./PhotosBackup
|
||||
./bin/photoscli failures clear --out ./PhotosBackup
|
||||
```
|
||||
|
||||
Typical reasons for failures:
|
||||
|
||||
- iCloud asset not currently downloadable.
|
||||
- Network interruption.
|
||||
- Photos permission issue.
|
||||
- Destination disk unavailable.
|
||||
- Destination disk full.
|
||||
- Invalid or changing Photos library state.
|
||||
|
||||
After retrying, run verification:
|
||||
|
||||
```bash
|
||||
./bin/photoscli verify --out ./PhotosBackup --manifest sqlite
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
Run verification after large backups:
|
||||
|
||||
```bash
|
||||
./bin/photoscli verify --out ./PhotosBackup --manifest sqlite
|
||||
```
|
||||
|
||||
Verification checks that manifest entries point to files on disk. It also reports zero-byte files and size mismatches when the manifest has a recorded size.
|
||||
|
||||
It exits with:
|
||||
|
||||
- `0` when all checked files exist.
|
||||
- `2` when files are missing.
|
||||
- `1` for argument or runtime errors.
|
||||
|
||||
You can also verify immediately after export:
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all --out ./PhotosBackup --manifest sqlite --verify
|
||||
```
|
||||
|
||||
## Reporting
|
||||
|
||||
Show manifest and failure counts:
|
||||
|
||||
```bash
|
||||
./bin/photoscli report --out ./PhotosBackup --manifest sqlite
|
||||
```
|
||||
|
||||
Example output:
|
||||
|
||||
```text
|
||||
entries 12345
|
||||
failures 2
|
||||
```
|
||||
|
||||
Use this after long runs to quickly check whether work was recorded and whether failures occurred.
|
||||
|
||||
## Diffing An Album Against A Backup
|
||||
|
||||
Use `diff` to find album assets that are not in the manifest:
|
||||
|
||||
```bash
|
||||
./bin/photoscli diff --album-id "Vacation" --out ./PhotosBackup --manifest sqlite
|
||||
```
|
||||
|
||||
Missing assets are printed as:
|
||||
|
||||
```text
|
||||
<asset-id> <filename>
|
||||
```
|
||||
|
||||
Exit code is `2` if missing assets are found.
|
||||
|
||||
## Filtering
|
||||
|
||||
### Favorites Only
|
||||
|
||||
```bash
|
||||
./bin/photoscli export --album-id "Favorites" --out ./Favorites --only-favorites
|
||||
```
|
||||
|
||||
### Media Type
|
||||
|
||||
Photos only, the default:
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all --out ./PhotosBackup --media photos
|
||||
```
|
||||
|
||||
Videos only:
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all --out ./VideoBackup --media videos --include-videos
|
||||
```
|
||||
|
||||
Everything:
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all --out ./FullBackup --media all --include-videos
|
||||
```
|
||||
|
||||
### Exclude Albums
|
||||
|
||||
Exclude exact album names:
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all --out ./PhotosBackup --exclude-album "Recently Deleted"
|
||||
```
|
||||
|
||||
Exclude by glob:
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all --out ./PhotosBackup --exclude-album "Temp*"
|
||||
```
|
||||
|
||||
Use multiple exclusions:
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all \
|
||||
--out ./PhotosBackup \
|
||||
--exclude-album "Recently Deleted" \
|
||||
--exclude-album "Temp*" \
|
||||
--exclude-album "Screenshots"
|
||||
```
|
||||
|
||||
### Date Filter
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all --out ./PhotosBackup --since 2024-01-01
|
||||
```
|
||||
|
||||
### Estimated Size Filter
|
||||
|
||||
`--min-size` and `--max-size` currently filter by estimated pixel count from dimensions, not encoded file size.
|
||||
|
||||
```bash
|
||||
./bin/photoscli export --album-id "Large" --out ./LargeOnly --min-size 12000000
|
||||
```
|
||||
|
||||
## Date-Based Folder Layouts
|
||||
|
||||
Use `--date-template` to append date folders based on asset creation date.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
./bin/photoscli export \
|
||||
--album-id "Vacation" \
|
||||
--out ./Vacation \
|
||||
--date-template YYYY/MM/DD
|
||||
```
|
||||
|
||||
Possible output:
|
||||
|
||||
```text
|
||||
Vacation/
|
||||
2024/
|
||||
07/
|
||||
15/
|
||||
0000_....jpg
|
||||
```
|
||||
|
||||
Supported tokens:
|
||||
|
||||
- `YYYY`
|
||||
- `MM`
|
||||
- `DD`
|
||||
|
||||
Assets without parseable creation dates stay in the base output path.
|
||||
|
||||
## XMP Sidecars
|
||||
|
||||
Use XMP sidecars when you want portable metadata next to exported files:
|
||||
|
||||
```bash
|
||||
./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:
|
||||
|
||||
```text
|
||||
IMG_0001.jpg -> IMG_0001.xmp
|
||||
IMG_0001.HEIC -> IMG_0001.xmp
|
||||
IMG_0001.jpg -> IMG_0001.json
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Configuration File
|
||||
|
||||
You can store default values in:
|
||||
|
||||
```text
|
||||
~/.photoscli.toml
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```toml
|
||||
size = 2048
|
||||
quality = 90
|
||||
concurrency = 4
|
||||
manifest = "sqlite"
|
||||
sort = "newest"
|
||||
media = "photos"
|
||||
retry = 3
|
||||
log = true
|
||||
sidecar = "xmp"
|
||||
reverse-geocode = true
|
||||
```
|
||||
|
||||
Use a custom config path:
|
||||
|
||||
```bash
|
||||
PHOTOSCLI_CONFIG=./photoscli.toml ./bin/photoscli backup-all --out ./PhotosBackup
|
||||
```
|
||||
|
||||
Command-line flags override config-file defaults.
|
||||
|
||||
Recommended config for recurring backups:
|
||||
|
||||
```toml
|
||||
manifest = "sqlite"
|
||||
log = true
|
||||
retry = 3
|
||||
concurrency = 4
|
||||
sort = "oldest"
|
||||
media = "photos"
|
||||
quality = 90
|
||||
size = 2048
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all --out ./PhotosBackup
|
||||
```
|
||||
|
||||
## Automation Examples
|
||||
|
||||
### Weekly Incremental Backup
|
||||
|
||||
```bash
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
OUT="/Volumes/BackupDrive/PhotosExport"
|
||||
BIN="$HOME/bin/photoscli"
|
||||
|
||||
"$BIN" backup-all \
|
||||
--out "$OUT" \
|
||||
--manifest sqlite \
|
||||
--log \
|
||||
--retry 3 \
|
||||
--concurrency 4 \
|
||||
--json
|
||||
|
||||
"$BIN" verify --out "$OUT" --manifest sqlite
|
||||
```
|
||||
|
||||
### Export Favorites For Sharing
|
||||
|
||||
```bash
|
||||
./bin/photoscli export \
|
||||
--album-id "Favorites" \
|
||||
--out ./FavoritesShare \
|
||||
--only-favorites \
|
||||
--size 2048 \
|
||||
--quality 88 \
|
||||
--no-manifest
|
||||
```
|
||||
|
||||
### Archive Originals From One Album
|
||||
|
||||
```bash
|
||||
./bin/photoscli export \
|
||||
--album-id "Family Archive" \
|
||||
--out ./FamilyArchiveOriginals \
|
||||
--originals \
|
||||
--manifest sqlite \
|
||||
--log \
|
||||
--retry 3
|
||||
```
|
||||
|
||||
### Backup Everything Including Videos
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all \
|
||||
--out ./FullPhotosBackup \
|
||||
--media all \
|
||||
--include-videos \
|
||||
--manifest sqlite \
|
||||
--log \
|
||||
--retry 3
|
||||
```
|
||||
|
||||
## Interpreting Exit Codes
|
||||
|
||||
`photoscli` uses stable exit codes for scripts:
|
||||
|
||||
- `0`: success.
|
||||
- `1`: invalid arguments, runtime error, or all exports failed.
|
||||
- `2`: partial failure, diff found missing manifest entries, or verify found missing files.
|
||||
- `3`: Photos access denied.
|
||||
|
||||
Example shell handling:
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all --out ./PhotosBackup --manifest sqlite --log
|
||||
rc=$?
|
||||
|
||||
case "$rc" in
|
||||
0) echo "backup succeeded" ;;
|
||||
2) echo "backup partially succeeded; inspect failures.jsonl" ;;
|
||||
3) echo "Photos access denied; check macOS privacy settings" ;;
|
||||
*) echo "backup failed with exit code $rc" ;;
|
||||
esac
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Access Denied
|
||||
|
||||
Symptom:
|
||||
|
||||
```text
|
||||
error: access denied
|
||||
```
|
||||
|
||||
Fix:
|
||||
|
||||
- Open System Settings.
|
||||
- Go to Privacy & Security > Photos.
|
||||
- Enable access for your terminal app or launcher.
|
||||
- Run `photoscli albums` again.
|
||||
|
||||
### iCloud Assets Fail To Export
|
||||
|
||||
Try:
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all --out ./PhotosBackup --retry 3
|
||||
```
|
||||
|
||||
If failures remain:
|
||||
|
||||
```bash
|
||||
./bin/photoscli retry-failed --out ./PhotosBackup
|
||||
```
|
||||
|
||||
Also check:
|
||||
|
||||
- Network connectivity.
|
||||
- iCloud Photos status.
|
||||
- Whether Photos.app can open/download the asset.
|
||||
|
||||
### Destination Disk Is Full
|
||||
|
||||
Free space, then rerun the same command. The manifest should skip already-exported assets and continue with remaining work.
|
||||
|
||||
### Duplicate Album Names
|
||||
|
||||
`backup-all` automatically appends the album ID to duplicate sibling album names. If you want exact control, export ambiguous albums individually using their PhotoKit local identifiers.
|
||||
|
||||
### Too Slow
|
||||
|
||||
Try a moderate concurrency increase:
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all --out ./PhotosBackup --concurrency 8
|
||||
```
|
||||
|
||||
If your library is heavily iCloud-backed, too much concurrency may increase transient failures. Use `--retry 3` and reduce concurrency if needed.
|
||||
|
||||
### Too Much Output Or Hard To Parse
|
||||
|
||||
Use JSON summary output:
|
||||
|
||||
```bash
|
||||
./bin/photoscli backup-all --out ./PhotosBackup --json
|
||||
```
|
||||
|
||||
Use structured logs:
|
||||
|
||||
```bash
|
||||
./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`.
|
||||
|
||||
Deep-verify checksum-backed manifests:
|
||||
|
||||
```bash
|
||||
./bin/photoscli verify --out ./PhotosBackup --deep
|
||||
```
|
||||
|
||||
`--deep` recomputes SHA-256 only for manifest entries that already have checksum metadata.
|
||||
|
||||
## Safe Operating Practices
|
||||
|
||||
- Run `--dry-run` before the first large backup.
|
||||
- Use `--manifest sqlite` for long-term backups.
|
||||
- Use `--log` for unattended runs.
|
||||
- Use `--retry 3` for iCloud-heavy libraries.
|
||||
- Run `verify` after large runs.
|
||||
- Keep the destination on a reliable disk.
|
||||
- Do not use `--no-manifest` for backups you expect to resume.
|
||||
- Prefer album local identifiers when names are duplicated.
|
||||
|
||||
## Limitations
|
||||
|
||||
- This is macOS-only.
|
||||
- Real Photos access requires macOS privacy permission.
|
||||
- `--format heic|png` is currently a validated CLI hint; true non-JPEG preview output still needs bridge support.
|
||||
- `--min-size` and `--max-size` use estimated pixel count from dimensions, not encoded file size.
|
||||
- Original export depends on asset resources exposed by PhotoKit.
|
||||
- iCloud-backed assets may require network downloads and can fail for reasons outside the CLI's control.
|
||||
- The manifest records exported asset IDs; it is not a cryptographic integrity database.
|
||||
@@ -20,6 +20,8 @@ char *photos_list_albums_json(void);
|
||||
|
||||
char *photos_list_assets_json(const char *album_id);
|
||||
|
||||
char *photos_reverse_geocode_json(double latitude, double longitude);
|
||||
|
||||
char *photos_export_preview_json(
|
||||
const char *asset_id,
|
||||
const char *output_dir,
|
||||
|
||||
+140
-3
@@ -1,6 +1,8 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <Photos/Photos.h>
|
||||
#import <CoreLocation/CoreLocation.h>
|
||||
#import <MapKit/MapKit.h>
|
||||
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
|
||||
#import <objc/message.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) {
|
||||
if (!date) return 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 *creationDateStr = iso8601_string(asset.creationDate);
|
||||
NSString *modificationDateStr = iso8601_string(asset.modificationDate);
|
||||
|
||||
NSMutableArray *resourcesList = [NSMutableArray arrayWithCapacity:resources.count];
|
||||
for (PHAssetResource *res in resources) {
|
||||
NSString *resTypeStr = resource_type_string(res.type);
|
||||
NSString *uti = res.uniformTypeIdentifier ?: @"";
|
||||
BOOL isLocal = resource_is_locally_available(res);
|
||||
[resourcesList addObject:@{
|
||||
NSMutableDictionary *resourceDict = [NSMutableDictionary dictionaryWithDictionary:@{
|
||||
@"type": resTypeStr,
|
||||
@"filename": res.originalFilename ?: @"",
|
||||
@"uti": uti,
|
||||
@"local": @(isLocal)
|
||||
}];
|
||||
NSNumber *size = resource_file_size(res);
|
||||
if (size) resourceDict[@"size"] = size;
|
||||
[resourcesList addObject:resourceDict];
|
||||
}
|
||||
|
||||
NSMutableDictionary *dict = [NSMutableDictionary dictionaryWithDictionary:@{
|
||||
@@ -374,17 +461,37 @@ char *photos_list_assets_json(const char *album_id) {
|
||||
@"filename": filename ?: @"",
|
||||
@"cloud": cloudStatus,
|
||||
@"mediaType": mediaTypeStr,
|
||||
@"mediaSubtypes": media_subtype_strings(asset.mediaSubtypes),
|
||||
@"sourceType": source_type_string(asset.sourceType),
|
||||
@"playbackStyle": playback_style_string(asset.playbackStyle),
|
||||
@"pixelWidth": @(asset.pixelWidth),
|
||||
@"pixelHeight": @(asset.pixelHeight),
|
||||
@"duration": @(asset.duration),
|
||||
@"isFavorite": @(asset.isFavorite)
|
||||
@"isFavorite": @(asset.isFavorite),
|
||||
@"isHidden": @(asset.isHidden),
|
||||
@"representsBurst": @(asset.representsBurst),
|
||||
@"burstSelectionTypes": burst_selection_type_strings(asset.burstSelectionTypes)
|
||||
}];
|
||||
if (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, *)) {
|
||||
dict[@"hasAdjustments"] = @(asset.hasAdjustments);
|
||||
}
|
||||
NSDictionary *adjustmentInfo = editing_input_info(asset);
|
||||
if (adjustmentInfo) {
|
||||
dict[@"adjustmentInfo"] = adjustmentInfo;
|
||||
}
|
||||
if (resourcesList.count > 0) {
|
||||
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)});
|
||||
}
|
||||
|
||||
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) {
|
||||
PHFetchResult<PHCollection *> *collections = [PHCollectionList fetchTopLevelUserCollectionsWithOptions:nil];
|
||||
|
||||
@@ -709,4 +846,4 @@ int photos_get_progress_slot_count(void) {
|
||||
|
||||
void photos_reset_progress_slots(void) {
|
||||
memset(progress_slots, 0, sizeof(progress_slots));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,11 @@ static int stub_access_rc = 0;
|
||||
static const char *stub_albums_json = "{\"albums\":[]}";
|
||||
static const char *stub_assets_json = "{\"assets\":[]}";
|
||||
static const char *stub_tree_json = "{\"collections\":[]}";
|
||||
static const char *stub_geocode_json = "{\"placemark\":{}}";
|
||||
static int stub_albums_null = 0;
|
||||
static int stub_assets_null = 0;
|
||||
static int stub_tree_null = 0;
|
||||
static int stub_geocode_null = 0;
|
||||
static int stub_cancelled = 0;
|
||||
static const char *stub_export_preview_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_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_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_assets_null(void) { stub_assets_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);
|
||||
}
|
||||
|
||||
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) {
|
||||
(void)asset_id;
|
||||
(void)output_dir;
|
||||
|
||||
+1119
-85
File diff suppressed because it is too large
Load Diff
+1170
-1
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
package manifest
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewEntryPath(t *testing.T) {
|
||||
e := newEntry("id1", "file.jpg", 123, "local")
|
||||
if e.ID != "id1" || e.Filename != "file.jpg" || e.Path != "file.jpg" || e.Size != 123 || e.Cloud != "local" || e.Exported == 0 {
|
||||
t.Fatalf("unexpected entry: %+v", e)
|
||||
}
|
||||
e = NewEntry("id2", "file2.jpg", "Album/file2.jpg", 456, "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)
|
||||
}
|
||||
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) {
|
||||
dir := t.TempDir()
|
||||
jm := LoadJSONL(dir)
|
||||
if err := jm.OpenAppend(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jm.AddEntry(Entry{ID: "x1", Filename: "file.jpg", Size: 3, Cloud: "local", Checksum: "sha256:abc"})
|
||||
jm.Close()
|
||||
loaded := LoadJSONL(dir).Entries()["x1"]
|
||||
if got := loaded.Path; got != "file.jpg" {
|
||||
t.Fatalf("jsonl path = %q", got)
|
||||
}
|
||||
if loaded.Checksum != "sha256:abc" {
|
||||
t.Fatalf("jsonl checksum = %q", loaded.Checksum)
|
||||
}
|
||||
|
||||
sdir := t.TempDir()
|
||||
sm, err := LoadSQLite(sdir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sm.OpenAppend(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sloaded := sm.Entries()["x1"]
|
||||
if got := sloaded.Path; got != "file.jpg" {
|
||||
t.Fatalf("sqlite path = %q", got)
|
||||
}
|
||||
if sloaded.Checksum != "sha256:def" {
|
||||
t.Fatalf("sqlite checksum = %q", sloaded.Checksum)
|
||||
}
|
||||
sm.Close()
|
||||
}
|
||||
@@ -40,8 +40,10 @@ func LoadJSONL(dir string) *jsonlManifest {
|
||||
type entryWithID struct {
|
||||
ID string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Size int64 `json:"size"`
|
||||
Cloud string `json:"cloud"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
Exported int64 `json:"exported"`
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
@@ -51,10 +53,16 @@ func LoadJSONL(dir string) *jsonlManifest {
|
||||
}
|
||||
var raw entryWithID
|
||||
if json.Unmarshal([]byte(line), &raw) == nil && raw.ID != "" {
|
||||
if raw.Path == "" {
|
||||
raw.Path = raw.Filename
|
||||
}
|
||||
m.entries[raw.ID] = Entry{
|
||||
ID: raw.ID,
|
||||
Filename: raw.Filename,
|
||||
Path: raw.Path,
|
||||
Size: raw.Size,
|
||||
Cloud: raw.Cloud,
|
||||
Checksum: raw.Checksum,
|
||||
Exported: raw.Exported,
|
||||
}
|
||||
}
|
||||
@@ -70,18 +78,26 @@ func (m *jsonlManifest) Has(id string) bool {
|
||||
}
|
||||
|
||||
func (m *jsonlManifest) Add(id string, filename string, size int64, cloud string) {
|
||||
m.AddEntry(newEntry(id, filename, size, cloud))
|
||||
}
|
||||
|
||||
func (m *jsonlManifest) AddEntry(entry Entry) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
entry := newEntry(id, filename, size, cloud)
|
||||
m.entries[id] = entry
|
||||
if entry.Path == "" {
|
||||
entry.Path = entry.Filename
|
||||
}
|
||||
m.entries[entry.ID] = entry
|
||||
if m.file != nil {
|
||||
data, _ := json.Marshal(struct {
|
||||
ID string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Size int64 `json:"size"`
|
||||
Cloud string `json:"cloud"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
Exported int64 `json:"exported"`
|
||||
}{ID: id, Filename: entry.Filename, 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([]byte("\n"))
|
||||
}
|
||||
|
||||
@@ -5,14 +5,17 @@ import "time"
|
||||
type Entry struct {
|
||||
ID string
|
||||
Filename string
|
||||
Path string
|
||||
Size int64
|
||||
Cloud string
|
||||
Checksum string
|
||||
Exported int64
|
||||
}
|
||||
|
||||
type Manifest interface {
|
||||
Has(id string) bool
|
||||
Add(id string, filename string, size int64, cloud string)
|
||||
AddEntry(entry Entry)
|
||||
Save() error
|
||||
Close()
|
||||
OpenAppend() error
|
||||
@@ -26,8 +29,23 @@ func newEntry(id, filename string, size int64, cloud string) Entry {
|
||||
return Entry{
|
||||
ID: id,
|
||||
Filename: filename,
|
||||
Path: filename,
|
||||
Size: size,
|
||||
Cloud: cloud,
|
||||
Exported: time.Now().Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
func NewEntry(id, filename, path string, size int64, cloud string) Entry {
|
||||
e := newEntry(id, filename, size, cloud)
|
||||
if path != "" {
|
||||
e.Path = path
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -49,7 +49,8 @@ func ConvertFromJSONL(dir string) (Manifest, error) {
|
||||
}
|
||||
|
||||
for id, e := range src.Entries() {
|
||||
dst.Add(id, e.Filename, e.Size, e.Cloud)
|
||||
e.ID = id
|
||||
dst.AddEntry(e)
|
||||
}
|
||||
|
||||
os.Remove(JSONLPath(dir))
|
||||
@@ -69,7 +70,8 @@ func ConvertFromSQLite(dir string) (Manifest, error) {
|
||||
}
|
||||
|
||||
for id, e := range src.Entries() {
|
||||
dst.Add(id, e.Filename, e.Size, e.Cloud)
|
||||
e.ID = id
|
||||
dst.AddEntry(e)
|
||||
}
|
||||
if err := dst.Save(); err != nil {
|
||||
return nil, fmt.Errorf("save jsonl: %w", err)
|
||||
|
||||
@@ -61,14 +61,18 @@ func (m *sqliteManifest) OpenAppend() error {
|
||||
_, err = execFn(`CREATE TABLE IF NOT EXISTS downloads (
|
||||
id TEXT PRIMARY KEY,
|
||||
filename TEXT NOT NULL DEFAULT '',
|
||||
path TEXT NOT NULL DEFAULT '',
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
cloud TEXT NOT NULL DEFAULT '',
|
||||
checksum TEXT NOT NULL DEFAULT '',
|
||||
exported INTEGER NOT NULL DEFAULT 0
|
||||
)`)
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return fmt.Errorf("create table: %w", err)
|
||||
}
|
||||
_, _ = 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)`)
|
||||
if err != nil {
|
||||
db.Close()
|
||||
@@ -91,12 +95,18 @@ func (m *sqliteManifest) Has(id string) bool {
|
||||
}
|
||||
|
||||
func (m *sqliteManifest) Add(id string, filename string, size int64, cloud string) {
|
||||
m.AddEntry(newEntry(id, filename, size, cloud))
|
||||
}
|
||||
|
||||
func (m *sqliteManifest) AddEntry(entry Entry) {
|
||||
if m.db == nil {
|
||||
return
|
||||
}
|
||||
entry := newEntry(id, filename, size, cloud)
|
||||
m.db.Exec(`INSERT OR REPLACE INTO downloads (id, filename, size, cloud, exported) VALUES (?, ?, ?, ?, ?)`,
|
||||
id, entry.Filename, entry.Size, entry.Cloud, entry.Exported)
|
||||
if entry.Path == "" {
|
||||
entry.Path = entry.Filename
|
||||
}
|
||||
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.Checksum, entry.Exported)
|
||||
}
|
||||
|
||||
func (m *sqliteManifest) Save() error {
|
||||
@@ -119,14 +129,17 @@ func (m *sqliteManifest) Entries() map[string]Entry {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]Entry)
|
||||
rows, err := m.db.Query(`SELECT id, filename, size, cloud, exported FROM downloads`)
|
||||
rows, err := m.db.Query(`SELECT id, filename, path, size, cloud, checksum, exported FROM downloads`)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var e Entry
|
||||
if err := rows.Scan(&e.ID, &e.Filename, &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 == "" {
|
||||
e.Path = e.Filename
|
||||
}
|
||||
out[e.ID] = e
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,10 +249,10 @@ func TestSQLiteCreateIndexError(t *testing.T) {
|
||||
return nil, err
|
||||
}
|
||||
m.execFunc = func(query string, args ...any) (sql.Result, error) {
|
||||
callCount++
|
||||
if callCount == 2 {
|
||||
if strings.Contains(query, "CREATE INDEX") {
|
||||
return nil, fmt.Errorf("injected CREATE INDEX error")
|
||||
}
|
||||
callCount++
|
||||
return db.Exec(query, args...)
|
||||
}
|
||||
return db, nil
|
||||
|
||||
@@ -9,6 +9,7 @@ type Bridge interface {
|
||||
RequestAccess() error
|
||||
ListAlbums() ([]Album, error)
|
||||
ListAssets(albumID string) ([]Asset, int, error)
|
||||
ReverseGeocode(latitude, longitude float64) (Placemark, error)
|
||||
ListTree() ([]CollectionNode, error)
|
||||
ExportPreview(assetID, outputDir string, targetSize, quality, 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
|
||||
}
|
||||
|
||||
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) {
|
||||
var errResp ErrorResponse
|
||||
if err := json.Unmarshal([]byte(jsonStr), &errResp); err == nil && errResp.Error != "" {
|
||||
|
||||
@@ -4,7 +4,7 @@ package photos
|
||||
|
||||
/*
|
||||
#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 <stdlib.h>
|
||||
*/
|
||||
@@ -45,6 +45,15 @@ func (*CgoBridge) ListAssets(albumID string) ([]Asset, int, error) {
|
||||
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) {
|
||||
cs := C.photos_list_tree_json()
|
||||
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_assets(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_assets_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 SetTestAssetsJSON(json string) { C.photos_test_set_assets(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 SetTestAssetsNull() { C.photos_test_set_assets_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))
|
||||
}
|
||||
|
||||
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) {
|
||||
cs := C.photos_list_tree_json()
|
||||
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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
+57
-11
@@ -6,17 +6,40 @@ type Album struct {
|
||||
}
|
||||
|
||||
type Asset struct {
|
||||
ID string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
Cloud string `json:"cloud"`
|
||||
MediaType string `json:"mediaType"`
|
||||
PixelWidth int `json:"pixelWidth"`
|
||||
PixelHeight int `json:"pixelHeight"`
|
||||
CreationDate *string `json:"creationDate,omitempty"`
|
||||
Duration float64 `json:"duration,omitempty"`
|
||||
IsFavorite bool `json:"isFavorite,omitempty"`
|
||||
HasAdjustments bool `json:"hasAdjustments,omitempty"`
|
||||
Resources []AssetResource `json:"resources,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
Cloud string `json:"cloud"`
|
||||
MediaType string `json:"mediaType"`
|
||||
MediaSubtypes []string `json:"mediaSubtypes,omitempty"`
|
||||
SourceType string `json:"sourceType,omitempty"`
|
||||
PlaybackStyle string `json:"playbackStyle,omitempty"`
|
||||
PixelWidth int `json:"pixelWidth"`
|
||||
PixelHeight int `json:"pixelHeight"`
|
||||
CreationDate *string `json:"creationDate,omitempty"`
|
||||
ModificationDate *string `json:"modificationDate,omitempty"`
|
||||
Duration float64 `json:"duration,omitempty"`
|
||||
IsFavorite bool `json:"isFavorite,omitempty"`
|
||||
IsHidden bool `json:"isHidden,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"`
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -24,6 +47,29 @@ type AssetResource struct {
|
||||
Filename string `json:"filename"`
|
||||
UTI string `json:"uti"`
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user