import tempfile
from fastcore.test import *
from anki.collection import Collection as AnkiCollection
import anki.lang
from anki.utils import field_checksum as anki_csum
from unittest.mock import patch as mock_patchCollections
Collection adds, finds, updates and removes notes in an Anki sqlite file. It generates cards from the notes’ templates and records changes for AnkiWeb sync. fastanki.schema handles the file format.
Text utilities
Anki detects duplicate notes using a checksum of the first field. It strips HTML before computing the checksum but keeps media filenames. For example, <img src=cat.jpg> has the same checksum as the plain-text string ' cat.jpg '.
A note’s GUID identifies it across collections. guid64 creates a random GUID in Anki’s base91 encoding.
field_csum
def field_csum(
text:str
):Anki’s dupe-detection checksum: first 4 bytes of the sha1 of the stripped first field
strip_html_media
def strip_html_media(
s:str
):Strip HTML from s, preserving media filenames, matching Anki’s checksum preprocessing
Compare checksums with Anki:
anki.lang.set_lang('en') # pylib's checksum helper strips HTML through a backend
for s in ['hello', 'dos', '<b>test</b>', 'a & b', '<img src=cat.jpg>', "<img src='dog gif.gif'>x",
'[sound:beep.mp3]', 'caf\xe9 au lait', '<style>p {}</style>text']:
test_eq(field_csum(s), anki_csum(s))test_eq(field_csum('hello'), 2868168221)
g = guid64()
assert 1 <= len(g) <= 11 and set(g) <= set(map(chr, _B91))
g'_>o$MB$jHA'
Opening a collection
fastanki uses its own collection, separate from any desktop Anki profile. It doesn’t need desktop Anki installed. Keeping separate files avoids competing for sqlite locks. The desktop sees our changes the same way your phone does, through AnkiWeb sync.
Pending changes have usn=-1. _dirty updates col.mod to tell sync the collection has changed.
td = Path(tempfile.mkdtemp())
col = Collection.open(td/'collection.anki2')
test_eq(col.q1('select ver from col'), 18)Notetypes
NT reads a notetype’s field names, sort field, kind and templates from the database.
NT
def NT(
con, ntid, name
):A notetype’s structure: fields, templates, and card generation rules
col.nt('Basic')NT('Basic', flds=['Front', 'Back'], 1 templates)
Notetype lookup is case-insensitive:
test_eq(col.nt('basic').id, col.nt('Basic').id)
test_eq(col.notetypes(), ['Basic','Cloze'])Decks
Use :: to separate levels in a deck name. Adding Spanish::Vocab also creates Spanish. Anki stores these as separate rows with \x1f in place of ::.
Collection.deck_id
def deck_id(
name, create:bool=False
):Id of deck name (::-separated), optionally creating it (and any missing parents)
did = col.deck_id('Spanish::Vocab', create=True)
test_eq(col.decks(), ['Default','Spanish','Spanish::Vocab'])
test_eq(col.deck_id('Spanish::Vocab'), did)
test_is(col.deck_id('Nope'), None)Adding notes
Note gives you field access by name, such as note['Front']. Collection.add saves a note and generates its cards.
For normal notetypes, a template generates a card when its front displays a non-empty field. Static text alone doesn’t count. renders_with_fields checks field substitutions and conditional sections in the front template. It ignores comments and doesn’t use the stored req cache.
field_is_empty treats whitespace and empty <br> and <div> tags as empty content.
renders_with_fields
def renders_with_fields(
qfmt, nonempty
):Would front template qfmt render non-empty given the set of nonempty field names?
note_cards
def note_cards(
nt, fields, tags:tuple=()
):Template ordinals that generate cards for a note’s fields (values in field order) and tags, using Anki’s render rule
Cloze notetypes generate a card for each distinct cloze number across the fields. note_cards returns zero-based card ordinals: {c1::...} gives ordinal 0.
bnt = col.nt('Basic')
test_eq(note_cards(bnt, ['hi','']), [0])
test_eq(note_cards(bnt, ['','back only']), [])
cnt = col.nt('Cloze')
test_eq(note_cards(cnt, ['{{c1::a}} and {{c3::b}}','']), [0,2])
test_eq(note_cards(cnt, ['no clozes here','']), [])Compare card generation with Anki for reversed and optional-reversed notetypes:
gp = Path(tempfile.mkdtemp())/'gen.anki2'
aoc = AnkiCollection(str(gp))
want = []
for mname, flds in [('Basic (and reversed card)', dict(Front='q', Back='a')),
('Basic (and reversed card)', dict(Front='q', Back='')),
('Basic (optional reversed card)', dict(Front='q', Back='a')),
('Basic (optional reversed card)', dict(Front='q', Back='a', **{'Add Reverse':'y'}))]:
n = aoc.new_note(aoc.models.by_name(mname))
for k,v in flds.items(): n[k] = v
aoc.add_note(n, 1)
want.append((mname, flds, sorted(c.ord for c in n.cards())))
aoc.close()
fc = Collection(gp)
for mname, flds, ords in want: test_eq(note_cards(fc.nt(mname), [flds.get(f,'') for f in fc.nt(mname).flds]), ords)
fc.close()For a new card, due is its queue position, not a date. add allocates positions from the collection’s nextPos counter.
Collection.add
def add(
model:str='Basic', deck:str='Default', tags:NoneType=None, **fields
):Add a note (and its cards), returning the Note
n = col.add(Front='hola', Back='hello', deck='Spanish::Vocab', tags='spanish')
nFront: hola | Back: hello | 🏷 spanish
test_eq(n['Front'], 'hola')
test_eq(n.tags, ['spanish'])
test_eq(col.q1('select count(*) from cards where nid=?', n.id), 1)
test_eq(col.q1('select due from cards where nid=?', n.id), 1)
cz = col.add(model='Cloze', Text='{{c1::uno}} y {{c2::dos}}')
test_eq(col.q('select ord, due from cards where nid=? order by ord', cz.id), [(0,2),(1,3)])Updating and removing
update_note saves field and tag changes and generates any missing cards. It never deletes existing cards.
Deletions go in graves for sync. The record types are 0 for cards, 1 for notes and 2 for decks.
Collection.update_note
def update_note(
note, tags:NoneType=None, add_tags:NoneType=None, **fields
):Update fields and/or tags of note (a Note or note id), returning the updated Note
n2 = col.update_note(n, Back='hello!', add_tags='greeting')
test_eq(n2['Back'], 'hello!')
test_eq(n2.tags, ['spanish','greeting'])
cz2 = col.update_note(cz.id, Text='{{c1::uno}} {{c2::dos}} {{c3::tres}}')
test_eq(col.q1('select count(*) from cards where nid=?', cz.id), 3)Collection.remove_deck
def remove_deck(
name
):Remove deck name and its subdecks, with their cards and notes
Collection.remove_notes
def remove_notes(
nids
):Remove notes (and their cards) by id, recording graves for sync
remove_deck deletes every note with a card in the deck or its subdecks. This also deletes the note’s cards in other decks. Anki keeps notes that still have cards elsewhere. Only Collection exposes deck removal.
col.add(Front='bye', deck='Doomed')
test_eq(col.remove_deck('Doomed'), 1)
test_is(col.deck_id('Doomed'), None)
test_eq(col.q1("select count(*) from notes where flds like 'bye%'"), 0)
test_eq(col.q1("select count(*) from graves where type=2"), 1)Finding
Use keyword arguments to find notes:
deck,tagandadded_daysfilter by deck, tag and age.- Field names, such as
Front, match a case-insensitive substring of the field’s value. whereandargsadd a SQL condition and its parameters. Usenfor thenotestable andcfor the joinedcardstable.
Results must match all the filters. A deck filter includes its subdecks.
Collection.find_notes
def find_notes(
deck:NoneType=None, tag:NoneType=None, added_days:NoneType=None, where:NoneType=None, args:tuple=(), **fields
):Notes matching all given criteria; field kwargs are case-insensitive substring matches
test_eq(col.find_note_ids(deck='Spanish'), [n.id])
test_eq(col.find_note_ids(tag='greeting'), [n.id])
test_eq([x.id for x in col.find_notes(Front='HOLA')], [n.id])
test_eq(col.find_notes(Front='nope'), [])
test_eq(len(col.find_notes()), 2)
test_eq(col.find_note_ids(where='n.id=?', args=[cz.id]), [cz.id])find_cards accepts the same filters and returns Card objects with scheduling fields. Set is_due=True to select learning and review cards due for study.
Collection.find_cards
def find_cards(
deck:NoneType=None, tag:NoneType=None, added_days:NoneType=None, is_due:NoneType=None, where:NoneType=None,
args:tuple=(), **fields
):Cards matching all given criteria (same keywords as find_notes, plus is_due)
cards = col.find_cards(deck='Spanish')
test_eq(len(cards), 1)
cards[0]Card 1784759309663 (nid: 1784759309663, due: 1, ivl: 0d, queue: 0)
is_due=True excludes new cards:
test_eq(col.find_cards(is_due=True), [])
test_eq(len(col.find_card_ids()), 4)Due counts
Collection.due_counts
def due_counts(
deck:NoneType=None
):(new, learning, review) counts for deck and its subdecks (or the whole collection)
test_eq(col.due_counts(), (4,0,0))
test_eq(col.due_counts('Spanish'), (1,0,0))Checking compatibility with Anki
Check that Anki can open and search this collection, then read an Anki-created note back with fastanki.
col.close()
ac = AnkiCollection(str(td/'collection.anki2'))
report, ok = ac.fix_integrity()
test_eq(ok, True)test_eq(ac.find_notes('deck:Spanish::Vocab'), [n.id])
test_eq(ac.find_notes('tag:greeting'), [n.id])
test_eq(sorted(ac.find_notes('')), sorted([n.id, cz.id]))
anote = ac.get_note(cz.id)
test_eq(anote['Text'], '{{c1::uno}} {{c2::dos}} {{c3::tres}}')theirs = ac.new_note(ac.models.by_name('Basic'))
theirs['Front'] = 'from anki'
ac.add_note(theirs, 1)
ac.close()
col = Collection.open(td/'collection.anki2')
test_eq(col.find_note_ids(Front='from anki'), [theirs.id])
col.close()