anki/aqt/sync.py
Damien Elmes f29a04ae29 bump sync ver to 7 and change meta return value
- /sync/meta now returns a dictionary
- it includes the following extra fields
- msg: if non-empty, show this message at the end of the sync
- cont: if false, show above message and abort sync
- uname: the user's email address, so it can be stored by the local client to
  show users who have forgotten which email address they used. in the future
  this will be saved only when logging in, so do a conditional access on it
2013-10-04 06:09:28 +09:00

510 lines
18 KiB
Python

# Copyright: Damien Elmes <anki@ichi2.net>
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import division
from aqt.qt import *
import socket, time, traceback, gc
import aqt
from anki import Collection
from anki.sync import Syncer, RemoteServer, FullSyncer, MediaSyncer, \
RemoteMediaServer
from anki.hooks import addHook, remHook
from aqt.utils import tooltip, askUserDialog, showWarning, showText, showInfo
# Sync manager
######################################################################
class SyncManager(QObject):
def __init__(self, mw, pm):
QObject.__init__(self, mw)
self.mw = mw
self.pm = pm
def sync(self):
if not self.pm.profile['syncKey']:
auth = self._getUserPass()
if not auth:
return
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
self._didFullUp = False
self._didError = False
gc.collect()
# create the thread, setup signals and start running
t = self.thread = SyncThread(
self.pm.collectionPath(), self.pm.profile['syncKey'],
auth=auth, media=self.pm.profile['syncMedia'])
self.connect(t, SIGNAL("event"), self.onEvent)
self.label = _("Connecting...")
self.mw.progress.start(immediate=True, label=self.label)
self.sentBytes = self.recvBytes = 0
self._updateLabel()
self.thread.start()
while not self.thread.isFinished():
self.mw.app.processEvents()
self.thread.wait(100)
self.mw.progress.finish()
if self.thread.syncMsg:
showText(self.thread.syncMsg)
if self.thread.uname:
self.pm.profile['syncUser'] = self.thread.uname
def delayedInfo():
if self._didFullUp and not self._didError:
showInfo(_("""\
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 \
automatically."""))
self.mw.progress.timer(1000, delayedInfo, False)
def _updateLabel(self):
self.mw.progress.update(label="%s\n%s" % (
self.label,
_("%(a)dkB up, %(b)dkB down") % dict(
a=self.sentBytes // 1024,
b=self.recvBytes // 1024)))
def onEvent(self, evt, *args):
pu = self.mw.progress.update
if evt == "badAuth":
tooltip(
_("AnkiWeb ID or password was incorrect; please try again."),
parent=self.mw)
# blank the key so we prompt user again
self.pm.profile['syncKey'] = None
self.pm.save()
elif evt == "corrupt":
pass
elif evt == "newKey":
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":
m = None; t = args[0]
if t == "login":
m = _("Syncing...")
elif t == "upload":
self._didFullUp = True
m = _("Uploading to AnkiWeb...")
elif t == "download":
m = _("Downloading from AnkiWeb...")
elif t == "sanity":
m = _("Checking...")
elif t == "findMedia":
m = _("Syncing Media...")
elif t == "upgradeRequired":
showText(_("""\
Please visit AnkiWeb, upgrade your deck, then try again."""))
if m:
self.label = m
self._updateLabel()
elif evt == "error":
self._didError = True
showText(_("Syncing failed:\n%s")%
self._rewriteError(args[0]))
elif evt == "clockOff":
self._clockOff()
elif evt == "checkFailed":
self._checkFailed()
elif evt == "mediaSanity":
showWarning(_("""\
A problem occurred while syncing media. Please sync again and Anki will \
correct the issue."""))
elif evt == "noChanges":
pass
elif evt == "fullSync":
self._confirmFullSync()
elif evt == "send":
# posted events not guaranteed to arrive in order
self.sentBytes = max(self.sentBytes, args[0])
self._updateLabel()
elif evt == "recv":
self.recvBytes = max(self.recvBytes, args[0])
self._updateLabel()
def _rewriteError(self, err):
if "Errno 61" in err:
return _("""\
Couldn't connect to AnkiWeb. Please check your network connection \
and try again.""")
elif "timed out" in err or "10060" in err:
return _("""\
The connection to AnkiWeb timed out. Please check your network \
connection and try again.""")
elif "500" in err:
return _("""\
AnkiWeb encountered an error. Please try again in a few minutes, and if \
the problem persists, please file a bug report.""")
elif "501" in err:
return _("""\
Please upgrade to the latest version of Anki.""")
# 502 is technically due to the server restarting, but we reuse the
# error message
elif "502" in err:
return _("AnkiWeb is under maintenance. Please try again in a few minutes.")
elif "503" in err:
return _("""\
AnkiWeb is too busy at the moment. Please try again in a few minutes.""")
elif "504" in err:
return _("504 gateway timeout error received. Please try temporarily disabling your antivirus.")
elif "409" in err:
return _("A previous sync failed; please try again in a few minutes.")
elif "10061" in err or "10013" in err:
return _(
"Antivirus or firewall software is preventing Anki from connecting to the internet.")
elif "Unable to find the server" in err:
return _(
"Server not found. Either your connection is down, or antivirus/firewall "
"software is blocking Anki from connecting to the internet.")
elif "407" in err:
return _("Proxy authentication required.")
return err
def _getUserPass(self):
d = QDialog(self.mw)
d.setWindowTitle("Anki")
d.setWindowModality(Qt.WindowModal)
vbox = QVBoxLayout()
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 \
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)
bb = QDialogButtonBox(QDialogButtonBox.Ok|QDialogButtonBox.Cancel)
bb.button(QDialogButtonBox.Ok).setAutoDefault(True)
self.connect(bb, SIGNAL("accepted()"), d.accept)
self.connect(bb, SIGNAL("rejected()"), d.reject)
vbox.addWidget(bb)
d.setLayout(vbox)
d.show()
d.exec_()
u = user.text()
p = passwd.text()
if not u or not p:
return
return (u, p)
def _confirmFullSync(self):
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.
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 \
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"
def _clockOff(self):
showWarning(_("""\
Syncing requires the clock on your computer to be set correctly. Please \
fix the clock and try again."""))
def _checkFailed(self):
showWarning(_("""\
Your collection is in an inconsistent state. Please run Tools>\
Check Database, then sync again."""))
def badUserPass(self):
aqt.preferences.Preferences(self, self.pm.profile).dialog.tabWidget.\
setCurrentIndex(1)
# Sync thread
######################################################################
class SyncThread(QThread):
def __init__(self, path, hkey, auth=None, media=True):
QThread.__init__(self)
self.path = path
self.hkey = hkey
self.auth = auth
self.media = media
def run(self):
try:
self.col = Collection(self.path)
except:
self.fireEvent("corrupt")
return
self.server = RemoteServer(self.hkey)
self.client = Syncer(self.col, self.server)
self.syncMsg = ""
self.uname = ""
self.sentTotal = 0
self.recvTotal = 0
# throttle updates; qt doesn't handle lots of posted events well
self.byteUpdate = time.time()
def syncEvent(type):
self.fireEvent("sync", type)
def canPost():
if (time.time() - self.byteUpdate) > 0.1:
self.byteUpdate = time.time()
return True
def sendEvent(bytes):
self.sentTotal += bytes
if canPost():
self.fireEvent("send", self.sentTotal)
def recvEvent(bytes):
self.recvTotal += bytes
if canPost():
self.fireEvent("recv", self.recvTotal)
addHook("sync", syncEvent)
addHook("httpSend", sendEvent)
addHook("httpRecv", recvEvent)
# run sync and catch any errors
try:
self._sync()
except:
err = traceback.format_exc()
if not isinstance(err, unicode):
err = unicode(err, "utf8", "replace")
self.fireEvent("error", err)
finally:
# don't bump mod time unless we explicitly save
self.col.close(save=False)
remHook("sync", syncEvent)
remHook("httpSend", sendEvent)
remHook("httpRecv", recvEvent)
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.client.sync()
except Exception, e:
log = traceback.format_exc()
try:
err = unicode(e[0], "utf8", "ignore")
except:
# number, exception with no args, etc
err = ""
if "Unable to find the server" in err:
self.fireEvent("offline")
else:
if not err:
err = log
if not isinstance(err, unicode):
err = unicode(err, "utf8", "replace")
self.fireEvent("error", err)
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")
# note mediaUSN for later
self.mediaUsn = self.client.mediaUsn
# 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.fireEvent("error", self.client.syncMsg)
else:
self.fireEvent("error", "Unknown sync return code.")
self.syncMsg = self.client.syncMsg
self.uname = self.client.uname
# then move on to media sync
self._syncMedia()
def _fullSync(self):
# if the local deck is empty, assume user is trying to download
if self.col.isEmpty():
f = "download"
else:
# tell the calling thread we need a decision on sync direction, and
# wait for a reply
self.fullSyncChoice = False
self.fireEvent("fullSync")
while not self.fullSyncChoice:
time.sleep(0.1)
f = self.fullSyncChoice
if f == "cancel":
return
self.client = FullSyncer(self.col, self.hkey, self.server.con)
if f == "upload":
if not self.client.upload():
self.fireEvent("upbad")
else:
self.client.download()
# reopen db and move on to media sync
self.col.reopen()
self._syncMedia()
def _syncMedia(self):
if not self.media:
return
self.server = RemoteMediaServer(self.hkey, self.server.con)
self.client = MediaSyncer(self.col, self.server)
ret = self.client.sync(self.mediaUsn)
if ret == "noChanges":
self.fireEvent("noMediaChanges")
elif ret == "sanityCheckFailed":
self.fireEvent("mediaSanity")
else:
self.fireEvent("mediaSuccess")
def fireEvent(self, *args):
self.emit(SIGNAL("event"), *args)
# Monkey-patch httplib & httplib2 so we can get progress info
######################################################################
CHUNK_SIZE = 65536
import httplib, httplib2, errno
from cStringIO import StringIO
from anki.hooks import runHook
# sending in httplib
def _incrementalSend(self, data):
"""Send `data' to the server."""
if self.sock is None:
if self.auto_open:
self.connect()
else:
raise httplib.NotConnected()
# if it's not a file object, make it one
if not hasattr(data, 'read'):
if isinstance(data, unicode):
data = data.encode("utf8")
data = StringIO(data)
while 1:
block = data.read(CHUNK_SIZE)
if not block:
break
self.sock.sendall(block)
runHook("httpSend", len(block))
httplib.HTTPConnection.send = _incrementalSend
# receiving in httplib2
def _conn_request(self, conn, request_uri, method, body, headers):
for i in range(2):
try:
if conn.sock is None:
conn.connect()
conn.request(method, request_uri, body, headers)
except socket.timeout:
raise
except socket.gaierror:
conn.close()
raise httplib2.ServerNotFoundError(
"Unable to find the server at %s" % conn.host)
except httplib2.ssl_SSLError:
conn.close()
raise
except socket.error, e:
err = 0
if hasattr(e, 'args'):
err = getattr(e, 'args')[0]
else:
err = e.errno
if err == errno.ECONNREFUSED: # Connection refused
raise
except httplib.HTTPException:
# Just because the server closed the connection doesn't apparently mean
# that the server didn't send a response.
if conn.sock is None:
if i == 0:
conn.close()
conn.connect()
continue
else:
conn.close()
raise
if i == 0:
conn.close()
conn.connect()
continue
pass
try:
response = conn.getresponse()
except (socket.error, httplib.HTTPException):
if i == 0:
conn.close()
conn.connect()
continue
else:
raise
else:
content = ""
if method == "HEAD":
response.close()
else:
buf = StringIO()
while 1:
data = response.read(CHUNK_SIZE)
if not data:
break
buf.write(data)
runHook("httpRecv", len(data))
content = buf.getvalue()
response = httplib2.Response(response)
if method != "HEAD":
content = httplib2._decompressContent(response, content)
break
return (response, content)
httplib2.Http._conn_request = _conn_request