summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/anki/apkg_generator.go30
-rw-r--r--internal/anki/sqlite_schemer.go159
-rw-r--r--internal/version.go2
3 files changed, 116 insertions, 75 deletions
diff --git a/internal/anki/apkg_generator.go b/internal/anki/apkg_generator.go
index ddd5aa2..83c1a4b 100644
--- a/internal/anki/apkg_generator.go
+++ b/internal/anki/apkg_generator.go
@@ -1,6 +1,8 @@
package anki
import (
+ "crypto/sha1"
+ "encoding/binary"
"fmt"
"io"
"os"
@@ -24,15 +26,17 @@ type APKGGenerator struct {
templates *CardTemplate
}
-// NewAPKGGenerator creates a new APKG generator
+// NewAPKGGenerator creates a new APKG generator.
+// Deck and model IDs are derived deterministically from deckName so that
+// re-exporting produces the same IDs and Anki merges cards instead of
+// creating duplicate notetypes/decks.
func NewAPKGGenerator(deckName string) *APKGGenerator {
- // Generate IDs based on timestamp to ensure uniqueness
- now := time.Now().UnixMilli()
+ deckID := stableID(deckName)
return &APKGGenerator{
deckName: deckName,
- deckID: now,
- modelID: now + 1,
- modelIDBgBg: now + 2,
+ deckID: deckID,
+ modelID: stableID(deckName + "/model/en-bg"),
+ modelIDBgBg: stableID(deckName + "/model/bg-bg"),
cards: make([]Card, 0),
mediaFiles: make(map[string]int),
mediaCounter: 0,
@@ -42,6 +46,20 @@ func NewAPKGGenerator(deckName string) *APKGGenerator {
}
}
+// stableID produces a stable positive int64 from a seed string.
+// The value looks like a millisecond timestamp (13 digits) so Anki treats it
+// the same way it treats its own generated IDs.
+func stableID(seed string) int64 {
+ h := sha1.Sum([]byte(seed))
+ v := int64(binary.BigEndian.Uint64(h[:8]))
+ if v < 0 {
+ v = -v
+ }
+ // Clamp to 13 digits (1e12..9.999e12) so it resembles a millis timestamp
+ const lo, hi = 1_000_000_000_000, 9_999_999_999_999
+ return lo + v%(hi-lo+1)
+}
+
// AddCard adds a card to the generator
func (g *APKGGenerator) AddCard(card Card) {
g.cards = append(g.cards, card)
diff --git a/internal/anki/sqlite_schemer.go b/internal/anki/sqlite_schemer.go
index d373bbf..2c61673 100644
--- a/internal/anki/sqlite_schemer.go
+++ b/internal/anki/sqlite_schemer.go
@@ -1,9 +1,12 @@
package anki
import (
+ "crypto/sha1"
"database/sql"
+ "encoding/binary"
"encoding/json"
"fmt"
+ "math/rand"
"path/filepath"
"strings"
"time"
@@ -283,35 +286,15 @@ func (s *SQLiteSchemer) insertNotesAndCards(db *sql.DB, g *APKGGenerator) error
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)
- }
- }
+ imageField := buildMediaField(card.ImageFile, g.mediaFiles, func(name string) string {
+ return fmt.Sprintf(`<img src="%s">`, name)
+ })
+ audioField := buildMediaField(card.AudioFile, g.mediaFiles, func(name string) string {
+ return fmt.Sprintf("[sound:%s]", name)
+ })
+ audioFieldBack := buildMediaField(card.AudioFileBack, g.mediaFiles, func(name string) string {
+ return fmt.Sprintf("[sound:%s]", name)
+ })
var fields string
var modelID int64
@@ -327,7 +310,7 @@ func (s *SQLiteSchemer) insertNotesAndCards(db *sql.DB, g *APKGGenerator) error
card.Notes,
}, "\x1f")
modelID = g.modelIDBgBg
- guid = fmt.Sprintf("tr_bgbg_%d_%s", now.Unix(), card.Bulgarian)
+ guid = ankiGUID(fmt.Sprintf("tr_bgbg_%s", card.Bulgarian))
} else {
english := card.Translation
if english == "" {
@@ -341,9 +324,11 @@ func (s *SQLiteSchemer) insertNotesAndCards(db *sql.DB, g *APKGGenerator) error
card.Notes,
}, "\x1f")
modelID = g.modelID
- guid = fmt.Sprintf("tr_%d_%s", now.Unix(), card.Bulgarian)
+ guid = ankiGUID(fmt.Sprintf("tr_%s", card.Bulgarian))
}
+ csum := fieldChecksum(card.Bulgarian)
+
noteQuery := `INSERT INTO notes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := db.Exec(noteQuery,
noteID,
@@ -354,7 +339,7 @@ func (s *SQLiteSchemer) insertNotesAndCards(db *sql.DB, g *APKGGenerator) error
"",
fields,
card.Bulgarian,
- 0,
+ csum,
0,
"",
)
@@ -362,50 +347,34 @@ func (s *SQLiteSchemer) insertNotesAndCards(db *sql.DB, g *APKGGenerator) error
return fmt.Errorf("failed to insert note: %w", err)
}
+ // For new cards (type=0), due is the position in the new-card queue
+ dueForward := i * 2
+ dueReverse := i*2 + 1
+
cardQuery := `INSERT INTO cards VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err = db.Exec(cardQuery,
- cardID1,
- noteID,
- g.deckID,
- 0,
- now.Unix(),
- -1,
- 0,
- 0,
- noteID,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- "",
+ cardID1, noteID, g.deckID,
+ 0, // ord
+ now.Unix(), // mod
+ -1, // usn
+ 0, // type (new)
+ 0, // queue (new)
+ dueForward, // due (position in new queue)
+ 0, 0, 0, 0, 0, 0, 0, 0, "",
)
if err != nil {
return fmt.Errorf("failed to insert forward card: %w", err)
}
_, err = db.Exec(cardQuery,
- cardID2,
- noteID,
- g.deckID,
- 1,
- now.Unix(),
- -1,
- 0,
- 0,
- noteID+1,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- 0,
- "",
+ cardID2, noteID, g.deckID,
+ 1, // ord
+ now.Unix(), // mod
+ -1, // usn
+ 0, // type (new)
+ 0, // queue (new)
+ dueReverse, // due (position in new queue)
+ 0, 0, 0, 0, 0, 0, 0, 0, "",
)
if err != nil {
return fmt.Errorf("failed to insert reverse card: %w", err)
@@ -414,3 +383,57 @@ func (s *SQLiteSchemer) insertNotesAndCards(db *sql.DB, g *APKGGenerator) error
return nil
}
+
+// buildMediaField resolves a media file path to an Anki field string using the formatter.
+func buildMediaField(filePath string, mediaFiles map[string]int, formatter func(string) string) string {
+ if filePath == "" || !fileExists(filePath) {
+ return ""
+ }
+ cardDirID := filepath.Base(filepath.Dir(filePath))
+ originalFilename := filepath.Base(filePath)
+ uniqueFilename := fmt.Sprintf("%s_%s", cardDirID, originalFilename)
+ if _, ok := mediaFiles[uniqueFilename]; ok {
+ return formatter(uniqueFilename)
+ }
+ return ""
+}
+
+// fieldChecksum computes Anki's csum: first 4 bytes of SHA-1 of the sort field, as uint32.
+func fieldChecksum(sortField string) uint32 {
+ h := sha1.Sum([]byte(sortField))
+ return binary.BigEndian.Uint32(h[:4])
+}
+
+// ankiGUID generates a short, stable, base91-style GUID from a seed string.
+// Anki expects GUIDs to be ~10 characters. We hash the seed for stability
+// (same word always produces the same GUID) and encode as base91.
+func ankiGUID(seed string) string {
+ h := sha1.Sum([]byte(seed))
+ return base91Encode(h[:8])
+}
+
+// base91Encode encodes bytes into Anki's base91 character set (same as Anki's Python implementation).
+func base91Encode(data []byte) string {
+ const table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" +
+ "!#$%&()*+,-./:;<=>?@[]^_`{|}~"
+ // Use a simple encoding: convert to a big random-ish number via the hash bytes
+ // and repeatedly divide by 91
+ var result []byte
+ // Treat data as a big-endian unsigned integer
+ val := uint64(0)
+ for _, b := range data {
+ val = val*256 + uint64(b)
+ }
+ for val > 0 {
+ result = append(result, table[val%91])
+ val /= 91
+ }
+ if len(result) == 0 {
+ // Fallback: generate a random GUID
+ r := rand.New(rand.NewSource(time.Now().UnixNano()))
+ for i := 0; i < 10; i++ {
+ result = append(result, table[r.Intn(91)])
+ }
+ }
+ return string(result)
+}
diff --git a/internal/version.go b/internal/version.go
index 409abe0..43a9227 100644
--- a/internal/version.go
+++ b/internal/version.go
@@ -1,3 +1,3 @@
package internal
-const Version = "0.29.0"
+const Version = "0.29.1"