summaryrefslogtreecommitdiff
path: root/internal/anki
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/anki
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/anki')
-rw-r--r--internal/anki/apkg_generator.go304
-rw-r--r--internal/anki/generator.go57
2 files changed, 298 insertions, 63 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
}