summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-18 19:31:44 +0300
committerPaul Buetow <paul@buetow.org>2025-07-18 19:31:44 +0300
commitcd3b1e5b2fab8075303c064ba33996a0250cd6a6 (patch)
treebf280f05decd692dd3f477815acdb10f7b781144
parentaa84a890ba80ba70a6ac311786cb9d80ae3d9e42 (diff)
fix: use timestamp+hash naming for directories in CLI to match GUI
- Updated CLI code to use internal.GenerateCardID() for directory names - Changed file naming to match GUI: word.txt, translation.txt, audio.mp3, image.jpg/png - Created internal/utils.go with shared GenerateCardID and SanitizeFilename functions - Fixed all references to use the new naming system consistently - Ensures both CLI and GUI use the same directory and file naming convention 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
-rw-r--r--cmd/totalrecall/main.go58
-rw-r--r--internal/anki/generator.go36
-rw-r--r--internal/gui/app.go607
-rw-r--r--internal/gui/audio_player.go54
-rw-r--r--internal/gui/generator.go52
-rw-r--r--internal/gui/navigation.go159
-rw-r--r--internal/utils.go43
-rw-r--r--test_gui_improvements.md46
8 files changed, 633 insertions, 422 deletions
diff --git a/cmd/totalrecall/main.go b/cmd/totalrecall/main.go
index 04174bc..8258050 100644
--- a/cmd/totalrecall/main.go
+++ b/cmd/totalrecall/main.go
@@ -338,20 +338,26 @@ func generateAudioWithVoice(word, voice string) error {
// Generate audio file
ctx := context.Background()
- filename := sanitizeFilename(word)
+ cardID := internal.GenerateCardID(word)
// Create subdirectory for this word
- wordDir := filepath.Join(outputDir, filename)
+ wordDir := filepath.Join(outputDir, cardID)
if err := os.MkdirAll(wordDir, 0755); err != nil {
return fmt.Errorf("failed to create word directory: %w", err)
}
+ // Save word metadata if not already present
+ metadataFile := filepath.Join(wordDir, "word.txt")
+ if _, err := os.Stat(metadataFile); os.IsNotExist(err) {
+ os.WriteFile(metadataFile, []byte(word), 0644)
+ }
+
// Add voice name to filename if generating multiple voices
var outputFile string
if allVoices {
- outputFile = filepath.Join(wordDir, fmt.Sprintf("%s_%s.%s", filename, voice, audioFormat))
+ outputFile = filepath.Join(wordDir, fmt.Sprintf("audio_%s.%s", voice, audioFormat))
} else {
- outputFile = filepath.Join(wordDir, fmt.Sprintf("%s.%s", filename, audioFormat))
+ outputFile = filepath.Join(wordDir, fmt.Sprintf("audio.%s", audioFormat))
}
// Generate the audio
@@ -418,18 +424,24 @@ func downloadImages(word string) error {
}
// Create subdirectory for this word
- filename := sanitizeFilename(word)
- wordDir := filepath.Join(outputDir, filename)
+ cardID := internal.GenerateCardID(word)
+ wordDir := filepath.Join(outputDir, cardID)
if err := os.MkdirAll(wordDir, 0755); err != nil {
return fmt.Errorf("failed to create word directory: %w", err)
}
+ // Save word metadata if not already present
+ metadataFile := filepath.Join(wordDir, "word.txt")
+ if _, err := os.Stat(metadataFile); os.IsNotExist(err) {
+ os.WriteFile(metadataFile, []byte(word), 0644)
+ }
+
// Create downloader
downloadOpts := &image.DownloadOptions{
OutputDir: wordDir,
OverwriteExisting: true, // Allow overwriting existing files
CreateDir: true,
- FileNamePattern: "{word}_{index}",
+ FileNamePattern: "image",
MaxSizeBytes: 5 * 1024 * 1024, // 5MB
}
@@ -446,24 +458,6 @@ func downloadImages(word string) error {
return nil
}
-func sanitizeFilename(s string) string {
- // Simple filename sanitization
- result := ""
- for _, r := range s {
- if isAlphaNumeric(r) || r == '-' || r == '_' {
- result += string(r)
- } else {
- result += "_"
- }
- }
- return result
-}
-
-func isAlphaNumeric(r rune) bool {
- return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
- (r >= '0' && r <= '9') || (r >= 'а' && r <= 'я') ||
- (r >= 'А' && r <= 'Я')
-}
func splitLines(s string) []string {
// Simple line splitter
@@ -533,7 +527,7 @@ func generateAnkiFile() error {
}
} else {
// Generate APKG
- outputPath := filepath.Join(outputDir, fmt.Sprintf("%s.apkg", sanitizeFilename(deckName)))
+ outputPath := filepath.Join(outputDir, fmt.Sprintf("%s.apkg", internal.SanitizeFilename(deckName)))
if err := gen.GenerateAPKG(outputPath, deckName); err != nil {
return fmt.Errorf("failed to generate APKG: %w", err)
}
@@ -674,15 +668,21 @@ func translateWord(word string) (string, error) {
func saveTranslation(word, translation string) error {
// Save translation to a text file
- filename := sanitizeFilename(word)
- wordDir := filepath.Join(outputDir, filename)
+ cardID := internal.GenerateCardID(word)
+ wordDir := filepath.Join(outputDir, cardID)
// Ensure directory exists
if err := os.MkdirAll(wordDir, 0755); err != nil {
return fmt.Errorf("failed to create word directory: %w", err)
}
- outputFile := filepath.Join(wordDir, fmt.Sprintf("%s_translation.txt", filename))
+ // Save word metadata if not already present
+ metadataFile := filepath.Join(wordDir, "word.txt")
+ if _, err := os.Stat(metadataFile); os.IsNotExist(err) {
+ os.WriteFile(metadataFile, []byte(word), 0644)
+ }
+
+ outputFile := filepath.Join(wordDir, "translation.txt")
content := fmt.Sprintf("%s = %s\n", word, translation)
diff --git a/internal/anki/generator.go b/internal/anki/generator.go
index 21b1995..0682d94 100644
--- a/internal/anki/generator.go
+++ b/internal/anki/generator.go
@@ -147,30 +147,38 @@ func (g *Generator) GenerateFromDirectory(dir string) error {
}
wordDir := filepath.Join(dir, entry.Name())
- sanitizedWord := entry.Name()
// Create card for this word
card := Card{}
- // Try to load translation and get original word
- translationFile := filepath.Join(wordDir, fmt.Sprintf("%s_translation.txt", sanitizedWord))
+ // Read the original Bulgarian word from word.txt
+ wordFile := filepath.Join(wordDir, "word.txt")
+ if data, err := os.ReadFile(wordFile); err == nil {
+ card.Bulgarian = strings.TrimSpace(string(data))
+ } else {
+ // Try old format with underscore for backward compatibility
+ wordFile = filepath.Join(wordDir, "_word.txt")
+ if data, err := os.ReadFile(wordFile); err == nil {
+ card.Bulgarian = strings.TrimSpace(string(data))
+ } else {
+ // Skip directories without word.txt
+ continue
+ }
+ }
+
+ // Try to load translation
+ translationFile := filepath.Join(wordDir, "translation.txt")
if data, err := os.ReadFile(translationFile); err == nil {
content := string(data)
if parts := strings.Split(content, "="); len(parts) >= 2 {
- card.Bulgarian = strings.TrimSpace(parts[0])
card.Translation = strings.TrimSpace(parts[1])
}
}
- // If no Bulgarian word found from translation, use directory name
- if card.Bulgarian == "" {
- card.Bulgarian = sanitizedWord
- }
-
// Look for audio file
audioFormats := []string{"mp3", "wav"}
for _, format := range audioFormats {
- audioFile := filepath.Join(wordDir, fmt.Sprintf("%s.%s", sanitizedWord, format))
+ audioFile := filepath.Join(wordDir, fmt.Sprintf("audio.%s", format))
if _, err := os.Stat(audioFile); err == nil {
card.AudioFile = audioFile
break
@@ -179,10 +187,8 @@ func (g *Generator) GenerateFromDirectory(dir string) error {
// Look for image files
imagePatterns := []string{
- fmt.Sprintf("%s.jpg", sanitizedWord),
- fmt.Sprintf("%s.png", sanitizedWord),
- fmt.Sprintf("%s_1.jpg", sanitizedWord),
- fmt.Sprintf("%s_1.png", sanitizedWord),
+ "image.jpg",
+ "image.png",
}
for _, pattern := range imagePatterns {
imageFile := filepath.Join(wordDir, pattern)
@@ -193,7 +199,7 @@ func (g *Generator) GenerateFromDirectory(dir string) error {
}
// Load phonetic information as notes
- phoneticFile := filepath.Join(wordDir, fmt.Sprintf("%s_phonetic.txt", sanitizedWord))
+ phoneticFile := filepath.Join(wordDir, "phonetic.txt")
if data, err := os.ReadFile(phoneticFile); err == nil {
// Preserve line breaks by converting \n to <br> for HTML display
notes := strings.TrimSpace(string(data))
diff --git a/internal/gui/app.go b/internal/gui/app.go
index 7e2e65f..c4b609e 100644
--- a/internal/gui/app.go
+++ b/internal/gui/app.go
@@ -16,10 +16,10 @@ import (
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
- "github.com/sashabaranov/go-openai"
fynetooltip "github.com/dweymouth/fyne-tooltip"
ttwidget "github.com/dweymouth/fyne-tooltip/widget"
-
+ "github.com/sashabaranov/go-openai"
+
"codeberg.org/snonux/totalrecall/internal"
"codeberg.org/snonux/totalrecall/internal/anki"
"codeberg.org/snonux/totalrecall/internal/audio"
@@ -30,51 +30,52 @@ type Application struct {
// Fyne components
app fyne.App
window fyne.Window
-
+
// UI elements
- wordInput *widget.Entry
- submitButton *ttwidget.Button
- imageDisplay *ImageDisplay
- audioPlayer *AudioPlayer
+ wordInput *widget.Entry
+ submitButton *ttwidget.Button
+ imageDisplay *ImageDisplay
+ audioPlayer *AudioPlayer
translationEntry *widget.Entry
- statusLabel *widget.Label
+ statusLabel *widget.Label
queueStatusLabel *widget.Label
imagePromptEntry *widget.Entry
phoneticDisplay *widget.Label
-
+
// Navigation buttons
- prevWordBtn *ttwidget.Button
- nextWordBtn *ttwidget.Button
-
+ prevWordBtn *ttwidget.Button
+ nextWordBtn *ttwidget.Button
+
// Action buttons
- keepButton *ttwidget.Button
- regenerateImageBtn *ttwidget.Button
+ keepButton *ttwidget.Button
+ regenerateImageBtn *ttwidget.Button
regenerateRandomImageBtn *ttwidget.Button
- regenerateAudioBtn *ttwidget.Button
- regenerateAllBtn *ttwidget.Button
- deleteButton *ttwidget.Button
-
+ regenerateAudioBtn *ttwidget.Button
+ regenerateAllBtn *ttwidget.Button
+ deleteButton *ttwidget.Button
+
// State management
- currentWord string
- currentAudioFile string
- currentImage string
+ currentWord string
+ currentAudioFile string
+ currentImage string
currentTranslation string
- 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
-
+ 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
+ wordChangeTimer *time.Timer // Timer for detecting word changes
+
// Word processing queue
queue *WordQueue
-
+
// Processing statistics
- processingCount int // Number of tasks currently processing (audio/image)
-
+ processingCount int // Number of tasks currently processing (audio/image)
+
// Configuration
config *Config
audioConfig *audio.Config
-
+
// Background processing
ctx context.Context
cancel context.CancelFunc
@@ -84,20 +85,20 @@ type Application struct {
// Config holds GUI application configuration
type Config struct {
- OutputDir string
- AudioFormat string
- ImageProvider string
- EnableCache bool
- OpenAIKey string
+ OutputDir string
+ AudioFormat string
+ ImageProvider string
+ EnableCache bool
+ OpenAIKey string
}
// DefaultConfig returns default GUI configuration
func DefaultConfig() *Config {
return &Config{
- OutputDir: "./anki_cards",
- AudioFormat: "mp3",
- ImageProvider: "openai",
- EnableCache: true,
+ OutputDir: "./anki_cards",
+ AudioFormat: "mp3",
+ ImageProvider: "openai",
+ EnableCache: true,
}
}
@@ -106,24 +107,24 @@ func New(config *Config) *Application {
if config == nil {
config = DefaultConfig()
}
-
+
// Ensure output directory exists
os.MkdirAll(config.OutputDir, 0755)
-
+
ctx, cancel := context.WithCancel(context.Background())
-
+
app := &Application{
- app: app.New(),
- config: config,
- ctx: ctx,
- cancel: cancel,
+ app: app.New(),
+ config: config,
+ ctx: ctx,
+ cancel: cancel,
savedCards: make([]anki.Card, 0),
}
-
+
// Initialize the word processing queue
app.queue = NewWordQueue(ctx)
app.queue.SetCallbacks(app.onQueueStatusUpdate, app.onJobComplete)
-
+
// Set up audio configuration
app.audioConfig = &audio.Config{
Provider: "openai",
@@ -137,15 +138,15 @@ func New(config *Config) *Application {
EnableCache: config.EnableCache,
CacheDir: "./.audio_cache",
}
-
+
app.setupUI()
-
+
// Scan existing words in output directory
app.scanExistingWords()
-
+
// Update initial queue status
app.updateQueueStatus()
-
+
return app
}
@@ -153,11 +154,11 @@ func New(config *Config) *Application {
func (a *Application) setupUI() {
a.window = a.app.NewWindow(fmt.Sprintf("TotalRecall v%s - Bulgarian Flashcard Generator", internal.Version))
a.window.Resize(fyne.NewSize(800, 600))
-
+
// Create input section with navigation
a.wordInput = widget.NewEntry()
a.wordInput.SetPlaceHolder("Bulgarian word...")
- a.wordInput.OnSubmitted = func(string) {
+ a.wordInput.OnSubmitted = func(string) {
a.onSubmit()
// Remove focus from input field after submit
a.window.Canvas().Unfocus()
@@ -166,12 +167,27 @@ func (a *Application) setupUI() {
// When user starts typing a new word, disconnect from any previous job
// to prevent mix-ups with background processing
a.mu.Lock()
+ oldWord := a.currentWord
if a.currentJobID != 0 && text != a.currentWord {
a.currentJobID = 0
}
a.mu.Unlock()
+
+ // Check for word change when user stops typing
+ if oldWord != "" && text != "" && oldWord != text {
+ // Set a timer to detect when user stops typing
+ if a.wordChangeTimer != nil {
+ a.wordChangeTimer.Stop()
+ }
+ a.wordChangeTimer = time.AfterFunc(1*time.Second, func() {
+ finalWord := strings.TrimSpace(a.wordInput.Text)
+ if finalWord != "" && finalWord != oldWord {
+ a.handleWordChange(oldWord, finalWord)
+ }
+ })
+ }
}
-
+
// Create translation entry
a.translationEntry = widget.NewEntry()
a.translationEntry.SetPlaceHolder("English translation...")
@@ -183,47 +199,47 @@ func (a *Application) setupUI() {
a.currentJobID = 0
}
a.mu.Unlock()
-
+
a.currentTranslation = text
// Save the updated translation immediately
a.saveTranslation()
}
- a.translationEntry.OnSubmitted = func(string) {
+ a.translationEntry.OnSubmitted = func(string) {
a.onSubmit()
// Remove focus from input field after submit
a.window.Canvas().Unfocus()
}
-
+
// Create navigation buttons with tooltips
a.submitButton = ttwidget.NewButton("", a.onSubmit)
a.submitButton.Icon = theme.ConfirmIcon()
a.submitButton.SetToolTip("Generate word (G)")
-
+
a.prevWordBtn = ttwidget.NewButton("", a.onPrevWord)
a.prevWordBtn.Icon = theme.NavigateBackIcon()
a.prevWordBtn.SetToolTip("Previous word (←)")
-
+
a.nextWordBtn = ttwidget.NewButton("", a.onNextWord)
a.nextWordBtn.Icon = theme.NavigateNextIcon()
a.nextWordBtn.SetToolTip("Next word (→)")
-
+
// Create a grid layout for inputs
inputGrid := container.New(layout.NewGridLayout(2),
a.wordInput,
a.translationEntry,
)
-
+
inputSection := container.NewBorder(
- nil, nil,
+ nil, nil,
a.prevWordBtn,
container.NewHBox(a.submitButton, a.nextWordBtn),
inputGrid,
)
-
+
// Create display section
a.imageDisplay = NewImageDisplay()
a.audioPlayer = NewAudioPlayer()
-
+
// Create image prompt entry
a.imagePromptEntry = widget.NewMultiLineEntry()
a.imagePromptEntry.SetPlaceHolder("Custom image prompt (optional)... Press Escape to exit field")
@@ -232,7 +248,7 @@ func (a *Application) setupUI() {
// Save the image prompt immediately when changed
a.saveImagePrompt()
}
-
+
// Create container for image and prompt with proper sizing
promptContainer := container.NewBorder(
widget.NewLabel("Image Prompt:"),
@@ -241,23 +257,23 @@ func (a *Application) setupUI() {
nil,
container.NewScroll(a.imagePromptEntry),
)
-
+
// Use a split container to give equal space to image and prompt
imageSection := container.NewHSplit(
a.imageDisplay,
promptContainer,
)
imageSection.SetOffset(0.5) // Equal 50/50 split
-
+
// Create phonetic display section
a.phoneticDisplay = widget.NewLabel("Phonetic information will appear here...")
a.phoneticDisplay.Wrapping = fyne.TextWrapWord
-
+
// Set minimum size for phonetic display (reduced to ~5 lines of text)
// Assuming ~20 pixels per line with standard font
phoneticScroll := container.NewScroll(a.phoneticDisplay)
phoneticScroll.SetMinSize(fyne.NewSize(0, 100))
-
+
phoneticContainer := container.NewBorder(
widget.NewLabel("Phonetic Information:"),
nil,
@@ -265,44 +281,44 @@ func (a *Application) setupUI() {
nil,
phoneticScroll,
)
-
+
// Create a container for audio player and phonetic info
audioPhoneticSection := container.NewVSplit(
phoneticContainer,
a.audioPlayer,
)
audioPhoneticSection.SetOffset(0.5) // Equal split between phonetic and audio
-
+
displaySection := container.NewBorder(
nil,
audioPhoneticSection,
nil, nil,
imageSection,
)
-
+
// Create action buttons with tooltips
a.keepButton = ttwidget.NewButtonWithIcon("", theme.DocumentCreateIcon(), a.onKeepAndContinue)
a.keepButton.SetToolTip("Keep card and new word (N)")
-
+
a.regenerateImageBtn = ttwidget.NewButtonWithIcon("", theme.ViewRefreshIcon(), a.onRegenerateImage)
a.regenerateImageBtn.SetToolTip("Regenerate image (I)")
-
+
a.regenerateRandomImageBtn = ttwidget.NewButtonWithIcon("", theme.MediaPhotoIcon(), a.onRegenerateRandomImage)
a.regenerateRandomImageBtn.SetToolTip("Random image (M)")
-
+
a.regenerateAudioBtn = ttwidget.NewButtonWithIcon("", theme.MediaRecordIcon(), a.onRegenerateAudio)
a.regenerateAudioBtn.SetToolTip("Regenerate audio (A)")
-
+
a.regenerateAllBtn = ttwidget.NewButtonWithIcon("", theme.ViewFullScreenIcon(), a.onRegenerateAll)
a.regenerateAllBtn.SetToolTip("Regenerate all (R)")
-
+
a.deleteButton = ttwidget.NewButtonWithIcon("", theme.DeleteIcon(), a.onDelete)
a.deleteButton.Importance = widget.DangerImportance
a.deleteButton.SetToolTip("Delete word (D)")
-
+
// Initially disable action buttons
a.setActionButtonsEnabled(false)
-
+
// Create toolbar with all action buttons aligned to the left
toolbar := container.NewHBox(
a.keepButton,
@@ -313,12 +329,12 @@ func (a *Application) setupUI() {
a.regenerateAudioBtn,
a.regenerateAllBtn,
)
-
+
// Create status section
a.statusLabel = widget.NewLabel("Ready")
a.queueStatusLabel = widget.NewLabel("Queue: Empty")
a.queueStatusLabel.TextStyle = fyne.TextStyle{Italic: true}
-
+
statusSection := container.NewBorder(
nil, nil, nil, nil,
container.NewVBox(
@@ -327,7 +343,7 @@ func (a *Application) setupUI() {
a.queueStatusLabel,
),
)
-
+
// Create menu
fileMenu := fyne.NewMenu("File",
fyne.NewMenuItem("Export to Anki... (E)", a.onExportToAnki),
@@ -336,10 +352,10 @@ func (a *Application) setupUI() {
fyne.NewMenuItemSeparator(),
fyne.NewMenuItem("Quit", a.app.Quit),
)
-
+
mainMenu := fyne.NewMainMenu(fileMenu)
a.window.SetMainMenu(mainMenu)
-
+
// Combine all sections with toolbar at the top
content := container.NewBorder(
container.NewVBox(
@@ -351,7 +367,7 @@ func (a *Application) setupUI() {
nil, nil,
displaySection,
)
-
+
// Add the tooltip layer to enable tooltips
a.window.SetContent(fynetooltip.AddWindowToolTipLayer(content, a.window.Canvas()))
a.window.SetOnClosed(func() {
@@ -359,7 +375,7 @@ func (a *Application) setupUI() {
a.queue.Stop()
a.wg.Wait()
})
-
+
// Set up keyboard shortcuts
a.setupKeyboardShortcuts()
}
@@ -375,12 +391,12 @@ func (a *Application) Run() {
func (a *Application) onSubmit() {
bulgarianText := strings.TrimSpace(a.wordInput.Text)
englishText := strings.TrimSpace(a.translationEntry.Text)
-
+
// Determine which word to process and if translation is needed
var wordToProcess string
var needsTranslation bool
var translationDirection string
-
+
if bulgarianText != "" && englishText != "" {
// Both provided - use Bulgarian as primary, no translation needed
wordToProcess = bulgarianText
@@ -400,7 +416,7 @@ func (a *Application) onSubmit() {
// Both empty
return
}
-
+
// Handle English to Bulgarian translation first if needed
if translationDirection == "en-to-bg" {
a.updateStatus(fmt.Sprintf("Translating '%s' to Bulgarian...", englishText))
@@ -431,34 +447,34 @@ func (a *Application) onSubmit() {
// Save the translation immediately
a.saveTranslation()
}
-
+
// Validate Bulgarian text
if err := audio.ValidateBulgarianText(wordToProcess); err != nil {
dialog.ShowError(err, a.window)
return
}
-
+
// Get custom prompt from the UI
customPrompt := a.imagePromptEntry.Text
-
+
// Add word to processing queue with custom prompt
job := a.queue.AddWordWithPrompt(wordToProcess, customPrompt)
-
+
// Store whether translation is needed and the translation if already provided
job.NeedsTranslation = needsTranslation
if a.currentTranslation != "" {
job.Translation = a.currentTranslation
}
-
+
// Don't clear the input fields yet - they should stay populated
// until the user is ready to enter a new word
-
+
// Update status to show word was queued
a.updateStatus(fmt.Sprintf("Added '%s' to queue (Job #%d)", wordToProcess, job.ID))
-
+
// Update queue status immediately
a.updateQueueStatus()
-
+
// Start processing if not already processing
a.processNextInQueue()
}
@@ -488,16 +504,18 @@ func (a *Application) generateMaterials(word string) {
})
}
a.mu.Unlock()
-
+
// Save translation to disk regardless
if translation != "" {
- filename := sanitizeFilename(word)
- translationFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_translation.txt", filename))
+ cardID := internal.GenerateCardID(word)
+ wordDir := filepath.Join(a.config.OutputDir, cardID)
+ os.MkdirAll(wordDir, 0755) // Ensure directory exists
+ translationFile := filepath.Join(wordDir, "translation.txt")
content := fmt.Sprintf("%s = %s\n", word, translation)
os.WriteFile(translationFile, []byte(content), 0644)
}
}
-
+
// Generate audio
fyne.Do(func() {
a.updateStatus("Generating audio...")
@@ -505,7 +523,7 @@ func (a *Application) generateMaterials(word string) {
})
audioFile, err := a.generateAudio(word)
a.decrementProcessing() // Audio processing ends
-
+
if err != nil {
fyne.Do(func() {
a.showError(fmt.Errorf("Audio generation failed: %w", err))
@@ -513,7 +531,7 @@ func (a *Application) generateMaterials(word string) {
})
return
}
-
+
// Only update UI if this word is still the current word
a.mu.Lock()
if a.currentWord == word {
@@ -523,16 +541,16 @@ func (a *Application) generateMaterials(word string) {
})
}
a.mu.Unlock()
-
+
// Generate images with custom prompt if provided
fyne.Do(func() {
- a.updateStatus("Downloading images...")
+ a.updateStatus("Waiting for/downloading images...")
a.incrementProcessing() // Image processing starts
})
-
+
// Get custom prompt from UI
customPrompt := a.imagePromptEntry.Text
-
+
// Pass the current translation to avoid re-translating
translation := a.currentTranslation
if translation == "" {
@@ -541,7 +559,7 @@ func (a *Application) generateMaterials(word string) {
}
imageFile, err := a.generateImagesWithPrompt(word, customPrompt, translation)
a.decrementProcessing() // Image processing ends
-
+
if err != nil {
fyne.Do(func() {
a.showError(fmt.Errorf("Image download failed: %w", err))
@@ -549,7 +567,7 @@ func (a *Application) generateMaterials(word string) {
})
return
}
-
+
// Only update UI if this word is still the current word
if imageFile != "" {
a.mu.Lock()
@@ -561,7 +579,7 @@ func (a *Application) generateMaterials(word string) {
}
a.mu.Unlock()
}
-
+
// Enable action buttons
fyne.Do(func() {
a.hideProgress()
@@ -582,39 +600,39 @@ func (a *Application) onKeepAndContinue() {
ImageFile: a.currentImage,
Translation: a.currentTranslation,
}
-
+
a.mu.Lock()
a.savedCards = append(a.savedCards, card)
count := len(a.savedCards)
a.mu.Unlock()
-
+
// Save translation, prompt, and phonetic files for future navigation
a.saveTranslation()
a.saveImagePrompt()
a.savePhoneticInfo()
-
+
// Rescan existing words to include the new one
a.scanExistingWords()
-
+
a.updateStatus(fmt.Sprintf("Card saved! Total cards: %d", count))
}
-
+
// Clear current job ID to allow navigation back to this word
a.mu.Lock()
currentJobID := a.currentJobID
a.currentJobID = 0
a.mu.Unlock()
-
+
// If there was a job in progress, it will continue in the background
if currentJobID != 0 {
a.updateStatus("Previous word continues processing in background")
}
-
+
// Clear UI and input fields for next word
a.clearUI()
a.wordInput.SetText("")
a.translationEntry.SetText("")
-
+
// Clear current state to prevent mix-ups with background jobs
a.mu.Lock()
a.currentWord = ""
@@ -622,12 +640,12 @@ func (a *Application) onKeepAndContinue() {
a.currentAudioFile = ""
a.currentImage = ""
a.mu.Unlock()
-
+
a.wordInput.FocusGained() // Focus input for next word
-
+
// Hide progress bar if it was showing
a.hideProgress()
-
+
// Re-enable submit button
a.submitButton.Enable()
}
@@ -639,20 +657,20 @@ func (a *Application) onRegenerateImage() {
a.regenerateRandomImageBtn.Disable()
a.regenerateAllBtn.Disable()
a.showProgress("Regenerating image...")
-
+
// Clear the current image immediately
a.imageDisplay.Clear()
-
+
// Get custom prompt from UI
customPrompt := a.imagePromptEntry.Text
-
+
a.incrementProcessing() // Image processing starts
-
+
a.wg.Add(1)
go func() {
defer a.wg.Done()
defer a.decrementProcessing() // Image processing ends
-
+
// Use the current translation to avoid re-translating
translation := a.currentTranslation
if translation == "" {
@@ -681,7 +699,7 @@ func (a *Application) onRegenerateImage() {
}
}
}
-
+
fyne.Do(func() {
a.hideProgress()
// Re-enable image-related buttons
@@ -699,20 +717,20 @@ func (a *Application) onRegenerateRandomImage() {
a.regenerateRandomImageBtn.Disable()
a.regenerateAllBtn.Disable()
a.showProgress("Generating random image...")
-
+
// Clear the current image immediately
a.imageDisplay.Clear()
-
+
// Clear the custom prompt to let the system generate a new one
customPrompt := ""
-
+
a.incrementProcessing() // Image processing starts
-
+
a.wg.Add(1)
go func() {
defer a.wg.Done()
defer a.decrementProcessing() // Image processing ends
-
+
// Use the current translation to avoid re-translating
translation := a.currentTranslation
if translation == "" {
@@ -741,7 +759,7 @@ func (a *Application) onRegenerateRandomImage() {
}
}
}
-
+
fyne.Do(func() {
a.hideProgress()
// Re-enable image-related buttons
@@ -758,14 +776,14 @@ func (a *Application) onRegenerateAudio() {
a.regenerateAudioBtn.Disable()
a.regenerateAllBtn.Disable()
a.showProgress("Regenerating audio...")
-
+
a.incrementProcessing() // Audio processing starts
-
+
a.wg.Add(1)
go func() {
defer a.wg.Done()
defer a.decrementProcessing() // Audio processing ends
-
+
audioFile, err := a.generateAudio(a.currentWord)
if err != nil {
fyne.Do(func() {
@@ -777,7 +795,7 @@ func (a *Application) onRegenerateAudio() {
a.audioPlayer.SetAudioFile(audioFile)
})
}
-
+
fyne.Do(func() {
a.hideProgress()
// Re-enable audio-related buttons
@@ -791,10 +809,10 @@ func (a *Application) onRegenerateAudio() {
func (a *Application) onRegenerateAll() {
a.setUIEnabled(false)
a.showProgress("Regenerating all materials...")
-
+
// Clear the current image immediately
a.imageDisplay.Clear()
-
+
a.wg.Add(1)
go func() {
defer a.wg.Done()
@@ -810,7 +828,7 @@ func (a *Application) onExportToAnki() {
dialog.ShowInformation("No Cards", "No cards found in anki_cards folder. Generate some cards first!", a.window)
return
}
-
+
// Count subdirectories (excluding hidden ones)
cardCount := 0
for _, entry := range entries {
@@ -818,20 +836,20 @@ func (a *Application) onExportToAnki() {
cardCount++
}
}
-
+
if cardCount == 0 {
dialog.ShowInformation("No Cards", "No cards found in anki_cards folder. Generate some cards first!", a.window)
return
}
-
+
// Create format selection dialog
formatOptions := []string{"APKG (Recommended)", "CSV (Legacy)"}
formatSelect := widget.NewSelect(formatOptions, nil)
formatSelect.SetSelected(formatOptions[0])
-
+
deckNameEntry := widget.NewEntry()
deckNameEntry.SetPlaceHolder("Bulgarian Vocabulary")
-
+
content := container.NewVBox(
widget.NewLabel("Export Format:"),
formatSelect,
@@ -841,50 +859,50 @@ func (a *Application) onExportToAnki() {
widget.NewLabel(""),
widget.NewRichTextFromMarkdown("**APKG**: Complete package with media files included\n**CSV**: Text only, requires manual media copy"),
)
-
+
customDialog := dialog.NewCustomConfirm("Export to Anki", "Export", "Cancel", content, func(export bool) {
if !export {
return
}
-
+
isAPKG := formatSelect.Selected == formatOptions[0]
deckName := deckNameEntry.Text
if deckName == "" {
deckName = "Bulgarian Vocabulary"
}
-
+
// Generate export directly to anki_cards folder
var outputPath string
var filename string
-
+
if isAPKG {
- filename = fmt.Sprintf("%s.apkg", sanitizeFilename(deckName))
+ filename = fmt.Sprintf("%s.apkg", internal.SanitizeFilename(deckName))
outputPath = filepath.Join(a.config.OutputDir, filename)
-
+
// Generate APKG from all cards in directory
gen := anki.NewGenerator(nil)
-
+
// Load all cards from the anki_cards directory
if err := gen.GenerateFromDirectory(a.config.OutputDir); err != nil {
dialog.ShowError(fmt.Errorf("Failed to load cards: %w", err), a.window)
return
}
-
+
if err := gen.GenerateAPKG(outputPath, deckName); err != nil {
dialog.ShowError(fmt.Errorf("Failed to generate APKG: %w", err), a.window)
return
}
-
+
// Get actual card count
total, withAudio, withImages := gen.Stats()
-
+
// Update status bar instead of showing dialog
- a.updateStatus(fmt.Sprintf("Exported %d cards to %s (%d with audio, %d with images)",
+ a.updateStatus(fmt.Sprintf("Exported %d cards to %s (%d with audio, %d with images)",
total, outputPath, withAudio, withImages))
} else {
filename = "anki_import.csv"
outputPath = filepath.Join(a.config.OutputDir, filename)
-
+
// Generate CSV from all cards in directory
gen := anki.NewGenerator(&anki.GeneratorOptions{
OutputPath: outputPath,
@@ -892,27 +910,27 @@ func (a *Application) onExportToAnki() {
IncludeHeaders: true,
AudioFormat: a.config.AudioFormat,
})
-
+
// Load all cards from the anki_cards directory
if err := gen.GenerateFromDirectory(a.config.OutputDir); err != nil {
dialog.ShowError(fmt.Errorf("Failed to load cards: %w", err), a.window)
return
}
-
+
if err := gen.GenerateCSV(); err != nil {
dialog.ShowError(fmt.Errorf("Failed to generate CSV: %w", err), a.window)
return
}
-
+
// Get actual card count
total, withAudio, withImages := gen.Stats()
-
+
// Update status bar instead of showing dialog
- a.updateStatus(fmt.Sprintf("Exported %d cards to %s (%d with audio, %d with images)",
+ a.updateStatus(fmt.Sprintf("Exported %d cards to %s (%d with audio, %d with images)",
total, outputPath, withAudio, withImages))
}
}, a.window)
-
+
customDialog.Resize(fyne.NewSize(400, 300))
customDialog.Show()
}
@@ -945,14 +963,14 @@ func (a *Application) onShowHotkeys() {
content := widget.NewRichTextFromMarkdown(hotkeys)
content.Wrapping = fyne.TextWrapWord
-
+
// Create a container with padding to prevent text cutoff
paddedContent := container.NewPadded(content)
-