Anki/qt/aqt/preferences.py
Ranjit Odedra d24d2e3394
Fix sync login dialog not using newly-set custom sync server #4395 (#4396)
* Fix sync login with custom server URL

Call update_network() before showing login dialog to ensure
the custom sync server URL is saved before attempting login.

Previously, the custom URL was only saved when closing the
preferences dialog, causing login to fail on first attempt.

Fixes #4395

* Add contributor info
2025-10-18 01:11:50 +03:00

477 lines
18 KiB
Python

# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
import functools
import re
from collections.abc import Callable
import anki.lang
import aqt
import aqt.forms
import aqt.operations
from anki.collection import OpChanges
from anki.utils import is_mac
from aqt import AnkiQt
from aqt.ankihub import ankihub_login, ankihub_logout
from aqt.operations.collection import set_preferences
from aqt.profiles import VideoDriver
from aqt.qt import *
from aqt.sync import sync_login
from aqt.theme import Theme
from aqt.url_schemes import show_url_schemes_dialog
from aqt.utils import (
HelpPage,
add_close_shortcut,
add_ellipsis_to_action_label,
askUser,
disable_help_button,
is_win,
openHelp,
showInfo,
showWarning,
tr,
)
class Preferences(QDialog):
def __init__(self, mw: AnkiQt) -> None:
QDialog.__init__(self, mw, Qt.WindowType.Window)
self.mw = mw
self.prof = self.mw.pm.profile
self.form = aqt.forms.preferences.Ui_Preferences()
self.form.setupUi(self)
for spinbox in (
self.form.lrnCutoff,
self.form.dayOffset,
self.form.timeLimit,
self.form.network_timeout,
):
spinbox.setSuffix(f" {spinbox.suffix()}")
disable_help_button(self)
help_button = self.form.buttonBox.button(QDialogButtonBox.StandardButton.Help)
assert help_button is not None
help_button.setAutoDefault(False)
close_button = self.form.buttonBox.button(QDialogButtonBox.StandardButton.Close)
assert close_button is not None
close_button.setAutoDefault(False)
qconnect(
self.form.buttonBox.helpRequested, lambda: openHelp(HelpPage.PREFERENCES)
)
self.silentlyClose = True
self.setup_collection()
self.setup_profile()
self.setup_global()
self.setup_configurable_answer_keys()
add_close_shortcut(self)
self.show()
def setup_configurable_answer_keys(self):
"""
Create a group box in Preferences with widgets that let the user edit answer keys.
"""
ease_labels = (
(1, tr.studying_again()),
(2, tr.studying_hard()),
(3, tr.studying_good()),
(4, tr.studying_easy()),
)
group = self.form.preferences_answer_keys
group.setLayout(layout := QFormLayout())
tab_widget: QWidget = self.form.url_schemes
for ease, label in ease_labels:
layout.addRow(
label,
line_edit := QLineEdit(self.mw.pm.get_answer_key(ease) or ""),
)
QWidget.setTabOrder(tab_widget, line_edit)
tab_widget = line_edit
qconnect(
line_edit.textChanged,
functools.partial(self.mw.pm.set_answer_key, ease),
)
line_edit.setPlaceholderText(tr.preferences_shortcut_placeholder())
def accept(self) -> None:
self.accept_with_callback()
def accept_with_callback(self, callback: Callable[[], None] | None = None) -> None:
# avoid exception if main window is already closed
if not self.mw.col:
return
def after_collection_update() -> None:
self.update_profile()
self.update_global()
self.mw.pm.save()
self.done(0)
aqt.dialogs.markClosed("Preferences")
if callback:
callback()
self.update_collection(after_collection_update)
def reject(self) -> None:
self.accept()
# Preferences stored in the collection
######################################################################
def setup_collection(self) -> None:
self.prefs = self.mw.col.get_preferences()
form = self.form
scheduling = self.prefs.scheduling
form.lrnCutoff.setValue(int(scheduling.learn_ahead_secs / 60.0))
form.dayOffset.setValue(scheduling.rollover)
reviewing = self.prefs.reviewing
form.timeLimit.setValue(int(reviewing.time_limit_secs / 60.0))
form.showEstimates.setChecked(reviewing.show_intervals_on_buttons)
form.showProgress.setChecked(reviewing.show_remaining_due_counts)
form.showPlayButtons.setChecked(not reviewing.hide_audio_play_buttons)
form.interrupt_audio.setChecked(reviewing.interrupt_audio_when_answering)
editing = self.prefs.editing
form.useCurrent.setCurrentIndex(
0 if editing.adding_defaults_to_current_deck else 1
)
form.paste_strips_formatting.setChecked(editing.paste_strips_formatting)
form.ignore_accents_in_search.setChecked(editing.ignore_accents_in_search)
form.pastePNG.setChecked(editing.paste_images_as_png)
form.render_latex.setChecked(editing.render_latex)
form.default_search_text.setText(editing.default_search_text)
form.backup_explanation.setText(
anki.lang.with_collapsed_whitespace(tr.preferences_backup_explanation())
)
form.daily_backups.setValue(self.prefs.backups.daily)
form.weekly_backups.setValue(self.prefs.backups.weekly)
form.monthly_backups.setValue(self.prefs.backups.monthly)
form.minutes_between_backups.setValue(self.prefs.backups.minimum_interval_mins)
add_ellipsis_to_action_label(self.form.url_schemes)
qconnect(self.form.url_schemes.clicked, show_url_schemes_dialog)
def update_collection(self, on_done: Callable[[], None]) -> None:
form = self.form
scheduling = self.prefs.scheduling
scheduling.learn_ahead_secs = form.lrnCutoff.value() * 60
scheduling.rollover = form.dayOffset.value()
reviewing = self.prefs.reviewing
reviewing.show_remaining_due_counts = form.showProgress.isChecked()
reviewing.show_intervals_on_buttons = form.showEstimates.isChecked()
reviewing.time_limit_secs = form.timeLimit.value() * 60
reviewing.hide_audio_play_buttons = not self.form.showPlayButtons.isChecked()
reviewing.interrupt_audio_when_answering = self.form.interrupt_audio.isChecked()
editing = self.prefs.editing
editing.adding_defaults_to_current_deck = not form.useCurrent.currentIndex()
editing.paste_images_as_png = self.form.pastePNG.isChecked()
editing.paste_strips_formatting = self.form.paste_strips_formatting.isChecked()
editing.render_latex = self.form.render_latex.isChecked()
editing.default_search_text = self.form.default_search_text.text()
editing.ignore_accents_in_search = (
self.form.ignore_accents_in_search.isChecked()
)
self.prefs.backups.daily = form.daily_backups.value()
self.prefs.backups.weekly = form.weekly_backups.value()
self.prefs.backups.monthly = form.monthly_backups.value()
self.prefs.backups.minimum_interval_mins = form.minutes_between_backups.value()
def after_prefs_update(changes: OpChanges) -> None:
self.mw.apply_collection_options()
on_done()
set_preferences(parent=self, preferences=self.prefs).success(
after_prefs_update
).run_in_background()
# Preferences stored in the profile
######################################################################
def setup_profile(self) -> None:
"Setup options stored in the user profile."
self.setup_network()
def update_profile(self) -> None:
self.update_network()
# Profile: network
######################################################################
def setup_network(self) -> None:
self.form.media_log.setText(tr.sync_media_log_button())
qconnect(self.form.media_log.clicked, self.on_media_log)
self.form.syncOnProgramOpen.setChecked(self.mw.pm.auto_syncing_enabled())
self.form.syncMedia.setChecked(self.mw.pm.media_syncing_enabled())
self.form.autoSyncMedia.setChecked(
self.mw.pm.periodic_sync_media_minutes() != 0
)
self.form.custom_sync_url.setText(self.mw.pm.custom_sync_url())
self.form.network_timeout.setValue(self.mw.pm.network_timeout())
self.form.check_for_updates.setChecked(self.mw.pm.check_for_updates())
qconnect(self.form.check_for_updates.stateChanged, self.mw.pm.set_update_check)
self.update_login_status()
qconnect(self.form.syncLogout.clicked, self.sync_logout)
qconnect(self.form.syncLogin.clicked, self.sync_login)
qconnect(self.form.syncAnkiHubLogout.clicked, self.ankihub_sync_logout)
qconnect(self.form.syncAnkiHubLogin.clicked, self.ankihub_sync_login)
def update_login_status(self) -> None:
assert self.prof is not None
if not self.prof.get("syncKey"):
self.form.syncUser.setText(tr.preferences_ankiweb_intro())
self.form.syncLogin.setVisible(True)
self.form.syncLogout.setVisible(False)
else:
self.form.syncUser.setText(self.prof.get("syncUser", ""))
self.form.syncLogin.setVisible(False)
self.form.syncLogout.setVisible(True)
if not self.mw.pm.ankihub_token():
self.form.syncAnkiHubUser.setText(tr.preferences_ankihub_intro())
self.form.syncAnkiHubLogin.setVisible(True)
self.form.syncAnkiHubLogout.setVisible(False)
else:
self.form.syncAnkiHubUser.setText(self.mw.pm.ankihub_username())
self.form.syncAnkiHubLogin.setVisible(False)
self.form.syncAnkiHubLogout.setVisible(True)
def on_media_log(self) -> None:
self.mw.media_syncer.show_sync_log()
def sync_login(self) -> None:
def on_success():
assert self.prof is not None
if self.prof.get("syncKey"):
self.update_login_status()
self.confirm_sync_after_login()
self.update_network()
sync_login(self.mw, on_success)
def sync_logout(self) -> None:
if self.mw.media_syncer.is_syncing():
showWarning("Can't log out while sync in progress.")
return
assert self.prof is not None
self.prof["syncKey"] = None
self.mw.col.media.force_resync()
self.update_login_status()
def ankihub_sync_login(self) -> None:
def on_success():
if self.mw.pm.ankihub_token():
self.update_login_status()
ankihub_login(self.mw, on_success)
def ankihub_sync_logout(self) -> None:
ankihub_token = self.mw.pm.ankihub_token()
if ankihub_token is None:
return
ankihub_logout(self.mw, self.update_login_status, ankihub_token)
def confirm_sync_after_login(self) -> None:
from aqt import mw
if askUser(tr.preferences_login_successful_sync_now(), parent=mw):
self.accept_with_callback(self.mw.on_sync_button_clicked)
def update_network(self) -> None:
assert self.prof is not None
self.prof["autoSync"] = self.form.syncOnProgramOpen.isChecked()
self.prof["syncMedia"] = self.form.syncMedia.isChecked()
self.mw.pm.set_periodic_sync_media_minutes(
self.form.autoSyncMedia.isChecked() and 15 or 0
)
if self.form.fullSync.isChecked():
self.mw.col.mod_schema(check=False)
self.mw.pm.set_custom_sync_url(self.form.custom_sync_url.text())
self.mw.pm.set_network_timeout(self.form.network_timeout.value())
# Global preferences
######################################################################
def setup_global(self) -> None:
"Setup options global to all profiles."
self.form.reduce_motion.setChecked(self.mw.pm.reduce_motion())
qconnect(self.form.reduce_motion.stateChanged, self.mw.pm.set_reduce_motion)
self.form.minimalist_mode.setChecked(self.mw.pm.minimalist_mode())
qconnect(self.form.minimalist_mode.stateChanged, self.mw.pm.set_minimalist_mode)
self.form.spacebar_rates_card.setChecked(self.mw.pm.spacebar_rates_card())
qconnect(
self.form.spacebar_rates_card.stateChanged,
self.mw.pm.set_spacebar_rates_card,
)
hide_choices = [tr.preferences_full_screen_only(), tr.preferences_always()]
self.form.hide_top_bar.setChecked(self.mw.pm.hide_top_bar())
qconnect(self.form.hide_top_bar.stateChanged, self.mw.pm.set_hide_top_bar)
qconnect(
self.form.hide_top_bar.stateChanged,
self.form.topBarComboBox.setVisible,
)
self.form.topBarComboBox.addItems(hide_choices)
self.form.topBarComboBox.setCurrentIndex(self.mw.pm.top_bar_hide_mode())
self.form.topBarComboBox.setVisible(self.form.hide_top_bar.isChecked())
qconnect(
self.form.topBarComboBox.currentIndexChanged,
self.mw.pm.set_top_bar_hide_mode,
)
self.form.hide_bottom_bar.setChecked(self.mw.pm.hide_bottom_bar())
qconnect(self.form.hide_bottom_bar.stateChanged, self.mw.pm.set_hide_bottom_bar)
qconnect(
self.form.hide_bottom_bar.stateChanged,
self.form.bottomBarComboBox.setVisible,
)
self.form.bottomBarComboBox.addItems(hide_choices)
self.form.bottomBarComboBox.setCurrentIndex(self.mw.pm.bottom_bar_hide_mode())
self.form.bottomBarComboBox.setVisible(self.form.hide_bottom_bar.isChecked())
qconnect(
self.form.bottomBarComboBox.currentIndexChanged,
self.mw.pm.set_bottom_bar_hide_mode,
)
self.form.uiScale.setValue(int(self.mw.pm.uiScale() * 100))
themes = [
tr.preferences_theme_follow_system(),
tr.preferences_theme_light(),
tr.preferences_theme_dark(),
]
self.form.theme.addItems(themes)
self.form.theme.setCurrentIndex(self.mw.pm.theme().value)
qconnect(self.form.theme.currentIndexChanged, self.on_theme_changed)
self.form.styleComboBox.addItems(["Anki"] + (["Native"] if not is_win else []))
self.form.styleComboBox.setCurrentIndex(self.mw.pm.get_widget_style())
qconnect(
self.form.styleComboBox.currentIndexChanged,
self.mw.pm.set_widget_style,
)
self.form.styleLabel.setVisible(not is_win)
self.form.styleComboBox.setVisible(not is_win)
qconnect(self.form.resetWindowSizes.clicked, self.on_reset_window_sizes)
self.setup_language()
self.setup_video_driver()
self.setupOptions()
def update_global(self) -> None:
restart_required = False
self.update_video_driver()
newScale = self.form.uiScale.value() / 100
if newScale != self.mw.pm.uiScale():
self.mw.pm.setUiScale(newScale)
restart_required = True
if restart_required:
showInfo(tr.preferences_changes_will_take_effect_when_you())
self.updateOptions()
def on_theme_changed(self, index: int) -> None:
self.mw.set_theme(Theme(index))
def on_reset_window_sizes(self) -> None:
assert self.prof is not None
regexp = re.compile(r"(Geom(etry)?|State|Splitter|Header)(\d+.\d+)?$")
for key in list(self.prof.keys()):
if regexp.search(key):
del self.prof[key]
showInfo(tr.preferences_reset_window_sizes_complete())
# legacy - one of Henrik's add-ons is currently wrapping them
def setupOptions(self) -> None:
pass
def updateOptions(self) -> None:
pass
# Global: language
######################################################################
def setup_language(self) -> None:
f = self.form
f.lang.addItems([x[0] for x in anki.lang.langs])
f.lang.setCurrentIndex(self.current_lang_index())
qconnect(f.lang.currentIndexChanged, self.on_language_index_changed)
def current_lang_index(self) -> int:
codes = [x[1] for x in anki.lang.langs]
lang = anki.lang.current_lang
if lang in anki.lang.compatMap:
lang = anki.lang.compatMap[lang]
else:
lang = lang.replace("-", "_")
try:
return codes.index(lang)
except Exception:
return codes.index("en_US")
def on_language_index_changed(self, idx: int) -> None:
code = anki.lang.langs[idx][1]
self.mw.pm.setLang(code)
showInfo(tr.preferences_please_restart_anki_to_complete_language(), parent=self)
# Global: video driver
######################################################################
def setup_video_driver(self) -> None:
self.video_drivers = VideoDriver.all_for_platform()
names = [video_driver_name_for_platform(d) for d in self.video_drivers]
self.form.video_driver.addItems(names)
self.form.video_driver.setCurrentIndex(
self.video_drivers.index(self.mw.pm.video_driver())
)
def update_video_driver(self) -> None:
new_driver = self.video_drivers[self.form.video_driver.currentIndex()]
if new_driver != self.mw.pm.video_driver():
self.mw.pm.set_video_driver(new_driver)
showInfo(tr.preferences_changes_will_take_effect_when_you())
def video_driver_name_for_platform(driver: VideoDriver) -> str:
if qtmajor < 6:
if driver == VideoDriver.ANGLE:
return tr.preferences_video_driver_angle()
elif driver == VideoDriver.Software:
if is_mac:
return tr.preferences_video_driver_software_mac()
else:
return tr.preferences_video_driver_software_other()
elif driver == VideoDriver.OpenGL:
if is_mac:
return tr.preferences_video_driver_opengl_mac()
else:
return tr.preferences_video_driver_opengl_other()
label = driver.name
if driver == VideoDriver.default_for_platform():
label += f" ({tr.preferences_video_driver_default()})"
return label