2 | # -*- coding: utf-8 -*-
3 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
4 |
5 | from aqt.qt import *
6 | import aqt.forms
7 | from aqt import appVersion
8 | from aqt.utils import openLink
9 |
10 | def show(mw):
11 | dialog = QDialog(mw)
12 | mw.setupDialogGC(dialog)
13 | abt = aqt.forms.about.Ui_About()
14 | abt.setupUi(dialog)
15 | abouttext = "
"
16 | abouttext += '' + _("Anki is a friendly, intelligent spaced learning \
17 | system. It's free and open source.")
18 | abouttext += "
"+_("Anki is licensed under the AGPL3 license. Please see "
19 | "the license file in the source distribution for more information.")
20 | abouttext += '
' + _("Version %s") % appVersion + '
'
21 | abouttext += ("Qt %s PyQt %s
") % (QT_VERSION_STR, PYQT_VERSION_STR)
22 | abouttext += (_("Visit website") % aqt.appWebsite) + \
23 | ""
24 | abouttext += '
' + _("Written by Damien Elmes, with patches, translation,\
25 | testing and design from:
%(cont)s") % {'cont': """Aaron Harsh, Ádám Szegi,
26 | Alex Fraser, Andreas Klauer, Andrew Wright, Bernhard Ibertsberger, C. van Rooyen, Charlene Barina,
27 | Christian Krause, Christian Rusche, David Smith, Dave Druelinger, Dotan Cohen,
28 | Emilio Wuerges, Emmanuel Jarri, Frank Harper, Gregor Skumavc, H. Mijail,
29 | Houssam Salem, Ian Lewis, Immanuel Asmus, Iroiro, Jarvik7,
30 | Jin Eun-Deok, Jo Nakashima, Johanna Lindh, Julien Baley, Jussi Määttä, Kieran Clancy, LaC, Laurent Steffan,
31 | Luca Ban, Luciano Esposito, Marco Giancotti, Marcus Rubeus, Mari Egami, Michael Jürges, Mark Wilbur,
32 | Matthew Duggan, Matthew Holtz, Meelis Vasser, Michael Keppler, Michael
33 | Montague, Michael Penkov, Michal Čadil, Morteza Salehi, Nathanael Law, Nick Cook, Niklas
34 | Laxström, Nguyễn Hào Khôi, Norbert Nagold, Ole Guldberg,
35 | Pcsl88, Petr Michalec, Piotr Kubowicz, Richard Colley, Roland Sieker, Samson Melamed,
36 | Stefaan De Pooter, Silja Ijas, Snezana Lukic, Soren Bjornstad, Susanna Björverud, Sylvain Durand,
37 | Tacutu, Timm Preetz, Timo Paulssen, Ursus, Victor Suba, Volker Jansen,
38 | Volodymyr Goncharenko, Xtru, 赵金鹏 and 黃文龍."""}
39 | abouttext += '
' + _("""\
40 | The icons were obtained from various sources; please see the Anki source
41 | for credits.""")
42 | abouttext += '
' + _("If you have contributed and are not on this list, \
43 | please get in touch.")
44 | abouttext += '
' + _("A big thanks to all the people who have provided \
45 | suggestions, bug reports and donations.")
46 | abt.label.setHtml(abouttext)
47 | dialog.adjustSize()
48 | dialog.show()
49 | dialog.exec_()
50 |
--------------------------------------------------------------------------------
/aqt/update.py:
--------------------------------------------------------------------------------
1 | # Copyright: Damien Elmes
2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
3 |
4 | import urllib.request, urllib.parse, urllib.error
5 | import urllib.request, urllib.error, urllib.parse
6 | import time
7 |
8 | from aqt.qt import *
9 | import aqt
10 | from aqt.utils import openLink
11 | from anki.utils import json, platDesc
12 | from aqt.utils import showText
13 |
14 |
15 | class LatestVersionFinder(QThread):
16 |
17 | newVerAvail = pyqtSignal(str)
18 | newMsg = pyqtSignal(dict)
19 | clockIsOff = pyqtSignal(float)
20 |
21 | def __init__(self, main):
22 | QThread.__init__(self)
23 | self.main = main
24 | self.config = main.pm.meta
25 |
26 | def _data(self):
27 | d = {"ver": aqt.appVersion,
28 | "os": platDesc(),
29 | "id": self.config['id'],
30 | "lm": self.config['lastMsg'],
31 | "crt": self.config['created']}
32 | return d
33 |
34 | def run(self):
35 | if not self.config['updates']:
36 | return
37 | d = self._data()
38 | d['proto'] = 1
39 | d = urllib.parse.urlencode(d).encode("utf8")
40 | try:
41 | f = urllib.request.urlopen(aqt.appUpdate, d)
42 | resp = f.read()
43 | if not resp:
44 | print("update check load failed")
45 | return
46 | resp = json.loads(resp.decode("utf8"))
47 | except:
48 | # behind proxy, corrupt message, etc
49 | print("update check failed")
50 | return
51 | if resp['msg']:
52 | self.newMsg.emit(resp)
53 | if resp['ver']:
54 | self.newVerAvail.emit(resp['ver'])
55 | diff = resp['time'] - time.time()
56 | if abs(diff) > 300:
57 | self.clockIsOff.emit(diff)
58 |
59 | def askAndUpdate(mw, ver):
60 | baseStr = (
61 | _('''Anki Updated
Anki %s has been released.
''') %
62 | ver)
63 | msg = QMessageBox(mw)
64 | msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
65 | msg.setIcon(QMessageBox.Information)
66 | msg.setText(baseStr + _("Would you like to download it now?"))
67 | button = QPushButton(_("Ignore this update"))
68 | msg.addButton(button, QMessageBox.RejectRole)
69 | msg.setDefaultButton(QMessageBox.Yes)
70 | ret = msg.exec_()
71 | if msg.clickedButton() == button:
72 | # ignore this update
73 | mw.pm.meta['suppressUpdate'] = ver
74 | elif ret == QMessageBox.Yes:
75 | openLink(aqt.appWebsite)
76 |
77 | def showMessages(mw, data):
78 | showText(data['msg'], parent=mw, type="html")
79 | mw.pm.meta['lastMsg'] = data['msgId']
80 |
--------------------------------------------------------------------------------
/anki/importing/pauker.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright: Andreas Klauer
3 | # License: BSD-3
4 |
5 | import gzip, math, random, time, cgi
6 | import xml.etree.ElementTree as ET
7 | from anki.importing.noteimp import NoteImporter, ForeignNote, ForeignCard
8 | from anki.stdmodels import addForwardReverse
9 |
10 | ONE_DAY = 60*60*24
11 |
12 | class PaukerImporter(NoteImporter):
13 | '''Import Pauker 1.8 Lesson (*.pau.gz)'''
14 |
15 | needMapper = False
16 | allowHTML = True
17 |
18 | def run(self):
19 | model = addForwardReverse(self.col)
20 | model['name'] = "Pauker"
21 | self.col.models.save(model)
22 | self.col.models.setCurrent(model)
23 | self.model = model
24 | self.initMapping()
25 | NoteImporter.run(self)
26 |
27 | def fields(self):
28 | '''Pauker is Front/Back'''
29 | return 2
30 |
31 | def foreignNotes(self):
32 | '''Build and return a list of notes.'''
33 | notes = []
34 |
35 | try:
36 | f = gzip.open(self.file)
37 | tree = ET.parse(f)
38 | lesson = tree.getroot()
39 | assert lesson.tag == "Lesson"
40 | finally:
41 | f.close()
42 |
43 | index = -4
44 |
45 | for batch in lesson.findall('./Batch'):
46 | index += 1
47 |
48 | for card in batch.findall('./Card'):
49 | # Create a note for this card.
50 | front = card.findtext('./FrontSide/Text')
51 | back = card.findtext('./ReverseSide/Text')
52 | note = ForeignNote()
53 | note.fields = [cgi.escape(x.strip()).replace('\n','
').replace(' ',' ') for x in [front,back]]
54 | notes.append(note)
55 |
56 | # Determine due date for cards.
57 | frontdue = card.find('./FrontSide[@LearnedTimestamp]')
58 | backdue = card.find('./ReverseSide[@Batch][@LearnedTimestamp]')
59 |
60 | if frontdue is not None:
61 | note.cards[0] = self._learnedCard(index, int(frontdue.attrib['LearnedTimestamp']))
62 |
63 | if backdue is not None:
64 | note.cards[1] = self._learnedCard(int(backdue.attrib['Batch']), int(backdue.attrib['LearnedTimestamp']))
65 |
66 | return notes
67 |
68 | def _learnedCard(self, batch, timestamp):
69 | ivl = math.exp(batch)
70 | now = time.time()
71 | due = ivl - (now - timestamp/1000.0)/ONE_DAY
72 | fc = ForeignCard()
73 | fc.due = self.col.sched.today + int(due+0.5)
74 | fc.ivl = random.randint(int(ivl*0.90), int(ivl+0.5))
75 | fc.factor = random.randint(1500,2500)
76 | return fc
77 |
--------------------------------------------------------------------------------
/designer/about.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 | About
4 |
5 |
6 |
7 | 0
8 | 0
9 | 410
10 | 664
11 |
12 |
13 |
14 |
15 | 0
16 | 0
17 |
18 |
19 |
20 | About Anki
21 |
22 |
23 |
24 | 0
25 |
26 |
27 | 0
28 |
29 |
30 | 0
31 |
32 |
33 | 0
34 |
35 | -
36 |
37 |
38 |
39 | about:blank
40 |
41 |
42 |
43 |
44 | -
45 |
46 |
47 | Qt::Horizontal
48 |
49 |
50 | QDialogButtonBox::Ok
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | AnkiWebView
59 | QWidget
60 |
61 | 1
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | buttonBox
70 | accepted()
71 | About
72 | accept()
73 |
74 |
75 | 248
76 | 254
77 |
78 |
79 | 157
80 | 274
81 |
82 |
83 |
84 |
85 | buttonBox
86 | rejected()
87 | About
88 | reject()
89 |
90 |
91 | 316
92 | 260
93 |
94 |
95 | 286
96 | 274
97 |
98 |
99 |
100 |
101 |
102 |
--------------------------------------------------------------------------------
/anki/stdmodels.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright: Damien Elmes
3 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
4 |
5 | from anki.lang import _
6 | from anki.consts import MODEL_CLOZE
7 |
8 | models = []
9 |
10 | # Basic
11 | ##########################################################################
12 |
13 | def addBasicModel(col):
14 | mm = col.models
15 | m = mm.new(_("Basic"))
16 | fm = mm.newField(_("Front"))
17 | mm.addField(m, fm)
18 | fm = mm.newField(_("Back"))
19 | mm.addField(m, fm)
20 | t = mm.newTemplate(_("Card 1"))
21 | t['qfmt'] = "{{"+_("Front")+"}}"
22 | t['afmt'] = "{{FrontSide}}\n\n
\n\n"+"{{"+_("Back")+"}}"
23 | mm.addTemplate(m, t)
24 | mm.add(m)
25 | return m
26 |
27 | models.append((lambda: _("Basic"), addBasicModel))
28 |
29 | # Forward & Reverse
30 | ##########################################################################
31 |
32 | def addForwardReverse(col):
33 | mm = col.models
34 | m = addBasicModel(col)
35 | m['name'] = _("Basic (and reversed card)")
36 | t = mm.newTemplate(_("Card 2"))
37 | t['qfmt'] = "{{"+_("Back")+"}}"
38 | t['afmt'] = "{{FrontSide}}\n\n
\n\n"+"{{"+_("Front")+"}}"
39 | mm.addTemplate(m, t)
40 | return m
41 |
42 | models.append((lambda: _("Basic (and reversed card)"), addForwardReverse))
43 |
44 | # Forward & Optional Reverse
45 | ##########################################################################
46 |
47 | def addForwardOptionalReverse(col):
48 | mm = col.models
49 | m = addBasicModel(col)
50 | m['name'] = _("Basic (optional reversed card)")
51 | av = _("Add Reverse")
52 | fm = mm.newField(av)
53 | mm.addField(m, fm)
54 | t = mm.newTemplate(_("Card 2"))
55 | t['qfmt'] = "{{#%s}}{{%s}}{{/%s}}" % (av, _("Back"), av)
56 | t['afmt'] = "{{FrontSide}}\n\n
\n\n"+"{{"+_("Front")+"}}"
57 | mm.addTemplate(m, t)
58 | return m
59 |
60 | models.append((lambda: _("Basic (optional reversed card)"),
61 | addForwardOptionalReverse))
62 |
63 | # Cloze
64 | ##########################################################################
65 |
66 | def addClozeModel(col):
67 | mm = col.models
68 | m = mm.new(_("Cloze"))
69 | m['type'] = MODEL_CLOZE
70 | txt = _("Text")
71 | fm = mm.newField(txt)
72 | mm.addField(m, fm)
73 | fm = mm.newField(_("Extra"))
74 | mm.addField(m, fm)
75 | t = mm.newTemplate(_("Cloze"))
76 | fmt = "{{cloze:%s}}" % txt
77 | m['css'] += """
78 | .cloze {
79 | font-weight: bold;
80 | color: blue;
81 | }"""
82 | t['qfmt'] = fmt
83 | t['afmt'] = fmt + "
\n{{%s}}" % _("Extra")
84 | mm.addTemplate(m, t)
85 | mm.add(m)
86 | return m
87 |
88 | models.append((lambda: _("Cloze"), addClozeModel))
89 |
--------------------------------------------------------------------------------
/designer/getaddons.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 | Dialog
4 |
5 |
6 |
7 | 0
8 | 0
9 | 367
10 | 204
11 |
12 |
13 |
14 | Install Add-on
15 |
16 |
17 | -
18 |
19 |
20 | To browse add-ons, please click the browse button below.<br><br>When you've found an add-on you like, please paste its code below.
21 |
22 |
23 | true
24 |
25 |
26 |
27 | -
28 |
29 |
30 | Qt::Vertical
31 |
32 |
33 |
34 | 20
35 | 40
36 |
37 |
38 |
39 |
40 | -
41 |
42 |
-
43 |
44 |
45 | Code:
46 |
47 |
48 |
49 | -
50 |
51 |
52 |
53 |
54 | -
55 |
56 |
57 | Qt::Horizontal
58 |
59 |
60 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | buttonBox
70 | accepted()
71 | Dialog
72 | accept()
73 |
74 |
75 | 248
76 | 254
77 |
78 |
79 | 157
80 | 274
81 |
82 |
83 |
84 |
85 | buttonBox
86 | rejected()
87 | Dialog
88 | reject()
89 |
90 |
91 | 316
92 | 260
93 |
94 |
95 | 286
96 | 274
97 |
98 |
99 |
100 |
101 |
102 |
--------------------------------------------------------------------------------
/aqt/modelchooser.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright: Damien Elmes
3 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
4 |
5 | from aqt.qt import *
6 | from anki.hooks import addHook, remHook, runHook
7 | from aqt.utils import shortcut
8 | import aqt
9 |
10 | class ModelChooser(QHBoxLayout):
11 |
12 | def __init__(self, mw, widget, label=True):
13 | QHBoxLayout.__init__(self)
14 | self.widget = widget
15 | self.mw = mw
16 | self.deck = mw.col
17 | self.label = label
18 | self.setContentsMargins(0,0,0,0)
19 | self.setSpacing(8)
20 | self.setupModels()
21 | addHook('reset', self.onReset)
22 | self.widget.setLayout(self)
23 |
24 | def setupModels(self):
25 | if self.label:
26 | self.modelLabel = QLabel(_("Type"))
27 | self.addWidget(self.modelLabel)
28 | # models box
29 | self.models = QPushButton()
30 | #self.models.setStyleSheet("* { text-align: left; }")
31 | self.models.setToolTip(shortcut(_("Change Note Type (Ctrl+N)")))
32 | s = QShortcut(QKeySequence(_("Ctrl+N")), self.widget, activated=self.onModelChange)
33 | self.models.setAutoDefault(False)
34 | self.addWidget(self.models)
35 | self.models.clicked.connect(self.onModelChange)
36 | # layout
37 | sizePolicy = QSizePolicy(
38 | QSizePolicy.Policy(7),
39 | QSizePolicy.Policy(0))
40 | self.models.setSizePolicy(sizePolicy)
41 | self.updateModels()
42 |
43 | def cleanup(self):
44 | remHook('reset', self.onReset)
45 |
46 | def onReset(self):
47 | self.updateModels()
48 |
49 | def show(self):
50 | self.widget.show()
51 |
52 | def hide(self):
53 | self.widget.hide()
54 |
55 | def onEdit(self):
56 | import aqt.models
57 | aqt.models.Models(self.mw, self.widget)
58 |
59 | def onModelChange(self):
60 | from aqt.studydeck import StudyDeck
61 | current = self.deck.models.current()['name']
62 | # edit button
63 | edit = QPushButton(_("Manage"), clicked=self.onEdit)
64 | def nameFunc():
65 | return sorted(self.deck.models.allNames())
66 | ret = StudyDeck(
67 | self.mw, names=nameFunc,
68 | accept=_("Choose"), title=_("Choose Note Type"),
69 | help="_notes", current=current, parent=self.widget,
70 | buttons=[edit], cancel=True, geomKey="selectModel")
71 | if not ret.name:
72 | return
73 | m = self.deck.models.byName(ret.name)
74 | self.deck.conf['curModel'] = m['id']
75 | cdeck = self.deck.decks.current()
76 | cdeck['mid'] = m['id']
77 | self.deck.decks.save(cdeck)
78 | runHook("currentModelChanged")
79 | self.mw.reset()
80 |
81 | def updateModels(self):
82 | self.models.setText(self.deck.models.current()['name'])
83 |
--------------------------------------------------------------------------------
/aqt/downloader.py:
--------------------------------------------------------------------------------
1 | # Copyright: Damien Elmes
2 | # -*- coding: utf-8 -*-
3 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
4 |
5 | import time, re, traceback
6 | from aqt.qt import *
7 | from anki.sync import httpCon
8 | from aqt.utils import showWarning
9 | from anki.hooks import addHook, remHook
10 | import aqt.sync # monkey-patches httplib2
11 |
12 | def download(mw, code):
13 | "Download addon/deck from AnkiWeb. On success caller must stop progress diag."
14 | # check code is valid
15 | try:
16 | code = int(code)
17 | except ValueError:
18 | showWarning(_("Invalid code."))
19 | return
20 | # create downloading thread
21 | thread = Downloader(code)
22 | def onRecv():
23 | try:
24 | mw.progress.update(label="%dKB downloaded" % (thread.recvTotal/1024))
25 | except NameError:
26 | # some users report the following error on long downloads
27 | # NameError: free variable 'mw' referenced before assignment in enclosing scope
28 | # unsure why this is happening, but guard against throwing the
29 | # error
30 | pass
31 | thread.recv.connect(onRecv)
32 | thread.start()
33 | mw.progress.start(immediate=True)
34 | while not thread.isFinished():
35 | mw.app.processEvents()
36 | thread.wait(100)
37 | if not thread.error:
38 | # success
39 | return thread.data, thread.fname
40 | else:
41 | mw.progress.finish()
42 | showWarning(_("Download failed: %s") % thread.error)
43 |
44 | class Downloader(QThread):
45 |
46 | recv = pyqtSignal()
47 |
48 | def __init__(self, code):
49 | QThread.__init__(self)
50 | self.code = code
51 | self.error = None
52 |
53 | def run(self):
54 | # setup progress handler
55 | self.byteUpdate = time.time()
56 | self.recvTotal = 0
57 | def canPost():
58 | if (time.time() - self.byteUpdate) > 0.1:
59 | self.byteUpdate = time.time()
60 | return True
61 | def recvEvent(bytes):
62 | self.recvTotal += bytes
63 | if canPost():
64 | self.recv.emit()
65 | addHook("httpRecv", recvEvent)
66 | con = httpCon()
67 | try:
68 | resp, cont = con.request(
69 | aqt.appShared + "download/%d" % self.code)
70 | except Exception as e:
71 | exc = traceback.format_exc()
72 | try:
73 | self.error = str(e[0])
74 | except:
75 | self.error = str(exc)
76 | return
77 | finally:
78 | remHook("httpRecv", recvEvent)
79 | if resp['status'] == '200':
80 | self.error = None
81 | self.fname = re.match("attachment; filename=(.+)",
82 | resp['content-disposition']).group(1)
83 | self.data = cont
84 | elif resp['status'] == '403':
85 | self.error = _("Invalid code.")
86 | else:
87 | self.error = _("Error downloading: %s") % resp['status']
88 |
--------------------------------------------------------------------------------
/aqt/tagedit.py:
--------------------------------------------------------------------------------
1 | # Copyright: Damien Elmes
2 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
3 |
4 | from aqt.qt import *
5 | import re
6 |
7 | class TagEdit(QLineEdit):
8 |
9 | lostFocus = pyqtSignal()
10 |
11 | # 0 = tags, 1 = decks
12 | def __init__(self, parent, type=0):
13 | QLineEdit.__init__(self, parent)
14 | self.col = None
15 | self.model = QStringListModel()
16 | self.type = type
17 | if type == 0:
18 | self.completer = TagCompleter(self.model, parent, self)
19 | else:
20 | self.completer = QCompleter(self.model, parent)
21 | self.completer.setCompletionMode(QCompleter.PopupCompletion)
22 | self.completer.setCaseSensitivity(Qt.CaseInsensitive)
23 | self.setCompleter(self.completer)
24 |
25 | def setCol(self, col):
26 | "Set the current col, updating list of available tags."
27 | self.col = col
28 | if self.type == 0:
29 | l = sorted(self.col.tags.all())
30 | else:
31 | l = sorted(self.col.decks.allNames())
32 | self.model.setStringList(l)
33 |
34 | def focusInEvent(self, evt):
35 | QLineEdit.focusInEvent(self, evt)
36 | self.showCompleter()
37 |
38 | def keyPressEvent(self, evt):
39 | if evt.key() in (Qt.Key_Enter, Qt.Key_Return):
40 | self.hideCompleter()
41 | QWidget.keyPressEvent(self, evt)
42 | return
43 | QLineEdit.keyPressEvent(self, evt)
44 | if not evt.text():
45 | # if it's a modifier, don't show
46 | return
47 | if evt.key() not in (
48 | Qt.Key_Enter, Qt.Key_Return, Qt.Key_Escape, Qt.Key_Space,
49 | Qt.Key_Tab, Qt.Key_Backspace, Qt.Key_Delete):
50 | self.showCompleter()
51 |
52 | def showCompleter(self):
53 | self.completer.setCompletionPrefix(self.text())
54 | self.completer.complete()
55 |
56 | def focusOutEvent(self, evt):
57 | QLineEdit.focusOutEvent(self, evt)
58 | self.lostFocus.emit()
59 | self.completer.popup().hide()
60 |
61 | def hideCompleter(self):
62 | self.completer.popup().hide()
63 |
64 | class TagCompleter(QCompleter):
65 |
66 | def __init__(self, model, parent, edit, *args):
67 | QCompleter.__init__(self, model, parent)
68 | self.tags = []
69 | self.edit = edit
70 | self.cursor = None
71 |
72 | def splitPath(self, tags):
73 | tags = tags.strip()
74 | tags = re.sub(" +", " ", tags)
75 | self.tags = self.edit.col.tags.split(tags)
76 | self.tags.append("")
77 | p = self.edit.cursorPosition()
78 | self.cursor = tags.count(" ", 0, p)
79 | return [self.tags[self.cursor]]
80 |
81 | def pathFromIndex(self, idx):
82 | if self.cursor is None:
83 | return self.edit.text()
84 | ret = QCompleter.pathFromIndex(self, idx)
85 | self.tags[self.cursor] = ret
86 | try:
87 | self.tags.remove("")
88 | except ValueError:
89 | pass
90 | return " ".join(self.tags)
91 |
--------------------------------------------------------------------------------
/anki/db.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright: Damien Elmes
3 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
4 |
5 | import os
6 | import time
7 |
8 | try:
9 | from pysqlite2 import dbapi2 as sqlite
10 | vi = sqlite.version_info
11 | if vi[0] > 2 or vi[1] > 6:
12 | # latest pysqlite breaks anki
13 | raise ImportError()
14 | except ImportError:
15 | from sqlite3 import dbapi2 as sqlite
16 |
17 | Error = sqlite.Error
18 |
19 | class DB(object):
20 | def __init__(self, path, timeout=0):
21 | self._db = sqlite.connect(path, timeout=timeout)
22 | self._path = path
23 | self.echo = os.environ.get("DBECHO")
24 | self.mod = False
25 |
26 | def execute(self, sql, *a, **ka):
27 | s = sql.strip().lower()
28 | # mark modified?
29 | for stmt in "insert", "update", "delete":
30 | if s.startswith(stmt):
31 | self.mod = True
32 | t = time.time()
33 | if ka:
34 | # execute("...where id = :id", id=5)
35 | res = self._db.execute(sql, ka)
36 | else:
37 | # execute("...where id = ?", 5)
38 | res = self._db.execute(sql, a)
39 | if self.echo:
40 | #print a, ka
41 | print(sql, "%0.3fms" % ((time.time() - t)*1000))
42 | if self.echo == "2":
43 | print(a, ka)
44 | return res
45 |
46 | def executemany(self, sql, l):
47 | self.mod = True
48 | t = time.time()
49 | self._db.executemany(sql, l)
50 | if self.echo:
51 | print(sql, "%0.3fms" % ((time.time() - t)*1000))
52 | if self.echo == "2":
53 | print(l)
54 |
55 | def commit(self):
56 | t = time.time()
57 | self._db.commit()
58 | if self.echo:
59 | print("commit %0.3fms" % ((time.time() - t)*1000))
60 |
61 | def executescript(self, sql):
62 | self.mod = True
63 | if self.echo:
64 | print(sql)
65 | self._db.executescript(sql)
66 |
67 | def rollback(self):
68 | self._db.rollback()
69 |
70 | def scalar(self, *a, **kw):
71 | res = self.execute(*a, **kw).fetchone()
72 | if res:
73 | return res[0]
74 | return None
75 |
76 | def all(self, *a, **kw):
77 | return self.execute(*a, **kw).fetchall()
78 |
79 | def first(self, *a, **kw):
80 | c = self.execute(*a, **kw)
81 | res = c.fetchone()
82 | c.close()
83 | return res
84 |
85 | def list(self, *a, **kw):
86 | return [x[0] for x in self.execute(*a, **kw)]
87 |
88 | def close(self):
89 | self._db.close()
90 |
91 | def set_progress_handler(self, *args):
92 | self._db.set_progress_handler(*args)
93 |
94 | def __enter__(self):
95 | self._db.execute("begin")
96 | return self
97 |
98 | def __exit__(self, exc_type, *args):
99 | self._db.close()
100 |
101 | def totalChanges(self):
102 | return self._db.total_changes
103 |
104 | def interrupt(self):
105 | self._db.interrupt()
106 |
--------------------------------------------------------------------------------
/aqt/deckchooser.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright: Damien Elmes
3 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
4 |
5 | from aqt.qt import *
6 | from anki.hooks import addHook, remHook
7 | from aqt.utils import shortcut
8 |
9 | class DeckChooser(QHBoxLayout):
10 |
11 | def __init__(self, mw, widget, label=True, start=None):
12 | QHBoxLayout.__init__(self)
13 | self.widget = widget
14 | self.mw = mw
15 | self.deck = mw.col
16 | self.label = label
17 | self.setContentsMargins(0,0,0,0)
18 | self.setSpacing(8)
19 | self.setupDecks()
20 | self.widget.setLayout(self)
21 | addHook('currentModelChanged', self.onModelChange)
22 |
23 | def setupDecks(self):
24 | if self.label:
25 | self.deckLabel = QLabel(_("Deck"))
26 | self.addWidget(self.deckLabel)
27 | # decks box
28 | self.deck = QPushButton(clicked=self.onDeckChange)
29 | self.deck.setToolTip(shortcut(_("Target Deck (Ctrl+D)")))
30 | s = QShortcut(QKeySequence(_("Ctrl+D")), self.widget, activated=self.onDeckChange)
31 | self.addWidget(self.deck)
32 | # starting label
33 | if self.mw.col.conf.get("addToCur", True):
34 | col = self.mw.col
35 | did = col.conf['curDeck']
36 | if col.decks.isDyn(did):
37 | # if they're reviewing, try default to current card
38 | c = self.mw.reviewer.card
39 | if self.mw.state == "review" and c:
40 | if not c.odid:
41 | did = c.did
42 | else:
43 | did = c.odid
44 | else:
45 | did = 1
46 | self.deck.setText(self.mw.col.decks.nameOrNone(
47 | did) or _("Default"))
48 | else:
49 | self.deck.setText(self.mw.col.decks.nameOrNone(
50 | self.mw.col.models.current()['did']) or _("Default"))
51 | # layout
52 | sizePolicy = QSizePolicy(
53 | QSizePolicy.Policy(7),
54 | QSizePolicy.Policy(0))
55 | self.deck.setSizePolicy(sizePolicy)
56 |
57 | def show(self):
58 | self.widget.show()
59 |
60 | def hide(self):
61 | self.widget.hide()
62 |
63 | def cleanup(self):
64 | remHook('currentModelChanged', self.onModelChange)
65 |
66 | def onModelChange(self):
67 | if not self.mw.col.conf.get("addToCur", True):
68 | self.deck.setText(self.mw.col.decks.nameOrNone(
69 | self.mw.col.models.current()['did']) or _("Default"))
70 |
71 | def onDeckChange(self):
72 | from aqt.studydeck import StudyDeck
73 | current = self.deck.text()
74 | ret = StudyDeck(
75 | self.mw, current=current, accept=_("Choose"),
76 | title=_("Choose Deck"), help="addingnotes",
77 | cancel=False, parent=self.widget, geomKey="selectDeck")
78 | self.deck.setText(ret.name)
79 |
80 | def selectedId(self):
81 | # save deck name
82 | name = self.deck.text()
83 | if not name.strip():
84 | did = 1
85 | else:
86 | did = self.mw.col.decks.id(name)
87 | return did
88 |
--------------------------------------------------------------------------------
/designer/addcards.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 | Dialog
4 |
5 |
6 |
7 | 0
8 | 0
9 | 453
10 | 366
11 |
12 |
13 |
14 | Add
15 |
16 |
17 |
18 | :/icons/list-add.png:/icons/list-add.png
19 |
20 |
21 |
22 | 3
23 |
24 |
25 | 12
26 |
27 |
28 | 6
29 |
30 |
31 | 12
32 |
33 |
34 | 12
35 |
36 | -
37 |
38 |
39 | 6
40 |
41 |
42 | 0
43 |
44 |
-
45 |
46 |
47 |
48 | 0
49 | 10
50 |
51 |
52 |
53 |
54 | -
55 |
56 |
57 |
58 |
59 | -
60 |
61 |
62 | Qt::Horizontal
63 |
64 |
65 |
66 | -
67 |
68 |
69 |
70 | 0
71 | 10
72 |
73 |
74 |
75 | true
76 |
77 |
78 |
79 | -
80 |
81 |
82 | Qt::Horizontal
83 |
84 |
85 | QDialogButtonBox::NoButton
86 |
87 |
88 |
89 |
90 |
91 |
92 | buttonBox
93 |
94 |
95 |
96 |
97 | buttonBox
98 | rejected()
99 | Dialog
100 | reject()
101 |
102 |
103 | 301
104 | -1
105 |
106 |
107 | 286
108 | 274
109 |
110 |
111 |
112 |
113 |
114 |
--------------------------------------------------------------------------------
/designer/modelopts.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 | Dialog
4 |
5 |
6 | Qt::ApplicationModal
7 |
8 |
9 |
10 | 0
11 | 0
12 | 276
13 | 323
14 |
15 |
16 |
17 |
18 |
19 |
20 | -
21 |
22 |
23 | 0
24 |
25 |
26 |
27 | LaTeX
28 |
29 |
30 |
-
31 |
32 |
33 | Header
34 |
35 |
36 |
37 | -
38 |
39 |
40 | true
41 |
42 |
43 |
44 | -
45 |
46 |
47 | Footer
48 |
49 |
50 |
51 | -
52 |
53 |
54 | true
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | -
63 |
64 |
65 | Qt::Horizontal
66 |
67 |
68 | QDialogButtonBox::Close|QDialogButtonBox::Help
69 |
70 |
71 |
72 |
73 |
74 |
75 | qtabwidget
76 | buttonBox
77 | latexHeader
78 | latexFooter
79 |
80 |
81 |
82 |
83 | buttonBox
84 | accepted()
85 | Dialog
86 | accept()
87 |
88 |
89 | 275
90 | 442
91 |
92 |
93 | 157
94 | 274
95 |
96 |
97 |
98 |
99 | buttonBox
100 | rejected()
101 | Dialog
102 | reject()
103 |
104 |
105 | 343
106 | 442
107 |
108 |
109 | 286
110 | 274
111 |
112 |
113 |
114 |
115 |
116 |
--------------------------------------------------------------------------------
/README.addons:
--------------------------------------------------------------------------------
1 | Porting add-ons to Anki 2.1
2 | ---------------------------
3 |
4 | 2.1 is still in alpha and prone to change, so you may wish to wait until it
5 | hits beta before starting to update add-ons. But if you'd like to dive in
6 | straight away, here are some tips on porting.
7 |
8 | Python 3
9 | ---------
10 |
11 | Anki 2.1 requires Python 3.4 or later. After installing Python 3 on your
12 | machine, you can use the 2to3 tool to automatically convert your existing
13 | scripts to Python 3 code on a folder by folder basis, like:
14 |
15 | 2to3-3.5 --output-dir=aqt3 -W -n aqt
16 | mv aqt aqt-old
17 | mv aqt3 aqt
18 |
19 | Most simple code can be converted automatically, but there may be parts of the
20 | code that you need to manually modify.
21 |
22 | Add-ons that don't deal with file access and bytestrings may well work on both
23 | Python 2 and 3 without any special work required.
24 |
25 | Qt5 / PyQt5
26 | ------------
27 |
28 | The syntax for connecting signals and slots has changed in PyQt5. Recent PyQt4
29 | versions support the new syntax as well, so after updating your add-ons you
30 | may find they still work in Anki 2.0.x as well.
31 |
32 | More info is available at
33 | http://pyqt.sourceforge.net/Docs/PyQt4/new_style_signals_slots.html
34 |
35 | Changes in Anki
36 | ----------------
37 |
38 | Qt 5 has deprecated WebKit in favour of the Chromium-based WebEngine, so
39 | Anki's webviews are now using WebEngine. Of note:
40 |
41 | - WebEngine uses a different method of communicating back to Python.
42 | AnkiWebView() is a wrapper for webviews which provides a pycmd(str) function in
43 | Javascript which will call the ankiwebview's onBridgeCmd(str) method. Various
44 | parts of Anki's UI like reviewer.py and deckbrowser.py have had to be
45 | modified to use this.
46 | - Javascript is evaluated asynchronously, so if you need the result of a JS
47 | expression you can use ankiwebview's evalWithCallback().
48 | - As a result of this asynchronous behaviour, editor.saveNow() now requires a
49 | callback. If your add-on performs actions in the browser, you likely need to
50 | call editor.saveNow() first and then run the rest of your code in the callback.
51 | Calls to .onSearch() will need to be changed to .search()/.onSearchActivated()
52 | as well. See the browser's .deleteNotes() for an example.
53 | - You can now debug the webviews using an external Chrome instance, by setting
54 | the env var QTWEBENGINE_REMOTE_DEBUGGING to 8080 prior to starting Anki,
55 | then surfing to localhost:8080 in Chrome. If you run into issues, try
56 | connecting with Chrome 49.
57 |
58 | Add-ons without a top level file
59 | ---------------------------------
60 |
61 | Add-ons no longer require a top level file - if you just distribute a single
62 | folder, the folder's __init__.py file will form the entry point. This will not
63 | work in 2.0.x however.
64 |
65 | Sharing updated add-ons
66 | ------------------------
67 |
68 | If you've succeeded in making an add-on that supports both 2.0.x and 2.1.x at
69 | the same time, please feel free to upload it to the shared add-ons area. If
70 | you've decided to make a separate 2.1.x version, it's probably best to just
71 | post a link to it in your current add-on description or upload it separately.
72 | When we get closer to a release I'll look into adding separate uploads for the
73 | two versions.
74 |
75 |
76 |
77 |
--------------------------------------------------------------------------------
/designer/models.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 | Dialog
4 |
5 |
6 | Qt::ApplicationModal
7 |
8 |
9 |
10 | 0
11 | 0
12 | 396
13 | 255
14 |
15 |
16 |
17 | Note Types
18 |
19 |
20 |
21 | 0
22 |
23 | -
24 |
25 |
26 | 6
27 |
28 |
-
29 |
30 |
31 |
32 | 0
33 | 0
34 |
35 |
36 |
37 |
38 | -
39 |
40 |
41 | 12
42 |
43 |
-
44 |
45 |
46 | Qt::Vertical
47 |
48 |
49 | QDialogButtonBox::Close|QDialogButtonBox::Help
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | -
58 |
59 |
60 | Qt::Vertical
61 |
62 |
63 | QSizePolicy::Minimum
64 |
65 |
66 |
67 | 20
68 | 6
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 | modelsList
77 |
78 |
79 |
80 |
81 |
82 |
83 | buttonBox
84 | accepted()
85 | Dialog
86 | accept()
87 |
88 |
89 | 252
90 | 513
91 |
92 |
93 | 157
94 | 274
95 |
96 |
97 |
98 |
99 | buttonBox
100 | rejected()
101 | Dialog
102 | reject()
103 |
104 |
105 | 320
106 | 513
107 |
108 |
109 | 286
110 | 274
111 |
112 |
113 |
114 |
115 |
116 |
--------------------------------------------------------------------------------
/anki/lang.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Copyright: Damien Elmes
3 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
4 |
5 | import os, sys, re
6 | import gettext
7 | import threading
8 |
9 | langs = [
10 | ("Afrikaans", "af"),
11 | ("Bahasa Melayu", "ms"),
12 | ("Dansk", "da"),
13 | ("Deutsch", "de"),
14 | ("Eesti", "et"),
15 | ("English", "en"),
16 | ("Español", "es"),
17 | ("Esperanto", "eo"),
18 | ("Euskara", "eu"),
19 | ("Français", "fr"),
20 | ("Galego", "gl"),
21 | ("Hrvatski", "hr"),
22 | ("Interlingua", "ia"),
23 | ("Italiano", "it"),
24 | ("Lenga d'òc", "oc"),
25 | ("Magyar", "hu"),
26 | ("Nederlands","nl"),
27 | ("Norsk","nb"),
28 | ("Occitan","oc"),
29 | ("Plattdüütsch", "nds"),
30 | ("Polski", "pl"),
31 | ("Português Brasileiro", "pt_BR"),
32 | ("Português", "pt"),
33 | ("Româneşte", "ro"),
34 | ("Slovenčina", "sk"),
35 | ("Slovenščina", "sl"),
36 | ("Suomi", "fi"),
37 | ("Svenska", "sv"),
38 | ("Tiếng Việt", "vi"),
39 | ("Türkçe", "tr"),
40 | ("Čeština", "cs"),
41 | ("Ελληνικά", "el"),
42 | ("Ελληνικά", "el"),
43 | ("босански", "bs"),
44 | ("Български", "bg"),
45 | ("Монгол хэл","mn"),
46 | ("русский язык", "ru"),
47 | ("Српски", "sr"),
48 | ("українська мова", "uk"),
49 | ("עִבְרִית", "he"),
50 | ("العربية", "ar"),
51 | ("فارسی", "fa"),
52 | ("ภาษาไทย", "th"),
53 | ("日本語", "ja"),
54 | ("简体中文", "zh_CN"),
55 | ("繁體中文", "zh_TW"),
56 | ("한국어", "ko"),
57 | ]
58 |
59 | threadLocal = threading.local()
60 |
61 | # global defaults
62 | currentLang = None
63 | currentTranslation = None
64 |
65 | def localTranslation():
66 | "Return the translation local to this thread, or the default."
67 | if getattr(threadLocal, 'currentTranslation', None):
68 | return threadLocal.currentTranslation
69 | else:
70 | return currentTranslation
71 |
72 | def _(str):
73 | return localTranslation().gettext(str)
74 |
75 | def ngettext(single, plural, n):
76 | return localTranslation().ngettext(single, plural, n)
77 |
78 | def langDir():
79 | dir = os.path.join(os.path.dirname(
80 | os.path.abspath(__file__)), "locale")
81 | if not os.path.isdir(dir):
82 | dir = os.path.join(os.path.dirname(sys.argv[0]), "locale")
83 | if not os.path.isdir(dir):
84 | dir = "/usr/share/anki/locale"
85 | return dir
86 |
87 | def setLang(lang, local=True):
88 | trans = gettext.translation(
89 | 'anki', langDir(), languages=[lang], fallback=True)
90 | if local:
91 | threadLocal.currentLang = lang
92 | threadLocal.currentTranslation = trans
93 | else:
94 | global currentLang, currentTranslation
95 | currentLang = lang
96 | currentTranslation = trans
97 |
98 | def getLang():
99 | "Return the language local to this thread, or the default."
100 | if getattr(threadLocal, 'currentLang', None):
101 | return threadLocal.currentLang
102 | else:
103 | return currentLang
104 |
105 | def noHint(str):
106 | "Remove translation hint from end of string."
107 | return re.sub("(^.*?)( ?\(.+?\))?$", "\\1", str)
108 |
109 | if not currentTranslation:
110 | setLang("en_US", local=False)
111 |
--------------------------------------------------------------------------------
/aqt/stats.py:
--------------------------------------------------------------------------------
1 | # Copyright: Damien Elmes
2 | # -*- coding: utf-8 -*-
3 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
4 |
5 | from aqt.qt import *
6 | import os, time
7 | from aqt.utils import saveGeom, restoreGeom, maybeHideClose, showInfo, addCloseShortcut
8 | import aqt
9 |
10 | # Deck Stats
11 | ######################################################################
12 |
13 | class DeckStats(QDialog):
14 |
15 | def __init__(self, mw):
16 | QDialog.__init__(self, mw, Qt.Window)
17 | mw.setupDialogGC(self)
18 | self.mw = mw
19 | self.name = "deckStats"
20 | self.period = 0
21 | self.form = aqt.forms.stats.Ui_Dialog()
22 | self.oldPos = None
23 | self.wholeCollection = False
24 | self.setMinimumWidth(700)
25 | f = self.form
26 | f.setupUi(self)
27 | restoreGeom(self, self.name)
28 | b = f.buttonBox.addButton(_("Save Image"),
29 | QDialogButtonBox.ActionRole)
30 | b.clicked.connect(self.browser)
31 | b.setAutoDefault(False)
32 | f.groups.clicked.connect(lambda: self.changeScope("deck"))
33 | f.groups.setShortcut("g")
34 | f.all.clicked.connect(lambda: self.changeScope("collection"))
35 | f.month.clicked.connect(lambda: self.changePeriod(0))
36 | f.year.clicked.connect(lambda: self.changePeriod(1))
37 | f.life.clicked.connect(lambda: self.changePeriod(2))
38 | maybeHideClose(self.form.buttonBox)
39 | addCloseShortcut(self)
40 | self.refresh()
41 | self.show()
42 | print("fixme: save image support in deck stats")
43 |
44 | def reject(self):
45 | saveGeom(self, self.name)
46 | QDialog.reject(self)
47 |
48 | def browser(self):
49 | name = time.strftime("-%Y-%m-%d@%H-%M-%S.png",
50 | time.localtime(time.time()))
51 | name = "anki-"+_("stats")+name
52 | desktopPath = QStandardPaths.writableLocation(
53 | QStandardPaths.DesktopLocation)
54 | if not os.path.exists(desktopPath):
55 | os.mkdir(desktopPath)
56 | path = os.path.join(desktopPath, name)
57 | p = self.form.web.page()
58 | oldsize = p.viewportSize()
59 | p.setViewportSize(p.mainFrame().contentsSize())
60 | image = QImage(p.viewportSize(), QImage.Format_ARGB32)
61 | painter = QPainter(image)
62 | p.mainFrame().render(painter)
63 | painter.end()
64 | isOK = image.save(path, "png")
65 | if isOK:
66 | showInfo(_("An image was saved to your desktop."))
67 | else:
68 | showInfo(_("""\
69 | Anki could not save the image. Please check that you have permission to write \
70 | to your desktop."""))
71 | p.setViewportSize(oldsize)
72 |
73 | def changePeriod(self, n):
74 | self.period = n
75 | self.refresh()
76 |
77 | def changeScope(self, type):
78 | self.wholeCollection = type == "collection"
79 | self.refresh()
80 |
81 | def refresh(self):
82 | self.mw.progress.start(immediate=True)
83 | stats = self.mw.col.stats()
84 | stats.wholeCollection = self.wholeCollection
85 | self.report = stats.report(type=self.period)
86 | self.form.web.stdHtml(""+self.report+"")
87 | self.mw.progress.finish()
88 |
--------------------------------------------------------------------------------
/tests/test_cards.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 |
3 | from tests.shared import getEmptyCol
4 |
5 | def test_previewCards():
6 | deck = getEmptyCol()
7 | f = deck.newNote()
8 | f['Front'] = '1'
9 | f['Back'] = '2'
10 | # non-empty and active
11 | cards = deck.previewCards(f, 0)
12 | assert len(cards) == 1
13 | assert cards[0].ord == 0
14 | # all templates
15 | cards = deck.previewCards(f, 2)
16 | assert len(cards) == 1
17 | # add the note, and test existing preview
18 | deck.addNote(f)
19 | cards = deck.previewCards(f, 1)
20 | assert len(cards) == 1
21 | assert cards[0].ord == 0
22 | # make sure we haven't accidentally added cards to the db
23 | assert deck.cardCount() == 1
24 |
25 | def test_delete():
26 | deck = getEmptyCol()
27 | f = deck.newNote()
28 | f['Front'] = '1'
29 | f['Back'] = '2'
30 | deck.addNote(f)
31 | cid = f.cards()[0].id
32 | deck.reset()
33 | deck.sched.answerCard(deck.sched.getCard(), 2)
34 | deck.remCards([cid])
35 | assert deck.cardCount() == 0
36 | assert deck.noteCount() == 0
37 | assert deck.db.scalar("select count() from notes") == 0
38 | assert deck.db.scalar("select count() from cards") == 0
39 | assert deck.db.scalar("select count() from graves") == 2
40 |
41 | def test_misc():
42 | d = getEmptyCol()
43 | f = d.newNote()
44 | f['Front'] = '1'
45 | f['Back'] = '2'
46 | d.addNote(f)
47 | c = f.cards()[0]
48 | id = d.models.current()['id']
49 | assert c.template()['ord'] == 0
50 |
51 | def test_genrem():
52 | d = getEmptyCol()
53 | f = d.newNote()
54 | f['Front'] = '1'
55 | f['Back'] = ''
56 | d.addNote(f)
57 | assert len(f.cards()) == 1
58 | m = d.models.current()
59 | mm = d.models
60 | # adding a new template should automatically create cards
61 | t = mm.newTemplate("rev")
62 | t['qfmt'] = '{{Front}}'
63 | t['afmt'] = ""
64 | mm.addTemplate(m, t)
65 | mm.save(m, templates=True)
66 | assert len(f.cards()) == 2
67 | # if the template is changed to remove cards, they'll be removed
68 | t['qfmt'] = "{{Back}}"
69 | mm.save(m, templates=True)
70 | d.remCards(d.emptyCids())
71 | assert len(f.cards()) == 1
72 | # if we add to the note, a card should be automatically generated
73 | f.load()
74 | f['Back'] = "1"
75 | f.flush()
76 | assert len(f.cards()) == 2
77 |
78 | def test_gendeck():
79 | d = getEmptyCol()
80 | cloze = d.models.byName("Cloze")
81 | d.models.setCurrent(cloze)
82 | f = d.newNote()
83 | f['Text'] = '{{c1::one}}'
84 | d.addNote(f)
85 | assert d.cardCount() == 1
86 | assert f.cards()[0].did == 1
87 | # set the model to a new default deck
88 | newId = d.decks.id("new")
89 | cloze['did'] = newId
90 | d.models.save(cloze)
91 | # a newly generated card should share the first card's deck
92 | f['Text'] += '{{c2::two}}'
93 | f.flush()
94 | assert f.cards()[1].did == 1
95 | # and same with multiple cards
96 | f['Text'] += '{{c3::three}}'
97 | f.flush()
98 | assert f.cards()[2].did == 1
99 | # if one of the cards is in a different deck, it should revert to the
100 | # model default
101 | c = f.cards()[1]
102 | c.did = newId
103 | c.flush()
104 | f['Text'] += '{{c4::four}}'
105 | f.flush()
106 | assert f.cards()[3].did == newId
107 |
108 |
109 |
110 |
--------------------------------------------------------------------------------
/designer/profiles.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 | Dialog
4 |
5 |
6 |
7 | 0
8 | 0
9 | 352
10 | 283
11 |
12 |
13 |
14 | Profiles
15 |
16 |
17 |
18 | :/icons/anki.png:/icons/anki.png
19 |
20 |
21 | -
22 |
23 |
24 | Profile:
25 |
26 |
27 |
28 | -
29 |
30 |
-
31 |
32 |
-
33 |
34 |
35 | -
36 |
37 |
38 | Password:
39 |
40 |
41 |
42 | -
43 |
44 |
45 | QLineEdit::Password
46 |
47 |
48 |
49 |
50 |
51 | -
52 |
53 |
-
54 |
55 |
56 | Open
57 |
58 |
59 |
60 | -
61 |
62 |
63 | Add
64 |
65 |
66 |
67 | -
68 |
69 |
70 | Rename
71 |
72 |
73 |
74 | -
75 |
76 |
77 | Delete
78 |
79 |
80 |
81 | -
82 |
83 |
84 | Quit
85 |
86 |
87 |
88 | -
89 |
90 |
91 | Qt::Vertical
92 |
93 |
94 |
95 | 20
96 | 40
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 | profiles
109 | passEdit
110 | login
111 | add
112 | rename
113 | delete_2
114 | quit
115 |
116 |
117 |
118 |
119 |
120 |
121 |
--------------------------------------------------------------------------------
/designer/taglimit.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 | Dialog
4 |
5 |
6 |
7 | 0
8 | 0
9 | 361
10 | 394
11 |
12 |
13 |
14 | Selective Study
15 |
16 |
17 | -
18 |
19 |
20 | Require one or more of these tags:
21 |
22 |
23 |
24 | -
25 |
26 |
27 | false
28 |
29 |
30 |
31 | 0
32 | 2
33 |
34 |
35 |
36 | QAbstractItemView::MultiSelection
37 |
38 |
39 |
40 | -
41 |
42 |
43 | Select tags to exclude:
44 |
45 |
46 |
47 | -
48 |
49 |
50 | true
51 |
52 |
53 |
54 | 0
55 | 2
56 |
57 |
58 |
59 | QAbstractItemView::MultiSelection
60 |
61 |
62 |
63 | -
64 |
65 |
66 | Qt::Horizontal
67 |
68 |
69 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 | buttonBox
79 | accepted()
80 | Dialog
81 | accept()
82 |
83 |
84 | 358
85 | 264
86 |
87 |
88 | 157
89 | 274
90 |
91 |
92 |
93 |
94 | buttonBox
95 | rejected()
96 | Dialog
97 | reject()
98 |
99 |
100 | 316
101 | 260
102 |
103 |
104 | 286
105 | 274
106 |
107 |
108 |
109 |
110 | activeCheck
111 | toggled(bool)
112 | activeList
113 | setEnabled(bool)
114 |
115 |
116 | 133
117 | 18
118 |
119 |
120 | 133
121 | 85
122 |
123 |
124 |
125 |
126 |
127 |
--------------------------------------------------------------------------------
/designer/browserdisp.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 | Dialog
4 |
5 |
6 |
7 | 0
8 | 0
9 | 412
10 | 241
11 |
12 |
13 |
14 | Browser Appearance
15 |
16 |
17 | -
18 |
19 |
20 | Override front template:
21 |
22 |
23 |
24 | -
25 |
26 |
27 | -
28 |
29 |
30 | Override back template:
31 |
32 |
33 |
34 | -
35 |
36 |
37 | -
38 |
39 |
40 | Override font:
41 |
42 |
43 |
44 | -
45 |
46 |
-
47 |
48 |
49 |
50 | 5
51 | 0
52 |
53 |
54 |
55 |
56 | -
57 |
58 |
59 | 6
60 |
61 |
62 |
63 |
64 |
65 | -
66 |
67 |
68 | Qt::Vertical
69 |
70 |
71 |
72 | 20
73 | 40
74 |
75 |
76 |
77 |
78 | -
79 |
80 |
81 | Qt::Horizontal
82 |
83 |
84 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok
85 |
86 |
87 |
88 |
89 |
90 |
91 | qfmt
92 | afmt
93 | font
94 | fontSize
95 | buttonBox
96 |
97 |
98 |
99 |
100 | buttonBox
101 | accepted()
102 | Dialog
103 | accept()
104 |
105 |
106 | 248
107 | 254
108 |
109 |
110 | 157
111 | 274
112 |
113 |
114 |
115 |
116 | buttonBox
117 | rejected()
118 | Dialog
119 | reject()
120 |
121 |
122 | 316
123 | 260
124 |
125 |
126 | 286
127 | 274
128 |
129 |
130 |
131 |
132 |
133 |
--------------------------------------------------------------------------------
/README.development:
--------------------------------------------------------------------------------
1 | Running from source
2 | --------------------
3 |
4 | For non-developers who want to try this development code, the easiest way is
5 | to use a binary package - please see:
6 |
7 | https://anki.tenderapp.com/discussions/beta-testing
8 |
9 | You are welcome to run Anki from source instead, but it is expected that you
10 | can sort out all dependencies and issues by yourself - we are not able to
11 | provide support for problems you encounter when running from source.
12 |
13 | Anki requires:
14 |
15 | - Python 3.4+
16 | - Qt 5.5+
17 | - PyQt5.6+
18 | - mplayer
19 | - lame
20 |
21 | It also requires a number of Python packages, which you can grab via pip:
22 |
23 | $ pip3 install -r requirements.txt
24 |
25 | You will also need PyQt development tools (specifically pyrcc5 and pyuic5).
26 | These are often contained in a separate package on Linux, such as
27 | 'pyqt5-dev-tools' on Debian/Ubuntu.
28 |
29 | To use the development version:
30 |
31 | $ git clone https://github.com/dae/anki.git
32 | $ cd anki
33 | $ ./tools/build_ui.sh
34 |
35 | If you get any errors, you will not be able to proceed, so please return to
36 | the top and check the requirements again.
37 |
38 | ALL USERS: Make sure you rebuild the UI every time you git pull, otherwise you
39 | will get errors down the road.
40 |
41 | The translations are stored in a bazaar repo for integration with Launchpad's
42 | translation services. If you want to use a language other than English:
43 |
44 | $ cd ..
45 | $ mv anki dtop # i18n code expects anki folder to be called dtop
46 | $ bzr clone lp:anki i18n
47 | $ cd i18n
48 | $ ./update-mos.sh
49 | $ cd ../dtop
50 |
51 | And now you're ready to run Anki:
52 | $ ./runanki
53 |
54 | If you get any errors, please make sure you don't have an older version of
55 | Anki installed in a system location.
56 |
57 | Before contributing code, please read the LICENSE file.
58 |
59 | If you'd like to contribute translations, please see the translations section
60 | of http://ankisrs.net/docs/manual.html#_contributing
61 |
62 | Windows & Mac users
63 | ---------------------
64 |
65 | The following was contributed by users in the past and will need updating
66 | for the latest version. It is left here in case it is any help:
67 |
68 | Windows:
69 |
70 | I have not tested the build scripts on Windows, so you'll need to solve any
71 | problems you encounter on your own. The easiest way is to use a source
72 | tarball instead of git, as that way you don't need to build the UI yourself.
73 |
74 | If you do want to use git, two alternatives have been contributed by users. As
75 | these are not official solutions, I'm afraid we can not provide you with any
76 | support for these.
77 |
78 | A powershell script:
79 |
80 | https://gist.github.com/vermiceli/108fec65759d19645ee3
81 |
82 | Or a way with git bash and perl:
83 |
84 | 1) Install "git bash".
85 | 2) In the tools directory, modify build_ui.sh. Locate the line that reads
86 | "pyuic4 $i -o $py" and alter it to be of the following form:
87 | "" "" $i -o $py
88 | These two paths must point to your python executable, and to pyuic.py, on your
89 | system. Typical paths would be:
90 | = C:\\Python27\\python.exe
91 | = C:\\Python27\\Lib\\site-packages\\PyQt4\\uic\\pyuic.py
92 |
93 | Mac:
94 |
95 | These instructions may be incomplete as prerequisites may have already been
96 | installed. Most likely you will need to have installed xcode
97 | (https://developer.apple.com/xcode/)
98 |
99 | Install homebrew (http://brew.sh/) and then install Anki prerequisites:
100 |
101 | $ brew install python PyQt mplayer lame portaudio
102 | $ pip install sqlalchemy
103 |
104 | Now you can follow the development commands at the start of this document.
105 |
--------------------------------------------------------------------------------
/aqt/taglimit.py:
--------------------------------------------------------------------------------
1 | # Copyright: Damien Elmes
2 | # License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html
3 |
4 | import aqt
5 | from aqt.qt import *
6 | from aqt.utils import saveGeom, restoreGeom
7 |
8 | class TagLimit(QDialog):
9 |
10 | def __init__(self, mw, parent):
11 | QDialog.__init__(self, parent, Qt.Window)
12 | self.mw = mw
13 | self.parent = parent
14 | self.deck = self.parent.deck
15 | self.dialog = aqt.forms.taglimit.Ui_Dialog()
16 | self.dialog.setupUi(self)
17 | self.rebuildTagList()
18 | restoreGeom(self, "tagLimit")
19 | self.exec_()
20 |
21 | def rebuildTagList(self):
22 | usertags = self.mw.col.tags.byDeck(self.deck['id'], True)
23 | yes = self.deck.get("activeTags", [])
24 | no = self.deck.get("inactiveTags", [])
25 | yesHash = {}
26 | noHash = {}
27 | for y in yes:
28 | yesHash[y] = True
29 | for n in no:
30 | noHash[n] = True
31 | groupedTags = []
32 | usertags.sort()
33 | icon = QIcon(":/icons/Anki_Fact.png")
34 | groupedTags.append([icon, usertags])
35 | self.tags = []
36 | for (icon, tags) in groupedTags:
37 | for t in tags:
38 | self.tags.append(t)
39 | item = QListWidgetItem(icon, t.replace("_", " "))
40 | self.dialog.activeList.addItem(item)
41 | if t in yesHash:
42 | mode = QItemSelectionModel.Select
43 | self.dialog.activeCheck.setChecked(True)
44 | else:
45 | mode = QItemSelectionModel.Deselect
46 | idx = self.dialog.activeList.indexFromItem(item)
47 | self.dialog.activeList.selectionModel().select(idx, mode)
48 | # inactive
49 | item = QListWidgetItem(icon, t.replace("_", " "))
50 | self.dialog.inactiveList.addItem(item)
51 | if t in noHash:
52 | mode = QItemSelectionModel.Select
53 | else:
54 | mode = QItemSelectionModel.Deselect
55 | idx = self.dialog.inactiveList.indexFromItem(item)
56 | self.dialog.inactiveList.selectionModel().select(idx, mode)
57 |
58 | def reject(self):
59 | self.tags = ""
60 | QDialog.reject(self)
61 |
62 | def accept(self):
63 | self.hide()
64 | n = 0
65 | # gather yes/no tags
66 | yes = []
67 | no = []
68 | for c in range(self.dialog.activeList.count()):
69 | # active
70 | if self.dialog.activeCheck.isChecked():
71 | item = self.dialog.activeList.item(c)
72 | idx = self.dialog.activeList.indexFromItem(item)
73 | if self.dialog.activeList.selectionModel().isSelected(idx):
74 | yes.append(self.tags[c])
75 | # inactive
76 | item = self.dialog.inactiveList.item(c)
77 | idx = self.dialog.inactiveList.indexFromItem(item)
78 | if self.dialog.inactiveList.selectionModel().isSelected(idx):
79 | no.append(self.tags[c])
80 | # save in the deck for future invocations
81 | self.deck['activeTags'] = yes
82 | self.deck['inactiveTags'] = no
83 | self.mw.col.decks.save(self.deck)
84 | # build query string
85 | self.tags = ""
86 | if yes:
87 | arr = []
88 | for req in yes:
89 | arr.append("tag:'%s'" % req)
90 | self.tags += "(" + " or ".join(arr) + ")"
91 | if no:
92 | arr = []
93 | for req in no:
94 | arr.append("-tag:'%s'" % req)
95 | self.tags += " " + " ".join(arr)
96 | saveGeom(self, "tagLimit")
97 | QDialog.accept(self)
98 |
--------------------------------------------------------------------------------
/designer/finddupes.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 | Dialog
4 |
5 |
6 |
7 | 0
8 | 0
9 | 531
10 | 345
11 |
12 |
13 |
14 | Find Duplicates
15 |
16 |
17 | -
18 |
19 |
-
20 |
21 |
22 | -
23 |
24 |
25 | Optional limit:
26 |
27 |
28 |
29 | -
30 |
31 |
32 | Look in field:
33 |
34 |
35 |
36 | -
37 |
38 |
39 |
40 |
41 | -
42 |
43 |
44 | QFrame::StyledPanel
45 |
46 |
47 | QFrame::Raised
48 |
49 |
50 |
51 | 0
52 |
53 |
54 | 0
55 |
56 |
57 | 0
58 |
59 |
60 | 0
61 |
62 |
-
63 |
64 |
65 |
66 | about:blank
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | -
75 |
76 |
77 | Qt::Horizontal
78 |
79 |
80 | QDialogButtonBox::Close
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 | AnkiWebView
89 | QWidget
90 |
91 | 1
92 |
93 |
94 |
95 | fields
96 | webView
97 | buttonBox
98 |
99 |
100 |
101 |
102 | buttonBox
103 | accepted()
104 | Dialog
105 | accept()
106 |
107 |
108 | 248
109 | 254
110 |
111 |
112 | 157
113 | 274
114 |
115 |
116 |
117 |
118 | buttonBox
119 | rejected()
120 | Dialog
121 | reject()
122 |
123 |
124 | 316
125 | 260
126 |
127 |
128 | 286
129 | 274
130 |
131 |
132 |
133 |
134 |
135 |
--------------------------------------------------------------------------------
/anki/template/view.py:
--------------------------------------------------------------------------------
1 | from anki.template import Template
2 | import os.path
3 | import re
4 |
5 | class View(object):
6 | # Path where this view's template(s) live
7 | template_path = '.'
8 |
9 | # Extension for templates
10 | template_extension = 'mustache'
11 |
12 | # The name of this template. If none is given the View will try
13 | # to infer it based on the class name.
14 | template_name = None
15 |
16 | # Absolute path to the template itself. Pystache will try to guess
17 | # if it's not provided.
18 | template_file = None
19 |
20 | # Contents of the template.
21 | template = None
22 |
23 | # Character encoding of the template file. If None, Pystache will not
24 | # do any decoding of the template.
25 | template_encoding = None
26 |
27 | def __init__(self, template=None, context=None, **kwargs):
28 | self.template = template
29 | self.context = context or {}
30 |
31 | # If the context we're handed is a View, we want to inherit
32 | # its settings.
33 | if isinstance(context, View):
34 | self.inherit_settings(context)
35 |
36 | if kwargs:
37 | self.context.update(kwargs)
38 |
39 | def inherit_settings(self, view):
40 | """Given another View, copies its settings."""
41 | if view.template_path:
42 | self.template_path = view.template_path
43 |
44 | if view.template_name:
45 | self.template_name = view.template_name
46 |
47 | def load_template(self):
48 | if self.template:
49 | return self.template
50 |
51 | if self.template_file:
52 | return self._load_template()
53 |
54 | name = self.get_template_name() + '.' + self.template_extension
55 |
56 | if isinstance(self.template_path, str):
57 | self.template_file = os.path.join(self.template_path, name)
58 | return self._load_template()
59 |
60 | for path in self.template_path:
61 | self.template_file = os.path.join(path, name)
62 | if os.path.exists(self.template_file):
63 | return self._load_template()
64 |
65 | raise IOError('"%s" not found in "%s"' % (name, ':'.join(self.template_path),))
66 |
67 |
68 | def _load_template(self):
69 | f = open(self.template_file, 'r')
70 | try:
71 | template = f.read()
72 | if self.template_encoding:
73 | template = str(template, self.template_encoding)
74 | finally:
75 | f.close()
76 | return template
77 |
78 | def get_template_name(self, name=None):
79 | """TemplatePartial => template_partial
80 | Takes a string but defaults to using the current class' name or
81 | the `template_name` attribute
82 | """
83 | if self.template_name:
84 | return self.template_name
85 |
86 | if not name:
87 | name = self.__class__.__name__
88 |
89 | def repl(match):
90 | return '_' + match.group(0).lower()
91 |
92 | return re.sub('[A-Z]', repl, name)[1:]
93 |
94 | def __contains__(self, needle):
95 | return needle in self.context or hasattr(self, needle)
96 |
97 | def __getitem__(self, attr):
98 | val = self.get(attr, None)
99 | if not val:
100 | raise KeyError("No such key.")
101 | return val
102 |
103 | def get(self, attr, default):
104 | attr = self.context.get(attr, getattr(self, attr, default))
105 |
106 | if hasattr(attr, '__call__'):
107 | return attr()
108 | else:
109 | return attr
110 |
111 | def render(self, encoding=None):
112 | template = self.load_template()
113 | return Template(template, self).render(encoding=encoding)
114 |
115 | def __str__(self):
116 | return self.render()
117 |
--------------------------------------------------------------------------------
/designer/findreplace.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 | Dialog
4 |
5 |
6 |
7 | 0
8 | 0
9 | 367
10 | 209
11 |
12 |
13 |
14 | Find and Replace
15 |
16 |
17 | -
18 |
19 |
-
20 |
21 |
22 | <b>Find</b>:
23 |
24 |
25 |
26 | -
27 |
28 |
29 | -
30 |
31 |
32 | <b>Replace With</b>:
33 |
34 |
35 |
36 | -
37 |
38 |
39 | -
40 |
41 |
42 | <b>In</b>:
43 |
44 |
45 |
46 | -
47 |
48 |
49 | -
50 |
51 |
52 | Treat input as regular expression
53 |
54 |
55 |
56 | -
57 |
58 |
59 | Ignore case
60 |
61 |
62 | true
63 |
64 |
65 |
66 |
67 |
68 | -
69 |
70 |
71 | Qt::Vertical
72 |
73 |
74 |
75 | 20
76 | 40
77 |
78 |
79 |
80 |
81 | -
82 |
83 |
84 | Qt::Horizontal
85 |
86 |
87 | QDialogButtonBox::Cancel|QDialogButtonBox::Help|QDialogButtonBox::Ok
88 |
89 |
90 |
91 |
92 |
93 |
94 | find
95 | replace
96 | field
97 | ignoreCase
98 | re
99 | buttonBox
100 |
101 |
102 |
103 |
104 | buttonBox
105 | accepted()
106 | Dialog
107 | accept()
108 |
109 |
110 | 256
111 | 154
112 |
113 |
114 | 157
115 | 274
116 |
117 |
118 |
119 |
120 | buttonBox
121 | rejected()
122 | Dialog
123 | reject()
124 |
125 |
126 | 290
127 | 154
128 |
129 |
130 | 286
131 | 274
132 |
133 |
134 |
135 |
136 |
137 |
--------------------------------------------------------------------------------
/aqt/dyndeckconf.py:
--------------------------------------------------------------------------------
1 | # Copyright: Damien Elmes
2 | # -*- coding: utf-8 -*-
3 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
4 |
5 | from aqt.qt import *
6 | import aqt
7 | from aqt.utils import showWarning, openHelp, askUser, saveGeom, restoreGeom
8 |
9 | class DeckConf(QDialog):
10 | def __init__(self, mw, first=False, search="", deck=None):
11 | QDialog.__init__(self, mw)
12 | self.mw = mw
13 | self.deck = deck or self.mw.col.decks.current()
14 | self.search = search
15 | self.form = aqt.forms.dyndconf.Ui_Dialog()
16 | self.form.setupUi(self)
17 | if first:
18 | label = _("Build")
19 | else:
20 | label = _("Rebuild")
21 | self.ok = self.form.buttonBox.addButton(
22 | label, QDialogButtonBox.AcceptRole)
23 | self.mw.checkpoint(_("Options"))
24 | self.setWindowModality(Qt.WindowModal)
25 | self.form.buttonBox.helpRequested.connect(lambda: openHelp("filtered"))
26 | self.setWindowTitle(_("Options for %s") % self.deck['name'])
27 | restoreGeom(self, "dyndeckconf")
28 | self.setupOrder()
29 | self.loadConf()
30 | if search:
31 | self.form.search.setText(search)
32 | self.form.search.selectAll()
33 | self.show()
34 | self.exec_()
35 | saveGeom(self, "dyndeckconf")
36 |
37 | def setupOrder(self):
38 | import anki.consts as cs
39 | self.form.order.addItems(list(cs.dynOrderLabels().values()))
40 |
41 | def loadConf(self):
42 | f = self.form
43 | d = self.deck
44 | search, limit, order = d['terms'][0]
45 | f.search.setText(search)
46 | if d['delays']:
47 | f.steps.setText(self.listToUser(d['delays']))
48 | f.stepsOn.setChecked(True)
49 | else:
50 | f.steps.setText("1 10")
51 | f.stepsOn.setChecked(False)
52 | f.resched.setChecked(d['resched'])
53 | f.order.setCurrentIndex(order)
54 | f.limit.setValue(limit)
55 |
56 | def saveConf(self):
57 | f = self.form
58 | d = self.deck
59 | d['delays'] = None
60 | if f.stepsOn.isChecked():
61 | steps = self.userToList(f.steps)
62 | if steps:
63 | d['delays'] = steps
64 | else:
65 | d['delays'] = None
66 | d['terms'][0] = [f.search.text(),
67 | f.limit.value(),
68 | f.order.currentIndex()]
69 | d['resched'] = f.resched.isChecked()
70 | self.mw.col.decks.save(d)
71 | return True
72 |
73 | def reject(self):
74 | self.ok = False
75 | QDialog.reject(self)
76 |
77 | def accept(self):
78 | if not self.saveConf():
79 | return
80 | if not self.mw.col.sched.rebuildDyn():
81 | if askUser(_("""\
82 | The provided search did not match any cards. Would you like to revise \
83 | it?""")):
84 | return
85 | self.mw.reset()
86 | QDialog.accept(self)
87 |
88 | # Step load/save - fixme: share with std options screen
89 | ########################################################
90 |
91 | def listToUser(self, l):
92 | return " ".join([str(x) for x in l])
93 |
94 | def userToList(self, w, minSize=1):
95 | items = str(w.text()).split(" ")
96 | ret = []
97 | for i in items:
98 | if not i:
99 | continue
100 | try:
101 | i = float(i)
102 | assert i > 0
103 | if i == int(i):
104 | i = int(i)
105 | ret.append(i)
106 | except:
107 | # invalid, don't update
108 | showWarning(_("Steps must be numbers."))
109 | return
110 | if len(ret) < minSize:
111 | showWarning(_("At least one step is required."))
112 | return
113 | return ret
114 |
--------------------------------------------------------------------------------
/aqt/errors.py:
--------------------------------------------------------------------------------
1 | # Copyright: Damien Elmes
2 | # -*- coding: utf-8 -*-
3 | # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
4 | import sys, traceback
5 | import cgi
6 |
7 | from anki.lang import _
8 | from aqt.qt import *
9 | from aqt.utils import showText, showWarning
10 |
11 | def excepthook(etype,val,tb):
12 | sys.stderr.write("Caught exception:\n%s%s\n" % (
13 | ''.join(traceback.format_tb(tb)),
14 | '{0}: {1}'.format(etype, val)))
15 | sys.excepthook = excepthook
16 |
17 | class ErrorHandler(QObject):
18 | "Catch stderr and write into buffer."
19 | ivl = 100
20 |
21 | errorTimer = pyqtSignal()
22 |
23 | def __init__(self, mw):
24 | QObject.__init__(self, mw)
25 | self.mw = mw
26 | self.timer = None
27 | self.errorTimer.connect(self._setTimer)
28 | self.pool = ""
29 | sys.stderr = self
30 |
31 | def write(self, data):
32 | # dump to stdout
33 | sys.stdout.write(data)
34 | # save in buffer
35 | self.pool += data
36 | # and update timer
37 | self.setTimer()
38 |
39 | def setTimer(self):
40 | # we can't create a timer from a different thread, so we post a
41 | # message to the object on the main thread
42 | self.errorTimer.emit()
43 |
44 | def _setTimer(self):
45 | if not self.timer:
46 | self.timer = QTimer(self.mw)
47 | self.timer.timeout.connect(self.onTimeout)
48 | self.timer.setInterval(self.ivl)
49 | self.timer.setSingleShot(True)
50 | self.timer.start()
51 |
52 | def tempFolderMsg(self):
53 | return _("""\
54 | The permissions on your system's temporary folder are incorrect, and Anki is \
55 | not able to correct them automatically. Please search for 'temp folder' in the \
56 | Anki manual for more information.""")
57 |
58 | def onTimeout(self):
59 | error = cgi.escape(self.pool)
60 | self.pool = ""
61 | self.mw.progress.clear()
62 | if "abortSchemaMod" in error:
63 | return
64 | if "Pyaudio not" in error:
65 | return showWarning(_("Please install PyAudio"))
66 | if "install mplayer" in error:
67 | return showWarning(_("Please install mplayer"))
68 | if "no default input" in error.lower():
69 | return showWarning(_("Please connect a microphone, and ensure "
70 | "other programs are not using the audio device."))
71 | if "invalidTempFolder" in error:
72 | return showWarning(self.tempFolderMsg())
73 | if "Beautiful Soup is not an HTTP client" in error:
74 | return
75 | if "disk I/O error" in error:
76 | return showWarning(_("""\
77 | An error occurred while accessing the database.
78 |
79 | Possible causes:
80 |
81 | - Antivirus, firewall, backup, or synchronization software may be \
82 | interfering with Anki. Try disabling such software and see if the \
83 | problem goes away.
84 | - Your disk may be full.
85 | - The Documents/Anki folder may be on a network drive.
86 | - Files in the Documents/Anki folder may not be writeable.
87 | - Your hard disk may have errors.
88 |
89 | It's a good idea to run Tools>Check Database to ensure your collection \
90 | is not corrupt.
91 | """))
92 | stdText = _("""\
93 | An error occurred. It may have been caused by a harmless bug,
94 | or your deck may have a problem.
95 | To confirm it's not a problem with your deck, please run
96 | Tools > Check Database.
97 |
If that doesn't fix the problem, please copy the following
98 | into a bug report:""")
99 | pluginText = _("""\
100 | An error occurred in an add-on.
101 | Please post on the add-on forum:
%s
""")
102 | pluginText %= "https://anki.tenderapp.com/discussions/add-ons"
103 | if "addon" in error:
104 | txt = pluginText
105 | else:
106 | txt = stdText
107 | # show dialog
108 | txt = txt + "
" + error + "
"
109 | showText(txt, type="html")
110 |
--------------------------------------------------------------------------------
/tests/test_latex.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 |
3 | import os
4 |
5 | import shutil
6 |
7 | from tests.shared import getEmptyCol
8 | from anki.utils import stripHTML
9 |
10 | def test_latex():
11 | d = getEmptyCol()
12 | # change latex cmd to simulate broken build
13 | import anki.latex
14 | anki.latex.latexCmds[0][0] = "nolatex"
15 | # add a note with latex
16 | f = d.newNote()
17 | f['Front'] = "[latex]hello[/latex]"
18 | d.addNote(f)
19 | # but since latex couldn't run, there's nothing there
20 | assert len(os.listdir(d.media.dir())) == 0
21 | # check the error message
22 | msg = f.cards()[0].q()
23 | assert "executing nolatex" in msg
24 | assert "installed" in msg
25 | # check if we have latex installed, and abort test if we don't
26 | if not shutil.which("latex") or not shutil.which("dvipng"):
27 | print("aborting test; latex or dvipng is not installed")
28 | return
29 | # fix path
30 | anki.latex.latexCmds[0][0] = "latex"
31 | # check media db should cause latex to be generated
32 | d.media.check()
33 | assert len(os.listdir(d.media.dir())) == 1
34 | assert ".png" in f.cards()[0].q()
35 | # adding new notes should cause generation on question display
36 | f = d.newNote()
37 | f['Front'] = "[latex]world[/latex]"
38 | d.addNote(f)
39 | f.cards()[0].q()
40 | assert len(os.listdir(d.media.dir())) == 2
41 | # another note with the same media should reuse
42 | f = d.newNote()
43 | f['Front'] = " [latex]world[/latex]"
44 | d.addNote(f)
45 | assert len(os.listdir(d.media.dir())) == 2
46 | oldcard = f.cards()[0]
47 | assert ".png" in oldcard.q()
48 | # if we turn off building, then previous cards should work, but cards with
49 | # missing media will show the latex
50 | anki.latex.build = False
51 | f = d.newNote()
52 | f['Front'] = "[latex]foo[/latex]"
53 | d.addNote(f)
54 | assert len(os.listdir(d.media.dir())) == 2
55 | assert stripHTML(f.cards()[0].q()) == "[latex]foo[/latex]"
56 | assert ".png" in oldcard.q()
57 | # turn it on again so other test don't suffer
58 | anki.latex.build = True
59 |
60 | def test_bad_latex_command_write18():
61 | (result, msg) = _test_includes_bad_command("\\write18")
62 | assert result, msg
63 |
64 | def test_bad_latex_command_readline():
65 | (result, msg) = _test_includes_bad_command("\\readline")
66 | assert result, msg
67 |
68 | def test_bad_latex_command_input():
69 | (result, msg) = _test_includes_bad_command("\\input")
70 | assert result, msg
71 |
72 | def test_bad_latex_command_include():
73 | (result, msg) = _test_includes_bad_command("\\include")
74 | assert result, msg
75 |
76 | def test_bad_latex_command_catcode():
77 | (result, msg) = _test_includes_bad_command("\\catcode")
78 | assert result, msg
79 |
80 | def test_bad_latex_command_openout():
81 | (result, msg) = _test_includes_bad_command("\\openout")
82 | assert result, msg
83 |
84 | def test_bad_latex_command_write():
85 | (result, msg) = _test_includes_bad_command("\\write")
86 | assert result, msg
87 |
88 | def test_bad_latex_command_loop():
89 | (result, msg) = _test_includes_bad_command("\\loop")
90 | assert result, msg
91 |
92 | def test_bad_latex_command_def():
93 | (result, msg) = _test_includes_bad_command("\\def")
94 | assert result, msg
95 |
96 | def test_bad_latex_command_shipout():
97 | (result, msg) = _test_includes_bad_command("\\shipout")
98 | assert result, msg
99 |
100 | def test_good_latex_command_works():
101 | # inserting commands beginning with a bad name should not raise an error
102 | (result, msg) = _test_includes_bad_command("\\defeq")
103 | assert not result, msg
104 | # normal commands should not either
105 | (result, msg) = _test_includes_bad_command("\\emph")
106 | assert not result, msg
107 |
108 | def _test_includes_bad_command(bad):
109 | d = getEmptyCol()
110 | f = d.newNote()
111 | f['Front'] = '[latex]%s[/latex]' % bad;
112 | d.addNote(f)
113 | q = f.cards()[0].q()
114 | return ("'%s' is not allowed on cards" % bad in q, "Card content: %s" % q)
115 |
--------------------------------------------------------------------------------
/designer/reposition.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 | Dialog
4 |
5 |
6 |
7 | 0
8 | 0
9 | 272
10 | 229
11 |
12 |
13 |
14 | Reposition New Cards
15 |
16 |
17 | -
18 |
19 |
20 |
21 |
22 |
23 |
24 | -
25 |
26 |
-
27 |
28 |
29 | Start position:
30 |
31 |
32 |
33 | -
34 |
35 |
36 | -20000000
37 |
38 |
39 | 200000000
40 |
41 |
42 | 0
43 |
44 |
45 |
46 | -
47 |
48 |
49 | Step:
50 |
51 |
52 |
53 | -
54 |
55 |
56 | 1
57 |
58 |
59 | 10000
60 |
61 |
62 |
63 |
64 |
65 | -
66 |
67 |
68 | Randomize order
69 |
70 |
71 |
72 | -
73 |
74 |
75 | Shift position of existing cards
76 |
77 |
78 | true
79 |
80 |
81 |
82 | -
83 |
84 |
85 | Qt::Vertical
86 |
87 |
88 |
89 | 20
90 | 40
91 |
92 |
93 |
94 |
95 | -
96 |
97 |
98 | Qt::Horizontal
99 |
100 |
101 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok
102 |
103 |
104 |
105 |
106 |
107 |
108 | start
109 | step
110 | randomize
111 | shift
112 | buttonBox
113 |
114 |
115 |
116 |
117 | buttonBox
118 | accepted()
119 | Dialog
120 | accept()
121 |
122 |
123 | 248
124 | 254
125 |
126 |
127 | 157
128 | 274
129 |
130 |
131 |
132 |
133 | buttonBox
134 | rejected()
135 | Dialog
136 | reject()
137 |
138 |
139 | 316
140 | 260
141 |
142 |
143 | 286
144 | 274
145 |
146 |
147 |
148 |
149 |
150 |
--------------------------------------------------------------------------------
/designer/exporting.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 | ExportDialog
4 |
5 |
6 |
7 | 0
8 | 0
9 | 295
10 | 202
11 |
12 |
13 |
14 | Export
15 |
16 |
17 | -
18 |
19 |
-
20 |
21 |
22 |
23 | 100
24 | 0
25 |
26 |
27 |
28 | <b>Export format</b>:
29 |
30 |
31 |
32 | -
33 |
34 |
35 | -
36 |
37 |
38 | <b>Include</b>:
39 |
40 |
41 |
42 | -
43 |
44 |
45 |
46 |
47 | -
48 |
49 |
-
50 |
51 |
52 | Include scheduling information
53 |
54 |
55 | true
56 |
57 |
58 |
59 | -
60 |
61 |
62 | Include media
63 |
64 |
65 | true
66 |
67 |
68 |
69 | -
70 |
71 |
72 | Include tags
73 |
74 |
75 | true
76 |
77 |
78 |
79 |
80 |
81 | -
82 |
83 |
84 | Qt::Vertical
85 |
86 |
87 |
88 | 20
89 | 40
90 |
91 |
92 |
93 |
94 | -
95 |
96 |
97 | Qt::Horizontal
98 |
99 |
100 | QDialogButtonBox::Cancel
101 |
102 |
103 |
104 |
105 |
106 |
107 | format
108 | deck
109 | includeSched
110 | includeMedia
111 | includeTags
112 | buttonBox
113 |
114 |
115 |
116 |
117 | buttonBox
118 | accepted()
119 | ExportDialog
120 | accept()
121 |
122 |
123 | 248
124 | 254
125 |
126 |
127 | 157
128 | 274
129 |
130 |
131 |
132 |
133 | buttonBox
134 | rejected()
135 | ExportDialog
136 | reject()
137 |
138 |
139 | 316
140 | 260
141 |
142 |
143 | 286
144 | 274
145 |
146 |
147 |
148 |
149 |
150 |
--------------------------------------------------------------------------------
/designer/browseropts.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 | Dialog
4 |
5 |
6 |
7 | 0
8 | 0
9 | 288
10 | 195
11 |
12 |
13 |
14 | Browser Options
15 |
16 |
17 | -
18 |
19 |
-
20 |
21 |
22 | <b>Font</b>:
23 |
24 |
25 |
26 | -
27 |
28 |
29 |
30 |
31 | -
32 |
33 |
-
34 |
35 |
36 | <b>Font Size</b>:
37 |
38 |
39 |
40 | -
41 |
42 |
43 |
44 | 75
45 | 0
46 |
47 |
48 |
49 |
50 | -
51 |
52 |
53 | <b>Line Size</b>:
54 |
55 |
56 |
57 | -
58 |
59 |
60 | -
61 |
62 |
63 | Qt::Horizontal
64 |
65 |
66 |
67 | 40
68 | 20
69 |
70 |
71 |
72 |
73 |
74 |
75 | -
76 |
77 |
78 | Search within formatting (slow)
79 |
80 |
81 |
82 | -
83 |
84 |
85 | Qt::Vertical
86 |
87 |
88 |
89 | 20
90 | 40
91 |
92 |
93 |
94 |
95 | -
96 |
97 |
98 | Qt::Horizontal
99 |
100 |
101 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok
102 |
103 |
104 |
105 |
106 |
107 |
108 | fontCombo
109 | fontSize
110 | lineSize
111 | fullSearch
112 | buttonBox
113 |
114 |
115 |
116 |
117 | buttonBox
118 | accepted()
119 | Dialog
120 | accept()
121 |
122 |
123 | 248
124 | 254
125 |
126 |
127 | 157
128 | 274
129 |
130 |
131 |
132 |
133 | buttonBox
134 | rejected()
135 | Dialog
136 | reject()
137 |
138 |
139 | 316
140 | 260
141 |
142 |
143 | 286
144 | 274
145 |
146 |
147 |
148 |
149 |
150 |
--------------------------------------------------------------------------------
/designer/addfield.ui:
--------------------------------------------------------------------------------
1 |
2 |
3 | Dialog
4 |
5 |
6 |
7 | 0
8 | 0
9 | 434
10 | 186
11 |
12 |
13 |
14 | Add Field
15 |
16 |
17 | -
18 |
19 |
-
20 |
21 |
22 | Front
23 |
24 |
25 | true
26 |
27 |
28 |
29 | -
30 |
31 |
32 | 6
33 |
34 |
35 | 200
36 |
37 |
38 |
39 | -
40 |
41 |
42 | Field:
43 |
44 |
45 |
46 | -
47 |
48 |
49 | Font:
50 |
51 |
52 |
53 | -
54 |
55 |
56 | -
57 |
58 |
59 | Size:
60 |
61 |
62 |
63 | -
64 |
65 |
66 | -
67 |
68 |
69 | Qt::Vertical
70 |
71 |
72 |
73 | 20
74 | 40
75 |
76 |
77 |
78 |
79 | -
80 |
81 |
82 | Back
83 |
84 |
85 |
86 | -
87 |
88 |
89 | Add to:
90 |
91 |
92 |
93 |
94 |
95 | -
96 |
97 |
98 | Qt::Vertical
99 |
100 |
101 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok
102 |
103 |
104 |
105 |
106 |
107 |
108 | fields
109 | font
110 | size
111 | radioQ
112 | radioA
113 | buttonBox
114 |
115 |
116 |
117 |
118 | buttonBox
119 | accepted()
120 | Dialog
121 | accept()
122 |
123 |
124 | 248
125 | 254
126 |
127 |
128 | 157
129 | 274
130 |
131 |
132 |
133 |
134 | buttonBox
135 | rejected()
136 | Dialog
137 | reject()
138 |
139 |
140 | 316
141 | 260
142 |
143 |
144 | 286
145 | 274
146 |
147 |
148 |
149 |
150 |
151 |
--------------------------------------------------------------------------------
/tests/test_collection.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 |
3 | import os, tempfile
4 | from tests.shared import assertException, getEmptyCol
5 | from anki.stdmodels import addBasicModel
6 |
7 | from anki import Collection as aopen
8 |
9 | newPath = None
10 | newMod = None
11 |
12 | def test_create():
13 | global newPath, newMod
14 | (fd, path) = tempfile.mkstemp(suffix=".anki2", prefix="test_attachNew")
15 | try:
16 | os.close(fd)
17 | os.unlink(path)
18 | except OSError:
19 | pass
20 | deck = aopen(path)
21 | # for open()
22 | newPath = deck.path
23 | deck.close()
24 | newMod = deck.mod
25 | del deck
26 |
27 | def test_open():
28 | deck = aopen(newPath)
29 | assert deck.mod == newMod
30 | deck.close()
31 |
32 | def test_openReadOnly():
33 | # non-writeable dir
34 | assertException(Exception,
35 | lambda: aopen("/attachroot.anki2"))
36 | # reuse tmp file from before, test non-writeable file
37 | os.chmod(newPath, 0)
38 | assertException(Exception,
39 | lambda: aopen(newPath))
40 | os.chmod(newPath, 0o666)
41 | os.unlink(newPath)
42 |
43 | def test_noteAddDelete():
44 | deck = getEmptyCol()
45 | # add a note
46 | f = deck.newNote()
47 | f['Front'] = "one"; f['Back'] = "two"
48 | n = deck.addNote(f)
49 | assert n == 1
50 | # test multiple cards - add another template
51 | m = deck.models.current(); mm = deck.models
52 | t = mm.newTemplate("Reverse")
53 | t['qfmt'] = "{{Back}}"
54 | t['afmt'] = "{{Front}}"
55 | mm.addTemplate(m, t)
56 | mm.save(m)
57 | # the default save doesn't generate cards
58 | assert deck.cardCount() == 1
59 | # but when templates are edited such as in the card layout screen, it
60 | # should generate cards on close
61 | mm.save(m, templates=True)
62 | assert deck.cardCount() == 2
63 | # creating new notes should use both cards
64 | f = deck.newNote()
65 | f['Front'] = "three"; f['Back'] = "four"
66 | n = deck.addNote(f)
67 | assert n == 2
68 | assert deck.cardCount() == 4
69 | # check q/a generation
70 | c0 = f.cards()[0]
71 | assert "three" in c0.q()
72 | # it should not be a duplicate
73 | assert not f.dupeOrEmpty()
74 | # now let's make a duplicate
75 | f2 = deck.newNote()
76 | f2['Front'] = "one"; f2['Back'] = ""
77 | assert f2.dupeOrEmpty()
78 | # empty first field should not be permitted either
79 | f2['Front'] = " "
80 | assert f2.dupeOrEmpty()
81 |
82 | def test_fieldChecksum():
83 | deck = getEmptyCol()
84 | f = deck.newNote()
85 | f['Front'] = "new"; f['Back'] = "new2"
86 | deck.addNote(f)
87 | assert deck.db.scalar(
88 | "select csum from notes") == int("c2a6b03f", 16)
89 | # changing the val should change the checksum
90 | f['Front'] = "newx"
91 | f.flush()
92 | assert deck.db.scalar(
93 | "select csum from notes") == int("302811ae", 16)
94 |
95 | def test_addDelTags():
96 | deck = getEmptyCol()
97 | f = deck.newNote()
98 | f['Front'] = "1"
99 | deck.addNote(f)
100 | f2 = deck.newNote()
101 | f2['Front'] = "2"
102 | deck.addNote(f2)
103 | # adding for a given id
104 | deck.tags.bulkAdd([f.id], "foo")
105 | f.load(); f2.load()
106 | assert "foo" in f.tags
107 | assert "foo" not in f2.tags
108 | # should be canonified
109 | deck.tags.bulkAdd([f.id], "foo aaa")
110 | f.load()
111 | assert f.tags[0] == "aaa"
112 | assert len(f.tags) == 2
113 |
114 | def test_timestamps():
115 | deck = getEmptyCol()
116 | assert len(deck.models.models) == 4
117 | for i in range(100):
118 | addBasicModel(deck)
119 | assert len(deck.models.models) == 104
120 |
121 | def test_furigana():
122 | deck = getEmptyCol()
123 | mm = deck.models
124 | m = mm.current()
125 | # filter should work
126 | m['tmpls'][0]['qfmt'] = '{{kana:Front}}'
127 | mm.save(m)
128 | n = deck.newNote()
129 | n['Front'] = 'foo[abc]'
130 | deck.addNote(n)
131 | c = n.cards()[0]
132 | assert c.q().endswith("abc")
133 | # and should avoid sound
134 | n['Front'] = 'foo[sound:abc.mp3]'
135 | n.flush()
136 | assert "sound:" in c.q(reload=True)
137 | # it shouldn't throw an error while people are editing
138 | m['tmpls'][0]['qfmt'] = '{{kana:}}'
139 | mm.save(m)
140 | c.q(reload=True)
141 |
--------------------------------------------------------------------------------
/tests/test_media.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 |
3 | import tempfile
4 | import os
5 | import time
6 |
7 | from .shared import getEmptyCol, testDir
8 |
9 |
10 | # copying files to media folder
11 | def test_add():
12 | d = getEmptyCol()
13 | dir = tempfile.mkdtemp(prefix="anki")
14 | path = os.path.join(dir, "foo.jpg")
15 | open(path, "w").write("hello")
16 | # new file, should preserve name
17 | assert d.media.addFile(path) == "foo.jpg"
18 | # adding the same file again should not create a duplicate
19 | assert d.media.addFile(path) == "foo.jpg"
20 | # but if it has a different md5, it should
21 | open(path, "w").write("world")
22 | assert d.media.addFile(path) == "foo (1).jpg"
23 |
24 | def test_strings():
25 | d = getEmptyCol()
26 | mf = d.media.filesInStr
27 | mid = list(d.models.models.keys())[0]
28 | assert mf(mid, "aoeu") == []
29 | assert mf(mid, "aoeu
ao") == ["foo.jpg"]
30 | assert mf(mid, "aoeu
ao") == ["foo.jpg"]
31 | assert mf(mid, "aoeu
ao") == [
32 | "foo.jpg", "bar.jpg"]
33 | assert mf(mid, "aoeu
ao") == ["foo.jpg"]
34 | assert mf(mid, "
") == ["one", "two"]
35 | assert mf(mid, "aoeu
ao") == ["foo.jpg"]
36 | assert mf(mid, "aoeu
ao") == [
37 | "foo.jpg", "fo"]
38 | assert mf(mid, "aou[sound:foo.mp3]aou") == ["foo.mp3"]
39 | sp = d.media.strip
40 | assert sp("aoeu") == "aoeu"
41 | assert sp("aoeu[sound:foo.mp3]aoeu") == "aoeuaoeu"
42 | assert sp("a
oeu") == "aoeu"
43 | es = d.media.escapeImages
44 | assert es("aoeu") == "aoeu"
45 | assert es("
") == "
"
46 | assert es('
') == '
'
47 |
48 | def test_deckIntegration():
49 | d = getEmptyCol()
50 | # create a media dir
51 | d.media.dir()
52 | # put a file into it
53 | file = str(os.path.join(testDir, "support/fake.png"))
54 | d.media.addFile(file)
55 | # add a note which references it
56 | f = d.newNote()
57 | f['Front'] = "one"; f['Back'] = "
"
58 | d.addNote(f)
59 | # and one which references a non-existent file
60 | f = d.newNote()
61 | f['Front'] = "one"; f['Back'] = "
"
62 | d.addNote(f)
63 | # and add another file which isn't used
64 | open(os.path.join(d.media.dir(), "foo.jpg"), "w").write("test")
65 | # check media
66 | ret = d.media.check()
67 | assert ret[0] == ["fake2.png"]
68 | assert ret[1] == ["foo.jpg"]
69 |
70 | def test_changes():
71 | d = getEmptyCol()
72 | assert d.media._changed()
73 | def added():
74 | return d.media.db.execute("select fname from media where csum is not null")
75 | def removed():
76 | return d.media.db.execute("select fname from media where csum is null")
77 | assert not list(added())
78 | assert not list(removed())
79 | # add a file
80 | dir = tempfile.mkdtemp(prefix="anki")
81 | path = os.path.join(dir, "foo.jpg")
82 | open(path, "w").write("hello")
83 | time.sleep(1)
84 | path = d.media.addFile(path)
85 | # should have been logged
86 | d.media.findChanges()
87 | assert list(added())
88 | assert not list(removed())
89 | # if we modify it, the cache won't notice
90 | time.sleep(1)
91 | open(path, "w").write("world")
92 | assert len(list(added())) == 1
93 | assert not list(removed())
94 | # but if we add another file, it will
95 | time.sleep(1)
96 | open(path+"2", "w").write("yo")
97 | d.media.findChanges()
98 | assert len(list(added())) == 2
99 | assert not list(removed())
100 | # deletions should get noticed too
101 | time.sleep(1)
102 | os.unlink(path+"2")
103 | d.media.findChanges()
104 | assert len(list(added())) == 1
105 | assert len(list(removed())) == 1
106 |
107 | def test_illegal():
108 | d = getEmptyCol()
109 | aString = "a:b|cd\\e/f\0g*h"
110 | good = "abcdefgh"
111 | assert d.media.stripIllegal(aString) == good
112 | for c in aString:
113 | bad = d.media.hasIllegal("somestring"+c+"morestring")
114 | if bad:
115 | assert(c not in good)
116 | else:
117 | assert(c in good)
118 |
--------------------------------------------------------------------------------
/tests/test_exporting.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 |
3 | import nose, os, tempfile
4 | from anki import Collection as aopen
5 | from anki.exporting import *
6 | from anki.importing import Anki2Importer
7 | from .shared import getEmptyCol
8 |
9 | deck = None
10 | ds = None
11 | testDir = os.path.dirname(__file__)
12 |
13 | def setup1():
14 | global deck
15 | deck = getEmptyCol()
16 | f = deck.newNote()
17 | f['Front'] = "foo"; f['Back'] = "bar"; f.tags = ["tag", "tag2"]
18 | deck.addNote(f)
19 | # with a different deck
20 | f = deck.newNote()
21 | f['Front'] = "baz"; f['Back'] = "qux"
22 | f.model()['did'] = deck.decks.id("new deck")
23 | deck.addNote(f)
24 |
25 | ##########################################################################
26 |
27 | @nose.with_setup(setup1)
28 | def test_export_anki():
29 | # create a new deck with its own conf to test conf copying
30 | did = deck.decks.id("test")
31 | dobj = deck.decks.get(did)
32 | confId = deck.decks.confId("newconf")
33 | conf = deck.decks.getConf(confId)
34 | conf['new']['perDay'] = 5
35 | deck.decks.save(conf)
36 | deck.decks.setConf(dobj, confId)
37 | # export
38 | e = AnkiExporter(deck)
39 | fd, newname = tempfile.mkstemp(prefix="ankitest", suffix=".anki2")
40 | newname = str(newname)
41 | os.close(fd)
42 | os.unlink(newname)
43 | e.exportInto(newname)
44 | # exporting should not have changed conf for original deck
45 | conf = deck.decks.confForDid(did)
46 | assert conf['id'] != 1
47 | # connect to new deck
48 | d2 = aopen(newname)
49 | assert d2.cardCount() == 2
50 | # as scheduling was reset, should also revert decks to default conf
51 | did = d2.decks.id("test", create=False)
52 | assert did
53 | conf2 = d2.decks.confForDid(did)
54 | assert conf2['new']['perDay'] == 20
55 | dobj = d2.decks.get(did)
56 | # conf should be 1
57 | assert dobj['conf'] == 1
58 | # try again, limited to a deck
59 | fd, newname = tempfile.mkstemp(prefix="ankitest", suffix=".anki2")
60 | newname = str(newname)
61 | os.close(fd)
62 | os.unlink(newname)
63 | e.did = 1
64 | e.exportInto(newname)
65 | d2 = aopen(newname)
66 | assert d2.cardCount() == 1
67 |
68 | @nose.with_setup(setup1)
69 | def test_export_ankipkg():
70 | # add a test file to the media folder
71 | open(os.path.join(deck.media.dir(), "今日.mp3"), "w").write("test")
72 | n = deck.newNote()
73 | n['Front'] = '[sound:今日.mp3]'
74 | deck.addNote(n)
75 | e = AnkiPackageExporter(deck)
76 | fd, newname = tempfile.mkstemp(prefix="ankitest", suffix=".apkg")
77 | newname = str(newname)
78 | os.close(fd)
79 | os.unlink(newname)
80 | e.exportInto(newname)
81 |
82 | @nose.with_setup(setup1)
83 | def test_export_anki_due():
84 | deck = getEmptyCol()
85 | f = deck.newNote()
86 | f['Front'] = "foo"
87 | deck.addNote(f)
88 | deck.crt -= 86400*10
89 | deck.sched.reset()
90 | c = deck.sched.getCard()
91 | deck.sched.answerCard(c, 2)
92 | deck.sched.answerCard(c, 2)
93 | # should have ivl of 1, due on day 11
94 | assert c.ivl == 1
95 | assert c.due == 11
96 | assert deck.sched.today == 10
97 | assert c.due - deck.sched.today == 1
98 | # export
99 | e = AnkiExporter(deck)
100 | e.includeSched = True
101 | fd, newname = tempfile.mkstemp(prefix="ankitest", suffix=".anki2")
102 | newname = str(newname)
103 | os.close(fd)
104 | os.unlink(newname)
105 | e.exportInto(newname)
106 | # importing into a new deck, the due date should be equivalent
107 | deck2 = getEmptyCol()
108 | imp = Anki2Importer(deck2, newname)
109 | imp.run()
110 | c = deck2.getCard(c.id)
111 | deck2.sched.reset()
112 | assert c.due - deck2.sched.today == 1
113 |
114 | # @nose.with_setup(setup1)
115 | # def test_export_textcard():
116 | # e = TextCardExporter(deck)
117 | # f = unicode(tempfile.mkstemp(prefix="ankitest")[1])
118 | # os.unlink(f)
119 | # e.exportInto(f)
120 | # e.includeTags = True
121 | # e.exportInto(f)
122 |
123 | @nose.with_setup(setup1)
124 | def test_export_textnote():
125 | e = TextNoteExporter(deck)
126 | fd, f = tempfile.mkstemp(prefix="ankitest")
127 | f = str(f)
128 | os.close(fd)
129 | os.unlink(f)
130 | e.exportInto(f)
131 | e.includeTags = True
132 | e.exportInto(f)
133 |
134 | def test_exporters():
135 | assert "*.apkg" in str(exporters())
136 |
--------------------------------------------------------------------------------