diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-08 10:09:01 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-08 10:09:01 +0300 |
| commit | 467e0c0e7d9849f83ebff78f10d1de04445c1852 (patch) | |
| tree | f1c2a30c6489df621b5f735be6fe3b9f64d21e02 /internal | |
| parent | acec1e0668084715dc1e981b11b1562243283f58 (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
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/anki/apkg_generator.go | 867 | ||||
| -rw-r--r-- | internal/anki/card_template.go | 260 | ||||
| -rw-r--r-- | internal/anki/sqlite_schemer.go | 416 | ||||
| -rw-r--r-- | internal/anki/templates/bg_bg_back.html | 13 | ||||
| -rw-r--r-- | internal/anki/templates/bg_bg_front.html | 11 | ||||
| -rw-r--r-- | internal/anki/templates/bg_bg_reverse_back.html | 18 | ||||
| -rw-r--r-- | internal/anki/templates/bg_bg_reverse_front.html | 6 | ||||
| -rw-r--r-- | internal/anki/templates/card.css | 68 | ||||
| -rw-r--r-- | internal/anki/templates/en_bg_back.html | 13 | ||||
| -rw-r--r-- | internal/anki/templates/en_bg_front.html | 8 | ||||
| -rw-r--r-- | internal/anki/templates/en_bg_reverse_back.html | 15 | ||||
| -rw-r--r-- | internal/anki/templates/en_bg_reverse_front.html | 6 | ||||
| -rw-r--r-- | internal/anki/zip_packager.go | 63 |
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" |
