Compare commits

5 Commits

Author SHA1 Message Date
Ein Anderssono a51db37fdb v0.8.2: add metadata-only sidecars
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled
2026-06-15 01:48:32 +02:00
Ein Anderssono 9cd702628d v0.8.1: improve XMP sidecars
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled
2026-06-15 01:36:04 +02:00
Ein Anderssono fffb30023b v0.8.0: enrich XMP metadata
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled
2026-06-15 01:21:49 +02:00
Ein Anderssono 4fe4c15adf v0.7.0: add XMP sidecars
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled
2026-06-15 00:57:13 +02:00
Ein Anderssono 36832060d0 docs: clarify Apple Silicon release target
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled
2026-06-15 00:41:49 +02:00
16 changed files with 1602 additions and 70 deletions
+20
View File
@@ -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.
+43
View File
@@ -2,6 +2,47 @@
This changelog is maintained from git history plus the published Gitea release series. Future releases should update this file and publish matching release notes on the release page. This changelog is maintained from git history plus the published Gitea release series. Future releases should update this file and publish matching release notes on the release page.
## v0.8.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 ## v0.6.0
Backup integrity and recovery release. Backup integrity and recovery release.
@@ -15,6 +56,8 @@ Backup integrity and recovery release.
- Add `retry-failed --clear-on-success`. - Add `retry-failed --clear-on-success`.
- Add `status` command with text and JSON output. - Add `status` command with text and JSON output.
- Update release workflow to publish release notes from `RELEASE_NOTES.md`. - 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. - Keep `CHANGELOG.md` as the canonical release history.
## v0.5.0 ## v0.5.0
+3 -3
View File
@@ -1,7 +1,7 @@
BINARY := ./bin/photoscli BINARY := ./bin/photoscli
MODULE := gitea.k3s.k0.nu/tools/photocli MODULE := gitea.k3s.k0.nu/tools/photocli
VERSION := 0.6.0 VERSION := 0.8.2
RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos.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
LDFLAGS := -X main.version=$(VERSION) LDFLAGS := -X main.version=$(VERSION)
@@ -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 $@ $<
+59 -1
View File
@@ -20,18 +20,33 @@ For a practical step-by-step manual with recommended backup workflows, recovery
- Failure tracking with `failures.jsonl` and `retry-failed`. - Failure tracking with `failures.jsonl` and `retry-failed`.
- Deduplicated failure management with `failures list`, `failures clear`, and `retry-failed --clear-on-success`. - Deduplicated failure management with `failures list`, `failures clear`, and `retry-failed --clear-on-success`.
- Verification, reporting, and diff commands for backup integrity. - Verification, reporting, and diff commands for backup integrity.
- Optional XMP sidecar verification with `verify --sidecar`.
- Metadata-only XMP refresh for manifest-backed exports with `--metadata-only`.
- Status command for quick backup summaries. - Status command for quick backup summaries.
- Opt-in rich XMP sidecar metadata with `--sidecar xmp`.
- 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.
## Requirements ## Requirements
- Apple Silicon Mac, M1/M2/M3/M4 or newer (`darwin/arm64`).
- macOS with Apple Photos. - macOS with Apple Photos.
- Go 1.25 or newer. - Go 1.25 or newer.
- Xcode command-line tools. - Xcode command-line tools.
- Photos privacy permission for the built binary or terminal app. - 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 ## Build
@@ -154,6 +169,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`
@@ -174,6 +190,7 @@ photoscli backup-all --out ./backup --exclude-album "Recently Deleted" --exclude
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`
@@ -254,6 +271,7 @@ Common flags for `export` and `backup-all`:
- `--dry-run`: print planned exports without writing files, manifests, or logs. - `--dry-run`: print planned exports without writing files, manifests, or logs.
- `--json`: print a machine-readable summary to stdout. - `--json`: print a machine-readable summary to stdout.
- `--verify`: run manifest/file verification after export. - `--verify`: run manifest/file verification after export.
- `--sidecar` with `verify`: also verify expected `.xmp` sidecars.
- `--log`: enable structured export logging. - `--log`: enable structured export logging.
- `--manifest jsonl|sqlite`: choose manifest backend, default `jsonl`. - `--manifest jsonl|sqlite`: choose manifest backend, default `jsonl`.
- `--no-manifest`: disable manifest reads/writes. - `--no-manifest`: disable manifest reads/writes.
@@ -265,6 +283,9 @@ Common flags for `export` and `backup-all`:
- `--min-size <n>`: filter by estimated pixel count. - `--min-size <n>`: filter by estimated pixel count.
- `--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.
- `--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:
@@ -317,6 +338,37 @@ 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. 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
```
Sidecars are opt-in and use the exported file basename:
```text
IMG_0001.jpg -> IMG_0001.xmp
IMG_0001.HEIC -> IMG_0001.xmp
```
The XMP contains photoscli metadata such as asset ID, filenames, album, manifest path, media type, dimensions, favorite state, cloud state, export mode, version, exported timestamp, size, and creation date when available. If `--sidecar xmp` is explicitly selected and the sidecar cannot be written, that asset is treated as failed.
Sidecars also include richer public PhotoKit metadata where available: modification date, duration, hidden state, adjustment state, media subtypes, source type, playback style, burst data, GPS coordinates, adjustment info, structured asset resources, standard XMP dates, EXIF GPS coordinates, favorite rating, and album/folder keywords. Add `--reverse-geocode` to include cached address fields from Apple MapKit for assets with GPS coordinates. Reverse geocoding requires macOS 26 or newer; on older macOS versions the export continues and XMP still includes GPS coordinates.
Verify generated sidecars with:
```bash
photoscli verify --out ./backup --sidecar
```
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:
@@ -428,3 +480,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.
+11 -19
View File
@@ -1,27 +1,19 @@
# v0.6.0 # v0.8.2
This release focuses on backup integrity, recovery workflows, and clearer operational status. This release adds metadata-only XMP sidecar refresh for existing manifest-backed exports.
## Highlights ## Highlights
- Manifest entries now include relative paths, so tree backups can be verified accurately. - Add `--metadata-only` for `export` and `backup-all`.
- SQLite manifests migrate automatically to include the new path column. - Use manifest paths to find existing media files and write or refresh `.xmp` sidecars without re-exporting media.
- `verify` now detects missing files, zero-byte files, and size mismatches by default. - Require `--sidecar xmp` and an enabled manifest for metadata-only mode.
- Exports use per-asset staging directories and rename completed files into place when possible. - Leave media files untouched; only generated XMP sidecars are written.
- Failure tracking is deduplicated by asset ID and records attempts and latest error details. - Support `--reverse-geocode` during metadata-only refresh.
- New `failures list` and `failures clear` commands.
- `retry-failed --clear-on-success` removes successfully retried failures.
- New `status` command with text and JSON output.
- Release workflow now publishes these release notes to the release page.
## Recommended Upgrade Notes
- Existing JSONL manifests continue to load; entries without `path` fall back to `filename`.
- Existing SQLite manifests are migrated with `ALTER TABLE downloads ADD COLUMN path ...` during open.
- `verify` is stricter now and may report problems that older versions ignored.
## Assets ## Assets
- `photoscli`: macOS binary. - `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
- `photoscli-0.6.0-macos.zip`: binary plus README, USERGUIDE, and CHANGELOG. - `photoscli-0.8.2-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.
+50
View File
@@ -22,6 +22,8 @@ It is especially useful when you want to:
- 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.
- Inspect or clear deduplicated failure records. - 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. - 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.
@@ -30,6 +32,7 @@ It is not intended to replace Apple Photos, iCloud Photos, or Time Machine. Thin
You need: You need:
- An Apple Silicon Mac, M1/M2/M3/M4 or newer. The prebuilt release binary is `darwin/arm64` only.
- macOS. - macOS.
- Apple Photos library available on the machine. - Apple Photos library available on the machine.
- Photos permission granted to the terminal app or binary. - Photos permission granted to the terminal app or binary.
@@ -50,6 +53,8 @@ Run the binary directly:
If you install or copy it somewhere else, replace `./bin/photoscli` in examples with `photoscli`. 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 ## First Run And Permissions
Start with a harmless command: Start with a harmless command:
@@ -535,6 +540,49 @@ Supported tokens:
Assets without parseable creation dates stay in the base output path. 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
```
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
```
The XMP includes photoscli archive metadata such as asset ID, original filename, exported filename, album, manifest path, media type, dimensions, favorite state, hidden state, cloud state, export mode, version, exported time, size, creation date, modification date, duration, adjustment state, media subtypes, source type, playback style, burst data, GPS coordinates, adjustment info, structured asset resources, standard XMP date fields, EXIF GPS fields, favorite rating, and album/folder keywords when PhotoKit exposes them.
For address metadata from GPS coordinates, opt in to Apple's reverse geocoder:
```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.
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 ## Configuration File
You can store default values in: You can store default values in:
@@ -554,6 +602,8 @@ sort = "newest"
media = "photos" media = "photos"
retry = 3 retry = 3
log = true log = true
sidecar = "xmp"
reverse-geocode = true
``` ```
Use a custom config path: Use a custom config path:
+2
View File
@@ -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,
+140 -3
View File
@@ -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];
@@ -709,4 +846,4 @@ int photos_get_progress_slot_count(void) {
void photos_reset_progress_slots(void) { void photos_reset_progress_slots(void) {
memset(progress_slots, 0, sizeof(progress_slots)); memset(progress_slots, 0, sizeof(progress_slots));
} }
+11
View File
@@ -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;
+563 -32
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"encoding/xml"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -22,22 +23,29 @@ var (
configValues map[string]string configValues map[string]string
configLoaded bool configLoaded bool
mkdirTempFunc = os.MkdirTemp mkdirTempFunc = os.MkdirTemp
createTempFunc = os.CreateTemp
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
) )
type exportOptions struct { type exportOptions struct {
dryRun bool dryRun bool
retry int retry int
onlyFavorites bool onlyFavorites bool
media string media string
jsonOut bool jsonOut bool
verify bool verify bool
format string format string
minSize int64 sidecar string
maxSize int64 metadataOnly bool
dateTemplate string reverseGeocode bool
minSize int64
maxSize int64
dateTemplate string
} }
type commandSummary struct { type commandSummary struct {
@@ -105,6 +113,9 @@ DESCRIPTION
originals, keep resumable manifests, log structured export events, and verify originals, keep resumable manifests, log structured export events, and verify
backup integrity. backup integrity.
Prebuilt releases target Apple Silicon Macs only (darwin/arm64: M1/M2/M3/M4
or newer). Intel Macs are not currently a supported release target.
The tool is intended for repeatable backups. By default it records exported The tool is intended for repeatable backups. By default it records exported
asset IDs in a manifest so later runs can skip work already completed. asset IDs in a manifest so later runs can skip work already completed.
@@ -157,6 +168,7 @@ 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.
retry-failed --out <dir> retry-failed --out <dir>
Retry assets previously written to failures.jsonl. Retry assets previously written to failures.jsonl.
@@ -205,6 +217,18 @@ 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
Write opt-in XMP sidecar metadata next to each exported file. Default:
none. If XMP writing fails, the asset is counted as failed.
--metadata-only
With --sidecar xmp, write or refresh XMP sidecars for files already in
the manifest without exporting media files. Requires a manifest.
--reverse-geocode
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>
Include only assets on or after a date. Accepts YYYY-MM-DD or RFC3339. Include only assets on or after a date. Accepts YYYY-MM-DD or RFC3339.
@@ -273,6 +297,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.
@@ -423,6 +449,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 {
@@ -525,8 +555,17 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
fmt.Fprintf(stderr, "dry-run: %d assets would be exported to %s\n", total, outDir) fmt.Fprintf(stderr, "dry-run: %d assets would be exported to %s\n", total, outDir)
return exitOK return exitOK
} }
fmt.Fprintf(stderr, "exporting %d assets (%s) to %s...\n", total, exportMode(originals), outDir) var exported, failed int
exported, failed := exportAssets(assets, outDir, size, quality, concurrency, originals, total, stderr, bridge, "", noManifest, mf, enableLog, opts) if opts.metadataOnly {
m, _ := manifest.Open(outDir, mf)
defer m.Close()
entries := manifestEntries(m)
fmt.Fprintf(stderr, "writing metadata for %d assets to %s...\n", total, outDir)
exported, failed = metadataOnlyAssets(assets, outDir, originals, "", entries, opts, bridge)
} else {
fmt.Fprintf(stderr, "exporting %d assets (%s) to %s...\n", total, exportMode(originals), outDir)
exported, failed = exportAssets(assets, outDir, size, quality, concurrency, originals, total, stderr, bridge, "", noManifest, mf, enableLog, opts)
}
if opts.jsonOut { if opts.jsonOut {
writeJSONSummary(stdout, commandSummary{Exported: exported, Failed: failed, Total: total}) writeJSONSummary(stdout, commandSummary{Exported: exported, Failed: failed, Total: total})
} }
@@ -541,7 +580,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)
@@ -581,6 +622,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 {
@@ -654,13 +699,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)
@@ -728,6 +785,383 @@ func addManifestEntry(m manifest.Manifest, pa pendingAsset, result photos.Export
m.AddEntry(manifest.NewEntry(pa.asset.ID, result.Filename, relPath, result.Size, result.Cloud)) m.AddEntry(manifest.NewEntry(pa.asset.ID, result.Filename, relPath, result.Size, result.Cloud))
} }
type xmpSidecarData struct {
AssetID string
OriginalFilename string
ExportedFilename string
Album string
AlbumPath string
Keywords []string
ManifestPath string
MediaType string
MediaSubtypes []string
SourceType string
PlaybackStyle string
PixelWidth int
PixelHeight int
Duration float64
IsFavorite bool
IsHidden bool
HasAdjustments bool
Cloud string
ExportMode string
PhotoscliVersion string
ExportedAt string
Size int64
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 {
ext := filepath.Ext(exportedPath)
return strings.TrimSuffix(exportedPath, ext) + ".xmp"
}
func renderXMP(d xmpSidecarData) []byte {
attrs := []struct{ key, val string }{
{"photoscli:xmpSchemaVersion", "2"},
{"photoscli:assetID", d.AssetID},
{"photoscli:originalFilename", d.OriginalFilename},
{"photoscli:exportedFilename", d.ExportedFilename},
{"photoscli:album", d.Album},
{"photoscli:albumPath", d.AlbumPath},
{"photoscli:manifestPath", d.ManifestPath},
{"photoscli:mediaType", d.MediaType},
{"photoscli:sourceType", d.SourceType},
{"photoscli:playbackStyle", d.PlaybackStyle},
{"photoscli:pixelWidth", fmt.Sprintf("%d", d.PixelWidth)},
{"photoscli:pixelHeight", fmt.Sprintf("%d", d.PixelHeight)},
{"photoscli:duration", fmt.Sprintf("%.3f", d.Duration)},
{"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:burstIdentifier", d.BurstIdentifier},
{"photoscli:representsBurst", fmt.Sprintf("%t", d.RepresentsBurst)},
{"photoscli:exportMode", d.ExportMode},
{"photoscli:photoscliVersion", d.PhotoscliVersion},
{"photoscli:exportedAt", d.ExportedAt},
{"photoscli:size", fmt.Sprintf("%d", d.Size)},
{"dc:title", d.ExportedFilename},
{"xmp:MetadataDate", d.ExportedAt},
{"photoscli:sidecarGeneratedAt", d.ExportedAt},
{"photoscli:sidecarGenerator", "photoscli " + d.PhotoscliVersion},
}
if d.IsFavorite {
attrs = append(attrs, struct{ key, val string }{"xmp:Rating", "5"})
}
if d.CreateDate != "" {
attrs = append(attrs,
struct{ key, val string }{"xmp:CreateDate", d.CreateDate},
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
sb.WriteString("<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n")
sb.WriteString("<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n")
sb.WriteString(" <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n")
sb.WriteString(" <rdf:Description xmlns:photoscli=\"https://gitea.k3s.k0.nu/tools/photocli/ns/1.0/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\" xmlns:exif=\"http://ns.adobe.com/exif/1.0/\" xmlns:photoshop=\"http://ns.adobe.com/photoshop/1.0/\"")
for _, a := range attrs {
sb.WriteString("\n ")
sb.WriteString(a.key)
sb.WriteString("=\"")
xml.EscapeText(&sb, []byte(a.val))
sb.WriteString("\"")
}
sb.WriteString(" >\n")
writeStringSeq(&sb, "dc:subject", d.Keywords)
writeStringSeq(&sb, "photoscli:mediaSubtypes", d.MediaSubtypes)
writeStringSeq(&sb, "photoscli:burstSelectionTypes", d.BurstSelectionTypes)
writeStringSeq(&sb, "photoscli:areasOfInterest", func() []string {
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("</x:xmpmeta>\n")
sb.WriteString("<?xpacket end=\"w\"?>\n")
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 {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
f, err := createTempFunc(filepath.Dir(path), ".*.xmp.tmp")
if err != nil {
return err
}
tmp := f.Name()
_ = f.Close()
if err := writeFileFunc(tmp, renderXMP(data), 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 != "xmp" {
return nil
}
mode := "preview"
if originals {
mode = "original"
}
root := pa.root
if root == "" {
root = pa.path
}
fullPath := filepath.Join(pa.path, result.Filename)
relPath, err := filepath.Rel(root, fullPath)
if err != nil || strings.HasPrefix(relPath, "..") {
relPath = result.Filename
}
relDir, err := filepath.Rel(root, pa.path)
if err != nil || strings.HasPrefix(relDir, "..") || relDir == "." {
relDir = ""
}
createDate := ""
if pa.asset.CreationDate != nil {
createDate = *pa.asset.CreationDate
}
modifyDate := ""
if pa.asset.ModificationDate != nil {
modifyDate = *pa.asset.ModificationDate
}
var placemark *photos.Placemark
if opts.reverseGeocode && pa.asset.Location != nil && cache != nil {
placemark = cache.lookup(pa.asset.Location.Latitude, pa.asset.Location.Longitude, bridge)
}
return writeXMPSidecar(sidecarPath(fullPath), xmpSidecarData{
AssetID: pa.asset.ID,
OriginalFilename: pa.asset.Filename,
ExportedFilename: result.Filename,
Album: pa.album,
AlbumPath: pa.path,
Keywords: keywordsFromAlbumPath(pa.album, relDir),
ManifestPath: relPath,
MediaType: pa.asset.MediaType,
MediaSubtypes: pa.asset.MediaSubtypes,
SourceType: pa.asset.SourceType,
PlaybackStyle: pa.asset.PlaybackStyle,
PixelWidth: pa.asset.PixelWidth,
PixelHeight: pa.asset.PixelHeight,
Duration: pa.asset.Duration,
IsFavorite: pa.asset.IsFavorite,
IsHidden: pa.asset.IsHidden,
HasAdjustments: pa.asset.HasAdjustments,
Cloud: result.Cloud,
ExportMode: mode,
PhotoscliVersion: version,
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Size: result.Size,
CreateDate: createDate,
ModifyDate: modifyDate,
Location: pa.asset.Location,
Placemark: placemark,
BurstIdentifier: pa.asset.BurstIdentifier,
RepresentsBurst: pa.asset.RepresentsBurst,
BurstSelectionTypes: pa.asset.BurstSelectionTypes,
AdjustmentInfo: pa.asset.AdjustmentInfo,
Resources: pa.asset.Resources,
})
}
func writeMetadataOnlySidecar(pa pendingAsset, entry manifest.Entry, originals bool, opts exportOptions, cache *geocodeCache, bridge photos.Bridge) error {
checkPath := entry.Path
if checkPath == "" {
checkPath = entry.Filename
}
if checkPath == "" {
return fmt.Errorf("manifest entry has no path")
}
root := pa.root
if root == "" {
root = pa.path
}
fullPath := filepath.Join(root, checkPath)
info, err := statFunc(fullPath)
if err != nil {
return fmt.Errorf("metadata target missing: %s", checkPath)
}
if info.Size() == 0 {
return fmt.Errorf("metadata target zero-byte: %s", checkPath)
}
pa.path = filepath.Dir(fullPath)
result := photos.ExportResult{Filename: filepath.Base(fullPath), Size: info.Size(), Cloud: entry.Cloud}
return writeSidecarIfNeeded(pa, result, originals, opts, cache, bridge)
}
func manifestEntries(m manifest.Manifest) map[string]manifest.Entry {
if r, ok := m.(manifest.EntryReader); ok {
return r.Entries()
}
return nil
}
func metadataOnlyAssets(assets []photos.Asset, outDir string, originals bool, dirPrefix string, entries map[string]manifest.Entry, opts exportOptions, bridge photos.Bridge) (int, int) {
pending := make([]pendingAsset, 0, len(assets))
for _, a := range assets {
pending = append(pending, pendingAsset{asset: a, root: outDir, path: outDir, album: dirPrefix})
}
return metadataOnlyPending(pending, entries, originals, opts, bridge)
}
func collectPendingAssets(nodes []photos.CollectionNode, outDir string, bridge photos.Bridge, skipVideos bool, originals bool, onProgress func(collectProgress), m manifest.Manifest, sortNewest bool, exclude []string, since time.Time, opts exportOptions) ([]pendingAsset, int) { func collectPendingAssets(nodes []photos.CollectionNode, outDir string, bridge photos.Bridge, skipVideos bool, originals bool, onProgress func(collectProgress), m manifest.Manifest, sortNewest bool, exclude []string, since time.Time, opts exportOptions) ([]pendingAsset, int) {
var items []pendingAsset var items []pendingAsset
var skipped int var skipped int
@@ -765,6 +1199,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 {
@@ -802,7 +1260,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
} }
@@ -866,13 +1324,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
}
cache = newGeocodeCache(root)
} }
return exportPendingParallel(pending, targetSize, quality, originals, total, bar, bridge, concurrency, m, lw, opts) 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
@@ -901,8 +1371,15 @@ func exportPendingSerial(pending []pendingAsset, targetSize, quality int, origin
} else if isSkipped { } else if isSkipped {
addManifestEntry(m, pa, result) addManifestEntry(m, pa, result)
} else { } else {
done++ if sidecarErr := writeSidecarIfNeeded(pa, result, originals, opts, geo, bridge); sidecarErr != nil {
addManifestEntry(m, pa, result) failed++
exportErr = sidecarErr
isErr = true
appendFailure(pa.path, pa, sidecarErr)
} else {
done++
addManifestEntry(m, pa, result)
}
} }
avgSpeed := float64(0) avgSpeed := float64(0)
if totalDur > 0 { if totalDur > 0 {
@@ -930,7 +1407,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
@@ -1030,8 +1511,15 @@ func exportPendingParallel(pending []pendingAsset, targetSize, quality int, orig
} else if isSkipped { } else if isSkipped {
addManifestEntry(m, entry.pa, entry.result) addManifestEntry(m, entry.pa, entry.result)
} else { } else {
done++ if sidecarErr := writeSidecarIfNeeded(entry.pa, entry.result, originals, opts, geo, bridge); sidecarErr != nil {
addManifestEntry(m, entry.pa, entry.result) failed++
entry.err = sidecarErr
isErr = true
appendFailure(entry.pa.path, entry.pa, sidecarErr)
} else {
done++
addManifestEntry(m, entry.pa, entry.result)
}
} }
avgSpeed := float64(0) avgSpeed := float64(0)
if totalDur > 0 { if totalDur > 0 {
@@ -1372,13 +1860,16 @@ func exportMode(originals bool) string {
func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) { func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
opts := exportOptions{ opts := exportOptions{
dryRun: hasFlag(args, "--dry-run"), dryRun: hasFlag(args, "--dry-run"),
onlyFavorites: hasFlag(args, "--only-favorites"), onlyFavorites: hasFlag(args, "--only-favorites"),
media: flagValWithDefault(args, "--media", "photos"), media: flagValWithDefault(args, "--media", "photos"),
jsonOut: hasFlag(args, "--json"), jsonOut: hasFlag(args, "--json"),
verify: hasFlag(args, "--verify"), verify: hasFlag(args, "--verify"),
format: flagValWithDefault(args, "--format", "jpeg"), format: flagValWithDefault(args, "--format", "jpeg"),
dateTemplate: flagVal(args, "--date-template"), sidecar: flagValWithDefault(args, "--sidecar", "none"),
metadataOnly: hasFlag(args, "--metadata-only"),
reverseGeocode: hasFlag(args, "--reverse-geocode"),
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" {
fmt.Fprintf(stderr, "error: --media must be photos, videos, or all, got %q\n", opts.media) fmt.Fprintf(stderr, "error: --media must be photos, videos, or all, got %q\n", opts.media)
@@ -1388,6 +1879,14 @@ 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" {
fmt.Fprintf(stderr, "error: --sidecar must be none or xmp, got %q\n", opts.sidecar)
return opts, false
}
if opts.metadataOnly && opts.sidecar != "xmp" {
fmt.Fprintln(stderr, "error: --metadata-only requires --sidecar xmp")
return opts, false
}
if v := flagVal(args, "--retry"); v != "" { if v := flagVal(args, "--retry"); v != "" {
n, err := strconv.Atoi(v) n, err := strconv.Atoi(v)
if err != nil || n < 0 { if err != nil || n < 0 {
@@ -1610,6 +2109,7 @@ func cmdDiff(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int
func cmdVerify(args []string, stdout, stderr io.Writer) int { func cmdVerify(args []string, stdout, stderr io.Writer) int {
outDir := flagVal(args, "--out") outDir := flagVal(args, "--out")
checkSidecar := hasFlag(args, "--sidecar")
if outDir == "" { if outDir == "" {
fmt.Fprintln(stderr, "error: --out is required") fmt.Fprintln(stderr, "error: --out is required")
return exitErr return exitErr
@@ -1648,6 +2148,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)
}
} }
if bad > 0 { if bad > 0 {
return exitPartial return exitPartial
@@ -1656,6 +2159,34 @@ func cmdVerify(args []string, stdout, stderr io.Writer) int {
return exitOK return exitOK
} }
func verifySidecar(stdout io.Writer, outDir, id, checkPath string) int {
xmpPath := sidecarPath(filepath.Join(outDir, checkPath))
rel, err := filepath.Rel(outDir, xmpPath)
if err != nil || strings.HasPrefix(rel, "..") {
rel = xmpPath
}
info, err := statFunc(xmpPath)
if err != nil {
fmt.Fprintf(stdout, "%s\t%s\tsidecar-missing\n", id, rel)
return 1
}
if info.Size() == 0 {
fmt.Fprintf(stdout, "%s\t%s\tsidecar-zero-byte\n", id, rel)
return 1
}
data, err := readFileFunc(xmpPath)
if err != nil {
fmt.Fprintf(stdout, "%s\t%s\tsidecar-unreadable\n", id, rel)
return 1
}
needle := `photoscli:assetID="` + id + `"`
if !strings.Contains(string(data), needle) {
fmt.Fprintf(stdout, "%s\t%s\tsidecar-asset-mismatch\n", id, rel)
return 1
}
return 0
}
func cmdRetryFailed(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int { 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")
+551
View File
@@ -31,9 +31,19 @@ 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 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 +67,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)
@@ -3828,6 +3844,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 +4149,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 +4158,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 +4200,512 @@ func TestMoreIntegrityBranches(t *testing.T) {
} }
} }
func TestVerifySidecarBranches(t *testing.T) {
dir := t.TempDir()
subdir := filepath.Join(dir, "sub")
if err := os.Mkdir(subdir, 0755); err != nil {
t.Fatal(err)
}
xmp := filepath.Join(dir, "asset.xmp")
if err := os.WriteFile(xmp, []byte(`photoscli:assetID="x1"`), 0644); err != nil {
t.Fatal(err)
}
oldRead := readFileFunc
readFileFunc = func(string) ([]byte, error) { return nil, fmt.Errorf("read") }
var out bytes.Buffer
if got := verifySidecar(&out, subdir, "x1", "../asset.jpg"); got != 1 || !strings.Contains(out.String(), "sidecar-unreadable") {
t.Fatalf("expected unreadable with rel fallback, got=%d out=%q", got, out.String())
}
readFileFunc = oldRead
}
func TestXMPSidecarHelpers(t *testing.T) {
if got := sidecarPath("/tmp/IMG_0001.HEIC"); got != "/tmp/IMG_0001.xmp" {
t.Fatalf("sidecar path = %q", got)
}
xmp := string(renderXMP(xmpSidecarData{
AssetID: `id&<>"`,
OriginalFilename: "IMG_0001.HEIC",
ExportedFilename: "IMG_0001.jpg",
Album: "A&B",
AlbumPath: "/tmp/A&B",
Keywords: []string{"A&B", "Trips"},
ManifestPath: "A&B/IMG_0001.jpg",
MediaType: "image",
MediaSubtypes: []string{"photoLive", `hdr&<>"`},
SourceType: "userLibrary",
PlaybackStyle: "livePhoto",
PixelWidth: 10,
PixelHeight: 20,
Duration: 1.25,
IsFavorite: true,
IsHidden: true,
HasAdjustments: true,
Cloud: "local",
ExportMode: "preview",
PhotoscliVersion: "test",
ExportedAt: "2026-01-01T00:00:00Z",
Size: 123,
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:xmpSchemaVersion=\"2\"", "photoscli:assetID=\"id&amp;&lt;&gt;&#34;\"", "photoscli:isFavorite=\"true\"", "xmp:Rating=\"5\"", "photoscli:hasAdjustments=\"true\"", "xmp:ModifyDate=\"2024-02-01T00:00:00Z\"", "photoshop:DateCreated=\"2024-01-01T00:00:00Z\"", "exif:GPSLatitude=\"59.32930000\"", "photoscli:latitude=\"59.32930000\"", "photoscli:addressCountry=\"Sweden\"", "photoscli:adjustmentFormatIdentifier=\"com.apple\"", "<dc:subject><rdf:Seq>", "<rdf:li>A&amp;B</rdf:li>", "<photoscli:resources><rdf:Seq>", "photoscli:resourceFilename=\"res&amp;.heic\"", "<photoscli:mediaSubtypes><rdf:Seq>"} {
if !strings.Contains(xmp, want) {
t.Fatalf("XMP missing %q in %s", want, xmp)
}
}
}
func TestKeywordsFromAlbumPath(t *testing.T) {
got := keywordsFromAlbumPath("Album", "Trips/Album/2024")
want := []string{"Album", "Trips", "2024"}
if len(got) != len(want) {
t.Fatalf("keywords len=%d want=%d: %#v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("keywords[%d]=%q want %q in %#v", i, got[i], want[i], got)
}
}
if got := keywordsFromAlbumPath("", "."); len(got) != 0 {
t.Fatalf("expected no dot keyword, got %#v", got)
}
}
func TestWriteXMPSidecar(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "photo.xmp")
if err := writeXMPSidecar(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), "photoscli:assetID=\"x1\"") {
t.Fatalf("unexpected xmp: %s", string(data))
}
badParent := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(badParent, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
if err := writeXMPSidecar(filepath.Join(badParent, "bad.xmp"), xmpSidecarData{}); err == nil {
t.Fatal("expected mkdir error")
}
}
func TestSidecarExportIntegration(t *testing.T) {
dir := t.TempDir()
date := "2024-01-02T03:04:05Z"
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "orig&.HEIC", MediaType: "image", PixelWidth: 10, PixelHeight: 20, IsFavorite: true, CreationDate: &date}}}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if err := os.WriteFile(filepath.Join(out, "photo.jpg"), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
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"})
if exported != 1 || failed != 0 {
t.Fatalf("exported=%d failed=%d", exported, failed)
}
data, err := os.ReadFile(filepath.Join(dir, "photo.xmp"))
if err != nil {
t.Fatal(err)
}
content := string(data)
for _, want := range []string{"photoscli:assetID=\"x1\"", "photoscli:originalFilename=\"orig&amp;.HEIC\"", "photoscli:album=\"Album\"", "xmp:CreateDate=\"2024-01-02T03:04:05Z\""} {
if !strings.Contains(content, want) {
t.Fatalf("sidecar missing %q in %s", want, content)
}
}
if _, err := os.Stat(filepath.Join(dir, "photo.jpg.xmp")); !os.IsNotExist(err) {
t.Fatal("sidecar should use basename, not double extension")
}
}
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) {
oldConfigValues, oldConfigLoaded := configValues, configLoaded
defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }()
dir := t.TempDir()
cfg := filepath.Join(dir, "config.toml")
if err := os.WriteFile(cfg, []byte("sidecar = \"xmp\"\n"), 0644); err != nil {
t.Fatal(err)
}
t.Setenv("PHOTOSCLI_CONFIG", cfg)
configValues, configLoaded = nil, false
opts, ok := parseExportOptions(nil, io.Discard)
if !ok || opts.sidecar != "xmp" {
t.Fatalf("expected sidecar config, opts=%+v ok=%v", opts, ok)
}
var stderr bytes.Buffer
if _, ok := parseExportOptions([]string{"--sidecar", "bad"}, &stderr); ok || !strings.Contains(stderr.String(), "--sidecar") {
t.Fatalf("expected sidecar validation error, stderr=%q", stderr.String())
}
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "photo.jpg"}}}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
return photos.ExportResult{Filename: "photo.jpg", Size: 4}, nil
}
oldRename := renameFunc
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"})
renameFunc = oldRename
if exported != 0 || failed != 1 {
t.Fatalf("expected sidecar failure, exported=%d failed=%d", exported, failed)
}
}
func TestMetadataOnlyExport(t *testing.T) {
dir := t.TempDir()
m := manifest.LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "photo.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
m.Close()
if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "orig.heic", MediaType: "image", IsFavorite: true}}}
b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) {
t.Fatal("metadata-only must not export media")
return photos.ExportResult{}, nil
}
out, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--sidecar", "xmp", "--metadata-only"}, b)
if rc != exitOK || out != "" || !strings.Contains(stderr, "wrote 1 metadata sidecars") {
t.Fatalf("metadata-only rc=%d out=%q stderr=%q", rc, out, stderr)
}
data, err := os.ReadFile(filepath.Join(dir, "photo.xmp"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "photoscli:assetID=\"x1\"") || !strings.Contains(string(data), "xmp:Rating=\"5\"") {
t.Fatalf("unexpected metadata sidecar: %s", string(data))
}
}
func TestMetadataOnlyExportErrors(t *testing.T) {
dir := t.TempDir()
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "photo.jpg"}}}
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", dir, "--metadata-only"}, b)
if rc != exitErr || !strings.Contains(stderr, "--metadata-only requires --sidecar xmp") {
t.Fatalf("expected sidecar requirement rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"export", "--album-id", "x", "--out", dir, "--sidecar", "xmp", "--metadata-only", "--no-manifest"}, b)
if rc != exitErr || !strings.Contains(stderr, "requires a manifest") {
t.Fatalf("expected manifest requirement rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"backup-all", "--out", dir, "--sidecar", "xmp", "--metadata-only", "--no-manifest"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "requires a manifest") {
t.Fatalf("expected backup manifest requirement rc=%d stderr=%q", rc, stderr)
}
}
func TestMetadataOnlyHelperBranches(t *testing.T) {
dir := t.TempDir()
pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "photo.jpg"}, path: dir}
if err := writeMetadataOnlySidecar(pa, manifest.Entry{ID: "x1"}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err == nil || !strings.Contains(err.Error(), "no path") {
t.Fatalf("expected no path error, got %v", err)
}
if err := writeMetadataOnlySidecar(pa, manifest.Entry{ID: "x1", Path: "missing.jpg"}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err == nil || !strings.Contains(err.Error(), "missing") {
t.Fatalf("expected missing error, got %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "zero.jpg"), nil, 0644); err != nil {
t.Fatal(err)
}
if err := writeMetadataOnlySidecar(pa, manifest.Entry{ID: "x1", Path: "zero.jpg"}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err == nil || !strings.Contains(err.Error(), "zero-byte") {
t.Fatalf("expected zero-byte error, got %v", err)
}
if entries := manifestEntries(noEntryManifest{}); entries != nil {
t.Fatalf("expected nil entries, got %#v", entries)
}
written, failed := metadataOnlyPending([]pendingAsset{{asset: photos.Asset{ID: "x1"}, path: dir}}, map[string]manifest.Entry{"x1": {ID: "x1", Path: "missing.jpg"}}, false, exportOptions{sidecar: "xmp"}, &mockBridge{})
if written != 0 || failed != 1 {
t.Fatalf("expected failed metadata pending, written=%d failed=%d", written, failed)
}
if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
written, failed = metadataOnlyPending([]pendingAsset{{asset: photos.Asset{ID: "x2", Location: &photos.AssetLocation{Latitude: 1, Longitude: 2}}, path: dir}}, map[string]manifest.Entry{"x2": {ID: "x2", Path: "photo.jpg"}}, false, exportOptions{sidecar: "xmp", reverseGeocode: true}, &mockBridge{reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) {
return photos.Placemark{Country: "Sweden"}, nil
}})
if written != 1 || failed != 0 {
t.Fatalf("expected reverse geocode metadata success, written=%d failed=%d", written, failed)
}
}
func TestMetadataOnlyBackupAll(t *testing.T) {
dir := t.TempDir()
m := manifest.LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "Album/photo.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
m.Close()
albumDir := filepath.Join(dir, "Album")
if err := os.Mkdir(albumDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(albumDir, "photo.jpg"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
b := &mockBridge{
tree: []photos.CollectionNode{{ID: "a1", Name: "Album", Kind: "album"}},
assetsByAlbum: map[string][]photos.Asset{"a1": {{ID: "x1", Filename: "orig.heic", MediaType: "image"}, {ID: "x2", Filename: "missing.heic", MediaType: "image"}}},
}
b.exportPreviewFn = func(string, string, int, int, int) (photos.ExportResult, error) {
t.Fatal("metadata-only backup-all must not export media")
return photos.ExportResult{}, nil
}
_, stderr, rc := runWith([]string{"backup-all", "--out", dir, "--sidecar", "xmp", "--metadata-only"}, b)
if rc != exitOK || !strings.Contains(stderr, "wrote 1 metadata sidecars") {
t.Fatalf("metadata-only backup rc=%d stderr=%q", rc, stderr)
}
if _, err := os.Stat(filepath.Join(albumDir, "photo.xmp")); err != nil {
t.Fatalf("expected metadata-only sidecar: %v", err)
}
}
func TestSidecarAdditionalBranches(t *testing.T) {
dir := t.TempDir()
oldCreateTemp := createTempFunc
createTempFunc = func(string, string) (*os.File, error) { return nil, fmt.Errorf("createtemp") }
if err := writeXMPSidecar(filepath.Join(dir, "bad.xmp"), xmpSidecarData{}); err == nil || !strings.Contains(err.Error(), "createtemp") {
t.Fatalf("expected create temp error, got %v", err)
}
createTempFunc = oldCreateTemp
oldWriteFile := writeFileFunc
writeFileFunc = func(string, []byte, os.FileMode) error { return fmt.Errorf("writefile") }
if err := writeXMPSidecar(filepath.Join(dir, "badwrite.xmp"), xmpSidecarData{}); err == nil || !strings.Contains(err.Error(), "writefile") {
t.Fatalf("expected write file error, got %v", err)
}
writeFileFunc = oldWriteFile
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"}, nil, &mockBridge{}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dir, "out.xmp"))
if err != nil {
t.Fatal(err)
}
content := string(data)
if !strings.Contains(content, "photoscli:exportMode=\"original\"") || !strings.Contains(content, "photoscli:manifestPath=\"out.jpg\"") {
t.Fatalf("unexpected sidecar: %s", content)
}
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"}, nil, &mockBridge{}); err != nil {
t.Fatal(err)
}
data, err = os.ReadFile(filepath.Join(dir, "fallback.xmp"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "photoscli:manifestPath=\"fallback.jpg\"") {
t.Fatalf("expected fallback manifest path, got %s", string(data))
}
}
func TestParallelSidecarExport(t *testing.T) {
dir := t.TempDir()
assets := []photos.Asset{
{ID: "x1", Filename: "one.jpg"},
{ID: "x2", Filename: "two.jpg"},
{ID: "x3", Filename: "three.jpg"},
{ID: "x4", Filename: "four.jpg"},
}
b := &mockBridge{assets: assets}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
name := fmt.Sprintf("%s.jpg", assetID)
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
}
exported, failed := exportAssets(assets, dir, 1024, 85, 4, false, len(assets), io.Discard, b, "", true, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp"})
if exported != 4 || failed != 0 {
t.Fatalf("exported=%d failed=%d", exported, failed)
}
if _, err := os.Stat(filepath.Join(dir, "x4.xmp")); err != nil {
t.Fatalf("expected parallel sidecar: %v", err)
}
}
func TestParallelSidecarFailure(t *testing.T) {
dir := t.TempDir()
assets := []photos.Asset{{ID: "x1", Filename: "one.jpg"}, {ID: "x2", Filename: "two.jpg"}, {ID: "x3", Filename: "three.jpg"}, {ID: "x4", Filename: "four.jpg"}}
b := &mockBridge{assets: assets}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
return photos.ExportResult{Filename: assetID + ".jpg", Size: 4, Cloud: "local"}, nil
}
oldWriteFile := writeFileFunc
writeFileFunc = func(string, []byte, os.FileMode) error { return fmt.Errorf("parallel sidecar") }
exported, failed := exportAssets(assets, dir, 1024, 85, 4, false, len(assets), io.Discard, b, "", true, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp"})
writeFileFunc = oldWriteFile
if exported != 0 || failed != 4 {
t.Fatalf("expected sidecar failures, exported=%d failed=%d", exported, failed)
}
}
func TestRetryFailedClearOnSuccess(t *testing.T) { func TestRetryFailedClearOnSuccess(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
appendFailure(dir, pendingAsset{asset: photos.Asset{ID: "bad", Filename: "bad.jpg"}, root: dir, path: dir, album: "Album"}, fmt.Errorf("boom")) appendFailure(dir, pendingAsset{asset: photos.Asset{ID: "bad", Filename: "bad.jpg"}, root: dir, path: dir, album: "Album"}, fmt.Errorf("boom"))
+12
View File
@@ -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 != "" {
+10 -1
View File
@@ -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 {
+13
View File
@@ -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 {
+57
View File
@@ -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
+57 -11
View File
@@ -6,17 +6,40 @@ type Album struct {
} }
type Asset struct { type Asset struct {
ID string `json:"id"` ID string `json:"id"`
Filename string `json:"filename"` Filename string `json:"filename"`
Cloud string `json:"cloud"` Cloud string `json:"cloud"`
MediaType string `json:"mediaType"` MediaType string `json:"mediaType"`
PixelWidth int `json:"pixelWidth"` MediaSubtypes []string `json:"mediaSubtypes,omitempty"`
PixelHeight int `json:"pixelHeight"` SourceType string `json:"sourceType,omitempty"`
CreationDate *string `json:"creationDate,omitempty"` PlaybackStyle string `json:"playbackStyle,omitempty"`
Duration float64 `json:"duration,omitempty"` PixelWidth int `json:"pixelWidth"`
IsFavorite bool `json:"isFavorite,omitempty"` PixelHeight int `json:"pixelHeight"`
HasAdjustments bool `json:"hasAdjustments,omitempty"` CreationDate *string `json:"creationDate,omitempty"`
Resources []AssetResource `json:"resources,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 { type AssetResource struct {
@@ -24,6 +47,29 @@ type AssetResource struct {
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 {