diff options
| author | Paul Buetow <paul@buetow.org> | 2026-01-21 21:43:40 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-01-21 21:43:40 +0200 |
| commit | 8a4b935792c50101cf65b36e44f376c90c2d08c1 (patch) | |
| tree | 2a8288e935fe89738fbc1243253bb638dc2620a8 /internal | |
| parent | 2bd22f79f739136a7d30cf156979e2f2b23b7c65 (diff) | |
improve: better audio player UI and debugging for bg-bg cards
- Add debug logging to navigation.go to diagnose audio file loading issues
Prints paths being checked and whether files are found
- Improve AudioPlayer UI for Bulgarian-Bulgarian cards:
- Add labels showing 'Front' and 'Back' for bg-bg audio buttons
- Labels only show when audio files are actually loaded
- Better visual distinction between the two playable audios
- Reorganized button layout with VBox for cleaner appearance
- Track bg-bg state in AudioPlayer (isBgBg field)
- Automatically set when back audio file is loaded
- Used to determine when to show labels
This makes it clearer that Bulgarian-Bulgarian cards have two independently
playable audio outputs, and helps debug why audio isn't being loaded.
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/anki/apkg_generator.go | 304 | ||||
| -rw-r--r-- | internal/anki/generator.go | 57 | ||||
| -rw-r--r-- | internal/batch/processor.go | 95 | ||||
| -rw-r--r-- | internal/batch/processor_test.go | 70 | ||||
| -rw-r--r-- | internal/cardtype.go | 58 | ||||
| -rw-r--r-- | internal/gui/app.go | 292 | ||||
| -rw-r--r-- | internal/gui/audio_player.go | 116 | ||||
| -rw-r--r-- | internal/gui/generator.go | 117 | ||||
| -rw-r--r-- | internal/gui/navigation.go | 14 | ||||
| -rw-r--r-- | internal/gui/queue.go | 2 | ||||
| -rw-r--r-- | internal/processor/processor.go | 138 |
11 files changed, 1035 insertions, 228 deletions
diff --git a/internal/anki/apkg_generator.go b/internal/anki/apkg_generator.go index 9707937..c5a31d2 100644 --- a/internal/anki/apkg_generator.go +++ b/internal/anki/apkg_generator.go @@ -16,12 +16,13 @@ import ( // APKGGenerator creates Anki package files (.apkg) type APKGGenerator struct { - deckName string - deckID int64 - modelID int64 - cards []Card - mediaFiles map[string]int // maps original filename to media number - mediaCounter int + deckName string + deckID int64 + modelID int64 + modelIDBgBg int64 // Separate model for bg-bg cards + cards []Card + mediaFiles map[string]int // maps original filename to media number + mediaCounter int } // NewAPKGGenerator creates a new APKG generator @@ -32,6 +33,7 @@ func NewAPKGGenerator(deckName string) *APKGGenerator { deckName: deckName, deckID: now, modelID: now + 1, + modelIDBgBg: now + 2, cards: make([]Card, 0), mediaFiles: make(map[string]int), mediaCounter: 0, @@ -242,7 +244,8 @@ func (g *APKGGenerator) insertCollection(db *sql.DB) error { // Create model (note type) configuration models := map[string]interface{}{ - fmt.Sprintf("%d", g.modelID): g.createNoteTypeConfig(), + fmt.Sprintf("%d", g.modelID): g.createNoteTypeConfig(), + fmt.Sprintf("%d", g.modelIDBgBg): g.createBgBgNoteTypeConfig(), } modelsJSON, _ := json.Marshal(models) @@ -515,6 +518,20 @@ func (g *APKGGenerator) getCSS() string { margin: 20px 0; } +.bulgarian-front { + font-size: 32px; + font-weight: bold; + color: #2c3e50; + margin: 20px 0; +} + +.bulgarian-back { + font-size: 28px; + font-weight: bold; + color: #27ae60; + margin: 20px 0; +} + .audio { margin: 15px 0; } @@ -533,6 +550,171 @@ hr#answer { }` } +// createBgBgNoteTypeConfig creates the note type configuration for Bulgarian-Bulgarian cards +func (g *APKGGenerator) createBgBgNoteTypeConfig() map[string]interface{} { + return map[string]interface{}{ + "id": g.modelIDBgBg, + "name": "Bulgarian-Bulgarian from TotalRecall", + "type": 0, + "mod": time.Now().Unix(), + "usn": -1, + "sortf": 0, + "did": g.deckID, + "req": [][]interface{}{[]interface{}{0, "all", []int{0}}, []interface{}{1, "all", []int{1}}}, + "vers": []int{}, + "tags": []string{}, + "latexPre": `\documentclass[12pt]{article} +\special{papersize=3in,5in} +\usepackage[utf8]{inputenc} +\usepackage{amssymb,amsmath} +\pagestyle{empty} +\setlength{\parindent}{0in} +\begin{document}`, + "latexPost": `\end{document}`, + "flds": []map[string]interface{}{ + { + "name": "BulgarianFront", + "ord": 0, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "media": []string{}, + }, + { + "name": "BulgarianBack", + "ord": 1, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "media": []string{}, + }, + { + "name": "Image", + "ord": 2, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "media": []string{}, + }, + { + "name": "AudioFront", + "ord": 3, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "media": []string{}, + }, + { + "name": "AudioBack", + "ord": 4, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + "media": []string{}, + }, + { + "name": "Notes", + "ord": 5, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 16, + "media": []string{}, + }, + }, + "tmpls": []map[string]interface{}{ + { + "name": "Forward", + "ord": 0, + "qfmt": g.getBgBgFrontTemplate(), + "afmt": g.getBgBgBackTemplate(), + "did": nil, + "bqfmt": "", + "bafmt": "", + }, + { + "name": "Reverse", + "ord": 1, + "qfmt": g.getBgBgReverseFrontTemplate(), + "afmt": g.getBgBgReverseBackTemplate(), + "did": nil, + "bqfmt": "", + "bafmt": "", + }, + }, + "css": g.getCSS(), + } +} + +// getBgBgFrontTemplate returns the question template for bg-bg cards +func (g *APKGGenerator) getBgBgFrontTemplate() string { + return `<div class="front"> +{{#Image}} +<div class="image-container"> +{{Image}} +</div> +{{/Image}} +<div class="bulgarian-front">{{BulgarianFront}}</div> +{{#AudioFront}} +<div class="audio">{{AudioFront}}</div> +{{/AudioFront}} +</div>` +} + +// getBgBgBackTemplate returns the answer template for bg-bg cards +func (g *APKGGenerator) getBgBgBackTemplate() string { + return `{{FrontSide}} + +<hr id="answer"> + +<div class="back"> +<div class="bulgarian-back">{{BulgarianBack}}</div> +{{#AudioBack}} +<div class="audio">{{AudioBack}}</div> +{{/AudioBack}} +{{#Notes}} +<div class="notes">{{Notes}}</div> +{{/Notes}} +</div>` +} + +// getBgBgReverseFrontTemplate returns the question template for bg-bg reverse cards +func (g *APKGGenerator) getBgBgReverseFrontTemplate() string { + return `<div class="front"> +<div class="bulgarian-back">{{BulgarianBack}}</div> +{{#AudioBack}} +{{AudioBack}} +{{/AudioBack}} +</div>` +} + +// getBgBgReverseBackTemplate returns the answer template for bg-bg reverse cards +func (g *APKGGenerator) getBgBgReverseBackTemplate() string { + return `{{FrontSide}} + +<hr id="answer"> + +<div class="back"> +<div class="bulgarian-front">{{BulgarianFront}}</div> +{{#AudioFront}} +<div class="audio">{{AudioFront}}</div> +{{/AudioFront}} +{{#Image}} +<div class="image-container"> +{{Image}} +</div> +{{/Image}} +{{#Notes}} +<div class="notes">{{Notes}}</div> +{{/Notes}} +</div>` +} + // insertNotesAndCards inserts all notes and cards into the database func (g *APKGGenerator) insertNotesAndCards(db *sql.DB) error { now := time.Now() @@ -543,58 +725,78 @@ func (g *APKGGenerator) insertNotesAndCards(db *sql.DB) error { cardID1 := noteID + 1 cardID2 := noteID + 2 - // Prepare field values - english := card.Translation - if english == "" { - english = "Translation needed" - } + // Determine if this is a bg-bg card + isBgBg := card.CardType == "bg-bg" imageField := "" if card.ImageFile != "" && fileExists(card.ImageFile) { - // Get card ID from the source path (parent directory name) - cardID := filepath.Base(filepath.Dir(card.ImageFile)) + cardDirID := filepath.Base(filepath.Dir(card.ImageFile)) originalFilename := filepath.Base(card.ImageFile) - // Create unique filename with card ID prefix - uniqueFilename := fmt.Sprintf("%s_%s", cardID, originalFilename) - + uniqueFilename := fmt.Sprintf("%s_%s", cardDirID, originalFilename) if _, ok := g.mediaFiles[uniqueFilename]; ok { - // Use the unique filename in the card content imageField = fmt.Sprintf(`<img src="%s">`, uniqueFilename) } } audioField := "" if card.AudioFile != "" && fileExists(card.AudioFile) { - // Get card ID from the source path (parent directory name) - cardID := filepath.Base(filepath.Dir(card.AudioFile)) + cardDirID := filepath.Base(filepath.Dir(card.AudioFile)) originalFilename := filepath.Base(card.AudioFile) - // Create unique filename with card ID prefix - uniqueFilename := fmt.Sprintf("%s_%s", cardID, originalFilename) - + uniqueFilename := fmt.Sprintf("%s_%s", cardDirID, originalFilename) if _, ok := g.mediaFiles[uniqueFilename]; ok { - // Use the unique filename in the card content audioField = fmt.Sprintf("[sound:%s]", uniqueFilename) } } - // Join fields with field separator (ASCII 31) - fields := strings.Join([]string{ - english, - card.Bulgarian, - imageField, - audioField, - card.Notes, - }, "\x1f") + audioFieldBack := "" + if card.AudioFileBack != "" && fileExists(card.AudioFileBack) { + cardDirID := filepath.Base(filepath.Dir(card.AudioFileBack)) + originalFilename := filepath.Base(card.AudioFileBack) + uniqueFilename := fmt.Sprintf("%s_%s", cardDirID, originalFilename) + if _, ok := g.mediaFiles[uniqueFilename]; ok { + audioFieldBack = fmt.Sprintf("[sound:%s]", uniqueFilename) + } + } - // Generate GUID - guid := fmt.Sprintf("tr_%d_%s", now.Unix(), card.Bulgarian) + var fields string + var modelID int64 + var guid string + + if isBgBg { + // Bulgarian-Bulgarian card: BulgarianFront, BulgarianBack, Image, AudioFront, AudioBack, Notes + fields = strings.Join([]string{ + card.Bulgarian, + card.Translation, + imageField, + audioField, + audioFieldBack, + card.Notes, + }, "\x1f") + modelID = g.modelIDBgBg + guid = fmt.Sprintf("tr_bgbg_%d_%s", now.Unix(), card.Bulgarian) + } else { + // English-Bulgarian card: English, Bulgarian, Image, Audio, Notes + english := card.Translation + if english == "" { + english = "Translation needed" + } + fields = strings.Join([]string{ + english, + card.Bulgarian, + imageField, + audioField, + card.Notes, + }, "\x1f") + modelID = g.modelID + guid = fmt.Sprintf("tr_%d_%s", now.Unix(), card.Bulgarian) + } // Insert note noteQuery := `INSERT INTO notes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` _, err := db.Exec(noteQuery, noteID, // id guid, // guid - g.modelID, // mid + modelID, // mid now.Unix(), // mod -1, // usn "", // tags @@ -665,16 +867,12 @@ func (g *APKGGenerator) insertNotesAndCards(db *sql.DB) error { // copyMediaFiles copies media files and assigns them numbers func (g *APKGGenerator) copyMediaFiles(tempDir string) error { - // Media files go directly in the temp directory with numeric names - for _, card := range g.cards { - // Copy audio file + // Copy audio file (front audio for bg-bg, only audio for en-bg) if card.AudioFile != "" && fileExists(card.AudioFile) { - // Get card ID from the source path (parent directory name) - cardID := filepath.Base(filepath.Dir(card.AudioFile)) + cardDirID := filepath.Base(filepath.Dir(card.AudioFile)) originalFilename := filepath.Base(card.AudioFile) - // Create unique filename with card ID prefix - uniqueFilename := fmt.Sprintf("%s_%s", cardID, originalFilename) + uniqueFilename := fmt.Sprintf("%s_%s", cardDirID, originalFilename) if _, exists := g.mediaFiles[uniqueFilename]; !exists { targetPath := filepath.Join(tempDir, fmt.Sprintf("%d", g.mediaCounter)) @@ -686,13 +884,27 @@ func (g *APKGGenerator) copyMediaFiles(tempDir string) error { } } + // Copy back audio file (only for bg-bg cards) + if card.AudioFileBack != "" && fileExists(card.AudioFileBack) { + cardDirID := filepath.Base(filepath.Dir(card.AudioFileBack)) + originalFilename := filepath.Base(card.AudioFileBack) + uniqueFilename := fmt.Sprintf("%s_%s", cardDirID, originalFilename) + + if _, exists := g.mediaFiles[uniqueFilename]; !exists { + targetPath := filepath.Join(tempDir, fmt.Sprintf("%d", g.mediaCounter)) + if err := copyFile(card.AudioFileBack, targetPath); err != nil { + return fmt.Errorf("failed to copy back audio file %s: %w", card.AudioFileBack, err) + } + g.mediaFiles[uniqueFilename] = g.mediaCounter + g.mediaCounter++ + } + } + // Copy image file if card.ImageFile != "" && fileExists(card.ImageFile) { - // Get card ID from the source path (parent directory name) - cardID := filepath.Base(filepath.Dir(card.ImageFile)) + cardDirID := filepath.Base(filepath.Dir(card.ImageFile)) originalFilename := filepath.Base(card.ImageFile) - // Create unique filename with card ID prefix - uniqueFilename := fmt.Sprintf("%s_%s", cardID, originalFilename) + uniqueFilename := fmt.Sprintf("%s_%s", cardDirID, originalFilename) if _, exists := g.mediaFiles[uniqueFilename]; !exists { targetPath := filepath.Join(tempDir, fmt.Sprintf("%d", g.mediaCounter)) diff --git a/internal/anki/generator.go b/internal/anki/generator.go index 0b393f3..85a4155 100644 --- a/internal/anki/generator.go +++ b/internal/anki/generator.go @@ -6,15 +6,19 @@ import ( "os" "path/filepath" "strings" + + "codeberg.org/snonux/totalrecall/internal" ) // Card represents a single Anki flashcard type Card struct { - Bulgarian string // The Bulgarian word/phrase - AudioFile string // Path to audio file - ImageFile string // Path to image file - Translation string // Optional translation - Notes string // Optional notes + Bulgarian string // The Bulgarian word/phrase + AudioFile string // Path to audio file (for en-bg: Bulgarian audio, for bg-bg: front audio) + AudioFileBack string // Path to back audio file (only for bg-bg cards) + ImageFile string // Path to image file + Translation string // Translation (English for en-bg, Bulgarian definition for bg-bg) + Notes string // Optional notes + CardType string // Card type: "en-bg" or "bg-bg" } // GeneratorOptions configures the Anki export @@ -143,23 +147,27 @@ func (g *Generator) GenerateFromDirectory(dir string) error { if err != nil { return fmt.Errorf("failed to read directory: %w", err) } - + // Process each subdirectory as a word for _, entry := range entries { if !entry.IsDir() { continue } - + // Skip hidden directories like .trashbin if strings.HasPrefix(entry.Name(), ".") { continue } - + wordDir := filepath.Join(dir, entry.Name()) - + // Create card for this word card := Card{} - + + // Load card type (defaults to en-bg for backwards compatibility) + cardType := internal.LoadCardType(wordDir) + card.CardType = string(cardType) + // Read the original Bulgarian word from word.txt wordFile := filepath.Join(wordDir, "word.txt") if data, err := os.ReadFile(wordFile); err == nil { @@ -174,7 +182,7 @@ func (g *Generator) GenerateFromDirectory(dir string) error { continue } } - + // Try to load translation translationFile := filepath.Join(wordDir, "translation.txt") if data, err := os.ReadFile(translationFile); err == nil { @@ -183,17 +191,32 @@ func (g *Generator) GenerateFromDirectory(dir string) error { card.Translation = strings.TrimSpace(parts[1]) } } - - // Look for audio file + + // Look for audio file(s) audioFormats := []string{"mp3", "wav"} for _, format := range audioFormats { + // For bg-bg cards, look for audio_front and audio_back + if cardType.IsBgBg() { + frontAudio := filepath.Join(wordDir, fmt.Sprintf("audio_front.%s", format)) + backAudio := filepath.Join(wordDir, fmt.Sprintf("audio_back.%s", format)) + if _, err := os.Stat(frontAudio); err == nil { + card.AudioFile = frontAudio + } + if _, err := os.Stat(backAudio); err == nil { + card.AudioFileBack = backAudio + } + if card.AudioFile != "" { + break + } + } + // For en-bg cards (or fallback), look for standard audio file audioFile := filepath.Join(wordDir, fmt.Sprintf("audio.%s", format)) if _, err := os.Stat(audioFile); err == nil { card.AudioFile = audioFile break } } - + // Look for image files imagePatterns := []string{ "image.jpg", @@ -206,7 +229,7 @@ func (g *Generator) GenerateFromDirectory(dir string) error { break } } - + // Load phonetic information as notes phoneticFile := filepath.Join(wordDir, "phonetic.txt") if data, err := os.ReadFile(phoneticFile); err == nil { @@ -214,13 +237,13 @@ func (g *Generator) GenerateFromDirectory(dir string) error { notes := strings.TrimSpace(string(data)) card.Notes = strings.ReplaceAll(notes, "\n", "<br>") } - + // Only add card if it has at least some content if card.AudioFile != "" || card.ImageFile != "" || card.Translation != "" { g.AddCard(card) } } - + return nil } diff --git a/internal/batch/processor.go b/internal/batch/processor.go index 0b20179..6867c3c 100644 --- a/internal/batch/processor.go +++ b/internal/batch/processor.go @@ -4,6 +4,8 @@ import ( "fmt" "os" "strings" + + "codeberg.org/snonux/totalrecall/internal" ) // WordEntry represents a word with optional translation @@ -12,6 +14,8 @@ type WordEntry struct { Translation string // NeedsTranslation indicates if translation from English to Bulgarian is needed NeedsTranslation bool + // CardType indicates whether this is en-bg or bg-bg card + CardType internal.CardType } // ReadBatchFile reads words from a file and returns WordEntry slice @@ -19,6 +23,7 @@ type WordEntry struct { // - Bulgarian word only: "ябълка" (will be translated to English) // - With translation: "ябълка = apple" (both provided, no translation needed) // - English only: "= apple" (will be translated to Bulgarian) +// - Bulgarian-Bulgarian: "word1 == definition" (bg-bg card, double equals) func ReadBatchFile(filename string) ([]WordEntry, error) { content, err := os.ReadFile(filename) if err != nil { @@ -30,42 +35,72 @@ func ReadBatchFile(filename string) ([]WordEntry, error) { for _, line := range splitLines(lines) { if line = trimSpace(line); line != "" { - // 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 == "" && 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, - NeedsTranslation: false, - }) - } - // Ignore lines with empty English part + entry := parseBatchLine(line) + if entry != nil { + entries = append(entries, *entry) + } + } + } + + return entries, nil +} + +// parseBatchLine parses a single batch file line and returns the appropriate WordEntry +func parseBatchLine(line string) *WordEntry { + // Check for Bulgarian-Bulgarian format first (double equals ==) + if strings.Contains(line, "==") { + parts := strings.SplitN(line, "==", 2) + if len(parts) == 2 { + bulgarian1 := strings.TrimSpace(parts[0]) + bulgarian2 := strings.TrimSpace(parts[1]) + + if bulgarian1 != "" && bulgarian2 != "" { + return &WordEntry{ + Bulgarian: bulgarian1, + Translation: bulgarian2, + NeedsTranslation: false, + CardType: internal.CardTypeBgBg, } - } else { - // Just a Bulgarian word - needs translation to English - entries = append(entries, WordEntry{ - Bulgarian: line, - Translation: "", + } + } + return nil + } + + // Check for English-Bulgarian format (single equals =) + 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 == "" && english != "" { + // Format: "= ENGLISH" - need to translate English to Bulgarian + return &WordEntry{ + Bulgarian: "", + Translation: english, + NeedsTranslation: true, + CardType: internal.CardTypeEnBg, + } + } else if bulgarian != "" && english != "" { + // Format: "BULGARIAN = ENGLISH" - both provided + return &WordEntry{ + Bulgarian: bulgarian, + Translation: english, NeedsTranslation: false, - }) + CardType: internal.CardTypeEnBg, + } } } + return nil } - return entries, nil + // Just a Bulgarian word - needs translation to English + return &WordEntry{ + Bulgarian: line, + Translation: "", + NeedsTranslation: false, + CardType: internal.CardTypeEnBg, + } } // splitLines splits a string by newlines diff --git a/internal/batch/processor_test.go b/internal/batch/processor_test.go index fd9065b..fc4f7b0 100644 --- a/internal/batch/processor_test.go +++ b/internal/batch/processor_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "reflect" "testing" + + "codeberg.org/snonux/totalrecall/internal" ) func TestReadBatchFile(t *testing.T) { @@ -30,9 +32,9 @@ func TestReadBatchFile(t *testing.T) { котка = cat куче = dog`, want: []WordEntry{ - {Bulgarian: "ябълка", Translation: "apple", NeedsTranslation: false}, - {Bulgarian: "котка", Translation: "cat", NeedsTranslation: false}, - {Bulgarian: "куче", Translation: "dog", NeedsTranslation: false}, + {Bulgarian: "ябълка", Translation: "apple", NeedsTranslation: false, CardType: internal.CardTypeEnBg}, + {Bulgarian: "котка", Translation: "cat", NeedsTranslation: false, CardType: internal.CardTypeEnBg}, + {Bulgarian: "куче", Translation: "dog", NeedsTranslation: false, CardType: internal.CardTypeEnBg}, }, }, { @@ -42,10 +44,10 @@ func TestReadBatchFile(t *testing.T) { куче хляб = bread`, want: []WordEntry{ - {Bulgarian: "ябълка", Translation: "", NeedsTranslation: false}, - {Bulgarian: "котка", Translation: "cat", NeedsTranslation: false}, - {Bulgarian: "куче", Translation: "", NeedsTranslation: false}, - {Bulgarian: "хляб", Translation: "bread", NeedsTranslation: false}, + {Bulgarian: "ябълка", Translation: "", NeedsTranslation: false, CardType: internal.CardTypeEnBg}, + {Bulgarian: "котка", Translation: "cat", NeedsTranslation: false, CardType: internal.CardTypeEnBg}, + {Bulgarian: "куче", Translation: "", NeedsTranslation: false, CardType: internal.CardTypeEnBg}, + {Bulgarian: "хляб", Translation: "bread", NeedsTranslation: false, CardType: internal.CardTypeEnBg}, }, }, { @@ -59,25 +61,25 @@ func TestReadBatchFile(t *testing.T) { `, want: []WordEntry{ - {Bulgarian: "ябълка", Translation: "", NeedsTranslation: false}, - {Bulgarian: "котка", Translation: "cat", NeedsTranslation: false}, - {Bulgarian: "куче", Translation: "", NeedsTranslation: false}, + {Bulgarian: "ябълка", Translation: "", NeedsTranslation: false, CardType: internal.CardTypeEnBg}, + {Bulgarian: "котка", Translation: "cat", NeedsTranslation: false, CardType: internal.CardTypeEnBg}, + {Bulgarian: "куче", Translation: "", NeedsTranslation: false, CardType: internal.CardTypeEnBg}, }, }, { name: "windows line endings", fileContent: "ябълка\r\nкотка = cat\r\nкуче", want: []WordEntry{ - {Bulgarian: "ябълка", Translation: "", NeedsTranslation: false}, - {Bulgarian: "котка", Translation: "cat", NeedsTranslation: false}, - {Bulgarian: "куче", Translation: "", NeedsTranslation: false}, + {Bulgarian: "ябълка", Translation: "", NeedsTranslation: false, CardType: internal.CardTypeEnBg}, + {Bulgarian: "котка", Translation: "cat", NeedsTranslation: false, CardType: internal.CardTypeEnBg}, + {Bulgarian: "куче", Translation: "", NeedsTranslation: false, CardType: internal.CardTypeEnBg}, }, }, { name: "multiple equals signs", fileContent: `test = word = with = equals`, want: []WordEntry{ - {Bulgarian: "test", Translation: "word = with = equals", NeedsTranslation: false}, + {Bulgarian: "test", Translation: "word = with = equals", NeedsTranslation: false, CardType: internal.CardTypeEnBg}, }, }, { @@ -86,9 +88,9 @@ func TestReadBatchFile(t *testing.T) { = cat = dog`, want: []WordEntry{ - {Bulgarian: "", Translation: "apple", NeedsTranslation: true}, - {Bulgarian: "", Translation: "cat", NeedsTranslation: true}, - {Bulgarian: "", Translation: "dog", NeedsTranslation: true}, + {Bulgarian: "", Translation: "apple", NeedsTranslation: true, CardType: internal.CardTypeEnBg}, + {Bulgarian: "", Translation: "cat", NeedsTranslation: true, CardType: internal.CardTypeEnBg}, + {Bulgarian: "", Translation: "dog", NeedsTranslation: true, CardType: internal.CardTypeEnBg}, }, }, { @@ -100,12 +102,34 @@ func TestReadBatchFile(t *testing.T) { = 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}, + {Bulgarian: "ябълка", Translation: "", NeedsTranslation: false, CardType: internal.CardTypeEnBg}, + {Bulgarian: "котка", Translation: "cat", NeedsTranslation: false, CardType: internal.CardTypeEnBg}, + {Bulgarian: "", Translation: "dog", NeedsTranslation: true, CardType: internal.CardTypeEnBg}, + {Bulgarian: "хляб", Translation: "bread", NeedsTranslation: false, CardType: internal.CardTypeEnBg}, + {Bulgarian: "", Translation: "table", NeedsTranslation: true, CardType: internal.CardTypeEnBg}, + {Bulgarian: "стол", Translation: "", NeedsTranslation: false, CardType: internal.CardTypeEnBg}, + }, + }, + { + name: "bulgarian-bulgarian format with double equals", + fileContent: `ябълка == плод +котка == домашно животно`, + want: []WordEntry{ + {Bulgarian: "ябълка", Translation: "плод", NeedsTranslation: false, CardType: internal.CardTypeBgBg}, + {Bulgarian: "котка", Translation: "домашно животно", NeedsTranslation: false, CardType: internal.CardTypeBgBg}, + }, + }, + { + name: "mixed en-bg and bg-bg formats", + fileContent: `ябълка = apple +котка == домашно животно +куче = dog +вода == течност`, + want: []WordEntry{ + {Bulgarian: "ябълка", Translation: "apple", NeedsTranslation: false, CardType: internal.CardTypeEnBg}, + {Bulgarian: "котка", Translation: "домашно животно", NeedsTranslation: false, CardType: internal.CardTypeBgBg}, + {Bulgarian: "куче", Translation: "dog", NeedsTranslation: false, CardType: internal.CardTypeEnBg}, + {Bulgarian: "вода", Translation: "течност", NeedsTranslation: false, CardType: internal.CardTypeBgBg}, }, }, } diff --git a/internal/cardtype.go b/internal/cardtype.go new file mode 100644 index 0000000..9b49236 --- /dev/null +++ b/internal/cardtype.go @@ -0,0 +1,58 @@ +package internal + +import ( + "os" + "path/filepath" + "strings" +) + +// CardType represents the type of flashcard +type CardType string + +const ( + // CardTypeEnBg represents English-Bulgarian cards (default) + CardTypeEnBg CardType = "en-bg" + // CardTypeBgBg represents Bulgarian-Bulgarian cards + CardTypeBgBg CardType = "bg-bg" +) + +// String returns the string representation of the card type +func (ct CardType) String() string { + return string(ct) +} + +// IsBgBg returns true if this is a Bulgarian-Bulgarian card +func (ct CardType) IsBgBg() bool { + return ct == CardTypeBgBg +} + +// DisplayName returns a human-readable name for the card type +func (ct CardType) DisplayName() string { + switch ct { + case CardTypeBgBg: + return "Bulgarian → Bulgarian" + default: + return "English → Bulgarian" + } +} + +// SaveCardType saves the card type to a file in the card directory +func SaveCardType(cardDir string, cardType CardType) error { + cardTypePath := filepath.Join(cardDir, "cardtype.txt") + return os.WriteFile(cardTypePath, []byte(string(cardType)), 0644) +} + +// LoadCardType loads the card type from a card directory +// Returns CardTypeEnBg as default for backwards compatibility +func LoadCardType(cardDir string) CardType { + cardTypePath := filepath.Join(cardDir, "cardtype.txt") + data, err := os.ReadFile(cardTypePath) + if err != nil { + return CardTypeEnBg + } + cardType := CardType(strings.TrimSpace(string(data))) + if cardType == CardTypeBgBg { + return CardTypeBgBg + } + return CardTypeEnBg +} diff --git a/internal/gui/app.go b/internal/gui/app.go index 9374973..cb0a4fe 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -39,6 +39,7 @@ type Application struct { imageDisplay *ImageDisplay audioPlayer *AudioPlayer translationEntry *CustomEntry + cardTypeSelect *widget.Select statusLabel *widget.Label queueStatusLabel *widget.Label imagePromptEntry *CustomMultiLineEntry @@ -57,19 +58,21 @@ type Application struct { deleteButton *ttwidget.Button // State management - currentWord string - currentAudioFile string - currentImage string - currentTranslation string - currentPhonetic string // Full phonetic information - currentJobID int - savedCards []anki.Card - existingWords []string // Words already in anki_cards folder - currentWordIndex int - deleteConfirming bool // Track if we're in delete confirmation mode - quitConfirming bool // Track if we're in quit confirmation mode - wordChangeTimer *time.Timer // Timer for detecting word changes - fileCheckTicker *time.Ticker // Ticker for checking missing files + currentWord string + currentAudioFile string + currentAudioFileBack string // Back audio file for bg-bg cards + currentImage string + currentTranslation string + currentPhonetic string // Full phonetic information + currentCardType string // Card type: "en-bg" or "bg-bg" + currentJobID int + savedCards []anki.Card + existingWords []string // Words already in anki_cards folder + currentWordIndex int + deleteConfirming bool // Track if we're in delete confirmation mode + quitConfirming bool // Track if we're in quit confirmation mode + wordChangeTimer *time.Timer // Timer for detecting word changes + fileCheckTicker *time.Ticker // Ticker for checking missing files // Word processing queue queue *WordQueue @@ -257,6 +260,19 @@ func (a *Application) setupUI() { a.window.Canvas().Unfocus() }) + // Create card type selector + a.cardTypeSelect = widget.NewSelect([]string{"English → Bulgarian", "Bulgarian → Bulgarian"}, func(selected string) { + if selected == "Bulgarian → Bulgarian" { + a.currentCardType = "bg-bg" + a.translationEntry.SetPlaceHolder("Bulgarian definition...") + } else { + a.currentCardType = "en-bg" + a.translationEntry.SetPlaceHolder("English translation...") + } + }) + a.cardTypeSelect.SetSelected("English → Bulgarian") + a.currentCardType = "en-bg" + // Create navigation buttons (tooltips will be set after tooltip layer is created) a.submitButton = ttwidget.NewButton("", a.onSubmit) a.submitButton.Icon = theme.ConfirmIcon() @@ -267,10 +283,11 @@ func (a *Application) setupUI() { a.nextWordBtn = ttwidget.NewButton("", a.onNextWord) a.nextWordBtn.Icon = theme.NavigateNextIcon() - // Create a grid layout for inputs - inputGrid := container.New(layout.NewGridLayout(2), + // Create a grid layout for inputs with card type selector + inputGrid := container.New(layout.NewGridLayout(3), a.wordInput, a.translationEntry, + a.cardTypeSelect, ) inputSection := container.NewBorder( @@ -468,51 +485,55 @@ func (a *Application) Run() { // onSubmit handles word submission func (a *Application) onSubmit() { bulgarianText := strings.TrimSpace(a.wordInput.Text) - englishText := strings.TrimSpace(a.translationEntry.Text) + secondaryText := strings.TrimSpace(a.translationEntry.Text) + isBgBg := a.currentCardType == "bg-bg" // Determine which word to process and if translation is needed var wordToProcess string var needsTranslation bool var translationDirection string - if bulgarianText != "" && englishText != "" { + if isBgBg { + // Bulgarian-Bulgarian mode: both fields should be Bulgarian + if bulgarianText == "" { + return + } + wordToProcess = bulgarianText + needsTranslation = false + a.currentTranslation = secondaryText + } else if bulgarianText != "" && secondaryText != "" { // Both provided - use Bulgarian as primary, no translation needed wordToProcess = bulgarianText needsTranslation = false - a.currentTranslation = englishText - } else if bulgarianText != "" && englishText == "" { + a.currentTranslation = secondaryText + } else if bulgarianText != "" && secondaryText == "" { // Only Bulgarian provided - translate to English wordToProcess = bulgarianText needsTranslation = true translationDirection = "bg-to-en" - } else if bulgarianText == "" && englishText != "" { + } else if bulgarianText == "" && secondaryText != "" { // Only English provided - translate to Bulgarian needsTranslation = true translationDirection = "en-to-bg" - // We'll get the Bulgarian word after translation } else { - // Both empty return } // Handle English to Bulgarian translation first if needed if translationDirection == "en-to-bg" { - a.updateStatus(fmt.Sprintf("Translating '%s' to Bulgarian...", englishText)) - bulgarian, err := a.translateEnglishToBulgarian(englishText) + a.updateStatus(fmt.Sprintf("Translating '%s' to Bulgarian...", secondaryText)) + bulgarian, err := a.translateEnglishToBulgarian(secondaryText) if err != nil { dialog.ShowError(fmt.Errorf("Translation failed: %w", err), a.window) return } wordToProcess = bulgarian a.wordInput.SetText(bulgarian) - a.currentTranslation = englishText - // Update current word for saving + a.currentTranslation = secondaryText a.currentWord = bulgarian - // Save the translation immediately a.saveTranslation() - needsTranslation = false // We've already done the translation, don't translate back + needsTranslation = false } else if translationDirection == "bg-to-en" { - // Handle Bulgarian to English translation immediately a.updateStatus(fmt.Sprintf("Translating '%s' to English...", bulgarianText)) english, err := a.translateWord(bulgarianText) if err != nil { @@ -521,8 +542,7 @@ func (a *Application) onSubmit() { } a.currentTranslation = english a.translationEntry.SetText(english) - needsTranslation = false // We've already done the translation - // Save the translation immediately + needsTranslation = false a.saveTranslation() } @@ -532,6 +552,14 @@ func (a *Application) onSubmit() { return } + // For bg-bg cards, also validate the back text + if isBgBg && secondaryText != "" { + if err := audio.ValidateBulgarianText(secondaryText); err != nil { + dialog.ShowError(fmt.Errorf("invalid back text: %w", err), a.window) + return + } + } + // Get custom prompt from the UI customPrompt := a.imagePromptEntry.Text @@ -540,13 +568,11 @@ func (a *Application) onSubmit() { // Store whether translation is needed and the translation if already provided job.NeedsTranslation = needsTranslation + job.CardType = a.currentCardType if a.currentTranslation != "" { job.Translation = a.currentTranslation } - // Don't clear the input fields yet - they should stay populated - // until the user is ready to enter a new word - // Update status to show word was queued a.updateStatus(fmt.Sprintf("Added '%s' to queue (Job #%d)", wordToProcess, job.ID)) @@ -1004,36 +1030,128 @@ func (a *Application) onRegenerateRandomImage() { }() } -// onRegenerateAudio regenerates audio with a different voice +// onRegenerateAudio regenerates front audio (or single audio for en-bg cards) func (a *Application) onRegenerateAudio() { // Only disable the audio-related buttons a.regenerateAudioBtn.Disable() a.regenerateAllBtn.Disable() - a.showProgress("Regenerating audio...") + + isBgBg := a.currentCardType == "bg-bg" + if isBgBg { + a.showProgress("Regenerating front audio...") + } else { + a.showProgress("Regenerating audio...") + } a.incrementProcessing() // Audio processing starts a.wg.Add(1) go func() { defer a.wg.Done() - defer a.decrementProcessing() // Image processing ends + defer a.decrementProcessing() + + // Store the word we're generating for + wordForGeneration := a.currentWord + + a.startOperation(wordForGeneration) + defer a.endOperation(wordForGeneration) + + // Get or create context for this card + cardCtx, _ := a.getOrCreateCardContext(wordForGeneration) + + // Ensure card directory exists + cardDir, err := a.ensureCardDirectory(wordForGeneration) + if err != nil { + fyne.Do(func() { + a.showError(fmt.Errorf("Failed to create card directory: %w", err)) + }) + return + } + + if isBgBg { + // For bg-bg cards, regenerate only front audio + audioFile, err := a.generateAudioFront(cardCtx, wordForGeneration, cardDir) + if err != nil { + fyne.Do(func() { + a.showError(fmt.Errorf("Front audio regeneration failed: %w", err)) + }) + } else { + a.mu.Lock() + if a.currentWord == wordForGeneration { + a.currentAudioFile = audioFile + a.mu.Unlock() + fyne.Do(func() { + a.mu.Lock() + if a.currentWord == wordForGeneration { + a.audioPlayer.SetAudioFile(audioFile) + } + a.mu.Unlock() + }) + } else { + a.mu.Unlock() + } + } + } else { + // For en-bg cards, regenerate single audio file + audioFile, err := a.generateAudio(cardCtx, wordForGeneration, cardDir) + if err != nil { + fyne.Do(func() { + a.showError(fmt.Errorf("Audio regeneration failed: %w", err)) + }) + } else { + a.mu.Lock() + if a.currentWord == wordForGeneration { + a.currentAudioFile = audioFile + a.mu.Unlock() + fyne.Do(func() { + a.mu.Lock() + if a.currentWord == wordForGeneration { + a.audioPlayer.SetAudioFile(audioFile) + } + a.mu.Unlock() + }) + } else { + a.mu.Unlock() + } + } + } + + fyne.Do(func() { + a.hideProgress() + a.regenerateAudioBtn.Enable() + a.regenerateAllBtn.Enable() + }) + }() +} + +// onRegenerateBackAudio regenerates back audio for bg-bg cards +func (a *Application) onRegenerateBackAudio() { + if a.currentCardType != "bg-bg" { + return + } + + a.regenerateAudioBtn.Disable() + a.regenerateAllBtn.Disable() + a.showProgress("Regenerating back audio...") + + a.incrementProcessing() + + a.wg.Add(1) + go func() { + defer a.wg.Done() + defer a.decrementProcessing() - // Use the current translation to avoid re-translating translation := a.currentTranslation if translation == "" { - // Use the text from translationEntry if currentTranslation is not set translation = strings.TrimSpace(a.translationEntry.Text) } - // Store the word we're generating for wordForGeneration := a.currentWord - a.startOperation(wordForGeneration) // Track operation start - defer a.endOperation(wordForGeneration) // Track operation end + a.startOperation(wordForGeneration) + defer a.endOperation(wordForGeneration) - // Get or create context for this card cardCtx, _ := a.getOrCreateCardContext(wordForGeneration) - // Ensure card directory exists cardDir, err := a.ensureCardDirectory(wordForGeneration) if err != nil { fyne.Do(func() { @@ -1042,22 +1160,20 @@ func (a *Application) onRegenerateAudio() { return } - audioFile, err := a.generateAudio(cardCtx, wordForGeneration, cardDir) + audioFile, err := a.generateAudioBack(cardCtx, translation, cardDir) if err != nil { fyne.Do(func() { - a.showError(fmt.Errorf("Audio regeneration failed: %w", err)) + a.showError(fmt.Errorf("Back audio regeneration failed: %w", err)) }) } else { - // Only update if we're still on the same word a.mu.Lock() if a.currentWord == wordForGeneration { - a.currentAudioFile = audioFile + a.currentAudioFileBack = audioFile a.mu.Unlock() fyne.Do(func() { - // Double-check inside the UI update that we're still on the same word a.mu.Lock() if a.currentWord == wordForGeneration { - a.audioPlayer.SetAudioFile(audioFile) + a.audioPlayer.SetBackAudioFile(audioFile) } a.mu.Unlock() }) @@ -1068,7 +1184,6 @@ func (a *Application) onRegenerateAudio() { fyne.Do(func() { a.hideProgress() - // Re-enable audio-related buttons a.regenerateAudioBtn.Enable() a.regenerateAllBtn.Enable() }) @@ -1416,9 +1531,13 @@ func (a *Application) onShowHotkeys() { ## Regeneration **i/и** Regenerate image **m/м** Random image -**a/а** Regenerate audio +**a/а** Regenerate audio (front for bg-bg) +**A/А** Regenerate back audio (bg-bg only) **r/р** Regenerate all -**p/п** Play audio + +## Playback +**p/п** Play audio (front for bg-bg) +**P/П** Play back audio (bg-bg only) **u/у** Toggle auto-play ## Export & Archive @@ -1694,7 +1813,7 @@ func (a *Application) setupTooltips() { a.regenerateRandomImageBtn.SetToolTip("Random image (m)") } if a.regenerateAudioBtn != nil { - a.regenerateAudioBtn.SetToolTip("Regenerate audio (a)") + a.regenerateAudioBtn.SetToolTip("Regenerate audio (a/A for back)") } if a.regenerateAllBtn != nil { a.regenerateAllBtn.SetToolTip("Regenerate all (r)") @@ -1708,7 +1827,10 @@ func (a *Application) setupTooltips() { // Audio player tooltips if a.audioPlayer != nil && a.audioPlayer.playButton != nil { - a.audioPlayer.playButton.SetToolTip("Play audio (p)") + a.audioPlayer.playButton.SetToolTip("Play audio (p/P for back)") + } + if a.audioPlayer != nil && a.audioPlayer.playBackButton != nil { + a.audioPlayer.playBackButton.SetToolTip("Play back audio (P)") } if a.audioPlayer != nil && a.audioPlayer.stopButton != nil { a.audioPlayer.stopButton.SetToolTip("Stop audio") @@ -1860,12 +1982,22 @@ func (a *Application) processWordJob(job *WordJob) { return } + // Determine if this is a bg-bg card + isBgBg := job.CardType == "bg-bg" + + // Save card type + if isBgBg { + internal.SaveCardType(cardDir, internal.CardTypeBgBg) + } else { + internal.SaveCardType(cardDir, internal.CardTypeEnBg) + } + // Handle translation var translation string var err error - if job.NeedsTranslation { - // Translate word + if job.NeedsTranslation && !isBgBg { + // Translate word (only for en-bg cards) fyne.Do(func() { a.updateStatus(fmt.Sprintf("Translating '%s'...", job.Word)) }) @@ -1900,8 +2032,9 @@ func (a *Application) processWordJob(job *WordJob) { // Create channels for parallel operations type audioResult struct { - file string - err error + file string + fileBack string + err error } type imageResult struct { file string @@ -1925,17 +2058,25 @@ func (a *Application) processWordJob(job *WordJob) { // 1. Audio generation go func() { - a.startOperation(job.Word) // Track operation start - defer a.endOperation(job.Word) // Track operation end + a.startOperation(job.Word) + defer a.endOperation(job.Word) fyne.Do(func() { - a.incrementProcessing() // Audio processing starts + a.incrementProcessing() }) - audioFile, err := a.generateAudio(cardCtx, job.Word, cardDir) - a.decrementProcessing() // Audio processing ends + var audioFile, audioFileBack string + var err error - audioChan <- audioResult{file: audioFile, err: err} + if isBgBg && translation != "" { + // Generate audio for both sides + audioFile, audioFileBack, err = a.generateAudioBgBg(cardCtx, job.Word, translation, cardDir) + } else { + audioFile, err = a.generateAudio(cardCtx, job.Word, cardDir) + } + a.decrementProcessing() + + audioChan <- audioResult{file: audioFile, fileBack: audioFileBack, err: err} }() // 2. Image generation (includes scene description) @@ -2010,7 +2151,7 @@ func (a *Application) processWordJob(job *WordJob) { }() // Wait for all operations to complete - var audioFile, imageFile string + var audioFile, audioFileBack, imageFile string var phoneticInfo string var hasError bool @@ -2021,18 +2162,19 @@ func (a *Application) processWordJob(job *WordJob) { hasError = true } else { audioFile = audioRes.file + audioFileBack = audioRes.fileBack // Update UI with audio immediately if this is still the current job a.mu.Lock() isCurrentJob := a.currentJobID == job.ID if isCurrentJob { a.currentAudioFile = audioFile + a.currentAudioFileBack = audioFileBack } a.mu.Unlock() if isCurrentJob { fyne.Do(func() { - // Double-check that we're still on the same job before updating UI a.mu.Lock() if a.currentJobID != job.ID { a.mu.Unlock() @@ -2041,7 +2183,9 @@ func (a *Application) processWordJob(job *WordJob) { a.mu.Unlock() a.audioPlayer.SetAudioFile(audioFile) - // Enable audio-related actions + if isBgBg && audioFileBack != "" { + a.audioPlayer.SetBackAudioFile(audioFileBack) + } a.regenerateAudioBtn.Enable() }) } @@ -2282,10 +2426,14 @@ func (a *Application) setupKeyboardShortcuts() { if !a.regenerateRandomImageBtn.Disabled() { a.onRegenerateRandomImage() } - case 'а', 'А': // а = a + case 'a', 'а': // a = regenerate front audio if !a.regenerateAudioBtn.Disabled() { a.onRegenerateAudio() } + case 'A', 'А': // A = regenerate back audio (for bg-bg cards) + if a.currentCardType == "bg-bg" && !a.regenerateAudioBtn.Disabled() { + a.onRegenerateBackAudio() + } case 'р', 'Р': // р = r if !a.regenerateAllBtn.Disabled() { a.onRegenerateAll() @@ -2294,10 +2442,14 @@ func (a *Application) setupKeyboardShortcuts() { if !a.deleteButton.Disabled() { a.onDelete() } - case 'п', 'П': // п = p (play audio) + case 'p', 'п': // p = play front audio if a.currentAudioFile != "" { a.audioPlayer.Play() } + case 'P', 'П': // P = play back audio (for bg-bg cards) + if a.currentAudioFileBack != "" { + a.audioPlayer.PlayBack() + } case 'ж', 'Ж': // ж = x a.onExportToAnki() case 'в', 'В': // в = v diff --git a/internal/gui/audio_player.go b/internal/gui/audio_player.go index c0b3a0d..f9d0ad7 100644 --- a/internal/gui/audio_player.go +++ b/internal/gui/audio_player.go @@ -21,13 +21,18 @@ import ( type AudioPlayer struct { widget.BaseWidget - container *fyne.Container - playButton *ttwidget.Button - stopButton *ttwidget.Button - statusLabel *widget.Label - phoneticLabel *widget.Label + container *fyne.Container + playButton *ttwidget.Button + playButtonLabel *widget.Label // Label for front audio button + playBackButton *ttwidget.Button // Play back audio for bg-bg cards + playBackLabel *widget.Label // Label for back audio button + stopButton *ttwidget.Button + statusLabel *widget.Label + phoneticLabel *widget.Label audioFile string + audioFileBack string // Back audio file for bg-bg cards + isBgBg bool // Track if this is a bg-bg card isPlaying bool playCmd *exec.Cmd voiceInfo string // Stores voice and speed info @@ -41,6 +46,15 @@ func NewAudioPlayer() *AudioPlayer { // Create controls (tooltips will be set later after tooltip layer is created) p.playButton = ttwidget.NewButton("", p.onPlay) p.playButton.Icon = theme.MediaPlayIcon() + + p.playButtonLabel = widget.NewLabel("") + p.playButtonLabel.TextStyle = fyne.TextStyle{Bold: true} + + p.playBackButton = ttwidget.NewButton("", p.onPlayBack) + p.playBackButton.Icon = theme.MediaSkipNextIcon() + + p.playBackLabel = widget.NewLabel("") + p.playBackLabel.TextStyle = fyne.TextStyle{Bold: true} p.stopButton = ttwidget.NewButton("", p.onStop) p.stopButton.Icon = theme.MediaStopIcon() @@ -56,11 +70,17 @@ func NewAudioPlayer() *AudioPlayer { // Initially disable controls p.playButton.Disable() + p.playBackButton.Disable() + p.playBackButton.Hide() // Only show for bg-bg cards + p.playBackLabel.Hide() p.stopButton.Disable() // Create main container with phonetic display p.container = container.NewHBox( - p.playButton, + container.NewVBox( + container.NewHBox(p.playButton, p.playButtonLabel), + container.NewHBox(p.playBackButton, p.playBackLabel), + ), p.stopButton, p.phoneticLabel, layout.NewSpacer(), @@ -108,6 +128,13 @@ func (p *AudioPlayer) SetAudioFile(audioFile string) { p.voiceInfo = "" } + // Update button label based on whether this is bg-bg + if p.isBgBg { + p.playButtonLabel.SetText("Front") + } else { + p.playButtonLabel.SetText("") + } + // Format status text with voice and speed info statusText := fmt.Sprintf("Audio: %s%s", filepath.Base(audioFile), p.voiceInfo) p.statusLabel.SetText(statusText) @@ -128,16 +155,53 @@ func (p *AudioPlayer) SetAudioFile(audioFile string) { } } +// SetBackAudioFile sets the back audio file for bg-bg cards +func (p *AudioPlayer) SetBackAudioFile(audioFile string) { + p.audioFileBack = audioFile + if audioFile != "" { + p.isBgBg = true + p.playBackButton.Enable() + p.playBackButton.Show() + p.playBackLabel.SetText("Back") + p.playBackLabel.Show() + // Update front label now that we know it's bg-bg + p.playButtonLabel.SetText("Front") + } else { + p.isBgBg = false + p.playBackButton.Disable() + p.playBackButton.Hide() + p.playBackLabel.SetText("") + p.playBackLabel.Hide() + // Clear front label if not bg-bg + p.playButtonLabel.SetText("") + } + // Refresh container to update layout after show/hide + if p.container != nil { + p.container.Refresh() + } +} + // Clear clears the audio player func (p *AudioPlayer) Clear() { - p.onStop() // Stop any playing audio + p.onStop() p.audioFile = "" + p.audioFileBack = "" + p.isBgBg = false p.isPlaying = false p.voiceInfo = "" p.playButton.Disable() + p.playBackButton.Disable() + p.playBackButton.Hide() + p.playButtonLabel.SetText("") + p.playBackLabel.SetText("") + p.playBackLabel.Hide() p.stopButton.Disable() p.statusLabel.SetText("No audio loaded") p.phoneticLabel.SetText("") + // Refresh container to update layout after hiding back button + if p.container != nil { + p.container.Refresh() + } } // SetPhonetic sets the phonetic transcription text @@ -179,6 +243,35 @@ func (p *AudioPlayer) onPlay() { p.statusLabel.SetText(fmt.Sprintf("Playing: %s%s", filepath.Base(p.audioFile), p.voiceInfo)) } +// onPlayBack handles back audio button click (for bg-bg cards) +func (p *AudioPlayer) onPlayBack() { + if p.audioFileBack == "" { + return + } + + if p.isPlaying { + p.onStop() + } + + // Temporarily swap audio files to play the back audio + originalFile := p.audioFile + p.audioFile = p.audioFileBack + + if err := p.startPlayback(); err != nil { + p.statusLabel.SetText(fmt.Sprintf("Error: %v", err)) + p.audioFile = originalFile + return + } + + p.isPlaying = true + p.playButton.SetIcon(theme.MediaPauseIcon()) + p.stopButton.Enable() + p.statusLabel.SetText(fmt.Sprintf("Playing back audio: %s", filepath.Base(p.audioFileBack))) + + // Restore original file after playback starts + p.audioFile = originalFile +} + // onStop handles stop button click func (p *AudioPlayer) onStop() { if p.playCmd != nil && p.playCmd.Process != nil { @@ -201,6 +294,15 @@ func (p *AudioPlayer) Play() { } } +// PlayBack triggers back audio playback (for bg-bg cards) +func (p *AudioPlayer) PlayBack() { + if !p.playBackButton.Disabled() { + fyne.Do(func() { + p.onPlayBack() + }) + } +} + // startPlayback starts audio playback using platform-specific commands func (p *AudioPlayer) startPlayback() error { var cmd *exec.Cmd diff --git a/internal/gui/generator.go b/internal/gui/generator.go index cfb8ad3..a3a0a4d 100644 --- a/internal/gui/generator.go +++ b/internal/gui/generator.go @@ -152,6 +152,123 @@ func (a *Application) generateAudio(ctx context.Context, word string, cardDir st return outputFile, nil } +// generateAudioFront generates front audio for a bg-bg card +func (a *Application) generateAudioFront(ctx context.Context, word string, cardDir string) (string, error) { + if cardDir == "" { + return "", fmt.Errorf("card directory not provided") + } + + allVoices := []string{"alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse"} + rand.Seed(time.Now().UnixNano()) + voice := allVoices[rand.Intn(len(allVoices))] + speed := 0.90 + rand.Float64()*0.10 + + audioConfig := *a.audioConfig + audioConfig.OpenAIVoice = voice + audioConfig.OpenAISpeed = speed + audioConfig.OutputDir = a.config.OutputDir + + provider, err := audio.NewProvider(&audioConfig) + if err != nil { + return "", err + } + + fmt.Printf("Generating front audio for '%s' with voice: %s, speed: %.2f\n", word, voice, speed) + frontFile := filepath.Join(cardDir, fmt.Sprintf("audio_front.%s", a.config.AudioFormat)) + if err := provider.GenerateAudio(ctx, word, frontFile); err != nil { + return "", fmt.Errorf("failed to generate front audio: %w", err) + } + + // Update metadata + metadataFile := filepath.Join(cardDir, "audio_metadata.txt") + metadata := fmt.Sprintf("voice=%s\nspeed=%.2f\ncardtype=bg-bg\n", voice, speed) + if err := os.WriteFile(metadataFile, []byte(metadata), 0644); err != nil { + fmt.Printf("Warning: Failed to save audio metadata: %v\n", err) + } + + return frontFile, nil +} + +// generateAudioBack generates back audio for a bg-bg card +func (a *Application) generateAudioBack(ctx context.Context, text string, cardDir string) (string, error) { + if cardDir == "" { + return "", fmt.Errorf("card directory not provided") + } + + allVoices := []string{"alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse"} + rand.Seed(time.Now().UnixNano()) + voice := allVoices[rand.Intn(len(allVoices))] + speed := 0.90 + rand.Float64()*0.10 + + audioConfig := *a.audioConfig + audioConfig.OpenAIVoice = voice + audioConfig.OpenAISpeed = speed + audioConfig.OutputDir = a.config.OutputDir + + provider, err := audio.NewProvider(&audioConfig) + if err != nil { + return "", err + } + + fmt.Printf("Generating back audio for '%s' with voice: %s, speed: %.2f\n", text, voice, speed) + backFile := filepath.Join(cardDir, fmt.Sprintf("audio_back.%s", a.config.AudioFormat)) + if err := provider.GenerateAudio(ctx, text, backFile); err != nil { + return "", fmt.Errorf("failed to generate back audio: %w", err) + } + + return backFile, nil +} + +// generateAudioBgBg generates audio for both sides of a bg-bg card +func (a *Application) generateAudioBgBg(ctx context.Context, front, back, cardDir string) (string, string, error) { + if cardDir == "" { + return "", "", fmt.Errorf("card directory not provided") + } + + allVoices := []string{"alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse"} + rand.Seed(time.Now().UnixNano()) + voice := allVoices[rand.Intn(len(allVoices))] + speed := 0.90 + rand.Float64()*0.10 + + audioConfig := *a.audioConfig + audioConfig.OpenAIVoice = voice + audioConfig.OpenAISpeed = speed + audioConfig.OutputDir = a.config.OutputDir + + provider, err := audio.NewProvider(&audioConfig) + if err != nil { + return "", "", err + } + + // Generate front audio + fmt.Printf("Generating front audio for '%s' with voice: %s, speed: %.2f\n", front, voice, speed) + frontFile := filepath.Join(cardDir, fmt.Sprintf("audio_front.%s", a.config.AudioFormat)) + if err := provider.GenerateAudio(ctx, front, frontFile); err != nil { + return "", "", fmt.Errorf("failed to generate front audio: %w", err) + } + + // Generate back audio + fmt.Printf("Generating back audio for '%s' with voice: %s, speed: %.2f\n", back, voice, speed) + backFile := filepath.Join(cardDir, fmt.Sprintf("audio_back.%s", a.config.AudioFormat)) + if err := provider.GenerateAudio(ctx, back, backFile); err != nil { + return frontFile, "", fmt.Errorf("failed to generate back audio: %w", err) + } + + // Save audio attribution + if err := a.saveAudioAttribution(front, frontFile, voice, speed); err != nil { + fmt.Printf("Warning: Failed to save audio attribution: %v\n", err) + } + + // Save voice metadata + metadataFile := filepath.Join(cardDir, "audio_metadata.txt") + metadata := fmt.Sprintf("voice=%s\nspeed=%.2f\ncardtype=bg-bg\n", voice, speed) + if err := os.WriteFile(metadataFile, []byte(metadata), 0644); err != nil { + fmt.Printf("Warning: Failed to save audio metadata: %v\n", err) + } + + return frontFile, backFile, nil +} + // generateImages downloads images for a word func (a *Application) generateImages(ctx context.Context, word string, cardDir string) (string, error) { return a.generateImagesWithPrompt(ctx, word, "", "", cardDir) diff --git a/internal/gui/navigation.go b/internal/gui/navigation.go index 4b1f8bf..82176d2 100644 --- a/internal/gui/navigation.go +++ b/internal/gui/navigation.go @@ -429,26 +429,40 @@ func (a *Application) loadExistingFiles(word string) { frontAudio := filepath.Join(wordDir, fmt.Sprintf("audio_front.%s", a.config.AudioFormat)) backAudio := filepath.Join(wordDir, fmt.Sprintf("audio_back.%s", a.config.AudioFormat)) + fmt.Printf("Looking for front audio at: %s\n", frontAudio) + fmt.Printf("Looking for back audio at: %s\n", backAudio) + if _, err := os.Stat(frontAudio); err == nil { + fmt.Printf("Found front audio file: %s\n", frontAudio) a.currentAudioFile = frontAudio fyne.Do(func() { a.audioPlayer.SetAudioFile(frontAudio) }) + } else { + fmt.Printf("Front audio file not found (error: %v)\n", err) } + if _, err := os.Stat(backAudio); err == nil { + fmt.Printf("Found back audio file: %s\n", backAudio) a.currentAudioFileBack = backAudio fyne.Do(func() { a.audioPlayer.SetBackAudioFile(backAudio) }) + } else { + fmt.Printf("Back audio file not found (error: %v)\n", err) } } else { // For en-bg cards, load standard audio file audioFile := filepath.Join(wordDir, fmt.Sprintf("audio.%s", a.config.AudioFormat)) + fmt.Printf("Looking for audio at: %s\n", audioFile) if _, err := os.Stat(audioFile); err == nil { + fmt.Printf("Found audio file: %s\n", audioFile) a.currentAudioFile = audioFile fyne.Do(func() { a.audioPlayer.SetAudioFile(audioFile) }) + } else { + fmt.Printf("Audio file not found (error: %v)\n", err) } // Hide back audio button for en-bg cards a.currentAudioFileBack = "" diff --git a/internal/gui/queue.go b/internal/gui/queue.go index 1c52c9f..86c6ffd 100644 --- a/internal/gui/queue.go +++ b/internal/gui/queue.go @@ -13,6 +13,7 @@ type WordJob struct { Word string Translation string AudioFile string + AudioFileBack string // Back audio file (only for bg-bg cards) ImageFile string // Changed from ImageFiles []string to single image Status JobStatus Error error @@ -20,6 +21,7 @@ type WordJob struct { CompletedAt time.Time CustomPrompt string // Custom prompt for image generation NeedsTranslation bool // Whether translation is needed + CardType string // Card type: "en-bg" or "bg-bg" } // JobStatus represents the current state of a job diff --git a/internal/processor/processor.go b/internal/processor/processor.go index 4bb6b0a..0947961 100644 --- a/internal/processor/processor.go +++ b/internal/processor/processor.go @@ -103,10 +103,9 @@ func (p *Processor) ProcessBatch() error { fmt.Printf(" [DEBUG] Word is not fully processed, will process it\n") } - if err := p.ProcessWordWithTranslation(entry.Bulgarian, entry.Translation); err != nil { + if err := p.ProcessWordWithTranslationAndType(entry.Bulgarian, entry.Translation, entry.CardType); err != nil { fmt.Fprintf(os.Stderr, "Error processing '%s': %v\n", entry.Bulgarian, err) errorCount++ - // Continue with next word } else { processedCount++ } @@ -141,38 +140,52 @@ func (p *Processor) ProcessSingleWord(word string) error { return p.ProcessWordWithTranslation(word, "") } -// ProcessWordWithTranslation processes a word with optional provided translation +// ProcessWordWithTranslation processes a word with optional provided translation (en-bg mode) func (p *Processor) ProcessWordWithTranslation(word, providedTranslation string) error { + return p.ProcessWordWithTranslationAndType(word, providedTranslation, internal.CardTypeEnBg) +} + +// ProcessWordWithTranslationAndType processes a word with optional provided translation and card type +func (p *Processor) ProcessWordWithTranslationAndType(word, providedTranslation string, cardType internal.CardType) error { var translationText string - // Use provided translation if available, otherwise translate + // For bg-bg cards, translation is the back side (Bulgarian definition) + // For en-bg cards, translation is the English word if providedTranslation != "" { translationText = providedTranslation - fmt.Printf(" Using provided translation: %s\n", translationText) - } else { - // Translate the word first + if cardType.IsBgBg() { + fmt.Printf(" Using provided definition: %s\n", translationText) + } else { + fmt.Printf(" Using provided translation: %s\n", translationText) + } + } else if !cardType.IsBgBg() { + // Only translate to English for en-bg cards fmt.Printf(" Translating to English...\n") var err error translationText, err = p.translator.TranslateWord(word) if err != nil { fmt.Printf(" Warning: Translation failed: %v\n", err) - translationText = "" // Continue without translation + translationText = "" } else { fmt.Printf(" Translation: %s\n", translationText) } } + // Find or create word directory + wordDir := p.findOrCreateWordDirectory(word) + + // Save card type + if err := internal.SaveCardType(wordDir, cardType); err != nil { + fmt.Printf(" Warning: Failed to save card type: %v\n", err) + } + // Store translation for Anki export if translationText != "" { p.translationCache.Add(word, translationText) - // Find or create word directory - wordDir := p.findOrCreateWordDirectory(word) - // 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) } @@ -183,9 +196,7 @@ func (p *Processor) ProcessWordWithTranslation(word, providedTranslation string) // Fetch phonetic information fmt.Printf(" Fetching phonetic information...\n") - wordDir := p.findOrCreateWordDirectory(word) if err := p.phoneticFetcher.FetchAndSave(word, wordDir); err != nil { - // Don't fail the whole process if phonetic info fails fmt.Printf(" Warning: Failed to fetch phonetic info: %v\n", err) } else { fmt.Printf(" Saved phonetic information\n") @@ -194,8 +205,15 @@ func (p *Processor) ProcessWordWithTranslation(word, providedTranslation string) // Generate audio if !p.flags.SkipAudio { fmt.Printf(" Generating audio...\n") - if err := p.generateAudio(word); err != nil { - return fmt.Errorf("audio generation failed: %w", err) + if cardType.IsBgBg() { + // Generate audio for both sides + if err := p.generateAudioBgBg(word, translationText); err != nil { + return fmt.Errorf("audio generation failed: %w", err) + } + } else { + if err := p.generateAudio(word); err != nil { + return fmt.Errorf("audio generation failed: %w", err) + } } } @@ -242,8 +260,39 @@ func (p *Processor) generateAudio(word string) error { return nil } +// generateAudioBgBg generates audio files for both sides of a bg-bg card +func (p *Processor) generateAudioBgBg(front, back string) error { + allVoicesList := []string{"alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse"} + + // Select a random voice (same voice for both sides for consistency) + voice := allVoicesList[rand.Intn(len(allVoicesList))] + if p.flags.OpenAIVoice != "" { + voice = p.flags.OpenAIVoice + } + fmt.Printf(" Using voice: %s\n", voice) + + // Generate front audio + fmt.Printf(" Generating front audio for '%s'...\n", front) + if err := p.generateAudioWithVoiceAndFilename(front, voice, "audio_front"); err != nil { + return fmt.Errorf("failed to generate front audio: %w", err) + } + + // Generate back audio + fmt.Printf(" Generating back audio for '%s'...\n", back) + if err := p.generateAudioWithVoiceAndFilename(back, voice, "audio_back"); err != nil { + return fmt.Errorf("failed to generate back audio: %w", err) + } + + return nil +} + // generateAudioWithVoice generates audio for a word with a specific voice func (p *Processor) generateAudioWithVoice(word, voice string) error { + return p.generateAudioWithVoiceAndFilename(word, voice, "audio") +} + +// generateAudioWithVoiceAndFilename generates audio for a word with a specific voice and filename +func (p *Processor) generateAudioWithVoiceAndFilename(word, voice, filenameBase string) error { // Generate random speed between 0.90 and 1.00 if not explicitly set speed := p.flags.OpenAISpeed if p.flags.OpenAISpeed == 0.9 && !viper.IsSet("audio.openai_speed") { @@ -288,12 +337,12 @@ func (p *Processor) generateAudioWithVoice(word, voice string) error { // Find existing card directory or create new one wordDir := p.findOrCreateWordDirectory(word) - // Add voice name to filename if generating multiple voices + // Build filename using the provided base var outputFile string - if p.flags.AllVoices { - outputFile = filepath.Join(wordDir, fmt.Sprintf("audio_%s.%s", voice, p.flags.AudioFormat)) + if p.flags.AllVoices && filenameBase == "audio" { + outputFile = filepath.Join(wordDir, fmt.Sprintf("%s_%s.%s", filenameBase, voice, p.flags.AudioFormat)) } else { - outputFile = filepath.Join(wordDir, fmt.Sprintf("audio.%s", p.flags.AudioFormat)) + outputFile = filepath.Join(wordDir, fmt.Sprintf("%s.%s", filenameBase, p.flags.AudioFormat)) } // Generate the audio @@ -581,23 +630,42 @@ func (p *Processor) isWordFullyProcessed(word string) bool { // 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 { + // Load card type to determine required audio files + cardType := internal.LoadCardType(wordDir) + + if cardType.IsBgBg() { + // For bg-bg cards, check for audio_front and audio_back + frontAudio := filepath.Join(wordDir, fmt.Sprintf("audio_front.%s", p.flags.AudioFormat)) + backAudio := filepath.Join(wordDir, fmt.Sprintf("audio_back.%s", p.flags.AudioFormat)) + if _, err := os.Stat(frontAudio); os.IsNotExist(err) { if os.Getenv("DEBUG_BATCH") != "" { - fmt.Printf(" [DEBUG] No audio file found: %s or pattern %s\n", audioFile, audioPattern) + fmt.Printf(" [DEBUG] No front audio file found: %s\n", frontAudio) + } + return false + } + if _, err := os.Stat(backAudio); os.IsNotExist(err) { + if os.Getenv("DEBUG_BATCH") != "" { + fmt.Printf(" [DEBUG] No back audio file found: %s\n", backAudio) + } + return false + } + } else { + // For en-bg cards, check for standard audio file + requiredFiles = append(requiredFiles, + "audio_attribution.txt", + "audio_metadata.txt", + ) + + audioFile := filepath.Join(wordDir, fmt.Sprintf("audio.%s", p.flags.AudioFormat)) + if _, err := os.Stat(audioFile); os.IsNotExist(err) { + 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 } - return false // No audio file found } } } |
