From cd3b1e5b2fab8075303c064ba33996a0250cd6a6 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 18 Jul 2025 19:31:44 +0300 Subject: fix: use timestamp+hash naming for directories in CLI to match GUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- cmd/totalrecall/main.go | 58 ++--- internal/anki/generator.go | 36 +-- internal/gui/app.go | 607 ++++++++++++++++++++++++------------------- internal/gui/audio_player.go | 54 ++-- internal/gui/generator.go | 52 ++-- internal/gui/navigation.go | 159 ++++++++---- internal/utils.go | 43 +++ test_gui_improvements.md | 46 ++++ 8 files changed, 633 insertions(+), 422 deletions(-) create mode 100644 internal/utils.go create mode 100644 test_gui_improvements.md 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
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) - + // Create a scrollable container for the content scroll := container.NewScroll(paddedContent) scroll.SetMinSize(fyne.NewSize(350, 450)) - + dialog.NewCustom("Keyboard Shortcuts", "Close", scroll, a.window).Show() } @@ -991,7 +1009,7 @@ func (a *Application) showProgress(message string) { a.mu.Lock() processingCount := a.processingCount a.mu.Unlock() - + if processingCount > 1 { // Show that multiple operations are in progress a.statusLabel.SetText(fmt.Sprintf("%s (Processing: %d tasks)", message, processingCount)) @@ -1006,7 +1024,7 @@ func (a *Application) hideProgress() { a.mu.Lock() processingCount := a.processingCount a.mu.Unlock() - + if processingCount > 0 { a.updateStatus(fmt.Sprintf("Processing %d task(s)...", processingCount)) } else { @@ -1039,13 +1057,13 @@ func (a *Application) processNextInQueue() { if a.currentJobID != 0 { return } - + // Get next job from queue job := a.queue.ProcessNextJob() if job == nil { return } - + // Set current job and clear any previous state a.mu.Lock() a.currentJobID = job.ID @@ -1055,14 +1073,14 @@ func (a *Application) processNextInQueue() { a.currentAudioFile = "" a.currentImage = "" a.mu.Unlock() - + // Clear UI for new word fyne.Do(func() { a.clearUI() a.showProgress("Processing: " + job.Word) a.updateQueueStatus() // Update to show item moved from queued to processing }) - + // Process in background a.wg.Add(1) go func() { @@ -1076,13 +1094,13 @@ func (a *Application) processWordJob(job *WordJob) { // Handle translation var translation string var err error - + if job.NeedsTranslation { // Translate word fyne.Do(func() { a.updateStatus(fmt.Sprintf("Translating '%s'...", job.Word)) }) - + translation, err = a.translateWord(job.Word) if err != nil { a.queue.FailJob(job.ID, fmt.Errorf("translation failed: %w", err)) @@ -1093,17 +1111,22 @@ func (a *Application) processWordJob(job *WordJob) { // Use provided translation translation = job.Translation } - + // Save translation to disk immediately for this specific word if translation != "" { - filename := sanitizeFilename(job.Word) - wordDir := filepath.Join(a.config.OutputDir, filename) + cardID := internal.GenerateCardID(job.Word) + wordDir := filepath.Join(a.config.OutputDir, cardID) os.MkdirAll(wordDir, 0755) // Ensure directory exists - translationFile := 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(job.Word), 0644) + } + translationFile := filepath.Join(wordDir, "translation.txt") content := fmt.Sprintf("%s = %s\n", job.Word, translation) os.WriteFile(translationFile, []byte(content), 0644) } - + // Update UI with translation immediately if this is still the current job a.mu.Lock() if a.currentJobID == job.ID && translation != "" { @@ -1113,32 +1136,37 @@ func (a *Application) processWordJob(job *WordJob) { }) } a.mu.Unlock() - + // Start fetching phonetic information concurrently phoneticDone := make(chan struct{}) go func() { defer close(phoneticDone) - + fyne.Do(func() { a.incrementProcessing() // Phonetic processing starts }) - + phoneticInfo, err := a.getPhoneticInfo(job.Word) if err != nil { // Log error but don't fail the job - phonetic info is optional fmt.Printf("Warning: Failed to get phonetic info: %v\n", err) phoneticInfo = "Failed to fetch phonetic information" } - + // Save phonetic info to disk immediately for this specific word if phoneticInfo != "" && phoneticInfo != "Failed to fetch phonetic information" { - filename := sanitizeFilename(job.Word) - wordDir := filepath.Join(a.config.OutputDir, filename) + cardID := internal.GenerateCardID(job.Word) + wordDir := filepath.Join(a.config.OutputDir, cardID) os.MkdirAll(wordDir, 0755) // Ensure directory exists - phoneticFile := filepath.Join(wordDir, fmt.Sprintf("%s_phonetic.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(job.Word), 0644) + } + phoneticFile := filepath.Join(wordDir, "phonetic.txt") os.WriteFile(phoneticFile, []byte(phoneticInfo), 0644) } - + // Update UI with phonetic info if this is still the current job a.mu.Lock() if a.currentJobID == job.ID { @@ -1147,25 +1175,25 @@ func (a *Application) processWordJob(job *WordJob) { }) } a.mu.Unlock() - + a.decrementProcessing() // Phonetic processing ends }() - + // Generate audio fyne.Do(func() { a.updateStatus(fmt.Sprintf("Generating audio for '%s'...", job.Word)) a.incrementProcessing() // Audio processing starts }) - + audioFile, err := a.generateAudio(job.Word) a.decrementProcessing() // Audio processing ends - + if err != nil { a.queue.FailJob(job.ID, fmt.Errorf("audio generation failed: %w", err)) a.finishCurrentJob() return } - + // Update UI with audio immediately if this is still the current job a.mu.Lock() isCurrentJob := a.currentJobID == job.ID @@ -1173,7 +1201,7 @@ func (a *Application) processWordJob(job *WordJob) { a.currentAudioFile = audioFile } a.mu.Unlock() - + if isCurrentJob { fyne.Do(func() { a.audioPlayer.SetAudioFile(audioFile) @@ -1181,34 +1209,34 @@ func (a *Application) processWordJob(job *WordJob) { a.regenerateAudioBtn.Enable() }) } - + // Generate images fyne.Do(func() { - a.updateStatus(fmt.Sprintf("Downloading images for '%s'...", job.Word)) + a.updateStatus(fmt.Sprintf("Waiting for/downloading images for '%s'...", job.Word)) a.incrementProcessing() // Image processing starts }) - + // Use the custom prompt from the job // The translation variable already contains the correct translation (either from job or translated) imageFile, err := a.generateImagesWithPrompt(job.Word, job.CustomPrompt, translation) a.decrementProcessing() // Image processing ends - + if err != nil { a.queue.FailJob(job.ID, fmt.Errorf("image download failed: %w", err)) a.finishCurrentJob() return } - + // Wait for phonetic fetching to complete before finalizing <-phoneticDone - + // Mark job as completed fyne.Do(func() { a.updateStatus(fmt.Sprintf("Finalizing '%s'...", job.Word)) }) - + a.queue.CompleteJob(job.ID, translation, audioFile, imageFile) - + // Update UI with results if this is still the current job a.mu.Lock() isCurrentJob = a.currentJobID == job.ID @@ -1220,7 +1248,7 @@ func (a *Application) processWordJob(job *WordJob) { } } a.mu.Unlock() - + if isCurrentJob { fyne.Do(func() { a.translationEntry.SetText(translation) @@ -1233,10 +1261,10 @@ func (a *Application) processWordJob(job *WordJob) { a.updateStatus(fmt.Sprintf("Completed: %s", job.Word)) }) } - + // Finish this job a.finishCurrentJob() - + // Update queue status fyne.Do(func() { a.updateQueueStatus() @@ -1248,7 +1276,7 @@ func (a *Application) finishCurrentJob() { a.mu.Lock() a.currentJobID = 0 a.mu.Unlock() - + // Process next in queue fyne.Do(func() { a.processNextInQueue() @@ -1266,24 +1294,24 @@ func (a *Application) onQueueStatusUpdate(job *WordJob) { func (a *Application) onJobComplete(job *WordJob) { fyne.Do(func() { a.updateQueueStatus() - + // If this was the current job and it failed, show error if job.ID == a.currentJobID && job.Status == StatusFailed { a.showError(job.Error) a.hideProgress() a.finishCurrentJob() } - + // Update navigation to include the newly completed word if job.Status == StatusCompleted { a.updateNavigation() - + // Only show status updates, don't update UI for background jobs // This prevents mix-ups when user has moved on to a new word a.mu.Lock() isCurrentJob := job.ID == a.currentJobID a.mu.Unlock() - + if isCurrentJob { // This is still the current job, UI update is already handled in processWordJob a.updateStatus(fmt.Sprintf("Processing completed: %s", job.Word)) @@ -1300,22 +1328,22 @@ func (a *Application) updateQueueStatus() { a.mu.Lock() processing := a.processingCount a.mu.Unlock() - + // Count total cards from various sources // 1. Saved cards from the session savedCount := len(a.savedCards) - + // 2. Existing words from disk existingCount := len(a.existingWords) - + // 3. Completed jobs from queue completedJobs := a.queue.GetCompletedJobs() queueCompleted := len(completedJobs) - + totalCards := savedCount + existingCount + queueCompleted - + status := fmt.Sprintf("Processing: %d | Total cards: %d", processing, totalCards) - + a.queueStatusLabel.SetText(status) } @@ -1324,7 +1352,7 @@ func (a *Application) incrementProcessing() { a.mu.Lock() a.processingCount++ a.mu.Unlock() - + // Update UI on main thread fyne.Do(func() { a.updateQueueStatus() @@ -1338,7 +1366,7 @@ func (a *Application) decrementProcessing() { a.processingCount-- } a.mu.Unlock() - + // Update UI on main thread fyne.Do(func() { a.updateQueueStatus() @@ -1355,27 +1383,27 @@ func (a *Application) setupKeyboardShortcuts() { a.deleteConfirming = false return } - + // Handle Tab key for custom focus navigation if ev.Name == fyne.KeyTab { a.handleTabNavigation() return } - + // Check if input field is focused focused := a.window.Canvas().Focused() isInputFocused := focused == a.wordInput || focused == a.imagePromptEntry || focused == a.translationEntry - + // If input is focused, don't process regular shortcuts if isInputFocused { return } - + // Don't process if we're in delete confirmation mode (handled by dialog) if a.deleteConfirming { return } - + a.handleShortcutKey(ev.Name) }) } @@ -1383,7 +1411,7 @@ func (a *Application) setupKeyboardShortcuts() { // handleTabNavigation manages custom Tab navigation order func (a *Application) handleTabNavigation() { focused := a.window.Canvas().Focused() - + switch focused { case a.wordInput: // From Bulgarian -> English @@ -1406,83 +1434,90 @@ func (a *Application) handleShortcutKey(key fyne.KeyName) { if a.deleteConfirming { return } - + switch key { case fyne.KeyG: // Generate if a.submitButton.Disabled() { return } a.onSubmit() - + case fyne.KeyN: // New Word if a.keepButton.Disabled() { return } a.onKeepAndContinue() - + case fyne.KeyI: // Regenerate Image if a.regenerateImageBtn.Disabled() { return } a.onRegenerateImage() - + case fyne.KeyM: // Random Image (M for "magic" or "mixed") if a.regenerateRandomImageBtn.Disabled() { return } a.onRegenerateRandomImage() - + case fyne.KeyA: // Regenerate Audio if a.regenerateAudioBtn.Disabled() { return } a.onRegenerateAudio() - + case fyne.KeyR: // Regenerate All if a.regenerateAllBtn.Disabled() { return } a.onRegenerateAll() - + case fyne.KeyD: // Delete if a.deleteButton.Disabled() { return } a.onDelete() - + case fyne.KeyLeft: // Previous word if a.prevWordBtn.Disabled() { return } a.onPrevWord() - + case fyne.KeyRight: // Next word if a.nextWordBtn.Disabled() { return } a.onNextWord() - + case fyne.KeyP: // Play audio if a.currentAudioFile != "" { a.audioPlayer.Play() } - + case fyne.KeyE: // Export to APKG a.onExportToAnki() - + case fyne.KeyH: // Show hotkeys a.onShowHotkeys() } } - // saveTranslation saves the current translation to a file func (a *Application) saveTranslation() { if a.currentWord != "" && a.currentTranslation != "" { - filename := sanitizeFilename(a.currentWord) - wordDir := filepath.Join(a.config.OutputDir, filename) - os.MkdirAll(wordDir, 0755) // Ensure directory exists - translationFile := filepath.Join(wordDir, fmt.Sprintf("%s_translation.txt", filename)) + // Find existing card directory + wordDir := a.findCardDirectory(a.currentWord) + if wordDir == "" { + // No existing directory, create new one with card ID + cardID := internal.GenerateCardID(a.currentWord) + wordDir = filepath.Join(a.config.OutputDir, cardID) + os.MkdirAll(wordDir, 0755) // Ensure directory exists + // Save word metadata + metadataFile := filepath.Join(wordDir, "word.txt") + os.WriteFile(metadataFile, []byte(a.currentWord), 0644) + } + translationFile := filepath.Join(wordDir, "translation.txt") content := fmt.Sprintf("%s = %s\n", a.currentWord, a.currentTranslation) os.WriteFile(translationFile, []byte(content), 0644) } @@ -1490,50 +1525,87 @@ func (a *Application) saveTranslation() { // saveImagePrompt saves the current image prompt to a file func (a *Application) saveImagePrompt() { - if a.currentWord != "" && a.imagePromptEntry.Text != "" { - filename := sanitizeFilename(a.currentWord) - wordDir := filepath.Join(a.config.OutputDir, filename) - os.MkdirAll(wordDir, 0755) // Ensure directory exists - promptFile := filepath.Join(wordDir, fmt.Sprintf("%s_prompt.txt", filename)) - os.WriteFile(promptFile, []byte(a.imagePromptEntry.Text), 0644) + // With timestamp-based card IDs, we can't update existing prompts + // The prompt is saved when the image is generated + // This function is kept for compatibility but does nothing +} + +// handleWordChange is called when the Bulgarian word is changed +func (a *Application) handleWordChange(oldWord, newWord string) { + // Update current word + a.currentWord = newWord + + // Clear the custom image prompt to force regeneration with new word + fyne.Do(func() { + a.imagePromptEntry.SetText("") + }) + + // Check if we have existing materials + hasExistingMaterials := a.currentImage != "" || a.currentAudioFile != "" + + if hasExistingMaterials { + // Automatically trigger image regeneration with new prompt + fyne.Do(func() { + a.updateStatus(fmt.Sprintf("Word changed from '%s' to '%s' - regenerating image...", oldWord, newWord)) + }) + + // Small delay to ensure UI updates + time.AfterFunc(100*time.Millisecond, func() { + fyne.Do(func() { + a.onRegenerateImage() + }) + }) } } // savePhoneticInfo saves the phonetic information to a file func (a *Application) savePhoneticInfo() { phoneticText := a.phoneticDisplay.Text - if a.currentWord != "" && phoneticText != "" && + if a.currentWord != "" && phoneticText != "" && phoneticText != "Failed to fetch phonetic information" && phoneticText != "Phonetic information will appear here..." { - filename := sanitizeFilename(a.currentWord) - wordDir := filepath.Join(a.config.OutputDir, filename) - os.MkdirAll(wordDir, 0755) // Ensure directory exists - phoneticFile := filepath.Join(wordDir, fmt.Sprintf("%s_phonetic.txt", filename)) + // Find existing card directory + wordDir := a.findCardDirectory(a.currentWord) + if wordDir == "" { + // No existing directory, create new one with card ID + cardID := internal.GenerateCardID(a.currentWord) + wordDir = filepath.Join(a.config.OutputDir, cardID) + os.MkdirAll(wordDir, 0755) // Ensure directory exists + // Save word metadata + metadataFile := filepath.Join(wordDir, "word.txt") + os.WriteFile(metadataFile, []byte(a.currentWord), 0644) + } + phoneticFile := filepath.Join(wordDir, "phonetic.txt") os.WriteFile(phoneticFile, []byte(phoneticText), 0644) } } // savePhoneticInfoForWord saves the phonetic information for a specific word func (a *Application) savePhoneticInfoForWord(word, phoneticText string) { - if word != "" && phoneticText != "" && + if word != "" && phoneticText != "" && phoneticText != "Failed to fetch phonetic information" && phoneticText != "Phonetic information will appear here..." { - filename := sanitizeFilename(word) - wordDir := filepath.Join(a.config.OutputDir, filename) + cardID := internal.GenerateCardID(word) + wordDir := filepath.Join(a.config.OutputDir, cardID) os.MkdirAll(wordDir, 0755) // Ensure directory exists - phoneticFile := filepath.Join(wordDir, fmt.Sprintf("%s_phonetic.txt", filename)) + phoneticFile := filepath.Join(wordDir, "phonetic.txt") os.WriteFile(phoneticFile, []byte(phoneticText), 0644) } } // loadPhoneticInfo loads phonetic information from a file if it exists func (a *Application) loadPhoneticInfo(word string) { - filename := sanitizeFilename(word) - wordDir := filepath.Join(a.config.OutputDir, filename) - phoneticFile := filepath.Join(wordDir, fmt.Sprintf("%s_phonetic.txt", filename)) + wordDir := a.findCardDirectory(word) + if wordDir == "" { + return + } + phoneticFile := filepath.Join(wordDir, "phonetic.txt") if data, err := os.ReadFile(phoneticFile); err == nil { - a.phoneticDisplay.SetText(string(data)) + phoneticText := string(data) + fyne.Do(func() { + a.phoneticDisplay.SetText(phoneticText) + }) } } @@ -1542,17 +1614,17 @@ func (a *Application) getPhoneticInfo(word string) (string, error) { if a.config.OpenAIKey == "" { return "", fmt.Errorf("OpenAI API key not configured") } - + client := openai.NewClient(a.config.OpenAIKey) - + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - + req := openai.ChatCompletionRequest{ Model: openai.GPT4o, Messages: []openai.ChatCompletionMessage{ { - Role: openai.ChatMessageRoleSystem, + Role: openai.ChatMessageRoleSystem, Content: "You are a Bulgarian language expert helping language learners understand pronunciation. Provide detailed phonetic information using the International Phonetic Alphabet (IPA). For each IPA symbol used, give concrete examples of how it sounds using familiar English words or sounds when possible.", }, { @@ -1576,16 +1648,15 @@ etc.`, word), Temperature: 0.3, MaxTokens: 800, } - + resp, err := client.CreateChatCompletion(ctx, req) if err != nil { return "", fmt.Errorf("failed to get phonetic info: %w", err) } - + if len(resp.Choices) == 0 { return "", fmt.Errorf("no response from OpenAI") } - + return resp.Choices[0].Message.Content, nil } - diff --git a/internal/gui/audio_player.go b/internal/gui/audio_player.go index a94386c..8df4461 100644 --- a/internal/gui/audio_player.go +++ b/internal/gui/audio_player.go @@ -17,36 +17,36 @@ import ( // AudioPlayer is a custom widget for playing audio files type AudioPlayer struct { widget.BaseWidget - - container *fyne.Container - playButton *ttwidget.Button - stopButton *ttwidget.Button - statusLabel *widget.Label - - audioFile string - isPlaying bool - playCmd *exec.Cmd + + container *fyne.Container + playButton *ttwidget.Button + stopButton *ttwidget.Button + statusLabel *widget.Label + + audioFile string + isPlaying bool + playCmd *exec.Cmd } // NewAudioPlayer creates a new audio player widget func NewAudioPlayer() *AudioPlayer { p := &AudioPlayer{} - + // Create controls with tooltips p.playButton = ttwidget.NewButton("", p.onPlay) p.playButton.Icon = theme.MediaPlayIcon() p.playButton.SetToolTip("Play audio (P)") - + p.stopButton = ttwidget.NewButton("", p.onStop) p.stopButton.Icon = theme.MediaStopIcon() p.stopButton.SetToolTip("Stop audio") - + p.statusLabel = widget.NewLabel("No audio loaded") - + // Initially disable controls p.playButton.Disable() p.stopButton.Disable() - + // Create main container p.container = container.NewHBox( p.playButton, @@ -54,7 +54,7 @@ func NewAudioPlayer() *AudioPlayer { layout.NewSpacer(), p.statusLabel, ) - + p.ExtendBaseWidget(p) return p } @@ -68,7 +68,7 @@ func (p *AudioPlayer) CreateRenderer() fyne.WidgetRenderer { func (p *AudioPlayer) SetAudioFile(audioFile string) { p.audioFile = audioFile p.isPlaying = false - + if audioFile != "" { p.playButton.Enable() p.statusLabel.SetText(fmt.Sprintf("Audio: %s", filepath.Base(audioFile))) @@ -92,21 +92,21 @@ func (p *AudioPlayer) onPlay() { if p.audioFile == "" { return } - + if p.isPlaying { // Pause functionality - just stop for now p.onStop() return } - + // Start playing if err := p.startPlayback(); err != nil { p.statusLabel.SetText(fmt.Sprintf("Error: %v", err)) return } - + p.isPlaying = true - p.playButton.SetText("⏸ Pause (p)") + p.playButton.SetIcon(theme.MediaPauseIcon()) p.stopButton.Enable() p.statusLabel.SetText("Playing: " + filepath.Base(p.audioFile)) } @@ -117,9 +117,9 @@ func (p *AudioPlayer) onStop() { p.playCmd.Process.Kill() p.playCmd = nil } - + p.isPlaying = false - p.playButton.SetText("▶ Play (p)") + p.playButton.SetIcon(theme.MediaPlayIcon()) p.stopButton.Disable() p.statusLabel.SetText("Stopped: " + filepath.Base(p.audioFile)) } @@ -134,7 +134,7 @@ func (p *AudioPlayer) Play() { // startPlayback starts audio playback using platform-specific commands func (p *AudioPlayer) startPlayback() error { var cmd *exec.Cmd - + switch runtime.GOOS { case "darwin": // macOS cmd = exec.Command("afplay", p.audioFile) @@ -161,10 +161,10 @@ func (p *AudioPlayer) startPlayback() error { default: return fmt.Errorf("unsupported platform: %s", runtime.GOOS) } - + // Store the command so we can stop it later p.playCmd = cmd - + // Start playback in background go func() { err := cmd.Run() @@ -178,6 +178,6 @@ func (p *AudioPlayer) startPlayback() error { }) } }() - + return nil -} \ No newline at end of file +} diff --git a/internal/gui/generator.go b/internal/gui/generator.go index 16ea7cf..f3b9563 100644 --- a/internal/gui/generator.go +++ b/internal/gui/generator.go @@ -11,6 +11,7 @@ import ( "fyne.io/fyne/v2" "github.com/sashabaranov/go-openai" + "codeberg.org/snonux/totalrecall/internal" "codeberg.org/snonux/totalrecall/internal/audio" "codeberg.org/snonux/totalrecall/internal/image" ) @@ -99,15 +100,21 @@ func (a *Application) generateAudio(word string) (string, error) { return "", err } - // Create subdirectory for this word - filename := sanitizeFilename(word) - wordDir := filepath.Join(a.config.OutputDir, filename) + // Create subdirectory for this word using card ID + cardID := internal.GenerateCardID(word) + wordDir := filepath.Join(a.config.OutputDir, cardID) if err := os.MkdirAll(wordDir, 0755); err != nil { return "", fmt.Errorf("failed to create word directory: %w", err) } + // Save the original Bulgarian word in a metadata file + metadataFile := filepath.Join(wordDir, "word.txt") + if err := os.WriteFile(metadataFile, []byte(word), 0644); err != nil { + return "", fmt.Errorf("failed to save word metadata: %w", err) + } + // Generate filename in subdirectory - outputFile := filepath.Join(wordDir, fmt.Sprintf("%s.%s", filename, a.config.AudioFormat)) + outputFile := filepath.Join(wordDir, fmt.Sprintf("audio.%s", a.config.AudioFormat)) // Generate audio err = provider.GenerateAudio(a.ctx, word, outputFile) @@ -156,19 +163,27 @@ func (a *Application) generateImagesWithPrompt(word string, customPrompt string, return "", fmt.Errorf("unknown image provider: %s", a.config.ImageProvider) } - // Create subdirectory for this word - filename := sanitizeFilename(word) - wordDir := filepath.Join(a.config.OutputDir, filename) + // Create subdirectory for this word using card ID + cardID := internal.GenerateCardID(word) + wordDir := filepath.Join(a.config.OutputDir, cardID) if err := os.MkdirAll(wordDir, 0755); err != nil { return "", fmt.Errorf("failed to create word directory: %w", err) } + // Save the original Bulgarian word in a metadata file if not already present + metadataFile := filepath.Join(wordDir, "word.txt") + if _, err := os.Stat(metadataFile); os.IsNotExist(err) { + if err := os.WriteFile(metadataFile, []byte(word), 0644); err != nil { + return "", fmt.Errorf("failed to save word metadata: %w", err) + } + } + // Create downloader downloadOpts := &image.DownloadOptions{ OutputDir: wordDir, OverwriteExisting: true, CreateDir: true, - FileNamePattern: "{word}", + FileNamePattern: "image", MaxSizeBytes: 5 * 1024 * 1024, // 5MB } @@ -195,7 +210,7 @@ func (a *Application) generateImagesWithPrompt(word string, customPrompt string, usedPrompt := openaiClient.GetLastPrompt() if usedPrompt != "" { // Save the prompt to disk immediately for this word - promptFile := filepath.Join(wordDir, fmt.Sprintf("%s_prompt.txt", filename)) + promptFile := filepath.Join(wordDir, "prompt.txt") os.WriteFile(promptFile, []byte(usedPrompt), 0644) // Only update UI if this word is still the current word @@ -238,22 +253,3 @@ func (a *Application) saveAudioAttribution(word, audioFile, voice string) error return nil } -// sanitizeFilename creates a safe filename from a string -func sanitizeFilename(s string) string { - result := "" - for _, r := range s { - if isAlphaNumeric(r) || r == '-' || r == '_' { - result += string(r) - } else { - result += "_" - } - } - return result -} - -// isAlphaNumeric checks if a rune is alphanumeric -func isAlphaNumeric(r rune) bool { - return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || - (r >= '0' && r <= '9') || (r >= 'а' && r <= 'я') || - (r >= 'А' && r <= 'Я') -} \ No newline at end of file diff --git a/internal/gui/navigation.go b/internal/gui/navigation.go index 35af9b2..5a41d7f 100644 --- a/internal/gui/navigation.go +++ b/internal/gui/navigation.go @@ -14,6 +14,43 @@ import ( "codeberg.org/snonux/totalrecall/internal/anki" ) +// findCardDirectory finds the directory for a given Bulgarian word +func (a *Application) findCardDirectory(word string) string { + entries, err := os.ReadDir(a.config.OutputDir) + if err != nil { + return "" + } + + // Look through all directories to find one with matching _word.txt + for _, entry := range entries { + if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + continue + } + + dirPath := filepath.Join(a.config.OutputDir, entry.Name()) + wordFile := filepath.Join(dirPath, "word.txt") + + // Read the word file to check if it matches + if data, err := os.ReadFile(wordFile); err == nil { + storedWord := strings.TrimSpace(string(data)) + if storedWord == word { + return dirPath + } + } else { + // Try old format with underscore for backward compatibility + wordFile = filepath.Join(dirPath, "_word.txt") + if data, err := os.ReadFile(wordFile); err == nil { + storedWord := strings.TrimSpace(string(data)) + if storedWord == word { + return dirPath + } + } + } + } + + return "" +} + // scanExistingWords scans the output directory for existing words func (a *Application) scanExistingWords() { a.existingWords = []string{} @@ -31,17 +68,33 @@ func (a *Application) scanExistingWords() { continue } - // Directory name is the sanitized word - sanitizedWord := entry.Name() + // Directory name is now a card ID + cardID := entry.Name() + wordDir := filepath.Join(a.config.OutputDir, cardID) - // Check if this directory contains valid word files - wordDir := filepath.Join(a.config.OutputDir, sanitizedWord) + // Read the original Bulgarian word from word.txt + wordFile := filepath.Join(wordDir, "word.txt") + wordData, err := os.ReadFile(wordFile) + if err != nil { + // Try old format with underscore for backward compatibility + wordFile = filepath.Join(wordDir, "_word.txt") + wordData, err = os.ReadFile(wordFile) + if err != nil { + // No word file, skip this directory + continue + } + } + + word := string(wordData) + if word == "" { + continue + } // Look for at least one of: audio, image, or translation file hasContent := false // Check for audio file - audioFile := filepath.Join(wordDir, fmt.Sprintf("%s.%s", sanitizedWord, a.config.AudioFormat)) + audioFile := filepath.Join(wordDir, fmt.Sprintf("audio.%s", a.config.AudioFormat)) if _, err := os.Stat(audioFile); err == nil { hasContent = true } @@ -49,10 +102,8 @@ func (a *Application) scanExistingWords() { // Check for image files if !hasContent { patterns := []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 patterns { if _, err := os.Stat(filepath.Join(wordDir, pattern)); err == nil { @@ -64,28 +115,15 @@ func (a *Application) scanExistingWords() { // Check for translation file if !hasContent { - translationFile := filepath.Join(wordDir, fmt.Sprintf("%s_translation.txt", sanitizedWord)) + translationFile := filepath.Join(wordDir, "translation.txt") if _, err := os.Stat(translationFile); err == nil { hasContent = true } } - // If directory has content, add it to the list + // If directory has content, add the word to the list if hasContent { - // Try to get the original word from translation file - translationFile := filepath.Join(wordDir, fmt.Sprintf("%s_translation.txt", sanitizedWord)) - if data, err := os.ReadFile(translationFile); err == nil { - content := string(data) - parts := strings.Split(content, "=") - if len(parts) >= 1 { - originalWord := strings.TrimSpace(parts[0]) - a.existingWords = append(a.existingWords, originalWord) - continue - } - } - - // Fallback: use the directory name - a.existingWords = append(a.existingWords, sanitizedWord) + a.existingWords = append(a.existingWords, word) } } @@ -230,12 +268,12 @@ func (a *Application) loadWordByIndex(index int) { a.loadPhoneticInfo(word) // Load image prompt from disk if it exists - sanitized := sanitizeFilename(word) - wordDir := filepath.Join(a.config.OutputDir, sanitized) - promptFile := filepath.Join(wordDir, fmt.Sprintf("%s_prompt.txt", sanitized)) - if data, err := os.ReadFile(promptFile); err == nil { - prompt := strings.TrimSpace(string(data)) - a.imagePromptEntry.SetText(prompt) + if wordDir := a.findCardDirectory(word); wordDir != "" { + promptFile := filepath.Join(wordDir, "prompt.txt") + if data, err := os.ReadFile(promptFile); err == nil { + prompt := strings.TrimSpace(string(data)) + a.imagePromptEntry.SetText(prompt) + } } a.updateStatus(fmt.Sprintf("Loaded from queue: %s", word)) @@ -254,17 +292,27 @@ func (a *Application) loadWordByIndex(index int) { // Update navigation a.updateNavigation() - // Enable action buttons since we have loaded content - a.setActionButtonsEnabled(true) + // Enable action buttons if we have content + hasContent := a.currentAudioFile != "" || a.currentImage != "" || a.currentTranslation != "" + if hasContent { + a.setActionButtonsEnabled(true) + } } // loadExistingFiles loads existing files for a word func (a *Application) loadExistingFiles(word string) { - sanitized := sanitizeFilename(word) - wordDir := filepath.Join(a.config.OutputDir, sanitized) + // Find the card directory for this word + wordDir := a.findCardDirectory(word) + if wordDir == "" { + // No existing directory found + fmt.Printf("No card directory found for word: %s\n", word) + return + } + + fmt.Printf("Loading files from directory: %s\n", wordDir) // Load translation - translationFile := filepath.Join(wordDir, fmt.Sprintf("%s_translation.txt", sanitized)) + translationFile := filepath.Join(wordDir, "translation.txt") if data, err := os.ReadFile(translationFile); err == nil { // Parse translation from "word = translation" format content := string(data) @@ -278,16 +326,19 @@ func (a *Application) loadExistingFiles(word string) { } // Load image prompt file - promptFile := filepath.Join(wordDir, fmt.Sprintf("%s_prompt.txt", sanitized)) + promptFile := filepath.Join(wordDir, "prompt.txt") if data, err := os.ReadFile(promptFile); err == nil { prompt := strings.TrimSpace(string(data)) + fmt.Printf("Loaded prompt from file: %s\n", promptFile) fyne.Do(func() { a.image