summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-06 11:21:16 +0300
committerPaul Buetow <paul@buetow.org>2026-04-06 11:21:16 +0300
commit5c76593737c3b707967bf2ac2394c22630323928 (patch)
tree28d184f602b1bc33db6eb227a3836965cc94744c
parentd30fba0ac226da67a78349d322f4ad44b6f4f1a7 (diff)
feat: add CLI prompt helper for gallery video generation
Implements promptForGalleryVideos(outputDir) which lists *_gallery_*.png files, asks the user y/n, then prompts for page selection (1,3,5 or all), returning a sorted slice of chosen page numbers. Also adds the pure helper parseSelection(input, max) with full unit tests covering all branches. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--internal/cli/prompts.go199
-rw-r--r--internal/cli/prompts_test.go192
2 files changed, 391 insertions, 0 deletions
diff --git a/internal/cli/prompts.go b/internal/cli/prompts.go
new file mode 100644
index 0000000..ff17767
--- /dev/null
+++ b/internal/cli/prompts.go
@@ -0,0 +1,199 @@
+package cli
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+// promptForGalleryVideos lists all *_gallery_*.png files found in outputDir,
+// shows them to the user, and asks whether they want to generate videos.
+// If the user agrees, it asks which pages to generate (e.g. "1,3,5" or "all")
+// and returns the parsed list of page numbers.
+//
+// Returns an empty slice when the user declines or enters nothing.
+// Returns an error only on unexpected I/O or parse failures.
+func promptForGalleryVideos(outputDir string) ([]int, error) {
+ pages, pngPaths, err := findGalleryPages(outputDir)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(pages) == 0 {
+ fmt.Println("No gallery PNG files found — skipping video generation.")
+ return nil, nil
+ }
+
+ printGalleryFiles(pngPaths)
+
+ agreed, err := askYesNo("Generate videos for these gallery pages? [y/N]: ")
+ if err != nil {
+ return nil, err
+ }
+ if !agreed {
+ return nil, nil
+ }
+
+ return askPageSelection(pages)
+}
+
+// findGalleryPages globs for *_gallery_*.png files in outputDir and returns
+// the sorted list of page numbers and matching file paths.
+func findGalleryPages(outputDir string) ([]int, []string, error) {
+ pattern := filepath.Join(outputDir, "*_gallery_*.png")
+ matches, err := filepath.Glob(pattern)
+ if err != nil {
+ return nil, nil, fmt.Errorf("cli: glob gallery files in %s: %w", outputDir, err)
+ }
+
+ sort.Strings(matches)
+
+ pageSet := map[int]struct{}{}
+ for _, m := range matches {
+ n := extractPageNumber(filepath.Base(m))
+ if n > 0 {
+ pageSet[n] = struct{}{}
+ }
+ }
+
+ pages := make([]int, 0, len(pageSet))
+ for n := range pageSet {
+ pages = append(pages, n)
+ }
+ sort.Ints(pages)
+
+ return pages, matches, nil
+}
+
+// extractPageNumber parses the page number from a gallery file name of the
+// form "<slug>_gallery_<N>.png". Returns 0 when the name does not match.
+func extractPageNumber(base string) int {
+ // Strip extension
+ name := strings.TrimSuffix(base, ".png")
+ // Find the last "_gallery_" segment and extract the trailing integer.
+ const marker = "_gallery_"
+ idx := strings.LastIndex(name, marker)
+ if idx < 0 {
+ return 0
+ }
+ numStr := name[idx+len(marker):]
+ n, err := strconv.Atoi(numStr)
+ if err != nil || n <= 0 {
+ return 0
+ }
+ return n
+}
+
+// printGalleryFiles prints each gallery PNG path so the user can review what
+// will be animated before confirming.
+func printGalleryFiles(paths []string) {
+ fmt.Println("Found gallery pages:")
+ for _, p := range paths {
+ fmt.Printf(" %s\n", p)
+ }
+}
+
+// askYesNo prints prompt, reads one line from stdin, and returns true only
+// when the user types "y" or "Y". Any other input (including empty) returns
+// false, matching a safe-default "no" behaviour.
+func askYesNo(prompt string) (bool, error) {
+ fmt.Print(prompt)
+ reader := bufio.NewReader(os.Stdin)
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ return false, fmt.Errorf("cli: reading user input: %w", err)
+ }
+ answer := strings.TrimSpace(strings.ToLower(line))
+ return answer == "y", nil
+}
+
+// askPageSelection prints a prompt asking the user which pages to include
+// and parses the reply into a slice of ints. "all" expands to every available
+// page number. An empty reply is treated as "all".
+func askPageSelection(availablePages []int) ([]int, error) {
+ max := 0
+ if len(availablePages) > 0 {
+ max = availablePages[len(availablePages)-1]
+ }
+
+ fmt.Printf("Which pages? (e.g. 1,3,5 or all) [all]: ")
+ reader := bufio.NewReader(os.Stdin)
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ return nil, fmt.Errorf("cli: reading page selection: %w", err)
+ }
+
+ input := strings.TrimSpace(line)
+ if input == "" || strings.ToLower(input) == "all" {
+ return availablePages, nil
+ }
+
+ selected, err := parseSelection(input, max)
+ if err != nil {
+ return nil, err
+ }
+
+ // Filter to only pages that actually exist.
+ pageExists := make(map[int]bool, len(availablePages))
+ for _, p := range availablePages {
+ pageExists[p] = true
+ }
+
+ result := make([]int, 0, len(selected))
+ for _, p := range selected {
+ if pageExists[p] {
+ result = append(result, p)
+ } else {
+ fmt.Printf(" Warning: page %d not found — skipping.\n", p)
+ }
+ }
+
+ return result, nil
+}
+
+// parseSelection converts a comma-separated string of page numbers (e.g. "1,3,5")
+// or the keyword "all" into a sorted, deduplicated slice of ints.
+//
+// max is used only when input is "all"; individual page numbers may exceed max
+// without error (the caller is responsible for validating against real files).
+// Returns an error for non-numeric tokens or numbers <= 0.
+func parseSelection(input string, max int) ([]int, error) {
+ input = strings.TrimSpace(input)
+ if strings.ToLower(input) == "all" {
+ pages := make([]int, max)
+ for i := range pages {
+ pages[i] = i + 1
+ }
+ return pages, nil
+ }
+
+ seen := map[int]struct{}{}
+ tokens := strings.Split(input, ",")
+
+ for _, tok := range tokens {
+ tok = strings.TrimSpace(tok)
+ if tok == "" {
+ continue
+ }
+ n, err := strconv.Atoi(tok)
+ if err != nil {
+ return nil, fmt.Errorf("cli: invalid page number %q: %w", tok, err)
+ }
+ if n <= 0 {
+ return nil, fmt.Errorf("cli: page numbers must be positive, got %d", n)
+ }
+ seen[n] = struct{}{}
+ }
+
+ result := make([]int, 0, len(seen))
+ for n := range seen {
+ result = append(result, n)
+ }
+ sort.Ints(result)
+
+ return result, nil
+}
diff --git a/internal/cli/prompts_test.go b/internal/cli/prompts_test.go
new file mode 100644
index 0000000..f50880d
--- /dev/null
+++ b/internal/cli/prompts_test.go
@@ -0,0 +1,192 @@
+package cli
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+)
+
+// ---------------------------------------------------------------------------
+// parseSelection
+// ---------------------------------------------------------------------------
+
+func TestParseSelection_All(t *testing.T) {
+ got, err := parseSelection("all", 4)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ want := []int{1, 2, 3, 4}
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("parseSelection(\"all\", 4) = %v, want %v", got, want)
+ }
+}
+
+func TestParseSelection_AllCaseInsensitive(t *testing.T) {
+ got, err := parseSelection("ALL", 3)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ want := []int{1, 2, 3}
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("parseSelection(\"ALL\", 3) = %v, want %v", got, want)
+ }
+}
+
+func TestParseSelection_CommaSeparated(t *testing.T) {
+ got, err := parseSelection("1,3,5", 10)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ want := []int{1, 3, 5}
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("parseSelection(\"1,3,5\", 10) = %v, want %v", got, want)
+ }
+}
+
+func TestParseSelection_SortsOutput(t *testing.T) {
+ // Input order is reversed; output must be sorted.
+ got, err := parseSelection("5,2,1", 10)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ want := []int{1, 2, 5}
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("parseSelection(\"5,2,1\", 10) = %v, want %v", got, want)
+ }
+}
+
+func TestParseSelection_DuplicatesDeduped(t *testing.T) {
+ got, err := parseSelection("2,2,3", 5)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ want := []int{2, 3}
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("parseSelection(\"2,2,3\", 5) = %v, want %v", got, want)
+ }
+}
+
+func TestParseSelection_SinglePage(t *testing.T) {
+ got, err := parseSelection("7", 10)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ want := []int{7}
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("parseSelection(\"7\", 10) = %v, want %v", got, want)
+ }
+}
+
+func TestParseSelection_WhitespaceTrimmed(t *testing.T) {
+ got, err := parseSelection(" 1 , 3 , 5 ", 10)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ want := []int{1, 3, 5}
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("parseSelection with spaces = %v, want %v", got, want)
+ }
+}
+
+func TestParseSelection_InvalidToken(t *testing.T) {
+ _, err := parseSelection("1,abc,3", 10)
+ if err == nil {
+ t.Fatal("expected error for non-numeric token, got nil")
+ }
+}
+
+func TestParseSelection_ZeroPage(t *testing.T) {
+ _, err := parseSelection("0,1", 5)
+ if err == nil {
+ t.Fatal("expected error for zero page number, got nil")
+ }
+}
+
+func TestParseSelection_NegativePage(t *testing.T) {
+ _, err := parseSelection("-1", 5)
+ if err == nil {
+ t.Fatal("expected error for negative page number, got nil")
+ }
+}
+
+func TestParseSelection_EmptyTokensIgnored(t *testing.T) {
+ // Trailing comma should not produce an error; the empty token is skipped.
+ got, err := parseSelection("1,2,", 5)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ want := []int{1, 2}
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("parseSelection(\"1,2,\", 5) = %v, want %v", got, want)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// extractPageNumber
+// ---------------------------------------------------------------------------
+
+func TestExtractPageNumber(t *testing.T) {
+ cases := []struct {
+ input string
+ want int
+ }{
+ {"story_gallery_1.png", 1},
+ {"my_story_gallery_10.png", 10},
+ {"no_match.png", 0},
+ {"_gallery_.png", 0}, // missing number after marker
+ {"gallery_0.png", 0}, // zero is invalid
+ {"gallery_-1.png", 0}, // negative is invalid
+ }
+
+ for _, tc := range cases {
+ got := extractPageNumber(tc.input)
+ if got != tc.want {
+ t.Errorf("extractPageNumber(%q) = %d, want %d", tc.input, got, tc.want)
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// findGalleryPages
+// ---------------------------------------------------------------------------
+
+func TestFindGalleryPages_NoFiles(t *testing.T) {
+ dir := t.TempDir()
+ pages, paths, err := findGalleryPages(dir)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(pages) != 0 || len(paths) != 0 {
+ t.Errorf("expected empty results for empty dir, got pages=%v paths=%v", pages, paths)
+ }
+}
+
+func TestFindGalleryPages_WithFiles(t *testing.T) {
+ dir := t.TempDir()
+
+ // Create dummy gallery PNGs.
+ for _, name := range []string{
+ "story_gallery_1.png",
+ "story_gallery_3.png",
+ "story_gallery_2.png",
+ } {
+ if err := os.WriteFile(filepath.Join(dir, name), []byte(""), 0644); err != nil {
+ t.Fatalf("creating test file: %v", err)
+ }
+ }
+
+ pages, paths, err := findGalleryPages(dir)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ wantPages := []int{1, 2, 3}
+ if !reflect.DeepEqual(pages, wantPages) {
+ t.Errorf("pages = %v, want %v", pages, wantPages)
+ }
+
+ if len(paths) != 3 {
+ t.Errorf("expected 3 paths, got %d", len(paths))
+ }
+}