Anki/anki/find.py
Damien Elmes afde11671e rework sibling handling and change bury semantics
First, burying changes:

- unburying now happens on day rollover, or when manually unburying from
  overview screen

- burying is not performed when returning to deck list, or when closing
  collection, so burying now must mark cards as modified to ensure sync
  consistent

- because they're no longer temporary to a session, make sure we exclude them
  in filtered decks in -is:suspended

Sibling spacing changes:

- core behaviour now based on automatically burying related cards when we
  answer a card

- applies to reviews, optionally to new cards, and never to cards in the
  learning queue (partly because we can't suspend/bury cards in that queue at
  the moment)

- this means spacing works consistently in filtered decks now, works on
  reviews even when user is late to review, and provides better separation of
  new cards

- if burying new cards disabled, we just discard them from the current queue.
  an option to set due=ord*space+due would be nicer, but would require
  changing a lot of code and is more appropriate for a future major version
  change. discarding from queue suffers from the same issue as the new card
  cycling in that queue rebuilds may cause cards to be shown close together,
  so the default burying behaviour is preferable

- refer to them as 'related cards' rather than 'siblings'

These changes don't require any changes to the database format, so they
should hopefully coexist with older clients without issue.
2013-08-10 15:56:26 +09:00

549 lines
17 KiB
Python

# -*- coding: utf-8 -*-
# Copyright: Damien Elmes <anki@ichi2.net>
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import re
from anki.utils import ids2str, splitFields, joinFields, intTime, fieldChecksum, stripHTMLMedia
from anki.consts import *
from anki.hooks import *
import sre_constants
# Find
##########################################################################
class Finder(object):
def __init__(self, col):
self.col = col
self.search = dict(
added=self._findAdded,
card=self._findTemplate,
deck=self._findDeck,
mid=self._findMid,
nid=self._findNids,
note=self._findModel,
prop=self._findProp,
rated=self._findRated,
tag=self._findTag,
dupe=self._findDupes,
)
self.search['is'] = self._findCardState
runHook("search", self.search)
def findCards(self, query, order=False):
"Return a list of card ids for QUERY."
tokens = self._tokenize(query)
preds, args = self._where(tokens)
if preds is None:
return []
order, rev = self._order(order)
sql = self._query(preds, order)
try:
res = self.col.db.list(sql, *args)
except:
# invalid grouping
return []
if rev:
res.reverse()
return res
def findNotes(self, query):
tokens = self._tokenize(query)
preds, args = self._where(tokens)
if preds is None:
return []
if preds:
preds = "(" + preds + ")"
else:
preds = "1"
sql = """
select distinct(n.id) from cards c, notes n where c.nid=n.id and """+preds
try:
res = self.col.db.list(sql, *args)
except:
# invalid grouping
return []
return res
# Tokenizing
######################################################################
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] != "-":
tokens.append("-")
# 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
elif txt == "skip":
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 "
s['isnot'] = False
if wrap:
txt = "(" + txt + ")"
s['q'] += txt
s['join'] = True
for token in tokens:
if s['bad']:
return None, None
# special tokens
if token == "-":
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 in self.search:
add(self.search[cmd]((val, args)))
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']
sort = None
if type.startswith("note"):
if type == "noteCrt":
sort = "n.id, c.ord"
elif type == "noteMod":
sort = "n.mod, c.ord"
elif type == "noteFld":
sort = "n.sfld collate nocase, c.ord"
elif type.startswith("card"):
if type == "cardMod":
sort = "c.mod"
elif type == "cardReps":
sort = "c.reps"
elif type == "cardDue":
sort = "c.type, c.due"
elif type == "cardEase":
sort = "c.factor"
elif type == "cardLapses":
sort = "c.lapses"
elif type == "cardIvl":
sort = "c.ivl"
if not sort:
# deck has invalid sort order; revert to noteCrt
sort = "n.id, c.ord"
return " order by " + sort, self.col.conf['sortBackwards']
# Commands
######################################################################
def _findTag(self, (val, args)):
if val == "none":
return 'n.tags = ""'
val = val.replace("*", "%")
if not val.startswith("%"):
val = "% " + val
if not val.endswith("%"):
val += " %"
args.append(val)
return "n.tags like ?"
def _findCardState(self, (val, args)):
if val in ("review", "new", "learn"):
if val == "review":
n = 2
elif val == "new":
n = 0
else:
return "queue in (1, 3)"
return "type = %d" % n
elif val == "suspended":
return "c.queue in (-1, -2)"
elif val == "due":
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, args)):
# days(:optional_ease)
r = val.split(":")
try:
days = int(r[0])
except ValueError:
return
days = min(days, 31)
# ease
ease = ""
if len(r) > 1:
if r[1] not in ("1", "2", "3", "4"):
return
ease = "and ease=%s" % r[1]
cutoff = (self.col.sched.dayCutoff - 86400*days)*1000
return ("c.id in (select cid from revlog where id>%d %s)" %
(cutoff, ease))
def _findAdded(self, (val, args)):
try:
days = int(val)
except ValueError:
return
cutoff = (self.col.sched.dayCutoff - 86400*days)*1000
return "c.id > %d" % cutoff
def _findProp(self, (val, args)):
# extract
m = re.match("(^.+?)(<=|>=|!=|=|<|>)(.+?$)", val)
if not m:
return
prop, cmp, val = m.groups()
prop = prop.lower()
# is val valid?
try:
if prop == "ease":
val = float(val)
else:
val = int(val)
except ValueError:
return
# is prop valid?
if prop not in ("due", "ivl", "reps", "lapses", "ease"):
return
# query
q = []
if prop == "due":
val += self.col.sched.today
# only valid for review/daily learning
q.append("(c.queue in (2,3))")
elif prop == "ease":
prop = "factor"
val = int(val*1000)
q.append("(%s %s %s)" % (prop, cmp, val))
return " and ".join(q)
def _findText(self, val, args):
val = val.replace("*", "%")
args.append("%"+val+"%")
args.append("%"+val+"%")
return "(n.sfld like ? escape '\\' or n.flds like ? escape '\\')"
def _findNids(self, (val, args)):
if re.search("[^0-9,]", val):
return
return "n.id in (%s)" % val
def _findMid(self, (val, args)):
if re.search("[^0-9]", val):
return
return "n.mid = %s" % val
def _findModel(self, (val, args)):
ids = []
val = val.lower()
for m in self.col.models.all():
if m['name'].lower() == val:
ids.append(m['id'])
return "n.mid in %s" % ids2str(ids)
def _findDeck(self, (val, args)):
# if searching for all decks, skip
if val == "*":
return "skip"
# deck types
elif val == "filtered":
return "c.odid"
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":
ids = dids(self.col.decks.current()['id'])
elif "*" not in val:
# single deck
ids = dids(self.col.decks.id(val, create=False))
else:
# wildcard
ids = set()
# should use re.escape in the future
val = val.replace("*", ".*")
val = val.replace("+", "\\+")
for d in self.col.decks.all():
if re.match("(?i)"+val, d['name']):
ids.update(dids(d['id']))
if not ids:
return
sids = ids2str(ids)
return "c.did in %s or c.odid in %s" % (sids, sids)
def _findTemplate(self, (val, args)):
# were we given an ordinal number?
try:
num = int(val) - 1
except:
num = None
if num is not None:
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, field, val):
field = field.lower()
val = val.replace("*", "%")
# find models that have that field
mods = {}
for m in self.col.models.all():
for f in m['flds']:
if f['name'].lower() == field:
mods[str(m['id'])] = (m, f['ord'])
if not mods:
# nothing has that field
return
# gather nids
regex = re.escape(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())),
"%"+val+"%"):
flds = splitFields(flds)
ord = mods[str(mid)][1]
strg = flds[ord]
try:
if re.search("(?i)^"+regex+"$", strg):
nids.append(id)
except sre_constants.error:
return
if not nids:
return "0"
return "n.id in %s" % ids2str(nids)
def _findDupes(self, (val, args)):
# caller must call stripHTMLMedia on passed val
try:
mid, val = val.split(",", 1)
except OSError:
return
csum = fieldChecksum(val)
nids = []
for nid, flds in self.col.db.execute(
"select id, flds from notes where mid=? and csum=?",
mid, csum):
if stripHTMLMedia(splitFields(flds)[0]) == val:
nids.append(nid)
return "n.id in %s" % ids2str(nids)
# Find and replace
##########################################################################
def findReplace(col, nids, src, dst, regex=False, field=None, fold=True):
"Find and replace fields in a note."
mmap = {}
if field:
for m in col.models.all():
for f in m['flds']:
if f['name'] == field:
mmap[str(m['id'])] = f['ord']
if not mmap:
return 0
# find and gather replacements
if not regex:
src = re.escape(src)
if fold:
src = "(?i)"+src
regex = re.compile(src)
def repl(str):
return re.sub(regex, dst, str)
d = []
snids = ids2str(nids)
nids = []
for nid, mid, flds in col.db.execute(
"select id, mid, flds from notes where id in "+snids):
origFlds = flds
# does it match?
sflds = splitFields(flds)
if field:
try:
ord = mmap[str(mid)]
sflds[ord] = repl(sflds[ord])
except KeyError:
# note doesn't have that field
continue
else:
for c in range(len(sflds)):
sflds[c] = repl(sflds[c])
flds = joinFields(sflds)
if flds != origFlds:
nids.append(nid)
d.append(dict(nid=nid,flds=flds,u=col.usn(),m=intTime()))
if not d:
return 0
# replace
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
##########################################################################
def findDupes(col, fieldName, search=""):
# limit search to notes with applicable field name
if search:
search = "("+search+") "
search += "'%s:*'" % fieldName
# go through notes
vals = {}
dupes = []
fields = {}
def ordForMid(mid):
if mid not in fields:
model = col.models.get(mid)
for c, f in enumerate(model['flds']):
if f['name'].lower() == fieldName.lower():
fields[mid] = c
break
return fields[mid]
for nid, mid, flds in col.db.all(
"select id, mid, flds from notes where id in "+ids2str(
col.findNotes(search))):
flds = splitFields(flds)
ord = ordForMid(mid)
if ord is None:
continue
val = flds[ord]
# empty does not count as duplicate
if not val:
continue
if val not in vals:
vals[val] = []
vals[val].append(nid)
if len(vals[val]) == 2:
dupes.append((val, vals[val]))
return dupes