v0.2.0: semaphore timeouts, error logging, dead code removal, parallel exports

Critical:
- Replace DISPATCH_TIME_FOREVER with 120s/30s timeouts in ObjC
- Log failed asset IDs and error messages in cmdExport/backupTree
- Show failed count in export summaries

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

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

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

Quality:
- Add go vet and -race flag to Makefile test target
- Add ADR for performSelector cloudIdentifier decision
- Add sync comments between Go/ObjC sanitizePathComponent
- Add package-level doc comment
- Add tests: partial failure, skipped album, album-not-found message
This commit is contained in:
Ein Anderssono
2026-06-11 21:12:47 +02:00
parent b460c68641
commit 85eaa3ea37
14 changed files with 274 additions and 651 deletions
+127 -54
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"io"
"strings"
"sync"
"gitea.k3s.k0.nu/tools/photocli/internal/photos"
)
@@ -89,20 +90,20 @@ func cmdAlbums(stdout, stderr io.Writer, bridge photos.Bridge) int {
}
func resolveAlbumID(bridge photos.Bridge, idOrName string) (string, error) {
_, _, err := bridge.ListAssets(idOrName)
if err == nil {
return idOrName, nil
}
albums, listErr := bridge.ListAlbums()
if listErr != nil {
return idOrName, err
return idOrName, listErr
}
for _, a := range albums {
if a.Title == idOrName {
return a.ID, nil
}
}
return idOrName, err
_, _, err := bridge.ListAssets(idOrName)
if err == nil {
return idOrName, nil
}
return idOrName, fmt.Errorf("album not found: %s", idOrName)
}
func cmdPhotos(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
@@ -125,11 +126,7 @@ func cmdPhotos(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
return 1
}
for _, a := range assets {
cloud := "local"
if a.Cloud {
cloud = "cloud"
}
fmt.Fprintf(stdout, "%s\t%s\t%s\n", a.ID, a.Filename, cloud)
fmt.Fprintf(stdout, "%s\t%s\t%s\n", a.ID, a.Filename, a.Cloud)
}
return 0
}
@@ -188,25 +185,7 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
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++
}
exported, failed := exportAssets(assets, outDir, size, originals, total, stderr, bridge, "")
if exported == 0 && failed > 0 {
fmt.Fprintf(stderr, "\nerror: all exports failed\n")
@@ -214,10 +193,14 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in
}
if originals {
fmt.Fprintf(stderr, "\nexported %d original files to %s\n", exported, outDir)
fmt.Fprintf(stderr, "\nexported %d original files to %s", exported, outDir)
} else {
fmt.Fprintf(stderr, "\nexported %d photos to %s\n", exported, outDir)
fmt.Fprintf(stderr, "\nexported %d photos to %s", exported, outDir)
}
if failed > 0 {
fmt.Fprintf(stderr, " (%d failed)", failed)
}
fmt.Fprintln(stderr)
return 0
}
@@ -250,60 +233,150 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge)
}
albumCount := countAlbums(nodes)
totalAssets, grandTotal, err := backupTree(nodes, outDir, size, originals, stderr, bridge)
totalAssets, _, failed, err := backupTree(nodes, outDir, size, originals, stderr, bridge)
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return 1
}
if originals {
fmt.Fprintf(stderr, "\nexported %d original files across %d albums to %s\n", totalAssets, albumCount, outDir)
fmt.Fprintf(stderr, "\nexported %d original files across %d albums to %s", totalAssets, albumCount, outDir)
} else {
fmt.Fprintf(stderr, "\nexported %d preview files across %d albums to %s\n", totalAssets, albumCount, outDir)
fmt.Fprintf(stderr, "\nexported %d preview files across %d albums to %s", totalAssets, albumCount, outDir)
}
_ = grandTotal
if failed > 0 {
fmt.Fprintf(stderr, " (%d failed)", failed)
}
fmt.Fprintln(stderr)
return 0
}
func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, originals bool, stderr io.Writer, bridge photos.Bridge) (int, int, error) {
func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, originals bool, stderr io.Writer, bridge photos.Bridge) (int, int, int, error) {
exported := 0
total := 0
failed := 0
for _, node := range nodes {
path := outDir + "/" + sanitizePathComponent(node.Name)
if node.Kind == "folder" {
n, t, err := backupTree(node.Children, path, targetSize, originals, stderr, bridge)
n, t, f, err := backupTree(node.Children, path, targetSize, originals, stderr, bridge)
if err != nil {
return exported, total, err
return exported, total, failed, err
}
exported += n
total += t
failed += f
continue
}
if node.Kind == "album" && node.ID != "" {
assets, assetTotal, err := bridge.ListAssets(node.ID)
if err != nil {
fmt.Fprintf(stderr, "\n skipped album %s: %v\n", node.Name, err)
continue
}
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++
}
n, f := exportAssets(assets, path, targetSize, originals, total, stderr, bridge, path+"/")
exported += n
failed += f
}
}
return exported, total, nil
return exported, total, failed, nil
}
type exportJob struct {
asset photos.Asset
index int
}
type exportResult struct {
index int
result photos.ExportResult
err error
}
func exportAssets(assets []photos.Asset, outDir string, targetSize int, originals bool, total int, stderr io.Writer, bridge photos.Bridge, dirPrefix string) (int, int) {
if len(assets) == 0 {
return 0, 0
}
if len(assets) < 4 {
return exportAssetsSerial(assets, outDir, targetSize, originals, total, stderr, bridge, dirPrefix)
}
return exportAssetsParallel(assets, outDir, targetSize, originals, total, stderr, bridge, 3, dirPrefix)
}
func exportAssetsSerial(assets []photos.Asset, outDir string, targetSize int, originals bool, total int, stderr io.Writer, bridge photos.Bridge, dirPrefix string) (int, int) {
exported := 0
failed := 0
for i, a := range assets {
result, exportErr := exportOne(bridge, a, outDir, targetSize, originals, i)
progressBar(stderr, exported+failed+1, total, dirPrefix+result.Filename, result.Size, result.Cloud)
if exportErr != nil {
fmt.Fprintf(stderr, "\n failed: %s: %v\n", a.Filename, exportErr)
failed++
continue
}
exported++
}
return exported, failed
}
func exportAssetsParallel(assets []photos.Asset, outDir string, targetSize int, originals bool, total int, stderr io.Writer, bridge photos.Bridge, workers int, dirPrefix string) (int, int) {
jobs := make(chan exportJob, len(assets))
results := make(chan exportResult, len(assets))
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
result, exportErr := exportOne(bridge, job.asset, outDir, targetSize, originals, job.index)
results <- exportResult{index: job.index, result: result, err: exportErr}
}
}()
}
go func() {
for i, a := range assets {
jobs <- exportJob{asset: a, index: i}
}
close(jobs)
}()
go func() {
wg.Wait()
close(results)
}()
ordered := make([]exportResult, len(assets))
for r := range results {
ordered[r.index] = r
}
exported := 0
failed := 0
for i, a := range assets {
r := ordered[i]
progressBar(stderr, exported+failed+1, total, dirPrefix+r.result.Filename, r.result.Size, r.result.Cloud)
if r.err != nil {
fmt.Fprintf(stderr, "\n failed: %s: %v\n", a.Filename, r.err)
failed++
continue
}
exported++
}
return exported, failed
}
func exportOne(bridge photos.Bridge, a photos.Asset, outDir string, targetSize int, originals bool, index int) (photos.ExportResult, error) {
if originals {
return bridge.ExportOriginal(a.ID, outDir, index)
}
return bridge.ExportPreview(a.ID, outDir, targetSize, index)
}
// Must stay in sync with sanitized_path_component in bridge/photokit_bridge.m
func sanitizePathComponent(name string) string {
s := strings.TrimSpace(name)
if s == "" {