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 --- README.md | 23 +- cmd/totalrecall/main.go | 62 +++- go.mod | 1 + go.sum | 2 + internal/anki/apkg_generator.go | 636 ++++++++++++++++++++++++++++++++++++++++ internal/anki/generator.go | 113 ++++--- internal/gui/app.go | 144 +++++---- internal/gui/generator.go | 22 +- internal/gui/navigation.go | 148 ++++++---- 9 files changed, 969 insertions(+), 182 deletions(-) create mode 100644 internal/anki/apkg_generator.go diff --git a/README.md b/README.md index 59d88d2..583de85 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,11 @@ export OPENAI_API_KEY="sk-..." totalrecall --batch words.txt ``` -4. Generate with Anki CSV: +4. Generate with Anki package: ```bash - totalrecall ябълка --anki + totalrecall ябълка --anki # Creates APKG file (recommended) + totalrecall ябълка --anki --anki-csv # Creates CSV file (legacy) + totalrecall ябълка --anki --deck-name "My Bulgarian Words" # Custom deck name ``` ### GUI Mode @@ -150,15 +152,30 @@ For each word, the tool generates: - `word.mp3` - Audio pronunciation (random voice) - `word_translation.txt` - English translation - `word_1.jpg`, `word_2.jpg`, etc. - Generated images -- `anki_import.csv` - Anki import file (when using --anki flag) +- `bulgarian_vocabulary.apkg` - Anki package file (when using --anki flag) +- `anki_import.csv` - Anki import file (when using --anki --anki-csv flags) With `--all-voices` flag: - `word_alloy.mp3`, `word_nova.mp3`, etc. - Audio in all 11 voices ## Anki Import +### Method 1: APKG Format (Recommended) 1. Generate materials with the `--anki` flag 2. In Anki, go to File → Import +3. Select the generated `.apkg` file +4. All media files are included automatically +5. Cards are ready to use with custom styling + +### Method 2: CSV Format (Legacy) +1. Generate materials with `--anki --anki-csv` flags +2. In Anki, go to File → Import 3. Select the generated `anki_import.csv` 4. Copy all media files to your Anki media folder 5. Map fields appropriately during import + +### GUI Export +The GUI mode offers an export dialog where you can: +- Choose between APKG and CSV formats +- Set a custom deck name +- Export all generated cards at once 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) diff --git a/go.mod b/go.mod index f507d7a..dc47cf2 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect + github.com/mattn/go-sqlite3 v1.14.28 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect diff --git a/go.sum b/go.sum index 091aae2..9316d14 100644 --- a/go.sum +++ b/go.sum @@ -56,6 +56,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= +github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= diff --git a/internal/anki/apkg_generator.go b/internal/anki/apkg_generator.go new file mode 100644 index 0000000..5c5c25d --- /dev/null +++ b/internal/anki/apkg_generator.go @@ -0,0 +1,636 @@ +package anki + +import ( + "archive/zip" + "database/sql" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +// APKGGenerator creates Anki package files (.apkg) +type APKGGenerator struct { + deckName string + deckID int64 + modelID int64 + cards []Card + mediaFiles map[string]int // maps original filename to media number + mediaCounter int +} + +// NewAPKGGenerator creates a new APKG generator +func NewAPKGGenerator(deckName string) *APKGGenerator { + // Generate IDs based on timestamp to ensure uniqueness + now := time.Now().UnixMilli() + return &APKGGenerator{ + deckName: deckName, + deckID: now, + modelID: now + 1, + cards: make([]Card, 0), + mediaFiles: make(map[string]int), + mediaCounter: 0, + } +} + +// AddCard adds a card to the generator +func (g *APKGGenerator) AddCard(card Card) { + g.cards = append(g.cards, card) +} + +// GenerateAPKG creates an .apkg file +func (g *APKGGenerator) GenerateAPKG(outputPath string) error { + // Create temporary directory for building the package + tempDir, err := os.MkdirTemp("", "anki_export_*") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tempDir) + + // Create SQLite database + dbPath := filepath.Join(tempDir, "collection.anki2") + if err := g.createDatabase(dbPath); err != nil { + return fmt.Errorf("failed to create database: %w", err) + } + + // Copy media files + mediaDir := filepath.Join(tempDir, "media") + if err := g.copyMediaFiles(mediaDir); err != nil { + return fmt.Errorf("failed to copy media files: %w", err) + } + + // Create media mapping file + if err := g.createMediaMapping(tempDir); err != nil { + return fmt.Errorf("failed to create media mapping: %w", err) + } + + // Create the .apkg zip file + if err := g.createZipPackage(tempDir, outputPath); err != nil { + return fmt.Errorf("failed to create zip package: %w", err) + } + + return nil +} + +// createDatabase creates the Anki SQLite database +func (g *APKGGenerator) createDatabase(dbPath string) error { + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + return err + } + defer db.Close() + + // Create tables + if err := g.createTables(db); err != nil { + return fmt.Errorf("failed to create tables: %w", err) + } + + // Insert collection metadata + if err := g.insertCollection(db); err != nil { + return fmt.Errorf("failed to insert collection: %w", err) + } + + // Insert notes and cards + if err := g.insertNotesAndCards(db); err != nil { + return fmt.Errorf("failed to insert notes and cards: %w", err) + } + + return nil +} + +// createTables creates the required Anki database tables +func (g *APKGGenerator) createTables(db *sql.DB) error { + queries := []string{ + `CREATE TABLE col ( + id integer PRIMARY KEY, + crt integer NOT NULL, + mod integer NOT NULL, + scm integer NOT NULL, + ver integer NOT NULL, + dty integer NOT NULL, + usn integer NOT NULL, + ls integer NOT NULL, + conf text NOT NULL, + models text NOT NULL, + decks text NOT NULL, + dconf text NOT NULL, + tags text NOT NULL + )`, + `CREATE TABLE notes ( + id integer PRIMARY KEY, + guid text NOT NULL, + mid integer NOT NULL, + mod integer NOT NULL, + usn integer NOT NULL, + tags text NOT NULL, + flds text NOT NULL, + sfld text NOT NULL, + csum integer NOT NULL, + flags integer NOT NULL, + data text NOT NULL + )`, + `CREATE TABLE cards ( + id integer PRIMARY KEY, + nid integer NOT NULL, + did integer NOT NULL, + ord integer NOT NULL, + mod integer NOT NULL, + usn integer NOT NULL, + type integer NOT NULL, + queue integer NOT NULL, + due integer NOT NULL, + ivl integer NOT NULL, + factor integer NOT NULL, + reps integer NOT NULL, + lapses integer NOT NULL, + left integer NOT NULL, + odue integer NOT NULL, + odid integer NOT NULL, + flags integer NOT NULL, + data text NOT NULL + )`, + `CREATE TABLE revlog ( + id integer PRIMARY KEY, + cid integer NOT NULL, + usn integer NOT NULL, + ease integer NOT NULL, + ivl integer NOT NULL, + lastIvl integer NOT NULL, + factor integer NOT NULL, + time integer NOT NULL, + type integer NOT NULL + )`, + `CREATE TABLE graves ( + usn integer NOT NULL, + oid integer NOT NULL, + type integer NOT NULL + )`, + // Create indexes + `CREATE INDEX ix_notes_csum ON notes (csum)`, + `CREATE INDEX ix_notes_usn ON notes (usn)`, + `CREATE INDEX ix_cards_usn ON cards (usn)`, + `CREATE INDEX ix_cards_nid ON cards (nid)`, + `CREATE INDEX ix_cards_sched ON cards (did, queue, due)`, + `CREATE INDEX ix_revlog_usn ON revlog (usn)`, + `CREATE INDEX ix_revlog_cid ON revlog (cid)`, + } + + for _, query := range queries { + if _, err := db.Exec(query); err != nil { + return fmt.Errorf("failed to execute query: %w", err) + } + } + + return nil +} + +// insertCollection inserts the collection metadata +func (g *APKGGenerator) insertCollection(db *sql.DB) error { + now := time.Now().Unix() + + // Create deck configuration + decks := map[string]interface{}{ + "1": map[string]interface{}{ + "id": 1, + "name": "Default", + "mod": now, + "desc": "", + "collapsed": false, + }, + fmt.Sprintf("%d", g.deckID): map[string]interface{}{ + "id": g.deckID, + "name": g.deckName, + "mod": now, + "desc": "Bulgarian vocabulary cards created by TotalRecall", + "collapsed": false, + }, + } + decksJSON, _ := json.Marshal(decks) + + // Create model (note type) configuration + models := map[string]interface{}{ + fmt.Sprintf("%d", g.modelID): g.createNoteTypeConfig(), + } + modelsJSON, _ := json.Marshal(models) + + // Default configuration + conf := map[string]interface{}{ + "nextPos": 1, + "estTimes": true, + "activeDecks": []int64{1}, + "sortType": "noteFld", + "sortBackwards": false, + "addToCur": true, + "curDeck": 1, + "newSpread": 0, + "dueCounts": true, + } + confJSON, _ := json.Marshal(conf) + + // Deck options + dconf := map[string]interface{}{ + "1": map[string]interface{}{ + "name": "Default", + "new": map[string]interface{}{ + "delays": []int{1, 10}, + "ints": []int{1, 4, 7}, + "initialFactor": 2500, + }, + "lapse": map[string]interface{}{ + "delays": []int{10}, + "mult": 0, + "minInt": 1, + }, + "rev": map[string]interface{}{ + "maxIvl": 36500, + }, + }, + } + dconfJSON, _ := json.Marshal(dconf) + + query := `INSERT INTO col VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + _, err := db.Exec(query, + 1, // id + now, // crt + now*1000, // mod + now*1000, // scm + 11, // ver (schema version) + 0, // dty + 0, // usn + 0, // ls + string(confJSON), + string(modelsJSON), + string(decksJSON), + string(dconfJSON), + "{}", // tags + ) + return err +} + +// createNoteTypeConfig creates the note type configuration +func (g *APKGGenerator) createNoteTypeConfig() map[string]interface{} { + return map[string]interface{}{ + "id": g.modelID, + "name": "Bulgarian Vocabulary", + "type": 0, + "mod": time.Now().Unix(), + "usn": -1, + "flds": []map[string]interface{}{ + { + "name": "English", + "ord": 0, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + }, + { + "name": "Bulgarian", + "ord": 1, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + }, + { + "name": "Image", + "ord": 2, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + }, + { + "name": "Audio", + "ord": 3, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 20, + }, + { + "name": "Notes", + "ord": 4, + "sticky": false, + "rtl": false, + "font": "Arial", + "size": 16, + }, + }, + "tmpls": []map[string]interface{}{ + { + "name": "Card 1", + "ord": 0, + "qfmt": g.getFrontTemplate(), + "afmt": g.getBackTemplate(), + }, + }, + "css": g.getCSS(), + "did": g.deckID, + } +} + +// getFrontTemplate returns the question template +func (g *APKGGenerator) getFrontTemplate() string { + return `
+{{#Image}} +
+{{Image}} +
+{{/Image}} +
{{English}}
+
` +} + +// getBackTemplate returns the answer template +func (g *APKGGenerator) getBackTemplate() string { + return `{{FrontSide}} + +
+ +
+
{{Bulgarian}}
+{{#Audio}} +
{{Audio}}
+{{/Audio}} +{{#Notes}} +
{{Notes}}
+{{/Notes}} +
` +} + +// getCSS returns the card styling +func (g *APKGGenerator) getCSS() string { + return `.card { + font-family: Arial, sans-serif; + font-size: 20px; + text-align: center; + color: #333; + background-color: white; +} + +.front, .back { + padding: 20px; +} + +.image-container { + margin: 20px auto; + max-width: 400px; +} + +.image-container img { + max-width: 100%; + height: auto; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); +} + +.english { + font-size: 28px; + font-weight: bold; + color: #2c3e50; + margin: 20px 0; +} + +.bulgarian { + font-size: 32px; + font-weight: bold; + color: #c0392b; + margin: 20px 0; +} + +.audio { + margin: 15px 0; +} + +.notes { + font-size: 16px; + color: #7f8c8d; + margin-top: 20px; + font-style: italic; +} + +hr#answer { + margin: 30px 0; + border: 0; + border-top: 1px solid #ecf0f1; +}` +} + +// insertNotesAndCards inserts all notes and cards into the database +func (g *APKGGenerator) insertNotesAndCards(db *sql.DB) error { + now := time.Now() + + for i, card := range g.cards { + // Generate unique IDs + noteID := now.UnixMilli() + int64(i*2) + cardID := noteID + 1 + + // Prepare field values + english := card.Translation + if english == "" { + english = "Translation needed" + } + + imageField := "" + if card.ImageFile != "" { + if num, ok := g.mediaFiles[filepath.Base(card.ImageFile)]; ok { + imageField = fmt.Sprintf(``, num) + } + } + + audioField := "" + if card.AudioFile != "" { + if num, ok := g.mediaFiles[filepath.Base(card.AudioFile)]; ok { + audioField = fmt.Sprintf("[sound:%d]", num) + } + } + + // Join fields with field separator (ASCII 31) + fields := strings.Join([]string{ + english, + card.Bulgarian, + imageField, + audioField, + card.Notes, + }, "\x1f") + + // Generate GUID + guid := fmt.Sprintf("tr_%d_%s", now.Unix(), card.Bulgarian) + + // Insert note + noteQuery := `INSERT INTO notes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + _, err := db.Exec(noteQuery, + noteID, // id + guid, // guid + g.modelID, // mid + now.Unix(), // mod + -1, // usn + "", // tags + fields, // flds + card.Bulgarian, // sfld (sort field) + 0, // csum + 0, // flags + "", // data + ) + if err != nil { + return fmt.Errorf("failed to insert note: %w", err) + } + + // Insert card + cardQuery := `INSERT INTO cards VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + _, err = db.Exec(cardQuery, + cardID, // id + noteID, // nid + g.deckID, // did + 0, // ord + now.Unix(), // mod + -1, // usn + 0, // type (0=new) + 0, // queue (0=new) + noteID, // due (for new cards, this is position) + 0, // ivl + 0, // factor + 0, // reps + 0, // lapses + 0, // left + 0, // odue + 0, // odid + 0, // flags + "", // data + ) + if err != nil { + return fmt.Errorf("failed to insert card: %w", err) + } + } + + return nil +} + +// copyMediaFiles copies media files and assigns them numbers +func (g *APKGGenerator) copyMediaFiles(mediaDir string) error { + // Media files don't go in a subdirectory for .apkg + // They go directly in the temp directory with numeric names + + for _, card := range g.cards { + // Copy audio file + if card.AudioFile != "" && fileExists(card.AudioFile) { + filename := filepath.Base(card.AudioFile) + if _, exists := g.mediaFiles[filename]; !exists { + targetPath := filepath.Join(filepath.Dir(mediaDir), fmt.Sprintf("%d", g.mediaCounter)) + if err := copyFile(card.AudioFile, targetPath); err != nil { + return fmt.Errorf("failed to copy audio file %s: %w", card.AudioFile, err) + } + g.mediaFiles[filename] = g.mediaCounter + g.mediaCounter++ + } + } + + // Copy image file + if card.ImageFile != "" && fileExists(card.ImageFile) { + filename := filepath.Base(card.ImageFile) + if _, exists := g.mediaFiles[filename]; !exists { + targetPath := filepath.Join(filepath.Dir(mediaDir), fmt.Sprintf("%d", g.mediaCounter)) + if err := copyFile(card.ImageFile, targetPath); err != nil { + return fmt.Errorf("failed to copy image file %s: %w", card.ImageFile, err) + } + g.mediaFiles[filename] = g.mediaCounter + g.mediaCounter++ + } + } + } + + return nil +} + +// createMediaMapping creates the media mapping JSON file +func (g *APKGGenerator) createMediaMapping(tempDir string) error { + // Create reverse mapping (number -> filename) + mapping := make(map[string]string) + for filename, num := range g.mediaFiles { + mapping[fmt.Sprintf("%d", num)] = filename + } + + data, err := json.Marshal(mapping) + if err != nil { + return err + } + + return os.WriteFile(filepath.Join(tempDir, "media"), data, 0644) +} + +// createZipPackage creates the final .apkg zip file +func (g *APKGGenerator) createZipPackage(tempDir, outputPath string) error { + // Create the zip file + zipFile, err := os.Create(outputPath) + if err != nil { + return err + } + defer zipFile.Close() + + archive := zip.NewWriter(zipFile) + defer archive.Close() + + // Walk the temp directory and add all files to the zip + return filepath.Walk(tempDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip directories + if info.IsDir() { + return nil + } + + // Get relative path + relPath, err := filepath.Rel(tempDir, path) + if err != nil { + return err + } + + // Create zip entry + writer, err := archive.Create(relPath) + if err != nil { + return err + } + + // Open and copy file + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + _, err = io.Copy(writer, file) + return err + }) +} + +// Helper functions + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func copyFile(src, dst string) error { + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + dstFile, err := os.Create(dst) + if err != nil { + return err + } + defer dstFile.Close() + + _, err = io.Copy(dstFile, srcFile) + return err +} \ No newline at end of file diff --git a/internal/anki/generator.go b/internal/anki/generator.go index 3685e97..eecce48 100644 --- a/internal/anki/generator.go +++ b/internal/anki/generator.go @@ -129,75 +129,80 @@ func (g *Generator) formatImageField(imageFile string) string { // GenerateFromDirectory creates cards from a directory of materials func (g *Generator) GenerateFromDirectory(dir string) error { - // Map to group files by word - wordFiles := make(map[string]*Card) + // Read all subdirectories + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("failed to read directory: %w", err) + } - // Walk the directory - err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err + // Process each subdirectory as a word + for _, entry := range entries { + if !entry.IsDir() { + continue } - // Skip directories - if info.IsDir() { - return nil + // Skip hidden directories like .trashbin + if strings.HasPrefix(entry.Name(), ".") { + continue } - // Get filename without extension - filename := info.Name() - ext := filepath.Ext(filename) - base := strings.TrimSuffix(filename, ext) + wordDir := filepath.Join(dir, entry.Name()) + sanitizedWord := entry.Name() - // Skip attribution files - if strings.HasSuffix(base, "_attribution") { - return nil - } + // Create card for this word + card := Card{} - // Extract word from filename (assumes format: word_type.ext or word_index.ext) - parts := strings.Split(base, "_") - if len(parts) == 0 { - return nil + // Try to load translation and get original word + translationFile := filepath.Join(wordDir, fmt.Sprintf("%s_translation.txt", sanitizedWord)) + if data, err := os.ReadFile(translationFile); err == nil { + content := string(data) + if parts := strings.Split(content, "="); len(parts) >= 2 { + card.Bulgarian = strings.TrimSpace(parts[0]) + card.Translation = strings.TrimSpace(parts[1]) + } } - word := parts[0] + // If no Bulgarian word found from translation, use directory name + if card.Bulgarian == "" { + card.Bulgarian = sanitizedWord + } - // Get or create card for this word - card, exists := wordFiles[word] - if !exists { - card = &Card{ - Bulgarian: word, + // Look for audio file + audioFormats := []string{"mp3", "wav"} + for _, format := range audioFormats { + audioFile := filepath.Join(wordDir, fmt.Sprintf("%s.%s", sanitizedWord, format)) + if _, err := os.Stat(audioFile); err == nil { + card.AudioFile = audioFile + break } - wordFiles[word] = card } - // Add file to appropriate field - switch strings.ToLower(ext) { - case ".mp3", ".wav": - if card.AudioFile == "" { // Use first audio file found - card.AudioFile = path - } - case ".jpg", ".jpeg", ".png", ".gif": - if card.ImageFile == "" { // Use first image file found - card.ImageFile = path + // Look for image files + imagePatterns := []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 imagePatterns { + imageFile := filepath.Join(wordDir, pattern) + if _, err := os.Stat(imageFile); err == nil { + card.ImageFile = imageFile + break } } - return nil - }) - - if err != nil { - return fmt.Errorf("failed to walk directory: %w", err) - } - - // Add all cards to generator - for _, card := range wordFiles { - g.AddCard(*card) + // Only add card if it has at least some content + if card.AudioFile != "" || card.ImageFile != "" || card.Translation != "" { + g.AddCard(card) + } } return nil } // GeneratePackage creates a complete Anki package with media files +// Deprecated: Use GenerateAPKG for proper .apkg format func (g *Generator) GeneratePackage(outputDir string) error { // Create output directory if err := os.MkdirAll(outputDir, 0755); err != nil { @@ -238,6 +243,20 @@ func (g *Generator) GeneratePackage(outputDir string) error { return g.GenerateCSV() } +// GenerateAPKG creates a proper .apkg file for Anki import +func (g *Generator) GenerateAPKG(outputPath, deckName string) error { + // Create APKG generator + apkgGen := NewAPKGGenerator(deckName) + + // Add all cards + for _, card := range g.cards { + apkgGen.AddCard(card) + } + + // Generate the .apkg file + return apkgGen.GenerateAPKG(outputPath) +} + // copyMediaFile copies a media file to the destination directory func (g *Generator) copyMediaFile(src, destDir string) (string, error) { // Get source file info 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() }) -- cgit v1.2.3