From acec1e0668084715dc1e981b11b1562243283f58 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 8 Apr 2026 10:07:06 +0300 Subject: refactor(gui): extract NavigationHandler, ExportHandler, QueueManager, KeyboardShortcuts Move navigation, export dialog, queue processing, and keyboard wiring out of Application into focused types with app *Application for shared state. Add ensureHandlers() for lazy init so tests that build Application literals still work. Wire queue callbacks to QueueManager; keep thin Application delegates for entry points used across the GUI. Made-with: Cursor --- internal/gui/export_handler.go | 212 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 internal/gui/export_handler.go (limited to 'internal/gui/export_handler.go') diff --git a/internal/gui/export_handler.go b/internal/gui/export_handler.go new file mode 100644 index 0000000..d0c46bb --- /dev/null +++ b/internal/gui/export_handler.go @@ -0,0 +1,212 @@ +package gui + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/storage" + "fyne.io/fyne/v2/widget" + + "codeberg.org/snonux/totalrecall/internal" + "codeberg.org/snonux/totalrecall/internal/anki" + appconfig "codeberg.org/snonux/totalrecall/internal/config" +) + +// ExportHandler owns the Export to Anki dialog and APKG/CSV export paths (SRP). +type ExportHandler struct { + app *Application +} + +// onExportToAnki opens the Export to Anki dialog where the user selects a format, +// deck name, and output directory. No-op when no exportable cards exist. +func (e *ExportHandler) onExportToAnki() { + a := e.app + if !e.hasExportableCards() { + dialog.ShowInformation("No Cards", "No cards found in anki_cards folder. Generate some cards first!", a.window) + return + } + + formatOptions := []string{"APKG (Recommended)", "CSV (Legacy)"} + formatSelect := widget.NewSelect(formatOptions, nil) + formatSelect.SetSelected(formatOptions[0]) + deckNameEntry := widget.NewEntry() + deckNameEntry.SetPlaceHolder("Bulgarian Vocabulary") + + selectedDir := e.defaultExportDir() + dirLabel := widget.NewLabel(selectedDir) + dirButton := widget.NewButton("Browse...", func() { + e.browseExportDir(&selectedDir, dirLabel) + }) + + content := e.buildExportDialogContent(formatSelect, deckNameEntry, dirLabel, dirButton) + e.showExportDialog(content, formatOptions, formatSelect, deckNameEntry, &selectedDir) +} + +func (e *ExportHandler) buildExportDialogContent(formatSelect *widget.Select, deckNameEntry *widget.Entry, dirLabel *widget.Label, dirButton *widget.Button) fyne.CanvasObject { + return container.NewVBox( + widget.NewLabel("Export Format:"), + formatSelect, + widget.NewSeparator(), + widget.NewLabel("Deck Name:"), + deckNameEntry, + widget.NewSeparator(), + widget.NewLabel("Export Directory:"), + container.NewBorder(nil, nil, nil, dirButton, dirLabel), + widget.NewLabel(""), + widget.NewRichTextFromMarkdown("**APKG**: Complete package with media files included\n**CSV**: Text only, requires manual media copy"), + ) +} + +func (e *ExportHandler) showExportDialog(content fyne.CanvasObject, formatOptions []string, formatSelect *widget.Select, deckNameEntry *widget.Entry, selectedDir *string) { + a := e.app + exportDialogOpen := true + + customDialog := dialog.NewCustomConfirm("Export to Anki", "Export (e)", "Cancel (c/Esc)", content, func(export bool) { + exportDialogOpen = false + if !export { + return + } + deckName := deckNameEntry.Text + if deckName == "" { + deckName = "Bulgarian Vocabulary" + } + e.performExport(formatSelect.Selected == formatOptions[0], deckName, *selectedDir) + }, a.window) + + e.wireExportDialogKeys(customDialog, &exportDialogOpen) + customDialog.Resize(fyne.NewSize(400, 300)) + customDialog.Show() +} + +// wireExportDialogKeys attaches keyboard shortcuts to the export dialog. +// e/е triggers confirm; c/ц/Esc cancels. Original handlers are restored on close. +func (e *ExportHandler) wireExportDialogKeys(customDialog *dialog.ConfirmDialog, exportDialogOpen *bool) { + a := e.app + origRune := a.window.Canvas().OnTypedRune() + origKey := a.window.Canvas().OnTypedKey() + + a.window.Canvas().SetOnTypedRune(func(r rune) { + if *exportDialogOpen { + switch r { + case 'e', 'E', 'е', 'Е': + customDialog.Hide() + *exportDialogOpen = false + customDialog.Confirm() + case 'c', 'C', 'ц', 'Ц': + customDialog.Hide() + *exportDialogOpen = false + } + return + } + if origRune != nil { + origRune(r) + } + }) + a.window.Canvas().SetOnTypedKey(func(ev *fyne.KeyEvent) { + if *exportDialogOpen && ev.Name == fyne.KeyEscape { + customDialog.Hide() + *exportDialogOpen = false + return + } + if origKey != nil { + origKey(ev) + } + }) + customDialog.SetOnClosed(func() { + *exportDialogOpen = false + a.window.Canvas().SetOnTypedRune(origRune) + a.window.Canvas().SetOnTypedKey(origKey) + }) +} + +func (e *ExportHandler) hasExportableCards() bool { + entries, err := os.ReadDir(e.app.config.OutputDir) + if err != nil || len(entries) == 0 { + return false + } + for _, entry := range entries { + if entry.IsDir() && !strings.HasPrefix(entry.Name(), ".") { + return true + } + } + return false +} + +func (e *ExportHandler) defaultExportDir() string { + homeDir, err := appconfig.HomeDir() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: %v\n", err) + } + return homeDir +} + +func (e *ExportHandler) browseExportDir(dir *string, label *widget.Label) { + a := e.app + folderDialog := dialog.NewFolderOpen(func(selected fyne.ListableURI, err error) { + if err != nil || selected == nil { + return + } + *dir = selected.Path() + label.SetText(*dir) + }, a.window) + + if uri, err := storage.ParseURI("file://" + *dir); err == nil { + if listableURI, ok := uri.(fyne.ListableURI); ok { + folderDialog.SetLocation(listableURI) + } + } + folderDialog.Show() +} + +func (e *ExportHandler) performExport(isAPKG bool, deckName, outputDir string) { + if isAPKG { + e.exportAPKG(deckName, outputDir) + } else { + e.exportCSV(outputDir) + } +} + +func (e *ExportHandler) exportAPKG(deckName, outputDir string) { + a := e.app + filename := fmt.Sprintf("%s.apkg", internal.SanitizeFilename(deckName)) + outputPath := filepath.Join(outputDir, filename) + + gen := anki.NewGenerator(nil) + if err := gen.GenerateFromDirectory(a.config.OutputDir); err != nil { + dialog.ShowError(fmt.Errorf("failed to load cards: %w", err), a.window) + return + } + if err := gen.GenerateAPKG(outputPath, deckName); err != nil { + dialog.ShowError(fmt.Errorf("failed to generate APKG: %w", err), a.window) + return + } + total, withAudio, withImages := gen.Stats() + a.updateStatus(fmt.Sprintf("Exported %d cards to %s (%d with audio, %d with images)", total, outputDir, withAudio, withImages)) +} + +func (e *ExportHandler) exportCSV(outputDir string) { + a := e.app + outputPath := filepath.Join(outputDir, "anki_import.csv") + + gen := anki.NewGenerator(&anki.GeneratorOptions{ + OutputPath: outputPath, + MediaFolder: a.config.OutputDir, + IncludeHeaders: true, + AudioFormat: a.config.AudioFormat, + }) + if err := gen.GenerateFromDirectory(a.config.OutputDir); err != nil { + dialog.ShowError(fmt.Errorf("failed to load cards: %w", err), a.window) + return + } + if err := gen.GenerateCSV(); err != nil { + dialog.ShowError(fmt.Errorf("failed to generate CSV: %w", err), a.window) + return + } + total, withAudio, withImages := gen.Stats() + a.updateStatus(fmt.Sprintf("Exported %d cards to %s (%d with audio, %d with images)", total, outputDir, withAudio, withImages)) +} -- cgit v1.2.3