from fastcore.utils import *
from fastanki.schema import *
from fastanki.collection import *
from fastanki.syncer import *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.
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'})
note1783999334909
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 thatTool 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