summaryrefslogtreecommitdiff
path: root/internal/gui
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-19 22:43:42 +0300
committerPaul Buetow <paul@buetow.org>2025-07-19 22:43:42 +0300
commit06a11a3aabec600a3388b7e818434052474ea341 (patch)
tree80821c7c19d98883dc7d9e4e03d793b3a6f9c06e /internal/gui
parent44cf5eee8fba096496f0704cec44fd436ecb5c2e (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.go87
-rw-r--r--internal/gui/generator.go109
-rw-r--r--internal/gui/navigation.go25
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