mirror of
https://github.com/ankitects/anki.git
synced 2025-09-19 06:22:22 -04:00

Because Qt translations need to be initialized before any widgets are created, and because the Qt language needs to match the gettext language in order for translated shortcuts to work, per-profile language settings aren't possible. So instead of storing the language in the profile, we use pm.meta['defaultLang'] for all profiles and initialize language handling in __init__.py The language selection in the preferences has been removed, because in a school setting a student fiddling with the language could potentially cause other students to be unable to navigate the UI. Instead, Anki will accept -l/--lang passed on the command line to override the original language chosen at first program startup.
947 lines
31 KiB
Python
Executable file
947 lines
31 KiB
Python
Executable file
# Copyright: Damien Elmes <anki@ichi2.net>
|
|
# -*- coding: utf-8 -*-
|
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|
|
|
import os, sys, re, stat, traceback, signal
|
|
import shutil, time, zipfile
|
|
from operator import itemgetter
|
|
|
|
from aqt.qt import *
|
|
QtConfig = pyqtconfig.Configuration()
|
|
|
|
from anki import Collection
|
|
from anki.sound import playFromText, clearAudioQueue, stripSounds
|
|
from anki.utils import stripHTML, checksum, isWin, isMac, intTime
|
|
from anki.hooks import runHook, addHook, remHook
|
|
import anki.consts
|
|
|
|
import aqt, aqt.progress, aqt.webview, aqt.toolbar
|
|
from aqt.utils import saveGeom, restoreGeom, showInfo, showWarning, \
|
|
saveState, restoreState, getOnlyText, askUser, GetTextDialog, \
|
|
askUserDialog, applyStyles, getText, showText, showCritical, getFile, \
|
|
tooltip, openHelp, openLink
|
|
|
|
class AnkiQt(QMainWindow):
|
|
def __init__(self, app, profileManager):
|
|
QMainWindow.__init__(self)
|
|
aqt.mw = self
|
|
self.app = app
|
|
self.pm = profileManager
|
|
# running 2.0 for the first time?
|
|
if self.pm.meta['firstRun']:
|
|
# load the new deck user profile
|
|
self.pm.load(self.pm.profiles()[0])
|
|
# upgrade if necessary
|
|
from aqt.upgrade import Upgrader
|
|
u = Upgrader(self)
|
|
u.maybeUpgrade()
|
|
self.pm.meta['firstRun'] = False
|
|
self.pm.save()
|
|
# init rest of app
|
|
try:
|
|
self.setupUI()
|
|
self.setupAddons()
|
|
except:
|
|
showInfo("Error during startup:\n%s" % traceback.format_exc())
|
|
sys.exit(1)
|
|
# Load profile in a timer so we can let the window finish init and not
|
|
# close on profile load error.
|
|
self.progress.timer(10, self.setupProfile, False)
|
|
|
|
def setupUI(self):
|
|
self.col = None
|
|
self.state = "overview"
|
|
self.forceFullSync = False
|
|
self.setupKeys()
|
|
self.setupThreads()
|
|
self.setupMainWindow()
|
|
self.setupStyle()
|
|
self.setupProxy()
|
|
self.setupMenus()
|
|
self.setupProgress()
|
|
self.setupErrorHandler()
|
|
self.setupSystemSpecific()
|
|
self.setupSignals()
|
|
self.setupAutoUpdate()
|
|
self.setupCardStats()
|
|
self.setupSchema()
|
|
self.setupEmptyCardDel()
|
|
self.updateTitleBar()
|
|
# screens
|
|
self.setupDeckBrowser()
|
|
self.setupOverview()
|
|
self.setupReviewer()
|
|
|
|
# Profiles
|
|
##########################################################################
|
|
|
|
def setupProfile(self):
|
|
# profile not provided on command line?
|
|
if not self.pm.name:
|
|
# if there's a single profile, load it automatically
|
|
profs = self.pm.profiles()
|
|
if len(profs) == 1:
|
|
try:
|
|
self.pm.load(profs[0])
|
|
except:
|
|
# password protected
|
|
pass
|
|
if not self.pm.name:
|
|
self.showProfileManager()
|
|
else:
|
|
self.loadProfile()
|
|
|
|
def showProfileManager(self):
|
|
d = self.profileDiag = QDialog()
|
|
f = self.profileForm = aqt.forms.profiles.Ui_Dialog()
|
|
f.setupUi(d)
|
|
d.connect(f.login, SIGNAL("clicked()"), self.onOpenProfile)
|
|
d.connect(f.quit, SIGNAL("clicked()"), lambda: sys.exit(0))
|
|
d.connect(f.add, SIGNAL("clicked()"), self.onAddProfile)
|
|
d.connect(f.rename, SIGNAL("clicked()"), self.onRenameProfile)
|
|
d.connect(f.delete_2, SIGNAL("clicked()"), self.onRemProfile)
|
|
d.connect(d, SIGNAL("rejected()"), lambda: d.close())
|
|
d.connect(f.profiles, SIGNAL("currentRowChanged(int)"),
|
|
self.onProfileRowChange)
|
|
self.refreshProfilesList()
|
|
# raise first, for osx testing
|
|
d.show()
|
|
d.activateWindow()
|
|
d.raise_()
|
|
d.exec_()
|
|
|
|
def refreshProfilesList(self):
|
|
f = self.profileForm
|
|
f.profiles.clear()
|
|
f.profiles.addItems(self.pm.profiles())
|
|
f.profiles.setCurrentRow(0)
|
|
|
|
def onProfileRowChange(self, n):
|
|
if n < 0:
|
|
# called on .clear()
|
|
return
|
|
name = self.pm.profiles()[n]
|
|
f = self.profileForm
|
|
passwd = not self.pm.load(name)
|
|
f.passEdit.setShown(passwd)
|
|
f.passLabel.setShown(passwd)
|
|
|
|
def openProfile(self):
|
|
name = self.pm.profiles()[self.profileForm.profiles.currentRow()]
|
|
passwd = self.profileForm.passEdit.text()
|
|
return self.pm.load(name, passwd)
|
|
|
|
def onOpenProfile(self):
|
|
if not self.openProfile():
|
|
showWarning(_("Invalid password."))
|
|
return
|
|
self.profileDiag.close()
|
|
self.loadProfile()
|
|
return True
|
|
|
|
def onAddProfile(self):
|
|
name = getOnlyText("Name:")
|
|
if name:
|
|
if name in self.pm.profiles():
|
|
return showWarning("Name exists.")
|
|
if not re.match("^[A-Za-z0-9 ]+$", name):
|
|
return showWarning(
|
|
"Only numbers, letters and spaces can be used.")
|
|
self.pm.create(name)
|
|
self.refreshProfilesList()
|
|
|
|
def onRenameProfile(self):
|
|
name = getOnlyText("New name:", default=self.pm.name)
|
|
if not self.openProfile():
|
|
return showWarning(_("Invalid password."))
|
|
if not name:
|
|
return
|
|
if name == self.pm.name:
|
|
return
|
|
if name in self.pm.profiles():
|
|
return showWarning("Name exists.")
|
|
if not re.match("^[A-Za-z0-9 ]+$", name):
|
|
return showWarning(
|
|
"Only numbers, letters and spaces can be used.")
|
|
self.pm.rename(name)
|
|
self.refreshProfilesList()
|
|
|
|
def onRemProfile(self):
|
|
profs = self.pm.profiles()
|
|
if len(profs) < 2:
|
|
return showWarning("There must be at least one profile.")
|
|
# password correct?
|
|
if not self.openProfile():
|
|
return
|
|
# sure?
|
|
if not askUser("""\
|
|
All cards, notes, and media for this profile will be deleted. \
|
|
Are you sure?"""):
|
|
return
|
|
self.pm.remove(self.pm.name)
|
|
self.refreshProfilesList()
|
|
|
|
def loadProfile(self):
|
|
# show main window
|
|
if self.pm.profile['mainWindowState']:
|
|
restoreGeom(self, "mainWindow")
|
|
restoreState(self, "mainWindow")
|
|
else:
|
|
self.resize(500, 400)
|
|
# toolbar needs to be retranslated
|
|
self.toolbar.draw()
|
|
# show and raise window for osx
|
|
self.show()
|
|
self.activateWindow()
|
|
self.raise_()
|
|
# maybe sync (will load DB)
|
|
self.onSync(auto=True)
|
|
runHook("profileLoaded")
|
|
|
|
def unloadProfile(self, browser=True):
|
|
if not self.pm.profile:
|
|
# already unloaded
|
|
return
|
|
runHook("unloadProfile")
|
|
self.unloadCollection()
|
|
self.onSync(auto=True, reload=False)
|
|
self.pm.profile['mainWindowGeom'] = self.saveGeometry()
|
|
self.pm.profile['mainWindowState'] = self.saveState()
|
|
self.pm.save()
|
|
self.pm.profile = None
|
|
self.hide()
|
|
if browser:
|
|
self.showProfileManager()
|
|
|
|
# Collection load/unload
|
|
##########################################################################
|
|
|
|
def loadCollection(self):
|
|
self.col = Collection(self.pm.collectionPath())
|
|
self.progress.setupDB(self.col.db)
|
|
# load overview if a single deck, otherwise deck list
|
|
if self.col.decks.count() > 1:
|
|
self.moveToState("deckBrowser")
|
|
else:
|
|
self.moveToState("overview")
|
|
|
|
def unloadCollection(self):
|
|
if self.col:
|
|
self.closeAllCollectionWindows()
|
|
self.maybeOptimize()
|
|
self.col.close()
|
|
self.col = None
|
|
self.backup()
|
|
|
|
# Backup and auto-optimize
|
|
##########################################################################
|
|
|
|
def backup(self):
|
|
nbacks = self.pm.profile['numBackups']
|
|
if not nbacks:
|
|
return
|
|
dir = self.pm.backupFolder()
|
|
path = self.pm.collectionPath()
|
|
# find existing backups
|
|
backups = []
|
|
for file in os.listdir(dir):
|
|
m = re.search("backup-(\d+).anki2", file)
|
|
if not m:
|
|
# unknown file
|
|
continue
|
|
backups.append((int(m.group(1)), file))
|
|
backups.sort()
|
|
# get next num
|
|
if not backups:
|
|
n = 1
|
|
else:
|
|
n = backups[-1][0] + 1
|
|
# do backup
|
|
newpath = os.path.join(dir, "backup-%d.anki2" % n)
|
|
shutil.copy2(path, newpath)
|
|
# remove if over
|
|
if len(backups) + 1 > nbacks:
|
|
delete = len(backups) + 1 - nbacks
|
|
delete = backups[:delete]
|
|
for file in delete:
|
|
os.unlink(os.path.join(dir, file[1]))
|
|
|
|
def maybeOptimize(self):
|
|
# has two weeks passed?
|
|
if (intTime() - self.pm.profile['lastOptimize']) < 86400*14:
|
|
return
|
|
self.progress.start(label=_("Optimizing..."), immediate=True)
|
|
self.col.optimize()
|
|
self.pm.profile['lastOptimize'] = intTime()
|
|
self.pm.save()
|
|
self.progress.finish()
|
|
|
|
# State machine
|
|
##########################################################################
|
|
|
|
def moveToState(self, state, *args):
|
|
#print "-> move from", self.state, "to", state
|
|
oldState = self.state or "dummy"
|
|
cleanup = getattr(self, "_"+oldState+"Cleanup", None)
|
|
if cleanup:
|
|
cleanup(state)
|
|
self.state = state
|
|
getattr(self, "_"+state+"State")(oldState, *args)
|
|
|
|
def _deckBrowserState(self, oldState):
|
|
self.deckBrowser.show()
|
|
|
|
def _colLoadingState(self, oldState):
|
|
"Run once, when col is loaded."
|
|
self.enableColMenuItems()
|
|
# ensure cwd is set if media dir exists
|
|
self.col.media.dir()
|
|
runHook("colLoading", self.col)
|
|
self.moveToState("overview")
|
|
|
|
def _overviewState(self, oldState):
|
|
self.col.reset()
|
|
self.overview.show()
|
|
|
|
def _reviewState(self, oldState):
|
|
self.reviewer.show()
|
|
|
|
def _reviewCleanup(self, newState):
|
|
if newState != "resetRequired":
|
|
self.reviewer.cleanup()
|
|
|
|
def noteChanged(self, nid):
|
|
"Called when a card or note is edited (but not deleted)."
|
|
runHook("noteChanged", nid)
|
|
|
|
# Resetting state
|
|
##########################################################################
|
|
|
|
def reset(self, guiOnly=False):
|
|
"Called for non-trivial edits. Rebuilds queue and updates UI."
|
|
if self.col:
|
|
if not guiOnly:
|
|
self.col.reset()
|
|
runHook("reset")
|
|
self.maybeEnableUndo()
|
|
self.moveToState(self.state)
|
|
|
|
def requireReset(self, modal=False):
|
|
"Signal queue needs to be rebuilt when edits are finished or by user."
|
|
self.autosave()
|
|
self.resetModal = modal
|
|
if self.state in ("overview", "review", "deckBrowser"):
|
|
self.moveToState("resetRequired")
|
|
|
|
def maybeReset(self):
|
|
self.autosave()
|
|
if self.state == "resetRequired":
|
|
self.state = self.returnState
|
|
self.reset()
|
|
|
|
def _resetRequiredState(self, oldState):
|
|
if oldState != "resetRequired":
|
|
self.returnState = oldState
|
|
if self.resetModal:
|
|
# we don't have to change the webview, as we have a covering window
|
|
return
|
|
self.web.setLinkHandler(lambda url: self.maybeReset())
|
|
i = _("Close the browser to resume.")
|
|
b = self.button("refresh", _("Resume Now"))
|
|
self.web.stdHtml("""
|
|
<center><div style="height: 100%%">
|
|
<div style="position:relative; vertical-align: middle;">
|
|
%s<br>
|
|
%s</div></div></center>
|
|
""" % (i, b), css=self.sharedCSS)
|
|
self.bottomWeb.hide()
|
|
|
|
# HTML helpers
|
|
##########################################################################
|
|
|
|
sharedCSS = """
|
|
body {
|
|
background: #f3f3f3;
|
|
margin: 2em;
|
|
}
|
|
h1 { margin-bottom: 0.2em; }
|
|
"""
|
|
|
|
def button(self, link, name, key=None, class_="", id=""):
|
|
class_ = "but "+ class_
|
|
if key:
|
|
key = _("Shortcut key: %s") % key
|
|
else:
|
|
key = ""
|
|
return '''
|
|
<button id="%s" class="%s" onclick="py.link('%s');return false;"
|
|
title="%s">%s</button>''' % (
|
|
id, class_, link, key, name)
|
|
|
|
# Main window setup
|
|
##########################################################################
|
|
|
|
def setupMainWindow(self):
|
|
# main window
|
|
self.form = aqt.forms.main.Ui_MainWindow()
|
|
self.form.setupUi(self)
|
|
# toolbar
|
|
tweb = aqt.webview.AnkiWebView()
|
|
tweb.setObjectName("toolbarWeb")
|
|
tweb.setFocusPolicy(Qt.WheelFocus)
|
|
tweb.setFixedHeight(32)
|
|
self.toolbar = aqt.toolbar.Toolbar(self, tweb)
|
|
self.toolbar.draw()
|
|
# main area
|
|
self.web = aqt.webview.AnkiWebView()
|
|
self.web.setObjectName("mainText")
|
|
self.web.setFocusPolicy(Qt.WheelFocus)
|
|
self.web.setMinimumWidth(400)
|
|
# bottom area
|
|
sweb = self.bottomWeb = aqt.webview.AnkiWebView()
|
|
#sweb.hide()
|
|
sweb.setFixedHeight(100)
|
|
sweb.setObjectName("bottomWeb")
|
|
sweb.setFocusPolicy(Qt.WheelFocus)
|
|
# add in a layout
|
|
self.mainLayout = QVBoxLayout()
|
|
self.mainLayout.setContentsMargins(0,0,0,0)
|
|
self.mainLayout.setSpacing(0)
|
|
self.mainLayout.addWidget(tweb)
|
|
self.mainLayout.addWidget(self.web)
|
|
self.mainLayout.addWidget(sweb)
|
|
self.form.centralwidget.setLayout(self.mainLayout)
|
|
|
|
def closeAllCollectionWindows(self):
|
|
aqt.dialogs.closeAll()
|
|
|
|
# Components
|
|
##########################################################################
|
|
|
|
def setupSignals(self):
|
|
signal.signal(signal.SIGINT, self.onSigInt)
|
|
|
|
def onSigInt(self, signum, frame):
|
|
self.onClose()
|
|
|
|
def setupProgress(self):
|
|
self.progress = aqt.progress.ProgressManager(self)
|
|
|
|
def setupErrorHandler(self):
|
|
import aqt.errors
|
|
self.errorHandler = aqt.errors.ErrorHandler(self)
|
|
|
|
def setupAddons(self):
|
|
import aqt.addons
|
|
self.addonManager = aqt.addons.AddonManager(self)
|
|
|
|
def setupThreads(self):
|
|
self._mainThread = QThread.currentThread()
|
|
|
|
def inMainThread(self):
|
|
return self._mainThread == QThread.currentThread()
|
|
|
|
def setupDeckBrowser(self):
|
|
from aqt.deckbrowser import DeckBrowser
|
|
self.deckBrowser = DeckBrowser(self)
|
|
|
|
def setupOverview(self):
|
|
from aqt.overview import Overview
|
|
self.overview = Overview(self)
|
|
|
|
def setupReviewer(self):
|
|
from aqt.reviewer import Reviewer
|
|
self.reviewer = Reviewer(self)
|
|
|
|
# Collection loading
|
|
##########################################################################
|
|
|
|
def loadDeck(self, deckPath, showErrors=True):
|
|
"Load a deck and update the user interface."
|
|
self.upgrading = False
|
|
try:
|
|
self.col = Deck(deckPath, queue=False)
|
|
except Exception, e:
|
|
if not showErrors:
|
|
return 0
|
|
# FIXME: this needs updating
|
|
if hasattr(e, 'data') and e.data.get('type') == 'inuse':
|
|
showWarning(_("Deck is already open."))
|
|
else:
|
|
showCritical(_("""\
|
|
File is corrupt or not an Anki database. Click help for more info.\n
|
|
Debug info:\n%s""") % traceback.format_exc(), help="DeckErrors")
|
|
self.moveToState("deckBrowser")
|
|
return 0
|
|
finally:
|
|
# we may have a progress window open if we were upgrading
|
|
self.progress.finish()
|
|
self.pm.profile.addRecentDeck(self.col.path)
|
|
self.setupMedia(self.col)
|
|
if not self.upgrading:
|
|
self.progress.setupDB(self.col.db)
|
|
self.moveToState("deckLoading")
|
|
return True
|
|
|
|
# Syncing
|
|
##########################################################################
|
|
|
|
def onSync(self, auto=False, reload=True):
|
|
if not auto or (self.pm.profile['syncKey'] and
|
|
self.pm.profile['autoSync']):
|
|
from aqt.sync import SyncManager
|
|
self.unloadCollection()
|
|
self.syncer = SyncManager(self, self.pm)
|
|
self.syncer.sync()
|
|
if reload:
|
|
if not self.col:
|
|
self.loadCollection()
|
|
|
|
def onFullSync(self):
|
|
if not askUser(_("""\
|
|
If you proceed, you will need to choose between a full download or full \
|
|
upload, overwriting any changes either here or on AnkiWeb. Proceed?""")):
|
|
return
|
|
self.forceFullSync = True
|
|
self.col.modSchema()
|
|
self.col.setMod()
|
|
self.forceFullSync = False
|
|
self.onSync()
|
|
|
|
# Tools
|
|
##########################################################################
|
|
|
|
def raiseMain(self):
|
|
if not self.app.activeWindow():
|
|
# make sure window is shown
|
|
self.setWindowState(self.windowState() & ~Qt.WindowMinimized)
|
|
return True
|
|
|
|
def setStatus(self, text, timeout=3000):
|
|
self.form.statusbar.showMessage(text, timeout)
|
|
|
|
def setupStyle(self):
|
|
applyStyles(self)
|
|
|
|
# Key handling
|
|
##########################################################################
|
|
|
|
def setupKeys(self):
|
|
self.keyHandler = None
|
|
|
|
def keyPressEvent(self, evt):
|
|
# do we have a delegate?
|
|
if self.keyHandler:
|
|
# did it eat the key?
|
|
if self.keyHandler(evt):
|
|
return
|
|
# run standard handler
|
|
QMainWindow.keyPressEvent(self, evt)
|
|
# check global keys
|
|
key = unicode(evt.text())
|
|
if key == "d":
|
|
self.moveToState("deckBrowser")
|
|
elif key == "s":
|
|
if self.state == "overview":
|
|
self.col.startTimebox()
|
|
self.moveToState("review")
|
|
else:
|
|
self.moveToState("overview")
|
|
elif key == "a":
|
|
self.onAddCard()
|
|
elif key == "b":
|
|
self.onBrowse()
|
|
elif key == "S":
|
|
self.onStats()
|
|
elif key == "y":
|
|
self.onSync()
|
|
|
|
# App exit
|
|
##########################################################################
|
|
|
|
def closeEvent(self, event):
|
|
"User hit the X button, etc."
|
|
event.accept()
|
|
self.onClose()
|
|
|
|
def onClose(self):
|
|
"Called from a shortcut key. Close current active window."
|
|
aw = self.app.activeWindow()
|
|
if not aw or aw == self:
|
|
self.unloadProfile(browser=False)
|
|
self.app.closeAllWindows()
|
|
else:
|
|
aw.close()
|
|
|
|
# Dockable widgets
|
|
##########################################################################
|
|
|
|
def addDockable(self, title, w):
|
|
dock = QDockWidget(title, self)
|
|
dock.setObjectName(title)
|
|
dock.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea)
|
|
dock.setFeatures(QDockWidget.DockWidgetClosable)
|
|
dock.setWidget(w)
|
|
if self.width() < 600:
|
|
self.resize(QSize(600, self.height()))
|
|
self.addDockWidget(Qt.RightDockWidgetArea, dock)
|
|
return dock
|
|
|
|
def remDockable(self, dock):
|
|
self.removeDockWidget(dock)
|
|
|
|
# Marking, suspending and deleting
|
|
##########################################################################
|
|
# These are only available while reviewing
|
|
|
|
def updateMarkAction(self, ):
|
|
self.form.actionMarkCard.blockSignals(True)
|
|
self.form.actionMarkCard.setChecked(
|
|
self.reviewer.card.note().hasTag("marked"))
|
|
self.form.actionMarkCard.blockSignals(False)
|
|
|
|
def onMark(self):
|
|
f = self.reviewer.card.note()
|
|
if f.hasTag("marked"):
|
|
f.delTag("marked")
|
|
tooltip("Mark Removed.")
|
|
else:
|
|
f.addTag("marked")
|
|
tooltip("Mark Added.")
|
|
f.flush()
|
|
|
|
def onSuspend(self):
|
|
self.checkpoint(_("Suspend"))
|
|
self.col.sched.suspendCards(
|
|
[c.id for c in self.reviewer.card.note().cards()])
|
|
tooltip("Note suspended.")
|
|
self.reset()
|
|
|
|
def onDelete(self):
|
|
self.checkpoint(_("Delete"))
|
|
self.col.remNotes([self.reviewer.card.note().id])
|
|
self.reset()
|
|
tooltip("Note and its cards deleted.")
|
|
|
|
def onBuryNote(self):
|
|
self.checkpoint(_("Bury"))
|
|
self.col.sched.buryNote(self.reviewer.card.nid)
|
|
self.reset()
|
|
tooltip("Note buried.")
|
|
|
|
# Undo & autosave
|
|
##########################################################################
|
|
|
|
def onUndo(self):
|
|
cid = self.col.undo()
|
|
if cid and self.state == "review":
|
|
card = self.col.getCard(cid)
|
|
self.reviewer.cardQueue.append(card)
|
|
self.reset()
|
|
self.maybeEnableUndo()
|
|
|
|
def maybeEnableUndo(self):
|
|
if self.col and self.col.undoName():
|
|
self.form.actionUndo.setText(_("Undo %s") %
|
|
self.col.undoName())
|
|
self.form.actionUndo.setEnabled(True)
|
|
runHook("undoState", True)
|
|
else:
|
|
self.form.actionUndo.setText(_("Undo"))
|
|
self.form.actionUndo.setEnabled(False)
|
|
runHook("undoState", False)
|
|
|
|
def checkpoint(self, name):
|
|
self.col.save(name)
|
|
self.maybeEnableUndo()
|
|
|
|
def autosave(self):
|
|
self.col.autosave()
|
|
self.maybeEnableUndo()
|
|
|
|
# Other menu operations
|
|
##########################################################################
|
|
|
|
def onAddCard(self):
|
|
aqt.dialogs.open("AddCards", self)
|
|
|
|
def onBrowse(self):
|
|
aqt.dialogs.open("Browser", self)
|
|
|
|
def onEditCurrent(self):
|
|
from aqt.editcurrent import EditCurrent
|
|
EditCurrent(self)
|
|
|
|
def setupCardStats(self):
|
|
import aqt.stats
|
|
self.cardStats = aqt.stats.CardStats(self)
|
|
|
|
def onDeckConf(self, deck=None):
|
|
if not deck:
|
|
deck = self.col.decks.current()
|
|
if deck['dyn']:
|
|
import aqt.dyndeckconf
|
|
aqt.dyndeckconf.DeckConf(self)
|
|
else:
|
|
import aqt.deckconf
|
|
aqt.deckconf.DeckConf(self, deck)
|
|
|
|
def onOverview(self):
|
|
self.col.reset()
|
|
self.moveToState("overview")
|
|
|
|
def onCardStats(self):
|
|
self.cardStats.toggle()
|
|
|
|
def onStats(self):
|
|
aqt.stats.DeckStats(self)
|
|
|
|
def onPrefs(self):
|
|
import aqt.preferences
|
|
aqt.preferences.Preferences(self)
|
|
|
|
def onAbout(self):
|
|
import aqt.about
|
|
aqt.about.show(self)
|
|
|
|
def onDonate(self):
|
|
openLink(aqt.appDonate)
|
|
|
|
def onDocumentation(self):
|
|
openHelp("")
|
|
|
|
# Importing & exporting
|
|
##########################################################################
|
|
|
|
def onImport(self):
|
|
import aqt.importing
|
|
aqt.importing.onImport(self)
|
|
|
|
def onExport(self):
|
|
import aqt.exporting
|
|
aqt.exporting.ExportDialog(self)
|
|
|
|
# Cramming
|
|
##########################################################################
|
|
|
|
def onCram(self, search=""):
|
|
import aqt.dyndeckconf
|
|
n = 1
|
|
decks = self.col.decks.allNames()
|
|
while _("Cram %d") % n in decks:
|
|
n += 1
|
|
name = _("Cram %d") % n
|
|
name = getOnlyText(_("Please name your cram deck:"), default=name)
|
|
if not name:
|
|
return
|
|
if name in decks:
|
|
showWarning(_("The provided name was already in use."))
|
|
return
|
|
did = self.col.decks.newDyn(name)
|
|
diag = aqt.dyndeckconf.DeckConf(self, first=True, search=search)
|
|
if not diag.ok:
|
|
# user cancelled first config
|
|
self.col.decks.rem(did)
|
|
else:
|
|
self.moveToState("overview")
|
|
|
|
# Menu, title bar & status
|
|
##########################################################################
|
|
|
|
def setupMenus(self):
|
|
m = self.form
|
|
s = SIGNAL("triggered()")
|
|
#self.connect(m.actionDownloadSharedPlugin, s, self.onGetSharedPlugin)
|
|
self.connect(m.actionSwitchProfile, s, self.unloadProfile)
|
|
self.connect(m.actionImport, s, self.onImport)
|
|
self.connect(m.actionExport, s, self.onExport)
|
|
self.connect(m.actionExit, s, self, SLOT("close()"))
|
|
self.connect(m.actionPreferences, s, self.onPrefs)
|
|
self.connect(m.actionCstats, s, self.onCardStats)
|
|
self.connect(m.actionAbout, s, self.onAbout)
|
|
self.connect(m.actionUndo, s, self.onUndo)
|
|
self.connect(m.actionFullDatabaseCheck, s, self.onCheckDB)
|
|
self.connect(m.actionCheckMediaDatabase, s, self.onCheckMediaDB)
|
|
self.connect(m.actionDocumentation, s, self.onDocumentation)
|
|
self.connect(m.actionDonate, s, self.onDonate)
|
|
self.connect(m.actionFullSync, s, self.onFullSync)
|
|
self.connect(m.actionStudyDeck, s, self.onStudyDeck)
|
|
|
|
def updateTitleBar(self):
|
|
self.setWindowTitle("Anki")
|
|
|
|
# Auto update
|
|
##########################################################################
|
|
|
|
def setupAutoUpdate(self):
|
|
import aqt.update
|
|
self.autoUpdate = aqt.update.LatestVersionFinder(self)
|
|
self.connect(self.autoUpdate, SIGNAL("newVerAvail"), self.newVerAvail)
|
|
self.connect(self.autoUpdate, SIGNAL("newMsg"), self.newMsg)
|
|
self.connect(self.autoUpdate, SIGNAL("clockIsOff"), self.clockIsOff)
|
|
self.autoUpdate.start()
|
|
|
|
def newVerAvail(self, data):
|
|
if self.pm.profile['suppressUpdate'] < data['latestVersion']:
|
|
aqt.update.askAndUpdate(self, data)
|
|
|
|
def newMsg(self, data):
|
|
aqt.update.showMessages(self, data)
|
|
|
|
def clockIsOff(self, diff):
|
|
if diff < 0:
|
|
ret = _("late")
|
|
else:
|
|
ret = _("early")
|
|
showWarning("""\
|
|
In order to ensure your collection works correctly when moved between \
|
|
devices, Anki requires the system clock to be set correctly. Your system \
|
|
clock appears to be wrong by more than 5 minutes.
|
|
|
|
This can be because the \
|
|
clock is slow or fast, because the date is set incorrectly, or because \
|
|
the timezone or daylight savings information is incorrect. Please correct \
|
|
the problem and restart Anki.""")
|
|
self.app.closeAllWindows()
|
|
|
|
# Schema modifications
|
|
##########################################################################
|
|
|
|
def setupSchema(self):
|
|
addHook("modSchema", self.onSchemaMod)
|
|
|
|
def onSchemaMod(self, arg):
|
|
# if triggered in sync, make sure we don't use the gui
|
|
if not self.inMainThread():
|
|
return True
|
|
# if from the full sync menu, ignore
|
|
if self.forceFullSync:
|
|
return True
|
|
return askUser(_("""\
|
|
The requested change will require a full upload of the database when \
|
|
you next synchronize your collection. If you have reviews or other changes \
|
|
waiting on another device that haven't been synchronized here yet, they \
|
|
will be lost. Continue?"""))
|
|
|
|
# Empty card deletion
|
|
##########################################################################
|
|
|
|
def setupEmptyCardDel(self):
|
|
addHook("remEmptyCards", self.onEmptyCards)
|
|
|
|
def onEmptyCards(self, flag, cnt):
|
|
if flag and askUser(_("""\
|
|
Your edits have left %d cards empty. Do you want to delete them?""" % cnt)):
|
|
return flag
|
|
return False
|
|
|
|
# Advanced features
|
|
##########################################################################
|
|
|
|
def onCheckDB(self):
|
|
"True if no problems"
|
|
self.progress.start(immediate=True)
|
|
ret, ok = self.col.fixIntegrity()
|
|
self.progress.finish()
|
|
if not ok:
|
|
showText(ret)
|
|
else:
|
|
tooltip(ret)
|
|
self.reset()
|
|
return ret
|
|
|
|
def onCheckMediaDB(self):
|
|
self.progress.start(immediate=True)
|
|
(nohave, unused) = self.col.media.check()
|
|
self.progress.finish()
|
|
# generate report
|
|
report = ""
|
|
if unused:
|
|
report += _(
|
|
"In media folder but not used by any cards:")
|
|
report += "\n" + "\n".join(unused)
|
|
if nohave:
|
|
if report:
|
|
report += "\n\n\n"
|
|
report += _(
|
|
"Used on cards but missing from media folder:")
|
|
report += "\n" + "\n".join(nohave)
|
|
if not report:
|
|
report = _("No unused or missing files found.")
|
|
# show report and offer to delete
|
|
diag = QDialog(self)
|
|
diag.setWindowTitle("Anki")
|
|
layout = QVBoxLayout(diag)
|
|
diag.setLayout(layout)
|
|
text = QTextEdit()
|
|
text.setReadOnly(True)
|
|
text.setPlainText(report)
|
|
layout.addWidget(text)
|
|
box = QDialogButtonBox(QDialogButtonBox.Close)
|
|
layout.addWidget(box)
|
|
b = QPushButton(_("Delete Unused"))
|
|
b.setAutoDefault(False)
|
|
box.addButton(b, QDialogButtonBox.ActionRole)
|
|
b.connect(
|
|
b, SIGNAL("clicked()"), lambda u=unused, d=diag: self.deleteUnused(u, d))
|
|
diag.connect(box, SIGNAL("rejected()"), diag, SLOT("reject()"))
|
|
diag.setMinimumHeight(400)
|
|
diag.setMinimumWidth(500)
|
|
diag.exec_()
|
|
|
|
def deleteUnused(self, unused, diag):
|
|
if not askUser(
|
|
_("Delete unused media? This operation can not be undone.")):
|
|
return
|
|
mdir = self.col.media.dir()
|
|
for f in unused:
|
|
path = os.path.join(mdir, f)
|
|
os.unlink(path)
|
|
tooltip("Deleted.")
|
|
diag.close()
|
|
|
|
def onStudyDeck(self):
|
|
from aqt.studydeck import StudyDeck
|
|
StudyDeck(self)
|
|
|
|
# System specific code
|
|
##########################################################################
|
|
|
|
def setupSystemSpecific(self):
|
|
addHook("macLoadEvent", self.onMacLoad)
|
|
if isMac:
|
|
qt_mac_set_menubar_icons(False)
|
|
#self.setUnifiedTitleAndToolBarOnMac(self.pm.profile['showToolbar'])
|
|
# mac users expect a minimize option
|
|
self.minimizeShortcut = QShortcut("Ctrl+m", self)
|
|
self.connect(self.minimizeShortcut, SIGNAL("activated()"),
|
|
self.onMacMinimize)
|
|
self.hideAccelerators()
|
|
self.hideStatusTips()
|
|
elif isWin:
|
|
# make sure ctypes is bundled
|
|
from ctypes import windll, wintypes
|
|
|
|
def hideAccelerators(self):
|
|
for action in self.findChildren(QAction):
|
|
txt = unicode(action.text())
|
|
m = re.match("^(.+)\(&.+\)(.+)?", txt)
|
|
if m:
|
|
action.setText(m.group(1) + (m.group(2) or ""))
|
|
|
|
def hideStatusTips(self):
|
|
for action in self.findChildren(QAction):
|
|
action.setStatusTip("")
|
|
|
|
def onMacMinimize(self):
|
|
self.setWindowState(self.windowState() | Qt.WindowMinimized)
|
|
|
|
def onMacLoad(self, fname):
|
|
self.loadDeck(fname)
|
|
|
|
# Proxy support
|
|
##########################################################################
|
|
|
|
def setupProxy(self):
|
|
return
|
|
# need to bundle socksipy and install a default socket handler
|