summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-07-19 23:35:44 +0300
committerPaul Buetow <paul@buetow.org>2025-07-19 23:35:44 +0300
commit6038d6e29d4878cccb46bf35bb9885ec7d376422 (patch)
treef7861a731ba20db54e4ca578d6cd586ad8a87ab0
parent3a6c690230f769ad33bc26e2fc5d5662e38fe3d6 (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.go13
-rw-r--r--internal/anki/apkg_generator.go278
-rw-r--r--internal/audio/provider.go45
-rw-r--r--internal/gui/app.go15
-rw-r--r--internal/gui/generator.go15
-rw-r--r--internal/version.go2
-rw-r--r--totalrecall.desktop2
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