summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-21 23:22:38 +0300
committerPaul Buetow <paul@buetow.org>2025-07-21 23:22:38 +0300
commitbc1c6e76d5a6ef2623d26d277473c459dd699f81 (patch)
tree3bc0c7b4b730d50fb15ae38458037fe2f8ac4b2f /internal
parenta43d503533f301db43af412167fda26364542e27 (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.go46
-rw-r--r--internal/archive/archive_test.go147
-rw-r--r--internal/archive/doc.go3
-rw-r--r--internal/batch/processor.go33
-rw-r--r--internal/batch/processor_test.go56
-rw-r--r--internal/cli/command.go10
-rw-r--r--internal/cli/flags.go1
-rw-r--r--internal/gui/app.go122
-rw-r--r--internal/processor/processor.go210
-rw-r--r--internal/processor/processor_test.go4
-rw-r--r--internal/translation/translator.go33
-rw-r--r--internal/translation/translator_test.go49
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()