v0.8.0: enrich XMP metadata
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled

This commit is contained in:
Ein Anderssono
2026-06-15 01:21:49 +02:00
parent 4fe4c15adf
commit fffb30023b
16 changed files with 791 additions and 107 deletions
+2
View File
@@ -12,4 +12,6 @@
- Manifest backends: JSONL default, SQLite optional via `modernc.org/sqlite`. - Manifest backends: JSONL default, SQLite optional via `modernc.org/sqlite`.
- Preserve manifest compatibility and migration behavior. - Preserve manifest compatibility and migration behavior.
- XMP sidecars are opt-in via `--sidecar xmp`; default must remain `none`. - XMP sidecars are opt-in via `--sidecar xmp`; default must remain `none`.
- Reverse geocoding is opt-in via `--reverse-geocode`, uses MapKit on macOS 26+, and must fail safely on older macOS without failing export.
- Do not claim Photos people/animal/object labels are exported unless Vision/Core ML analysis is explicitly implemented.
- Do not commit generated artifacts from `bin/` or coverage files. - Do not commit generated artifacts from `bin/` or coverage files.
+10
View File
@@ -2,6 +2,16 @@
This changelog is maintained from git history plus the published Gitea release series. Future releases should update this file and publish matching release notes on the release page. 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.0
Rich PhotoKit metadata and reverse-geocoded XMP release.
- Expand asset metadata from public PhotoKit fields: modification date, hidden state, media subtypes, source type, playback style, burst data, GPS coordinates, adjustment state, adjustment info, and richer resource metadata.
- Expand XMP sidecars with duration, hidden/adjustment state, modification date, structured media subtype/burst/resource lists, GPS coordinates, and adjustment metadata.
- Add opt-in `--reverse-geocode` using Apple reverse geocoding APIs to add address metadata for GPS assets.
- Cache reverse-geocode results under `.photoscli/geocode-cache.jsonl` so repeated exports do not repeatedly query Apple geocoding.
- Keep Vision/Core ML people, animal, object, and scene analysis out of this release; that remains future work.
## v0.7.0 ## v0.7.0
XMP sidecar metadata release. XMP sidecar metadata release.
+2 -2
View File
@@ -1,6 +1,6 @@
BINARY := ./bin/photoscli BINARY := ./bin/photoscli
MODULE := gitea.k3s.k0.nu/tools/photocli MODULE := gitea.k3s.k0.nu/tools/photocli
VERSION := 0.7.0 VERSION := 0.8.0
RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip
RELEASE_NOTES := RELEASE_NOTES.md RELEASE_NOTES := RELEASE_NOTES.md
BRIDGE_DIR := bridge BRIDGE_DIR := bridge
@@ -20,7 +20,7 @@ $(LIB): $(OBJ)
ar rcs $@ $< ar rcs $@ $<
$(OBJ): $(BRIDGE_DIR)/photokit_bridge.m $(BRIDGE_DIR)/photokit_bridge.h $(OBJ): $(BRIDGE_DIR)/photokit_bridge.m $(BRIDGE_DIR)/photokit_bridge.h
cc -c -x objective-c -fobjc-arc -framework Photos -framework Foundation -o $@ $< cc -c -x objective-c -fobjc-arc -o $@ $<
$(STUB_LIB): $(STUB_OBJ) $(STUB_LIB): $(STUB_OBJ)
ar rcs $@ $< ar rcs $@ $<
+7 -1
View File
@@ -21,7 +21,8 @@ For a practical step-by-step manual with recommended backup workflows, recovery
- 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.
- Status command for quick backup summaries. - Status command for quick backup summaries.
- Opt-in XMP sidecar metadata with `--sidecar xmp`. - Opt-in rich XMP sidecar metadata with `--sidecar xmp`.
- Optional Apple MapKit reverse geocoding for GPS assets on macOS 26+ with `--reverse-geocode`.
- Script-friendly exit codes and optional JSON summaries. - Script-friendly exit codes and optional JSON summaries.
- 100% test coverage for the Go CLI and parsing layers. - 100% test coverage for the Go CLI and parsing layers.
@@ -166,6 +167,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`
@@ -186,6 +188,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`
@@ -278,6 +281,7 @@ Common flags for `export` and `backup-all`:
- `--max-size <n>`: filter by estimated pixel count. - `--max-size <n>`: filter by estimated pixel count.
- `--format jpeg|heic|png`: preview format hint. Current bridge output is still the existing preview path; non-JPEG bridge output is future work. - `--format jpeg|heic|png`: preview format hint. Current bridge output is still the existing preview path; non-JPEG bridge output is future work.
- `--sidecar none|xmp`: write opt-in XMP metadata sidecars next to exported files. - `--sidecar none|xmp`: write opt-in XMP metadata sidecars next to exported files.
- `--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:
@@ -347,6 +351,8 @@ IMG_0001.HEIC -> IMG_0001.xmp
The XMP contains photoscli metadata such as asset ID, filenames, album, manifest path, media type, dimensions, favorite state, cloud state, export mode, version, exported timestamp, size, and creation date when available. If `--sidecar xmp` is explicitly selected and the sidecar cannot be written, that asset is treated as failed. The XMP contains photoscli metadata such as asset ID, filenames, album, manifest path, media type, dimensions, favorite state, cloud state, export mode, version, exported timestamp, size, and creation date when available. If `--sidecar xmp` is explicitly selected and the sidecar cannot be written, that asset is treated as failed.
In v0.8.0, sidecars also include richer public PhotoKit metadata where available: modification date, duration, hidden state, adjustment state, media subtypes, source type, playback style, burst data, GPS coordinates, adjustment info, and structured asset resources. Add `--reverse-geocode` to include cached address fields from Apple MapKit for assets with GPS coordinates. Reverse geocoding requires macOS 26 or newer; on older macOS versions the export continues and XMP still includes GPS coordinates.
## Failure Tracking ## Failure Tracking
Failed exports are deduplicated by asset ID and stored in: Failed exports are deduplicated by asset ID and stored in:
+9 -9
View File
@@ -1,20 +1,20 @@
# v0.7.0 # v0.8.0
This release adds opt-in XMP sidecar metadata for archival exports. This release expands XMP sidecars with richer public PhotoKit metadata and adds optional reverse geocoding through Apple MapKit on macOS 26 or newer.
## Highlights ## Highlights
- Add `--sidecar none|xmp` with default `none`. - XMP sidecars now include richer public PhotoKit metadata: modification date, duration, hidden state, adjustment state, media subtypes, source type, playback style, burst data, GPS coordinates, adjustment info, and structured asset resources when available.
- Write XMP sidecars next to exported files when `--sidecar xmp` is selected. - Add `--reverse-geocode` to enrich GPS assets with cached address metadata from Apple MapKit on macOS 26+.
- XMP files use the exported file basename, for example `IMG_0001.jpg` -> `IMG_0001.xmp`. - On older macOS versions, reverse geocoding is skipped safely; export continues and GPS coordinates remain in XMP.
- Sidecars include photoscli metadata such as asset ID, filenames, album, manifest path, media type, dimensions, favorite state, cloud state, export mode, version, exported timestamp, size, and creation date when available. - Reverse-geocode cache is stored under `.photoscli/geocode-cache.jsonl` in the backup root.
- XMP writes are atomic and fail the asset when explicitly requested sidecar output cannot be written. - Existing `--sidecar xmp` behavior remains opt-in and atomic; sidecar write failure still fails that asset.
- Config files can set `sidecar = "xmp"`. - Vision/Core ML people, animal, object, and scene analysis is not included in this release.
## Assets ## Assets
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`). - `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
- `photoscli-0.7.0-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG. - `photoscli-0.8.0-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG.
- `USERGUIDE.md`: standalone user guide. - `USERGUIDE.md`: standalone user guide.
Intel Macs are not currently a supported release target. Intel Macs are not currently a supported release target.
+11 -1
View File
@@ -23,6 +23,7 @@ It is especially useful when you want to:
- 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. - 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.
@@ -554,7 +555,15 @@ IMG_0001.jpg -> IMG_0001.xmp
IMG_0001.HEIC -> IMG_0001.xmp IMG_0001.HEIC -> IMG_0001.xmp
``` ```
The XMP includes photoscli archive metadata such as asset ID, original filename, exported filename, album, manifest path, media type, dimensions, favorite state, cloud state, export mode, version, exported time, size, and creation date when available. The XMP includes photoscli archive metadata such as asset ID, original filename, exported filename, album, manifest path, media type, dimensions, favorite state, hidden state, cloud state, export mode, version, exported time, size, creation date, modification date, duration, adjustment state, media subtypes, source type, playback style, burst data, GPS coordinates, adjustment info, and structured asset resources when PhotoKit exposes them.
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.
If you explicitly request `--sidecar xmp` and the XMP file cannot be written, the asset is counted as failed. If you explicitly request `--sidecar xmp` and the XMP file cannot be written, the asset is counted as failed.
@@ -578,6 +587,7 @@ media = "photos"
retry = 3 retry = 3
log = true log = true
sidecar = "xmp" sidecar = "xmp"
reverse-geocode = true
``` ```
Use a custom config path: Use a custom config path:
+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,
+139 -2
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];
+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;
+205 -9
View File
@@ -39,6 +39,7 @@ type exportOptions struct {
verify bool verify bool
format string format string
sidecar string sidecar string
reverseGeocode bool
minSize int64 minSize int64
maxSize int64 maxSize int64
dateTemplate string dateTemplate string
@@ -216,6 +217,10 @@ COMMON EXPORT FLAGS
Write opt-in XMP sidecar metadata next to each exported file. Default: Write opt-in XMP sidecar metadata next to each exported file. Default:
none. If XMP writing fails, the asset is counted as failed. none. If XMP writing fails, the asset is counted as failed.
--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.
@@ -284,6 +289,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.
@@ -747,15 +754,86 @@ type xmpSidecarData struct {
AlbumPath string AlbumPath string
ManifestPath string ManifestPath string
MediaType string MediaType string
MediaSubtypes []string
SourceType string
PlaybackStyle string
PixelWidth int PixelWidth int
PixelHeight int PixelHeight int
Duration float64
IsFavorite bool IsFavorite bool
IsHidden bool
HasAdjustments bool
Cloud string Cloud string
ExportMode string ExportMode string
PhotoscliVersion string PhotoscliVersion string
ExportedAt string ExportedAt string
Size int64 Size int64
CreateDate string CreateDate string
ModifyDate string
Location *photos.AssetLocation
Placemark *photos.Placemark
BurstIdentifier string
RepresentsBurst bool
BurstSelectionTypes []string
AdjustmentInfo *photos.AdjustmentInfo
Resources []photos.AssetResource
}
type geocodeCache struct {
path string
mu sync.Mutex
items map[string]photos.Placemark
}
type geocodeCacheEntry struct {
Key string `json:"key"`
Placemark photos.Placemark `json:"placemark"`
}
func newGeocodeCache(root string) *geocodeCache {
c := &geocodeCache{path: filepath.Join(root, ".photoscli", "geocode-cache.jsonl"), items: map[string]photos.Placemark{}}
data, err := os.ReadFile(c.path)
if err != nil {
return c
}
for _, line := range strings.Split(string(data), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
var e geocodeCacheEntry
if json.Unmarshal([]byte(line), &e) == nil && e.Key != "" {
c.items[e.Key] = e.Placemark
}
}
return c
}
func geocodeKey(lat, lon float64) string { return fmt.Sprintf("%.5f,%.5f", lat, lon) }
func (c *geocodeCache) lookup(lat, lon float64, bridge photos.Bridge) *photos.Placemark {
if c == nil {
return nil
}
key := geocodeKey(lat, lon)
c.mu.Lock()
if p, ok := c.items[key]; ok {
c.mu.Unlock()
return &p
}
c.mu.Unlock()
p, err := bridge.ReverseGeocode(lat, lon)
if err != nil {
return nil
}
c.mu.Lock()
c.items[key] = p
_ = os.MkdirAll(filepath.Dir(c.path), 0755)
if f, err := openFileFunc(c.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
_ = json.NewEncoder(f).Encode(geocodeCacheEntry{Key: key, Placemark: p})
_ = f.Close()
}
c.mu.Unlock()
return &p
} }
func sidecarPath(exportedPath string) string { func sidecarPath(exportedPath string) string {
@@ -772,10 +850,17 @@ func renderXMP(d xmpSidecarData) []byte {
{"photoscli:albumPath", d.AlbumPath}, {"photoscli:albumPath", d.AlbumPath},
{"photoscli:manifestPath", d.ManifestPath}, {"photoscli:manifestPath", d.ManifestPath},
{"photoscli:mediaType", d.MediaType}, {"photoscli:mediaType", d.MediaType},
{"photoscli:sourceType", d.SourceType},
{"photoscli:playbackStyle", d.PlaybackStyle},
{"photoscli:pixelWidth", fmt.Sprintf("%d", d.PixelWidth)}, {"photoscli:pixelWidth", fmt.Sprintf("%d", d.PixelWidth)},
{"photoscli:pixelHeight", fmt.Sprintf("%d", d.PixelHeight)}, {"photoscli:pixelHeight", fmt.Sprintf("%d", d.PixelHeight)},
{"photoscli:duration", fmt.Sprintf("%.3f", d.Duration)},
{"photoscli:isFavorite", fmt.Sprintf("%t", d.IsFavorite)}, {"photoscli:isFavorite", fmt.Sprintf("%t", d.IsFavorite)},
{"photoscli:isHidden", fmt.Sprintf("%t", d.IsHidden)},
{"photoscli:hasAdjustments", fmt.Sprintf("%t", d.HasAdjustments)},
{"photoscli:cloud", d.Cloud}, {"photoscli:cloud", d.Cloud},
{"photoscli:burstIdentifier", d.BurstIdentifier},
{"photoscli:representsBurst", fmt.Sprintf("%t", d.RepresentsBurst)},
{"photoscli:exportMode", d.ExportMode}, {"photoscli:exportMode", d.ExportMode},
{"photoscli:photoscliVersion", d.PhotoscliVersion}, {"photoscli:photoscliVersion", d.PhotoscliVersion},
{"photoscli:exportedAt", d.ExportedAt}, {"photoscli:exportedAt", d.ExportedAt},
@@ -785,6 +870,38 @@ func renderXMP(d xmpSidecarData) []byte {
if d.CreateDate != "" { if d.CreateDate != "" {
attrs = append(attrs, struct{ key, val string }{"xmp:CreateDate", d.CreateDate}) attrs = append(attrs, struct{ key, val string }{"xmp:CreateDate", d.CreateDate})
} }
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)},
)
}
if d.Placemark != nil {
attrs = append(attrs,
struct{ key, val string }{"photoscli:addressName", d.Placemark.Name},
struct{ key, val string }{"photoscli:addressCountry", d.Placemark.Country},
struct{ key, val string }{"photoscli:addressCountryCode", d.Placemark.CountryCode},
struct{ key, val string }{"photoscli:addressRegion", d.Placemark.AdministrativeArea},
struct{ key, val string }{"photoscli:addressCity", d.Placemark.Locality},
struct{ key, val string }{"photoscli:addressSubLocality", d.Placemark.SubLocality},
struct{ key, val string }{"photoscli:addressStreet", strings.TrimSpace(d.Placemark.Thoroughfare + " " + d.Placemark.SubThoroughfare)},
struct{ key, val string }{"photoscli:addressPostalCode", d.Placemark.PostalCode},
struct{ key, val string }{"photoscli:addressFormatted", d.Placemark.FormattedAddress},
struct{ key, val string }{"photoscli:reverseGeocoder", "MapKit"},
)
}
if d.AdjustmentInfo != nil {
attrs = append(attrs,
struct{ key, val string }{"photoscli:adjustmentFormatIdentifier", d.AdjustmentInfo.FormatIdentifier},
struct{ key, val string }{"photoscli:adjustmentFormatVersion", d.AdjustmentInfo.FormatVersion},
struct{ key, val string }{"photoscli:adjustmentBaseFilename", d.AdjustmentInfo.BaseFilename},
)
}
var sb strings.Builder var sb strings.Builder
sb.WriteString("<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n") sb.WriteString("<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n")
sb.WriteString("<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n") sb.WriteString("<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n")
@@ -797,13 +914,53 @@ func renderXMP(d xmpSidecarData) []byte {
xml.EscapeText(&sb, []byte(a.val)) xml.EscapeText(&sb, []byte(a.val))
sb.WriteString("\"") sb.WriteString("\"")
} }
sb.WriteString(" />\n") sb.WriteString(" >\n")
writeStringSeq(&sb, "photoscli:mediaSubtypes", d.MediaSubtypes)
writeStringSeq(&sb, "photoscli:burstSelectionTypes", d.BurstSelectionTypes)
writeStringSeq(&sb, "photoscli:areasOfInterest", func() []string {
if d.Placemark == nil {
return nil
}
return d.Placemark.AreasOfInterest
}())
writeResourceSeq(&sb, d.Resources)
sb.WriteString(" </rdf:Description>\n")
sb.WriteString(" </rdf:RDF>\n") sb.WriteString(" </rdf:RDF>\n")
sb.WriteString("</x:xmpmeta>\n") sb.WriteString("</x:xmpmeta>\n")
sb.WriteString("<?xpacket end=\"w\"?>\n") sb.WriteString("<?xpacket end=\"w\"?>\n")
return []byte(sb.String()) return []byte(sb.String())
} }
func writeStringSeq(sb *strings.Builder, name string, vals []string) {
if len(vals) == 0 {
return
}
sb.WriteString("\n <" + name + "><rdf:Seq>")
for _, v := range vals {
sb.WriteString("<rdf:li>")
xml.EscapeText(sb, []byte(v))
sb.WriteString("</rdf:li>")
}
sb.WriteString("</rdf:Seq></" + name + ">")
}
func writeResourceSeq(sb *strings.Builder, resources []photos.AssetResource) {
if len(resources) == 0 {
return
}
sb.WriteString("\n <photoscli:resources><rdf:Seq>")
for _, r := range resources {
sb.WriteString("<rdf:li><rdf:Description")
for _, a := range []struct{ key, val string }{{"photoscli:resourceType", r.Type}, {"photoscli:resourceFilename", r.Filename}, {"photoscli:resourceUTI", r.UTI}, {"photoscli:resourceLocal", fmt.Sprintf("%t", r.Local)}, {"photoscli:resourceSize", fmt.Sprintf("%d", r.Size)}} {
sb.WriteString(" " + a.key + "=\"")
xml.EscapeText(sb, []byte(a.val))
sb.WriteString("\"")
}
sb.WriteString(" /></rdf:li>")
}
sb.WriteString("</rdf:Seq></photoscli:resources>")
}
func writeXMPSidecar(path string, data xmpSidecarData) error { func writeXMPSidecar(path string, data xmpSidecarData) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err return err
@@ -825,7 +982,7 @@ func writeXMPSidecar(path string, data xmpSidecarData) error {
return nil return nil
} }
func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals bool, opts exportOptions) error { func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals bool, opts exportOptions, cache *geocodeCache, bridge photos.Bridge) error {
if opts.sidecar != "xmp" { if opts.sidecar != "xmp" {
return nil return nil
} }
@@ -846,6 +1003,14 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
if pa.asset.CreationDate != nil { if pa.asset.CreationDate != nil {
createDate = *pa.asset.CreationDate 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{ return writeXMPSidecar(sidecarPath(fullPath), xmpSidecarData{
AssetID: pa.asset.ID, AssetID: pa.asset.ID,
OriginalFilename: pa.asset.Filename, OriginalFilename: pa.asset.Filename,
@@ -854,15 +1019,29 @@ func writeSidecarIfNeeded(pa pendingAsset, result photos.ExportResult, originals
AlbumPath: pa.path, AlbumPath: pa.path,
ManifestPath: relPath, ManifestPath: relPath,
MediaType: pa.asset.MediaType, MediaType: pa.asset.MediaType,
MediaSubtypes: pa.asset.MediaSubtypes,
SourceType: pa.asset.SourceType,
PlaybackStyle: pa.asset.PlaybackStyle,
PixelWidth: pa.asset.PixelWidth, PixelWidth: pa.asset.PixelWidth,
PixelHeight: pa.asset.PixelHeight, PixelHeight: pa.asset.PixelHeight,
Duration: pa.asset.Duration,
IsFavorite: pa.asset.IsFavorite, IsFavorite: pa.asset.IsFavorite,
IsHidden: pa.asset.IsHidden,
HasAdjustments: pa.asset.HasAdjustments,
Cloud: result.Cloud, Cloud: result.Cloud,
ExportMode: mode, ExportMode: mode,
PhotoscliVersion: version, PhotoscliVersion: version,
ExportedAt: time.Now().UTC().Format(time.RFC3339), ExportedAt: time.Now().UTC().Format(time.RFC3339),
Size: result.Size, Size: result.Size,
CreateDate: createDate, CreateDate: createDate,
ModifyDate: modifyDate,
Location: 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,
}) })
} }
@@ -1004,13 +1183,25 @@ func backupTree(nodes []photos.CollectionNode, outDir string, targetSize, qualit
} }
func exportPending(pending []pendingAsset, targetSize, quality int, originals bool, total int, bar *progressBar, bridge photos.Bridge, m manifest.Manifest, concurrency int, lw manifest.LogWriter, opts exportOptions) (int, int) { func exportPending(pending []pendingAsset, targetSize, quality int, originals bool, total int, bar *progressBar, bridge photos.Bridge, m manifest.Manifest, concurrency int, lw manifest.LogWriter, opts exportOptions) (int, int) {
if len(pending) < 4 { var cache *geocodeCache
return exportPendingSerial(pending, targetSize, quality, originals, total, bar, bridge, m, lw, opts) if opts.reverseGeocode && len(pending) > 0 {
root := pending[0].root
if root == "" {
root = pending[0].path
} }
return exportPendingParallel(pending, targetSize, quality, originals, total, bar, bridge, concurrency, m, lw, opts) cache = newGeocodeCache(root)
}
if len(pending) < 4 {
return exportPendingSerial(pending, targetSize, quality, originals, total, bar, bridge, m, lw, opts, cache)
}
return exportPendingParallel(pending, targetSize, quality, originals, total, bar, bridge, concurrency, m, lw, opts, cache)
} }
func exportPendingSerial(pending []pendingAsset, targetSize, quality int, originals bool, total int, bar *progressBar, bridge photos.Bridge, m manifest.Manifest, lw manifest.LogWriter, opts exportOptions) (int, int) { func exportPendingSerial(pending []pendingAsset, targetSize, quality int, originals bool, total int, bar *progressBar, bridge photos.Bridge, m manifest.Manifest, lw manifest.LogWriter, opts exportOptions, cache ...*geocodeCache) (int, int) {
var geo *geocodeCache
if len(cache) > 0 {
geo = cache[0]
}
done := 0 done := 0
failed := 0 failed := 0
var totalBytes int64 var totalBytes int64
@@ -1039,7 +1230,7 @@ func exportPendingSerial(pending []pendingAsset, targetSize, quality int, origin
} else if isSkipped { } else if isSkipped {
addManifestEntry(m, pa, result) addManifestEntry(m, pa, result)
} else { } else {
if sidecarErr := writeSidecarIfNeeded(pa, result, originals, opts); sidecarErr != nil { if sidecarErr := writeSidecarIfNeeded(pa, result, originals, opts, geo, bridge); sidecarErr != nil {
failed++ failed++
exportErr = sidecarErr exportErr = sidecarErr
isErr = true isErr = true
@@ -1075,7 +1266,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
@@ -1175,7 +1370,7 @@ 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 {
if sidecarErr := writeSidecarIfNeeded(entry.pa, entry.result, originals, opts); sidecarErr != nil { if sidecarErr := writeSidecarIfNeeded(entry.pa, entry.result, originals, opts, geo, bridge); sidecarErr != nil {
failed++ failed++
entry.err = sidecarErr entry.err = sidecarErr
isErr = true isErr = true
@@ -1531,6 +1726,7 @@ func parseExportOptions(args []string, stderr io.Writer) (exportOptions, bool) {
verify: hasFlag(args, "--verify"), verify: hasFlag(args, "--verify"),
format: flagValWithDefault(args, "--format", "jpeg"), format: flagValWithDefault(args, "--format", "jpeg"),
sidecar: flagValWithDefault(args, "--sidecar", "none"), sidecar: flagValWithDefault(args, "--sidecar", "none"),
reverseGeocode: hasFlag(args, "--reverse-geocode"),
dateTemplate: flagVal(args, "--date-template"), dateTemplate: flagVal(args, "--date-template"),
} }
if opts.media != "photos" && opts.media != "videos" && opts.media != "all" { if opts.media != "photos" && opts.media != "videos" && opts.media != "all" {
+176 -3
View File
@@ -31,6 +31,7 @@ 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
} }
@@ -57,6 +58,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)
@@ -4167,17 +4174,31 @@ func TestXMPSidecarHelpers(t *testing.T) {
AlbumPath: "/tmp/A&B", AlbumPath: "/tmp/A&B",
ManifestPath: "A&B/IMG_0001.jpg", ManifestPath: "A&B/IMG_0001.jpg",
MediaType: "image", MediaType: "image",
MediaSubtypes: []string{"photoLive", `hdr&<>"`},
SourceType: "userLibrary",
PlaybackStyle: "livePhoto",
PixelWidth: 10, PixelWidth: 10,
PixelHeight: 20, PixelHeight: 20,
Duration: 1.25,
IsFavorite: true, IsFavorite: true,
IsHidden: true,
HasAdjustments: true,
Cloud: "local", Cloud: "local",
ExportMode: "preview", ExportMode: "preview",
PhotoscliVersion: "test", PhotoscliVersion: "test",
ExportedAt: "2026-01-01T00:00:00Z", ExportedAt: "2026-01-01T00:00:00Z",
Size: 123, Size: 123,
CreateDate: "2024-01-01T00:00:00Z", CreateDate: "2024-01-01T00:00:00Z",
ModifyDate: "2024-02-01T00:00:00Z",
Location: &photos.AssetLocation{Latitude: 59.3293, Longitude: 18.0686, Altitude: 10, HorizontalAccuracy: 5},
Placemark: &photos.Placemark{Country: "Sweden", CountryCode: "SE", Locality: "Stockholm", FormattedAddress: "Stockholm, Sweden", AreasOfInterest: []string{"Gamla stan"}},
BurstIdentifier: "burst1",
RepresentsBurst: true,
BurstSelectionTypes: []string{"autoPick"},
AdjustmentInfo: &photos.AdjustmentInfo{FormatIdentifier: "com.apple", FormatVersion: "1.0", BaseFilename: "base.heic"},
Resources: []photos.AssetResource{{Type: "photo", Filename: `res&.heic`, UTI: "public.heic", Local: true, Size: 99}},
})) }))
for _, want := range []string{"photoscli:assetID=\"id&amp;&lt;&gt;&#34;\"", "photoscli:isFavorite=\"true\"", "xmp:CreateDate=\"2024-01-01T00:00:00Z\""} { for _, want := range []string{"photoscli:assetID=\"id&amp;&lt;&gt;&#34;\"", "photoscli:isFavorite=\"true\"", "photoscli:hasAdjustments=\"true\"", "xmp:ModifyDate=\"2024-02-01T00:00:00Z\"", "photoscli:latitude=\"59.32930000\"", "photoscli:addressCountry=\"Sweden\"", "photoscli:adjustmentFormatIdentifier=\"com.apple\"", "<photoscli:resources><rdf:Seq>", "photoscli:resourceFilename=\"res&amp;.heic\"", "<photoscli:mediaSubtypes><rdf:Seq>"} {
if !strings.Contains(xmp, want) { if !strings.Contains(xmp, want) {
t.Fatalf("XMP missing %q in %s", want, xmp) t.Fatalf("XMP missing %q in %s", want, xmp)
} }
@@ -4235,6 +4256,158 @@ func TestSidecarExportIntegration(t *testing.T) {
} }
} }
func TestSidecarReverseGeocodeCache(t *testing.T) {
dir := t.TempDir()
geoCalls := 0
b := &mockBridge{assets: []photos.Asset{{ID: "x1", Filename: "geo.jpg", Location: &photos.AssetLocation{Latitude: 59.3293, Longitude: 18.0686}}}}
b.reverseGeocodeFn = func(lat, lon float64) (photos.Placemark, error) {
geoCalls++
return photos.Placemark{Country: "Sweden", Locality: "Stockholm", FormattedAddress: "Stockholm, Sweden"}, nil
}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
name := fmt.Sprintf("photo%d.jpg", geoCalls)
if err := os.WriteFile(filepath.Join(out, name), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: name, Size: 4, Cloud: "local"}, nil
}
for i := 0; i < 2; i++ {
exported, failed := exportAssets(b.assets, dir, 1024, 85, 1, false, 1, io.Discard, b, "Album", true, manifest.FormatJSONL, false, exportOptions{sidecar: "xmp", reverseGeocode: true})
if exported != 1 || failed != 0 {
t.Fatalf("run %d exported=%d failed=%d", i, exported, failed)
}
}
if geoCalls != 1 {
t.Fatalf("expected cached geocode after first call, got %d", geoCalls)
}
data, err := os.ReadFile(filepath.Join(dir, "photo1.xmp"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "photoscli:addressCity=\"Stockholm\"") || !strings.Contains(string(data), "photoscli:reverseGeocoder=\"MapKit\"") {
t.Fatalf("missing geocode fields: %s", string(data))
}
if _, err := os.Stat(filepath.Join(dir, ".photoscli", "geocode-cache.jsonl")); err != nil {
t.Fatalf("missing geocode cache: %v", err)
}
}
func TestGeocodeCacheBranches(t *testing.T) {
dir := t.TempDir()
cache := newGeocodeCache(dir)
if got := cache.lookup(1, 2, &mockBridge{reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) {
return photos.Placemark{}, fmt.Errorf("offline")
}}); got != nil {
t.Fatalf("expected nil on geocode error, got %+v", got)
}
if got := (*geocodeCache)(nil).lookup(1, 2, &mockBridge{}); got != nil {
t.Fatalf("expected nil cache lookup, got %+v", got)
}
oldOpen := openFileFunc
openFileFunc = func(string, int, os.FileMode) (*os.File, error) { return nil, fmt.Errorf("open") }
got := cache.lookup(3, 4, &mockBridge{reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) {
return photos.Placemark{Country: "Nowhere"}, nil
}})
openFileFunc = oldOpen
if got == nil || got.Country != "Nowhere" {
t.Fatalf("expected placemark despite cache write error, got %+v", got)
}
}
func TestSidecarReverseGeocodeWithoutCache(t *testing.T) {
dir := t.TempDir()
pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "geo.jpg", Location: &photos.AssetLocation{Latitude: 1, Longitude: 2}}, root: dir, path: dir}
if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: "geo.jpg", Size: 1}, false, exportOptions{sidecar: "xmp", reverseGeocode: true}, nil, &mockBridge{}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dir, "geo.xmp"))
if err != nil {
t.Fatal(err)
}
content := string(data)
if !strings.Contains(content, "photoscli:latitude=\"1.00000000\"") || strings.Contains(content, "photoscli:reverseGeocoder") {
t.Fatalf("unexpected reverse geocode content: %s", content)
}
}
func TestSidecarModificationDate(t *testing.T) {
dir := t.TempDir()
modified := "2024-03-04T05:06:07Z"
pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "mod.jpg", ModificationDate: &modified}, path: dir}
if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: "mod.jpg", Size: 1}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dir, "mod.xmp"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "xmp:ModifyDate=\"2024-03-04T05:06:07Z\"") {
t.Fatalf("missing modify date: %s", string(data))
}
}
func TestExportPendingReverseGeocodeNoPending(t *testing.T) {
bar := newProgressBar(io.Discard, 1)
done, failed := exportPending(nil, 1024, 85, false, 0, bar, &mockBridge{}, nil, 1, manifest.NoopLogWriter, exportOptions{sidecar: "xmp", reverseGeocode: true})
if done != 0 || failed != 0 {
t.Fatalf("done=%d failed=%d", done, failed)
}
}
func TestExportPendingGeocodeCacheRootFallback(t *testing.T) {
dir := t.TempDir()
a := photos.Asset{ID: "g1", Filename: "geo.jpg", Location: &photos.AssetLocation{Latitude: 1, Longitude: 2}}
b := &mockBridge{assets: []photos.Asset{a}, reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) {
return photos.Placemark{Country: "Sweden"}, nil
}}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
if err := os.WriteFile(filepath.Join(out, "geo.jpg"), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: "geo.jpg", Size: 4}, nil
}
bar := newProgressBar(io.Discard, 1)
done, failed := exportPending([]pendingAsset{{asset: a, path: dir}}, 1024, 85, false, 1, bar, b, nil, 1, manifest.NoopLogWriter, exportOptions{sidecar: "xmp", reverseGeocode: true})
if done != 1 || failed != 0 {
t.Fatalf("done=%d failed=%d", done, failed)
}
if _, err := os.Stat(filepath.Join(dir, ".photoscli", "geocode-cache.jsonl")); err != nil {
t.Fatalf("expected fallback-root cache: %v", err)
}
}
func TestExportPendingCreatesGeocodeCacheForParallel(t *testing.T) {
dir := t.TempDir()
assets := []photos.Asset{
{ID: "g1", Filename: "one.jpg", Location: &photos.AssetLocation{Latitude: 1, Longitude: 2}},
{ID: "g2", Filename: "two.jpg", Location: &photos.AssetLocation{Latitude: 3, Longitude: 4}},
{ID: "g3", Filename: "three.jpg", Location: &photos.AssetLocation{Latitude: 5, Longitude: 6}},
{ID: "g4", Filename: "four.jpg", Location: &photos.AssetLocation{Latitude: 7, Longitude: 8}},
}
pending := make([]pendingAsset, len(assets))
for i, a := range assets {
pending[i] = pendingAsset{asset: a, root: dir, path: dir, album: "Geo"}
}
b := &mockBridge{assets: assets, reverseGeocodeFn: func(float64, float64) (photos.Placemark, error) {
return photos.Placemark{Country: "Sweden"}, nil
}}
b.exportPreviewFn = func(assetID, out string, targetSize, quality, index int) (photos.ExportResult, error) {
name := assetID + ".jpg"
if err := os.WriteFile(filepath.Join(out, name), []byte("data"), 0644); err != nil {
return photos.ExportResult{}, err
}
return photos.ExportResult{Filename: name, Size: 4}, nil
}
bar := newProgressBar(io.Discard, 4)
done, failed := exportPending(pending, 1024, 85, false, len(pending), bar, b, nil, 4, manifest.NoopLogWriter, exportOptions{sidecar: "xmp", reverseGeocode: true})
if done != len(pending) || failed != 0 {
t.Fatalf("done=%d failed=%d", done, failed)
}
if _, err := os.Stat(filepath.Join(dir, ".photoscli", "geocode-cache.jsonl")); err != nil {
t.Fatalf("expected geocode cache: %v", err)
}
}
func TestSidecarConfigAndErrors(t *testing.T) { func TestSidecarConfigAndErrors(t *testing.T) {
oldConfigValues, oldConfigLoaded := configValues, configLoaded oldConfigValues, oldConfigLoaded := configValues, configLoaded
defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }() defer func() { configValues, configLoaded = oldConfigValues, oldConfigLoaded }()
@@ -4283,7 +4456,7 @@ func TestSidecarAdditionalBranches(t *testing.T) {
writeFileFunc = oldWriteFile writeFileFunc = oldWriteFile
pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "orig.HEIC"}, path: dir, album: "Album"} pa := pendingAsset{asset: photos.Asset{ID: "x1", Filename: "orig.HEIC"}, path: dir, album: "Album"}
if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: "out.jpg", Size: 1, Cloud: "local"}, true, exportOptions{sidecar: "xmp"}); err != nil { if err := writeSidecarIfNeeded(pa, photos.ExportResult{Filename: "out.jpg", Size: 1, Cloud: "local"}, true, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
data, err := os.ReadFile(filepath.Join(dir, "out.xmp")) data, err := os.ReadFile(filepath.Join(dir, "out.xmp"))
@@ -4295,7 +4468,7 @@ func TestSidecarAdditionalBranches(t *testing.T) {
t.Fatalf("unexpected sidecar: %s", content) t.Fatalf("unexpected sidecar: %s", content)
} }
otherRoot := filepath.Join(dir, "other") otherRoot := filepath.Join(dir, "other")
if err := writeSidecarIfNeeded(pendingAsset{asset: photos.Asset{ID: "x2"}, root: otherRoot, path: dir}, photos.ExportResult{Filename: "fallback.jpg"}, false, exportOptions{sidecar: "xmp"}); err != nil { if err := writeSidecarIfNeeded(pendingAsset{asset: photos.Asset{ID: "x2"}, root: otherRoot, path: dir}, photos.ExportResult{Filename: "fallback.jpg"}, false, exportOptions{sidecar: "xmp"}, nil, &mockBridge{}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
data, err = os.ReadFile(filepath.Join(dir, "fallback.xmp")) data, err = os.ReadFile(filepath.Join(dir, "fallback.xmp"))
+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
+46
View File
@@ -10,20 +10,66 @@ type Asset struct {
Filename string `json:"filename"` Filename string `json:"filename"`
Cloud string `json:"cloud"` Cloud string `json:"cloud"`
MediaType string `json:"mediaType"` MediaType string `json:"mediaType"`
MediaSubtypes []string `json:"mediaSubtypes,omitempty"`
SourceType string `json:"sourceType,omitempty"`
PlaybackStyle string `json:"playbackStyle,omitempty"`
PixelWidth int `json:"pixelWidth"` PixelWidth int `json:"pixelWidth"`
PixelHeight int `json:"pixelHeight"` PixelHeight int `json:"pixelHeight"`
CreationDate *string `json:"creationDate,omitempty"` CreationDate *string `json:"creationDate,omitempty"`
ModificationDate *string `json:"modificationDate,omitempty"`
Duration float64 `json:"duration,omitempty"` Duration float64 `json:"duration,omitempty"`
IsFavorite bool `json:"isFavorite,omitempty"` IsFavorite bool `json:"isFavorite,omitempty"`
IsHidden bool `json:"isHidden,omitempty"`
HasAdjustments bool `json:"hasAdjustments,omitempty"` HasAdjustments bool `json:"hasAdjustments,omitempty"`
Location *AssetLocation `json:"location,omitempty"`
BurstIdentifier string `json:"burstIdentifier,omitempty"`
RepresentsBurst bool `json:"representsBurst,omitempty"`
BurstSelectionTypes []string `json:"burstSelectionTypes,omitempty"`
AdjustmentInfo *AdjustmentInfo `json:"adjustmentInfo,omitempty"`
Resources []AssetResource `json:"resources,omitempty"` Resources []AssetResource `json:"resources,omitempty"`
} }
type AssetLocation struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Altitude float64 `json:"altitude,omitempty"`
HorizontalAccuracy float64 `json:"horizontalAccuracy,omitempty"`
}
type AdjustmentInfo struct {
FormatIdentifier string `json:"formatIdentifier,omitempty"`
FormatVersion string `json:"formatVersion,omitempty"`
BaseFilename string `json:"baseFilename,omitempty"`
}
type AssetResource struct { type AssetResource struct {
Type string `json:"type"` Type string `json:"type"`
Filename string `json:"filename"` Filename string `json:"filename"`
UTI string `json:"uti"` UTI string `json:"uti"`
Local bool `json:"local"` Local bool `json:"local"`
Size int64 `json:"size,omitempty"`
}
type Placemark struct {
Name string `json:"name,omitempty"`
Country string `json:"country,omitempty"`
CountryCode string `json:"countryCode,omitempty"`
AdministrativeArea string `json:"administrativeArea,omitempty"`
SubAdministrativeArea string `json:"subAdministrativeArea,omitempty"`
Locality string `json:"locality,omitempty"`
SubLocality string `json:"subLocality,omitempty"`
Thoroughfare string `json:"thoroughfare,omitempty"`
SubThoroughfare string `json:"subThoroughfare,omitempty"`
PostalCode string `json:"postalCode,omitempty"`
FormattedAddress string `json:"formattedAddress,omitempty"`
InlandWater string `json:"inlandWater,omitempty"`
Ocean string `json:"ocean,omitempty"`
AreasOfInterest []string `json:"areasOfInterest,omitempty"`
}
type PlacemarkResponse struct {
Placemark Placemark `json:"placemark"`
Error string `json:"error,omitempty"`
} }
type ExportResult struct { type ExportResult struct {