summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-16 15:54:16 +0300
committerPaul Buetow <paul@buetow.org>2025-07-16 15:54:16 +0300
commite49ecfe601c924fa68671477331a860acf8a62f7 (patch)
treec57ad6ef1b982f7f0c50cd7b123a4a52c44d6b05
parent7187e7464f16a9d2991ba2da3c672fdb3cf5de72 (diff)
feat: add concurrent processing, queue system, and UI improvements
- Implement concurrent word processing with background queue - Add processing statistics showing active tasks and total cards - Enable circular navigation through cards - Fix card deletion to update total count correctly - Add version number to GUI window title - Prevent unwanted UI reloads for background-processed cards - Enable immediate audio playback before image generation completes - Update 'Keep & Continue' button to 'New Word' - Improve queue status tracking for all processing operations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
-rw-r--r--GUI.md2
-rw-r--r--TODO.md6
-rw-r--r--internal/gui/app.go390
-rw-r--r--internal/gui/navigation.go133
-rw-r--r--internal/gui/queue.go278
5 files changed, 752 insertions, 57 deletions
diff --git a/GUI.md b/GUI.md
index 17b66cc..c53d706 100644
--- a/GUI.md
+++ b/GUI.md
@@ -48,7 +48,7 @@ The GUI provides:
- Audio player with play controls
- Translation display
- **Bottom Section**: Action buttons
- - "Keep & Continue" - saves the current card
+ - "New Word" - saves the current card and clears for a new word
- "Regenerate Image" - gets a new image
- "Regenerate Audio" - generates with a different voice
- "Regenerate All" - regenerates everything
diff --git a/TODO.md b/TODO.md
index d89b2bb..ab84a41 100644
--- a/TODO.md
+++ b/TODO.md
@@ -5,9 +5,13 @@
- [x] Interactive word input with Bulgarian validation
- [x] Live preview of generated images and audio
- [x] Fine-grained regeneration (image-only, audio-only, or both)
-- [x] Session management with "Keep & Continue" functionality
+- [x] Session management with "New Word" functionality
- [x] Export to Anki CSV from GUI session
- [x] Progress indicators and status updates
+- [x] Concurrent word processing in GUI - users can enter new words while previous ones are being generated
+- [x] Word queue system with background processing
+- [x] Queue status display showing pending, processing, and completed jobs
+- [x] Navigation supports both disk files and queue-completed words
## GUI Enhancements (Future)
- [ ] Implement actual audio playback (currently shows controls only)
diff --git a/internal/gui/app.go b/internal/gui/app.go
index 73dcd37..28c703e 100644
--- a/internal/gui/app.go
+++ b/internal/gui/app.go
@@ -15,6 +15,7 @@ import (
"fyne.io/fyne/v2/storage"
"fyne.io/fyne/v2/widget"
+ "codeberg.org/snonux/totalrecall/internal"
"codeberg.org/snonux/totalrecall/internal/anki"
"codeberg.org/snonux/totalrecall/internal/audio"
)
@@ -33,6 +34,7 @@ type Application struct {
translationText *widget.Label
progressBar *widget.ProgressBar
statusLabel *widget.Label
+ queueStatusLabel *widget.Label
// Navigation buttons
prevWordBtn *widget.Button
@@ -50,10 +52,17 @@ type Application struct {
currentAudioFile string
currentImages []string
currentTranslation string
+ currentJobID int
savedCards []anki.Card
existingWords []string // Words already in anki_cards folder
currentWordIndex int
+ // Word processing queue
+ queue *WordQueue
+
+ // Processing statistics
+ processingCount int // Number of tasks currently processing (audio/image)
+
// Configuration
config *Config
audioConfig *audio.Config
@@ -107,6 +116,10 @@ func New(config *Config) *Application {
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",
@@ -126,12 +139,15 @@ func New(config *Config) *Application {
// Scan existing words in output directory
app.scanExistingWords()
+ // Update initial queue status
+ app.updateQueueStatus()
+
return app
}
// setupUI creates the main user interface
func (a *Application) setupUI() {
- a.window = a.app.NewWindow("TotalRecall - Bulgarian Flashcard Generator")
+ 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
@@ -164,7 +180,7 @@ func (a *Application) setupUI() {
)
// Create action buttons
- a.keepButton = widget.NewButton("Keep & Continue", a.onKeepAndContinue)
+ a.keepButton = widget.NewButton("New Word", a.onKeepAndContinue)
a.regenerateImageBtn = widget.NewButton("Regenerate Image", a.onRegenerateImage)
a.regenerateAudioBtn = widget.NewButton("Regenerate Audio", a.onRegenerateAudio)
a.regenerateAllBtn = widget.NewButton("Regenerate All", a.onRegenerateAll)
@@ -188,12 +204,16 @@ func (a *Application) setupUI() {
a.progressBar = widget.NewProgressBar()
a.progressBar.Hide()
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(
a.progressBar,
a.statusLabel,
+ widget.NewSeparator(),
+ a.queueStatusLabel,
),
)
@@ -225,6 +245,7 @@ func (a *Application) setupUI() {
a.window.SetContent(content)
a.window.SetOnClosed(func() {
a.cancel()
+ a.queue.Stop()
a.wg.Wait()
})
}
@@ -247,22 +268,23 @@ func (a *Application) onSubmit() {
return
}
- // Clear previous content
- a.clearUI()
+ // Add word to processing queue
+ job := a.queue.AddWord(word)
- a.currentWord = word
- a.setUIEnabled(false)
- a.showProgress("Generating materials for: " + word)
+ // Clear the input field for next word
+ a.wordInput.SetText("")
- // Generate in background
- a.wg.Add(1)
- go func() {
- defer a.wg.Done()
- a.generateMaterials(word)
- }()
+ // Update status to show word was queued
+ a.updateStatus(fmt.Sprintf("Added '%s' to queue (Job #%d)", word, job.ID))
+
+ // Update queue status immediately
+ a.updateQueueStatus()
+
+ // Start processing if not already processing
+ a.processNextInQueue()
}
-// generateMaterials generates all materials for a word
+// generateMaterials generates all materials for a word (used by regenerate functions)
func (a *Application) generateMaterials(word string) {
// Translate word
fyne.Do(func() {
@@ -284,8 +306,11 @@ func (a *Application) generateMaterials(word string) {
// Generate audio
fyne.Do(func() {
a.updateStatus("Generating audio...")
+ a.incrementProcessing() // Audio processing starts
})
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))
@@ -301,8 +326,11 @@ func (a *Application) generateMaterials(word string) {
// Generate images
fyne.Do(func() {
a.updateStatus("Downloading images...")
+ a.incrementProcessing() // Image processing starts
})
images, err := a.generateImages(word)
+ a.decrementProcessing() // Image processing ends
+
if err != nil {
fyne.Do(func() {
a.showError(fmt.Errorf("Image download failed: %w", err))
@@ -324,37 +352,58 @@ func (a *Application) generateMaterials(word string) {
})
}
-// onKeepAndContinue saves the current card and clears for next
+// onKeepAndContinue saves the current card and clears for a new word
func (a *Application) onKeepAndContinue() {
- // Save current card
- card := anki.Card{
- Bulgarian: a.currentWord,
- AudioFile: a.currentAudioFile,
- ImageFiles: a.currentImages,
- Translation: a.currentTranslation,
+ // Check if we have a complete word to save
+ if a.currentWord != "" && a.currentAudioFile != "" && len(a.currentImages) > 0 {
+ // Save current card
+ card := anki.Card{
+ Bulgarian: a.currentWord,
+ AudioFile: a.currentAudioFile,
+ ImageFiles: a.currentImages,
+ Translation: a.currentTranslation,
+ }
+
+ a.mu.Lock()
+ a.savedCards = append(a.savedCards, card)
+ count := len(a.savedCards)
+ a.mu.Unlock()
+
+ // Save translation file for future navigation
+ if a.currentTranslation != "" {
+ filename := sanitizeFilename(a.currentWord)
+ translationFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_translation.txt", filename))
+ content := fmt.Sprintf("%s = %s\n", a.currentWord, a.currentTranslation)
+ os.WriteFile(translationFile, []byte(content), 0644)
+ }
+
+ // 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()
- a.savedCards = append(a.savedCards, card)
- count := len(a.savedCards)
+ currentJobID := a.currentJobID
+ a.currentJobID = 0
a.mu.Unlock()
- // Save translation file for future navigation
- if a.currentTranslation != "" {
- filename := sanitizeFilename(a.currentWord)
- translationFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_translation.txt", filename))
- content := fmt.Sprintf("%s = %s\n", a.currentWord, a.currentTranslation)
- os.WriteFile(translationFile, []byte(content), 0644)
+ // If there was a job in progress, it will continue in the background
+ if currentJobID != 0 {
+ a.updateStatus("Previous word continues processing in background")
}
- // Rescan existing words to include the new one
- a.scanExistingWords()
-
// Clear UI for next word
a.clearUI()
- a.updateStatus(fmt.Sprintf("Card saved! Total cards: %d", count))
a.wordInput.SetText("")
a.wordInput.FocusGained() // Focus input for next word
+
+ // Hide progress bar if it was showing
+ a.hideProgress()
+
+ // Re-enable submit button
+ a.submitButton.Enable()
}
// onRegenerateImage regenerates only the image
@@ -365,9 +414,12 @@ func (a *Application) onRegenerateImage() {
// Clear the current image immediately
a.imageDisplay.Clear()
+ a.incrementProcessing() // Image processing starts
+
a.wg.Add(1)
go func() {
defer a.wg.Done()
+ defer a.decrementProcessing() // Image processing ends
images, err := a.generateImages(a.currentWord)
if err != nil {
@@ -393,9 +445,12 @@ func (a *Application) onRegenerateAudio() {
a.setActionButtonsEnabled(false)
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 {
@@ -504,7 +559,8 @@ func (a *Application) setActionButtonsEnabled(enabled bool) {
a.regenerateAllBtn.Enable()
a.deleteButton.Enable()
} else {
- a.keepButton.Disable()
+ // Keep "New Word" button enabled to allow starting a new word during processing
+ // a.keepButton.Disable() // Don't disable this
a.regenerateImageBtn.Disable()
a.regenerateAudioBtn.Disable()
a.regenerateAllBtn.Disable()
@@ -514,7 +570,7 @@ func (a *Application) setActionButtonsEnabled(enabled bool) {
func (a *Application) showProgress(message string) {
a.progressBar.Show()
- a.progressBar.SetValue(0.5) // Indeterminate progress
+ a.progressBar.SetValue(0.1) // Start at 10%
a.statusLabel.SetText(message)
}
@@ -536,4 +592,266 @@ func (a *Application) clearUI() {
a.audioPlayer.Clear()
a.translationText.SetText("")
a.setActionButtonsEnabled(false)
-} \ No newline at end of file
+}
+
+// processNextInQueue processes the next word in the queue
+func (a *Application) processNextInQueue() {
+ // Check if we're already processing
+ if a.currentJobID != 0 {
+ return
+ }
+
+ // Get next job from queue
+ job := a.queue.ProcessNextJob()
+ if job == nil {
+ return
+ }
+
+ // Set current job
+ a.mu.Lock()
+ a.currentJobID = job.ID
+ a.currentWord = job.Word
+ 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() {
+ defer a.wg.Done()
+ a.processWordJob(job)
+ }()
+}
+
+// processWordJob processes a single word job
+func (a *Application) processWordJob(job *WordJob) {
+ // 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))
+ a.finishCurrentJob()
+ return
+ }
+
+ // Update UI with translation immediately if this is still the current job
+ a.mu.Lock()
+ if a.currentJobID == job.ID {
+ a.currentTranslation = translation
+ fyne.Do(func() {
+ a.translationText.SetText(fmt.Sprintf("%s = %s", job.Word, translation))
+ })
+ }
+ a.mu.Unlock()
+
+ // Generate audio
+ fyne.Do(func() {
+ a.updateStatus(fmt.Sprintf("Generating audio for '%s'...", job.Word))
+ a.progressBar.SetValue(0.4)
+ 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()
+ if a.currentJobID == job.ID {
+ a.currentAudioFile = audioFile
+ fyne.Do(func() {
+ a.audioPlayer.SetAudioFile(audioFile)
+ // Enable audio-related actions
+ a.regenerateAudioBtn.Enable()
+ })
+ }
+ a.mu.Unlock()
+
+ // Generate images
+ fyne.Do(func() {
+ a.updateStatus(fmt.Sprintf("Downloading images for '%s'...", job.Word))
+ a.progressBar.SetValue(0.7)
+ a.incrementProcessing() // Image processing starts
+ })
+
+ imageFiles, err := a.generateImages(job.Word)
+ a.decrementProcessing() // Image processing ends
+
+ if err != nil {
+ a.queue.FailJob(job.ID, fmt.Errorf("image download failed: %w", err))
+ a.finishCurrentJob()
+ return
+ }
+
+ // Mark job as completed
+ fyne.Do(func() {
+ a.progressBar.SetValue(0.95)
+ a.updateStatus(fmt.Sprintf("Finalizing '%s'...", job.Word))
+ })
+
+ a.queue.CompleteJob(job.ID, translation, audioFile, imageFiles)
+
+ // Update UI with results if this is still the current job
+ a.mu.Lock()
+ if a.currentJobID == job.ID {
+ a.currentTranslation = translation
+ a.currentAudioFile = audioFile
+ a.currentImages = imageFiles
+
+ fyne.Do(func() {
+ a.translationText.SetText(fmt.Sprintf("%s = %s", job.Word, translation))
+ a.imageDisplay.SetImages(imageFiles)
+ a.audioPlayer.SetAudioFile(audioFile)
+ a.hideProgress()
+ a.setActionButtonsEnabled(true)
+ a.updateStatus(fmt.Sprintf("Completed: %s", job.Word))
+ })
+ }
+ a.mu.Unlock()
+
+ // Finish this job
+ a.finishCurrentJob()
+
+ // Update queue status
+ fyne.Do(func() {
+ a.updateQueueStatus()
+ })
+}
+
+// finishCurrentJob clears the current job and processes next in queue
+func (a *Application) finishCurrentJob() {
+ a.mu.Lock()
+ a.currentJobID = 0
+ a.mu.Unlock()
+
+ // Process next in queue
+ fyne.Do(func() {
+ a.processNextInQueue()
+ })
+}
+
+// onQueueStatusUpdate handles queue status updates
+func (a *Application) onQueueStatusUpdate(job *WordJob) {
+ fyne.Do(func() {
+ a.updateQueueStatus()
+ })
+}
+
+// onJobComplete handles job completion
+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()
+
+ // Check if the completed job is for the currently displayed word
+ // Only update UI if the current word is still empty (waiting for this job)
+ if job.Word == a.currentWord && job.ID != a.currentJobID {
+ // Check if the UI is still empty/waiting for content
+ hasContent := a.currentAudioFile != "" || len(a.currentImages) > 0
+
+ if !hasContent {
+ // Update the UI with the completed results since it's still waiting
+ // Update each component individually to show progress
+ if job.Translation != "" && a.currentTranslation == "" {
+ a.currentTranslation = job.Translation
+ a.translationText.SetText(fmt.Sprintf("%s = %s", job.Word, job.Translation))
+ }
+ if job.AudioFile != "" && a.currentAudioFile == "" {
+ a.currentAudioFile = job.AudioFile
+ a.audioPlayer.SetAudioFile(job.AudioFile)
+ a.regenerateAudioBtn.Enable()
+ }
+ if len(job.ImageFiles) > 0 && len(a.currentImages) == 0 {
+ a.currentImages = job.ImageFiles
+ a.imageDisplay.SetImages(job.ImageFiles)
+ a.regenerateImageBtn.Enable()
+ }
+
+ // Enable all action buttons since we now have complete content
+ a.setActionButtonsEnabled(true)
+ a.updateStatus(fmt.Sprintf("Processing completed: %s", job.Word))
+ } else {
+ // Word already has content, just show notification
+ a.updateStatus(fmt.Sprintf("Background processing completed: %s", job.Word))
+ }
+ } else if job.ID != a.currentJobID {
+ // Show a subtle notification for other background completions
+ a.updateStatus(fmt.Sprintf("Background processing completed: %s", job.Word))
+ }
+ }
+ })
+}
+
+// updateQueueStatus updates the queue status label
+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)
+}
+
+// incrementProcessing increments the processing count and updates the status
+func (a *Application) incrementProcessing() {
+ a.mu.Lock()
+ a.processingCount++
+ a.mu.Unlock()
+
+ // Update UI on main thread
+ fyne.Do(func() {
+ a.updateQueueStatus()
+ })
+}
+
+// decrementProcessing decrements the processing count and updates the status
+func (a *Application) decrementProcessing() {
+ a.mu.Lock()
+ if a.processingCount > 0 {
+ a.processingCount--
+ }
+ a.mu.Unlock()
+
+ // Update UI on main thread
+ fyne.Do(func() {
+ a.updateQueueStatus()
+ })
+}
+
diff --git a/internal/gui/navigation.go b/internal/gui/navigation.go
index f8931ba..f24dcc1 100644
--- a/internal/gui/navigation.go
+++ b/internal/gui/navigation.go
@@ -9,6 +9,8 @@ import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/dialog"
+
+ "codeberg.org/snonux/totalrecall/internal/anki"
)
// scanExistingWords scans the output directory for existing words
@@ -62,53 +64,100 @@ func (a *Application) scanExistingWords() {
// updateNavigation updates the navigation button states
func (a *Application) updateNavigation() {
- if len(a.existingWords) > 0 {
+ // Get all available words (existing + completed from queue)
+ allWords := a.getAllAvailableWords()
+
+ if len(allWords) > 1 {
+ // Enable both buttons when there's more than one word (allows circular navigation)
a.prevWordBtn.Enable()
a.nextWordBtn.Enable()
// Find current word index
a.currentWordIndex = -1
- for i, word := range a.existingWords {
+ for i, word := range allWords {
if word == a.currentWord {
a.currentWordIndex = i
break
}
}
-
- // Disable at boundaries
- if a.currentWordIndex <= 0 {
- a.prevWordBtn.Disable()
- }
- if a.currentWordIndex >= len(a.existingWords)-1 || a.currentWordIndex == -1 {
- a.nextWordBtn.Disable()
- }
+ } else if len(allWords) == 1 {
+ // With only one word, disable navigation
+ a.prevWordBtn.Disable()
+ a.nextWordBtn.Disable()
} else {
+ // No words at all
a.prevWordBtn.Disable()
a.nextWordBtn.Disable()
}
}
+// getAllAvailableWords returns all words (from disk and completed queue jobs)
+func (a *Application) getAllAvailableWords() []string {
+ // Start with existing words from disk
+ words := make([]string, len(a.existingWords))
+ copy(words, a.existingWords)
+
+ // Add completed jobs from queue
+ completedJobs := a.queue.GetCompletedJobs()
+ for _, job := range completedJobs {
+ // Check if this word is already in the list
+ found := false
+ for _, w := range words {
+ if w == job.Word {
+ found = true
+ break
+ }
+ }
+ if !found {
+ words = append(words, job.Word)
+ }
+ }
+
+ // Sort the combined list
+ sort.Strings(words)
+ return words
+}
+
// onPrevWord loads the previous word
func (a *Application) onPrevWord() {
- if a.currentWordIndex > 0 {
- a.loadWordByIndex(a.currentWordIndex - 1)
+ allWords := a.getAllAvailableWords()
+ if len(allWords) == 0 {
+ return
}
+
+ newIndex := a.currentWordIndex - 1
+ // Wrap around to the end if at beginning
+ if newIndex < 0 {
+ newIndex = len(allWords) - 1
+ }
+
+ a.loadWordByIndex(newIndex)
}
// onNextWord loads the next word
func (a *Application) onNextWord() {
- if a.currentWordIndex < len(a.existingWords)-1 && a.currentWordIndex >= 0 {
- a.loadWordByIndex(a.currentWordIndex + 1)
+ allWords := a.getAllAvailableWords()
+ if len(allWords) == 0 {
+ return
}
+
+ newIndex := a.currentWordIndex + 1
+ // Wrap around to the beginning if at end
+ if newIndex >= len(allWords) {
+ newIndex = 0
+ }
+
+ a.loadWordByIndex(newIndex)
}
-// loadWordByIndex loads a word by its index in existingWords
+// loadWordByIndex loads a word by its index in the combined word list
func (a *Application) loadWordByIndex(index int) {
- if index < 0 || index >= len(a.existingWords) {
+ allWords := a.getAllAvailableWords()
+ if index < 0 || index >= len(allWords) {
return
}
- word := a.existingWords[index]
+ word := allWords[index]
a.currentWord = word
a.currentWordIndex = index
@@ -118,8 +167,38 @@ func (a *Application) loadWordByIndex(index int) {
// Clear UI
a.clearUI()
- // Load existing files
- a.loadExistingFiles(word)
+ // Check if this word is from a completed queue job
+ var fromQueue bool
+ completedJobs := a.queue.GetCompletedJobs()
+ for _, job := range completedJobs {
+ if job.Word == word && job.Status == StatusCompleted {
+ // Load from queue job
+ a.currentTranslation = job.Translation
+ a.currentAudioFile = job.AudioFile
+ a.currentImages = job.ImageFiles
+
+ fyne.Do(func() {
+ if job.Translation != "" {
+ a.translationText.SetText(fmt.Sprintf("%s = %s", word, job.Translation))
+ }
+ if job.AudioFile != "" {
+ a.audioPlayer.SetAudioFile(job.AudioFile)
+ }
+ if len(job.ImageFiles) > 0 {
+ a.imageDisplay.SetImages(job.ImageFiles)
+ }
+ a.updateStatus(fmt.Sprintf("Loaded from queue: %s", word))
+ })
+
+ fromQueue = true
+ break
+ }
+ }
+
+ // If not from queue, load existing files from disk
+ if !fromQueue {
+ a.loadExistingFiles(word)
+ }
// Update navigation
a.updateNavigation()
@@ -243,12 +322,28 @@ func (a *Application) deleteCurrentWord() {
}
a.existingWords = newWords
+ // Also remove from saved cards if present
+ a.mu.Lock()
+ newSavedCards := make([]anki.Card, 0, len(a.savedCards))
+ for _, card := range a.savedCards {
+ if card.Bulgarian != a.currentWord {
+ newSavedCards = append(newSavedCards, card)
+ }
+ }
+ a.savedCards = newSavedCards
+ a.mu.Unlock()
+
+ // Also remove from completed queue jobs
+ a.queue.RemoveCompletedJobByWord(a.currentWord)
+
// Clear UI
a.clearUI()
// Update status
fyne.Do(func() {
a.updateStatus(fmt.Sprintf("Deleted %d files for '%s'", deletedCount, a.currentWord))
+ // Update queue status to reflect the reduced card count
+ a.updateQueueStatus()
})
// Clear current word
diff --git a/internal/gui/queue.go b/internal/gui/queue.go
new file mode 100644
index 0000000..7b1c5de
--- /dev/null
+++ b/internal/gui/queue.go
@@ -0,0 +1,278 @@
+package gui
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "time"
+)
+
+// WordJob represents a single word processing job
+type WordJob struct {
+ ID int
+ Word string
+ Translation string
+ AudioFile string
+ ImageFiles []string
+ Status JobStatus
+ Error error
+ StartedAt time.Time
+ CompletedAt time.Time
+}
+
+// JobStatus represents the current state of a job
+type JobStatus int
+
+const (
+ StatusQueued JobStatus = iota
+ StatusProcessing
+ StatusCompleted
+ StatusFailed
+)
+
+func (s JobStatus) String() string {
+ switch s {
+ case StatusQueued:
+ return "Queued"
+ case StatusProcessing:
+ return "Processing"
+ case StatusCompleted:
+ return "Completed"
+ case StatusFailed:
+ return "Failed"
+ default:
+ return "Unknown"
+ }
+}
+
+// WordQueue manages the queue of words to be processed
+type WordQueue struct {
+ jobs chan *WordJob
+ results map[int]*WordJob
+ processing map[int]*WordJob
+ completed []*WordJob
+
+ nextID int
+ mu sync.RWMutex
+
+ // Callbacks for UI updates
+ onStatusUpdate func(job *WordJob)
+ onJobComplete func(job *WordJob)
+
+ ctx context.Context
+ cancel context.CancelFunc
+ wg sync.WaitGroup
+}
+
+// NewWordQueue creates a new word processing queue
+func NewWordQueue(ctx context.Context) *WordQueue {
+ queueCtx, cancel := context.WithCancel(ctx)
+
+ q := &WordQueue{
+ jobs: make(chan *WordJob, 100),
+ results: make(map[int]*WordJob),
+ processing: make(map[int]*WordJob),
+ completed: make([]*WordJob, 0),
+ nextID: 1,
+ ctx: queueCtx,
+ cancel: cancel,
+ }
+
+ // Don't start a worker - the GUI will pull jobs
+
+ return q
+}
+
+// SetCallbacks sets the callback functions for UI updates
+func (q *WordQueue) SetCallbacks(onStatusUpdate func(*WordJob), onJobComplete func(*WordJob)) {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+ q.onStatusUpdate = onStatusUpdate
+ q.onJobComplete = onJobComplete
+}
+
+// AddWord adds a word to the processing queue
+func (q *WordQueue) AddWord(word string) *WordJob {
+ q.mu.Lock()
+ job := &WordJob{
+ ID: q.nextID,
+ Word: word,
+ Status: StatusQueued,
+ }
+ q.nextID++
+ q.results[job.ID] = job
+ q.mu.Unlock()
+
+ // Try to add to queue
+ select {
+ case q.jobs <- job:
+ q.updateJobStatus(job, StatusQueued)
+ return job
+ case <-q.ctx.Done():
+ job.Status = StatusFailed
+ job.Error = fmt.Errorf("queue is shutting down")
+ return job
+ }
+}
+
+// GetJob returns a job by ID
+func (q *WordQueue) GetJob(id int) *WordJob {
+ q.mu.RLock()
+ defer q.mu.RUnlock()
+ return q.results[id]
+}
+
+// GetQueueStatus returns the current queue statistics
+func (q *WordQueue) GetQueueStatus() (queued, processing, completed, failed int) {
+ q.mu.RLock()
+ defer q.mu.RUnlock()
+
+ // Count based on job statuses for accuracy
+ for _, job := range q.results {
+ switch job.Status {
+ case StatusQueued:
+ queued++
+ case StatusProcessing:
+ processing++
+ case StatusCompleted:
+ completed++
+ case StatusFailed:
+ failed++
+ }
+ }
+
+ return
+}
+
+// GetActiveJobs returns all jobs that are currently queued or processing
+func (q *WordQueue) GetActiveJobs() []*WordJob {
+ q.mu.RLock()
+ defer q.mu.RUnlock()
+
+ var jobs []*WordJob
+
+ // Add processing jobs
+ for _, job := range q.processing {
+ jobs = append(jobs, job)
+ }
+
+ // Add queued jobs from channel (non-blocking)
+ queuedJobs := make([]*WordJob, 0)
+ for {
+ select {
+ case job := <-q.jobs:
+ queuedJobs = append(queuedJobs, job)
+ default:
+ // Re-add jobs back to queue
+ for _, job := range queuedJobs {
+ q.jobs <- job
+ }
+ jobs = append(jobs, queuedJobs...)
+ return jobs
+ }
+ }
+}
+
+// GetCompletedJobs returns all completed jobs
+func (q *WordQueue) GetCompletedJobs() []*WordJob {
+ q.mu.RLock()
+ defer q.mu.RUnlock()
+ return append([]*WordJob{}, q.completed...)
+}
+
+// Stop gracefully shuts down the queue
+func (q *WordQueue) Stop() {
+ q.cancel()
+ close(q.jobs)
+}
+
+// CompleteJob marks a job as completed with results
+func (q *WordQueue) CompleteJob(jobID int, translation, audioFile string, imageFiles []string) {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ if job, exists := q.results[jobID]; exists {
+ job.Status = StatusCompleted
+ job.Translation = translation
+ job.AudioFile = audioFile
+ job.ImageFiles = imageFiles
+ job.CompletedAt = time.Now()
+
+ delete(q.processing, jobID)
+ q.completed = append(q.completed, job)
+
+ if q.onJobComplete != nil {
+ q.onJobComplete(job)
+ }
+ }
+}
+
+// FailJob marks a job as failed with an error
+func (q *WordQueue) FailJob(jobID int, err error) {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ if job, exists := q.results[jobID]; exists {
+ job.Status = StatusFailed
+ job.Error = err
+ job.CompletedAt = time.Now()
+
+ delete(q.processing, jobID)
+
+ if q.onJobComplete != nil {
+ q.onJobComplete(job)
+ }
+ }
+}
+
+// updateJobStatus updates the status of a job and calls the callback
+func (q *WordQueue) updateJobStatus(job *WordJob, status JobStatus) {
+ job.Status = status
+ if q.onStatusUpdate != nil {
+ q.onStatusUpdate(job)
+ }
+}
+
+// ProcessNextJob should be called by the GUI to process the next job in queue
+func (q *WordQueue) ProcessNextJob() *WordJob {
+ select {
+ case job := <-q.jobs:
+ q.mu.Lock()
+ q.processing[job.ID] = job
+ job.Status = StatusProcessing
+ job.StartedAt = time.Now()
+ q.mu.Unlock()
+
+ // Call the status update callback
+ if q.onStatusUpdate != nil {
+ q.onStatusUpdate(job)
+ }
+
+ return job
+
+ default:
+ return nil
+ }
+}
+
+// RemoveCompletedJobByWord removes a completed job for a specific word
+func (q *WordQueue) RemoveCompletedJobByWord(word string) {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ // Remove from completed jobs list
+ newCompleted := make([]*WordJob, 0, len(q.completed))
+ for _, job := range q.completed {
+ if job.Word != word {
+ newCompleted = append(newCompleted, job)
+ }
+ }
+ q.completed = newCompleted
+
+ // Also remove from results map
+ for id, job := range q.results {
+ if job.Word == word && job.Status == StatusCompleted {
+ delete(q.results, id)
+ }
+ }
+} \ No newline at end of file