package anki
import (
"encoding/csv"
"fmt"
"os"
"path/filepath"
"strings"
"codeberg.org/snonux/totalrecall/internal"
)
// Card represents a single Anki flashcard
type Card struct {
Bulgarian string // The Bulgarian word/phrase
AudioFile string // Path to audio file (for en-bg: Bulgarian audio, for bg-bg: front audio)
AudioFileBack string // Path to back audio file (only for bg-bg cards)
ImageFile string // Path to image file
Translation string // Translation (English for en-bg, Bulgarian definition for bg-bg)
Notes string // Optional notes
CardType string // Card type: "en-bg" or "bg-bg"
}
// GeneratorOptions configures the Anki export
type GeneratorOptions struct {
OutputPath string // Output CSV file path
MediaFolder string // Folder containing media files
IncludeHeaders bool // Include CSV headers
AudioFormat string // Audio file format (mp3, wav)
ImageFormat string // Image file format (jpg, png)
}
// DefaultGeneratorOptions returns sensible defaults
func DefaultGeneratorOptions() *GeneratorOptions {
return &GeneratorOptions{
OutputPath: "anki_import.csv",
MediaFolder: ".",
IncludeHeaders: true,
AudioFormat: "mp3",
ImageFormat: "jpg",
}
}
// Generator creates Anki-compatible import files
type Generator struct {
options *GeneratorOptions
cards []Card
}
// NewGenerator creates a new Anki generator
func NewGenerator(options *GeneratorOptions) *Generator {
if options == nil {
options = DefaultGeneratorOptions()
}
return &Generator{
options: options,
cards: make([]Card, 0),
}
}
// AddCard adds a card to the collection
func (g *Generator) AddCard(card Card) {
g.cards = append(g.cards, card)
}
// GetCards returns a slice of all cards for modification
func (g *Generator) GetCards() []Card {
return g.cards
}
// GenerateCSV creates a CSV file for Anki import.
func (g *Generator) GenerateCSV() (err error) {
// Create output file
file, err := os.Create(g.options.OutputPath)
if err != nil {
return fmt.Errorf("failed to create CSV file: %w", err)
}
defer func() {
if closeErr := file.Close(); err == nil && closeErr != nil {
err = fmt.Errorf("failed to close CSV file: %w", closeErr)
}
}()
// Create CSV writer
writer := csv.NewWriter(file)
defer func() {
writer.Flush()
if flushErr := writer.Error(); err == nil && flushErr != nil {
err = fmt.Errorf("failed to flush CSV file: %w", flushErr)
}
}()
// Write headers if requested
if g.options.IncludeHeaders {
headers := []string{"Bulgarian", "Audio", "Image", "Translation", "Notes"}
if err := writer.Write(headers); err != nil {
return fmt.Errorf("failed to write headers: %w", err)
}
}
// Write cards
for _, card := range g.cards {
record := []string{
card.Bulgarian,
g.formatAudioField(card.AudioFile),
g.formatImageField(card.ImageFile),
card.Translation,
card.Notes,
}
if err := writer.Write(record); err != nil {
return fmt.Errorf("failed to write card: %w", err)
}
}
return nil
}
// formatAudioField formats the audio file reference for Anki
func (g *Generator) formatAudioField(audioFile string) string {
if audioFile == "" {
return ""
}
// Get card ID from the source path (parent directory name)
cardID := filepath.Base(filepath.Dir(audioFile))
originalFilename := filepath.Base(audioFile)
// Create filename with card ID prefix for uniqueness in Anki
filename := fmt.Sprintf("%s_%s", cardID, originalFilename)
// Anki audio format: [sound:filename.mp3]
return fmt.Sprintf("[sound:%s]", filename)
}
// formatImageField formats image file reference for Anki
func (g *Generator) formatImageField(imageFile string) string {
if imageFile == "" {
return ""
}
// Get card ID from the source path (parent directory name)
cardID := filepath.Base(filepath.Dir(imageFile))
originalFilename := filepath.Base(imageFile)
// Create filename with card ID prefix for uniqueness in Anki
filename := fmt.Sprintf("%s_%s", cardID, originalFilename)
return fmt.Sprintf(`
`, filename)
}
// GenerateFromDirectory creates cards from a directory of materials
func (g *Generator) GenerateFromDirectory(dir string) error {
// Read all subdirectories
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("failed to read directory: %w", err)
}
// Process each subdirectory as a word
for _, entry := range entries {
if !entry.IsDir() {
continue
}
// Skip hidden directories like .trashbin
if strings.HasPrefix(entry.Name(), ".") {
continue
}
wordDir := filepath.Join(dir, entry.Name())
// Create card for this word
card := Card{}
// Load card type (defaults to en-bg for backwards compatibility)
cardType := internal.LoadCardType(wordDir)
card.CardType = string(cardType)
// Read the original Bulgarian word from word.txt
wordFile := filepath.Join(wordDir, "word.txt")
if data, err := os.ReadFile(wordFile); err == nil {
card.Bulgarian = strings.TrimSpace(string(data))
} else {
// Try old format with underscore for backward compatibility
wordFile = filepath.Join(wordDir, "_word.txt")
if data, err := os.ReadFile(wordFile); err == nil {
card.Bulgarian = strings.TrimSpace(string(data))
} else {
// Skip directories without word.txt
continue
}
}
// Try to load translation
translationFile := filepath.Join(wordDir, "translation.txt")
if data, err := os.ReadFile(translationFile); err == nil {
content := string(data)
if parts := strings.Split(content, "="); len(parts) >= 2 {
card.Translation = strings.TrimSpace(parts[1])
}
}
// Look for audio file(s)
if cardType.IsBgBg() {
card.AudioFile = ResolveAudioFile(wordDir, "audio_front", "")
card.AudioFileBack = ResolveAudioFile(wordDir, "audio_back", "")
} else {
card.AudioFile = ResolveAudioFile(wordDir, "audio", "")
}
// Look for image files
imagePatterns := []string{
"image.jpg",
"image.png",
}
for _, pattern := range imagePatterns {
imageFile := filepath.Join(wordDir, pattern)
if _, err := os.Stat(imageFile); err == nil {
card.ImageFile = imageFile
break
}
}
// Load phonetic information as notes
phoneticFile := filepath.Join(wordDir, "phonetic.txt")
if data, err := os.ReadFile(phoneticFile); err == nil {
// Preserve line breaks by converting \n to
for HTML display
notes := strings.TrimSpace(string(data))
card.Notes = strings.ReplaceAll(notes, "\n", "
")
}
// 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 {
return fmt.Errorf("failed to create output directory: %w", err)
}
// Create media directory
mediaDir := filepath.Join(outputDir, "collection.media")
if err := os.MkdirAll(mediaDir, 0755); err != nil {
return fmt.Errorf("failed to create media directory: %w", err)
}
// Copy media files and update paths
for i, card := range g.cards {
// Copy audio file
if card.AudioFile != "" {
newPath, err := g.copyMediaFile(card.AudioFile, mediaDir)
if err != nil {
return fmt.Errorf("failed to copy audio file: %w", err)
}
g.cards[i].AudioFile = newPath
}
// Copy image file
if card.ImageFile != "" {
newPath, err := g.copyMediaFile(card.ImageFile, mediaDir)
if err != nil {
return fmt.Errorf("failed to copy image file: %w", err)
}
g.cards[i].ImageFile = newPath
}
}
// Update output path to package directory
g.options.OutputPath = filepath.Join(outputDir, "import.csv")
// Generate CSV
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) (filename string, err error) {
// Get source file info
srcInfo, err := os.Stat(src)
if err != nil {
return "", err
}
// Get the card ID from the source path (parent directory name)
cardID := filepath.Base(filepath.Dir(src))
// Create destination filename with card ID prefix
originalFilename := filepath.Base(src)
filename = fmt.Sprintf("%s_%s", cardID, originalFilename)
destPath := filepath.Join(destDir, filename)
// Check if file already exists
if _, err := os.Stat(destPath); err == nil {
// File exists, generate unique name
ext := filepath.Ext(filename)
base := strings.TrimSuffix(filename, ext)
for i := 1; ; i++ {
filename = fmt.Sprintf("%s_%d%s", base, i, ext)
destPath = filepath.Join(destDir, filename)
if _, err := os.Stat(destPath); os.IsNotExist(err) {
break
}
}
}
// Open source file
srcFile, err := os.Open(src)
if err != nil {
return "", err
}
defer func() {
if closeErr := srcFile.Close(); err == nil && closeErr != nil {
err = fmt.Errorf("failed to close source media file: %w", closeErr)
}
}()
// Create destination file
destFile, err := os.Create(destPath)
if err != nil {
return "", err
}
defer func() {
if closeErr := destFile.Close(); err == nil && closeErr != nil {
err = fmt.Errorf("failed to close destination media file: %w", closeErr)
}
}()
// Copy content
if _, err := destFile.ReadFrom(srcFile); err != nil {
return "", err
}
// Preserve file mode
if err := os.Chmod(destPath, srcInfo.Mode()); err != nil {
return "", err
}
return filename, nil
}
// Stats returns statistics about the card collection
func (g *Generator) Stats() (totalCards, withAudio, withImages int) {
totalCards = len(g.cards)
for _, card := range g.cards {
if card.AudioFile != "" {
withAudio++
}
if card.ImageFile != "" {
withImages++
}
}
return
}