Compare commits

7 Commits

Author SHA1 Message Date
Ein Anderssono e888f7cad1 v0.2.5: Unicode progress bar with cloud download speed
- Unicode block progress bar (█▓░)
- Cloud downloads show ☁ with average speed (MB/s, KB/s, B/s)
- Truncated filenames with ellipsis for long names
- Error indicator (✗) in progress bar
- Simplified to serial export for clean cancel behavior
- Added IsCancelled() to Bridge interface
2026-06-11 21:59:42 +02:00
Ein Anderssono 479c284dfc v0.2.4: stop export loop on Ctrl+C instead of flooding failures
- Add IsCancelled() to Bridge interface
- Check bridge.IsCancelled() before each export in serial/parallel/backupTree
- Parallel workers mark remaining slots as 'cancelled' instead of exporting
- Add photos_request_is_cancelled to ObjC and C stub
2026-06-11 21:44:55 +02:00
Ein Anderssono 009c71e6bb v0.2.3: fix export write failures and Ctrl+C cancellation
- Add ensure_directory calls in both export functions (preview + original)
- Include NSError.localizedDescription in write failed messages
- Make semaphore_wait_with_timeout poll photos_cancelled every ~1s
- Add status messages: auth, loading tree, per-album progress
- Fix parallel export: slot-based progress shows results in order
2026-06-11 21:37:11 +02:00
Ein Anderssono b2d4c6188d fix: make Ctrl+C cancel ObjC semaphore waits within ~1s
semaphore_wait_with_timeout now polls photos_cancelled every second
instead of blocking for the full timeout duration
2026-06-11 21:24:03 +02:00
Ein Anderssono 27ff1b5c83 v0.2.1: add status messages, fix parallel export progress
- mustAuth: print 'requesting photo library access...' / 'access granted'
- cmdBackupAll: print 'loading photo library tree...' / 'found N albums'
- cmdExport: print 'loading assets...' / 'exporting N assets (mode)...'
- backupTree: print 'album: Name (N assets)' per album
- exportAssetsParallel: slot-based progress shows each asset as it completes
  instead of waiting for all to finish
- Fix data race: use jobs channel instead of shared range iteration
2026-06-11 21:18:34 +02:00
Ein Anderssono 85eaa3ea37 v0.2.0: semaphore timeouts, error logging, dead code removal, parallel exports
Critical:
- Replace DISPATCH_TIME_FOREVER with 120s/30s timeouts in ObjC
- Log failed asset IDs and error messages in cmdExport/backupTree
- Show failed count in export summaries

Cleanup:
- Remove legacy Bridge methods (ExportAlbumPreviews, ExportAlbumOriginals, BackupAll)
- Remove legacy ObjC functions and C stub equivalents
- Remove photos.go delegates (package-level pass-throughs)
- Remove InterpretExportResult (only used by legacy methods)
- Clean up mockBridge fields (rename Fn2 -> Fn)
- Fix rc race condition in main_main.go (atomic.Int32)
- Remove unused variables (_ = grandTotal, _ = sig)

Design:
- Fix resolveAlbumID: ListAlbums first (cheap), then direct ID
- Unify Cloud type: Asset.Cloud string (was bool)
- Extract shared export logic into exportAssets/exportOne
- Add worker pool for parallel exports (3 workers when assets >= 4)
- Fix backupTree progress bar counter and directory prefix

Robustness:
- Add nil checks for stringWithUTF8String: in ObjC
- Log directory creation errors in ensure_directory (ObjC)

Quality:
- Add go vet and -race flag to Makefile test target
- Add ADR for performSelector cloudIdentifier decision
- Add sync comments between Go/ObjC sanitizePathComponent
- Add package-level doc comment
- Add tests: partial failure, skipped album, album-not-found message
2026-06-11 21:12:47 +02:00
Ein Anderssono b460c68641 add pipeline and tea release targets 2026-06-11 20:37:30 +02:00
14 changed files with 338 additions and 673 deletions
+12 -17
View File
@@ -1,6 +1,6 @@
BINARY := ./bin/photoscli
MODULE := gitea.k3s.k0.nu/tools/photocli
VERSION := 0.1.0
VERSION := 0.2.5
BRIDGE_DIR := bridge
LDFLAGS := -X main.version=$(VERSION)
OBJ := $(BRIDGE_DIR)/photokit_bridge.o
@@ -10,7 +10,7 @@ STUB_LIB := $(BRIDGE_DIR)/libphotokit_bridge_stub.a
GITEA_HOST := gitea-1.tail82444.ts.net
GITEA_REPO := tools/photocli
.PHONY: all build clean test coverage tag release
.PHONY: all build clean test coverage tag release pipeline
all: build
@@ -30,7 +30,8 @@ build: $(LIB)
go build -ldflags "$(LDFLAGS)" -o $(BINARY) $(MODULE)/cmd/photoscli
test: $(STUB_LIB)
go test -tags=test -coverprofile=coverage.out -covermode=atomic -coverpkg=./... ./...
go vet -tags=test ./...
go test -tags=test -race -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
@@ -48,17 +49,11 @@ tag:
git tag v$(VERSION)
git push origin v$(VERSION)
release: build tag
ifndef GITEA_TOKEN
$(error GITEA_TOKEN is required. Set it with: export GITEA_TOKEN=your-token)
endif
curl -sf -X POST "https://$(GITEA_HOST)/api/v1/repos/$(GITEA_REPO)/releases" \
-H "Authorization: token $(GITEA_TOKEN)" \
-H "Content-Type: application/json" \
-d '{"tag_name":"v$(VERSION)","name":"v$(VERSION)","body":"photoscli v$(VERSION)"}' | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['id'])" > /tmp/_photoscli_release_id
@echo "created release v$(VERSION)"
curl -sf -X POST "https://$(GITEA_HOST)/api/v1/repos/$(GITEA_REPO)/releases/$$(cat /tmp/_photoscli_release_id)/assets?name=photoscli" \
-H "Authorization: token $(GITEA_TOKEN)" \
-F "attachment=@$(BINARY)"
@echo "uploaded $(BINARY) to release v$(VERSION)"
@rm -f /tmp/_photoscli_release_id
release:
tea releases create --repo $(GITEA_REPO) --tag v$(VERSION) --title "v$(VERSION)" --asset $(BINARY)
pipeline: clean test build
@echo "--- verifying version ---"
$(BINARY) version
@echo "--- all checks passed, ready to release ---"
@echo "run: make release"
+2 -17
View File
@@ -26,25 +26,10 @@ char *photos_export_original_json(
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);
int photos_request_is_cancelled(void);
void photos_free_string(char *value);
#ifdef __cplusplus
+45 -217
View File
@@ -9,6 +9,18 @@ static NSDictionary *make_error_dict(NSString *message) {
return @{@"error": message};
}
static BOOL semaphore_wait_with_timeout(dispatch_semaphore_t sem, int64_t seconds) {
int64_t deadline = (int64_t)[NSDate timeIntervalSinceReferenceDate] + seconds;
while (1) {
if (photos_cancelled) return NO;
int64_t remaining = deadline - (int64_t)[NSDate timeIntervalSinceReferenceDate];
if (remaining <= 0) return NO;
int64_t waitSecs = remaining < 1 ? remaining : 1;
dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, waitSecs * NSEC_PER_SEC);
if (dispatch_semaphore_wait(sem, timeout) == 0) return YES;
}
}
static NSDictionary *collection_to_dict(PHCollection *collection) {
NSString *name = collection.localizedTitle ?: @"Untitled";
NSString *identifier = collection.localIdentifier ?: @"";
@@ -80,8 +92,12 @@ static NSData *nsimage_to_jpeg(NSImage *image, CGFloat compression) {
static BOOL ensure_directory(NSString *outputDir) {
NSFileManager *fm = [NSFileManager defaultManager];
NSError *dirErr = nil;
return [fm createDirectoryAtPath:outputDir withIntermediateDirectories:YES
BOOL ok = [fm createDirectoryAtPath:outputDir withIntermediateDirectories:YES
attributes:nil error:&dirErr];
if (!ok && dirErr) {
NSLog(@"ensure_directory failed: %@", dirErr);
}
return ok;
}
static PHFetchResult<PHAsset *> *fetch_assets_for_collection(PHAssetCollection *album) {
@@ -91,17 +107,6 @@ static PHFetchResult<PHAsset *> *fetch_assets_for_collection(PHAssetCollection *
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];
@@ -132,6 +137,7 @@ static NSString *unique_path_for_filename(NSString *outputDir, NSString *filenam
return [outputDir stringByAppendingPathComponent:prefixed];
}
// Must stay in sync with sanitizePathComponent in cmd/photoscli/main.go
static NSString *sanitized_path_component(NSString *name) {
NSString *source = name ?: @"Untitled";
NSMutableString *safe = [[source stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] mutableCopy];
@@ -150,146 +156,6 @@ static NSString *sanitized_path_component(NSString *name) {
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) {
@@ -298,7 +164,9 @@ int photos_request_access(void) {
status = s;
dispatch_semaphore_signal(sem);
}];
dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
if (!semaphore_wait_with_timeout(sem, 30)) {
return -1;
}
}
return (status == PHAuthorizationStatusAuthorized) ? 0 : -1;
}
@@ -350,6 +218,7 @@ 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];
if (!nsAlbumId) return json_from_object(make_error_dict(@"invalid UTF-8 in album_id"));
PHFetchResult<PHAssetCollection *> *albums =
[PHAssetCollection fetchAssetCollectionsWithLocalIdentifiers:@[nsAlbumId]
options:nil];
@@ -396,66 +265,6 @@ char *photos_list_tree_json(void) {
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"));
@@ -463,6 +272,11 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i
NSString *nsAssetId = [NSString stringWithUTF8String:asset_id];
NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir];
if (!nsAssetId || !nsOutputDir) return json_from_object(make_error_dict(@"invalid UTF-8 in arguments"));
if (!ensure_directory(nsOutputDir)) {
return json_from_object(make_error_dict(@"failed to create output directory"));
}
PHFetchResult<PHAsset *> *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil];
if (fetch.count == 0) return json_from_object(make_error_dict(@"asset not found"));
@@ -498,7 +312,9 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i
dispatch_semaphore_signal(sem);
}];
dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
if (!semaphore_wait_with_timeout(sem, 120)) {
return json_from_object(@{@"error": @"timeout waiting for image", @"cloud": asset_cloud_status_string(asset)});
}
if (!imageData) {
return json_from_object(@{@"error": @"failed to render image", @"cloud": asset_cloud_status_string(asset)});
@@ -510,7 +326,8 @@ char *photos_export_preview_json(const char *asset_id, const char *output_dir, i
NSError *writeErr = nil;
if (![imageData writeToFile:filepath options:NSDataWritingAtomic error:&writeErr]) {
return json_from_object(@{@"error": @"write failed", @"cloud": asset_cloud_status_string(asset)});
NSString *msg = writeErr ? writeErr.localizedDescription : @"unknown write error";
return json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", msg], @"cloud": asset_cloud_status_string(asset)});
}
NSNumber *fileSize = nil;
@@ -531,6 +348,11 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
NSString *nsAssetId = [NSString stringWithUTF8String:asset_id];
NSString *nsOutputDir = [NSString stringWithUTF8String:output_dir];
if (!nsAssetId || !nsOutputDir) return json_from_object(make_error_dict(@"invalid UTF-8 in arguments"));
if (!ensure_directory(nsOutputDir)) {
return json_from_object(make_error_dict(@"failed to create output directory"));
}
PHFetchResult<PHAsset *> *fetch = [PHAsset fetchAssetsWithLocalIdentifiers:@[nsAssetId] options:nil];
if (fetch.count == 0) return json_from_object(make_error_dict(@"asset not found"));
@@ -564,10 +386,12 @@ char *photos_export_original_json(const char *asset_id, const char *output_dir,
writeErr = error;
dispatch_semaphore_signal(sem);
}];
dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
if (!semaphore_wait_with_timeout(sem, 120)) {
return json_from_object(@{@"error": @"timeout waiting for resource", @"cloud": asset_cloud_status_string(asset)});
}
if (writeErr) {
return json_from_object(@{@"error": @"write failed", @"cloud": asset_cloud_status_string(asset)});
return json_from_object(@{@"error": [NSString stringWithFormat:@"write failed: %@", writeErr.localizedDescription], @"cloud": asset_cloud_status_string(asset)});
}
NSString *writtenFilename = [filepath lastPathComponent];
@@ -591,3 +415,7 @@ void photos_free_string(char *value) {
void photos_request_cancel(void) {
photos_cancelled = 1;
}
int photos_request_is_cancelled(void) {
return photos_cancelled;
}
+4 -26
View File
@@ -13,9 +13,6 @@ 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;
@@ -32,9 +29,6 @@ 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; }
@@ -72,26 +66,6 @@ char *photos_list_tree_json(void) {
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);
}
@@ -100,6 +74,10 @@ void photos_request_cancel(void) {
stub_cancelled = 1;
}
int photos_request_is_cancelled(void) {
return stub_cancelled;
}
void photos_test_set_export_preview_json(const char *json) {
stub_export_preview_json = json;
}
+139 -54
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"io"
"strings"
"time"
"gitea.k3s.k0.nu/tools/photocli/internal/photos"
)
@@ -66,10 +67,12 @@ Flags:
}
func mustAuth(stderr io.Writer, bridge photos.Bridge) int {
fmt.Fprintln(stderr, "requesting photo library access...")
if err := bridge.RequestAccess(); err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return 1
}
fmt.Fprintln(stderr, "access granted")
return 0
}
@@ -77,6 +80,7 @@ func cmdAlbums(stdout, stderr io.Writer, bridge photos.Bridge) int {
if rc := mustAuth(stderr, bridge); rc != 0 {
return rc
}
fmt.Fprintln(stderr, "loading albums...")
albums, err := bridge.ListAlbums()
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
@@ -89,20 +93,20 @@ func cmdAlbums(stdout, stderr io.Writer, bridge photos.Bridge) int {
}
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
return idOrName, listErr
}
for _, a := range albums {
if a.Title == idOrName {
return a.ID, nil
}
}
return idOrName, err
_, _, err := bridge.ListAssets(idOrName)
if err == nil {
return idOrName, nil
}
return idOrName, fmt.Errorf("album not found: %s", idOrName)
}
func cmdPhotos(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
@@ -125,11 +129,7 @@ func cmdPhotos(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
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)
fmt.Fprintf(stdout, "%s\t%s\t%s\n", a.ID, a.Filename, a.Cloud)
}
return 0
}
@@ -182,31 +182,15 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
}
}
fmt.Fprintf(stderr, "loading assets for album %s...\n", albumID)
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++
}
fmt.Fprintf(stderr, "exporting %d assets (%s) to %s...\n", total, exportMode(originals), outDir)
exported, failed := exportAssets(assets, outDir, size, originals, total, stderr, bridge, "")
if exported == 0 && failed > 0 {
fmt.Fprintf(stderr, "\nerror: all exports failed\n")
@@ -214,10 +198,14 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
}
if originals {
fmt.Fprintf(stderr, "\nexported %d original files to %s\n", exported, outDir)
fmt.Fprintf(stderr, "\nexported %d original files to %s", exported, outDir)
} else {
fmt.Fprintf(stderr, "\nexported %d photos to %s\n", exported, outDir)
fmt.Fprintf(stderr, "\nexported %d photos to %s", exported, outDir)
}
if failed > 0 {
fmt.Fprintf(stderr, " (%d failed)", failed)
}
fmt.Fprintln(stderr)
return 0
}
@@ -243,67 +231,107 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge)
}
}
fmt.Fprintln(stderr, "loading photo library tree...")
nodes, err := bridge.ListTree()
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return 1
}
albumCount := countAlbums(nodes)
fmt.Fprintf(stderr, "found %d albums, exporting to %s...\n", albumCount, outDir)
totalAssets, grandTotal, err := backupTree(nodes, outDir, size, originals, stderr, bridge)
totalAssets, _, failed, 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)
fmt.Fprintf(stderr, "\nexported %d original files across %d albums to %s", totalAssets, albumCount, outDir)
} else {
fmt.Fprintf(stderr, "\nexported %d preview files across %d albums to %s\n", totalAssets, albumCount, outDir)
fmt.Fprintf(stderr, "\nexported %d preview files across %d albums to %s", totalAssets, albumCount, outDir)
}
_ = grandTotal
if failed > 0 {
fmt.Fprintf(stderr, " (%d failed)", failed)
}
fmt.Fprintln(stderr)
return 0
}
func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, originals bool, stderr io.Writer, bridge photos.Bridge) (int, int, error) {
func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, originals bool, stderr io.Writer, bridge photos.Bridge) (int, int, int, error) {
exported := 0
total := 0
failed := 0
for _, node := range nodes {
if bridge.IsCancelled() {
break
}
path := outDir + "/" + sanitizePathComponent(node.Name)
if node.Kind == "folder" {
n, t, err := backupTree(node.Children, path, targetSize, originals, stderr, bridge)
n, t, f, err := backupTree(node.Children, path, targetSize, originals, stderr, bridge)
if err != nil {
return exported, total, err
return exported, total, failed, err
}
exported += n
total += t
failed += f
continue
}
if node.Kind == "album" && node.ID != "" {
assets, assetTotal, err := bridge.ListAssets(node.ID)
if err != nil {
fmt.Fprintf(stderr, "\n skipped album %s: %v\n", node.Name, err)
continue
}
fmt.Fprintf(stderr, "\nalbum: %s (%d assets)\n", node.Name, assetTotal)
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)
n, f := exportAssets(assets, path, targetSize, originals, total, stderr, bridge, path+"/")
exported += n
failed += f
}
progressBar(stderr, exported+1, total, path+"/"+result.Filename, result.Size, result.Cloud)
}
return exported, total, failed, nil
}
func exportAssets(assets []photos.Asset, outDir string, targetSize int, originals bool, total int, stderr io.Writer, bridge photos.Bridge, dirPrefix string) (int, int) {
exported := 0
failed := 0
var totalBytes int64
var totalDur time.Duration
for i, a := range assets {
if bridge.IsCancelled() {
break
}
start := time.Now()
result, exportErr := exportOne(bridge, a, outDir, targetSize, originals, i)
dur := time.Since(start)
if exportErr == nil {
totalBytes += result.Size
totalDur += dur
}
avgSpeed := float64(0)
if totalDur > 0 {
avgSpeed = float64(totalBytes) / totalDur.Seconds()
}
progressBar(stderr, exported+failed+1, total, dirPrefix+result.Filename, result.Size, result.Cloud, avgSpeed, exportErr != nil)
if exportErr != nil {
fmt.Fprintf(stderr, "\n failed: %s: %v\n", a.Filename, exportErr)
failed++
continue
}
exported++
}
}
}
return exported, total, nil
return exported, failed
}
func exportOne(bridge photos.Bridge, a photos.Asset, outDir string, targetSize int, originals bool, index int) (photos.ExportResult, error) {
if originals {
return bridge.ExportOriginal(a.ID, outDir, index)
}
return bridge.ExportPreview(a.ID, outDir, targetSize, index)
}
// Must stay in sync with sanitized_path_component in bridge/photokit_bridge.m
func sanitizePathComponent(name string) string {
s := strings.TrimSpace(name)
if s == "" {
@@ -357,15 +385,62 @@ func flagValWithDefault(args []string, name, def string) string {
return def
}
func progressBar(w io.Writer, current, total int, filename string, size int64, cloud string) {
func progressBar(w io.Writer, current, total int, filename string, size int64, cloud string, avgSpeed float64, isErr bool) {
pct := 0
if total > 0 {
pct = current * 100 / total
}
barWidth := 30
barWidth := 25
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)
partial := (pct * barWidth % 100) * len(blockPartial) / 100
bar := strings.Repeat(string(blockFull), filled)
if filled < barWidth {
if partial > 0 {
bar += string(blockPartial[partial])
}
bar += strings.Repeat(string(blockEmpty), barWidth-filled-1)
}
fileSize := formatSize(size)
cloudLabel := ""
if cloud == "cloud" {
cloudLabel = formatSpeed(avgSpeed)
if cloudLabel != "" {
cloudLabel = " " + cloudLabel
}
cloudLabel = " ☁" + cloudLabel
}
name := filename
maxName := 30
if len(name) > maxName {
name = "…" + name[len(name)-maxName+1:]
}
fmt.Fprintf(w, "\r%s [%s] %3d%% %s%s %s", name, bar, pct, fileSize, cloudLabel, statusLabel(isErr))
}
var blockFull = '█'
var blockEmpty = '░'
var blockPartial = []rune{'▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'}
func statusLabel(isErr bool) string {
if isErr {
return "✗"
}
return ""
}
func formatSpeed(bytesPerSec float64) string {
if bytesPerSec <= 0 {
return ""
}
const kb = 1024
const mb = kb * 1024
if bytesPerSec >= mb {
return fmt.Sprintf("%.1f MB/s", bytesPerSec/mb)
}
if bytesPerSec >= kb {
return fmt.Sprintf("%.1f KB/s", bytesPerSec/kb)
}
return fmt.Sprintf("%.0f B/s", bytesPerSec)
}
func formatSize(bytes int64) string {
@@ -377,5 +452,15 @@ func formatSize(bytes int64) string {
if bytes >= mb {
return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
}
if bytes >= kb {
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
}
return fmt.Sprintf("%d B", bytes)
}
func exportMode(originals bool) string {
if originals {
return "originals"
}
return "previews"
}
+5 -5
View File
@@ -3,6 +3,7 @@ package main
import (
"os"
"os/signal"
"sync/atomic"
"syscall"
"gitea.k3s.k0.nu/tools/photocli/internal/photos"
@@ -15,21 +16,20 @@ func main() {
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
done := make(chan struct{})
var rc int
var rc atomic.Int32
go func() {
rc = run(os.Args[1:], os.Stdout, os.Stderr, photos.DefaultBridge)
rc.Store(int32(run(os.Args[1:], os.Stdout, os.Stderr, photos.DefaultBridge)))
close(done)
}()
select {
case <-done:
case sig := <-sigCh:
case <-sigCh:
photos.DefaultBridge.Cancel()
os.Stderr.Write([]byte("\nreceived signal, finishing current file...\n"))
<-done
_ = sig
}
os.Exit(rc)
os.Exit(int(rc.Load()))
}
+91 -42
View File
@@ -20,18 +20,9 @@ type mockBridge struct {
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)
exportPreviewFn func(string, string, int, int) (photos.ExportResult, error)
exportOrigFn func(string, string, int) (photos.ExportResult, 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 }
@@ -46,37 +37,20 @@ func (m *mockBridge) ListAssets(albumID string) ([]photos.Asset, int, error) {
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)
if m.exportPreviewFn != nil {
return m.exportPreviewFn(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)
if m.exportOrigFn != nil {
return m.exportOrigFn(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 (m *mockBridge) IsCancelled() bool { return m.cancelled }
func runWith(args []string, b photos.Bridge) (string, string, int) {
var out, err bytes.Buffer
@@ -187,7 +161,7 @@ func TestCmdPhotosMissingAlbumID(t *testing.T) {
func TestCmdPhotosSuccess(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "as1", Filename: "IMG_0001.JPG"}, {ID: "as2", Filename: "IMG_0002.JPG", Cloud: true}},
assets: []photos.Asset{{ID: "as1", Filename: "IMG_0001.JPG", Cloud: "local"}, {ID: "as2", Filename: "IMG_0002.JPG", Cloud: "cloud"}},
}
out, _, rc := runWith([]string{"photos", "--album-id", "x"}, b)
if rc != 0 {
@@ -211,7 +185,7 @@ func TestCmdPhotosAuthDenied(t *testing.T) {
}
func TestCmdPhotosBridgeError(t *testing.T) {
b := &mockBridge{assetsErr: fmt.Errorf("fail")}
b := &mockBridge{albumsErr: fmt.Errorf("fail")}
_, stderr, rc := runWith([]string{"photos", "--album-id", "x"}, b)
if rc != 1 {
t.Errorf("rc = %d", rc)
@@ -273,6 +247,9 @@ func TestCmdBackupAllPreviewSuccess(t *testing.T) {
if !strings.Contains(stderr, "exported 2 preview files across 2 albums to /backup") {
t.Errorf("stderr = %q", stderr)
}
if strings.Contains(stderr, "failed") {
t.Errorf("unexpected failed count in stderr = %q", stderr)
}
}
func TestCmdBackupAllOriginalsSuccess(t *testing.T) {
@@ -289,6 +266,9 @@ func TestCmdBackupAllOriginalsSuccess(t *testing.T) {
if !strings.Contains(stderr, "exported 3 original files across 1 albums to /backup") {
t.Errorf("stderr = %q", stderr)
}
if strings.Contains(stderr, "failed") {
t.Errorf("unexpected failed count in stderr = %q", stderr)
}
}
func TestCmdBackupAllMissingOutDir(t *testing.T) {
@@ -329,7 +309,7 @@ func TestCmdBackupAllExportError(t *testing.T) {
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "as1", Filename: "img.jpg"}},
},
exportPreviewFn2: func(string, string, int, int) (photos.ExportResult, error) {
exportPreviewFn: func(string, string, int, int) (photos.ExportResult, error) {
return photos.ExportResult{}, fmt.Errorf("disk full")
},
}
@@ -337,7 +317,12 @@ func TestCmdBackupAllExportError(t *testing.T) {
if rc != 0 {
t.Fatalf("rc = %d, stderr = %q", rc, stderr)
}
_ = stderr
if !strings.Contains(stderr, "failed: img.jpg: disk full") {
t.Errorf("stderr should contain failure detail, got: %q", stderr)
}
if !strings.Contains(stderr, "(1 failed)") {
t.Errorf("stderr should contain failed count, got: %q", stderr)
}
}
func TestCmdExportMissingAlbumID(t *testing.T) {
@@ -420,7 +405,7 @@ func TestCmdExportAuthDenied(t *testing.T) {
func TestCmdExportBridgeError(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}},
exportPreviewFn2: func(string, string, int, int) (photos.ExportResult, error) {
exportPreviewFn: func(string, string, int, int) (photos.ExportResult, error) {
return photos.ExportResult{}, fmt.Errorf("disk full")
},
}
@@ -431,6 +416,9 @@ func TestCmdExportBridgeError(t *testing.T) {
if !strings.Contains(stderr, "all exports failed") {
t.Errorf("stderr = %q", stderr)
}
if !strings.Contains(stderr, "failed: img.jpg: disk full") {
t.Errorf("stderr should contain failure detail, got: %q", stderr)
}
}
func TestCmdExportOriginalsSuccess(t *testing.T) {
@@ -462,7 +450,7 @@ func TestCmdExportOriginalsIgnoresSizeValidation(t *testing.T) {
func TestCmdExportOriginalsBridgeError(t *testing.T) {
b := &mockBridge{
assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}},
exportOrigFn2: func(string, string, int) (photos.ExportResult, error) {
exportOrigFn: func(string, string, int) (photos.ExportResult, error) {
return photos.ExportResult{}, fmt.Errorf("copy failed")
},
}
@@ -473,6 +461,9 @@ func TestCmdExportOriginalsBridgeError(t *testing.T) {
if !strings.Contains(stderr, "all exports failed") {
t.Errorf("stderr = %q", stderr)
}
if !strings.Contains(stderr, "failed: img.jpg: copy failed") {
t.Errorf("stderr should contain failure detail, got: %q", stderr)
}
}
func TestFlagVal(t *testing.T) {
@@ -527,7 +518,7 @@ func TestFlagValEmptyArgs(t *testing.T) {
func TestResolveAlbumIDDirectMatch(t *testing.T) {
b := &mockBridge{
assetsByAlbum: map[string][]photos.Asset{
"ABC/L0/001": {{ID: "a1", Filename: "img.jpg"}},
"ABC/L0/001": {{ID: "a1", Filename: "img.jpg", Cloud: "local"}},
},
}
id, err := resolveAlbumID(b, "ABC/L0/001")
@@ -546,7 +537,7 @@ func TestResolveAlbumIDByName(t *testing.T) {
{ID: "DEF/L0/001", Title: "Work"},
},
assetsByAlbum: map[string][]photos.Asset{
"ABC/L0/001": {{ID: "a1", Filename: "img.jpg"}},
"ABC/L0/001": {{ID: "a1", Filename: "img.jpg", Cloud: "local"}},
},
}
id, err := resolveAlbumID(b, "DnD")
@@ -588,7 +579,7 @@ func TestCmdPhotosResolvesAlbumName(t *testing.T) {
{ID: "ABC/L0/001", Title: "DnD"},
},
assetsByAlbum: map[string][]photos.Asset{
"ABC/L0/001": {{ID: "a1", Filename: "img.jpg"}},
"ABC/L0/001": {{ID: "a1", Filename: "img.jpg", Cloud: "local"}},
},
}
out, stderr, rc := runWith([]string{"photos", "--album-id", "DnD"}, b)
@@ -635,3 +626,61 @@ func TestCmdExportOriginalsResolvesAlbumName(t *testing.T) {
t.Errorf("stderr = %q", stderr)
}
}
func TestCmdExportPartialFailure(t *testing.T) {
call := 0
b := &mockBridge{
albums: []photos.Album{{ID: "a1", Title: "Album"}},
assetsByAlbum: map[string][]photos.Asset{
"a1": {{ID: "x1", Filename: "ok.jpg", Cloud: "local"}, {ID: "x2", Filename: "bad.jpg", Cloud: "local"}, {ID: "x3", Filename: "ok2.jpg", Cloud: "local"}},
},
exportPreviewFn: func(string, string, int, int) (photos.ExportResult, error) {
call++
if call == 2 {
return photos.ExportResult{}, fmt.Errorf("disk full")
}
return photos.ExportResult{Filename: "ok.jpg", Size: 1024, Cloud: "local"}, nil
},
}
_, stderr, rc := runWith([]string{"export", "--album-id", "Album", "--out", "/tmp"}, b)
if rc != 0 {
t.Errorf("rc = %d, stderr = %q", rc, stderr)
}
if !strings.Contains(stderr, "failed: bad.jpg: disk full") {
t.Errorf("stderr should contain failure detail, got: %q", stderr)
}
if !strings.Contains(stderr, "(1 failed)") {
t.Errorf("stderr should contain failed count, got: %q", stderr)
}
if !strings.Contains(stderr, "exported 2 photos") {
t.Errorf("stderr should contain export count, got: %q", stderr)
}
}
func TestCmdBackupAllSkippedAlbum(t *testing.T) {
b := &mockBridge{
tree: []photos.CollectionNode{{ID: "bad-album", Name: "Broken", Kind: "album"}},
assetsByAlbum: map[string][]photos.Asset{},
}
_, stderr, rc := runWith([]string{"backup-all", "--out", "/backup"}, b)
if rc != 0 {
t.Fatalf("rc = %d, stderr = %q", rc, stderr)
}
if !strings.Contains(stderr, "skipped album Broken") {
t.Errorf("stderr should contain skipped album, got: %q", stderr)
}
}
func TestResolveAlbumIDNotFoundMessage(t *testing.T) {
b := &mockBridge{
albums: []photos.Album{{ID: "x", Title: "Other"}},
assetsByAlbum: map[string][]photos.Asset{},
}
_, err := resolveAlbumID(b, "Nonexistent")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "album not found: Nonexistent") {
t.Errorf("err = %q", err.Error())
}
}
+21
View File
@@ -0,0 +1,21 @@
# ADR 001: Cloud Status Detection via performSelector
## Status
Accepted
## Context
We need to detect whether a photo asset is stored locally or in iCloud. Apple's PhotoKit does not expose `PHAsset.cloudIdentifier` as a public property on macOS. The `PHAsset` class has this property on iOS but it is undocumented on macOS.
## Decision
Use `performSelector:@selector(cloudIdentifier)` with `@try/@catch` to detect cloud status. If the selector returns a non-null value, the asset is in iCloud. If it throws an exception, fall back to checking `PHAssetResource.isLocallyAvailable` (also via `performSelector`).
## Consequences
- This accesses an undocumented Apple API. It may break in any macOS update without warning.
- The `@try/@catch` pattern prevents crashes if the selector is removed.
- If both checks fail or throw, we default to "local" — this may incorrectly report cloud-only assets as local.
- This approach could cause notarization issues if Apple enforces stricter private API checks in the future.
- No alternative public API exists on macOS for this purpose as of macOS 14.
+1 -23
View File
@@ -10,12 +10,10 @@ type Bridge interface {
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()
IsCancelled() bool
}
func ParseAlbumsJSON(jsonStr string) ([]Album, error) {
@@ -62,23 +60,3 @@ func ParseExportResultJSON(jsonStr string) (ExportResult, 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
}
}
+4 -33
View File
@@ -54,43 +54,14 @@ func (*CgoBridge) ListTree() ([]CollectionNode, error) {
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) IsCancelled() bool {
return C.photos_request_is_cancelled() != 0
}
func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) {
cid := C.CString(assetID)
defer C.free(unsafe.Pointer(cid))
+4 -37
View File
@@ -12,9 +12,6 @@ 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);
@@ -34,9 +31,6 @@ 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() }
@@ -80,41 +74,14 @@ func (*CgoBridge) ListTree() ([]CollectionNode, error) {
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) IsCancelled() bool {
return C.photos_request_is_cancelled() != 0
}
func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) {
cid := C.CString(assetID)
defer C.free(unsafe.Pointer(cid))
+2 -30
View File
@@ -1,36 +1,8 @@
// Package photos provides a Go bridge to Apple's PhotoKit framework via CGo,
// enabling programmatic access to photos, albums, and export operations.
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)
}
-164
View File
@@ -209,45 +209,6 @@ func TestParseTreeJSON(t *testing.T) {
}
}
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
@@ -299,68 +260,6 @@ 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{}
@@ -425,69 +324,6 @@ func TestCgoBridgeListTreeNil(t *testing.T) {
}
}
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{}
+1 -1
View File
@@ -8,7 +8,7 @@ type Album struct {
type Asset struct {
ID string `json:"id"`
Filename string `json:"filename"`
Cloud bool `json:"cloud"`
Cloud string `json:"cloud"`
}
type ExportResult struct {