initial commit: applephotos CLI with progress, cloud status, per-asset export

This commit is contained in:
Ein Anderssono
2026-06-11 20:25:07 +02:00
commit 6ec16f3966
21 changed files with 3488 additions and 0 deletions
+39
View File
@@ -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
+206
View File
@@ -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 `<album-id>\t<title>`
- `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
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+54
View File
@@ -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
+593
View File
@@ -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;
}
Binary file not shown.
+109
View File
@@ -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;
}
Binary file not shown.
+376
View File
@@ -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))
}
+33
View File
@@ -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)
}
+625
View File
@@ -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)
}
}
+417
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
module github.com/einand/applephotos
go 1.22
+84
View File
@@ -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
}
}
+120
View File
@@ -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))
}
+142
View File
@@ -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))
}
+36
View File
@@ -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)
}
+604
View File
@@ -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
}
+47
View File
@@ -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
}