new add-on handling
- separate dialog for managing add-ons - only add-ons compatible with Anki 2.1 will be shown on AnkiWeb - can delete or toggle disabled on multiple add-ons at once - check for updates button - button to view add-on's AnkiWeb page The new handling drops support for single file .py add-ons, and requires add-ons to store all files in a single folder. This ensures all files are cleaned up properly when updating or deleting an add-on, and prevents file conflicts between separate add-ons. See the updated add-on docs for more: https://apps.ankiweb.net/docs/addons21.html#add-on-folders https://apps.ankiweb.net/docs/addons21.html#sharing-add-ons README.addons has been moved to the above page
This commit is contained in:
parent
f824db2143
commit
7288a9b063
@ -1,98 +0,0 @@
|
|||||||
Porting add-ons to Anki 2.1
|
|
||||||
---------------------------
|
|
||||||
|
|
||||||
Python 3
|
|
||||||
---------
|
|
||||||
|
|
||||||
Anki 2.1 requires Python 3.5 or later. After installing Python 3 on your
|
|
||||||
machine, you can use the 2to3 tool to automatically convert your existing
|
|
||||||
scripts to Python 3 code on a folder by folder basis, like:
|
|
||||||
|
|
||||||
2to3-3.5 --output-dir=aqt3 -W -n aqt
|
|
||||||
mv aqt aqt-old
|
|
||||||
mv aqt3 aqt
|
|
||||||
|
|
||||||
Most simple code can be converted automatically, but there may be parts of the
|
|
||||||
code that you need to manually modify.
|
|
||||||
|
|
||||||
Qt5 / PyQt5
|
|
||||||
------------
|
|
||||||
|
|
||||||
The syntax for connecting signals and slots has changed in PyQt5. Recent PyQt4
|
|
||||||
versions support the new syntax as well, so after updating your add-ons you
|
|
||||||
may find they still work in Anki 2.0.x as well.
|
|
||||||
|
|
||||||
More info is available at
|
|
||||||
http://pyqt.sourceforge.net/Docs/PyQt4/new_style_signals_slots.html
|
|
||||||
|
|
||||||
One add-on author reported that the following tool was useful to automatically
|
|
||||||
convert the code:
|
|
||||||
https://github.com/rferrazz/pyqt4topyqt5
|
|
||||||
|
|
||||||
Compatibility with Anki 2.0
|
|
||||||
----------------------------
|
|
||||||
|
|
||||||
It should be possible for many add-ons to support both Anki 2.0 and 2.1 at the
|
|
||||||
same time.
|
|
||||||
|
|
||||||
Most Python 3 code will run on Python 2 as well, though extra work may be
|
|
||||||
required when dealing with file access and byte strings.
|
|
||||||
|
|
||||||
The Qt modules are in 'PyQt5' instead of 'PyQt4'. You can do a conditional
|
|
||||||
import, but an easier way is to import from aqt.qt - eg "from aqt.qt import *"
|
|
||||||
|
|
||||||
The most difficult part is the change from the unsupported QtWebKit to
|
|
||||||
QtWebEngine. If you do any non-trivial work with webviews, some work will be
|
|
||||||
required to port your code to Anki 2.1, as described in the next section.
|
|
||||||
|
|
||||||
If you find that non-trivial changes are required to get your add-on working
|
|
||||||
with Anki 2.1, the easiest option is to drop support for older Anki versions.
|
|
||||||
Please see the 'sharing updated add-ons' section below for more information.
|
|
||||||
|
|
||||||
Webview changes
|
|
||||||
----------------
|
|
||||||
|
|
||||||
Qt 5 has dropped WebKit in favour of the Chromium-based WebEngine, so
|
|
||||||
Anki's webviews are now using WebEngine. Of note:
|
|
||||||
|
|
||||||
- You can now debug the webviews using an external Chrome instance, by setting
|
|
||||||
the env var QTWEBENGINE_REMOTE_DEBUGGING to 8080 prior to starting Anki,
|
|
||||||
then surfing to localhost:8080 in Chrome.
|
|
||||||
- WebEngine uses a different method of communicating back to Python.
|
|
||||||
AnkiWebView() is a wrapper for webviews which provides a pycmd(str) function in
|
|
||||||
Javascript which will call the ankiwebview's onBridgeCmd(str) method. Various
|
|
||||||
parts of Anki's UI like reviewer.py and deckbrowser.py have had to be
|
|
||||||
modified to use this.
|
|
||||||
- Javascript is evaluated asynchronously, so if you need the result of a JS
|
|
||||||
expression you can use ankiwebview's evalWithCallback().
|
|
||||||
- As a result of this asynchronous behaviour, editor.saveNow() now requires a
|
|
||||||
callback. If your add-on performs actions in the browser, you likely need to
|
|
||||||
call editor.saveNow() first and then run the rest of your code in the callback.
|
|
||||||
Calls to .onSearch() will need to be changed to .search()/.onSearchActivated()
|
|
||||||
as well. See the browser's .deleteNotes() for an example.
|
|
||||||
- Various operations that were supported by WebKit like setScrollPosition() now
|
|
||||||
need to be implemented in javascript.
|
|
||||||
- Page actions like mw.web.triggerPageAction(QWebEnginePage.Copy) are also
|
|
||||||
asynchronous, and need to be rewritten to use javascript or a delay.
|
|
||||||
- WebEngine doesn't provide a keyPressEvent() like WebKit did, so the code
|
|
||||||
that catches shortcuts not attached to a menu or button has had to be changed.
|
|
||||||
See the way reviewer.py calls setStateShortcuts() for an example.
|
|
||||||
|
|
||||||
Add-ons without a top level file
|
|
||||||
---------------------------------
|
|
||||||
|
|
||||||
Add-ons no longer require a top level file - if you just distribute a single
|
|
||||||
folder, the folder's __init__.py file will form the entry point. This will not
|
|
||||||
work in 2.0.x however.
|
|
||||||
|
|
||||||
Sharing updated add-ons
|
|
||||||
------------------------
|
|
||||||
|
|
||||||
If you've succeeded in making an add-on that supports both 2.0.x and 2.1.x at
|
|
||||||
the same time, please feel free to upload it to the shared add-ons area, and
|
|
||||||
mention in the description that it works with both versions.
|
|
||||||
|
|
||||||
If you've decided to make a separate 2.1.x version, it's probably best to just
|
|
||||||
post a link to it in your current add-on description or upload it separately.
|
|
||||||
When we get closer to a release I'll look into adding separate uploads for the
|
|
||||||
two versions.
|
|
@ -5,9 +5,9 @@ For info on contributing things other than code, such as translations, decks
|
|||||||
and add-ons, please see http://ankisrs.net/docs/manual.html#contributing
|
and add-ons, please see http://ankisrs.net/docs/manual.html#contributing
|
||||||
|
|
||||||
The goal of Anki 2.1.x is to bring Anki up to date with Python 3 and Qt 5,
|
The goal of Anki 2.1.x is to bring Anki up to date with Python 3 and Qt 5,
|
||||||
while changing as little else as possible. Modern Linux distros have started
|
while maintaining compatibility with Anki 2.0.x. Some users will be stuck on
|
||||||
dropping support for Qt 4, so we need to keep changes to a minimum in order to
|
Anki 2.0 for a while due to unported add-ons or old hardware, so it's
|
||||||
get an update out faster.
|
important that 2.1 doesn't make breaking changes to the file format.
|
||||||
|
|
||||||
Also of consideration is that the Anki code is indirectly used by the mobile
|
Also of consideration is that the Anki code is indirectly used by the mobile
|
||||||
clients, which try their best to keep as close to the Anki code as possible so
|
clients, which try their best to keep as close to the Anki code as possible so
|
||||||
|
379
aqt/addons.py
379
aqt/addons.py
@ -2,9 +2,11 @@
|
|||||||
# -*- 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 io
|
import io
|
||||||
import sys, os, traceback
|
import json
|
||||||
from io import StringIO
|
import re
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from send2trash import send2trash
|
||||||
|
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
from aqt.utils import showInfo, openFolder, isWin, openLink, \
|
from aqt.utils import showInfo, openFolder, isWin, openLink, \
|
||||||
askUser, restoreGeom, saveGeom, showWarning, tooltip
|
askUser, restoreGeom, saveGeom, showWarning, tooltip
|
||||||
@ -13,131 +15,103 @@ import aqt.forms
|
|||||||
import aqt
|
import aqt
|
||||||
from aqt.downloader import download
|
from aqt.downloader import download
|
||||||
from anki.lang import _
|
from anki.lang import _
|
||||||
|
from anki.utils import intTime
|
||||||
# in the future, it would be nice to save the addon id and unzippped file list
|
from anki.sync import AnkiRequestsClient
|
||||||
# to the config so that we can clear up all files and check for updates
|
|
||||||
|
|
||||||
class AddonManager:
|
class AddonManager:
|
||||||
|
|
||||||
def __init__(self, mw):
|
def __init__(self, mw):
|
||||||
self.mw = mw
|
self.mw = mw
|
||||||
|
self.dirty = False
|
||||||
f = self.mw.form
|
f = self.mw.form
|
||||||
f.actionOpenPluginFolder.triggered.connect(self.onOpenAddonFolder)
|
f.actionAdd_ons.triggered.connect(self.onAddonsDialog)
|
||||||
f.actionDownloadSharedPlugin.triggered.connect(self.onGetAddons)
|
|
||||||
self._menus = []
|
|
||||||
if isWin:
|
|
||||||
self.clearAddonCache()
|
|
||||||
sys.path.insert(0, self.addonsFolder())
|
sys.path.insert(0, self.addonsFolder())
|
||||||
if not self.mw.safeMode:
|
if not self.mw.safeMode:
|
||||||
self.loadAddons()
|
self.loadAddons()
|
||||||
|
|
||||||
def files(self):
|
def allAddons(self):
|
||||||
return [f for f in os.listdir(self.addonsFolder())
|
l = []
|
||||||
if f.endswith(".py")]
|
for d in os.listdir(self.addonsFolder()):
|
||||||
|
path = self.addonsFolder(d)
|
||||||
|
if not os.path.exists(os.path.join(path, "__init__.py")):
|
||||||
|
continue
|
||||||
|
l.append(d)
|
||||||
|
return l
|
||||||
|
|
||||||
def directories(self):
|
def managedAddons(self):
|
||||||
return [d for d in os.listdir(self.addonsFolder())
|
return [d for d in self.allAddons()
|
||||||
if not d.startswith('.') and
|
if re.match("^\d+$", d)]
|
||||||
not d == "__pycache__" and
|
|
||||||
os.path.isdir(os.path.join(self.addonsFolder(), d))]
|
def addonsFolder(self, dir=None):
|
||||||
|
root = self.mw.pm.addonFolder()
|
||||||
|
if not dir:
|
||||||
|
return root
|
||||||
|
return os.path.join(root, dir)
|
||||||
|
|
||||||
def loadAddons(self):
|
def loadAddons(self):
|
||||||
for file in self.files():
|
for dir in self.allAddons():
|
||||||
|
meta = self.addonMeta(dir)
|
||||||
|
if meta.get("disabled"):
|
||||||
|
continue
|
||||||
|
self.dirty = True
|
||||||
try:
|
try:
|
||||||
__import__(file.replace(".py", ""))
|
__import__(dir)
|
||||||
except:
|
except:
|
||||||
traceback.print_exc()
|
showWarning(_("""\
|
||||||
for directory in self.directories():
|
An add-on you installed failed to load. If problems persist, please \
|
||||||
try:
|
go to the Tools>Add-ons menu, and disable or delete the add-on.
|
||||||
__import__(directory)
|
|
||||||
except:
|
|
||||||
traceback.print_exc()
|
|
||||||
self.rebuildAddonsMenu()
|
|
||||||
|
|
||||||
# Menus
|
When loading '%(name)s':
|
||||||
|
%(traceback)s
|
||||||
|
""") % dict(name=meta.get("name", dir), traceback=traceback.format_exc()))
|
||||||
|
|
||||||
|
def onAddonsDialog(self):
|
||||||
|
AddonsDialog(self)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
######################################################################
|
######################################################################
|
||||||
|
|
||||||
def onOpenAddonFolder(self, checked, path=None):
|
def _addonMetaPath(self, dir):
|
||||||
if path is None:
|
return os.path.join(self.addonsFolder(dir), "meta.json")
|
||||||
path = self.addonsFolder()
|
|
||||||
openFolder(path)
|
|
||||||
|
|
||||||
def rebuildAddonsMenu(self):
|
def addonMeta(self, dir):
|
||||||
for m in self._menus:
|
path = self._addonMetaPath(dir)
|
||||||
self.mw.form.menuPlugins.removeAction(m.menuAction())
|
try:
|
||||||
for file in self.files():
|
return json.load(open(path, encoding="utf8"))
|
||||||
m = self.mw.form.menuPlugins.addMenu(
|
except:
|
||||||
os.path.splitext(file)[0])
|
return dict()
|
||||||
self._menus.append(m)
|
|
||||||
p = os.path.join(self.addonsFolder(), file)
|
|
||||||
a = QAction(_("Edit..."), self.mw, triggered=lambda x, y=p: self.onEdit(y))
|
|
||||||
m.addAction(a)
|
|
||||||
a = QAction(_("Delete..."), self.mw, triggered=lambda x, y=p: self.onRem(y))
|
|
||||||
m.addAction(a)
|
|
||||||
|
|
||||||
def onEdit(self, path):
|
def writeAddonMeta(self, dir, meta):
|
||||||
d = QDialog(self.mw)
|
path = self._addonMetaPath(dir)
|
||||||
frm = aqt.forms.editaddon.Ui_Dialog()
|
json.dump(meta, open(path, "w", encoding="utf8"))
|
||||||
frm.setupUi(d)
|
|
||||||
d.setWindowTitle(os.path.basename(path))
|
|
||||||
frm.text.setPlainText(open(path).read())
|
|
||||||
frm.buttonBox.accepted.connect(lambda: self.onAcceptEdit(path, frm))
|
|
||||||
d.exec_()
|
|
||||||
|
|
||||||
def onAcceptEdit(self, path, frm):
|
def toggleEnabled(self, dir):
|
||||||
open(path, "wb").write(frm.text.toPlainText().encode("utf8"))
|
meta = self.addonMeta(dir)
|
||||||
showInfo(_("Edits saved. Please restart Anki."))
|
meta['disabled'] = not meta.get("disabled")
|
||||||
|
self.writeAddonMeta(dir, meta)
|
||||||
|
|
||||||
def onRem(self, path):
|
def addonName(self, dir):
|
||||||
if not askUser(_("Delete %s?") % os.path.basename(path)):
|
return self.addonMeta(dir).get("name", dir)
|
||||||
return
|
|
||||||
os.unlink(path)
|
|
||||||
self.rebuildAddonsMenu()
|
|
||||||
showInfo(_("Deleted. Please restart Anki."))
|
|
||||||
|
|
||||||
# Tools
|
# Installing and deleting add-ons
|
||||||
######################################################################
|
######################################################################
|
||||||
|
|
||||||
def addonsFolder(self):
|
def install(self, sid, data, fname):
|
||||||
dir = self.mw.pm.addonFolder()
|
|
||||||
return dir
|
|
||||||
|
|
||||||
def clearAddonCache(self):
|
|
||||||
"Clear .pyc files which may cause crashes if Python version updated."
|
|
||||||
dir = self.addonsFolder()
|
|
||||||
for curdir, dirs, files in os.walk(dir):
|
|
||||||
for f in files:
|
|
||||||
if not f.endswith(".pyc"):
|
|
||||||
continue
|
|
||||||
os.unlink(os.path.join(curdir, f))
|
|
||||||
|
|
||||||
def registerAddon(self, name, updateId):
|
|
||||||
# not currently used
|
|
||||||
return
|
|
||||||
|
|
||||||
# Installing add-ons
|
|
||||||
######################################################################
|
|
||||||
|
|
||||||
def onGetAddons(self):
|
|
||||||
showInfo("""\
|
|
||||||
Most add-ons built for Anki 2.0.x will not work on this version of Anki \
|
|
||||||
until they are updated. To avoid errors during startup, please only \
|
|
||||||
download add-ons that say they support Anki 2.1.x in the description.""")
|
|
||||||
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, "wb").write(data)
|
|
||||||
return
|
|
||||||
# .zip file
|
|
||||||
try:
|
try:
|
||||||
z = ZipFile(io.BytesIO(data))
|
z = ZipFile(io.BytesIO(data))
|
||||||
except zipfile.BadZipfile:
|
except zipfile.BadZipfile:
|
||||||
showWarning(_("The download was corrupt. Please try again."))
|
showWarning(_("The download was corrupt. Please try again."))
|
||||||
return
|
return
|
||||||
base = self.addonsFolder()
|
|
||||||
|
name = os.path.splitext(fname)[0]
|
||||||
|
|
||||||
|
# remove old version first
|
||||||
|
base = self.addonsFolder(sid)
|
||||||
|
if os.path.exists(base):
|
||||||
|
self.deleteAddon(sid)
|
||||||
|
|
||||||
|
# extract
|
||||||
|
os.mkdir(base)
|
||||||
for n in z.namelist():
|
for n in z.namelist():
|
||||||
if n.endswith("/"):
|
if n.endswith("/"):
|
||||||
# folder; ignore
|
# folder; ignore
|
||||||
@ -145,11 +119,190 @@ download add-ons that say they support Anki 2.1.x in the description.""")
|
|||||||
# write
|
# write
|
||||||
z.extract(n, base)
|
z.extract(n, base)
|
||||||
|
|
||||||
|
# write metadata
|
||||||
|
meta = dict(name=name,
|
||||||
|
mod=intTime())
|
||||||
|
self.writeAddonMeta(sid, meta)
|
||||||
|
|
||||||
|
def deleteAddon(self, dir):
|
||||||
|
send2trash(self.addonsFolder(dir))
|
||||||
|
|
||||||
|
# Downloading
|
||||||
|
######################################################################
|
||||||
|
|
||||||
|
def downloadIds(self, ids):
|
||||||
|
log = []
|
||||||
|
errs = []
|
||||||
|
self.mw.progress.start(immediate=True)
|
||||||
|
for n in ids:
|
||||||
|
ret = download(self.mw, n)
|
||||||
|
if ret[0] == "error":
|
||||||
|
errs.append(_("Error downloading %(id)s: %(error)s") % dict(id=n, error=ret[1]))
|
||||||
|
continue
|
||||||
|
data, fname = ret
|
||||||
|
self.install(str(n), data, fname)
|
||||||
|
name = os.path.splitext(fname)[0]
|
||||||
|
log.append(_("Downloaded %(fname)s" % dict(fname=name)))
|
||||||
|
self.mw.progress.finish()
|
||||||
|
return log, errs
|
||||||
|
|
||||||
|
# Updating
|
||||||
|
######################################################################
|
||||||
|
|
||||||
|
def checkForUpdates(self):
|
||||||
|
client = AnkiRequestsClient()
|
||||||
|
|
||||||
|
# get mod times
|
||||||
|
self.mw.progress.start(immediate=True)
|
||||||
|
try:
|
||||||
|
# ..of enabled items downloaded from ankiweb
|
||||||
|
addons = []
|
||||||
|
for dir in self.managedAddons():
|
||||||
|
meta = self.addonMeta(dir)
|
||||||
|
if not meta.get("disabled"):
|
||||||
|
addons.append(dir)
|
||||||
|
|
||||||
|
mods = []
|
||||||
|
while addons:
|
||||||
|
chunk = addons[:25]
|
||||||
|
del addons[:25]
|
||||||
|
mods.extend(self._getModTimes(client, chunk))
|
||||||
|
return self._updatedIds(mods)
|
||||||
|
finally:
|
||||||
|
self.mw.progress.finish()
|
||||||
|
|
||||||
|
def _getModTimes(self, client, chunk):
|
||||||
|
resp = client.get(
|
||||||
|
aqt.appShared + "updates/" + ",".join(chunk))
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json()
|
||||||
|
else:
|
||||||
|
raise Exception("Unexpected response code from AnkiWeb: {}".format(resp.status_code))
|
||||||
|
|
||||||
|
def _updatedIds(self, mods):
|
||||||
|
updated = []
|
||||||
|
for dir, ts in mods:
|
||||||
|
sid = str(dir)
|
||||||
|
if self.addonMeta(sid).get("mod") < ts:
|
||||||
|
updated.append(sid)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
# Add-ons Dialog
|
||||||
|
######################################################################
|
||||||
|
|
||||||
|
class AddonsDialog(QDialog):
|
||||||
|
|
||||||
|
def __init__(self, addonsManager):
|
||||||
|
self.mgr = addonsManager
|
||||||
|
self.mw = addonsManager.mw
|
||||||
|
|
||||||
|
super().__init__(self.mw)
|
||||||
|
|
||||||
|
f = self.form = aqt.forms.addons.Ui_Dialog()
|
||||||
|
f.setupUi(self)
|
||||||
|
f.getAddons.clicked.connect(self.onGetAddons)
|
||||||
|
f.checkForUpdates.clicked.connect(self.onCheckForUpdates)
|
||||||
|
f.toggleEnabled.clicked.connect(self.onToggleEnabled)
|
||||||
|
f.viewPage.clicked.connect(self.onViewPage)
|
||||||
|
f.viewFiles.clicked.connect(self.onViewFiles)
|
||||||
|
f.delete_2.clicked.connect(self.onDelete)
|
||||||
|
self.redrawAddons()
|
||||||
|
self.show()
|
||||||
|
|
||||||
|
def redrawAddons(self):
|
||||||
|
self.addons = [(self.annotatedName(d), d) for d in self.mgr.allAddons()]
|
||||||
|
self.addons.sort()
|
||||||
|
self.form.addonList.clear()
|
||||||
|
self.form.addonList.addItems([r[0] for r in self.addons])
|
||||||
|
if self.addons:
|
||||||
|
self.form.addonList.setCurrentRow(0)
|
||||||
|
|
||||||
|
def annotatedName(self, dir):
|
||||||
|
meta = self.mgr.addonMeta(dir)
|
||||||
|
buf = self.mgr.addonName(dir)
|
||||||
|
if meta.get('disabled'):
|
||||||
|
buf += _(" (disabled)")
|
||||||
|
return buf
|
||||||
|
|
||||||
|
def selectedAddons(self):
|
||||||
|
idxs = [x.row() for x in self.form.addonList.selectedIndexes()]
|
||||||
|
return [self.addons[idx][1] for idx in idxs]
|
||||||
|
|
||||||
|
def onlyOneSelected(self):
|
||||||
|
dirs = self.selectedAddons()
|
||||||
|
if len(dirs) != 1:
|
||||||
|
showInfo("Please select a single add-on first.")
|
||||||
|
return
|
||||||
|
return dirs[0]
|
||||||
|
|
||||||
|
def onToggleEnabled(self):
|
||||||
|
for dir in self.selectedAddons():
|
||||||
|
self.mgr.toggleEnabled(dir)
|
||||||
|
self.redrawAddons()
|
||||||
|
|
||||||
|
def onViewPage(self):
|
||||||
|
addon = self.onlyOneSelected()
|
||||||
|
if not addon:
|
||||||
|
return
|
||||||
|
if re.match("^\d+$", addon):
|
||||||
|
openLink(aqt.appShared + f"info/{addon}")
|
||||||
|
else:
|
||||||
|
showWarning(_("Add-on was not downloaded from AnkiWeb."))
|
||||||
|
|
||||||
|
def onViewFiles(self):
|
||||||
|
# if nothing selected, open top level folder
|
||||||
|
selected = self.selectedAddons()
|
||||||
|
if not selected:
|
||||||
|
openFolder(self.mgr.addonsFolder())
|
||||||
|
return
|
||||||
|
|
||||||
|
# otherwise require a single selection
|
||||||
|
addon = self.onlyOneSelected()
|
||||||
|
if not addon:
|
||||||
|
return
|
||||||
|
path = self.mgr.addonsFolder(addon)
|
||||||
|
openFolder(path)
|
||||||
|
|
||||||
|
def onDelete(self):
|
||||||
|
selected = self.selectedAddons()
|
||||||
|
if not selected:
|
||||||
|
return
|
||||||
|
if not askUser(_("Delete the %(num)d selected add-ons?") %
|
||||||
|
dict(num=len(selected))):
|
||||||
|
return
|
||||||
|
for dir in selected:
|
||||||
|
self.mgr.deleteAddon(dir)
|
||||||
|
self.redrawAddons()
|
||||||
|
|
||||||
|
def onGetAddons(self):
|
||||||
|
GetAddons(self)
|
||||||
|
|
||||||
|
def onCheckForUpdates(self):
|
||||||
|
updated = self.mgr.checkForUpdates()
|
||||||
|
if not updated:
|
||||||
|
tooltip(_("No updates available."))
|
||||||
|
else:
|
||||||
|
names = [self.mgr.addonName(d) for d in updated]
|
||||||
|
if askUser(_("Update the following add-ons?") +
|
||||||
|
"\n" + "\n".join(names)):
|
||||||
|
log, errs = self.mgr.downloadIds(updated)
|
||||||
|
if log:
|
||||||
|
tooltip("\n".join(log), parent=self)
|
||||||
|
if errs:
|
||||||
|
showWarning("\n".join(errs), parent=self)
|
||||||
|
|
||||||
|
self.redrawAddons()
|
||||||
|
|
||||||
|
# Fetching Add-ons
|
||||||
|
######################################################################
|
||||||
|
|
||||||
class GetAddons(QDialog):
|
class GetAddons(QDialog):
|
||||||
|
|
||||||
def __init__(self, mw):
|
def __init__(self, dlg):
|
||||||
QDialog.__init__(self, mw)
|
QDialog.__init__(self, dlg)
|
||||||
self.mw = mw
|
self.addonsDlg = dlg
|
||||||
|
self.mgr = dlg.mgr
|
||||||
|
self.mw = self.mgr.mw
|
||||||
self.form = aqt.forms.getaddons.Ui_Dialog()
|
self.form = aqt.forms.getaddons.Ui_Dialog()
|
||||||
self.form.setupUi(self)
|
self.form.setupUi(self)
|
||||||
b = self.form.buttonBox.addButton(
|
b = self.form.buttonBox.addButton(
|
||||||
@ -160,11 +313,9 @@ class GetAddons(QDialog):
|
|||||||
saveGeom(self, "getaddons")
|
saveGeom(self, "getaddons")
|
||||||
|
|
||||||
def onBrowse(self):
|
def onBrowse(self):
|
||||||
openLink(aqt.appShared + "addons/")
|
openLink(aqt.appShared + "addons/?v=2.1")
|
||||||
|
|
||||||
def accept(self):
|
def accept(self):
|
||||||
QDialog.accept(self)
|
|
||||||
|
|
||||||
# get codes
|
# get codes
|
||||||
try:
|
try:
|
||||||
ids = [int(n) for n in self.form.code.text().split()]
|
ids = [int(n) for n in self.form.code.text().split()]
|
||||||
@ -172,18 +323,12 @@ class GetAddons(QDialog):
|
|||||||
showWarning(_("Invalid code."))
|
showWarning(_("Invalid code."))
|
||||||
return
|
return
|
||||||
|
|
||||||
errors = []
|
log, errs = self.mgr.downloadIds(ids)
|
||||||
|
|
||||||
self.mw.progress.start(immediate=True)
|
if log:
|
||||||
for n in ids:
|
tooltip("\n".join(log), parent=self.addonsDlg)
|
||||||
ret = download(self.mw, n)
|
if errs:
|
||||||
if ret[0] == "error":
|
showWarning("\n".join(errs))
|
||||||
errors.append(_("Error downloading %(id)s: %(error)s") % dict(id=n, error=ret[1]))
|
|
||||||
continue
|
self.addonsDlg.redrawAddons()
|
||||||
data, fname = ret
|
QDialog.accept(self)
|
||||||
self.mw.addonManager.install(data, fname)
|
|
||||||
self.mw.progress.finish()
|
|
||||||
if not errors:
|
|
||||||
tooltip(_("Download successful. Please restart Anki."), period=3000)
|
|
||||||
else:
|
|
||||||
showWarning("\n".join(errors))
|
|
||||||
|
@ -10,7 +10,7 @@ from anki.hooks import addHook, remHook
|
|||||||
import aqt
|
import aqt
|
||||||
|
|
||||||
def download(mw, code):
|
def download(mw, code):
|
||||||
"Download addon/deck from AnkiWeb. Caller must start & stop progress diag."
|
"Download addon from AnkiWeb. Caller must start & stop progress diag."
|
||||||
# create downloading thread
|
# create downloading thread
|
||||||
thread = Downloader(code)
|
thread = Downloader(code)
|
||||||
def onRecv():
|
def onRecv():
|
||||||
@ -53,11 +53,11 @@ class Downloader(QThread):
|
|||||||
client = AnkiRequestsClient()
|
client = AnkiRequestsClient()
|
||||||
try:
|
try:
|
||||||
resp = client.get(
|
resp = client.get(
|
||||||
aqt.appShared + "download/%d" % self.code)
|
aqt.appShared + "download/%s?v=2.1" % self.code)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
data = client.streamContent(resp)
|
data = client.streamContent(resp)
|
||||||
elif resp.status_code in (403,404):
|
elif resp.status_code in (403,404):
|
||||||
self.error = _("Invalid code")
|
self.error = _("Invalid code, or add-on not available for your version of Anki.")
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
self.error = _("Error downloading: %s" % resp.status_code)
|
self.error = _("Error downloading: %s" % resp.status_code)
|
||||||
|
@ -105,16 +105,17 @@ or your deck may have a problem.
|
|||||||
<p>If that doesn't fix the problem, please copy the following<br>
|
<p>If that doesn't fix the problem, please copy the following<br>
|
||||||
into a bug report:""")
|
into a bug report:""")
|
||||||
pluginText = _("""\
|
pluginText = _("""\
|
||||||
An error occurred in an add-on.<br><br>
|
<p>An error occurred. Please start Anki while holding down the shift \
|
||||||
Please start Anki while holding down the shift key, and see if<br>
|
key, which will temporarily disable the add-ons you have installed.</p>
|
||||||
the error goes away.<br><br>
|
|
||||||
If the error goes away, please report the issue on the add-on<br>
|
<p>If the problem occurs even with add-ons disabled, please report the \
|
||||||
forum: %s
|
issue on our support site.</p>
|
||||||
<br><br>If the error occurs even with add-ons disabled, please<br>
|
|
||||||
report the issue on our support site.
|
<p>If the issue only occurs when add-ons are enabled, plesae use the \
|
||||||
|
Tools>Add-ons menu item to disable one add-on and restart Anki, \
|
||||||
|
repeating until you discover the add-on that is causing the problem.</p>
|
||||||
""")
|
""")
|
||||||
pluginText %= "https://anki.tenderapp.com/discussions/add-ons"
|
if self.mw.addonManager.dirty:
|
||||||
if "addon" in error:
|
|
||||||
txt = pluginText
|
txt = pluginText
|
||||||
else:
|
else:
|
||||||
txt = stdText
|
txt = stdText
|
||||||
|
104
designer/addons.ui
Normal file
104
designer/addons.ui
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ui version="4.0">
|
||||||
|
<class>Dialog</class>
|
||||||
|
<widget class="QDialog" name="Dialog">
|
||||||
|
<property name="windowModality">
|
||||||
|
<enum>Qt::ApplicationModal</enum>
|
||||||
|
</property>
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>577</width>
|
||||||
|
<height>379</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>Add-ons</string>
|
||||||
|
</property>
|
||||||
|
<property name="modal">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||||
|
<item>
|
||||||
|
<widget class="QLabel" name="label">
|
||||||
|
<property name="text">
|
||||||
|
<string>Changes will take effect when Anki is restarted.</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QListWidget" name="addonList">
|
||||||
|
<property name="selectionMode">
|
||||||
|
<enum>QAbstractItemView::ContiguousSelection</enum>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<layout class="QVBoxLayout" name="verticalLayout">
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="getAddons">
|
||||||
|
<property name="text">
|
||||||
|
<string>Get Add-ons...</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="checkForUpdates">
|
||||||
|
<property name="text">
|
||||||
|
<string>Check for Updates</string>
|
||||||
|
</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>
|
||||||
|
<widget class="QPushButton" name="viewPage">
|
||||||
|
<property name="text">
|
||||||
|
<string>View Add-on Page</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="viewFiles">
|
||||||
|
<property name="text">
|
||||||
|
<string>View Files</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="toggleEnabled">
|
||||||
|
<property name="text">
|
||||||
|
<string>Toggle Enabled</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<widget class="QPushButton" name="delete_2">
|
||||||
|
<property name="text">
|
||||||
|
<string>Delete</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
<resources/>
|
||||||
|
<connections/>
|
||||||
|
</ui>
|
@ -79,14 +79,6 @@
|
|||||||
<property name="title">
|
<property name="title">
|
||||||
<string>&Tools</string>
|
<string>&Tools</string>
|
||||||
</property>
|
</property>
|
||||||
<widget class="QMenu" name="menuPlugins">
|
|
||||||
<property name="title">
|
|
||||||
<string>&Add-ons</string>
|
|
||||||
</property>
|
|
||||||
<addaction name="actionDownloadSharedPlugin"/>
|
|
||||||
<addaction name="actionOpenPluginFolder"/>
|
|
||||||
<addaction name="separator"/>
|
|
||||||
</widget>
|
|
||||||
<addaction name="actionStudyDeck"/>
|
<addaction name="actionStudyDeck"/>
|
||||||
<addaction name="actionCreateFiltered"/>
|
<addaction name="actionCreateFiltered"/>
|
||||||
<addaction name="separator"/>
|
<addaction name="separator"/>
|
||||||
@ -94,7 +86,7 @@
|
|||||||
<addaction name="actionCheckMediaDatabase"/>
|
<addaction name="actionCheckMediaDatabase"/>
|
||||||
<addaction name="actionEmptyCards"/>
|
<addaction name="actionEmptyCards"/>
|
||||||
<addaction name="separator"/>
|
<addaction name="separator"/>
|
||||||
<addaction name="menuPlugins"/>
|
<addaction name="actionAdd_ons"/>
|
||||||
<addaction name="separator"/>
|
<addaction name="separator"/>
|
||||||
<addaction name="actionNoteTypes"/>
|
<addaction name="actionNoteTypes"/>
|
||||||
<addaction name="actionPreferences"/>
|
<addaction name="actionPreferences"/>
|
||||||
@ -237,6 +229,11 @@
|
|||||||
<string>Ctrl+Shift+N</string>
|
<string>Ctrl+Shift+N</string>
|
||||||
</property>
|
</property>
|
||||||
</action>
|
</action>
|
||||||
|
<action name="actionAdd_ons">
|
||||||
|
<property name="text">
|
||||||
|
<string>Add-ons...</string>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
</widget>
|
</widget>
|
||||||
<resources>
|
<resources>
|
||||||
<include location="icons.qrc"/>
|
<include location="icons.qrc"/>
|
||||||
|
Loading…
Reference in New Issue
Block a user