631 lines
17 KiB
Go
631 lines
17 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"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 "version", "--version", "-v":
|
|
fmt.Fprintln(stdout, version)
|
|
return 0
|
|
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] [--include-videos]
|
|
photoscli export --album-id <id> --out <dir> [--size <px>] [--originals] [--include-videos]
|
|
photoscli version
|
|
|
|
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
|
|
version Print version
|
|
|
|
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
|
|
--include-videos Include video assets (videos are skipped by default)`)
|
|
}
|
|
|
|
func mustAuth(stderr io.Writer, bridge photos.Bridge) int {
|
|
fmt.Fprintln(stderr, "requesting photo library access...")
|
|
if err := bridge.RequestAccess(); err != nil {
|
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
|
return 1
|
|
}
|
|
fmt.Fprintln(stderr, "access granted")
|
|
return 0
|
|
}
|
|
|
|
func cmdAlbums(stdout, stderr io.Writer, bridge photos.Bridge) int {
|
|
if rc := mustAuth(stderr, bridge); rc != 0 {
|
|
return rc
|
|
}
|
|
fmt.Fprintln(stderr, "loading albums...")
|
|
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) {
|
|
albums, listErr := bridge.ListAlbums()
|
|
if listErr != nil {
|
|
return idOrName, listErr
|
|
}
|
|
for _, a := range albums {
|
|
if a.Title == idOrName {
|
|
return a.ID, nil
|
|
}
|
|
}
|
|
_, _, 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 {
|
|
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 {
|
|
fmt.Fprintf(stdout, "%s\t%s\t%s\t%s\t%dx%d", a.ID, a.Filename, a.Cloud, a.MediaType, a.PixelWidth, a.PixelHeight)
|
|
if a.CreationDate != nil {
|
|
fmt.Fprintf(stdout, "\t%s", *a.CreationDate)
|
|
}
|
|
if a.Duration > 0 {
|
|
fmt.Fprintf(stdout, "\t%.1fs", a.Duration)
|
|
}
|
|
if a.IsFavorite {
|
|
fmt.Fprintf(stdout, "\t*")
|
|
}
|
|
fmt.Fprintln(stdout)
|
|
}
|
|
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")
|
|
skipVideos := !hasFlag(args, "--include-videos")
|
|
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
|
|
}
|
|
}
|
|
|
|
fmt.Fprintf(stderr, "loading assets for album %s...\n", albumID)
|
|
assets, total, err := bridge.ListAssets(resolved)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
|
return 1
|
|
}
|
|
|
|
if skipVideos {
|
|
assets, total = filterVideos(assets)
|
|
}
|
|
|
|
fmt.Fprintf(stderr, "exporting %d assets (%s) to %s...\n", total, exportMode(originals), outDir)
|
|
exported, failed := exportAssets(assets, outDir, size, originals, total, stderr, bridge, "")
|
|
|
|
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", exported, outDir)
|
|
} else {
|
|
fmt.Fprintf(stderr, "\nexported %d photos to %s", exported, outDir)
|
|
}
|
|
if failed > 0 {
|
|
fmt.Fprintf(stderr, " (%d failed)", failed)
|
|
}
|
|
fmt.Fprintln(stderr)
|
|
return 0
|
|
}
|
|
|
|
func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
|
|
outDir := flagVal(args, "--out")
|
|
originals := hasFlag(args, "--originals")
|
|
skipVideos := !hasFlag(args, "--include-videos")
|
|
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
|
|
}
|
|
}
|
|
|
|
fmt.Fprintln(stderr, "loading photo library tree...")
|
|
nodes, err := bridge.ListTree()
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "error: %v\n", err)
|
|
return 1
|
|
}
|
|
albumCount := countAlbums(nodes)
|
|
fmt.Fprintf(stderr, "found %d albums, building index...\n", albumCount)
|
|
|
|
totalAssets, failed, err := backupTree(nodes, outDir, size, originals, skipVideos, 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", totalAssets, albumCount, outDir)
|
|
} else {
|
|
fmt.Fprintf(stderr, "\nexported %d preview files across %d albums to %s", totalAssets, albumCount, outDir)
|
|
}
|
|
if failed > 0 {
|
|
fmt.Fprintf(stderr, " (%d failed)", failed)
|
|
}
|
|
fmt.Fprintln(stderr)
|
|
return 0
|
|
}
|
|
|
|
type pendingAsset struct {
|
|
asset photos.Asset
|
|
path string
|
|
album string
|
|
}
|
|
|
|
func collectPendingAssets(nodes []photos.CollectionNode, outDir string, bridge photos.Bridge, skipVideos bool, originals bool, stderr io.Writer) ([]pendingAsset, int) {
|
|
var items []pendingAsset
|
|
var skipped int
|
|
collectNodes(nodes, outDir, bridge, skipVideos, originals, &items, &skipped, stderr)
|
|
return items, skipped
|
|
}
|
|
|
|
func collectNodes(nodes []photos.CollectionNode, outDir string, bridge photos.Bridge, skipVideos bool, originals bool, items *[]pendingAsset, skipped *int, stderr io.Writer) {
|
|
for _, node := range nodes {
|
|
if bridge.IsCancelled() {
|
|
return
|
|
}
|
|
path := outDir + "/" + sanitizePathComponent(node.Name)
|
|
if node.Kind == "folder" {
|
|
collectNodes(node.Children, path, bridge, skipVideos, originals, items, skipped, stderr)
|
|
continue
|
|
}
|
|
if node.Kind == "album" && node.ID != "" {
|
|
assets, _, err := bridge.ListAssets(node.ID)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, " \u26a0 album %s: %v\n", node.Name, err)
|
|
continue
|
|
}
|
|
if skipVideos {
|
|
assets, _ = filterVideos(assets)
|
|
}
|
|
for _, a := range assets {
|
|
if fileExistsOnDisk(a, path, originals, len(*items)+*skipped) {
|
|
*skipped++
|
|
continue
|
|
}
|
|
*items = append(*items, pendingAsset{asset: a, path: path, album: node.Name})
|
|
}
|
|
fmt.Fprintf(stderr, " indexing: %d files (%d skipped)\r", len(*items), *skipped)
|
|
}
|
|
}
|
|
}
|
|
|
|
func backupTree(nodes []photos.CollectionNode, outDir string, targetSize int, originals bool, skipVideos bool, stderr io.Writer, bridge photos.Bridge) (int, int, error) {
|
|
pending, skipped := collectPendingAssets(nodes, outDir, bridge, skipVideos, originals, stderr)
|
|
if bridge.IsCancelled() {
|
|
return 0, 0, nil
|
|
}
|
|
total := len(pending)
|
|
fmt.Fprintf(stderr, " indexed %d files (%d skipped), exporting to %s...\n", total, skipped, outDir)
|
|
bar := newProgressBar(stderr, 3)
|
|
exported, failed := exportPending(pending, targetSize, originals, total, bar, bridge)
|
|
bar.clear()
|
|
return exported, failed, nil
|
|
}
|
|
|
|
func exportPending(pending []pendingAsset, targetSize int, originals bool, total int, bar *progressBar, bridge photos.Bridge) (int, int) {
|
|
if len(pending) < 4 {
|
|
return exportPendingSerial(pending, targetSize, originals, total, bar, bridge)
|
|
}
|
|
return exportPendingParallel(pending, targetSize, originals, total, bar, bridge, 3)
|
|
}
|
|
|
|
func exportPendingSerial(pending []pendingAsset, targetSize int, originals bool, total int, bar *progressBar, bridge photos.Bridge) (int, int) {
|
|
done := 0
|
|
failed := 0
|
|
var totalBytes int64
|
|
var totalDur time.Duration
|
|
|
|
for i, pa := range pending {
|
|
if bridge.IsCancelled() {
|
|
break
|
|
}
|
|
bar.setWorker(0, pa.asset.Filename, 0, pa.asset.Cloud, "exporting")
|
|
bar.setTotal(done+failed, total, "")
|
|
bar.setAlbum(pa.album, 0, 0)
|
|
bar.draw()
|
|
start := time.Now()
|
|
result, exportErr := exportOne(bridge, pa.asset, pa.path, targetSize, originals, i)
|
|
dur := time.Since(start)
|
|
isErr := exportErr != nil
|
|
isSkipped := result.Skipped
|
|
if !isErr && !isSkipped {
|
|
totalBytes += result.Size
|
|
totalDur += dur
|
|
}
|
|
if isErr {
|
|
failed++
|
|
} else {
|
|
done++
|
|
}
|
|
avgSpeed := float64(0)
|
|
if totalDur > 0 {
|
|
avgSpeed = float64(totalBytes) / totalDur.Seconds()
|
|
}
|
|
bar.setTotal(done+failed, total, "")
|
|
bar.setAlbum(pa.album, 0, 0)
|
|
bar.setWorker(0, "", 0, "", "")
|
|
var logLine string
|
|
if isErr {
|
|
logLine = fmt.Sprintf("\u274c %s: %v", pa.asset.Filename, exportErr)
|
|
} else if isSkipped {
|
|
logLine = fmt.Sprintf("\u23ed %s", result.Filename)
|
|
} else if result.Cloud == "cloud" {
|
|
logLine = fmt.Sprintf("\u2601 %s - %s - downloaded %s", result.Filename, formatSize(result.Size), formatSpeed(avgSpeed))
|
|
} else {
|
|
logLine = fmt.Sprintf("\u2705 %s - %s - copied", result.Filename, formatSize(result.Size))
|
|
}
|
|
bar.logCompleted(logLine)
|
|
}
|
|
return done, failed
|
|
}
|
|
|
|
func exportPendingParallel(pending []pendingAsset, targetSize int, originals bool, total int, bar *progressBar, bridge photos.Bridge, workers int) (int, int) {
|
|
type resultEntry struct {
|
|
result photos.ExportResult
|
|
err error
|
|
pa pendingAsset
|
|
dur time.Duration
|
|
}
|
|
|
|
completed := make(chan resultEntry, len(pending))
|
|
|
|
jobs := make(chan int, len(pending))
|
|
var wg sync.WaitGroup
|
|
for w := 0; w < workers; w++ {
|
|
wg.Add(1)
|
|
go func(workerID int) {
|
|
defer wg.Done()
|
|
for i := range jobs {
|
|
if bridge.IsCancelled() {
|
|
completed <- resultEntry{err: fmt.Errorf("cancelled"), pa: pending[i]}
|
|
continue
|
|
}
|
|
bar.setWorker(workerID, pending[i].asset.Filename, 0, pending[i].asset.Cloud, "exporting")
|
|
start := time.Now()
|
|
var result photos.ExportResult
|
|
var exportErr error
|
|
if originals {
|
|
result, exportErr = bridge.ExportOriginalWithSlot(pending[i].asset.ID, pending[i].path, i, workerID)
|
|
} else {
|
|
result, exportErr = bridge.ExportPreviewWithSlot(pending[i].asset.ID, pending[i].path, targetSize, i, workerID)
|
|
}
|
|
dur := time.Since(start)
|
|
bar.setWorker(workerID, "", 0, "", "")
|
|
completed <- resultEntry{result: result, err: exportErr, pa: pending[i], dur: dur}
|
|
}
|
|
}(w)
|
|
}
|
|
|
|
go func() {
|
|
for i := range pending {
|
|
if bridge.IsCancelled() {
|
|
break
|
|
}
|
|
jobs <- i
|
|
}
|
|
close(jobs)
|
|
}()
|
|
|
|
slots := photos.GetProgressSlots()
|
|
pollDone := make(chan struct{})
|
|
go func() {
|
|
ticker := time.NewTicker(100 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
if bridge.IsCancelled() {
|
|
return
|
|
}
|
|
slots = photos.GetProgressSlots()
|
|
for i := 0; i < workers && i < len(slots); i++ {
|
|
bar.updateWorkerProgress(i, slots[i].Progress, slots[i].BytesDone, slots[i].BytesTotal)
|
|
}
|
|
bar.draw()
|
|
case <-pollDone:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
done := 0
|
|
failed := 0
|
|
var totalBytes int64
|
|
var totalDur time.Duration
|
|
for n := 0; n < len(pending); n++ {
|
|
var entry resultEntry
|
|
select {
|
|
case entry = <-completed:
|
|
case <-time.After(2 * time.Second):
|
|
if bridge.IsCancelled() {
|
|
close(pollDone)
|
|
wg.Wait()
|
|
return done, failed
|
|
}
|
|
continue
|
|
}
|
|
if bridge.IsCancelled() {
|
|
close(pollDone)
|
|
wg.Wait()
|
|
return done, failed
|
|
}
|
|
isErr := entry.err != nil
|
|
isSkipped := entry.result.Skipped
|
|
if !isErr && !isSkipped {
|
|
totalBytes += entry.result.Size
|
|
totalDur += entry.dur
|
|
}
|
|
if isErr {
|
|
failed++
|
|
} else {
|
|
done++
|
|
}
|
|
avgSpeed := float64(0)
|
|
if totalDur > 0 {
|
|
avgSpeed = float64(totalBytes) / totalDur.Seconds()
|
|
}
|
|
bar.setTotal(done+failed, total, "")
|
|
bar.setAlbum(entry.pa.album, 0, 0)
|
|
|
|
var logLine string
|
|
if isErr {
|
|
logLine = fmt.Sprintf("\u274c %s: %v", entry.pa.asset.Filename, entry.err)
|
|
} else if isSkipped {
|
|
logLine = fmt.Sprintf("\u23ed %s", entry.result.Filename)
|
|
} else if entry.result.Cloud == "cloud" {
|
|
logLine = fmt.Sprintf("\u2601 %s - %s - downloaded %s", entry.result.Filename, formatSize(entry.result.Size), formatSpeed(avgSpeed))
|
|
} else {
|
|
logLine = fmt.Sprintf("\u2705 %s - %s - copied", entry.result.Filename, formatSize(entry.result.Size))
|
|
}
|
|
bar.logCompleted(logLine)
|
|
}
|
|
close(pollDone)
|
|
wg.Wait()
|
|
photos.ResetProgressSlots()
|
|
return done, failed
|
|
}
|
|
|
|
func exportAssets(assets []photos.Asset, outDir string, targetSize int, originals bool, total int, stderr io.Writer, bridge photos.Bridge, dirPrefix string) (int, int) {
|
|
pending := make([]pendingAsset, len(assets))
|
|
for i, a := range assets {
|
|
pending[i] = pendingAsset{asset: a, path: outDir, album: dirPrefix}
|
|
}
|
|
bar := newProgressBar(stderr, 1)
|
|
exported, failed := exportPending(pending, targetSize, originals, len(pending), bar, bridge)
|
|
bar.clear()
|
|
return exported, failed
|
|
}
|
|
|
|
func fileExistsOnDisk(asset photos.Asset, outDir string, originals bool, index int) bool {
|
|
var candidates []string
|
|
if originals {
|
|
if asset.Filename != "" {
|
|
candidates = append(candidates, filepath.Join(outDir, asset.Filename))
|
|
base := strings.TrimSuffix(asset.Filename, filepath.Ext(asset.Filename))
|
|
ext := filepath.Ext(asset.Filename)
|
|
candidates = append(candidates, filepath.Join(outDir, fmt.Sprintf("%04d_%s%s", index, base, ext)))
|
|
}
|
|
} else {
|
|
safeID := sanitizePathComponent(asset.ID)
|
|
candidates = append(candidates, filepath.Join(outDir, fmt.Sprintf("%04d_%s.jpg", index, safeID)))
|
|
}
|
|
for _, p := range candidates {
|
|
info, err := os.Stat(p)
|
|
if err == nil && info.Size() > 0 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func filterVideos(assets []photos.Asset) ([]photos.Asset, int) {
|
|
filtered := make([]photos.Asset, 0, len(assets))
|
|
for _, a := range assets {
|
|
if a.MediaType != "video" && a.MediaType != "audio" {
|
|
filtered = append(filtered, a)
|
|
}
|
|
}
|
|
return filtered, len(filtered)
|
|
}
|
|
|
|
// Must stay in sync with sanitized_path_component in bridge/photokit_bridge.m
|
|
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 exportMode(originals bool) string {
|
|
if originals {
|
|
return "originals"
|
|
}
|
|
return "previews"
|
|
}
|