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 | |
| 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.
| -rw-r--r-- | PLAN.md | 147 | ||||
| -rw-r--r-- | input.txt | 2 | ||||
| -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 | ||||
| -rwxr-xr-x | main | bin | 0 -> 39506328 bytes |
14 files changed, 1184 insertions, 228 deletions
@@ -0,0 +1,147 @@ +# Plan: Bulgarian-Bulgarian Flashcard Support + +## Status: ✅ IMPLEMENTED + +All features have been implemented and tested. + +## Overview +Add support for Bulgarian-Bulgarian flashcards alongside the existing English-Bulgarian mode. This enables monolingual learning where both sides of the flashcard are in Bulgarian (e.g., word and definition, or word and synonym). + +## Current State +- ✅ English-Bulgarian and Bulgarian-Bulgarian flashcards are supported +- ✅ File-based storage uses `translation.txt` and `cardtype.txt` +- ✅ Audio is generated for both sides of bg-bg cards +- ✅ Batch import supports: `english=bulgarian` (en-bg) and `bulgarian1==bulgarian2` (bg-bg) + +--- + +## 1. Internal Database/Storage Changes ✅ + +### 1.1 Add Card Type Indicator +**File:** `internal/cardtype.go` (NEW) + +- Created new `CardType` type with `CardTypeEnBg` and `CardTypeBgBg` constants +- Added `SaveCardType()` and `LoadCardType()` functions +- Backwards compatible: missing `cardtype.txt` defaults to `en-bg` + +### 1.2 Update Structs ✅ +**Files:** `internal/batch/processor.go`, `internal/anki/generator.go`, `internal/gui/queue.go` + +Added `CardType` field to: +- `WordEntry` struct +- `Card` struct (with `AudioFileBack` for bg-bg) +- `WordJob` struct (with `AudioFileBack` and `CardType`) + +### 1.3 Second Audio File ✅ +**File:** `internal/processor/processor.go`, `internal/gui/generator.go` + +For `bg-bg` cards, stores two audio files: +- `audio_front.mp3` - pronunciation of first Bulgarian term +- `audio_back.mp3` - pronunciation of second Bulgarian term + +--- + +## 2. Audio Generation Changes ✅ + +### 2.1 Generate Audio for Both Sides +**Files:** `internal/processor/processor.go`, `internal/gui/generator.go` + +- Added `generateAudioBgBg()` function for CLI processor +- Added `generateAudioBgBg()` function for GUI +- Both sides use the same voice for consistency + +### 2.2 Update Processor ✅ +- Detects card type and calls appropriate audio generation +- Saves to `audio_front.mp3` and `audio_back.mp3` for `bg-bg` + +--- + +## 3. GUI Support ✅ + +### 3.1 Card Type Selector +**File:** `internal/gui/app.go` + +Added dropdown selector: +- "English → Bulgarian" (default) +- "Bulgarian → Bulgarian" + +### 3.2 Update Input Labels ✅ +When `bg-bg` is selected: +- Translation placeholder changes to "Bulgarian definition..." + +### 3.3 Audio Preview ✅ +**File:** `internal/gui/audio_player.go` + +For `bg-bg` cards: +- Added "Play Back Audio" button (skip next icon) +- Button only visible for bg-bg cards + +### 3.4 Navigation Updates ✅ +**File:** `internal/gui/navigation.go` + +- Loads card type when navigating to existing cards +- Updates card type selector based on loaded card +- Loads both front and back audio for bg-bg cards + +--- + +## 4. Batch Importer Support ✅ + +### 4.1 Input Format Detection +**File:** `internal/batch/processor.go` + +Detects card type from line format: +- `bulgarian1==bulgarian2` → `bg-bg` mode (double equals) +- `english=bulgarian` or `bulgarian=english` → `en-bg` mode (single equals) + +### 4.2 Parsing Logic ✅ +``` +Line contains "==" → Split on "==" → bg-bg card +Line contains "=" (single) → Split on "=" → en-bg card +``` + +### 4.3 Processing Pipeline ✅ +- Parses and detects card type per line +- Passes card type through the processing pipeline +- Generates appropriate audio files based on type + +--- + +## 5. Anki Export Changes ✅ + +### 5.1 Update Export Format +**File:** `internal/anki/apkg_generator.go` + +For `bg-bg` cards: +- Created separate note type "Bulgarian-Bulgarian from TotalRecall" +- Fields: BulgarianFront, BulgarianBack, Image, AudioFront, AudioBack, Notes +- Includes both audio files in the Anki package + +### 5.2 Card Templates ✅ +- Forward: Shows front Bulgarian word + front audio, answer shows back + back audio +- Reverse: Shows back Bulgarian word + back audio, answer shows front + front audio +- Different CSS styling for front/back Bulgarian text + +--- + +## Test Coverage ✅ + +Added new tests in `internal/batch/processor_test.go`: +- `bulgarian-bulgarian_format_with_double_equals` +- `mixed_en-bg_and_bg-bg_formats` + +All tests pass: +``` +=== RUN TestReadBatchFile/bulgarian-bulgarian_format_with_double_equals +--- PASS: TestReadBatchFile/bulgarian-bulgarian_format_with_double_equals +=== RUN TestReadBatchFile/mixed_en-bg_and_bg-bg_formats +--- PASS: TestReadBatchFile/mixed_en-bg_and_bg-bg_formats +``` + +--- + +## Migration/Compatibility ✅ + +- Existing cards without `cardtype.txt` default to `en-bg` +- No migration needed for existing data +- Batch files can mix `en-bg` and `bg-bg` entries in the same file diff --git a/input.txt b/input.txt new file mode 100644 index 0000000..e69dd22 --- /dev/null +++ b/input.txt @@ -0,0 +1,2 @@ +котка == домашно животно +ябълка = apple 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 i |
