mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 22:12:21 -04:00
revamped addon downloading
This commit is contained in:
parent
c8ef15c3d0
commit
79e3984a7a
4 changed files with 216 additions and 263 deletions
117
aqt/addons.py
117
aqt/addons.py
|
@ -2,10 +2,17 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
import sys, os, re, traceback
|
import sys, os, re, traceback, time
|
||||||
|
from cStringIO import StringIO
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
from aqt.utils import showInfo, showWarning, openFolder, isWin
|
from aqt.utils import showInfo, showWarning, openFolder, isWin, openLink
|
||||||
from anki.hooks import runHook
|
from anki.hooks import runHook, addHook, remHook
|
||||||
|
from aqt.webview import AnkiWebView
|
||||||
|
from zipfile import ZipFile
|
||||||
|
import aqt.forms
|
||||||
|
import aqt
|
||||||
|
from anki.sync import httpCon
|
||||||
|
import aqt.sync # monkey-patches httplib2
|
||||||
|
|
||||||
class AddonManager(object):
|
class AddonManager(object):
|
||||||
|
|
||||||
|
@ -15,6 +22,7 @@ class AddonManager(object):
|
||||||
self.mw.connect(f.actionOpenPluginFolder, s, self.onOpenAddonFolder)
|
self.mw.connect(f.actionOpenPluginFolder, s, self.onOpenAddonFolder)
|
||||||
self.mw.connect(f.actionEnableAllPlugins, s, self.onEnableAllAddons)
|
self.mw.connect(f.actionEnableAllPlugins, s, self.onEnableAllAddons)
|
||||||
self.mw.connect(f.actionDisableAllPlugins, s, self.onDisableAllAddons)
|
self.mw.connect(f.actionDisableAllPlugins, s, self.onDisableAllAddons)
|
||||||
|
self.mw.connect(f.actionDownloadSharedPlugin, s, self.onGetAddons)
|
||||||
if isWin:
|
if isWin:
|
||||||
self.clearAddonCache()
|
self.clearAddonCache()
|
||||||
sys.path.insert(0, self.addonsFolder())
|
sys.path.insert(0, self.addonsFolder())
|
||||||
|
@ -112,3 +120,106 @@ class AddonManager(object):
|
||||||
def registerAddon(self, name, updateId):
|
def registerAddon(self, name, updateId):
|
||||||
# not currently used
|
# not currently used
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Installing add-ons
|
||||||
|
######################################################################
|
||||||
|
|
||||||
|
def onGetAddons(self):
|
||||||
|
GetAddons(self.mw)
|
||||||
|
|
||||||
|
def install(self, data, fname):
|
||||||
|
if fname.endswith(".py"):
|
||||||
|
# .py files go directly into the addon folder
|
||||||
|
path = os.path.join(self.addonsFolder(), fname)
|
||||||
|
open(path, "w").write(data)
|
||||||
|
return
|
||||||
|
# .zip file
|
||||||
|
z = ZipFile(StringIO(data))
|
||||||
|
base = self.addonsFolder()
|
||||||
|
for n in z.namelist():
|
||||||
|
if n.endswith("/"):
|
||||||
|
# folder; ignore
|
||||||
|
continue
|
||||||
|
# write
|
||||||
|
z.extract(n, base)
|
||||||
|
|
||||||
|
class GetAddons(QDialog):
|
||||||
|
|
||||||
|
def __init__(self, mw):
|
||||||
|
QDialog.__init__(self, mw)
|
||||||
|
self.mw = mw
|
||||||
|
self.form = aqt.forms.getaddons.Ui_Dialog()
|
||||||
|
self.form.setupUi(self)
|
||||||
|
b = self.form.buttonBox.addButton(
|
||||||
|
_("Browse"), QDialogButtonBox.ActionRole)
|
||||||
|
self.connect(b, SIGNAL("clicked()"), self.onBrowse)
|
||||||
|
self.exec_()
|
||||||
|
|
||||||
|
def onBrowse(self):
|
||||||
|
openLink(aqt.appShared + "addons/")
|
||||||
|
|
||||||
|
def accept(self):
|
||||||
|
try:
|
||||||
|
code = int(self.form.code.text())
|
||||||
|
except ValueError:
|
||||||
|
showWarning(_("Invalid code."))
|
||||||
|
return
|
||||||
|
QDialog.accept(self)
|
||||||
|
# create downloader thread
|
||||||
|
self.thread = AddonDownloader(code)
|
||||||
|
self.connect(self.thread, SIGNAL("recv"), self.onRecv)
|
||||||
|
self.recvBytes = 0
|
||||||
|
self.thread.start()
|
||||||
|
self.mw.progress.start(immediate=True)
|
||||||
|
while not self.thread.isFinished():
|
||||||
|
self.mw.app.processEvents()
|
||||||
|
self.thread.wait(100)
|
||||||
|
if not self.thread.error:
|
||||||
|
# success
|
||||||
|
self.mw.addonManager.install(self.thread.data, self.thread.fname)
|
||||||
|
self.mw.progress.finish()
|
||||||
|
showInfo(_("Download successful. Please restart Anki."))
|
||||||
|
else:
|
||||||
|
self.mw.progress.finish()
|
||||||
|
showWarning(_("Download failed: %s") % self.thread.error)
|
||||||
|
|
||||||
|
def onRecv(self, total):
|
||||||
|
self.mw.progress.update(label="%dKB downloaded" % (total/1024))
|
||||||
|
|
||||||
|
class AddonDownloader(QThread):
|
||||||
|
|
||||||
|
def __init__(self, code):
|
||||||
|
QThread.__init__(self)
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
# setup progress handler
|
||||||
|
self.byteUpdate = time.time()
|
||||||
|
self.recvTotal = 0
|
||||||
|
def canPost():
|
||||||
|
if (time.time() - self.byteUpdate) > 0.1:
|
||||||
|
self.byteUpdate = time.time()
|
||||||
|
return True
|
||||||
|
def recvEvent(bytes):
|
||||||
|
self.recvTotal += bytes
|
||||||
|
if canPost():
|
||||||
|
self.emit(SIGNAL("recv"), self.recvTotal)
|
||||||
|
addHook("httpRecv", recvEvent)
|
||||||
|
con = httpCon()
|
||||||
|
try:
|
||||||
|
resp, cont = con.request(
|
||||||
|
aqt.appShared + "download/%d" % self.code)
|
||||||
|
except Exception, e:
|
||||||
|
self.error = unicode(e)
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
remHook("httpRecv", recvEvent)
|
||||||
|
if resp['status'] == '200':
|
||||||
|
self.error = None
|
||||||
|
self.fname = re.match("attachment; filename=(.+)",
|
||||||
|
resp['content-disposition']).group(1)
|
||||||
|
self.data = cont
|
||||||
|
elif resp['status'] == '403':
|
||||||
|
self.error = _("Invalid code.")
|
||||||
|
else:
|
||||||
|
self.error = _("Error downloading: %s") % resp['status']
|
||||||
|
|
259
aqt/getshared.py
259
aqt/getshared.py
|
@ -1,259 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Copyright: Damien Elmes <anki@ichi2.net>
|
|
||||||
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|
||||||
|
|
||||||
from aqt.qt import *
|
|
||||||
import aqt, simplejson, time, cStringIO, zipfile, os, re, gzip
|
|
||||||
import traceback, urllib2, socket, cgi
|
|
||||||
from aqt.ui.utils import saveGeom, restoreGeom, showInfo
|
|
||||||
from anki.utils import fmtTimeSpan
|
|
||||||
|
|
||||||
URL = "http://ankiweb.net/file/"
|
|
||||||
#URL = "http://localhost:8001/file/"
|
|
||||||
|
|
||||||
R_ID = 0
|
|
||||||
R_USERNAME = 1
|
|
||||||
R_TITLE = 2
|
|
||||||
R_DESCRIPTION = 3
|
|
||||||
R_TAGS = 4
|
|
||||||
R_VERSION = 5
|
|
||||||
R_FACTS = 6
|
|
||||||
R_SIZE = 7
|
|
||||||
R_COUNT = 8
|
|
||||||
R_MODIFIED = 9
|
|
||||||
R_FNAME = 10
|
|
||||||
|
|
||||||
class GetShared(QDialog):
|
|
||||||
|
|
||||||
def __init__(self, parent, type):
|
|
||||||
QDialog.__init__(self, parent, Qt.Window)
|
|
||||||
self.parent = parent
|
|
||||||
aqt.form = ankiqt.forms.getshared.Ui_Dialog()
|
|
||||||
self.form.setupUi(self)
|
|
||||||
self.ok = True
|
|
||||||
self.conErrMsg = _("""\
|
|
||||||
<b>Unable to connect to the server.<br><br>
|
|
||||||
Please check your network connection or try again in a few minutes.</b><br>
|
|
||||||
<br>
|
|
||||||
Error was:<pre>%s</pre>""")
|
|
||||||
restoreGeom(self, "getshared")
|
|
||||||
self.setupTable()
|
|
||||||
self.onChangeType(type)
|
|
||||||
if type == 0:
|
|
||||||
self.setWindowTitle(_("Download Shared Col"))
|
|
||||||
else:
|
|
||||||
self.setWindowTitle(_("Download Shared Plugin"))
|
|
||||||
if self.ok:
|
|
||||||
self.exec_()
|
|
||||||
|
|
||||||
def setupTable(self):
|
|
||||||
self.connect(
|
|
||||||
self.form.table, SIGNAL("currentCellChanged(int,int,int,int)"),
|
|
||||||
self.onCellChanged)
|
|
||||||
self.form.table.verticalHeader().setDefaultSectionSize(
|
|
||||||
self.parent.pm.profile['editLineSize'])
|
|
||||||
self.connect(self.form.search, SIGNAL("textChanged(QString)"),
|
|
||||||
self.limit)
|
|
||||||
|
|
||||||
def fetchData(self):
|
|
||||||
self.parent.setProgressParent(None)
|
|
||||||
self.parent.startProgress()
|
|
||||||
self.parent.updateProgress()
|
|
||||||
try:
|
|
||||||
socket.setdefaulttimeout(30)
|
|
||||||
try:
|
|
||||||
sock = urllib2.urlopen(
|
|
||||||
URL + "search?t=%d&c=1" % self.type)
|
|
||||||
data = sock.read()
|
|
||||||
try:
|
|
||||||
data = gzip.GzipFile(fileobj=cStringIO.StringIO(data)).read()
|
|
||||||
except:
|
|
||||||
# the server is sending gzipped data, but a transparent
|
|
||||||
# proxy or antivirus software may be decompressing it
|
|
||||||
# before we get it
|
|
||||||
pass
|
|
||||||
self.allList = simplejson.loads(unicode(data))
|
|
||||||
except:
|
|
||||||
showInfo(self.conErrMsg % cgi.escape(unicode(
|
|
||||||
traceback.format_exc(), "utf-8", "replace")))
|
|
||||||
self.close()
|
|
||||||
self.ok = False
|
|
||||||
return
|
|
||||||
finally:
|
|
||||||
self.parent.finishProgress()
|
|
||||||
socket.setdefaulttimeout(None)
|
|
||||||
self.form.search.setFocus()
|
|
||||||
self.typeChanged()
|
|
||||||
self.limit()
|
|
||||||
|
|
||||||
def limit(self, txt=""):
|
|
||||||
if not txt:
|
|
||||||
self.curList = self.allList
|
|
||||||
else:
|
|
||||||
txt = unicode(txt).lower()
|
|
||||||
self.curList = [
|
|
||||||
l for l in self.allList
|
|
||||||
if (txt in l[R_TITLE].lower() or
|
|
||||||
txt in l[R_DESCRIPTION].lower() or
|
|
||||||
txt in l[R_TAGS].lower())]
|
|
||||||
self.redraw()
|
|
||||||
|
|
||||||
def redraw(self):
|
|
||||||
self.form.table.setSortingEnabled(False)
|
|
||||||
self.form.table.setRowCount(len(self.curList))
|
|
||||||
self.items = {}
|
|
||||||
if self.type == 0:
|
|
||||||
cols = (R_TITLE, R_FACTS, R_COUNT, R_MODIFIED)
|
|
||||||
else:
|
|
||||||
cols = (R_TITLE, R_COUNT, R_MODIFIED)
|
|
||||||
for rc, r in enumerate(self.curList):
|
|
||||||
for cc, c in enumerate(cols):
|
|
||||||
if c == R_FACTS or c == R_COUNT:
|
|
||||||
txt = unicode("%15d" % r[c])
|
|
||||||
elif c == R_MODIFIED:
|
|
||||||
days = int(((time.time() - r[c])/(24*60*60)))
|
|
||||||
txt = ngettext("%6d day ago", "%6d days ago", days) % days
|
|
||||||
else:
|
|
||||||
txt = unicode(r[c])
|
|
||||||
item = QTableWidgetItem(txt)
|
|
||||||
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
|
|
||||||
self.items[item] = r
|
|
||||||
self.form.table.setItem(rc, cc, item)
|
|
||||||
self.form.table.setSortingEnabled(True)
|
|
||||||
if self.type == 0:
|
|
||||||
self.form.table.sortItems(2, Qt.DescendingOrder)
|
|
||||||
else:
|
|
||||||
self.form.table.sortItems(1, Qt.DescendingOrder)
|
|
||||||
self.form.table.selectRow(0)
|
|
||||||
self.onCellChanged(None, None, None, None)
|
|
||||||
|
|
||||||
def onCellChanged(self, row, col, x, y):
|
|
||||||
ci = self.form.table.currentItem()
|
|
||||||
if not ci:
|
|
||||||
self.form.bottomLabel.setText(_("Nothing selected."))
|
|
||||||
return
|
|
||||||
r = self.items[ci]
|
|
||||||
self.curRow = r
|
|
||||||
self.form.bottomLabel.setText(_("""\
|
|
||||||
<b>Title</b>: %(title)s<br>
|
|
||||||
<b>Tags</b>: %(tags)s<br>
|
|
||||||
<b>Size</b>: %(size)0.2fKB<br>
|
|
||||||
<b>Uploader</b>: %(author)s<br>
|
|
||||||
<b>Downloads</b>: %(count)s<br>
|
|
||||||
<b>Modified</b>: %(mod)s ago<br>
|
|
||||||
<br>%(description)s""") % {
|
|
||||||
'title': r[R_TITLE],
|
|
||||||
'tags': r[R_TAGS],
|
|
||||||
'size': r[R_SIZE] / 1024.0,
|
|
||||||
'author': r[R_USERNAME],
|
|
||||||
'count': r[R_COUNT],
|
|
||||||
'mod': fmtTimeSpan(time.time() - r[R_MODIFIED]),
|
|
||||||
'description': r[R_DESCRIPTION].replace("\n", "<br>"),
|
|
||||||
})
|
|
||||||
self.form.scrollAreaWidgetContents.adjustSize()
|
|
||||||
self.form.scrollArea.setWidget(self.form.scrollAreaWidgetContents)
|
|
||||||
|
|
||||||
def onChangeType(self, type):
|
|
||||||
self.type = type
|
|
||||||
self.fetchData()
|
|
||||||
|
|
||||||
def typeChanged(self):
|
|
||||||
self.form.table.clear()
|
|
||||||
if self.type == 0:
|
|
||||||
self.form.table.setColumnCount(4)
|
|
||||||
self.form.table.setHorizontalHeaderLabels([
|
|
||||||
_("Title"), _("Facts"), _("Downloads"), _("Modified")])
|
|
||||||
else:
|
|
||||||
self.form.table.setColumnCount(3)
|
|
||||||
self.form.table.setHorizontalHeaderLabels([
|
|
||||||
_("Title"), _("Downloads"), _("Modified")])
|
|
||||||
self.form.table.horizontalHeader().setResizeMode(
|
|
||||||
0, QHeaderView.Stretch)
|
|
||||||
self.form.table.verticalHeader().hide()
|
|
||||||
|
|
||||||
def accept(self):
|
|
||||||
if self.type == 0:
|
|
||||||
if not self.parent.saveAndClose(hideWelcome=True, parent=self):
|
|
||||||
return QDialog.accept(self)
|
|
||||||
# fixme: use namedtmp
|
|
||||||
(fd, tmpname) = tempfile.mkstemp(prefix="anki")
|
|
||||||
tmpfile = os.fdopen(fd, "w+b")
|
|
||||||
cnt = 0
|
|
||||||
try:
|
|
||||||
socket.setdefaulttimeout(30)
|
|
||||||
self.parent.setProgressParent(self)
|
|
||||||
self.parent.startProgress()
|
|
||||||
self.parent.updateProgress()
|
|
||||||
try:
|
|
||||||
sock = urllib2.urlopen(
|
|
||||||
URL + "get?id=%d" %
|
|
||||||
self.curRow[R_ID])
|
|
||||||
while 1:
|
|
||||||
data = sock.read(32768)
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
cnt += len(data)
|
|
||||||
tmpfile.write(data)
|
|
||||||
self.parent.updateProgress(
|
|
||||||
label=_("Downloaded %dKB") % (cnt/1024.0))
|
|
||||||
except:
|
|
||||||
showInfo(self.conErrMsg % cgi.escape(unicode(
|
|
||||||
traceback.format_exc(), "utf-8", "replace")))
|
|
||||||
self.close()
|
|
||||||
return
|
|
||||||
finally:
|
|
||||||
socket.setdefaulttimeout(None)
|
|
||||||
self.parent.setProgressParent(None)
|
|
||||||
self.parent.finishProgress()
|
|
||||||
QDialog.accept(self)
|
|
||||||
# file is fetched
|
|
||||||
tmpfile.seek(0)
|
|
||||||
self.handleFile(tmpfile)
|
|
||||||
QDialog.accept(self)
|
|
||||||
|
|
||||||
def handleFile(self, file):
|
|
||||||
ext = os.path.splitext(self.curRow[R_FNAME])[1]
|
|
||||||
if ext == ".zip":
|
|
||||||
z = zipfile.ZipFile(file)
|
|
||||||
else:
|
|
||||||
z = None
|
|
||||||
tit = self.curRow[R_TITLE]
|
|
||||||
tit = re.sub("[^][A-Za-z0-9 ()\-]", "", tit)
|
|
||||||
tit = tit[0:40]
|
|
||||||
if self.type == 0:
|
|
||||||
# col
|
|
||||||
dd = self.parent.pm.profile['documentDir']
|
|
||||||
p = os.path.join(dd, tit + ".anki")
|
|
||||||
if os.path.exists(p):
|
|
||||||
tit += "%d" % time.time()
|
|
||||||
for l in z.namelist():
|
|
||||||
if l == "shared.anki":
|
|
||||||
dpath = os.path.join(dd, tit + ".anki")
|
|
||||||
open(dpath, "wb").write(z.read(l))
|
|
||||||
elif l.startswith("shared.media/"):
|
|
||||||
try:
|
|
||||||
os.mkdir(os.path.join(dd, tit + ".media"))
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
open(os.path.join(dd, tit + ".media",
|
|
||||||
os.path.basename(l)),"wb").write(z.read(l))
|
|
||||||
self.parent.loadCol(dpath)
|
|
||||||
else:
|
|
||||||
pd = self.parent.pluginsFolder()
|
|
||||||
if z:
|
|
||||||
for l in z.infolist():
|
|
||||||
try:
|
|
||||||
os.makedirs(os.path.join(
|
|
||||||
pd, os.path.dirname(l.filename)))
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
if l.filename.endswith("/"):
|
|
||||||
# directory
|
|
||||||
continue
|
|
||||||
path = os.path.join(pd, l.filename)
|
|
||||||
open(path, "wb").write(z.read(l.filename))
|
|
||||||
else:
|
|
||||||
open(os.path.join(pd, tit + ext), "wb").write(file.read())
|
|
||||||
showInfo(_("Plugin downloaded. Please restart Anki."),
|
|
||||||
parent=self)
|
|
||||||
|
|
101
designer/getaddons.ui
Normal file
101
designer/getaddons.ui
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ui version="4.0">
|
||||||
|
<class>Dialog</class>
|
||||||
|
<widget class="QDialog" name="Dialog">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>367</width>
|
||||||
|
<height>204</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>Install Add-on</string>
|
||||||
|
</property>
|
||||||
|
<layout class="QVBoxLayout" name="verticalLayout">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="label">
|
||||||
|
<property name="text">
|
||||||
|
<string>To browse add-ons, please click the browse button below.<br><br>When you've found an add-on you like, please paste its code below.</string>
|
||||||
|
</property>
|
||||||
|
<property name="wordWrap">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<spacer name="verticalSpacer">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Vertical</enum>
|
||||||
|
</property>
|
||||||
|
<property name="sizeHint" stdset="0">
|
||||||
|
<size>
|
||||||
|
<width>20</width>
|
||||||
|
<height>40</height>
|
||||||
|
</size>
|
||||||
|
</property>
|
||||||
|
</spacer>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="label_2">
|
||||||
|
<property name="text">
|
||||||
|
<string>Code:</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QLineEdit" name="code"/>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QDialogButtonBox" name="buttonBox">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Horizontal</enum>
|
||||||
|
</property>
|
||||||
|
<property name="standardButtons">
|
||||||
|
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
<resources/>
|
||||||
|
<connections>
|
||||||
|
<connection>
|
||||||
|
<sender>buttonBox</sender>
|
||||||
|
<signal>accepted()</signal>
|
||||||
|
<receiver>Dialog</receiver>
|
||||||
|
<slot>accept()</slot>
|
||||||
|
<hints>
|
||||||
|
<hint type="sourcelabel">
|
||||||
|
<x>248</x>
|
||||||
|
<y>254</y>
|
||||||
|
</hint>
|
||||||
|
<hint type="destinationlabel">
|
||||||
|
<x>157</x>
|
||||||
|
<y>274</y>
|
||||||
|
</hint>
|
||||||
|
</hints>
|
||||||
|
</connection>
|
||||||
|
<connection>
|
||||||
|
<sender>buttonBox</sender>
|
||||||
|
<signal>rejected()</signal>
|
||||||
|
<receiver>Dialog</receiver>
|
||||||
|
<slot>reject()</slot>
|
||||||
|
<hints>
|
||||||
|
<hint type="sourcelabel">
|
||||||
|
<x>316</x>
|
||||||
|
<y>260</y>
|
||||||
|
</hint>
|
||||||
|
<hint type="destinationlabel">
|
||||||
|
<x>286</x>
|
||||||
|
<y>274</y>
|
||||||
|
</hint>
|
||||||
|
</hints>
|
||||||
|
</connection>
|
||||||
|
</connections>
|
||||||
|
</ui>
|
|
@ -200,7 +200,7 @@
|
||||||
</action>
|
</action>
|
||||||
<action name="actionDownloadSharedPlugin">
|
<action name="actionDownloadSharedPlugin">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Get Add-ons...</string>
|
<string>Browse && Install...</string>
|
||||||
</property>
|
</property>
|
||||||
<property name="statusTip">
|
<property name="statusTip">
|
||||||
<string>Download a plugin to add new features or change Anki's behaviour</string>
|
<string>Download a plugin to add new features or change Anki's behaviour</string>
|
||||||
|
|
Loading…
Reference in a new issue