2019-02-05 04:59:03 +01:00
|
|
|
# Copyright: Ankitects Pty Ltd and contributors
|
2012-12-21 08:51:59 +01:00
|
|
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
2020-02-08 23:59:29 +01:00
|
|
|
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
|
2020-01-03 17:57:33 +01:00
|
|
|
import os
|
2013-10-18 00:48:45 +02:00
|
|
|
import re
|
|
|
|
import signal
|
2016-07-04 05:22:35 +02:00
|
|
|
import time
|
2020-04-25 11:44:48 +02:00
|
|
|
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
|
2017-01-08 10:29:57 +01:00
|
|
|
from threading import Thread
|
2021-02-02 14:30:53 +01:00
|
|
|
from typing import Any, Callable, Dict, List, Optional, Sequence, TextIO, Tuple, cast
|
2019-12-20 10:19:03 +01:00
|
|
|
|
2020-01-24 06:48:40 +01:00
|
|
|
import anki
|
2013-10-18 00:48:45 +02:00
|
|
|
import aqt
|
2019-12-20 10:19:03 +01:00
|
|
|
import aqt.mediasrv
|
2020-01-02 10:43:19 +01:00
|
|
|
import aqt.mpv
|
2013-10-18 00:48:45 +02:00
|
|
|
import aqt.progress
|
2020-01-02 10:43:19 +01:00
|
|
|
import aqt.sound
|
2013-10-18 00:48:45 +02:00
|
|
|
import aqt.stats
|
2019-12-20 10:19:03 +01:00
|
|
|
import aqt.toolbar
|
|
|
|
import aqt.webview
|
2020-01-13 04:57:51 +01:00
|
|
|
from anki import hooks
|
2021-01-31 06:55:08 +01:00
|
|
|
from anki._backend import RustBackend as _RustBackend
|
2021-03-12 08:56:13 +01:00
|
|
|
from anki.collection import (
|
|
|
|
BackendUndo,
|
|
|
|
Checkpoint,
|
|
|
|
Collection,
|
|
|
|
Config,
|
|
|
|
ReviewUndo,
|
|
|
|
UndoResult,
|
|
|
|
)
|
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
|
2020-01-20 13:01:38 +01:00
|
|
|
from anki.sound import AVTag, SoundOrVideoTag
|
2021-03-04 10:17:19 +01:00
|
|
|
from anki.types import assert_exhaustive
|
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
|
2020-04-25 11:44:48 +02:00
|
|
|
from aqt.emptycards import show_empty_cards
|
2020-01-23 09:19:54 +01:00
|
|
|
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
|
2020-01-23 06:08:10 +01:00
|
|
|
from aqt.theme import theme_manager
|
2019-12-23 01:34:10 +01:00
|
|
|
from aqt.utils import (
|
2020-02-27 11:32:57 +01:00
|
|
|
TR,
|
2021-01-25 14:45:47 +01:00
|
|
|
HelpPage,
|
2019-12-23 01:34:10 +01:00
|
|
|
askUser,
|
|
|
|
checkInvalidFilename,
|
2021-01-07 05:24:49 +01:00
|
|
|
disable_help_button,
|
2019-12-23 01:34:10 +01:00
|
|
|
getFile,
|
|
|
|
getOnlyText,
|
|
|
|
openHelp,
|
|
|
|
openLink,
|
|
|
|
restoreGeom,
|
2020-05-31 01:58:00 +02:00
|
|
|
restoreSplitter,
|
2019-12-23 01:34:10 +01:00
|
|
|
restoreState,
|
2020-05-31 01:58:00 +02:00
|
|
|
saveGeom,
|
|
|
|
saveSplitter,
|
2019-12-23 01:34:10 +01:00
|
|
|
showInfo,
|
|
|
|
showWarning,
|
|
|
|
tooltip,
|
2020-02-27 11:32:57 +01:00
|
|
|
tr,
|
2019-12-23 01:34:10 +01:00
|
|
|
)
|
2013-12-06 05:27:13 +01:00
|
|
|
|
2020-01-23 09:19:54 +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):
|
2021-02-01 11:59:18 +01:00
|
|
|
Unknown = "unknown"
|
2020-08-16 18:49:51 +02:00
|
|
|
AddCardsAddNote = "addCardsAddNote"
|
|
|
|
EditCurrentInit = "editCurrentInit"
|
|
|
|
EditorBridgeCmd = "editorBridgeCmd"
|
|
|
|
BrowserSetDeck = "browserSetDeck"
|
|
|
|
BrowserAddTags = "browserAddTags"
|
2021-01-06 15:04:03 +01:00
|
|
|
BrowserRemoveTags = "browserRemoveTags"
|
2020-08-16 18:49:51 +02:00
|
|
|
BrowserSuspend = "browserSuspend"
|
|
|
|
BrowserReposition = "browserReposition"
|
|
|
|
BrowserReschedule = "browserReschedule"
|
|
|
|
BrowserFindReplace = "browserFindReplace"
|
|
|
|
BrowserTagDupes = "browserTagDupes"
|
2021-01-04 11:36:45 +01:00
|
|
|
BrowserDeleteDeck = "browserDeleteDeck"
|
2020-08-16 18:33:33 +02:00
|
|
|
|
|
|
|
|
2020-02-08 23:59:29 +01:00
|
|
|
class ResetRequired:
|
2021-02-01 14:28:21 +01:00
|
|
|
def __init__(self, mw: AnkiQt) -> None:
|
2020-02-08 23:59:29 +01:00
|
|
|
self.mw = mw
|
|
|
|
|
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
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
|
2020-01-23 06:08:10 +01:00
|
|
|
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,
|
2021-01-31 06:55:08 +01:00
|
|
|
backend: _RustBackend,
|
2019-12-23 01:34:10 +01:00
|
|
|
opts: Namespace,
|
|
|
|
args: List[Any],
|
|
|
|
) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
QMainWindow.__init__(self)
|
2020-03-14 00:45:00 +01:00
|
|
|
self.backend = backend
|
2012-12-21 08:51:59 +01:00
|
|
|
self.state = "startup"
|
2017-10-03 04:12:57 +02:00
|
|
|
self.opts = opts
|
2020-05-20 09:56:52 +02:00
|
|
|
self.col: Optional[Collection] = None
|
2020-05-04 13:30:41 +02:00
|
|
|
self.taskman = TaskManager(self)
|
2020-02-04 03:26:10 +01:00
|
|
|
self.media_syncer = MediaSyncer(self)
|
2012-12-21 08:51:59 +01:00
|
|
|
aqt.mw = self
|
|
|
|
self.app = app
|
2019-12-19 02:39:40 +01:00
|
|
|
self.pm = profileManager
|
2012-12-21 08:51:59 +01:00
|
|
|
# init rest of app
|
2020-10-12 21:57:49 +02:00
|
|
|
self.safeMode = (
|
|
|
|
self.app.queryKeyboardModifiers() & Qt.ShiftModifier
|
|
|
|
) or self.opts.safemode
|
2012-12-21 08:51:59 +01:00
|
|
|
try:
|
|
|
|
self.setupUI()
|
2020-01-04 04:34:16 +01:00
|
|
|
self.setupAddons(args)
|
2020-07-02 02:23:14 +02:00
|
|
|
self.finish_ui_setup()
|
2012-12-21 08:51:59 +01:00
|
|
|
except:
|
2020-11-17 12:47:47 +01:00
|
|
|
showInfo(tr(TR.QT_MISC_ERROR_DURING_STARTUP, val=traceback.format_exc()))
|
2012-12-21 08:51:59 +01:00
|
|
|
sys.exit(1)
|
2013-05-17 08:32:17 +02:00
|
|
|
# must call this after ui set up
|
|
|
|
if self.safeMode:
|
2020-11-18 02:32:22 +01:00
|
|
|
tooltip(tr(TR.QT_MISC_SHIFT_KEY_WAS_HELD_DOWN_SKIPPING))
|
2012-12-21 08:51:59 +01:00
|
|
|
# were we given a file to import?
|
2020-01-04 04:34:16 +01:00
|
|
|
if args and args[0] and not self._isAddon(args[0]):
|
2016-05-12 06:45:35 +02:00
|
|
|
self.onAppMsg(args[0])
|
2012-12-21 08:51:59 +01:00
|
|
|
# Load profile in a timer so we can let the window finish init and not
|
|
|
|
# close on profile load error.
|
2019-04-16 05:24:38 +02:00
|
|
|
if isWin:
|
|
|
|
fn = self.setupProfileAfterWebviewsLoaded
|
|
|
|
else:
|
|
|
|
fn = self.setupProfile
|
2020-05-28 13:30:22 +02:00
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def on_window_init() -> None:
|
2020-05-28 13:30:22 +02:00
|
|
|
fn()
|
|
|
|
gui_hooks.main_window_did_init()
|
|
|
|
|
|
|
|
self.progress.timer(10, on_window_init, False, requiresCollection=False)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def setupUI(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
self.col = None
|
2017-01-08 04:38:12 +01:00
|
|
|
self.setupCrashLog()
|
2021-03-05 04:07:52 +01:00
|
|
|
self.disable_automatic_garbage_collection()
|
2012-12-21 08:51:59 +01:00
|
|
|
self.setupAppMsg()
|
|
|
|
self.setupKeys()
|
|
|
|
self.setupThreads()
|
2017-07-28 08:19:06 +02:00
|
|
|
self.setupMediaServer()
|
2017-10-05 05:48:24 +02:00
|
|
|
self.setupSound()
|
2019-03-06 14:18:26 +01:00
|
|
|
self.setupSpellCheck()
|
2020-01-23 06:08:10 +01:00
|
|
|
self.setupStyle()
|
2012-12-21 08:51:59 +01:00
|
|
|
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()
|
2012-12-21 08:51:59 +01:00
|
|
|
self.updateTitleBar()
|
|
|
|
# screens
|
|
|
|
self.setupDeckBrowser()
|
|
|
|
self.setupOverview()
|
|
|
|
self.setupReviewer()
|
|
|
|
|
2020-07-02 02:23:14 +02:00
|
|
|
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:
|
2019-04-16 05:24:38 +02:00
|
|
|
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,
|
|
|
|
)
|
2019-04-16 05:24:38 +02:00
|
|
|
return
|
|
|
|
else:
|
|
|
|
w.requiresCol = True
|
|
|
|
|
|
|
|
self.setupProfile()
|
|
|
|
|
2020-04-25 11:44:48 +02:00
|
|
|
def weakref(self) -> AnkiQt:
|
|
|
|
"Shortcut to create a weak reference that doesn't break code completion."
|
|
|
|
return weakref.proxy(self) # type: ignore
|
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
# Profiles
|
|
|
|
##########################################################################
|
|
|
|
|
2017-08-16 11:45:39 +02:00
|
|
|
class ProfileManager(QMainWindow):
|
|
|
|
onClose = pyqtSignal()
|
|
|
|
closeFires = True
|
|
|
|
|
2020-08-01 04:27:54 +02:00
|
|
|
def closeEvent(self, evt: QCloseEvent) -> None:
|
2017-08-16 11:45:39 +02:00
|
|
|
if self.closeFires:
|
2020-08-01 04:27:54 +02:00
|
|
|
self.onClose.emit() # type: ignore
|
2017-08-16 11:45:39 +02:00
|
|
|
evt.accept()
|
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def closeWithoutQuitting(self) -> None:
|
2017-08-16 11:45:39 +02:00
|
|
|
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"]:
|
2019-04-09 10:48:50 +02:00
|
|
|
# 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
|
2019-04-09 10:48:50 +02:00
|
|
|
self.pm.save()
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
self.pendingImport: Optional[str] = None
|
2017-08-16 11:45:39 +02:00
|
|
|
self.restoringBackup = False
|
2012-12-21 08:51:59 +01:00
|
|
|
# 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:
|
2017-08-16 11:45:39 +02:00
|
|
|
self.pm.load(profs[0])
|
2012-12-21 08:51:59 +01:00
|
|
|
if not self.pm.name:
|
|
|
|
self.showProfileManager()
|
|
|
|
else:
|
|
|
|
self.loadProfile()
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def showProfileManager(self) -> None:
|
2017-08-16 06:38:55 +02:00
|
|
|
self.pm.profile = None
|
2012-12-21 08:51:59 +01:00
|
|
|
self.state = "profileManager"
|
2017-08-16 11:45:39 +02:00
|
|
|
d = self.profileDiag = self.ProfileManager()
|
|
|
|
f = self.profileForm = aqt.forms.profiles.Ui_MainWindow()
|
2012-12-21 08:51:59 +01:00
|
|
|
f.setupUi(d)
|
2020-05-04 05:23:08 +02:00
|
|
|
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)
|
2017-08-16 11:45:39 +02:00
|
|
|
f.statusbar.setVisible(False)
|
2020-05-04 05:23:08 +02:00
|
|
|
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))
|
2017-08-16 11:45:39 +02:00
|
|
|
# enter key opens profile
|
2019-12-23 01:34:10 +01:00
|
|
|
QShortcut(QKeySequence("Return"), d, activated=self.onOpenProfile) # type: ignore
|
2012-12-21 08:51:59 +01:00
|
|
|
self.refreshProfilesList()
|
|
|
|
# raise first, for osx testing
|
|
|
|
d.show()
|
2018-02-01 03:14:04 +01:00
|
|
|
d.activateWindow()
|
|
|
|
d.raise_()
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def refreshProfilesList(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
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:
|
2012-12-21 08:51:59 +01:00
|
|
|
if n < 0:
|
|
|
|
# called on .clear()
|
|
|
|
return
|
|
|
|
name = self.pm.profiles()[n]
|
2017-08-16 11:45:39 +02:00
|
|
|
self.pm.load(name)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def openProfile(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
name = self.pm.profiles()[self.profileForm.profiles.currentRow()]
|
2021-02-01 14:28:21 +01:00
|
|
|
self.pm.load(name)
|
|
|
|
return
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def onOpenProfile(self) -> None:
|
2020-02-10 00:32:56 +01:00
|
|
|
self.profileDiag.hide()
|
|
|
|
# code flow is confusing here - if load fails, profile dialog
|
|
|
|
# will be shown again
|
2017-08-16 11:45:39 +02:00
|
|
|
self.loadProfile(self.profileDiag.closeWithoutQuitting)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2020-06-05 15:44:59 +02:00
|
|
|
def profileNameOk(self, name: str) -> bool:
|
|
|
|
return not checkInvalidFilename(name) and name != "addons21"
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def onAddProfile(self) -> None:
|
2020-11-17 08:42:43 +01:00
|
|
|
name = getOnlyText(tr(TR.ACTIONS_NAME)).strip()
|
2012-12-21 08:51:59 +01:00
|
|
|
if name:
|
|
|
|
if name in self.pm.profiles():
|
2021-02-01 14:28:21 +01:00
|
|
|
showWarning(tr(TR.QT_MISC_NAME_EXISTS))
|
|
|
|
return
|
2012-12-21 08:51:59 +01:00
|
|
|
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:
|
2020-11-17 08:42:43 +01:00
|
|
|
name = getOnlyText(tr(TR.ACTIONS_NEW_NAME), default=self.pm.name).strip()
|
2012-12-21 08:51:59 +01:00
|
|
|
if not name:
|
|
|
|
return
|
|
|
|
if name == self.pm.name:
|
|
|
|
return
|
|
|
|
if name in self.pm.profiles():
|
2021-02-01 14:28:21 +01:00
|
|
|
showWarning(tr(TR.QT_MISC_NAME_EXISTS))
|
|
|
|
return
|
2012-12-21 08:51:59 +01:00
|
|
|
if not self.profileNameOk(name):
|
|
|
|
return
|
|
|
|
self.pm.rename(name)
|
|
|
|
self.refreshProfilesList()
|
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def onRemProfile(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
profs = self.pm.profiles()
|
|
|
|
if len(profs) < 2:
|
2021-02-01 14:28:21 +01:00
|
|
|
showWarning(tr(TR.QT_MISC_THERE_MUST_BE_AT_LEAST_ONE))
|
|
|
|
return
|
2012-12-21 08:51:59 +01:00
|
|
|
# sure?
|
2019-12-23 01:34:10 +01:00
|
|
|
if not askUser(
|
2020-11-18 02:32:22 +01:00
|
|
|
tr(TR.QT_MISC_ALL_CARDS_NOTES_AND_MEDIA_FOR),
|
2019-12-23 01:34:10 +01:00
|
|
|
msgfunc=QMessageBox.warning,
|
|
|
|
defaultno=True,
|
|
|
|
):
|
2012-12-21 08:51:59 +01:00
|
|
|
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(
|
2020-11-18 02:32:22 +01:00
|
|
|
tr(TR.QT_MISC_REPLACE_YOUR_COLLECTION_WITH_AN_EARLIER),
|
2019-12-23 01:34:10 +01:00
|
|
|
msgfunc=QMessageBox.warning,
|
|
|
|
defaultno=True,
|
|
|
|
):
|
2017-08-16 11:45:39 +02:00
|
|
|
return
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2021-02-02 14:30:53 +01:00
|
|
|
def doOpen(path: str) -> None:
|
2017-08-16 11:45:39 +02:00
|
|
|
self._openBackup(path)
|
2019-12-23 01:34:10 +01:00
|
|
|
|
|
|
|
getFile(
|
|
|
|
self.profileDiag,
|
2020-11-17 08:42:43 +01:00
|
|
|
tr(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(),
|
|
|
|
)
|
2017-08-16 11:45:39 +02:00
|
|
|
|
2021-02-02 15:00:29 +01:00
|
|
|
def _openBackup(self, path: str) -> None:
|
2017-08-16 11:45:39 +02:00
|
|
|
try:
|
|
|
|
# move the existing collection to the trash, as it may not open
|
|
|
|
self.pm.trashCollection()
|
|
|
|
except:
|
2020-11-17 08:42:43 +01:00
|
|
|
showWarning(tr(TR.QT_MISC_UNABLE_TO_MOVE_EXISTING_FILE_TO))
|
2017-08-16 11:45:39 +02:00
|
|
|
return
|
|
|
|
|
|
|
|
self.pendingImport = path
|
|
|
|
self.restoringBackup = True
|
|
|
|
|
2020-11-18 02:32:22 +01:00
|
|
|
showInfo(tr(TR.QT_MISC_AUTOMATIC_SYNCING_AND_BACKUPS_HAVE_BEEN))
|
2017-08-16 11:45:39 +02:00
|
|
|
|
|
|
|
self.onOpenProfile()
|
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def _on_downgrade(self) -> None:
|
2020-04-16 01:00:49 +02:00
|
|
|
self.progress.start()
|
|
|
|
profiles = self.pm.profiles()
|
2020-04-16 01:53:29 +02:00
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def downgrade() -> List[str]:
|
2020-04-16 01:53:29 +02:00
|
|
|
return self.pm.downgrade(profiles)
|
|
|
|
|
2021-02-02 14:30:53 +01:00
|
|
|
def on_done(future: Future) -> None:
|
2020-04-16 01:00:49 +02:00
|
|
|
self.progress.finish()
|
2020-04-16 01:53:29 +02:00
|
|
|
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
|
2020-04-16 01:00:49 +02:00
|
|
|
self.profileDiag.close()
|
2020-04-16 01:53:29 +02:00
|
|
|
|
2020-04-16 01:00:49 +02:00
|
|
|
self.taskman.run_in_background(downgrade, on_done)
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def loadProfile(self, onsuccess: Optional[Callable] = None) -> None:
|
2017-08-16 06:38:55 +02:00
|
|
|
if not self.loadCollection():
|
|
|
|
return
|
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
# show main window
|
2019-12-23 01:34:10 +01:00
|
|
|
if self.pm.profile["mainWindowState"]:
|
2012-12-21 08:51:59 +01:00
|
|
|
restoreGeom(self, "mainWindow")
|
|
|
|
restoreState(self, "mainWindow")
|
2013-05-22 06:04:45 +02:00
|
|
|
# titlebar
|
2021-02-11 01:09:06 +01:00
|
|
|
self.setWindowTitle(f"{self.pm.name} - Anki")
|
2012-12-21 08:51:59 +01:00
|
|
|
# show and raise window for osx
|
|
|
|
self.show()
|
|
|
|
self.activateWindow()
|
|
|
|
self.raise_()
|
2017-08-16 06:38:55 +02:00
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
# import pending?
|
|
|
|
if self.pendingImport:
|
2020-01-04 04:30:33 +01:00
|
|
|
if self._isAddon(self.pendingImport):
|
|
|
|
self.installAddon(self.pendingImport)
|
|
|
|
else:
|
|
|
|
self.handleImport(self.pendingImport)
|
2012-12-21 08:51:59 +01:00
|
|
|
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:
|
2020-12-01 01:20:55 +01:00
|
|
|
self._refresh_after_sync()
|
|
|
|
if onsuccess:
|
|
|
|
onsuccess()
|
|
|
|
|
|
|
|
self.maybe_auto_sync_on_open_close(_onsuccess)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
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:
|
2017-08-16 06:38:55 +02:00
|
|
|
self._unloadProfile()
|
|
|
|
onsuccess()
|
|
|
|
|
2020-01-15 07:53:24 +01:00
|
|
|
gui_hooks.profile_will_close()
|
2017-08-16 06:38:55 +02:00
|
|
|
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()
|
2012-12-21 08:51:59 +01:00
|
|
|
self.pm.save()
|
|
|
|
self.hide()
|
2017-08-16 06:38:55 +02:00
|
|
|
|
2017-08-16 11:45:39 +02:00
|
|
|
self.restoringBackup = False
|
|
|
|
|
2017-08-16 06:38:55 +02:00
|
|
|
# at this point there should be no windows left
|
|
|
|
self._checkForUnclosedWidgets()
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def _checkForUnclosedWidgets(self) -> None:
|
2017-08-16 06:38:55 +02:00
|
|
|
for w in self.app.topLevelWidgets():
|
|
|
|
if w.isVisible():
|
2017-08-25 04:14:59 +02:00
|
|
|
# windows with this property are safe to close immediately
|
2017-09-08 10:42:26 +02:00
|
|
|
if getattr(w, "silentlyClose", None):
|
2017-08-25 04:14:59 +02:00
|
|
|
w.close()
|
|
|
|
else:
|
2021-02-11 00:37:38 +01:00
|
|
|
print(f"Window should have been closed: {w}")
|
2017-08-16 06:38:55 +02:00
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def unloadProfileAndExit(self) -> None:
|
2017-08-16 06:38:55 +02:00
|
|
|
self.unloadProfile(self.cleanupAndExit)
|
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def unloadProfileAndShowProfileManager(self) -> None:
|
2017-08-16 06:38:55 +02:00
|
|
|
self.unloadProfile(self.showProfileManager)
|
2017-08-08 04:55:30 +02:00
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def cleanupAndExit(self) -> None:
|
2017-08-08 04:55:30 +02:00
|
|
|
self.errorHandler.unload()
|
|
|
|
self.mediaServer.shutdown()
|
|
|
|
self.app.exit(0)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2017-10-05 05:48:24 +02:00
|
|
|
# Sound/video
|
|
|
|
##########################################################################
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def setupSound(self) -> None:
|
2020-01-20 21:49:09 +01:00
|
|
|
aqt.sound.setup_audio(self.taskman, self.pm.base)
|
2017-10-05 05:48:24 +02:00
|
|
|
|
2020-01-24 02:06:11 +01:00
|
|
|
def _add_play_buttons(self, text: str) -> str:
|
|
|
|
"Return card text with play buttons added, or stripped."
|
2021-03-10 09:20:37 +01:00
|
|
|
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)
|
2021-03-10 09:20:37 +01:00
|
|
|
else:
|
|
|
|
return aqt.sound.av_refs_to_play_icons(text)
|
2020-01-24 02:06:11 +01:00
|
|
|
|
|
|
|
def prepare_card_text_for_display(self, text: str) -> str:
|
2020-11-10 14:50:17 +01:00
|
|
|
text = self.col.media.escape_media_filenames(text)
|
2020-01-24 02:06:11 +01:00
|
|
|
text = self._add_play_buttons(text)
|
|
|
|
return text
|
2020-01-21 12:00:17 +01:00
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
# Collection load/unload
|
|
|
|
##########################################################################
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def loadCollection(self) -> bool:
|
2012-12-21 08:51:59 +01:00
|
|
|
try:
|
2020-03-26 00:53:15 +01:00
|
|
|
self._loadCollection()
|
2017-08-16 06:38:55 +02:00
|
|
|
except Exception as e:
|
2020-04-16 01:47:34 +02:00
|
|
|
if "FileTooNew" in str(e):
|
2020-04-16 01:53:29 +02:00
|
|
|
showWarning(
|
|
|
|
"This profile requires a newer version of Anki to open. Did you forget to use the Downgrade button prior to switching Anki versions?"
|
|
|
|
)
|
2020-04-16 01:47:34 +02:00
|
|
|
else:
|
|
|
|
showWarning(
|
2021-02-11 01:09:06 +01:00
|
|
|
f"{tr(TR.ERRORS_UNABLE_OPEN_COLLECTION)}\n{traceback.format_exc()}"
|
2020-04-16 01:47:34 +02:00
|
|
|
)
|
2018-02-05 03:15:57 +01:00
|
|
|
# clean up open collection if possible
|
2020-05-02 04:42:52 +02:00
|
|
|
try:
|
|
|
|
self.backend.close_collection(False)
|
|
|
|
except Exception as e:
|
|
|
|
print("unable to close collection:", e)
|
|
|
|
self.col = None
|
2018-02-05 03:15:57 +01:00
|
|
|
# return to profile manager
|
possible fix for " super-class ... Preferences was never called"
Can't reproduce the issue, but it seems the user was able to open the
preferences screen when no collection was loaded. If an error was
caught in loadCollection() the main window was not being hidden, so
perhaps a timing issue was preventing the profiles screen from taking
modal focus.
Removed the check in the prefs init - it is hopefully no longer
necessary, and returning before QDialog.__init__() was called was
causing the problem.
Caught exception:
File "aqt/webview.py", line 27, in cmd
File "aqt/webview.py", line 85, in _onCmd
File "aqt/webview.py", line 360, in _onBridgeCmd
File "aqt/toolbar.py", line 56, in _linkHandler
File "aqt/toolbar.py", line 80, in _syncLinkHandler
File "aqt/main.py", line 669, in onSync
File "aqt/main.py", line 365, in unloadCollection
File "aqt/main.py", line 611, in closeAllWindows
File "aqt/__init__.py", line 110, in closeAll
<class 'RuntimeError'>: super-class __init__() of type Preferences was never called
2019-04-21 11:02:03 +02:00
|
|
|
self.hide()
|
2017-08-16 06:38:55 +02:00
|
|
|
self.showProfileManager()
|
|
|
|
return False
|
|
|
|
|
2020-03-26 00:53:15 +01:00
|
|
|
# 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:
|
2021-03-05 13:45:55 +01:00
|
|
|
self.update_undo_actions()
|
2020-03-26 00:53:15 +01:00
|
|
|
gui_hooks.collection_did_load(self.col)
|
2021-03-10 09:20:37 +01:00
|
|
|
self.apply_collection_options()
|
2020-03-26 00:53:15 +01:00
|
|
|
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:
|
2020-05-20 09:43:34 +02:00
|
|
|
cpath = self.pm.collectionPath()
|
|
|
|
self.col = Collection(cpath, backend=self.backend, log=True)
|
2017-11-01 03:38:43 +01:00
|
|
|
self.setEnabled(True)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2021-02-01 11:59:18 +01:00
|
|
|
def reopen(self) -> None:
|
2020-05-20 09:43:34 +02:00
|
|
|
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:
|
2017-08-16 06:38:55 +02:00
|
|
|
self._unloadCollection()
|
|
|
|
onsuccess()
|
2013-04-23 15:37:21 +02:00
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def after_sync() -> None:
|
2020-05-31 10:51:05 +02:00
|
|
|
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)
|
2017-08-16 06:38:55 +02:00
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def _unloadCollection(self) -> None:
|
2017-08-16 11:45:39 +02:00
|
|
|
if not self.col:
|
|
|
|
return
|
|
|
|
if self.restoringBackup:
|
2020-11-17 08:42:43 +01:00
|
|
|
label = tr(TR.QT_MISC_CLOSING)
|
2017-08-16 11:45:39 +02:00
|
|
|
else:
|
2020-11-17 08:42:43 +01:00
|
|
|
label = tr(TR.QT_MISC_BACKING_UP)
|
2020-05-31 03:24:33 +02:00
|
|
|
self.progress.start(label=label)
|
2017-08-16 06:38:55 +02:00
|
|
|
corrupt = False
|
|
|
|
try:
|
|
|
|
self.maybeOptimize()
|
|
|
|
if not devMode:
|
2020-04-18 02:21:31 +02:00
|
|
|
corrupt = self.col.db.scalar("pragma quick_check") != "ok"
|
2017-08-16 06:38:55 +02:00
|
|
|
except:
|
|
|
|
corrupt = True
|
|
|
|
try:
|
2020-04-16 01:00:49 +02:00
|
|
|
self.col.close(downgrade=False)
|
2020-04-08 02:05:33 +02:00
|
|
|
except Exception as e:
|
|
|
|
print(e)
|
2017-08-16 13:06:50 +02:00
|
|
|
corrupt = True
|
2017-08-16 11:45:39 +02:00
|
|
|
finally:
|
|
|
|
self.col = None
|
2020-04-09 06:19:20 +02:00
|
|
|
self.progress.finish()
|
2017-08-16 13:06:50 +02:00
|
|
|
if corrupt:
|
2020-11-18 02:32:22 +01:00
|
|
|
showWarning(tr(TR.QT_MISC_YOUR_COLLECTION_FILE_APPEARS_TO_BE))
|
2017-08-16 11:45:39 +02:00
|
|
|
if not corrupt and not self.restoringBackup:
|
2017-08-16 06:38:55 +02:00
|
|
|
self.backup()
|
2017-08-16 11:45:39 +02:00
|
|
|
|
2021-02-06 10:01:48 +01:00
|
|
|
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()
|
|
|
|
|
2021-03-10 09:20:37 +01:00
|
|
|
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
|
|
|
|
)
|
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
# Backup and auto-optimize
|
|
|
|
##########################################################################
|
|
|
|
|
2017-01-08 10:29:57 +01:00
|
|
|
class BackupThread(Thread):
|
2021-02-02 14:30:53 +01:00
|
|
|
def __init__(self, path: str, data: bytes) -> None:
|
2017-01-08 10:29:57 +01:00
|
|
|
Thread.__init__(self)
|
|
|
|
self.path = path
|
|
|
|
self.data = data
|
|
|
|
# create the file in calling thread to ensure the same
|
|
|
|
# file is not created twice
|
2020-05-10 02:58:42 +02:00
|
|
|
with open(self.path, "wb") as file:
|
|
|
|
pass
|
2017-01-08 10:29:57 +01:00
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def run(self) -> None:
|
2017-01-08 10:29:57 +01:00
|
|
|
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:
|
2021-02-06 10:01:48 +01:00
|
|
|
"Read data into memory, and complete backup on a background thread."
|
|
|
|
assert not self.col or not self.col.db
|
|
|
|
|
2019-12-23 01:34:10 +01:00
|
|
|
nbacks = self.pm.profile["numBackups"]
|
2017-08-01 06:28:13 +02:00
|
|
|
if not nbacks or devMode:
|
2012-12-21 08:51:59 +01:00
|
|
|
return
|
|
|
|
dir = self.pm.backupFolder()
|
|
|
|
path = self.pm.collectionPath()
|
2017-08-14 10:53:39 +02:00
|
|
|
|
|
|
|
# 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())
|
|
|
|
)
|
2017-08-14 10:53:39 +02:00
|
|
|
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()
|
2017-08-14 10:53:39 +02:00
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
# find existing backups
|
|
|
|
backups = []
|
|
|
|
for file in os.listdir(dir):
|
2017-08-14 10:53:39 +02:00
|
|
|
# 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)
|
2012-12-21 08:51:59 +01:00
|
|
|
if not m:
|
|
|
|
continue
|
2017-08-14 10:53:39 +02:00
|
|
|
backups.append(file)
|
2012-12-21 08:51:59 +01:00
|
|
|
backups.sort()
|
2017-08-14 10:53:39 +02:00
|
|
|
|
|
|
|
# remove old ones
|
|
|
|
while len(backups) > nbacks:
|
|
|
|
fname = backups.pop(0)
|
|
|
|
path = os.path.join(dir, fname)
|
2017-10-05 06:39:47 +02:00
|
|
|
os.unlink(path)
|
2021-02-06 10:01:48 +01:00
|
|
|
|
|
|
|
self.taskman.run_on_main(gui_hooks.backup_did_complete)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def maybeOptimize(self) -> None:
|
2013-05-07 08:17:46 +02:00
|
|
|
# have two weeks passed?
|
2019-12-23 01:34:10 +01:00
|
|
|
if (intTime() - self.pm.profile["lastOptimize"]) < 86400 * 14:
|
2012-12-21 08:51:59 +01:00
|
|
|
return
|
2020-11-17 08:42:43 +01:00
|
|
|
self.progress.start(label=tr(TR.QT_MISC_OPTIMIZING))
|
2012-12-21 08:51:59 +01:00
|
|
|
self.col.optimize()
|
2019-12-23 01:34:10 +01:00
|
|
|
self.pm.profile["lastOptimize"] = intTime()
|
2012-12-21 08:51:59 +01:00
|
|
|
self.pm.save()
|
|
|
|
self.progress.finish()
|
|
|
|
|
|
|
|
# State machine
|
|
|
|
##########################################################################
|
|
|
|
|
2021-02-02 14:30:53 +01:00
|
|
|
def moveToState(self, state: str, *args: Any) -> None:
|
2019-12-23 01:34:10 +01:00
|
|
|
# print("-> move from", self.state, "to", state)
|
2012-12-21 08:51:59 +01:00
|
|
|
oldState = self.state or "dummy"
|
2021-02-11 01:09:06 +01:00
|
|
|
cleanup = getattr(self, f"_{oldState}Cleanup", None)
|
2012-12-21 08:51:59 +01:00
|
|
|
if cleanup:
|
2019-03-04 02:22:40 +01:00
|
|
|
# pylint: disable=not-callable
|
2012-12-21 08:51:59 +01:00
|
|
|
cleanup(state)
|
2017-06-22 08:36:54 +02:00
|
|
|
self.clearStateShortcuts()
|
2012-12-21 08:51:59 +01:00
|
|
|
self.state = state
|
2020-01-15 07:53:24 +01:00
|
|
|
gui_hooks.state_will_change(state, oldState)
|
2021-02-11 01:09:06 +01:00
|
|
|
getattr(self, f"_{state}State")(oldState, *args)
|
2017-01-17 05:05:05 +01:00
|
|
|
if state != "resetRequired":
|
|
|
|
self.bottomWeb.show()
|
2020-01-15 07:53:24 +01:00
|
|
|
gui_hooks.state_did_change(state, oldState)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
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()
|
2012-12-21 08:51:59 +01:00
|
|
|
self.deckBrowser.show()
|
|
|
|
|
2020-08-11 22:56:58 +02:00
|
|
|
def _selectedDeck(self) -> Optional[Deck]:
|
2012-12-21 08:51:59 +01:00
|
|
|
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):
|
2020-11-17 08:42:43 +01:00
|
|
|
showInfo(tr(TR.QT_MISC_PLEASE_SELECT_A_DECK))
|
2019-12-20 09:43:52 +01:00
|
|
|
return None
|
2012-12-21 08:51:59 +01:00
|
|
|
return self.col.decks.get(did)
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def _overviewState(self, oldState: str) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
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:
|
2012-12-21 08:51:59 +01:00
|
|
|
self.reviewer.show()
|
|
|
|
|
2021-02-01 11:59:18 +01:00
|
|
|
def _reviewCleanup(self, newState: str) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
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:
|
2012-12-21 08:51:59 +01:00
|
|
|
"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()
|
2021-03-05 13:45:55 +01:00
|
|
|
self.update_undo_actions()
|
2012-12-21 08:51:59 +01:00
|
|
|
self.moveToState(self.state)
|
|
|
|
|
2021-02-01 11:59:18 +01:00
|
|
|
def requireReset(
|
|
|
|
self,
|
|
|
|
modal: bool = False,
|
|
|
|
reason: ResetReason = ResetReason.Unknown,
|
|
|
|
context: Any = None,
|
|
|
|
) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
"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
|
|
|
|
):
|
2012-12-21 08:51:59 +01:00
|
|
|
self.moveToState("resetRequired")
|
|
|
|
|
2021-02-01 11:59:18 +01:00
|
|
|
def interactiveState(self) -> bool:
|
2012-12-21 08:51:59 +01:00
|
|
|
"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:
|
2012-12-21 08:51:59 +01:00
|
|
|
self.autosave()
|
|
|
|
if self.state == "resetRequired":
|
|
|
|
self.state = self.returnState
|
|
|
|
self.reset()
|
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def delayedMaybeReset(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
# if we redraw the page in a button click event it will often crash on
|
|
|
|
# windows
|
|
|
|
self.progress.timer(100, self.maybeReset, False)
|
|
|
|
|
2020-02-08 23:59:29 +01:00
|
|
|
def _resetRequiredState(self, oldState: str) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
if oldState != "resetRequired":
|
|
|
|
self.returnState = oldState
|
|
|
|
if self.resetModal:
|
|
|
|
# we don't have to change the webview, as we have a covering window
|
|
|
|
return
|
2020-02-12 22:00:13 +01:00
|
|
|
web_context = ResetRequired(self)
|
|
|
|
self.web.set_bridge_command(lambda url: self.delayedMaybeReset(), web_context)
|
2020-11-17 08:42:43 +01:00
|
|
|
i = tr(TR.QT_MISC_WAITING_FOR_EDITING_TO_FINISH)
|
|
|
|
b = self.button("refresh", tr(TR.QT_MISC_RESUME_NOW), id="resume")
|
2019-12-23 01:34:10 +01:00
|
|
|
self.web.stdHtml(
|
2021-02-11 01:09:06 +01:00
|
|
|
f"""
|
|
|
|
<center><div style="height: 100%">
|
2012-12-21 08:51:59 +01:00
|
|
|
<div style="position:relative; vertical-align: middle;">
|
2021-02-11 01:09:06 +01:00
|
|
|
{i}<br><br>
|
|
|
|
{b}</div></div></center>
|
2017-08-01 05:57:15 +02:00
|
|
|
<script>$('#resume').focus()</script>
|
2021-02-11 01:09:06 +01:00
|
|
|
""",
|
2020-02-12 22:00:13 +01:00
|
|
|
context=web_context,
|
2019-12-23 01:34:10 +01:00
|
|
|
)
|
2012-12-21 08:51:59 +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:
|
2021-02-11 01:09:06 +01:00
|
|
|
class_ = f"but {class_}"
|
2012-12-21 08:51:59 +01:00
|
|
|
if key:
|
2020-11-17 12:47:47 +01:00
|
|
|
key = tr(TR.ACTIONS_SHORTCUT_KEY, val=key)
|
2012-12-21 08:51:59 +01:00
|
|
|
else:
|
|
|
|
key = ""
|
2019-12-23 01:34:10 +01:00
|
|
|
return """
|
2016-06-06 07:50:03 +02:00
|
|
|
<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,
|
|
|
|
)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
# Main window setup
|
|
|
|
##########################################################################
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def setupMainWindow(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
# main window
|
|
|
|
self.form = aqt.forms.main.Ui_MainWindow()
|
|
|
|
self.form.setupUi(self)
|
|
|
|
# toolbar
|
2020-02-12 21:03:11 +01:00
|
|
|
tweb = self.toolbarWeb = aqt.webview.AnkiWebView(title="top toolbar")
|
2012-12-21 08:51:59 +01:00
|
|
|
tweb.setFocusPolicy(Qt.WheelFocus)
|
|
|
|
self.toolbar = aqt.toolbar.Toolbar(self, tweb)
|
|
|
|
# main area
|
2020-02-12 21:03:11 +01:00
|
|
|
self.web = aqt.webview.AnkiWebView(title="main webview")
|
2012-12-21 08:51:59 +01:00
|
|
|
self.web.setFocusPolicy(Qt.WheelFocus)
|
|
|
|
self.web.setMinimumWidth(400)
|
|
|
|
# bottom area
|
2020-02-12 21:03:11 +01:00
|
|
|
sweb = self.bottomWeb = aqt.webview.AnkiWebView(title="bottom toolbar")
|
2012-12-21 08:51:59 +01:00
|
|
|
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)
|
2012-12-21 08:51:59 +01:00
|
|
|
self.mainLayout.setSpacing(0)
|
|
|
|
self.mainLayout.addWidget(tweb)
|
|
|
|
self.mainLayout.addWidget(self.web)
|
|
|
|
self.mainLayout.addWidget(sweb)
|
|
|
|
self.form.centralwidget.setLayout(self.mainLayout)
|
|
|
|
|
2019-04-09 10:48:50 +02:00
|
|
|
# force webengine processes to load before cwd is changed
|
|
|
|
if isWin:
|
2019-04-10 09:31:55 +02:00
|
|
|
for o in self.web, self.bottomWeb:
|
2019-04-16 05:24:38 +02:00
|
|
|
o.requiresCol = False
|
|
|
|
o._domReady = False
|
|
|
|
o._page.setContent(bytes("", "ascii"))
|
2019-04-09 10:48:50 +02:00
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def closeAllWindows(self, onsuccess: Callable) -> None:
|
2017-08-16 11:45:39 +02:00
|
|
|
aqt.dialogs.closeAll(onsuccess)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
# Components
|
|
|
|
##########################################################################
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def setupSignals(self) -> None:
|
2020-07-18 03:26:04 +02:00
|
|
|
signal.signal(signal.SIGINT, self.onUnixSignal)
|
|
|
|
signal.signal(signal.SIGTERM, self.onUnixSignal)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
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:
|
2012-12-21 08:51:59 +01:00
|
|
|
self.col.db.rollback()
|
|
|
|
self.close()
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
self.progress.timer(100, quit, False)
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def setupProgress(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
self.progress = aqt.progress.ProgressManager(self)
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def setupErrorHandler(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
import aqt.errors
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
self.errorHandler = aqt.errors.ErrorHandler(self)
|
|
|
|
|
2020-01-04 04:34:16 +01:00
|
|
|
def setupAddons(self, args: Optional[List]) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
import aqt.addons
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
self.addonManager = aqt.addons.AddonManager(self)
|
2020-01-04 04:34:16 +01:00
|
|
|
|
|
|
|
if args and args[0] and self._isAddon(args[0]):
|
|
|
|
self.installAddon(args[0], startup=True)
|
|
|
|
|
2017-08-28 12:51:43 +02:00
|
|
|
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()
|
|
|
|
elap = intTime() - last_check
|
|
|
|
|
|
|
|
if elap > 86_400:
|
|
|
|
check_and_prompt_for_updates(
|
2021-03-09 14:27:28 +01:00
|
|
|
self,
|
|
|
|
self.addonManager,
|
|
|
|
self.on_updates_installed,
|
|
|
|
requested_by_user=False,
|
2020-01-19 02:31:09 +01:00
|
|
|
)
|
|
|
|
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)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
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-03-06 14:18:26 +01:00
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def setupThreads(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
self._mainThread = QThread.currentThread()
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def inMainThread(self) -> bool:
|
2012-12-21 08:51:59 +01:00
|
|
|
return self._mainThread == QThread.currentThread()
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def setupDeckBrowser(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
from aqt.deckbrowser import DeckBrowser
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
self.deckBrowser = DeckBrowser(self)
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def setupOverview(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
from aqt.overview import Overview
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
self.overview = Overview(self)
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def setupReviewer(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
from aqt.reviewer import Reviewer
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
self.reviewer = Reviewer(self)
|
|
|
|
|
|
|
|
# Syncing
|
|
|
|
##########################################################################
|
|
|
|
|
2021-02-01 11:59:18 +01:00
|
|
|
def on_sync_button_clicked(self) -> None:
|
2020-02-04 02:41:20 +01:00
|
|
|
if self.media_syncer.is_syncing():
|
2020-02-04 03:26:10 +01:00
|
|
|
self.media_syncer.show_sync_log()
|
2020-02-04 02:41:20 +01:00
|
|
|
else:
|
2020-05-31 02:53:54 +02:00
|
|
|
auth = self.pm.sync_auth()
|
|
|
|
if not auth:
|
2020-11-30 01:14:43 +01:00
|
|
|
sync_login(
|
|
|
|
self,
|
2020-12-01 01:20:55 +01:00
|
|
|
lambda: self._sync_collection_and_media(self._refresh_after_sync),
|
2020-11-30 01:14:43 +01:00
|
|
|
)
|
2020-05-31 02:53:54 +02:00
|
|
|
else:
|
2020-12-01 01:20:55 +01:00
|
|
|
self._sync_collection_and_media(self._refresh_after_sync)
|
2020-11-30 01:14:43 +01:00
|
|
|
|
2021-02-01 11:59:18 +01:00
|
|
|
def _refresh_after_sync(self) -> None:
|
2020-11-30 01:14:43 +01:00
|
|
|
self.toolbar.redraw()
|
2017-08-16 06:38:55 +02:00
|
|
|
|
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()
|
2017-08-16 06:38:55 +02:00
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def on_collection_sync_finished() -> None:
|
2021-03-04 10:17:19 +01:00
|
|
|
self.col.clear_python_undo()
|
2020-08-20 07:35:23 +02:00
|
|
|
self.col.models._clear_cache()
|
2020-09-14 12:22:01 +02:00
|
|
|
gui_hooks.sync_did_finish()
|
2020-09-15 13:06:11 +02:00
|
|
|
self.reset()
|
2017-08-16 06:38:55 +02:00
|
|
|
|
2020-09-14 13:06:20 +02:00
|
|
|
after_sync()
|
|
|
|
|
2020-09-14 12:22:01 +02:00
|
|
|
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()
|
2017-08-16 06:38:55 +02:00
|
|
|
|
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:
|
2020-05-31 03:24:33 +02:00
|
|
|
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
|
2020-05-31 03:24:33 +02:00
|
|
|
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 03:24:33 +02:00
|
|
|
|
2020-05-31 02:53:54 +02:00
|
|
|
onSync = on_sync_button_clicked
|
2020-05-30 04:28:22 +02:00
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
# Tools
|
|
|
|
##########################################################################
|
|
|
|
|
2020-08-01 04:27:54 +02:00
|
|
|
def raiseMain(self) -> bool:
|
2012-12-21 08:51:59 +01:00
|
|
|
if not self.app.activeWindow():
|
|
|
|
# make sure window is shown
|
2020-08-01 04:27:54 +02:00
|
|
|
self.setWindowState(self.windowState() & ~Qt.WindowMinimized) # type: ignore
|
2012-12-21 08:51:59 +01:00
|
|
|
return True
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def setupStyle(self) -> None:
|
2020-01-23 06:08:10 +01:00
|
|
|
theme_manager.night_mode = self.pm.night_mode()
|
|
|
|
theme_manager.apply_style(self.app)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
# Key handling
|
|
|
|
##########################################################################
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def setupKeys(self) -> None:
|
2017-06-22 08:36:54 +02:00
|
|
|
globalShortcuts = [
|
2018-03-18 15:50:50 +01:00
|
|
|
("Ctrl+:", self.onDebug),
|
2017-06-22 08:36:54 +02:00
|
|
|
("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),
|
2017-06-22 08:36:54 +02:00
|
|
|
]
|
|
|
|
self.applyShortcuts(globalShortcuts)
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
self.stateShortcuts: Sequence[Tuple[str, Callable]] = []
|
2017-06-22 08:36:54 +02:00
|
|
|
|
2019-12-23 01:34:10 +01:00
|
|
|
def applyShortcuts(
|
|
|
|
self, shortcuts: Sequence[Tuple[str, Callable]]
|
|
|
|
) -> List[QShortcut]:
|
2017-06-22 08:36:54 +02:00
|
|
|
qshortcuts = []
|
|
|
|
for key, fn in shortcuts:
|
2019-12-23 01:34:10 +01:00
|
|
|
scut = QShortcut(QKeySequence(key), self, activated=fn) # type: ignore
|
2018-08-29 02:07:33 +02:00
|
|
|
scut.setAutoRepeat(False)
|
|
|
|
qshortcuts.append(scut)
|
2017-06-22 08:36:54 +02:00
|
|
|
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)
|
2020-01-15 03:46:53 +01:00
|
|
|
# legacy hook
|
2021-02-11 01:09:06 +01:00
|
|
|
runHook(f"{self.state}StateShortcuts", shortcuts)
|
2017-06-22 08:36:54 +02:00
|
|
|
self.stateShortcuts = self.applyShortcuts(shortcuts)
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def clearStateShortcuts(self) -> None:
|
2017-06-22 08:36:54 +02:00
|
|
|
for qs in self.stateShortcuts:
|
|
|
|
sip.delete(qs)
|
|
|
|
self.stateShortcuts = []
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def onStudyKey(self) -> None:
|
2017-06-22 08:36:54 +02:00
|
|
|
if self.state == "overview":
|
|
|
|
self.col.startTimebox()
|
|
|
|
self.moveToState("review")
|
2017-06-06 07:56:21 +02:00
|
|
|
else:
|
2017-06-22 08:36:54 +02:00
|
|
|
self.moveToState("overview")
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
# App exit
|
|
|
|
##########################################################################
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def closeEvent(self, event: QCloseEvent) -> None:
|
2017-08-16 11:45:39 +02:00
|
|
|
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()
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
# Undo & autosave
|
|
|
|
##########################################################################
|
|
|
|
|
2021-03-12 08:54:56 +01:00
|
|
|
def undo(self, on_done: Optional[Callable[[UndoResult], None]]) -> None:
|
|
|
|
def on_done_outer(fut: Future) -> None:
|
|
|
|
result = fut.result()
|
|
|
|
reviewing = self.state == "review"
|
|
|
|
just_refresh_reviewer = False
|
2021-03-04 10:17:19 +01:00
|
|
|
|
2021-03-12 08:54:56 +01:00
|
|
|
if result is None:
|
|
|
|
# should not happen
|
|
|
|
showInfo("nothing to undo")
|
|
|
|
self.update_undo_actions()
|
|
|
|
return
|
2021-03-04 10:17:19 +01:00
|
|
|
|
2021-03-12 08:54:56 +01:00
|
|
|
elif isinstance(result, ReviewUndo):
|
|
|
|
name = tr(TR.SCHEDULING_REVIEW)
|
2021-03-04 10:17:19 +01:00
|
|
|
|
2021-03-12 08:54:56 +01:00
|
|
|
if reviewing:
|
|
|
|
# push the undone card to the top of the queue
|
|
|
|
cid = result.card.id
|
|
|
|
card = self.col.getCard(cid)
|
|
|
|
self.reviewer.cardQueue.append(card)
|
2021-03-05 10:09:08 +01:00
|
|
|
|
2021-03-12 08:54:56 +01:00
|
|
|
gui_hooks.review_did_undo(cid)
|
2021-03-05 10:09:08 +01:00
|
|
|
|
2021-03-12 08:54:56 +01:00
|
|
|
just_refresh_reviewer = True
|
2021-03-04 10:17:19 +01:00
|
|
|
|
2021-03-12 08:54:56 +01:00
|
|
|
elif isinstance(result, BackendUndo):
|
|
|
|
name = result.name
|
2021-03-04 10:17:19 +01:00
|
|
|
|
2021-03-12 08:54:56 +01:00
|
|
|
if reviewing and self.col.sched.version == 3:
|
|
|
|
# new scheduler will have taken care of updating queue
|
|
|
|
just_refresh_reviewer = True
|
2021-03-04 10:17:19 +01:00
|
|
|
|
2021-03-12 08:54:56 +01:00
|
|
|
elif isinstance(result, Checkpoint):
|
|
|
|
name = result.name
|
2021-03-04 10:17:19 +01:00
|
|
|
|
2021-03-12 08:54:56 +01:00
|
|
|
else:
|
|
|
|
assert_exhaustive(result)
|
|
|
|
assert False
|
2021-03-04 10:17:19 +01:00
|
|
|
|
2021-03-12 08:54:56 +01:00
|
|
|
if just_refresh_reviewer:
|
|
|
|
self.reviewer.nextCard()
|
|
|
|
else:
|
|
|
|
# full queue+gui reset required
|
|
|
|
self.reset()
|
2021-03-05 10:09:08 +01:00
|
|
|
|
2021-03-12 08:54:56 +01:00
|
|
|
tooltip(tr(TR.UNDO_ACTION_UNDONE, action=name))
|
|
|
|
gui_hooks.state_did_revert(name)
|
|
|
|
self.update_undo_actions()
|
|
|
|
if on_done:
|
|
|
|
on_done(result)
|
|
|
|
|
|
|
|
self.taskman.with_progress(self.col.undo, on_done_outer)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2021-03-05 13:45:55 +01:00
|
|
|
def update_undo_actions(self) -> None:
|
|
|
|
"""Update menu text and enable/disable menu item as appropriate.
|
|
|
|
Plural as this may handle redo in the future too."""
|
2021-03-04 10:17:19 +01:00
|
|
|
if self.col:
|
|
|
|
status = self.col.undo_status()
|
|
|
|
undo_action = status.undo or None
|
|
|
|
else:
|
|
|
|
undo_action = None
|
|
|
|
|
|
|
|
if undo_action:
|
|
|
|
undo_action = tr(TR.UNDO_UNDO_ACTION, val=undo_action)
|
|
|
|
self.form.actionUndo.setText(undo_action)
|
2012-12-21 08:51:59 +01:00
|
|
|
self.form.actionUndo.setEnabled(True)
|
2020-01-15 07:53:24 +01:00
|
|
|
gui_hooks.undo_state_did_change(True)
|
2012-12-21 08:51:59 +01:00
|
|
|
else:
|
2021-03-04 10:17:19 +01:00
|
|
|
self.form.actionUndo.setText(tr(TR.UNDO_UNDO))
|
2012-12-21 08:51:59 +01:00
|
|
|
self.form.actionUndo.setEnabled(False)
|
2020-01-15 07:53:24 +01:00
|
|
|
gui_hooks.undo_state_did_change(False)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2021-02-01 11:59:18 +01:00
|
|
|
def checkpoint(self, name: str) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
self.col.save(name)
|
2021-03-05 13:45:55 +01:00
|
|
|
self.update_undo_actions()
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def autosave(self) -> None:
|
2021-03-05 04:07:52 +01:00
|
|
|
self.col.autosave()
|
2021-03-05 13:45:55 +01:00
|
|
|
self.update_undo_actions()
|
|
|
|
|
|
|
|
maybeEnableUndo = update_undo_actions
|
2021-03-12 08:54:56 +01:00
|
|
|
onUndo = undo
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
# Other menu operations
|
|
|
|
##########################################################################
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def onAddCard(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
aqt.dialogs.open("AddCards", self)
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def onBrowse(self) -> None:
|
2021-02-01 11:54:28 +01:00
|
|
|
aqt.dialogs.open("Browser", self, card=self.reviewer.card)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2021-02-01 11:59:18 +01:00
|
|
|
def onEditCurrent(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
aqt.dialogs.open("EditCurrent", self)
|
|
|
|
|
2021-02-01 23:33:41 +01:00
|
|
|
def onDeckConf(self, deck: Optional[Deck] = None) -> None:
|
2021-02-01 09:56:10 +01:00
|
|
|
import aqt.deckconf
|
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
if not deck:
|
|
|
|
deck = self.col.decks.current()
|
2019-12-23 01:34:10 +01:00
|
|
|
if deck["dyn"]:
|
2021-02-01 08:50:19 +01:00
|
|
|
aqt.dialogs.open("DynDeckConfDialog", self, deck=deck)
|
2012-12-21 08:51:59 +01:00
|
|
|
else:
|
|
|
|
aqt.deckconf.DeckConf(self, deck)
|
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def onOverview(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
self.col.reset()
|
|
|
|
self.moveToState("overview")
|
|
|
|
|
2021-02-01 11:59:18 +01:00
|
|
|
def onStats(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
deck = self._selectedDeck()
|
|
|
|
if not deck:
|
|
|
|
return
|
2020-06-30 09:08:10 +02:00
|
|
|
want_old = self.app.queryKeyboardModifiers() & Qt.ShiftModifier
|
|
|
|
if want_old:
|
|
|
|
aqt.dialogs.open("DeckStats", self)
|
|
|
|
else:
|
|
|
|
aqt.dialogs.open("NewDeckStats", self)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def onPrefs(self) -> None:
|
2017-09-10 07:15:12 +02:00
|
|
|
aqt.dialogs.open("Preferences", self)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
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:
|
2017-06-26 05:05:11 +02:00
|
|
|
aqt.dialogs.open("About", self)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def onDonate(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
openLink(aqt.appDonate)
|
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def onDocumentation(self) -> None:
|
2021-01-25 14:45:47 +01:00
|
|
|
openHelp(HelpPage.INDEX)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
# Importing & exporting
|
|
|
|
##########################################################################
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def handleImport(self, path: str) -> None:
|
2013-06-30 00:08:37 +02:00
|
|
|
import aqt.importing
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2013-06-30 00:08:37 +02:00
|
|
|
if not os.path.exists(path):
|
2020-11-17 08:42:43 +01:00
|
|
|
showInfo(tr(TR.QT_MISC_PLEASE_USE_FILEIMPORT_TO_IMPORT_THIS))
|
2019-12-20 09:43:52 +01:00
|
|
|
return None
|
2013-06-30 00:08:37 +02:00
|
|
|
|
|
|
|
aqt.importing.importFile(self, path)
|
2019-12-20 09:43:52 +01:00
|
|
|
return None
|
2013-06-30 00:08:37 +02:00
|
|
|
|
2021-02-01 11:59:18 +01:00
|
|
|
def onImport(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
import aqt.importing
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
aqt.importing.onImport(self)
|
|
|
|
|
2021-02-01 11:59:18 +01:00
|
|
|
def onExport(self, did: Optional[int] = None) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
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)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2020-01-03 17:57:33 +01:00
|
|
|
# Installing add-ons from CLI / mimetype handler
|
|
|
|
##########################################################################
|
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def installAddon(self, path: str, startup: bool = False) -> None:
|
2020-01-03 17:57:33 +01:00
|
|
|
from aqt.addons import installAddonPackages
|
2020-01-03 18:23:28 +01:00
|
|
|
|
2020-01-04 04:34:16 +01:00
|
|
|
installAddonPackages(
|
2020-01-04 04:45:43 +01:00
|
|
|
self.addonManager,
|
|
|
|
[path],
|
|
|
|
warn=True,
|
|
|
|
advise_restart=not startup,
|
|
|
|
strictly_modal=startup,
|
|
|
|
parent=None if startup else self,
|
2020-01-04 04:34:16 +01:00
|
|
|
)
|
2020-01-03 17:57:33 +01:00
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
# Cramming
|
|
|
|
##########################################################################
|
|
|
|
|
2021-02-01 23:33:41 +01:00
|
|
|
def onCram(self) -> None:
|
2021-01-31 19:32:51 +01:00
|
|
|
aqt.dialogs.open("DynDeckConfDialog", self)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
# Menu, title bar & status
|
|
|
|
##########################################################################
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def setupMenus(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
m = self.form
|
2020-05-04 05:23:08 +02:00
|
|
|
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)
|
2018-10-23 10:40:16 +02:00
|
|
|
if qtminor < 11:
|
2020-02-05 14:46:11 +01:00
|
|
|
m.actionUndo.setShortcut(QKeySequence("Ctrl+Alt+Z"))
|
2020-05-04 05:23:08 +02:00
|
|
|
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)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def updateTitleBar(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
self.setWindowTitle("Anki")
|
|
|
|
|
|
|
|
# Auto update
|
|
|
|
##########################################################################
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def setupAutoUpdate(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
import aqt.update
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
self.autoUpdate = aqt.update.LatestVersionFinder(self)
|
2020-05-04 05:23:08 +02:00
|
|
|
qconnect(self.autoUpdate.newVerAvail, self.newVerAvail)
|
|
|
|
qconnect(self.autoUpdate.newMsg, self.newMsg)
|
|
|
|
qconnect(self.autoUpdate.clockIsOff, self.clockIsOff)
|
2012-12-21 08:51:59 +01:00
|
|
|
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:
|
2012-12-21 08:51:59 +01:00
|
|
|
aqt.update.askAndUpdate(self, ver)
|
|
|
|
|
2021-02-02 14:30:53 +01:00
|
|
|
def newMsg(self, data: Dict) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
aqt.update.showMessages(self, data)
|
|
|
|
|
2021-02-02 14:30:53 +01:00
|
|
|
def clockIsOff(self, diff: int) -> None:
|
2020-11-15 09:29:16 +01:00
|
|
|
if devMode:
|
|
|
|
print("clock is off; ignoring")
|
|
|
|
return
|
2020-11-18 00:22:27 +01:00
|
|
|
diffText = tr(TR.QT_MISC_SECOND, count=diff)
|
2020-11-18 02:32:22 +01:00
|
|
|
warn = tr(TR.QT_MISC_IN_ORDER_TO_ENSURE_YOUR_COLLECTION, val="%s") % diffText
|
2013-10-20 03:26:11 +02:00
|
|
|
showWarning(warn)
|
2012-12-21 08:51:59 +01:00
|
|
|
self.app.closeAllWindows()
|
|
|
|
|
2020-02-05 03:38:36 +01:00
|
|
|
# Timers
|
2012-12-21 08:51:59 +01:00
|
|
|
##########################################################################
|
|
|
|
|
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)
|
2021-03-05 04:07:52 +01:00
|
|
|
# periodic garbage collection
|
|
|
|
self.progress.timer(15 * 60 * 1000, self.garbage_collect_now, False)
|
2020-07-18 03:26:04 +02:00
|
|
|
# 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)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def onRefreshTimer(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
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()
|
2020-07-01 03:35:24 +02:00
|
|
|
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
|
2012-12-21 08:51:59 +01:00
|
|
|
##########################################################################
|
|
|
|
|
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)
|
2020-01-15 08:45:35 +01:00
|
|
|
hooks.notes_will_be_deleted.append(self.onRemNotes)
|
2020-01-15 07:53:24 +01:00
|
|
|
hooks.card_odue_was_invalid.append(self.onOdueInvalid)
|
2018-05-31 05:20:10 +02:00
|
|
|
|
2020-01-20 13:01:38 +01:00
|
|
|
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
|
2013-11-17 08:03:58 +01:00
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def onOdueInvalid(self) -> None:
|
2020-11-18 02:32:22 +01:00
|
|
|
showWarning(tr(TR.QT_MISC_INVALID_PROPERTY_FOUND_ON_CARD_PLEASE))
|
2013-05-22 05:27:37 +02:00
|
|
|
|
2020-01-20 13:01:38 +01: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")
|
2018-07-23 05:19:01 +02:00
|
|
|
|
2020-01-20 13:01:38 +01:00
|
|
|
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):
|
2018-07-23 05:19:01 +02:00
|
|
|
return
|
|
|
|
|
|
|
|
self._activeWindowOnPlay = self.app.activeWindow() or self._activeWindowOnPlay
|
2018-05-31 05:20:10 +02:00
|
|
|
|
2020-01-22 05:39:18 +01:00
|
|
|
def on_av_player_did_end_playing(self, player: Any) -> None:
|
2020-01-20 13:01:38 +01:00
|
|
|
"Restore window focus after a video was played."
|
2018-05-31 05:20:10 +02:00
|
|
|
w = self._activeWindowOnPlay
|
2018-07-23 05:19:01 +02:00
|
|
|
if not self.app.activeWindow() and w and not sip.isdeleted(w) and w.isVisible():
|
2018-05-31 05:20:10 +02:00
|
|
|
w.activateWindow()
|
|
|
|
w.raise_()
|
|
|
|
self._activeWindowOnPlay = None
|
2018-04-30 09:12:26 +02:00
|
|
|
|
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")
|
2013-05-31 03:42:24 +02:00
|
|
|
for id, mid, flds in col.db.execute(
|
2021-02-11 01:09:06 +01:00
|
|
|
f"select id, mid, flds from notes where id in {ids2str(nids)}"
|
2019-12-23 01:34:10 +01:00
|
|
|
):
|
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
|
|
|
|
##########################################################################
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2020-05-15 05:59:44 +02:00
|
|
|
# this will gradually be phased out
|
2021-02-01 11:59:18 +01:00
|
|
|
def onSchemaMod(self, arg: bool) -> bool:
|
2020-05-05 05:48:38 +02:00
|
|
|
assert self.inMainThread()
|
2020-03-26 12:05:02 +01:00
|
|
|
progress_shown = self.progress.busy()
|
|
|
|
if progress_shown:
|
|
|
|
self.progress.finish()
|
2020-11-18 02:32:22 +01:00
|
|
|
ret = askUser(tr(TR.QT_MISC_THE_REQUESTED_CHANGE_WILL_REQUIRE_A))
|
2020-03-26 12:05:02 +01:00
|
|
|
if progress_shown:
|
|
|
|
self.progress.start()
|
|
|
|
return ret
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2020-05-15 05:59:44 +02:00
|
|
|
# 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
|
2020-11-18 02:32:22 +01:00
|
|
|
return askUser(tr(TR.QT_MISC_THE_REQUESTED_CHANGE_WILL_REQUIRE_A))
|
2020-05-15 05:59:44 +02:00
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
# 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)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
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)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2021-02-01 11:59:18 +01:00
|
|
|
def onStudyDeck(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
from aqt.studydeck import StudyDeck
|
2019-12-23 01:34:10 +01:00
|
|
|
|
|
|
|
ret = StudyDeck(self, dyn=True, current=self.col.decks.current()["name"])
|
2012-12-21 08:51:59 +01:00
|
|
|
if ret.name:
|
|
|
|
self.col.decks.select(self.col.decks.id(ret.name))
|
|
|
|
self.moveToState("overview")
|
|
|
|
|
2020-04-25 11:44:48 +02:00
|
|
|
def onEmptyCards(self) -> None:
|
|
|
|
show_empty_cards(self)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
# Debugging
|
|
|
|
######################################################################
|
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def onDebug(self) -> None:
|
2020-04-26 02:19:13 +02:00
|
|
|
frm = self.debug_diag_form = aqt.forms.debug.Ui_Dialog()
|
2020-05-31 01:58:00 +02:00
|
|
|
|
|
|
|
class DebugDialog(QDialog):
|
2021-02-01 14:28:21 +01:00
|
|
|
def reject(self) -> None:
|
2020-05-31 01:58:00 +02:00
|
|
|
super().reject()
|
|
|
|
saveSplitter(frm.splitter, "DebugConsoleWindow")
|
|
|
|
saveGeom(self, "DebugConsoleWindow")
|
|
|
|
|
|
|
|
d = self.debugDiag = DebugDialog()
|
|
|
|
d.silentlyClose = True
|
2021-01-07 05:24:49 +01:00
|
|
|
disable_help_button(d)
|
2012-12-21 08:51:59 +01:00
|
|
|
frm.setupUi(d)
|
2020-05-31 01:58:00 +02:00
|
|
|
restoreGeom(d, "DebugConsoleWindow")
|
|
|
|
restoreSplitter(frm.splitter, "DebugConsoleWindow")
|
2019-02-16 10:26:49 +01:00
|
|
|
font = QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
|
|
|
font.setPointSize(frm.text.font().pointSize() + 1)
|
|
|
|
frm.text.setFont(font)
|
|
|
|
frm.log.setFont(font)
|
2012-12-21 08:51:59 +01:00
|
|
|
s = self.debugDiagShort = QShortcut(QKeySequence("ctrl+return"), d)
|
2020-05-04 05:23:08 +02:00
|
|
|
qconnect(s.activated, lambda: self.onDebugRet(frm))
|
2019-12-23 01:34:10 +01:00
|
|
|
s = self.debugDiagShort = QShortcut(QKeySequence("ctrl+shift+return"), d)
|
2020-05-04 05:23:08 +02:00
|
|
|
qconnect(s.activated, lambda: self.onDebugPrint(frm))
|
2019-02-16 10:31:35 +01:00
|
|
|
s = self.debugDiagShort = QShortcut(QKeySequence("ctrl+l"), d)
|
2020-05-04 05:23:08 +02:00
|
|
|
qconnect(s.activated, frm.log.clear)
|
2019-02-16 10:31:35 +01:00
|
|
|
s = self.debugDiagShort = QShortcut(QKeySequence("ctrl+shift+l"), d)
|
2020-05-04 05:23:08 +02:00
|
|
|
qconnect(s.activated, frm.text.clear)
|
2020-04-10 08:38:38 +02:00
|
|
|
|
|
|
|
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")
|
2020-04-10 08:38:38 +02:00
|
|
|
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")
|
2020-04-10 08:38:38 +02:00
|
|
|
a.setShortcuts(QKeySequence("ctrl+shift+l"))
|
|
|
|
qconnect(a.triggered, frm.text.clear)
|
2021-02-01 11:59:18 +01:00
|
|
|
menu.exec_(QCursor.pos())
|
2020-04-10 08:38:38 +02:00
|
|
|
|
|
|
|
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)
|
2012-12-21 08:51:59 +01:00
|
|
|
d.show()
|
|
|
|
|
2020-08-01 04:27:54 +02:00
|
|
|
def _captureOutput(self, on: bool) -> None:
|
2021-02-02 14:30:53 +01:00
|
|
|
mw2 = self
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2017-02-06 23:21:33 +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
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
if on:
|
|
|
|
self._output = ""
|
|
|
|
self._oldStderr = sys.stderr
|
|
|
|
self._oldStdout = sys.stdout
|
2020-08-01 04:27:54 +02:00
|
|
|
s = cast(TextIO, Stream())
|
2012-12-21 08:51:59 +01:00
|
|
|
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:
|
2020-08-31 04:05:36 +02:00
|
|
|
import copy
|
|
|
|
import pprint
|
2012-12-21 08:51:59 +01:00
|
|
|
|
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
|
2012-12-21 08:51:59 +01:00
|
|
|
|
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
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2021-02-02 14:30:53 +01:00
|
|
|
def onDebugPrint(self, frm: aqt.forms.debug.Ui_Dialog) -> None:
|
2019-02-16 10:57:09 +01:00
|
|
|
cursor = frm.text.textCursor()
|
|
|
|
position = cursor.position()
|
|
|
|
cursor.select(QTextCursor.LineUnderCursor)
|
|
|
|
line = cursor.selectedText()
|
|
|
|
pfx, sfx = "pp(", ")"
|
|
|
|
if not line.startswith(pfx):
|
2021-02-11 00:37:38 +01:00
|
|
|
line = f"{pfx}{line}{sfx}"
|
2019-02-16 10:57:09 +01:00
|
|
|
cursor.insertText(line)
|
|
|
|
cursor.setPosition(position + len(pfx))
|
|
|
|
frm.text.setTextCursor(cursor)
|
2012-12-21 08:51:59 +01:00
|
|
|
self.onDebugRet(frm)
|
|
|
|
|
2021-02-02 14:30:53 +01:00
|
|
|
def onDebugRet(self, frm: aqt.forms.debug.Ui_Dialog) -> None:
|
2020-08-31 04:05:36 +02:00
|
|
|
import pprint
|
|
|
|
import traceback
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2012-12-21 08:51:59 +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
|
2016-05-12 06:45:35 +02:00
|
|
|
exec(text)
|
2012-12-21 08:51:59 +01:00
|
|
|
except:
|
|
|
|
self._output += traceback.format_exc()
|
|
|
|
self._captureOutput(False)
|
|
|
|
buf = ""
|
|
|
|
for c, line in enumerate(text.strip().split("\n")):
|
|
|
|
if c == 0:
|
2021-02-11 01:09:06 +01:00
|
|
|
buf += f">>> {line}\n"
|
2012-12-21 08:51:59 +01:00
|
|
|
else:
|
2021-02-11 01:09:06 +01:00
|
|
|
buf += f"... {line}\n"
|
2013-04-11 08:25:59 +02:00
|
|
|
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)
|
2013-04-11 08:25:59 +02:00
|
|
|
except UnicodeDecodeError:
|
2020-11-17 08:42:43 +01:00
|
|
|
to_append = tr(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)
|
2012-12-21 08:51:59 +01:00
|
|
|
frm.log.ensureCursorVisible()
|
|
|
|
|
|
|
|
# System specific code
|
|
|
|
##########################################################################
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def setupSystemSpecific(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
self.hideMenuAccels = False
|
|
|
|
if isMac:
|
|
|
|
# mac users expect a minimize option
|
|
|
|
self.minimizeShortcut = QShortcut("Ctrl+M", self)
|
2020-05-04 05:23:08 +02:00
|
|
|
qconnect(self.minimizeShortcut.activated, self.onMacMinimize)
|
2012-12-21 08:51:59 +01:00
|
|
|
self.hideMenuAccels = True
|
|
|
|
self.maybeHideAccelerators()
|
|
|
|
self.hideStatusTips()
|
|
|
|
elif isWin:
|
2020-03-18 15:36:32 +01:00
|
|
|
# make sure ctypes is bundled
|
2019-12-23 01:34:10 +01:00
|
|
|
from ctypes import windll, wintypes # type: ignore
|
|
|
|
|
2020-03-17 04:24:37 +01:00
|
|
|
_dummy1 = windll
|
|
|
|
_dummy2 = wintypes
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def maybeHideAccelerators(self, tgt: Optional[Any] = None) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
if not self.hideMenuAccels:
|
|
|
|
return
|
|
|
|
tgt = tgt or self
|
|
|
|
for action in tgt.findChildren(QAction):
|
2016-05-12 06:45:35 +02:00
|
|
|
txt = str(action.text())
|
2017-12-11 08:25:51 +01:00
|
|
|
m = re.match(r"^(.+)\(&.+\)(.+)?", txt)
|
2012-12-21 08:51:59 +01:00
|
|
|
if m:
|
|
|
|
action.setText(m.group(1) + (m.group(2) or ""))
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def hideStatusTips(self) -> None:
|
2012-12-21 08:51:59 +01:00
|
|
|
for action in self.findChildren(QAction):
|
|
|
|
action.setStatusTip("")
|
|
|
|
|
2020-08-01 04:27:54 +02:00
|
|
|
def onMacMinimize(self) -> None:
|
|
|
|
self.setWindowState(self.windowState() | Qt.WindowMinimized) # type: ignore
|
2012-12-21 08:51:59 +01:00
|
|
|
|
|
|
|
# Single instance support
|
|
|
|
##########################################################################
|
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def setupAppMsg(self) -> None:
|
2020-05-04 05:23:08 +02:00
|
|
|
qconnect(self.app.appMsg, self.onAppMsg)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2021-02-01 11:23:48 +01:00
|
|
|
def onAppMsg(self, buf: str) -> None:
|
2020-01-04 04:30:33 +01:00
|
|
|
is_addon = self._isAddon(buf)
|
2020-01-03 18:23:28 +01:00
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
if self.state == "startup":
|
|
|
|
# try again in a second
|
2021-02-01 11:23:48 +01:00
|
|
|
self.progress.timer(
|
2019-12-23 01:34:10 +01:00
|
|
|
1000, lambda: self.onAppMsg(buf), False, requiresCollection=False
|
|
|
|
)
|
2021-02-01 11:23:48 +01:00
|
|
|
return
|
2012-12-21 08:51:59 +01:00
|
|
|
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
|
2012-12-21 08:51:59 +01:00
|
|
|
self.pendingImport = buf
|
2020-01-03 17:57:33 +01:00
|
|
|
if is_addon:
|
2020-11-17 08:42:43 +01:00
|
|
|
msg = tr(TR.QT_MISC_ADDON_WILL_BE_INSTALLED_WHEN_A)
|
2020-01-03 17:57:33 +01:00
|
|
|
else:
|
2020-11-17 08:42:43 +01:00
|
|
|
msg = tr(TR.QT_MISC_DECK_WILL_BE_IMPORTED_WHEN_A)
|
2021-02-01 11:23:48 +01:00
|
|
|
tooltip(msg)
|
|
|
|
return
|
2012-12-21 08:51:59 +01:00
|
|
|
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(
|
2020-11-18 02:32:22 +01:00
|
|
|
tr(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
|
2012-12-21 08:51:59 +01:00
|
|
|
# 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
|
|
|
|
2020-01-03 17:57:33 +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
|
2016-07-04 05:22:35 +02:00
|
|
|
|
2020-01-04 04:30:33 +01:00
|
|
|
def _isAddon(self, buf: str) -> bool:
|
|
|
|
return buf.endswith(self.addonManager.ext)
|
|
|
|
|
2016-07-04 05:22:35 +02:00
|
|
|
# GC
|
|
|
|
##########################################################################
|
2021-03-05 04:07:52 +01:00
|
|
|
# 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)
|
|
|
|
)
|
2016-07-04 05:22:35 +02:00
|
|
|
|
2021-03-05 04:07:52 +01:00
|
|
|
def deferred_delete_and_garbage_collect(self, obj: QObject) -> None:
|
2016-07-04 05:22:35 +02:00
|
|
|
obj.deleteLater()
|
2021-03-05 04:07:52 +01:00
|
|
|
self.progress.timer(
|
|
|
|
1000, self.garbage_collect_now, False, requiresCollection=False
|
|
|
|
)
|
2016-07-04 05:22:35 +02:00
|
|
|
|
2021-03-05 04:07:52 +01:00
|
|
|
def disable_automatic_garbage_collection(self) -> None:
|
2017-01-08 05:42:50 +01:00
|
|
|
gc.collect()
|
|
|
|
gc.disable()
|
|
|
|
|
2021-03-05 04:07:52 +01:00
|
|
|
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
|
2016-07-04 05:22:35 +02:00
|
|
|
gc.collect()
|
2016-07-07 15:39:48 +02:00
|
|
|
|
2021-03-05 04:07:52 +01:00
|
|
|
# legacy aliases
|
|
|
|
|
|
|
|
setupDialogGC = garbage_collect_on_dialog_finish
|
|
|
|
gcWindow = deferred_delete_and_garbage_collect
|
|
|
|
|
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")
|
2017-01-13 11:56:24 +01:00
|
|
|
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:
|
2019-03-02 18:57:51 +01:00
|
|
|
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:
|
2021-02-11 01:09:06 +01:00
|
|
|
return f'<base href="{self.serverURL()}">'
|
2017-08-11 12:37:04 +02:00
|
|
|
|
2019-12-20 09:43:52 +01:00
|
|
|
def serverURL(self) -> str:
|
2018-11-12 13:23:47 +01:00
|
|
|
return "http://127.0.0.1:%d/" % self.mediaServer.getPort()
|