summaryrefslogtreecommitdiff
path: root/internal/gui
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-18 14:00:13 +0300
committerPaul Buetow <paul@buetow.org>2025-07-18 14:00:13 +0300
commita6e9947b904406ec5b49e88c77689d5c6ef6d04b (patch)
treefc89df6a57d6ee3e8ef3487d9d07d01797b4c696 /internal/gui
parente2f45921c45c9322c2ddb679d0511ba20772dcae (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.go144
-rw-r--r--internal/gui/generator.go22
-rw-r--r--internal/gui/navigation.go148
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()
})