From a6e9947b904406ec5b49e88c77689d5c6ef6d04b Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 18 Jul 2025 14:00:13 +0300 Subject: feat: major refactor - APKG export support and subdirectory organization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/totalrecall/main.go | 62 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 49 insertions(+), 13 deletions(-) (limited to 'cmd') diff --git a/cmd/totalrecall/main.go b/cmd/totalrecall/main.go index 83695e8..04174bc 100644 --- a/cmd/totalrecall/main.go +++ b/cmd/totalrecall/main.go @@ -32,6 +32,8 @@ var ( skipAudio bool skipImages bool generateAnki bool + ankiCSV bool + deckName string listModels bool allVoices bool guiMode bool @@ -81,7 +83,9 @@ func init() { rootCmd.Flags().StringVar(&batchFile, "batch", "", "Process words from file (one per line)") rootCmd.Flags().BoolVar(&skipAudio, "skip-audio", false, "Skip audio generation") rootCmd.Flags().BoolVar(&skipImages, "skip-images", false, "Skip image download") - rootCmd.Flags().BoolVar(&generateAnki, "anki", false, "Generate Anki import CSV file") + rootCmd.Flags().BoolVar(&generateAnki, "anki", false, "Generate Anki import file (APKG format by default, use --anki-csv for legacy CSV)") + rootCmd.Flags().BoolVar(&ankiCSV, "anki-csv", false, "Generate legacy CSV format instead of APKG when using --anki") + rootCmd.Flags().StringVar(&deckName, "deck-name", "Bulgarian Vocabulary", "Deck name for APKG export") rootCmd.Flags().BoolVar(&listModels, "list-models", false, "List available OpenAI models for the current API key") rootCmd.Flags().BoolVar(&allVoices, "all-voices", false, "Generate audio in all available voices (creates multiple files)") rootCmd.Flags().BoolVar(&guiMode, "gui", false, "Launch interactive GUI mode") @@ -203,13 +207,17 @@ func runCommand(cmd *cobra.Command, args []string) error { } } - // Generate Anki CSV if requested + // Generate Anki file if requested if generateAnki { fmt.Printf("\nGenerating Anki import file...\n") - if err := generateAnkiCSV(); err != nil { - fmt.Fprintf(os.Stderr, "Warning: Failed to generate Anki CSV: %v\n", err) + if err := generateAnkiFile(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: Failed to generate Anki file: %v\n", err) } else { - fmt.Println("Anki import file created: anki_import.csv") + if ankiCSV { + fmt.Println("Anki import file created: anki_import.csv") + } else { + fmt.Printf("Anki package created: %s.apkg\n", deckName) + } } } @@ -332,12 +340,18 @@ func generateAudioWithVoice(word, voice string) error { ctx := context.Background() filename := sanitizeFilename(word) + // Create subdirectory for this word + wordDir := filepath.Join(outputDir, filename) + if err := os.MkdirAll(wordDir, 0755); err != nil { + return fmt.Errorf("failed to create word directory: %w", err) + } + // Add voice name to filename if generating multiple voices var outputFile string if allVoices { - outputFile = filepath.Join(outputDir, fmt.Sprintf("%s_%s.%s", filename, voice, audioFormat)) + outputFile = filepath.Join(wordDir, fmt.Sprintf("%s_%s.%s", filename, voice, audioFormat)) } else { - outputFile = filepath.Join(outputDir, fmt.Sprintf("%s.%s", filename, audioFormat)) + outputFile = filepath.Join(wordDir, fmt.Sprintf("%s.%s", filename, audioFormat)) } // Generate the audio @@ -403,9 +417,16 @@ func downloadImages(word string) error { return fmt.Errorf("unknown image provider: %s", imageAPI) } + // Create subdirectory for this word + filename := sanitizeFilename(word) + wordDir := filepath.Join(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: outputDir, + OutputDir: wordDir, OverwriteExisting: true, // Allow overwriting existing files CreateDir: true, FileNamePattern: "{word}_{index}", @@ -484,7 +505,7 @@ func isSpace(r rune) bool { return r == ' ' || r == '\t' || r == '\n' || r == '\r' } -func generateAnkiCSV() error { +func generateAnkiFile() error { // Create Anki generator gen := anki.NewGenerator(&anki.GeneratorOptions{ OutputPath: filepath.Join(outputDir, "anki_import.csv"), @@ -505,9 +526,17 @@ func generateAnkiCSV() error { } } - // Generate CSV - if err := gen.GenerateCSV(); err != nil { - return fmt.Errorf("failed to generate CSV: %w", err) + if ankiCSV { + // Generate CSV + if err := gen.GenerateCSV(); err != nil { + return fmt.Errorf("failed to generate CSV: %w", err) + } + } else { + // Generate APKG + outputPath := filepath.Join(outputDir, fmt.Sprintf("%s.apkg", sanitizeFilename(deckName))) + if err := gen.GenerateAPKG(outputPath, deckName); err != nil { + return fmt.Errorf("failed to generate APKG: %w", err) + } } // Print stats @@ -646,7 +675,14 @@ func translateWord(word string) (string, error) { func saveTranslation(word, translation string) error { // Save translation to a text file filename := sanitizeFilename(word) - outputFile := filepath.Join(outputDir, fmt.Sprintf("%s_translation.txt", filename)) + wordDir := filepath.Join(outputDir, filename) + + // Ensure directory exists + if err := os.MkdirAll(wordDir, 0755); err != nil { + return fmt.Errorf("failed to create word directory: %w", err) + } + + outputFile := filepath.Join(wordDir, fmt.Sprintf("%s_translation.txt", filename)) content := fmt.Sprintf("%s = %s\n", word, translation) -- cgit v1.2.3