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