class AnkiQt(QMainWindow): """ col -- The collection state -- It's states which kind of content main shows. Either: -- startup -- resetRequired: during review, when edit or browser is opened, the window show "waiting for editing to finish. Resume now -- sync -- overview -- review -- profileManager -- deckBrowser stateShortcuts -- shortcuts related to the kind of window currently in main. bottomWeb -- a ankiwebview, with the bottom of the main window. Shown unless for reset required. app -- an object of class AnkiApp. """ def __init__(self, app, profileManager, opts, args): QMainWindow.__init__(self) self.state = "startup" self.opts = opts aqt.mw = self self.app = app self.pm = profileManager # init rest of app self.safeMode = self.app.queryKeyboardModifiers() & Qt.ShiftModifier try: self.setupUI() self.setupAddons() except: showInfo(_("Error during startup:\n%s") % traceback.format_exc()) sys.exit(1) # must call this after ui set up if self.safeMode: tooltip( _("Shift key was held down. Skipping automatic " "syncing and add-on loading.")) # were we given a file to import? if args and args[0]: self.onAppMsg(args[0]) # Load profile in a timer so we can let the window finish init and not # close on profile load error. if isWin: fn = self.setupProfileAfterWebviewsLoaded else: fn = self.setupProfile self.progress.timer(10, fn, False, requiresCollection=False) def setupUI(self): self.col = None self.setupCrashLog() self.disableGC() self.setupAppMsg() self.setupKeys() self.setupThreads() self.setupMediaServer() self.setupSound() self.setupSpellCheck() self.setupMainWindow() self.setupSystemSpecific() self.setupStyle() self.setupMenus() self.setupProgress() self.setupErrorHandler() self.setupSignals() self.setupAutoUpdate() self.setupHooks() self.setupRefreshTimer() self.updateTitleBar() # screens self.setupDeckBrowser() self.setupOverview() self.setupReviewer() def setupProfileAfterWebviewsLoaded(self): for w in (self.web, self.bottomWeb): if not w._domDone: self.progress.timer(10, self.setupProfileAfterWebviewsLoaded, False, requiresCollection=False) return else: w.requiresCol = True self.setupProfile() # Profiles ########################################################################## class ProfileManager(QMainWindow): onClose = pyqtSignal() closeFires = True def closeEvent(self, evt): if self.closeFires: self.onClose.emit() evt.accept() def closeWithoutQuitting(self): self.closeFires = False self.close() self.closeFires = True def setupProfile(self): if self.pm.meta['firstRun']: # load the new deck user profile self.pm.load(self.pm.profiles()[0]) self.pm.meta['firstRun'] = False self.pm.save() self.pendingImport = None self.restoringBackup = False # 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: self.pm.load(profs[0]) if not self.pm.name: self.showProfileManager() else: self.loadProfile() def showProfileManager(self): self.pm.profile = None self.state = "profileManager" d = self.profileDiag = self.ProfileManager() f = self.profileForm = aqt.forms.profiles.Ui_MainWindow() f.setupUi(d) f.login.clicked.connect(self.onOpenProfile) f.profiles.itemDoubleClicked.connect(self.onOpenProfile) f.openBackup.clicked.connect(self.onOpenBackup) f.quit.clicked.connect(d.close) d.onClose.connect(self.cleanupAndExit) f.add.clicked.connect(self.onAddProfile) f.rename.clicked.connect(self.onRenameProfile) f.delete_2.clicked.connect(self.onRemProfile) f.profiles.currentRowChanged.connect(self.onProfileRowChange) f.statusbar.setVisible(False) # enter key opens profile QShortcut(QKeySequence("Return"), d, activated=self.onOpenProfile) self.refreshProfilesList() # raise first, for osx testing d.show() d.activateWindow() d.raise_() def refreshProfilesList(self): f = self.profileForm f.profiles.clear() profs = self.pm.profiles() f.profiles.addItems(profs) try: idx = profs.index(self.pm.name) except: idx = 0 f.profiles.setCurrentRow(idx) def onProfileRowChange(self, n): if n < 0: # called on .clear() return name = self.pm.profiles()[n] f = self.profileForm self.pm.load(name) def openProfile(self): name = self.pm.profiles()[self.profileForm.profiles.currentRow()] return self.pm.load(name) def onOpenProfile(self): self.loadProfile(self.profileDiag.closeWithoutQuitting) def profileNameOk(self, str): return not checkInvalidFilename(str) def onAddProfile(self): name = getOnlyText(_("Name:")).strip() if name: if name in self.pm.profiles(): return showWarning(_("Name exists.")) if not self.profileNameOk(name): return self.pm.create(name) self.pm.name = name self.refreshProfilesList() def onRenameProfile(self): name = getOnlyText(_("New name:"), default=self.pm.name).strip() if not name: return if name == self.pm.name: return if name in self.pm.profiles(): return showWarning(_("Name exists.")) if not self.profileNameOk(name): return 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.")) # sure? if not askUser(_("""\ All cards, notes, and media for this profile will be deleted. \ Are you sure?"""), msgfunc=QMessageBox.warning, defaultno=True): return self.pm.remove(self.pm.name) self.refreshProfilesList() def onOpenBackup(self): if not askUser(_("""\ Replace your collection with an earlier backup?"""), msgfunc=QMessageBox.warning, defaultno=True): return def doOpen(path): self._openBackup(path) getFile(self.profileDiag, _("Revert to backup"), cb=doOpen, filter="*.colpkg", dir=self.pm.backupFolder()) def _openBackup(self, path): try: # move the existing collection to the trash, as it may not open self.pm.trashCollection() except: showWarning( _("Unable to move existing file to trash - please try restarting your computer." )) return self.pendingImport = path self.restoringBackup = True showInfo( _("""\ Automatic syncing and backups have been disabled while restoring. To enable them again, \ close the profile or restart Anki.""")) self.onOpenProfile() def loadProfile(self, onsuccess=None): self.maybeAutoSync() if not self.loadCollection(): return # show main window if self.pm.profile['mainWindowState']: restoreGeom(self, "mainWindow") restoreState(self, "mainWindow") # titlebar self.setWindowTitle(self.pm.name + " - Anki") # show and raise window for osx self.show() self.activateWindow() self.raise_() # import pending? if self.pendingImport: self.handleImport(self.pendingImport) self.pendingImport = None runHook("profileLoaded") if onsuccess: onsuccess() def unloadProfile(self, onsuccess): def callback(): self._unloadProfile() onsuccess() runHook("unloadProfile") self.unloadCollection(callback) def _unloadProfile(self): self.pm.profile['mainWindowGeom'] = self.saveGeometry() self.pm.profile['mainWindowState'] = self.saveState() self.pm.save() self.hide() self.restoringBackup = False # at this point there should be no windows left self._checkForUnclosedWidgets() self.maybeAutoSync() def _checkForUnclosedWidgets(self): for w in self.app.topLevelWidgets(): if w.isVisible(): # windows with this property are safe to close immediately if getattr(w, "silentlyClose", None): w.close() else: print("Window should have been closed: {}".format(w)) def unloadProfileAndExit(self): self.unloadProfile(self.cleanupAndExit) def unloadProfileAndShowProfileManager(self): self.unloadProfile(self.showProfileManager) def cleanupAndExit(self): self.errorHandler.unload() self.mediaServer.shutdown() anki.sound.cleanupMPV() self.app.exit(0) # Sound/video ########################################################################## def setupSound(self): if isWin: return try: anki.sound.setupMPV() except FileNotFoundError: print("mpv not found, reverting to mplayer") except anki.mpv.MPVProcessError: print("mpv too old, reverting to mplayer") # Collection load/unload ########################################################################## def loadCollection(self): try: return self._loadCollection() except Exception as e: t = _("""\ Anki was unable to open your collection file. If problems persist after \ restarting your computer, please use the Open Backup button in the profile \ manager. Debug info: """) + traceback.format_exc() showWarning(t) print(t, file=sys.stderr) # clean up open collection if possible if self.col: try: self.col.close(save=False) except: pass self.col = None # return to profile manager self.hide() self.showProfileManager() return False def _loadCollection(self): cpath = self.pm.collectionPath() self.col = Collection(cpath, log=True) self.setEnabled(True) self.progress.setupDB(self.col.db) self.maybeEnableUndo() self.moveToState("deckBrowser") return True def unloadCollection(self, onsuccess): def callback(): self.setEnabled(False) self._unloadCollection() onsuccess() self.closeAllWindows(callback) def _unloadCollection(self): if not self.col: return if self.restoringBackup: label = _("Closing...") else: label = _("Backing Up...") self.progress.start(label=label, immediate=True) corrupt = False try: self.maybeOptimize() if not devMode: corrupt = self.col.db.scalar("pragma integrity_check") != "ok" except: corrupt = True try: self.col.close() except: corrupt = True finally: self.col = None if corrupt: showWarning( _("Your collection file appears to be corrupt. \ This can happen when the file is copied or moved while Anki is open, or \ when the collection is stored on a network or cloud drive. If problems \ persist after restarting your computer, please open an automatic backup \ from the profile screen.")) if not corrupt and not self.restoringBackup: self.backup() self.progress.finish() # Backup and auto-optimize ########################################################################## class BackupThread(Thread): def __init__(self, path, data): Thread.__init__(self) self.path = path self.data = data # create the file in calling thread to ensure the same # file is not created twice open(self.path, "wb").close() def run(self): z = zipfile.ZipFile(self.path, "w", zipfile.ZIP_DEFLATED) z.writestr("collection.anki2", self.data) z.writestr("media", "{}") z.close() def backup(self): nbacks = self.pm.profile['numBackups'] if not nbacks or devMode: return dir = self.pm.backupFolder() path = self.pm.collectionPath() # do backup fname = time.strftime("backup-%Y-%m-%d-%H.%M.%S.colpkg", time.localtime(time.time())) newpath = os.path.join(dir, fname) with open(path, "rb") as f: data = f.read() b = self.BackupThread(newpath, data) b.start() # find existing backups backups = [] for file in os.listdir(dir): # only look for new-style format m = re.match(r"backup-\d{4}-\d{2}-.+.colpkg", file) if not m: continue backups.append(file) backups.sort() # remove old ones while len(backups) > nbacks: fname = backups.pop(0) path = os.path.join(dir, fname) os.unlink(path) def maybeOptimize(self): # have 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): """Call self._oldStateCleanup(state) if it exists for oldState. It seems it's the case only for review. remove shortcut related to this state run hooks beforeStateChange and afterStateChange. By default they are empty. show the bottom, unless its reset required. """ #print("-> move from", self.state, "to", state) oldState = self.state or "dummy" cleanup = getattr(self, "_" + oldState + "Cleanup", None) if cleanup: # pylint: disable=not-callable cleanup(state) self.clearStateShortcuts() self.state = state runHook('beforeStateChange', state, oldState, *args) getattr(self, "_" + state + "State")(oldState, *args) if state != "resetRequired": self.bottomWeb.show() runHook('afterStateChange', 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 _selectedDeck(self): did = self.col.decks.selected() if not self.col.decks.nameOrNone(did): showInfo(_("Please select a deck.")) return return self.col.decks.get(did) def _overviewState(self, oldState): if not self._selectedDeck(): return self.moveToState("deckBrowser") self.col.reset() self.overview.show() def _reviewState(self, oldState): self.reviewer.show() def _reviewCleanup(self, newState): """Run hook "reviewCleanup". Unless new state is resetRequired or review.""" if newState != "resetRequired" and newState != "review": 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. set Edit>undo change state (show the bottom bar, remove shortcut from last state) run hooks beforeStateChange and afterStateChange. By default they are empty. call cleanup of last state. call the hook "reset". It contains at least the onReset method from the current window if it is browser, (and its changeModel), editCurrent, addCard, studyDeck, modelChooser. Reset reinitialize those window without closing them. unless guiOnly: Deal with the fact that it's potentially a new day. Reset number of learning, review, new cards according to current decks empty queues. Set haveQueues to true. """ 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.interactiveState(): self.moveToState("resetRequired") def interactiveState(self): "True if not in profile manager, syncing, etc." return self.state in ("overview", "review", "deckBrowser") def maybeReset(self): self.autosave() if self.state == "resetRequired": self.state = self.returnState self.reset() def delayedMaybeReset(self): # if we redraw the page in a button click event it will often crash on # windows self.progress.timer(100, self.maybeReset, False) 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.resetHandlers() self.web.onBridgeCmd = lambda url: self.delayedMaybeReset() i = _("Waiting for editing to finish.") b = self.button("refresh", _("Resume Now"), id="resume") self.web.stdHtml(""" <center><div style="height: 100%%"> <div style="position:relative; vertical-align: middle;"> %s<br><br> %s</div></div></center> <script>$('#resume').focus()</script> """ % (i, b)) self.bottomWeb.hide() self.web.setFocus() # HTML helpers ########################################################################## def button(self, link, name, key=None, class_="", id="", extra=""): class_ = "but " + class_ if key: key = _("Shortcut key: %s") % key else: key = "" return ''' <button id="%s" class="%s" onclick="pycmd('%s');return false;" title="%s" %s>%s</button>''' % (id, class_, link, key, extra, name) # Main window setup ########################################################################## def setupMainWindow(self): # main window self.form = aqt.forms.main.Ui_MainWindow() self.form.setupUi(self) # toolbar tweb = self.toolbarWeb = aqt.webview.AnkiWebView() tweb.title = "top toolbar" tweb.setFocusPolicy(Qt.WheelFocus) self.toolbar = aqt.toolbar.Toolbar(self, tweb) self.toolbar.draw() # main area self.web = aqt.webview.AnkiWebView() self.web.title = "main webview" self.web.setFocusPolicy(Qt.WheelFocus) self.web.setMinimumWidth(400) # bottom area sweb = self.bottomWeb = aqt.webview.AnkiWebView() sweb.title = "bottom toolbar" 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) # force webengine processes to load before cwd is changed if isWin: for o in self.web, self.bottomWeb: o.requiresCol = False o._domReady = False o._page.setContent(bytes("", "ascii")) def closeAllWindows(self, onsuccess): aqt.dialogs.closeAll(onsuccess) # Components ########################################################################## def setupSignals(self): signal.signal(signal.SIGINT, self.onSigInt) def onSigInt(self, signum, frame): # interrupt any current transaction and schedule a rollback & quit if self.col: self.col.db.interrupt() def quit(): self.col.db.rollback() self.close() self.progress.timer(100, quit, False) 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) if not self.safeMode: self.addonManager.loadAddons() def setupSpellCheck(self): os.environ["QTWEBENGINE_DICTIONARIES_PATH"] = (os.path.join( self.pm.base, "dictionaries")) 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) # Syncing ########################################################################## # expects a current profile and a loaded collection; reloads # collection after sync completes def onSync(self): self.unloadCollection(self._onSync) def _onSync(self): self._sync() if not self.loadCollection(): return # expects a current profile, but no collection loaded def maybeAutoSync(self): if (not self.pm.profile['syncKey'] or not self.pm.profile['autoSync'] or self.safeMode or self.restoringBackup): return # ok to sync self._sync() def _sync(self): from aqt.sync import SyncManager self.state = "sync" self.syncer = SyncManager(self, self.pm) self.syncer.sync() # Tools ########################################################################## def raiseMain(self): if not self.app.activeWindow(): # make sure window is shown self.setWindowState(self.windowState() & ~Qt.WindowMinimized) return True def setupStyle(self): buf = "" if isWin and platform.release() == '10': # add missing bottom border to menubar buf += """ QMenuBar { border-bottom: 1px solid #aaa; background: white; } """ # qt bug? setting the above changes the browser sidebar # to white as well, so set it back buf += """ QTreeWidget { background: #eee; } """ # allow addons to modify the styling buf = runFilter("setupStyle", buf) # allow users to extend styling p = os.path.join(aqt.mw.pm.base, "style.css") if os.path.exists(p): buf += open(p).read() self.app.setStyleSheet(buf) # Key handling ########################################################################## def setupKeys(self): globalShortcuts = [("Ctrl+:", self.onDebug), ("d", lambda: self.moveToState("deckBrowser")), ("s", self.onStudyKey), ("a", self.onAddCard), ("b", self.onBrowse), ("t", self.onStats), ("y", self.onSync)] self.applyShortcuts(globalShortcuts) self.stateShortcuts = [] def applyShortcuts(self, shortcuts): """A list of shortcuts. Keyword arguments: shortcuts -- a list of pair (shortcut key, function called by the shortcut) """ qshortcuts = [] for key, fn in shortcuts: scut = QShortcut(QKeySequence(key), self, activated=fn) scut.setAutoRepeat(False) qshortcuts.append(scut) return qshortcuts def setStateShortcuts(self, shortcuts): """set stateShortcuts to QShortcut from shortcuts run hook CURRENTSTATEStateShorcuts """ runHook(self.state + "StateShortcuts", shortcuts) self.stateShortcuts = self.applyShortcuts(shortcuts) def clearStateShortcuts(self): """Delete the shortcut of current state, empty stateShortcuts""" for qs in self.stateShortcuts: sip.delete(qs) self.stateShortcuts = [] def onStudyKey(self): if self.state == "overview": self.col.startTimebox() self.moveToState("review") else: self.moveToState("overview") # App exit ########################################################################## def closeEvent(self, event): if self.state == "profileManager": # if profile manager active, this event may fire via OS X menu bar's # quit option self.profileDiag.close() event.accept() else: # ignore the event for now, as we need time to clean up event.ignore() self.unloadProfileAndExit() # Undo & autosave ########################################################################## def onUndo(self): n = self.col.undoName() if not n: return cid = self.col.undo() if cid and self.state == "review": card = self.col.getCard(cid) self.col.sched.reset() self.reviewer.cardQueue.append(card) self.reviewer.nextCard() runHook("revertedCard", cid) else: self.reset() tooltip(_("Reverted to state prior to '%s'.") % n.lower()) runHook("revertedState", n) self.maybeEnableUndo() def maybeEnableUndo(self): """Enable undo in the GUI if something can be undone. Call the hook undoState(somethingCanBeUndone).""" if self.col and self.col.undoName(): #Whether something can be undone 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): saved = self.col.autosave() self.maybeEnableUndo() if saved: self.doGC() # Other menu operations ########################################################################## def onAddCard(self): """Open the addCards window.""" aqt.dialogs.open("AddCards", self) def onBrowse(self): """Open the browser window.""" aqt.dialogs.open("Browser", self) def onEditCurrent(self): """Open the editing window.""" aqt.dialogs.open("EditCurrent", self) def onDeckConf(self, deck=None): """Open the deck editor. According to whether the deck is dynamic or not, open distinct window keyword arguments: deck -- The deck to edit. If not give, current Deck""" if not deck: deck = self.col.decks.current() if deck['dyn']: import aqt.dyndeckconf aqt.dyndeckconf.DeckConf(self, deck=deck) else: import aqt.deckconf aqt.deckconf.DeckConf(self, deck) def onOverview(self): self.col.reset() self.moveToState("overview") def onStats(self): """Open stats for selected decks If there are no selected deck, don't do anything.""" deck = self._selectedDeck() if not deck: return aqt.dialogs.open("DeckStats", self) def onPrefs(self): """Open preference window""" aqt.dialogs.open("Preferences", self) def onNoteTypes(self): import aqt.models aqt.models.Models(self, self, fromMain=True) def onAbout(self): """Open the about window""" aqt.dialogs.open("About", self) def onDonate(self): """Ask the OS to open the donate web page""" openLink(aqt.appDonate) def onDocumentation(self): """Ask the OS to open the documentation web page""" openHelp("") # Importing & exporting ########################################################################## def handleImport(self, path): import aqt.importing if not os.path.exists(path): return showInfo(_("Please use File>Import to import this file.")) aqt.importing.importFile(self, path) def onImport(self): import aqt.importing aqt.importing.onImport(self) def onExport(self, did=None): """Open exporting window, with did as in its argument.""" import aqt.exporting aqt.exporting.ExportDialog(self, did=did) # Cramming ########################################################################## def onCram(self, search=""): import aqt.dyndeckconf n = 1 deck = self.col.decks.current() if not search: if not deck['dyn']: search = 'deck:"%s" ' % deck['name'] decks = self.col.decks.allNames() while _("Filtered Deck %d") % n in decks: n += 1 name = _("Filtered Deck %d") % n 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) self.col.decks.select(deck['id']) # Menu, title bar & status ########################################################################## def setupMenus(self): m = self.form m.actionSwitchProfile.triggered.connect( self.unloadProfileAndShowProfileManager) m.actionImport.triggered.connect(self.onImport) m.actionExport.triggered.connect(self.onExport) m.actionExit.triggered.connect(self.close) m.actionPreferences.triggered.connect(self.onPrefs) m.actionAbout.triggered.connect(self.onAbout) m.actionUndo.triggered.connect(self.onUndo) if qtminor < 11: m.actionUndo.setShortcut(QKeySequence(_("Ctrl+Alt+Z"))) m.actionFullDatabaseCheck.triggered.connect(self.onCheckDB) m.actionCheckMediaDatabase.triggered.connect(self.onCheckMediaDB) m.actionDocumentation.triggered.connect(self.onDocumentation) m.actionDonate.triggered.connect(self.onDonate) m.actionStudyDeck.triggered.connect(self.onStudyDeck) m.actionCreateFiltered.triggered.connect(self.onCram) m.actionEmptyCards.triggered.connect(self.onEmptyCards) m.actionNoteTypes.triggered.connect(self.onNoteTypes) def updateTitleBar(self): self.setWindowTitle("Anki") # Auto update ########################################################################## def setupAutoUpdate(self): import aqt.update self.autoUpdate = aqt.update.LatestVersionFinder(self) self.autoUpdate.newVerAvail.connect(self.newVerAvail) self.autoUpdate.newMsg.connect(self.newMsg) self.autoUpdate.clockIsOff.connect(self.clockIsOff) self.autoUpdate.start() def newVerAvail(self, ver): if self.pm.meta.get('suppressUpdate', None) != ver: aqt.update.askAndUpdate(self, ver) def newMsg(self, data): aqt.update.showMessages(self, data) def clockIsOff(self, diff): diffText = ngettext("%s second", "%s seconds", diff) % diff warn = _("""\ In order to ensure your collection works correctly when moved between \ devices, Anki requires your computer's internal clock to be set correctly. \ The internal clock can be wrong even if your system is showing the correct \ local time. Please go to the time settings on your computer and check the following: - AM/PM - Clock drift - Day, month and year - Timezone - Daylight savings Difference to correct time: %s.""") % diffText showWarning(warn) self.app.closeAllWindows() # Count refreshing ########################################################################## def setupRefreshTimer(self): # every 10 minutes self.progress.timer(10 * 60 * 1000, self.onRefreshTimer, True) def onRefreshTimer(self): if self.state == "deckBrowser": self.deckBrowser.refresh() elif self.state == "overview": self.overview.refresh() # Permanent libanki hooks ########################################################################## def setupHooks(self): """Adds onSchemadMod, onRemNotes and onOdueInvalid to their hooks""" addHook("modSchema", self.onSchemaMod) addHook("remNotes", self.onRemNotes) addHook("odueInvalid", self.onOdueInvalid) addHook("mpvWillPlay", self.onMpvWillPlay) addHook("mpvIdleHook", self.onMpvIdle) self._activeWindowOnPlay = None def onOdueInvalid(self): showWarning( _("""\ Invalid property found on card. Please use Tools>Check Database, \ and if the problem comes up again, please ask on the support site.""")) def _isVideo(self, file): head, ext = os.path.splitext(file.lower()) return ext in (".mp4", ".mov", ".mpg", ".mpeg", ".mkv", ".avi") def onMpvWillPlay(self, file): if not self._isVideo(file): return self._activeWindowOnPlay = self.app.activeWindow( ) or self._activeWindowOnPlay def onMpvIdle(self): w = self._activeWindowOnPlay if not self.app.activeWindow() and w and not sip.isdeleted( w) and w.isVisible(): w.activateWindow() w.raise_() self._activeWindowOnPlay = None # Log note deletion ########################################################################## def onRemNotes(self, col, nids): """Append (id, model id and fields) to the end of deleted.txt This is done for each id of nids. This method is added to the hook remNotes; and executed on note deletion. """ path = os.path.join(self.pm.profileFolder(), "deleted.txt") existed = os.path.exists(path) with open(path, "ab") as f: if not existed: f.write(b"nid\tmid\tfields\n") for id, mid, flds in col.db.execute( "select id, mid, flds from notes where id in %s" % ids2str(nids)): fields = splitFields(flds) f.write( ("\t".join([str(id), str(mid)] + fields)).encode("utf8")) f.write(b"\n") # Schema modifications ########################################################################## def onSchemaMod(self, arg): """Ask the user whether they accept to do an action which will request a full reupload of the db""" 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?""")) # 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) # if an error has directed the user to check the database, # silently clean up any broken reset hooks which distract from # the underlying issue while True: try: self.reset() break except Exception as e: print("swallowed exception in reset hook:", e) continue return ret def onCheckMediaDB(self): self.progress.start(immediate=True) (nohave, unused, warnings) = self.col.media.check() self.progress.finish() # generate report report = "" if warnings: report += "\n".join(warnings) + "\n" if unused: if report: report += "\n\n\n" 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: tooltip(_("No unused or missing files found.")) return # 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) if unused: b = QPushButton(_("Delete Unused Files")) b.setAutoDefault(False) box.addButton(b, QDialogButtonBox.ActionRole) b.clicked.connect( lambda c, u=unused, d=diag: self.deleteUnused(u, d)) box.rejected.connect(diag.reject) diag.setMinimumHeight(400) diag.setMinimumWidth(500) restoreGeom(diag, "checkmediadb") diag.exec_() saveGeom(diag, "checkmediadb") def deleteUnused(self, unused, diag): if not askUser(_("Delete unused media?")): return mdir = self.col.media.dir() for f in unused: path = os.path.join(mdir, f) if os.path.exists(path): send2trash(path) tooltip(_("Deleted.")) diag.close() def onStudyDeck(self): from aqt.studydeck import StudyDeck ret = StudyDeck(self, dyn=True, current=self.col.decks.current()['name']) if ret.name: self.col.decks.select(self.col.decks.id(ret.name)) self.moveToState("overview") def onEmptyCards(self): """Method called by Tools>Empty Cards...""" self.progress.start(immediate=True) cids = self.col.emptyCids() if not cids: self.progress.finish() tooltip(_("No empty cards.")) return report = self.col.emptyCardReport(cids) self.progress.finish() part1 = ngettext("%d card", "%d cards", len(cids)) % len(cids) part1 = _("%s to delete:") % part1 diag, box = showText(part1 + "\n\n" + report, run=False, geomKey="emptyCards") box.addButton(_("Delete Cards"), QDialogButtonBox.AcceptRole) box.button(QDialogButtonBox.Close).setDefault(True) def onDelete(): saveGeom(diag, "emptyCards") QDialog.accept(diag) self.checkpoint(_("Delete Empty")) self.col.remCards(cids) tooltip( ngettext("%d card deleted.", "%d cards deleted.", len(cids)) % len(cids)) self.reset() box.accepted.connect(onDelete) diag.show() # Debugging ###################################################################### def onDebug(self): d = self.debugDiag = QDialog() d.silentlyClose = True frm = aqt.forms.debug.Ui_Dialog() frm.setupUi(d) font = QFontDatabase.systemFont(QFontDatabase.FixedFont) font.setPointSize(frm.text.font().pointSize() + 1) frm.text.setFont(font) frm.log.setFont(font) s = self.debugDiagShort = QShortcut(QKeySequence("ctrl+return"), d) s.activated.connect(lambda: self.onDebugRet(frm)) s = self.debugDiagShort = QShortcut(QKeySequence("ctrl+shift+return"), d) s.activated.connect(lambda: self.onDebugPrint(frm)) s = self.debugDiagShort = QShortcut(QKeySequence("ctrl+l"), d) s.activated.connect(frm.log.clear) s = self.debugDiagShort = QShortcut(QKeySequence("ctrl+shift+l"), d) s.activated.connect(frm.text.clear) d.show() def _captureOutput(self, on): mw = self class Stream: def write(self, data): mw._output += data if on: self._output = "" self._oldStderr = sys.stderr self._oldStdout = sys.stdout s = Stream() sys.stderr = s sys.stdout = s else: sys.stderr = self._oldStderr sys.stdout = self._oldStdout def _debugCard(self): return self.reviewer.card.__dict__ def _debugBrowserCard(self): return aqt.dialogs._dialogs['Browser'][1].card.__dict__ def onDebugPrint(self, frm): cursor = frm.text.textCursor() position = cursor.position() cursor.select(QTextCursor.LineUnderCursor) line = cursor.selectedText() pfx, sfx = "pp(", ")" if not line.startswith(pfx): line = "{}{}{}".format(pfx, line, sfx) cursor.insertText(line) cursor.setPosition(position + len(pfx)) frm.text.setTextCursor(cursor) self.onDebugRet(frm) def onDebugRet(self, frm): import pprint, traceback text = frm.text.toPlainText() card = self._debugCard bcard = self._debugBrowserCard mw = self pp = pprint.pprint self._captureOutput(True) try: # pylint: disable=exec-used exec(text) except: self._output += traceback.format_exc() self._captureOutput(False) buf = "" for c, line in enumerate(text.strip().split("\n")): if c == 0: buf += ">>> %s\n" % line else: buf += "... %s\n" % line try: frm.log.appendPlainText(buf + (self._output or "<no output>")) except UnicodeDecodeError: frm.log.appendPlainText(_("<non-unicode text>")) frm.log.ensureCursorVisible() # System specific code ########################################################################## def setupSystemSpecific(self): self.hideMenuAccels = False if isMac: # mac users expect a minimize option self.minimizeShortcut = QShortcut("Ctrl+M", self) self.minimizeShortcut.activated.connect(self.onMacMinimize) self.hideMenuAccels = True self.maybeHideAccelerators() self.hideStatusTips() elif isWin: # make sure ctypes is bundled from ctypes import windll, wintypes _dummy = windll _dummy = wintypes def maybeHideAccelerators(self, tgt=None): if not self.hideMenuAccels: return tgt = tgt or self for action in tgt.findChildren(QAction): txt = str(action.text()) m = re.match(r"^(.+)\(&.+\)(.+)?", 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) # Single instance support ########################################################################## def setupAppMsg(self): self.app.appMsg.connect(self.onAppMsg) def onAppMsg(self, buf): if self.state == "startup": # try again in a second return self.progress.timer(1000, lambda: self.onAppMsg(buf), False, requiresCollection=False) elif self.state == "profileManager": # can't raise window while in profile manager if buf == "raise": return self.pendingImport = buf return tooltip( _("Deck will be imported when a profile is opened.")) if not self.interactiveState() or self.progress.busy(): # we can't raise the main window while in profile dialog, syncing, etc if buf != "raise": showInfo(_("""\ Please ensure a profile is open and Anki is not busy, then try again."""), parent=None) return # raise window if isWin: # on windows we can raise the window by minimizing and restoring self.showMinimized() self.setWindowState(Qt.WindowActive) self.showNormal() else: # on osx we can raise the window. on unity the icon in the tray will just flash. self.activateWindow() self.raise_() if buf == "raise": return # import self.handleImport(buf) # GC ########################################################################## # ensure gc runs in main thread def setupDialogGC(self, obj): obj.finished.connect(lambda: self.gcWindow(obj)) def gcWindow(self, obj): obj.deleteLater() self.progress.timer(1000, self.doGC, False, requiresCollection=False) def disableGC(self): gc.collect() gc.disable() def doGC(self): assert not self.progress.inDB gc.collect() # Crash log ########################################################################## def setupCrashLog(self): p = os.path.join(self.pm.base, "crash.log") self._crashLog = open(p, "ab", 0) faulthandler.enable(self._crashLog) # Media server ########################################################################## def setupMediaServer(self): self.mediaServer = aqt.mediasrv.MediaServer(self) self.mediaServer.start() def baseHTML(self): return '<base href="%s">' % self.serverURL() def serverURL(self): return "http://127.0.0.1:%d/" % self.mediaServer.getPort()
class AnkiQt(QMainWindow): def __init__(self, app, profileManager, opts, args): QMainWindow.__init__(self) self.state = "startup" self.opts = opts aqt.mw = self self.app = app self.pm = profileManager # init rest of app self.safeMode = self.app.queryKeyboardModifiers() & Qt.ShiftModifier try: self.setupUI() self.setupAddons() except: showInfo(_("Error during startup:\n%s") % traceback.format_exc()) sys.exit(1) # must call this after ui set up if self.safeMode: tooltip(_("Shift key was held down. Skipping automatic " "syncing and add-on loading.")) # were we given a file to import? if args and args[0]: self.onAppMsg(args[0]) # 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, requiresCollection=False) def setupUI(self): self.col = None self.setupCrashLog() self.disableGC() self.setupAppMsg() self.setupKeys() self.setupThreads() self.setupMediaServer() self.setupSound() self.setupSpellCheck() self.setupMainWindow() self.setupSystemSpecific() self.setupStyle() self.setupMenus() self.setupProgress() self.setupErrorHandler() self.setupSignals() self.setupAutoUpdate() self.setupHooks() self.setupRefreshTimer() self.updateTitleBar() # screens self.setupDeckBrowser() self.setupOverview() self.setupReviewer() # Profiles ########################################################################## class ProfileManager(QMainWindow): onClose = pyqtSignal() closeFires = True def closeEvent(self, evt): if self.closeFires: self.onClose.emit() evt.accept() def closeWithoutQuitting(self): self.closeFires = False self.close() self.closeFires = True def setupProfile(self): if self.pm.meta['firstRun']: # load the new deck user profile self.pm.load(self.pm.profiles()[0]) self.pm.meta['firstRun'] = False self.pm.save() self.pendingImport = None self.restoringBackup = False # 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: self.pm.load(profs[0]) if not self.pm.name: self.showProfileManager() else: self.loadProfile() def showProfileManager(self): self.pm.profile = None self.state = "profileManager" d = self.profileDiag = self.ProfileManager() f = self.profileForm = aqt.forms.profiles.Ui_MainWindow() f.setupUi(d) f.login.clicked.connect(self.onOpenProfile) f.profiles.itemDoubleClicked.connect(self.onOpenProfile) f.openBackup.clicked.connect(self.onOpenBackup) f.quit.clicked.connect(d.close) d.onClose.connect(self.cleanupAndExit) f.add.clicked.connect(self.onAddProfile) f.rename.clicked.connect(self.onRenameProfile) f.delete_2.clicked.connect(self.onRemProfile) f.profiles.currentRowChanged.connect(self.onProfileRowChange) f.statusbar.setVisible(False) # enter key opens profile QShortcut(QKeySequence("Return"), d, activated=self.onOpenProfile) self.refreshProfilesList() # raise first, for osx testing d.show() d.activateWindow() d.raise_() def refreshProfilesList(self): f = self.profileForm f.profiles.clear() profs = self.pm.profiles() f.profiles.addItems(profs) try: idx = profs.index(self.pm.name) except: idx = 0 f.profiles.setCurrentRow(idx) def onProfileRowChange(self, n): if n < 0: # called on .clear() return name = self.pm.profiles()[n] f = self.profileForm self.pm.load(name) def openProfile(self): name = self.pm.profiles()[self.profileForm.profiles.currentRow()] return self.pm.load(name) def onOpenProfile(self): self.loadProfile(self.profileDiag.closeWithoutQuitting) def profileNameOk(self, str): return not checkInvalidFilename(str) def onAddProfile(self): name = getOnlyText(_("Name:")) if name: name = name.strip() if name in self.pm.profiles(): return showWarning(_("Name exists.")) if not self.profileNameOk(name): return self.pm.create(name) self.pm.name = name self.refreshProfilesList() def onRenameProfile(self): name = getOnlyText(_("New name:"), default=self.pm.name) if not name: return if name == self.pm.name: return if name in self.pm.profiles(): return showWarning(_("Name exists.")) if not self.profileNameOk(name): return 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.")) # sure? if not askUser(_("""\ All cards, notes, and media for this profile will be deleted. \ Are you sure?"""), msgfunc=QMessageBox.warning, defaultno=True): return self.pm.remove(self.pm.name) self.refreshProfilesList() def onOpenBackup(self): if not askUser(_("""\ Replace your collection with an earlier backup?"""), msgfunc=QMessageBox.warning, defaultno=True): return def doOpen(path): self._openBackup(path) getFile(self.profileDiag, _("Revert to backup"), cb=doOpen, filter="*.colpkg", dir=self.pm.backupFolder()) def _openBackup(self, path): try: # move the existing collection to the trash, as it may not open self.pm.trashCollection() except: showWarning(_("Unable to move existing file to trash - please try restarting your computer.")) return self.pendingImport = path self.restoringBackup = True showInfo(_("""\ Automatic syncing and backups have been disabled while restoring. To enable them again, \ close the profile or restart Anki.""")) self.onOpenProfile() def loadProfile(self, onsuccess=None): self.maybeAutoSync() if not self.loadCollection(): return # show main window if self.pm.profile['mainWindowState']: restoreGeom(self, "mainWindow") restoreState(self, "mainWindow") # titlebar self.setWindowTitle(self.pm.name + " - Anki") # show and raise window for osx self.show() self.activateWindow() self.raise_() # import pending? if self.pendingImport: self.handleImport(self.pendingImport) self.pendingImport = None runHook("profileLoaded") if onsuccess: onsuccess() def unloadProfile(self, onsuccess): def callback(): self._unloadProfile() onsuccess() runHook("unloadProfile") self.unloadCollection(callback) def _unloadProfile(self): self.pm.profile['mainWindowGeom'] = self.saveGeometry() self.pm.profile['mainWindowState'] = self.saveState() self.pm.save() self.hide() self.restoringBackup = False # at this point there should be no windows left self._checkForUnclosedWidgets() self.maybeAutoSync() def _checkForUnclosedWidgets(self): for w in self.app.topLevelWidgets(): if w.isVisible(): # windows with this property are safe to close immediately if getattr(w, "silentlyClose", None): w.close() else: print("Window should have been closed: {}".format(w)) def unloadProfileAndExit(self): self.unloadProfile(self.cleanupAndExit) def unloadProfileAndShowProfileManager(self): self.unloadProfile(self.showProfileManager) def cleanupAndExit(self): self.errorHandler.unload() self.mediaServer.shutdown() anki.sound.cleanupMPV() self.app.exit(0) # Sound/video ########################################################################## def setupSound(self): if isWin: return try: anki.sound.setupMPV() except FileNotFoundError: print("mpv not found, reverting to mplayer") except anki.mpv.MPVProcessError: print("mpv too old, reverting to mplayer") # Collection load/unload ########################################################################## def loadCollection(self): try: return self._loadCollection() except Exception as e: showWarning(_("""\ Anki was unable to open your collection file. If problems persist after \ restarting your computer, please use the Open Backup button in the profile \ manager. Debug info: """)+traceback.format_exc()) # clean up open collection if possible if self.col: try: self.col.close(save=False) except: pass self.col = None # return to profile manager self.showProfileManager() return False def _loadCollection(self): cpath = self.pm.collectionPath() self.col = Collection(cpath, log=True) self.setEnabled(True) self.progress.setupDB(self.col.db) self.maybeEnableUndo() self.moveToState("deckBrowser") return True def unloadCollection(self, onsuccess): def callback(): self.setEnabled(False) self._unloadCollection() onsuccess() self.closeAllWindows(callback) def _unloadCollection(self): if not self.col: return if self.restoringBackup: label = _("Closing...") else: label = _("Backing Up...") self.progress.start(label=label, immediate=True) corrupt = False try: self.maybeOptimize() if not devMode: corrupt = self.col.db.scalar("pragma integrity_check") != "ok" except: corrupt = True try: self.col.close() except: corrupt = True finally: self.col = None if corrupt: showWarning(_("Your collection file appears to be corrupt. \ This can happen when the file is copied or moved while Anki is open, or \ when the collection is stored on a network or cloud drive. If problems \ persist after restarting your computer, please open an automatic backup \ from the profile screen.")) if not corrupt and not self.restoringBackup: self.backup() self.progress.finish() # Backup and auto-optimize ########################################################################## class BackupThread(Thread): def __init__(self, path, data): Thread.__init__(self) self.path = path self.data = data # create the file in calling thread to ensure the same # file is not created twice open(self.path, "wb").close() def run(self): z = zipfile.ZipFile(self.path, "w", zipfile.ZIP_DEFLATED) z.writestr("collection.anki2", self.data) z.writestr("media", "{}") z.close() def backup(self): nbacks = self.pm.profile['numBackups'] if not nbacks or devMode: return dir = self.pm.backupFolder() path = self.pm.collectionPath() # do backup fname = time.strftime("backup-%Y-%m-%d-%H.%M.%S.colpkg", time.localtime(time.time())) newpath = os.path.join(dir, fname) with open(path, "rb") as f: data = f.read() b = self.BackupThread(newpath, data) b.start() # find existing backups backups = [] for file in os.listdir(dir): # only look for new-style format m = re.match(r"backup-\d{4}-\d{2}-.+.colpkg", file) if not m: continue backups.append(file) backups.sort() # remove old ones while len(backups) > nbacks: fname = backups.pop(0) path = os.path.join(dir, fname) os.unlink(path) def maybeOptimize(self): # have 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: # pylint: disable=not-callable cleanup(state) self.clearStateShortcuts() self.state = state runHook('beforeStateChange', state, oldState, *args) getattr(self, "_"+state+"State")(oldState, *args) if state != "resetRequired": self.bottomWeb.show() runHook('afterStateChange', 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 _selectedDeck(self): did = self.col.decks.selected() if not self.col.decks.nameOrNone(did): showInfo(_("Please select a deck.")) return return self.col.decks.get(did) def _overviewState(self, oldState): if not self._selectedDeck(): return self.moveToState("deckBrowser") self.col.reset() self.overview.show() def _reviewState(self, oldState): self.reviewer.show() def _reviewCleanup(self, newState): if newState != "resetRequired" and newState != "review": 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.interactiveState(): self.moveToState("resetRequired") def interactiveState(self): "True if not in profile manager, syncing, etc." return self.state in ("overview", "review", "deckBrowser") def maybeReset(self): self.autosave() if self.state == "resetRequired": self.state = self.returnState self.reset() def delayedMaybeReset(self): # if we redraw the page in a button click event it will often crash on # windows self.progress.timer(100, self.maybeReset, False) 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.resetHandlers() self.web.onBridgeCmd = lambda url: self.delayedMaybeReset() i = _("Waiting for editing to finish.") b = self.button("refresh", _("Resume Now"), id="resume") self.web.stdHtml(""" <center><div style="height: 100%%"> <div style="position:relative; vertical-align: middle;"> %s<br><br> %s</div></div></center> <script>$('#resume').focus()</script> """ % (i, b)) self.bottomWeb.hide() self.web.setFocus() # HTML helpers ########################################################################## def button(self, link, name, key=None, class_="", id="", extra=""): class_ = "but "+ class_ if key: key = _("Shortcut key: %s") % key else: key = "" return ''' <button id="%s" class="%s" onclick="pycmd('%s');return false;" title="%s" %s>%s</button>''' % ( id, class_, link, key, extra, name) # Main window setup ########################################################################## def setupMainWindow(self): # main window self.form = aqt.forms.main.Ui_MainWindow() self.form.setupUi(self) # toolbar tweb = self.toolbarWeb = aqt.webview.AnkiWebView() tweb.title = "top toolbar" tweb.setFocusPolicy(Qt.WheelFocus) self.toolbar = aqt.toolbar.Toolbar(self, tweb) self.toolbar.draw() # main area self.web = aqt.webview.AnkiWebView() self.web.title = "main webview" self.web.setFocusPolicy(Qt.WheelFocus) self.web.setMinimumWidth(400) # bottom area sweb = self.bottomWeb = aqt.webview.AnkiWebView() sweb.title = "bottom toolbar" 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) # force webengine processes to load before cwd is changed if isWin: for o in self.web, self.bottomWeb: o._setHtml("") def closeAllWindows(self, onsuccess): aqt.dialogs.closeAll(onsuccess) # Components ########################################################################## def setupSignals(self): signal.signal(signal.SIGINT, self.onSigInt) def onSigInt(self, signum, frame): # interrupt any current transaction and schedule a rollback & quit if self.col: self.col.db.interrupt() def quit(): self.col.db.rollback() self.close() self.progress.timer(100, quit, False) 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) if not self.safeMode: self.addonManager.loadAddons() def setupSpellCheck(self): os.environ["QTWEBENGINE_DICTIONARIES_PATH"] = ( os.path.join(self.pm.base, "dictionaries")) 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) # Syncing ########################################################################## # expects a current profile and a loaded collection; reloads # collection after sync completes def onSync(self): self.unloadCollection(self._onSync) def _onSync(self): self._sync() if not self.loadCollection(): return # expects a current profile, but no collection loaded def maybeAutoSync(self): if (not self.pm.profile['syncKey'] or not self.pm.profile['autoSync'] or self.safeMode or self.restoringBackup): return # ok to sync self._sync() def _sync(self): from aqt.sync import SyncManager self.state = "sync" self.syncer = SyncManager(self, self.pm) self.syncer.sync() # Tools ########################################################################## def raiseMain(self): if not self.app.activeWindow(): # make sure window is shown self.setWindowState(self.windowState() & ~Qt.WindowMinimized) return True def setupStyle(self): buf = "" if isWin and platform.release() == '10': # add missing bottom border to menubar buf += """ QMenuBar { border-bottom: 1px solid #aaa; background: white; } """ # qt bug? setting the above changes the browser sidebar # to white as well, so set it back buf += """ QTreeWidget { background: #eee; } """ # allow addons to modify the styling buf = runFilter("setupStyle", buf) # allow users to extend styling p = os.path.join(aqt.mw.pm.base, "style.css") if os.path.exists(p): buf += open(p).read() self.app.setStyleSheet(buf) # Key handling ########################################################################## def setupKeys(self): globalShortcuts = [ ("Ctrl+:", self.onDebug), ("d", lambda: self.moveToState("deckBrowser")), ("s", self.onStudyKey), ("a", self.onAddCard), ("b", self.onBrowse), ("t", self.onStats), ("y", self.onSync) ] self.applyShortcuts(globalShortcuts) self.stateShortcuts = [] def applyShortcuts(self, shortcuts): qshortcuts = [] for key, fn in shortcuts: scut = QShortcut(QKeySequence(key), self, activated=fn) scut.setAutoRepeat(False) qshortcuts.append(scut) return qshortcuts def setStateShortcuts(self, shortcuts): runHook(self.state+"StateShortcuts", shortcuts) self.stateShortcuts = self.applyShortcuts(shortcuts) def clearStateShortcuts(self): for qs in self.stateShortcuts: sip.delete(qs) self.stateShortcuts = [] def onStudyKey(self): if self.state == "overview": self.col.startTimebox() self.moveToState("review") else: self.moveToState("overview") # App exit ########################################################################## def closeEvent(self, event): if self.state == "profileManager": # if profile manager active, this event may fire via OS X menu bar's # quit option self.profileDiag.close() event.accept() else: # ignore the event for now, as we need time to clean up event.ignore() self.unloadProfileAndExit() # Undo & autosave ########################################################################## def onUndo(self): n = self.col.undoName() if not n: return cid = self.col.undo() if cid and self.state == "review": card = self.col.getCard(cid) self.col.sched.reset() self.reviewer.cardQueue.append(card) self.reviewer.nextCard() self.maybeEnableUndo() return tooltip(_("Reverted to state prior to '%s'.") % n.lower()) 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): saved = self.col.autosave() self.maybeEnableUndo() if saved: self.doGC() # Other menu operations ########################################################################## def onAddCard(self): aqt.dialogs.open("AddCards", self) def onBrowse(self): aqt.dialogs.open("Browser", self) def onEditCurrent(self): aqt.dialogs.open("EditCurrent", self) def onDeckConf(self, deck=None): if not deck: deck = self.col.decks.current() if deck['dyn']: import aqt.dyndeckconf aqt.dyndeckconf.DeckConf(self, deck=deck) else: import aqt.deckconf aqt.deckconf.DeckConf(self, deck) def onOverview(self): self.col.reset() self.moveToState("overview") def onStats(self): deck = self._selectedDeck() if not deck: return aqt.dialogs.open("DeckStats", self) def onPrefs(self): aqt.dialogs.open("Preferences", self) def onNoteTypes(self): import aqt.models aqt.models.Models(self, self, fromMain=True) def onAbout(self): aqt.dialogs.open("About", self) def onDonate(self): openLink(aqt.appDonate) def onDocumentation(self): openHelp("") # Importing & exporting ########################################################################## def handleImport(self, path): import aqt.importing if not os.path.exists(path): return showInfo(_("Please use File>Import to import this file.")) aqt.importing.importFile(self, path) def onImport(self): import aqt.importing aqt.importing.onImport(self) def onExport(self, did=None): import aqt.exporting aqt.exporting.ExportDialog(self, did=did) # Cramming ########################################################################## def onCram(self, search=""): import aqt.dyndeckconf n = 1 deck = self.col.decks.current() if not search: if not deck['dyn']: search = 'deck:"%s" ' % deck['name'] decks = self.col.decks.allNames() while _("Filtered Deck %d") % n in decks: n += 1 name = _("Filtered Deck %d") % n 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) self.col.decks.select(deck['id']) # Menu, title bar & status ########################################################################## def setupMenus(self): m = self.form m.actionSwitchProfile.triggered.connect( self.unloadProfileAndShowProfileManager) m.actionImport.triggered.connect(self.onImport) m.actionExport.triggered.connect(self.onExport) m.actionExit.triggered.connect(self.close) m.actionPreferences.triggered.connect(self.onPrefs) m.actionAbout.triggered.connect(self.onAbout) m.actionUndo.triggered.connect(self.onUndo) if qtminor < 11: m.actionUndo.setShortcut(QKeySequence(_("Ctrl+Alt+Z"))) m.actionFullDatabaseCheck.triggered.connect(self.onCheckDB) m.actionCheckMediaDatabase.triggered.connect(self.onCheckMediaDB) m.actionDocumentation.triggered.connect(self.onDocumentation) m.actionDonate.triggered.connect(self.onDonate) m.actionStudyDeck.triggered.connect(self.onStudyDeck) m.actionCreateFiltered.triggered.connect(self.onCram) m.actionEmptyCards.triggered.connect(self.onEmptyCards) m.actionNoteTypes.triggered.connect(self.onNoteTypes) def updateTitleBar(self): self.setWindowTitle("Anki") # Auto update ########################################################################## def setupAutoUpdate(self): import aqt.update self.autoUpdate = aqt.update.LatestVersionFinder(self) self.autoUpdate.newVerAvail.connect(self.newVerAvail) self.autoUpdate.newMsg.connect(self.newMsg) self.autoUpdate.clockIsOff.connect(self.clockIsOff) self.autoUpdate.start() def newVerAvail(self, ver): if self.pm.meta.get('suppressUpdate', None) != ver: aqt.update.askAndUpdate(self, ver) def newMsg(self, data): aqt.update.showMessages(self, data) def clockIsOff(self, diff): diffText = ngettext("%s second", "%s seconds", diff) % diff warn = _("""\ In order to ensure your collection works correctly when moved between \ devices, Anki requires your computer's internal clock to be set correctly. \ The internal clock can be wrong even if your system is showing the correct \ local time. Please go to the time settings on your computer and check the following: - AM/PM - Clock drift - Day, month and year - Timezone - Daylight savings Difference to correct time: %s.""") % diffText showWarning(warn) self.app.closeAllWindows() # Count refreshing ########################################################################## def setupRefreshTimer(self): # every 10 minutes self.progress.timer(10*60*1000, self.onRefreshTimer, True) def onRefreshTimer(self): if self.state == "deckBrowser": self.deckBrowser.refresh() elif self.state == "overview": self.overview.refresh() # Permanent libanki hooks ########################################################################## def setupHooks(self): addHook("modSchema", self.onSchemaMod) addHook("remNotes", self.onRemNotes) addHook("odueInvalid", self.onOdueInvalid) addHook("mpvWillPlay", self.onMpvWillPlay) addHook("mpvIdleHook", self.onMpvIdle) self._activeWindowOnPlay = None def onOdueInvalid(self): showWarning(_("""\ Invalid property found on card. Please use Tools>Check Database, \ and if the problem comes up again, please ask on the support site.""")) def _isVideo(self, file): head, ext = os.path.splitext(file.lower()) return ext in (".mp4", ".mov", ".mpg", ".mpeg", ".mkv", ".avi") def onMpvWillPlay(self, file): if not self._isVideo(file): return self._activeWindowOnPlay = self.app.activeWindow() or self._activeWindowOnPlay def onMpvIdle(self): w = self._activeWindowOnPlay if not self.app.activeWindow() and w and not sip.isdeleted(w) and w.isVisible(): w.activateWindow() w.raise_() self._activeWindowOnPlay = None # Log note deletion ########################################################################## def onRemNotes(self, col, nids): path = os.path.join(self.pm.profileFolder(), "deleted.txt") existed = os.path.exists(path) with open(path, "ab") as f: if not existed: f.write(b"nid\tmid\tfields\n") for id, mid, flds in col.db.execute( "select id, mid, flds from notes where id in %s" % ids2str(nids)): fields = splitFields(flds) f.write(("\t".join([str(id), str(mid)] + fields)).encode("utf8")) f.write(b"\n") # Schema modifications ########################################################################## def onSchemaMod(self, arg): 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?""")) # 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) # if an error has directed the user to check the database, # silently clean up any broken reset hooks which distract from # the underlying issue while True: try: self.reset() break except Exception as e: print("swallowed exception in reset hook:", e) continue return ret def onCheckMediaDB(self): self.progress.start(immediate=True) (nohave, unused, warnings) = self.col.media.check() self.progress.finish() # generate report report = "" if warnings: report += "\n".join(warnings) + "\n" if unused: if report: report += "\n\n\n" 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: tooltip(_("No unused or missing files found.")) return # 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) if unused: b = QPushButton(_("Delete Unused Files")) b.setAutoDefault(False) box.addButton(b, QDialogButtonBox.ActionRole) b.clicked.connect( lambda c, u=unused, d=diag: self.deleteUnused(u, d)) box.rejected.connect(diag.reject) diag.setMinimumHeight(400) diag.setMinimumWidth(500) restoreGeom(diag, "checkmediadb") diag.exec_() saveGeom(diag, "checkmediadb") def deleteUnused(self, unused, diag): if not askUser( _("Delete unused media?")): return mdir = self.col.media.dir() for f in unused: path = os.path.join(mdir, f) if os.path.exists(path): send2trash(path) tooltip(_("Deleted.")) diag.close() def onStudyDeck(self): from aqt.studydeck import StudyDeck ret = StudyDeck( self, dyn=True, current=self.col.decks.current()['name']) if ret.name: self.col.decks.select(self.col.decks.id(ret.name)) self.moveToState("overview") def onEmptyCards(self): self.progress.start(immediate=True) cids = self.col.emptyCids() if not cids: self.progress.finish() tooltip(_("No empty cards.")) return report = self.col.emptyCardReport(cids) self.progress.finish() part1 = ngettext("%d card", "%d cards", len(cids)) % len(cids) part1 = _("%s to delete:") % part1 diag, box = showText(part1 + "\n\n" + report, run=False, geomKey="emptyCards") box.addButton(_("Delete Cards"), QDialogButtonBox.AcceptRole) box.button(QDialogButtonBox.Close).setDefault(True) def onDelete(): saveGeom(diag, "emptyCards") QDialog.accept(diag) self.checkpoint(_("Delete Empty")) self.col.remCards(cids) tooltip(ngettext("%d card deleted.", "%d cards deleted.", len(cids)) % len(cids)) self.reset() box.accepted.connect(onDelete) diag.show() # Debugging ###################################################################### def onDebug(self): d = self.debugDiag = QDialog() d.silentlyClose = True frm = aqt.forms.debug.Ui_Dialog() frm.setupUi(d) font = QFontDatabase.systemFont(QFontDatabase.FixedFont) font.setPointSize(frm.text.font().pointSize() + 1) frm.text.setFont(font) frm.log.setFont(font) s = self.debugDiagShort = QShortcut(QKeySequence("ctrl+return"), d) s.activated.connect(lambda: self.onDebugRet(frm)) s = self.debugDiagShort = QShortcut( QKeySequence("ctrl+shift+return"), d) s.activated.connect(lambda: self.onDebugPrint(frm)) s = self.debugDiagShort = QShortcut(QKeySequence("ctrl+l"), d) s.activated.connect(frm.log.clear) s = self.debugDiagShort = QShortcut(QKeySequence("ctrl+shift+l"), d) s.activated.connect(frm.text.clear) d.show() def _captureOutput(self, on): mw = self class Stream: def write(self, data): mw._output += data if on: self._output = "" self._oldStderr = sys.stderr self._oldStdout = sys.stdout s = Stream() sys.stderr = s sys.stdout = s else: sys.stderr = self._oldStderr sys.stdout = self._oldStdout def _debugCard(self): return self.reviewer.card.__dict__ def _debugBrowserCard(self): return aqt.dialogs._dialogs['Browser'][1].card.__dict__ def onDebugPrint(self, frm): cursor = frm.text.textCursor() position = cursor.position() cursor.select(QTextCursor.LineUnderCursor) line = cursor.selectedText() pfx, sfx = "pp(", ")" if not line.startswith(pfx): line = "{}{}{}".format(pfx, line, sfx) cursor.insertText(line) cursor.setPosition(position + len(pfx)) frm.text.setTextCursor(cursor) self.onDebugRet(frm) def onDebugRet(self, frm): import pprint, traceback text = frm.text.toPlainText() card = self._debugCard bcard = self._debugBrowserCard mw = self pp = pprint.pprint self._captureOutput(True) try: # pylint: disable=exec-used exec(text) except: self._output += traceback.format_exc() self._captureOutput(False) buf = "" for c, line in enumerate(text.strip().split("\n")): if c == 0: buf += ">>> %s\n" % line else: buf += "... %s\n" % line try: frm.log.appendPlainText(buf + (self._output or "<no output>")) except UnicodeDecodeError: frm.log.appendPlainText(_("<non-unicode text>")) frm.log.ensureCursorVisible() # System specific code ########################################################################## def setupSystemSpecific(self): self.hideMenuAccels = False if isMac: # mac users expect a minimize option self.minimizeShortcut = QShortcut("Ctrl+M", self) self.minimizeShortcut.activated.connect(self.onMacMinimize) self.hideMenuAccels = True self.maybeHideAccelerators() self.hideStatusTips() elif isWin: # make sure ctypes is bundled from ctypes import windll, wintypes _dummy = windll _dummy = wintypes def maybeHideAccelerators(self, tgt=None): if not self.hideMenuAccels: return tgt = tgt or self for action in tgt.findChildren(QAction): txt = str(action.text()) m = re.match(r"^(.+)\(&.+\)(.+)?", 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) # Single instance support ########################################################################## def setupAppMsg(self): self.app.appMsg.connect(self.onAppMsg) def onAppMsg(self, buf): if self.state == "startup": # try again in a second return self.progress.timer(1000, lambda: self.onAppMsg(buf), False) elif self.state == "profileManager": # can't raise window while in profile manager if buf == "raise": return self.pendingImport = buf return tooltip(_("Deck will be imported when a profile is opened.")) if not self.interactiveState() or self.progress.busy(): # we can't raise the main window while in profile dialog, syncing, etc if buf != "raise": showInfo(_("""\ Please ensure a profile is open and Anki is not busy, then try again."""), parent=None) return # raise window if isWin: # on windows we can raise the window by minimizing and restoring self.showMinimized() self.setWindowState(Qt.WindowActive) self.showNormal() else: # on osx we can raise the window. on unity the icon in the tray will just flash. self.activateWindow() self.raise_() if buf == "raise": return # import self.handleImport(buf) # GC ########################################################################## # ensure gc runs in main thread def setupDialogGC(self, obj): obj.finished.connect(lambda: self.gcWindow(obj)) def gcWindow(self, obj): obj.deleteLater() self.progress.timer(1000, self.doGC, False, requiresCollection=False) def disableGC(self): gc.collect() gc.disable() def doGC(self): assert not self.progress.inDB gc.collect() # Crash log ########################################################################## def setupCrashLog(self): p = os.path.join(self.pm.base, "crash.log") self._crashLog = open(p, "ab", 0) faulthandler.enable(self._crashLog) # Media server ########################################################################## def setupMediaServer(self): self.mediaServer = aqt.mediasrv.MediaServer(self) self.mediaServer.start() def baseHTML(self): return '<base href="%s">' % self.serverURL() def serverURL(self): return "http://127.0.0.1:%d/" % self.mediaServer.getPort()
class AnkiQt(QMainWindow): col: _Collection pm: ProfileManagerType web: aqt.webview.AnkiWebView bottomWeb: aqt.webview.AnkiWebView def __init__( self, app: QApplication, profileManager: ProfileManagerType, opts: Namespace, args: List[Any], ) -> None: QMainWindow.__init__(self) self.state = "startup" self.opts = opts self.col: Optional[_Collection] = None self.taskman = TaskManager() self.media_syncer = MediaSyncer(self) aqt.mw = self self.app = app self.pm = profileManager # init rest of app self.safeMode = self.app.queryKeyboardModifiers() & Qt.ShiftModifier try: self.setupUI() self.setupAddons(args) except: showInfo(_("Error during startup:\n%s") % traceback.format_exc()) sys.exit(1) # must call this after ui set up if self.safeMode: tooltip( _("Shift key was held down. Skipping automatic " "syncing and add-on loading.")) # were we given a file to import? if args and args[0] and not self._isAddon(args[0]): self.onAppMsg(args[0]) # Load profile in a timer so we can let the window finish init and not # close on profile load error. if isWin: fn = self.setupProfileAfterWebviewsLoaded else: fn = self.setupProfile self.progress.timer(10, fn, False, requiresCollection=False) def setupUI(self) -> None: self.col = None self.setupCrashLog() self.disableGC() self.setupAppMsg() self.setupKeys() self.setupThreads() self.setupMediaServer() self.setupSound() self.setupSpellCheck() self.setupStyle() self.setupMainWindow() self.setupSystemSpecific() self.setupMenus() self.setupProgress() self.setupErrorHandler() self.setupSignals() self.setupAutoUpdate() self.setupHooks() self.setup_timers() self.updateTitleBar() # screens self.setupDeckBrowser() self.setupOverview() self.setupReviewer() def setupProfileAfterWebviewsLoaded(self): for w in (self.web, self.bottomWeb): if not w._domDone: self.progress.timer( 10, self.setupProfileAfterWebviewsLoaded, False, requiresCollection=False, ) return else: w.requiresCol = True self.setupProfile() # Profiles ########################################################################## class ProfileManager(QMainWindow): onClose = pyqtSignal() closeFires = True def closeEvent(self, evt): if self.closeFires: self.onClose.emit() evt.accept() def closeWithoutQuitting(self): self.closeFires = False self.close() self.closeFires = True def setupProfile(self) -> None: if self.pm.meta["firstRun"]: # load the new deck user profile self.pm.load(self.pm.profiles()[0]) self.pm.meta["firstRun"] = False self.pm.save() self.pendingImport: Optional[str] = None self.restoringBackup = False # 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: self.pm.load(profs[0]) if not self.pm.name: self.showProfileManager() else: self.loadProfile() def showProfileManager(self) -> None: self.pm.profile = None self.state = "profileManager" d = self.profileDiag = self.ProfileManager() f = self.profileForm = aqt.forms.profiles.Ui_MainWindow() f.setupUi(d) f.login.clicked.connect(self.onOpenProfile) f.profiles.itemDoubleClicked.connect(self.onOpenProfile) f.openBackup.clicked.connect(self.onOpenBackup) f.quit.clicked.connect(d.close) d.onClose.connect(self.cleanupAndExit) f.add.clicked.connect(self.onAddProfile) f.rename.clicked.connect(self.onRenameProfile) f.delete_2.clicked.connect(self.onRemProfile) f.profiles.currentRowChanged.connect(self.onProfileRowChange) f.statusbar.setVisible(False) # enter key opens profile QShortcut(QKeySequence("Return"), d, activated=self.onOpenProfile) # type: ignore self.refreshProfilesList() # raise first, for osx testing d.show() d.activateWindow() d.raise_() def refreshProfilesList(self) -> None: f = self.profileForm f.profiles.clear() profs = self.pm.profiles() f.profiles.addItems(profs) try: idx = profs.index(self.pm.name) except: idx = 0 f.profiles.setCurrentRow(idx) def onProfileRowChange(self, n: int) -> None: if n < 0: # called on .clear() return name = self.pm.profiles()[n] f = self.profileForm self.pm.load(name) def openProfile(self): name = self.pm.profiles()[self.profileForm.profiles.currentRow()] return self.pm.load(name) def onOpenProfile(self) -> None: self.profileDiag.hide() # code flow is confusing here - if load fails, profile dialog # will be shown again self.loadProfile(self.profileDiag.closeWithoutQuitting) def profileNameOk(self, str): return not checkInvalidFilename(str) def onAddProfile(self): name = getOnlyText(_("Name:")).strip() if name: if name in self.pm.profiles(): return showWarning(_("Name exists.")) if not self.profileNameOk(name): return self.pm.create(name) self.pm.name = name self.refreshProfilesList() def onRenameProfile(self): name = getOnlyText(_("New name:"), default=self.pm.name).strip() if not name: return if name == self.pm.name: return if name in self.pm.profiles(): return showWarning(_("Name exists.")) if not self.profileNameOk(name): return 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.")) # sure? if not askUser( _("""\ All cards, notes, and media for this profile will be deleted. \ Are you sure?"""), msgfunc=QMessageBox.warning, defaultno=True, ): return self.pm.remove(self.pm.name) self.refreshProfilesList() def onOpenBackup(self): if not askUser( _("""\ Replace your collection with an earlier backup?"""), msgfunc=QMessageBox.warning, defaultno=True, ): return def doOpen(path): self._openBackup(path) getFile( self.profileDiag, _("Revert to backup"), cb=doOpen, filter="*.colpkg", dir=self.pm.backupFolder(), ) def _openBackup(self, path): try: # move the existing collection to the trash, as it may not open self.pm.trashCollection() except: showWarning( _("Unable to move existing file to trash - please try restarting your computer." )) return self.pendingImport = path self.restoringBackup = True showInfo( _("""\ Automatic syncing and backups have been disabled while restoring. To enable them again, \ close the profile or restart Anki.""")) self.onOpenProfile() def loadProfile(self, onsuccess: Optional[Callable] = None) -> None: self.maybeAutoSync() if not self.loadCollection(): return self.maybe_auto_sync_media() self.pm.apply_profile_options() # show main window if self.pm.profile["mainWindowState"]: restoreGeom(self, "mainWindow") restoreState(self, "mainWindow") # titlebar self.setWindowTitle(self.pm.name + " - Anki") # show and raise window for osx self.show() self.activateWindow() self.raise_() # import pending? if self.pendingImport: if self._isAddon(self.pendingImport): self.installAddon(self.pendingImport) else: self.handleImport(self.pendingImport) self.pendingImport = None gui_hooks.profile_did_open() if onsuccess: onsuccess() def unloadProfile(self, onsuccess: Callable) -> None: def callback(): self._unloadProfile() onsuccess() # start media sync if not already running self.maybe_auto_sync_media() gui_hooks.profile_will_close() self.unloadCollection(callback) def _unloadProfile(self) -> None: self.pm.profile["mainWindowGeom"] = self.saveGeometry() self.pm.profile["mainWindowState"] = self.saveState() self.pm.save() self.hide() self.restoringBackup = False # at this point there should be no windows left self._checkForUnclosedWidgets() self.maybeAutoSync(True) def _checkForUnclosedWidgets(self) -> None: for w in self.app.topLevelWidgets(): if w.isVisible(): # windows with this property are safe to close immediately if getattr(w, "silentlyClose", None): w.close() else: print("Window should have been closed: {}".format(w)) def unloadProfileAndExit(self) -> None: self.unloadProfile(self.cleanupAndExit) def unloadProfileAndShowProfileManager(self): self.unloadProfile(self.showProfileManager) def cleanupAndExit(self) -> None: self.errorHandler.unload() self.mediaServer.shutdown() self.app.exit(0) # Sound/video ########################################################################## def setupSound(self) -> None: aqt.sound.setup_audio(self.taskman, self.pm.base) def _add_play_buttons(self, text: str) -> str: "Return card text with play buttons added, or stripped." if self.pm.profile.get("showPlayButtons", True): return aqt.sound.av_refs_to_play_icons(text) else: return anki.sound.strip_av_refs(text) def prepare_card_text_for_display(self, text: str) -> str: text = self.col.media.escapeImages(text) text = self._add_play_buttons(text) return text # Collection load/unload ########################################################################## def loadCollection(self) -> bool: try: return self._loadCollection() except Exception as e: showWarning( tr(TR.ERRORS_UNABLE_OPEN_COLLECTION) + "\n" + traceback.format_exc()) # clean up open collection if possible if self.col: try: self.col.close(save=False) except: pass self.col = None # return to profile manager self.hide() self.showProfileManager() return False def _loadCollection(self) -> bool: cpath = self.pm.collectionPath() self.col = Collection(cpath, log=True) self.setEnabled(True) self.progress.setupDB(self.col.db) self.maybeEnableUndo() self.moveToState("deckBrowser") return True def unloadCollection(self, onsuccess: Callable) -> None: def callback(): self.setEnabled(False) self._unloadCollection() onsuccess() self.closeAllWindows(callback) def _unloadCollection(self) -> None: if not self.col: return if self.restoringBackup: label = _("Closing...") else: label = _("Backing Up...") self.progress.start(label=label, immediate=True) corrupt = False try: self.maybeOptimize() if not devMode: corrupt = self.col.db.scalar("pragma integrity_check") != "ok" except: corrupt = True try: self.col.close() except: corrupt = True finally: self.col = None if corrupt: showWarning( _("Your collection file appears to be corrupt. \ This can happen when the file is copied or moved while Anki is open, or \ when the collection is stored on a network or cloud drive. If problems \ persist after restarting your computer, please open an automatic backup \ from the profile screen.")) if not corrupt and not self.restoringBackup: self.backup() self.progress.finish() # Backup and auto-optimize ########################################################################## class BackupThread(Thread): def __init__(self, path, data): Thread.__init__(self) self.path = path self.data = data # create the file in calling thread to ensure the same # file is not created twice open(self.path, "wb").close() def run(self): z = zipfile.ZipFile(self.path, "w", zipfile.ZIP_DEFLATED) z.writestr("collection.anki2", self.data) z.writestr("media", "{}") z.close() def backup(self) -> None: nbacks = self.pm.profile["numBackups"] if not nbacks or devMode: return dir = self.pm.backupFolder() path = self.pm.collectionPath() # do backup fname = time.strftime("backup-%Y-%m-%d-%H.%M.%S.colpkg", time.localtime(time.time())) newpath = os.path.join(dir, fname) with open(path, "rb") as f: data = f.read() b = self.BackupThread(newpath, data) b.start() # find existing backups backups = [] for file in os.listdir(dir): # only look for new-style format m = re.match(r"backup-\d{4}-\d{2}-.+.colpkg", file) if not m: continue backups.append(file) backups.sort() # remove old ones while len(backups) > nbacks: fname = backups.pop(0) path = os.path.join(dir, fname) os.unlink(path) gui_hooks.backup_did_complete() def maybeOptimize(self) -> None: # have 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: str, *args) -> None: # print("-> move from", self.state, "to", state) oldState = self.state or "dummy" cleanup = getattr(self, "_" + oldState + "Cleanup", None) if cleanup: # pylint: disable=not-callable cleanup(state) self.clearStateShortcuts() self.state = state gui_hooks.state_will_change(state, oldState) getattr(self, "_" + state + "State")(oldState, *args) if state != "resetRequired": self.bottomWeb.show() gui_hooks.state_did_change(state, oldState) def _deckBrowserState(self, oldState: str) -> None: self.maybe_check_for_addon_updates() self.deckBrowser.show() def _colLoadingState(self, oldState) -> None: "Run once, when col is loaded." self.enableColMenuItems() # ensure cwd is set if media dir exists self.col.media.dir() gui_hooks.collection_did_load(self.col) self.moveToState("overview") def _selectedDeck(self) -> Optional[Dict[str, Any]]: did = self.col.decks.selected() if not self.col.decks.nameOrNone(did): showInfo(_("Please select a deck.")) return None return self.col.decks.get(did) def _overviewState(self, oldState: str) -> None: if not self._selectedDeck(): return self.moveToState("deckBrowser") self.col.reset() self.overview.show() def _reviewState(self, oldState): self.reviewer.show() def _reviewCleanup(self, newState): if newState != "resetRequired" and newState != "review": self.reviewer.cleanup() # Resetting state ########################################################################## def reset(self, guiOnly: bool = False) -> None: "Called for non-trivial edits. Rebuilds queue and updates UI." if self.col: if not guiOnly: self.col.reset() gui_hooks.state_did_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.interactiveState(): self.moveToState("resetRequired") def interactiveState(self): "True if not in profile manager, syncing, etc." return self.state in ("overview", "review", "deckBrowser") def maybeReset(self) -> None: self.autosave() if self.state == "resetRequired": self.state = self.returnState self.reset() def delayedMaybeReset(self): # if we redraw the page in a button click event it will often crash on # windows self.progress.timer(100, self.maybeReset, False) def _resetRequiredState(self, oldState: str) -> None: if oldState != "resetRequired": self.returnState = oldState if self.resetModal: # we don't have to change the webview, as we have a covering window return web_context = ResetRequired(self) self.web.set_bridge_command(lambda url: self.delayedMaybeReset(), web_context) i = _("Waiting for editing to finish.") b = self.button("refresh", _("Resume Now"), id="resume") self.web.stdHtml( """ <center><div style="height: 100%%"> <div style="position:relative; vertical-align: middle;"> %s<br><br> %s</div></div></center> <script>$('#resume').focus()</script> """ % (i, b), context=web_context, ) self.bottomWeb.hide() self.web.setFocus() # HTML helpers ########################################################################## def button( self, link: str, name: str, key: Optional[str] = None, class_: str = "", id: str = "", extra: str = "", ) -> str: class_ = "but " + class_ if key: key = _("Shortcut key: %s") % key else: key = "" return """ <button id="%s" class="%s" onclick="pycmd('%s');return false;" title="%s" %s>%s</button>""" % ( id, class_, link, key, extra, name, ) # Main window setup ########################################################################## def setupMainWindow(self) -> None: # main window self.form = aqt.forms.main.Ui_MainWindow() self.form.setupUi(self) # toolbar tweb = self.toolbarWeb = aqt.webview.AnkiWebView(title="top toolbar") tweb.setFocusPolicy(Qt.WheelFocus) self.toolbar = aqt.toolbar.Toolbar(self, tweb) self.toolbar.draw() # main area self.web = aqt.webview.AnkiWebView(title="main webview") self.web.setFocusPolicy(Qt.WheelFocus) self.web.setMinimumWidth(400) # bottom area sweb = self.bottomWeb = aqt.webview.AnkiWebView(title="bottom toolbar") 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) # force webengine processes to load before cwd is changed if isWin: for o in self.web, self.bottomWeb: o.requiresCol = False o._domReady = False o._page.setContent(bytes("", "ascii")) def closeAllWindows(self, onsuccess: Callable) -> None: aqt.dialogs.closeAll(onsuccess) # Components ########################################################################## def setupSignals(self) -> None: signal.signal(signal.SIGINT, self.onSigInt) def onSigInt(self, signum, frame): # interrupt any current transaction and schedule a rollback & quit if self.col: self.col.db.interrupt() def quit(): self.col.db.rollback() self.close() self.progress.timer(100, quit, False) def setupProgress(self) -> None: self.progress = aqt.progress.ProgressManager(self) def setupErrorHandler(self) -> None: import aqt.errors self.errorHandler = aqt.errors.ErrorHandler(self) def setupAddons(self, args: Optional[List]) -> None: import aqt.addons self.addonManager = aqt.addons.AddonManager(self) if args and args[0] and self._isAddon(args[0]): self.installAddon(args[0], startup=True) if not self.safeMode: self.addonManager.loadAddons() self.maybe_check_for_addon_updates() def maybe_check_for_addon_updates(self): last_check = self.pm.last_addon_update_check() elap = intTime() - last_check if elap > 86_400: check_and_prompt_for_updates(self, self.addonManager, self.on_updates_installed) self.pm.set_last_addon_update_check(intTime()) def on_updates_installed(self, log: List[DownloadLogEntry]) -> None: if log: show_log_to_user(self, log) def setupSpellCheck(self) -> None: os.environ["QTWEBENGINE_DICTIONARIES_PATH"] = os.path.join( self.pm.base, "dictionaries") def setupThreads(self) -> None: self._mainThread = QThread.currentThread() def inMainThread(self) -> bool: return self._mainThread == QThread.currentThread() def setupDeckBrowser(self) -> None: from aqt.deckbrowser import DeckBrowser self.deckBrowser = DeckBrowser(self) def setupOverview(self) -> None: from aqt.overview import Overview self.overview = Overview(self) def setupReviewer(self) -> None: from aqt.reviewer import Reviewer self.reviewer = Reviewer(self) # Syncing ########################################################################## # expects a current profile and a loaded collection; reloads # collection after sync completes def onSync(self): if self.media_syncer.is_syncing(): self.media_syncer.show_sync_log() else: self.unloadCollection(self._onSync) def _onSync(self): self._sync() if not self.loadCollection(): return self.media_syncer.start() # expects a current profile, but no collection loaded def maybeAutoSync(self, closing=False) -> None: if (not self.pm.profile["syncKey"] or not self.pm.profile["autoSync"] or self.safeMode or self.restoringBackup): return # ok to sync self._sync() # if media still syncing at this point, pop up progress diag if closing: self.media_syncer.show_diag_until_finished() def maybe_auto_sync_media(self) -> None: if not self.pm.profile[ "autoSync"] or self.safeMode or self.restoringBackup: return self.media_syncer.start() def _sync(self): from aqt.sync import SyncManager self.state = "sync" self.syncer = SyncManager(self, self.pm) self.syncer.sync() # Tools ########################################################################## def raiseMain(self): if not self.app.activeWindow(): # make sure window is shown self.setWindowState(self.windowState() & ~Qt.WindowMinimized) return True def setupStyle(self) -> None: theme_manager.night_mode = self.pm.night_mode() theme_manager.apply_style(self.app) # Key handling ########################################################################## def setupKeys(self) -> None: globalShortcuts = [ ("Ctrl+:", self.onDebug), ("d", lambda: self.moveToState("deckBrowser")), ("s", self.onStudyKey), ("a", self.onAddCard), ("b", self.onBrowse), ("t", self.onStats), ("y", self.onSync), ] self.applyShortcuts(globalShortcuts) self.stateShortcuts: Sequence[Tuple[str, Callable]] = [] def applyShortcuts( self, shortcuts: Sequence[Tuple[str, Callable]]) -> List[QShortcut]: qshortcuts = [] for key, fn in shortcuts: scut = QShortcut(QKeySequence(key), self, activated=fn) # type: ignore scut.setAutoRepeat(False) qshortcuts.append(scut) return qshortcuts def setStateShortcuts(self, shortcuts: List[Tuple[str, Callable]]) -> None: gui_hooks.state_shortcuts_will_change(self.state, shortcuts) # legacy hook runHook(self.state + "StateShortcuts", shortcuts) self.stateShortcuts = self.applyShortcuts(shortcuts) def clearStateShortcuts(self) -> None: for qs in self.stateShortcuts: sip.delete(qs) self.stateShortcuts = [] def onStudyKey(self) -> None: if self.state == "overview": self.col.startTimebox() self.moveToState("review") else: self.moveToState("overview") # App exit ########################################################################## def closeEvent(self, event: QCloseEvent) -> None: if self.state == "profileManager": # if profile manager active, this event may fire via OS X menu bar's # quit option self.profileDiag.close() event.accept() else: # ignore the event for now, as we need time to clean up event.ignore() self.unloadProfileAndExit() # Undo & autosave ########################################################################## def onUndo(self) -> None: n = self.col.undoName() if not n: return cid = self.col.undo() if cid and self.state == "review": card = self.col.getCard(cid) self.col.sched.reset() self.reviewer.cardQueue.append(card) self.reviewer.nextCard() gui_hooks.review_did_undo(cid) else: self.reset() tooltip(_("Reverted to state prior to '%s'.") % n.lower()) gui_hooks.state_did_revert(n) self.maybeEnableUndo() def maybeEnableUndo(self) -> None: if self.col and self.col.undoName(): self.form.actionUndo.setText(_("Undo %s") % self.col.undoName()) self.form.actionUndo.setEnabled(True) gui_hooks.undo_state_did_change(True) else: self.form.actionUndo.setText(_("Undo")) self.form.actionUndo.setEnabled(False) gui_hooks.undo_state_did_change(False) def checkpoint(self, name): self.col.save(name) self.maybeEnableUndo() def autosave(self) -> None: saved = self.col.autosave() self.maybeEnableUndo() if saved: self.doGC() # Other menu operations ########################################################################## def onAddCard(self) -> None: aqt.dialogs.open("AddCards", self) def onBrowse(self) -> None: aqt.dialogs.open("Browser", self) def onEditCurrent(self): aqt.dialogs.open("EditCurrent", self) def onDeckConf(self, deck=None): if not deck: deck = self.col.decks.current() if deck["dyn"]: import aqt.dyndeckconf aqt.dyndeckconf.DeckConf(self, deck=deck) else: import aqt.deckconf aqt.deckconf.DeckConf(self, deck) def onOverview(self): self.col.reset() self.moveToState("overview") def onStats(self): deck = self._selectedDeck() if not deck: return aqt.dialogs.open("DeckStats", self) def onPrefs(self): aqt.dialogs.open("Preferences", self) def onNoteTypes(self): import aqt.models aqt.models.Models(self, self, fromMain=True) def onAbout(self): aqt.dialogs.open("About", self) def onDonate(self): openLink(aqt.appDonate) def onDocumentation(self): openHelp("") # Importing & exporting ########################################################################## def handleImport(self, path: str) -> None: import aqt.importing if not os.path.exists(path): showInfo(_("Please use File>Import to import this file.")) return None aqt.importing.importFile(self, path) return None def onImport(self): import aqt.importing aqt.importing.onImport(self) def onExport(self, did=None): import aqt.exporting aqt.exporting.ExportDialog(self, did=did) # Installing add-ons from CLI / mimetype handler ########################################################################## def installAddon(self, path: str, startup: bool = False): from aqt.addons import installAddonPackages installAddonPackages( self.addonManager, [path], warn=True, advise_restart=not startup, strictly_modal=startup, parent=None if startup else self, ) # Cramming ########################################################################## def onCram(self, search=""): import aqt.dyndeckconf n = 1 deck = self.col.decks.current() if not search: if not deck["dyn"]: search = 'deck:"%s" ' % deck["name"] decks = self.col.decks.allNames() while _("Filtered Deck %d") % n in decks: n += 1 name = _("Filtered Deck %d") % n 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) self.col.decks.select(deck["id"]) # Menu, title bar & status ########################################################################## def setupMenus(self) -> None: m = self.form m.actionSwitchProfile.triggered.connect( self.unloadProfileAndShowProfileManager) m.actionImport.triggered.connect(self.onImport) m.actionExport.triggered.connect(self.onExport) m.actionExit.triggered.connect(self.close) m.actionPreferences.triggered.connect(self.onPrefs) m.actionAbout.triggered.connect(self.onAbout) m.actionUndo.triggered.connect(self.onUndo) if qtminor < 11: m.actionUndo.setShortcut(QKeySequence("Ctrl+Alt+Z")) m.actionFullDatabaseCheck.triggered.connect(self.onCheckDB) m.actionCheckMediaDatabase.triggered.connect(self.on_check_media_db) m.actionDocumentation.triggered.connect(self.onDocumentation) m.actionDonate.triggered.connect(self.onDonate) m.actionStudyDeck.triggered.connect(self.onStudyDeck) m.actionCreateFiltered.triggered.connect(self.onCram) m.actionEmptyCards.triggered.connect(self.onEmptyCards) m.actionNoteTypes.triggered.connect(self.onNoteTypes) def updateTitleBar(self) -> None: self.setWindowTitle("Anki") # Auto update ########################################################################## def setupAutoUpdate(self) -> None: import aqt.update self.autoUpdate = aqt.update.LatestVersionFinder(self) self.autoUpdate.newVerAvail.connect(self.newVerAvail) # type: ignore self.autoUpdate.newMsg.connect(self.newMsg) # type: ignore self.autoUpdate.clockIsOff.connect(self.clockIsOff) # type: ignore self.autoUpdate.start() def newVerAvail(self, ver): if self.pm.meta.get("suppressUpdate", None) != ver: aqt.update.askAndUpdate(self, ver) def newMsg(self, data): aqt.update.showMessages(self, data) def clockIsOff(self, diff): diffText = ngettext("%s second", "%s seconds", diff) % diff warn = (_("""\ In order to ensure your collection works correctly when moved between \ devices, Anki requires your computer's internal clock to be set correctly. \ The internal clock can be wrong even if your system is showing the correct \ local time. Please go to the time settings on your computer and check the following: - AM/PM - Clock drift - Day, month and year - Timezone - Daylight savings Difference to correct time: %s.""") % diffText) showWarning(warn) self.app.closeAllWindows() # Timers ########################################################################## def setup_timers(self) -> None: # refresh decks every 10 minutes self.progress.timer(10 * 60 * 1000, self.onRefreshTimer, True) # check media sync every 5 minutes self.progress.timer(5 * 60 * 1000, self.on_autosync_timer, True) def onRefreshTimer(self): if self.state == "deckBrowser": self.deckBrowser.refresh() elif self.state == "overview": self.overview.refresh() def on_autosync_timer(self): elap = self.media_syncer.seconds_since_last_sync() # autosync if 15 minutes have elapsed since last sync if elap > 15 * 60: self.maybe_auto_sync_media() # Permanent libanki hooks ########################################################################## def setupHooks(self) -> None: hooks.schema_will_change.append(self.onSchemaMod) hooks.notes_will_be_deleted.append(self.onRemNotes) hooks.card_odue_was_invalid.append(self.onOdueInvalid) gui_hooks.av_player_will_play.append(self.on_av_player_will_play) gui_hooks.av_player_did_end_playing.append( self.on_av_player_did_end_playing) self._activeWindowOnPlay: Optional[QWidget] = None def onOdueInvalid(self): showWarning( _("""\ Invalid property found on card. Please use Tools>Check Database, \ and if the problem comes up again, please ask on the support site.""")) def _isVideo(self, tag: AVTag) -> bool: if isinstance(tag, SoundOrVideoTag): head, ext = os.path.splitext(tag.filename.lower()) return ext in (".mp4", ".mov", ".mpg", ".mpeg", ".mkv", ".avi") return False def on_av_player_will_play(self, tag: AVTag) -> None: "Record active window to restore after video playing." if not self._isVideo(tag): return self._activeWindowOnPlay = self.app.activeWindow( ) or self._activeWindowOnPlay def on_av_player_did_end_playing(self, player: Any) -> None: "Restore window focus after a video was played." w = self._activeWindowOnPlay if not self.app.activeWindow() and w and not sip.isdeleted( w) and w.isVisible(): w.activateWindow() w.raise_() self._activeWindowOnPlay = None # Log note deletion ########################################################################## def onRemNotes(self, col: _Collection, nids: List[int]) -> None: path = os.path.join(self.pm.profileFolder(), "deleted.txt") existed = os.path.exists(path) with open(path, "ab") as f: if not existed: f.write(b"nid\tmid\tfields\n") for id, mid, flds in col.db.execute( "select id, mid, flds from notes where id in %s" % ids2str(nids)): fields = splitFields(flds) f.write( ("\t".join([str(id), str(mid)] + fields)).encode("utf8")) f.write(b"\n") # Schema modifications ########################################################################## def onSchemaMod(self, arg): 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?""")) # 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) # if an error has directed the user to check the database, # silently clean up any broken reset hooks which distract from # the underlying issue while True: try: self.reset() break except Exception as e: print("swallowed exception in reset hook:", e) continue return ret def on_check_media_db(self) -> None: check_media_db(self) def onStudyDeck(self): from aqt.studydeck import StudyDeck ret = StudyDeck(self, dyn=True, current=self.col.decks.current()["name"]) if ret.name: self.col.decks.select(self.col.decks.id(ret.name)) self.moveToState("overview") def onEmptyCards(self): self.progress.start(immediate=True) cids = self.col.emptyCids() cids = gui_hooks.empty_cards_will_be_deleted(cids) if not cids: self.progress.finish() tooltip(_("No empty cards.")) return report = self.col.emptyCardReport(cids) self.progress.finish() part1 = ngettext("%d card", "%d cards", len(cids)) % len(cids) part1 = _("%s to delete:") % part1 diag, box = showText(part1 + "\n\n" + report, run=False, geomKey="emptyCards") box.addButton(_("Delete Cards"), QDialogButtonBox.AcceptRole) box.button(QDialogButtonBox.Close).setDefault(True) def onDelete(): saveGeom(diag, "emptyCards") QDialog.accept(diag) self.checkpoint(_("Delete Empty")) self.col.remCards(cids) tooltip( ngettext("%d card deleted.", "%d cards deleted.", len(cids)) % len(cids)) self.reset() box.accepted.connect(onDelete) diag.show() # Debugging ###################################################################### def onDebug(self): d = self.debugDiag = QDialog() d.silentlyClose = True frm = aqt.forms.debug.Ui_Dialog() frm.setupUi(d) font = QFontDatabase.systemFont(QFontDatabase.FixedFont) font.setPointSize(frm.text.font().pointSize() + 1) frm.text.setFont(font) frm.log.setFont(font) s = self.debugDiagShort = QShortcut(QKeySequence("ctrl+return"), d) s.activated.connect(lambda: self.onDebugRet(frm)) s = self.debugDiagShort = QShortcut(QKeySequence("ctrl+shift+return"), d) s.activated.connect(lambda: self.onDebugPrint(frm)) s = self.debugDiagShort = QShortcut(QKeySequence("ctrl+l"), d) s.activated.connect(frm.log.clear) s = self.debugDiagShort = QShortcut(QKeySequence("ctrl+shift+l"), d) s.activated.connect(frm.text.clear) gui_hooks.debug_console_will_show(d) d.show() def _captureOutput(self, on): mw = self class Stream: def write(self, data): mw._output += data if on: self._output = "" self._oldStderr = sys.stderr self._oldStdout = sys.stdout s = Stream() sys.stderr = s sys.stdout = s else: sys.stderr = self._oldStderr sys.stdout = self._oldStdout def _debugCard(self): return self.reviewer.card.__dict__ def _debugBrowserCard(self): return aqt.dialogs._dialogs["Browser"][1].card.__dict__ def onDebugPrint(self, frm): cursor = frm.text.textCursor() position = cursor.position() cursor.select(QTextCursor.LineUnderCursor) line = cursor.selectedText() pfx, sfx = "pp(", ")" if not line.startswith(pfx): line = "{}{}{}".format(pfx, line, sfx) cursor.insertText(line) cursor.setPosition(position + len(pfx)) frm.text.setTextCursor(cursor) self.onDebugRet(frm) def onDebugRet(self, frm): import pprint, traceback text = frm.text.toPlainText() card = self._debugCard bcard = self._debugBrowserCard mw = self pp = pprint.pprint self._captureOutput(True) try: # pylint: disable=exec-used exec(text) except: self._output += traceback.format_exc() self._captureOutput(False) buf = "" for c, line in enumerate(text.strip().split("\n")): if c == 0: buf += ">>> %s\n" % line else: buf += "... %s\n" % line try: to_append = buf + (self._output or "<no output>") to_append = gui_hooks.debug_console_did_evaluate_python( to_append, text, frm) frm.log.appendPlainText(to_append) except UnicodeDecodeError: to_append = _("<non-unicode text>") to_append = gui_hooks.debug_console_did_evaluate_python( to_append, text, frm) frm.log.appendPlainText(to_append) frm.log.ensureCursorVisible() # System specific code ########################################################################## def setupSystemSpecific(self) -> None: self.hideMenuAccels = False if isMac: # mac users expect a minimize option self.minimizeShortcut = QShortcut("Ctrl+M", self) self.minimizeShortcut.activated.connect( self.onMacMinimize) # type: ignore self.hideMenuAccels = True self.maybeHideAccelerators() self.hideStatusTips() elif isWin: # make sure ctypes is bundled from ctypes import windll, wintypes # type: ignore _dummy = windll _dummy = wintypes def maybeHideAccelerators(self, tgt: Optional[Any] = None) -> None: if not self.hideMenuAccels: return tgt = tgt or self for action in tgt.findChildren(QAction): txt = str(action.text()) m = re.match(r"^(.+)\(&.+\)(.+)?", txt) if m: action.setText(m.group(1) + (m.group(2) or "")) def hideStatusTips(self) -> None: for action in self.findChildren(QAction): action.setStatusTip("") def onMacMinimize(self): self.setWindowState(self.windowState() | Qt.WindowMinimized) # Single instance support ########################################################################## def setupAppMsg(self) -> None: self.app.appMsg.connect(self.onAppMsg) def onAppMsg(self, buf: str) -> Optional[QTimer]: is_addon = self._isAddon(buf) if self.state == "startup": # try again in a second return self.progress.timer(1000, lambda: self.onAppMsg(buf), False, requiresCollection=False) elif self.state == "profileManager": # can't raise window while in profile manager if buf == "raise": return None self.pendingImport = buf if is_addon: msg = _("Add-on will be installed when a profile is opened.") else: msg = _("Deck will be imported when a profile is opened.") return tooltip(msg) if not self.interactiveState() or self.progress.busy(): # we can't raise the main window while in profile dialog, syncing, etc if buf != "raise": showInfo( _("""\ Please ensure a profile is open and Anki is not busy, then try again."""), parent=None, ) return None # raise window if isWin: # on windows we can raise the window by minimizing and restoring self.showMinimized() self.setWindowState(Qt.WindowActive) self.showNormal() else: # on osx we can raise the window. on unity the icon in the tray will just flash. self.activateWindow() self.raise_() if buf == "raise": return None # import / add-on installation if is_addon: self.installAddon(buf) else: self.handleImport(buf) return None def _isAddon(self, buf: str) -> bool: return buf.endswith(self.addonManager.ext) # GC ########################################################################## # ensure gc runs in main thread def setupDialogGC(self, obj: Any) -> None: obj.finished.connect(lambda: self.gcWindow(obj)) def gcWindow(self, obj: Any) -> None: obj.deleteLater() self.progress.timer(1000, self.doGC, False, requiresCollection=False) def disableGC(self) -> None: gc.collect() gc.disable() def doGC(self) -> None: assert not self.progress.inDB gc.collect() # Crash log ########################################################################## def setupCrashLog(self) -> None: p = os.path.join(self.pm.base, "crash.log") self._crashLog = open(p, "ab", 0) faulthandler.enable(self._crashLog) # Media server ########################################################################## def setupMediaServer(self) -> None: self.mediaServer = aqt.mediasrv.MediaServer(self) self.mediaServer.start() def baseHTML(self) -> str: return '<base href="%s">' % self.serverURL() def serverURL(self) -> str: return "http://127.0.0.1:%d/" % self.mediaServer.getPort()
class AnkiQt(QMainWindow): def __init__(self, app, profileManager, args): QMainWindow.__init__(self) self.state = "startup" self.setAcceptDrops(True) aqt.mw = self self.app = app if isWin: self._xpstyle = QStyleFactory.create("WindowsXP") self.app.setStyle(self._xpstyle) self.pm = profileManager # running 2.0 for the first time? self.pm.meta['firstRun'] = False self.pm.save() self.night_mode = None # init rest of app if qtmajor == 4 and qtminor < 8: # can't get modifiers immediately on qt4.7, so no safe mode there self.safeMode = False else: self.safeMode = self.app.queryKeyboardModifiers() & Qt.ShiftModifier self.setupModules() self.setupUI() self.setupAddons() # must call this after ui set up if self.safeMode: tooltip(_("Shift key was held down. Skipping automatic " "syncing and add-on loading.")) # were we given a file to import? if args and args[0]: self.onAppMsg(args[0]) # Load profile in a timer so we can let the window finish init and not # close on profile load error. if isMac and qtmajor >= 5: self.show() self.progress.timer(10, self.setupProfile, False) def setupUI(self): try: self.col = None self.setupAppMsg() self.setupKeys() self.setupThreads() self.setupFonts() self.setupMainWindow() self.setupSystemSpecific() self.setupStyle() self.setupMenus() self.setupProgress() self.setupErrorHandler() self.setupSignals() # self.setupAutoUpdate() self.setupHooks() self.setupRefreshTimer() self.updateTitleBar() # managers self.setupManagers() # screens self.setupDeckBrowser() self.setupOverview() self.setupReviewer() except: showInfo(_("Error loading UI:\n%s") % traceback.format_exc()) sys.exit(1) # Profiles ########################################################################## def setupProfile(self): self.pendingImport = None self.restoringBackup = False # 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): self.state = "profileManager" 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.profiles, SIGNAL("itemDoubleClicked(QListWidgetItem*)"), 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()) self.refreshProfilesList() # raise first, for osx testing d.show() d.activateWindow() d.raise_() d.exec_() def refreshProfilesList(self): f = self.profileForm f.profiles.clear() profs = self.pm.profiles() f.profiles.addItems(profs) try: idx = profs.index(self.pm.name) except: idx = 0 f.profiles.setCurrentRow(idx) def onProfileRowChange(self, n): if n < 0: # called on .clear() return name = self.pm.profiles()[n] f = self.profileForm self.pm.load(name) def openProfile(self): name = self.pm.profiles()[self.profileForm.profiles.currentRow()] return self.pm.load(name) def onOpenProfile(self): if not self.openProfile(): # showWarning(_("Invalid password.")) return self.profileDiag.close() self.loadProfile() return True def profileNameOk(self, str): return not checkInvalidFilename(str) def onAddProfile(self): name = getOnlyText(_("Name:")) if name: name = name.strip() if name in self.pm.profiles(): return showWarning(_("Name exists.")) if not self.profileNameOk(name): return self.pm.create(name) self.pm.name = 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 self.profileNameOk(name): return 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() # titlebar self.setWindowTitle("Anki - " + self.pm.name) # show and raise window for osx self.show() self.activateWindow() self.raise_() # maybe sync (will load DB) if self.pendingImport and os.path.basename( self.pendingImport).startswith("backup-"): # skip sync when importing a backup self.loadCollection() else: self.onSync(auto=True) # import pending? if self.pendingImport: if self.pm.profile['key']: showInfo(_("""\ To import into a password protected profile, please open the profile before attempting to import.""")) else: self.handleImport(self.pendingImport) self.pendingImport = None runHook("profileLoaded") def unloadProfile(self, browser=True): if not self.pm.profile: # already unloaded return runHook("unloadProfile") if not self.unloadCollection(): return self.state = "profileManager" 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() self.restoringBackup = False if browser: self.showProfileManager() def onSwitchProfile(self): self.form.actionSwitchProfile.setDisabled(True) self.unloadProfile() self.form.actionSwitchProfile.setDisabled(False) # Collection load/unload ########################################################################## def loadCollection(self): cpath = self.pm.collectionPath() try: self.col = Collection(cpath, log=True) except anki.db.DBError: # warn user showWarning(_("""\ Your collection is corrupt. Please create a new profile, then \ see the manual for how to restore from an automatic backup. Debug info: """)+traceback.format_exc()) self.unloadProfile() except Exception as e: # the custom exception handler won't catch these if we immediately # unload, so we have to manually handle it if "invalidColVersion" == str(e): showWarning("""\ This profile requires a newer version of Anki to open. Did you forget to use the Downgrade button prior to switching Anki versions?""") sys.exit(1) if "invalidTempFolder" in repr(str(e)): showWarning(self.errorHandler.tempFolderMsg()) self.unloadProfile() return self.unloadProfile() raise self.progress.setupDB(self.col.db) self.col.db._db.create_function( "filterTxtSearch", 1, noOpTxtSearch ) self.maybeEnableUndo() self.moveToState("deckBrowser") def unloadCollection(self): """ Unload the collection. This unloads a collection if there is one and returns True if there is no collection after the call. (Because the unload worked or because there was no collection to start with.) """ if self.col: if not self.closeAllCollectionWindows(): return self.progress.start(immediate=True) corrupt = False try: self.maybeOptimize() except: corrupt = True if not corrupt: if os.getenv("ANKIDEV", 0): corrupt = False else: corrupt = self.col.db.scalar("pragma integrity_check") != "ok" if corrupt: showWarning(_("Your collection file appears to be corrupt. \ This can happen when the file is copied or moved while Anki is open, or \ when the collection is stored on a network or cloud drive. Please see \ the manual for information on how to restore from an automatic backup.")) self.col.close() self.col = None if not corrupt: self.backup() self.progress.finish() return True # Backup and auto-optimize ########################################################################## def backup(self): if os.getenv("ANKIDEV", 0): print("dev mode, no backups") return nbacks = self.pm.profile['numBackups'] if not nbacks: print("Backup disabled") return if not self.form.actionSwitchProfile.isEnabled(): if self.pm.profile.get( 'ccbc.skipBackupOnSwitchProfile', False ): print("Backup skipped on switch profile") return elif self.form.actionExit.isEnabled(): if self.pm.profile.get( 'ccbc.skipBackupOnQuickExit', False ): print("Backup skipped on quick exit") return if self.pm.profile.get('compressBackups', True): zipStorage = zipfile.ZIP_DEFLATED else: zipStorage = zipfile.ZIP_STORED # print("Backup collection started...") dir = self.pm.backupFolder() path = self.pm.collectionPath() # find existing backups backups = [] for file in os.listdir(dir): m = re.search("backup-(\d+).apkg", file) if not m: # unknown file continue backups.append((int(m.group(1)), file)) backups.sort() # get next num n = backups[-1][0] + 1 if backups else 1 # do backup newpath = os.path.join(dir, "backup-%d.apkg" % n) z = zipfile.ZipFile(newpath, "w", zipStorage) z.write(path, "collection.anki2") z.writestr("media", "{}") z.close() # 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])) print("Backup completed (total:%d)" % n) def maybeOptimize(self): # have two weeks passed? if (intTime() - self.pm.profile['lastOptimize']) < 86400*14: return self.progress.start(label=_("Optimizing..."), immediate=True) try: self.col.optimize() self.pm.profile['lastOptimize'] = intTime() self.pm.save() finally: 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 runHook('beforeStateChange', state, oldState, *args) getattr(self, "_"+state+"State")(oldState, *args) runHook('afterStateChange', 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 _selectedDeck(self): did = self.col.decks.selected() if not self.col.decks.nameOrNone(did): showInfo(_("Please select a deck.")) return return self.col.decks.get(did) def _overviewState(self, oldState): if not self._selectedDeck(): return self.moveToState("deckBrowser") self.col.reset() self.overview.show() def _reviewState(self, oldState): self.reviewer.show() def _reviewCleanup(self, newState): if newState != "resetRequired" and newState != "review": 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.interactiveState(): self.moveToState("resetRequired") def interactiveState(self): "True if not in profile manager, syncing, etc." return self.state in ("overview", "review", "deckBrowser") def maybeReset(self): self.autosave() if self.state == "resetRequired": self.state = self.returnState self.reset() def delayedMaybeReset(self): # if we redraw the page in a button click event it will often crash on # windows self.progress.timer(100, self.maybeReset, False) 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.delayedMaybeReset()) i = _("Waiting for editing to finish.") b = self.button("refresh", _("Resume Now"), id="resume") css = self.web.bundledCSS("resetRequired.css") #trigger 2.1 addons 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 + css) self.bottomWeb.hide() self.web.setFocus() self.web.eval("$('#resume').focus()") # 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(20+self.fontHeightDelta) 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): return aqt.dialogs.closeAll() def hideAllCollectionWindows(self): aqt.dialogs.hideAll() def showAllCollectionWindows(self): aqt.dialogs.showAll() # Managers setup ########################################################################## def setupManagers(self): from aqt.view import ViewManager self.viewmanager = ViewManager(self) # Components ########################################################################## def setupSignals(self): signal.signal(signal.SIGINT, self.onSigInt) def onSigInt(self, signum, frame): # interrupt any current transaction and schedule a rollback & quit self.col.db.interrupt() def quit(): self.col.db.rollback() self.close() self.progress.timer(100, quit, False) def setupProgress(self): self.progress = aqt.progress.ProgressManager(self) def setupErrorHandler(self): import aqt.errors self.errorHandler = aqt.errors.ErrorHandler(self) def setupAddons(self): if self.safeMode: return try: import aqt.addons self.addonManager = aqt.addons.AddonManager(self) self.addonManager.loadAddons() except: showInfo(_("Error loading addons:\n%s") % traceback.format_exc()) sys.exit(1) def setupModules(self): if self.safeMode: return try: import aqt.modules self.moduleManager = aqt.modules.ModuleManager(self) self.moduleManager.loadModules() except: showInfo(_("Error loading modules:\n%s") % traceback.format_exc()) sys.exit(1) 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) # Syncing ########################################################################## def onSync(self, auto=False, reload=True): if not auto or (self.pm.profile['syncKey'] and self.pm.profile['autoSync'] and not self.safeMode): try: from aqt.sync import SyncManager except: if not auto: showInfo(_("Please install a sync module.")) return if not self.unloadCollection(): return # set a sync state so the refresh timer doesn't fire while deck # unloaded self.state = "sync" self.syncer = SyncManager(self, self.pm) self.syncer.sync() if reload: if not self.col: self.loadCollection() # 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): buf = "" # allow addons to modify the styling buf = runFilter("setupStyle", buf) # allow users to extend styling p = os.path.join(aqt.mw.pm.base, "style.css") if os.path.exists(p): buf += open(p).read() self.setStyleSheet(buf) # Key handling ########################################################################## def setupKeys(self): self.keyHandler = None # debug shortcut self.debugShortcut = QShortcut(QKeySequence("Ctrl+:"), self) self.connect( self.debugShortcut, SIGNAL("activated()"), self.onDebug) 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 = evt.text() if key == "d": self.moveToState("deckBrowser") elif key == "s": if self.state == "overview": self.col.startTimebox() self.moveToState("review") if self.state == "overview": tooltip(_("No cards are due yet.")) 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 closeWarning(self): if not self.pm.profile.get( 'ccbc.warnOnQuickExit', True ): return True return askUser(_("Are you sure you want to exit?")) def onMenuExit(self): self.form.actionExit.setDisabled(True) self.close() self.form.actionExit.setDisabled(False) def closeEvent(self, event): "User hit the X button, etc." if self.state == "profileManager": # if profile manager active, this event may fire via OS X menu bar's # quit option self.profileDiag.close() event.accept() elif self.onClose(force=True): event.accept() else: event.ignore() def onClose(self, force=False): "Called from a shortcut key. Close current active window." aw = self.app.activeWindow() all_gui_closed = False if not aw or aw == self or force: if not self.closeAllCollectionWindows(): return False all_gui_closed = True if self.form.actionExit.isEnabled(): if not self.closeWarning(): return False if all_gui_closed: self.unloadProfile(browser=False) self.app.closeAllWindows() return True aw.close() # Undo & autosave ########################################################################## def onUndo(self): n = self.col.undoName() if not n: return cid = self.col.undo() if cid and self.state == "review": card = self.col.getCard(cid) self.col.sched.reset() self.reviewer.cardQueue.append(card) self.reviewer.nextCard() runHook("revertedCard", cid) else: self.reset() tooltip(_("Reverted to state prior to '%s'.") % n.lower()) runHook("revertedState", n) 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): runHook("checkpoint", name) #allows data tobe flushed self.col.save(name) self.maybeEnableUndo() def autosave(self): self.col.autosave() self.maybeEnableUndo() # Other menu operations ########################################################################## def onAddCard(self): from aqt import addcards name = "AddCards_%d"%addcards.AddCards.unique_id aqt.dialogs._dialogs[name] = [addcards.AddCards, None] instance = aqt.dialogs.open(name, self) addcards.AddCards.unique_id += 1 def onBrowse(self): from aqt import browser rev = self.state=='review' b=aqt.dialogs.open("Browser", self, not rev) if rev: b.form.searchEdit.lineEdit().setText("is:current") b.onSearch() def onStats(self): deck = self._selectedDeck() if not deck: return from aqt import stats aqt.dialogs.open("DeckStats", self) def onEditCurrent(self): from aqt import editcurrent aqt.dialogs.open("EditCurrent", self) def onDeckConf(self, deck=None): if not deck: deck = self.col.decks.current() if deck['dyn']: import aqt.dyndeckconf aqt.dyndeckconf.DeckConf(self, deck=deck) else: import aqt.deckconf aqt.deckconf.DeckConf(self, deck) def onOverview(self): self.col.reset() self.moveToState("overview") def onPrefs(self): import aqt.preferences aqt.preferences.Preferences(self) def onNoteTypes(self): import aqt.models aqt.models.Models(self, self, fromMain=True) # Importing & exporting ########################################################################## def handleImport(self, path): import aqt.importing if not os.path.exists(path): return showInfo(_("Please use File>Import to import this file.")) aqt.importing.importFile(self, path) def onImport(self): import aqt.importing aqt.importing.onImport(self) def onExport(self, did=None): import aqt.exporting aqt.exporting.ExportDialog(self, did=did) # Cramming ########################################################################## def onCram(self, search=""): import aqt.dyndeckconf n = 1 deck = self.col.decks.current() if not search: if not deck['dyn']: search = 'deck:"%s" ' % deck['name'] decks = self.col.decks.allNames() while _("Filtered Deck %d") % n in decks: n += 1 name = _("Filtered Deck %d") % n 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) self.col.decks.select(deck['id']) # Menu, title bar & status ########################################################################## def setupMenus(self): f = self.form f.actionExit.triggered.connect(self.onMenuExit) f.actionBossKey.triggered.connect(self.boss_key) f.actionSwitchProfile.triggered.connect(self.onSwitchProfile) f.actionImport.triggered.connect(self.onImport) f.actionExport.triggered.connect(self.onExport) f.actionPreferences.triggered.connect(self.onPrefs) f.actionUndo.triggered.connect(self.onUndo) f.actionFullDatabaseCheck.triggered.connect(self.onCheckDB) f.actionCheckMediaDatabase.triggered.connect(self.onCheckMediaDB) f.actionStudyDeck.triggered.connect(self.onStudyDeck) f.actionCreateFiltered.triggered.connect(self.onCram) f.actionEmptyCards.triggered.connect(self.onEmptyCards) f.actionNoteTypes.triggered.connect(self.onNoteTypes) def updateTitleBar(self): self.setWindowTitle("CCBC") # Auto update ########################################################################## def setupAutoUpdate(self): pass def newVerAvail(self, ver): pass def newMsg(self, data): aqt.update.showMessages(self, data) def clockIsOff(self, diff): diffText = ngettext("%s second", "%s seconds", diff) % diff warn = _("""\ In order to ensure your collection works correctly when moved between \ devices, Anki requires your computer's internal clock to be set correctly. \ The internal clock can be wrong even if your system is showing the correct \ local time. Please go to the time settings on your computer and check the following: - AM/PM - Clock drift - Day, month and year - Timezone - Daylight savings Difference to correct time: %s.""") % diffText showWarning(warn) self.app.closeAllWindows() # Count refreshing ########################################################################## def setupRefreshTimer(self): # every 10 minutes self.progress.timer(10*60*1000, self.onRefreshTimer, True) def onRefreshTimer(self): if not self.col: return #exiting anki if self.state == "deckBrowser": self.deckBrowser.refresh() elif self.state == "overview": self.overview.refresh() # Permanent libanki hooks ########################################################################## def setupHooks(self): addHook("modSchema", self.onSchemaMod) addHook("remNotes", self.onRemNotes) addHook("odueInvalid", self.onOdueInvalid) def onOdueInvalid(self): showWarning(_("""\ Invalid property found on card. Please use Tools>Check Database, \ and if the problem comes up again, please ask on the support site.""")) # Log note deletion ########################################################################## def onRemNotes(self, col, nids): path = os.path.join(self.pm.profileFolder(), "deleted.txt") existed = os.path.exists(path) with open(path, 'a', encoding='utf-8') as f: if not existed: f.write("nid\tmid\tfields\n") for id, mid, flds in col.db.execute( "select id, mid, flds from notes where id in %s" % ids2str(nids)): fields = splitFields(flds) f.write(("\t".join([str(id), str(mid)] + fields))) f.write("\n") # Schema modifications ########################################################################## def onSchemaMod(self, arg): 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?""")) # Advanced features ########################################################################## def onCheckDB(self): "True if no problems" self.progress.start(immediate=True) try: ret, ok = self.col.fixIntegrity() finally: self.progress.finish() if not ok: showText(ret) else: tooltip(ret) self.reset() return ret def onCheckMediaDB(self): self.progress.start(immediate=True) try: (nohave, unused, invalid) = self.col.media.check() finally: self.progress.finish() # generate report report = "" if invalid: report += _("Invalid encoding; please rename:") report += "\n" + "\n".join(invalid) if unused: numberOfUnusedFilesLabel = len(unused) if report: report += "\n\n\n" report += ngettext("%d file found in media folder not used by any cards:", "%d files found in media folder not used by any cards:", numberOfUnusedFilesLabel) % numberOfUnusedFilesLabel 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: tooltip(_("No unused or missing files found.")) return # 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) if unused: b = QPushButton(_("Delete Unused")) b.setAutoDefault(False) b.clicked.connect(lambda:self.deleteUnused(unused,diag)) box.addButton(b, QDialogButtonBox.ActionRole) b = QPushButton(_("Explore Unused")) b.setAutoDefault(False) b.clicked.connect(lambda:openFolder(self.pm.mediaFolder())) box.addButton(b, QDialogButtonBox.ActionRole) if nohave: b = QPushButton(_("Browse Missing")) b.setAutoDefault(False) b.clicked.connect(lambda:self.browseMissingMedia(nohave,diag)) box.addButton(b, QDialogButtonBox.ActionRole) diag.connect(box, SIGNAL("rejected()"), diag, SLOT("reject()")) diag.setMinimumHeight(400) diag.setMinimumWidth(500) restoreGeom(diag, "checkmediadb") diag.exec_() saveGeom(diag, "checkmediadb") def browseMissingMedia(self, nohave, diag): #dialog blocks browser from being usable diag.close() from aqt import mw, dialogs browser = dialogs.open("Browser", mw, False) browser.form.searchEdit.lineEdit().setText("tag:MissingMedia") browser.onSearchActivated() def deleteUnused(self, unused, diag): if not askUser( _("Delete unused media?")): return mdir = self.col.media.dir() self.progress.start(immediate=True) try: numberOfFilesDeleted = 0 lastProgress = 0 for c, f in enumerate(unused): path = os.path.join(mdir, f) if os.path.exists(path): send2trash(path) numberOfFilesDeleted += 1 now = time.time() if now - lastProgress >= 0.3: numberOfRemainingFilesToBeDeleted = len(unused) - c lastProgress = now label = ngettext( "%d file remaining...", "%d files remaining...", numberOfRemainingFilesToBeDeleted ) % numberOfRemainingFilesToBeDeleted self.progress.update(label) finally: self.progress.finish() tooltip(ngettext( "Deleted %d file.", "Deleted %d files.", numberOfFilesDeleted ) % numberOfFilesDeleted ) diag.close() def onStudyDeck(self): from aqt.studydeck import StudyDeck ret = StudyDeck( self, dyn=True, current=self.col.decks.current()['name']) if ret.name: self.col.decks.select(self.col.decks.id(ret.name)) self.moveToState("overview") def onEmptyCards(self): def browseCards(cids): from aqt import dialogs browser = dialogs.open("Browser", self, False) browser.form.searchEdit.lineEdit().setText("cid:%s"%ids2str(cids)) browser.onSearchActivated() self.progress.start(immediate=True) try: cids = self.col.emptyCids() if not cids: self.progress.finish() tooltip(_("No empty cards.")) return report = self.col.emptyCardReport(cids) finally: self.progress.finish() part1 = ngettext("%d card", "%d cards", len(cids)) % len(cids) part1 = _("%s to delete:") % part1 diag, box = showText(part1 + "\n\n" + report, run=False, geomKey="emptyCards") b = QPushButton(_("Browse Cards")) b.setAutoDefault(False) b.clicked.connect(lambda:browseCards(cids)) box.addButton(b, QDialogButtonBox.ActionRole) box.addButton(_("Delete Cards"), QDialogButtonBox.AcceptRole) box.button(QDialogButtonBox.Close).setDefault(True) from ccbc.plugins.Keep_empty_note.keep_empty_note import onDelete box.accepted.connect(lambda:onDelete(self,diag,cids)) diag.show() # Debugging ###################################################################### def onDebug(self): d = self.debugDiag = QDialog() frm = aqt.forms.debug.Ui_Dialog() frm.setupUi(d) s = self.debugDiagShort = QShortcut(QKeySequence("ctrl+return"), d) self.connect(s, SIGNAL("activated()"), lambda: self.onDebugRet(frm)) s = self.debugDiagShort = QShortcut( QKeySequence("ctrl+shift+return"), d) self.connect(s, SIGNAL("activated()"), lambda: self.onDebugprint(frm)) d.show() def _captureOutput(self, on): mw = self class Stream(object): def write(self, data): mw._output += data if on: self._output = "" self._oldStderr = sys.stderr self._oldStdout = sys.stdout s = Stream() sys.stderr = s sys.stdout = s else: sys.stderr = self._oldStderr sys.stdout = self._oldStdout def _debugCard(self): return self.reviewer.card.__dict__ def _debugBrowserCard(self): return aqt.dialogs._dialogs['Browser'][1].card.__dict__ def onDebugprint(self, frm): frm.text.setPlainText("pp(%s)" % frm.text.toPlainText()) self.onDebugRet(frm) def onDebugRet(self, frm): import pprint, traceback text = frm.text.toPlainText() card = self._debugCard bcard = self._debugBrowserCard mw = self pp = pprint.pprint self._captureOutput(True) try: exec(text) except: self._output += traceback.format_exc() self._captureOutput(False) buf = "" for c, line in enumerate(text.strip().split("\n")): if c == 0: buf += ">>> %s\n" % line else: buf += "... %s\n" % line try: frm.log.appendPlainText(buf + (self._output or "<no output>")) except UnicodeDecodeError: frm.log.appendPlainText(_("<non-unicode text>")) frm.log.ensureCursorVisible() # System specific code ########################################################################## def setupFonts(self): f = QFontInfo(self.font()) ws = QWebSettings.globalSettings() self.fontHeight = f.pixelSize() self.fontFamily = f.family() self.fontHeightDelta = max(0, self.fontHeight - 13) ws.setFontFamily(QWebSettings.StandardFont, self.fontFamily) ws.setFontSize(QWebSettings.DefaultFontSize, self.fontHeight) def setupSystemSpecific(self): self.hideMenuAccels = False if isMac: # mac users expect a minimize option self.minimizeShortcut = QShortcut("Ctrl+M", self) self.connect(self.minimizeShortcut, SIGNAL("activated()"), self.onMacMinimize) self.hideMenuAccels = True self.maybeHideAccelerators() self.hideStatusTips() elif isWin: # make sure ctypes is bundled from ctypes import windll, wintypes _dummy = windll _dummy = wintypes def maybeHideAccelerators(self, tgt=None): if not self.hideMenuAccels: return tgt = tgt or self for action in tgt.findChildren(QAction): txt = 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) # Single instance support ########################################################################## def setupAppMsg(self): self.connect(self.app, SIGNAL("appMsg"), self.onAppMsg) def onAppMsg(self, buf): if self.state == "startup": # try again in a second return self.progress.timer(1000, lambda: self.onAppMsg(buf), False) elif self.state == "profileManager": # can't raise window while in profile manager if buf == "raise": return self.pendingImport = buf return tooltip(_("Deck will be imported when a profile is opened.")) if not self.interactiveState() or self.progress.busy(): # we can't raise the main window while in profile dialog, syncing, etc if buf != "raise": showInfo(_("""\ Please ensure a profile is open and Anki is not busy, then try again."""), parent=None) return # raise window if isWin: # on windows we can raise the window by minimizing and restoring self.showMinimized() self.setWindowState(Qt.WindowActive) self.showNormal() else: # on osx we can raise the window. on unity the icon in the tray will just flash. self.activateWindow() self.raise_() if buf == "raise": return # import # if not isinstance(buf, unicode): # buf = unicode(buf, "utf8", "ignore") self.handleImport(buf) # GC ########################################################################## # ensure gc runs in main thread def setupDialogGC(self, obj): obj.finished.connect(lambda: self.gcWindow(obj)) def gcWindow(self, obj): obj.deleteLater() self.progress.timer(1000, self.doGC, False) def disableGC(self): gc.collect() gc.disable() def doGC(self): assert not self.progress.inDB gc.collect() # Crash log ########################################################################## def setupCrashLog(self): p = os.path.join(self.pm.base, "crash.log") self._crashLog = open(p, "ab", 0) faulthandler.enable(self._crashLog) def onAbout(self): from aqt.utils import supportText, showText addmgr = self.addonManager addons = "\n".join(addmgr.annotatedName(d) for d in addmgr.allAddons()) info = "\n".join((supportText(), "Add-ons:\n\n{}".format(addons))) showText(info) # Handle Drag n Drop ########################################################################## def dragEnterEvent(self, event): mime = event.mimeData() if not mime.hasUrls(): return None for url in mime.urls(): f = url.toLocalFile() _,ext = os.path.splitext(f) ext = ext.lower() if ext == self.addonManager.ext: self.addonManager.onAddonsDialog() #passes the ball to addonManager's dragEnterEvent break # def dropEvent(self, evt): # Nothing drops here, no focus # see aqt.webview # pass # Media server ########################################################################## # def setupMediaServer(self): # self.mediaServer = aqt.mediasrv.MediaServer(self) # self.mediaServer.start() # def baseHTML(self): # return '<base href="%s">' % self.serverURL() # def serverURL(self): # return "http://127.0.0.1:%d/" % self.mediaServer.getPort() # Boss key (privacy mode) ########################################################################## def boss_key(self): cmd = self.pm.profile.get("ccbc.bossCmd","notepad.exe") if not cmd: # TODO: Write gui for this option # Use debugger to set command for ccbc.bossCmd showInfo("No external text editor was set.") return if isWin: cmd = cmd.replace('/','\\') anki.sound.clearAudioQueue() import subprocess, time from anki.utils import tmpdir fname = os.path.join(tmpdir(), "note%d.txt"%time.time()) f = open(fname, "w") f.write(self.pm.profile.get("ccbc.bossText","To Whom It May Concern:")) f.close() runHook("BOSS_KEY", True) self.hideAllCollectionWindows() self.hide() try: self.debugDiag.close() except AttributeError: pass self.pm.save() self.col.autosave() subprocess.call('''%s "%s"'''%(cmd,fname), shell=True) self.show() self.showAllCollectionWindows() runHook("BOSS_KEY", False)