summaryrefslogtreecommitdiff
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
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>
-rw-r--r--README.md23
-rw-r--r--cmd/totalrecall/main.go62
-rw-r--r--go.mod1
-rw-r--r--go.sum2
-rw-r--r--internal/anki/apkg_generator.go636
-rw-r--r--internal/anki/generator.go113
-rw-r--r--internal/gui/app.go144
-rw-r--r--internal/gui/generator.go22
-rw-r--r--internal/gui/navigation.go148
9 files changed, 969 insertions, 182 deletions
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 `<div class="front">
+{{#Image}}
+<div class="image-container">
+{{Image}}
+</div>
+{{/Image}}
+<div class="english">{{English}}</div>
+</div>`
+}
+
+// getBackTemplate returns the answer template
+func (g *APKGGenerator) getBackTemplate() string {
+ return `{{FrontSide}}
+
+<hr id="answer">
+
+<div class="back">
+<div class="bulgarian">{{Bulgarian}}</div>
+{{#Audio}}
+<div class="audio">{{Audio}}</div>
+{{/Audio}}
+{{#Notes}}
+<div class="notes">{{Notes}}</div>
+{{/Notes}}
+</div>`
+}
+
+// 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(`<img src="%d">`, 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 {