diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-10 23:11:30 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-10 23:11:30 +0300 |
| commit | 65430e9876a32988839deb7a70dc1e42da21cf64 (patch) | |
| tree | e0de040cb53d5b90587908f2d55e86e49664fc35 /internal | |
| parent | 4b4b3665230faafbd994f09894cb07c8f9d4b359 (diff) | |
Release 0.28.2v0.28.2
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/cli/command.go | 1 | ||||
| -rw-r--r-- | internal/cli/command_test.go | 12 | ||||
| -rw-r--r-- | internal/cli/flags.go | 2 | ||||
| -rw-r--r-- | internal/cli/flags_test.go | 3 | ||||
| -rw-r--r-- | internal/processor/failed_asset_retry.go | 290 | ||||
| -rw-r--r-- | internal/processor/image_downloader.go | 10 | ||||
| -rw-r--r-- | internal/processor/processor_test.go | 148 | ||||
| -rw-r--r-- | internal/store/store.go | 32 | ||||
| -rw-r--r-- | internal/store/store_test.go | 30 | ||||
| -rw-r--r-- | internal/version.go | 2 |
10 files changed, 523 insertions, 7 deletions
diff --git a/internal/cli/command.go b/internal/cli/command.go index 43a03bc..526dffe 100644 --- a/internal/cli/command.go +++ b/internal/cli/command.go @@ -22,6 +22,7 @@ Examples: totalrecall # Launch interactive GUI (default) totalrecall ябълка # Generate materials for "apple" via CLI totalrecall --batch words.txt # Process multiple words from file + totalrecall --retry-failed-assets # Resume incomplete cards in the output directory totalrecall --archive # Archive existing cards directory Batch file formats: diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index 80626eb..4c95c3b 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -32,6 +32,9 @@ func TestCreateRootCommand(t *testing.T) { if !strings.Contains(cmd.Long, "Explicit CLI and batch runs also use Nano Banana by default") { t.Errorf("Expected Long description to describe the CLI and batch Nano Banana default") } + if !strings.Contains(cmd.Long, "--retry-failed-assets") { + t.Errorf("Expected Long description to mention retry-failed-assets mode") + } // Test that flags are set up flagTests := []struct { @@ -45,6 +48,7 @@ func TestCreateRootCommand(t *testing.T) { {"batch", true}, {"skip-audio", true}, {"skip-images", true}, + {"retry-failed-assets", true}, {"anki", true}, {"anki-csv", true}, {"deck-name", true}, @@ -119,6 +123,14 @@ func TestSetupFlags(t *testing.T) { t.Errorf("Expected image-api help to describe the CLI Nano Banana default and config fallback, got %q", imageAPIFlag.Usage) } + retryFailedAssetsFlag := cmd.Flags().Lookup("retry-failed-assets") + if retryFailedAssetsFlag == nil { + t.Fatal("retry-failed-assets flag not found") + } + if !strings.Contains(retryFailedAssetsFlag.Usage, "stopping on the first error") { + t.Errorf("Expected retry-failed-assets help to describe stop-on-first-error behavior, got %q", retryFailedAssetsFlag.Usage) + } + openAIVoiceFlag := cmd.Flags().Lookup("openai-voice") if openAIVoiceFlag == nil { t.Fatal("openai-voice flag not found") diff --git a/internal/cli/flags.go b/internal/cli/flags.go index 43e3b23..bdcc88b 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -38,6 +38,7 @@ type Flags struct { VideoEnabled bool // --video: whether to prompt for Veo video generation after --story completes SkipAudio bool SkipImages bool + RetryFailedAssets bool GenerateAnki bool AnkiCSV bool DeckName string @@ -131,6 +132,7 @@ func setupFlags(cmd *cobra.Command, flags *Flags) { "Prompt to generate Veo videos after comic generation (default true; use --video=false to skip)") cmd.Flags().BoolVar(&flags.SkipAudio, "skip-audio", false, "Skip audio generation") cmd.Flags().BoolVar(&flags.SkipImages, "skip-images", false, "Skip image download") + cmd.Flags().BoolVar(&flags.RetryFailedAssets, "retry-failed-assets", false, "Scan existing cards and regenerate missing or failed audio/image assets, stopping on the first error") cmd.Flags().BoolVar(&flags.GenerateAnki, "anki", false, "Generate Anki import file (APKG format by default, use --anki-csv for legacy CSV)") cmd.Flags().BoolVar(&flags.AnkiCSV, "anki-csv", false, "Generate legacy CSV format instead of APKG when using --anki") cmd.Flags().StringVar(&flags.DeckName, "deck-name", flags.DeckName, "Deck name for APKG export") diff --git a/internal/cli/flags_test.go b/internal/cli/flags_test.go index 4b59bee..5c56770 100644 --- a/internal/cli/flags_test.go +++ b/internal/cli/flags_test.go @@ -51,6 +51,7 @@ func TestNewFlags(t *testing.T) { }{ {"SkipAudio", flags.SkipAudio}, {"SkipImages", flags.SkipImages}, + {"RetryFailedAssets", flags.RetryFailedAssets}, {"GenerateAnki", flags.GenerateAnki}, {"AnkiCSV", flags.AnkiCSV}, {"ListModels", flags.ListModels}, @@ -94,7 +95,7 @@ func TestFlagsStructure(t *testing.T) { expectedFields := []string{ "CfgFile", "OutputDir", "AudioFormat", "AudioFormatSpecified", "AudioProvider", "ImageAPI", "ImageAPISpecified", "BatchFile", - "SkipAudio", "SkipImages", "GenerateAnki", "AnkiCSV", "DeckName", + "SkipAudio", "SkipImages", "RetryFailedAssets", "GenerateAnki", "AnkiCSV", "DeckName", "ListModels", "AllVoices", "NoAutoPlay", "OpenAIModel", "OpenAIVoice", "OpenAISpeed", "OpenAIInstruction", "OpenAIImageModel", "OpenAIImageSize", "OpenAIImageQuality", "OpenAIImageStyle", diff --git a/internal/processor/failed_asset_retry.go b/internal/processor/failed_asset_retry.go new file mode 100644 index 0000000..88d1abe --- /dev/null +++ b/internal/processor/failed_asset_retry.go @@ -0,0 +1,290 @@ +package processor + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "codeberg.org/snonux/totalrecall/internal" + "codeberg.org/snonux/totalrecall/internal/anki" + "codeberg.org/snonux/totalrecall/internal/audio" + "codeberg.org/snonux/totalrecall/internal/store" +) + +type failedAssetKind string + +const ( + failedAssetAudio failedAssetKind = "audio" + failedAssetImage failedAssetKind = "image" + failedAssetBgBgAudioPair failedAssetKind = "front and back audio" + failedAssetBgBgFrontAudio failedAssetKind = "front audio" + failedAssetBgBgBackAudio failedAssetKind = "back audio" +) + +type failedAssetPlan struct { + Card store.CardDirectory + CardType internal.CardType + Translation string + ImagePrompt string + Assets []failedAssetKind +} + +// RetryFailedAssets scans the existing card output directory for incomplete or +// failed asset generations and retries them in deterministic order. The retry +// loop stops immediately on the first fresh error so users can rerun the same +// command after an upstream rate limit clears. +func (p *Processor) RetryFailedAssets() error { + if err := os.MkdirAll(p.Flags.OutputDir, 0755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + plans, err := p.scanFailedAssetPlans() + if err != nil { + return err + } + if len(plans) == 0 { + fmt.Printf("No failed assets found in: %s\n", p.Flags.OutputDir) + return nil + } + + totalAssets := 0 + for _, plan := range plans { + totalAssets += len(plan.Assets) + } + + fmt.Printf("Found %d failed asset(s) across %d card(s) in %s\n", totalAssets, len(plans), p.Flags.OutputDir) + + regenerated := 0 + for _, plan := range plans { + fmt.Printf("\nRetrying card: %s\n", plan.Card.Word) + for _, asset := range plan.Assets { + fmt.Printf(" Regenerating %s...\n", asset) + + assetCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + err := p.regenerateFailedAsset(assetCtx, plan, asset) + cancel() + if err != nil { + return fmt.Errorf("stopped after %d successful regeneration(s); %s for %q failed: %w", regenerated, asset, plan.Card.Word, err) + } + + regenerated++ + } + } + + fmt.Printf("\nRegenerated %d failed asset(s).\n", regenerated) + return nil +} + +func (p *Processor) scanFailedAssetPlans() ([]failedAssetPlan, error) { + cards := p.cardStore.ListCardDirectories(nil) + plans := make([]failedAssetPlan, 0, len(cards)) + + for _, card := range cards { + plan := p.buildFailedAssetPlan(card) + if len(plan.Assets) == 0 { + continue + } + plans = append(plans, plan) + } + + return plans, nil +} + +func (p *Processor) buildFailedAssetPlan(card store.CardDirectory) failedAssetPlan { + plan := failedAssetPlan{ + Card: card, + CardType: internal.LoadCardType(card.Path), + Translation: readStoredTranslation(card.Path), + ImagePrompt: readStoredImagePrompt(card.Path), + } + + if !p.Flags.SkipAudio { + if plan.CardType.IsBgBg() { + frontReady := audioAssetReady(card.Path, "audio_front", p.EffectiveAudioFormat()) + backReady := audioAssetReady(card.Path, "audio_back", p.EffectiveAudioFormat()) + + switch { + case !frontReady && !backReady: + plan.Assets = append(plan.Assets, failedAssetBgBgAudioPair) + case !frontReady: + plan.Assets = append(plan.Assets, failedAssetBgBgFrontAudio) + case !backReady: + plan.Assets = append(plan.Assets, failedAssetBgBgBackAudio) + } + } else if !audioAssetReady(card.Path, "audio", p.EffectiveAudioFormat()) { + plan.Assets = append(plan.Assets, failedAssetAudio) + } + } + + if !p.Flags.SkipImages && !imageAssetReady(card.Path) { + plan.Assets = append(plan.Assets, failedAssetImage) + } + + return plan +} + +func (p *Processor) regenerateFailedAsset(ctx context.Context, plan failedAssetPlan, asset failedAssetKind) error { + switch asset { + case failedAssetAudio: + return p.generateAudio(ctx, plan.Card.Word) + case failedAssetImage: + return p.downloadImagesWithPrompt(ctx, plan.Card.Word, plan.Translation, plan.ImagePrompt) + case failedAssetBgBgAudioPair: + if strings.TrimSpace(plan.Translation) == "" { + return fmt.Errorf("missing back-side text in translation.txt") + } + return p.generateAudioBgBg(ctx, plan.Card.Word, plan.Translation) + case failedAssetBgBgFrontAudio: + return p.generateCardAudioSideInDir(ctx, plan.Card.Word, plan.Card.Path, "audio_front", "front audio") + case failedAssetBgBgBackAudio: + if strings.TrimSpace(plan.Translation) == "" { + return fmt.Errorf("missing back-side text in translation.txt") + } + return p.generateCardAudioSideInDir(ctx, plan.Translation, plan.Card.Path, "audio_back", "back audio") + default: + return fmt.Errorf("unknown failed asset kind %q", asset) + } +} + +func (p *Processor) generateCardAudioSideInDir(ctx context.Context, text, wordDir, filenameBase, label string) error { + provider := p.AudioProviderName() + voice := p.audioVoiceForProvider() + p.logSelectedAudioVoice(provider, voice) + + run := func(candidate string) error { + if candidate != voice { + fmt.Printf(" Retrying Gemini audio with voice: %s\n", candidate) + } + fmt.Printf(" Generating %s for '%s'...\n", label, text) + return p.generateAudioWithVoiceAndFilenameInDir(ctx, text, candidate, filenameBase, wordDir) + } + + if provider == "gemini" && p.GeminiVoice() == "" { + _, err := audio.RunWithVoiceFallbacks(voice, run, func(candidate string) { + fmt.Printf(" Warning: Gemini returned no audio for voice %s\n", candidate) + }) + return err + } + + return run(voice) +} + +func audioAssetReady(wordDir, baseName, preferredFormat string) bool { + paths := anki.ResolveAudioPaths(wordDir, baseName, preferredFormat) + if len(paths) == 0 { + return false + } + + for _, path := range paths { + if !fileExistsAndNonEmpty(path) || !fileExistsAndNonEmpty(audio.AttributionPath(path)) { + return false + } + } + + return fileExistsAndNonEmpty(filepath.Join(wordDir, "audio_metadata.txt")) +} + +func imageAssetReady(wordDir string) bool { + if firstUsableImagePath(wordDir) == "" { + return false + } + if !fileExistsAndNonEmpty(filepath.Join(wordDir, "image_attribution.txt")) { + return false + } + return readStoredImagePrompt(wordDir) != "" +} + +func firstUsableImagePath(wordDir string) string { + imagePatterns := []string{ + "image_*.jpg", + "image_*.png", + "image_*.webp", + "image.jpg", + "image.png", + "image.webp", + } + + for _, pattern := range imagePatterns { + if strings.Contains(pattern, "*") { + matches, _ := filepath.Glob(filepath.Join(wordDir, pattern)) + for _, match := range matches { + if fileExistsAndNonEmpty(match) { + return match + } + } + continue + } + + path := filepath.Join(wordDir, pattern) + if fileExistsAndNonEmpty(path) { + return path + } + } + + return "" +} + +func readStoredTranslation(wordDir string) string { + data, err := os.ReadFile(filepath.Join(wordDir, "translation.txt")) + if err != nil { + return "" + } + + parts := strings.SplitN(string(data), "=", 2) + if len(parts) != 2 { + return strings.TrimSpace(string(data)) + } + + return strings.TrimSpace(parts[1]) +} + +func readStoredImagePrompt(wordDir string) string { + data, err := os.ReadFile(filepath.Join(wordDir, "image_prompt.txt")) + if err != nil { + return "" + } + + prompt := strings.TrimSpace(string(data)) + if prompt == "" || looksLikeFailedPrompt(prompt) { + return "" + } + + return prompt +} + +func looksLikeFailedPrompt(prompt string) bool { + lower := strings.ToLower(strings.TrimSpace(prompt)) + if lower == "" { + return true + } + + markers := []string{ + "rate limit", + "too many requests", + "quota exceeded", + "resource exhausted", + "failed to generate", + "generation failed", + "temporarily unavailable", + "http 429", + } + + for _, marker := range markers { + if strings.Contains(lower, marker) { + return true + } + } + + return false +} + +func fileExistsAndNonEmpty(path string) bool { + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return false + } + return info.Size() > 0 +} diff --git a/internal/processor/image_downloader.go b/internal/processor/image_downloader.go index 1d6c002..fa0f6cf 100644 --- a/internal/processor/image_downloader.go +++ b/internal/processor/image_downloader.go @@ -24,6 +24,13 @@ import ( // AI image providers can generate more contextually accurate images. // ctx is passed to the image downloader so the caller's deadline applies. func (p *Processor) downloadImagesWithTranslation(ctx context.Context, word, translationText string) error { + return p.downloadImagesWithPrompt(ctx, word, translationText, "") +} + +// downloadImagesWithPrompt downloads images for a word and optionally reuses a +// previously-saved prompt. When customPrompt is empty the provider generates a +// fresh prompt as usual. +func (p *Processor) downloadImagesWithPrompt(ctx context.Context, word, translationText, customPrompt string) error { searcher, err := p.newImageSearcher() if err != nil { return err @@ -43,6 +50,9 @@ func (p *Processor) downloadImagesWithTranslation(ctx context.Context, word, tra if translationText != "" { searchOpts.Translation = translationText } + if strings.TrimSpace(customPrompt) != "" { + searchOpts.CustomPrompt = strings.TrimSpace(customPrompt) + } // Register a prompt callback so the AI-generated prompt is persisted // to disk before the download completes (used by the GUI and for debugging). diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go index 649c092..64d6938 100644 --- a/internal/processor/processor_test.go +++ b/internal/processor/processor_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + "codeberg.org/snonux/totalrecall/internal" "codeberg.org/snonux/totalrecall/internal/audio" "codeberg.org/snonux/totalrecall/internal/cli" "codeberg.org/snonux/totalrecall/internal/gui" @@ -1333,6 +1334,153 @@ func TestProcessBatch_ValidFile(t *testing.T) { } } +func TestRetryFailedAssets_RegeneratesMissingEnBgAssetsInOrder(t *testing.T) { + t.Setenv("GOOGLE_API_KEY", "test-google-key") + + flags := cli.NewFlags() + flags.OutputDir = t.TempDir() + flags.ImageAPI = image.ImageProviderNanoBanana + flags.ImageAPISpecified = true + p := NewProcessor(flags, &Config{}) + + cardDir := p.findOrCreateWordDirectory("ябълка") + if err := os.WriteFile(filepath.Join(cardDir, "translation.txt"), []byte("ябълка = apple\n"), 0644); err != nil { + t.Fatalf("setup translation.txt: %v", err) + } + + fakeProvider := &fakeAudioProvider{ + generateFunc: func(_ string, outputFile string) error { + return os.WriteFile(outputFile, []byte("audio data"), 0644) + }, + } + p.newAudioProvider = func(*audio.Config) (audio.Provider, error) { + return fakeProvider, nil + } + p.imageFactories.NewNanoBananaClient = func(*image.NanoBananaConfig) image.PromptAwareClient { + return &stubImageSearcher{} + } + + output := captureStdout(t, func() { + if err := p.RetryFailedAssets(); err != nil { + t.Fatalf("RetryFailedAssets() unexpected error: %v", err) + } + }) + + if fakeProvider.generateCalls != 1 { + t.Fatalf("audio generate calls = %d, want 1", fakeProvider.generateCalls) + } + if !strings.Contains(output, "Regenerating audio") || !strings.Contains(output, "Regenerating image") { + t.Fatalf("stdout missing retry steps: %q", output) + } + if strings.Index(output, "Regenerating audio") > strings.Index(output, "Regenerating image") { + t.Fatalf("retry order is wrong, output = %q", output) + } + if _, err := os.Stat(filepath.Join(cardDir, "audio.mp3")); err != nil { + t.Fatalf("expected audio.mp3 to be created: %v", err) + } + if _, err := os.Stat(filepath.Join(cardDir, "image.png")); err != nil { + t.Fatalf("expected image.png to be created: %v", err) + } + promptData, err := os.ReadFile(filepath.Join(cardDir, "image_prompt.txt")) + if err != nil { + t.Fatalf("expected image_prompt.txt to be created: %v", err) + } + if strings.TrimSpace(string(promptData)) != "stub nanobanana prompt" { + t.Fatalf("image prompt = %q, want %q", strings.TrimSpace(string(promptData)), "stub nanobanana prompt") + } +} + +func TestRetryFailedAssets_StopsOnFirstError(t *testing.T) { + flags := cli.NewFlags() + flags.OutputDir = t.TempDir() + flags.SkipImages = true + p := NewProcessor(flags, &Config{}) + + firstDir := p.findOrCreateWordDirectory("ябълка") + secondDir := p.findOrCreateWordDirectory("круша") + if err := os.WriteFile(filepath.Join(firstDir, "translation.txt"), []byte("ябълка = apple\n"), 0644); err != nil { + t.Fatalf("setup first translation.txt: %v", err) + } + if err := os.WriteFile(filepath.Join(secondDir, "translation.txt"), []byte("круша = pear\n"), 0644); err != nil { + t.Fatalf("setup second translation.txt: %v", err) + } + + fakeProvider := &fakeAudioProvider{ + generateFunc: func(_ string, _ string) error { + return errors.New("rate limit exceeded") + }, + } + p.newAudioProvider = func(*audio.Config) (audio.Provider, error) { + return fakeProvider, nil + } + + err := p.RetryFailedAssets() + if err == nil { + t.Fatal("expected RetryFailedAssets() to stop on first error") + } + if !strings.Contains(err.Error(), "rate limit exceeded") { + t.Fatalf("RetryFailedAssets() error = %v, want rate limit message", err) + } + if fakeProvider.generateCalls != 1 { + t.Fatalf("audio generate calls = %d, want 1", fakeProvider.generateCalls) + } + if _, statErr := os.Stat(filepath.Join(secondDir, "audio.mp3")); !os.IsNotExist(statErr) { + t.Fatalf("second card audio should not have been generated, stat err = %v", statErr) + } +} + +func TestRetryFailedAssets_RegeneratesOnlyMissingBgBgBackAudio(t *testing.T) { + flags := cli.NewFlags() + flags.OutputDir = t.TempDir() + flags.SkipImages = true + p := NewProcessor(flags, &Config{}) + + cardDir := p.findOrCreateWordDirectory("ябълка") + if err := internal.SaveCardType(cardDir, internal.CardTypeBgBg); err != nil { + t.Fatalf("setup cardtype.txt: %v", err) + } + if err := os.WriteFile(filepath.Join(cardDir, "translation.txt"), []byte("ябълка = плод\n"), 0644); err != nil { + t.Fatalf("setup translation.txt: %v", err) + } + + frontAudio := filepath.Join(cardDir, "audio_front.mp3") + if err := os.WriteFile(frontAudio, []byte("front audio"), 0644); err != nil { + t.Fatalf("setup audio_front.mp3: %v", err) + } + if err := os.WriteFile(audio.AttributionPath(frontAudio), []byte("front attribution"), 0644); err != nil { + t.Fatalf("setup front attribution: %v", err) + } + if err := os.WriteFile(filepath.Join(cardDir, "audio_metadata.txt"), []byte("format=mp3\naudio_file=audio_front.mp3\n"), 0644); err != nil { + t.Fatalf("setup audio_metadata.txt: %v", err) + } + + fakeProvider := &fakeAudioProvider{ + generateFunc: func(_ string, outputFile string) error { + return os.WriteFile(outputFile, []byte("back audio"), 0644) + }, + } + p.newAudioProvider = func(*audio.Config) (audio.Provider, error) { + return fakeProvider, nil + } + + if err := p.RetryFailedAssets(); err != nil { + t.Fatalf("RetryFailedAssets() unexpected error: %v", err) + } + + if fakeProvider.generateCalls != 1 { + t.Fatalf("audio generate calls = %d, want 1", fakeProvider.generateCalls) + } + if !strings.HasSuffix(fakeProvider.lastOutputFile, "audio_back.mp3") { + t.Fatalf("last output file = %q, want audio_back.mp3", fakeProvider.lastOutputFile) + } + if _, err := os.Stat(filepath.Join(cardDir, "audio_back.mp3")); err != nil { + t.Fatalf("expected audio_back.mp3 to be created: %v", err) + } + if _, err := os.Stat(frontAudio); err != nil { + t.Fatalf("front audio should remain present: %v", err) + } +} + func TestProcessWordWithTranslation_ProvidedTranslation(t *testing.T) { flags := cli.NewFlags() flags.OutputDir = t.TempDir() diff --git a/internal/store/store.go b/internal/store/store.go index 16f50ba..daafb28 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -25,6 +25,13 @@ type CardStore struct { outputDir string } +// CardDirectory describes one discovered on-disk card directory and the word +// stored inside it. +type CardDirectory struct { + Path string + Word string +} + // New constructs a CardStore rooted at outputDir. func New(outputDir string) *CardStore { return &CardStore{outputDir: outputDir} @@ -56,13 +63,26 @@ func (cs *CardStore) FindOrCreateCardDirectory(word string) string { // candidate directory; pass nil to accept all directories that have a word // file. func (cs *CardStore) ScanWords(hasContent func(wordDir string) bool) []string { + cards := cs.ListCardDirectories(hasContent) + words := make([]string, 0, len(cards)) + for _, card := range cards { + words = append(words, card.Word) + } + sort.Strings(words) + return words +} + +// ListCardDirectories scans the output directory for non-hidden card +// subdirectories that contain word metadata. The result is sorted by directory +// path so callers can process cards in deterministic creation order. +func (cs *CardStore) ListCardDirectories(hasContent func(wordDir string) bool) []CardDirectory { entries, err := os.ReadDir(cs.outputDir) if err != nil { // Output directory does not exist yet; return empty list silently. - return []string{} + return []CardDirectory{} } - words := make([]string, 0, len(entries)) + cards := make([]CardDirectory, 0, len(entries)) for _, entry := range entries { if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { @@ -77,12 +97,14 @@ func (cs *CardStore) ScanWords(hasContent func(wordDir string) bool) []string { } if hasContent == nil || hasContent(wordDir) { - words = append(words, word) + cards = append(cards, CardDirectory{Path: wordDir, Word: word}) } } - sort.Strings(words) - return words + sort.Slice(cards, func(i, j int) bool { + return cards[i].Path < cards[j].Path + }) + return cards } // GenerateCardID creates a unique ID for a card based on the current timestamp diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 6d34d0c..02da883 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -138,3 +138,33 @@ func TestCardStoreScanWords(t *testing.T) { t.Errorf("ScanWords(nil) = %v; want 2 words", allWords) } } + +func TestCardStoreListCardDirectories(t *testing.T) { + tmpDir := t.TempDir() + + makeCard := func(id, word string) string { + cardDir := filepath.Join(tmpDir, id) + _ = os.MkdirAll(cardDir, 0755) + _ = os.WriteFile(filepath.Join(cardDir, "word.txt"), []byte(word), 0644) + return cardDir + } + + card2 := makeCard("card2", "куче") + makeCard("card1", "котка") + makeCard(".hidden", "hidden") + + cs := store.New(tmpDir) + cards := cs.ListCardDirectories(func(wordDir string) bool { + return wordDir != card2 + }) + + if len(cards) != 1 { + t.Fatalf("ListCardDirectories() returned %d cards, want 1", len(cards)) + } + if cards[0].Word != "котка" { + t.Fatalf("ListCardDirectories()[0].Word = %q, want %q", cards[0].Word, "котка") + } + if filepath.Base(cards[0].Path) != "card1" { + t.Fatalf("ListCardDirectories()[0].Path = %q, want base %q", cards[0].Path, "card1") + } +} diff --git a/internal/version.go b/internal/version.go index 7f9bfc6..d0c1d3a 100644 --- a/internal/version.go +++ b/internal/version.go @@ -1,3 +1,3 @@ package internal -const Version = "0.28.1" +const Version = "0.28.2" |
