| | | | | linguist.page@gmail.com

Why not just build the deck by hand in Anki

Anki’s own editor is fine for a deck of twenty cards. It stops being fine once the content is bilingual grammar cards for something like Matn al-Ājurrūmiyyah, or a growing set of French/Arabic vocabulary — at that point every card has multiple fields (Arabic text, transliteration, translation, grammatical note, audio reference), and hand-entering them in Anki’s GUI is slow and error-prone in exactly the way that repetitive data entry always is: a field goes in the wrong slot on card 40 and nobody notices until review.

genanki is a Python library that builds .apkg files programmatically — decks, note types, and cards defined in code instead of clicked together in a UI. The cards still live in Anki once generated; only the authoring step changes.

The schema comes first

Before touching genanki, the actual design decision is the YAML schema the cards will be authored in. A flat list of question/answer pairs works for simple vocab but falls apart for grammar cards that need multiple fields:

deck_name: "Ājurrūmiyyah - Grammar"
note_type: "Bilingual Grammar"
cards:
  - arabic: "الكَلاَمُ"
    transliteration: "al-kalām"
    translation: "speech / a complete sentence"
    note: "Defined as an utterance conveying complete meaning"
    tags: ["ajurrumiyyah", "chapter-1"]
  - arabic: "مَرْفُوعٌ"
    transliteration: "marfūʿ"
    translation: "in the nominative case"
    note: "One of the four grammatical states (i'rab)"
    tags: ["ajurrumiyyah", "chapter-2", "irab"]

Keeping this as plain YAML rather than inventing a custom format means the content is reviewable and editable without any tooling at all — a text editor is sufficient to add or fix a card.

Defining the note type in genanki

genanki requires two things: a Model (Anki’s note type — the fields and card templates) and a Deck (the actual container). The model is defined once and reused across every deck of the same kind:

import genanki

GRAMMAR_MODEL = genanki.Model(
    1607392319,  # arbitrary but must stay constant across regenerations
    'Bilingual Grammar',
    fields=[
        {'name': 'Arabic'},
        {'name': 'Transliteration'},
        {'name': 'Translation'},
        {'name': 'Note'},
    ],
    templates=[
        {
            'name': 'Card 1',
            'qfmt': '<div class="arabic">{{Arabic}}</div>',
            'afmt': '''{{FrontSide}}
                <hr id="answer">
                <div class="translit">{{Transliteration}}</div>
                <div class="translation">{{Translation}}</div>
                <div class="note">{{Note}}</div>''',
        },
    ],
    css='.arabic { font-size: 32px; direction: rtl; text-align: right; }'
)

The model ID (1607392319 above) is the detail most likely to cause quiet problems later — it has to stay the same every time the deck is regenerated, or Anki treats it as an entirely new note type on the next import and duplicates every card instead of updating them. Picking a random constant once and hardcoding it, rather than generating a fresh one per run, is what makes the pipeline idempotent.

Generating the deck

With the model and schema in place, the generator script is mostly a loop:

import yaml
import genanki

def build_deck(yaml_path, output_path):
    with open(yaml_path) as f:
        data = yaml.safe_load(f)

    deck = genanki.Deck(
        2059400110,  # also must stay constant
        data['deck_name']
    )

    for card in data['cards']:
        note = genanki.Note(
            model=GRAMMAR_MODEL,
            fields=[
                card['arabic'],
                card['transliteration'],
                card['translation'],
                card.get('note', ''),
            ],
            tags=card.get('tags', [])
        )
        deck.add_note(note)

    genanki.Package(deck).write_to_file(output_path)

Running this against the YAML file above produces a .apkg ready to import into Anki or sync via AnkiWeb — no manual card entry involved, and re-running it after editing the YAML regenerates the whole deck cleanly because both IDs are pinned.

Where AnkiDroid import broke, and why

The desktop Anki import worked without issue; AnkiDroid was the one that surfaced problems, and both traced back to the same root cause: AnkiDroid’s importer is stricter about a couple of things the desktop client tolerates.

package = genanki.Package(deck)
package.media_files = ['audio/intro.mp3', 'audio/card2.mp3']
package.write_to_file(output_path)
'qfmt': '<div class="arabic" dir="rtl">{{Arabic}}</div>',

Neither of these surfaced in desktop testing, which is the general lesson here: if a deck is going to be used on AnkiDroid at all, it has to actually be tested on AnkiDroid before considering the pipeline done — desktop Anki’s renderer is meaningfully more forgiving than the mobile one.

Why this is worth the setup cost

For a deck of a few dozen cards, hand-entry in Anki’s editor is genuinely faster than writing a genanki pipeline. The pipeline earns its cost at the point where the deck needs to be regenerated — a translation gets corrected, a new chapter of cards gets added, a template styling change needs to apply to every existing card. At that point, editing one YAML file and one CSS string and re-running the script beats manually re-editing dozens of individual notes in the GUI, and the model/deck ID pinning means regeneration updates in place instead of duplicating.