anki/qt/aqt/sync.py

343 lines
9.1 KiB
Python
Raw Normal View History

2019-02-05 04:59:03 +01:00
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
2020-05-30 04:28:22 +02:00
from __future__ import annotations
import enum
import os
2021-02-02 14:30:53 +01:00
from concurrent.futures import Future
from typing import Callable
2020-05-30 04:28:22 +02:00
import aqt
import aqt.main
from anki.errors import Interrupted, SyncError, SyncErrorKind
from anki.lang import without_unicode_isolation
from anki.sync import SyncOutput, SyncStatus
PEP8 for rest of pylib (#1451) * PEP8 dbproxy.py * PEP8 errors.py * PEP8 httpclient.py * PEP8 lang.py * PEP8 latex.py * Add decorator to deprectate key words * Make replacement for deprecated attribute optional * Use new helper `_print_replacement_warning()` * PEP8 media.py * PEP8 rsbackend.py * PEP8 sound.py * PEP8 stdmodels.py * PEP8 storage.py * PEP8 sync.py * PEP8 tags.py * PEP8 template.py * PEP8 types.py * Fix DeprecatedNamesMixinForModule The class methods need to be overridden with instance methods, so every module has its own dicts. * Use `# pylint: disable=invalid-name` instead of id * PEP8 utils.py * Only decorate `__getattr__` with `@no_type_check` * Fix mypy issue with snakecase Importing it from `anki._vendor` raises attribute errors. * Format * Remove inheritance of DeprecatedNamesMixin There's almost no shared code now and overriding classmethods with instance methods raises mypy issues. * Fix traceback frames of deprecation warnings * remove fn/TimedLog (dae) Neither Anki nor add-ons appear to have been using it * fix some issues with stringcase use (dae) - the wheel was depending on the PyPI version instead of our vendored version - _vendor:stringcase should not have been listed in the anki py_library. We already include the sources in py_srcs, and need to refer to them directly. By listing _vendor:stringcase as well, we were making a top-level stringcase library available, which would have only worked for distributing because the wheel definition was also incorrect. - mypy errors are what caused me to mistakenly add the above - they were because the type: ignore at the top of stringcase.py was causing mypy to completely ignore the file, so it was not aware of any attributes it contained.
2021-10-25 06:50:13 +02:00
from anki.utils import plat_desc
from aqt import gui_hooks
2020-05-30 04:28:22 +02:00
from aqt.qt import (
QDialog,
QDialogButtonBox,
QGridLayout,
QLabel,
QLineEdit,
Qt,
QTimer,
QVBoxLayout,
qconnect,
)
from aqt.utils import (
askUser,
askUserDialog,
disable_help_button,
showText,
showWarning,
tr,
)
2020-05-30 04:28:22 +02:00
class FullSyncChoice(enum.Enum):
CANCEL = 0
UPLOAD = 1
DOWNLOAD = 2
2021-02-01 14:28:21 +01:00
def get_sync_status(
mw: aqt.main.AnkiQt, callback: Callable[[SyncStatus], None]
) -> None:
2020-05-30 04:28:22 +02:00
auth = mw.pm.sync_auth()
if not auth:
2021-02-01 14:28:21 +01:00
callback(SyncStatus(required=SyncStatus.NO_CHANGES)) # pylint:disable=no-member
return
2020-05-30 04:28:22 +02:00
2021-02-02 14:30:53 +01:00
def on_future_done(fut: Future) -> None:
try:
out = fut.result()
except Exception as e:
# swallow errors
print("sync status check failed:", str(e))
return
callback(out)
2020-05-30 04:28:22 +02:00
mw.taskman.run_in_background(lambda: mw.col.sync_status(auth), on_future_done)
2020-05-30 04:28:22 +02:00
2021-02-01 14:28:21 +01:00
def handle_sync_error(mw: aqt.main.AnkiQt, err: Exception) -> None:
if isinstance(err, SyncError):
if err.kind is SyncErrorKind.AUTH:
mw.pm.clear_sync_auth()
elif isinstance(err, Interrupted):
# no message to show
return
showWarning(str(err))
def on_normal_sync_timer(mw: aqt.main.AnkiQt) -> None:
progress = mw.col.latest_progress()
if not progress.HasField("normal_sync"):
return
sync_progress = progress.normal_sync
mw.progress.update(
label=f"{sync_progress.added}\n{sync_progress.removed}",
2020-08-31 05:29:28 +02:00
process=False,
)
mw.progress.set_title(sync_progress.stage)
if mw.progress.want_cancel():
mw.col.abort_sync()
2020-05-31 02:53:54 +02:00
def sync_collection(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
2020-05-30 04:28:22 +02:00
auth = mw.pm.sync_auth()
if not auth:
raise Exception("expected auth")
2020-05-30 04:28:22 +02:00
2021-02-01 14:28:21 +01:00
def on_timer() -> None:
on_normal_sync_timer(mw)
timer = QTimer(mw)
qconnect(timer.timeout, on_timer)
timer.start(150)
2021-02-02 14:30:53 +01:00
def on_future_done(fut: Future) -> None:
2020-05-30 04:28:22 +02:00
mw.col.db.begin()
timer.stop()
2020-05-30 06:19:21 +02:00
try:
out: SyncOutput = fut.result()
except Exception as err:
handle_sync_error(mw, err)
2020-05-31 02:53:54 +02:00
return on_done()
2020-05-30 06:19:21 +02:00
2020-05-30 04:28:22 +02:00
mw.pm.set_host_number(out.host_number)
2020-05-30 12:52:32 +02:00
if out.server_message:
showText(out.server_message)
2020-05-30 04:28:22 +02:00
if out.required == out.NO_CHANGES:
# all done
2020-05-31 02:53:54 +02:00
return on_done()
else:
2020-05-31 02:53:54 +02:00
full_sync(mw, out, on_done)
2020-05-30 04:28:22 +02:00
mw.col.save(trx=False)
mw.taskman.with_progress(
lambda: mw.col.sync_collection(auth),
2020-05-31 02:53:54 +02:00
on_future_done,
2021-03-26 04:48:26 +01:00
label=tr.sync_checking(),
immediate=True,
2020-05-30 04:28:22 +02:00
)
2020-05-31 02:53:54 +02:00
def full_sync(
mw: aqt.main.AnkiQt, out: SyncOutput, on_done: Callable[[], None]
) -> None:
2020-05-30 04:28:22 +02:00
if out.required == out.FULL_DOWNLOAD:
2020-05-31 02:53:54 +02:00
confirm_full_download(mw, on_done)
2020-05-30 04:28:22 +02:00
elif out.required == out.FULL_UPLOAD:
2020-05-31 02:53:54 +02:00
full_upload(mw, on_done)
2020-05-30 04:28:22 +02:00
else:
choice = ask_user_to_decide_direction()
if choice == FullSyncChoice.UPLOAD:
2020-05-31 02:53:54 +02:00
full_upload(mw, on_done)
2020-05-30 04:28:22 +02:00
elif choice == FullSyncChoice.DOWNLOAD:
2020-05-31 02:53:54 +02:00
full_download(mw, on_done)
else:
on_done()
2020-05-30 04:28:22 +02:00
2020-05-31 02:53:54 +02:00
def confirm_full_download(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
2020-05-30 04:28:22 +02:00
# confirmation step required, as some users customize their notetypes
# in an empty collection, then want to upload them
2021-03-26 04:48:26 +01:00
if not askUser(tr.sync_confirm_empty_download()):
2020-05-31 02:53:54 +02:00
return on_done()
2020-05-30 04:28:22 +02:00
else:
2020-05-31 02:53:54 +02:00
mw.closeAllWindows(lambda: full_download(mw, on_done))
2020-05-30 04:28:22 +02:00
def on_full_sync_timer(mw: aqt.main.AnkiQt) -> None:
progress = mw.col.latest_progress()
if not progress.HasField("full_sync"):
2020-05-30 04:28:22 +02:00
return
sync_progress = progress.full_sync
2020-05-30 04:28:22 +02:00
if sync_progress.transferred == sync_progress.total:
2021-03-26 04:48:26 +01:00
label = tr.sync_checking()
else:
label = None
2020-05-30 12:52:32 +02:00
mw.progress.update(
value=sync_progress.transferred,
max=sync_progress.total,
process=False,
label=label,
2020-05-30 12:52:32 +02:00
)
2020-05-30 04:28:22 +02:00
if mw.progress.want_cancel():
mw.col.abort_sync()
2020-05-30 04:28:22 +02:00
2020-05-31 02:53:54 +02:00
def full_download(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
2021-02-01 14:28:21 +01:00
def on_timer() -> None:
2020-05-30 04:28:22 +02:00
on_full_sync_timer(mw)
timer = QTimer(mw)
qconnect(timer.timeout, on_timer)
timer.start(150)
# hook needs to be called early, on the main thread
gui_hooks.collection_will_temporarily_close(mw.col)
def download() -> None:
mw.create_backup_now()
mw.col.close_for_full_sync()
mw.col.full_download(mw.pm.sync_auth())
2021-02-02 14:30:53 +01:00
def on_future_done(fut: Future) -> None:
2020-05-30 04:28:22 +02:00
timer.stop()
mw.reopen(after_full_sync=True)
2020-05-30 04:28:22 +02:00
mw.reset()
try:
2020-05-30 04:28:22 +02:00
fut.result()
except Exception as err:
handle_sync_error(mw, err)
mw.media_syncer.start()
2020-05-31 02:53:54 +02:00
return on_done()
2020-05-30 04:28:22 +02:00
mw.taskman.with_progress(
download,
2020-05-31 02:53:54 +02:00
on_future_done,
2021-03-26 04:48:26 +01:00
label=tr.sync_downloading_from_ankiweb(),
2020-05-30 04:28:22 +02:00
)
2020-05-31 02:53:54 +02:00
def full_upload(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None:
gui_hooks.collection_will_temporarily_close(mw.col)
2020-05-30 04:28:22 +02:00
mw.col.close_for_full_sync()
2021-02-01 14:28:21 +01:00
def on_timer() -> None:
2020-05-30 04:28:22 +02:00
on_full_sync_timer(mw)
timer = QTimer(mw)
qconnect(timer.timeout, on_timer)
timer.start(150)
2021-02-02 14:30:53 +01:00
def on_future_done(fut: Future) -> None:
2020-05-30 04:28:22 +02:00
timer.stop()
mw.reopen(after_full_sync=True)
2020-05-30 04:28:22 +02:00
mw.reset()
try:
2020-05-30 04:28:22 +02:00
fut.result()
except Exception as err:
handle_sync_error(mw, err)
return on_done()
mw.media_syncer.start()
2020-05-31 02:53:54 +02:00
return on_done()
2020-05-30 04:28:22 +02:00
mw.taskman.with_progress(
lambda: mw.col.full_upload(mw.pm.sync_auth()),
2020-05-31 02:53:54 +02:00
on_future_done,
2021-03-26 04:48:26 +01:00
label=tr.sync_uploading_to_ankiweb(),
2020-05-30 04:28:22 +02:00
)
2020-05-31 02:53:54 +02:00
def sync_login(
2021-02-02 14:30:53 +01:00
mw: aqt.main.AnkiQt,
on_success: Callable[[], None],
username: str = "",
password: str = "",
2020-05-30 04:28:22 +02:00
) -> None:
while True:
(username, password) = get_id_and_pass_from_user(mw, username, password)
if not username and not password:
return
2020-05-30 04:28:22 +02:00
if username and password:
break
2021-02-02 14:30:53 +01:00
def on_future_done(fut: Future) -> None:
try:
2020-05-30 04:28:22 +02:00
auth = fut.result()
except SyncError as e:
if e.kind is SyncErrorKind.AUTH:
2020-05-30 04:28:22 +02:00
showWarning(str(e))
2020-05-31 02:53:54 +02:00
sync_login(mw, on_success, username, password)
else:
handle_sync_error(mw, e)
return
except Exception as err:
handle_sync_error(mw, err)
2020-05-30 04:28:22 +02:00
return
2020-05-30 04:28:22 +02:00
mw.pm.set_host_number(auth.host_number)
mw.pm.set_sync_key(auth.hkey)
mw.pm.set_sync_username(username)
on_success()
mw.taskman.with_progress(
lambda: mw.col.sync_login(username=username, password=password),
2020-05-31 02:53:54 +02:00
on_future_done,
2020-05-30 04:28:22 +02:00
)
def ask_user_to_decide_direction() -> FullSyncChoice:
button_labels = [
2021-03-26 04:48:26 +01:00
tr.sync_upload_to_ankiweb(),
tr.sync_download_from_ankiweb(),
tr.sync_cancel_button(),
2020-05-30 04:28:22 +02:00
]
2021-03-26 04:48:26 +01:00
diag = askUserDialog(tr.sync_conflict_explanation(), button_labels)
2020-05-30 04:28:22 +02:00
diag.setDefault(2)
ret = diag.run()
if ret == button_labels[0]:
return FullSyncChoice.UPLOAD
elif ret == button_labels[1]:
return FullSyncChoice.DOWNLOAD
else:
return FullSyncChoice.CANCEL
def get_id_and_pass_from_user(
2021-02-02 14:30:53 +01:00
mw: aqt.main.AnkiQt, username: str = "", password: str = ""
) -> tuple[str, str]:
2020-05-30 04:28:22 +02:00
diag = QDialog(mw)
diag.setWindowTitle("Anki")
disable_help_button(diag)
diag.setWindowModality(Qt.WindowModality.WindowModal)
2020-05-30 04:28:22 +02:00
vbox = QVBoxLayout()
info_label = QLabel(
2020-08-24 15:37:13 +02:00
without_unicode_isolation(
tr.sync_account_required(link="https://ankiweb.net/account/register")
2020-08-24 15:37:13 +02:00
)
2020-05-30 04:28:22 +02:00
)
info_label.setOpenExternalLinks(True)
info_label.setWordWrap(True)
vbox.addWidget(info_label)
vbox.addSpacing(20)
g = QGridLayout()
2021-03-26 04:48:26 +01:00
l1 = QLabel(tr.sync_ankiweb_id_label())
2020-05-30 04:28:22 +02:00
g.addWidget(l1, 0, 0)
user = QLineEdit()
user.setText(username)
g.addWidget(user, 0, 1)
2021-03-26 04:48:26 +01:00
l2 = QLabel(tr.sync_password_label())
2020-05-30 04:28:22 +02:00
g.addWidget(l2, 1, 0)
passwd = QLineEdit()
passwd.setText(password)
passwd.setEchoMode(QLineEdit.EchoMode.Password)
2020-05-30 04:28:22 +02:00
g.addWidget(passwd, 1, 1)
vbox.addLayout(g)
bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) # type: ignore
bb.button(QDialogButtonBox.StandardButton.Ok).setAutoDefault(True)
2020-05-30 04:28:22 +02:00
qconnect(bb.accepted, diag.accept)
qconnect(bb.rejected, diag.reject)
vbox.addWidget(bb)
diag.setLayout(vbox)
diag.show()
accepted = diag.exec()
2020-05-30 04:28:22 +02:00
if not accepted:
return ("", "")
return (user.text().strip(), passwd.text())
2020-07-16 05:55:53 +02:00
# export platform version to syncing code
PEP8 for rest of pylib (#1451) * PEP8 dbproxy.py * PEP8 errors.py * PEP8 httpclient.py * PEP8 lang.py * PEP8 latex.py * Add decorator to deprectate key words * Make replacement for deprecated attribute optional * Use new helper `_print_replacement_warning()` * PEP8 media.py * PEP8 rsbackend.py * PEP8 sound.py * PEP8 stdmodels.py * PEP8 storage.py * PEP8 sync.py * PEP8 tags.py * PEP8 template.py * PEP8 types.py * Fix DeprecatedNamesMixinForModule The class methods need to be overridden with instance methods, so every module has its own dicts. * Use `# pylint: disable=invalid-name` instead of id * PEP8 utils.py * Only decorate `__getattr__` with `@no_type_check` * Fix mypy issue with snakecase Importing it from `anki._vendor` raises attribute errors. * Format * Remove inheritance of DeprecatedNamesMixin There's almost no shared code now and overriding classmethods with instance methods raises mypy issues. * Fix traceback frames of deprecation warnings * remove fn/TimedLog (dae) Neither Anki nor add-ons appear to have been using it * fix some issues with stringcase use (dae) - the wheel was depending on the PyPI version instead of our vendored version - _vendor:stringcase should not have been listed in the anki py_library. We already include the sources in py_srcs, and need to refer to them directly. By listing _vendor:stringcase as well, we were making a top-level stringcase library available, which would have only worked for distributing because the wheel definition was also incorrect. - mypy errors are what caused me to mistakenly add the above - they were because the type: ignore at the top of stringcase.py was causing mypy to completely ignore the file, so it was not aware of any attributes it contained.
2021-10-25 06:50:13 +02:00
os.environ["PLATFORM"] = plat_desc()