mirror of
https://github.com/ankitects/anki.git
synced 2026-01-13 14:03:55 -05:00
refactor: split responsibilities between MainWindow and ProfileManager
This commit is contained in:
parent
50b7588231
commit
2f51d903af
9 changed files with 519 additions and 236 deletions
|
|
@ -778,6 +778,10 @@ def _run(argv: list[str] | None = None, exec: bool = True) -> AnkiApp | None:
|
|||
)
|
||||
sys.exit(1)
|
||||
|
||||
from aqt.utils import cleanup_and_exit
|
||||
|
||||
cleanup_and_exit.app = app
|
||||
|
||||
# load the main window
|
||||
import aqt.main
|
||||
|
||||
|
|
|
|||
42
qt/aqt/cleanupandexitusecase.py
Normal file
42
qt/aqt/cleanupandexitusecase.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import gc
|
||||
from typing import Callable, List
|
||||
|
||||
|
||||
class CleanupAndExitUseCase:
|
||||
"""
|
||||
Handles graceful application shutdown by notifying registered subscribers and exiting the app.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.app = None
|
||||
self._subscribers: List[Callable[[], None]] = []
|
||||
|
||||
def subscribe(self, callback: Callable[[], None]):
|
||||
"""
|
||||
Registers a callback to be called before application exit.
|
||||
|
||||
Args:
|
||||
callback: A no-argument function to run before exiting.
|
||||
"""
|
||||
self._subscribers.append(callback)
|
||||
|
||||
def unsubscribe(self, callback: Callable[[], None]):
|
||||
"""
|
||||
Unregisters a previously registered callback.
|
||||
|
||||
Args:
|
||||
callback: The function to remove from the subscriber list.
|
||||
"""
|
||||
if callback in self._subscribers:
|
||||
self._subscribers.remove(callback)
|
||||
|
||||
def __call__(self):
|
||||
"""
|
||||
Executes all registered callbacks, performs cleanup, and exits the app.
|
||||
"""
|
||||
for callback in self._subscribers:
|
||||
callback()
|
||||
|
||||
gc.collect()
|
||||
self.app._unset_windows_shutdown_block_reason()
|
||||
self.app.exit(0)
|
||||
|
|
@ -215,3 +215,24 @@ def import_progress_update(progress: Progress, update: ProgressUpdate) -> None:
|
|||
update.label = progress.importing
|
||||
if update.user_wants_abort:
|
||||
update.abort = True
|
||||
|
||||
from aqt.openbackup import (
|
||||
restore_backup,
|
||||
restore_backup_with_confirm,
|
||||
choose_and_restore_backup,
|
||||
)
|
||||
|
||||
def _restore(path, success, error):
|
||||
from aqt import mw
|
||||
|
||||
if mw.col:
|
||||
mw.unloadCollection(lambda: import_collection_package_op(
|
||||
mw, path, success=success
|
||||
).failure(error).run_in_background())
|
||||
else:
|
||||
import_collection_package_op(
|
||||
mw, path, success=success
|
||||
).failure(error).run_in_background()
|
||||
|
||||
|
||||
restore_backup.set_restore_func(_restore)
|
||||
|
|
|
|||
319
qt/aqt/main.py
319
qt/aqt/main.py
|
|
@ -13,7 +13,6 @@ import traceback
|
|||
import weakref
|
||||
from argparse import Namespace
|
||||
from collections.abc import Callable, Sequence
|
||||
from concurrent.futures import Future
|
||||
from typing import Any, Literal, TypeVar, cast
|
||||
|
||||
import anki
|
||||
|
|
@ -78,6 +77,7 @@ from aqt.utils import (
|
|||
KeyboardModifiersPressed,
|
||||
askUser,
|
||||
checkInvalidFilename,
|
||||
cleanup_and_exit,
|
||||
current_window,
|
||||
disallow_full_screen,
|
||||
getFile,
|
||||
|
|
@ -220,18 +220,39 @@ class AnkiQt(QMainWindow):
|
|||
# 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
|
||||
|
||||
def on_window_init() -> None:
|
||||
fn()
|
||||
gui_hooks.main_window_did_init()
|
||||
def run_later():
|
||||
try:
|
||||
if is_win:
|
||||
self.setupProfileAfterWebviewsLoaded()
|
||||
else:
|
||||
self.setupProfile()
|
||||
|
||||
self.progress.single_shot(10, on_window_init, False)
|
||||
gui_hooks.main_window_did_init()
|
||||
|
||||
except Exception:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
# Schedule actual work
|
||||
self.progress.single_shot(10, run_later, False)
|
||||
|
||||
on_window_init()
|
||||
|
||||
from aqt.utils import cleanup_and_exit
|
||||
|
||||
def _exit():
|
||||
self.errorHandler.unload()
|
||||
self.mediaServer.shutdown()
|
||||
# Rust background jobs are not awaited implicitly
|
||||
self.backend.await_backup_completion()
|
||||
self.deleteLater()
|
||||
|
||||
cleanup_and_exit.subscribe(_exit)
|
||||
|
||||
def setupUI(self) -> None:
|
||||
self.col = None
|
||||
|
|
@ -256,6 +277,17 @@ class AnkiQt(QMainWindow):
|
|||
self.setupDeckBrowser()
|
||||
self.setupOverview()
|
||||
self.setupReviewer()
|
||||
self.setupProfileDialog()
|
||||
|
||||
from aqt.openbackup import choose_and_restore_backup
|
||||
choose_and_restore_backup.set_choose_path_func(lambda callback:
|
||||
getFile(
|
||||
self,
|
||||
tr.qt_misc_revert_to_backup(),
|
||||
cb=callback,
|
||||
filter="*.colpkg",
|
||||
dir=self.pm.backupFolder(),
|
||||
))
|
||||
|
||||
def finish_ui_setup(self) -> None:
|
||||
"Actions that are deferred until after add-on loading."
|
||||
|
|
@ -268,7 +300,7 @@ class AnkiQt(QMainWindow):
|
|||
if not w._domDone:
|
||||
self.progress.single_shot(
|
||||
10,
|
||||
self.setupProfileAfterWebviewsLoaded,
|
||||
lambda: self.setupProfileAfterWebviewsLoaded(),
|
||||
False,
|
||||
)
|
||||
return
|
||||
|
|
@ -290,20 +322,6 @@ class AnkiQt(QMainWindow):
|
|||
# Profiles
|
||||
##########################################################################
|
||||
|
||||
class ProfileManager(QMainWindow):
|
||||
onClose = pyqtSignal()
|
||||
closeFires = True
|
||||
|
||||
def closeEvent(self, evt: QCloseEvent) -> None:
|
||||
if self.closeFires:
|
||||
self.onClose.emit() # type: ignore
|
||||
evt.accept()
|
||||
|
||||
def closeWithoutQuitting(self) -> None:
|
||||
self.closeFires = False
|
||||
self.close()
|
||||
self.closeFires = True
|
||||
|
||||
def setupProfile(self) -> None:
|
||||
if self.pm.meta["firstRun"]:
|
||||
# load the new deck user profile
|
||||
|
|
@ -325,195 +343,41 @@ class AnkiQt(QMainWindow):
|
|||
self.pm.load(name)
|
||||
|
||||
if not self.pm.name:
|
||||
self.showProfileManager()
|
||||
pass
|
||||
else:
|
||||
self.loadProfile()
|
||||
|
||||
def setupProfileDialog(self):
|
||||
from aqt.profiledialog import ProfileDialog
|
||||
|
||||
self.profileDialog = ProfileDialog(self.pm)
|
||||
|
||||
def showProfileManager(self) -> None:
|
||||
self.pm.profile = None
|
||||
self.moveToState("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)
|
||||
f.downgrade_button.setText(tr.profiles_downgrade_and_quit())
|
||||
# enter key opens profile
|
||||
QShortcut(QKeySequence("Return"), d, activated=self.onOpenProfile) # type: ignore
|
||||
self.refreshProfilesList()
|
||||
# raise first, for osx testing
|
||||
d.show()
|
||||
d.activateWindow()
|
||||
d.raise_()
|
||||
|
||||
def refreshProfilesList(self) -> None:
|
||||
f = self.profileForm
|
||||
f.profiles.clear()
|
||||
profs = self.pm.profiles()
|
||||
f.profiles.addItems(profs)
|
||||
try:
|
||||
idx = profs.index(self.pm.name)
|
||||
except Exception:
|
||||
idx = 0
|
||||
f.profiles.setCurrentRow(idx)
|
||||
|
||||
def onProfileRowChange(self, n: int) -> None:
|
||||
if n < 0:
|
||||
# called on .clear()
|
||||
return
|
||||
name = self.pm.profiles()[n]
|
||||
self.pm.load(name)
|
||||
|
||||
def openProfile(self) -> None:
|
||||
name = self.pm.profiles()[self.profileForm.profiles.currentRow()]
|
||||
self.pm.load(name)
|
||||
return
|
||||
|
||||
def onOpenProfile(self, *, callback: Callable[[], None] | None = None) -> None:
|
||||
def on_done() -> None:
|
||||
self.profileDiag.closeWithoutQuitting()
|
||||
if callback:
|
||||
callback()
|
||||
|
||||
self.profileDiag.hide()
|
||||
# code flow is confusing here - if load fails, profile dialog
|
||||
# will be shown again
|
||||
self.loadProfile(on_done)
|
||||
|
||||
def profileNameOk(self, name: str) -> bool:
|
||||
return not checkInvalidFilename(name) and name != "addons21"
|
||||
|
||||
def onAddProfile(self) -> None:
|
||||
name = getOnlyText(tr.actions_name()).strip()
|
||||
if name:
|
||||
if name in self.pm.profiles():
|
||||
showWarning(tr.qt_misc_name_exists())
|
||||
return
|
||||
if not self.profileNameOk(name):
|
||||
return
|
||||
self.pm.create(name)
|
||||
self.pm.name = name
|
||||
self.refreshProfilesList()
|
||||
|
||||
def onRenameProfile(self) -> None:
|
||||
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():
|
||||
showWarning(tr.qt_misc_name_exists())
|
||||
return
|
||||
if not self.profileNameOk(name):
|
||||
return
|
||||
self.pm.rename(name)
|
||||
self.refreshProfilesList()
|
||||
|
||||
def onRemProfile(self) -> None:
|
||||
profs = self.pm.profiles()
|
||||
if len(profs) < 2:
|
||||
showWarning(tr.qt_misc_there_must_be_at_least_one())
|
||||
return
|
||||
# sure?
|
||||
if not askUser(
|
||||
tr.qt_misc_all_cards_notes_and_media_for2(name=self.pm.name),
|
||||
msgfunc=QMessageBox.warning,
|
||||
defaultno=True,
|
||||
):
|
||||
return
|
||||
self.pm.remove(self.pm.name)
|
||||
self.refreshProfilesList()
|
||||
self.profileDialog.show()
|
||||
|
||||
def _handle_load_backup_success(self) -> None:
|
||||
"""
|
||||
Actions that occur when profile backup has been loaded successfully
|
||||
"""
|
||||
if self.state == "profileManager":
|
||||
self.profileDiag.closeWithoutQuitting()
|
||||
|
||||
self.loadProfile()
|
||||
self.loadCollection()
|
||||
|
||||
def _handle_load_backup_failure(self, error: Exception) -> None:
|
||||
"""
|
||||
Actions that occur when a profile has loaded unsuccessfully
|
||||
"""
|
||||
showWarning(str(error))
|
||||
if self.state != "profileManager":
|
||||
self.loadProfile()
|
||||
self.loadCollection()
|
||||
|
||||
def onOpenBackup(self) -> None:
|
||||
from aqt.openbackup import choose_and_restore_backup
|
||||
choose_and_restore_backup(self._handle_load_backup_success, self._handle_load_backup_failure)
|
||||
|
||||
def do_open(path: str) -> None:
|
||||
if not askUser(
|
||||
tr.qt_misc_replace_your_collection_with_an_earlier2(
|
||||
os.path.basename(path)
|
||||
),
|
||||
msgfunc=QMessageBox.warning,
|
||||
defaultno=True,
|
||||
):
|
||||
return
|
||||
|
||||
showInfo(tr.qt_misc_automatic_syncing_and_backups_have_been())
|
||||
|
||||
# Collection is still loaded if called from main window, so we unload. This is already
|
||||
# unloaded if called from the ProfileManager window.
|
||||
if self.col:
|
||||
self.unloadProfile(lambda: self._start_restore_backup(path))
|
||||
return
|
||||
|
||||
self._start_restore_backup(path)
|
||||
|
||||
getFile(
|
||||
self.profileDiag if self.state == "profileManager" else self,
|
||||
tr.qt_misc_revert_to_backup(),
|
||||
cb=do_open, # type: ignore
|
||||
filter="*.colpkg",
|
||||
dir=self.pm.backupFolder(),
|
||||
)
|
||||
|
||||
def _start_restore_backup(self, path: str):
|
||||
self.restoring_backup = True
|
||||
|
||||
import_collection_package_op(
|
||||
self, path, success=self._handle_load_backup_success
|
||||
).failure(self._handle_load_backup_failure).run_in_background()
|
||||
|
||||
def _on_downgrade(self) -> None:
|
||||
self.progress.start()
|
||||
profiles = self.pm.profiles()
|
||||
|
||||
def downgrade() -> list[str]:
|
||||
return self.pm.downgrade(profiles)
|
||||
|
||||
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
|
||||
def loadProfile(self) -> None:
|
||||
self.loadCollection()
|
||||
|
||||
def _restoreUI(self):
|
||||
self.setup_sound()
|
||||
self.flags = FlagManager(self)
|
||||
# show main window
|
||||
|
|
@ -537,8 +401,6 @@ class AnkiQt(QMainWindow):
|
|||
def _onsuccess(synced: bool) -> None:
|
||||
if synced:
|
||||
self._refresh_after_sync()
|
||||
if onsuccess:
|
||||
onsuccess()
|
||||
if not self.safeMode:
|
||||
self.maybe_check_for_addon_updates(self.setup_auto_update)
|
||||
|
||||
|
|
@ -568,6 +430,7 @@ class AnkiQt(QMainWindow):
|
|||
|
||||
refresh_reviewer_on_day_rollover_change()
|
||||
gui_hooks.profile_did_open()
|
||||
self.moveToState("deckBrowser")
|
||||
self.maybe_auto_sync_on_open_close(_onsuccess)
|
||||
|
||||
def unloadProfile(self, onsuccess: Callable) -> None:
|
||||
|
|
@ -601,28 +464,11 @@ class AnkiQt(QMainWindow):
|
|||
print(f"Window should have been closed: {w}")
|
||||
|
||||
def unloadProfileAndExit(self) -> None:
|
||||
self.unloadProfile(self.cleanupAndExit)
|
||||
self.unloadProfile(cleanup_and_exit)
|
||||
|
||||
def unloadProfileAndShowProfileManager(self) -> None:
|
||||
self.unloadProfile(self.showProfileManager)
|
||||
|
||||
def cleanupAndExit(self) -> None:
|
||||
self.errorHandler.unload()
|
||||
self.mediaServer.shutdown()
|
||||
# Rust background jobs are not awaited implicitly
|
||||
self.backend.await_backup_completion()
|
||||
self.deleteLater()
|
||||
app = self.app
|
||||
app._unset_windows_shutdown_block_reason()
|
||||
|
||||
def exit():
|
||||
# try to ensure Qt objects are deleted in a logical order,
|
||||
# to prevent crashes on shutdown
|
||||
gc.collect()
|
||||
app.exit(0)
|
||||
|
||||
self.progress.single_shot(100, exit, False)
|
||||
|
||||
# Sound/video
|
||||
##########################################################################
|
||||
|
||||
|
|
@ -633,7 +479,6 @@ class AnkiQt(QMainWindow):
|
|||
aqt.sound.cleanup_audio()
|
||||
|
||||
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):
|
||||
return anki.sound.strip_av_refs(text)
|
||||
else:
|
||||
|
|
@ -647,27 +492,33 @@ class AnkiQt(QMainWindow):
|
|||
# Collection load/unload
|
||||
##########################################################################
|
||||
|
||||
def onCollectionLoad(self, col) -> None:
|
||||
self._restoreUI()
|
||||
|
||||
def onCollectionLoadError(self, e: Exception) -> None:
|
||||
self.hide()
|
||||
if "FileTooNew" in str(e):
|
||||
showWarning(
|
||||
"This profile requires a newer version of Anki to open. Did you forget to use the Downgrade button prior to switching Anki versions?"
|
||||
)
|
||||
else:
|
||||
showWarning(
|
||||
f"{tr.errors_unable_open_collection()}\n{traceback.format_exc()}"
|
||||
)
|
||||
|
||||
self.showProfileManager()
|
||||
|
||||
def loadCollection(self) -> bool:
|
||||
try:
|
||||
self._loadCollection()
|
||||
except Exception as e:
|
||||
if "FileTooNew" in str(e):
|
||||
showWarning(
|
||||
"This profile requires a newer version of Anki to open. Did you forget to use the Downgrade button prior to switching Anki versions?"
|
||||
)
|
||||
else:
|
||||
showWarning(
|
||||
f"{tr.errors_unable_open_collection()}\n{traceback.format_exc()}"
|
||||
)
|
||||
# clean up open collection if possible
|
||||
try:
|
||||
self.backend.close_collection(downgrade_to_schema11=False)
|
||||
except Exception as e:
|
||||
print("unable to close collection:", e)
|
||||
except Exception as e_:
|
||||
print("unable to close collection:", e_)
|
||||
self.col = None
|
||||
# return to profile manager
|
||||
self.hide()
|
||||
self.showProfileManager()
|
||||
gui_hooks.collection_load_did_fail(e)
|
||||
return False
|
||||
|
||||
# make sure we don't get into an inconsistent state if an add-on
|
||||
|
|
@ -676,7 +527,7 @@ class AnkiQt(QMainWindow):
|
|||
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()
|
||||
|
|
@ -986,6 +837,8 @@ title="{}" {}>{}</button>""".format(
|
|||
webview.force_load_hack()
|
||||
|
||||
gui_hooks.card_review_webview_did_init(self.web, AnkiWebViewKind.MAIN)
|
||||
gui_hooks.collection_did_load.append(self.onCollectionLoad)
|
||||
gui_hooks.collection_load_did_fail.append(self.onCollectionLoadError)
|
||||
|
||||
def closeAllWindows(self, onsuccess: Callable) -> None:
|
||||
aqt.dialogs.closeAll(onsuccess)
|
||||
|
|
@ -1230,15 +1083,9 @@ title="{}" {}>{}</button>""".format(
|
|||
##########################################################################
|
||||
|
||||
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()
|
||||
# ignore the event for now, as we need time to clean up
|
||||
event.ignore()
|
||||
self.unloadProfileAndExit()
|
||||
|
||||
# Undo & autosave
|
||||
##########################################################################
|
||||
|
|
|
|||
73
qt/aqt/openbackup.py
Normal file
73
qt/aqt/openbackup.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import os
|
||||
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
from aqt.utils import tr
|
||||
from aqt.utils import getFile, askUser, showInfo
|
||||
|
||||
|
||||
def confirm(path):
|
||||
return askUser(
|
||||
tr.qt_misc_replace_your_collection_with_an_earlier2(os.path.basename(path)),
|
||||
msgfunc=QMessageBox.warning,
|
||||
defaultno=True,
|
||||
)
|
||||
|
||||
def inform():
|
||||
showInfo("Automatic syncing and backups have been disabled while restoring. To enable them again, close the profile or restart Anki.")
|
||||
|
||||
|
||||
class RestoreBackupUseCase:
|
||||
def __init__(self):
|
||||
self.restore_func = lambda path, success, error: error(Exception("No back up function set."))
|
||||
self.inform_func = lambda: None
|
||||
|
||||
def set_restore_func(self, restore_func):
|
||||
self.restore_func = restore_func
|
||||
|
||||
def set_inform_func(self, inform_func):
|
||||
self.inform_func = inform_func
|
||||
|
||||
def __call__(self, path: str, success, error):
|
||||
self.inform_func()
|
||||
self.restore_func(path, success, error)
|
||||
|
||||
class RestoreBackupWithConfirmUseCase:
|
||||
def __init__(self, open_backup_use_case: RestoreBackupUseCase):
|
||||
self.confirm_func = lambda path: False
|
||||
self.open_backup_use_case = open_backup_use_case
|
||||
|
||||
def set_confirm_func(self, confirm_func):
|
||||
self.confirm_func = confirm_func
|
||||
|
||||
def __call__(self, path: str, success, error):
|
||||
if self.confirm_func(path):
|
||||
self.open_backup_use_case(path, success, error)
|
||||
|
||||
class ChooseBackupUseCase:
|
||||
def __init__(self, restore_use_case: RestoreBackupUseCase):
|
||||
self.choose_path_func = lambda callback: None
|
||||
self.confirm_func = lambda path: False
|
||||
self.restore_use_case = restore_use_case
|
||||
|
||||
def set_choose_path_func(self, choose_path_func):
|
||||
self.choose_path_func = choose_path_func
|
||||
|
||||
def set_confirm_func(self, confirm_func):
|
||||
self.confirm_func = confirm_func
|
||||
|
||||
def __call__(self, success, error):
|
||||
def on_path_chosen(path):
|
||||
if self.confirm_func(path):
|
||||
self.restore_use_case(path, success, error)
|
||||
|
||||
self.choose_path_func(on_path_chosen)
|
||||
|
||||
|
||||
restore_backup = RestoreBackupUseCase()
|
||||
|
||||
restore_backup_with_confirm = RestoreBackupWithConfirmUseCase(restore_backup)
|
||||
restore_backup_with_confirm.set_confirm_func(confirm)
|
||||
|
||||
choose_and_restore_backup = ChooseBackupUseCase(restore_backup)
|
||||
choose_and_restore_backup.set_confirm_func(confirm)
|
||||
163
qt/aqt/profiledialog.py
Normal file
163
qt/aqt/profiledialog.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import Future
|
||||
|
||||
from PyQt6.QtWidgets import QMessageBox, QMainWindow
|
||||
|
||||
from aqt import mw
|
||||
from aqt.profiles import ProfileManager as ProfileManagerType
|
||||
from aqt.utils import askUser, checkInvalidFilename, getOnlyText, showInfo, showWarning, tr
|
||||
|
||||
|
||||
class ProfileDialog:
|
||||
_activeProfile: int = 0
|
||||
_profiles = []
|
||||
|
||||
def __init__(self, pm: ProfileManagerType):
|
||||
self.pm = pm
|
||||
|
||||
def _refreshProfiles(self):
|
||||
self._profiles = self.pm.profiles()
|
||||
try:
|
||||
self._activeProfile = self._profiles.index(self.pm.name)
|
||||
except Exception:
|
||||
self._activeProfile = 0
|
||||
mw.profileForm.profiles.clear()
|
||||
mw.profileForm.profiles.addItems(self._profiles)
|
||||
mw.profileForm.profiles.setCurrentRow(self._activeProfile)
|
||||
|
||||
def _profileNameOk(self, name: str) -> bool:
|
||||
return not checkInvalidFilename(name) and name != "addons21"
|
||||
|
||||
def _activeProfileName(self):
|
||||
return self._profiles[self._activeProfile]
|
||||
|
||||
def onOpenProfile(self) -> None:
|
||||
self.pm.load(self._activeProfileName())
|
||||
if mw.loadCollection():
|
||||
self.d.hide()
|
||||
|
||||
def onAddProfile(self) -> None:
|
||||
name = getOnlyText(tr.actions_name()).strip()
|
||||
if name:
|
||||
if name in self._profiles:
|
||||
showWarning(tr.qt_misc_name_exists())
|
||||
return
|
||||
if not self._profileNameOk(name):
|
||||
return
|
||||
self.pm.create(name)
|
||||
self.pm.name = name
|
||||
self._refreshProfiles()
|
||||
|
||||
def onOpenBackup(self) -> None:
|
||||
from aqt.openbackup import (
|
||||
restore_backup,
|
||||
restore_backup_with_confirm,
|
||||
choose_and_restore_backup
|
||||
)
|
||||
|
||||
def success():
|
||||
self.pm.load(self._activeProfileName())
|
||||
if mw.loadCollection():
|
||||
self.d.hide()
|
||||
|
||||
def error(e: Exception):
|
||||
showWarning("Backup could not be restored\n" + str(e))
|
||||
|
||||
choose_and_restore_backup(success, error)
|
||||
|
||||
def onQuit(self) -> None:
|
||||
from aqt.utils import cleanup_and_exit
|
||||
|
||||
cleanup_and_exit()
|
||||
|
||||
def onRenameProfile(self) -> None:
|
||||
name = getOnlyText(
|
||||
tr.actions_new_name(), default=self._activeProfileName()
|
||||
).strip()
|
||||
if not name:
|
||||
return
|
||||
if name == self._activeProfileName():
|
||||
return
|
||||
if name in self._profiles:
|
||||
showWarning(tr.qt_misc_name_exists())
|
||||
return
|
||||
if not self._profileNameOk(name):
|
||||
return
|
||||
self.pm.rename(name)
|
||||
self._refreshProfiles()
|
||||
|
||||
def onRemProfile(self) -> None:
|
||||
if len(self._profiles) < 2:
|
||||
showWarning(tr.qt_misc_there_must_be_at_least_one())
|
||||
return
|
||||
# sure?
|
||||
if not askUser(
|
||||
tr.qt_misc_all_cards_notes_and_media_for2(name=self._activeProfileName()),
|
||||
msgfunc=QMessageBox.warning,
|
||||
defaultno=True,
|
||||
):
|
||||
return
|
||||
self.pm.remove(self._activeProfileName())
|
||||
self._refreshProfiles()
|
||||
|
||||
def onProfileRowChange(self, n: int) -> None:
|
||||
if n < 0:
|
||||
# called on .clear()
|
||||
return
|
||||
|
||||
self._activeProfile = n
|
||||
|
||||
def onDowngrade(self) -> None:
|
||||
mw.progress.start()
|
||||
profiles = mw.pm.profiles()
|
||||
|
||||
def downgrade() -> list[str]:
|
||||
return mw.pm.downgrade(profiles)
|
||||
|
||||
def on_done(future: Future) -> None:
|
||||
mw.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
|
||||
from aqt.utils import cleanup_and_exit
|
||||
|
||||
cleanup_and_exit()
|
||||
|
||||
mw.taskman.run_in_background(downgrade, on_done)
|
||||
|
||||
def show(self):
|
||||
import aqt
|
||||
from aqt.qt import QKeySequence, QShortcut, qconnect
|
||||
from aqt.utils import tr
|
||||
|
||||
d = self.d = QMainWindow()
|
||||
f = mw.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, self.onQuit)
|
||||
qconnect(f.add.clicked, self.onAddProfile)
|
||||
qconnect(f.rename.clicked, self.onRenameProfile)
|
||||
d.closeEvent = lambda ev: self.onQuit()
|
||||
qconnect(f.delete_2.clicked, self.onRemProfile)
|
||||
qconnect(f.profiles.currentRowChanged, self.onProfileRowChange)
|
||||
f.statusbar.setVisible(False)
|
||||
qconnect(f.downgrade_button.clicked, self.onDowngrade)
|
||||
f.downgrade_button.setText(tr.profiles_downgrade_and_quit())
|
||||
# enter key opens profile
|
||||
QShortcut(QKeySequence("Return"), d, activated=self.onOpenProfile) # type: ignore
|
||||
self._refreshProfiles()
|
||||
# raise first, for osx testing
|
||||
d.show()
|
||||
d.activateWindow()
|
||||
d.raise_()
|
||||
|
||||
|
|
@ -28,6 +28,7 @@ from anki.utils import (
|
|||
no_bundled_libs,
|
||||
version_with_build,
|
||||
)
|
||||
from aqt.cleanupandexitusecase import CleanupAndExitUseCase
|
||||
from aqt.qt import *
|
||||
from aqt.qt import (
|
||||
PYQT_VERSION_STR,
|
||||
|
|
@ -85,6 +86,8 @@ from aqt.theme import theme_manager
|
|||
if TYPE_CHECKING:
|
||||
TextFormat = Literal["plain", "rich", "markdown"]
|
||||
|
||||
cleanup_and_exit = CleanupAndExitUseCase()
|
||||
|
||||
|
||||
def aqt_data_path() -> Path:
|
||||
# packaged?
|
||||
|
|
|
|||
125
qt/tests/test_profiledialog.py
Normal file
125
qt/tests/test_profiledialog.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import importlib
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the root project directory to sys.path
|
||||
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch, call
|
||||
|
||||
|
||||
|
||||
class TestProfileDialog(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.pm = Mock()
|
||||
self.pm.profiles.return_value = ["user1", "user2"]
|
||||
self.pm.name = "user1"
|
||||
|
||||
# Patch mw and profileForm
|
||||
patcher1 = patch("aqt.mw")
|
||||
|
||||
self.mock_mw = patcher1.start()
|
||||
self.addCleanup(patcher1.stop)
|
||||
if "aqt.profiledialog" in sys.modules:
|
||||
importlib.reload(sys.modules["aqt.profiledialog"])
|
||||
else:
|
||||
importlib.import_module("aqt.profiledialog")
|
||||
|
||||
from aqt.profiledialog import ProfileDialog
|
||||
|
||||
# mock profileForm
|
||||
self.mock_profiles = Mock()
|
||||
self.mock_mw.profileForm.profiles = self.mock_profiles
|
||||
|
||||
# other mocks
|
||||
self.mock_mw.profileNameOk.return_value = True
|
||||
self.mock_mw.pm = self.pm
|
||||
self.mock_mw.taskman = Mock()
|
||||
self.mock_mw.progress = Mock()
|
||||
|
||||
self.dialog = ProfileDialog(self.pm)
|
||||
|
||||
def test_refresh_profiles_sets_correct_index(self):
|
||||
self.dialog._refreshProfiles()
|
||||
|
||||
self.assertEqual(self.dialog._activeProfile, 0)
|
||||
self.mock_profiles.clear.assert_called_once()
|
||||
self.mock_profiles.addItems.assert_called_once_with(["user1", "user2"])
|
||||
self.mock_profiles.setCurrentRow.assert_called_once_with(0)
|
||||
|
||||
def test_active_profile_name(self):
|
||||
self.dialog._profiles = ["x", "y"]
|
||||
self.dialog._activeProfile = 1
|
||||
self.assertEqual(self.dialog._activeProfileName(), "y")
|
||||
|
||||
def test_on_open_profile_calls_load(self):
|
||||
self.dialog._profiles = ["user1"]
|
||||
self.dialog._activeProfile = 0
|
||||
self.dialog.onOpenProfile()
|
||||
self.pm.load.assert_called_once_with("user1")
|
||||
self.mock_mw.loadCollection.assert_called_once()
|
||||
|
||||
def test_on_add_profile_adds_new_profile(self):
|
||||
self.dialog._profiles = ["user1"]
|
||||
with patch("aqt.profiledialog.getOnlyText", return_value="newuser"), \
|
||||
patch("aqt.profiledialog.tr") as mock_tr:
|
||||
mock_tr.actions_name.return_value = "Enter name"
|
||||
self.dialog.onAddProfile()
|
||||
self.pm.create.assert_called_once_with("newuser")
|
||||
self.assertEqual(self.pm.name, "newuser")
|
||||
|
||||
def test_on_add_profile_duplicate(self):
|
||||
self.dialog._profiles = ["user1", "dupe"]
|
||||
with patch("aqt.profiledialog.getOnlyText", return_value="dupe"), \
|
||||
patch("aqt.profiledialog.tr") as mock_tr, \
|
||||
patch("aqt.profiledialog.showWarning") as mock_warn:
|
||||
mock_tr.actions_name.return_value = "Enter name"
|
||||
mock_tr.qt_misc_name_exists.return_value = "Name exists"
|
||||
self.dialog.onAddProfile()
|
||||
mock_warn.assert_called_once_with("Name exists")
|
||||
|
||||
def test_on_rename_profile_valid(self):
|
||||
self.dialog._profiles = ["user1"]
|
||||
self.dialog._activeProfile = 0
|
||||
with patch("aqt.profiledialog.getOnlyText", return_value="renamed"), \
|
||||
patch("aqt.profiledialog.tr") as mock_tr:
|
||||
mock_tr.actions_new_name.return_value = "New name"
|
||||
self.dialog.onRenameProfile()
|
||||
self.pm.rename.assert_called_once_with("renamed")
|
||||
|
||||
def test_on_rem_profile_confirms_and_removes(self):
|
||||
self.dialog._profiles = ["user1", "user2"]
|
||||
self.dialog._activeProfile = 0
|
||||
with patch("aqt.profiledialog.tr") as mock_tr, \
|
||||
patch("aqt.profiledialog.askUser", return_value=True):
|
||||
mock_tr.qt_misc_all_cards_notes_and_media_for2.return_value = "Delete?"
|
||||
self.dialog.onRemProfile()
|
||||
self.pm.remove.assert_called_once_with("user1")
|
||||
|
||||
def test_on_profile_row_change_updates_index(self):
|
||||
self.dialog._activeProfile = 0
|
||||
self.dialog.onProfileRowChange(1)
|
||||
self.assertEqual(self.dialog._activeProfile, 1)
|
||||
|
||||
def test_on_profile_row_change_negative_does_not_update(self):
|
||||
self.dialog._activeProfile = 0
|
||||
self.dialog.onProfileRowChange(-1)
|
||||
self.assertEqual(self.dialog._activeProfile, 0)
|
||||
|
||||
def test_on_downgrade_runs_task(self):
|
||||
self.mock_mw.pm.downgrade.return_value = []
|
||||
future = Mock()
|
||||
future.result.return_value = []
|
||||
on_done = None
|
||||
|
||||
def run_in_background(fn, cb):
|
||||
nonlocal on_done
|
||||
on_done = cb
|
||||
return fn()
|
||||
|
||||
self.mock_mw.taskman.run_in_background.side_effect = run_in_background
|
||||
self.dialog.onDowngrade()
|
||||
self.assertTrue(callable(on_done))
|
||||
on_done(Mock(result=lambda: []))
|
||||
self.mock_mw.progress.start.assert_called()
|
||||
self.mock_mw.progress.finish.assert_called()
|
||||
|
|
@ -40,6 +40,11 @@ from aqt.undo import UndoActionsInfo
|
|||
hooks = [
|
||||
# Reviewing
|
||||
###################
|
||||
Hook(
|
||||
name="collection_load_did_fail",
|
||||
args=["e: Exception"],
|
||||
doc="""Called when the collection could not be loaded.""",
|
||||
),
|
||||
Hook(
|
||||
name="overview_did_refresh",
|
||||
args=["overview: aqt.overview.Overview"],
|
||||
|
|
|
|||
Loading…
Reference in a new issue