summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-08 10:09:01 +0300
committerPaul Buetow <paul@buetow.org>2026-04-08 10:09:01 +0300
commit467e0c0e7d9849f83ebff78f10d1de04445c1852 (patch)
treef1c2a30c6489df621b5f735be6fe3b9f64d21e02
parentacec1e0668084715dc1e981b11b1562243283f58 (diff)
refactor(anki): split APKG generator into SQLiteSchemer, ZipPackager, CardTemplate
Extract SQLite schema and inserts into SQLiteSchemer, zip creation into ZipPackager, and embedded HTML/CSS templates into CardTemplate with external .html and .css files. APKGGenerator orchestrates copy, media mapping, DB build, and packaging. Made-with: Cursor
-rw-r--r--internal/anki/apkg_generator.go867
-rw-r--r--internal/anki/card_template.go260
-rw-r--r--internal/anki/sqlite_schemer.go416
-rw-r--r--internal/anki/templates/bg_bg_back.html13
-rw-r--r--internal/anki/templates/bg_bg_front.html11
-rw-r--r--internal/anki/templates/bg_bg_reverse_back.html18
-rw-r--r--internal/anki/templates/bg_bg_reverse_front.html6
-rw-r--r--internal/anki/templates/card.css68
-rw-r--r--internal/anki/templates/en_bg_back.html13
-rw-r--r--internal/anki/templates/en_bg_front.html8
-rw-r--r--internal/anki/templates/en_bg_reverse_back.html15
-rw-r--r--internal/anki/templates/en_bg_reverse_front.html6
-rw-r--r--internal/anki/zip_packager.go63
13 files changed, 908 insertions, 856 deletions
diff --git a/internal/anki/apkg_generator.go b/internal/anki/apkg_generator.go
index c3f489a..ddd5aa2 100644
--- a/internal/anki/apkg_generator.go
+++ b/internal/anki/apkg_generator.go
@@ -1,17 +1,12 @@
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)
@@ -23,6 +18,10 @@ type APKGGenerator struct {
cards []Card
mediaFiles map[string]int // maps original filename to media number
mediaCounter int
+
+ schemer *SQLiteSchemer
+ packager *ZipPackager
+ templates *CardTemplate
}
// NewAPKGGenerator creates a new APKG generator
@@ -37,6 +36,9 @@ func NewAPKGGenerator(deckName string) *APKGGenerator {
cards: make([]Card, 0),
mediaFiles: make(map[string]int),
mediaCounter: 0,
+ schemer: NewSQLiteSchemer(),
+ packager: NewZipPackager(),
+ templates: MustCardTemplate(),
}
}
@@ -88,806 +90,12 @@ func (g *APKGGenerator) GenerateAPKG(outputPath string) error {
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 func() {
- _ = 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
- // 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},
- "browserCollapsed": false,
- "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},
- "browserCollapsed": false,
- "extendNew": 10,
- "extendRev": 50,
- },
- }
- decksJSON, err := marshalJSON("decks", decks)
- if err != nil {
- return err
- }
-
- // Create model (note type) configuration
- models := map[string]interface{}{
- fmt.Sprintf("%d", g.modelID): g.createNoteTypeConfig(),
- fmt.Sprintf("%d", g.modelIDBgBg): g.createBgBgNoteTypeConfig(),
- }
- modelsJSON, err := marshalJSON("models", models)
- if err != nil {
- return err
- }
-
- // 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,
- "collapseTime": 1200,
- "timeLim": 0,
- "schedVer": 1,
- "curModel": fmt.Sprintf("%d", g.modelID),
- "dayLearnFirst": false,
- }
- confJSON, err := marshalJSON("conf", conf)
- if err != nil {
- return err
- }
-
- // Deck options
- dconf := map[string]interface{}{
- "1": map[string]interface{}{
- "id": 1,
- "name": "Default",
- "dyn": 0,
- "new": map[string]interface{}{
- "delays": []int{1, 10},
- "ints": []int{1, 4, 7},
- "initialFactor": 2500,
- "perDay": 20,
- "order": 1,
- "bury": true,
- "separate": true,
- },
- "lapse": map[string]interface{}{
- "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,
- "minSpace": 1,
- },
- "timer": 0,
- "maxTaken": 60,
- "usn": 0,
- "mod": now,
- "autoplay": true,
- "replayq": true,
- },
- }
- dconfJSON, err := marshalJSON("dconf", dconf)
- if err != nil {
- return err
- }
-
- 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
-}
-
-func marshalJSON(name string, value any) ([]byte, error) {
- data, err := json.Marshal(value)
- if err != nil {
- return nil, fmt.Errorf("marshal %s: %w", name, err)
- }
-
- return data, nil
-}
-
-// createNoteTypeConfig creates the note type configuration
-func (g *APKGGenerator) createNoteTypeConfig() map[string]interface{} {
- return map[string]interface{}{
- "id": g.modelID,
- "name": "Vocabulary from TotalRecall (Basic + Reverse)",
- "type": 0,
- "mod": time.Now().Unix(),
- "usn": -1,
- "sortf": 0,
- "did": g.deckID,
- "req": [][]interface{}{[]interface{}{0, "all", []int{0}}, []interface{}{1, "all", []int{1}}},
- "vers": []int{},
- "tags": []string{},
- "latexPre": `\documentclass[12pt]{article}
-\special{papersize=3in,5in}
-\usepackage[utf8]{inputenc}
-\usepackage{amssymb,amsmath}
-\pagestyle{empty}
-\setlength{\parindent}{0in}
-\begin{document}`,
- "latexPost": `\end{document}`,
- "flds": []map[string]interface{}{
- {
- "name": "English",
- "ord": 0,
- "sticky": false,
- "rtl": false,
- "font": "Arial",
- "size": 20,
- "media": []string{},
- },
- {
- "name": "Bulgarian",
- "ord": 1,
- "sticky": false,
- "rtl": false,
- "font": "Arial",
- "size": 20,
- "media": []string{},
- },
- {
- "name": "Image",
- "ord": 2,
- "sticky": false,
- "rtl": false,
- "font": "Arial",
- "size": 20,
- "media": []string{},
- },
- {
- "name": "Audio",
- "ord": 3,
- "sticky": false,
- "rtl": false,
- "font": "Arial",
- "size": 20,
- "media": []string{},
- },
- {
- "name": "Notes",
- "ord": 4,
- "sticky": false,
- "rtl": false,
- "font": "Arial",
- "size": 16,
- "media": []string{},
- },
- },
- "tmpls": []map[string]interface{}{
- {
- "name": "Forward",
- "ord": 0,
- "qfmt": g.getFrontTemplate(),
- "afmt": g.getBackTemplate(),
- "did": nil,
- "bqfmt": "",
- "bafmt": "",
- },
- {
- "name": "Reverse",
- "ord": 1,
- "qfmt": g.getReverseFrontTemplate(),
- "afmt": g.getReverseBackTemplate(),
- "did": nil,
- "bqfmt": "",
- "bafmt": "",
- },
- },
- "css": g.getCSS(),
- }
-}
-
-// 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>`
-}
-
-// getReverseFrontTemplate returns the question template for the reverse card
-func (g *APKGGenerator) getReverseFrontTemplate() string {
- return `<div class="front">
-<div class="bulgarian">{{Bulgarian}}</div>
-{{#Audio}}
-{{Audio}}
-{{/Audio}}
-</div>`
-}
-
-// getReverseBackTemplate returns the answer template for the reverse card
-func (g *APKGGenerator) getReverseBackTemplate() string {
- return `{{FrontSide}}
-
-<hr id="answer">
-
-<div class="back">
-<div class="english">{{English}}</div>
-{{#Image}}
-<div class="image-container">
-{{Image}}
-</div>
-{{/Image}}
-{{#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;
-}
-
-.bulgarian-front {
- font-size: 32px;
- font-weight: bold;
- color: #2c3e50;
- margin: 20px 0;
-}
-
-.bulgarian-back {
- font-size: 28px;
- font-weight: bold;
- color: #27ae60;
- margin: 20px 0;
-}
-
-.audio {
- margin: 15px 0;
+ return g.schemer.CreateDatabase(dbPath, g, g.templates)
}
-.notes {
- font-size: 16px;
- color: #7f8c8d;
- margin-top: 20px;
- font-style: italic;
-}
-
-hr#answer {
- margin: 30px 0;
- border: 0;
- border-top: 1px solid #ecf0f1;
-}`
-}
-
-// createBgBgNoteTypeConfig creates the note type configuration for Bulgarian-Bulgarian cards
-func (g *APKGGenerator) createBgBgNoteTypeConfig() map[string]interface{} {
- return map[string]interface{}{
- "id": g.modelIDBgBg,
- "name": "Bulgarian-Bulgarian from TotalRecall",
- "type": 0,
- "mod": time.Now().Unix(),
- "usn": -1,
- "sortf": 0,
- "did": g.deckID,
- "req": [][]interface{}{[]interface{}{0, "all", []int{0}}, []interface{}{1, "all", []int{1}}},
- "vers": []int{},
- "tags": []string{},
- "latexPre": `\documentclass[12pt]{article}
-\special{papersize=3in,5in}
-\usepackage[utf8]{inputenc}
-\usepackage{amssymb,amsmath}
-\pagestyle{empty}
-\setlength{\parindent}{0in}
-\begin{document}`,
- "latexPost": `\end{document}`,
- "flds": []map[string]interface{}{
- {
- "name": "BulgarianFront",
- "ord": 0,
- "sticky": false,
- "rtl": false,
- "font": "Arial",
- "size": 20,
- "media": []string{},
- },
- {
- "name": "BulgarianBack",
- "ord": 1,
- "sticky": false,
- "rtl": false,
- "font": "Arial",
- "size": 20,
- "media": []string{},
- },
- {
- "name": "Image",
- "ord": 2,
- "sticky": false,
- "rtl": false,
- "font": "Arial",
- "size": 20,
- "media": []string{},
- },
- {
- "name": "AudioFront",
- "ord": 3,
- "sticky": false,
- "rtl": false,
- "font": "Arial",
- "size": 20,
- "media": []string{},
- },
- {
- "name": "AudioBack",
- "ord": 4,
- "sticky": false,
- "rtl": false,
- "font": "Arial",
- "size": 20,
- "media": []string{},
- },
- {
- "name": "Notes",
- "ord": 5,
- "sticky": false,
- "rtl": false,
- "font": "Arial",
- "size": 16,
- "media": []string{},
- },
- },
- "tmpls": []map[string]interface{}{
- {
- "name": "Forward",
- "ord": 0,
- "qfmt": g.getBgBgFrontTemplate(),
- "afmt": g.getBgBgBackTemplate(),
- "did": nil,
- "bqfmt": "",
- "bafmt": "",
- },
- {
- "name": "Reverse",
- "ord": 1,
- "qfmt": g.getBgBgReverseFrontTemplate(),
- "afmt": g.getBgBgReverseBackTemplate(),
- "did": nil,
- "bqfmt": "",
- "bafmt": "",
- },
- },
- "css": g.getCSS(),
- }
-}
-
-// getBgBgFrontTemplate returns the question template for bg-bg cards
-func (g *APKGGenerator) getBgBgFrontTemplate() string {
- return `<div class="front">
-{{#Image}}
-<div class="image-container">
-{{Image}}
-</div>
-{{/Image}}
-<div class="bulgarian-front">{{BulgarianFront}}</div>
-{{#AudioFront}}
-<div class="audio">{{AudioFront}}</div>
-{{/AudioFront}}
-</div>`
-}
-
-// getBgBgBackTemplate returns the answer template for bg-bg cards
-func (g *APKGGenerator) getBgBgBackTemplate() string {
- return `{{FrontSide}}
-
-<hr id="answer">
-
-<div class="back">
-<div class="bulgarian-back">{{BulgarianBack}}</div>
-{{#AudioBack}}
-<div class="audio">{{AudioBack}}</div>
-{{/AudioBack}}
-{{#Notes}}
-<div class="notes">{{Notes}}</div>
-{{/Notes}}
-</div>`
-}
-
-// getBgBgReverseFrontTemplate returns the question template for bg-bg reverse cards
-func (g *APKGGenerator) getBgBgReverseFrontTemplate() string {
- return `<div class="front">
-<div class="bulgarian-back">{{BulgarianBack}}</div>
-{{#AudioBack}}
-{{AudioBack}}
-{{/AudioBack}}
-</div>`
-}
-
-// getBgBgReverseBackTemplate returns the answer template for bg-bg reverse cards
-func (g *APKGGenerator) getBgBgReverseBackTemplate() string {
- return `{{FrontSide}}
-
-<hr id="answer">
-
-<div class="back">
-<div class="bulgarian-front">{{BulgarianFront}}</div>
-{{#AudioFront}}
-<div class="audio">{{AudioFront}}</div>
-{{/AudioFront}}
-{{#Image}}
-<div class="image-container">
-{{Image}}
-</div>
-{{/Image}}
-{{#Notes}}
-<div class="notes">{{Notes}}</div>
-{{/Notes}}
-</div>`
-}
-
-// 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, leaving space for 2 cards per note
- noteID := now.UnixMilli() + int64(i*3)
- cardID1 := noteID + 1
- cardID2 := noteID + 2
-
- // Determine if this is a bg-bg card
- isBgBg := card.CardType == "bg-bg"
-
- imageField := ""
- if card.ImageFile != "" && fileExists(card.ImageFile) {
- cardDirID := filepath.Base(filepath.Dir(card.ImageFile))
- originalFilename := filepath.Base(card.ImageFile)
- uniqueFilename := fmt.Sprintf("%s_%s", cardDirID, originalFilename)
- if _, ok := g.mediaFiles[uniqueFilename]; ok {
- imageField = fmt.Sprintf(`<img src="%s">`, uniqueFilename)
- }
- }
-
- audioField := ""
- if card.AudioFile != "" && fileExists(card.AudioFile) {
- cardDirID := filepath.Base(filepath.Dir(card.AudioFile))
- originalFilename := filepath.Base(card.AudioFile)
- uniqueFilename := fmt.Sprintf("%s_%s", cardDirID, originalFilename)
- if _, ok := g.mediaFiles[uniqueFilename]; ok {
- audioField = fmt.Sprintf("[sound:%s]", uniqueFilename)
- }
- }
-
- audioFieldBack := ""
- if card.AudioFileBack != "" && fileExists(card.AudioFileBack) {
- cardDirID := filepath.Base(filepath.Dir(card.AudioFileBack))
- originalFilename := filepath.Base(card.AudioFileBack)
- uniqueFilename := fmt.Sprintf("%s_%s", cardDirID, originalFilename)
- if _, ok := g.mediaFiles[uniqueFilename]; ok {
- audioFieldBack = fmt.Sprintf("[sound:%s]", uniqueFilename)
- }
- }
-
- var fields string
- var modelID int64
- var guid string
-
- if isBgBg {
- // Bulgarian-Bulgarian card: BulgarianFront, BulgarianBack, Image, AudioFront, AudioBack, Notes
- fields = strings.Join([]string{
- card.Bulgarian,
- card.Translation,
- imageField,
- audioField,
- audioFieldBack,
- card.Notes,
- }, "\x1f")
- modelID = g.modelIDBgBg
- guid = fmt.Sprintf("tr_bgbg_%d_%s", now.Unix(), card.Bulgarian)
- } else {
- // English-Bulgarian card: English, Bulgarian, Image, Audio, Notes
- english := card.Translation
- if english == "" {
- english = "Translation needed"
- }
- fields = strings.Join([]string{
- english,
- card.Bulgarian,
- imageField,
- audioField,
- card.Notes,
- }, "\x1f")
- modelID = g.modelID
- 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
- 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 1 (Forward)
- cardQuery := `INSERT INTO cards VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
- _, err = db.Exec(cardQuery,
- cardID1, // id
- noteID, // nid
- g.deckID, // did
- 0, // ord (template 0)
- 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 forward card: %w", err)
- }
-
- // Insert card 2 (Reverse)
- _, err = db.Exec(cardQuery,
- cardID2, // id
- noteID, // nid
- g.deckID, // did
- 1, // ord (template 1)
- now.Unix(), // mod
- -1, // usn
- 0, // type (0=new)
- 0, // queue (0=new)
- noteID+1, // due (for new cards, this is position, should be unique)
- 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 reverse card: %w", err)
- }
- }
-
- return nil
+func (g *APKGGenerator) createZipPackage(tempDir, outputPath string) error {
+ return g.packager.CreatePackage(tempDir, outputPath)
}
// copyMediaFiles copies media files and assigns them numbers
@@ -953,7 +161,7 @@ func (g *APKGGenerator) createMediaMapping(tempDir string) error {
mapping[fmt.Sprintf("%d", num)] = filename
}
- data, err := json.Marshal(mapping)
+ data, err := marshalJSON("media", mapping)
if err != nil {
return err
}
@@ -961,59 +169,6 @@ func (g *APKGGenerator) createMediaMapping(tempDir string) error {
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 func() {
- _ = zipFile.Close()
- }()
-
- archive := zip.NewWriter(zipFile)
- defer func() {
- _ = 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 func() {
- _ = file.Close()
- }()
-
- _, err = io.Copy(writer, file)
- return err
- })
-}
-
// Helper functions
func fileExists(path string) bool {
diff --git a/internal/anki/card_template.go b/internal/anki/card_template.go
new file mode 100644
index 0000000..602f8dc
--- /dev/null
+++ b/internal/anki/card_template.go
@@ -0,0 +1,260 @@
+package anki
+
+import (
+ "embed"
+ "fmt"
+ "time"
+)
+
+//go:embed templates/*.html templates/*.css
+var embeddedCardAssets embed.FS
+
+// CardTemplate loads Anki card HTML/CSS from embedded files and builds note-type JSON for the collection.
+type CardTemplate struct {
+ enBgFront, enBgBack, enBgReverseFront, enBgReverseBack string
+ bgBgFront, bgBgBack, bgBgReverseFront, bgBgReverseBack string
+ css string
+}
+
+// NewCardTemplate reads embedded template and stylesheet files.
+func NewCardTemplate() (*CardTemplate, error) {
+ read := func(name string) (string, error) {
+ b, err := embeddedCardAssets.ReadFile("templates/" + name)
+ if err != nil {
+ return "", fmt.Errorf("read template %s: %w", name, err)
+ }
+ return string(b), nil
+ }
+ c := &CardTemplate{}
+ var err error
+ if c.enBgFront, err = read("en_bg_front.html"); err != nil {
+ return nil, err
+ }
+ if c.enBgBack, err = read("en_bg_back.html"); err != nil {
+ return nil, err
+ }
+ if c.enBgReverseFront, err = read("en_bg_reverse_front.html"); err != nil {
+ return nil, err
+ }
+ if c.enBgReverseBack, err = read("en_bg_reverse_back.html"); err != nil {
+ return nil, err
+ }
+ if c.bgBgFront, err = read("bg_bg_front.html"); err != nil {
+ return nil, err
+ }
+ if c.bgBgBack, err = read("bg_bg_back.html"); err != nil {
+ return nil, err
+ }
+ if c.bgBgReverseFront, err = read("bg_bg_reverse_front.html"); err != nil {
+ return nil, err
+ }
+ if c.bgBgReverseBack, err = read("bg_bg_reverse_back.html"); err != nil {
+ return nil, err
+ }
+ if c.css, err = read("card.css"); err != nil {
+ return nil, err
+ }
+ return c, nil
+}
+
+// MustCardTemplate returns NewCardTemplate() or panics; suitable for process startup when assets must exist.
+func MustCardTemplate() *CardTemplate {
+ t, err := NewCardTemplate()
+ if err != nil {
+ panic(err)
+ }
+ return t
+}
+
+// EnBgNoteTypeConfig builds the English–Bulgarian note type map for Anki's models JSON.
+func (c *CardTemplate) EnBgNoteTypeConfig(modelID, deckID int64) map[string]interface{} {
+ return map[string]interface{}{
+ "id": modelID,
+ "name": "Vocabulary from TotalRecall (Basic + Reverse)",
+ "type": 0,
+ "mod": time.Now().Unix(),
+ "usn": -1,
+ "sortf": 0,
+ "did": deckID,
+ "req": [][]interface{}{[]interface{}{0, "all", []int{0}}, []interface{}{1, "all", []int{1}}},
+ "vers": []int{},
+ "tags": []string{},
+ "latexPre": `\documentclass[12pt]{article}
+\special{papersize=3in,5in}
+\usepackage[utf8]{inputenc}
+\usepackage{amssymb,amsmath}
+\pagestyle{empty}
+\setlength{\parindent}{0in}
+\begin{document}`,
+ "latexPost": `\end{document}`,
+ "flds": []map[string]interface{}{
+ {
+ "name": "English",
+ "ord": 0,
+ "sticky": false,
+ "rtl": false,
+ "font": "Arial",
+ "size": 20,
+ "media": []string{},
+ },
+ {
+ "name": "Bulgarian",
+ "ord": 1,
+ "sticky": false,
+ "rtl": false,
+ "font": "Arial",
+ "size": 20,
+ "media": []string{},
+ },
+ {
+ "name": "Image",
+ "ord": 2,
+ "sticky": false,
+ "rtl": false,
+ "font": "Arial",
+ "size": 20,
+ "media": []string{},
+ },
+ {
+ "name": "Audio",
+ "ord": 3,
+ "sticky": false,
+ "rtl": false,
+ "font": "Arial",
+ "size": 20,
+ "media": []string{},
+ },
+ {
+ "name": "Notes",
+ "ord": 4,
+ "sticky": false,
+ "rtl": false,
+ "font": "Arial",
+ "size": 16,
+ "media": []string{},
+ },
+ },
+ "tmpls": []map[string]interface{}{
+ {
+ "name": "Forward",
+ "ord": 0,
+ "qfmt": c.enBgFront,
+ "afmt": c.enBgBack,
+ "did": nil,
+ "bqfmt": "",
+ "bafmt": "",
+ },
+ {
+ "name": "Reverse",
+ "ord": 1,
+ "qfmt": c.enBgReverseFront,
+ "afmt": c.enBgReverseBack,
+ "did": nil,
+ "bqfmt": "",
+ "bafmt": "",
+ },
+ },
+ "css": c.css,
+ }
+}
+
+// BgBgNoteTypeConfig builds the Bulgarian–Bulgarian note type map for Anki's models JSON.
+func (c *CardTemplate) BgBgNoteTypeConfig(modelIDBgBg, deckID int64) map[string]interface{} {
+ return map[string]interface{}{
+ "id": modelIDBgBg,
+ "name": "Bulgarian-Bulgarian from TotalRecall",
+ "type": 0,
+ "mod": time.Now().Unix(),
+ "usn": -1,
+ "sortf": 0,
+ "did": deckID,
+ "req": [][]interface{}{[]interface{}{0, "all", []int{0}}, []interface{}{1, "all", []int{1}}},
+ "vers": []int{},
+ "tags": []string{},
+ "latexPre": `\documentclass[12pt]{article}
+\special{papersize=3in,5in}
+\usepackage[utf8]{inputenc}
+\usepackage{amssymb,amsmath}
+\pagestyle{empty}
+\setlength{\parindent}{0in}
+\begin{document}`,
+ "latexPost": `\end{document}`,
+ "flds": []map[string]interface{}{
+ {
+ "name": "BulgarianFront",
+ "ord": 0,
+ "sticky": false,
+ "rtl": false,
+ "font": "Arial",
+ "size": 20,
+ "media": []string{},
+ },
+ {
+ "name": "BulgarianBack",
+ "ord": 1,
+ "sticky": false,
+ "rtl": false,
+ "font": "Arial",
+ "size": 20,
+ "media": []string{},
+ },
+ {
+ "name": "Image",
+ "ord": 2,
+ "sticky": false,
+ "rtl": false,
+ "font": "Arial",
+ "size": 20,
+ "media": []string{},
+ },
+ {
+ "name": "AudioFront",
+ "ord": 3,
+ "sticky": false,
+ "rtl": false,
+ "font": "Arial",
+ "size": 20,
+ "media": []string{},
+ },<