commit 6ec16f39664cd6e4fb0ae9cf4f729bd3174373ca Author: Ein Anderssono Date: Thu Jun 11 20:25:07 2026 +0200 initial commit: applephotos CLI with progress, cloud status, per-asset export diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0fa127d --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +BINARY := ./bin/applephotos +MODULE := github.com/einand/applephotos +BRIDGE_DIR := bridge +OBJ := $(BRIDGE_DIR)/photokit_bridge.o +LIB := $(BRIDGE_DIR)/libphotokit_bridge.a +STUB_OBJ := $(BRIDGE_DIR)/photokit_bridge_stub.o +STUB_LIB := $(BRIDGE_DIR)/libphotokit_bridge_stub.a + +.PHONY: build clean test coverage + +$(LIB): $(OBJ) + ar rcs $@ $< + +$(OBJ): $(BRIDGE_DIR)/photokit_bridge.m $(BRIDGE_DIR)/photokit_bridge.h + cc -c -x objective-c -fobjc-arc -framework Photos -framework Foundation -o $@ $< + +$(STUB_LIB): $(STUB_OBJ) + ar rcs $@ $< + +$(STUB_OBJ): $(BRIDGE_DIR)/photokit_bridge_stub.c $(BRIDGE_DIR)/photokit_bridge.h + cc -c -o $@ $< + +build: $(LIB) + go build -o $(BINARY) $(MODULE)/cmd/applephotos + +test: $(STUB_LIB) + go test -tags=test -coverprofile=coverage.out -covermode=atomic -coverpkg=./... ./... + @grep -v 'main_main.go' coverage.out > coverage_filtered.out 2>/dev/null || true + @mv coverage_filtered.out coverage.out 2>/dev/null || true + @go tool cover -func=coverage.out | tail -1 + +coverage: $(STUB_LIB) + go test -tags=test -coverprofile=coverage.out -covermode=atomic -coverpkg=./... ./... + @grep -v 'main_main.go' coverage.out > coverage_filtered.out 2>/dev/null || true + @mv coverage_filtered.out coverage.out 2>/dev/null || true + go tool cover -func=coverage.out + +clean: + rm -f $(BINARY) $(OBJ) $(LIB) $(STUB_OBJ) $(STUB_LIB) coverage.out \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2f5822d --- /dev/null +++ b/README.md @@ -0,0 +1,206 @@ +# applephotos + +`applephotos` is a small macOS-only CLI written in Go that reads data from Apple Photos through a PhotoKit bridge. + +It supports five tasks: + +- listing albums +- listing asset IDs and filenames in an album +- showing the folder and album tree +- backing up all albums into the Photos folder tree +- exporting resized JPEG previews or original files from an album + +## What The Code Does + +The executable lives in `cmd/applephotos` and calls into `internal/photos`, which wraps an Objective-C bridge in `bridge/`. + +Current behavior: + +- `albums` prints one line per album as `\t` +- `photos --album-id <id-or-title>` prints one asset local identifier and filename per line; accepts either a PhotoKit local identifier or an album title +- `tree` prints the human-readable folder and album hierarchy from Apple Photos +- `backup-all --out <dir> [--size <px>] [--originals]` exports every album into a matching folder tree under the output directory +- `export --album-id <id-or-title> --out <dir> [--size <px>] [--originals]` exports either JPEG previews or original files and reports the number exported on stderr; `--album-id` accepts either a PhotoKit local identifier or an album title + +The bridge uses PhotoKit to: + +- request access to the user's Photos library +- fetch album collections by local identifier or album title +- fetch album assets sorted by `creationDate` ascending +- render resized images with `PHImageManager` +- write JPEG files with compression `0.85` +- support graceful cancellation via a cancel flag checked between file exports + +## Requirements + +- macOS +- Go 1.22+ +- Xcode command-line tools + +The project builds with cgo and links against `Photos`, `Foundation`, and `AppKit`. + +## Build + +```bash +make build +``` + +Output binary: + +```bash +./bin/applephotos +``` + +## Test + +```bash +make test +``` + +Tests run against a stub bridge, so they do not require real Photos access. + +## Usage + +```bash +./bin/applephotos albums +./bin/applephotos photos --album-id "<album-local-identifier>" +./bin/applephotos photos --album-id "Vacation" +./bin/applephotos tree +./bin/applephotos backup-all --out ./backup +./bin/applephotos backup-all --out ./backup --originals +./bin/applephotos export --album-id "<album-local-identifier>" --out ./export +./bin/applephotos export --album-id "Vacation" --out ./export +./bin/applephotos export --album-id "<album-local-identifier>" --out ./export --size 2048 +./bin/applephotos export --album-id "<album-local-identifier>" --out ./export --originals +``` + +### Commands + +`albums` + +- Requests Photos access +- Lists albums as tab-separated album ID and title + +Example output: + +```text +5E9F.../L0/001 Vacation +8A1B.../L0/001 Work +``` + +`photos --album-id <id-or-title>` + +- Requests Photos access +- If the value looks like a PhotoKit local identifier, uses it directly +- Otherwise searches album titles for a match and resolves the identifier +- Lists asset local identifiers for the given album + +Example output: + +```text +1F2A.../L0/001 IMG_0001.JPG +9C4D.../L0/001 IMG_0002.JPG +``` + +`backup-all --out <dir> [--size <px>] [--originals]` + +- Requests Photos access +- Walks the Photos folder and album hierarchy +- Creates directories as `out/folder/album/files` +- Exports previews by default +- Exports original files when `--originals` is present +- Uses `--size` only for preview export + +Example layout: + +```text +backup/ + Trips/ + Italy 2024/ + Venice/ + 0000_....jpg + Favorites/ + 0000_....jpg +``` + +`export --album-id <id-or-title> --out <dir> [--size <px>] [--originals]` + +- Requests Photos access +- Resolves `--album-id` by local identifier first, then by album title if not found +- Creates the output directory if needed +- Exports resized JPEG previews by default +- Exports original files when `--originals` is present +- Writes a summary like `exported 10 photos to ./export` or `exported 10 original files to ./export` to stderr + +`--size` is the target bounding box passed to PhotoKit for preview export. Default: `1024`. + +`--originals` switches export mode to original-file export. In that mode, `--size` is ignored. + +`tree` + +- Requests Photos access +- Prints folders and albums as an indented tree +- Omits internal album IDs for human-readable output + +Example output: + +```text +Trips + Italy 2024 + Venice +Favorites +``` + +## Permissions + +On first use, macOS may prompt for Photos access. + +If access is denied, the CLI returns an error telling you to grant access in: + +`System Settings > Privacy & Security` + +## Export Details + +Exported files currently: + +- are JPEGs when exporting previews +- keep their original filenames when exporting originals when possible +- fall back to a sanitized asset identifier if an original filename is unavailable +- prefix duplicate original filenames with the asset index to avoid collisions +- name preview exports like `0000_<asset-local-identifier>.jpg` +- replace `/` and `\` in asset IDs with `_` for generated preview filenames +- replace `/` and `\` in folder and album names with `_` when creating backup directory names +- preserve ordering based on ascending asset creation date + +If some assets fail but at least one succeeds, the command still succeeds and reports the number exported. + +If all exports fail, the command returns an error. + +## Signal Handling + +Sending `SIGINT` (Ctrl+C) or `SIGTERM` during export or backup triggers a graceful shutdown: + +1. The CLI prints `received signal, finishing current file...` to stderr +2. The current file export is allowed to complete +3. No further files are started +4. The process exits after the in-progress file finishes + +A second signal forces an immediate exit. + +- `cmd/applephotos`: CLI entrypoint, argument parsing, and album name resolution +- `internal/photos`: Go bridge interface, JSON parsing, and error mapping +- `bridge/`: Objective-C PhotoKit implementation plus a C test stub + +Data passed from Objective-C to Go is serialized as JSON and unmarshaled into Go structs. + +## Known Limitations + +- The tree view only shows user collections exposed through PhotoKit's top-level user collections API +- Album title resolution matches the first album with that title; if multiple albums share a title, use the local identifier instead +- `photos` only prints asset IDs and filenames, not dates or metadata +- Preview export uses PhotoKit preview rendering, not original file export +- Original export currently writes the first PhotoKit asset resource for each asset, which may not capture every related representation for complex assets +- iCloud-backed assets may require network download during export +- Export is synchronous and has no progress output +- A second interrupt signal forces an immediate exit without waiting for the current file +- Partial export failures are not listed individually diff --git a/bin/applephotos b/bin/applephotos new file mode 100755 index 0000000..b244578 Binary files /dev/null and b/bin/applephotos differ diff --git a/bridge/libphotokit_bridge.a b/bridge/libphotokit_bridge.a new file mode 100644 index 0000000..9bfc873 Binary files /dev/null and b/bridge/libphotokit_bridge.a differ diff --git a/bridge/libphotokit_bridge_stub.a b/bridge/libphotokit_bridge_stub.a new file mode 100644 index 0000000..f711859 Binary files /dev/null and b/bridge/libphotokit_bridge_stub.a differ diff --git a/bridge/photokit_bridge.h b/bridge/photokit_bridge.h new file mode 100644 index 0000000..0109464 --- /dev/null +++ b/bridge/photokit_bridge.h @@ -0,0 +1,54 @@ +#ifndef PHOTOKIT_BRIDGE_H +#define PHOTOKIT_BRIDGE_H + +#ifdef __cplusplus +extern "C" { +#endif + +int photos_request_access(void); + +char *photos_list_albums_json(void); + +char *photos_list_assets_json(const char *album_id); + +char *photos_export_preview_json( + const char *asset_id, + const char *output_dir, + int target_size, + int index +); + +char *photos_export_original_json( + const char *asset_id, + const char *output_dir, + int index +); + +char *photos_list_tree_json(void); + +int photos_export_album_previews( + const char *album_id, + const char *output_dir, + int target_size +); + +int photos_export_album_originals( + const char *album_id, + const char *output_dir +); + +int photos_backup_all( + const char *output_dir, + int target_size, + int originals +); + +void photos_request_cancel(void); + +void photos_free_string(char *value); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/bridge/photokit_bridge.m b/bridge/photokit_bridge.m new file mode 100644 index 0000000..cdb3a92 --- /dev/null +++ b/bridge/photokit_bridge.m @@ -0,0 +1,593 @@ +#import <Foundation/Foundation.h> +#import <AppKit/AppKit.h> +#import <Photos/Photos.h> +#import "photokit_bridge.h" + +static volatile int photos_cancelled = 0; + +static NSDictionary *make_error_dict(NSString *message) { + return @{@"error": message}; +} + +static NSDictionary *collection_to_dict(PHCollection *collection) { + NSString *name = collection.localizedTitle ?: @"Untitled"; + NSString *identifier = collection.localIdentifier ?: @""; + + if ([collection isKindOfClass:[PHCollectionList class]]) { + PHCollectionList *list = (PHCollectionList *)collection; + PHFetchResult<PHCollection *> *children = [PHCollectionList fetchCollectionsInCollectionList:list + options:nil]; + NSMutableArray *childList = [NSMutableArray arrayWithCapacity:children.count]; + for (NSUInteger i = 0; i < children.count; i++) { + NSDictionary *child = collection_to_dict(children[i]); + if (child) { + [childList addObject:child]; + } + } + return @{ + @"id": identifier, + @"name": name, + @"kind": @"folder", + @"children": childList, + }; + } + + if ([collection isKindOfClass:[PHAssetCollection class]]) { + return @{ + @"id": identifier, + @"name": name, + @"kind": @"album", + @"children": @[], + }; + } + + return nil; +} + +static char *json_from_object(id obj) { + if (!obj) return NULL; + NSData *data = [NSJSONSerialization dataWithJSONObject:obj options:0 error:nil]; + if (!data) return NULL; + const char *utf8 = [data bytes]; + size_t len = [data length]; + char *copy = (char *)malloc(len + 1); + if (!copy) return NULL; + memcpy(copy, utf8, len); + copy[len] = '\0'; + return copy; +} + +static NSData *nsimage_to_jpeg(NSImage *image, CGFloat compression) { + NSData *result = nil; + NSBitmapImageRep *rep = nil; + for (NSImageRep *r in image.representations) { + if ([r isKindOfClass:[NSBitmapImageRep class]]) { + rep = (NSBitmapImageRep *)r; + break; + } + } + if (!rep) { + rep = [[NSBitmapImageRep alloc] initWithData:[image TIFFRepresentation]]; + } + if (rep) { + NSDictionary *props = @{NSImageCompressionMethod: @(NSTIFFCompressionJPEG), + NSImageCompressionFactor: @(compression)}; + result = [rep representationUsingType:NSBitmapImageFileTypeJPEG properties:props]; + } + return result; +} + +static BOOL ensure_directory(NSString *outputDir) { + NSFileManager *fm = [NSFileManager defaultManager]; + NSError *dirErr = nil; + return [fm createDirectoryAtPath:outputDir withIntermediateDirectories:YES + attributes:nil error:&dirErr]; +} + +static PHFetchResult<PHAsset *> *fetch_assets_for_collection(PHAssetCollection *album) { + PHFetchOptions *opts = [[PHFetchOptions alloc] init]; + opts.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"creationDate" + ascending:YES]]; + return [PHAsset fetchAssetsInAssetCollection:album options:opts]; +} + +static PHFetchResult<PHAsset *> *fetch_assets_for_album(const char *album_id) { + if (!album_id) return nil; + + NSString *nsAlbumId = [NSString stringWithUTF8String:album_id]; + PHFetchResult<PHAssetCollection *> *albums = + [PHAssetCollection fetchAssetCollectionsWithLocalIdentifiers:@[nsAlbumId] + options:nil]; + if (albums.count == 0) return nil; + + return fetch_assets_for_collection(albums.firstObject); +} + +static NSString *sanitized_asset_identifier(NSString *assetID) { + NSMutableString *safe = [assetID mutableCopy]; + NSRange fullRange = NSMakeRange(0, safe.length); + [safe replaceOccurrencesOfString:@"/" + withString:@"_" + options:0 + range:fullRange]; + [safe replaceOccurrencesOfString:@"\\" + withString:@"_" + options:0 + range:NSMakeRange(0, safe.length)]; + return safe; +} + +static NSString *unique_path_for_filename(NSString *outputDir, NSString *filename, NSUInteger index) { + NSFileManager *fm = [NSFileManager defaultManager]; + NSString *candidate = [outputDir stringByAppendingPathComponent:filename]; + if (![fm fileExistsAtPath:candidate]) { + return candidate; + } + + NSString *base = [filename stringByDeletingPathExtension]; + NSString *ext = [filename pathExtension]; + NSString *prefixed = ext.length > 0 + ? [NSString stringWithFormat:@"%04lu_%@.%@", (unsigned long)index, base, ext] + : [NSString stringWithFormat:@"%04lu_%@", (unsigned long)index, base]; + return [outputDir stringByAppendingPathComponent:prefixed]; +} + +static NSString *sanitized_path_component(NSString *name) { + NSString *source = name ?: @"Untitled"; + NSMutableString *safe = [[source stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] mutableCopy]; + if (safe.length == 0) { + return @"Untitled"; + } + NSRange fullRange = NSMakeRange(0, safe.length); + [safe replaceOccurrencesOfString:@"/" + withString:@"_" + options:0 + range:fullRange]; + [safe replaceOccurrencesOfString:@"\\" + withString:@"_" + options:0 + range:NSMakeRange(0, safe.length)]; + return safe; +} + +static int export_preview_assets(PHFetchResult<PHAsset *> *assets, NSString *outputDir, int target_size) { + PHImageManager *im = [PHImageManager defaultManager]; + CGFloat scale = (CGFloat)target_size; + CGSize targetCGSize = CGSizeMake(scale, scale); + PHImageRequestOptions *imgOpts = [[PHImageRequestOptions alloc] init]; + imgOpts.deliveryMode = PHImageRequestOptionsDeliveryModeHighQualityFormat; + imgOpts.resizeMode = PHImageRequestOptionsResizeModeExact; + imgOpts.networkAccessAllowed = YES; + imgOpts.synchronous = YES; + + __block int exported = 0; + __block int failed = 0; + + for (NSUInteger i = 0; i < assets.count; i++) { + if (photos_cancelled) break; + PHAsset *asset = assets[i]; + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + __block NSData *imageData = nil; + + [im requestImageForAsset:asset + targetSize:targetCGSize + contentMode:PHImageContentModeAspectFit + options:imgOpts + resultHandler:^(NSImage *result, NSDictionary *info) { + if (info[PHImageErrorKey]) { + dispatch_semaphore_signal(sem); + return; + } + if (result) { + imageData = nsimage_to_jpeg(result, 0.85); + } + dispatch_semaphore_signal(sem); + }]; + + dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); + + if (!imageData) { + failed++; + continue; + } + + NSString *safe = sanitized_asset_identifier(asset.localIdentifier); + NSString *filename = [NSString stringWithFormat:@"%04lu_%@.jpg", (unsigned long)i, safe]; + NSString *filepath = [outputDir stringByAppendingPathComponent:filename]; + + NSError *writeErr = nil; + if (![imageData writeToFile:filepath options:NSDataWritingAtomic error:&writeErr]) { + failed++; + continue; + } + + exported++; + } + + return (failed > 0 && exported == 0) ? -4 : exported; +} + +static int export_original_assets(PHFetchResult<PHAsset *> *assets, NSString *outputDir) { + PHAssetResourceManager *rm = [PHAssetResourceManager defaultManager]; + PHAssetResourceRequestOptions *opts = [[PHAssetResourceRequestOptions alloc] init]; + opts.networkAccessAllowed = YES; + + __block int exported = 0; + __block int failed = 0; + + for (NSUInteger i = 0; i < assets.count; i++) { + if (photos_cancelled) break; + PHAsset *asset = assets[i]; + NSArray<PHAssetResource *> *resources = [PHAssetResource assetResourcesForAsset:asset]; + if (resources.count == 0) { + failed++; + continue; + } + + PHAssetResource *resource = resources.firstObject; + NSString *filename = resource.originalFilename; + if (!filename || filename.length == 0) { + filename = sanitized_asset_identifier(asset.localIdentifier); + } + + NSString *filepath = unique_path_for_filename(outputDir, filename, i); + NSURL *fileURL = [NSURL fileURLWithPath:filepath]; + + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + __block NSError *writeErr = nil; + [rm writeDataForAssetResource:resource + toFile:fileURL + options:opts + completionHandler:^(NSError * _Nullable error) { + writeErr = error; + dispatch_semaphore_signal(sem); + }]; + dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); + + if (writeErr) { + failed++; + continue; + } + exported++; + } + + return (failed > 0 && exported == 0) ? -4 : exported; +} + +static int backup_collection(PHCollection *collection, NSString *outputDir, int target_size, BOOL originals) { + NSString *path = [outputDir stringByAppendingPathComponent:sanitized_path_component(collection.localizedTitle)]; + + if ([collection isKindOfClass:[PHCollectionList class]]) { + PHCollectionList *list = (PHCollectionList *)collection; + PHFetchResult<PHCollection *> *children = [PHCollectionList fetchCollectionsInCollectionList:list options:nil]; + __block int total = 0; + __block BOOL failedAny = NO; + for (NSUInteger i = 0; i < children.count; i++) { + if (photos_cancelled) break; + int rc = backup_collection(children[i], path, target_size, originals); + if (rc >= 0) { + total += rc; + } else if (rc == -4) { + failedAny = YES; + } else { + return rc; + } + } + if (failedAny && total == 0) { + return -4; + } + return total; + } + + if ([collection isKindOfClass:[PHAssetCollection class]]) { + if (!ensure_directory(path)) { + return -2; + } + PHFetchResult<PHAsset *> *assets = fetch_assets_for_collection((PHAssetCollection *)collection); + return originals ? export_original_assets(assets, path) : export_preview_assets(assets, path, target_size); + } + + return 0; +} + +int photos_request_access(void) { + __block PHAuthorizationStatus status = [PHPhotoLibrary authorizationStatus]; + if (status == PHAuthorizationStatusNotDetermined) { + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + [PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus s) { + status = s; + dispatch_semaphore_signal(sem); + }]; + dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); + } + return (status == PHAuthorizationStatusAuthorized) ? 0 : -1; +} + +char *photos_list_albums_json(void) { + PHFetchResult<PHAssetCollection *> *albums = + [PHAssetCollection fetchAssetCollectionsWithType:PHAssetCollectionTypeAlbum + subtype:PHAssetCollectionSubtypeAny + options:nil]; + + NSMutableArray *list = [NSMutableArray arrayWithCapacity:albums.count]; + for (NSUInteger i = 0; i < albums.count; i++) { + PHAssetCollection *album = albums[i]; + NSString *title = album.localizedTitle; + NSString *lid = album.localIdentifier; + + [list addObject:@{ + @"id": lid ?: @"", + @"title": title ?: @"" + }]; + } + + return json_from_object(@{@"albums": list}); +} + +static NSString *asset_cloud_status_string(PHAsset *asset) { + @try { + id cloudId = [asset performSelector:@selector(cloudIdentifier)]; + if (cloudId && ![cloudId isEqual:[NSNull null]]) { + return @"cloud"; + } + } @catch (NSException *exception) { + } + PHAssetResource *resource = [PHAssetResource assetResourcesForAsset:asset].firstObject; + if (resource) { + @try { + id locallyAvailable = [resource performSelector:@selector(isLocallyAvailable)]; + if (locallyAvailable && [locallyAvailable boolValue]) { + return @"local"; + } + return @"cloud"; + } @catch (NSException *exception) { + } + } + return @"local"; +} + +char *photos_list_assets_json(const char *album_id) { + if (!album_id) return json_from_object(make_error_dict(@"album_id is required")); + + NSString *nsAlbumId = [NSString stringWithUTF8String:album_id]; + PHFetchResult<PHAssetCollection *> *albums = + [PHAssetCollection fetchAssetCollectionsWithLocalIdentifiers:@[nsAlbumId] + options:nil]; + if (albums.count == 0) return json_from_object(make_error_dict(@"album not found")); + + PHAssetCollection *album = albums.firstObject; + PHFetchOptions *opts = [[PHFetchOptions alloc] init]; + opts.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"creationDate" + ascending:YES]]; + PHFetchResult<PHAsset *> *assets = + [PHAsset fetchAssetsInAssetCollection:album options:opts]; + + NSMutableArray *list = [NSMutableArray arrayWithCapacity:assets.count]; + for (NSUInteger i = 0; i < assets.count; i++) { + PHAsset *asset = assets[i]; + NSString *lid = asset.localIdentifier; + NSString *filename = nil; + NSArray<PHAssetResource *> *resources = [PHAssetResource assetResourcesForAsset:asset]; + if (resources.count > 0) { + filename = resources.firstObject.originalFilename; + } + NSString *cloudStatus = asset_cloud_status_string(asset); + [list addObject:@{ + @"id": lid ?: @"", + @"filename": filename ?: @"", + @"cloud": cloudStatus + }]; + } + + return json_from_object(@{@"assets": list, @"total": @(assets.count)}); +} + +char *photos_list_tree_json(void) { + PHFetchResult<PHCollection *> *collections = [PHCollectionList fetchTopLevelUserCollectionsWithOptions:nil]; + + NSMutableArray *list = [NSMutableArray arrayWithCapacity:collections.count]; + for (NSUInteger i = 0; i < collections.count; i++) { + NSDictionary *node = collection_to_dict(collections[i]); + if (node) { + [list addObject:node]; + } + } + + return json_from_object(@{@"collections": list}); +} + +int photos_export_album_previews(const char *album_id, const char *output_dir, int target_size) { + if (!album_id || !output_dir) return -1; + if (target_size <= 0) target_size = 1024; + + NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir]; + + if (!ensure_directory(nsOutputDir)) { + return -2; + } + + PHFetchResult<PHAsset *> *assets = fetch_assets_for_album(album_id); + if (!assets) return -3; + + return export_preview_assets(assets, nsOutputDir, target_size); +} + +int photos_export_album_originals(const char *album_id, const char *output_dir) { + if (!album_id || !output_dir) return -1; + + NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir]; + if (!ensure_directory(nsOutputDir)) { + return -2; + } + + PHFetchResult<PHAsset *> *assets = fetch_assets_for_album(album_id); + if (!assets) return -3; + + return export_original_assets(assets, nsOutputDir); +} + +int photos_backup_all(const char *output_dir, int target_size, int originals) { + if (!output_dir) return -1; + if (!originals && target_size <= 0) target_size = 1024; + + NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir]; + if (!ensure_directory(nsOutputDir)) { + return -2; + } + + PHFetchResult<PHCollection *> *collections = [PHCollectionList fetchTopLevelUserCollectionsWithOptions:nil]; + __block int total = 0; + __block BOOL failedAny = NO; + + for (NSUInteger i = 0; i < collections.count; i++) { + if (photos_cancelled) break; + int rc = backup_collection(collections[i], nsOutputDir, target_size, originals != 0); + if (rc >= 0) { + total += rc; + } else if (rc == -4) { + failedAny = YES; + } else { + return rc; + } + } + + if (failedAny && total == 0) { + return -4; + } + return total; +} + +char *photos_export_preview_json(const char *asset_id, const char *output_dir, int target_size, int index) { + if (!asset_id || !output_dir) return json_from_object(make_error_dict(@"asset_id and output_dir required")); + if (target_size <= 0) target_size = 1024; + + NSString *nsAssetId = [NSString stringWithUTF8String:asset_id]; + NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir]; + + PHFetchResult<PHAsset *> *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil]; + if (fetch.count == 0) return json_from_object(make_error_dict(@"asset not found")); + + PHAsset *asset = fetch.firstObject; + + PHImageManager *im = [PHImageManager defaultManager]; + CGFloat scale = (CGFloat)target_size; + CGSize targetCGSize = CGSizeMake(scale, scale); + PHImageRequestOptions *imgOpts = [[PHImageRequestOptions alloc] init]; + imgOpts.deliveryMode = PHImageRequestOptionsDeliveryModeHighQualityFormat; + imgOpts.resizeMode = PHImageRequestOptionsResizeModeExact; + imgOpts.networkAccessAllowed = YES; + imgOpts.synchronous = YES; + + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + __block NSData *imageData = nil; + __block NSDictionary *errorInfo = nil; + + [im requestImageForAsset:asset + targetSize:targetCGSize + contentMode:PHImageContentModeAspectFit + options:imgOpts + resultHandler:^(NSImage *result, NSDictionary *info) { + if (info[PHImageErrorKey]) { + errorInfo = info; + dispatch_semaphore_signal(sem); + return; + } + if (result) { + imageData = nsimage_to_jpeg(result, 0.85); + } + dispatch_semaphore_signal(sem); + }]; + + dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); + + if (!imageData) { + return json_from_object(@{@"error": @"failed to render image", @"cloud": asset_cloud_status_string(asset)}); + } + + NSString *safe = sanitized_asset_identifier(asset.localIdentifier); + NSString *filename = [NSString stringWithFormat:@"%04lu_%@.jpg", (unsigned long)index, safe]; + NSString *filepath = [nsOutputDir stringByAppendingPathComponent:filename]; + + NSError *writeErr = nil; + if (![imageData writeToFile:filepath options:NSDataWritingAtomic error:&writeErr]) { + return json_from_object(@{@"error": @"write failed", @"cloud": asset_cloud_status_string(asset)}); + } + + NSNumber *fileSize = nil; + NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:filepath error:nil]; + if (attrs) { + fileSize = attrs[NSFileSize]; + } + + return json_from_object(@{ + @"filename": filename, + @"size": fileSize ?: @0, + @"cloud": asset_cloud_status_string(asset) + }); +} + +char *photos_export_original_json(const char *asset_id, const char *output_dir, int index) { + if (!asset_id || !output_dir) return json_from_object(make_error_dict(@"asset_id and output_dir required")); + + NSString *nsAssetId = [NSString stringWithUTF8String:asset_id]; + NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir]; + + PHFetchResult<PHAsset *> *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil]; + if (fetch.count == 0) return json_from_object(make_error_dict(@"asset not found")); + + PHAsset *asset = fetch.firstObject; + + NSArray<PHAssetResource *> *resources = [PHAssetResource assetResourcesForAsset:asset]; + if (resources.count == 0) { + return json_from_object(@{@"error": @"no resources", @"cloud": asset_cloud_status_string(asset)}); + } + + PHAssetResource *resource = resources.firstObject; + NSString *filename = resource.originalFilename; + if (!filename || filename.length == 0) { + filename = sanitized_asset_identifier(asset.localIdentifier); + } + + NSString *filepath = unique_path_for_filename(nsOutputDir, filename, (NSUInteger)index); + NSURL *fileURL = [NSURL fileURLWithPath:filepath]; + + PHAssetResourceManager *rm = [PHAssetResourceManager defaultManager]; + PHAssetResourceRequestOptions *opts = [[PHAssetResourceRequestOptions alloc] init]; + opts.networkAccessAllowed = YES; + + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + __block NSError *writeErr = nil; + [rm writeDataForAssetResource:resource + toFile:fileURL + options:opts + completionHandler:^(NSError * _Nullable error) { + writeErr = error; + dispatch_semaphore_signal(sem); + }]; + dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER); + + if (writeErr) { + return json_from_object(@{@"error": @"write failed", @"cloud": asset_cloud_status_string(asset)}); + } + + NSString *writtenFilename = [filepath lastPathComponent]; + NSNumber *fileSize = nil; + NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:filepath error:nil]; + if (attrs) { + fileSize = attrs[NSFileSize]; + } + + return json_from_object(@{ + @"filename": writtenFilename, + @"size": fileSize ?: @0, + @"cloud": asset_cloud_status_string(asset) + }); +} + +void photos_free_string(char *value) { + if (value) free(value); +} + +void photos_request_cancel(void) { + photos_cancelled = 1; +} diff --git a/bridge/photokit_bridge.o b/bridge/photokit_bridge.o new file mode 100644 index 0000000..8c12b89 Binary files /dev/null and b/bridge/photokit_bridge.o differ diff --git a/bridge/photokit_bridge_stub.c b/bridge/photokit_bridge_stub.c new file mode 100644 index 0000000..e1fffcd --- /dev/null +++ b/bridge/photokit_bridge_stub.c @@ -0,0 +1,109 @@ +#include <stdlib.h> +#include <string.h> + +static char *alloc_json(const char *s) { + size_t len = strlen(s); + char *copy = (char *)malloc(len + 1); + memcpy(copy, s, len); + copy[len] = '\0'; + return copy; +} + +static int stub_access_rc = 0; +static const char *stub_albums_json = "{\"albums\":[]}"; +static const char *stub_assets_json = "{\"assets\":[]}"; +static const char *stub_tree_json = "{\"collections\":[]}"; +static int stub_export_rc = 0; +static int stub_export_originals_rc = 0; +static int stub_backup_all_rc = 0; +static int stub_albums_null = 0; +static int stub_assets_null = 0; +static int stub_tree_null = 0; +static int stub_cancelled = 0; +static const char *stub_export_preview_json = NULL; +static const char *stub_export_original_json = NULL; + +static char *maybe_alloc_json(const char *s) { + if (!s) return NULL; + return alloc_json(s); +} + +void photos_test_set_access(int rc) { stub_access_rc = rc; } +void photos_test_set_albums(const char *json) { stub_albums_json = json; stub_albums_null = 0; } +void photos_test_set_assets(const char *json) { stub_assets_json = json; stub_assets_null = 0; } +void photos_test_set_tree(const char *json) { stub_tree_json = json; stub_tree_null = 0; } +void photos_test_set_export_rc(int rc) { stub_export_rc = rc; } +void photos_test_set_export_originals_rc(int rc) { stub_export_originals_rc = rc; } +void photos_test_set_backup_all_rc(int rc) { stub_backup_all_rc = rc; } +void photos_test_set_albums_null(void) { stub_albums_null = 1; } +void photos_test_set_assets_null(void) { stub_assets_null = 1; } +void photos_test_set_tree_null(void) { stub_tree_null = 1; } + +int photos_request_access(void) { return stub_access_rc; } + +char *photos_list_albums_json(void) { + if (stub_albums_null) return NULL; + return alloc_json(stub_albums_json); +} + +char *photos_list_assets_json(const char *album_id) { + (void)album_id; + if (stub_assets_null) return NULL; + return alloc_json(stub_assets_json); +} + +char *photos_export_preview_json(const char *asset_id, const char *output_dir, int target_size, int index) { + (void)asset_id; + (void)output_dir; + (void)target_size; + (void)index; + return maybe_alloc_json(stub_export_preview_json); +} + +char *photos_export_original_json(const char *asset_id, const char *output_dir, int index) { + (void)asset_id; + (void)output_dir; + (void)index; + return maybe_alloc_json(stub_export_original_json); +} + +char *photos_list_tree_json(void) { + if (stub_tree_null) return NULL; + return alloc_json(stub_tree_json); +} + +int photos_export_album_previews(const char *album_id, const char *output_dir, int target_size) { + (void)album_id; + (void)output_dir; + (void)target_size; + return stub_export_rc; +} + +int photos_export_album_originals(const char *album_id, const char *output_dir) { + (void)album_id; + (void)output_dir; + return stub_export_originals_rc; +} + +int photos_backup_all(const char *output_dir, int target_size, int originals) { + (void)output_dir; + (void)target_size; + (void)originals; + return stub_backup_all_rc; +} + +void photos_free_string(char *value) { + if (value) free(value); +} + +void photos_request_cancel(void) { + stub_cancelled = 1; +} + +void photos_test_set_export_preview_json(const char *json) { + stub_export_preview_json = json; +} + +void photos_test_set_export_original_json(const char *json) { + stub_export_original_json = json; +} diff --git a/bridge/photokit_bridge_stub.o b/bridge/photokit_bridge_stub.o new file mode 100644 index 0000000..d3dc1aa Binary files /dev/null and b/bridge/photokit_bridge_stub.o differ diff --git a/cmd/applephotos/main.go b/cmd/applephotos/main.go new file mode 100644 index 0000000..e163554 --- /dev/null +++ b/cmd/applephotos/main.go @@ -0,0 +1,376 @@ +package main + +import ( + "fmt" + "io" + "strings" + + "github.com/einand/applephotos/internal/photos" +) + +func run(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int { + if len(args) < 1 { + usage(stderr) + return 1 + } + + cmd := args[0] + switch cmd { + case "albums": + return cmdAlbums(stdout, stderr, bridge) + case "photos": + return cmdPhotos(args[1:], stdout, stderr, bridge) + case "tree": + return cmdTree(stdout, stderr, bridge) + case "backup-all": + return cmdBackupAll(args[1:], stdout, stderr, bridge) + case "export": + return cmdExport(args[1:], stdout, stderr, bridge) + case "help", "--help", "-h": + usage(stderr) + return 0 + default: + fmt.Fprintf(stderr, "unknown command: %s\n", cmd) + usage(stderr) + return 1 + } +} + +func usage(w io.Writer) { + fmt.Fprintln(w, `applephotos — export optimized images from Apple Photos + +Usage: + applephotos albums + applephotos photos --album-id <id> + applephotos tree + applephotos backup-all --out <dir> [--size <px>] [--originals] + applephotos export --album-id <id> --out <dir> [--size <px>] [--originals] + +Commands: + albums List user-created albums + photos List photo assets in an album + tree Show folder and album hierarchy + backup-all Export all albums into the Photos folder tree + export Export optimized JPEG previews or original files + +Flags: + --album-id <id> Album local identifier or title (required for photos/export) + --out <dir> Output directory (required for export/backup-all) + --size <px> Target longest-side in pixels (default: 1024, preview export only) + --originals Export original files instead of JPEG previews`) +} + +func mustAuth(stderr io.Writer, bridge photos.Bridge) int { + if err := bridge.RequestAccess(); err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + return 0 +} + +func cmdAlbums(stdout, stderr io.Writer, bridge photos.Bridge) int { + if rc := mustAuth(stderr, bridge); rc != 0 { + return rc + } + albums, err := bridge.ListAlbums() + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + for _, a := range albums { + fmt.Fprintf(stdout, "%s\t%s\n", a.ID, a.Title) + } + return 0 +} + +func resolveAlbumID(bridge photos.Bridge, idOrName string) (string, error) { + _, _, err := bridge.ListAssets(idOrName) + if err == nil { + return idOrName, nil + } + albums, listErr := bridge.ListAlbums() + if listErr != nil { + return idOrName, err + } + for _, a := range albums { + if a.Title == idOrName { + return a.ID, nil + } + } + return idOrName, err +} + +func cmdPhotos(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int { + albumID := flagVal(args, "--album-id") + if albumID == "" { + fmt.Fprintln(stderr, "error: --album-id is required") + return 1 + } + if rc := mustAuth(stderr, bridge); rc != 0 { + return rc + } + resolved, err := resolveAlbumID(bridge, albumID) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + assets, _, err := bridge.ListAssets(resolved) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + for _, a := range assets { + cloud := "local" + if a.Cloud { + cloud = "cloud" + } + fmt.Fprintf(stdout, "%s\t%s\t%s\n", a.ID, a.Filename, cloud) + } + return 0 +} + +func cmdTree(stdout, stderr io.Writer, bridge photos.Bridge) int { + if rc := mustAuth(stderr, bridge); rc != 0 { + return rc + } + nodes, err := bridge.ListTree() + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + for _, node := range nodes { + printNode(stdout, node, 0) + } + return 0 +} + +func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int { + albumID := flagVal(args, "--album-id") + outDir := flagVal(args, "--out") + originals := hasFlag(args, "--originals") + sizeStr := flagValWithDefault(args, "--size", "1024") + + if albumID == "" { + fmt.Fprintln(stderr, "error: --album-id is required") + return 1 + } + if outDir == "" { + fmt.Fprintln(stderr, "error: --out is required") + return 1 + } + + if rc := mustAuth(stderr, bridge); rc != 0 { + return rc + } + + resolved, err := resolveAlbumID(bridge, albumID) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + + var size int + if !originals { + if _, err2 := fmt.Sscanf(sizeStr, "%d", &size); err2 != nil || size <= 0 { + fmt.Fprintf(stderr, "error: --size must be a positive integer, got %q\n", sizeStr) + return 1 + } + } + + assets, total, err := bridge.ListAssets(resolved) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + + exported := 0 + failed := 0 + for i, a := range assets { + var result photos.ExportResult + var exportErr error + if originals { + result, exportErr = bridge.ExportOriginal(a.ID, outDir, i) + } else { + result, exportErr = bridge.ExportPreview(a.ID, outDir, size, i) + } + + progressBar(stderr, exported+failed+1, total, result.Filename, result.Size, result.Cloud) + + if exportErr != nil { + failed++ + continue + } + exported++ + } + + if exported == 0 && failed > 0 { + fmt.Fprintf(stderr, "\nerror: all exports failed\n") + return 1 + } + + if originals { + fmt.Fprintf(stderr, "\nexported %d original files to %s\n", exported, outDir) + } else { + fmt.Fprintf(stderr, "\nexported %d photos to %s\n", exported, outDir) + } + return 0 +} + +func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int { + outDir := flagVal(args, "--out") + originals := hasFlag(args, "--originals") + sizeStr := flagValWithDefault(args, "--size", "1024") + + if outDir == "" { + fmt.Fprintln(stderr, "error: --out is required") + return 1 + } + + if rc := mustAuth(stderr, bridge); rc != 0 { + return rc + } + + var size int + if !originals { + if _, err := fmt.Sscanf(sizeStr, "%d", &size); err != nil || size <= 0 { + fmt.Fprintf(stderr, "error: --size must be a positive integer, got %q\n", sizeStr) + return 1 + } + } + + nodes, err := bridge.ListTree() + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + albumCount := countAlbums(nodes) + + totalAssets, grandTotal, err := backupTree(nodes, outDir, size, originals, stderr, bridge) + if err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return 1 + } + + if originals { + fmt.Fprintf(stderr, "\nexported %d original files across %d albums to %s\n", totalAssets, albumCount, outDir) + } else { + fmt.Fprintf(stderr, "\nexported %d preview files across %d albums to %s\n", totalAssets, albumCount, outDir) + } + _ = grandTotal + return 0 +} + +func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, originals bool, stderr io.Writer, bridge photos.Bridge) (int, int, error) { + exported := 0 + total := 0 + for _, node := range nodes { + path := outDir + "/" + sanitizePathComponent(node.Name) + if node.Kind == "folder" { + n, t, err := backupTree(node.Children, path, targetSize, originals, stderr, bridge) + if err != nil { + return exported, total, err + } + exported += n + total += t + continue + } + if node.Kind == "album" && node.ID != "" { + assets, assetTotal, err := bridge.ListAssets(node.ID) + if err != nil { + continue + } + total += assetTotal + for i, a := range assets { + var result photos.ExportResult + var exportErr error + if originals { + result, exportErr = bridge.ExportOriginal(a.ID, path, i) + } else { + result, exportErr = bridge.ExportPreview(a.ID, path, targetSize, i) + } + progressBar(stderr, exported+1, total, path+"/"+result.Filename, result.Size, result.Cloud) + if exportErr != nil { + continue + } + exported++ + } + } + } + return exported, total, nil +} + +func sanitizePathComponent(name string) string { + s := strings.TrimSpace(name) + if s == "" { + s = "Untitled" + } + s = strings.ReplaceAll(s, "/", "_") + s = strings.ReplaceAll(s, "\\", "_") + return s +} + +func flagVal(args []string, name string) string { + return flagValWithDefault(args, name, "") +} + +func hasFlag(args []string, name string) bool { + for _, arg := range args { + if arg == name { + return true + } + } + return false +} + +func printNode(w io.Writer, node photos.CollectionNode, depth int) { + for i := 0; i < depth; i++ { + fmt.Fprint(w, " ") + } + fmt.Fprintln(w, node.Name) + for _, child := range node.Children { + printNode(w, child, depth+1) + } +} + +func countAlbums(nodes []photos.CollectionNode) int { + total := 0 + for _, node := range nodes { + if node.Kind == "album" { + total++ + } + total += countAlbums(node.Children) + } + return total +} + +func flagValWithDefault(args []string, name, def string) string { + for i, arg := range args { + if arg == name && i+1 < len(args) { + return args[i+1] + } + } + return def +} + +func progressBar(w io.Writer, current, total int, filename string, size int64, cloud string) { + pct := 0 + if total > 0 { + pct = current * 100 / total + } + barWidth := 30 + filled := pct * barWidth / 100 + bar := strings.Repeat("=", filled) + strings.Repeat(" ", barWidth-filled) + fmt.Fprintf(w, "\r[%s] %3d%% %s %s %s", bar, pct, filename, formatSize(size), cloud) +} + +func formatSize(bytes int64) string { + if bytes <= 0 { + return "" + } + const kb = 1024 + const mb = kb * 1024 + if bytes >= mb { + return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb)) + } + return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb)) +} diff --git a/cmd/applephotos/main_main.go b/cmd/applephotos/main_main.go new file mode 100644 index 0000000..cb03bba --- /dev/null +++ b/cmd/applephotos/main_main.go @@ -0,0 +1,33 @@ +package main + +import ( + "os" + "os/signal" + "syscall" + + "github.com/einand/applephotos/internal/photos" +) + +func main() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + done := make(chan struct{}) + var rc int + + go func() { + rc = run(os.Args[1:], os.Stdout, os.Stderr, photos.DefaultBridge) + close(done) + }() + + select { + case <-done: + case sig := <-sigCh: + photos.DefaultBridge.Cancel() + os.Stderr.Write([]byte("\nreceived signal, finishing current file...\n")) + <-done + _ = sig + } + + os.Exit(rc) +} \ No newline at end of file diff --git a/cmd/applephotos/main_test.go b/cmd/applephotos/main_test.go new file mode 100644 index 0000000..e8770fb --- /dev/null +++ b/cmd/applephotos/main_test.go @@ -0,0 +1,625 @@ +//go:build test + +package main + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/einand/applephotos/internal/photos" +) + +type mockBridge struct { + accessErr error + albums []photos.Album + albumsErr error + assets []photos.Asset + assetsErr error + assetsByAlbum map[string][]photos.Asset + tree []photos.CollectionNode + treeErr error + exportN int + exportErr error + exportOrigN int + exportOrigErr error + exportPreviewFn func(string, string, int) (int, error) + exportOrigFn func(string, string) (int, error) + backupAllN int + backupAllErr error + backupAllFn func(string, int, bool) (int, error) + cancelled bool + exportPreviewFn2 func(string, string, int, int) (photos.ExportResult, error) + exportOrigFn2 func(string, string, int) (photos.ExportResult, error) +} + +func (m *mockBridge) RequestAccess() error { return m.accessErr } +func (m *mockBridge) ListAlbums() ([]photos.Album, error) { return m.albums, m.albumsErr } +func (m *mockBridge) ListAssets(albumID string) ([]photos.Asset, int, error) { + if m.assetsByAlbum != nil { + if assets, ok := m.assetsByAlbum[albumID]; ok { + return assets, len(assets), nil + } + return nil, 0, fmt.Errorf("album not found") + } + return m.assets, len(m.assets), m.assetsErr +} +func (m *mockBridge) ListTree() ([]photos.CollectionNode, error) { return m.tree, m.treeErr } +func (m *mockBridge) ExportAlbumPreviews(albumID, out string, size int) (int, error) { + if m.exportPreviewFn != nil { + return m.exportPreviewFn(albumID, out, size) + } + return m.exportN, m.exportErr +} +func (m *mockBridge) ExportAlbumOriginals(albumID, out string) (int, error) { + if m.exportOrigFn != nil { + return m.exportOrigFn(albumID, out) + } + return m.exportOrigN, m.exportOrigErr +} +func (m *mockBridge) ExportPreview(assetID, out string, targetSize, index int) (photos.ExportResult, error) { + if m.exportPreviewFn2 != nil { + return m.exportPreviewFn2(assetID, out, targetSize, index) + } + return photos.ExportResult{Filename: "0000_test.jpg", Size: 1024, Cloud: "local"}, nil +} +func (m *mockBridge) ExportOriginal(assetID, out string, index int) (photos.ExportResult, error) { + if m.exportOrigFn2 != nil { + return m.exportOrigFn2(assetID, out, index) + } + return photos.ExportResult{Filename: "test.jpg", Size: 2048, Cloud: "cloud"}, nil +} +func (m *mockBridge) BackupAll(out string, size int, originals bool) (int, error) { + if m.backupAllFn != nil { + return m.backupAllFn(out, size, originals) + } + return m.backupAllN, m.backupAllErr +} +func (m *mockBridge) Cancel() { m.cancelled = true } + +func runWith(args []string, b photos.Bridge) (string, string, int) { + var out, err bytes.Buffer + rc := run(args, &out, &err, b) + return out.String(), err.String(), rc +} + +func TestRunNoArgs(t *testing.T) { + _, stderr, rc := runWith(nil, &mockBridge{}) + if rc != 1 { + t.Errorf("rc = %d, want 1", rc) + } + if !strings.Contains(stderr, "applephotos") { + t.Errorf("stderr should contain usage, got: %s", stderr) + } +} + +func TestRunHelp(t *testing.T) { + for _, cmd := range []string{"help", "--help", "-h"} { + _, stderr, rc := runWith([]string{cmd}, &mockBridge{}) + if rc != 0 { + t.Errorf("%s: rc = %d, want 0", cmd, rc) + } + if !strings.Contains(stderr, "applephotos") { + t.Errorf("%s: stderr should contain usage", cmd) + } + } +} + +func TestRunUnknownCommand(t *testing.T) { + _, stderr, rc := runWith([]string{"foo"}, &mockBridge{}) + if rc != 1 { + t.Errorf("rc = %d, want 1", rc) + } + if !strings.Contains(stderr, "unknown command: foo") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdAlbumsSuccess(t *testing.T) { + b := &mockBridge{ + albums: []photos.Album{{ID: "a1", Title: "Vacation"}, {ID: "a2", Title: "Work"}}, + } + out, _, rc := runWith([]string{"albums"}, b) + if rc != 0 { + t.Errorf("rc = %d", rc) + } + expected := "a1\tVacation\na2\tWork\n" + if out != expected { + t.Errorf("out = %q, want %q", out, expected) + } +} + +func TestCmdAlbumsAuthDenied(t *testing.T) { + b := &mockBridge{accessErr: fmt.Errorf("denied")} + _, stderr, rc := runWith([]string{"albums"}, b) + if rc != 1 { + t.Errorf("rc = %d, want 1", rc) + } + if !strings.Contains(stderr, "denied") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdAlbumsBridgeError(t *testing.T) { + b := &mockBridge{albumsErr: fmt.Errorf("boom")} + _, stderr, rc := runWith([]string{"albums"}, b) + if rc != 1 { + t.Errorf("rc = %d, want 1", rc) + } + if !strings.Contains(stderr, "boom") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdAlbumsEmpty(t *testing.T) { + b := &mockBridge{albums: []photos.Album{}} + out, _, rc := runWith([]string{"albums"}, b) + if rc != 0 { + t.Errorf("rc = %d", rc) + } + if out != "" { + t.Errorf("out = %q, want empty", out) + } +} + +func TestCmdPhotosMissingAlbumID(t *testing.T) { + _, stderr, rc := runWith([]string{"photos"}, &mockBridge{}) + if rc != 1 { + t.Errorf("rc = %d, want 1", rc) + } + if !strings.Contains(stderr, "--album-id is required") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdPhotosSuccess(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "as1", Filename: "IMG_0001.JPG"}, {ID: "as2", Filename: "IMG_0002.JPG", Cloud: true}}, + } + out, _, rc := runWith([]string{"photos", "--album-id", "x"}, b) + if rc != 0 { + t.Errorf("rc = %d", rc) + } + expected := "as1\tIMG_0001.JPG\tlocal\nas2\tIMG_0002.JPG\tcloud\n" + if out != expected { + t.Errorf("out = %q, want %q", out, expected) + } +} + +func TestCmdPhotosAuthDenied(t *testing.T) { + b := &mockBridge{accessErr: fmt.Errorf("nope")} + _, stderr, rc := runWith([]string{"photos", "--album-id", "x"}, b) + if rc != 1 { + t.Errorf("rc = %d", rc) + } + if !strings.Contains(stderr, "nope") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdPhotosBridgeError(t *testing.T) { + b := &mockBridge{assetsErr: fmt.Errorf("fail")} + _, stderr, rc := runWith([]string{"photos", "--album-id", "x"}, b) + if rc != 1 { + t.Errorf("rc = %d", rc) + } + if !strings.Contains(stderr, "fail") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdTreeSuccess(t *testing.T) { + b := &mockBridge{tree: []photos.CollectionNode{ + {Name: "Trips", Kind: "folder", Children: []photos.CollectionNode{{Name: "Italy 2024", Kind: "folder", Children: []photos.CollectionNode{{Name: "Venice", Kind: "album"}}}}}, + {Name: "Favorites", Kind: "album"}, + }} + out, _, rc := runWith([]string{"tree"}, b) + if rc != 0 { + t.Errorf("rc = %d", rc) + } + expected := "Trips\n Italy 2024\n Venice\nFavorites\n" + if out != expected { + t.Errorf("out = %q, want %q", out, expected) + } +} + +func TestCmdTreeAuthDenied(t *testing.T) { + b := &mockBridge{accessErr: fmt.Errorf("denied")} + _, stderr, rc := runWith([]string{"tree"}, b) + if rc != 1 { + t.Errorf("rc = %d, want 1", rc) + } + if !strings.Contains(stderr, "denied") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdTreeBridgeError(t *testing.T) { + b := &mockBridge{treeErr: fmt.Errorf("boom")} + _, stderr, rc := runWith([]string{"tree"}, b) + if rc != 1 { + t.Errorf("rc = %d, want 1", rc) + } + if !strings.Contains(stderr, "boom") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdBackupAllPreviewSuccess(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{{Name: "Trips", Kind: "folder", Children: []photos.CollectionNode{{ID: "a1", Name: "Venice", Kind: "album"}}}, {ID: "a2", Name: "Favorites", Kind: "album"}}, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "as1", Filename: "img1.jpg"}}, + "a2": {{ID: "as2", Filename: "img2.jpg"}}, + }, + } + _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup", "--size", "2048"}, b) + if rc != 0 { + t.Fatalf("rc = %d, stderr = %q", rc, stderr) + } + if !strings.Contains(stderr, "exported 2 preview files across 2 albums to /backup") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdBackupAllOriginalsSuccess(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{{Name: "Trips", Kind: "folder", Children: []photos.CollectionNode{{ID: "a1", Name: "Venice", Kind: "album"}}}}, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "as1", Filename: "img.jpg"}, {ID: "as2", Filename: "img2.jpg"}, {ID: "as3", Filename: "img3.jpg"}}, + }, + } + _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup", "--originals", "--size", "bad"}, b) + if rc != 0 { + t.Fatalf("rc = %d, stderr = %q", rc, stderr) + } + if !strings.Contains(stderr, "exported 3 original files across 1 albums to /backup") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdBackupAllMissingOutDir(t *testing.T) { + _, stderr, rc := runWith([]string{"backup-all"}, &mockBridge{}) + if rc != 1 { + t.Fatalf("rc = %d", rc) + } + if !strings.Contains(stderr, "--out is required") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdBackupAllInvalidSize(t *testing.T) { + b := &mockBridge{tree: []photos.CollectionNode{}} + _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup", "--size", "bad"}, b) + if rc != 1 { + t.Fatalf("rc = %d", rc) + } + if !strings.Contains(stderr, "--size must be a positive integer") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdBackupAllTreeError(t *testing.T) { + b := &mockBridge{treeErr: fmt.Errorf("tree fail")} + _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup"}, b) + if rc != 1 { + t.Fatalf("rc = %d", rc) + } + if !strings.Contains(stderr, "tree fail") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdBackupAllExportError(t *testing.T) { + b := &mockBridge{ + tree: []photos.CollectionNode{{ID: "a1", Name: "Venice", Kind: "album"}}, + assetsByAlbum: map[string][]photos.Asset{ + "a1": {{ID: "as1", Filename: "img.jpg"}}, + }, + exportPreviewFn2: func(string, string, int, int) (photos.ExportResult, error) { + return photos.ExportResult{}, fmt.Errorf("disk full") + }, + } + _, stderr, rc := runWith([]string{"backup-all", "--out", "/backup"}, b) + if rc != 0 { + t.Fatalf("rc = %d, stderr = %q", rc, stderr) + } + _ = stderr +} + +func TestCmdExportMissingAlbumID(t *testing.T) { + _, stderr, rc := runWith([]string{"export", "--out", "/tmp"}, &mockBridge{}) + if rc != 1 { + t.Errorf("rc = %d", rc) + } + if !strings.Contains(stderr, "--album-id is required") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdExportMissingOutDir(t *testing.T) { + _, stderr, rc := runWith([]string{"export", "--album-id", "x"}, &mockBridge{}) + if rc != 1 { + t.Errorf("rc = %d", rc) + } + if !strings.Contains(stderr, "--out is required") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdExportInvalidSize(t *testing.T) { + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--size", "abc"}, &mockBridge{}) + if rc != 1 { + t.Errorf("rc = %d", rc) + } + if !strings.Contains(stderr, "--size must be a positive integer") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdExportNegativeSize(t *testing.T) { + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--size", "-5"}, &mockBridge{}) + if rc != 1 { + t.Errorf("rc = %d", rc) + } + if !strings.Contains(stderr, "--size must be a positive integer") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdExportSuccess(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}}, + } + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp/out"}, b) + if rc != 0 { + t.Errorf("rc = %d, stderr = %q", rc, stderr) + } + if !strings.Contains(stderr, "exported 1 photos to /tmp/out") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdExportDefaultSize(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}}, + } + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp"}, b) + if rc != 0 { + t.Errorf("rc = %d", rc) + } + if !strings.Contains(stderr, "exported 1") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdExportAuthDenied(t *testing.T) { + b := &mockBridge{accessErr: fmt.Errorf("no")} + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp"}, b) + if rc != 1 { + t.Errorf("rc = %d", rc) + } + if !strings.Contains(stderr, "no") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdExportBridgeError(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}}, + exportPreviewFn2: func(string, string, int, int) (photos.ExportResult, error) { + return photos.ExportResult{}, fmt.Errorf("disk full") + }, + } + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp"}, b) + if rc != 1 { + t.Errorf("rc = %d, stderr = %q", rc, stderr) + } + if !strings.Contains(stderr, "all exports failed") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdExportOriginalsSuccess(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}}, + } + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp/out", "--originals"}, b) + if rc != 0 { + t.Errorf("rc = %d, stderr = %q", rc, stderr) + } + if !strings.Contains(stderr, "exported 1 original files to /tmp/out") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdExportOriginalsIgnoresSizeValidation(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}}, + } + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp/out", "--originals", "--size", "abc"}, b) + if rc != 0 { + t.Errorf("rc = %d, stderr = %q", rc, stderr) + } + if strings.Contains(stderr, "--size must be a positive integer") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdExportOriginalsBridgeError(t *testing.T) { + b := &mockBridge{ + assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}}, + exportOrigFn2: func(string, string, int) (photos.ExportResult, error) { + return photos.ExportResult{}, fmt.Errorf("copy failed") + }, + } + _, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp/out", "--originals"}, b) + if rc != 1 { + t.Errorf("rc = %d, stderr = %q", rc, stderr) + } + if !strings.Contains(stderr, "all exports failed") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestFlagVal(t *testing.T) { + args := []string{"--album-id", "abc", "--out", "/tmp"} + if v := flagVal(args, "--album-id"); v != "abc" { + t.Errorf("got %q", v) + } + if v := flagVal(args, "--out"); v != "/tmp" { + t.Errorf("got %q", v) + } + if v := flagVal(args, "--missing"); v != "" { + t.Errorf("got %q, want empty", v) + } +} + +func TestFlagValTrailing(t *testing.T) { + args := []string{"--album-id"} + if v := flagVal(args, "--album-id"); v != "" { + t.Errorf("got %q, want empty for trailing flag", v) + } +} + +func TestFlagValWithDefault(t *testing.T) { + args := []string{"--size", "2048"} + if v := flagValWithDefault(args, "--size", "1024"); v != "2048" { + t.Errorf("got %q", v) + } + if v := flagValWithDefault(args, "--missing", "fallback"); v != "fallback" { + t.Errorf("got %q", v) + } +} + +func TestHasFlag(t *testing.T) { + args := []string{"export", "--originals", "--album-id", "x"} + if !hasFlag(args, "--originals") { + t.Fatal("expected --originals to be found") + } + if hasFlag(args, "--missing") { + t.Fatal("did not expect missing flag") + } +} + +func TestFlagValEmptyArgs(t *testing.T) { + if v := flagVal(nil, "--album-id"); v != "" { + t.Errorf("got %q", v) + } + if v := flagValWithDefault(nil, "--size", "1024"); v != "1024" { + t.Errorf("got %q", v) + } +} + +func TestResolveAlbumIDDirectMatch(t *testing.T) { + b := &mockBridge{ + assetsByAlbum: map[string][]photos.Asset{ + "ABC/L0/001": {{ID: "a1", Filename: "img.jpg"}}, + }, + } + id, err := resolveAlbumID(b, "ABC/L0/001") + if err != nil { + t.Fatal(err) + } + if id != "ABC/L0/001" { + t.Errorf("got %q", id) + } +} + +func TestResolveAlbumIDByName(t *testing.T) { + b := &mockBridge{ + albums: []photos.Album{ + {ID: "ABC/L0/001", Title: "DnD"}, + {ID: "DEF/L0/001", Title: "Work"}, + }, + assetsByAlbum: map[string][]photos.Asset{ + "ABC/L0/001": {{ID: "a1", Filename: "img.jpg"}}, + }, + } + id, err := resolveAlbumID(b, "DnD") + if err != nil { + t.Fatal(err) + } + if id != "ABC/L0/001" { + t.Errorf("got %q, want ABC/L0/001", id) + } +} + +func TestResolveAlbumIDNotFound(t *testing.T) { + b := &mockBridge{ + albums: []photos.Album{ + {ID: "ABC/L0/001", Title: "DnD"}, + }, + assetsByAlbum: map[string][]photos.Asset{}, + } + _, err := resolveAlbumID(b, "Nonexistent") + if err == nil { + t.Fatal("expected error") + } +} + +func TestResolveAlbumIDListAlbumsFails(t *testing.T) { + b := &mockBridge{ + albumsErr: fmt.Errorf("no access"), + assetsByAlbum: map[string][]photos.Asset{}, + } + _, err := resolveAlbumID(b, "DnD") + if err == nil { + t.Fatal("expected error") + } +} + +func TestCmdPhotosResolvesAlbumName(t *testing.T) { + b := &mockBridge{ + albums: []photos.Album{ + {ID: "ABC/L0/001", Title: "DnD"}, + }, + assetsByAlbum: map[string][]photos.Asset{ + "ABC/L0/001": {{ID: "a1", Filename: "img.jpg"}}, + }, + } + out, stderr, rc := runWith([]string{"photos", "--album-id", "DnD"}, b) + if rc != 0 { + t.Fatalf("rc = %d, stderr = %q", rc, stderr) + } + if !strings.Contains(out, "a1\timg.jpg\tlocal") { + t.Errorf("out = %q", out) + } +} + +func TestCmdExportResolvesAlbumName(t *testing.T) { + b := &mockBridge{ + albums: []photos.Album{ + {ID: "ABC/L0/001", Title: "DnD"}, + }, + assetsByAlbum: map[string][]photos.Asset{ + "ABC/L0/001": {{ID: "a1", Filename: "img.jpg"}, {ID: "a2", Filename: "img2.jpg"}, {ID: "a3", Filename: "img3.jpg"}, {ID: "a4", Filename: "img4.jpg"}, {ID: "a5", Filename: "img5.jpg"}}, + }, + } + _, stderr, rc := runWith([]string{"export", "--album-id", "DnD", "--out", "/tmp/out"}, b) + if rc != 0 { + t.Fatalf("rc = %d, stderr = %q", rc, stderr) + } + if !strings.Contains(stderr, "exported 5 photos") { + t.Errorf("stderr = %q", stderr) + } +} + +func TestCmdExportOriginalsResolvesAlbumName(t *testing.T) { + b := &mockBridge{ + albums: []photos.Album{ + {ID: "ABC/L0/001", Title: "DnD"}, + }, + assetsByAlbum: map[string][]photos.Asset{ + "ABC/L0/001": {{ID: "a1", Filename: "img.jpg"}, {ID: "a2", Filename: "img2.jpg"}, {ID: "a3", Filename: "img3.jpg"}}, + }, + } + _, stderr, rc := runWith([]string{"export", "--album-id", "DnD", "--out", "/tmp/out", "--originals"}, b) + if rc != 0 { + t.Fatalf("rc = %d, stderr = %q", rc, stderr) + } + if !strings.Contains(stderr, "exported 3 original files") { + t.Errorf("stderr = %q", stderr) + } +} diff --git a/coverage.out b/coverage.out new file mode 100644 index 0000000..134163e --- /dev/null +++ b/coverage.out @@ -0,0 +1,417 @@ +mode: atomic +github.com/einand/applephotos/cmd/applephotos/main.go:11.77,12.19 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:12.19,15.3 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:17.2,18.13 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:19.16,20.43 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:21.16,22.53 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:23.14,24.41 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:25.20,26.56 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:27.16,28.53 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:29.30,31.11 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:32.10,35.11 3 0 +github.com/einand/applephotos/cmd/applephotos/main.go:39.25,61.2 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:63.59,64.47 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:64.47,67.3 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:68.2,68.10 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:71.68,72.45 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:72.45,74.3 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:75.2,76.16 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:76.16,79.3 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:80.2,80.27 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:80.27,82.3 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:83.2,83.10 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:86.76,88.16 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:88.16,90.3 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:91.2,92.20 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:92.20,94.3 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:95.2,95.27 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:95.27,96.26 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:96.26,98.4 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:100.2,100.22 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:103.83,105.19 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:105.19,108.3 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:109.2,109.45 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:109.45,111.3 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:112.2,113.16 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:113.16,116.3 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:117.2,118.16 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:118.16,121.3 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:122.2,122.27 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:122.27,124.14 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:124.14,126.4 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:127.3,127.63 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:129.2,129.10 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:132.66,133.45 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:133.45,135.3 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:136.2,137.16 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:137.16,140.3 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:141.2,141.29 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:141.29,143.3 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:144.2,144.10 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:147.83,153.19 5 0 +github.com/einand/applephotos/cmd/applephotos/main.go:153.19,156.3 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:157.2,157.18 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:157.18,160.3 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:162.2,162.45 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:162.45,164.3 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:166.2,167.16 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:167.16,170.3 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:172.2,173.16 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:173.16,174.76 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:174.76,177.4 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:180.2,181.16 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:181.16,184.3 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:186.2,188.27 3 0 +github.com/einand/applephotos/cmd/applephotos/main.go:188.27,191.16 3 0 +github.com/einand/applephotos/cmd/applephotos/main.go:191.16,193.4 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:193.9,195.4 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:197.3,199.23 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:199.23,201.12 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:203.3,203.13 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:206.2,206.33 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:206.33,209.3 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:211.2,211.15 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:211.15,213.3 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:213.8,215.3 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:216.2,216.10 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:219.86,224.18 4 0 +github.com/einand/applephotos/cmd/applephotos/main.go:224.18,227.3 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:229.2,229.45 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:229.45,231.3 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:233.2,234.16 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:234.16,235.74 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:235.74,238.4 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:241.2,242.16 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:242.16,245.3 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:246.2,249.16 3 0 +github.com/einand/applephotos/cmd/applephotos/main.go:249.16,252.3 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:254.2,254.15 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:254.15,256.3 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:256.8,258.3 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:259.2,260.10 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:263.153,266.29 3 0 +github.com/einand/applephotos/cmd/applephotos/main.go:266.29,268.28 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:268.28,270.18 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:270.18,272.5 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:273.4,275.12 3 0 +github.com/einand/applephotos/cmd/applephotos/main.go:277.3,277.44 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:277.44,279.18 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:279.18,280.13 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:282.4,283.29 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:283.29,286.18 3 0 +github.com/einand/applephotos/cmd/applephotos/main.go:286.18,288.6 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:288.11,290.6 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:291.5,292.25 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:292.25,293.14 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:295.5,295.15 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:299.2,299.29 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:302.48,304.13 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:304.13,306.3 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:307.2,309.10 3 0 +github.com/einand/applephotos/cmd/applephotos/main.go:312.49,314.2 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:316.47,317.27 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:317.27,318.18 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:318.18,320.4 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:322.2,322.14 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:325.68,326.29 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:326.29,328.3 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:329.2,330.38 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:330.38,332.3 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:335.53,337.29 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:337.29,338.27 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:338.27,340.4 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:341.3,341.38 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:343.2,343.14 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:346.65,347.27 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:347.27,348.37 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:348.37,350.4 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:352.2,352.12 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:355.94,357.15 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:357.15,359.3 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:360.2,363.89 4 0 +github.com/einand/applephotos/cmd/applephotos/main.go:366.37,367.16 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:367.16,369.3 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:370.2,372.17 3 0 +github.com/einand/applephotos/cmd/applephotos/main.go:372.17,374.3 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:375.2,375.59 1 0 +github.com/einand/applephotos/internal/photos/bridge.go:21.55,23.63 2 7 +github.com/einand/applephotos/internal/photos/bridge.go:23.63,25.3 1 1 +github.com/einand/applephotos/internal/photos/bridge.go:26.2,26.25 1 6 +github.com/einand/applephotos/internal/photos/bridge.go:29.60,31.89 2 9 +github.com/einand/applephotos/internal/photos/bridge.go:31.89,33.3 1 2 +github.com/einand/applephotos/internal/photos/bridge.go:35.2,36.63 2 7 +github.com/einand/applephotos/internal/photos/bridge.go:36.63,38.3 1 1 +github.com/einand/applephotos/internal/photos/bridge.go:39.2,39.37 1 6 +github.com/einand/applephotos/internal/photos/bridge.go:42.62,44.89 2 8 +github.com/einand/applephotos/internal/photos/bridge.go:44.89,46.3 1 1 +github.com/einand/applephotos/internal/photos/bridge.go:48.2,49.63 2 7 +github.com/einand/applephotos/internal/photos/bridge.go:49.63,51.3 1 1 +github.com/einand/applephotos/internal/photos/bridge.go:52.2,52.30 1 6 +github.com/einand/applephotos/internal/photos/bridge.go:55.66,57.63 2 4 +github.com/einand/applephotos/internal/photos/bridge.go:57.63,59.3 1 1 +github.com/einand/applephotos/internal/photos/bridge.go:60.2,60.22 1 3 +github.com/einand/applephotos/internal/photos/bridge.go:60.22,62.3 1 1 +github.com/einand/applephotos/internal/photos/bridge.go:63.2,63.31 1 2 +github.com/einand/applephotos/internal/photos/bridge.go:66.49,67.12 1 18 +github.com/einand/applephotos/internal/photos/bridge.go:68.10,69.73 1 1 +github.com/einand/applephotos/internal/photos/bridge.go:70.10,71.60 1 2 +github.com/einand/applephotos/internal/photos/bridge.go:72.10,73.42 1 2 +github.com/einand/applephotos/internal/photos/bridge.go:74.10,75.45 1 2 +github.com/einand/applephotos/internal/photos/bridge.go:76.10,77.36 1 1 +github.com/einand/applephotos/internal/photos/bridge.go:78.10,79.13 1 10 +github.com/einand/applephotos/internal/photos/bridge.go:79.13,81.4 1 1 +github.com/einand/applephotos/internal/photos/bridge.go:82.3,82.17 1 9 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:33.39,33.78 1 3 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:34.39,34.84 1 3 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:35.39,35.84 1 4 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:36.39,36.82 1 3 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:37.39,37.81 1 3 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:38.39,38.91 1 3 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:39.39,39.85 1 3 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:40.39,40.74 1 1 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:41.39,41.74 1 1 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:42.39,42.72 1 1 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:43.44,43.102 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:44.45,44.104 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:46.41,48.13 2 3 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:48.13,50.3 1 1 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:51.2,51.12 1 2 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:54.49,56.15 2 3 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:56.15,58.3 1 1 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:59.2,60.40 2 2 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:63.68,67.15 4 4 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:67.15,69.3 1 1 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:70.2,71.40 2 3 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:74.56,76.15 2 3 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:76.15,78.3 1 1 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:79.2,80.38 2 2 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:83.95,90.2 6 3 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:92.80,99.2 6 3 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:101.92,106.15 4 3 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:106.15,108.3 1 1 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:110.2,111.39 2 3 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:114.28,116.2 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:118.105,124.15 6 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:124.15,126.3 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:127.2,128.46 2 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:131.94,137.15 6 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:137.15,139.3 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:140.2,141.46 2 0 +github.com/einand/applephotos/internal/photos/photos.go:8.28,8.68 1 1 +github.com/einand/applephotos/internal/photos/photos.go:10.36,10.73 1 1 +github.com/einand/applephotos/internal/photos/photos.go:12.55,12.99 1 1 +github.com/einand/applephotos/internal/photos/photos.go:14.43,14.78 1 1 +github.com/einand/applephotos/internal/photos/photos.go:16.82,18.2 1 1 +github.com/einand/applephotos/internal/photos/photos.go:20.67,22.2 1 1 +github.com/einand/applephotos/internal/photos/photos.go:24.79,26.2 1 1 +github.com/einand/applephotos/internal/photos/photos.go:28.15,28.41 1 0 +github.com/einand/applephotos/internal/photos/photos.go:30.92,32.2 1 0 +github.com/einand/applephotos/internal/photos/photos.go:34.81,36.2 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:11.77,12.19 1 36 +github.com/einand/applephotos/cmd/applephotos/main.go:12.19,15.3 2 1 +github.com/einand/applephotos/cmd/applephotos/main.go:17.2,18.13 2 35 +github.com/einand/applephotos/cmd/applephotos/main.go:19.16,20.43 1 4 +github.com/einand/applephotos/cmd/applephotos/main.go:21.16,22.53 1 5 +github.com/einand/applephotos/cmd/applephotos/main.go:23.14,24.41 1 3 +github.com/einand/applephotos/cmd/applephotos/main.go:25.20,26.56 1 6 +github.com/einand/applephotos/cmd/applephotos/main.go:27.16,28.53 1 13 +github.com/einand/applephotos/cmd/applephotos/main.go:29.30,31.11 2 3 +github.com/einand/applephotos/cmd/applephotos/main.go:32.10,35.11 3 1 +github.com/einand/applephotos/cmd/applephotos/main.go:39.25,61.2 1 5 +github.com/einand/applephotos/cmd/applephotos/main.go:63.59,64.47 1 27 +github.com/einand/applephotos/cmd/applephotos/main.go:64.47,67.3 2 4 +github.com/einand/applephotos/cmd/applephotos/main.go:68.2,68.10 1 23 +github.com/einand/applephotos/cmd/applephotos/main.go:71.68,72.45 1 4 +github.com/einand/applephotos/cmd/applephotos/main.go:72.45,74.3 1 1 +github.com/einand/applephotos/cmd/applephotos/main.go:75.2,76.16 2 3 +github.com/einand/applephotos/cmd/applephotos/main.go:76.16,79.3 2 1 +github.com/einand/applephotos/cmd/applephotos/main.go:80.2,80.27 1 2 +github.com/einand/applephotos/cmd/applephotos/main.go:80.27,82.3 1 2 +github.com/einand/applephotos/cmd/applephotos/main.go:83.2,83.10 1 2 +github.com/einand/applephotos/cmd/applephotos/main.go:86.76,88.16 2 17 +github.com/einand/applephotos/cmd/applephotos/main.go:88.16,90.3 1 10 +github.com/einand/applephotos/cmd/applephotos/main.go:91.2,92.20 2 7 +github.com/einand/applephotos/cmd/applephotos/main.go:92.20,94.3 1 1 +github.com/einand/applephotos/cmd/applephotos/main.go:95.2,95.27 1 6 +github.com/einand/applephotos/cmd/applephotos/main.go:95.27,96.26 1 5 +github.com/einand/applephotos/cmd/applephotos/main.go:96.26,98.4 1 4 +github.com/einand/applephotos/cmd/applephotos/main.go:100.2,100.22 1 2 +github.com/einand/applephotos/cmd/applephotos/main.go:103.83,105.19 2 5 +github.com/einand/applephotos/cmd/applephotos/main.go:105.19,108.3 2 1 +github.com/einand/applephotos/cmd/applephotos/main.go:109.2,109.45 1 4 +github.com/einand/applephotos/cmd/applephotos/main.go:109.45,111.3 1 1 +github.com/einand/applephotos/cmd/applephotos/main.go:112.2,113.16 2 3 +github.com/einand/applephotos/cmd/applephotos/main.go:113.16,116.3 2 1 +github.com/einand/applephotos/cmd/applephotos/main.go:117.2,118.16 2 2 +github.com/einand/applephotos/cmd/applephotos/main.go:118.16,121.3 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:122.2,122.27 1 2 +github.com/einand/applephotos/cmd/applephotos/main.go:122.27,124.14 2 3 +github.com/einand/applephotos/cmd/applephotos/main.go:124.14,126.4 1 1 +github.com/einand/applephotos/cmd/applephotos/main.go:127.3,127.63 1 3 +github.com/einand/applephotos/cmd/applephotos/main.go:129.2,129.10 1 2 +github.com/einand/applephotos/cmd/applephotos/main.go:132.66,133.45 1 3 +github.com/einand/applephotos/cmd/applephotos/main.go:133.45,135.3 1 1 +github.com/einand/applephotos/cmd/applephotos/main.go:136.2,137.16 2 2 +github.com/einand/applephotos/cmd/applephotos/main.go:137.16,140.3 2 1 +github.com/einand/applephotos/cmd/applephotos/main.go:141.2,141.29 1 1 +github.com/einand/applephotos/cmd/applephotos/main.go:141.29,143.3 1 2 +github.com/einand/applephotos/cmd/applephotos/main.go:144.2,144.10 1 1 +github.com/einand/applephotos/cmd/applephotos/main.go:147.83,153.19 5 13 +github.com/einand/applephotos/cmd/applephotos/main.go:153.19,156.3 2 1 +github.com/einand/applephotos/cmd/applephotos/main.go:157.2,157.18 1 12 +github.com/einand/applephotos/cmd/applephotos/main.go:157.18,160.3 2 1 +github.com/einand/applephotos/cmd/applephotos/main.go:162.2,162.45 1 11 +github.com/einand/applephotos/cmd/applephotos/main.go:162.45,164.3 1 1 +github.com/einand/applephotos/cmd/applephotos/main.go:166.2,167.16 2 10 +github.com/einand/applephotos/cmd/applephotos/main.go:167.16,170.3 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:172.2,173.16 2 10 +github.com/einand/applephotos/cmd/applephotos/main.go:173.16,174.76 1 6 +github.com/einand/applephotos/cmd/applephotos/main.go:174.76,177.4 2 2 +github.com/einand/applephotos/cmd/applephotos/main.go:180.2,181.16 2 8 +github.com/einand/applephotos/cmd/applephotos/main.go:181.16,184.3 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:186.2,188.27 3 8 +github.com/einand/applephotos/cmd/applephotos/main.go:188.27,191.16 3 14 +github.com/einand/applephotos/cmd/applephotos/main.go:191.16,193.4 1 6 +github.com/einand/applephotos/cmd/applephotos/main.go:193.9,195.4 1 8 +github.com/einand/applephotos/cmd/applephotos/main.go:197.3,199.23 2 14 +github.com/einand/applephotos/cmd/applephotos/main.go:199.23,201.12 2 2 +github.com/einand/applephotos/cmd/applephotos/main.go:203.3,203.13 1 12 +github.com/einand/applephotos/cmd/applephotos/main.go:206.2,206.33 1 8 +github.com/einand/applephotos/cmd/applephotos/main.go:206.33,209.3 2 2 +github.com/einand/applephotos/cmd/applephotos/main.go:211.2,211.15 1 6 +github.com/einand/applephotos/cmd/applephotos/main.go:211.15,213.3 1 3 +github.com/einand/applephotos/cmd/applephotos/main.go:213.8,215.3 1 3 +github.com/einand/applephotos/cmd/applephotos/main.go:216.2,216.10 1 6 +github.com/einand/applephotos/cmd/applephotos/main.go:219.86,224.18 4 6 +github.com/einand/applephotos/cmd/applephotos/main.go:224.18,227.3 2 1 +github.com/einand/applephotos/cmd/applephotos/main.go:229.2,229.45 1 5 +github.com/einand/applephotos/cmd/applephotos/main.go:229.45,231.3 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:233.2,234.16 2 5 +github.com/einand/applephotos/cmd/applephotos/main.go:234.16,235.74 1 4 +github.com/einand/applephotos/cmd/applephotos/main.go:235.74,238.4 2 1 +github.com/einand/applephotos/cmd/applephotos/main.go:241.2,242.16 2 4 +github.com/einand/applephotos/cmd/applephotos/main.go:242.16,245.3 2 1 +github.com/einand/applephotos/cmd/applephotos/main.go:246.2,249.16 3 3 +github.com/einand/applephotos/cmd/applephotos/main.go:249.16,252.3 2 0 +github.com/einand/applephotos/cmd/applephotos/main.go:254.2,254.15 1 3 +github.com/einand/applephotos/cmd/applephotos/main.go:254.15,256.3 1 1 +github.com/einand/applephotos/cmd/applephotos/main.go:256.8,258.3 1 2 +github.com/einand/applephotos/cmd/applephotos/main.go:259.2,260.10 2 3 +github.com/einand/applephotos/cmd/applephotos/main.go:263.153,266.29 3 5 +github.com/einand/applephotos/cmd/applephotos/main.go:266.29,268.28 2 6 +github.com/einand/applephotos/cmd/applephotos/main.go:268.28,270.18 2 2 +github.com/einand/applephotos/cmd/applephotos/main.go:270.18,272.5 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:273.4,275.12 3 2 +github.com/einand/applephotos/cmd/applephotos/main.go:277.3,277.44 1 4 +github.com/einand/applephotos/cmd/applephotos/main.go:277.44,279.18 2 4 +github.com/einand/applephotos/cmd/applephotos/main.go:279.18,280.13 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:282.4,283.29 2 4 +github.com/einand/applephotos/cmd/applephotos/main.go:283.29,286.18 3 6 +github.com/einand/applephotos/cmd/applephotos/main.go:286.18,288.6 1 3 +github.com/einand/applephotos/cmd/applephotos/main.go:288.11,290.6 1 3 +github.com/einand/applephotos/cmd/applephotos/main.go:291.5,292.25 2 6 +github.com/einand/applephotos/cmd/applephotos/main.go:292.25,293.14 1 1 +github.com/einand/applephotos/cmd/applephotos/main.go:295.5,295.15 1 5 +github.com/einand/applephotos/cmd/applephotos/main.go:299.2,299.29 1 5 +github.com/einand/applephotos/cmd/applephotos/main.go:302.48,304.13 2 6 +github.com/einand/applephotos/cmd/applephotos/main.go:304.13,306.3 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:307.2,309.10 3 6 +github.com/einand/applephotos/cmd/applephotos/main.go:312.49,314.2 1 42 +github.com/einand/applephotos/cmd/applephotos/main.go:316.47,317.27 1 21 +github.com/einand/applephotos/cmd/applephotos/main.go:317.27,318.18 1 77 +github.com/einand/applephotos/cmd/applephotos/main.go:318.18,320.4 1 6 +github.com/einand/applephotos/cmd/applephotos/main.go:322.2,322.14 1 15 +github.com/einand/applephotos/cmd/applephotos/main.go:325.68,326.29 1 4 +github.com/einand/applephotos/cmd/applephotos/main.go:326.29,328.3 1 3 +github.com/einand/applephotos/cmd/applephotos/main.go:329.2,330.38 2 4 +github.com/einand/applephotos/cmd/applephotos/main.go:330.38,332.3 1 2 +github.com/einand/applephotos/cmd/applephotos/main.go:335.53,337.29 2 9 +github.com/einand/applephotos/cmd/applephotos/main.go:337.29,338.27 1 6 +github.com/einand/applephotos/cmd/applephotos/main.go:338.27,340.4 1 4 +github.com/einand/applephotos/cmd/applephotos/main.go:341.3,341.38 1 6 +github.com/einand/applephotos/cmd/applephotos/main.go:343.2,343.14 1 9 +github.com/einand/applephotos/cmd/applephotos/main.go:346.65,347.27 1 64 +github.com/einand/applephotos/cmd/applephotos/main.go:347.27,348.37 1 140 +github.com/einand/applephotos/cmd/applephotos/main.go:348.37,350.4 1 42 +github.com/einand/applephotos/cmd/applephotos/main.go:352.2,352.12 1 22 +github.com/einand/applephotos/cmd/applephotos/main.go:355.94,357.15 2 20 +github.com/einand/applephotos/cmd/applephotos/main.go:357.15,359.3 1 20 +github.com/einand/applephotos/cmd/applephotos/main.go:360.2,363.89 4 20 +github.com/einand/applephotos/cmd/applephotos/main.go:366.37,367.16 1 20 +github.com/einand/applephotos/cmd/applephotos/main.go:367.16,369.3 1 3 +github.com/einand/applephotos/cmd/applephotos/main.go:370.2,372.17 3 17 +github.com/einand/applephotos/cmd/applephotos/main.go:372.17,374.3 1 0 +github.com/einand/applephotos/cmd/applephotos/main.go:375.2,375.59 1 17 +github.com/einand/applephotos/internal/photos/bridge.go:21.55,23.63 2 0 +github.com/einand/applephotos/internal/photos/bridge.go:23.63,25.3 1 0 +github.com/einand/applephotos/internal/photos/bridge.go:26.2,26.25 1 0 +github.com/einand/applephotos/internal/photos/bridge.go:29.60,31.89 2 0 +github.com/einand/applephotos/internal/photos/bridge.go:31.89,33.3 1 0 +github.com/einand/applephotos/internal/photos/bridge.go:35.2,36.63 2 0 +github.com/einand/applephotos/internal/photos/bridge.go:36.63,38.3 1 0 +github.com/einand/applephotos/internal/photos/bridge.go:39.2,39.37 1 0 +github.com/einand/applephotos/internal/photos/bridge.go:42.62,44.89 2 0 +github.com/einand/applephotos/internal/photos/bridge.go:44.89,46.3 1 0 +github.com/einand/applephotos/internal/photos/bridge.go:48.2,49.63 2 0 +github.com/einand/applephotos/internal/photos/bridge.go:49.63,51.3 1 0 +github.com/einand/applephotos/internal/photos/bridge.go:52.2,52.30 1 0 +github.com/einand/applephotos/internal/photos/bridge.go:55.66,57.63 2 0 +github.com/einand/applephotos/internal/photos/bridge.go:57.63,59.3 1 0 +github.com/einand/applephotos/internal/photos/bridge.go:60.2,60.22 1 0 +github.com/einand/applephotos/internal/photos/bridge.go:60.22,62.3 1 0 +github.com/einand/applephotos/internal/photos/bridge.go:63.2,63.31 1 0 +github.com/einand/applephotos/internal/photos/bridge.go:66.49,67.12 1 0 +github.com/einand/applephotos/internal/photos/bridge.go:68.10,69.73 1 0 +github.com/einand/applephotos/internal/photos/bridge.go:70.10,71.60 1 0 +github.com/einand/applephotos/internal/photos/bridge.go:72.10,73.42 1 0 +github.com/einand/applephotos/internal/photos/bridge.go:74.10,75.45 1 0 +github.com/einand/applephotos/internal/photos/bridge.go:76.10,77.36 1 0 +github.com/einand/applephotos/internal/photos/bridge.go:78.10,79.13 1 0 +github.com/einand/applephotos/internal/photos/bridge.go:79.13,81.4 1 0 +github.com/einand/applephotos/internal/photos/bridge.go:82.3,82.17 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:33.39,33.78 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:34.39,34.84 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:35.39,35.84 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:36.39,36.82 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:37.39,37.81 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:38.39,38.91 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:39.39,39.85 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:40.39,40.74 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:41.39,41.74 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:42.39,42.72 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:43.44,43.102 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:44.45,44.104 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:46.41,48.13 2 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:48.13,50.3 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:51.2,51.12 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:54.49,56.15 2 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:56.15,58.3 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:59.2,60.40 2 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:63.68,67.15 4 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:67.15,69.3 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:70.2,71.40 2 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:74.56,76.15 2 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:76.15,78.3 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:79.2,80.38 2 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:83.95,90.2 6 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:92.80,99.2 6 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:101.92,106.15 4 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:106.15,108.3 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:110.2,111.39 2 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:114.28,116.2 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:118.105,124.15 6 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:124.15,126.3 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:127.2,128.46 2 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:131.94,137.15 6 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:137.15,139.3 1 0 +github.com/einand/applephotos/internal/photos/cgo_bridge_test_impl.go:140.2,141.46 2 0 +github.com/einand/applephotos/internal/photos/photos.go:8.28,8.68 1 0 +github.com/einand/applephotos/internal/photos/photos.go:10.36,10.73 1 0 +github.com/einand/applephotos/internal/photos/photos.go:12.55,12.99 1 0 +github.com/einand/applephotos/internal/photos/photos.go:14.43,14.78 1 0 +github.com/einand/applephotos/internal/photos/photos.go:16.82,18.2 1 0 +github.com/einand/applephotos/internal/photos/photos.go:20.67,22.2 1 0 +github.com/einand/applephotos/internal/photos/photos.go:24.79,26.2 1 0 +github.com/einand/applephotos/internal/photos/photos.go:28.15,28.41 1 0 +github.com/einand/applephotos/internal/photos/photos.go:30.92,32.2 1 0 +github.com/einand/applephotos/internal/photos/photos.go:34.81,36.2 1 0 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..51a1445 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/einand/applephotos + +go 1.22 \ No newline at end of file diff --git a/internal/photos/bridge.go b/internal/photos/bridge.go new file mode 100644 index 0000000..d26a500 --- /dev/null +++ b/internal/photos/bridge.go @@ -0,0 +1,84 @@ +package photos + +import ( + "encoding/json" + "fmt" +) + +type Bridge interface { + RequestAccess() error + ListAlbums() ([]Album, error) + ListAssets(albumID string) ([]Asset, int, error) + ListTree() ([]CollectionNode, error) + ExportAlbumPreviews(albumID, outputDir string, targetSize int) (int, error) + ExportAlbumOriginals(albumID, outputDir string) (int, error) + ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) + ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) + BackupAll(outputDir string, targetSize int, originals bool) (int, error) + Cancel() +} + +func ParseAlbumsJSON(jsonStr string) ([]Album, error) { + var resp AlbumsResponse + if err := json.Unmarshal([]byte(jsonStr), &resp); err != nil { + return nil, err + } + return resp.Albums, nil +} + +func ParseAssetsJSON(jsonStr string) ([]Asset, int, error) { + var errResp ErrorResponse + if err := json.Unmarshal([]byte(jsonStr), &errResp); err == nil && errResp.Error != "" { + return nil, 0, fmt.Errorf("%s", errResp.Error) + } + + var resp AssetsResponse + if err := json.Unmarshal([]byte(jsonStr), &resp); err != nil { + return nil, 0, err + } + return resp.Assets, resp.Total, nil +} + +func ParseTreeJSON(jsonStr string) ([]CollectionNode, error) { + var errResp ErrorResponse + if err := json.Unmarshal([]byte(jsonStr), &errResp); err == nil && errResp.Error != "" { + return nil, fmt.Errorf("%s", errResp.Error) + } + + var resp TreeResponse + if err := json.Unmarshal([]byte(jsonStr), &resp); err != nil { + return nil, err + } + return resp.Collections, nil +} + +func ParseExportResultJSON(jsonStr string) (ExportResult, error) { + var resp ExportResultResponse + if err := json.Unmarshal([]byte(jsonStr), &resp); err != nil { + return ExportResult{}, err + } + if resp.Error != "" { + return ExportResult{}, fmt.Errorf("%s", resp.Error) + } + return resp.ExportResult, nil +} + +func InterpretExportResult(rc int) (int, error) { + switch rc { + case -1: + return 0, fmt.Errorf("invalid arguments or directory creation failed") + case -2: + return 0, fmt.Errorf("could not create output directory") + case -3: + return 0, fmt.Errorf("album not found") + case -4: + return 0, fmt.Errorf("all exports failed") + case -5: + return 0, fmt.Errorf("cancelled") + default: + if rc < 0 { + return 0, fmt.Errorf("unknown error (code %d)", rc) + } + return rc, nil + } +} diff --git a/internal/photos/cgo_bridge.go b/internal/photos/cgo_bridge.go new file mode 100644 index 0000000..866269c --- /dev/null +++ b/internal/photos/cgo_bridge.go @@ -0,0 +1,120 @@ +//go:build !test + +package photos + +/* +#cgo CFLAGS: -I${SRCDIR}/../../bridge +#cgo LDFLAGS: -L${SRCDIR}/../../bridge -lphotokit_bridge -framework Photos -framework Foundation -framework AppKit +#include "photokit_bridge.h" +#include <stdlib.h> +*/ +import "C" + +import "unsafe" + +type CgoBridge struct{} + +var DefaultBridge Bridge = &CgoBridge{} + +func (*CgoBridge) RequestAccess() error { + rc := C.photos_request_access() + if rc != 0 { + return errAccessDenied + } + return nil +} + +func (*CgoBridge) ListAlbums() ([]Album, error) { + cs := C.photos_list_albums_json() + if cs == nil { + return nil, errBridgeNil + } + defer C.photos_free_string(cs) + return ParseAlbumsJSON(C.GoString(cs)) +} + +func (*CgoBridge) ListAssets(albumID string) ([]Asset, int, error) { + cid := C.CString(albumID) + defer C.free(unsafe.Pointer(cid)) + + cs := C.photos_list_assets_json(cid) + if cs == nil { + return nil, 0, errBridgeNil + } + defer C.photos_free_string(cs) + return ParseAssetsJSON(C.GoString(cs)) +} + +func (*CgoBridge) ListTree() ([]CollectionNode, error) { + cs := C.photos_list_tree_json() + if cs == nil { + return nil, errBridgeNil + } + defer C.photos_free_string(cs) + return ParseTreeJSON(C.GoString(cs)) +} + +func (*CgoBridge) ExportAlbumPreviews(albumID, outputDir string, targetSize int) (int, error) { + cid := C.CString(albumID) + defer C.free(unsafe.Pointer(cid)) + cdir := C.CString(outputDir) + defer C.free(unsafe.Pointer(cdir)) + + rc := C.photos_export_album_previews(cid, cdir, C.int(targetSize)) + return InterpretExportResult(int(rc)) +} + +func (*CgoBridge) ExportAlbumOriginals(albumID, outputDir string) (int, error) { + cid := C.CString(albumID) + defer C.free(unsafe.Pointer(cid)) + cdir := C.CString(outputDir) + defer C.free(unsafe.Pointer(cdir)) + + rc := C.photos_export_album_originals(cid, cdir) + return InterpretExportResult(int(rc)) +} + +func (*CgoBridge) BackupAll(outputDir string, targetSize int, originals bool) (int, error) { + cdir := C.CString(outputDir) + defer C.free(unsafe.Pointer(cdir)) + + coriginals := 0 + if originals { + coriginals = 1 + } + + rc := C.photos_backup_all(cdir, C.int(targetSize), C.int(coriginals)) + return InterpretExportResult(int(rc)) +} + +func (*CgoBridge) Cancel() { + C.photos_request_cancel() +} + +func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) { + cid := C.CString(assetID) + defer C.free(unsafe.Pointer(cid)) + cdir := C.CString(outputDir) + defer C.free(unsafe.Pointer(cdir)) + + cs := C.photos_export_preview_json(cid, cdir, C.int(targetSize), C.int(index)) + if cs == nil { + return ExportResult{}, errBridgeNil + } + defer C.photos_free_string(cs) + return ParseExportResultJSON(C.GoString(cs)) +} + +func (*CgoBridge) ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) { + cid := C.CString(assetID) + defer C.free(unsafe.Pointer(cid)) + cdir := C.CString(outputDir) + defer C.free(unsafe.Pointer(cdir)) + + cs := C.photos_export_original_json(cid, cdir, C.int(index)) + if cs == nil { + return ExportResult{}, errBridgeNil + } + defer C.photos_free_string(cs) + return ParseExportResultJSON(C.GoString(cs)) +} diff --git a/internal/photos/cgo_bridge_test_impl.go b/internal/photos/cgo_bridge_test_impl.go new file mode 100644 index 0000000..3576507 --- /dev/null +++ b/internal/photos/cgo_bridge_test_impl.go @@ -0,0 +1,142 @@ +//go:build test + +package photos + +/* +#cgo CFLAGS: -I${SRCDIR}/../../bridge +#cgo LDFLAGS: -L${SRCDIR}/../../bridge -lphotokit_bridge_stub +#include "photokit_bridge.h" +#include <stdlib.h> + +void photos_test_set_access(int rc); +void photos_test_set_albums(const char *json); +void photos_test_set_assets(const char *json); +void photos_test_set_tree(const char *json); +void photos_test_set_export_rc(int rc); +void photos_test_set_export_originals_rc(int rc); +void photos_test_set_backup_all_rc(int rc); +void photos_test_set_albums_null(void); +void photos_test_set_assets_null(void); +void photos_test_set_tree_null(void); +void photos_request_cancel(void); +void photos_test_set_export_preview_json(const char *json); +void photos_test_set_export_original_json(const char *json); +*/ +import "C" + +import "unsafe" + +type CgoBridge struct{} + +var DefaultBridge Bridge = &CgoBridge{} + +func SetTestAccessRC(rc int) { C.photos_test_set_access(C.int(rc)) } +func SetTestAlbumsJSON(json string) { C.photos_test_set_albums(C.CString(json)) } +func SetTestAssetsJSON(json string) { C.photos_test_set_assets(C.CString(json)) } +func SetTestTreeJSON(json string) { C.photos_test_set_tree(C.CString(json)) } +func SetTestExportRC(rc int) { C.photos_test_set_export_rc(C.int(rc)) } +func SetTestExportOriginalsRC(rc int) { C.photos_test_set_export_originals_rc(C.int(rc)) } +func SetTestBackupAllRC(rc int) { C.photos_test_set_backup_all_rc(C.int(rc)) } +func SetTestAlbumsNull() { C.photos_test_set_albums_null() } +func SetTestAssetsNull() { C.photos_test_set_assets_null() } +func SetTestTreeNull() { C.photos_test_set_tree_null() } +func SetTestExportPreviewJSON(json string) { C.photos_test_set_export_preview_json(C.CString(json)) } +func SetTestExportOriginalJSON(json string) { C.photos_test_set_export_original_json(C.CString(json)) } + +func (*CgoBridge) RequestAccess() error { + rc := C.photos_request_access() + if rc != 0 { + return errAccessDenied + } + return nil +} + +func (*CgoBridge) ListAlbums() ([]Album, error) { + cs := C.photos_list_albums_json() + if cs == nil { + return nil, errBridgeNil + } + defer C.photos_free_string(cs) + return ParseAlbumsJSON(C.GoString(cs)) +} + +func (*CgoBridge) ListAssets(albumID string) ([]Asset, int, error) { + cid := C.CString(albumID) + defer C.free(unsafe.Pointer(cid)) + cs := C.photos_list_assets_json(cid) + if cs == nil { + return nil, 0, errBridgeNil + } + defer C.photos_free_string(cs) + return ParseAssetsJSON(C.GoString(cs)) +} + +func (*CgoBridge) ListTree() ([]CollectionNode, error) { + cs := C.photos_list_tree_json() + if cs == nil { + return nil, errBridgeNil + } + defer C.photos_free_string(cs) + return ParseTreeJSON(C.GoString(cs)) +} + +func (*CgoBridge) ExportAlbumPreviews(albumID, outputDir string, targetSize int) (int, error) { + cid := C.CString(albumID) + defer C.free(unsafe.Pointer(cid)) + cdir := C.CString(outputDir) + defer C.free(unsafe.Pointer(cdir)) + rc := C.photos_export_album_previews(cid, cdir, C.int(targetSize)) + return InterpretExportResult(int(rc)) +} + +func (*CgoBridge) ExportAlbumOriginals(albumID, outputDir string) (int, error) { + cid := C.CString(albumID) + defer C.free(unsafe.Pointer(cid)) + cdir := C.CString(outputDir) + defer C.free(unsafe.Pointer(cdir)) + rc := C.photos_export_album_originals(cid, cdir) + return InterpretExportResult(int(rc)) +} + +func (*CgoBridge) BackupAll(outputDir string, targetSize int, originals bool) (int, error) { + cdir := C.CString(outputDir) + defer C.free(unsafe.Pointer(cdir)) + + coriginals := 0 + if originals { + coriginals = 1 + } + + rc := C.photos_backup_all(cdir, C.int(targetSize), C.int(coriginals)) + return InterpretExportResult(int(rc)) +} + +func (*CgoBridge) Cancel() { + C.photos_request_cancel() +} + +func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) { + cid := C.CString(assetID) + defer C.free(unsafe.Pointer(cid)) + cdir := C.CString(outputDir) + defer C.free(unsafe.Pointer(cdir)) + cs := C.photos_export_preview_json(cid, cdir, C.int(targetSize), C.int(index)) + if cs == nil { + return ExportResult{}, errBridgeNil + } + defer C.photos_free_string(cs) + return ParseExportResultJSON(C.GoString(cs)) +} + +func (*CgoBridge) ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) { + cid := C.CString(assetID) + defer C.free(unsafe.Pointer(cid)) + cdir := C.CString(outputDir) + defer C.free(unsafe.Pointer(cdir)) + cs := C.photos_export_original_json(cid, cdir, C.int(index)) + if cs == nil { + return ExportResult{}, errBridgeNil + } + defer C.photos_free_string(cs) + return ParseExportResultJSON(C.GoString(cs)) +} diff --git a/internal/photos/photos.go b/internal/photos/photos.go new file mode 100644 index 0000000..93f5887 --- /dev/null +++ b/internal/photos/photos.go @@ -0,0 +1,36 @@ +package photos + +import "fmt" + +var errAccessDenied = fmt.Errorf("photos access denied: grant Full Disk Access or Photos permission in System Settings > Privacy & Security") +var errBridgeNil = fmt.Errorf("bridge returned nil") + +func RequestAccess() error { return DefaultBridge.RequestAccess() } + +func ListAlbums() ([]Album, error) { return DefaultBridge.ListAlbums() } + +func ListAssets(albumID string) ([]Asset, int, error) { return DefaultBridge.ListAssets(albumID) } + +func ListTree() ([]CollectionNode, error) { return DefaultBridge.ListTree() } + +func ExportAlbumPreviews(albumID, outputDir string, targetSize int) (int, error) { + return DefaultBridge.ExportAlbumPreviews(albumID, outputDir, targetSize) +} + +func ExportAlbumOriginals(albumID, outputDir string) (int, error) { + return DefaultBridge.ExportAlbumOriginals(albumID, outputDir) +} + +func BackupAll(outputDir string, targetSize int, originals bool) (int, error) { + return DefaultBridge.BackupAll(outputDir, targetSize, originals) +} + +func Cancel() { DefaultBridge.Cancel() } + +func ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) { + return DefaultBridge.ExportPreview(assetID, outputDir, targetSize, index) +} + +func ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) { + return DefaultBridge.ExportOriginal(assetID, outputDir, index) +} diff --git a/internal/photos/photos_test.go b/internal/photos/photos_test.go new file mode 100644 index 0000000..6b08296 --- /dev/null +++ b/internal/photos/photos_test.go @@ -0,0 +1,604 @@ +//go:build test + +package photos + +import ( + "encoding/json" + "testing" +) + +func TestParseAlbumsJSON(t *testing.T) { + tests := []struct { + name string + json string + want []Album + wantErr bool + }{ + { + name: "empty albums", + json: `{"albums":[]}`, + want: []Album{}, + }, + { + name: "single album", + json: `{"albums":[{"id":"abc","title":"Vacation"}]}`, + want: []Album{{ID: "abc", Title: "Vacation"}}, + }, + { + name: "multiple albums", + json: `{"albums":[{"id":"a1","title":"Album1"},{"id":"a2","title":"Album2"}]}`, + want: []Album{{ID: "a1", Title: "Album1"}, {ID: "a2", Title: "Album2"}}, + }, + { + name: "invalid json", + json: `not json`, + wantErr: true, + }, + { + name: "missing albums key", + json: `{}`, + want: []Album{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseAlbumsJSON(tt.json) + if (err != nil) != tt.wantErr { + t.Errorf("ParseAlbumsJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + if len(got) != len(tt.want) { + t.Errorf("ParseAlbumsJSON() got %d albums, want %d", len(got), len(tt.want)) + return + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("ParseAlbumsJSON()[%d] = %+v, want %+v", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestParseAssetsJSON(t *testing.T) { + tests := []struct { + name string + json string + want []Asset + wantTotal int + wantErr bool + errMsg string + }{ + { + name: "empty assets", + json: `{"assets":[],"total":0}`, + want: []Asset{}, + wantTotal: 0, + }, + { + name: "single asset", + json: `{"assets":[{"id":"asset1","filename":"IMG_0001.JPG"}],"total":1}`, + want: []Asset{{ID: "asset1", Filename: "IMG_0001.JPG"}}, + wantTotal: 1, + }, + { + name: "multiple assets", + json: `{"assets":[{"id":"a1","filename":"a.jpg"},{"id":"a2","filename":"b.jpg"},{"id":"a3","filename":"c.jpg"}],"total":3}`, + want: []Asset{{ID: "a1", Filename: "a.jpg"}, {ID: "a2", Filename: "b.jpg"}, {ID: "a3", Filename: "c.jpg"}}, + wantTotal: 3, + }, + { + name: "error response", + json: `{"error":"album not found"}`, + wantErr: true, + errMsg: "album not found", + }, + { + name: "invalid json", + json: `not json`, + wantErr: true, + }, + { + name: "empty error is not an error", + json: `{"error":"","assets":[{"id":"a1","filename":"IMG.JPG"}],"total":1}`, + want: []Asset{{ID: "a1", Filename: "IMG.JPG"}}, + wantTotal: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, total, err := ParseAssetsJSON(tt.json) + if (err != nil) != tt.wantErr { + t.Errorf("ParseAssetsJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + if tt.errMsg != "" && err.Error() != tt.errMsg { + t.Errorf("ParseAssetsJSON() error = %q, want %q", err.Error(), tt.errMsg) + } + return + } + if total != tt.wantTotal { + t.Errorf("ParseAssetsJSON() total = %d, want %d", total, tt.wantTotal) + } + if len(got) != len(tt.want) { + t.Errorf("ParseAssetsJSON() got %d assets, want %d", len(got), len(tt.want)) + return + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("ParseAssetsJSON()[%d] = %+v, want %+v", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestParseTreeJSON(t *testing.T) { + tests := []struct { + name string + json string + want []CollectionNode + wantErr bool + errMsg string + }{ + { + name: "empty tree", + json: `{"collections":[]}`, + want: []CollectionNode{}, + }, + { + name: "nested tree", + json: `{"collections":[{"id":"f1","name":"Trips","kind":"folder","children":[{"id":"f2","name":"Italy 2024","kind":"folder","children":[{"id":"a1","name":"Venice","kind":"album","children":[]}]}]},{"id":"a2","name":"Favorites","kind":"album","children":[]}]}`, + want: []CollectionNode{ + {ID: "f1", Name: "Trips", Kind: "folder", Children: []CollectionNode{{ID: "f2", Name: "Italy 2024", Kind: "folder", Children: []CollectionNode{{ID: "a1", Name: "Venice", Kind: "album"}}}}}, + {ID: "a2", Name: "Favorites", Kind: "album"}, + }, + }, + { + name: "error response", + json: `{"error":"library unavailable"}`, + wantErr: true, + errMsg: "library unavailable", + }, + { + name: "invalid json", + json: `not json`, + wantErr: true, + }, + { + name: "missing collections key", + json: `{}`, + want: nil, + }, + { + name: "empty error is not an error", + json: `{"error":"","collections":[{"id":"a9","name":"Root Album","kind":"album"}]}`, + want: []CollectionNode{{ID: "a9", Name: "Root Album", Kind: "album"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseTreeJSON(tt.json) + if (err != nil) != tt.wantErr { + t.Errorf("ParseTreeJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + if tt.errMsg != "" && err.Error() != tt.errMsg { + t.Errorf("ParseTreeJSON() error = %q, want %q", err.Error(), tt.errMsg) + } + return + } + if len(got) != len(tt.want) { + t.Errorf("ParseTreeJSON() got %d collections, want %d", len(got), len(tt.want)) + return + } + for i := range got { + if !equalCollectionNode(got[i], tt.want[i]) { + t.Errorf("ParseTreeJSON()[%d] = %+v, want %+v", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestInterpretExportResult(t *testing.T) { + tests := []struct { + name string + rc int + wantN int + wantErr bool + errMsg string + }{ + {name: "success", rc: 5, wantN: 5}, + {name: "zero exports", rc: 0, wantN: 0}, + {name: "single export", rc: 1, wantN: 1}, + {name: "invalid args", rc: -1, wantErr: true, errMsg: "invalid arguments or directory creation failed"}, + {name: "mkdir failed", rc: -2, wantErr: true, errMsg: "could not create output directory"}, + {name: "album not found", rc: -3, wantErr: true, errMsg: "album not found"}, + {name: "all exports failed", rc: -4, wantErr: true, errMsg: "all exports failed"}, + {name: "cancelled", rc: -5, wantErr: true, errMsg: "cancelled"}, + {name: "unknown error", rc: -99, wantErr: true, errMsg: "unknown error (code -99)"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + n, err := InterpretExportResult(tt.rc) + if (err != nil) != tt.wantErr { + t.Errorf("InterpretExportResult() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + if err.Error() != tt.errMsg { + t.Errorf("InterpretExportResult() error = %q, want %q", err.Error(), tt.errMsg) + } + return + } + if n != tt.wantN { + t.Errorf("InterpretExportResult() = %d, want %d", n, tt.wantN) + } + }) + } +} + +func TestAlbumsResponseUnmarshal(t *testing.T) { + raw := `{"albums":[{"id":"x","title":"Y"}]}` + var resp AlbumsResponse + if err := json.Unmarshal([]byte(raw), &resp); err != nil { + t.Fatal(err) + } + if len(resp.Albums) != 1 || resp.Albums[0].ID != "x" || resp.Albums[0].Title != "Y" { + t.Errorf("got %+v", resp.Albums) + } +} + +func TestAssetsResponseUnmarshal(t *testing.T) { + raw := `{"assets":[{"id":"z","filename":"IMG_9999.JPG"}],"total":1}` + var resp AssetsResponse + if err := json.Unmarshal([]byte(raw), &resp); err != nil { + t.Fatal(err) + } + if len(resp.Assets) != 1 || resp.Assets[0].ID != "z" || resp.Assets[0].Filename != "IMG_9999.JPG" { + t.Errorf("got %+v", resp.Assets) + } + if resp.Total != 1 { + t.Errorf("got total %d, want 1", resp.Total) + } +} + +func TestTreeResponseUnmarshal(t *testing.T) { + raw := `{"collections":[{"id":"f1","name":"Trips","kind":"folder","children":[{"id":"a1","name":"Venice","kind":"album"}]}]}` + var resp TreeResponse + if err := json.Unmarshal([]byte(raw), &resp); err != nil { + t.Fatal(err) + } + if len(resp.Collections) != 1 || resp.Collections[0].ID != "f1" || resp.Collections[0].Name != "Trips" || len(resp.Collections[0].Children) != 1 || resp.Collections[0].Children[0].ID != "a1" || resp.Collections[0].Children[0].Name != "Venice" { + t.Errorf("got %+v", resp.Collections) + } +} + +func TestErrorResponseUnmarshal(t *testing.T) { + raw := `{"error":"oops"}` + var resp ErrorResponse + if err := json.Unmarshal([]byte(raw), &resp); err != nil { + t.Fatal(err) + } + if resp.Error != "oops" { + t.Errorf("got %q", resp.Error) + } +} + +func TestCgoBridgeImplementsBridge(t *testing.T) { + var _ Bridge = &CgoBridge{} +} + +func TestPackageLevelDelegatesToDefaultBridge(t *testing.T) { + SetTestAccessRC(0) + SetTestAlbumsJSON(`{"albums":[{"id":"p1","title":"PAlbum"}]}`) + SetTestAssetsJSON(`{"assets":[{"id":"pa1","filename":"IMG_1001.JPG"}],"total":1}`) + SetTestTreeJSON(`{"collections":[{"id":"f1","name":"Trips","kind":"folder","children":[{"id":"a1","name":"Venice","kind":"album"}]}]}`) + SetTestExportRC(3) + SetTestExportOriginalsRC(2) + SetTestBackupAllRC(5) + + if err := RequestAccess(); err != nil { + t.Fatal(err) + } + + albums, err := ListAlbums() + if err != nil { + t.Fatal(err) + } + if len(albums) != 1 || albums[0].ID != "p1" { + t.Errorf("got %+v", albums) + } + + assets, _, err := ListAssets("p1") + if err != nil { + t.Fatal(err) + } + if len(assets) != 1 || assets[0].ID != "pa1" || assets[0].Filename != "IMG_1001.JPG" { + t.Errorf("got %+v", assets) + } + + tree, err := ListTree() + if err != nil { + t.Fatal(err) + } + if len(tree) != 1 || tree[0].ID != "f1" || tree[0].Name != "Trips" || len(tree[0].Children) != 1 || tree[0].Children[0].ID != "a1" || tree[0].Children[0].Name != "Venice" { + t.Errorf("got %+v", tree) + } + + n, err := ExportAlbumPreviews("p1", "/tmp/x", 1024) + if err != nil { + t.Fatal(err) + } + if n != 3 { + t.Errorf("got %d", n) + } + + n, err = ExportAlbumOriginals("p1", "/tmp/x") + if err != nil { + t.Fatal(err) + } + if n != 2 { + t.Errorf("got %d", n) + } + + n, err = BackupAll("/tmp/x", 1024, false) + if err != nil { + t.Fatal(err) + } + if n != 5 { + t.Errorf("got %d", n) + } +} + +func TestCgoBridgeListAlbumsViaStub(t *testing.T) { + SetTestAlbumsJSON(`{"albums":[{"id":"abc","title":"TestAlbum"}]}`) + bridge := &CgoBridge{} + albums, err := bridge.ListAlbums() + if err != nil { + t.Fatal(err) + } + if len(albums) != 1 || albums[0].ID != "abc" || albums[0].Title != "TestAlbum" { + t.Errorf("got %+v", albums) + } +} + +func TestCgoBridgeListAssetsViaStub(t *testing.T) { + SetTestAssetsJSON(`{"assets":[{"id":"asset-1","filename":"A.JPG"},{"id":"asset-2","filename":"B.JPG"}],"total":2}`) + bridge := &CgoBridge{} + assets, _, err := bridge.ListAssets("any") + if err != nil { + t.Fatal(err) + } + if len(assets) != 2 { + t.Errorf("got %d assets", len(assets)) + } + if assets[0].Filename != "A.JPG" || assets[1].Filename != "B.JPG" { + t.Errorf("got %+v", assets) + } +} + +func TestCgoBridgeListAssetsErrorViaStub(t *testing.T) { + SetTestAssetsJSON(`{"error":"album not found"}`) + bridge := &CgoBridge{} + _, _, err := bridge.ListAssets("bad-id") + if err == nil || err.Error() != "album not found" { + t.Errorf("got %v", err) + } +} + +func TestCgoBridgeListTreeViaStub(t *testing.T) { + SetTestTreeJSON(`{"collections":[{"id":"f1","name":"Trips","kind":"folder","children":[{"id":"f2","name":"Italy 2024","kind":"folder","children":[{"id":"a1","name":"Venice","kind":"album"}]}]},{"id":"a2","name":"Favorites","kind":"album"}]}`) + bridge := &CgoBridge{} + tree, err := bridge.ListTree() + if err != nil { + t.Fatal(err) + } + if len(tree) != 2 { + t.Errorf("got %d collections", len(tree)) + } + if tree[0].ID != "f1" || tree[0].Name != "Trips" || len(tree[0].Children) != 1 || tree[0].Children[0].ID != "f2" || tree[0].Children[0].Name != "Italy 2024" { + t.Errorf("got %+v", tree) + } + if tree[1].ID != "a2" || tree[1].Name != "Favorites" || tree[1].Kind != "album" { + t.Errorf("got %+v", tree) + } +} + +func TestCgoBridgeListTreeNil(t *testing.T) { + SetTestTreeNull() + defer SetTestTreeJSON(`{"collections":[]}`) + bridge := &CgoBridge{} + _, err := bridge.ListTree() + if err != errBridgeNil { + t.Errorf("got %v, want %v", err, errBridgeNil) + } +} + +func TestCgoBridgeExportViaStub(t *testing.T) { + SetTestExportRC(7) + bridge := &CgoBridge{} + n, err := bridge.ExportAlbumPreviews("id", "/tmp/out", 512) + if err != nil { + t.Fatal(err) + } + if n != 7 { + t.Errorf("got %d", n) + } +} + +func TestCgoBridgeExportErrorViaStub(t *testing.T) { + SetTestExportRC(-3) + bridge := &CgoBridge{} + _, err := bridge.ExportAlbumPreviews("id", "/tmp/out", 512) + if err == nil || err.Error() != "album not found" { + t.Errorf("got %v", err) + } +} + +func TestCgoBridgeExportOriginalsViaStub(t *testing.T) { + SetTestExportOriginalsRC(6) + bridge := &CgoBridge{} + n, err := bridge.ExportAlbumOriginals("id", "/tmp/out") + if err != nil { + t.Fatal(err) + } + if n != 6 { + t.Errorf("got %d", n) + } +} + +func TestCgoBridgeExportOriginalsErrorViaStub(t *testing.T) { + SetTestExportOriginalsRC(-4) + bridge := &CgoBridge{} + _, err := bridge.ExportAlbumOriginals("id", "/tmp/out") + if err == nil || err.Error() != "all exports failed" { + t.Errorf("got %v", err) + } +} + +func TestCgoBridgeBackupAllViaStub(t *testing.T) { + SetTestBackupAllRC(8) + bridge := &CgoBridge{} + n, err := bridge.BackupAll("/tmp/out", 1024, false) + if err != nil { + t.Fatal(err) + } + if n != 8 { + t.Errorf("got %d", n) + } +} + +func TestCgoBridgeBackupAllErrorViaStub(t *testing.T) { + SetTestBackupAllRC(-2) + bridge := &CgoBridge{} + _, err := bridge.BackupAll("/tmp/out", 1024, true) + if err == nil || err.Error() != "could not create output directory" { + t.Errorf("got %v", err) + } +} + +func TestCgoBridgeRequestAccessGranted(t *testing.T) { + SetTestAccessRC(0) + bridge := &CgoBridge{} + if err := bridge.RequestAccess(); err != nil { + t.Errorf("got %v", err) + } +} + +func TestCgoBridgeRequestAccessDenied(t *testing.T) { + SetTestAccessRC(-1) + bridge := &CgoBridge{} + err := bridge.RequestAccess() + if err == nil { + t.Fatal("expected error") + } + if err != errAccessDenied { + t.Errorf("got %v, want %v", err, errAccessDenied) + } +} + +func TestCgoBridgeListAlbumsNil(t *testing.T) { + SetTestAlbumsNull() + defer SetTestAlbumsJSON(`{"albums":[]}`) + bridge := &CgoBridge{} + _, err := bridge.ListAlbums() + if err != errBridgeNil { + t.Errorf("got %v, want %v", err, errBridgeNil) + } +} + +func TestCgoBridgeListAssetsNil(t *testing.T) { + SetTestAssetsNull() + defer SetTestAssetsJSON(`{"assets":[],"total":0}`) + bridge := &CgoBridge{} + _, _, err := bridge.ListAssets("any") + if err != errBridgeNil { + t.Errorf("got %v, want %v", err, errBridgeNil) + } +} + +func TestErrAccessDeniedMessage(t *testing.T) { + if errAccessDenied.Error() == "" { + t.Error("errAccessDenied should have a message") + } +} + +func TestErrBridgeNilMessage(t *testing.T) { + if errBridgeNil.Error() == "" { + t.Error("errBridgeNil should have a message") + } +} + +func TestParseExportResultJSON(t *testing.T) { + tests := []struct { + name string + json string + want ExportResult + wantErr bool + errMsg string + }{ + { + name: "success", + json: `{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`, + want: ExportResult{Filename: "0000_test.jpg", Size: 1024, Cloud: "local"}, + }, + { + name: "cloud asset", + json: `{"filename":"photo.jpg","size":2048,"cloud":"cloud"}`, + want: ExportResult{Filename: "photo.jpg", Size: 2048, Cloud: "cloud"}, + }, + { + name: "error response", + json: `{"error":"write failed","cloud":"local"}`, + wantErr: true, + errMsg: "write failed", + }, + { + name: "invalid json", + json: `not json`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseExportResultJSON(tt.json) + if (err != nil) != tt.wantErr { + t.Errorf("ParseExportResultJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + if tt.errMsg != "" && err.Error() != tt.errMsg { + t.Errorf("ParseExportResultJSON() error = %q, want %q", err.Error(), tt.errMsg) + } + return + } + if got != tt.want { + t.Errorf("ParseExportResultJSON() = %+v, want %+v", got, tt.want) + } + }) + } +} + +func equalCollectionNode(a, b CollectionNode) bool { + if a.ID != b.ID || a.Name != b.Name || a.Kind != b.Kind || len(a.Children) != len(b.Children) { + return false + } + for i := range a.Children { + if !equalCollectionNode(a.Children[i], b.Children[i]) { + return false + } + } + return true +} diff --git a/internal/photos/types.go b/internal/photos/types.go new file mode 100644 index 0000000..cac5912 --- /dev/null +++ b/internal/photos/types.go @@ -0,0 +1,47 @@ +package photos + +type Album struct { + ID string `json:"id"` + Title string `json:"title"` +} + +type Asset struct { + ID string `json:"id"` + Filename string `json:"filename"` + Cloud bool `json:"cloud"` +} + +type ExportResult struct { + Filename string `json:"filename"` + Size int64 `json:"size"` + Cloud string `json:"cloud"` + Error string `json:"error,omitempty"` +} + +type CollectionNode struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Kind string `json:"kind"` + Children []CollectionNode `json:"children,omitempty"` +} + +type AlbumsResponse struct { + Albums []Album `json:"albums"` +} + +type AssetsResponse struct { + Assets []Asset `json:"assets"` + Total int `json:"total"` +} + +type TreeResponse struct { + Collections []CollectionNode `json:"collections"` +} + +type ErrorResponse struct { + Error string `json:"error"` +} + +type ExportResultResponse struct { + ExportResult +}