summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-01-21 21:43:40 +0200
committerPaul Buetow <paul@buetow.org>2026-01-21 21:43:40 +0200
commit8a4b935792c50101cf65b36e44f376c90c2d08c1 (patch)
tree2a8288e935fe89738fbc1243253bb638dc2620a8
parent2bd22f79f739136a7d30cf156979e2f2b23b7c65 (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.md147
-rw-r--r--input.txt2
-rw-r--r--internal/anki/apkg_generator.go304
-rw-r--r--internal/anki/generator.go57
-rw-r--r--internal/batch/processor.go95
-rw-r--r--internal/batch/processor_test.go70
-rw-r--r--internal/cardtype.go58
-rw-r--r--internal/gui/app.go292
-rw-r--r--internal/gui/audio_player.go116
-rw-r--r--internal/gui/generator.go117
-rw-r--r--internal/gui/navigation.go14
-rw-r--r--internal/gui/queue.go2
-rw-r--r--internal/processor/processor.go138
-rwxr-xr-xmainbin0 -> 39506328 bytes
14 files changed, 1184 insertions, 228 deletions
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..35b7f6e
--- /dev/null
+++ b/PLAN.md
@@ -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/car