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
This commit is contained in:
@@ -10,11 +10,8 @@ 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()
|
||||
}
|
||||
|
||||
@@ -62,23 +59,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,39 +54,6 @@ 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()
|
||||
}
|
||||
|
||||
@@ -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,37 +74,6 @@ 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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
var errBridgeNil = fmt.Errorf("bridge returned nil")
|
||||
@@ -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{}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user