# Collections


<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->

With `fastanki.schema` handling the file format, this module does the
actual work: a
[`Collection`](https://AnswerDotAI.github.io/fastanki/collection.html#collection)
class that adds, finds, updates and removes notes, generating cards the
way Anki would. Every change is bookkept for sync (`usn=-1` marks
pending rows, deletions go to `graves`, `col.mod` tracks modification
time), so a collection managed here syncs cleanly with AnkiWeb and any
other Anki client.

``` python
import hashlib, html, random, time, json
from contextlib import contextmanager
from fastcore.utils import *
from fastanki.schema import *
from fastanki._proto import notetypes_pb2
```

``` python
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 fingerprints the first field of every note with a checksum used for
duplicate detection, computed over the text with HTML stripped but media
filenames kept (so `<img src=cat.jpg>` and a note that mentions cat.jpg
collide, as they should). The guid identifies a note across collections,
as 10 or so characters of base91.

``` python
_media_re = re.compile(r'(?i)<(?:img|audio|object)\b[^>]*?\bsrc\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))[^>]*>')
_html_re = re.compile(r'(?is)<style.*?(?:</style>|$)|<script.*?(?:</script>|$)|<!--.*?-->|<.*?>')

def strip_html_media(s:str):
    "Strip HTML from `s`, preserving media filenames, matching Anki's checksum preprocessing"
    s = _media_re.sub(lambda m: f" {first(g for g in m.groups() if g is not None)} ", s)
    s = _html_re.sub('', s)
    return html.unescape(s).replace('\xa0',' ')

def field_csum(text:str):
    "Anki's dupe-detection checksum: first 4 bytes of the sha1 of the stripped first field"
    return int.from_bytes(hashlib.sha1(strip_html_media(text).encode()).digest()[:4], 'big')

_B91 = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&()*+,-./:;<=>?@[]^_`{|}~"

def guid64():
    "A new random note guid, in Anki's base91 encoding"
    n = random.getrandbits(64)
    buf = ''
    while True:                                         # do-while: a base91 number always has >=1 digit, so the guid is never empty
        n,r = divmod(n, len(_B91)); buf += chr(_B91[r])
        if not n: return buf
```

Checked directly against Anki’s own implementation, on plain text, HTML,
entities, and media references:

``` python
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))
```

``` python
test_eq(field_csum('hello'), 2868168221)
g = guid64()
assert 1 <= len(g) <= 11 and set(g) <= set(map(chr, _B91))
g
```

## Opening a collection

fastanki keeps its own collection, separate from any desktop Anki
profile: on machines where fastanki runs there’s usually no desktop Anki
at all, and where there is one, staying out of its data folder means
never fighting it for the sqlite lock. The desktop sees our changes the
same way your phone does, through AnkiWeb sync.

``` python
def data_dir():
    "fastanki's data folder (override with FASTANKI_DIR)"
    return Path(os.environ.get('FASTANKI_DIR', '~/.fastanki')).expanduser()

class Collection:
    def __init__(self, path): self.path,self.con = Path(path),connect(path)
    @classmethod
    def open(cls, path=None):
        "Open collection at `path` (default `data_dir()/collection.anki2`), creating if needed"
        path = Path(path) if path else data_dir()/'collection.anki2'
        if not path.exists(): create_collection(path).close()
        return cls(path)
    def close(self): self.con.close()
    def __enter__(self): return self
    def __exit__(self, *args): self.close()
    def q(self, sql, *params): return self.con.execute(sql, params).fetchall()
    def q1(self, sql, *params):
        "Single value from a single row, or None"
        r = self.con.execute(sql, params).fetchone()
        return r[0] if r else None
    def _dirty(self):
        "Record that the collection changed, for sync's modification check"
        # max(...,mod+1) keeps mod strictly increasing: a change in the same ms as the last sync's finish must still look newer, else meta reports 'noChanges' and skips it
        self.con.execute('update col set mod=max(?, mod+1)', (now_ms(),))
    @contextmanager
    def _tx(self):
        "One write transaction so id allocation and writes stay atomic across connections: BEGIN IMMEDIATE at the top level, nested calls reuse it via a savepoint"
        if self.con.getautocommit():
            self.con.execute('BEGIN IMMEDIATE')
            try: yield; self.con.execute('COMMIT')
            except: self.con.execute('ROLLBACK'); raise
        else:
            with self.con: yield
```

`q` and `q1` keep the sql one-liners readable throughout the rest of the
module. `_dirty` bumps `col.mod`, which is how sync notices there’s
something to send.

``` python
td = Path(tempfile.mkdtemp())
col = Collection.open(td/'collection.anki2')
test_eq(col.q1('select ver from col'), 18)
```

## Notetypes

A notetype’s structure spans three tables plus a protobuf config.
[`NT`](https://AnswerDotAI.github.io/fastanki/collection.html#nt)
gathers what note operations need: field names, sort field, kind, and
the per-template card generation requirements.

``` python
class NT:
    "A notetype's structure: fields, templates, and card generation rules"
    def __init__(self, con, ntid, name):
        self.id,self.name = ntid,name
        cfg = notetypes_pb2.Notetype.Config()
        cfg.ParseFromString(con.execute('select config from notetypes where id=?',(ntid,)).fetchone()[0])
        self.cloze = cfg.kind==1
        self.sortf = cfg.sort_field_idx
        self.flds = [r[0] for r in con.execute('select name from fields where ntid=? order by ord',(ntid,))]
        self.tmpls = []   # (ord, front-template) per card template, for render-based generation
        for o,b in con.execute('select ord, config from templates where ntid=? order by ord',(ntid,)):
            tc = notetypes_pb2.Notetype.Template.Config(); tc.ParseFromString(b); self.tmpls.append((o, tc.q_format))
    def __repr__(self): return f"NT({self.name!r}, flds={self.flds}, {'cloze' if self.cloze else f'{len(self.tmpls)} templates'})"

@patch
def nt(self:Collection, name):
    "Notetype structure by name (case-insensitive)"
    r = self.con.execute('select id, name from notetypes where name=?',(name,)).fetchone()
    if not r: raise KeyError(f"No notetype {name!r}")
    return NT(self.con, *r)

@patch
def notetypes(self:Collection): return [r[0] for r in self.q('select name from notetypes order by name')]
```

``` python
col.nt('Basic')
```

The `name=?` lookup goes through sqlite’s
[`unicase`](https://AnswerDotAI.github.io/fastanki/schema.html#unicase)
collation on the name column, so `col.nt('basic')` finds Basic:

``` python
test_eq(col.nt('basic').id, col.nt('Basic').id)
test_eq(col.notetypes(), ['Basic','Cloze'])
```

## Decks

Deck names nest with `::` in Anki’s UI, but the table stores the
separator as `\x1f`, one row per level. Adding `Spanish::Vocab` creates
`Spanish` too, as Anki does.

``` python
def _dname(name): return name.replace('::','\x1f')

@patch
def deck_id(self:Collection, name, create=False):
    "Id of deck `name` (`::`-separated), optionally creating it (and any missing parents)"
    did = self.q1('select id from decks where name=?', _dname(name))
    if did or not create: return did
    parts = name.split('::')
    with self._tx():
        for i in range(1, len(parts)):
            self.deck_id('::'.join(parts[:i]), create=True)
        did = ts_id(self.con, 'decks')
        self.con.execute('insert into decks values (?,?,?,-1,?,?)', (did, _dname(name), now_ms()//1000, deck_common(), deck_kind()))
        self._dirty()
    return did

@patch
def decks(self:Collection): return [r[0].replace('\x1f','::') for r in self.q('select name from decks order by name')]
```

``` python
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`](https://AnswerDotAI.github.io/fastanki/collection.html#note) is
a plain holder for one row of the `notes` table, with dict-style field
access by name. The interesting work is in `add_note`: joining fields
with `\x1f`, computing `sfld` and `csum`, and deciding which cards to
generate. For normal notetypes each template carries a requirement (“any
of these fields non-empty” or “all of them”); for cloze notetypes
there’s one card per distinct `{c1::...}` number across the fields.

Card generation follows Anki’s rule rather than the stored `req` cache:
a normal template generates a card when its **front** would render
non-empty for the note’s non-empty fields.
[`renders_with_fields`](https://AnswerDotAI.github.io/fastanki/collection.html#renders_with_fields)
parses the front template (`{Field}`, `{#Field}…{{/Field}}`,
`{^Field}…{{/Field}}`, comments) and reports whether any non-empty field
reaches the output; static text alone never counts.
[`field_is_empty`](https://AnswerDotAI.github.io/fastanki/collection.html#field_is_empty)
matches Anki: whitespace and empty `<br>`/`<div>` tags only.

``` python
_field_empty_re = re.compile(r'(?si)^(?:\s|</?(?:br|div) ?/?>)*$')
def field_is_empty(s): return bool(_field_empty_re.match(s))

_tmpl_re = re.compile(r'\{\{(.+?)\}\}', re.S)
def _parse_tmpl(qfmt):
    "Parse a template into nested nodes: ('text',) ('rep',key) ('cond',key,kids) ('negc',key,kids)"
    toks,pos = [],0
    for m in _tmpl_re.finditer(qfmt):
        if m.start()>pos: toks.append(('text',))
        c = m.group(1).strip()
        if   c.startswith('#'): toks.append(('open', c[1:].strip()))
        elif c.startswith('^'): toks.append(('neg',  c[1:].strip()))
        elif c.startswith('/'): toks.append(('close',))
        elif c.startswith('!'): toks.append(('text',))
        else: toks.append(('rep', c.rsplit(':',1)[-1].strip('{} ')))
        pos = m.end()
    if pos<len(qfmt): toks.append(('text',))
    def build(i):
        nodes=[]
        while i<len(toks):
            t=toks[i]
            if t[0]=='close': return nodes,i+1
            if t[0] in ('open','neg'): kids,i=build(i+1); nodes.append(('cond' if t[0]=='open' else 'negc', t[1], kids))
            else: nodes.append(t); i+=1
        return nodes,i
    return build(0)[0]

def _tmpl_empty(nodes, nonempty):
    "Anki's rule: a template is empty unless some non-empty field's content reaches the output"
    for n in nodes:
        if n[0]=='rep' and n[1] in nonempty: return False
        if n[0]=='cond' and n[1] in nonempty and not _tmpl_empty(n[2], nonempty): return False
        if n[0]=='negc' and n[1] not in nonempty and not _tmpl_empty(n[2], nonempty): return False
    return True

def renders_with_fields(qfmt, nonempty):
    "Would front template `qfmt` render non-empty given the set of `nonempty` field names?"
    return not _tmpl_empty(_parse_tmpl(qfmt), nonempty)
```

``` python
class Note:
    def __init__(self, id, guid, mid, mod, usn, tags, flds, nt):
        store_attr()
        self.fields = dict(zip(nt.flds, flds.split('\x1f')))
        self.tags = tags.split()
    def __getitem__(self, k): return self.fields[k]
    def __setitem__(self, k, v):
        if k not in self.fields: raise KeyError(f"No field {k!r} in {self.nt.name}")
        self.fields[k] = v
    def __repr__(self):
        flds = ', '.join(f'{k}={v!r}' for k,v in self.fields.items())
        return f"Note({self.id}, {flds}, tags={self.tags})"
    def _repr_markdown_(self):
        flds = ' | '.join(f"**{k}**: {v}" for k,v in self.fields.items())
        return f"{flds} | \N{LABEL} {' '.join(self.tags) or '-'}"

_cloze_re = re.compile(r'\{\{c(\d+)::')

_SPECIAL_FIELDS = ('Card','CardFlag','Deck','Subdeck','Type','CardID')  # always-present special fields (FrontSide excluded on the front; Tags handled below)
def note_cards(nt, fields, tags=()):
    "Template ordinals that generate cards for a note's `fields` (values in field order) and `tags`, using Anki's render rule"
    if nt.cloze: return sorted({min(int(m)-1,499) for f in fields for m in _cloze_re.findall(f) if int(m)>=1})
    nonempty = {f for f,v in zip(nt.flds, fields) if not field_is_empty(v)}
    nonempty |= {s for s in _SPECIAL_FIELDS if s not in nt.flds}
    if tags and 'Tags' not in nt.flds: nonempty.add('Tags')
    return [o for o,qfmt in nt.tmpls if renders_with_fields(qfmt, nonempty)]
```

Cloze ordinals are 1-based in note text (`{c1::...}`) but 0-based as
card `ord`s, so
[`note_cards`](https://AnswerDotAI.github.io/fastanki/collection.html#note_cards)
returns them shifted. `KIND_NONE` (0) requirements mean the template
never generates; `any` is 1 and `all` is 2 in the protobuf enum.

``` python
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','']), [])
```

And the render rule agrees with Anki itself across the reversed and
optional-reversed (conditional `{#Add Reverse}`) notetypes: Anki
generates the cards, and
[`note_cards`](https://AnswerDotAI.github.io/fastanki/collection.html#note_cards)
predicts the same ordinals from the note’s fields.

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

Now `add` itself. Anki assigns each new card a position (`due` for a new
card is its queue position, not a date), drawn from the `nextPos`
counter:

``` python
@patch
def _next_pos(self:Collection):
    pos = json.loads(self.q1("select val from config where key='nextPos'") or b'0')
    self.con.execute("insert or replace into config values ('nextPos',-1,?,?)", (now_ms()//1000, json.dumps(pos+1).encode()))
    return pos

@patch
def add(self:Collection, model='Basic', deck='Default', tags=None, **fields):
    "Add a note (and its cards), returning the `Note`"
    nt = self.nt(model)
    unknown = set(fields) - set(nt.flds)
    if unknown: raise KeyError(f"Fields not in {nt.name}: {unknown}")
    vals = [fields.get(f,'') for f in nt.flds]
    ords = note_cards(nt, vals, tags)
    if not ords: raise ValueError("Note would generate no cards (check required fields)")
    with self._tx():
        did = self.deck_id(deck, create=True)
        nid,now = ts_id(self.con,'notes'), now_ms()//1000
        tags = [tags] if isinstance(tags,str) else (tags or [])
        tagstr = f" {' '.join(tags)} " if tags else ''
        self.con.execute("insert into notes values (?,?,?,?,-1,?,?,?,?,0,'')",
            (nid, guid64(), nt.id, now, tagstr, '\x1f'.join(vals), strip_html_media(vals[nt.sortf]), field_csum(vals[0])))
        cid = ts_id(self.con,'cards')
        for i,o in enumerate(ords):
            self.con.execute("insert into cards values (?,?,?,?,?,-1,0,0,?,0,0,0,0,0,0,0,0,'{}')",
                (cid+i, nid, did, o, now, self._next_pos()))
        self._dirty()
    return self.get_note(nid)
```

``` python
@patch
def get_note(self:Collection, nid):
    "Retrieve a `Note` by id"
    r = self.con.execute('select id, guid, mid, mod, usn, tags, flds from notes where id=?',(nid,)).fetchone()
    if not r: raise KeyError(f"No note {nid}")
    ntr = self.con.execute('select id, name from notetypes where id=?',(r[2],)).fetchone()
    return Note(*r, nt=NT(self.con, *ntr))
```

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

``` python
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

Updates rewrite the note row with fresh `mod`, `usn=-1`, `sfld` and
`csum`, and generate any cards a new cloze number now requires (never
deleting existing ones, matching Anki). Removals record graves, the
tombstones sync uses to propagate deletions: type 0 is a card, 1 a note,
2 a deck.

``` python
@patch
def update_note(self:Collection, note, tags=None, add_tags=None, **fields):
    "Update fields and/or tags of `note` (a `Note` or note id), returning the updated `Note`"
    if not isinstance(note,Note): note = self.get_note(note)
    for k,v in fields.items(): note[k] = v
    if tags is not None: note.tags = [tags] if isinstance(tags,str) else tags
    if add_tags: note.tags += [t for t in ([add_tags] if isinstance(add_tags,str) else add_tags) if t not in note.tags]
    vals = [note.fields[f] for f in note.nt.flds]
    tagstr = f" {' '.join(note.tags)} " if note.tags else ''
    with self._tx():
        self.con.execute('update notes set mod=?, usn=-1, tags=?, flds=?, sfld=?, csum=? where id=?',
            (now_ms()//1000, tagstr, '\x1f'.join(vals), strip_html_media(vals[note.nt.sortf]), field_csum(vals[0]), note.id))
        have = {r[0] for r in self.q('select ord from cards where nid=?', note.id)}
        did = self.q1('select did from cards where nid=?', note.id)
        cid,now = ts_id(self.con,'cards'), now_ms()//1000
        for i,o in enumerate(o for o in note_cards(note.nt, vals, note.tags) if o not in have):
            self.con.execute("insert into cards values (?,?,?,?,?,-1,0,0,?,0,0,0,0,0,0,0,0,'{}')",
                (cid+i, note.id, did, o, now, self._next_pos()))
        self._dirty()
    return self.get_note(note.id)
```

``` python
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)
```

``` python
@patch
def remove_notes(self:Collection, nids):
    "Remove notes (and their cards) by id, recording graves for sync"
    nids = [n.id if isinstance(n,Note) else n for n in listify(nids)]
    with self._tx():
        for nid in nids:
            for (cid,) in self.q('select id from cards where nid=?', nid):
                self.con.execute('insert or ignore into graves values (?,0,-1)', (cid,))
            self.con.execute('insert or ignore into graves values (?,1,-1)', (nid,))
            self.con.execute('delete from cards where nid=?', (nid,))
            self.con.execute('delete from notes where id=?', (nid,))
        self._dirty()
    return len(nids)

@patch
def remove_deck(self:Collection, name):
    "Remove deck `name` and its subdecks, with their cards and notes"
    pat = _dname(name)
    dids = [r[0] for r in self.q("select id from decks where name=? or name like ?", pat, pat+'\x1f%')]
    if not dids: return 0
    ph = ','.join('?'*len(dids))
    nids = [r[0] for r in self.q(f'select distinct nid from cards where did in ({ph})', *dids)]
    with self._tx():
        self.remove_notes(nids)
        for did in dids:
            self.con.execute('insert or ignore into graves values (?,2,-1)', (did,))
            self.con.execute('delete from decks where id=?', (did,))
        self._dirty()
    return len(dids)
```

Anki’s “remove deck” takes the notes down with it when their cards all
lived there; we do the simpler, stricter thing and remove every note
that had a card in the deck. The functional API only exposes deck
removal through
[`Collection`](https://AnswerDotAI.github.io/fastanki/collection.html#collection),
so surprises stay contained.

``` python
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

Anki has a search language; we have Python. Keyword arguments compose to
SQL: `deck`, `tag` and `added_days` map to indexed columns, any other
keyword is treated as a field name and matched case-insensitively as a
substring, and `where`/`args` drop through to raw SQL against the
`notes` table (aliased `n`) joined with `cards` (aliased `c`) for
anything the keywords don’t cover.

``` python
@patch
def _find_sql(self:Collection, deck=None, tag=None, added_days=None, where=None, args=()):
    conds,ps = [],[]
    if deck:
        pat = _dname(deck)
        conds.append('c.did in (select id from decks where name=? or name like ?)'); ps += [pat, pat+'\x1f%']
    if tag: conds.append('n.tags like ?'); ps.append(f'% {tag} %')
    if added_days is not None: conds.append('n.id >= ?'); ps.append((int(time.time())-added_days*86400)*1000)
    if where: conds.append(f'({where})'); ps += list(args)
    return ' and '.join(conds) or '1=1', ps

def _fld_match(nt_flds, fields, flds_str):
    d = dict(zip(nt_flds, flds_str.split('\x1f')))
    return all(k in d and v.casefold() in d[k].casefold() for k,v in fields.items())

@patch
def find_notes(self:Collection, deck=None, tag=None, added_days=None, where=None, args=(), **fields):
    "Notes matching all given criteria; field kwargs are case-insensitive substring matches"
    cond,ps = self._find_sql(deck, tag, added_days, where, args)
    sql = f'select distinct n.id, n.guid, n.mid, n.mod, n.usn, n.tags, n.flds from notes n join cards c on c.nid=n.id where {cond}'
    nts,res = {},[]
    for r in self.q(sql, *ps):
        mid = r[2]
        if mid not in nts: nts[mid] = NT(self.con, mid, self.q1('select name from notetypes where id=?', mid))
        if not fields or _fld_match(nts[mid].flds, fields, r[6]): res.append(Note(*r, nt=nts[mid]))
    return sorted(res, key=lambda n:n.id)

@patch
def find_note_ids(self:Collection, **kw): return [n.id for n in self.find_notes(**kw)]
```

``` python
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])
```

Cards get the same treatment.
[`Card`](https://AnswerDotAI.github.io/fastanki/collection.html#card)
carries the scheduling columns; `is_due` selects learning cards whose
time has come and review cards due today or earlier.

``` python
class Card:
    def __init__(self, id, nid, did, ord, mod, usn, type, queue, due, ivl): store_attr()
    def __repr__(self): return f"Card({self.id}, nid={self.nid}, due={self.due}, ivl={self.ivl}, queue={self.queue})"
    def _repr_markdown_(self): return f"Card {self.id} (nid: {self.nid}, due: {self.due}, ivl: {self.ivl}d, queue: {self.queue})"

@patch
def today(self:Collection):
    "Days since collection creation, Anki's day counter"
    return (int(time.time()) - self.q1('select crt from col'))//86400

@patch
def find_cards(self:Collection, deck=None, tag=None, added_days=None, is_due=None, where=None, args=(), **fields):
    "Cards matching all given criteria (same keywords as `find_notes`, plus `is_due`)"
    nids = None
    if fields: nids = {x.id for x in self.find_notes(deck=deck, tag=tag, added_days=added_days, **fields)}
    cond,ps = self._find_sql(deck, tag, added_days, where, args)
    if is_due: cond += f' and (c.queue=1 and c.due<=? or c.queue in (2,3) and c.due<=?)'; ps += [int(time.time())+1200, self.today()]
    sql = f'select c.id, c.nid, c.did, c.ord, c.mod, c.usn, c.type, c.queue, c.due, c.ivl from cards c join notes n on c.nid=n.id where {cond} order by c.id'
    return [Card(*r) for r in self.q(sql, *ps) if nids is None or r[1] in nids]

@patch
def find_card_ids(self:Collection, **kw): return [c.id for c in self.find_cards(**kw)]
```

``` python
cards = col.find_cards(deck='Spanish')
test_eq(len(cards), 1)
cards[0]
```

New cards aren’t “due” in Anki’s sense, and nothing has been studied in
this collection, so:

``` python
test_eq(col.find_cards(is_due=True), [])
test_eq(len(col.find_card_ids()), 4)
```

## Due counts

``` python
@patch
def due_counts(self:Collection, deck=None):
    "(new, learning, review) counts for `deck` and its subdecks (or the whole collection)"
    cond,ps = self._find_sql(deck)
    sql = ('select sum(c.queue=0), sum(c.queue=1 and c.due<=? or c.queue=3 and c.due<=?), sum(c.queue=2 and c.due<=?) '
           f'from cards c join notes n on c.nid=n.id where {cond}')
    r = self.q(sql, int(time.time())+1200, self.today(), self.today(), *ps)[0]
    return tuple(x or 0 for x in r)
```

``` python
test_eq(col.due_counts(), (4,0,0))
test_eq(col.due_counts('Spanish'), (1,0,0))
```

## The oracle test

Everything above wrote rows we believe are Anki-compatible. Time to ask
Anki: open the collection we’ve been mutating, run its database check,
use its own search to find our notes, add a note of its own, and make
sure we can read that back.

``` python
col.close()
ac = AnkiCollection(str(td/'collection.anki2'))
report, ok = ac.fix_integrity()
test_eq(ok, True)
```

``` python
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}}')
```

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