anki/qt/aqt/main.py

1725 lines
57 KiB
Python
Raw Normal View History

2019-02-05 04:59:03 +01:00
# Copyright: Ankitects Pty Ltd and contributors
# 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 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
2021-02-02 14:30:53 +01:00
from concurrent.futures import Future
from threading import Thread
from typing import Any, Literal, Sequence, TextIO, TypeVar, 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
from anki._backend import RustBackend as _RustBackend
2021-04-06 06:36:13 +02:00
from anki.collection import Collection, Config, OpChanges, UndoStatus
from anki.decks import DeckDict, DeckId
2020-01-15 04:49:26 +01:00
from anki.hooks import runHook
from anki.notes import NoteId
from anki.sound import AVTag, SoundOrVideoTag
from anki.utils import dev_mode, ids2str, int_time, is_lin, is_mac, is_win, split_fields
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.flags import FlagManager
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
from aqt.operations.collection import redo, undo
from aqt.operations.deck import set_current_deck
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, theme_manager
from aqt.undo import UndoActionsInfo
2019-12-23 01:34:10 +01:00
from aqt.utils import (
HelpPage,
KeyboardModifiersPressed,
2019-12-23 01:34:10 +01:00
askUser,
checkInvalidFilename,
current_window,
disable_help_button,
2019-12-23 01:34:10 +01:00
getFile,
getOnlyText,
openHelp,
openLink,
restoreGeom,
restoreSplitter,
2019-12-23 01:34:10 +01:00
restoreState,
saveGeom,
saveSplitter,
saveState,
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()
2021-03-14 10:54:15 +01:00
MainWindowState = Literal[
"startup", "deckBrowser", "overview", "review", "resetRequired", "profileManager"
]
T = TypeVar("T")
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: aqt.AnkiApp,
2019-12-23 01:34:10 +01:00
profileManager: ProfileManagerType,
backend: _RustBackend,
2019-12-23 01:34:10 +01:00
opts: Namespace,
args: list[Any],
2019-12-23 01:34:10 +01:00
) -> None:
QMainWindow.__init__(self)
2020-03-14 00:45:00 +01:00
self.backend = backend
2021-03-14 10:54:15 +01:00
self.state: MainWindowState = "startup"
self.opts = opts
self.col: Collection | None = 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 = (
bool(self.app.queryKeyboardModifiers() & Qt.KeyboardModifier.ShiftModifier)
or self.opts.safemode
)
try:
self.setupUI()
self.setupAddons(args)
self.finish_ui_setup()
except:
showInfo(tr.qt_misc_error_during_startup(val=traceback.format_exc()))
sys.exit(1)
2013-05-17 08:32:17 +02:00
# must call this after ui set up
if self.safeMode:
2021-03-26 04:48:26 +01:00
tooltip(tr.qt_misc_shift_key_was_held_down_skipping())
# 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 is_win:
fn = self.setupProfileAfterWebviewsLoaded
else:
fn = self.setupProfile
2021-02-01 14:28:21 +01:00
def on_window_init() -> None:
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
self.disable_automatic_garbage_collection()
self.setupAppMsg()
self.setupKeys()
self.setupThreads()
self.setupMediaServer()
2017-10-05 05:48:24 +02:00
self.setupSound()
self.setupSpellCheck()
self.setupProgress()
self.setupStyle()
self.setupMainWindow()
self.setupSystemSpecific()
self.setupMenus()
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()
self.setup_focus()
2021-11-14 02:35:43 +01:00
self.setup_shortcuts()
# 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()
2021-02-01 14:28:21 +01:00
def setupProfileAfterWebviewsLoaded(self) -> None:
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
def setup_focus(self) -> None:
qconnect(self.app.focusChanged, self.on_focus_changed)
def on_focus_changed(self, old: QWidget, new: QWidget) -> None:
gui_hooks.focus_did_change(new, old)
2021-11-14 02:35:43 +01:00
def setup_shortcuts(self) -> None:
QShortcut(
QKeySequence("Ctrl+Meta+F" if is_mac else "F11"),
2021-11-14 02:35:43 +01:00
self,
self.on_toggle_fullscreen,
).setContext(Qt.ShortcutContext.ApplicationShortcut)
def on_toggle_fullscreen(self) -> None:
window = self.app.activeWindow()
window.setWindowState(window.windowState() ^ Qt.WindowState.WindowFullScreen)
# Profiles
##########################################################################
class ProfileManager(QMainWindow):
onClose = pyqtSignal()
closeFires = True
def closeEvent(self, evt: QCloseEvent) -> None:
if self.closeFires:
self.onClose.emit() # type: ignore
evt.accept()
2021-02-01 14:28:21 +01:00
def closeWithoutQuitting(self) -> None:
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()
self.pendingImport: str | None = 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)
2021-03-26 04:48:26 +01:00
f.downgrade_button.setText(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]
self.pm.load(name)
2021-02-01 14:28:21 +01:00
def openProfile(self) -> None:
name = self.pm.profiles()[self.profileForm.profiles.currentRow()]
2021-02-01 14:28:21 +01:00
self.pm.load(name)
return
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"
2021-02-01 14:28:21 +01:00
def onAddProfile(self) -> None:
2021-03-26 04:48:26 +01:00
name = getOnlyText(tr.actions_name()).strip()
if name:
if name in self.pm.profiles():
2021-03-26 04:48:26 +01:00
showWarning(tr.qt_misc_name_exists())
2021-02-01 14:28:21 +01:00
return
if not self.profileNameOk(name):
return
self.pm.create(name)
self.pm.name = name
self.refreshProfilesList()
2021-02-01 14:28:21 +01:00
def onRenameProfile(self) -> None:
2021-03-26 04:48:26 +01:00
name = getOnlyText(tr.actions_new_name(), default=self.pm.name).strip()
if not name:
return
if name == self.pm.name:
return
if name in self.pm.profiles():
2021-03-26 04:48:26 +01:00
showWarning(tr.qt_misc_name_exists())
2021-02-01 14:28:21 +01:00
return
if not self.profileNameOk(name):
return
self.pm.rename(name)
self.refreshProfilesList()
2021-02-01 14:28:21 +01:00
def onRemProfile(self) -> None:
profs = self.pm.profiles()
if len(profs) < 2:
2021-03-26 04:48:26 +01:00
showWarning(tr.qt_misc_there_must_be_at_least_one())
2021-02-01 14:28:21 +01:00
return
# sure?
2019-12-23 01:34:10 +01:00
if not askUser(
2021-03-26 04:48:26 +01:00
tr.qt_misc_all_cards_notes_and_media_for(),
2019-12-23 01:34:10 +01:00
msgfunc=QMessageBox.warning,
defaultno=True,
):
return
self.pm.remove(self.pm.name)
self.refreshProfilesList()
2021-02-01 14:28:21 +01:00
def onOpenBackup(self) -> None:
2019-12-23 01:34:10 +01:00
if not askUser(
2021-03-26 04:48:26 +01:00
tr.qt_misc_replace_your_collection_with_an_earlier(),
2019-12-23 01:34:10 +01:00
msgfunc=QMessageBox.warning,
defaultno=True,
):
return
2019-12-23 01:34:10 +01:00
2021-02-02 14:30:53 +01:00
def doOpen(path: str) -> None:
self._openBackup(path)
2019-12-23 01:34:10 +01:00
getFile(
self.profileDiag,
2021-03-26 04:48:26 +01:00
tr.qt_misc_revert_to_backup(),
2021-02-02 14:30:53 +01:00
cb=doOpen, # type: ignore
2019-12-23 01:34:10 +01:00
filter="*.colpkg",
dir=self.pm.backupFolder(),
)
def _openBackup(self, path: str) -> None:
try:
# move the existing collection to the trash, as it may not open
self.pm.trashCollection()
except:
2021-03-26 04:48:26 +01:00
showWarning(tr.qt_misc_unable_to_move_existing_file_to())
return
self.pendingImport = path
self.restoringBackup = True
2021-03-26 04:48:26 +01:00
showInfo(tr.qt_misc_automatic_syncing_and_backups_have_been())
self.onOpenProfile()
2021-02-01 14:28:21 +01:00
def _on_downgrade(self) -> None:
self.progress.start()
profiles = self.pm.profiles()
def downgrade() -> list[str]:
return self.pm.downgrade(profiles)
2021-02-02 14:30:53 +01:00
def on_done(future: Future) -> None:
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)
def loadProfile(self, onsuccess: Callable | None = None) -> None:
if not self.loadCollection():
return
self.flags = FlagManager(self)
# 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(f"{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
2021-02-01 14:28:21 +01:00
def _onsuccess() -> None:
self._refresh_after_sync()
if onsuccess:
onsuccess()
self.maybe_auto_sync_on_open_close(_onsuccess)
2019-12-20 09:43:52 +01:00
def unloadProfile(self, onsuccess: Callable) -> None:
2021-02-01 14:28:21 +01:00
def callback() -> None:
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:
saveGeom(self, "mainWindow")
saveState(self, "mainWindow")
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(f"Window should have been closed: {w}")
2019-12-20 09:43:52 +01:00
def unloadProfileAndExit(self) -> None:
self.unloadProfile(self.cleanupAndExit)
2021-02-01 14:28:21 +01:00
def unloadProfileAndShowProfileManager(self) -> None:
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.col.get_config_bool(Config.Bool.HIDE_AUDIO_PLAY_BUTTONS):
2020-01-24 06:48:40 +01:00
return anki.sound.strip_av_refs(text)
else:
return aqt.sound.av_refs_to_play_icons(text)
def prepare_card_text_for_display(self, text: str) -> str:
text = self.col.media.escape_media_filenames(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(
2021-03-26 02:27:22 +01:00
"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(
2021-03-26 04:48:26 +01:00
f"{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.update_undo_actions()
gui_hooks.collection_did_load(self.col)
self.apply_collection_options()
self.moveToState("deckBrowser")
except Exception as e:
# dump error to stderr so it gets picked up by errors.py
traceback.print_exc()
return True
2021-02-01 11:59:18 +01:00
def _loadCollection(self) -> None:
cpath = self.pm.collectionPath()
2021-06-27 07:12:22 +02:00
self.col = Collection(cpath, backend=self.backend)
self.setEnabled(True)
2021-02-01 11:59:18 +01:00
def reopen(self) -> None:
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:
2021-02-01 14:28:21 +01:00
def after_media_sync() -> None:
self._unloadCollection()
onsuccess()
2021-02-01 14:28:21 +01:00
def after_sync() -> None:
self.media_syncer.show_diag_until_finished(after_media_sync)
2021-02-01 14:28:21 +01:00
def before_sync() -> None:
2020-05-31 02:53:54 +02:00
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:
2021-03-26 04:48:26 +01:00
label = tr.qt_misc_closing()
else:
2021-03-26 04:48:26 +01:00
label = tr.qt_misc_backing_up()
self.progress.start(label=label)
corrupt = False
try:
self.maybeOptimize()
if not dev_mode:
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:
2021-03-26 04:48:26 +01:00
showWarning(tr.qt_misc_your_collection_file_appears_to_be())
if not corrupt and not self.restoringBackup:
self.backup()
def _close_for_full_download(self) -> None:
"Backup and prepare collection to be overwritten."
self.col.close(downgrade=False)
self.backup()
self.col.reopen(after_full_sync=False)
self.col.close_for_full_sync()
def apply_collection_options(self) -> None:
"Setup audio after collection loaded."
aqt.sound.av_player.interrupt_current_audio = self.col.get_config_bool(
Config.Bool.INTERRUPT_AUDIO_WHEN_ANSWERING
)
# Backup and auto-optimize
##########################################################################
class BackupThread(Thread):
2021-02-02 14:30:53 +01:00
def __init__(self, path: str, data: bytes) -> None:
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
2021-02-01 14:28:21 +01:00
def run(self) -> None:
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:
"Read data into memory, and complete backup on a background thread."
if self.col and self.col.db:
raise Exception("collection must be closed")
2019-12-23 01:34:10 +01:00
nbacks = self.pm.profile["numBackups"]
if not nbacks or dev_mode:
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)
self.taskman.run_on_main(gui_hooks.backup_did_complete)
2019-12-20 09:43:52 +01:00
def maybeOptimize(self) -> None:
# have two weeks passed?
PEP8 for rest of pylib (#1451) * PEP8 dbproxy.py * PEP8 errors.py * PEP8 httpclient.py * PEP8 lang.py * PEP8 latex.py * Add decorator to deprectate key words * Make replacement for deprecated attribute optional * Use new helper `_print_replacement_warning()` * PEP8 media.py * PEP8 rsbackend.py * PEP8 sound.py * PEP8 stdmodels.py * PEP8 storage.py * PEP8 sync.py * PEP8 tags.py * PEP8 template.py * PEP8 types.py * Fix DeprecatedNamesMixinForModule The class methods need to be overridden with instance methods, so every module has its own dicts. * Use `# pylint: disable=invalid-name` instead of id * PEP8 utils.py * Only decorate `__getattr__` with `@no_type_check` * Fix mypy issue with snakecase Importing it from `anki._vendor` raises attribute errors. * Format * Remove inheritance of DeprecatedNamesMixin There's almost no shared code now and overriding classmethods with instance methods raises mypy issues. * Fix traceback frames of deprecation warnings * remove fn/TimedLog (dae) Neither Anki nor add-ons appear to have been using it * fix some issues with stringcase use (dae) - the wheel was depending on the PyPI version instead of our vendored version - _vendor:stringcase should not have been listed in the anki py_library. We already include the sources in py_srcs, and need to refer to them directly. By listing _vendor:stringcase as well, we were making a top-level stringcase library available, which would have only worked for distributing because the wheel definition was also incorrect. - mypy errors are what caused me to mistakenly add the above - they were because the type: ignore at the top of stringcase.py was causing mypy to completely ignore the file, so it was not aware of any attributes it contained.
2021-10-25 06:50:13 +02:00
if (int_time() - self.pm.profile["lastOptimize"]) < 86400 * 14:
return
2021-03-26 04:48:26 +01:00
self.progress.start(label=tr.qt_misc_optimizing())
self.col.optimize()
PEP8 for rest of pylib (#1451) * PEP8 dbproxy.py * PEP8 errors.py * PEP8 httpclient.py * PEP8 lang.py * PEP8 latex.py * Add decorator to deprectate key words * Make replacement for deprecated attribute optional * Use new helper `_print_replacement_warning()` * PEP8 media.py * PEP8 rsbackend.py * PEP8 sound.py * PEP8 stdmodels.py * PEP8 storage.py * PEP8 sync.py * PEP8 tags.py * PEP8 template.py * PEP8 types.py * Fix DeprecatedNamesMixinForModule The class methods need to be overridden with instance methods, so every module has its own dicts. * Use `# pylint: disable=invalid-name` instead of id * PEP8 utils.py * Only decorate `__getattr__` with `@no_type_check` * Fix mypy issue with snakecase Importing it from `anki._vendor` raises attribute errors. * Format * Remove inheritance of DeprecatedNamesMixin There's almost no shared code now and overriding classmethods with instance methods raises mypy issues. * Fix traceback frames of deprecation warnings * remove fn/TimedLog (dae) Neither Anki nor add-ons appear to have been using it * fix some issues with stringcase use (dae) - the wheel was depending on the PyPI version instead of our vendored version - _vendor:stringcase should not have been listed in the anki py_library. We already include the sources in py_srcs, and need to refer to them directly. By listing _vendor:stringcase as well, we were making a top-level stringcase library available, which would have only worked for distributing because the wheel definition was also incorrect. - mypy errors are what caused me to mistakenly add the above - they were because the type: ignore at the top of stringcase.py was causing mypy to completely ignore the file, so it was not aware of any attributes it contained.
2021-10-25 06:50:13 +02:00
self.pm.profile["lastOptimize"] = int_time()
self.pm.save()
self.progress.finish()
2021-03-14 10:54:15 +01:00
# Tracking main window state (deck browser, reviewer, etc)
##########################################################################
2021-03-14 10:54:15 +01:00
def moveToState(self, state: MainWindowState, *args: Any) -> None:
2019-12-23 01:34:10 +01:00
# print("-> move from", self.state, "to", state)
2021-03-14 10:54:15 +01:00
oldState = self.state
cleanup = getattr(self, f"_{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)
getattr(self, f"_{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()
def _selectedDeck(self) -> DeckDict | None:
did = self.col.decks.selected()
Simplify note adding and the deck/notetype choosers The existing code was really difficult to reason about: - The default notetype depended on the selected deck, and vice versa, and this logic was buried in the deck and notetype choosing screens, and models.py. - Changes to the notetype were not passed back directly, but were fired via a hook, which changed any screen in the app that had a notetype selector. It also wasn't great for performance, as the most recent deck and tags were embedded in the notetype, which can be expensive to save and sync for large notetypes. To address these points: - The current deck for a notetype, and notetype for a deck, are now stored in separate config variables, instead of directly in the deck or notetype. These are cheap to read and write, and we'll be able to sync them individually in the future once config syncing is updated in the future. I seem to recall some users not wanting the tag saving behaviour, so I've dropped that for now, but if people end up missing it, it would be simple to add as an extra auxiliary config variable. - The logic for getting the starting deck and notetype has been moved into the backend. It should be the same as the older Python code, with one exception: when "change deck depending on notetype" is enabled in the preferences, it will start with the current notetype ("curModel"), instead of first trying to get a deck-specific notetype. - ModelChooser has been duplicated into notetypechooser.py, and it has been updated to solely be concerned with keeping track of a selected notetype - it no longer alters global state.
2021-03-08 14:23:24 +01:00
if not self.col.decks.name_if_exists(did):
2021-03-26 04:48:26 +01:00
showInfo(tr.qt_misc_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()
2021-02-01 11:59:18 +01:00
def _reviewState(self, oldState: str) -> None:
self.reviewer.show()
2021-02-01 11:59:18 +01:00
def _reviewCleanup(self, newState: str) -> None:
if newState != "resetRequired" and newState != "review":
self.reviewer.cleanup()
# Resetting state
##########################################################################
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
def _increase_background_ops(self) -> None:
if not self._background_op_count:
2021-03-18 01:54:02 +01:00
gui_hooks.backend_will_block()
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
self._background_op_count += 1
def _decrease_background_ops(self) -> None:
self._background_op_count -= 1
if not self._background_op_count:
2021-03-18 01:54:02 +01:00
gui_hooks.backend_did_block()
if not self._background_op_count >= 0:
raise Exception("no background ops active")
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
2021-03-14 10:54:15 +01:00
def _synthesize_op_did_execute_from_reset(self) -> None:
"""Fire the `operation_did_execute` hook with everything marked as changed,
after legacy code has called .reset()"""
undoable ops now return changes directly; add new *_ops.py files - Introduced a new transact() method that wraps the return value in a separate struct that describes the changes that were made. - Changes are now gathered from the undo log, so we don't need to guess at what was changed - eg if update_note() is called with identical note contents, no changes are returned. Card changes will only be set if cards were actually generated by the update_note() call, and tag will only be set if a new tag was added. - mw.perform_op() has been updated to expect the op to return the changes, or a structure with the changes in it, and it will use them to fire the change hook, instead of fetching the changes from undo_status(), so there is no risk of race conditions. - the various calls to mw.perform_op() have been split into separate files like card_ops.py. Aside from making the code cleaner, this works around a rather annoying issue with mypy. Because we run it with no_strict_optional, mypy is happy to accept an operation that returns None, despite the type signature saying it requires changes to be returned. Turning no_strict_optional on for the whole codebase is not practical at the moment, but we can enable it for individual files. Still todo: - The cursor keeps moving back to the start of a field when typing - we need to ignore the refresh hook when we are the initiator. - The busy cursor icon should probably be delayed a few hundreds ms. - Still need to think about a nicer way of handling saveNow() - op_made_changes(), op_affects_study_queue() might be better embedded as properties in the object instead
2021-03-16 05:26:42 +01:00
op = OpChanges()
for field in op.DESCRIPTOR.fields:
if field.name != "kind":
setattr(op, field.name, True)
gui_hooks.operation_did_execute(op, None)
2021-03-14 10:54:15 +01:00
def on_operation_did_execute(
self, changes: OpChanges, handler: object | None
) -> None:
"Notify current screen of changes."
focused = current_window() == self
if self.state == "review":
dirty = self.reviewer.op_executed(changes, handler, focused)
elif self.state == "overview":
dirty = self.overview.op_executed(changes, handler, focused)
elif self.state == "deckBrowser":
dirty = self.deckBrowser.op_executed(changes, handler, focused)
else:
dirty = False
if not focused and dirty:
self.fade_out_webview()
if changes.mtime:
self.toolbar.update_sync_status()
def on_focus_did_change(
self, new_focus: QWidget | None, _old: QWidget | None
) -> None:
"If main window has received focus, ensure current UI state is updated."
if new_focus and new_focus.window() == self:
if self.state == "review":
self.reviewer.refresh_if_needed()
elif self.state == "overview":
self.overview.refresh_if_needed()
elif self.state == "deckBrowser":
self.deckBrowser.refresh_if_needed()
def fade_out_webview(self) -> None:
self.web.eval("document.body.style.opacity = 0.3")
def fade_in_webview(self) -> None:
self.web.eval("document.body.style.opacity = 1")
2021-03-14 10:54:15 +01:00
def reset(self, unused_arg: bool = False) -> None:
"""Legacy method of telling UI to refresh after changes made to DB.
New code should use CollectionOp() instead."""
if self.col:
2021-03-14 10:54:15 +01:00
# fire new `operation_did_execute` hook first. If the overview
# or review screen are currently open, they will rebuild the study
# queues (via mw.col.reset())
self._synthesize_op_did_execute_from_reset()
# fire the old reset hook
2020-01-15 07:53:24 +01:00
gui_hooks.state_did_reset()
self.update_undo_actions()
2021-03-14 10:54:15 +01:00
# legacy
2021-02-01 11:59:18 +01:00
def requireReset(
self,
modal: bool = False,
reason: Any = None,
2021-02-01 11:59:18 +01:00
context: Any = None,
) -> None:
traceback.print_stack(file=sys.stdout)
print("requireReset() is obsolete; please use CollectionOp()")
self.reset()
2019-12-20 09:43:52 +01:00
def maybeReset(self) -> None:
pass
2021-02-01 14:28:21 +01:00
def delayedMaybeReset(self) -> None:
pass
2021-03-14 10:54:15 +01:00
def _resetRequiredState(self, oldState: MainWindowState) -> None:
pass
# HTML helpers
##########################################################################
2019-12-23 01:34:10 +01:00
def button(
self,
link: str,
name: str,
key: str | None = None,
2019-12-23 01:34:10 +01:00
class_: str = "",
id: str = "",
extra: str = "",
) -> str:
class_ = f"but {class_}"
if key:
key = tr.actions_shortcut_key(val=key)
else:
key = ""
2019-12-23 01:34:10 +01:00
return """
<button id="{}" class="{}" onclick="pycmd('{}');return false;"
title="{}" {}>{}</button>""".format(
2019-12-23 01:34:10 +01:00
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.FocusPolicy.WheelFocus)
self.toolbar = aqt.toolbar.Toolbar(self, tweb)
# main area
self.web = aqt.webview.AnkiWebView(title="main webview")
self.web.setFocusPolicy(Qt.FocusPolicy.WheelFocus)
self.web.setMinimumWidth(400)
# bottom area
sweb = self.bottomWeb = aqt.webview.AnkiWebView(title="bottom toolbar")
sweb.setFocusPolicy(Qt.FocusPolicy.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 is_win:
for webview in self.web, self.bottomWeb:
webview.force_load_hack()
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)
2021-02-02 14:30:53 +01:00
def onUnixSignal(self, signum: Any, frame: Any) -> None:
2020-03-02 11:50:17 +01:00
# schedule a rollback & quit
2021-02-01 14:28:21 +01:00
def quit() -> None:
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: list | None) -> 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()
2021-02-01 11:59:18 +01:00
def maybe_check_for_addon_updates(self) -> None:
2020-01-19 02:31:09 +01:00
last_check = self.pm.last_addon_update_check()
PEP8 for rest of pylib (#1451) * PEP8 dbproxy.py * PEP8 errors.py * PEP8 httpclient.py * PEP8 lang.py * PEP8 latex.py * Add decorator to deprectate key words * Make replacement for deprecated attribute optional * Use new helper `_print_replacement_warning()` * PEP8 media.py * PEP8 rsbackend.py * PEP8 sound.py * PEP8 stdmodels.py * PEP8 storage.py * PEP8 sync.py * PEP8 tags.py * PEP8 template.py * PEP8 types.py * Fix DeprecatedNamesMixinForModule The class methods need to be overridden with instance methods, so every module has its own dicts. * Use `# pylint: disable=invalid-name` instead of id * PEP8 utils.py * Only decorate `__getattr__` with `@no_type_check` * Fix mypy issue with snakecase Importing it from `anki._vendor` raises attribute errors. * Format * Remove inheritance of DeprecatedNamesMixin There's almost no shared code now and overriding classmethods with instance methods raises mypy issues. * Fix traceback frames of deprecation warnings * remove fn/TimedLog (dae) Neither Anki nor add-ons appear to have been using it * fix some issues with stringcase use (dae) - the wheel was depending on the PyPI version instead of our vendored version - _vendor:stringcase should not have been listed in the anki py_library. We already include the sources in py_srcs, and need to refer to them directly. By listing _vendor:stringcase as well, we were making a top-level stringcase library available, which would have only worked for distributing because the wheel definition was also incorrect. - mypy errors are what caused me to mistakenly add the above - they were because the type: ignore at the top of stringcase.py was causing mypy to completely ignore the file, so it was not aware of any attributes it contained.
2021-10-25 06:50:13 +02:00
elap = int_time() - last_check
2020-01-19 02:31:09 +01:00
if elap > 86_400:
check_and_prompt_for_updates(
self,
self.addonManager,
self.on_updates_installed,
requested_by_user=False,
2020-01-19 02:31:09 +01:00
)
PEP8 for rest of pylib (#1451) * PEP8 dbproxy.py * PEP8 errors.py * PEP8 httpclient.py * PEP8 lang.py * PEP8 latex.py * Add decorator to deprectate key words * Make replacement for deprecated attribute optional * Use new helper `_print_replacement_warning()` * PEP8 media.py * PEP8 rsbackend.py * PEP8 sound.py * PEP8 stdmodels.py * PEP8 storage.py * PEP8 sync.py * PEP8 tags.py * PEP8 template.py * PEP8 types.py * Fix DeprecatedNamesMixinForModule The class methods need to be overridden with instance methods, so every module has its own dicts. * Use `# pylint: disable=invalid-name` instead of id * PEP8 utils.py * Only decorate `__getattr__` with `@no_type_check` * Fix mypy issue with snakecase Importing it from `anki._vendor` raises attribute errors. * Format * Remove inheritance of DeprecatedNamesMixin There's almost no shared code now and overriding classmethods with instance methods raises mypy issues. * Fix traceback frames of deprecation warnings * remove fn/TimedLog (dae) Neither Anki nor add-ons appear to have been using it * fix some issues with stringcase use (dae) - the wheel was depending on the PyPI version instead of our vendored version - _vendor:stringcase should not have been listed in the anki py_library. We already include the sources in py_srcs, and need to refer to them directly. By listing _vendor:stringcase as well, we were making a top-level stringcase library available, which would have only worked for distributing because the wheel definition was also incorrect. - mypy errors are what caused me to mistakenly add the above - they were because the type: ignore at the top of stringcase.py was causing mypy to completely ignore the file, so it was not aware of any attributes it contained.
2021-10-25 06:50:13 +02:00
self.pm.set_last_addon_update_check(int_time())
2020-01-19 02:31:09 +01:00
def on_updates_installed(self, log: list[DownloadLogEntry]) -> None:
2020-01-19 02:31:09 +01:00
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()
clear_unused_tags and browser redraw improvements - clear_unused_tags() is now undoable, and returns the number of removed notes - add a new mw.query_op() helper for immutable queries - decouple "freeze/unfreeze ui state" hooks from the "interface update required" hook, so that the former is fired even on error, and can be made re-entrant - use a 'block_updates' flag in Python, instead of setUpdatesEnabled(), as the latter has the side-effect of preventing child windows like tooltips from appearing, and forces a full redrawn when updates are enabled again. The new behaviour leads to the card list blanking out when a long-running op is running, but in the future if we cache the cell values we can just display them from the cache instead. - we were indiscriminately saving the note with saveNow(), due to the call to saveTags(). Changed so that it only saves when the tags field is focused. - drain the "on_done" queue on main before launching a new background task, to lower the chances of something in on_done making a small query to the DB and hanging until a long op finishes - the duplicate check in the editor was executed after the webview loads, leading to it hanging until the sidebar finishes loading. Run it at set_note() time instead, so that the editor loads first. - don't throw an error when a long-running op started with with_progress() finishes after the window it was launched from has closed - don't throw an error when the browser is closed before the sidebar has finished loading
2021-03-17 12:27:42 +01:00
self._background_op_count = 0
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
##########################################################################
2021-02-01 11:59:18 +01:00
def on_sync_button_clicked(self) -> None:
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:
sync_login(
self,
lambda: self._sync_collection_and_media(self._refresh_after_sync),
)
2020-05-31 02:53:54 +02:00
else:
self._sync_collection_and_media(self._refresh_after_sync)
2021-02-01 11:59:18 +01:00
def _refresh_after_sync(self) -> None:
self.toolbar.redraw()
2021-02-01 14:28:21 +01:00
def _sync_collection_and_media(self, after_sync: Callable[[], None]) -> None:
2020-05-31 02:53:54 +02:00
"Caller should ensure auth available."
# start media sync if not already running
if not self.media_syncer.is_syncing():
self.media_syncer.start()
2021-02-01 14:28:21 +01:00
def on_collection_sync_finished() -> None:
self.col.clear_python_undo()
self.col.models._clear_cache()
gui_hooks.sync_did_finish()
2020-09-15 13:06:11 +02:00
self.reset()
after_sync()
gui_hooks.sync_will_start()
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
2021-02-01 14:28:21 +01:00
def _sync(self) -> None:
2020-05-31 02:53:54 +02:00
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.WindowState.WindowMinimized) # type: ignore
return True
2019-12-20 09:43:52 +01:00
def setupStyle(self) -> None:
theme_manager.apply_style()
if is_lin:
# On Linux, the check requires invoking an external binary,
# which we don't want to be doing frequently
interval_secs = 300
else:
interval_secs = 5
self.progress.timer(
interval_secs * 1000,
theme_manager.apply_style_if_system_style_changed,
True,
False,
)
def set_theme(self, theme: Theme) -> None:
self.pm.set_theme(theme)
self.setupStyle()
# 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)
self.stateShortcuts: list[QShortcut] = []
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
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
runHook(f"{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) # type: ignore
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 undo(self) -> None:
"Call operations/collection.py:undo() directly instead."
2021-04-06 06:36:13 +02:00
undo(parent=self)
def redo(self) -> None:
"Call operations/collection.py:redo() directly instead."
redo(parent=self)
def undo_actions_info(self) -> UndoActionsInfo:
"Info about the current undo/redo state for updating menus."
status = self.col.undo_status() if self.col else UndoStatus()
return UndoActionsInfo.from_undo_status(status)
def update_undo_actions(self) -> None:
"""Tell the UI to redraw the undo/redo menu actions based on the current state.
Usually you do not need to call this directly; it is called when a
CollectionOp is run, and will be called when the legacy .reset() or
.checkpoint() methods are used."""
info = self.undo_actions_info()
self.form.actionUndo.setText(info.undo_text)
self.form.actionUndo.setEnabled(info.can_undo)
self.form.actionRedo.setText(info.redo_text)
self.form.actionRedo.setEnabled(info.can_redo)
self.form.actionRedo.setVisible(info.show_redo)
gui_hooks.undo_state_did_change(info)
2021-03-14 10:54:15 +01:00
2021-02-01 11:59:18 +01:00
def checkpoint(self, name: str) -> None:
self.col.save(name)
self.update_undo_actions()
2019-12-20 09:43:52 +01:00
def autosave(self) -> None:
self.col.autosave()
self.update_undo_actions()
maybeEnableUndo = update_undo_actions
onUndo = undo
# 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, card=self.reviewer.card)
2021-02-01 11:59:18 +01:00
def onEditCurrent(self) -> None:
aqt.dialogs.open("EditCurrent", self)
2021-02-01 14:28:21 +01:00
def onOverview(self) -> None:
self.col.reset()
self.moveToState("overview")
2021-02-01 11:59:18 +01:00
def onStats(self) -> None:
deck = self._selectedDeck()
if not deck:
return
want_old = KeyboardModifiersPressed().shift
if want_old:
aqt.dialogs.open("DeckStats", self)
else:
aqt.dialogs.open("NewDeckStats", self)
2021-02-01 14:28:21 +01:00
def onPrefs(self) -> None:
aqt.dialogs.open("Preferences", self)
2021-02-01 11:59:18 +01:00
def onNoteTypes(self) -> None:
2012-12-22 00:21:24 +01:00
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)
2021-02-01 14:28:21 +01:00
def onAbout(self) -> None:
aqt.dialogs.open("About", self)
2021-02-01 14:28:21 +01:00
def onDonate(self) -> None:
openLink(aqt.appDonate)
2021-02-01 14:28:21 +01:00
def onDocumentation(self) -> None:
openHelp(HelpPage.INDEX)
# legacy
def onDeckConf(self, deck: DeckDict | None = None) -> None:
pass
# 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):
2021-03-26 04:48:26 +01:00
showInfo(tr.qt_misc_please_use_fileimport_to_import_this())
2019-12-20 09:43:52 +01:00
return None
aqt.importing.importFile(self, path)
2019-12-20 09:43:52 +01:00
return None
2021-02-01 11:59:18 +01:00
def onImport(self) -> None:
import aqt.importing
2019-12-23 01:34:10 +01:00
aqt.importing.onImport(self)
def onExport(self, did: DeckId | None = None) -> 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
##########################################################################
2021-02-01 14:28:21 +01:00
def installAddon(self, path: str, startup: bool = False) -> None:
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) -> None:
2021-03-24 04:17:12 +01:00
aqt.dialogs.open("FilteredDeckConfigDialog", self)
# 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.undo)
qconnect(m.actionRedo.triggered, self.redo)
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()
2021-02-02 14:30:53 +01:00
def newVerAvail(self, ver: str) -> None:
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: dict) -> None:
aqt.update.showMessages(self, data)
2021-02-02 14:30:53 +01:00
def clockIsOff(self, diff: int) -> None:
if dev_mode:
2020-11-15 09:29:16 +01:00
print("clock is off; ignoring")
return
diffText = tr.qt_misc_second(count=diff)
warn = tr.qt_misc_in_order_to_ensure_your_collection(val="%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)
# periodic garbage collection
self.progress.timer(15 * 60 * 1000, self.garbage_collect_now, False)
# 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)
2021-02-01 14:28:21 +01:00
def onRefreshTimer(self) -> None:
if self.state == "deckBrowser":
self.deckBrowser.refresh()
elif self.state == "overview":
self.overview.refresh()
2021-02-01 11:59:18 +01:00
def on_autosync_timer(self) -> None:
2020-02-05 03:38:36 +01:00
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()
# Permanent 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)
gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
gui_hooks.focus_did_change.append(self.on_focus_did_change)
2020-01-13 05:38:05 +01:00
self._activeWindowOnPlay: QWidget | None = None
2021-02-01 14:28:21 +01:00
def onOdueInvalid(self) -> None:
2021-03-26 04:48:26 +01:00
showWarning(tr.qt_misc_invalid_property_found_on_card_please())
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
##########################################################################
def onRemNotes(self, col: Collection, nids: Sequence[NoteId]) -> 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(
f"select id, mid, flds from notes where id in {ids2str(nids)}"
2019-12-23 01:34:10 +01:00
):
PEP8 for rest of pylib (#1451) * PEP8 dbproxy.py * PEP8 errors.py * PEP8 httpclient.py * PEP8 lang.py * PEP8 latex.py * Add decorator to deprectate key words * Make replacement for deprecated attribute optional * Use new helper `_print_replacement_warning()` * PEP8 media.py * PEP8 rsbackend.py * PEP8 sound.py * PEP8 stdmodels.py * PEP8 storage.py * PEP8 sync.py * PEP8 tags.py * PEP8 template.py * PEP8 types.py * Fix DeprecatedNamesMixinForModule The class methods need to be overridden with instance methods, so every module has its own dicts. * Use `# pylint: disable=invalid-name` instead of id * PEP8 utils.py * Only decorate `__getattr__` with `@no_type_check` * Fix mypy issue with snakecase Importing it from `anki._vendor` raises attribute errors. * Format * Remove inheritance of DeprecatedNamesMixin There's almost no shared code now and overriding classmethods with instance methods raises mypy issues. * Fix traceback frames of deprecation warnings * remove fn/TimedLog (dae) Neither Anki nor add-ons appear to have been using it * fix some issues with stringcase use (dae) - the wheel was depending on the PyPI version instead of our vendored version - _vendor:stringcase should not have been listed in the anki py_library. We already include the sources in py_srcs, and need to refer to them directly. By listing _vendor:stringcase as well, we were making a top-level stringcase library available, which would have only worked for distributing because the wheel definition was also incorrect. - mypy errors are what caused me to mistakenly add the above - they were because the type: ignore at the top of stringcase.py was causing mypy to completely ignore the file, so it was not aware of any attributes it contained.
2021-10-25 06:50:13 +02:00
fields = split_fields(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
2021-02-01 11:59:18 +01:00
def onSchemaMod(self, arg: bool) -> bool:
if not self.inMainThread():
raise Exception("not in main thread")
progress_shown = self.progress.busy()
if progress_shown:
self.progress.finish()
2021-03-26 04:48:26 +01:00
ret = askUser(tr.qt_misc_the_requested_change_will_require_a())
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."""
2021-06-27 07:12:22 +02:00
if self.col.schema_changed():
return True
2021-03-26 04:48:26 +01:00
return askUser(tr.qt_misc_the_requested_change_will_require_a())
# Advanced features
##########################################################################
2021-02-01 11:59:18 +01:00
def onCheckDB(self) -> None:
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:
2020-11-09 10:45:14 +01:00
gui_hooks.media_check_will_start()
2020-02-10 08:58:54 +01:00
check_media_db(self)
2021-02-01 11:59:18 +01:00
def onStudyDeck(self) -> None:
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:
# fixme: this is silly, it should be returning an ID
deck_id = self.col.decks.id(ret.name)
set_current_deck(parent=self, deck_id=deck_id).success(
lambda out: self.moveToState("overview")
).run_in_background()
def onEmptyCards(self) -> None:
show_empty_cards(self)
# Debugging
######################################################################
2021-02-01 14:28:21 +01:00
def onDebug(self) -> None:
frm = self.debug_diag_form = aqt.forms.debug.Ui_Dialog()
class DebugDialog(QDialog):
silentlyClose = True
2021-02-01 14:28:21 +01:00
def reject(self) -> None:
super().reject()
saveSplitter(frm.splitter, "DebugConsoleWindow")
saveGeom(self, "DebugConsoleWindow")
d = self.debugDiag = DebugDialog()
disable_help_button(d)
frm.setupUi(d)
restoreGeom(d, "DebugConsoleWindow")
restoreSplitter(frm.splitter, "DebugConsoleWindow")
font = QFontDatabase.systemFont(QFontDatabase.SystemFont.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: Union[QCloseEvent, QContextMenuEvent], 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.setShortcut(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.setShortcut(QKeySequence("ctrl+shift+l"))
qconnect(a.triggered, frm.text.clear)
menu.exec(QCursor.pos())
frm.log.contextMenuEvent = lambda ev: addContextMenu(ev, "log") # type: ignore[assignment]
frm.text.contextMenuEvent = lambda ev: addContextMenu(ev, "text") # type: ignore[assignment]
2020-03-04 18:11:13 +01:00
gui_hooks.debug_console_will_show(d)
d.show()
def _captureOutput(self, on: bool) -> None:
2021-02-02 14:30:53 +01:00
mw2 = self
2019-12-23 01:34:10 +01:00
class Stream:
2021-02-02 14:30:53 +01:00
def write(self, data: str) -> None:
mw2._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 copy
import pprint
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) -> anki.cards.Card | None:
2020-03-23 08:44:26 +01:00
card = self.reviewer.card
self._card_repr(card)
return card
def _debugBrowserCard(self) -> anki.cards.Card | None:
2020-03-23 08:44:26 +01:00
card = aqt.dialogs._dialogs["Browser"][1].card
self._card_repr(card)
return card
2021-02-02 14:30:53 +01:00
def onDebugPrint(self, frm: aqt.forms.debug.Ui_Dialog) -> None:
cursor = frm.text.textCursor()
position = cursor.position()
cursor.select(QTextCursor.SelectionType.LineUnderCursor)
line = cursor.selectedText()
pfx, sfx = "pp(", ")"
if not line.startswith(pfx):
line = f"{pfx}{line}{sfx}"
cursor.insertText(line)
cursor.setPosition(position + len(pfx))
frm.text.setTextCursor(cursor)
self.onDebugRet(frm)
2021-02-02 14:30:53 +01:00
def onDebugRet(self, frm: aqt.forms.debug.Ui_Dialog) -> None:
import pprint
import 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 += f">>> {line}\n"
else:
buf += f"... {line}\n"
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:
2021-03-26 04:48:26 +01:00
to_append = tr.qt_misc_non_unicode_text()
2020-03-04 18:20:02 +01:00
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 is_mac:
# 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 is_win:
# make sure ctypes is bundled
2019-12-23 01:34:10 +01:00
from ctypes import windll, wintypes # type: ignore
_dummy1 = windll
_dummy2 = wintypes
def maybeHideAccelerators(self, tgt: Any | None = None) -> None:
if not self.hideMenuAccels:
return
tgt = tgt or self
for action_ in tgt.findChildren(QAction):
action = cast(QAction, action_)
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):
cast(QAction, action).setStatusTip("")
def onMacMinimize(self) -> None:
self.setWindowState(self.windowState() | Qt.WindowState.WindowMinimized) # type: ignore
# Single instance support
##########################################################################
2019-12-20 09:43:52 +01:00
def setupAppMsg(self) -> None:
qconnect(self.app.appMsg, self.onAppMsg)
def onAppMsg(self, buf: str) -> None:
is_addon = self._isAddon(buf)
2020-01-03 18:23:28 +01:00
if self.state == "startup":
# try again in a second
self.progress.timer(
2019-12-23 01:34:10 +01:00
1000, lambda: self.onAppMsg(buf), False, requiresCollection=False
)
return
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:
2021-03-26 04:48:26 +01:00
msg = tr.qt_misc_addon_will_be_installed_when_a()
else:
2021-03-26 04:48:26 +01:00
msg = tr.qt_misc_deck_will_be_imported_when_a()
tooltip(msg)
return
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(
2021-03-26 04:48:26 +01:00
tr.qt_misc_please_ensure_a_profile_is_open(),
2019-12-23 01:34:10 +01:00
parent=None,
)
2019-12-20 09:43:52 +01:00
return None
# raise window
if is_win:
# on windows we can raise the window by minimizing and restoring
self.showMinimized()
self.setWindowState(Qt.WindowState.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)
def interactiveState(self) -> bool:
"True if not in profile manager, syncing, etc."
return self.state in ("overview", "review", "deckBrowser")
# GC
##########################################################################
# The default Python garbage collection can trigger on any thread. This can
# cause crashes if Qt objects are garbage-collected, as Qt expects access
# only on the main thread. So Anki disables the default GC on startup, and
# instead runs it on a timer, and after dialog close.
# The gc after dialog close is necessary to free up the memory and extra
# processes that webviews spawn, as a lot of the GUI code creates ref cycles.
def garbage_collect_on_dialog_finish(self, dialog: QDialog) -> None:
qconnect(
dialog.finished, lambda: self.deferred_delete_and_garbage_collect(dialog)
)
def deferred_delete_and_garbage_collect(self, obj: QObject) -> None:
obj.deleteLater()
self.progress.timer(
1000, self.garbage_collect_now, False, requiresCollection=False
)
def disable_automatic_garbage_collection(self) -> None:
gc.collect()
gc.disable()
def garbage_collect_now(self) -> None:
# gc.collect() has optional arguments that will cause problems if
# it's passed directly to a QTimer, and pylint complains if we
# wrap it in a lambda, so we use this trivial wrapper
gc.collect()
2016-07-07 15:39:48 +02:00
# legacy aliases
setupDialogGC = garbage_collect_on_dialog_finish
gcWindow = deferred_delete_and_garbage_collect
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 f'<base href="{self.serverURL()}">'
2019-12-20 09:43:52 +01:00
def serverURL(self) -> str:
return "http://127.0.0.1:%d/" % self.mediaServer.getPort()
# legacy
class ResetReason(enum.Enum):
Unknown = "unknown"
AddCardsAddNote = "addCardsAddNote"
EditCurrentInit = "editCurrentInit"
EditorBridgeCmd = "editorBridgeCmd"
BrowserSetDeck = "browserSetDeck"
BrowserAddTags = "browserAddTags"
BrowserRemoveTags = "browserRemoveTags"
BrowserSuspend = "browserSuspend"
BrowserReposition = "browserReposition"
BrowserReschedule = "browserReschedule"
BrowserFindReplace = "browserFindReplace"
BrowserTagDupes = "browserTagDupes"
BrowserDeleteDeck = "browserDeleteDeck"