diff options
| author | Paul Buetow <paul@buetow.org> | 2025-07-21 23:22:38 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-07-21 23:22:38 +0300 |
| commit | bc1c6e76d5a6ef2623d26d277473c459dd699f81 (patch) | |
| tree | 3bc0c7b4b730d50fb15ae38458037fe2f8ac4b2f /internal | |
| parent | a43d503533f301db43af412167fda26364542e27 (diff) | |
feat: Enhanced bulk import, archive functionality, and export improvements
## Bulk Import Enhancements
- Added support for three flexible batch file formats:
- `BULGARIAN = ENGLISH` - Both provided, no translation needed
- `= ENGLISH` - Only English provided, auto-translated to Bulgarian
- `BULGARIAN` - Only Bulgarian provided, auto-translated to English
- Implemented smart file checking to skip already processed words
- Check all required files (word.txt, translation.txt, phonetic.txt, audio/image files and their attribution/metadata)
- Added batch processing summary with statistics
## Archive Functionality
- Renamed --clear flag to --archive for clarity
- Archive cards directory to ~/.local/state/totalrecall/archive/cards-TIMESTAMP
- Added archive button to GUI toolbar with folder icon
- Archive confirmation dialog supports keyboard shortcuts (y/n/c/ESC)
## Export Improvements
- Anki exports now show full file path in output
- Changed default export location to home directory (~) for both CLI and GUI
- Auto-adjust image size to 1024x1024 when DALL-E 3 is selected
## Other Improvements
- Added TranslateEnglishToBulgarian method for reverse translation
- Enhanced batch processing with better error handling and progress reporting
- Improved file integrity checking for complete word processing
π€ Generated with [opencode](https://opencode.ai)
Co-Authored-By: opencode <noreply@opencode.ai>
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/archive/archive.go | 46 | ||||
| -rw-r--r-- | internal/archive/archive_test.go | 147 | ||||
| -rw-r--r-- | internal/archive/doc.go | 3 | ||||
| -rw-r--r-- | internal/batch/processor.go | 33 | ||||
| -rw-r--r-- | internal/batch/processor_test.go | 56 | ||||
| -rw-r--r-- | internal/cli/command.go | 10 | ||||
| -rw-r--r-- | internal/cli/flags.go | 1 | ||||
| -rw-r--r-- | internal/gui/app.go | 122 | ||||
| -rw-r--r-- | internal/processor/processor.go | 210 | ||||
| -rw-r--r-- | internal/processor/processor_test.go | 4 | ||||
| -rw-r--r-- | internal/translation/translator.go | 33 | ||||
| -rw-r--r-- | internal/translation/translator_test.go | 49 |
12 files changed, 658 insertions, 56 deletions
diff --git a/internal/archive/archive.go b/internal/archive/archive.go new file mode 100644 index 0000000..9d58935 --- /dev/null +++ b/internal/archive/archive.go @@ -0,0 +1,46 @@ +package archive + +import ( + "fmt" + "os" + "path/filepath" + "time" +) + +// ArchiveCards moves the cards directory to an archive with timestamp +func ArchiveCards(cardsDir string) error { + // Check if cards directory exists + if _, err := os.Stat(cardsDir); os.IsNotExist(err) { + return fmt.Errorf("cards directory does not exist: %s", cardsDir) + } + + // Get parent directory and create archive path + parentDir := filepath.Dir(cardsDir) + archiveDir := filepath.Join(parentDir, "archive") + + // Create archive directory if it doesn't exist + if err := os.MkdirAll(archiveDir, 0755); err != nil { + return fmt.Errorf("failed to create archive directory: %w", err) + } + + // Generate timestamp + timestamp := time.Now().Format("20060102-150405") + archiveName := fmt.Sprintf("cards-%s", timestamp) + archivePath := filepath.Join(archiveDir, archiveName) + + // Check if archive already exists (unlikely but possible) + if _, err := os.Stat(archivePath); err == nil { + // Add microseconds to make it unique + timestamp = time.Now().Format("20060102-150405.000000") + archiveName = fmt.Sprintf("cards-%s", timestamp) + archivePath = filepath.Join(archiveDir, archiveName) + } + + // Rename cards directory to archive + if err := os.Rename(cardsDir, archivePath); err != nil { + return fmt.Errorf("failed to archive cards directory: %w", err) + } + + fmt.Printf("Cards directory archived to: %s\n", archivePath) + return nil +} diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go new file mode 100644 index 0000000..affe907 --- /dev/null +++ b/internal/archive/archive_test.go @@ -0,0 +1,147 @@ +package archive + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestArchiveCards(t *testing.T) { + // Create temp directory + tmpDir := t.TempDir() + + // Create cards directory with some test files + cardsDir := filepath.Join(tmpDir, "cards") + if err := os.MkdirAll(cardsDir, 0755); err != nil { + t.Fatalf("Failed to create cards directory: %v", err) + } + + // Create some test files in cards directory + testFile := filepath.Join(cardsDir, "test.txt") + if err := os.WriteFile(testFile, []byte("test content"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Create a subdirectory with a file + subDir := filepath.Join(cardsDir, "subdir") + if err := os.MkdirAll(subDir, 0755); err != nil { + t.Fatalf("Failed to create subdirectory: %v", err) + } + subFile := filepath.Join(subDir, "subfile.txt") + if err := os.WriteFile(subFile, []byte("sub content"), 0644); err != nil { + t.Fatalf("Failed to create sub file: %v", err) + } + + // Archive the cards directory + if err := ArchiveCards(cardsDir); err != nil { + t.Fatalf("ArchiveCards failed: %v", err) + } + + // Check that cards directory no longer exists + if _, err := os.Stat(cardsDir); !os.IsNotExist(err) { + t.Error("Cards directory still exists after archiving") + } + + // Check that archive directory was created + archiveDir := filepath.Join(tmpDir, "archive") + if _, err := os.Stat(archiveDir); os.IsNotExist(err) { + t.Error("Archive directory was not created") + } + + // Check that archived directory exists with timestamp + entries, err := os.ReadDir(archiveDir) + if err != nil { + t.Fatalf("Failed to read archive directory: %v", err) + } + + if len(entries) != 1 { + t.Fatalf("Expected 1 entry in archive directory, got %d", len(entries)) + } + + // Verify the archived directory name starts with "cards-" + archivedName := entries[0].Name() + if !strings.HasPrefix(archivedName, "cards-") { + t.Errorf("Archived directory name doesn't start with 'cards-': %s", archivedName) + } + + // Verify timestamp format (should be cards-YYYYMMDD-HHMMSS) + parts := strings.Split(archivedName, "-") + if len(parts) < 3 { + t.Errorf("Invalid archive name format: %s", archivedName) + } + + // Check that archived files exist + archivedPath := filepath.Join(archiveDir, archivedName) + archivedTestFile := filepath.Join(archivedPath, "test.txt") + if _, err := os.Stat(archivedTestFile); os.IsNotExist(err) { + t.Error("Test file not found in archive") + } + + archivedSubFile := filepath.Join(archivedPath, "subdir", "subfile.txt") + if _, err := os.Stat(archivedSubFile); os.IsNotExist(err) { + t.Error("Sub file not found in archive") + } +} + +func TestArchiveCards_NonExistentDirectory(t *testing.T) { + tmpDir := t.TempDir() + nonExistentDir := filepath.Join(tmpDir, "nonexistent") + + err := ArchiveCards(nonExistentDir) + if err == nil { + t.Error("Expected error for non-existent directory") + } + + if !strings.Contains(err.Error(), "does not exist") { + t.Errorf("Expected 'does not exist' error, got: %v", err) + } +} + +func TestArchiveCards_MultipleArchives(t *testing.T) { + // Create temp directory + tmpDir := t.TempDir() + + // Archive twice to ensure unique timestamps + for i := 0; i < 2; i++ { + // Create cards directory + cardsDir := filepath.Join(tmpDir, "cards") + if err := os.MkdirAll(cardsDir, 0755); err != nil { + t.Fatalf("Failed to create cards directory: %v", err) + } + + // Create a test file + testFile := filepath.Join(cardsDir, "test.txt") + content := []byte("test content " + string(rune(i))) + if err := os.WriteFile(testFile, content, 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Small delay to ensure different timestamps + if i == 1 { + time.Sleep(10 * time.Millisecond) + } + + // Archive + if err := ArchiveCards(cardsDir); err != nil { + t.Fatalf("ArchiveCards failed on iteration %d: %v", i, err) + } + } + + // Check that we have 2 archives + archiveDir := filepath.Join(tmpDir, "archive") + entries, err := os.ReadDir(archiveDir) + if err != nil { + t.Fatalf("Failed to read archive directory: %v", err) + } + + if len(entries) != 2 { + t.Fatalf("Expected 2 entries in archive directory, got %d", len(entries)) + } + + // Verify both archives have different names + if entries[0].Name() == entries[1].Name() { + t.Error("Archive names are not unique") + } +} diff --git a/internal/archive/doc.go b/internal/archive/doc.go new file mode 100644 index 0000000..a442ce8 --- /dev/null +++ b/internal/archive/doc.go @@ -0,0 +1,3 @@ +// Package archive handles archiving of the cards directory with timestamps. +// It provides functionality to move existing cards to timestamped archive folders. +package archive diff --git a/internal/batch/processor.go b/internal/batch/processor.go index 75a38aa..0b20179 100644 --- a/internal/batch/processor.go +++ b/internal/batch/processor.go @@ -10,12 +10,15 @@ import ( type WordEntry struct { Bulgarian string Translation string + // NeedsTranslation indicates if translation from English to Bulgarian is needed + NeedsTranslation bool } // ReadBatchFile reads words from a file and returns WordEntry slice // Supports formats: -// - Bulgarian word only: "ΡΠ±ΡΠ»ΠΊΠ°" -// - With translation: "ΡΠ±ΡΠ»ΠΊΠ° = apple" +// - Bulgarian word only: "ΡΠ±ΡΠ»ΠΊΠ°" (will be translated to English) +// - With translation: "ΡΠ±ΡΠ»ΠΊΠ° = apple" (both provided, no translation needed) +// - English only: "= apple" (will be translated to Bulgarian) func ReadBatchFile(filename string) ([]WordEntry, error) { content, err := os.ReadFile(filename) if err != nil { @@ -27,24 +30,36 @@ func ReadBatchFile(filename string) ([]WordEntry, error) { for _, line := range splitLines(lines) { if line = trimSpace(line); line != "" { - // Check if line contains '=' for bulgarian = english format + // Check if line contains '=' for translation format if strings.Contains(line, "=") { parts := strings.SplitN(line, "=", 2) if len(parts) == 2 { bulgarian := strings.TrimSpace(parts[0]) english := strings.TrimSpace(parts[1]) - if bulgarian != "" { + + if bulgarian == "" && english != "" { + // Format: "= ENGLISH" - need to translate English to Bulgarian + entries = append(entries, WordEntry{ + Bulgarian: "", // Will be filled by translation + Translation: english, + NeedsTranslation: true, + }) + } else if bulgarian != "" && english != "" { + // Format: "BULGARIAN = ENGLISH" - both provided entries = append(entries, WordEntry{ - Bulgarian: bulgarian, - Translation: english, + Bulgarian: bulgarian, + Translation: english, + NeedsTranslation: false, }) } + // Ignore lines with empty English part } } else { - // Just a bulgarian word + // Just a Bulgarian word - needs translation to English entries = append(entries, WordEntry{ - Bulgarian: line, - Translation: "", + Bulgarian: line, + Translation: "", + NeedsTranslation: false, }) } } diff --git a/internal/batch/processor_test.go b/internal/batch/processor_test.go index 1df8284..fd9065b 100644 --- a/internal/batch/processor_test.go +++ b/internal/batch/processor_test.go @@ -30,9 +30,9 @@ func TestReadBatchFile(t *testing.T) { ΠΊΠΎΡΠΊΠ° = cat ΠΊΡΡΠ΅ = dog`, want: []WordEntry{ - {Bulgarian: "ΡΠ±ΡΠ»ΠΊΠ°", Translation: "apple"}, - {Bulgarian: "ΠΊΠΎΡΠΊΠ°", Translation: "cat"}, - {Bulgarian: "ΠΊΡΡΠ΅", Translation: "dog"}, + {Bulgarian: "ΡΠ±ΡΠ»ΠΊΠ°", Translation: "apple", NeedsTranslation: false}, + {Bulgarian: "ΠΊΠΎΡΠΊΠ°", Translation: "cat", NeedsTranslation: false}, + {Bulgarian: "ΠΊΡΡΠ΅", Translation: "dog", NeedsTranslation: false}, }, }, { @@ -42,10 +42,10 @@ func TestReadBatchFile(t *testing.T) { ΠΊΡΡΠ΅ Ρ
Π»ΡΠ± = bread`, want: []WordEntry{ - {Bulgarian: "ΡΠ±ΡΠ»ΠΊΠ°", Translation: ""}, - {Bulgarian: "ΠΊΠΎΡΠΊΠ°", Translation: "cat"}, - {Bulgarian: "ΠΊΡΡΠ΅", Translation: ""}, - {Bulgarian: "Ρ
Π»ΡΠ±", Translation: "bread"}, + {Bulgarian: "ΡΠ±ΡΠ»ΠΊΠ°", Translation: "", NeedsTranslation: false}, + {Bulgarian: "ΠΊΠΎΡΠΊΠ°", Translation: "cat", NeedsTranslation: false}, + {Bulgarian: "ΠΊΡΡΠ΅", Translation: "", NeedsTranslation: false}, + {Bulgarian: "Ρ
Π»ΡΠ±", Translation: "bread", NeedsTranslation: false}, }, }, { @@ -59,25 +59,53 @@ func TestReadBatchFile(t *testing.T) { `, want: []WordEntry{ - {Bulgarian: "ΡΠ±ΡΠ»ΠΊΠ°", Translation: ""}, - {Bulgarian: "ΠΊΠΎΡΠΊΠ°", Translation: "cat"}, - {Bulgarian: "ΠΊΡΡΠ΅", Translation: ""}, + {Bulgarian: "ΡΠ±ΡΠ»ΠΊΠ°", Translation: "", NeedsTranslation: false}, + {Bulgarian: "ΠΊΠΎΡΠΊΠ°", Translation: "cat", NeedsTranslation: false}, + {Bulgarian: "ΠΊΡΡΠ΅", Translation: "", NeedsTranslation: false}, }, }, { name: "windows line endings", fileContent: "ΡΠ±ΡΠ»ΠΊΠ°\r\nΠΊΠΎΡΠΊΠ° = cat\r\nΠΊΡΡΠ΅", want: []WordEntry{ - {Bulgarian: "ΡΠ±ΡΠ»ΠΊΠ°", Translation: ""}, - {Bulgarian: "ΠΊΠΎΡΠΊΠ°", Translation: "cat"}, - {Bulgarian: "ΠΊΡΡΠ΅", Translation: ""}, + {Bulgarian: "ΡΠ±ΡΠ»ΠΊΠ°", Translation: "", NeedsTranslation: false}, + {Bulgarian: "ΠΊΠΎΡΠΊΠ°", Translation: "cat", NeedsTranslation: false}, + {Bulgarian: "ΠΊΡΡΠ΅", Translation: "", NeedsTranslation: false}, }, }, { name: "multiple equals signs", fileContent: `test = word = with = equals`, want: []WordEntry{ - {Bulgarian: "test", Translation: "word = with = equals"}, + {Bulgarian: "test", Translation: "word = with = equals", NeedsTranslation: false}, + }, + }, + { + name: "english only format", + fileContent: `= apple += cat += dog`, + want: []WordEntry{ + {Bulgarian: "", Translation: "apple", NeedsTranslation: true}, + {Bulgarian: "", Translation: "cat", NeedsTranslation: true}, + {Bulgarian: "", Translation: "dog", NeedsTranslation: true}, + }, + }, + { + name: "all three formats mixed", + fileContent: `ΡΠ±ΡΠ»ΠΊΠ° +ΠΊΠΎΡΠΊΠ° = cat += dog +Ρ
Π»ΡΠ± = bread += table +ΡΡΠΎΠ»`, + want: []WordEntry{ + {Bulgarian: "ΡΠ±ΡΠ»ΠΊΠ°", Translation: "", NeedsTranslation: false}, + {Bulgarian: "ΠΊΠΎΡΠΊΠ°", Translation: "cat", NeedsTranslation: false}, + {Bulgarian: "", Translation: "dog", NeedsTranslation: true}, + {Bulgarian: "Ρ
Π»ΡΠ±", Translation: "bread", NeedsTranslation: false}, + {Bulgarian: "", Translation: "table", NeedsTranslation: true}, + {Bulgarian: "ΡΡΠΎΠ»", Translation: "", NeedsTranslation: false}, }, }, } diff --git a/internal/cli/command.go b/internal/cli/command.go index 81f9914..155cb40 100644 --- a/internal/cli/command.go +++ b/internal/cli/command.go @@ -24,7 +24,13 @@ representative images from web search APIs. Examples: totalrecall # Launch interactive GUI (default) totalrecall ΡΠ±ΡΠ»ΠΊΠ° # Generate materials for "apple" via CLI - totalrecall --batch words.txt # Process multiple words from file`, + totalrecall --batch words.txt # Process multiple words from file + totalrecall --archive # Archive existing cards directory + +Batch file formats: + ΡΠ±ΡΠ»ΠΊΠ° # Bulgarian word (will be translated to English) + ΡΠ±ΡΠ»ΠΊΠ° = apple # Bulgarian with translation provided + = apple # English only (will be translated to Bulgarian)`, Args: cobra.MaximumNArgs(1), Version: internal.Version, } @@ -41,6 +47,7 @@ func setupFlags(cmd *cobra.Command, flags *Flags) { defaultOutputDir := filepath.Join(home, ".local", "state", "totalrecall", "cards") // Global flags + // AGENT: The default config file location should be ~/.config/totalrecall/config.yaml cmd.PersistentFlags().StringVar(&flags.CfgFile, "config", "", "config file (default is $HOME/.totalrecall.yaml)") // Local flags @@ -56,6 +63,7 @@ func setupFlags(cmd *cobra.Command, flags *Flags) { cmd.Flags().BoolVar(&flags.ListModels, "list-models", false, "List available OpenAI models for the current API key") cmd.Flags().BoolVar(&flags.AllVoices, "all-voices", false, "Generate audio in all available voices (creates multiple files)") cmd.Flags().BoolVar(&flags.NoAutoPlay, "no-auto-play", false, "Disable automatic audio playback in GUI mode (auto-play is enabled by default)") + cmd.Flags().BoolVar(&flags.Archive, "archive", false, "Archive existing cards directory with timestamp") // OpenAI flags cmd.Flags().StringVar(&flags.OpenAIModel, "openai-model", flags.OpenAIModel, "OpenAI TTS model: tts-1, tts-1-hd, gpt-4o-mini-tts") diff --git a/internal/cli/flags.go b/internal/cli/flags.go index ab409f5..904059f 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -16,6 +16,7 @@ type Flags struct { ListModels bool AllVoices bool NoAutoPlay bool + Archive bool // OpenAI flags OpenAIModel string diff --git a/internal/gui/app.go b/internal/gui/app.go index ef0ee43..cd4b711 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -23,6 +23,7 @@ import ( "codeberg.org/snonux/totalrecall/internal" "codeberg.org/snonux/totalrecall/internal/anki" + "codeberg.org/snonux/totalrecall/internal/archive" "codeberg.org/snonux/totalrecall/internal/audio" ) @@ -345,8 +346,9 @@ func (a *Application) setupUI() { // But keep delete button enabled for cancelling operations a.deleteButton.Enable() - // Create export and help buttons for toolbar + // Create export, archive and help buttons for toolbar exportButton := ttwidget.NewButtonWithIcon("", theme.UploadIcon(), a.onExportToAnki) + archiveButton := ttwidget.NewButtonWithIcon("", theme.FolderOpenIcon(), a.onArchive) helpButton := ttwidget.NewButtonWithIcon("", theme.HelpIcon(), a.onShowHotkeys) // Create toolbar with navigation buttons first, then action buttons @@ -363,6 +365,7 @@ func (a *Application) setupUI() { a.regenerateAllBtn, widget.NewSeparator(), exportButton, + archiveButton, helpButton, ) @@ -400,13 +403,16 @@ func (a *Application) setupUI() { // Now that tooltip layer is created, set all tooltips a.setupTooltips() - // Set tooltips for export and help buttons with a delay + // Set tooltips for export, archive and help buttons with a delay go func() { time.Sleep(500 * time.Millisecond) fyne.Do(func() { if exportButton != nil { exportButton.SetToolTip("Export to Anki (x)") } + if archiveButton != nil { + archiveButton.SetToolTip("Archive all cards") + } if helpButton != nil { helpButton.SetToolTip("Show hotkeys (?)") } @@ -1034,7 +1040,7 @@ func (a *Application) onExportToAnki() { // Export directory selection homeDir, _ := os.UserHomeDir() - defaultExportDir := filepath.Join(homeDir, "Downloads") + defaultExportDir := homeDir // Changed from Downloads to home directory selectedDir := defaultExportDir dirLabel := widget.NewLabel(selectedDir) @@ -1199,6 +1205,110 @@ func (a *Application) onExportToAnki() { customDialog.Show() } +// onArchive archives the current cards directory +func (a *Application) onArchive() { + // Function to perform the archive + performArchive := func() { + // Get the cards directory path + home, _ := os.UserHomeDir() + cardsDir := filepath.Join(home, ".local", "state", "totalrecall", "cards") + + // Archive the cards + if err := archive.ArchiveCards(cardsDir); err != nil { + dialog.ShowError(err, a.window) + return + } + + // Clear the saved cards list + a.mu.Lock() + a.savedCards = []anki.Card{} + a.existingWords = []string{} + a.mu.Unlock() + + // Update status + a.updateStatus("Cards archived successfully") + + // Refresh the current word display + a.scanExistingWords() + if a.currentWord != "" { + a.loadExistingFiles(a.currentWord) + } + } + + // Create confirmation dialog + confirmDialog := dialog.NewConfirm("Archive Cards", + "Are you sure you want to archive all existing cards?\n\nThis will move the cards directory to:\n~/.local/state/totalrecall/archive/cards-TIMESTAMP", + func(confirmed bool) { + if confirmed { + performArchive() + } + }, + a.window, + ) + + // Track if we're in archive confirmation mode + archiveConfirming := true + + // Save original key handlers + oldKeyHandler := a.window.Canvas().OnTypedKey() + oldRuneHandler := a.window.Canvas().OnTypedRune() + + // Handle both Latin and Cyrillic keys + a.window.Canvas().SetOnTypedRune(func(r rune) { + if archiveConfirming { + switch r { + case 'y', 'Y', 'Ρ', 'Πͺ': + confirmDialog.Hide() + archiveConfirming = false + performArchive() + // Restore original handlers + a.window.Canvas().SetOnTypedKey(oldKeyHandler) + a.window.Canvas().SetOnTypedRune(oldRuneHandler) + case 'n', 'N', 'Π½', 'Π', 'c', 'C', 'Ρ', 'Π¦': + confirmDialog.Hide() + archiveConfirming = false + // Restore original handlers + a.window.Canvas().SetOnTypedKey(oldKeyHandler) + a.window.Canvas().SetOnTypedRune(oldRuneHandler) + } + } else if oldRuneHandler != nil { + oldRuneHandler(r) + } + }) + + // Handle special keys + a.window.Canvas().SetOnTypedKey(func(ev *fyne.KeyEvent) { + if archiveConfirming { + switch ev.Name { + case fyne.KeyY: + confirmDialog.Hide() + archiveConfirming = false + performArchive() + // Restore original handlers + a.window.Canvas().SetOnTypedKey(oldKeyHandler) + a.window.Canvas().SetOnTypedRune(oldRuneHandler) + case fyne.KeyN, fyne.KeyC, fyne.KeyEscape: + confirmDialog.Hide() + archiveConfirming = false + // Restore original handlers + a.window.Canvas().SetOnTypedKey(oldKeyHandler) + a.window.Canvas().SetOnTypedRune(oldRuneHandler) + } + } else if oldKeyHandler != nil { + oldKeyHandler(ev) + } + }) + + // Set up dialog close handler to restore key handlers + confirmDialog.SetOnClosed(func() { + archiveConfirming = false + a.window.Canvas().SetOnTypedKey(oldKeyHandler) + a.window.Canvas().SetOnTypedRune(oldRuneHandler) + }) + + confirmDialog.Show() +} + // onShowHotkeys displays a dialog with all available keyboard shortcuts func (a *Application) onShowHotkeys() { hotkeys := `[Project Page: https://codeberg.org/snonux/totalrecall](https://codeberg.org/snonux/totalrecall) @@ -1237,6 +1347,12 @@ func (a *Application) onShowHotkeys() { **c/Ρ** Close dialog **q/Ρ** Quit application +## Dialogs +**y/Ρ** Confirm action +**n/Π½** Cancel action +**c/Ρ** Cancel action +**Esc** Cancel action + --- *All hotkeys work with both Latin and Cyrillic keyboards* diff --git a/internal/processor/processor.go b/internal/processor/processor.go index 99ffa47..43e75ca 100644 --- a/internal/processor/processor.go +++ b/internal/processor/processor.go @@ -48,28 +48,80 @@ func (p *Processor) ProcessBatch() error { return err } - // Validate words - for _, entry := range entries { - if err := audio.ValidateBulgarianText(entry.Bulgarian); err != nil { - return fmt.Errorf("invalid word '%s': %w", entry.Bulgarian, err) - } - } - // Create output directory (including parent directories) if err := os.MkdirAll(p.flags.OutputDir, 0755); err != nil { return fmt.Errorf("failed to create output directory: %w", err) } + // First pass: handle entries that need English to Bulgarian translation + for i, entry := range entries { + if entry.NeedsTranslation && entry.Translation != "" { + // Translate English to Bulgarian + bulgarian, err := p.translator.TranslateEnglishToBulgarian(entry.Translation) + if err != nil { + fmt.Fprintf(os.Stderr, "Error translating '%s' to Bulgarian: %v\n", entry.Translation, err) + continue + } + entries[i].Bulgarian = bulgarian + fmt.Printf("Translated '%s' to Bulgarian: %s\n", entry.Translation, bulgarian) + } + } + + // Validate Bulgarian words + for _, entry := range entries { + if entry.Bulgarian != "" { + if err := audio.ValidateBulgarianText(entry.Bulgarian); err != nil { + return fmt.Errorf("invalid word '%s': %w", entry.Bulgarian, err) + } + } + } + + // Track statistics + skippedCount := 0 + processedCount := 0 + errorCount := 0 + // Process each entry for i, entry := range entries { + if entry.Bulgarian == "" { + continue // Skip entries without Bulgarian word + } + fmt.Printf("\nProcessing %d/%d: %s\n", i+1, len(entries), entry.Bulgarian) + // Check if word already exists and has all required files + if os.Getenv("DEBUG_BATCH") != "" { + fmt.Printf(" [DEBUG] Checking if word is fully processed...\n") + } + if p.isWordFullyProcessed(entry.Bulgarian) { + wordDir := p.findCardDirectory(entry.Bulgarian) + fmt.Printf(" β Skipping '%s' - already fully processed in %s\n", entry.Bulgarian, filepath.Base(wordDir)) + skippedCount++ + continue + } + if os.Getenv("DEBUG_BATCH") != "" { + fmt.Printf(" [DEBUG] Word is not fully processed, will process it\n") + } + if err := p.ProcessWordWithTranslation(entry.Bulgarian, entry.Translation); err != nil { fmt.Fprintf(os.Stderr, "Error processing '%s': %v\n", entry.Bulgarian, err) + errorCount++ // Continue with next word + } else { + processedCount++ } } + // Print summary + fmt.Printf("\n=== Batch Processing Summary ===\n") + fmt.Printf("Total words: %d\n", len(entries)) + fmt.Printf("Processed: %d\n", processedCount) + fmt.Printf("Skipped (already complete): %d\n", skippedCount) + if errorCount > 0 { + fmt.Printf("Errors: %d\n", errorCount) + } + fmt.Printf("================================\n") + return nil } @@ -117,9 +169,15 @@ func (p *Processor) ProcessWordWithTranslation(word, providedTranslation string) // Find or create word directory wordDir := p.findOrCreateWordDirectory(word) - // Save translation to file - if err := translation.SaveTranslation(wordDir, word, translationText); err != nil { - fmt.Printf(" Warning: Failed to save translation: %v\n", err) + // Check if translation file already exists + translationFile := filepath.Join(wordDir, "translation.txt") + if _, err := os.Stat(translationFile); os.IsNotExist(err) { + // Save translation to file + if err := translation.SaveTranslation(wordDir, word, translationText); err != nil { + fmt.Printf(" Warning: Failed to save translation: %v\n", err) + } + } else { + fmt.Printf(" Translation file already exists\n") } } @@ -344,11 +402,23 @@ func (p *Processor) downloadImagesWithTranslation(word, translationText string) return nil } -// GenerateAnkiFile generates the Anki import file -func (p *Processor) GenerateAnkiFile() error { +// GenerateAnkiFile generates the Anki import file and returns the output path +func (p *Processor) GenerateAnkiFile() (string, error) { + // When --anki is used from CLI, save to home directory + var outputDir string + if p.flags.GenerateAnki { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + outputDir = homeDir + } else { + outputDir = p.flags.OutputDir + } + // Create Anki generator gen := anki.NewGenerator(&anki.GeneratorOptions{ - OutputPath: filepath.Join(p.flags.OutputDir, "anki_import.csv"), + OutputPath: filepath.Join(outputDir, "anki_import.csv"), MediaFolder: p.flags.OutputDir, IncludeHeaders: true, AudioFormat: p.flags.AudioFormat, @@ -356,7 +426,7 @@ func (p *Processor) GenerateAnkiFile() error { // Generate cards from output directory if err := gen.GenerateFromDirectory(p.flags.OutputDir); err != nil { - return fmt.Errorf("failed to generate cards: %w", err) + return "", fmt.Errorf("failed to generate cards: %w", err) } // Add translations to cards @@ -367,16 +437,18 @@ func (p *Processor) GenerateAnkiFile() error { } } + var outputPath string if p.flags.AnkiCSV { // Generate CSV + outputPath = filepath.Join(outputDir, "anki_import.csv") if err := gen.GenerateCSV(); err != nil { - return fmt.Errorf("failed to generate CSV: %w", err) + return "", fmt.Errorf("failed to generate CSV: %w", err) } } else { // Generate APKG - outputPath := filepath.Join(p.flags.OutputDir, fmt.Sprintf("%s.apkg", internal.SanitizeFilename(p.flags.DeckName))) + outputPath = filepath.Join(outputDir, fmt.Sprintf("%s.apkg", internal.SanitizeFilename(p.flags.DeckName))) if err := gen.GenerateAPKG(outputPath, p.flags.DeckName); err != nil { - return fmt.Errorf("failed to generate APKG: %w", err) + return "", fmt.Errorf("failed to generate APKG: %w", err) } } @@ -385,7 +457,7 @@ func (p *Processor) GenerateAnkiFile() error { fmt.Printf(" Generated %d cards (%d with audio, %d with images)\n", total, withAudio, withImages) - return nil + return outputPath, nil } // RunGUIMode launches the GUI application @@ -461,21 +533,105 @@ func (p *Processor) findCardDirectory(word string) string { if storedWord == word { return dirPath } - } else { - // Try old format with underscore for backward compatibility - wordFile = filepath.Join(dirPath, "_word.txt") - if data, err := os.ReadFile(wordFile); err == nil { - storedWord := strings.TrimSpace(string(data)) - if storedWord == word { - return dirPath - } - } } } return "" } +// isWordFullyProcessed checks if a word has already been fully processed +func (p *Processor) isWordFullyProcessed(word string) bool { + // Find the word directory + wordDir := p.findCardDirectory(word) + if wordDir == "" { + return false // No directory exists + } + + // Debug logging + if os.Getenv("DEBUG_BATCH") != "" { + fmt.Printf(" [DEBUG] Checking word directory: %s\n", wordDir) + } + + // Check for required files + requiredFiles := []string{ + "word.txt", // Word metadata + "translation.txt", // Translation file + "phonetic.txt", // Phonetic information + } + + // Check for audio-related files (unless skipped) + if !p.flags.SkipAudio { + // Add audio-related files to required list + requiredFiles = append(requiredFiles, + "audio_attribution.txt", + "audio_metadata.txt", + ) + + // Check for audio file (without voice suffix for single voice mode) + audioFile := filepath.Join(wordDir, fmt.Sprintf("audio.%s", p.flags.AudioFormat)) + if _, err := os.Stat(audioFile); os.IsNotExist(err) { + // Also check for audio files with voice suffix (for all-voices mode) + audioPattern := fmt.Sprintf("audio_*.%s", p.flags.AudioFormat) + matches, _ := filepath.Glob(filepath.Join(wordDir, audioPattern)) + if len(matches) == 0 { + if os.Getenv("DEBUG_BATCH") != "" { + fmt.Printf(" [DEBUG] No audio file found: %s or pattern %s\n", audioFile, audioPattern) + } + return false // No audio file found + } + } + } + + // Check for image-related files (unless skipped) + if !p.flags.SkipImages { + // Add image-related files to required list + requiredFiles = append(requiredFiles, + "image_attribution.txt", + "image_prompt.txt", + ) + + // Check for at least one image file + imagePatterns := []string{"image_*.jpg", "image_*.png", "image_*.webp", "image.jpg", "image.png", "image.webp"} + hasImage := false + for _, pattern := range imagePatterns { + if strings.Contains(pattern, "*") { + matches, _ := filepath.Glob(filepath.Join(wordDir, pattern)) + if len(matches) > 0 { + hasImage = true + break + } + } else { + // Direct file check + if _, err := os.Stat(filepath.Join(wordDir, pattern)); err == nil { + hasImage = true + break + } + } + } + if !hasImage { + if os.Getenv("DEBUG_BATCH") != "" { + fmt.Printf(" [DEBUG] No image files found in %s\n", wordDir) + } + return false // No image files found + } + } + + // Check all required files exist + for _, file := range requiredFiles { + filePath := filepath.Join(wordDir, file) + if _, err := os.Stat(filePath); os.IsNotExist(err) { + if os.Getenv("DEBUG_BATCH") != "" { + fmt.Printf(" [DEBUG] Required file missing: %s\n", filePath) + } + return false // Required file missing + } + } + + if os.Getenv("DEBUG_BATCH") != "" { + fmt.Printf(" [DEBUG] All required files exist, word is fully processed\n") + } + return true // All required files exist +} func (p *Processor) saveAudioAttribution(word, audioFile string, config *audio.Config) error { // Create attribution text attribution := fmt.Sprintf("Audio generated by OpenAI TTS\n\n") diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go index 3a90a05..26ab71b 100644 --- a/internal/processor/processor_test.go +++ b/internal/processor/processor_test.go @@ -208,7 +208,7 @@ func TestGenerateAnkiFile(t *testing.T) { p.translationCache.Add("ΡΠ±ΡΠ»ΠΊΠ°", "apple") p.translationCache.Add("ΠΊΠΎΡΠΊΠ°", "cat") - err := p.GenerateAnkiFile() + _, err := p.GenerateAnkiFile() if err != nil { t.Errorf("GenerateAnkiFile failed: %v", err) } @@ -240,7 +240,7 @@ func TestGenerateAnkiFile_APKG(t *testing.T) { os.WriteFile(filepath.Join(word1Dir, "ΡΠ±ΡΠ»ΠΊΠ°.mp3"), []byte("audio1"), 0644) os.WriteFile(filepath.Join(word2Dir, "ΠΊΠΎΡΠΊΠ°.mp3"), []byte("audio2"), 0644) - err := p.GenerateAnkiFile() + _, err := p.GenerateAnkiFile() if err != nil { t.Errorf("GenerateAnkiFile (APKG) failed: %v", err) } diff --git a/internal/translation/translator.go b/internal/translation/translator.go index ab3a879..662e37d 100644 --- a/internal/translation/translator.go +++ b/internal/translation/translator.go @@ -57,6 +57,39 @@ func (t *Translator) TranslateWord(word string) (string, error) { return translation, nil } +// TranslateEnglishToBulgarian translates an English word to Bulgarian +func (t *Translator) TranslateEnglishToBulgarian(word string) (string, error) { + if t.apiKey == "" { + return "", fmt.Errorf("OpenAI API key not found") + } + + ctx := context.Background() + + req := openai.ChatCompletionRequest{ + Model: openai.GPT4oMini, + Messages: []openai.ChatCompletionMessage{ + { + Role: openai.ChatMessageRoleUser, + Content: fmt.Sprintf("Translate the English word '%s' to Bulgarian. Respond with only the Bulgarian translation in Cyrillic script, nothing else.", word), + }, + }, + MaxTokens: 50, + Temperature: 0.3, + } + + resp, err := t.client.CreateChatCompletion(ctx, req) + if err != nil { + return "", fmt.Errorf("OpenAI API error: %w", err) + } + + if len(resp.Choices) == 0 { + return "", fmt.Errorf("no translation returned") + } + + translation := strings.TrimSpace(resp.Choices[0].Message.Content) + return translation, nil +} + // SaveTranslation saves the translation to a file in the word directory func SaveTranslation(wordDir, word, translation string) error { outputFile := filepath.Join(wordDir, "translation.txt") diff --git a/internal/translation/translator_test.go b/internal/translation/translator_test.go index 3688311..c87ad9c 100644 --- a/internal/translation/translator_test.go +++ b/internal/translation/translator_test.go @@ -60,6 +60,55 @@ func TestTranslateWord_Integration(t *testing.T) { t.Logf("Translation of 'ΡΠ±ΡΠ»ΠΊΠ°': %s", translation) } +func TestTranslateEnglishToBulgarian_NoAPIKey(t *testing.T) { + translator := NewTranslator("") + + _, err := translator.TranslateEnglishToBulgarian("apple") + if err == nil { + t.Error("Expected error for missing API key") + } + + if err.Error() != "OpenAI API key not found" { + t.Errorf("Expected 'OpenAI API key not found' error, got: %v", err) + } +} + +func TestTranslateEnglishToBulgarian_Integration(t *testing.T) { + // Skip if no API key + apiKey := os.Getenv("OPENAI_API_KEY") + if apiKey == "" { + t.Skip("Skipping integration test: OPENAI_API_KEY not set") + } + + translator := NewTranslator(apiKey) + + // Test with a simple word + translation, err := translator.TranslateEnglishToBulgarian("apple") + if err != nil { + t.Errorf("TranslateEnglishToBulgarian failed: %v", err) + } + + // Check that we got a reasonable translation + // The exact translation might vary, but it should be in Cyrillic + if translation == "" { + t.Error("Got empty translation") + } + + // Check that the result contains Cyrillic characters + hasCyrillic := false + for _, r := range translation { + if r >= 'Π' && r <= 'Ρ' { + hasCyrillic = true + break + } + } + if !hasCyrillic { + t.Errorf("Expected Cyrillic translation, got: %s", translation) + } + + t.Logf("Translation of 'apple': %s", translation) +} + func TestSaveTranslation(t *testing.T) { tmpDir := t.TempDir() |
