improved finding

- new per-character tokenizer that is easier to read and maintain
- OR searches now supported
- grouping with ( and ) now supported
- sorting is off by default now
- searching in html is disabled for now, but may come back later
- use ? arguments instead of named arguments to ease porting to other
  platforms
- field:foo is no longer wrapped in implicit wildcards
- avoid joining notes table if it not required - about 30% faster
- fixed sql injection bug in findNids()
- commands no longer have to take care of negation themselves
- commands can return nothing to immediately flag the query as invalid
This commit is contained in:
Damien Elmes 2012-05-23 20:21:47 +09:00
parent ef4ff86f8c
commit 6af7f6e846
4 changed files with 297 additions and 355 deletions

View file

@ -510,8 +510,8 @@ where c.nid == f.id
# Finding cards
##########################################################################
def findCards(self, query, full=False, order=None):
return anki.find.Finder(self).findCards(query, full, order)
def findCards(self, query, order=False):
return anki.find.Finder(self).findCards(query, order)
def findReplace(self, nids, src, dst, regex=None, field=None, fold=True):
return anki.find.findReplace(self, nids, src, dst, regex, field, fold)

View file

@ -6,32 +6,6 @@ import re
from anki.utils import ids2str, splitFields, joinFields, stripHTML, intTime
from anki.consts import *
SEARCH_TAG = 0
SEARCH_TYPE = 1
SEARCH_PHRASE = 2
SEARCH_NID = 3
SEARCH_TEMPLATE = 4
SEARCH_FIELD = 5
SEARCH_MODEL = 6
SEARCH_DECK = 7
SEARCH_PROP = 8
SEARCH_RATED = 9
# Tools
##########################################################################
def fieldNames(col, downcase=True):
fields = set()
names = []
for m in col.models.all():
for f in m['flds']:
if f['name'].lower() not in fields:
names.append(f['name'])
fields.add(f['name'].lower())
if downcase:
return list(fields)
return names
# Find
##########################################################################
@ -40,37 +14,172 @@ class Finder(object):
def __init__(self, col):
self.col = col
def findCards(self, query, full=False, order=None):
def findCards(self, query, order=False):
"Return a list of card ids for QUERY."
self.order = order
self.query = query
self.full = full
self._findLimits()
if not self.lims['valid']:
tokens = self._tokenize(query)
preds, args = self._where(tokens)
if preds is None:
return []
(q, args) = self._whereClause()
order = self._order()
query = """\
select c.id from cards c, notes n where %s
and c.nid=n.id %s""" % (q, order)
res = self.col.db.list(query, **args)
if not self.order and self.col.conf['sortBackwards']:
order, rev = self._order(order)
sql = self._query(preds, order)
res = self.col.db.list(sql, *args)
if rev:
res.reverse()
return res
def _whereClause(self):
q = " and ".join(self.lims['preds'])
if not q:
q = "1"
return q, self.lims['args']
# Tokenizing
######################################################################
def _order(self):
# user provided override?
if self.order:
return self.order
def _tokenize(self, query):
inQuote = False
tokens = []
token = ""
for c in query:
# quoted text
if c in ("'", '"'):
if inQuote:
if c == inQuote:
inQuote = False
else:
token += c
elif token:
# quotes are allowed to start directly after a :
if token[-1] == ":":
inQuote = c
else:
token += c
else:
inQuote = c
# separator
elif c == " ":
if inQuote:
token += c
elif token:
# space marks token finished
tokens.append(token)
token = ""
# nesting
elif c in ("(", ")"):
if inQuote:
token += c
else:
if c == ")" and token:
tokens.append(token)
token = ""
tokens.append(c)
# negation
elif c == "-":
if token:
token += c
elif not tokens or tokens[-1] != "not":
tokens.append("not")
# normal character
else:
token += c
# if we finished in a token, add it
if token:
tokens.append(token)
return tokens
# Query building
######################################################################
def _where(self, tokens):
# state and query
s = dict(isnot=False, isor=False, join=False, q="", bad=False)
args = []
def add(txt, wrap=True):
# failed command?
if not txt:
# if it was to be negated then we can just ignore it
if s['isnot']:
s['isnot'] = False
return
else:
s['bad'] = True
return
# do we need a conjunction?
if s['join']:
if s['isor']:
s['q'] += " or "
s['isor'] = False
else:
s['q'] += " and "
if s['isnot']:
s['q'] += " not "
if wrap:
txt = "(" + txt + ")"
s['q'] += txt
s['join'] = True
for token in tokens:
if s['bad']:
return None, None
# special tokens
if token == "not":
s['isnot'] = True
elif token.lower() == "or":
s['isor'] = True
elif token == "(":
add(token, wrap=False)
s['join'] = False
elif token == ")":
s['q'] += ")"
# commands
elif ":" in token:
cmd, val = token.split(":", 1)
cmd = cmd.lower()
if cmd == "tag":
add(self._findTag(val, args))
elif cmd == "is":
add(self._findCardState(val))
elif cmd == "nid":
add(self._findNids(val))
elif cmd == "card":
add(self._findTemplate(val))
elif cmd == "note":
add(self._findModel(val))
elif cmd == "deck":
add(self._findDeck(val))
elif cmd == "prop":
add(self._findProp(val))
elif cmd == "rated":
add(self._findRated(val))
else:
add(self._findField(cmd, val))
# normal text search
else:
add(self._findText(token, args))
if s['bad']:
return None, None
return s['q'], args
def _query(self, preds, order):
# can we skip the note table?
if "n." not in preds and "n." not in order:
sql = "select c.id from cards c where "
else:
sql = "select c.id from cards c, notes n where c.nid=n.id and "
# combine with preds
if preds:
sql += "(" + preds + ")"
else:
sql += "1"
# order
if order:
sql += " " + order
return sql
# Ordering
######################################################################
def _order(self, order):
if not order:
return "", False
elif order is not True:
# custom order string provided
return " order by " + order, False
# use deck default
type = self.col.conf['sortType']
if not type:
return
if type.startswith("note"):
if type == "noteCrt":
sort = "n.id, c.ord"
@ -97,57 +206,23 @@ and c.nid=n.id %s""" % (q, order)
raise Exception()
else:
raise Exception()
return " order by " + sort
return " order by " + sort, self.col.conf['sortBackwards']
def _findLimits(self):
"Generate a list of note/card limits for the query."
self.lims = {
'preds': [],
'args': {},
'valid': True,
}
for c, (token, isNeg, type) in enumerate(self._parseQuery()):
if type == SEARCH_TAG:
self._findTag(token, isNeg, c)
elif type == SEARCH_TYPE:
self._findCardState(token, isNeg)
elif type == SEARCH_NID:
self._findNids(token)
elif type == SEARCH_TEMPLATE:
self._findTemplate(token, isNeg)
elif type == SEARCH_FIELD:
self._findField(token, isNeg)
elif type == SEARCH_MODEL:
self._findModel(token, isNeg)
elif type == SEARCH_DECK:
self._findDeck(token, isNeg)
elif type == SEARCH_PROP:
self._findProp(token, isNeg)
elif type == SEARCH_RATED:
self._findRated(token, isNeg)
else:
self._findText(token, isNeg, c)
# Commands
######################################################################
def _findTag(self, val, neg, c):
def _findTag(self, val, args):
if val == "none":
if neg:
t = "tags != ''"
else:
t = "tags = ''"
self.lims['preds'].append(t)
return
extra = "not" if neg else ""
return 'tags = ""'
val = val.replace("*", "%")
if not val.startswith("%"):
val = "% " + val
if not val.endswith("%"):
val += " %"
self.lims['args']["_tag_%d" % c] = val
self.lims['preds'].append(
"tags %s like :_tag_%d""" % (extra, c))
args.append(val)
return "n.tags like ?"
def _findCardState(self, val, neg):
cond = None
def _findCardState(self, val):
if val in ("review", "new", "learn"):
if val == "review":
n = 2
@ -155,40 +230,33 @@ and c.nid=n.id %s""" % (q, order)
n = 0
else:
n = 1
cond = "type = %d" % n
return "type = %d" % n
elif val == "suspended":
cond = "queue = -1"
return "c.queue = -1"
elif val == "due":
cond = "(queue = 2 and due <= %d)" % self.col.sched.today
if cond:
if neg:
cond = "not (%s)" % cond
self.lims['preds'].append(cond)
else:
self.lims['valid'] = False
return """
(c.queue in (2,3) and c.due <= %d) or
(c.queue = 1 and c.due <= %d)""" % (
self.col.sched.today, self.col.sched.dayCutoff)
def _findRated(self, val, neg):
def _findRated(self, val):
r = val.split(":")
if len(r) != 2 or r[0] not in ("1", "2", "3", "4"):
self.lims['valid'] = False
return
try:
days = int(r[1])
except ValueError:
self.lims['valid'] = False
return
# bound the search
days = min(days, 31)
lim = self.col.sched.dayCutoff - 86400*days
self.lims['preds'].append(
"c.id in (select cid from revlog where ease=%s and id>%d)" %
(r[0], (lim*1000)))
return ("c.id in (select cid from revlog where ease=%s and id>%d)" %
(r[0], (lim*1000)))
def _findProp(self, val, neg):
def _findProp(self, val):
# extract
m = re.match("(^.+?)(<=|>=|=|<|>)(.+?$)", val)
if not m:
self.lims['valid'] = False
return
prop, cmp, val = m.groups()
prop = prop.lower()
@ -199,134 +267,90 @@ and c.nid=n.id %s""" % (q, order)
else:
val = int(val)
except ValueError:
self.lims['valid'] = False
return
# is prop valid?
if prop not in ("due", "ivl", "reps", "lapses", "ease"):
self.lims['valid'] = False
return
# query
extra = "not" if neg else ""
q = []
if prop == "due":
val += self.col.sched.today
# only valid for review/daily learning
self.lims['preds'].append("queue in (2,3)")
q.append("(c.queue in (2,3))")
elif prop == "ease":
prop = "factor"
val = int(val*1000)
sql = "%s (%s %s %s)" % ("not" if neg else "",
prop, cmp, val)
self.lims['preds'].append(sql)
q.append("(%s %s %s)" % (prop, cmp, val))
return " and ".join(q)
def _findText(self, val, neg, c):
def _findText(self, val, args):
val = val.replace("*", "%")
if not self.full:
self.lims['args']["_text_%d"%c] = "%"+val+"%"
txt = """
(sfld like :_text_%d escape '\\' or flds like :_text_%d escape '\\')""" % (c,c)
if not neg:
self.lims['preds'].append(txt)
else:
self.lims['preds'].append("not " + txt)
else:
nids = []
extra = "not" if neg else ""
for nid, flds in self.col.db.execute(
"select id, flds from notes"):
if val in stripHTML(flds):
nids.append(nid)
self.lims['preds'].append("n.id %s in %s " % (extra, ids2str(nids)))
args.append("%"+val+"%")
args.append("%"+val+"%")
return "(n.sfld like ? escape '\\' or n.flds like ? escape '\\')"
def _findNids(self, val):
self.lims['preds'].append("n.id in (%s)" % val)
if re.search("[^0-9,]", val):
return
return "n.id in (%s)" % val
def _findModel(self, val, isNeg):
extra = "not" if isNeg else ""
def _findModel(self, val):
ids = []
for m in self.col.models.all():
if m['name'].lower() == val:
ids.append(m['id'])
self.lims['preds'].append("mid %s in %s" % (extra, ids2str(ids)))
return "n.mid in %s" % ids2str(ids)
def _findDeck(self, val, isNeg):
ids = []
def _findDeck(self, val):
def dids(did):
if not did:
return None
return [did] + [a[1] for a in self.col.decks.children(did)]
# current deck?
ids = None
if val.lower() == "current":
id = self.col.decks.current()['id']
elif val.lower() == "none":
if isNeg:
extra = ""
else:
extra = "not"
self.lims['preds'].append(
"c.did %s in %s" % (extra, ids2str(self.col.decks.allIds())))
return
ids = dids(self.col.decks.current()['id'])
elif "*" not in val:
# single deck
id = self.col.decks.id(val, create=False) or 0
ids = dids(self.col.decks.id(val, create=False))
else:
# wildcard
ids = set()
val = val.replace("*", ".*")
for d in self.col.decks.all():
if re.match("(?i)"+val, d['name']):
id = d['id']
ids.extend([id] + [
a[1] for a in self.col.decks.children(id)])
if not ids:
# invalid search
self.lims['valid'] = False
return
ids.update(dids(d['id']))
if not ids:
ids = [id] + [a[1] for a in self.col.decks.children(id)]
return
sids = ids2str(ids)
if not isNeg:
# normal search
self.lims['preds'].append(
"(c.odid in %s or c.did in %s)" % (sids, sids))
else:
# inverted search
self.lims['preds'].append("""
((case c.odid when 0 then 1 else c.odid not in %s end) and c.did not in %s)
""" % (sids, sids))
return "c.did in %s or c.odid in %s" % (sids, sids)
def _findTemplate(self, val, isNeg):
lims = []
comp = "!=" if isNeg else "="
found = False
def _findTemplate(self, val):
# were we given an ordinal number?
try:
num = int(val) - 1
except:
num = None
lims = []
# were we given an ordinal number?
if num is not None:
found = True
self.lims['preds'].append("ord %s %d" % (comp, num))
else:
# search for template names
for m in self.col.models.all():
for t in m['tmpls']:
# template name?
if t['name'].lower() == val.lower():
if m['type'] == MODEL_CLOZE:
# if the user has asked for a cloze card, we want
# to give all ordinals, so we just limit to the
# model instead
lims.append("(mid = %s)" % m['id'])
found = True
else:
lims.append((
"(nid in (select id from notes where mid = %s) "
"and ord %s %d)") % (m['id'], comp, t['ord']))
found = True
if lims:
self.lims['preds'].append("(" + " or ".join(lims) + ")")
self.lims['valid'] = found
return "c.ord = %d" % num
# search for template names
lims = []
for m in self.col.models.all():
for t in m['tmpls']:
if t['name'].lower() == val.lower():
if m['type'] == MODEL_CLOZE:
# if the user has asked for a cloze card, we want
# to give all ordinals, so we just limit to the
# model instead
lims.append("(n.mid = %s)" % m['id'])
else:
lims.append("(n.mid = %s and c.ord = %s)" % (
m['id'], t['ord']))
return " or ".join(lims)
def _findField(self, token, isNeg):
field = value = ''
parts = token.split(':', 1);
field = parts[0].lower()
value = "%" + parts[1].replace("*", "%") + "%"
def _findField(self, field, val):
field = field.lower()
val = val.replace("*", "%")
# find models that have that field
mods = {}
for m in self.col.models.all():
@ -335,146 +359,23 @@ and c.nid=n.id %s""" % (q, order)
mods[str(m['id'])] = (m, f['ord'])
if not mods:
# nothing has that field
self.lims['valid'] = False
return
# gather nids
regex = value.replace("_", ".").replace("%", ".*")
regex = val.replace("_", ".").replace("%", ".*")
nids = []
for (id,mid,flds) in self.col.db.execute("""
select id, mid, flds from notes
where mid in %s and flds like ? escape '\\'""" % (
ids2str(mods.keys())),
"%" if self.full else value):
"%"+val+"%"):
flds = splitFields(flds)
ord = mods[str(mid)][1]
strg = flds[ord]
if self.full:
strg = stripHTML(strg)
if re.search("(?i)"+regex, strg):
if re.search("(?i)^"+regex+"$", strg):
nids.append(id)
extra = "not" if isNeg else ""
self.lims['preds'].append("""
n.mid in %s and n.id %s in %s""" % (
ids2str(mods.keys()), extra, ids2str(nids)))
# Most of this function was written by Marcus
def _parseQuery(self):
tokens = []
res = []
allowedfields = fieldNames(self.col)
def addSearchFieldToken(field, value, isNeg):
if field.lower() in allowedfields:
res.append((field + ':' + value, isNeg, SEARCH_FIELD))
else:
for p in phraselog:
res.append((p['value'], p['is_neg'], p['type']))
# break query into words or phraselog
# an extra space is added so the loop never ends in the middle
# completing a token
for match in re.findall(
r'(-)?\'(([^\'\\]|\\.)*)\'|(-)?"(([^"\\]|\\.)*)"|(-)?([^ ]+)|([ ]+)',
self.query + ' '):
value = (match[1] or match[4] or match[7])
isNeg = (match[0] == '-' or match[3] == '-' or match[6] == '-')
tokens.append({'value': value, 'is_neg': isNeg})
intoken = isNeg = False
field = '' #name of the field for field related commands
phraselog = [] #log of phrases in case potential command is not a commad
for c, token in enumerate(tokens):
doprocess = True # only look for commands when this is true
#prevent cases such as "field" : value as being processed as a command
if len(token['value']) == 0:
if intoken is True and type == SEARCH_FIELD and field:
#case: fieldname: any thing here check for existance of fieldname
addSearchFieldToken(field, '*', isNeg)
phraselog = [] # reset phrases since command is completed
intoken = doprocess = False
if intoken is True:
if type == SEARCH_FIELD and field:
#case: fieldname:"value"
addSearchFieldToken(field, token['value'], isNeg)
intoken = doprocess = False
elif type == SEARCH_FIELD and not field:
#case: "fieldname":"name" or "field" anything
if token['value'].startswith(":") and len(phraselog) == 1:
#we now know a colon is next, so mark it as field
# and keep looking for the value
field = phraselog[0]['value']
parts = token['value'].split(':', 1)
phraselog.append(
{'value': token['value'], 'is_neg': False,
'type': SEARCH_PHRASE})
if parts[1]:
#value is included with the :, so wrap it up
addSearchFieldToken(field, parts[1], isNeg)
intoken = doprocess = False
doprocess = False
else:
#case: "fieldname"string/"fieldname"tag:name
intoken = False
if intoken is False and doprocess is False:
#command has been fully processed
phraselog = [] # reset phraselog, since we used it for a command
if intoken is False:
#include any non-command related phrases in the query
for p in phraselog: res.append(
(p['value'], p['is_neg'], p['type']))
phraselog = []
if intoken is False and doprocess is True:
field = ''
isNeg = token['is_neg']
if token['value'].startswith("tag:"):
token['value'] = token['value'][4:]
type = SEARCH_TAG
elif token['value'].startswith("is:"):
token['value'] = token['value'][3:].lower()
type = SEARCH_TYPE
elif token['value'].startswith("note:"):
token['value'] = token['value'][5:].lower()
type = SEARCH_MODEL
elif token['value'].startswith("deck:"):
token['value'] = token['value'][5:].lower()
type = SEARCH_DECK
elif token['value'].startswith("prop:"):
token['value'] = token['value'][5:].lower()
type = SEARCH_PROP
elif token['value'].startswith("rated:"):
token['value'] = token['value'][6:].lower()
type = SEARCH_RATED
elif token['value'].startswith("nid:") and len(token['value']) > 4:
dec = token['value'][4:]
try:
int(dec)
token['value'] = token['value'][4:]
except:
try:
for d in dec.split(","):
int(d)
token['value'] = token['value'][4:]
except:
token['value'] = "0"
type = SEARCH_NID
elif token['value'].startswith("card:"):
token['value'] = token['value'][5:]
type = SEARCH_TEMPLATE
else:
type = SEARCH_FIELD
intoken = True
parts = token['value'].split(':', 1)
phraselog.append(
{'value': token['value'], 'is_neg': isNeg,
'type': SEARCH_PHRASE})
if len(parts) == 2 and parts[0]:
field = parts[0]
if parts[1]:
#simple fieldname:value case -
#no need to look for more data
addSearchFieldToken(field, parts[1], isNeg)
intoken = doprocess = False
if intoken is False: phraselog = []
if intoken is False and doprocess is True:
res.append((token['value'], isNeg, type))
return res
if not nids:
return
return "n.id in %s" % ids2str(nids)
# Find and replace
##########################################################################
@ -522,11 +423,24 @@ def findReplace(col, nids, src, dst, regex=False, field=None, fold=True):
if not d:
return 0
# replace
col.db.executemany("update notes set flds=:flds,mod=:m,usn=:u where id=:nid", d)
col.db.executemany(
"update notes set flds=:flds,mod=:m,usn=:u where id=:nid", d)
col.updateFieldCache(nids)
col.genCards(nids)
return len(d)
def fieldNames(col, downcase=True):
fields = set()
names = []
for m in col.models.all():
for f in m['flds']:
if f['name'].lower() not in fields:
names.append(f['name'])
fields.add(f['name'].lower())
if downcase:
return list(fields)
return names
# Find duplicates
##########################################################################

View file

@ -880,23 +880,23 @@ due = odue, odue = 0, odid = 0, usn = ?, mod = ? where %s""" % lim,
def _dynOrder(self, deck):
o = deck['order']
if o == DYN_OLDEST:
return "order by c.mod"
return "c.mod"
elif o == DYN_RANDOM:
return "order by random()"
return "random()"
elif o == DYN_SMALLINT:
return "order by ivl"
return "ivl"
elif o == DYN_BIGINT:
return "order by ivl desc"
return "ivl desc"
elif o == DYN_LAPSES:
return "order by lapses desc"
elif o == DYN_FAILED:
return """
and c.id in (select cid from revlog where ease = 1 and time > %d)
order by c.mod""" % ((self.dayCutoff-86400)*1000)
return "lapses desc"
# elif o == DYN_FAILED:
# return """
# and c.id in (select cid from revlog where ease = 1 and time > %d)
# order by c.mod""" % ((self.dayCutoff-86400)*1000)
elif o == DYN_ADDED:
return "order by n.id"
return "n.id"
elif o == DYN_DUE:
return "order by c.due"
return "c.due"
def _moveToDyn(self, did, ids):
deck = self.col.decks.get(did)

View file

@ -1,7 +1,23 @@
# coding: utf-8
from anki.find import Finder
from tests.shared import getEmptyDeck
def test_parse():
f = Finder(None)
assert f._tokenize("hello world") == ["hello", "world"]
assert f._tokenize("hello world") == ["hello", "world"]
assert f._tokenize("one -two") == ["one", "not", "two"]
assert f._tokenize("one --two") == ["one", "not", "two"]
assert f._tokenize("one or -two") == ["one", "or", "not", "two"]
assert f._tokenize("'hello \"world\"'") == ["hello \"world\""]
assert f._tokenize('"hello world"') == ["hello world"]
assert f._tokenize("one (two or ( three or four))") == [
"one", "(", "two", "or", "(", "three", "or", "four",
")", ")"]
assert f._tokenize("embedded'string") == ["embedded'string"]
assert f._tokenize("deck:'two words'") == ["deck:two words"]
def test_findCards():
deck = getEmptyDeck()
f = deck.newNote()
@ -29,7 +45,7 @@ def test_findCards():
mm.addTemplate(m, t)
mm.save(m)
f = deck.newNote()
f['Front'] = u'template test'
f['Front'] = u'test'
f['Back'] = u'foo bar'
deck.addNote(f)
latestCardIds = [c.id for c in f.cards()]
@ -87,19 +103,20 @@ def test_findCards():
assert len(deck.findCards("front:sheep")) == 0
assert len(deck.findCards("back:sheep")) == 2
assert len(deck.findCards("-back:sheep")) == 3
assert len(deck.findCards("front:")) == 5
assert len(deck.findCards("front:do")) == 0
assert len(deck.findCards("front:*")) == 5
# ordering
deck.conf['sortType'] = "noteCrt"
assert deck.findCards("front:")[-1] in latestCardIds
assert deck.findCards("")[-1] in latestCardIds
assert deck.findCards("front:*", order=True)[-1] in latestCardIds
assert deck.findCards("", order=True)[-1] in latestCardIds
deck.conf['sortType'] = "noteFld"
assert deck.findCards("")[0] == catCard.id
assert deck.findCards("")[-1] in latestCardIds
assert deck.findCards("", order=True)[0] == catCard.id
assert deck.findCards("", order=True)[-1] in latestCardIds
deck.conf['sortType'] = "cardMod"
assert deck.findCards("")[-1] in latestCardIds
assert deck.findCards("")[0] == firstCardId
assert deck.findCards("", order=True)[-1] in latestCardIds
assert deck.findCards("", order=True)[0] == firstCardId
deck.conf['sortBackwards'] = True
assert deck.findCards("")[0] in latestCardIds
assert deck.findCards("", order=True)[0] in latestCardIds
# model
assert len(deck.findCards("note:basic")) == 5
assert len(deck.findCards("-note:basic")) == 0
@ -118,14 +135,13 @@ def test_findCards():
deck.addNote(f)
# as it's the sort field, it matches
assert len(deck.findCards("helloworld")) == 2
assert len(deck.findCards("helloworld", full=True)) == 2
#assert len(deck.findCards("helloworld", full=True)) == 2
# if we put it on the back, it won't
(f['Front'], f['Back']) = (f['Back'], f['Front'])
f.flush()
assert len(deck.findCards("helloworld")) == 0
assert len(deck.findCards("helloworld", full=True)) == 2
assert len(deck.findCards("front:helloworld")) == 0
assert len(deck.findCards("back:helloworld", full=True)) == 2
#assert len(deck.findCards("helloworld", full=True)) == 2
#assert len(deck.findCards("back:helloworld", full=True)) == 2
# searching for an invalid special tag should not error
assert len(deck.findCards("is:invalid")) == 0
# should be able to limit to parent deck, no children
@ -173,6 +189,18 @@ def test_findCards():
assert len(deck.findCards("rated:2:1")) == 1
assert len(deck.findCards("rated:2:0")) == 0
assert len(deck.findCards("rated:2:2")) == 1
# empty field
assert len(deck.findCards("front:")) == 0
f = deck.newNote()
f['Front'] = u''
f['Back'] = u'abc2'
assert deck.addNote(f) == 1
assert len(deck.findCards("front:")) == 1
# OR searches and nesting
assert len(deck.findCards("tag:monkey or tag:sheep")) == 2
assert len(deck.findCards("(tag:monkey OR tag:sheep)")) == 2
assert len(deck.findCards("tag:monkey or (tag:sheep sheep)")) == 2
assert len(deck.findCards("tag:monkey or (tag:sheep octopus)")) == 1
def test_findReplace():
deck = getEmptyDeck()