initial commit: applephotos CLI with progress, cloud status, per-asset export
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
Executable
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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.
@@ -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.
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user