diff options
| author | Paul Buetow <paul@buetow.org> | 2025-07-19 23:35:44 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-07-19 23:35:44 +0300 |
| commit | 6038d6e29d4878cccb46bf35bb9885ec7d376422 (patch) | |
| tree | f7861a731ba20db54e4ca578d6cd586ad8a87ab0 | |
| parent | 3a6c690230f769ad33bc26e2fc5d5662e38fe3d6 (diff) | |
feat: improve flashcard storage and audio regenerationv0.6.0
- Change default card storage from ~/Downloads to ~/.local/state/totalrecall/cards/
- Keep .apkg exports in ~/Downloads for user convenience
- Fix audio regeneration to use random voice and speed (0.9-1.0)
- Fix GNOME dock icon by updating StartupWMClass to "Totalrecall"
- Fix navigation to properly find cards in new XDG state directory
- Ensure config defaults are properly filled when using GUI mode
- Bump version to 0.6.0
π€ Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
| -rw-r--r-- | cmd/totalrecall/main.go | 13 | ||||
| -rw-r--r-- | internal/anki/apkg_generator.go | 278 | ||||
| -rw-r--r-- | internal/audio/provider.go | 45 | ||||
| -rw-r--r-- | internal/gui/app.go | 15 | ||||
| -rw-r--r-- | internal/gui/generator.go | 15 | ||||
| -rw-r--r-- | internal/version.go | 2 | ||||
| -rw-r--r-- | totalrecall.desktop | 2 |
7 files changed, 200 insertions, 170 deletions
diff --git a/cmd/totalrecall/main.go b/cmd/totalrecall/main.go index f50750f..ab06987 100644 --- a/cmd/totalrecall/main.go +++ b/cmd/totalrecall/main.go @@ -73,7 +73,7 @@ func init() { // Initialize random number generator rand.Seed(time.Now().UnixNano()) - // Set default output directory to Downloads + // Set default output directory based on mode home, _ := os.UserHomeDir() defaultOutputDir := filepath.Join(home, "Downloads") @@ -931,12 +931,21 @@ Word: [IPA transcription] func runGUIMode() error { // Create GUI configuration from command line flags and viper config guiConfig := &gui.Config{ - OutputDir: outputDir, AudioFormat: audioFormat, ImageProvider: imageAPI, OpenAIKey: getOpenAIKey(), } + // Only set OutputDir if it was explicitly provided via flag + // Check if the outputDir is different from the default + home, _ := os.UserHomeDir() + defaultOutputDir := filepath.Join(home, "Downloads") + if outputDir != defaultOutputDir { + // User explicitly set a different output directory + guiConfig.OutputDir = outputDir + } + // Otherwise, gui.New will use its own default (XDG state directory) + // Create and run GUI application app := gui.New(guiConfig) app.Run() diff --git a/internal/anki/apkg_generator.go b/internal/anki/apkg_generator.go index 6da0cfb..0f74ba1 100644 --- a/internal/anki/apkg_generator.go +++ b/internal/anki/apkg_generator.go @@ -191,43 +191,43 @@ func (g *APKGGenerator) createTables(db *sql.DB) error { // insertCollection inserts the collection metadata func (g *APKGGenerator) insertCollection(db *sql.DB) error { now := time.Now().Unix() - + // Create deck configuration // The arrays are [learningCount, reviewCount] for today's stats decks := map[string]interface{}{ "1": map[string]interface{}{ - "id": 1, - "name": "Default", - "mod": now, - "desc": "", - "collapsed": false, - "dyn": 0, - "conf": 1, - "usn": 0, - "newToday": []int{0, 0}, - "revToday": []int{0, 0}, - "lrnToday": []int{0, 0}, - "timeToday": []int{0, 0}, + "id": 1, + "name": "Default", + "mod": now, + "desc": "", + "collapsed": false, + "dyn": 0, + "conf": 1, + "usn": 0, + "newToday": []int{0, 0}, + "revToday": []int{0, 0}, + "lrnToday": []int{0, 0}, + "timeToday": []int{0, 0}, "browserCollapsed": false, - "extendNew": 10, - "extendRev": 50, + "extendNew": 10, + "extendRev": 50, }, 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, - "dyn": 0, - "conf": 1, - "usn": 0, - "newToday": []int{0, 0}, - "revToday": []int{0, 0}, - "lrnToday": []int{0, 0}, - "timeToday": []int{0, 0}, + "id": g.deckID, + "name": g.deckName, + "mod": now, + "desc": "Bulgarian vocabulary cards created by TotalRecall", + "collapsed": false, + "dyn": 0, + "conf": 1, + "usn": 0, + "newToday": []int{0, 0}, + "revToday": []int{0, 0}, + "lrnToday": []int{0, 0}, + "timeToday": []int{0, 0}, "browserCollapsed": false, - "extendNew": 10, - "extendRev": 50, + "extendNew": 10, + "extendRev": 50, }, } decksJSON, _ := json.Marshal(decks) @@ -240,19 +240,19 @@ func (g *APKGGenerator) insertCollection(db *sql.DB) error { // Default configuration conf := map[string]interface{}{ - "nextPos": 1, - "estTimes": true, - "activeDecks": []int64{1}, - "sortType": "noteFld", + "nextPos": 1, + "estTimes": true, + "activeDecks": []int64{1}, + "sortType": "noteFld", "sortBackwards": false, - "addToCur": true, - "curDeck": 1, - "newSpread": 0, - "dueCounts": true, - "collapseTime": 1200, - "timeLim": 0, - "schedVer": 1, - "curModel": fmt.Sprintf("%d", g.modelID), + "addToCur": true, + "curDeck": 1, + "newSpread": 0, + "dueCounts": true, + "collapseTime": 1200, + "timeLim": 0, + "schedVer": 1, + "curModel": fmt.Sprintf("%d", g.modelID), "dayLearnFirst": false, } confJSON, _ := json.Marshal(conf) @@ -260,59 +260,59 @@ func (g *APKGGenerator) insertCollection(db *sql.DB) error { // Deck options dconf := map[string]interface{}{ "1": map[string]interface{}{ - "id": 1, + "id": 1, "name": "Default", - "dyn": 0, + "dyn": 0, "new": map[string]interface{}{ - "delays": []int{1, 10}, - "ints": []int{1, 4, 7}, + "delays": []int{1, 10}, + "ints": []int{1, 4, 7}, "initialFactor": 2500, - "perDay": 20, - "order": 1, - "bury": true, - "separate": true, + "perDay": 20, + "order": 1, + "bury": true, + "separate": true, }, "lapse": map[string]interface{}{ - "delays": []int{10}, - "mult": 0, - "minInt": 1, - "leechFails": 8, + "delays": []int{10}, + "mult": 0, + "minInt": 1, + "leechFails": 8, "leechAction": 0, }, "rev": map[string]interface{}{ - "perDay": 100, - "ease4": 1.3, - "fuzz": 0.05, - "maxIvl": 36500, - "ivlFct": 1, - "bury": true, + "perDay": 100, + "ease4": 1.3, + "fuzz": 0.05, + "maxIvl": 36500, + "ivlFct": 1, + "bury": true, "minSpace": 1, }, - "timer": 0, + "timer": 0, "maxTaken": 60, - "usn": 0, - "mod": now, + "usn": 0, + "mod": now, "autoplay": true, - "replayq": true, + "replayq": true, }, } 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 + 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 + "{}", // tags ) return err } @@ -320,11 +320,11 @@ func (g *APKGGenerator) insertCollection(db *sql.DB) error { // 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, + "id": g.modelID, + "name": "Vocabulary from TotalRecall", + "type": 0, + "mod": time.Now().Unix(), + "usn": -1, "sortf": 0, "did": g.deckID, "req": [][]interface{}{[]interface{}{0, "all", []int{0}}}, @@ -387,11 +387,11 @@ func (g *APKGGenerator) createNoteTypeConfig() map[string]interface{} { }, "tmpls": []map[string]interface{}{ { - "name": "Card 1", - "ord": 0, - "qfmt": g.getFrontTemplate(), - "afmt": g.getBackTemplate(), - "did": nil, + "name": "Card 1", + "ord": 0, + "qfmt": g.getFrontTemplate(), + "afmt": g.getBackTemplate(), + "did": nil, "bqfmt": "", "bafmt": "", }, @@ -490,18 +490,18 @@ hr#answer { // 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 != "" && fileExists(card.ImageFile) { // Get card ID from the source path (parent directory name) @@ -509,13 +509,13 @@ func (g *APKGGenerator) insertNotesAndCards(db *sql.DB) error { originalFilename := filepath.Base(card.ImageFile) // Create unique filename with card ID prefix uniqueFilename := fmt.Sprintf("%s_%s", cardID, originalFilename) - + if _, ok := g.mediaFiles[uniqueFilename]; ok { // Use the unique filename in the card content imageField = fmt.Sprintf(`<img src="%s">`, uniqueFilename) } } - + audioField := "" if card.AudioFile != "" && fileExists(card.AudioFile) { // Get card ID from the source path (parent directory name) @@ -523,13 +523,13 @@ func (g *APKGGenerator) insertNotesAndCards(db *sql.DB) error { originalFilename := filepath.Base(card.AudioFile) // Create unique filename with card ID prefix uniqueFilename := fmt.Sprintf("%s_%s", cardID, originalFilename) - + if _, ok := g.mediaFiles[uniqueFilename]; ok { // Use the unique filename in the card content audioField = fmt.Sprintf("[sound:%s]", uniqueFilename) } } - + // Join fields with field separator (ASCII 31) fields := strings.Join([]string{ english, @@ -538,63 +538,63 @@ func (g *APKGGenerator) insertNotesAndCards(db *sql.DB) error { 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 + 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 + 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(tempDir string) error { // Media files go directly in the temp directory with numeric names - + for _, card := range g.cards { // Copy audio file if card.AudioFile != "" && fileExists(card.AudioFile) { @@ -603,7 +603,7 @@ func (g *APKGGenerator) copyMediaFiles(tempDir string) error { originalFilename := filepath.Base(card.AudioFile) // Create unique filename with card ID prefix uniqueFilename := fmt.Sprintf("%s_%s", cardID, originalFilename) - + if _, exists := g.mediaFiles[uniqueFilename]; !exists { targetPath := filepath.Join(tempDir, fmt.Sprintf("%d", g.mediaCounter)) if err := copyFile(card.AudioFile, targetPath); err != nil { @@ -613,7 +613,7 @@ func (g *APKGGenerator) copyMediaFiles(tempDir string) error { g.mediaCounter++ } } - + // Copy image file if card.ImageFile != "" && fileExists(card.ImageFile) { // Get card ID from the source path (parent directory name) @@ -621,7 +621,7 @@ func (g *APKGGenerator) copyMediaFiles(tempDir string) error { originalFilename := filepath.Base(card.ImageFile) // Create unique filename with card ID prefix uniqueFilename := fmt.Sprintf("%s_%s", cardID, originalFilename) - + if _, exists := g.mediaFiles[uniqueFilename]; !exists { targetPath := filepath.Join(tempDir, fmt.Sprintf("%d", g.mediaCounter)) if err := copyFile(card.ImageFile, targetPath); err != nil { @@ -632,7 +632,7 @@ func (g *APKGGenerator) copyMediaFiles(tempDir string) error { } } } - + return nil } @@ -643,12 +643,12 @@ func (g *APKGGenerator) createMediaMapping(tempDir string) error { 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) } @@ -660,40 +660,40 @@ func (g *APKGGenerator) createZipPackage(tempDir, outputPath string) error { 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 }) @@ -712,13 +712,13 @@ func copyFile(src, dst string) error { 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/audio/provider.go b/internal/audio/provider.go index 53b08c1..0142fb3 100644 --- a/internal/audio/provider.go +++ b/internal/audio/provider.go @@ -9,10 +9,10 @@ import ( type Provider interface { // GenerateAudio generates audio from text and saves it to the specified file GenerateAudio(ctx context.Context, text string, outputFile string) error - + // Name returns the provider name Name() string - + // IsAvailable checks if the provider is properly configured and available IsAvailable() error } @@ -22,14 +22,14 @@ type Config struct { Provider string // Provider name: "openai" OutputDir string // Directory for output files OutputFormat string // Output format: "mp3" or "wav" - + // OpenAI-specific settings OpenAIKey string OpenAIModel string // "tts-1", "tts-1-hd", or "gpt-4o-mini-tts" OpenAIVoice string // "alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse" OpenAISpeed float64 // 0.25 to 4.0 OpenAIInstruction string // Voice instructions for gpt-4o-mini-tts model - + // Caching settings EnableCache bool CacheDir string @@ -38,15 +38,16 @@ type Config struct { // DefaultConfig returns default configuration func DefaultProviderConfig() *Config { return &Config{ - Provider: "openai", - OutputDir: "./", - OutputFormat: "mp3", - OpenAIModel: "gpt-4o-mini-tts", // New model with voice instructions support - OpenAIVoice: "alloy", - OpenAISpeed: 0.98, // Default speed for clarity + Provider: "openai", + OutputDir: "./", + OutputFormat: "mp3", + OpenAIModel: "gpt-4o-mini-tts", // New model with voice instructions support + OpenAIVoice: "alloy", + OpenAISpeed: 1.0, + // OpenAISpeed: 0.98, // Default speed for clarity OpenAIInstruction: "You are speaking Bulgarian language (Π±ΡΠ»Π³Π°ΡΡΠΊΠΈ Π΅Π·ΠΈΠΊ). Pronounce the Bulgarian text with authentic Bulgarian phonetics, not Russian. Speak slowly and clearly for language learners.", - EnableCache: true, - CacheDir: "./.audio_cache", + EnableCache: true, + CacheDir: "./.audio_cache", } } @@ -55,14 +56,14 @@ func NewProvider(config *Config) (Provider, error) { if config == nil { config = DefaultProviderConfig() } - + switch config.Provider { case "openai": if config.OpenAIKey == "" { return nil, fmt.Errorf("OpenAI API key is required") } return NewOpenAIProvider(config) - + default: return nil, fmt.Errorf("unknown audio provider: %s", config.Provider) } @@ -70,8 +71,8 @@ func NewProvider(config *Config) (Provider, error) { // ProviderWithFallback wraps a primary provider with a fallback option type ProviderWithFallback struct { - primary Provider - fallback Provider + primary Provider + fallback Provider } // NewProviderWithFallback creates a provider that falls back to secondary if primary fails @@ -87,9 +88,9 @@ func (p *ProviderWithFallback) GenerateAudio(ctx context.Context, text string, o err := p.primary.GenerateAudio(ctx, text, outputFile) if err != nil { // Log the primary error - fmt.Printf("Primary provider (%s) failed: %v. Falling back to %s\n", + fmt.Printf("Primary provider (%s) failed: %v. Falling back to %s\n", p.primary.Name(), err, p.fallback.Name()) - + // Try fallback return p.fallback.GenerateAudio(ctx, text, outputFile) } @@ -107,12 +108,12 @@ func (p *ProviderWithFallback) IsAvailable() error { if primaryErr == nil { return nil } - + fallbackErr := p.fallback.IsAvailable() if fallbackErr == nil { return nil } - - return fmt.Errorf("both providers unavailable: primary=%v, fallback=%v", + + return fmt.Errorf("both providers unavailable: primary=%v, fallback=%v", primaryErr, fallbackErr) -}
\ No newline at end of file +} diff --git a/internal/gui/app.go b/internal/gui/app.go index 4e70970..3e256b8 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -100,7 +100,8 @@ type Config struct { // DefaultConfig returns default GUI configuration func DefaultConfig() *Config { homeDir, _ := os.UserHomeDir() - outputDir := filepath.Join(homeDir, "Downloads") + // Use XDG Base Directory specification for state data + outputDir := filepath.Join(homeDir, ".local", "state", "totalrecall", "cards") return &Config{ OutputDir: outputDir, @@ -113,6 +114,18 @@ func DefaultConfig() *Config { func New(config *Config) *Application { if config == nil { config = DefaultConfig() + } else { + // Fill in missing fields with defaults + defaults := DefaultConfig() + if config.OutputDir == "" { + config.OutputDir = defaults.OutputDir + } + if config.AudioFormat == "" { + config.AudioFormat = defaults.AudioFormat + } + if config.ImageProvider == "" { + config.ImageProvider = defaults.ImageProvider + } } // Ensure output directory exists diff --git a/internal/gui/generator.go b/internal/gui/generator.go index 316cab8..6cd0fca 100644 --- a/internal/gui/generator.go +++ b/internal/gui/generator.go @@ -115,12 +115,19 @@ func (a *Application) generateAudio(ctx context.Context, word string) (string, e speed = 1.0 } - // Update audio config with selected voice and speed - a.audioConfig.OpenAIVoice = voice - a.audioConfig.OpenAISpeed = speed + // Create a copy of audio config with selected voice and speed + audioConfig := *a.audioConfig + audioConfig.OpenAIVoice = voice + audioConfig.OpenAISpeed = speed + audioConfig.OutputDir = a.config.OutputDir // Ensure correct output directory + + // Log the regeneration details + if isRegeneration { + fmt.Printf("Regenerating audio for '%s' with voice: %s, speed: %.2f\n", word, voice, speed) + } // Create audio provider - provider, err := audio.NewProvider(a.audioConfig) + provider, err := audio.NewProvider(&audioConfig) if err != nil { return "", err } diff --git a/internal/version.go b/internal/version.go index 60aa9ce..0a144ef 100644 --- a/internal/version.go +++ b/internal/version.go @@ -1,3 +1,3 @@ package internal -const Version = "0.5.2" +const Version = "0.6.0" diff --git a/totalrecall.desktop b/totalrecall.desktop index 41be16e..a913b31 100644 --- a/totalrecall.desktop +++ b/totalrecall.desktop @@ -10,4 +10,4 @@ Terminal=false Categories=Education;Languages; Keywords=bulgarian;flashcards;anki;language;learning; StartupNotify=true -StartupWMClass=totalrecall
\ No newline at end of file +StartupWMClass=Totalrecall
\ No newline at end of file |
