diff options
| author | Paul Buetow <paul@buetow.org> | 2025-07-18 14:00:13 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-07-18 14:00:13 +0300 |
| commit | a6e9947b904406ec5b49e88c77689d5c6ef6d04b (patch) | |
| tree | fc89df6a57d6ee3e8ef3487d9d07d01797b4c696 /internal/gui | |
| parent | e2f45921c45c9322c2ddb679d0511ba20772dcae (diff) | |
feat: major refactor - APKG export support and subdirectory organization
Major Features:
- Added native .apkg (Anki package) export format with embedded media
- Reorganized file structure to use subdirectories per word
- Enhanced GUI export dialog with format selection (APKG/CSV)
APKG Export Implementation:
- Created apkg_generator.go with full SQLite-based Anki package generation
- Includes custom card templates with professional CSS styling
- Front side: Image + English word
- Back side: Image + Bulgarian word + Audio + Notes
- All media files automatically embedded in package
- Custom deck names supported via --deck-name flag
Directory Structure Changes:
- Each word now gets its own subdirectory (e.g., anki_cards/ябълка/)
- All related files (audio, images, translations, prompts) stored together
- Cleaner organization and easier management
- Prevents file naming conflicts
GUI Updates:
- Export dialog no longer shows file browser, exports directly to anki_cards
- Format selection between APKG (recommended) and CSV (legacy)
- Fixed navigation to properly load image prompts from subdirectories
- Delete function now moves entire word directory to trash
CLI Updates:
- --anki flag now generates APKG by default
- --anki-csv flag for legacy CSV format
- All file generation uses subdirectory structure
Bug Fixes:
- Fixed handling of multi-word entries (e.g., "картоф картофи")
- Fixed GenerateFromDirectory to properly handle words with underscores
- Fixed phonetic files being treated as separate cards
- Fixed image prompt preservation during navigation
Breaking Changes:
- File structure changed from flat to subdirectory-based
- Existing files need to be reorganized into subdirectories
🤖 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 | 144 | ||||
| -rw-r--r-- | internal/gui/generator.go | 22 | ||||
| -rw-r--r-- | internal/gui/navigation.go | 148 |
3 files changed, 195 insertions, 119 deletions
diff --git a/internal/gui/app.go b/internal/gui/app.go index 2caffe4..84cf611 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -14,7 +14,6 @@ import ( "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/storage" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/sashabaranov/go-openai" @@ -807,61 +806,95 @@ func (a *Application) onRegenerateAll() { }() } -// onExportToAnki exports saved cards to Anki CSV +// onExportToAnki exports saved cards to Anki with format selection func (a *Application) onExportToAnki() { if len(a.savedCards) == 0 { dialog.ShowInformation("No Cards", "No cards to export. Generate some cards first!", a.window) return } - // Create save dialog - saveDialog := dialog.NewFileSave(func(writer fyne.URIWriteCloser, err error) { - if err != nil { - dialog.ShowError(err, a.window) - return - } - if writer == nil { + // 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, + widget.NewSeparator(), + widget.NewLabel("Deck Name:"), + deckNameEntry, + 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 } - defer writer.Close() - // Generate Anki CSV - outputPath := writer.URI().Path() - gen := anki.NewGenerator(&anki.GeneratorOptions{ - OutputPath: outputPath, - MediaFolder: a.config.OutputDir, - IncludeHeaders: true, - AudioFormat: a.config.AudioFormat, - }) - - // Add all saved cards - for _, card := range a.savedCards { - gen.AddCard(card) + isAPKG := formatSelect.Selected == formatOptions[0] + deckName := deckNameEntry.Text + if deckName == "" { + deckName = "Bulgarian Vocabulary" } - // Generate CSV - if err := gen.GenerateCSV(); err != nil { - dialog.ShowError(fmt.Errorf("Failed to generate CSV: %w", err), a.window) - return - } + // Generate export directly to anki_cards folder + var outputPath string + var filename string - dialog.ShowInformation("Export Complete", - fmt.Sprintf("Exported %d cards to:\n%s\n\nNote: The CSV file should be in the same directory as your media files (%s) for Anki import to work correctly.", - len(a.savedCards), outputPath, a.config.OutputDir), - a.window) - }, a.window) - - saveDialog.SetFileName("anki_import.csv") - saveDialog.SetFilter(storage.NewExtensionFileFilter([]string{".csv"})) - - // Try to set the default location to the anki_cards directory - if uri, err := storage.ParseURI("file://" + a.config.OutputDir); err == nil { - if listableURI, ok := uri.(fyne.ListableURI); ok { - saveDialog.SetLocation(listableURI) + if isAPKG { + filename = fmt.Sprintf("%s.apkg", sanitizeFilename(deckName)) + outputPath = filepath.Join(a.config.OutputDir, filename) + + // Generate APKG + gen := anki.NewGenerator(nil) + for _, card := range a.savedCards { + gen.AddCard(card) + } + + if err := gen.GenerateAPKG(outputPath, deckName); err != nil { + dialog.ShowError(fmt.Errorf("Failed to generate APKG: %w", err), a.window) + return + } + + dialog.ShowInformation("Export Complete", + fmt.Sprintf("Exported %d cards to:\n%s\n\nThe APKG file includes all media and can be imported directly into Anki.", + len(a.savedCards), outputPath), + a.window) + } else { + filename = "anki_import.csv" + outputPath = filepath.Join(a.config.OutputDir, filename) + + // Generate CSV + gen := anki.NewGenerator(&anki.GeneratorOptions{ + OutputPath: outputPath, + MediaFolder: a.config.OutputDir, + IncludeHeaders: true, + AudioFormat: a.config.AudioFormat, + }) + + for _, card := range a.savedCards { + gen.AddCard(card) + } + + if err := gen.GenerateCSV(); err != nil { + dialog.ShowError(fmt.Errorf("Failed to generate CSV: %w", err), a.window) + return + } + + dialog.ShowInformation("Export Complete", + fmt.Sprintf("Exported %d cards to:\n%s\n\nNote: The CSV file should be in the same directory as your media files (%s) for Anki import to work correctly.", + len(a.savedCards), outputPath, a.config.OutputDir), + a.window) } - } + }, a.window) - saveDialog.Show() + customDialog.Resize(fyne.NewSize(400, 300)) + customDialog.Show() } // onPreferences shows the preferences dialog @@ -1011,7 +1044,9 @@ func (a *Application) processWordJob(job *WordJob) { // Save translation to disk immediately for this specific word if translation != "" { filename := sanitizeFilename(job.Word) - translationFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_translation.txt", filename)) + wordDir := filepath.Join(a.config.OutputDir, filename) + os.MkdirAll(wordDir, 0755) // Ensure directory exists + translationFile := filepath.Join(wordDir, fmt.Sprintf("%s_translation.txt", filename)) content := fmt.Sprintf("%s = %s\n", job.Word, translation) os.WriteFile(translationFile, []byte(content), 0644) } @@ -1045,7 +1080,9 @@ func (a *Application) processWordJob(job *WordJob) { // Save phonetic info to disk immediately for this specific word if phoneticInfo != "" && phoneticInfo != "Failed to fetch phonetic information" { filename := sanitizeFilename(job.Word) - phoneticFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_phonetic.txt", filename)) + wordDir := filepath.Join(a.config.OutputDir, filename) + os.MkdirAll(wordDir, 0755) // Ensure directory exists + phoneticFile := filepath.Join(wordDir, fmt.Sprintf("%s_phonetic.txt", filename)) os.WriteFile(phoneticFile, []byte(phoneticInfo), 0644) } @@ -1384,7 +1421,9 @@ func (a *Application) handleShortcutKey(key fyne.KeyName) { func (a *Application) saveTranslation() { if a.currentWord != "" && a.currentTranslation != "" { filename := sanitizeFilename(a.currentWord) - translationFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_translation.txt", filename)) + wordDir := filepath.Join(a.config.OutputDir, filename) + os.MkdirAll(wordDir, 0755) // Ensure directory exists + translationFile := filepath.Join(wordDir, fmt.Sprintf("%s_translation.txt", filename)) content := fmt.Sprintf("%s = %s\n", a.currentWord, a.currentTranslation) os.WriteFile(translationFile, []byte(content), 0644) } @@ -1394,7 +1433,9 @@ func (a *Application) saveTranslation() { func (a *Application) saveImagePrompt() { if a.currentWord != "" && a.imagePromptEntry.Text != "" { filename := sanitizeFilename(a.currentWord) - promptFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_prompt.txt", filename)) + wordDir := filepath.Join(a.config.OutputDir, filename) + os.MkdirAll(wordDir, 0755) // Ensure directory exists + promptFile := filepath.Join(wordDir, fmt.Sprintf("%s_prompt.txt", filename)) os.WriteFile(promptFile, []byte(a.imagePromptEntry.Text), 0644) } } @@ -1406,7 +1447,9 @@ func (a *Application) savePhoneticInfo() { phoneticText != "Failed to fetch phonetic information" && phoneticText != "Phonetic information will appear here..." { filename := sanitizeFilename(a.currentWord) - phoneticFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_phonetic.txt", filename)) + wordDir := filepath.Join(a.config.OutputDir, filename) + os.MkdirAll(wordDir, 0755) // Ensure directory exists + phoneticFile := filepath.Join(wordDir, fmt.Sprintf("%s_phonetic.txt", filename)) os.WriteFile(phoneticFile, []byte(phoneticText), 0644) } } @@ -1417,7 +1460,9 @@ func (a *Application) savePhoneticInfoForWord(word, phoneticText string) { phoneticText != "Failed to fetch phonetic information" && phoneticText != "Phonetic information will appear here..." { filename := sanitizeFilename(word) - phoneticFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_phonetic.txt", filename)) + wordDir := filepath.Join(a.config.OutputDir, filename) + os.MkdirAll(wordDir, 0755) // Ensure directory exists + phoneticFile := filepath.Join(wordDir, fmt.Sprintf("%s_phonetic.txt", filename)) os.WriteFile(phoneticFile, []byte(phoneticText), 0644) } } @@ -1425,7 +1470,8 @@ func (a *Application) savePhoneticInfoForWord(word, phoneticText string) { // loadPhoneticInfo loads phonetic information from a file if it exists func (a *Application) loadPhoneticInfo(word string) { filename := sanitizeFilename(word) - phoneticFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_phonetic.txt", filename)) + wordDir := filepath.Join(a.config.OutputDir, filename) + phoneticFile := filepath.Join(wordDir, fmt.Sprintf("%s_phonetic.txt", filename)) if data, err := os.ReadFile(phoneticFile); err == nil { a.phoneticDisplay.SetText(string(data)) diff --git a/internal/gui/generator.go b/internal/gui/generator.go index d26bb81..16ea7cf 100644 --- a/internal/gui/generator.go +++ b/internal/gui/generator.go @@ -99,9 +99,15 @@ func (a *Application) generateAudio(word string) (string, error) { return "", err } - // Generate filename + // Create subdirectory for this word filename := sanitizeFilename(word) - outputFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s.%s", filename, a.config.AudioFormat)) + wordDir := filepath.Join(a.config.OutputDir, filename) + if err := os.MkdirAll(wordDir, 0755); err != nil { + return "", fmt.Errorf("failed to create word directory: %w", err) + } + + // Generate filename in subdirectory + outputFile := filepath.Join(wordDir, fmt.Sprintf("%s.%s", filename, a.config.AudioFormat)) // Generate audio err = provider.GenerateAudio(a.ctx, word, outputFile) @@ -150,9 +156,16 @@ func (a *Application) generateImagesWithPrompt(word string, customPrompt string, return "", fmt.Errorf("unknown image provider: %s", a.config.ImageProvider) } + // Create subdirectory for this word + filename := sanitizeFilename(word) + wordDir := filepath.Join(a.config.OutputDir, filename) + if err := os.MkdirAll(wordDir, 0755); err != nil { + return "", fmt.Errorf("failed to create word directory: %w", err) + } + // Create downloader downloadOpts := &image.DownloadOptions{ - OutputDir: a.config.OutputDir, + OutputDir: wordDir, OverwriteExisting: true, CreateDir: true, FileNamePattern: "{word}", @@ -182,8 +195,7 @@ func (a *Application) generateImagesWithPrompt(word string, customPrompt string, usedPrompt := openaiClient.GetLastPrompt() if usedPrompt != "" { // Save the prompt to disk immediately for this word - filename := sanitizeFilename(word) - promptFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_prompt.txt", filename)) + promptFile := filepath.Join(wordDir, fmt.Sprintf("%s_prompt.txt", filename)) os.WriteFile(promptFile, []byte(usedPrompt), 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 05afa67..35af9b2 100644 --- a/internal/gui/navigation.go +++ b/internal/gui/navigation.go @@ -25,33 +25,71 @@ func (a *Application) scanExistingWords() { return } - // Collect unique words - wordMap := make(map[string]bool) - + // Each subdirectory represents a word for _, entry := range entries { - if entry.IsDir() { + if !entry.IsDir() { continue } - name := entry.Name() - // Skip attribution and translation files - if strings.Contains(name, "_attribution") || strings.Contains(name, "_translation") { - continue + // Directory name is the sanitized word + sanitizedWord := entry.Name() + + // Check if this directory contains valid word files + wordDir := filepath.Join(a.config.OutputDir, sanitizedWord) + + // Look for at least one of: audio, image, or translation file + hasContent := false + + // Check for audio file + audioFile := filepath.Join(wordDir, fmt.Sprintf("%s.%s", sanitizedWord, a.config.AudioFormat)) + if _, err := os.Stat(audioFile); err == nil { + hasContent = true + } + + // Check for image files + if !hasContent { + patterns := []string{ + fmt.Sprintf("%s.jpg", sanitizedWord), + fmt.Sprintf("%s.png", sanitizedWord), + fmt.Sprintf("%s_1.jpg", sanitizedWord), + fmt.Sprintf("%s_1.png", sanitizedWord), + } + for _, pattern := range patterns { + if _, err := os.Stat(filepath.Join(wordDir, pattern)); err == nil { + hasContent = true + break + } + } } - // Extract word from filename (before first underscore or dot) - base := strings.TrimSuffix(name, filepath.Ext(name)) - parts := strings.Split(base, "_") - if len(parts) > 0 { - word := parts[0] - wordMap[word] = true + // Check for translation file + if !hasContent { + translationFile := filepath.Join(wordDir, fmt.Sprintf("%s_translation.txt", sanitizedWord)) + if _, err := os.Stat(translationFile); err == nil { + hasContent = true + } + } + + // If directory has content, add it to the list + if hasContent { + // Try to get the original word from translation file + translationFile := filepath.Join(wordDir, fmt.Sprintf("%s_translation.txt", sanitizedWord)) + if data, err := os.ReadFile(translationFile); err == nil { + content := string(data) + parts := strings.Split(content, "=") + if len(parts) >= 1 { + originalWord := strings.TrimSpace(parts[0]) + a.existingWords = append(a.existingWords, originalWord) + continue + } + } + + // Fallback: use the directory name + a.existingWords = append(a.existingWords, sanitizedWord) } } - // Convert map to sorted slice - for word := range wordMap { - a.existingWords = append(a.existingWords, word) - } + // Sort the words sort.Strings(a.existingWords) // Update navigation buttons @@ -193,7 +231,8 @@ func (a *Application) loadWordByIndex(index int) { // Load image prompt from disk if it exists sanitized := sanitizeFilename(word) - promptFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_prompt.txt", sanitized)) + wordDir := filepath.Join(a.config.OutputDir, sanitized) + promptFile := filepath.Join(wordDir, fmt.Sprintf("%s_prompt.txt", sanitized)) if data, err := os.ReadFile(promptFile); err == nil { prompt := strings.TrimSpace(string(data)) a.imagePromptEntry.SetText(prompt) @@ -222,9 +261,10 @@ func (a *Application) loadWordByIndex(index int) { // loadExistingFiles loads existing files for a word func (a *Application) loadExistingFiles(word string) { sanitized := sanitizeFilename(word) + wordDir := filepath.Join(a.config.OutputDir, sanitized) // Load translation - translationFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_translation.txt", sanitized)) + translationFile := filepath.Join(wordDir, fmt.Sprintf("%s_translation.txt", sanitized)) if data, err := os.ReadFile(translationFile); err == nil { // Parse translation from "word = translation" format content := string(data) @@ -238,7 +278,7 @@ func (a *Application) loadExistingFiles(word string) { } // Load image prompt file - promptFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_prompt.txt", sanitized)) + promptFile := filepath.Join(wordDir, fmt.Sprintf("%s_prompt.txt", sanitized)) if data, err := os.ReadFile(promptFile); err == nil { prompt := strings.TrimSpace(string(data)) fyne.Do(func() { @@ -247,7 +287,7 @@ func (a *Application) loadExistingFiles(word string) { } // Load phonetic information - phoneticFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s_phonetic.txt", sanitized)) + phoneticFile := filepath.Join(wordDir, fmt.Sprintf("%s_phonetic.txt", sanitized)) if data, err := os.ReadFile(phoneticFile); err == nil { phoneticInfo := string(data) fyne.Do(func() { @@ -256,7 +296,7 @@ func (a *Application) loadExistingFiles(word string) { } // Load audio file - audioFile := filepath.Join(a.config.OutputDir, fmt.Sprintf("%s.%s", sanitized, a.config.AudioFormat)) + audioFile := filepath.Join(wordDir, fmt.Sprintf("%s.%s", sanitized, a.config.AudioFormat)) if _, err := os.Stat(audioFile); err == nil { a.currentAudioFile = audioFile fyne.Do(func() { @@ -275,7 +315,7 @@ func (a *Application) loadExistingFiles(word string) { } for _, pattern := range patterns { - imagePath := filepath.Join(a.config.OutputDir, pattern) + imagePath := filepath.Join(wordDir, pattern) if _, err := os.Stat(imagePath); err == nil { a.currentImage = imagePath break // Just load the first image found @@ -358,10 +398,10 @@ func (a *Application) onDelete() { confirmDialog.Show() } -// deleteCurrentWord moves all files for the current word to trash +// deleteCurrentWord moves the word's subdirectory to trash func (a *Application) deleteCurrentWord() { sanitized := sanitizeFilename(a.currentWord) - deletedCount := 0 + wordDir := filepath.Join(a.config.OutputDir, sanitized) // Create trash directory if it doesn't exist trashDir := filepath.Join(a.config.OutputDir, ".trashbin") @@ -372,46 +412,24 @@ func (a *Application) deleteCurrentWord() { return } - // List of possible files to move to trash - patterns := []string{ - fmt.Sprintf("%s.mp3", sanitized), - fmt.Sprintf("%s.wav", sanitized), - fmt.Sprintf("%s.jpg", sanitized), - fmt.Sprintf("%s.png", sanitized), - fmt.Sprintf("%s.gif", sanitized), - fmt.Sprintf("%s_*.jpg", sanitized), - fmt.Sprintf("%s_*.png", sanitized), - fmt.Sprintf("%s_translation.txt", sanitized), - fmt.Sprintf("%s_prompt.txt", sanitized), - fmt.Sprintf("%s_phonetic.txt", sanitized), - fmt.Sprintf("%s_attribution.txt", sanitized), - fmt.Sprintf("%s_*_attribution.txt", sanitized), + // Check if word directory exists + if _, err := os.Stat(wordDir); os.IsNotExist(err) { + fyne.Do(func() { + a.updateStatus("No files found for this word") + }) + return } - // Move files matching patterns to trash - for _, pattern := range patterns { - matches, err := filepath.Glob(filepath.Join(a.config.OutputDir, pattern)) - if err != nil { - continue - } - for _, match := range matches { - filename := filepath.Base(match) - destPath := filepath.Join(trashDir, filename) - - // If file already exists in trash, add timestamp to filename - if _, err := os.Stat(destPath); err == nil { - base := strings.TrimSuffix(filename, filepath.Ext(filename)) - ext := filepath.Ext(filename) - timestamp := time.Now().Format("20060102_150405") - filename = fmt.Sprintf("%s_%s%s", base, timestamp, ext) - destPath = filepath.Join(trashDir, filename) - } - - // Move file to trash - if err := os.Rename(match, destPath); err == nil { - deletedCount++ - } - } + // Create destination path in trash + timestamp := time.Now().Format("20060102_150405") + trashWordDir := filepath.Join(trashDir, fmt.Sprintf("%s_%s", sanitized, timestamp)) + + // Move entire directory to trash + if err := os.Rename(wordDir, trashWordDir); err != nil { + fyne.Do(func() { + a.updateStatus(fmt.Sprintf("Failed to move files to trash: %v", err)) + }) + return } // Remove from existingWords @@ -442,7 +460,7 @@ func (a *Application) deleteCurrentWord() { // Update status fyne.Do(func() { - a.updateStatus(fmt.Sprintf("Moved %d files for '%s' to trash", deletedCount, a.currentWord)) + a.updateStatus(fmt.Sprintf("Moved '%s' to trash", a.currentWord)) // Update queue status to reflect the reduced card count a.updateQueueStatus() }) |
