mirror of
https://github.com/ankitects/anki.git
synced 2025-11-14 08:37:11 -05:00
Earlier today I pushed a change that split this code up into multiple repos, but that has proved to complicate things too much. So we're back to a single repo, except the individual submodules are better separated than they were before. The README files need updating again; I will push them out soon. Aside from splitting out the different modules, the sound code has moved from from anki to aqt.
610 lines
19 KiB
Python
610 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 collections
|
|
import json
|
|
import re
|
|
|
|
import aqt
|
|
from anki.consts import *
|
|
from anki.hooks import runFilter
|
|
from anki.lang import _, ngettext
|
|
from anki.utils import bodyClass, isMac, isWin, joinFields
|
|
from aqt.qt import *
|
|
from aqt.sound import clearAudioQueue, playFromText
|
|
from aqt.utils import (
|
|
askUser,
|
|
downArrow,
|
|
getOnlyText,
|
|
mungeQA,
|
|
openHelp,
|
|
restoreGeom,
|
|
saveGeom,
|
|
showInfo,
|
|
showWarning,
|
|
)
|
|
from aqt.webview import AnkiWebView
|
|
|
|
|
|
class CardLayout(QDialog):
|
|
def __init__(self, mw, note, ord=0, parent=None, addMode=False):
|
|
QDialog.__init__(self, parent or mw, Qt.Window)
|
|
mw.setupDialogGC(self)
|
|
self.mw = aqt.mw
|
|
self.parent = parent or mw
|
|
self.note = note
|
|
self.ord = ord
|
|
self.col = self.mw.col
|
|
self.mm = self.mw.col.models
|
|
self.model = note.model()
|
|
self.mw.checkpoint(_("Card Types"))
|
|
self.addMode = addMode
|
|
if addMode:
|
|
# save it to DB temporarily
|
|
self.emptyFields = []
|
|
for name, val in list(note.items()):
|
|
if val.strip():
|
|
continue
|
|
self.emptyFields.append(name)
|
|
note[name] = "(%s)" % name
|
|
note.flush()
|
|
self.removeColons()
|
|
self.setupTopArea()
|
|
self.setupMainArea()
|
|
self.setupButtons()
|
|
self.setupShortcuts()
|
|
self.setWindowTitle(_("Card Types for %s") % self.model["name"])
|
|
v1 = QVBoxLayout()
|
|
v1.addWidget(self.topArea)
|
|
v1.addWidget(self.mainArea)
|
|
v1.addLayout(self.buttons)
|
|
v1.setContentsMargins(12, 12, 12, 12)
|
|
self.setLayout(v1)
|
|
self.redraw()
|
|
restoreGeom(self, "CardLayout")
|
|
self.setWindowModality(Qt.ApplicationModal)
|
|
self.show()
|
|
# take the focus away from the first input area when starting up,
|
|
# as users tend to accidentally type into the template
|
|
self.setFocus()
|
|
|
|
def redraw(self):
|
|
did = None
|
|
if hasattr(self.parent, "deckChooser"):
|
|
did = self.parent.deckChooser.selectedId()
|
|
self.cards = self.col.previewCards(self.note, 2, did=did)
|
|
idx = self.ord
|
|
if idx >= len(self.cards):
|
|
self.ord = len(self.cards) - 1
|
|
|
|
self.redrawing = True
|
|
self.updateTopArea()
|
|
self.updateMainArea()
|
|
self.redrawing = False
|
|
self.onCardSelected(self.ord)
|
|
|
|
def setupShortcuts(self):
|
|
for i in range(1, 9):
|
|
QShortcut(
|
|
QKeySequence("Ctrl+%d" % i),
|
|
self,
|
|
activated=lambda i=i: self.selectCard(i),
|
|
)
|
|
|
|
def selectCard(self, n):
|
|
self.ord = n - 1
|
|
self.redraw()
|
|
|
|
def setupTopArea(self):
|
|
self.topArea = QWidget()
|
|
self.topAreaForm = aqt.forms.clayout_top.Ui_Form()
|
|
self.topAreaForm.setupUi(self.topArea)
|
|
self.topAreaForm.templateOptions.setText(_("Options") + " " + downArrow())
|
|
self.topAreaForm.templateOptions.clicked.connect(self.onMore)
|
|
self.topAreaForm.templatesBox.currentIndexChanged.connect(self.onCardSelected)
|
|
|
|
def updateTopArea(self):
|
|
cnt = self.mw.col.models.useCount(self.model)
|
|
self.topAreaForm.changesLabel.setText(
|
|
ngettext(
|
|
"Changes below will affect the %(cnt)d note that uses this card type.",
|
|
"Changes below will affect the %(cnt)d notes that use this card type.",
|
|
cnt,
|
|
)
|
|
% dict(cnt=cnt)
|
|
)
|
|
self.updateCardNames()
|
|
|
|
def updateCardNames(self):
|
|
self.redrawing = True
|
|
combo = self.topAreaForm.templatesBox
|
|
combo.clear()
|
|
combo.addItems(self._summarizedName(t) for t in self.model["tmpls"])
|
|
combo.setCurrentIndex(self.ord)
|
|
combo.setEnabled(not self._isCloze())
|
|
self.redrawing = False
|
|
|
|
def _summarizedName(self, tmpl):
|
|
return "{}: {} -> {}".format(
|
|
tmpl["name"],
|
|
self._fieldsOnTemplate(tmpl["qfmt"]),
|
|
self._fieldsOnTemplate(tmpl["afmt"]),
|
|
)
|
|
|
|
def _fieldsOnTemplate(self, fmt):
|
|
matches = re.findall("{{[^#/}]+?}}", fmt)
|
|
charsAllowed = 30
|
|
result = collections.OrderedDict()
|
|
for m in matches:
|
|
# strip off mustache
|
|
m = re.sub(r"[{}]", "", m)
|
|
# strip off modifiers
|
|
m = m.split(":")[-1]
|
|
# don't show 'FrontSide'
|
|
if m == "FrontSide":
|
|
continue
|
|
|
|
if m not in result:
|
|
result[m] = True
|
|
charsAllowed -= len(m)
|
|
if charsAllowed <= 0:
|
|
break
|
|
|
|
str = "+".join(result.keys())
|
|
if charsAllowed <= 0:
|
|
str += "+..."
|
|
return str
|
|
|
|
def _isCloze(self):
|
|
return self.model["type"] == MODEL_CLOZE
|
|
|
|
def setupMainArea(self):
|
|
w = self.mainArea = QWidget()
|
|
l = QHBoxLayout()
|
|
l.setContentsMargins(0, 0, 0, 0)
|
|
l.setSpacing(3)
|
|
left = QWidget()
|
|
# template area
|
|
tform = self.tform = aqt.forms.template.Ui_Form()
|
|
tform.setupUi(left)
|
|
tform.label1.setText(" →")
|
|
tform.label2.setText(" →")
|
|
tform.labelc1.setText(" ↗")
|
|
tform.labelc2.setText(" ↘")
|
|
if self.style().objectName() == "gtk+":
|
|
# gtk+ requires margins in inner layout
|
|
tform.tlayout1.setContentsMargins(0, 11, 0, 0)
|
|
tform.tlayout2.setContentsMargins(0, 11, 0, 0)
|
|
tform.tlayout3.setContentsMargins(0, 11, 0, 0)
|
|
tform.groupBox_3.setTitle(_("Styling (shared between cards)"))
|
|
tform.front.textChanged.connect(self.saveCard)
|
|
tform.css.textChanged.connect(self.saveCard)
|
|
tform.back.textChanged.connect(self.saveCard)
|
|
l.addWidget(left, 5)
|
|
# preview area
|
|
right = QWidget()
|
|
pform = self.pform = aqt.forms.preview.Ui_Form()
|
|
pform.setupUi(right)
|
|
if self.style().objectName() == "gtk+":
|
|
# gtk+ requires margins in inner layout
|
|
pform.frontPrevBox.setContentsMargins(0, 11, 0, 0)
|
|
pform.backPrevBox.setContentsMargins(0, 11, 0, 0)
|
|
|
|
self.setupWebviews()
|
|
|
|
l.addWidget(right, 5)
|
|
w.setLayout(l)
|
|
|
|
def setupWebviews(self):
|
|
pform = self.pform
|
|
pform.frontWeb = AnkiWebView()
|
|
pform.frontPrevBox.addWidget(pform.frontWeb)
|
|
pform.backWeb = AnkiWebView()
|
|
pform.backPrevBox.addWidget(pform.backWeb)
|
|
jsinc = [
|
|
"jquery.js",
|
|
"browsersel.js",
|
|
"mathjax/conf.js",
|
|
"mathjax/MathJax.js",
|
|
"reviewer.js",
|
|
]
|
|
pform.frontWeb.stdHtml(
|
|
self.mw.reviewer.revHtml(), css=["reviewer.css"], js=jsinc
|
|
)
|
|
pform.backWeb.stdHtml(
|
|
self.mw.reviewer.revHtml(), css=["reviewer.css"], js=jsinc
|
|
)
|
|
|
|
def updateMainArea(self):
|
|
if self._isCloze():
|
|
cnt = len(self.mm.availOrds(self.model, joinFields(self.note.fields)))
|
|
for g in self.pform.groupBox, self.pform.groupBox_2:
|
|
g.setTitle(g.title() + _(" (1 of %d)") % max(cnt, 1))
|
|
|
|
def onRemove(self):
|
|
if len(self.model["tmpls"]) < 2:
|
|
return showInfo(_("At least one card type is required."))
|
|
idx = self.ord
|
|
cards = self.mm.tmplUseCount(self.model, idx)
|
|
cards = ngettext("%d card", "%d cards", cards) % cards
|
|
msg = _("Delete the '%(a)s' card type, and its %(b)s?") % dict(
|
|
a=self.model["tmpls"][idx]["name"], b=cards
|
|
)
|
|
if not askUser(msg):
|
|
return
|
|
if not self.mm.remTemplate(self.model, self.cards[idx].template()):
|
|
return showWarning(
|
|
_(
|
|
"""\
|
|
Removing this card type would cause one or more notes to be deleted. \
|
|
Please create a new card type first."""
|
|
)
|
|
)
|
|
self.redraw()
|
|
|
|
def removeColons(self):
|
|
# colons in field names conflict with the template language
|
|
for fld in self.model["flds"]:
|
|
if ":" in fld["name"]:
|
|
self.mm.renameField(self.model, fld, fld["name"])
|
|
|
|
# Buttons
|
|
##########################################################################
|
|
|
|
def setupButtons(self):
|
|
l = self.buttons = QHBoxLayout()
|
|
help = QPushButton(_("Help"))
|
|
help.setAutoDefault(False)
|
|
l.addWidget(help)
|
|
help.clicked.connect(self.onHelp)
|
|
l.addStretch()
|
|
addField = QPushButton(_("Add Field"))
|
|
addField.setAutoDefault(False)
|
|
l.addWidget(addField)
|
|
addField.clicked.connect(self.onAddField)
|
|
if not self._isCloze():
|
|
flip = QPushButton(_("Flip"))
|
|
flip.setAutoDefault(False)
|
|
l.addWidget(flip)
|
|
flip.clicked.connect(self.onFlip)
|
|
l.addStretch()
|
|
close = QPushButton(_("Close"))
|
|
close.setAutoDefault(False)
|
|
l.addWidget(close)
|
|
close.clicked.connect(self.accept)
|
|
|
|
# Cards
|
|
##########################################################################
|
|
|
|
def onCardSelected(self, idx):
|
|
if self.redrawing:
|
|
return
|
|
self.card = self.cards[idx]
|
|
self.ord = idx
|
|
self.playedAudio = {}
|
|
self.readCard()
|
|
self.renderPreview()
|
|
|
|
def readCard(self):
|
|
t = self.card.template()
|
|
self.redrawing = True
|
|
self.tform.front.setPlainText(t["qfmt"])
|
|
self.tform.css.setPlainText(self.model["css"])
|
|
self.tform.back.setPlainText(t["afmt"])
|
|
self.tform.front.setAcceptRichText(False)
|
|
self.tform.css.setAcceptRichText(False)
|
|
self.tform.back.setAcceptRichText(False)
|
|
self.tform.front.setTabStopWidth(30)
|
|
self.tform.css.setTabStopWidth(30)
|
|
self.tform.back.setTabStopWidth(30)
|
|
self.redrawing = False
|
|
|
|
def saveCard(self):
|
|
if self.redrawing:
|
|
return
|
|
text = self.tform.front.toPlainText()
|
|
self.card.template()["qfmt"] = text
|
|
text = self.tform.css.toPlainText()
|
|
self.card.model()["css"] = text
|
|
text = self.tform.back.toPlainText()
|
|
self.card.template()["afmt"] = text
|
|
self.renderPreview()
|
|
|
|
# Preview
|
|
##########################################################################
|
|
|
|
_previewTimer = None
|
|
|
|
def renderPreview(self):
|
|
# schedule a preview when timing stops
|
|
self.cancelPreviewTimer()
|
|
self._previewTimer = self.mw.progress.timer(500, self._renderPreview, False)
|
|
|
|
def cancelPreviewTimer(self):
|
|
if self._previewTimer:
|
|
self._previewTimer.stop()
|
|
self._previewTimer = None
|
|
|
|
def _renderPreview(self):
|
|
self.cancelPreviewTimer()
|
|
|
|
c = self.card
|
|
ti = self.maybeTextInput
|
|
|
|
bodyclass = bodyClass(self.mw.col, c)
|
|
|
|
q = ti(mungeQA(self.mw.col, c.q(reload=True)))
|
|
q = runFilter("prepareQA", q, c, "clayoutQuestion")
|
|
|
|
a = ti(mungeQA(self.mw.col, c.a()), type="a")
|
|
a = runFilter("prepareQA", a, c, "clayoutAnswer")
|
|
|
|
# use _showAnswer to avoid the longer delay
|
|
self.pform.frontWeb.eval("_showAnswer(%s,'%s');" % (json.dumps(q), bodyclass))
|
|
self.pform.backWeb.eval("_showAnswer(%s, '%s');" % (json.dumps(a), bodyclass))
|
|
|
|
clearAudioQueue()
|
|
if c.id not in self.playedAudio:
|
|
playFromText(c.q())
|
|
playFromText(c.a())
|
|
self.playedAudio[c.id] = True
|
|
|
|
self.updateCardNames()
|
|
|
|
def maybeTextInput(self, txt, type="q"):
|
|
if "[[type:" not in txt:
|
|
return txt
|
|
origLen = len(txt)
|
|
txt = txt.replace("<hr id=answer>", "")
|
|
hadHR = origLen != len(txt)
|
|
|
|
def answerRepl(match):
|
|
res = self.mw.reviewer.correct("exomple", "an example")
|
|
if hadHR:
|
|
res = "<hr id=answer>" + res
|
|
return res
|
|
|
|
if type == "q":
|
|
repl = "<input id='typeans' type=text value='exomple' readonly='readonly'>"
|
|
repl = "<center>%s</center>" % repl
|
|
else:
|
|
repl = answerRepl
|
|
return re.sub(r"\[\[type:.+?\]\]", repl, txt)
|
|
|
|
# Card operations
|
|
######################################################################
|
|
|
|
def onRename(self):
|
|
name = getOnlyText(_("New name:"), default=self.card.template()["name"])
|
|
if not name:
|
|
return
|
|
if name in [
|
|
c.template()["name"] for c in self.cards if c.template()["ord"] != self.ord
|
|
]:
|
|
return showWarning(_("That name is already used."))
|
|
self.card.template()["name"] = name
|
|
self.redraw()
|
|
|
|
def onReorder(self):
|
|
n = len(self.cards)
|
|
cur = self.card.template()["ord"] + 1
|
|
pos = getOnlyText(_("Enter new card position (1...%s):") % n, default=str(cur))
|
|
if not pos:
|
|
return
|
|
try:
|
|
pos = int(pos)
|
|
except ValueError:
|
|
return
|
|
if pos < 1 or pos > n:
|
|
return
|
|
if pos == cur:
|
|
return
|
|
pos -= 1
|
|
self.mm.moveTemplate(self.model, self.card.template(), pos)
|
|
self.ord = pos
|
|
self.redraw()
|
|
|
|
def _newCardName(self):
|
|
n = len(self.cards) + 1
|
|
while 1:
|
|
name = _("Card %d") % n
|
|
if name not in [c.template()["name"] for c in self.cards]:
|
|
break
|
|
n += 1
|
|
return name
|
|
|
|
def onAddCard(self):
|
|
cnt = self.mw.col.models.useCount(self.model)
|
|
txt = (
|
|
ngettext(
|
|
"This will create %d card. Proceed?",
|
|
"This will create %d cards. Proceed?",
|
|
cnt,
|
|
)
|
|
% cnt
|
|
)
|
|
if not askUser(txt):
|
|
return
|
|
name = self._newCardName()
|
|
t = self.mm.newTemplate(name)
|
|
old = self.card.template()
|
|
t["qfmt"] = old["qfmt"]
|
|
t["afmt"] = old["afmt"]
|
|
self.mm.addTemplate(self.model, t)
|
|
self.ord = len(self.cards)
|
|
self.redraw()
|
|
|
|
def onFlip(self):
|
|
old = self.card.template()
|
|
self._flipQA(old, old)
|
|
self.redraw()
|
|
|
|
def _flipQA(self, src, dst):
|
|
m = re.match("(?s)(.+)<hr id=answer>(.+)", src["afmt"])
|
|
if not m:
|
|
showInfo(
|
|
_(
|
|
"""\
|
|
Anki couldn't find the line between the question and answer. Please \
|
|
adjust the template manually to switch the question and answer."""
|
|
)
|
|
)
|
|
return
|
|
dst["afmt"] = "{{FrontSide}}\n\n<hr id=answer>\n\n%s" % src["qfmt"]
|
|
dst["qfmt"] = m.group(2).strip()
|
|
return True
|
|
|
|
def onMore(self):
|
|
m = QMenu(self)
|
|
|
|
if not self._isCloze():
|
|
a = m.addAction(_("Add Card Type..."))
|
|
a.triggered.connect(self.onAddCard)
|
|
|
|
a = m.addAction(_("Remove Card Type..."))
|
|
a.triggered.connect(self.onRemove)
|
|
|
|
a = m.addAction(_("Rename Card Type..."))
|
|
a.triggered.connect(self.onRename)
|
|
|
|
a = m.addAction(_("Reposition Card Type..."))
|
|
a.triggered.connect(self.onReorder)
|
|
|
|
m.addSeparator()
|
|
|
|
t = self.card.template()
|
|
if t["did"]:
|
|
s = _(" (on)")
|
|
else:
|
|
s = _(" (off)")
|
|
a = m.addAction(_("Deck Override...") + s)
|
|
a.triggered.connect(self.onTargetDeck)
|
|
|
|
a = m.addAction(_("Browser Appearance..."))
|
|
a.triggered.connect(self.onBrowserDisplay)
|
|
|
|
m.exec_(self.topAreaForm.templateOptions.mapToGlobal(QPoint(0, 0)))
|
|
|
|
def onBrowserDisplay(self):
|
|
d = QDialog()
|
|
f = aqt.forms.browserdisp.Ui_Dialog()
|
|
f.setupUi(d)
|
|
t = self.card.template()
|
|
f.qfmt.setText(t.get("bqfmt", ""))
|
|
f.afmt.setText(t.get("bafmt", ""))
|
|
if t.get("bfont"):
|
|
f.overrideFont.setChecked(True)
|
|
f.font.setCurrentFont(QFont(t.get("bfont", "Arial")))
|
|
f.fontSize.setValue(t.get("bsize", 12))
|
|
f.buttonBox.accepted.connect(lambda: self.onBrowserDisplayOk(f))
|
|
d.exec_()
|
|
|
|
def onBrowserDisplayOk(self, f):
|
|
t = self.card.template()
|
|
t["bqfmt"] = f.qfmt.text().strip()
|
|
t["bafmt"] = f.afmt.text().strip()
|
|
if f.overrideFont.isChecked():
|
|
t["bfont"] = f.font.currentFont().family()
|
|
t["bsize"] = f.fontSize.value()
|
|
else:
|
|
for key in ("bfont", "bsize"):
|
|
if key in t:
|
|
del t[key]
|
|
|
|
def onTargetDeck(self):
|
|
from aqt.tagedit import TagEdit
|
|
|
|
t = self.card.template()
|
|
d = QDialog(self)
|
|
d.setWindowTitle("Anki")
|
|
d.setMinimumWidth(400)
|
|
l = QVBoxLayout()
|
|
lab = QLabel(
|
|
_(
|
|
"""\
|
|
Enter deck to place new %s cards in, or leave blank:"""
|
|
)
|
|
% self.card.template()["name"]
|
|
)
|
|
lab.setWordWrap(True)
|
|
l.addWidget(lab)
|
|
te = TagEdit(d, type=1)
|
|
te.setCol(self.col)
|
|
l.addWidget(te)
|
|
if t["did"]:
|
|
te.setText(self.col.decks.get(t["did"])["name"])
|
|
te.selectAll()
|
|
bb = QDialogButtonBox(QDialogButtonBox.Close)
|
|
bb.rejected.connect(d.close)
|
|
l.addWidget(bb)
|
|
d.setLayout(l)
|
|
d.exec_()
|
|
if not te.text().strip():
|
|
t["did"] = None
|
|
else:
|
|
t["did"] = self.col.decks.id(te.text())
|
|
|
|
def onAddField(self):
|
|
diag = QDialog(self)
|
|
form = aqt.forms.addfield.Ui_Dialog()
|
|
form.setupUi(diag)
|
|
fields = [f["name"] for f in self.model["flds"]]
|
|
form.fields.addItems(fields)
|
|
form.font.setCurrentFont(QFont("Arial"))
|
|
form.size.setValue(20)
|
|
diag.show()
|
|
# Work around a Qt bug,
|
|
# https://bugreports.qt-project.org/browse/QTBUG-1894
|
|
if isMac or isWin:
|
|
# No problems on Macs or Windows.
|
|
form.fields.showPopup()
|
|
else:
|
|
# Delay showing the pop-up.
|
|
self.mw.progress.timer(200, form.fields.showPopup, False)
|
|
if not diag.exec_():
|
|
return
|
|
if form.radioQ.isChecked():
|
|
obj = self.tform.front
|
|
else:
|
|
obj = self.tform.back
|
|
self._addField(
|
|
obj,
|
|
fields[form.fields.currentIndex()],
|
|
form.font.currentFont().family(),
|
|
form.size.value(),
|
|
)
|
|
|
|
def _addField(self, widg, field, font, size):
|
|
t = widg.toPlainText()
|
|
t += "\n<div style='font-family: %s; font-size: %spx;'>{{%s}}</div>\n" % (
|
|
font,
|
|
size,
|
|
field,
|
|
)
|
|
widg.setPlainText(t)
|
|
self.saveCard()
|
|
|
|
# Closing & Help
|
|
######################################################################
|
|
|
|
def accept(self):
|
|
self.reject()
|
|
|
|
def reject(self):
|
|
self.cancelPreviewTimer()
|
|
clearAudioQueue()
|
|
if self.addMode:
|
|
# remove the filler fields we added
|
|
for name in self.emptyFields:
|
|
self.note[name] = ""
|
|
self.mw.col.db.execute("delete from notes where id = ?", self.note.id)
|
|
self.mm.save(self.model, templates=True)
|
|
self.mw.reset()
|
|
saveGeom(self, "CardLayout")
|
|
self.pform.frontWeb = None
|
|
self.pform.backWeb = None
|
|
return QDialog.reject(self)
|
|
|
|
def onHelp(self):
|
|
openHelp("templates")
|