diff options
| author | Paul Buetow <paul@buetow.org> | 2025-07-18 19:31:44 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-07-18 19:31:44 +0300 |
| commit | cd3b1e5b2fab8075303c064ba33996a0250cd6a6 (patch) | |
| tree | bf280f05decd692dd3f477815acdb10f7b781144 /internal | |
| parent | aa84a890ba80ba70a6ac311786cb9d80ae3d9e42 (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>
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/anki/generator.go | 36 | ||||
| -rw-r--r-- | internal/gui/app.go | 607 | ||||
| -rw-r--r-- | internal/gui/audio_player.go | 54 | ||||
| -rw-r--r-- | internal/gui/generator.go | 52 | ||||
| -rw-r--r-- | internal/gui/navigation.go | 159 | ||||
| -rw-r--r-- | internal/utils.go | 43 |
6 files changed, 558 insertions, 393 deletions
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) - + // 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) + } |
