Collections

Notes, cards, decks and search, straight to the sqlite file

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.

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_patch

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.


source

guid64

def guid64():

A new random note guid, in Anki’s base91 encoding


source

field_csum

def field_csum(
    text:str
):

Anki’s dupe-detection checksum: first 4 bytes of the sha1 of the stripped first field


source

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 &amp; b', '<img src=cat.jpg>', "<img src='dog gif.gif'>x",
          '[sound:beep.mp3]', 'caf\xe9&nbsp;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.


source

Collection

def Collection(
    path
):

source

data_dir

def data_dir():

fastanki’s data folder (override with FASTANKI_DIR)

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.


source

Collection.notetypes

def notetypes():

source

Collection.nt

def nt(
    name
):

Notetype structure by name (case-insensitive)


source

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 ::.


source

Collection.decks

def decks():

source

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.


source

renders_with_fields

def renders_with_fields(
    qfmt, nonempty
):

Would front template qfmt render non-empty given the set of nonempty field names?


source

field_is_empty

def field_is_empty(
    s
):

source

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


source

Note

def Note(
    id, guid, mid, mod, usn, tags, flds, nt
):

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.


source

Collection.add

def add(
    model:str='Basic', deck:str='Default', tags:NoneType=None, **fields
):

Add a note (and its cards), returning the Note


source

Collection.get_note

def get_note(
    nid
):

Retrieve a Note by id

n = col.add(Front='hola', Back='hello', deck='Spanish::Vocab', tags='spanish')
n

Front: 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.


source

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)

source

Collection.remove_deck

def remove_deck(
    name
):

Remove deck name and its subdecks, with their cards and notes


source

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, tag and added_days filter by deck, tag and age.
  • Field names, such as Front, match a case-insensitive substring of the field’s value.
  • where and args add a SQL condition and its parameters. Use n for the notes table and c for the joined cards table.

Results must match all the filters. A deck filter includes its subdecks.


source

Collection.find_note_ids

def find_note_ids(
    **kw
):

source

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.


source

Collection.find_card_ids

def find_card_ids(
    **kw
):

source

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)


source

Collection.today

def today():

Days since collection creation, Anki’s day counter


source

Card

def Card(
    id, nid, did, ord, mod, usn, type, queue, due, ivl
):
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


source

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()