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

This commit is contained in:
Ein Anderssono
2026-06-11 20:25:07 +02:00
commit 6ec16f3966
21 changed files with 3488 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
package photos
import (
"encoding/json"
"fmt"
)
type Bridge interface {
RequestAccess() error
ListAlbums() ([]Album, error)
ListAssets(albumID string) ([]Asset, int, error)
ListTree() ([]CollectionNode, error)
ExportAlbumPreviews(albumID, outputDir string, targetSize int) (int, error)
ExportAlbumOriginals(albumID, outputDir string) (int, error)
ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error)
ExportOriginal(assetID, outputDir string, index int) (ExportResult, error)
BackupAll(outputDir string, targetSize int, originals bool) (int, error)
Cancel()
}
func ParseAlbumsJSON(jsonStr string) ([]Album, error) {
var resp AlbumsResponse
if err := json.Unmarshal([]byte(jsonStr), &resp); err != nil {
return nil, err
}
return resp.Albums, nil
}
func ParseAssetsJSON(jsonStr string) ([]Asset, int, error) {
var errResp ErrorResponse
if err := json.Unmarshal([]byte(jsonStr), &errResp); err == nil && errResp.Error != "" {
return nil, 0, fmt.Errorf("%s", errResp.Error)
}
var resp AssetsResponse
if err := json.Unmarshal([]byte(jsonStr), &resp); err != nil {
return nil, 0, err
}
return resp.Assets, resp.Total, nil
}
func ParseTreeJSON(jsonStr string) ([]CollectionNode, error) {
var errResp ErrorResponse
if err := json.Unmarshal([]byte(jsonStr), &errResp); err == nil && errResp.Error != "" {
return nil, fmt.Errorf("%s", errResp.Error)
}
var resp TreeResponse
if err := json.Unmarshal([]byte(jsonStr), &resp); err != nil {
return nil, err
}
return resp.Collections, nil
}
func ParseExportResultJSON(jsonStr string) (ExportResult, error) {
var resp ExportResultResponse
if err := json.Unmarshal([]byte(jsonStr), &resp); err != nil {
return ExportResult{}, err
}
if resp.Error != "" {
return ExportResult{}, fmt.Errorf("%s", resp.Error)
}
return resp.ExportResult, nil
}
func InterpretExportResult(rc int) (int, error) {
switch rc {
case -1:
return 0, fmt.Errorf("invalid arguments or directory creation failed")
case -2:
return 0, fmt.Errorf("could not create output directory")
case -3:
return 0, fmt.Errorf("album not found")
case -4:
return 0, fmt.Errorf("all exports failed")
case -5:
return 0, fmt.Errorf("cancelled")
default:
if rc < 0 {
return 0, fmt.Errorf("unknown error (code %d)", rc)
}
return rc, nil
}
}
+120
View File
@@ -0,0 +1,120 @@
//go:build !test
package photos
/*
#cgo CFLAGS: -I${SRCDIR}/../../bridge
#cgo LDFLAGS: -L${SRCDIR}/../../bridge -lphotokit_bridge -framework Photos -framework Foundation -framework AppKit
#include "photokit_bridge.h"
#include <stdlib.h>
*/
import "C"
import "unsafe"
type CgoBridge struct{}
var DefaultBridge Bridge = &CgoBridge{}
func (*CgoBridge) RequestAccess() error {
rc := C.photos_request_access()
if rc != 0 {
return errAccessDenied
}
return nil
}
func (*CgoBridge) ListAlbums() ([]Album, error) {
cs := C.photos_list_albums_json()
if cs == nil {
return nil, errBridgeNil
}
defer C.photos_free_string(cs)
return ParseAlbumsJSON(C.GoString(cs))
}
func (*CgoBridge) ListAssets(albumID string) ([]Asset, int, error) {
cid := C.CString(albumID)
defer C.free(unsafe.Pointer(cid))
cs := C.photos_list_assets_json(cid)
if cs == nil {
return nil, 0, errBridgeNil
}
defer C.photos_free_string(cs)
return ParseAssetsJSON(C.GoString(cs))
}
func (*CgoBridge) ListTree() ([]CollectionNode, error) {
cs := C.photos_list_tree_json()
if cs == nil {
return nil, errBridgeNil
}
defer C.photos_free_string(cs)
return ParseTreeJSON(C.GoString(cs))
}
func (*CgoBridge) ExportAlbumPreviews(albumID, outputDir string, targetSize int) (int, error) {
cid := C.CString(albumID)
defer C.free(unsafe.Pointer(cid))
cdir := C.CString(outputDir)
defer C.free(unsafe.Pointer(cdir))
rc := C.photos_export_album_previews(cid, cdir, C.int(targetSize))
return InterpretExportResult(int(rc))
}
func (*CgoBridge) ExportAlbumOriginals(albumID, outputDir string) (int, error) {
cid := C.CString(albumID)
defer C.free(unsafe.Pointer(cid))
cdir := C.CString(outputDir)
defer C.free(unsafe.Pointer(cdir))
rc := C.photos_export_album_originals(cid, cdir)
return InterpretExportResult(int(rc))
}
func (*CgoBridge) BackupAll(outputDir string, targetSize int, originals bool) (int, error) {
cdir := C.CString(outputDir)
defer C.free(unsafe.Pointer(cdir))
coriginals := 0
if originals {
coriginals = 1
}
rc := C.photos_backup_all(cdir, C.int(targetSize), C.int(coriginals))
return InterpretExportResult(int(rc))
}
func (*CgoBridge) Cancel() {
C.photos_request_cancel()
}
func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) {
cid := C.CString(assetID)
defer C.free(unsafe.Pointer(cid))
cdir := C.CString(outputDir)
defer C.free(unsafe.Pointer(cdir))
cs := C.photos_export_preview_json(cid, cdir, C.int(targetSize), C.int(index))
if cs == nil {
return ExportResult{}, errBridgeNil
}
defer C.photos_free_string(cs)
return ParseExportResultJSON(C.GoString(cs))
}
func (*CgoBridge) ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) {
cid := C.CString(assetID)
defer C.free(unsafe.Pointer(cid))
cdir := C.CString(outputDir)
defer C.free(unsafe.Pointer(cdir))
cs := C.photos_export_original_json(cid, cdir, C.int(index))
if cs == nil {
return ExportResult{}, errBridgeNil
}
defer C.photos_free_string(cs)
return ParseExportResultJSON(C.GoString(cs))
}
+142
View File
@@ -0,0 +1,142 @@
//go:build test
package photos
/*
#cgo CFLAGS: -I${SRCDIR}/../../bridge
#cgo LDFLAGS: -L${SRCDIR}/../../bridge -lphotokit_bridge_stub
#include "photokit_bridge.h"
#include <stdlib.h>
void photos_test_set_access(int rc);
void photos_test_set_albums(const char *json);
void photos_test_set_assets(const char *json);
void photos_test_set_tree(const char *json);
void photos_test_set_export_rc(int rc);
void photos_test_set_export_originals_rc(int rc);
void photos_test_set_backup_all_rc(int rc);
void photos_test_set_albums_null(void);
void photos_test_set_assets_null(void);
void photos_test_set_tree_null(void);
void photos_request_cancel(void);
void photos_test_set_export_preview_json(const char *json);
void photos_test_set_export_original_json(const char *json);
*/
import "C"
import "unsafe"
type CgoBridge struct{}
var DefaultBridge Bridge = &CgoBridge{}
func SetTestAccessRC(rc int) { C.photos_test_set_access(C.int(rc)) }
func SetTestAlbumsJSON(json string) { C.photos_test_set_albums(C.CString(json)) }
func SetTestAssetsJSON(json string) { C.photos_test_set_assets(C.CString(json)) }
func SetTestTreeJSON(json string) { C.photos_test_set_tree(C.CString(json)) }
func SetTestExportRC(rc int) { C.photos_test_set_export_rc(C.int(rc)) }
func SetTestExportOriginalsRC(rc int) { C.photos_test_set_export_originals_rc(C.int(rc)) }
func SetTestBackupAllRC(rc int) { C.photos_test_set_backup_all_rc(C.int(rc)) }
func SetTestAlbumsNull() { C.photos_test_set_albums_null() }
func SetTestAssetsNull() { C.photos_test_set_assets_null() }
func SetTestTreeNull() { C.photos_test_set_tree_null() }
func SetTestExportPreviewJSON(json string) { C.photos_test_set_export_preview_json(C.CString(json)) }
func SetTestExportOriginalJSON(json string) { C.photos_test_set_export_original_json(C.CString(json)) }
func (*CgoBridge) RequestAccess() error {
rc := C.photos_request_access()
if rc != 0 {
return errAccessDenied
}
return nil
}
func (*CgoBridge) ListAlbums() ([]Album, error) {
cs := C.photos_list_albums_json()
if cs == nil {
return nil, errBridgeNil
}
defer C.photos_free_string(cs)
return ParseAlbumsJSON(C.GoString(cs))
}
func (*CgoBridge) ListAssets(albumID string) ([]Asset, int, error) {
cid := C.CString(albumID)
defer C.free(unsafe.Pointer(cid))
cs := C.photos_list_assets_json(cid)
if cs == nil {
return nil, 0, errBridgeNil
}
defer C.photos_free_string(cs)
return ParseAssetsJSON(C.GoString(cs))
}
func (*CgoBridge) ListTree() ([]CollectionNode, error) {
cs := C.photos_list_tree_json()
if cs == nil {
return nil, errBridgeNil
}
defer C.photos_free_string(cs)
return ParseTreeJSON(C.GoString(cs))
}
func (*CgoBridge) ExportAlbumPreviews(albumID, outputDir string, targetSize int) (int, error) {
cid := C.CString(albumID)
defer C.free(unsafe.Pointer(cid))
cdir := C.CString(outputDir)
defer C.free(unsafe.Pointer(cdir))
rc := C.photos_export_album_previews(cid, cdir, C.int(targetSize))
return InterpretExportResult(int(rc))
}
func (*CgoBridge) ExportAlbumOriginals(albumID, outputDir string) (int, error) {
cid := C.CString(albumID)
defer C.free(unsafe.Pointer(cid))
cdir := C.CString(outputDir)
defer C.free(unsafe.Pointer(cdir))
rc := C.photos_export_album_originals(cid, cdir)
return InterpretExportResult(int(rc))
}
func (*CgoBridge) BackupAll(outputDir string, targetSize int, originals bool) (int, error) {
cdir := C.CString(outputDir)
defer C.free(unsafe.Pointer(cdir))
coriginals := 0
if originals {
coriginals = 1
}
rc := C.photos_backup_all(cdir, C.int(targetSize), C.int(coriginals))
return InterpretExportResult(int(rc))
}
func (*CgoBridge) Cancel() {
C.photos_request_cancel()
}
func (*CgoBridge) ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) {
cid := C.CString(assetID)
defer C.free(unsafe.Pointer(cid))
cdir := C.CString(outputDir)
defer C.free(unsafe.Pointer(cdir))
cs := C.photos_export_preview_json(cid, cdir, C.int(targetSize), C.int(index))
if cs == nil {
return ExportResult{}, errBridgeNil
}
defer C.photos_free_string(cs)
return ParseExportResultJSON(C.GoString(cs))
}
func (*CgoBridge) ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) {
cid := C.CString(assetID)
defer C.free(unsafe.Pointer(cid))
cdir := C.CString(outputDir)
defer C.free(unsafe.Pointer(cdir))
cs := C.photos_export_original_json(cid, cdir, C.int(index))
if cs == nil {
return ExportResult{}, errBridgeNil
}
defer C.photos_free_string(cs)
return ParseExportResultJSON(C.GoString(cs))
}
+36
View File
@@ -0,0 +1,36 @@
package photos
import "fmt"
var errAccessDenied = fmt.Errorf("photos access denied: grant Full Disk Access or Photos permission in System Settings > Privacy & Security")
var errBridgeNil = fmt.Errorf("bridge returned nil")
func RequestAccess() error { return DefaultBridge.RequestAccess() }
func ListAlbums() ([]Album, error) { return DefaultBridge.ListAlbums() }
func ListAssets(albumID string) ([]Asset, int, error) { return DefaultBridge.ListAssets(albumID) }
func ListTree() ([]CollectionNode, error) { return DefaultBridge.ListTree() }
func ExportAlbumPreviews(albumID, outputDir string, targetSize int) (int, error) {
return DefaultBridge.ExportAlbumPreviews(albumID, outputDir, targetSize)
}
func ExportAlbumOriginals(albumID, outputDir string) (int, error) {
return DefaultBridge.ExportAlbumOriginals(albumID, outputDir)
}
func BackupAll(outputDir string, targetSize int, originals bool) (int, error) {
return DefaultBridge.BackupAll(outputDir, targetSize, originals)
}
func Cancel() { DefaultBridge.Cancel() }
func ExportPreview(assetID, outputDir string, targetSize, index int) (ExportResult, error) {
return DefaultBridge.ExportPreview(assetID, outputDir, targetSize, index)
}
func ExportOriginal(assetID, outputDir string, index int) (ExportResult, error) {
return DefaultBridge.ExportOriginal(assetID, outputDir, index)
}
+604
View File
@@ -0,0 +1,604 @@
//go:build test
package photos
import (
"encoding/json"
"testing"
)
func TestParseAlbumsJSON(t *testing.T) {
tests := []struct {
name string
json string
want []Album
wantErr bool
}{
{
name: "empty albums",
json: `{"albums":[]}`,
want: []Album{},
},
{
name: "single album",
json: `{"albums":[{"id":"abc","title":"Vacation"}]}`,
want: []Album{{ID: "abc", Title: "Vacation"}},
},
{
name: "multiple albums",
json: `{"albums":[{"id":"a1","title":"Album1"},{"id":"a2","title":"Album2"}]}`,
want: []Album{{ID: "a1", Title: "Album1"}, {ID: "a2", Title: "Album2"}},
},
{
name: "invalid json",
json: `not json`,
wantErr: true,
},
{
name: "missing albums key",
json: `{}`,
want: []Album{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseAlbumsJSON(tt.json)
if (err != nil) != tt.wantErr {
t.Errorf("ParseAlbumsJSON() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
return
}
if len(got) != len(tt.want) {
t.Errorf("ParseAlbumsJSON() got %d albums, want %d", len(got), len(tt.want))
return
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("ParseAlbumsJSON()[%d] = %+v, want %+v", i, got[i], tt.want[i])
}
}
})
}
}
func TestParseAssetsJSON(t *testing.T) {
tests := []struct {
name string
json string
want []Asset
wantTotal int
wantErr bool
errMsg string
}{
{
name: "empty assets",
json: `{"assets":[],"total":0}`,
want: []Asset{},
wantTotal: 0,
},
{
name: "single asset",
json: `{"assets":[{"id":"asset1","filename":"IMG_0001.JPG"}],"total":1}`,
want: []Asset{{ID: "asset1", Filename: "IMG_0001.JPG"}},
wantTotal: 1,
},
{
name: "multiple assets",
json: `{"assets":[{"id":"a1","filename":"a.jpg"},{"id":"a2","filename":"b.jpg"},{"id":"a3","filename":"c.jpg"}],"total":3}`,
want: []Asset{{ID: "a1", Filename: "a.jpg"}, {ID: "a2", Filename: "b.jpg"}, {ID: "a3", Filename: "c.jpg"}},
wantTotal: 3,
},
{
name: "error response",
json: `{"error":"album not found"}`,
wantErr: true,
errMsg: "album not found",
},
{
name: "invalid json",
json: `not json`,
wantErr: true,
},
{
name: "empty error is not an error",
json: `{"error":"","assets":[{"id":"a1","filename":"IMG.JPG"}],"total":1}`,
want: []Asset{{ID: "a1", Filename: "IMG.JPG"}},
wantTotal: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, total, err := ParseAssetsJSON(tt.json)
if (err != nil) != tt.wantErr {
t.Errorf("ParseAssetsJSON() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
if tt.errMsg != "" && err.Error() != tt.errMsg {
t.Errorf("ParseAssetsJSON() error = %q, want %q", err.Error(), tt.errMsg)
}
return
}
if total != tt.wantTotal {
t.Errorf("ParseAssetsJSON() total = %d, want %d", total, tt.wantTotal)
}
if len(got) != len(tt.want) {
t.Errorf("ParseAssetsJSON() got %d assets, want %d", len(got), len(tt.want))
return
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("ParseAssetsJSON()[%d] = %+v, want %+v", i, got[i], tt.want[i])
}
}
})
}
}
func TestParseTreeJSON(t *testing.T) {
tests := []struct {
name string
json string
want []CollectionNode
wantErr bool
errMsg string
}{
{
name: "empty tree",
json: `{"collections":[]}`,
want: []CollectionNode{},
},
{
name: "nested tree",
json: `{"collections":[{"id":"f1","name":"Trips","kind":"folder","children":[{"id":"f2","name":"Italy 2024","kind":"folder","children":[{"id":"a1","name":"Venice","kind":"album","children":[]}]}]},{"id":"a2","name":"Favorites","kind":"album","children":[]}]}`,
want: []CollectionNode{
{ID: "f1", Name: "Trips", Kind: "folder", Children: []CollectionNode{{ID: "f2", Name: "Italy 2024", Kind: "folder", Children: []CollectionNode{{ID: "a1", Name: "Venice", Kind: "album"}}}}},
{ID: "a2", Name: "Favorites", Kind: "album"},
},
},
{
name: "error response",
json: `{"error":"library unavailable"}`,
wantErr: true,
errMsg: "library unavailable",
},
{
name: "invalid json",
json: `not json`,
wantErr: true,
},
{
name: "missing collections key",
json: `{}`,
want: nil,
},
{
name: "empty error is not an error",
json: `{"error":"","collections":[{"id":"a9","name":"Root Album","kind":"album"}]}`,
want: []CollectionNode{{ID: "a9", Name: "Root Album", Kind: "album"}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseTreeJSON(tt.json)
if (err != nil) != tt.wantErr {
t.Errorf("ParseTreeJSON() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
if tt.errMsg != "" && err.Error() != tt.errMsg {
t.Errorf("ParseTreeJSON() error = %q, want %q", err.Error(), tt.errMsg)
}
return
}
if len(got) != len(tt.want) {
t.Errorf("ParseTreeJSON() got %d collections, want %d", len(got), len(tt.want))
return
}
for i := range got {
if !equalCollectionNode(got[i], tt.want[i]) {
t.Errorf("ParseTreeJSON()[%d] = %+v, want %+v", i, got[i], tt.want[i])
}
}
})
}
}
func TestInterpretExportResult(t *testing.T) {
tests := []struct {
name string
rc int
wantN int
wantErr bool
errMsg string
}{
{name: "success", rc: 5, wantN: 5},
{name: "zero exports", rc: 0, wantN: 0},
{name: "single export", rc: 1, wantN: 1},
{name: "invalid args", rc: -1, wantErr: true, errMsg: "invalid arguments or directory creation failed"},
{name: "mkdir failed", rc: -2, wantErr: true, errMsg: "could not create output directory"},
{name: "album not found", rc: -3, wantErr: true, errMsg: "album not found"},
{name: "all exports failed", rc: -4, wantErr: true, errMsg: "all exports failed"},
{name: "cancelled", rc: -5, wantErr: true, errMsg: "cancelled"},
{name: "unknown error", rc: -99, wantErr: true, errMsg: "unknown error (code -99)"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
n, err := InterpretExportResult(tt.rc)
if (err != nil) != tt.wantErr {
t.Errorf("InterpretExportResult() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
if err.Error() != tt.errMsg {
t.Errorf("InterpretExportResult() error = %q, want %q", err.Error(), tt.errMsg)
}
return
}
if n != tt.wantN {
t.Errorf("InterpretExportResult() = %d, want %d", n, tt.wantN)
}
})
}
}
func TestAlbumsResponseUnmarshal(t *testing.T) {
raw := `{"albums":[{"id":"x","title":"Y"}]}`
var resp AlbumsResponse
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Albums) != 1 || resp.Albums[0].ID != "x" || resp.Albums[0].Title != "Y" {
t.Errorf("got %+v", resp.Albums)
}
}
func TestAssetsResponseUnmarshal(t *testing.T) {
raw := `{"assets":[{"id":"z","filename":"IMG_9999.JPG"}],"total":1}`
var resp AssetsResponse
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Assets) != 1 || resp.Assets[0].ID != "z" || resp.Assets[0].Filename != "IMG_9999.JPG" {
t.Errorf("got %+v", resp.Assets)
}
if resp.Total != 1 {
t.Errorf("got total %d, want 1", resp.Total)
}
}
func TestTreeResponseUnmarshal(t *testing.T) {
raw := `{"collections":[{"id":"f1","name":"Trips","kind":"folder","children":[{"id":"a1","name":"Venice","kind":"album"}]}]}`
var resp TreeResponse
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Collections) != 1 || resp.Collections[0].ID != "f1" || resp.Collections[0].Name != "Trips" || len(resp.Collections[0].Children) != 1 || resp.Collections[0].Children[0].ID != "a1" || resp.Collections[0].Children[0].Name != "Venice" {
t.Errorf("got %+v", resp.Collections)
}
}
func TestErrorResponseUnmarshal(t *testing.T) {
raw := `{"error":"oops"}`
var resp ErrorResponse
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
t.Fatal(err)
}
if resp.Error != "oops" {
t.Errorf("got %q", resp.Error)
}
}
func TestCgoBridgeImplementsBridge(t *testing.T) {
var _ Bridge = &CgoBridge{}
}
func TestPackageLevelDelegatesToDefaultBridge(t *testing.T) {
SetTestAccessRC(0)
SetTestAlbumsJSON(`{"albums":[{"id":"p1","title":"PAlbum"}]}`)
SetTestAssetsJSON(`{"assets":[{"id":"pa1","filename":"IMG_1001.JPG"}],"total":1}`)
SetTestTreeJSON(`{"collections":[{"id":"f1","name":"Trips","kind":"folder","children":[{"id":"a1","name":"Venice","kind":"album"}]}]}`)
SetTestExportRC(3)
SetTestExportOriginalsRC(2)
SetTestBackupAllRC(5)
if err := RequestAccess(); err != nil {
t.Fatal(err)
}
albums, err := ListAlbums()
if err != nil {
t.Fatal(err)
}
if len(albums) != 1 || albums[0].ID != "p1" {
t.Errorf("got %+v", albums)
}
assets, _, err := ListAssets("p1")
if err != nil {
t.Fatal(err)
}
if len(assets) != 1 || assets[0].ID != "pa1" || assets[0].Filename != "IMG_1001.JPG" {
t.Errorf("got %+v", assets)
}
tree, err := ListTree()
if err != nil {
t.Fatal(err)
}
if len(tree) != 1 || tree[0].ID != "f1" || tree[0].Name != "Trips" || len(tree[0].Children) != 1 || tree[0].Children[0].ID != "a1" || tree[0].Children[0].Name != "Venice" {
t.Errorf("got %+v", tree)
}
n, err := ExportAlbumPreviews("p1", "/tmp/x", 1024)
if err != nil {
t.Fatal(err)
}
if n != 3 {
t.Errorf("got %d", n)
}
n, err = ExportAlbumOriginals("p1", "/tmp/x")
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Errorf("got %d", n)
}
n, err = BackupAll("/tmp/x", 1024, false)
if err != nil {
t.Fatal(err)
}
if n != 5 {
t.Errorf("got %d", n)
}
}
func TestCgoBridgeListAlbumsViaStub(t *testing.T) {
SetTestAlbumsJSON(`{"albums":[{"id":"abc","title":"TestAlbum"}]}`)
bridge := &CgoBridge{}
albums, err := bridge.ListAlbums()
if err != nil {
t.Fatal(err)
}
if len(albums) != 1 || albums[0].ID != "abc" || albums[0].Title != "TestAlbum" {
t.Errorf("got %+v", albums)
}
}
func TestCgoBridgeListAssetsViaStub(t *testing.T) {
SetTestAssetsJSON(`{"assets":[{"id":"asset-1","filename":"A.JPG"},{"id":"asset-2","filename":"B.JPG"}],"total":2}`)
bridge := &CgoBridge{}
assets, _, err := bridge.ListAssets("any")
if err != nil {
t.Fatal(err)
}
if len(assets) != 2 {
t.Errorf("got %d assets", len(assets))
}
if assets[0].Filename != "A.JPG" || assets[1].Filename != "B.JPG" {
t.Errorf("got %+v", assets)
}
}
func TestCgoBridgeListAssetsErrorViaStub(t *testing.T) {
SetTestAssetsJSON(`{"error":"album not found"}`)
bridge := &CgoBridge{}
_, _, err := bridge.ListAssets("bad-id")
if err == nil || err.Error() != "album not found" {
t.Errorf("got %v", err)
}
}
func TestCgoBridgeListTreeViaStub(t *testing.T) {
SetTestTreeJSON(`{"collections":[{"id":"f1","name":"Trips","kind":"folder","children":[{"id":"f2","name":"Italy 2024","kind":"folder","children":[{"id":"a1","name":"Venice","kind":"album"}]}]},{"id":"a2","name":"Favorites","kind":"album"}]}`)
bridge := &CgoBridge{}
tree, err := bridge.ListTree()
if err != nil {
t.Fatal(err)
}
if len(tree) != 2 {
t.Errorf("got %d collections", len(tree))
}
if tree[0].ID != "f1" || tree[0].Name != "Trips" || len(tree[0].Children) != 1 || tree[0].Children[0].ID != "f2" || tree[0].Children[0].Name != "Italy 2024" {
t.Errorf("got %+v", tree)
}
if tree[1].ID != "a2" || tree[1].Name != "Favorites" || tree[1].Kind != "album" {
t.Errorf("got %+v", tree)
}
}
func TestCgoBridgeListTreeNil(t *testing.T) {
SetTestTreeNull()
defer SetTestTreeJSON(`{"collections":[]}`)
bridge := &CgoBridge{}
_, err := bridge.ListTree()
if err != errBridgeNil {
t.Errorf("got %v, want %v", err, errBridgeNil)
}
}
func TestCgoBridgeExportViaStub(t *testing.T) {
SetTestExportRC(7)
bridge := &CgoBridge{}
n, err := bridge.ExportAlbumPreviews("id", "/tmp/out", 512)
if err != nil {
t.Fatal(err)
}
if n != 7 {
t.Errorf("got %d", n)
}
}
func TestCgoBridgeExportErrorViaStub(t *testing.T) {
SetTestExportRC(-3)
bridge := &CgoBridge{}
_, err := bridge.ExportAlbumPreviews("id", "/tmp/out", 512)
if err == nil || err.Error() != "album not found" {
t.Errorf("got %v", err)
}
}
func TestCgoBridgeExportOriginalsViaStub(t *testing.T) {
SetTestExportOriginalsRC(6)
bridge := &CgoBridge{}
n, err := bridge.ExportAlbumOriginals("id", "/tmp/out")
if err != nil {
t.Fatal(err)
}
if n != 6 {
t.Errorf("got %d", n)
}
}
func TestCgoBridgeExportOriginalsErrorViaStub(t *testing.T) {
SetTestExportOriginalsRC(-4)
bridge := &CgoBridge{}
_, err := bridge.ExportAlbumOriginals("id", "/tmp/out")
if err == nil || err.Error() != "all exports failed" {
t.Errorf("got %v", err)
}
}
func TestCgoBridgeBackupAllViaStub(t *testing.T) {
SetTestBackupAllRC(8)
bridge := &CgoBridge{}
n, err := bridge.BackupAll("/tmp/out", 1024, false)
if err != nil {
t.Fatal(err)
}
if n != 8 {
t.Errorf("got %d", n)
}
}
func TestCgoBridgeBackupAllErrorViaStub(t *testing.T) {
SetTestBackupAllRC(-2)
bridge := &CgoBridge{}
_, err := bridge.BackupAll("/tmp/out", 1024, true)
if err == nil || err.Error() != "could not create output directory" {
t.Errorf("got %v", err)
}
}
func TestCgoBridgeRequestAccessGranted(t *testing.T) {
SetTestAccessRC(0)
bridge := &CgoBridge{}
if err := bridge.RequestAccess(); err != nil {
t.Errorf("got %v", err)
}
}
func TestCgoBridgeRequestAccessDenied(t *testing.T) {
SetTestAccessRC(-1)
bridge := &CgoBridge{}
err := bridge.RequestAccess()
if err == nil {
t.Fatal("expected error")
}
if err != errAccessDenied {
t.Errorf("got %v, want %v", err, errAccessDenied)
}
}
func TestCgoBridgeListAlbumsNil(t *testing.T) {
SetTestAlbumsNull()
defer SetTestAlbumsJSON(`{"albums":[]}`)
bridge := &CgoBridge{}
_, err := bridge.ListAlbums()
if err != errBridgeNil {
t.Errorf("got %v, want %v", err, errBridgeNil)
}
}
func TestCgoBridgeListAssetsNil(t *testing.T) {
SetTestAssetsNull()
defer SetTestAssetsJSON(`{"assets":[],"total":0}`)
bridge := &CgoBridge{}
_, _, err := bridge.ListAssets("any")
if err != errBridgeNil {
t.Errorf("got %v, want %v", err, errBridgeNil)
}
}
func TestErrAccessDeniedMessage(t *testing.T) {
if errAccessDenied.Error() == "" {
t.Error("errAccessDenied should have a message")
}
}
func TestErrBridgeNilMessage(t *testing.T) {
if errBridgeNil.Error() == "" {
t.Error("errBridgeNil should have a message")
}
}
func TestParseExportResultJSON(t *testing.T) {
tests := []struct {
name string
json string
want ExportResult
wantErr bool
errMsg string
}{
{
name: "success",
json: `{"filename":"0000_test.jpg","size":1024,"cloud":"local"}`,
want: ExportResult{Filename: "0000_test.jpg", Size: 1024, Cloud: "local"},
},
{
name: "cloud asset",
json: `{"filename":"photo.jpg","size":2048,"cloud":"cloud"}`,
want: ExportResult{Filename: "photo.jpg", Size: 2048, Cloud: "cloud"},
},
{
name: "error response",
json: `{"error":"write failed","cloud":"local"}`,
wantErr: true,
errMsg: "write failed",
},
{
name: "invalid json",
json: `not json`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseExportResultJSON(tt.json)
if (err != nil) != tt.wantErr {
t.Errorf("ParseExportResultJSON() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
if tt.errMsg != "" && err.Error() != tt.errMsg {
t.Errorf("ParseExportResultJSON() error = %q, want %q", err.Error(), tt.errMsg)
}
return
}
if got != tt.want {
t.Errorf("ParseExportResultJSON() = %+v, want %+v", got, tt.want)
}
})
}
}
func equalCollectionNode(a, b CollectionNode) bool {
if a.ID != b.ID || a.Name != b.Name || a.Kind != b.Kind || len(a.Children) != len(b.Children) {
return false
}
for i := range a.Children {
if !equalCollectionNode(a.Children[i], b.Children[i]) {
return false
}
}
return true
}
+47
View File
@@ -0,0 +1,47 @@
package photos
type Album struct {
ID string `json:"id"`
Title string `json:"title"`
}
type Asset struct {
ID string `json:"id"`
Filename string `json:"filename"`
Cloud bool `json:"cloud"`
}
type ExportResult struct {
Filename string `json:"filename"`
Size int64 `json:"size"`
Cloud string `json:"cloud"`
Error string `json:"error,omitempty"`
}
type CollectionNode struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
Kind string `json:"kind"`
Children []CollectionNode `json:"children,omitempty"`
}
type AlbumsResponse struct {
Albums []Album `json:"albums"`
}
type AssetsResponse struct {
Assets []Asset `json:"assets"`
Total int `json:"total"`
}
type TreeResponse struct {
Collections []CollectionNode `json:"collections"`
}
type ErrorResponse struct {
Error string `json:"error"`
}
type ExportResultResponse struct {
ExportResult
}