summaryrefslogtreecommitdiff
path: root/internal
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 /internal
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.
Diffstat (limited to 'internal')
-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
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