diff options
| author | Paul Buetow <paul@buetow.org> | 2025-07-19 22:43:42 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-07-19 22:43:42 +0300 |
| commit | 06a11a3aabec600a3388b7e818434052474ea341 (patch) | |
| tree | 80821c7c19d98883dc7d9e4e03d793b3a6f9c06e /internal/gui | |
| parent | 44cf5eee8fba096496f0704cec44fd436ecb5c2e (diff) | |
feat: add context cancellation for card operations
- Add per-card context tracking to cancel ongoing operations
- Cancel audio/image generation when card is deleted
- Keep delete button enabled during generation for cancellation
- Add cleanup hook to remove recreated directories after deletion
This allows users to cancel in-progress card generation by deleting
the card, preventing orphaned API calls and file creation.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'internal/gui')
| -rw-r--r-- | internal/gui/app.go | 87 | ||||
| -rw-r--r-- | internal/gui/generator.go | 109 | ||||
| -rw-r--r-- | internal/gui/navigation.go | 25 |
3 files changed, 154 insertions, 67 deletions
diff --git a/internal/gui/app.go b/internal/gui/app.go index 7e63ab8..a2ed7d5 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -83,6 +83,10 @@ type Application struct { cancel context.CancelFunc wg sync.WaitGroup mu sync.Mutex + + // Per-card cancellation tracking + cardContexts map[string]context.CancelFunc // Map of word -> cancel function + cardMu sync.Mutex // Mutex for cardContexts map } // Config holds GUI application configuration @@ -120,11 +124,12 @@ func New(config *Config) *Application { myApp.SetIcon(GetAppIcon()) app := &Application{ - app: myApp, - config: config, - ctx: ctx, - cancel: cancel, - savedCards: make([]anki.Card, 0), + app: myApp, + config: config, + ctx: ctx, + cancel: cancel, + savedCards: make([]anki.Card, 0), + cardContexts: make(map[string]context.CancelFunc), } // Initialize the word processing queue @@ -316,6 +321,8 @@ func (a *Application) setupUI() { // Initially disable action buttons a.setActionButtonsEnabled(false) + // But keep delete button enabled for cancelling operations + a.deleteButton.Enable() // Create export and help buttons for toolbar exportButton := ttwidget.NewButtonWithIcon("", theme.UploadIcon(), a.onExportToAnki) @@ -490,6 +497,8 @@ func (a *Application) onSubmit() { // generateMaterials generates all materials for a word (used by regenerate functions) func (a *Application) generateMaterials(word string) { + // Get or create context for this card + cardCtx, _ := a.getOrCreateCardContext(word) // Check if we already have a translation if a.currentTranslation == "" { // Translate word @@ -538,7 +547,7 @@ func (a *Application) generateMaterials(word string) { a.updateStatus("Generating audio...") a.incrementProcessing() // Audio processing starts }) - audioFile, err := a.generateAudio(word) + audioFile, err := a.generateAudio(cardCtx, word) a.decrementProcessing() // Audio processing ends if err != nil { @@ -574,7 +583,7 @@ func (a *Application) generateMaterials(word string) { // Use the text from translationEntry if currentTranslation is not set translation = strings.TrimSpace(a.translationEntry.Text) } - imageFile, err := a.generateImagesWithPrompt(word, customPrompt, translation) + imageFile, err := a.generateImagesWithPrompt(cardCtx, word, customPrompt, translation) a.decrementProcessing() // Image processing ends if err != nil { @@ -726,7 +735,11 @@ func (a *Application) onRegenerateImage() { } // Store the word we're generating for wordForGeneration := a.currentWord - imageFile, err := a.generateImagesWithPrompt(wordForGeneration, customPrompt, translation) + + // Get or create context for this card + cardCtx, _ := a.getOrCreateCardContext(wordForGeneration) + + imageFile, err := a.generateImagesWithPrompt(cardCtx, wordForGeneration, customPrompt, translation) if err != nil { fyne.Do(func() { a.showError(fmt.Errorf("Image regeneration failed: %w", err)) @@ -786,7 +799,11 @@ func (a *Application) onRegenerateRandomImage() { } // Store the word we're generating for wordForGeneration := a.currentWord - imageFile, err := a.generateImagesWithPrompt(wordForGeneration, customPrompt, translation) + + // Get or create context for this card + cardCtx, _ := a.getOrCreateCardContext(wordForGeneration) + + imageFile, err := a.generateImagesWithPrompt(cardCtx, wordForGeneration, customPrompt, translation) if err != nil { fyne.Do(func() { a.showError(fmt.Errorf("Random image generation failed: %w", err)) @@ -831,7 +848,10 @@ func (a *Application) onRegenerateAudio() { defer a.wg.Done() defer a.decrementProcessing() // Audio processing ends - audioFile, err := a.generateAudio(a.currentWord) + // Get or create context for this card + cardCtx, _ := a.getOrCreateCardContext(a.currentWord) + + audioFile, err := a.generateAudio(cardCtx, a.currentWord) if err != nil { fyne.Do(func() { a.showError(fmt.Errorf("Audio regeneration failed: %w", err)) @@ -1159,7 +1179,8 @@ func (a *Application) setActionButtonsEnabled(enabled bool) { a.regenerateRandomImageBtn.Disable() a.regenerateAudioBtn.Disable() a.regenerateAllBtn.Disable() - a.deleteButton.Disable() + // Keep delete button enabled to allow cancelling generation + // a.deleteButton.Disable() // Don't disable this } } @@ -1273,8 +1294,48 @@ func (a *Application) processNextInQueue() { }() } +// getOrCreateCardContext returns a context for the given word, creating one if needed +func (a *Application) getOrCreateCardContext(word string) (context.Context, context.CancelFunc) { + a.cardMu.Lock() + defer a.cardMu.Unlock() + + // Check if we already have a cancel function for this word + if cancel, exists := a.cardContexts[word]; exists { + // Cancel the old context first + cancel() + } + + // Create new context for this word + ctx, cancel := context.WithCancel(a.ctx) + a.cardContexts[word] = cancel + + return ctx, cancel +} + +// cancelCardOperations cancels all ongoing operations for a specific word +func (a *Application) cancelCardOperations(word string) { + a.cardMu.Lock() + defer a.cardMu.Unlock() + + if cancel, exists := a.cardContexts[word]; exists { + cancel() + delete(a.cardContexts, word) + } +} + // processWordJob processes a single word job func (a *Application) processWordJob(job *WordJob) { + // Get or create context for this card + cardCtx, _ := a.getOrCreateCardContext(job.Word) + + // Check if context is already cancelled + select { + case <-cardCtx.Done(): + a.queue.FailJob(job.ID, fmt.Errorf("job cancelled")) + a.finishCurrentJob() + return + default: + } // Handle translation var translation string var err error @@ -1375,7 +1436,7 @@ func (a *Application) processWordJob(job *WordJob) { a.incrementProcessing() // Audio processing starts }) - audioFile, err := a.generateAudio(job.Word) + audioFile, err := a.generateAudio(cardCtx, job.Word) a.decrementProcessing() // Audio processing ends if err != nil { @@ -1408,7 +1469,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(job.Word, job.CustomPrompt, translation) + imageFile, err := a.generateImagesWithPrompt(cardCtx, job.Word, job.CustomPrompt, translation) a.decrementProcessing() // Image processing ends if err != nil { diff --git a/internal/gui/generator.go b/internal/gui/generator.go index 08785ed..f7bed2f 100644 --- a/internal/gui/generator.go +++ b/internal/gui/generator.go @@ -1,6 +1,7 @@ package gui import ( + "context" "fmt" "math/rand" "os" @@ -10,7 +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" @@ -21,9 +22,9 @@ func (a *Application) translateWord(word string) (string, error) { if a.config.OpenAIKey == "" { return "", fmt.Errorf("OpenAI API key not configured") } - + client := openai.NewClient(a.config.OpenAIKey) - + req := openai.ChatCompletionRequest{ Model: openai.GPT4oMini, Messages: []openai.ChatCompletionMessage{ @@ -35,16 +36,16 @@ func (a *Application) translateWord(word string) (string, error) { MaxTokens: 50, Temperature: 0.3, } - + resp, err := client.CreateChatCompletion(a.ctx, req) if err != nil { return "", fmt.Errorf("OpenAI API error: %w", err) } - + if len(resp.Choices) == 0 { return "", fmt.Errorf("no translation returned") } - + translation := strings.TrimSpace(resp.Choices[0].Message.Content) return translation, nil } @@ -54,9 +55,9 @@ func (a *Application) translateEnglishToBulgarian(word string) (string, error) { if a.config.OpenAIKey == "" { return "", fmt.Errorf("OpenAI API key not configured") } - + client := openai.NewClient(a.config.OpenAIKey) - + req := openai.ChatCompletionRequest{ Model: openai.GPT4oMini, Messages: []openai.ChatCompletionMessage{ @@ -68,22 +69,22 @@ func (a *Application) translateEnglishToBulgarian(word string) (string, error) { MaxTokens: 50, Temperature: 0.3, } - + resp, err := client.CreateChatCompletion(a.ctx, req) if err != nil { return "", fmt.Errorf("OpenAI API error: %w", err) } - + if len(resp.Choices) == 0 { return "", fmt.Errorf("no translation returned") } - + translation := strings.TrimSpace(resp.Choices[0].Message.Content) return translation, nil } // generateAudio generates audio for a word -func (a *Application) generateAudio(word string) (string, error) { +func (a *Application) generateAudio(ctx context.Context, word string) (string, error) { // Check if this is a regeneration by looking for existing audio file wordDir := a.findCardDirectory(word) isRegeneration := false @@ -93,37 +94,38 @@ func (a *Application) generateAudio(word string) (string, error) { isRegeneration = true } } - + // For regeneration, use random voice and speed; otherwise use defaults var voice string var speed float64 - + if isRegeneration { // Get available voices allVoices := []string{"alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse"} - + // Select a random voice rand.Seed(time.Now().UnixNano()) voice = allVoices[rand.Intn(len(allVoices))] - + // Generate random speed between 0.90 and 1.00 speed = 0.90 + rand.Float64()*0.10 } else { // Use defaults for first generation voice = "alloy" - speed = 0.98 + speed = 1.0 + // speed = 0.98 } - + // Update audio config with selected voice and speed a.audioConfig.OpenAIVoice = voice a.audioConfig.OpenAISpeed = speed - + // Create audio provider provider, err := audio.NewProvider(a.audioConfig) if err != nil { return "", err } - + // Find existing card directory or create new one again after provider creation wordDir = a.findCardDirectory(word) if wordDir == "" { @@ -133,69 +135,69 @@ func (a *Application) generateAudio(word string) (string, error) { 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("audio.%s", a.config.AudioFormat)) - + // Generate audio - err = provider.GenerateAudio(a.ctx, word, outputFile) + err = provider.GenerateAudio(ctx, word, outputFile) if err != nil { return "", err } - + // Save audio attribution if err := a.saveAudioAttribution(word, outputFile, voice, speed); err != nil { // Non-fatal error, just log it fmt.Printf("Warning: Failed to save audio attribution: %v\n", err) } - + // Save voice metadata for GUI display metadataFile := filepath.Join(wordDir, "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) } - + return outputFile, nil } // generateImages downloads images for a word -func (a *Application) generateImages(word string) (string, error) { - return a.generateImagesWithPrompt(word, "", "") +func (a *Application) generateImages(ctx context.Context, word string) (string, error) { + return a.generateImagesWithPrompt(ctx, word, "", "") } // generateImagesWithPrompt downloads a single image for a word with optional custom prompt and translation -func (a *Application) generateImagesWithPrompt(word string, customPrompt string, translation string) (string, error) { +func (a *Application) generateImagesWithPrompt(ctx context.Context, word string, customPrompt string, translation string) (string, error) { // Create image searcher based on provider var searcher image.ImageSearcher var err error - + switch a.config.ImageProvider { case "openai": openaiConfig := &image.OpenAIConfig{ - APIKey: a.config.OpenAIKey, - Model: "dall-e-2", // DALL-E 2 supports 512x512 - Size: "512x512", // Half of 1024x1024 - Quality: "standard", - Style: "natural", + APIKey: a.config.OpenAIKey, + Model: "dall-e-2", // DALL-E 2 supports 512x512 + Size: "512x512", // Half of 1024x1024 + Quality: "standard", + Style: "natural", } - + searcher = image.NewOpenAIClient(openaiConfig) if openaiConfig.APIKey == "" { return "", fmt.Errorf("OpenAI API key is required for image generation") } - + default: return "", fmt.Errorf("unknown image provider: %s", a.config.ImageProvider) } - + // Find existing card directory or create new one wordDir := a.findCardDirectory(word) if wordDir == "" { @@ -205,14 +207,14 @@ func (a *Application) generateImagesWithPrompt(word string, customPrompt string, 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) } } - + // Create downloader downloadOpts := &image.DownloadOptions{ OutputDir: wordDir, @@ -221,9 +223,9 @@ func (a *Application) generateImagesWithPrompt(word string, customPrompt string, FileNamePattern: "image", MaxSizeBytes: 5 * 1024 * 1024, // 5MB } - + downloader := image.NewDownloader(searcher, downloadOpts) - + // Create search options with custom prompt and translation if provided searchOpts := image.DefaultSearchOptions(word) if customPrompt != "" { @@ -232,13 +234,13 @@ func (a *Application) generateImagesWithPrompt(word string, customPrompt string, if translation != "" { searchOpts.Translation = translation } - + // Download single image - _, path, err := downloader.DownloadBestMatchWithOptions(a.ctx, searchOpts) + _, path, err := downloader.DownloadBestMatchWithOptions(ctx, searchOpts) if err != nil { return "", err } - + // If using OpenAI, get the last used prompt if a.config.ImageProvider == "openai" { if openaiClient, ok := searcher.(*image.OpenAIClient); ok { @@ -247,12 +249,12 @@ func (a *Application) generateImagesWithPrompt(word string, customPrompt string, // Save the prompt to disk immediately for this word promptFile := filepath.Join(wordDir, "image_prompt.txt") os.WriteFile(promptFile, []byte(usedPrompt), 0644) - + // Only update UI if this word is still the current word a.mu.Lock() isCurrentWord := a.currentWord == word a.mu.Unlock() - + if isCurrentWord { fyne.Do(func() { a.imagePromptEntry.SetText(usedPrompt) @@ -261,7 +263,7 @@ func (a *Application) generateImagesWithPrompt(word string, customPrompt string, } } } - + return path, nil } @@ -272,19 +274,18 @@ func (a *Application) saveAudioAttribution(word, audioFile, voice string, speed attribution += fmt.Sprintf("Model: %s\n", a.audioConfig.OpenAIModel) attribution += fmt.Sprintf("Voice: %s\n", voice) attribution += fmt.Sprintf("Speed: %.2f\n", speed) - + if a.audioConfig.OpenAIInstruction != "" { attribution += fmt.Sprintf("\nVoice instructions:\n%s\n", a.audioConfig.OpenAIInstruction) } - + attribution += fmt.Sprintf("\nGenerated at: %s\n", time.Now().Format("2006-01-02 15:04:05")) - + // Save to file attrPath := strings.TrimSuffix(audioFile, filepath.Ext(audioFile)) + "_attribution.txt" if err := os.WriteFile(attrPath, []byte(attribution), 0644); err != nil { return fmt.Errorf("failed to write audio attribution file: %w", err) } - + return nil } - diff --git a/internal/gui/navigation.go b/internal/gui/navigation.go index 8cba6c3..85f8f8f 100644 --- a/internal/gui/navigation.go +++ b/internal/gui/navigation.go @@ -646,6 +646,9 @@ func (a *Application) onDelete() { // deleteCurrentWord moves the word's subdirectory to trash func (a *Application) deleteCurrentWord() { + // Cancel any ongoing operations for this card + a.cancelCardOperations(a.currentWord) + // Find the card directory for this word wordDir := a.findCardDirectory(a.currentWord) if wordDir == "" { @@ -712,6 +715,7 @@ func (a *Application) deleteCurrentWord() { }) // Clear current word + deletedWord := a.currentWord a.currentWord = "" a.wordInput.SetText("") @@ -724,5 +728,26 @@ func (a *Application) deleteCurrentWord() { // No more words a.updateNavigation() a.setActionButtonsEnabled(false) + // But keep delete button enabled + a.deleteButton.Enable() } + + // Start a cleanup goroutine to remove directory after any pending operations complete + go func() { + // Wait a bit for any ongoing operations to notice cancellation + time.Sleep(500 * time.Millisecond) + + // Check if the directory was somehow recreated (by a racing operation) + recreatedDir := a.findCardDirectory(deletedWord) + if recreatedDir != "" { + // Directory was recreated, try to delete it again + timestamp := time.Now().Format("20060102_150405") + trashWordDir := filepath.Join(trashDir, fmt.Sprintf("%s_%s_cleanup", filepath.Base(recreatedDir), timestamp)) + + // Move to trash again + if err := os.Rename(recreatedDir, trashWordDir); err == nil { + fmt.Printf("Cleanup: moved recreated directory for '%s' to trash\n", deletedWord) + } + } + }() }
\ No newline at end of file |
