rename applephotos to photoscli, update module path to gitea.k3s.k0.nu/tools/photocli
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gitea.k3s.k0.nu/tools/photocli/internal/photos"
|
||||
)
|
||||
|
||||
func run(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
|
||||
if len(args) < 1 {
|
||||
usage(stderr)
|
||||
return 1
|
||||
}
|
||||
|
||||
cmd := args[0]
|
||||
switch cmd {
|
||||
case "albums":
|
||||
return cmdAlbums(stdout, stderr, bridge)
|
||||
case "photos":
|
||||
return cmdPhotos(args[1:], stdout, stderr, bridge)
|
||||
case "tree":
|
||||
return cmdTree(stdout, stderr, bridge)
|
||||
case "backup-all":
|
||||
return cmdBackupAll(args[1:], stdout, stderr, bridge)
|
||||
case "export":
|
||||
return cmdExport(args[1:], stdout, stderr, bridge)
|
||||
case "help", "--help", "-h":
|
||||
usage(stderr)
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintf(stderr, "unknown command: %s\n", cmd)
|
||||
usage(stderr)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func usage(w io.Writer) {
|
||||
fmt.Fprintln(w, `photoscli — export optimized images from Apple Photos
|
||||
|
||||
Usage:
|
||||
photoscli albums
|
||||
photoscli photos --album-id <id>
|
||||
photoscli tree
|
||||
photoscli backup-all --out <dir> [--size <px>] [--originals]
|
||||
photoscli export --album-id <id> --out <dir> [--size <px>] [--originals]
|
||||
|
||||
Commands:
|
||||
albums List user-created albums
|
||||
photos List photo assets in an album
|
||||
tree Show folder and album hierarchy
|
||||
backup-all Export all albums into the Photos folder tree
|
||||
export Export optimized JPEG previews or original files
|
||||
|
||||
Flags:
|
||||
--album-id <id> Album local identifier or title (required for photos/export)
|
||||
--out <dir> Output directory (required for export/backup-all)
|
||||
--size <px> Target longest-side in pixels (default: 1024, preview export only)
|
||||
--originals Export original files instead of JPEG previews`)
|
||||
}
|
||||
|
||||
func mustAuth(stderr io.Writer, bridge photos.Bridge) int {
|
||||
if err := bridge.RequestAccess(); err != nil {
|
||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdAlbums(stdout, stderr io.Writer, bridge photos.Bridge) int {
|
||||
if rc := mustAuth(stderr, bridge); rc != 0 {
|
||||
return rc
|
||||
}
|
||||
albums, err := bridge.ListAlbums()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
for _, a := range albums {
|
||||
fmt.Fprintf(stdout, "%s\t%s\n", a.ID, a.Title)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func resolveAlbumID(bridge photos.Bridge, idOrName string) (string, error) {
|
||||
_, _, err := bridge.ListAssets(idOrName)
|
||||
if err == nil {
|
||||
return idOrName, nil
|
||||
}
|
||||
albums, listErr := bridge.ListAlbums()
|
||||
if listErr != nil {
|
||||
return idOrName, err
|
||||
}
|
||||
for _, a := range albums {
|
||||
if a.Title == idOrName {
|
||||
return a.ID, nil
|
||||
}
|
||||
}
|
||||
return idOrName, err
|
||||
}
|
||||
|
||||
func cmdPhotos(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
|
||||
albumID := flagVal(args, "--album-id")
|
||||
if albumID == "" {
|
||||
fmt.Fprintln(stderr, "error: --album-id is required")
|
||||
return 1
|
||||
}
|
||||
if rc := mustAuth(stderr, bridge); rc != 0 {
|
||||
return rc
|
||||
}
|
||||
resolved, err := resolveAlbumID(bridge, albumID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
assets, _, err := bridge.ListAssets(resolved)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
for _, a := range assets {
|
||||
cloud := "local"
|
||||
if a.Cloud {
|
||||
cloud = "cloud"
|
||||
}
|
||||
fmt.Fprintf(stdout, "%s\t%s\t%s\n", a.ID, a.Filename, cloud)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdTree(stdout, stderr io.Writer, bridge photos.Bridge) int {
|
||||
if rc := mustAuth(stderr, bridge); rc != 0 {
|
||||
return rc
|
||||
}
|
||||
nodes, err := bridge.ListTree()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
for _, node := range nodes {
|
||||
printNode(stdout, node, 0)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
|
||||
albumID := flagVal(args, "--album-id")
|
||||
outDir := flagVal(args, "--out")
|
||||
originals := hasFlag(args, "--originals")
|
||||
sizeStr := flagValWithDefault(args, "--size", "1024")
|
||||
|
||||
if albumID == "" {
|
||||
fmt.Fprintln(stderr, "error: --album-id is required")
|
||||
return 1
|
||||
}
|
||||
if outDir == "" {
|
||||
fmt.Fprintln(stderr, "error: --out is required")
|
||||
return 1
|
||||
}
|
||||
|
||||
if rc := mustAuth(stderr, bridge); rc != 0 {
|
||||
return rc
|
||||
}
|
||||
|
||||
resolved, err := resolveAlbumID(bridge, albumID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
var size int
|
||||
if !originals {
|
||||
if _, err2 := fmt.Sscanf(sizeStr, "%d", &size); err2 != nil || size <= 0 {
|
||||
fmt.Fprintf(stderr, "error: --size must be a positive integer, got %q\n", sizeStr)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
assets, total, err := bridge.ListAssets(resolved)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
exported := 0
|
||||
failed := 0
|
||||
for i, a := range assets {
|
||||
var result photos.ExportResult
|
||||
var exportErr error
|
||||
if originals {
|
||||
result, exportErr = bridge.ExportOriginal(a.ID, outDir, i)
|
||||
} else {
|
||||
result, exportErr = bridge.ExportPreview(a.ID, outDir, size, i)
|
||||
}
|
||||
|
||||
progressBar(stderr, exported+failed+1, total, result.Filename, result.Size, result.Cloud)
|
||||
|
||||
if exportErr != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
exported++
|
||||
}
|
||||
|
||||
if exported == 0 && failed > 0 {
|
||||
fmt.Fprintf(stderr, "\nerror: all exports failed\n")
|
||||
return 1
|
||||
}
|
||||
|
||||
if originals {
|
||||
fmt.Fprintf(stderr, "\nexported %d original files to %s\n", exported, outDir)
|
||||
} else {
|
||||
fmt.Fprintf(stderr, "\nexported %d photos to %s\n", exported, outDir)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
|
||||
outDir := flagVal(args, "--out")
|
||||
originals := hasFlag(args, "--originals")
|
||||
sizeStr := flagValWithDefault(args, "--size", "1024")
|
||||
|
||||
if outDir == "" {
|
||||
fmt.Fprintln(stderr, "error: --out is required")
|
||||
return 1
|
||||
}
|
||||
|
||||
if rc := mustAuth(stderr, bridge); rc != 0 {
|
||||
return rc
|
||||
}
|
||||
|
||||
var size int
|
||||
if !originals {
|
||||
if _, err := fmt.Sscanf(sizeStr, "%d", &size); err != nil || size <= 0 {
|
||||
fmt.Fprintf(stderr, "error: --size must be a positive integer, got %q\n", sizeStr)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
nodes, err := bridge.ListTree()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
albumCount := countAlbums(nodes)
|
||||
|
||||
totalAssets, grandTotal, err := backupTree(nodes, outDir, size, originals, stderr, bridge)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
if originals {
|
||||
fmt.Fprintf(stderr, "\nexported %d original files across %d albums to %s\n", totalAssets, albumCount, outDir)
|
||||
} else {
|
||||
fmt.Fprintf(stderr, "\nexported %d preview files across %d albums to %s\n", totalAssets, albumCount, outDir)
|
||||
}
|
||||
_ = grandTotal
|
||||
return 0
|
||||
}
|
||||
|
||||
func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, originals bool, stderr io.Writer, bridge photos.Bridge) (int, int, error) {
|
||||
exported := 0
|
||||
total := 0
|
||||
for _, node := range nodes {
|
||||
path := outDir + "/" + sanitizePathComponent(node.Name)
|
||||
if node.Kind == "folder" {
|
||||
n, t, err := backupTree(node.Children, path, targetSize, originals, stderr, bridge)
|
||||
if err != nil {
|
||||
return exported, total, err
|
||||
}
|
||||
exported += n
|
||||
total += t
|
||||
continue
|
||||
}
|
||||
if node.Kind == "album" && node.ID != "" {
|
||||
assets, assetTotal, err := bridge.ListAssets(node.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
total += assetTotal
|
||||
for i, a := range assets {
|
||||
var result photos.ExportResult
|
||||
var exportErr error
|
||||
if originals {
|
||||
result, exportErr = bridge.ExportOriginal(a.ID, path, i)
|
||||
} else {
|
||||
result, exportErr = bridge.ExportPreview(a.ID, path, targetSize, i)
|
||||
}
|
||||
progressBar(stderr, exported+1, total, path+"/"+result.Filename, result.Size, result.Cloud)
|
||||
if exportErr != nil {
|
||||
continue
|
||||
}
|
||||
exported++
|
||||
}
|
||||
}
|
||||
}
|
||||
return exported, total, nil
|
||||
}
|
||||
|
||||
func sanitizePathComponent(name string) string {
|
||||
s := strings.TrimSpace(name)
|
||||
if s == "" {
|
||||
s = "Untitled"
|
||||
}
|
||||
s = strings.ReplaceAll(s, "/", "_")
|
||||
s = strings.ReplaceAll(s, "\\", "_")
|
||||
return s
|
||||
}
|
||||
|
||||
func flagVal(args []string, name string) string {
|
||||
return flagValWithDefault(args, name, "")
|
||||
}
|
||||
|
||||
func hasFlag(args []string, name string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func printNode(w io.Writer, node photos.CollectionNode, depth int) {
|
||||
for i := 0; i < depth; i++ {
|
||||
fmt.Fprint(w, " ")
|
||||
}
|
||||
fmt.Fprintln(w, node.Name)
|
||||
for _, child := range node.Children {
|
||||
printNode(w, child, depth+1)
|
||||
}
|
||||
}
|
||||
|
||||
func countAlbums(nodes []photos.CollectionNode) int {
|
||||
total := 0
|
||||
for _, node := range nodes {
|
||||
if node.Kind == "album" {
|
||||
total++
|
||||
}
|
||||
total += countAlbums(node.Children)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func flagValWithDefault(args []string, name, def string) string {
|
||||
for i, arg := range args {
|
||||
if arg == name && i+1 < len(args) {
|
||||
return args[i+1]
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func progressBar(w io.Writer, current, total int, filename string, size int64, cloud string) {
|
||||
pct := 0
|
||||
if total > 0 {
|
||||
pct = current * 100 / total
|
||||
}
|
||||
barWidth := 30
|
||||
filled := pct * barWidth / 100
|
||||
bar := strings.Repeat("=", filled) + strings.Repeat(" ", barWidth-filled)
|
||||
fmt.Fprintf(w, "\r[%s] %3d%% %s %s %s", bar, pct, filename, formatSize(size), cloud)
|
||||
}
|
||||
|
||||
func formatSize(bytes int64) string {
|
||||
if bytes <= 0 {
|
||||
return ""
|
||||
}
|
||||
const kb = 1024
|
||||
const mb = kb * 1024
|
||||
if bytes >= mb {
|
||||
return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
|
||||
}
|
||||
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"gitea.k3s.k0.nu/tools/photocli/internal/photos"
|
||||
)
|
||||
|
||||
func main() {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
done := make(chan struct{})
|
||||
var rc int
|
||||
|
||||
go func() {
|
||||
rc = run(os.Args[1:], os.Stdout, os.Stderr, photos.DefaultBridge)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case sig := <-sigCh:
|
||||
photos.DefaultBridge.Cancel()
|
||||
os.Stderr.Write([]byte("\nreceived signal, finishing current file...\n"))
|
||||
<-done
|
||||
_ = sig
|
||||
}
|
||||
|
||||
os.Exit(rc)
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
//go:build test
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.k3s.k0.nu/tools/photocli/internal/photos"
|
||||
)
|
||||
|
||||
type mockBridge struct {
|
||||
accessErr error
|
||||
albums []photos.Album
|
||||
albumsErr error
|
||||
assets []photos.Asset
|
||||
assetsErr error
|
||||
assetsByAlbum map[string][]photos.Asset
|
||||
tree []photos.CollectionNode
|
||||
treeErr error
|
||||
exportN int
|
||||
exportErr error
|
||||
exportOrigN int
|
||||
exportOrigErr error
|
||||
exportPreviewFn func(string, string, int) (int, error)
|
||||
exportOrigFn func(string, string) (int, error)
|
||||
backupAllN int
|
||||
backupAllErr error
|
||||
backupAllFn func(string, int, bool) (int, error)
|
||||
cancelled bool
|
||||
exportPreviewFn2 func(string, string, int, int) (photos.ExportResult, error)
|
||||
exportOrigFn2 func(string, string, int) (photos.ExportResult, error)
|
||||
}
|
||||
|
||||
func (m *mockBridge) RequestAccess() error { return m.accessErr }
|
||||
func (m *mockBridge) ListAlbums() ([]photos.Album, error) { return m.albums, m.albumsErr }
|
||||
func (m *mockBridge) ListAssets(albumID string) ([]photos.Asset, int, error) {
|
||||
if m.assetsByAlbum != nil {
|
||||
if assets, ok := m.assetsByAlbum[albumID]; ok {
|
||||
return assets, len(assets), nil
|
||||
}
|
||||
return nil, 0, fmt.Errorf("album not found")
|
||||
}
|
||||
return m.assets, len(m.assets), m.assetsErr
|
||||
}
|
||||
func (m *mockBridge) ListTree() ([]photos.CollectionNode, error) { return m.tree, m.treeErr }
|
||||
func (m *mockBridge) ExportAlbumPreviews(albumID, out string, size int) (int, error) {
|
||||
if m.exportPreviewFn != nil {
|
||||
return m.exportPreviewFn(albumID, out, size)
|
||||
}
|
||||
return m.exportN, m.exportErr
|
||||
}
|
||||
func (m *mockBridge) ExportAlbumOriginals(albumID, out string) (int, error) {
|
||||
if m.exportOrigFn != nil {
|
||||
return m.exportOrigFn(albumID, out)
|
||||
}
|
||||
return m.exportOrigN, m.exportOrigErr
|
||||
}
|
||||
func (m *mockBridge) ExportPreview(assetID, out string, targetSize, index int) (photos.ExportResult, error) {
|
||||
if m.exportPreviewFn2 != nil {
|
||||
return m.exportPreviewFn2(assetID, out, targetSize, index)
|
||||
}
|
||||
return photos.ExportResult{Filename: "0000_test.jpg", Size: 1024, Cloud: "local"}, nil
|
||||
}
|
||||
func (m *mockBridge) ExportOriginal(assetID, out string, index int) (photos.ExportResult, error) {
|
||||
if m.exportOrigFn2 != nil {
|
||||
return m.exportOrigFn2(assetID, out, index)
|
||||
}
|
||||
return photos.ExportResult{Filename: "test.jpg", Size: 2048, Cloud: "cloud"}, nil
|
||||
}
|
||||
func (m *mockBridge) BackupAll(out string, size int, originals bool) (int, error) {
|
||||
if m.backupAllFn != nil {
|
||||
return m.backupAllFn(out, size, originals)
|
||||
}
|
||||
return m.backupAllN, m.backupAllErr
|
||||
}
|
||||
func (m *mockBridge) Cancel() { m.cancelled = true }
|
||||
|
||||
func runWith(args []string, b photos.Bridge) (string, string, int) {
|
||||
var out, err bytes.Buffer
|
||||
rc := run(args, &out, &err, b)
|
||||
return out.String(), err.String(), rc
|
||||
}
|
||||
|
||||
func TestRunNoArgs(t *testing.T) {
|
||||
_, stderr, rc := runWith(nil, &mockBridge{})
|
||||
if rc != 1 {
|
||||
t.Errorf("rc = %d, want 1", rc)
|
||||
}
|
||||
if !strings.Contains(stderr, "photoscli") {
|
||||
t.Errorf("stderr should contain usage, got: %s", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHelp(t *testing.T) {
|
||||
for _, cmd := range []string{"help", "--help", "-h"} {
|
||||
_, stderr, rc := runWith([]string{cmd}, &mockBridge{})
|
||||
if rc != 0 {
|
||||
t.Errorf("%s: rc = %d, want 0", cmd, rc)
|
||||
}
|
||||
if !strings.Contains(stderr, "photoscli") {
|
||||
t.Errorf("%s: stderr should contain usage", cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunUnknownCommand(t *testing.T) {
|
||||
_, stderr, rc := runWith([]string{"foo"}, &mockBridge{})
|
||||
if rc != 1 {
|
||||
t.Errorf("rc = %d, want 1", rc)
|
||||
}
|
||||
if !strings.Contains(stderr, "unknown command: foo") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdAlbumsSuccess(t *testing.T) {
|
||||
b := &mockBridge{
|
||||
albums: []photos.Album{{ID: "a1", Title: "Vacation"}, {ID: "a2", Title: "Work"}},
|
||||
}
|
||||
out, _, rc := runWith([]string{"albums"}, b)
|
||||
if rc != 0 {
|
||||
t.Errorf("rc = %d", rc)
|
||||
}
|
||||
expected := "a1\tVacation\na2\tWork\n"
|
||||
if out != expected {
|
||||
t.Errorf("out = %q, want %q", out, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdAlbumsAuthDenied(t *testing.T) {
|
||||
b := &mockBridge{accessErr: fmt.Errorf("denied")}
|
||||
_, stderr, rc := runWith([]string{"albums"}, b)
|
||||
if rc != 1 {
|
||||
t.Errorf("rc = %d, want 1", rc)
|
||||
}
|
||||
if !strings.Contains(stderr, "denied") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdAlbumsBridgeError(t *testing.T) {
|
||||
b := &mockBridge{albumsErr: fmt.Errorf("boom")}
|
||||
_, stderr, rc := runWith([]string{"albums"}, b)
|
||||
if rc != 1 {
|
||||
t.Errorf("rc = %d, want 1", rc)
|
||||
}
|
||||
if !strings.Contains(stderr, "boom") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdAlbumsEmpty(t *testing.T) {
|
||||
b := &mockBridge{albums: []photos.Album{}}
|
||||
out, _, rc := runWith([]string{"albums"}, b)
|
||||
if rc != 0 {
|
||||
t.Errorf("rc = %d", rc)
|
||||
}
|
||||
if out != "" {
|
||||
t.Errorf("out = %q, want empty", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdPhotosMissingAlbumID(t *testing.T) {
|
||||
_, stderr, rc := runWith([]string{"photos"}, &mockBridge{})
|
||||
if rc != 1 {
|
||||
t.Errorf("rc = %d, want 1", rc)
|
||||
}
|
||||
if !strings.Contains(stderr, "--album-id is required") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdPhotosSuccess(t *testing.T) {
|
||||
b := &mockBridge{
|
||||
assets: []photos.Asset{{ID: "as1", Filename: "IMG_0001.JPG"}, {ID: "as2", Filename: "IMG_0002.JPG", Cloud: true}},
|
||||
}
|
||||
out, _, rc := runWith([]string{"photos", "--album-id", "x"}, b)
|
||||
if rc != 0 {
|
||||
t.Errorf("rc = %d", rc)
|
||||
}
|
||||
expected := "as1\tIMG_0001.JPG\tlocal\nas2\tIMG_0002.JPG\tcloud\n"
|
||||
if out != expected {
|
||||
t.Errorf("out = %q, want %q", out, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdPhotosAuthDenied(t *testing.T) {
|
||||
b := &mockBridge{accessErr: fmt.Errorf("nope")}
|
||||
_, stderr, rc := runWith([]string{"photos", "--album-id", "x"}, b)
|
||||
if rc != 1 {
|
||||
t.Errorf("rc = %d", rc)
|
||||
}
|
||||
if !strings.Contains(stderr, "nope") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdPhotosBridgeError(t *testing.T) {
|
||||
b := &mockBridge{assetsErr: fmt.Errorf("fail")}
|
||||
_, stderr, rc := runWith([]string{"photos", "--album-id", "x"}, b)
|
||||
if rc != 1 {
|
||||
t.Errorf("rc = %d", rc)
|
||||
}
|
||||
if !strings.Contains(stderr, "fail") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdTreeSuccess(t *testing.T) {
|
||||
b := &mockBridge{tree: []photos.CollectionNode{
|
||||
{Name: "Trips", Kind: "folder", Children: []photos.CollectionNode{{Name: "Italy 2024", Kind: "folder", Children: []photos.CollectionNode{{Name: "Venice", Kind: "album"}}}}},
|
||||
{Name: "Favorites", Kind: "album"},
|
||||
}}
|
||||
out, _, rc := runWith([]string{"tree"}, b)
|
||||
if rc != 0 {
|
||||
t.Errorf("rc = %d", rc)
|
||||
}
|
||||
expected := "Trips\n Italy 2024\n Venice\nFavorites\n"
|
||||
if out != expected {
|
||||
t.Errorf("out = %q, want %q", out, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdTreeAuthDenied(t *testing.T) {
|
||||
b := &mockBridge{accessErr: fmt.Errorf("denied")}
|
||||
_, stderr, rc := runWith([]string{"tree"}, b)
|
||||
if rc != 1 {
|
||||
t.Errorf("rc = %d, want 1", rc)
|
||||
}
|
||||
if !strings.Contains(stderr, "denied") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdTreeBridgeError(t *testing.T) {
|
||||
b := &mockBridge{treeErr: fmt.Errorf("boom")}
|
||||
_, stderr, rc := runWith([]string{"tree"}, b)
|
||||
if rc != 1 {
|
||||
t.Errorf("rc = %d, want 1", rc)
|
||||
}
|
||||
if !strings.Contains(stderr, "boom") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdBackupAllPreviewSuccess(t *testing.T) {
|
||||
b := &mockBridge{
|
||||
tree: []photos.CollectionNode{{Name: "Trips", Kind: "folder", Children: []photos.CollectionNode{{ID: "a1", Name: "Venice", Kind: "album"}}}, {ID: "a2", Name: "Favorites", Kind: "album"}},
|
||||
assetsByAlbum: map[string][]photos.Asset{
|
||||
"a1": {{ID: "as1", Filename: "img1.jpg"}},
|
||||
"a2": {{ID: "as2", Filename: "img2.jpg"}},
|
||||
},
|
||||
}
|
||||
_, stderr, rc := runWith([]string{"backup-all", "--out", "/backup", "--size", "2048"}, b)
|
||||
if rc != 0 {
|
||||
t.Fatalf("rc = %d, stderr = %q", rc, stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "exported 2 preview files across 2 albums to /backup") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdBackupAllOriginalsSuccess(t *testing.T) {
|
||||
b := &mockBridge{
|
||||
tree: []photos.CollectionNode{{Name: "Trips", Kind: "folder", Children: []photos.CollectionNode{{ID: "a1", Name: "Venice", Kind: "album"}}}},
|
||||
assetsByAlbum: map[string][]photos.Asset{
|
||||
"a1": {{ID: "as1", Filename: "img.jpg"}, {ID: "as2", Filename: "img2.jpg"}, {ID: "as3", Filename: "img3.jpg"}},
|
||||
},
|
||||
}
|
||||
_, stderr, rc := runWith([]string{"backup-all", "--out", "/backup", "--originals", "--size", "bad"}, b)
|
||||
if rc != 0 {
|
||||
t.Fatalf("rc = %d, stderr = %q", rc, stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "exported 3 original files across 1 albums to /backup") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdBackupAllMissingOutDir(t *testing.T) {
|
||||
_, stderr, rc := runWith([]string{"backup-all"}, &mockBridge{})
|
||||
if rc != 1 {
|
||||
t.Fatalf("rc = %d", rc)
|
||||
}
|
||||
if !strings.Contains(stderr, "--out is required") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdBackupAllInvalidSize(t *testing.T) {
|
||||
b := &mockBridge{tree: []photos.CollectionNode{}}
|
||||
_, stderr, rc := runWith([]string{"backup-all", "--out", "/backup", "--size", "bad"}, b)
|
||||
if rc != 1 {
|
||||
t.Fatalf("rc = %d", rc)
|
||||
}
|
||||
if !strings.Contains(stderr, "--size must be a positive integer") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdBackupAllTreeError(t *testing.T) {
|
||||
b := &mockBridge{treeErr: fmt.Errorf("tree fail")}
|
||||
_, stderr, rc := runWith([]string{"backup-all", "--out", "/backup"}, b)
|
||||
if rc != 1 {
|
||||
t.Fatalf("rc = %d", rc)
|
||||
}
|
||||
if !strings.Contains(stderr, "tree fail") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdBackupAllExportError(t *testing.T) {
|
||||
b := &mockBridge{
|
||||
tree: []photos.CollectionNode{{ID: "a1", Name: "Venice", Kind: "album"}},
|
||||
assetsByAlbum: map[string][]photos.Asset{
|
||||
"a1": {{ID: "as1", Filename: "img.jpg"}},
|
||||
},
|
||||
exportPreviewFn2: func(string, string, int, int) (photos.ExportResult, error) {
|
||||
return photos.ExportResult{}, fmt.Errorf("disk full")
|
||||
},
|
||||
}
|
||||
_, stderr, rc := runWith([]string{"backup-all", "--out", "/backup"}, b)
|
||||
if rc != 0 {
|
||||
t.Fatalf("rc = %d, stderr = %q", rc, stderr)
|
||||
}
|
||||
_ = stderr
|
||||
}
|
||||
|
||||
func TestCmdExportMissingAlbumID(t *testing.T) {
|
||||
_, stderr, rc := runWith([]string{"export", "--out", "/tmp"}, &mockBridge{})
|
||||
if rc != 1 {
|
||||
t.Errorf("rc = %d", rc)
|
||||
}
|
||||
if !strings.Contains(stderr, "--album-id is required") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExportMissingOutDir(t *testing.T) {
|
||||
_, stderr, rc := runWith([]string{"export", "--album-id", "x"}, &mockBridge{})
|
||||
if rc != 1 {
|
||||
t.Errorf("rc = %d", rc)
|
||||
}
|
||||
if !strings.Contains(stderr, "--out is required") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExportInvalidSize(t *testing.T) {
|
||||
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--size", "abc"}, &mockBridge{})
|
||||
if rc != 1 {
|
||||
t.Errorf("rc = %d", rc)
|
||||
}
|
||||
if !strings.Contains(stderr, "--size must be a positive integer") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExportNegativeSize(t *testing.T) {
|
||||
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp", "--size", "-5"}, &mockBridge{})
|
||||
if rc != 1 {
|
||||
t.Errorf("rc = %d", rc)
|
||||
}
|
||||
if !strings.Contains(stderr, "--size must be a positive integer") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExportSuccess(t *testing.T) {
|
||||
b := &mockBridge{
|
||||
assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}},
|
||||
}
|
||||
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp/out"}, b)
|
||||
if rc != 0 {
|
||||
t.Errorf("rc = %d, stderr = %q", rc, stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "exported 1 photos to /tmp/out") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExportDefaultSize(t *testing.T) {
|
||||
b := &mockBridge{
|
||||
assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}},
|
||||
}
|
||||
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp"}, b)
|
||||
if rc != 0 {
|
||||
t.Errorf("rc = %d", rc)
|
||||
}
|
||||
if !strings.Contains(stderr, "exported 1") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExportAuthDenied(t *testing.T) {
|
||||
b := &mockBridge{accessErr: fmt.Errorf("no")}
|
||||
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp"}, b)
|
||||
if rc != 1 {
|
||||
t.Errorf("rc = %d", rc)
|
||||
}
|
||||
if !strings.Contains(stderr, "no") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExportBridgeError(t *testing.T) {
|
||||
b := &mockBridge{
|
||||
assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}},
|
||||
exportPreviewFn2: func(string, string, int, int) (photos.ExportResult, error) {
|
||||
return photos.ExportResult{}, fmt.Errorf("disk full")
|
||||
},
|
||||
}
|
||||
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp"}, b)
|
||||
if rc != 1 {
|
||||
t.Errorf("rc = %d, stderr = %q", rc, stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "all exports failed") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExportOriginalsSuccess(t *testing.T) {
|
||||
b := &mockBridge{
|
||||
assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}},
|
||||
}
|
||||
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp/out", "--originals"}, b)
|
||||
if rc != 0 {
|
||||
t.Errorf("rc = %d, stderr = %q", rc, stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "exported 1 original files to /tmp/out") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExportOriginalsIgnoresSizeValidation(t *testing.T) {
|
||||
b := &mockBridge{
|
||||
assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}},
|
||||
}
|
||||
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp/out", "--originals", "--size", "abc"}, b)
|
||||
if rc != 0 {
|
||||
t.Errorf("rc = %d, stderr = %q", rc, stderr)
|
||||
}
|
||||
if strings.Contains(stderr, "--size must be a positive integer") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExportOriginalsBridgeError(t *testing.T) {
|
||||
b := &mockBridge{
|
||||
assets: []photos.Asset{{ID: "a1", Filename: "img.jpg"}},
|
||||
exportOrigFn2: func(string, string, int) (photos.ExportResult, error) {
|
||||
return photos.ExportResult{}, fmt.Errorf("copy failed")
|
||||
},
|
||||
}
|
||||
_, stderr, rc := runWith([]string{"export", "--album-id", "x", "--out", "/tmp/out", "--originals"}, b)
|
||||
if rc != 1 {
|
||||
t.Errorf("rc = %d, stderr = %q", rc, stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "all exports failed") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagVal(t *testing.T) {
|
||||
args := []string{"--album-id", "abc", "--out", "/tmp"}
|
||||
if v := flagVal(args, "--album-id"); v != "abc" {
|
||||
t.Errorf("got %q", v)
|
||||
}
|
||||
if v := flagVal(args, "--out"); v != "/tmp" {
|
||||
t.Errorf("got %q", v)
|
||||
}
|
||||
if v := flagVal(args, "--missing"); v != "" {
|
||||
t.Errorf("got %q, want empty", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagValTrailing(t *testing.T) {
|
||||
args := []string{"--album-id"}
|
||||
if v := flagVal(args, "--album-id"); v != "" {
|
||||
t.Errorf("got %q, want empty for trailing flag", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagValWithDefault(t *testing.T) {
|
||||
args := []string{"--size", "2048"}
|
||||
if v := flagValWithDefault(args, "--size", "1024"); v != "2048" {
|
||||
t.Errorf("got %q", v)
|
||||
}
|
||||
if v := flagValWithDefault(args, "--missing", "fallback"); v != "fallback" {
|
||||
t.Errorf("got %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasFlag(t *testing.T) {
|
||||
args := []string{"export", "--originals", "--album-id", "x"}
|
||||
if !hasFlag(args, "--originals") {
|
||||
t.Fatal("expected --originals to be found")
|
||||
}
|
||||
if hasFlag(args, "--missing") {
|
||||
t.Fatal("did not expect missing flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagValEmptyArgs(t *testing.T) {
|
||||
if v := flagVal(nil, "--album-id"); v != "" {
|
||||
t.Errorf("got %q", v)
|
||||
}
|
||||
if v := flagValWithDefault(nil, "--size", "1024"); v != "1024" {
|
||||
t.Errorf("got %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAlbumIDDirectMatch(t *testing.T) {
|
||||
b := &mockBridge{
|
||||
assetsByAlbum: map[string][]photos.Asset{
|
||||
"ABC/L0/001": {{ID: "a1", Filename: "img.jpg"}},
|
||||
},
|
||||
}
|
||||
id, err := resolveAlbumID(b, "ABC/L0/001")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != "ABC/L0/001" {
|
||||
t.Errorf("got %q", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAlbumIDByName(t *testing.T) {
|
||||
b := &mockBridge{
|
||||
albums: []photos.Album{
|
||||
{ID: "ABC/L0/001", Title: "DnD"},
|
||||
{ID: "DEF/L0/001", Title: "Work"},
|
||||
},
|
||||
assetsByAlbum: map[string][]photos.Asset{
|
||||
"ABC/L0/001": {{ID: "a1", Filename: "img.jpg"}},
|
||||
},
|
||||
}
|
||||
id, err := resolveAlbumID(b, "DnD")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != "ABC/L0/001" {
|
||||
t.Errorf("got %q, want ABC/L0/001", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAlbumIDNotFound(t *testing.T) {
|
||||
b := &mockBridge{
|
||||
albums: []photos.Album{
|
||||
{ID: "ABC/L0/001", Title: "DnD"},
|
||||
},
|
||||
assetsByAlbum: map[string][]photos.Asset{},
|
||||
}
|
||||
_, err := resolveAlbumID(b, "Nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAlbumIDListAlbumsFails(t *testing.T) {
|
||||
b := &mockBridge{
|
||||
albumsErr: fmt.Errorf("no access"),
|
||||
assetsByAlbum: map[string][]photos.Asset{},
|
||||
}
|
||||
_, err := resolveAlbumID(b, "DnD")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdPhotosResolvesAlbumName(t *testing.T) {
|
||||
b := &mockBridge{
|
||||
albums: []photos.Album{
|
||||
{ID: "ABC/L0/001", Title: "DnD"},
|
||||
},
|
||||
assetsByAlbum: map[string][]photos.Asset{
|
||||
"ABC/L0/001": {{ID: "a1", Filename: "img.jpg"}},
|
||||
},
|
||||
}
|
||||
out, stderr, rc := runWith([]string{"photos", "--album-id", "DnD"}, b)
|
||||
if rc != 0 {
|
||||
t.Fatalf("rc = %d, stderr = %q", rc, stderr)
|
||||
}
|
||||
if !strings.Contains(out, "a1\timg.jpg\tlocal") {
|
||||
t.Errorf("out = %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExportResolvesAlbumName(t *testing.T) {
|
||||
b := &mockBridge{
|
||||
albums: []photos.Album{
|
||||
{ID: "ABC/L0/001", Title: "DnD"},
|
||||
},
|
||||
assetsByAlbum: map[string][]photos.Asset{
|
||||
"ABC/L0/001": {{ID: "a1", Filename: "img.jpg"}, {ID: "a2", Filename: "img2.jpg"}, {ID: "a3", Filename: "img3.jpg"}, {ID: "a4", Filename: "img4.jpg"}, {ID: "a5", Filename: "img5.jpg"}},
|
||||
},
|
||||
}
|
||||
_, stderr, rc := runWith([]string{"export", "--album-id", "DnD", "--out", "/tmp/out"}, b)
|
||||
if rc != 0 {
|
||||
t.Fatalf("rc = %d, stderr = %q", rc, stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "exported 5 photos") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdExportOriginalsResolvesAlbumName(t *testing.T) {
|
||||
b := &mockBridge{
|
||||
albums: []photos.Album{
|
||||
{ID: "ABC/L0/001", Title: "DnD"},
|
||||
},
|
||||
assetsByAlbum: map[string][]photos.Asset{
|
||||
"ABC/L0/001": {{ID: "a1", Filename: "img.jpg"}, {ID: "a2", Filename: "img2.jpg"}, {ID: "a3", Filename: "img3.jpg"}},
|
||||
},
|
||||
}
|
||||
_, stderr, rc := runWith([]string{"export", "--album-id", "DnD", "--out", "/tmp/out", "--originals"}, b)
|
||||
if rc != 0 {
|
||||
t.Fatalf("rc = %d, stderr = %q", rc, stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "exported 3 original files") {
|
||||
t.Errorf("stderr = %q", stderr)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user