diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-06 11:21:16 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-06 11:21:16 +0300 |
| commit | 5c76593737c3b707967bf2ac2394c22630323928 (patch) | |
| tree | 28d184f602b1bc33db6eb227a3836965cc94744c | |
| parent | d30fba0ac226da67a78349d322f4ad44b6f4f1a7 (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.go | 199 | ||||
| -rw-r--r-- | internal/cli/prompts_test.go | 192 |
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)) + } +} |
