import tempfile
from fastcore.test import *
from google.protobuf.json_format import MessageToDictSyncing
fastanki syncs collections with AnkiWeb or a self-hosted Anki server. A delta sync exchanges changes since the previous sync. A full sync replaces one side’s entire SQLite database.
The wire
SyncServer sends POST requests to {endpoint}sync/. It compresses request bodies with zstd. Bodies contain JSON, except for full uploads, which contain the database file. The client also decompresses responses with an anki-original-size header.
The anki-sync header contains:
v: protocol version, currently 11.k: authentication key, empty during login.c: client version string.s: random session ID.
AnkiWeb uses HTTP 308 redirects to assign users to servers. The client follows the redirect and saves the new endpoint with the authentication key.
SyncServer
def SyncServer(
endpoint:NoneType=None, hkey:str=''
):One AnkiWeb-protocol server (AnkiWeb itself, or self-hosted)
Schema 11 conversions
Delta sync exchanges notetypes, decks and deck configurations in Anki’s 2012 JSON format, called schema 11. This supports older clients even when both databases use a newer schema.
The conversion functions preserve unknown keys in an other JSON blob. Some fields change representation between formats. For example, autoplay becomes disable_autoplay, with the opposite Boolean value. An initial ease of 2500 becomes 2.5.
nt_from_s11
def nt_from_s11(
m
):Convert a schema-11 notetype dict to (notetype_row, field_rows, template_rows)
deck_from_s11
def deck_from_s11(
d
):Convert a schema-11 deck dict to a decks table row (filtered decks unsupported: kept as-is if present locally)
dconf_to_s11
def dconf_to_s11(
con, dcid
):Convert deck config dcid’s row to a schema-11 dict
dconf_from_s11
def dconf_from_s11(
d
):Convert a schema-11 deck config dict to a deck_config table row
Check that conversions preserve values from Anki’s Python API.
from anki.collection import Collection as AnkiCollection
import anki.langdef sub_eq(a, b, path=''):
"Assert every key/value in `a` appears in `b` (recursively, with numeric tolerance)"
for k,v in a.items():
w = b.get(k)
if isinstance(v,dict): sub_eq(v, w or {}, f'{path}{k}.')
elif isinstance(v,float) or isinstance(w,float): assert abs(v-(w or 0))<1e-6, f'{path}{k}: {v} vs {w}'
elif isinstance(v,bool) or isinstance(w,bool): assert bool(v)==bool(w), f'{path}{k}: {v} vs {w}'
else: assert v==w, f'{path}{k}: {v!r} vs {w!r}'
td = Path(tempfile.mkdtemp())
ac = AnkiCollection(str(td/'oracle.anki2'))
mcon = connect(td/'scratch.anki2')
mcon.execute(SCHEMA)
m11 = ac.models.by_name('Cloze')
row,flds,tmpls = nt_from_s11(m11)
mcon.execute('insert into notetypes values (?,?,?,?,?)', row)
mcon.executemany('insert into fields values (?,?,?,?)', flds)
mcon.executemany('insert into templates values (?,?,?,?,?,?)', tmpls)
back = nt_to_s11(mcon, row[0])
sub_eq(dict(m11), back)d11 = ac.decks.get(1)
mcon.execute('insert into decks values (?,?,?,?,?,?)', deck_from_s11(d11))
sub_eq({k:v for k,v in d11.items() if k!='mid'}, deck_to_s11(mcon, 1))
c11 = ac.decks.get_config(1)
mcon.execute('insert into deck_config values (?,?,?,?,?)', dconf_from_s11(c11))
sub_eq(dict(c11), dconf_to_s11(mcon, 1))Gathering and applying changes
A delta sync exchanges data in this order:
- Graves record deletions of cards, notes and decks.
- Unchunked changes contain notetypes, decks, deck configurations and tags.
- Chunks contain notes, cards and review log entries as positional JSON arrays.
Pending local rows have usn=-1. The client assigns the server’s current usn to these rows when sending them.
The sync state machine
sync_collection calls meta to compare collection state. It returns noChanges when the modification timestamps match. If changes require a full sync, it raises FullSyncRequired.
A delta sync calls the remaining endpoints in order:
startapplyGravesapplyChangeschunk, repeated until all server chunks have been downloadedapplyChunk, repeated until all local chunks have been uploadedsanityCheck2finish
sanityCheck2 compares object counts. On a mismatch, the client rolls back local changes and raises SanityCheckFailed. It changes the schema timestamp to require a full sync on the next attempt.
Collection.sync_collection
def sync_collection(
srv
):Delta-sync with srv, returning ‘noChanges’, ‘success’, or raising FullSyncRequired
SanityCheckFailed
def SanityCheckFailed(
*args, **kwargs
):Common base class for all non-exit exceptions.
FullSyncRequired
def FullSyncRequired(
*args, **kwargs
):Common base class for all non-exit exceptions.
SyncRequired
def SyncRequired(
*args, **kwargs
):Common base class for all non-exit exceptions.
Full sync and login
full_download replaces the local database with the server’s copy. full_upload replaces the server’s database with the local copy. The server checks the uploaded database’s integrity before returning OK.
Login uses hostKey to obtain an authentication key. sync saves the key and endpoint in auth.json beside the collection.
Collection.sync
def sync(
user:NoneType=None, passw:NoneType=None, endpoint:NoneType=None, upload:bool=False
):Sync with AnkiWeb (or endpoint): logs in if needed, handles delta and full syncs
Collection.load_auth
def load_auth(
endpoint:NoneType=None
):A SyncServer from saved auth, or None
Collection.full_download
def full_download(
srv
):Replace the local collection with the server’s copy
Against a real server
Anki’s Rust implementation is the protocol reference. The examples below use fastanki and Anki as clients of a local Anki sync server. They check changes in both directions without contacting AnkiWeb.
These cells use eval: false. tests/test_sync.py runs the sequence with a fixture that starts and stops the server in a temporary data directory.
start_sync_server requires the anki package, a development dependency. It runs python -m anki.syncserver on a free local port and waits for a response. Call terminate() on the returned process to stop it. It also stops when Python exits.
start_sync_server
def start_sync_server(
base, # Folder for the server's data
user:str='tester', # Username the server will accept
passw:str='s3kret', # Password for `user`
):Start the anki wheel’s sync server on a free port, returning (process, endpoint); it stops at exit, or sooner via .terminate()
server, EP = start_sync_server(td/'server')Upload a new collection. The local and server schema timestamps differ, requiring a full sync.
col = Collection.open(td/'ours'/'collection.anki2')
n1 = col.add(Front='syncme', Back='please', tags='wire', deck='Sync::Deep')
ncz = col.add(model='Cloze', Text='{{c1::first}} {{c2::second}}')
test_fail(lambda: col.sync_collection(SyncServer(EP, hkey='bad')))
res = col.sync(user='tester', passw='s3kret', endpoint=EP, upload=True)
test_eq(res, 'full sync')test_eq(col.sync(), 'noChanges')Use Anki to download and check the collection.
(td/'second').mkdir()
oc2 = AnkiCollection(str(td/'second'/'collection.anki2'))
auth = oc2.sync_login('tester', 's3kret', endpoint=EP)
st = oc2.sync_collection(auth, sync_media=False)
oc2.close_for_full_sync()
oc2.full_upload_or_download(auth=auth, server_usn=st.server_media_usn, upload=False)
oc2.reopen(after_full_sync=True)
test_eq(sorted(oc2.find_notes('')), sorted([n1.id, ncz.id]))
test_eq(oc2.find_notes('tag:wire'), [n1.id])
test_eq(oc2.find_notes('deck:Sync::Deep'), [n1.id])
onote = oc2.get_note(ncz.id)
test_eq(onote['Text'], '{{c1::first}} {{c2::second}}')Add a note and deck in Anki, edit another note, and delete the cloze note. Sync these changes to the server.
theirs = oc2.new_note(oc2.models.by_name('Basic'))
theirs['Front'] = 'from the other side'
oc2.add_note(theirs, oc2.decks.add_normal_deck_with_name('Oracle').id)
on = oc2.get_note(n1.id)
on['Back'] = 'edited remotely'
oc2.update_note(on)
oc2.remove_notes([ncz.id])
st = oc2.sync_collection(auth, sync_media=False)
test_eq(st.required, st.NO_CHANGES)Check that fastanki downloads Anki’s changes.
test_eq(col.sync(), 'success')
test_eq(col.find_note_ids(Front='from the other side'), [theirs.id])
test_eq(col.get_note(n1.id)['Back'], 'edited remotely')
test_fail(lambda: col.get_note(ncz.id))
test_eq([d for d in col.decks() if d=='Oracle'], ['Oracle'])Check that Anki receives fastanki’s deletion and new tagged note.
n2 = col.add(Front='round trip', tags=['back','forth'])
col.remove_notes(n1.id)
test_eq(col.sync(), 'success')
st = oc2.sync_collection(auth, sync_media=False)
oc2.close()
oc3 = AnkiCollection(str(td/'second'/'collection.anki2'))
test_eq(sorted(oc3.find_notes('')), sorted([theirs.id, n2.id]))
test_eq(oc3.find_notes('tag:forth'), [n2.id])
oc3.close()
col.close()
server.terminate()