anki/qt/aqt/main.py

1669 lines
53 KiB
Python
Raw Normal View History

2019-02-05 04:59:03 +01:00
# Copyright: Ankitects Pty Ltd and contributors
# -*- coding: utf-8 -*-
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
2020-08-16 18:33:33 +02:00
import enum
2019-12-20 10:19:03 +01:00
import faulthandler
import gc
import os
import re
import signal
import time
import weakref
2019-12-20 10:19:03 +01:00
import zipfile
from argparse import Namespace
from threading import Thread
2020-08-11 22:56:58 +02:00
from typing import Any, Callable, List, Optional, Sequence, TextIO, Tuple, cast
2019-12-20 10:19:03 +01:00
2020-01-24 06:48:40 +01:00
import anki
import aqt
2019-12-20 10:19:03 +01:00
import aqt.mediasrv
import aqt.mpv
import aqt.progress
import aqt.sound
import aqt.stats
2019-12-20 10:19:03 +01:00
import aqt.toolbar
import aqt.webview
from anki import hooks
2020-05-20 09:56:52 +02:00
from anki.collection import Collection
2020-08-11 22:56:58 +02:00
from anki.decks import Deck
2020-01-15 04:49:26 +01:00
from anki.hooks import runHook
2019-03-04 02:58:34 +01:00
from anki.lang import _, ngettext
2020-03-14 00:45:00 +01:00
from anki.rsbackend import RustBackend
from anki.sound import AVTag, SoundOrVideoTag
2019-12-20 10:19:03 +01:00
from anki.utils import devMode, ids2str, intTime, isMac, isWin, splitFields
2020-01-13 05:38:05 +01:00
from aqt import gui_hooks
2020-01-19 02:31:09 +01:00
from aqt.addons import DownloadLogEntry, check_and_prompt_for_updates, show_log_to_user
2020-06-08 12:28:11 +02:00
from aqt.dbcheck import check_db
from aqt.emptycards import show_empty_cards
from aqt.legacy import install_pylib_legacy
2020-02-11 06:51:30 +01:00
from aqt.mediacheck import check_media_db
2020-02-04 02:48:51 +01:00
from aqt.mediasync import MediaSyncer
2019-12-20 10:19:03 +01:00
from aqt.profiles import ProfileManager as ProfileManagerType
from aqt.qt import *
from aqt.qt import sip
2020-05-31 02:53:54 +02:00
from aqt.sync import sync_collection, sync_login
2020-01-19 01:05:37 +01:00
from aqt.taskman import TaskManager
from aqt.theme import theme_manager
2019-12-23 01:34:10 +01:00
from aqt.utils import (
TR,
2019-12-23 01:34:10 +01:00
askUser,
checkInvalidFilename,
getFile,
getOnlyText,
openHelp,
openLink,
restoreGeom,
restoreSplitter,
2019-12-23 01:34:10 +01:00
restoreState,
saveGeom,
saveSplitter,
2019-12-23 01:34:10 +01:00
showInfo,
showWarning,
tooltip,
tr,
2019-12-23 01:34:10 +01:00
)
2013-12-06 05:27:13 +01:00
install_pylib_legacy()
2019-12-20 09:43:52 +01:00
2020-08-16 18:33:33 +02:00
class ResetReason(enum.Enum):
2020-08-16 18:49:51 +02:00
AddCardsAddNote = "addCardsAddNote"
EditCurrentInit = "editCurrentInit"
EditorBridgeCmd = "editorBridgeCmd"
BrowserSetDeck = "browserSetDeck"
BrowserAddTags = "browserAddTags"
BrowserSuspend = "browserSuspend"
BrowserReposition = "browserReposition"
BrowserReschedule = "browserReschedule"
BrowserFindReplace = "browserFindReplace"
BrowserTagDupes = "browserTagDupes"
2020-08-16 18:33:33 +02:00
class ResetRequired:
def __init__(self, mw: AnkiQt):
self.mw = mw
class AnkiQt(QMainWindow):
2020-05-20 09:56:52 +02:00
col: Collection
2019-12-20 09:43:52 +01:00
pm: ProfileManagerType
web: aqt.webview.AnkiWebView
bottomWeb: aqt.webview.AnkiWebView
2019-12-20 09:43:52 +01:00
2019-12-23 01:34:10 +01:00
def __init__(
self,
app: QApplication,
profileManager: ProfileManagerType,
2020-03-14 00:45:00 +01:00
backend: RustBackend,
2019-12-23 01:34:10 +01:00
opts: Namespace,
args: List[Any],
) -> None:
QMainWindow.__init__(self)
2020-03-14 00:45:00 +01:00
self.backend = backend
self.state = "startup"
self.opts = opts
2020-05-20 09:56:52 +02:00
self.col: Optional[Collection] = None
self.taskman = TaskManager(self)
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)
self.finish_ui_setup()
except:
showInfo(_("Error during startup:\n%s") % traceback.format_exc())
sys.exit(1)
2013-05-17 08:32:17 +02:00
# must call this after ui set up
if self.safeMode:
2019-12-23 01:34:10 +01:00
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
def on_window_init():
fn()
gui_hooks.main_window_did_init()
self.progress.timer(10, on_window_init, False, requiresCollection=False)
2019-12-20 09:43:52 +01:00
def setupUI(self) -> None:
self.col = None
2017-01-08 04:38:12 +01:00
self.setupCrashLog()
self.disableGC()
self.setupAppMsg()
self.setupKeys()
self.setupThreads()
self.setupMediaServer()
2017-10-05 05:48:24 +02:00
self.setupSound()
self.setupSpellCheck()
self.setupStyle()
self.setupMainWindow()
self.setupSystemSpecific()
self.setupMenus()
self.setupProgress()
self.setupErrorHandler()
self.setupSignals()
self.setupAutoUpdate()
2013-05-22 05:27:37 +02:00
self.setupHooks()
2020-02-05 03:38:36 +01:00
self.setup_timers()
self.updateTitleBar()
# screens
self.setupDeckBrowser()
self.setupOverview()
self.setupReviewer()
def finish_ui_setup(self) -> None:
"Actions that are deferred until after add-on loading."
self.toolbar.draw()
def setupProfileAfterWebviewsLoaded(self):
for w in (self.web, self.bottomWeb):
if not w._domDone:
2019-12-23 01:34:10 +01:00
self.progress.timer(
10,
self.setupProfileAfterWebviewsLoaded,
False,
requiresCollection=False,
)
return
else:
w.requiresCol = True
self.setupProfile()
def weakref(self) -> AnkiQt:
"Shortcut to create a weak reference that doesn't break code completion."
return weakref.proxy(self) # type: ignore
# Profiles
##########################################################################
class ProfileManager(QMainWindow):
onClose = pyqtSignal()
closeFires = True
def closeEvent(self, evt: QCloseEvent) -> None:
if self.closeFires:
self.onClose.emit() # type: ignore
evt.accept()
def closeWithoutQuitting(self):
self.closeFires = False
self.close()
self.closeFires = True
2019-12-20 09:43:52 +01:00
def setupProfile(self) -> None:
2019-12-23 01:34:10 +01:00
if self.pm.meta["firstRun"]:
# load the new deck user profile
self.pm.load(self.pm.profiles()[0])
2019-12-23 01:34:10 +01:00
self.pm.meta["firstRun"] = False
self.pm.save()
2019-12-20 09:43:52 +01:00
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()
2019-12-20 09:43:52 +01:00
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)
qconnect(f.login.clicked, self.onOpenProfile)
qconnect(f.profiles.itemDoubleClicked, self.onOpenProfile)
qconnect(f.openBackup.clicked, self.onOpenBackup)
qconnect(f.quit.clicked, d.close)
qconnect(d.onClose, self.cleanupAndExit)
qconnect(f.add.clicked, self.onAddProfile)
qconnect(f.rename.clicked, self.onRenameProfile)
qconnect(f.delete_2.clicked, self.onRemProfile)
qconnect(f.profiles.currentRowChanged, self.onProfileRowChange)
f.statusbar.setVisible(False)
qconnect(f.downgrade_button.clicked, self._on_downgrade)
2020-07-17 07:06:14 +02:00
f.downgrade_button.setText(tr(TR.PROFILES_DOWNGRADE_AND_QUIT))
# enter key opens profile
2019-12-23 01:34:10 +01:00
QShortcut(QKeySequence("Return"), d, activated=self.onOpenProfile) # type: ignore
self.refreshProfilesList()
# raise first, for osx testing
d.show()
d.activateWindow()
d.raise_()
2019-12-20 09:43:52 +01:00
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)
2019-12-20 09:43:52 +01:00
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)
2019-12-20 09:43:52 +01:00
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, name: str) -> bool:
return not checkInvalidFilename(name) and name != "addons21"
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?
2019-12-23 01:34:10 +01:00
if not askUser(
_(
"""\
All cards, notes, and media for this profile will be deleted. \
2019-12-23 01:34:10 +01:00
Are you sure?"""
),
msgfunc=QMessageBox.warning,
defaultno=True,
):
return
self.pm.remove(self.pm.name)
self.refreshProfilesList()
def onOpenBackup(self):
2019-12-23 01:34:10 +01:00
if not askUser(
_(
"""\
Replace your collection with an earlier backup?"""
),
msgfunc=QMessageBox.warning,
defaultno=True,
):
return
2019-12-23 01:34:10 +01:00
def doOpen(path):
self._openBackup(path)
2019-12-23 01:34:10 +01:00
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:
2019-12-23 01:34:10 +01:00
showWarning(
_(
"Unable to move existing file to trash - please try restarting your computer."
)
)
return
self.pendingImport = path
self.restoringBackup = True
2019-12-23 01:34:10 +01:00
showInfo(
_(
"""\
Automatic syncing and backups have been disabled while restoring. To enable them again, \
2019-12-23 01:34:10 +01:00
close the profile or restart Anki."""
)
)
self.onOpenProfile()
def _on_downgrade(self):
self.progress.start()
profiles = self.pm.profiles()
def downgrade():
return self.pm.downgrade(profiles)
def on_done(future):
self.progress.finish()
problems = future.result()
if not problems:
showInfo("Profiles can now be opened with an older version of Anki.")
else:
showWarning(
"The following profiles could not be downgraded: {}".format(
", ".join(problems)
)
)
return
self.profileDiag.close()
self.taskman.run_in_background(downgrade, on_done)
2019-12-20 09:43:52 +01:00
def loadProfile(self, onsuccess: Optional[Callable] = None) -> None:
if not self.loadCollection():
return
self.pm.apply_profile_options()
# show main window
2019-12-23 01:34:10 +01:00
if self.pm.profile["mainWindowState"]:
restoreGeom(self, "mainWindow")
restoreState(self, "mainWindow")
2013-05-22 06:04:45 +02:00
# 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
2020-01-15 07:53:24 +01:00
gui_hooks.profile_did_open()
2020-05-31 02:53:54 +02:00
if onsuccess is None:
onsuccess = lambda: None
self.maybe_auto_sync_on_open_close(onsuccess)
2019-12-20 09:43:52 +01:00
def unloadProfile(self, onsuccess: Callable) -> None:
def callback():
self._unloadProfile()
onsuccess()
2020-01-15 07:53:24 +01:00
gui_hooks.profile_will_close()
self.unloadCollection(callback)
2019-12-20 09:43:52 +01:00
def _unloadProfile(self) -> None:
2019-12-23 01:34:10 +01:00
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()
2019-12-20 09:43:52 +01:00
def _checkForUnclosedWidgets(self) -> None:
for w in self.app.topLevelWidgets():
if w.isVisible():
# windows with this property are safe to close immediately
2017-09-08 10:42:26 +02:00
if getattr(w, "silentlyClose", None):
w.close()
else:
print("Window should have been closed: {}".format(w))
2019-12-20 09:43:52 +01:00
def unloadProfileAndExit(self) -> None:
self.unloadProfile(self.cleanupAndExit)
def unloadProfileAndShowProfileManager(self):
self.unloadProfile(self.showProfileManager)
2019-12-20 09:43:52 +01:00
def cleanupAndExit(self) -> None:
self.errorHandler.unload()
self.mediaServer.shutdown()
self.app.exit(0)
2017-10-05 05:48:24 +02:00
# Sound/video
##########################################################################
2019-12-20 09:43:52 +01:00
def setupSound(self) -> None:
aqt.sound.setup_audio(self.taskman, self.pm.base)
2017-10-05 05:48:24 +02:00
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:
2020-01-24 06:48:40 +01:00
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
##########################################################################
2019-12-20 09:43:52 +01:00
def loadCollection(self) -> bool:
try:
self._loadCollection()
except Exception as e:
if "FileTooNew" in 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?"
)
else:
showWarning(
tr(TR.ERRORS_UNABLE_OPEN_COLLECTION) + "\n" + traceback.format_exc()
)
# clean up open collection if possible
try:
self.backend.close_collection(False)
except Exception as e:
print("unable to close collection:", e)
self.col = None
# return to profile manager
self.hide()
self.showProfileManager()
return False
# make sure we don't get into an inconsistent state if an add-on
# has broken the deck browser or the did_load hook
try:
self.maybeEnableUndo()
gui_hooks.collection_did_load(self.col)
self.moveToState("deckBrowser")
except Exception as e:
# dump error to stderr so it gets picked up by errors.py
traceback.print_exc()
return True
def _loadCollection(self):
cpath = self.pm.collectionPath()
self.col = Collection(cpath, backend=self.backend, log=True)
self.setEnabled(True)
2020-03-06 05:03:23 +01:00
def reopen(self):
self.col.reopen()
2020-03-06 05:03:23 +01:00
2019-12-20 09:43:52 +01:00
def unloadCollection(self, onsuccess: Callable) -> None:
def after_media_sync():
self._unloadCollection()
onsuccess()
def after_sync():
self.media_syncer.show_diag_until_finished(after_media_sync)
2020-05-31 02:53:54 +02:00
def before_sync():
self.setEnabled(False)
self.maybe_auto_sync_on_open_close(after_sync)
self.closeAllWindows(before_sync)
2019-12-20 09:43:52 +01:00
def _unloadCollection(self) -> None:
if not self.col:
return
if self.restoringBackup:
label = _("Closing...")
else:
label = _("Backing Up...")
self.progress.start(label=label)
corrupt = False
try:
self.maybeOptimize()
if not devMode:
corrupt = self.col.db.scalar("pragma quick_check") != "ok"
except:
corrupt = True
try:
self.col.close(downgrade=False)
2020-04-08 02:05:33 +02:00
except Exception as e:
print(e)
corrupt = True
finally:
self.col = None
self.progress.finish()
if corrupt:
2019-12-23 01:34:10 +01:00
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 \
2019-12-23 01:34:10 +01:00
from the profile screen."
)
)
if not corrupt and not self.restoringBackup:
self.backup()
# 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
with open(self.path, "wb") as file:
pass
def run(self):
z = zipfile.ZipFile(self.path, "w", zipfile.ZIP_DEFLATED)
z.writestr("collection.anki2", self.data)
z.writestr("media", "{}")
z.close()
2019-12-20 09:43:52 +01:00
def backup(self) -> None:
2019-12-23 01:34:10 +01:00
nbacks = self.pm.profile["numBackups"]
if not nbacks or devMode:
return
dir = self.pm.backupFolder()
path = self.pm.collectionPath()
# do backup
2019-12-23 01:34:10 +01:00
fname = time.strftime(
"backup-%Y-%m-%d-%H.%M.%S.colpkg", time.localtime(time.time())
)
newpath = os.path.join(dir, fname)
2017-12-11 08:25:51 +01:00
with open(path, "rb") as f:
data = f.read()
2020-05-11 17:09:22 +02:00
self.BackupThread(newpath, data).start()
# find existing backups
backups = []
for file in os.listdir(dir):
# only look for new-style format
2017-12-11 08:25:51 +01:00
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)
2020-03-20 11:59:59 +01:00
gui_hooks.backup_did_complete()
2019-12-20 09:43:52 +01:00
def maybeOptimize(self) -> None:
# have two weeks passed?
2019-12-23 01:34:10 +01:00
if (intTime() - self.pm.profile["lastOptimize"]) < 86400 * 14:
return
self.progress.start(label=_("Optimizing..."))
self.col.optimize()
2019-12-23 01:34:10 +01:00
self.pm.profile["lastOptimize"] = intTime()
self.pm.save()
self.progress.finish()
# State machine
##########################################################################
2019-12-20 09:43:52 +01:00
def moveToState(self, state: str, *args) -> None:
2019-12-23 01:34:10 +01:00
# print("-> move from", self.state, "to", state)
oldState = self.state or "dummy"
2019-12-23 01:34:10 +01:00
cleanup = getattr(self, "_" + oldState + "Cleanup", None)
if cleanup:
# pylint: disable=not-callable
cleanup(state)
self.clearStateShortcuts()
self.state = state
2020-01-15 07:53:24 +01:00
gui_hooks.state_will_change(state, oldState)
2019-12-23 01:34:10 +01:00
getattr(self, "_" + state + "State")(oldState, *args)
if state != "resetRequired":
self.bottomWeb.show()
2020-01-15 07:53:24 +01:00
gui_hooks.state_did_change(state, oldState)
2019-12-20 09:43:52 +01:00
def _deckBrowserState(self, oldState: str) -> None:
2020-01-19 02:31:09 +01:00
self.maybe_check_for_addon_updates()
self.deckBrowser.show()
2020-08-11 22:56:58 +02:00
def _selectedDeck(self) -> Optional[Deck]:
did = self.col.decks.selected()
if not self.col.decks.nameOrNone(did):
showInfo(_("Please select a deck."))
2019-12-20 09:43:52 +01:00
return None
return self.col.decks.get(did)
2019-12-20 09:43:52 +01:00
def _overviewState(self, oldState: str) -> None:
if not self._selectedDeck():
return self.moveToState("deckBrowser")
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
##########################################################################
2019-12-20 09:43:52 +01:00
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()
2020-01-15 07:53:24 +01:00
gui_hooks.state_did_reset()
self.maybeEnableUndo()
self.moveToState(self.state)
2020-08-12 13:53:21 +02:00
def requireReset(self, modal=False, reason="unknown", context=None):
"Signal queue needs to be rebuilt when edits are finished or by user."
self.autosave()
self.resetModal = modal
2020-08-16 18:33:33 +02:00
if gui_hooks.main_window_should_require_reset(
2020-08-09 13:51:26 +02:00
self.interactiveState(), reason, context
):
self.moveToState("resetRequired")
def interactiveState(self):
"True if not in profile manager, syncing, etc."
return self.state in ("overview", "review", "deckBrowser")
2019-12-20 09:43:52 +01:00
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")
2019-12-23 01:34:10 +01:00
self.web.stdHtml(
"""
<center><div style="height: 100%%">
<div style="position:relative; vertical-align: middle;">
%s<br><br>
%s</div></div></center>
2017-08-01 05:57:15 +02:00
<script>$('#resume').focus()</script>
2019-12-23 01:34:10 +01:00
"""
% (i, b),
context=web_context,
2019-12-23 01:34:10 +01:00
)
self.bottomWeb.hide()
self.web.setFocus()
# HTML helpers
##########################################################################
2019-12-23 01:34:10 +01:00
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 = ""
2019-12-23 01:34:10 +01:00
return """
<button id="%s" class="%s" onclick="pycmd('%s');return false;"
2019-12-23 01:34:10 +01:00
title="%s" %s>%s</button>""" % (
id,
class_,
link,
key,
extra,
name,
)
# Main window setup
##########################################################################
2019-12-20 09:43:52 +01:00
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)
# 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()
2019-12-23 01:34:10 +01:00
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"))
2019-12-20 09:43:52 +01:00
def closeAllWindows(self, onsuccess: Callable) -> None:
aqt.dialogs.closeAll(onsuccess)
# Components
##########################################################################
2019-12-20 09:43:52 +01:00
def setupSignals(self) -> None:
signal.signal(signal.SIGINT, self.onUnixSignal)
signal.signal(signal.SIGTERM, self.onUnixSignal)
def onUnixSignal(self, signum, frame):
2020-03-02 11:50:17 +01:00
# schedule a rollback & quit
def quit():
self.col.db.rollback()
self.close()
2019-12-23 01:34:10 +01:00
self.progress.timer(100, quit, False)
2019-12-20 09:43:52 +01:00
def setupProgress(self) -> None:
self.progress = aqt.progress.ProgressManager(self)
2019-12-20 09:43:52 +01:00
def setupErrorHandler(self) -> None:
import aqt.errors
2019-12-23 01:34:10 +01:00
self.errorHandler = aqt.errors.ErrorHandler(self)
def setupAddons(self, args: Optional[List]) -> None:
import aqt.addons
2019-12-23 01:34:10 +01:00
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()
2020-01-19 02:31:09 +01:00
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)
2019-12-20 09:43:52 +01:00
def setupSpellCheck(self) -> None:
2019-12-23 01:34:10 +01:00
os.environ["QTWEBENGINE_DICTIONARIES_PATH"] = os.path.join(
self.pm.base, "dictionaries"
)
2019-12-20 09:43:52 +01:00
def setupThreads(self) -> None:
self._mainThread = QThread.currentThread()
2019-12-20 09:43:52 +01:00
def inMainThread(self) -> bool:
return self._mainThread == QThread.currentThread()
2019-12-20 09:43:52 +01:00
def setupDeckBrowser(self) -> None:
from aqt.deckbrowser import DeckBrowser
2019-12-23 01:34:10 +01:00
self.deckBrowser = DeckBrowser(self)
2019-12-20 09:43:52 +01:00
def setupOverview(self) -> None:
from aqt.overview import Overview
2019-12-23 01:34:10 +01:00
self.overview = Overview(self)
2019-12-20 09:43:52 +01:00
def setupReviewer(self) -> None:
from aqt.reviewer import Reviewer
2019-12-23 01:34:10 +01:00
self.reviewer = Reviewer(self)
# Syncing
##########################################################################
2020-05-31 02:53:54 +02:00
def on_sync_button_clicked(self):
if self.media_syncer.is_syncing():
self.media_syncer.show_sync_log()
else:
2020-05-31 02:53:54 +02:00
auth = self.pm.sync_auth()
if not auth:
2020-06-02 07:10:58 +02:00
sync_login(self, lambda: self._sync_collection_and_media(lambda: None))
2020-05-31 02:53:54 +02:00
else:
self._sync_collection_and_media(lambda: None)
2020-05-31 02:53:54 +02:00
def _sync_collection_and_media(self, after_sync: Callable[[], None]):
"Caller should ensure auth available."
# start media sync if not already running
if not self.media_syncer.is_syncing():
self.media_syncer.start()
2020-05-31 02:53:54 +02:00
def on_collection_sync_finished():
self.col.clearUndo()
self.col.models._clear_cache()
2020-05-31 02:53:54 +02:00
self.reset()
after_sync()
2020-05-31 02:53:54 +02:00
sync_collection(self, on_done=on_collection_sync_finished)
def maybe_auto_sync_on_open_close(self, after_sync: Callable[[], None]) -> None:
"If disabled, after_sync() is called immediately."
if self.can_auto_sync():
self._sync_collection_and_media(after_sync)
else:
after_sync()
2020-02-05 02:55:46 +01:00
def maybe_auto_sync_media(self) -> None:
2020-05-31 02:53:54 +02:00
if self.can_auto_sync():
2020-02-05 02:55:46 +01:00
return
2020-05-31 02:53:54 +02:00
# media_syncer takes care of media syncing preference check
2020-02-05 02:55:46 +01:00
self.media_syncer.start()
2020-05-31 02:53:54 +02:00
def can_auto_sync(self) -> bool:
return (
self.pm.auto_syncing_enabled()
2020-05-31 03:49:05 +02:00
and bool(self.pm.sync_auth())
2020-05-31 02:53:54 +02:00
and not self.safeMode
and not self.restoringBackup
)
2020-05-30 04:28:22 +02:00
2020-05-31 02:53:54 +02:00
# legacy
def _sync(self):
pass
2020-05-31 02:53:54 +02:00
onSync = on_sync_button_clicked
2020-05-30 04:28:22 +02:00
# Tools
##########################################################################
def raiseMain(self) -> bool:
if not self.app.activeWindow():
# make sure window is shown
self.setWindowState(self.windowState() & ~Qt.WindowMinimized) # type: ignore
return True
2019-12-20 09:43:52 +01:00
def setupStyle(self) -> None:
theme_manager.night_mode = self.pm.night_mode()
theme_manager.apply_style(self.app)
# Key handling
##########################################################################
2019-12-20 09:43:52 +01:00
def setupKeys(self) -> None:
globalShortcuts = [
("Ctrl+:", self.onDebug),
("d", lambda: self.moveToState("deckBrowser")),
("s", self.onStudyKey),
("a", self.onAddCard),
("b", self.onBrowse),
2018-05-31 05:05:30 +02:00
("t", self.onStats),
2020-05-31 02:53:54 +02:00
("y", self.on_sync_button_clicked),
]
self.applyShortcuts(globalShortcuts)
2019-12-20 09:43:52 +01:00
self.stateShortcuts: Sequence[Tuple[str, Callable]] = []
2019-12-23 01:34:10 +01:00
def applyShortcuts(
self, shortcuts: Sequence[Tuple[str, Callable]]
) -> List[QShortcut]:
qshortcuts = []
for key, fn in shortcuts:
2019-12-23 01:34:10 +01:00
scut = QShortcut(QKeySequence(key), self, activated=fn) # type: ignore
scut.setAutoRepeat(False)
qshortcuts.append(scut)
return qshortcuts
2019-12-20 09:43:52 +01:00
def setStateShortcuts(self, shortcuts: List[Tuple[str, Callable]]) -> None:
2020-01-15 07:53:24 +01:00
gui_hooks.state_shortcuts_will_change(self.state, shortcuts)
# legacy hook
2019-12-23 01:34:10 +01:00
runHook(self.state + "StateShortcuts", shortcuts)
self.stateShortcuts = self.applyShortcuts(shortcuts)
2019-12-20 09:43:52 +01:00
def clearStateShortcuts(self) -> None:
for qs in self.stateShortcuts:
sip.delete(qs)
self.stateShortcuts = []
2019-12-20 09:43:52 +01:00
def onStudyKey(self) -> None:
if self.state == "overview":
self.col.startTimebox()
self.moveToState("review")
else:
self.moveToState("overview")
# App exit
##########################################################################
2019-12-20 09:43:52 +01:00
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:
2012-12-21 23:42:52 +01:00
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()
2020-01-15 07:53:24 +01:00
gui_hooks.review_did_undo(cid)
else:
self.reset()
tooltip(_("Reverted to state prior to '%s'.") % n.lower())
2020-01-15 07:53:24 +01:00
gui_hooks.state_did_revert(n)
self.maybeEnableUndo()
2019-12-20 09:43:52 +01:00
def maybeEnableUndo(self) -> None:
if self.col and self.col.undoName():
2019-12-23 01:34:10 +01:00
self.form.actionUndo.setText(_("Undo %s") % self.col.undoName())
self.form.actionUndo.setEnabled(True)
2020-01-15 07:53:24 +01:00
gui_hooks.undo_state_did_change(True)
else:
self.form.actionUndo.setText(_("Undo"))
self.form.actionUndo.setEnabled(False)
2020-01-15 07:53:24 +01:00
gui_hooks.undo_state_did_change(False)
def checkpoint(self, name):
self.col.save(name)
self.maybeEnableUndo()
2019-12-20 09:43:52 +01:00
def autosave(self) -> None:
saved = self.col.autosave()
self.maybeEnableUndo()
if saved:
self.doGC()
# Other menu operations
##########################################################################
2019-12-20 09:43:52 +01:00
def onAddCard(self) -> None:
aqt.dialogs.open("AddCards", self)
2019-12-20 09:43:52 +01:00
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()
2019-12-23 01:34:10 +01:00
if deck["dyn"]:
import aqt.dyndeckconf
2019-12-23 01:34:10 +01:00
aqt.dyndeckconf.DeckConf(self, deck=deck)
else:
import aqt.deckconf
2019-12-23 01:34:10 +01:00
aqt.deckconf.DeckConf(self, deck)
def onOverview(self):
self.col.reset()
self.moveToState("overview")
def onStats(self):
deck = self._selectedDeck()
if not deck:
return
want_old = self.app.queryKeyboardModifiers() & Qt.ShiftModifier
if want_old:
aqt.dialogs.open("DeckStats", self)
else:
aqt.dialogs.open("NewDeckStats", self)
def onPrefs(self):
aqt.dialogs.open("Preferences", self)
2012-12-22 00:21:24 +01:00
def onNoteTypes(self):
import aqt.models
2019-12-23 01:34:10 +01:00
2012-12-22 00:21:24 +01:00
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
##########################################################################
2019-12-20 09:43:52 +01:00
def handleImport(self, path: str) -> None:
import aqt.importing
2019-12-23 01:34:10 +01:00
if not os.path.exists(path):
2019-12-20 09:43:52 +01:00
showInfo(_("Please use File>Import to import this file."))
return None
aqt.importing.importFile(self, path)
2019-12-20 09:43:52 +01:00
return None
def onImport(self):
import aqt.importing
2019-12-23 01:34:10 +01:00
aqt.importing.onImport(self)
2014-06-20 02:13:12 +02:00
def onExport(self, did=None):
import aqt.exporting
2019-12-23 01:34:10 +01:00
2014-06-20 02:13:12 +02:00
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
2020-01-03 18:23:28 +01:00
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
2019-12-23 01:34:10 +01:00
n = 1
deck = self.col.decks.current()
if not search:
2019-12-23 01:34:10 +01:00
if not deck["dyn"]:
search = 'deck:"%s" ' % deck["name"]
while self.col.decks.id_for_name(_("Filtered Deck %d") % n):
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)
2019-12-23 01:34:10 +01:00
self.col.decks.select(deck["id"])
# Menu, title bar & status
##########################################################################
2019-12-20 09:43:52 +01:00
def setupMenus(self) -> None:
m = self.form
qconnect(
m.actionSwitchProfile.triggered, self.unloadProfileAndShowProfileManager
)
qconnect(m.actionImport.triggered, self.onImport)
qconnect(m.actionExport.triggered, self.onExport)
qconnect(m.actionExit.triggered, self.close)
qconnect(m.actionPreferences.triggered, self.onPrefs)
qconnect(m.actionAbout.triggered, self.onAbout)
qconnect(m.actionUndo.triggered, self.onUndo)
if qtminor < 11:
2020-02-05 14:46:11 +01:00
m.actionUndo.setShortcut(QKeySequence("Ctrl+Alt+Z"))
qconnect(m.actionFullDatabaseCheck.triggered, self.onCheckDB)
qconnect(m.actionCheckMediaDatabase.triggered, self.on_check_media_db)
qconnect(m.actionDocumentation.triggered, self.onDocumentation)
qconnect(m.actionDonate.triggered, self.onDonate)
qconnect(m.actionStudyDeck.triggered, self.onStudyDeck)
qconnect(m.actionCreateFiltered.triggered, self.onCram)
qconnect(m.actionEmptyCards.triggered, self.onEmptyCards)
qconnect(m.actionNoteTypes.triggered, self.onNoteTypes)
2019-12-20 09:43:52 +01:00
def updateTitleBar(self) -> None:
self.setWindowTitle("Anki")
# Auto update
##########################################################################
2019-12-20 09:43:52 +01:00
def setupAutoUpdate(self) -> None:
import aqt.update
2019-12-23 01:34:10 +01:00
self.autoUpdate = aqt.update.LatestVersionFinder(self)
qconnect(self.autoUpdate.newVerAvail, self.newVerAvail)
qconnect(self.autoUpdate.newMsg, self.newMsg)
qconnect(self.autoUpdate.clockIsOff, self.clockIsOff)
self.autoUpdate.start()
def newVerAvail(self, ver):
2019-12-23 01:34:10 +01:00
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):
2013-12-06 05:27:13 +01:00
diffText = ngettext("%s second", "%s seconds", diff) % diff
2019-12-23 01:34:10 +01:00
warn = (
_(
"""\
2013-12-09 07:45:39 +01:00
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
2019-12-23 01:34:10 +01:00
Difference to correct time: %s."""
)
% diffText
)
showWarning(warn)
self.app.closeAllWindows()
2020-02-05 03:38:36 +01:00
# Timers
##########################################################################
2020-02-05 03:38:36 +01:00
def setup_timers(self) -> None:
# refresh decks every 10 minutes
2019-12-23 01:34:10 +01:00
self.progress.timer(10 * 60 * 1000, self.onRefreshTimer, True)
2020-02-05 03:38:36 +01:00
# check media sync every 5 minutes
self.progress.timer(5 * 60 * 1000, self.on_autosync_timer, True)
# ensure Python interpreter runs at least once per second, so that
# SIGINT/SIGTERM is processed without a long delay
self.progress.timer(1000, lambda: None, True, False)
def onRefreshTimer(self):
if self.state == "deckBrowser":
self.deckBrowser.refresh()
elif self.state == "overview":
self.overview.refresh()
2020-02-05 03:38:36 +01:00
def on_autosync_timer(self):
elap = self.media_syncer.seconds_since_last_sync()
minutes = self.pm.auto_sync_media_minutes()
if not minutes:
return
if elap > minutes * 60:
2020-02-05 03:38:36 +01:00
self.maybe_auto_sync_media()
2013-05-22 05:27:37 +02:00
# Permanent libanki hooks
##########################################################################
2019-12-20 09:43:52 +01:00
def setupHooks(self) -> None:
2020-01-15 07:53:24 +01:00
hooks.schema_will_change.append(self.onSchemaMod)
hooks.notes_will_be_deleted.append(self.onRemNotes)
2020-01-15 07:53:24 +01:00
hooks.card_odue_was_invalid.append(self.onOdueInvalid)
gui_hooks.av_player_will_play.append(self.on_av_player_will_play)
2020-01-22 05:39:18 +01:00
gui_hooks.av_player_did_end_playing.append(self.on_av_player_did_end_playing)
2020-01-13 05:38:05 +01:00
self._activeWindowOnPlay: Optional[QWidget] = None
def onOdueInvalid(self):
2019-12-23 01:34:10 +01:00
showWarning(
_(
"""\
Invalid property found on card. Please use Tools>Check Database, \
2019-12-23 01:34:10 +01:00
and if the problem comes up again, please ask on the support site."""
)
)
2013-05-22 05:27:37 +02:00
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
2020-01-22 05:39:18 +01:00
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
2013-05-22 05:27:37 +02:00
# Log note deletion
##########################################################################
2020-06-04 10:21:04 +02:00
def onRemNotes(self, col: Collection, nids: Sequence[int]) -> None:
2013-05-22 05:27:37 +02:00
path = os.path.join(self.pm.profileFolder(), "deleted.txt")
existed = os.path.exists(path)
2017-01-08 11:44:52 +01:00
with open(path, "ab") as f:
2013-05-22 05:27:37 +02:00
if not existed:
2017-01-08 11:44:52 +01:00
f.write(b"nid\tmid\tfields\n")
for id, mid, flds in col.db.execute(
2019-12-23 01:34:10 +01:00
"select id, mid, flds from notes where id in %s" % ids2str(nids)
):
2013-05-22 05:27:37 +02:00
fields = splitFields(flds)
2017-01-08 11:44:52 +01:00
f.write(("\t".join([str(id), str(mid)] + fields)).encode("utf8"))
f.write(b"\n")
2013-05-22 05:27:37 +02:00
# Schema modifications
##########################################################################
# this will gradually be phased out
def onSchemaMod(self, arg):
assert self.inMainThread()
progress_shown = self.progress.busy()
if progress_shown:
self.progress.finish()
ret = askUser(
2019-12-23 01:34:10 +01:00
_(
"""\
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 \
2019-12-23 01:34:10 +01:00
will be lost. Continue?"""
)
)
if progress_shown:
self.progress.start()
return ret
# in favour of this
def confirm_schema_modification(self) -> bool:
"""If schema unmodified, ask user to confirm change.
True if confirmed or already modified."""
if self.col.schemaChanged():
return True
return askUser(
_(
"""\
The requested change will require a full upload of the database when \
you next synchronize your collection. If you have reviews or other changes \
waiting on another device that haven't been synchronized here yet, they \
will be lost. Continue?"""
)
)
# Advanced features
##########################################################################
def onCheckDB(self):
2020-06-08 12:28:11 +02:00
check_db(self)
2020-02-10 08:58:54 +01:00
def on_check_media_db(self) -> None:
check_media_db(self)
def onStudyDeck(self):
from aqt.studydeck import StudyDeck
2019-12-23 01:34:10 +01:00
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) -> None:
show_empty_cards(self)
# Debugging
######################################################################
def onDebug(self):
frm = self.debug_diag_form = aqt.forms.debug.Ui_Dialog()
class DebugDialog(QDialog):
def reject(self):
super().reject()
saveSplitter(frm.splitter, "DebugConsoleWindow")
saveGeom(self, "DebugConsoleWindow")
d = self.debugDiag = DebugDialog()
d.silentlyClose = True
frm.setupUi(d)
restoreGeom(d, "DebugConsoleWindow")
restoreSplitter(frm.splitter, "DebugConsoleWindow")
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)
qconnect(s.activated, lambda: self.onDebugRet(frm))
2019-12-23 01:34:10 +01:00
s = self.debugDiagShort = QShortcut(QKeySequence("ctrl+shift+return"), d)
qconnect(s.activated, lambda: self.onDebugPrint(frm))
s = self.debugDiagShort = QShortcut(QKeySequence("ctrl+l"), d)
qconnect(s.activated, frm.log.clear)
s = self.debugDiagShort = QShortcut(QKeySequence("ctrl+shift+l"), d)
qconnect(s.activated, frm.text.clear)
def addContextMenu(ev: QCloseEvent, name: str) -> None:
ev.accept()
menu = frm.log.createStandardContextMenu(QCursor.pos())
menu.addSeparator()
if name == "log":
2020-04-11 06:19:27 +02:00
a = menu.addAction("Clear Log")
a.setShortcuts(QKeySequence("ctrl+l"))
qconnect(a.triggered, frm.log.clear)
elif name == "text":
2020-04-11 06:19:27 +02:00
a = menu.addAction("Clear Code")
a.setShortcuts(QKeySequence("ctrl+shift+l"))
qconnect(a.triggered, frm.text.clear)
menu.exec(QCursor.pos())
frm.log.contextMenuEvent = lambda ev: addContextMenu(ev, "log")
frm.text.contextMenuEvent = lambda ev: addContextMenu(ev, "text")
2020-03-04 18:11:13 +01:00
gui_hooks.debug_console_will_show(d)
d.show()
def _captureOutput(self, on: bool) -> None:
mw = self
2019-12-23 01:34:10 +01:00
class Stream:
def write(self, data):
mw._output += data
2019-12-23 01:34:10 +01:00
if on:
self._output = ""
self._oldStderr = sys.stderr
self._oldStdout = sys.stdout
s = cast(TextIO, Stream())
sys.stderr = s
sys.stdout = s
else:
sys.stderr = self._oldStderr
sys.stdout = self._oldStdout
2020-03-23 08:44:26 +01:00
def _card_repr(self, card: anki.cards.Card) -> None:
import pprint, copy
2020-03-23 08:44:26 +01:00
if not card:
print("no card")
return
print("Front:", card.question())
print("\n")
print("Back:", card.answer())
print("\nNote:")
note = copy.copy(card.note())
for k, v in note.items():
print(f"- {k}:", v)
print("\n")
del note.fields
del note._fmap
pprint.pprint(note.__dict__)
print("\nCard:")
c = copy.copy(card)
c._render_output = None
pprint.pprint(c.__dict__)
def _debugCard(self) -> Optional[anki.cards.Card]:
card = self.reviewer.card
self._card_repr(card)
return card
2020-03-23 08:44:26 +01:00
def _debugBrowserCard(self) -> Optional[anki.cards.Card]:
card = aqt.dialogs._dialogs["Browser"][1].card
self._card_repr(card)
return card
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
2019-12-23 01:34:10 +01:00
text = frm.text.toPlainText()
card = self._debugCard
bcard = self._debugBrowserCard
mw = self
pp = pprint.pprint
self._captureOutput(True)
try:
2019-03-04 07:01:10 +01:00
# 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:
2020-03-04 18:20:02 +01:00
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:
2020-03-04 18:20:02 +01:00
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
##########################################################################
2019-12-20 09:43:52 +01:00
def setupSystemSpecific(self) -> None:
self.hideMenuAccels = False
if isMac:
# mac users expect a minimize option
self.minimizeShortcut = QShortcut("Ctrl+M", self)
qconnect(self.minimizeShortcut.activated, self.onMacMinimize)
self.hideMenuAccels = True
self.maybeHideAccelerators()
self.hideStatusTips()
elif isWin:
# make sure ctypes is bundled
2019-12-23 01:34:10 +01:00
from ctypes import windll, wintypes # type: ignore
_dummy1 = windll
_dummy2 = wintypes
2019-12-20 09:43:52 +01:00
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())
2017-12-11 08:25:51 +01:00
m = re.match(r"^(.+)\(&.+\)(.+)?", txt)
if m:
action.setText(m.group(1) + (m.group(2) or ""))
2019-12-20 09:43:52 +01:00
def hideStatusTips(self) -> None:
for action in self.findChildren(QAction):
action.setStatusTip("")
def onMacMinimize(self) -> None:
self.setWindowState(self.windowState() | Qt.WindowMinimized) # type: ignore
# Single instance support
##########################################################################
2019-12-20 09:43:52 +01:00
def setupAppMsg(self) -> None:
qconnect(self.app.appMsg, self.onAppMsg)
2019-12-20 09:43:52 +01:00
def onAppMsg(self, buf: str) -> Optional[QTimer]:
is_addon = self._isAddon(buf)
2020-01-03 18:23:28 +01:00
if self.state == "startup":
# try again in a second
2019-12-23 01:34:10 +01:00
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":
2019-12-20 09:43:52 +01:00
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":
2019-12-23 01:34:10 +01:00
showInfo(
_(
"""\
Please ensure a profile is open and Anki is not busy, then try again."""
),
parent=None,
)
2019-12-20 09:43:52 +01:00
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":
2019-12-20 09:43:52 +01:00
return None
2020-01-03 18:23:28 +01:00
# import / add-on installation
if is_addon:
self.installAddon(buf)
else:
self.handleImport(buf)
2020-01-03 18:23:28 +01:00
2019-12-20 09:43:52 +01:00
return None
def _isAddon(self, buf: str) -> bool:
return buf.endswith(self.addonManager.ext)
# GC
##########################################################################
# ensure gc runs in main thread
2019-12-20 09:43:52 +01:00
def setupDialogGC(self, obj: Any) -> None:
qconnect(obj.finished, lambda: self.gcWindow(obj))
2019-12-20 09:43:52 +01:00
def gcWindow(self, obj: Any) -> None:
obj.deleteLater()
self.progress.timer(1000, self.doGC, False, requiresCollection=False)
2019-12-20 09:43:52 +01:00
def disableGC(self) -> None:
gc.collect()
gc.disable()
2019-12-20 09:43:52 +01:00
def doGC(self) -> None:
gc.collect()
2016-07-07 15:39:48 +02:00
2017-01-08 04:38:12 +01:00
# Crash log
##########################################################################
2019-12-20 09:43:52 +01:00
def setupCrashLog(self) -> None:
2017-01-08 04:38:12 +01:00
p = os.path.join(self.pm.base, "crash.log")
self._crashLog = open(p, "ab", 0)
2017-01-08 04:38:12 +01:00
faulthandler.enable(self._crashLog)
2016-07-07 15:39:48 +02:00
# Media server
##########################################################################
2019-12-20 09:43:52 +01:00
def setupMediaServer(self) -> None:
self.mediaServer = aqt.mediasrv.MediaServer(self)
2016-07-07 15:39:48 +02:00
self.mediaServer.start()
2019-12-20 09:43:52 +01:00
def baseHTML(self) -> str:
return '<base href="%s">' % self.serverURL()
2019-12-20 09:43:52 +01:00
def serverURL(self) -> str:
return "http://127.0.0.1:%d/" % self.mediaServer.getPort()