Core API

One-call functions over the pure-python engine

Each call opens the default collection, does its work, and closes up, so there’s no session state to manage. fb variants take plain front/back strings and scalar ids.

from fastcore.utils import *
from fastanki.schema import *
from fastanki.collection import *
from fastanki.syncer import *
import tempfile
from fastcore.test import *
os.environ['FASTANKI_DIR'] = tempfile.mkdtemp()

This notebook runs against a throwaway data folder; without that override, everything below uses ~/.fastanki.

Cards and notes

def add_card(
    model:str='Basic', # Notetype name (the tool description lists each notetype's fields)
    deck:str='Default', # Deck name (`::` for nesting; created if needed)
    tags:str=None, # Space-separated tags
    fields:dict=None, # Field name -> value, e.g. {'Front':'2+2', 'Back':'4'}
):
    "Add a card of any notetype, returning the new note id. Field names must match the notetype."
    with Collection.open() as col:
        return col.add(model=model, deck=deck, tags=tags.split() if tags else None, **(fields or {})).id

def add_fb_card(
    front:str, # Front (question) text
    back:str, # Back (answer) text
    deck:str='Default', # Deck name (`::` for nesting; created if needed)
    tags:str=None, # Space-separated tags
):
    "Add a Basic card, returning the new note id."
    return add_card(deck=deck, tags=tags, fields={'Front':front, 'Back':back})

def add_cloze_card(
    text:str, # Cloze text with `{c1::hidden}` deletions
    back_extra:str='', # Extra info shown on the back of every card
    deck:str='Default', # Deck name (`::` for nesting; created if needed)
    tags:str=None, # Space-separated tags
):
    "Add a Cloze card (`{c1::hidden}` syntax), returning the new note id."
    return add_card(model='Cloze', deck=deck, tags=tags, fields={'Text':text, 'Back Extra':back_extra})
nid = add_fb_card('What is the capital of France?', 'Paris', tags='geo')
note = add_card(deck='Spanish::Vocab', tags='spanish', fields={'Front':'hola','Back':'hello'})
note
1783999334909
czid = add_cloze_card('Minus times {{c1::minus}} is {{c2::plus}}', tags='maths')
test_eq(type(czid), int)

Finding, updating, removing

def find_notes(deck:str=None, # Deck name (matches subdecks too)
               tag:str=None, # Tag to match
               added_days:int=None, # Only notes added in the last this-many days
               fields:dict=None): # Field name -> case-insensitive substring, e.g. {'Front':'hello'}
    "Notes matching all given criteria."
    with Collection.open() as col: return col.find_notes(deck=deck, tag=tag, added_days=added_days, **(fields or {}))

def find_note_ids(deck:str=None, # Deck name (matches subdecks too)
                  tag:str=None, # Tag to match
                  added_days:int=None, # Only notes added in the last this-many days
                  fields:dict=None): # Field name -> case-insensitive substring, e.g. {'Front':'hello'}
    "Ids of notes matching all given criteria."
    return [n.id for n in find_notes(deck=deck, tag=tag, added_days=added_days, fields=fields)]
def find_cards(deck:str=None, # Deck name (matches subdecks too)
               tag:str=None, # Tag to match
               added_days:int=None, # Only cards added in the last this-many days
               is_due:bool=None, # Only cards due for review
               fields:dict=None): # Field name -> case-insensitive substring, e.g. {'Front':'hello'}
    "Cards matching all given criteria."
    with Collection.open() as col: return col.find_cards(deck=deck, tag=tag, added_days=added_days, is_due=is_due, **(fields or {}))

def find_card_ids(deck:str=None, # Deck name (matches subdecks too)
                  tag:str=None, # Tag to match
                  added_days:int=None, # Only cards added in the last this-many days
                  is_due:bool=None, # Only cards due for review
                  fields:dict=None): # Field name -> case-insensitive substring, e.g. {'Front':'hello'}
    "Ids of cards matching all given criteria."
    return [c.id for c in find_cards(deck=deck, tag=tag, added_days=added_days, is_due=is_due, fields=fields)]
def get_note(
    note_id:int, # Id of the note to retrieve
):
    "Retrieve a note by id."
    with Collection.open() as col: return col.get_note(note_id)
test_eq(find_note_ids(tag='geo'), [nid])
test_eq(find_note_ids(deck='Spanish'), [note])
test_eq(find_note_ids(fields={'Front':'capital'}), [nid])
test_eq(len(find_cards()), 4)
get_note(nid)

Front: What is the capital of France? | Back: Paris | 🏷 geo

def update_note(note, tags=None, add_tags=None, **fields):
    "Update fields and/or tags of a `Note` or note id; `tags` replaces, `add_tags` appends."
    with Collection.open() as col: return col.update_note(note, tags=tags, add_tags=add_tags, **fields)

def update_fb_note(
    note_id:int, # Id of the Basic note to update
    front:str='', # New Front text (empty leaves it unchanged)
    back:str='', # New Back text (empty leaves it unchanged)
    tags:str=None, # Space-separated tags, replacing all existing tags
    add_tags:str=None, # Space-separated tags to add, keeping existing ones
):
    "Update a Basic note's front/back and/or tags."
    kw = {}
    if front: kw['Front'] = front
    if back: kw['Back'] = back
    if tags: tags = tags.split()
    if add_tags: add_tags = add_tags.split()
    return update_note(note_id, tags=tags, add_tags=add_tags, **kw)

def del_note(
    notes:list, # Note ids (or `Note` objects) to delete, along with their cards
):
    "Delete note(s) (and their cards) by `Note` or id, singly or in a list."
    with Collection.open() as col: return col.remove_notes(notes)
n2 = update_note(note, Back='hello!', add_tags='greeting')
test_eq(n2['Back'], 'hello!')
test_eq(n2.tags, ['spanish','greeting'])
test_eq(update_fb_note(nid, back='Paris, France').fields['Back'], 'Paris, France')
test_eq(del_note([nid, czid]), 2)
test_eq(find_note_ids(tag='geo'), [])

Syncing

def sync(
    user:str=None, # AnkiWeb email (only needed the first time)
    passw:str=None, # AnkiWeb password (only the first time; a host key is saved after)
    endpoint:str=None, # Sync server URL (defaults to AnkiWeb)
    upload:bool=False, # Force-upload the local collection, replacing the server copy
):
    "Sync the default collection with AnkiWeb. Pass credentials the first time; they're saved after that."
    with Collection.open() as col: return col.sync(user=user, passw=passw, endpoint=endpoint, upload=upload)

The first sync of a fresh collection is a full one: by default that’s a download (the server copy wins), and replacing a non-empty server copy with a fresh empty collection is refused unless the server side is empty too. Pass upload=True deliberately to push your local copy wholesale.

sync(user=os.environ['ANKI_USER'], passw=os.environ['ANKI_PASS'])  # first time
sync()  # after that

Tool use

def anki_tools(): print('&`[add_card, add_fb_card, add_cloze_card, find_notes, find_note_ids, find_cards, find_card_ids, get_note, del_note, update_fb_note, sync]`')
anki_tools()
['add_card',
 'add_fb_card',
 'add_cloze_card',
 'find_notes',
 'find_note_ids',
 'find_cards',
 'find_card_ids',
 'get_note',
 'del_note',
 'update_fb_note',
 'sync']

add_card handles any notetype via a fields dict, and rejects unknown field names with a clear error:

czid = add_card(model='Cloze', fields={'Text':'{{c1::pi}} ~ 3.14'})
test_eq(get_note(czid)['Text'], '{{c1::pi}} ~ 3.14')
test_fail(lambda: add_card(fields={'Nope':'x'}), contains='Nope')   # unknown field -> clear error