From 18a475657cbc7b2ff8ee537b082eeef25e9bf619 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Tue, 22 Jul 2025 16:03:13 +0300 Subject: Fix race conditions in background processing and prevent deletion of active cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix race condition where images, audio, and phonetic info could be saved to wrong flashcard when navigating quickly between cards - Add pre-determined card directory that's passed to all background operations - Track active operations per word to prevent deletion during generation - Block deletion of cards that are queued or being processed - Show appropriate error messages when deletion is blocked This ensures files are always saved to the correct card directory and prevents data loss from deleting cards with active operations. 🤖 Generated with [opencode](https://opencode.ai) Co-Authored-By: opencode --- internal/gui/app.go | 210 +++++++++++++++++++++++++++++++++------------ internal/gui/generator.go | 58 ++++--------- internal/gui/navigation.go | 12 +++ internal/gui/queue.go | 67 +++++++++------ 4 files changed, 223 insertions(+), 124 deletions(-) diff --git a/internal/gui/app.go b/internal/gui/app.go index 547a22b..f776d9b 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -93,6 +93,10 @@ type Application struct { // Per-card cancellation tracking cardContexts map[string]context.CancelFunc // Map of word -> cancel function cardMu sync.Mutex // Mutex for cardContexts map + + // Active operations tracking + activeOperations map[string]int // Map of word -> count of active operations + activeOpMu sync.Mutex // Mutex for activeOperations map } // Config holds GUI application configuration @@ -147,13 +151,14 @@ func New(config *Config) *Application { myApp.SetIcon(GetAppIcon()) app := &Application{ - app: myApp, - config: config, - ctx: ctx, - cancel: cancel, - savedCards: make([]anki.Card, 0), - cardContexts: make(map[string]context.CancelFunc), - autoPlayEnabled: config.AutoPlay, // Use config setting + app: myApp, + config: config, + ctx: ctx, + cancel: cancel, + savedCards: make([]anki.Card, 0), + cardContexts: make(map[string]context.CancelFunc), + activeOperations: make(map[string]int), + autoPlayEnabled: config.AutoPlay, // Use config setting } // Initialize the word processing queue @@ -556,6 +561,16 @@ func (a *Application) onSubmit() { func (a *Application) generateMaterials(word string) { // Get or create context for this card cardCtx, _ := a.getOrCreateCardContext(word) + + // Ensure card directory exists + cardDir, err := a.ensureCardDirectory(word) + if err != nil { + fyne.Do(func() { + a.showError(fmt.Errorf("Failed to create card directory: %w", err)) + a.setUIEnabled(true) + }) + return + } // Check if we already have a translation if a.currentTranslation == "" { // Translate word @@ -580,25 +595,13 @@ func (a *Application) generateMaterials(word string) { } a.mu.Unlock() - // Save translation to disk regardless + // Save translation to disk using the pre-determined directory if translation != "" { - // Find existing card directory first - wordDir := a.findCardDirectory(word) - if wordDir == "" { - // No existing directory, create new one with card ID - cardID := internal.GenerateCardID(word) - 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(word), 0644) - } - translationFile := filepath.Join(wordDir, "translation.txt") + translationFile := filepath.Join(cardDir, "translation.txt") content := fmt.Sprintf("%s = %s\n", word, translation) os.WriteFile(translationFile, []byte(content), 0644) } } - // Create channels for parallel operations type audioResult struct { file string @@ -634,11 +637,14 @@ func (a *Application) generateMaterials(word string) { // 1. Audio generation go func() { + a.startOperation(word) // Track operation start + defer a.endOperation(word) // Track operation end + fyne.Do(func() { a.incrementProcessing() // Audio processing starts }) - audioFile, err := a.generateAudio(cardCtx, word) + audioFile, err := a.generateAudio(cardCtx, word, cardDir) a.decrementProcessing() // Audio processing ends audioChan <- audioResult{file: audioFile, err: err} @@ -646,6 +652,9 @@ func (a *Application) generateMaterials(word string) { // 2. Image generation go func() { + a.startOperation(word) // Track operation start + defer a.endOperation(word) // Track operation end + fyne.Do(func() { a.incrementProcessing() // Image processing starts // Show generating status if this is still the current word @@ -656,7 +665,7 @@ func (a *Application) generateMaterials(word string) { a.mu.Unlock() }) - imageFile, err := a.generateImagesWithPrompt(cardCtx, word, customPrompt, translation) + imageFile, err := a.generateImagesWithPrompt(cardCtx, word, customPrompt, translation, cardDir) a.decrementProcessing() // Image processing ends imageChan <- imageResult{file: imageFile, err: err} @@ -664,6 +673,9 @@ func (a *Application) generateMaterials(word string) { // 3. Phonetic information fetching go func() { + a.startOperation(word) // Track operation start + defer a.endOperation(word) // Track operation end + fyne.Do(func() { a.incrementProcessing() // Phonetic processing starts }) @@ -677,11 +689,11 @@ func (a *Application) generateMaterials(word string) { fmt.Printf("Successfully fetched phonetic info for '%s': %s\n", word, phoneticInfo) } - // Save phonetic info to disk + // Save phonetic info to disk using the pre-determined directory if phoneticInfo != "" && phoneticInfo != "Failed to fetch phonetic information" { - a.savePhoneticInfoForWord(word, phoneticInfo) + phoneticFile := filepath.Join(cardDir, "phonetic.txt") + os.WriteFile(phoneticFile, []byte(phoneticInfo), 0644) } - // Update UI immediately with phonetic info if this is still the current word if phoneticInfo != "" && phoneticInfo != "Failed to fetch phonetic information" { a.mu.Lock() @@ -879,7 +891,16 @@ func (a *Application) onRegenerateImage() { // Get or create context for this card cardCtx, _ := a.getOrCreateCardContext(wordForGeneration) - imageFile, err := a.generateImagesWithPrompt(cardCtx, wordForGeneration, customPrompt, translation) + // Ensure card directory exists + cardDir, err := a.ensureCardDirectory(wordForGeneration) + if err != nil { + fyne.Do(func() { + a.showError(fmt.Errorf("Failed to create card directory: %w", err)) + }) + return + } + + imageFile, err := a.generateImagesWithPrompt(cardCtx, wordForGeneration, customPrompt, translation, cardDir) if err != nil { fyne.Do(func() { a.showError(fmt.Errorf("Image regeneration failed: %w", err)) @@ -943,7 +964,16 @@ func (a *Application) onRegenerateRandomImage() { // Get or create context for this card cardCtx, _ := a.getOrCreateCardContext(wordForGeneration) - imageFile, err := a.generateImagesWithPrompt(cardCtx, wordForGeneration, customPrompt, translation) + // Ensure card directory exists + cardDir, err := a.ensureCardDirectory(wordForGeneration) + if err != nil { + fyne.Do(func() { + a.showError(fmt.Errorf("Failed to create card directory: %w", err)) + }) + return + } + + imageFile, err := a.generateImagesWithPrompt(cardCtx, wordForGeneration, customPrompt, translation, cardDir) if err != nil { fyne.Do(func() { a.showError(fmt.Errorf("Random image generation failed: %w", err)) @@ -986,15 +1016,33 @@ func (a *Application) onRegenerateAudio() { a.wg.Add(1) go func() { defer a.wg.Done() - defer a.decrementProcessing() // Audio processing ends + defer a.decrementProcessing() // Image processing ends + // Use the current translation to avoid re-translating + translation := a.currentTranslation + if translation == "" { + // Use the text from translationEntry if currentTranslation is not set + translation = strings.TrimSpace(a.translationEntry.Text) + } // Store the word we're generating for wordForGeneration := a.currentWord + a.startOperation(wordForGeneration) // Track operation start + defer a.endOperation(wordForGeneration) // Track operation end + // Get or create context for this card cardCtx, _ := a.getOrCreateCardContext(wordForGeneration) - audioFile, err := a.generateAudio(cardCtx, wordForGeneration) + // Ensure card directory exists + cardDir, err := a.ensureCardDirectory(wordForGeneration) + if err != nil { + fyne.Do(func() { + a.showError(fmt.Errorf("Failed to create card directory: %w", err)) + }) + return + } + + audioFile, err := a.generateAudio(cardCtx, wordForGeneration, cardDir) if err != nil { fyne.Do(func() { a.showError(fmt.Errorf("Audio regeneration failed: %w", err)) @@ -1725,6 +1773,30 @@ func (a *Application) getOrCreateCardContext(word string) (context.Context, cont return ctx, cancel } +// ensureCardDirectory ensures a card directory exists for the given word and returns its path +func (a *Application) ensureCardDirectory(word string) (string, error) { + // First check if directory already exists + wordDir := a.findCardDirectory(word) + if wordDir != "" { + return wordDir, nil + } + + // Create new directory with 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) + } + + return wordDir, nil +} + // cancelCardOperations cancels all ongoing operations for a specific word func (a *Application) cancelCardOperations(word string) { a.cardMu.Lock() @@ -1736,6 +1808,36 @@ func (a *Application) cancelCardOperations(word string) { } } +// startOperation marks the start of an operation for a word +func (a *Application) startOperation(word string) { + a.activeOpMu.Lock() + defer a.activeOpMu.Unlock() + a.activeOperations[word]++ +} + +// endOperation marks the end of an operation for a word +func (a *Application) endOperation(word string) { + a.activeOpMu.Lock() + defer a.activeOpMu.Unlock() + + if count, exists := a.activeOperations[word]; exists { + if count > 1 { + a.activeOperations[word]-- + } else { + delete(a.activeOperations, word) + } + } +} + +// hasActiveOperations checks if a word has any active operations +func (a *Application) hasActiveOperations(word string) bool { + a.activeOpMu.Lock() + defer a.activeOpMu.Unlock() + + count, exists := a.activeOperations[word] + return exists && count > 0 +} + // processWordJob processes a single word job func (a *Application) processWordJob(job *WordJob) { // Get or create context for this card @@ -1749,6 +1851,15 @@ func (a *Application) processWordJob(job *WordJob) { return default: } + + // Ensure card directory exists upfront + cardDir, dirErr := a.ensureCardDirectory(job.Word) + if dirErr != nil { + a.queue.FailJob(job.ID, fmt.Errorf("failed to create card directory: %w", dirErr)) + a.finishCurrentJob() + return + } + // Handle translation var translation string var err error @@ -1772,18 +1883,7 @@ func (a *Application) processWordJob(job *WordJob) { // Save translation to disk immediately for this specific word if translation != "" { - // Find existing card directory first - wordDir := a.findCardDirectory(job.Word) - if wordDir == "" { - // No existing directory, create new one with card ID - cardID := internal.GenerateCardID(job.Word) - 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(job.Word), 0644) - } - translationFile := filepath.Join(wordDir, "translation.txt") + translationFile := filepath.Join(cardDir, "translation.txt") content := fmt.Sprintf("%s = %s\n", job.Word, translation) os.WriteFile(translationFile, []byte(content), 0644) } @@ -1825,11 +1925,14 @@ func (a *Application) processWordJob(job *WordJob) { // 1. Audio generation go func() { + a.startOperation(job.Word) // Track operation start + defer a.endOperation(job.Word) // Track operation end + fyne.Do(func() { a.incrementProcessing() // Audio processing starts }) - audioFile, err := a.generateAudio(cardCtx, job.Word) + audioFile, err := a.generateAudio(cardCtx, job.Word, cardDir) a.decrementProcessing() // Audio processing ends audioChan <- audioResult{file: audioFile, err: err} @@ -1837,6 +1940,9 @@ func (a *Application) processWordJob(job *WordJob) { // 2. Image generation (includes scene description) go func() { + a.startOperation(job.Word) // Track operation start + defer a.endOperation(job.Word) // Track operation end + fyne.Do(func() { a.incrementProcessing() // Image processing starts // Show generating status if this is still the current job @@ -1849,7 +1955,7 @@ func (a *Application) processWordJob(job *WordJob) { // Use the custom prompt from the job // The translation variable already contains the correct translation (either from job or translated) - imageFile, err := a.generateImagesWithPrompt(cardCtx, job.Word, job.CustomPrompt, translation) + imageFile, err := a.generateImagesWithPrompt(cardCtx, job.Word, job.CustomPrompt, translation, cardDir) a.decrementProcessing() // Image processing ends imageChan <- imageResult{file: imageFile, err: err} @@ -1857,6 +1963,9 @@ func (a *Application) processWordJob(job *WordJob) { // 3. Phonetic information fetching go func() { + a.startOperation(job.Word) // Track operation start + defer a.endOperation(job.Word) // Track operation end + fyne.Do(func() { a.incrementProcessing() // Phonetic processing starts }) @@ -1872,18 +1981,7 @@ func (a *Application) processWordJob(job *WordJob) { // Save phonetic info to disk immediately for this specific word if phoneticInfo != "" && phoneticInfo != "Failed to fetch phonetic information" { - // Find existing card directory first - wordDir := a.findCardDirectory(job.Word) - if wordDir == "" { - // No existing directory, create new one with card ID - cardID := internal.GenerateCardID(job.Word) - 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(job.Word), 0644) - } - phoneticFile := filepath.Join(wordDir, "phonetic.txt") + phoneticFile := filepath.Join(cardDir, "phonetic.txt") os.WriteFile(phoneticFile, []byte(phoneticInfo), 0644) } diff --git a/internal/gui/generator.go b/internal/gui/generator.go index 814e578..cfb8ad3 100644 --- a/internal/gui/generator.go +++ b/internal/gui/generator.go @@ -12,7 +12,6 @@ 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" ) @@ -84,12 +83,11 @@ func (a *Application) translateEnglishToBulgarian(word string) (string, error) { } // generateAudio generates audio for a word -func (a *Application) generateAudio(ctx context.Context, word string) (string, error) { +func (a *Application) generateAudio(ctx context.Context, word string, cardDir string) (string, error) { // Check if this is a regeneration by looking for existing audio file - wordDir := a.findCardDirectory(word) isRegeneration := false - if wordDir != "" { - audioFile := filepath.Join(wordDir, fmt.Sprintf("audio.%s", a.config.AudioFormat)) + if cardDir != "" { + audioFile := filepath.Join(cardDir, fmt.Sprintf("audio.%s", a.config.AudioFormat)) if _, err := os.Stat(audioFile); err == nil { isRegeneration = true } @@ -124,25 +122,13 @@ func (a *Application) generateAudio(ctx context.Context, word string) (string, e return "", err } - // Find existing card directory or create new one again after provider creation - wordDir = a.findCardDirectory(word) - if wordDir == "" { - // No existing directory, create new one with 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) - } + // Use the provided card directory + if cardDir == "" { + return "", fmt.Errorf("card directory not provided") } // Generate filename in subdirectory - outputFile := filepath.Join(wordDir, fmt.Sprintf("audio.%s", a.config.AudioFormat)) + outputFile := filepath.Join(cardDir, fmt.Sprintf("audio.%s", a.config.AudioFormat)) // Generate audio err = provider.GenerateAudio(ctx, word, outputFile) @@ -157,7 +143,7 @@ func (a *Application) generateAudio(ctx context.Context, word string) (string, e } // Save voice metadata for GUI display - metadataFile := filepath.Join(wordDir, "audio_metadata.txt") + metadataFile := filepath.Join(cardDir, "audio_metadata.txt") metadata := fmt.Sprintf("voice=%s\nspeed=%.2f\n", voice, speed) if err := os.WriteFile(metadataFile, []byte(metadata), 0644); err != nil { fmt.Printf("Warning: Failed to save audio metadata: %v\n", err) @@ -167,12 +153,12 @@ func (a *Application) generateAudio(ctx context.Context, word string) (string, e } // generateImages downloads images for a word -func (a *Application) generateImages(ctx context.Context, word string) (string, error) { - return a.generateImagesWithPrompt(ctx, word, "", "") +func (a *Application) generateImages(ctx context.Context, word string, cardDir string) (string, error) { + return a.generateImagesWithPrompt(ctx, word, "", "", cardDir) } // generateImagesWithPrompt downloads a single image for a word with optional custom prompt and translation -func (a *Application) generateImagesWithPrompt(ctx context.Context, word string, customPrompt string, translation string) (string, error) { +func (a *Application) generateImagesWithPrompt(ctx context.Context, word string, customPrompt string, translation string, cardDir string) (string, error) { // Create image searcher based on provider var searcher image.ImageSearcher var err error @@ -196,26 +182,14 @@ func (a *Application) generateImagesWithPrompt(ctx context.Context, word string, return "", fmt.Errorf("unknown image provider: %s", a.config.ImageProvider) } - // Find existing card directory or create new one - wordDir := a.findCardDirectory(word) - if wordDir == "" { - // No existing directory, create new one with 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) - } + // Use the provided card directory + if cardDir == "" { + return "", fmt.Errorf("card directory not provided") } // Create downloader downloadOpts := &image.DownloadOptions{ - OutputDir: wordDir, + OutputDir: cardDir, OverwriteExisting: true, CreateDir: true, FileNamePattern: "image", @@ -229,7 +203,7 @@ func (a *Application) generateImagesWithPrompt(ctx context.Context, word string, if openaiClient, ok := searcher.(*image.OpenAIClient); ok { openaiClient.SetPromptCallback(func(prompt string) { // Save the prompt to disk immediately for this word - promptFile := filepath.Join(wordDir, "image_prompt.txt") + promptFile := filepath.Join(cardDir, "image_prompt.txt") os.WriteFile(promptFile, []byte(prompt), 0644) // Only update UI if this word is still the current word diff --git a/internal/gui/navigation.go b/internal/gui/navigation.go index c91a57a..d7410f1 100644 --- a/internal/gui/navigation.go +++ b/internal/gui/navigation.go @@ -580,6 +580,18 @@ func (a *Application) onDelete() { return } + // Check if this word has active operations + if a.hasActiveOperations(a.currentWord) { + dialog.ShowError(fmt.Errorf("Cannot delete '%s' while content is being generated.\nPlease wait for generation to complete.", a.currentWord), a.window) + return + } + + // Also check if word is in the processing queue + if a.queue.IsWordProcessing(a.currentWord) { + dialog.ShowError(fmt.Errorf("Cannot delete '%s' while it is in the processing queue.\nPlease wait for processing to complete.", a.currentWord), a.window) + return + } + // Create custom confirmation dialog with keyboard support message := fmt.Sprintf("Move all files for '%s' to trash?\n\nPress y to confirm or n to cancel", a.currentWord) confirmDialog := dialog.NewConfirm("Move to Trash", message, func(confirm bool) { diff --git a/internal/gui/queue.go b/internal/gui/queue.go index f72b059..1c52c9f 100644 --- a/internal/gui/queue.go +++ b/internal/gui/queue.go @@ -53,14 +53,14 @@ type WordQueue struct { results map[int]*WordJob processing map[int]*WordJob completed []*WordJob - - nextID int - mu sync.RWMutex - + + 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 @@ -69,7 +69,7 @@ type WordQueue struct { // 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), @@ -79,9 +79,9 @@ func NewWordQueue(ctx context.Context) *WordQueue { ctx: queueCtx, cancel: cancel, } - + // Don't start a worker - the GUI will pull jobs - + return q } @@ -110,7 +110,7 @@ func (q *WordQueue) AddWordWithPrompt(word, customPrompt string) *WordJob { q.nextID++ q.results[job.ID] = job q.mu.Unlock() - + // Try to add to queue select { case q.jobs <- job: @@ -134,7 +134,7 @@ func (q *WordQueue) GetJob(id int) *WordJob { 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 { @@ -148,7 +148,7 @@ func (q *WordQueue) GetQueueStatus() (queued, processing, completed, failed int) failed++ } } - + return } @@ -156,14 +156,14 @@ func (q *WordQueue) GetQueueStatus() (queued, processing, completed, failed int) 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 { @@ -198,17 +198,17 @@ func (q *WordQueue) Stop() { func (q *WordQueue) CompleteJob(jobID int, translation, audioFile, imageFile 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.ImageFile = imageFile job.CompletedAt = time.Now() - + delete(q.processing, jobID) q.completed = append(q.completed, job) - + if q.onJobComplete != nil { q.onJobComplete(job) } @@ -219,14 +219,14 @@ func (q *WordQueue) CompleteJob(jobID int, translation, audioFile, imageFile str 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) } @@ -250,14 +250,14 @@ func (q *WordQueue) ProcessNextJob() *WordJob { 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 } @@ -267,7 +267,7 @@ func (q *WordQueue) ProcessNextJob() *WordJob { 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 { @@ -276,11 +276,26 @@ func (q *WordQueue) RemoveCompletedJobByWord(word string) { } } 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 +} + +// IsWordProcessing checks if a word is currently being processed or queued +func (q *WordQueue) IsWordProcessing(word string) bool { + q.mu.Lock() + defer q.mu.Unlock() + + // Check all jobs in results + for _, job := range q.results { + if job.Word == word && (job.Status == StatusQueued || job.Status == StatusProcessing) { + return true + } + } + + return false +} -- cgit v1.2.3