anki/qt/aqt/sync.py

497 lines
17 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
2013-10-10 04:48:38 +02:00
import gc
2019-12-20 10:19:03 +01:00
import time
2013-10-10 04:48:38 +02:00
2020-01-15 04:49:26 +01:00
from anki import hooks
2019-03-04 02:58:34 +01:00
from anki.lang import _
2019-12-20 10:19:03 +01:00
from anki.storage import Collection
2020-02-04 02:48:51 +01:00
from anki.sync import FullSyncer, RemoteServer, Syncer
2019-12-20 10:19:03 +01:00
from aqt.qt import *
from aqt.utils import askUserDialog, showInfo, showText, showWarning, tooltip
2013-10-10 04:48:38 +02:00
# Sync manager
######################################################################
2019-12-23 01:34:10 +01:00
class SyncManager(QObject):
def __init__(self, mw, pm):
QObject.__init__(self, mw)
self.mw = mw
self.pm = pm
def sync(self):
2019-12-23 01:34:10 +01:00
if not self.pm.profile["syncKey"]:
auth = self._getUserPass()
if not auth:
return
2019-12-23 01:34:10 +01:00
self.pm.profile["syncUser"] = auth[0]
self._sync(auth)
else:
self._sync()
def _sync(self, auth=None):
# to avoid gui widgets being garbage collected in the worker thread,
# run gc in advance
2013-02-27 06:07:11 +01:00
self._didFullUp = False
self._didError = False
gc.collect()
# create the thread, setup signals and start running
t = self.thread = SyncThread(
2019-12-23 01:34:10 +01:00
self.pm.collectionPath(),
self.pm.profile["syncKey"],
auth=auth,
2018-07-28 10:08:16 +02:00
hostNum=self.pm.profile.get("hostNum"),
)
2019-12-15 23:51:38 +01:00
t._event.connect(self.onEvent)
t.progress_event.connect(self.on_progress)
self.label = _("Connecting...")
prog = self.mw.progress.start(immediate=True, label=self.label)
self.sentBytes = self.recvBytes = 0
self._updateLabel()
self.thread.start()
while not self.thread.isFinished():
if prog.wantCancel:
self.thread.flagAbort()
# make sure we don't display 'upload success' msg
self._didFullUp = False
# abort may take a while
self.mw.progress.update(_("Stopping..."))
self.mw.app.processEvents()
self.thread.wait(100)
self.mw.progress.finish()
if self.thread.syncMsg:
showText(self.thread.syncMsg)
if self.thread.uname:
2019-12-23 01:34:10 +01:00
self.pm.profile["syncUser"] = self.thread.uname
self.pm.profile["hostNum"] = self.thread.hostNum
def delayedInfo():
if self._didFullUp and not self._didError:
2019-12-23 01:34:10 +01:00
showInfo(
_(
"""\
2013-02-27 06:07:11 +01:00
Your collection was successfully uploaded to AnkiWeb.
If you use any other devices, please sync them now, and choose \
to download the collection you have just uploaded from this computer. \
After doing so, future reviews and added cards will be merged \
2019-12-23 01:34:10 +01:00
automatically."""
)
)
2019-04-21 10:56:30 +02:00
self.mw.progress.timer(1000, delayedInfo, False, requiresCollection=False)
def _updateLabel(self):
2019-12-23 01:34:10 +01:00
self.mw.progress.update(
label="%s\n%s"
% (
self.label,
_("%(a)0.1fkB up, %(b)0.1fkB down")
% dict(a=self.sentBytes / 1024, b=self.recvBytes / 1024),
)
)
def on_progress(self, upload: int, download: int) -> None:
# posted events not guaranteed to arrive in order; don't go backwards
self.sentBytes = max(self.sentBytes, upload)
self.recvBytes = max(self.recvBytes, download)
self._updateLabel()
def onEvent(self, evt, *args):
pu = self.mw.progress.update
if evt == "badAuth":
tooltip(
_("AnkiWeb ID or password was incorrect; please try again."),
2019-12-23 01:34:10 +01:00
parent=self.mw,
)
# blank the key so we prompt user again
2019-12-23 01:34:10 +01:00
self.pm.profile["syncKey"] = None
self.pm.save()
elif evt == "corrupt":
pass
elif evt == "newKey":
2019-12-23 01:34:10 +01:00
self.pm.profile["syncKey"] = args[0]
self.pm.save()
elif evt == "offline":
tooltip(_("Syncing failed; internet offline."))
elif evt == "upbad":
self._didFullUp = False
self._checkFailed()
elif evt == "sync":
2019-12-23 01:34:10 +01:00
m = None
t = args[0]
if t == "login":
m = _("Syncing...")
elif t == "upload":
2013-02-27 06:07:11 +01:00
self._didFullUp = True
m = _("Uploading to AnkiWeb...")
elif t == "download":
m = _("Downloading from AnkiWeb...")
elif t == "sanity":
m = _("Checking...")
elif t == "upgradeRequired":
2019-12-23 01:34:10 +01:00
showText(
_(
"""\
Please visit AnkiWeb, upgrade your deck, then try again."""
)
)
if m:
self.label = m
self._updateLabel()
elif evt == "syncMsg":
self.label = args[0]
self._updateLabel()
elif evt == "error":
self._didError = True
2019-12-23 01:34:10 +01:00
showText(_("Syncing failed:\n%s") % self._rewriteError(args[0]))
elif evt == "clockOff":
self._clockOff()
elif evt == "checkFailed":
self._checkFailed()
elif evt == "noChanges":
pass
elif evt == "fullSync":
self._confirmFullSync()
elif evt == "downloadClobber":
2019-12-23 01:34:10 +01:00
showInfo(
_(
"Your AnkiWeb collection does not contain any cards. Please sync again and choose 'Upload' instead."
)
)
def _rewriteError(self, err):
if "Errno 61" in err:
2019-12-23 01:34:10 +01:00
return _(
"""\
Couldn't connect to AnkiWeb. Please check your network connection \
2019-12-23 01:34:10 +01:00
and try again."""
)
elif "timed out" in err or "10060" in err:
2019-12-23 01:34:10 +01:00
return _(
"""\
The connection to AnkiWeb timed out. Please check your network \
2019-12-23 01:34:10 +01:00
connection and try again."""
)
elif "code: 500" in err:
2019-12-23 01:34:10 +01:00
return _(
"""\
AnkiWeb encountered an error. Please try again in a few minutes, and if \
2019-12-23 01:34:10 +01:00
the problem persists, please file a bug report."""
)
elif "code: 501" in err:
2019-12-23 01:34:10 +01:00
return _(
"""\
Please upgrade to the latest version of Anki."""
)
# 502 is technically due to the server restarting, but we reuse the
# error message
elif "code: 502" in err:
2013-05-17 06:58:38 +02:00
return _("AnkiWeb is under maintenance. Please try again in a few minutes.")
elif "code: 503" in err:
2019-12-23 01:34:10 +01:00
return _(
"""\
AnkiWeb is too busy at the moment. Please try again in a few minutes."""
)
elif "code: 504" in err:
2019-12-23 01:34:10 +01:00
return _(
"504 gateway timeout error received. Please try temporarily disabling your antivirus."
)
elif "code: 409" in err:
2019-12-23 01:34:10 +01:00
return _(
"Only one client can access AnkiWeb at a time. If a previous sync failed, please try again in a few minutes."
)
2013-11-27 15:02:03 +01:00
elif "10061" in err or "10013" in err or "10053" in err:
return _(
2019-12-23 01:34:10 +01:00
"Antivirus or firewall software is preventing Anki from connecting to the internet."
)
2014-01-23 19:10:04 +01:00
elif "10054" in err or "Broken pipe" in err:
2019-12-23 01:34:10 +01:00
return _(
"Connection timed out. Either your internet connection is experiencing problems, or you have a very large file in your media folder."
)
2018-05-31 05:24:49 +02:00
elif "Unable to find the server" in err or "socket.gaierror" in err:
2013-02-11 18:06:03 +01:00
return _(
"Server not found. Either your connection is down, or antivirus/firewall "
2019-12-23 01:34:10 +01:00
"software is blocking Anki from connecting to the internet."
)
elif "code: 407" in err:
return _("Proxy authentication required.")
elif "code: 413" in err:
return _("Your collection or a media file is too large to sync.")
2014-02-05 22:50:41 +01:00
elif "EOF occurred in violation of protocol" in err:
2019-12-23 01:34:10 +01:00
return (
_(
"Error establishing a secure connection. This is usually caused by antivirus, firewall or VPN software, or problems with your ISP."
)
+ " (eof)"
)
2014-09-01 14:06:41 +02:00
elif "certificate verify failed" in err:
2019-12-23 01:34:10 +01:00
return (
_(
"Error establishing a secure connection. This is usually caused by antivirus, firewall or VPN software, or problems with your ISP."
)
+ " (invalid cert)"
)
return err
def _getUserPass(self):
d = QDialog(self.mw)
d.setWindowTitle("Anki")
2013-01-08 02:36:04 +01:00
d.setWindowModality(Qt.WindowModal)
vbox = QVBoxLayout()
2019-12-23 01:34:10 +01:00
l = QLabel(
_(
"""\
<h1>Account Required</h1>
A free account is required to keep your collection synchronized. Please \
<a href="%s">sign up</a> for an account, then \
2019-12-23 01:34:10 +01:00
enter your details below."""
)
% "https://ankiweb.net/account/login"
)
l.setOpenExternalLinks(True)
l.setWordWrap(True)
vbox.addWidget(l)
vbox.addSpacing(20)
g = QGridLayout()
l1 = QLabel(_("AnkiWeb ID:"))
g.addWidget(l1, 0, 0)
user = QLineEdit()
g.addWidget(user, 0, 1)
l2 = QLabel(_("Password:"))
g.addWidget(l2, 1, 0)
passwd = QLineEdit()
passwd.setEchoMode(QLineEdit.Password)
g.addWidget(passwd, 1, 1)
vbox.addLayout(g)
2019-12-23 01:34:10 +01:00
bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
bb.button(QDialogButtonBox.Ok).setAutoDefault(True)
bb.accepted.connect(d.accept)
bb.rejected.connect(d.reject)
vbox.addWidget(bb)
d.setLayout(vbox)
d.show()
accepted = d.exec_()
u = user.text()
p = passwd.text()
if not accepted or not u or not p:
return
return (u, p)
def _confirmFullSync(self):
2018-05-01 06:34:03 +02:00
self.mw.progress.finish()
if self.thread.localIsEmpty:
diag = askUserDialog(
_("Local collection has no cards. Download from AnkiWeb?"),
2019-12-23 01:34:10 +01:00
[_("Download from AnkiWeb"), _("Cancel")],
)
diag.setDefault(1)
else:
2019-12-23 01:34:10 +01:00
diag = askUserDialog(
_(
"""\
Your decks here and on AnkiWeb differ in such a way that they can't \
be merged together, so it's necessary to overwrite the decks on one \
side with the decks from the other.
2013-02-27 06:07:11 +01:00
If you choose download, Anki will download the collection from AnkiWeb, \
and any changes you have made on your computer since the last sync will \
be lost.
If you choose upload, Anki will upload your collection to AnkiWeb, and \
any changes you have made on AnkiWeb or your other devices since the \
last sync to this device will be lost.
After all devices are in sync, future reviews and added cards can be merged \
2019-12-23 01:34:10 +01:00
automatically."""
),
[_("Upload to AnkiWeb"), _("Download from AnkiWeb"), _("Cancel")],
)
diag.setDefault(2)
ret = diag.run()
if ret == _("Upload to AnkiWeb"):
self.thread.fullSyncChoice = "upload"
elif ret == _("Download from AnkiWeb"):
self.thread.fullSyncChoice = "download"
else:
self.thread.fullSyncChoice = "cancel"
2018-05-01 06:34:03 +02:00
self.mw.progress.start(immediate=True)
def _clockOff(self):
2019-12-23 01:34:10 +01:00
showWarning(
_(
"""\
Syncing requires the clock on your computer to be set correctly. Please \
2019-12-23 01:34:10 +01:00
fix the clock and try again."""
)
)
def _checkFailed(self):
2019-12-23 01:34:10 +01:00
showWarning(
_(
"""\
2013-05-14 08:10:58 +02:00
Your collection is in an inconsistent state. Please run Tools>\
2019-12-23 01:34:10 +01:00
Check Database, then sync again."""
)
)
2013-05-14 08:10:58 +02:00
# Sync thread
######################################################################
2019-12-23 01:34:10 +01:00
class SyncThread(QThread):
2019-12-15 23:51:38 +01:00
_event = pyqtSignal(str, str)
progress_event = pyqtSignal(int, int)
def __init__(self, path, hkey, auth=None, hostNum=None):
QThread.__init__(self)
self.path = path
self.hkey = hkey
self.auth = auth
2018-07-28 10:08:16 +02:00
self.hostNum = hostNum
2019-12-23 01:34:10 +01:00
self._abort = 0 # 1=flagged, 2=aborting
def flagAbort(self):
self._abort = 1
def run(self):
# init this first so an early crash doesn't cause an error
# in the main thread
self.syncMsg = ""
self.uname = ""
try:
2020-03-05 01:20:16 +01:00
self.col = Collection(self.path)
except:
self.fireEvent("corrupt")
return
2018-07-28 10:08:16 +02:00
self.server = RemoteServer(self.hkey, hostNum=self.hostNum)
self.client = Syncer(self.col, self.server)
self.sentTotal = 0
self.recvTotal = 0
2019-12-23 01:34:10 +01:00
def syncEvent(type):
self.fireEvent("sync", type)
2019-12-23 01:34:10 +01:00
def syncMsg(msg):
self.fireEvent("syncMsg", msg)
2019-12-23 01:34:10 +01:00
def http_progress(upload: int, download: int) -> None:
if not self._abort:
self.sentTotal += upload
self.recvTotal += download
self.progress_event.emit(self.sentTotal, self.recvTotal) # type: ignore
elif self._abort == 1:
self._abort = 2
raise Exception("sync cancelled")
2019-12-23 01:34:10 +01:00
self.server.client.progress_hook = http_progress
2019-12-23 01:34:10 +01:00
2020-01-15 07:53:24 +01:00
hooks.sync_stage_did_change.append(syncEvent)
hooks.sync_progress_did_change.append(syncMsg)
# run sync and catch any errors
try:
self._sync()
except:
err = traceback.format_exc()
self.fireEvent("error", err)
finally:
# don't bump mod time unless we explicitly save
self.col.close(save=False, downgrade=False)
2020-01-15 07:53:24 +01:00
hooks.sync_stage_did_change.remove(syncEvent)
hooks.sync_progress_did_change.remove(syncMsg)
def _abortingSync(self):
try:
return self.client.sync()
except Exception as e:
if "sync cancelled" in str(e):
self.server.abort()
raise
else:
raise
def _sync(self):
if self.auth:
# need to authenticate and obtain host key
self.hkey = self.server.hostKey(*self.auth)
if not self.hkey:
# provided details were invalid
return self.fireEvent("badAuth")
else:
# write new details and tell calling thread to save
self.fireEvent("newKey", self.hkey)
# run sync and check state
try:
ret = self._abortingSync()
except Exception as e:
log = traceback.format_exc()
err = repr(str(e))
2019-12-23 01:34:10 +01:00
if (
"Unable to find the server" in err
or "Errno 2" in err
or "getaddrinfo" in err
):
self.fireEvent("offline")
elif "sync cancelled" in err:
pass
else:
2018-01-20 02:24:01 +01:00
self.fireEvent("error", log)
return
if ret == "badAuth":
return self.fireEvent("badAuth")
elif ret == "clockOff":
return self.fireEvent("clockOff")
elif ret == "basicCheckFailed" or ret == "sanityCheckFailed":
return self.fireEvent("checkFailed")
# full sync?
if ret == "fullSync":
return self._fullSync()
# save and note success state
if ret == "noChanges":
self.fireEvent("noChanges")
elif ret == "success":
self.fireEvent("success")
elif ret == "serverAbort":
self.syncMsg = self.client.syncMsg
return
else:
self.fireEvent("error", "Unknown sync return code.")
self.syncMsg = self.client.syncMsg
self.uname = self.client.uname
2018-07-28 10:08:16 +02:00
self.hostNum = self.client.hostNum
def _fullSync(self):
# tell the calling thread we need a decision on sync direction, and
# wait for a reply
self.fullSyncChoice = False
self.localIsEmpty = self.col.isEmpty()
self.fireEvent("fullSync")
while not self.fullSyncChoice:
time.sleep(0.1)
f = self.fullSyncChoice
if f == "cancel":
return
2019-12-23 01:34:10 +01:00
self.client = FullSyncer(
self.col, self.hkey, self.server.client, hostNum=self.hostNum
)
try:
if f == "upload":
if not self.client.upload():
self.fireEvent("upbad")
else:
ret = self.client.download()
if ret == "downloadClobber":
self.fireEvent(ret)
return
except Exception as e:
if "sync cancelled" in str(e):
return
raise
def fireEvent(self, cmd, arg=""):
2019-12-15 23:51:38 +01:00
self._event.emit(cmd, arg)