Anki/anki/models.py
Arthur Milchior b78480fe52 New model can be edited without full sync
This commit solves a problem that I had many time in the past. When I
create a new model, I usually want to edit it. Clone of existing
models present no interest by themselves. And as soon as I edit it, I
need to do a full sync.

As far as I understand ankiweb (which is sadly closed source), the
full sync is required because ankiweb needs to know that the model
associated to note type on the server did change. But since the model
is new, it has no note type associated to on the server, so there is
no need to do a full sync immediatly. Since the model is new, it also
means there is no risk of the inconsistency with a change made in
another computer/smartphone.

Thus, when a field/template is added, I check that the model is not
new by checking both whether it's id is not null, and also that it's
usn is not -1. (I set usn early in the model's life)

If it does not make into anki, then it'll be an add-on. But it's worth
a try first.
2019-10-24 04:44:52 +02:00

602 lines
19 KiB
Python

# -*- coding: utf-8 -*-
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import copy, re, json
from anki.utils import intTime, joinFields, splitFields, ids2str,\
checksum
from anki.lang import _
from anki.consts import *
from anki.hooks import runHook
import time
# Models
##########################################################################
# - careful not to add any lists/dicts/etc here, as they aren't deep copied
defaultModel = {
'sortf': 0,
'did': 1,
'latexPre': """\
\\documentclass[12pt]{article}
\\special{papersize=3in,5in}
\\usepackage[utf8]{inputenc}
\\usepackage{amssymb,amsmath}
\\pagestyle{empty}
\\setlength{\\parindent}{0in}
\\begin{document}
""",
'latexPost': "\\end{document}",
'mod': 0,
'usn': 0,
'vers': [], # FIXME: remove when other clients have caught up
'type': MODEL_STD,
'css': """\
.card {
font-family: arial;
font-size: 20px;
text-align: center;
color: black;
background-color: white;
}
"""
}
defaultField = {
'name': "",
'ord': None,
'sticky': False,
# the following alter editing, and are used as defaults for the
# template wizard
'rtl': False,
'font': "Arial",
'size': 20,
# reserved for future use
'media': [],
}
defaultTemplate = {
'name': "",
'ord': None,
'qfmt': "",
'afmt': "",
'did': None,
'bqfmt': "",
'bafmt': "",
# we don't define these so that we pick up system font size until set
#'bfont': "Arial",
#'bsize': 12,
}
class ModelManager:
# Saving/loading registry
#############################################################
def __init__(self, col):
self.col = col
def load(self, json_):
"Load registry from JSON."
self.changed = False
self.models = json.loads(json_)
def save(self, m=None, templates=False):
"Mark M modified if provided, and schedule registry flush."
if m and m['id']:
m['mod'] = intTime()
m['usn'] = self.col.usn()
self._updateRequired(m)
if templates:
self._syncTemplates(m)
self.changed = True
runHook("newModel")
def flush(self):
"Flush the registry if any models were changed."
if self.changed:
self.ensureNotEmpty()
self.col.db.execute("update col set models = ?",
json.dumps(self.models))
self.changed = False
def ensureNotEmpty(self):
if not self.models:
from anki.stdmodels import addBasicModel
addBasicModel(self.col)
return True
# Retrieving and creating models
#############################################################
def current(self, forDeck=True):
"Get current model."
m = self.get(self.col.decks.current().get('mid'))
if not forDeck or not m:
m = self.get(self.col.conf['curModel'])
return m or list(self.models.values())[0]
def setCurrent(self, m):
self.col.conf['curModel'] = m['id']
self.col.setMod()
def get(self, id):
"Get model with ID, or None."
id = str(id)
if id in self.models:
return self.models[id]
def all(self):
"Get all models."
return list(self.models.values())
def allNames(self):
return [m['name'] for m in self.all()]
def byName(self, name):
"Get model with NAME."
for m in list(self.models.values()):
if m['name'] == name:
return m
def new(self, name):
"Create a new model, save it in the registry, and return it."
# caller should call save() after modifying
m = defaultModel.copy()
m['name'] = name
m['mod'] = intTime()
m['flds'] = []
m['tmpls'] = []
m['tags'] = []
m['id'] = None
m['usn'] = self.col.usn()
return m
def rem(self, m):
"Delete model, and all its cards/notes."
self.col.modSchema(check=True)
current = self.current()['id'] == m['id']
# delete notes/cards
self.col.remCards(self.col.db.list("""
select id from cards where nid in (select id from notes where mid = ?)""",
m['id']))
# then the model
del self.models[str(m['id'])]
self.save()
# GUI should ensure last model is not deleted
if current:
self.setCurrent(list(self.models.values())[0])
def add(self, m):
self._setID(m)
self.update(m)
self.setCurrent(m)
self.save(m)
def ensureNameUnique(self, m):
for mcur in self.all():
if (mcur['name'] == m['name'] and mcur['id'] != m['id']):
m['name'] += "-" + checksum(str(time.time()))[:5]
break
def update(self, m):
"Add or update an existing model. Used for syncing and merging."
self.ensureNameUnique(m)
self.models[str(m['id'])] = m
# mark registry changed, but don't bump mod time
self.save()
def _setID(self, m):
while 1:
id = str(intTime(1000))
if id not in self.models:
break
m['id'] = id
def have(self, id):
return str(id) in self.models
def ids(self):
return list(self.models.keys())
# Tools
##################################################
def nids(self, m):
"Note ids for M."
return self.col.db.list(
"select id from notes where mid = ?", m['id'])
def useCount(self, m):
"Number of note using M."
return self.col.db.scalar(
"select count() from notes where mid = ?", m['id'])
def tmplUseCount(self, m, ord):
return self.col.db.scalar("""
select count() from cards, notes where cards.nid = notes.id
and notes.mid = ? and cards.ord = ?""", m['id'], ord)
# Copying
##################################################
def copy(self, m):
"Copy, save and return."
m2 = copy.deepcopy(m)
m2['name'] = _("%s copy") % m2['name']
self.add(m2)
m['usn'] = self.col.usn()
return m2
# Fields
##################################################
def newField(self, name):
f = defaultField.copy()
f['name'] = name
return f
def fieldMap(self, m):
"Mapping of field name -> (ord, field)."
return dict((f['name'], (f['ord'], f)) for f in m['flds'])
def fieldNames(self, m):
return [f['name'] for f in m['flds']]
def sortIdx(self, m):
return m['sortf']
def setSortIdx(self, m, idx):
assert 0 <= idx < len(m['flds'])
self.col.modSchema(check=True)
m['sortf'] = idx
self.col.updateFieldCache(self.nids(m))
self.save(m)
def addField(self, m, field):
self._modSchemaIfRequired(m)
m['flds'].append(field)
self._updateFieldOrds(m)
self.save(m)
def add(fields):
fields.append("")
return fields
self._transformFields(m, add)
def remField(self, m, field):
self.col.modSchema(check=True)
# save old sort field
sortFldName = m['flds'][m['sortf']]['name']
idx = m['flds'].index(field)
m['flds'].remove(field)
# restore old sort field if possible, or revert to first field
m['sortf'] = 0
for c, f in enumerate(m['flds']):
if f['name'] == sortFldName:
m['sortf'] = c
break
self._updateFieldOrds(m)
def delete(fields):
del fields[idx]
return fields
self._transformFields(m, delete)
if m['flds'][m['sortf']]['name'] != sortFldName:
# need to rebuild sort field
self.col.updateFieldCache(self.nids(m))
# saves
self.renameField(m, field, None)
def moveField(self, m, field, idx):
self.col.modSchema(check=True)
oldidx = m['flds'].index(field)
if oldidx == idx:
return
# remember old sort field
sortf = m['flds'][m['sortf']]
# move
m['flds'].remove(field)
m['flds'].insert(idx, field)
# restore sort field
m['sortf'] = m['flds'].index(sortf)
self._updateFieldOrds(m)
self.save(m)
def move(fields, oldidx=oldidx):
val = fields[oldidx]
del fields[oldidx]
fields.insert(idx, val)
return fields
self._transformFields(m, move)
def renameField(self, m, field, newName):
self.col.modSchema(check=True)
pat = r'{{([^{}]*)([:#^/]|[^:#/^}][^:}]*?:|)%s}}'
def wrap(txt):
def repl(match):
return '{{' + match.group(1) + match.group(2) + txt + '}}'
return repl
for t in m['tmpls']:
for fmt in ('qfmt', 'afmt'):
if newName:
t[fmt] = re.sub(
pat % re.escape(field['name']), wrap(newName), t[fmt])
else:
t[fmt] = re.sub(
pat % re.escape(field['name']), "", t[fmt])
field['name'] = newName
self.save(m)
def _updateFieldOrds(self, m):
for c, f in enumerate(m['flds']):
f['ord'] = c
def _transformFields(self, m, fn):
# model hasn't been added yet?
if not m['id']:
return
r = []
for (id, flds) in self.col.db.execute(
"select id, flds from notes where mid = ?", m['id']):
r.append((joinFields(fn(splitFields(flds))),
intTime(), self.col.usn(), id))
self.col.db.executemany(
"update notes set flds=?,mod=?,usn=? where id = ?", r)
# Templates
##################################################
def newTemplate(self, name):
t = defaultTemplate.copy()
t['name'] = name
return t
def addTemplate(self, m, template):
"Note: should col.genCards() afterwards."
self._modSchemaIfRequired(m)
m['tmpls'].append(template)
self._updateTemplOrds(m)
self.save(m)
def remTemplate(self, m, template):
"False if removing template would leave orphan notes."
assert len(m['tmpls']) > 1
# find cards using this template
ord = m['tmpls'].index(template)
cids = self.col.db.list("""
select c.id from cards c, notes f where c.nid=f.id and mid = ? and ord = ?""",
m['id'], ord)
# all notes with this template must have at least two cards, or we
# could end up creating orphaned notes
if self.col.db.scalar("""
select nid, count() from cards where
nid in (select nid from cards where id in %s)
group by nid
having count() < 2
limit 1""" % ids2str(cids)):
return False
# ok to proceed; remove cards
self.col.modSchema(check=True)
self.col.remCards(cids)
# shift ordinals
self.col.db.execute("""
update cards set ord = ord - 1, usn = ?, mod = ?
where nid in (select id from notes where mid = ?) and ord > ?""",
self.col.usn(), intTime(), m['id'], ord)
m['tmpls'].remove(template)
self._updateTemplOrds(m)
self.save(m)
return True
def _updateTemplOrds(self, m):
for c, t in enumerate(m['tmpls']):
t['ord'] = c
def moveTemplate(self, m, template, idx):
oldidx = m['tmpls'].index(template)
if oldidx == idx:
return
oldidxs = dict((id(t), t['ord']) for t in m['tmpls'])
m['tmpls'].remove(template)
m['tmpls'].insert(idx, template)
self._updateTemplOrds(m)
# generate change map
map = []
for t in m['tmpls']:
map.append("when ord = %d then %d" % (oldidxs[id(t)], t['ord']))
# apply
self.save(m)
self.col.db.execute("""
update cards set ord = (case %s end),usn=?,mod=? where nid in (
select id from notes where mid = ?)""" % " ".join(map),
self.col.usn(), intTime(), m['id'])
def _syncTemplates(self, m):
rem = self.col.genCards(self.nids(m))
# Model changing
##########################################################################
# - maps are ord->ord, and there should not be duplicate targets
# - newModel should be self if model is not changing
def change(self, m, nids, newModel, fmap, cmap):
self.col.modSchema(check=True)
assert newModel['id'] == m['id'] or (fmap and cmap)
if fmap:
self._changeNotes(nids, newModel, fmap)
if cmap:
self._changeCards(nids, m, newModel, cmap)
self.col.genCards(nids)
def _changeNotes(self, nids, newModel, map):
d = []
nfields = len(newModel['flds'])
for (nid, flds) in self.col.db.execute(
"select id, flds from notes where id in "+ids2str(nids)):
newflds = {}
flds = splitFields(flds)
for old, new in list(map.items()):
newflds[new] = flds[old]
flds = []
for c in range(nfields):
flds.append(newflds.get(c, ""))
flds = joinFields(flds)
d.append(dict(nid=nid, flds=flds, mid=newModel['id'],
m=intTime(),u=self.col.usn()))
self.col.db.executemany(
"update notes set flds=:flds,mid=:mid,mod=:m,usn=:u where id = :nid", d)
self.col.updateFieldCache(nids)
def _changeCards(self, nids, oldModel, newModel, map):
d = []
deleted = []
for (cid, ord) in self.col.db.execute(
"select id, ord from cards where nid in "+ids2str(nids)):
# if the src model is a cloze, we ignore the map, as the gui
# doesn't currently support mapping them
if oldModel['type'] == MODEL_CLOZE:
new = ord
if newModel['type'] != MODEL_CLOZE:
# if we're mapping to a regular note, we need to check if
# the destination ord is valid
if len(newModel['tmpls']) <= ord:
new = None
else:
# mapping from a regular note, so the map should be valid
new = map[ord]
if new is not None:
d.append(dict(
cid=cid,new=new,u=self.col.usn(),m=intTime()))
else:
deleted.append(cid)
self.col.db.executemany(
"update cards set ord=:new,usn=:u,mod=:m where id=:cid",
d)
self.col.remCards(deleted)
def _modSchemaIfRequired(self, m):
if m['id'] and m["usn"] != -1:
self.col.modSchema(check=True)
# Schema hash
##########################################################################
def scmhash(self, m):
"Return a hash of the schema, to see if models are compatible."
s = ""
for f in m['flds']:
s += f['name']
for t in m['tmpls']:
s += t['name']
return checksum(s)
# Required field/text cache
##########################################################################
def _updateRequired(self, m):
if m['type'] == MODEL_CLOZE:
# nothing to do
return
req = []
flds = [f['name'] for f in m['flds']]
for t in m['tmpls']:
ret = self._reqForTemplate(m, flds, t)
req.append((t['ord'], ret[0], ret[1]))
m['req'] = req
def _reqForTemplate(self, m, flds, t):
a = []
b = []
for f in flds:
a.append("ankiflag")
b.append("")
data = [1, 1, m['id'], 1, t['ord'], "", joinFields(a), 0]
full = self.col._renderQA(data)['q']
data = [1, 1, m['id'], 1, t['ord'], "", joinFields(b), 0]
empty = self.col._renderQA(data)['q']
# if full and empty are the same, the template is invalid and there is
# no way to satisfy it
if full == empty:
return "none", [], []
type = 'all'
req = []
for i in range(len(flds)):
tmp = a[:]
tmp[i] = ""
data[6] = joinFields(tmp)
# if no field content appeared, field is required
if "ankiflag" not in self.col._renderQA(data)['q']:
req.append(i)
if req:
return type, req
# if there are no required fields, switch to any mode
type = 'any'
req = []
for i in range(len(flds)):
tmp = b[:]
tmp[i] = "1"
data[6] = joinFields(tmp)
# if not the same as empty, this field can make the card non-blank
if self.col._renderQA(data)['q'] != empty:
req.append(i)
return type, req
def availOrds(self, m, flds):
"Given a joined field string, return available template ordinals."
if m['type'] == MODEL_CLOZE:
return self._availClozeOrds(m, flds)
fields = {}
for c, f in enumerate(splitFields(flds)):
fields[c] = f.strip()
avail = []
for ord, type, req in m['req']:
# unsatisfiable template
if type == "none":
continue
# AND requirement?
elif type == "all":
ok = True
for idx in req:
if not fields[idx]:
# missing and was required
ok = False
break
if not ok:
continue
# OR requirement?
elif type == "any":
ok = False
for idx in req:
if fields[idx]:
ok = True
break
if not ok:
continue
avail.append(ord)
return avail
def _availClozeOrds(self, m, flds, allowEmpty=True):
sflds = splitFields(flds)
map = self.fieldMap(m)
ords = set()
matches = re.findall("{{[^}]*?cloze:(?:[^}]?:)*(.+?)}}", m['tmpls'][0]['qfmt'])
matches += re.findall("<%cloze:(.+?)%>", m['tmpls'][0]['qfmt'])
for fname in matches:
if fname not in map:
continue
ord = map[fname][0]
ords.update([int(m)-1 for m in re.findall(
r"(?s){{c(\d+)::.+?}}", sflds[ord])])
if -1 in ords:
ords.remove(-1)
if not ords and allowEmpty:
# empty clozes use first ord
return [0]
return list(ords)
# Sync handling
##########################################################################
def beforeUpload(self):
for m in self.all():
m['usn'] = 0
self.save()