refactor cloze deletions and text:field, add forecast

Previously cloze deletions were handled by copying the contents of one field
into another and applying transforms to it. This had a number of problems:
- after you add a card, you can't undo the cloze deletion
- if you spot a mistake, you have to edit it twice (or more if you have more
  than one cloze for a sentence)
- making multiple clozes requires copying & pasting the sentence multiple
  times
- this also lead to much bigger decks if the sentences being cloze-deleted are
  large
- related clozes can't be spaced apart as siblings

To address these issues, we introduce the idea of cloze tags in the card
template and fields. If the template has the text:

{{cloze:1:field}}

And a field has the following contents:

{{c1::hello}}

Then the template will automatically replace that part of the text with either
occluded text, or a highlighted answer. All other clozes in the field are
displayed normally.

At the same time, we add support for text: into the template library, instead
of manually creating text: fields in the dict for every field.

Finally, add a forecast routine to get the due counts for the next week, which
is used in the GUI.
This commit is contained in:
Damien Elmes 2011-03-21 19:24:14 +09:00
parent 8705085200
commit ed75e4bee2
6 changed files with 145 additions and 17 deletions

View file

@ -194,7 +194,7 @@ qconf=?, conf=?, data=?""",
# check we have card models available
cms = self.findTemplates(fact)
if not cms:
return None
return 0
# flush the fact
fact.id = self.nextID("fid")
fact.flush()
@ -430,13 +430,10 @@ select id from cards where fid in (select id from facts where mid = ?)""",
fields = {}
for (name, (idx, conf)) in model.fieldMap().items():
fields[name] = flist[idx]
fields["text:"+name] = stripHTML(fields[name])
if fields[name]:
fields["text:"+name] = stripHTML(fields[name])
fields[name] = '<span class="fm%s-%s">%s</span>' % (
hexifyID(data[2]), hexifyID(idx), fields[name])
else:
fields["text:"+name] = ""
fields[name] = ""
fields['Tags'] = data[5]
fields['Model'] = model.name
@ -446,6 +443,10 @@ select id from cards where fid in (select id from facts where mid = ?)""",
# render q & a
d = dict(id=data[0])
for (type, format) in (("q", template['qfmt']), ("a", template['afmt'])):
if type == "q":
format = format.replace("cloze:", "cq:")
else:
format = format.replace("cloze:", "ca:")
fields = runFilter("mungeFields", fields, model, gname, data, self)
html = anki.template.render(format, fields)
d[type] = runFilter("mungeQA", html, fields, model, gname, data, self)

View file

@ -57,6 +57,17 @@ class Scheduler(object):
"Does not include fetched but unanswered."
return (self.newCount, self.lrnCount, self.revCount)
def dueForecast(self, days=7):
"Return counts over next DAYS. Includes today."
return self.db.list("""
select count() from cards
where queue = 2 %s
and due between ? and ?
group by due
order by due""" % self._groupLimit("rev"),
self.today,
self.today+days-1)
def countIdx(self, card):
return card.queue

View file

@ -35,3 +35,30 @@ def BasicModel(deck):
return m
models.append(BasicModel)
# Cloze
##########################################################################
def ClozeModel(deck):
m = Model(deck)
m.name = _("Cloze")
fm = m.newField()
fm['name'] = _("Text")
fm['req'] = True
fm['uniq'] = True
m.addField(fm)
fm = m.newField()
fm['name'] = _("Notes")
m.addField(fm)
for i in range(8):
n = i+1
t = m.newTemplate()
t['name'] = _("Cloze") + " %d" % n
t['qfmt'] = ("{{#cloze:%d:Text}}<br>{{cloze:%d:%s}}<br>"+
"{{/cloze:%d:Text}}") % (n, n, _("Text"), n)
t['afmt'] = ("{{cloze:%d:" + _("Text") + "}}") % n
t['afmt'] += "<br>{{" + _("Notes") + "}}"
m.addTemplate(t)
return m
models.append(ClozeModel)

View file

@ -9,7 +9,7 @@ from anki.lang import _
from anki.utils import intTime
from anki.db import DB
from anki.deck import _Deck
from anki.stdmodels import BasicModel
from anki.stdmodels import BasicModel, ClozeModel
from anki.errors import AnkiError
def Deck(path, queue=True, lock=True):
@ -33,6 +33,9 @@ def Deck(path, queue=True, lock=True):
_upgradeDeck(deck, ver)
elif create:
deck.addModel(BasicModel(deck))
deck.addModel(ClozeModel(deck))
# default to basic
deck.conf['currentModelId'] = 1
deck.save()
if lock:
deck.lock()

View file

@ -1,6 +1,10 @@
import re
import cgi
import collections
from anki.utils import stripHTML
clozeReg = r"\{\{c%s::(.*?)(::(.*?))?\}\}"
modifiers = {}
def modifier(symbol):
@ -79,7 +83,19 @@ class Template(object):
section, section_name, inner = match.group(0, 1, 2)
section_name = section_name.strip()
it = get_or_attr(context, section_name, None)
# check for cloze
m = re.match("c[qa]:(\d+):(.+)", section_name)
if m:
# get full field text
txt = get_or_attr(context, m.group(2), None)
m = re.search(clozeReg%m.group(1), txt)
if m:
it = m.group(1)
else:
it = None
else:
it = get_or_attr(context, section_name, None)
replacer = ''
# if it and isinstance(it, collections.Callable):
# replacer = it(inner)
@ -135,18 +151,39 @@ class Template(object):
@modifier(None)
def render_unescaped(self, tag_name=None, context=None):
"""Render a tag without escaping it."""
return unicode(get_or_attr(context, tag_name, '{unknown field %s}' % tag_name))
if tag_name.startswith("text:"):
tag = tag_name[5:]
txt = get_or_attr(context, tag)
if txt:
return stripHTML(txt)
return ""
elif tag_name.startswith("cq:") or tag_name.startswith("ca:"):
m = re.match("c(.):(\d+):(.+)", tag_name)
(type, ord, tag) = (m.group(1), m.group(2), m.group(3))
txt = get_or_attr(context, tag)
if txt:
return self.clozeText(txt, ord, type)
return ""
return get_or_attr(context, tag_name, '{unknown field %s}' % tag_name)
# @modifier('>')
# def render_partial(self, tag_name=None, context=None):
# """Renders a partial within the current context."""
# # Import view here to avoid import loop
# from pystache.view import View
# view = View(context=context)
# view.template_name = tag_name
# return view.render()
# fixme: need a way to conditionally add these, so that including extra
# fields in the question fmt doesn't make empty clozes
def clozeText(self, txt, ord, type):
reg = clozeReg
m = re.search(reg%ord, txt)
if not m:
# cloze doesn't exist; return empty
return ""
# replace chosen cloze with type
if type == "q":
if m.group(2):
txt = re.sub(reg%ord, "<b>...(\\3)</b>", txt)
else:
txt = re.sub(reg%ord, "<b>...</b>", txt)
else:
txt = re.sub(reg%ord, "<b>\\1</b>", txt)
# and display other clozes normally
return re.sub(reg%".*?", "\\1", txt)
@modifier('=')
def render_delimiter(self, tag_name=None, context=None):

View file

@ -89,6 +89,55 @@ def test_templates():
assert c.ord == 0
stripHTML(c.q()) == "2"
def test_text():
d = getEmptyDeck()
m = d.currentModel()
m.templates[0]['qfmt'] = "{{text:Front}}"
m.flush()
f = d.newFact()
f['Front'] = u'hello<b>world'
d.addFact(f)
assert f.cards()[0].q() == "helloworld"
def test_cloze():
d = getEmptyDeck()
d.conf['currentModelId'] = 2
f = d.newFact()
assert f.model().name == "Cloze"
# a cloze model with no clozes is empty
f['Text'] = u'nothing'
assert d.addFact(f) == 0
# try with one cloze
f['Text'] = "hello {{c1::world}}"
assert d.addFact(f) == 1
assert "hello <b>...</b>" in f.cards()[0].q()
assert "hello <b>world</b>" in f.cards()[0].a()
# and with a comment
f = d.newFact()
f['Text'] = "hello {{c1::world::typical}}"
assert d.addFact(f) == 1
assert "<b>...(typical)</b>" in f.cards()[0].q()
assert "<b>world</b>" in f.cards()[0].a()
# and with 2 clozes
f = d.newFact()
f['Text'] = "hello {{c1::world}} {{c2::bar}}"
assert d.addFact(f) == 2
(c1, c2) = f.cards()
assert "<b>...</b> bar" in c1.q()
assert "<b>world</b> bar" in c1.a()
assert "world <b>...</b>" in c2.q()
assert "world <b>bar</b>" in c2.a()
# clozes should be supported in sections too
m = d.currentModel()
m.templates[0]['qfmt'] = "{{#cloze:1:Text}}{{Notes}}{{/cloze:1:Text}}"
m.flush()
f = d.newFact()
f['Text'] = "hello"
f['Notes'] = "world"
assert d.addFact(f) == 0
f['Text'] = "hello {{c1::foo}}"
assert d.addFact(f) == 1
def test_modelChange():
print "model change"
return