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:
Damien Elmes 2017-08-26 15:14:20 +10:00
parent f824db2143
commit 7288a9b063
7 changed files with 388 additions and 239 deletions

View File

@ -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.

View File

@ -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
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
dropping support for Qt 4, so we need to keep changes to a minimum in order to
get an update out faster.
while maintaining compatibility with Anki 2.0.x. Some users will be stuck on
Anki 2.0 for a while due to unported add-ons or old hardware, so it's
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
clients, which try their best to keep as close to the Anki code as possible so

View File

@ -2,9 +2,11 @@
# -*- coding: utf-8 -*-
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import io
import sys, os, traceback
from io import StringIO
import json
import re
import zipfile
from send2trash import send2trash
from aqt.qt import *
from aqt.utils import showInfo, openFolder, isWin, openLink, \
askUser, restoreGeom, saveGeom, showWarning, tooltip
@ -13,131 +15,103 @@ import aqt.forms
import aqt
from aqt.downloader import download
from anki.lang import _
# in the future, it would be nice to save the addon id and unzippped file list
# to the config so that we can clear up all files and check for updates
from anki.utils import intTime
from anki.sync import AnkiRequestsClient
class AddonManager:
def __init__(self, mw):
self.mw = mw
self.dirty = False
f = self.mw.form
f.actionOpenPluginFolder.triggered.connect(self.onOpenAddonFolder)
f.actionDownloadSharedPlugin.triggered.connect(self.onGetAddons)
self._menus = []
if isWin:
self.clearAddonCache()
f.actionAdd_ons.triggered.connect(self.onAddonsDialog)
sys.path.insert(0, self.addonsFolder())
if not self.mw.safeMode:
self.loadAddons()
def files(self):
return [f for f in os.listdir(self.addonsFolder())
if f.endswith(".py")]
def allAddons(self):
l = []
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):
return [d for d in os.listdir(self.addonsFolder())
if not d.startswith('.') and
not d == "__pycache__" and
os.path.isdir(os.path.join(self.addonsFolder(), d))]
def managedAddons(self):
return [d for d in self.allAddons()
if re.match("^\d+$", 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):
for file in self.files():
try:
__import__(file.replace(".py", ""))
except:
traceback.print_exc()
for directory in self.directories():
try:
__import__(directory)
except:
traceback.print_exc()
self.rebuildAddonsMenu()
# Menus
######################################################################
def onOpenAddonFolder(self, checked, path=None):
if path is None:
path = self.addonsFolder()
openFolder(path)
def rebuildAddonsMenu(self):
for m in self._menus:
self.mw.form.menuPlugins.removeAction(m.menuAction())
for file in self.files():
m = self.mw.form.menuPlugins.addMenu(
os.path.splitext(file)[0])
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):
d = QDialog(self.mw)
frm = aqt.forms.editaddon.Ui_Dialog()
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):
open(path, "wb").write(frm.text.toPlainText().encode("utf8"))
showInfo(_("Edits saved. Please restart Anki."))
def onRem(self, path):
if not askUser(_("Delete %s?") % os.path.basename(path)):
return
os.unlink(path)
self.rebuildAddonsMenu()
showInfo(_("Deleted. Please restart Anki."))
# Tools
######################################################################
def addonsFolder(self):
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"):
for dir in self.allAddons():
meta = self.addonMeta(dir)
if meta.get("disabled"):
continue
os.unlink(os.path.join(curdir, f))
self.dirty = True
try:
__import__(dir)
except:
showWarning(_("""\
An add-on you installed failed to load. If problems persist, please \
go to the Tools>Add-ons menu, and disable or delete the add-on.
def registerAddon(self, name, updateId):
# not currently used
return
When loading '%(name)s':
%(traceback)s
""") % dict(name=meta.get("name", dir), traceback=traceback.format_exc()))
# Installing add-ons
def onAddonsDialog(self):
AddonsDialog(self)
# Metadata
######################################################################
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 _addonMetaPath(self, dir):
return os.path.join(self.addonsFolder(dir), "meta.json")
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
def addonMeta(self, dir):
path = self._addonMetaPath(dir)
try:
return json.load(open(path, encoding="utf8"))
except:
return dict()
def writeAddonMeta(self, dir, meta):
path = self._addonMetaPath(dir)
json.dump(meta, open(path, "w", encoding="utf8"))
def toggleEnabled(self, dir):
meta = self.addonMeta(dir)
meta['disabled'] = not meta.get("disabled")
self.writeAddonMeta(dir, meta)
def addonName(self, dir):
return self.addonMeta(dir).get("name", dir)
# Installing and deleting add-ons
######################################################################
def install(self, sid, data, fname):
try:
z = ZipFile(io.BytesIO(data))
except zipfile.BadZipfile:
showWarning(_("The download was corrupt. Please try again."))
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():
if n.endswith("/"):
# folder; ignore
@ -145,11 +119,190 @@ download add-ons that say they support Anki 2.1.x in the description.""")
# write
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):
def __init__(self, mw):
QDialog.__init__(self, mw)
self.mw = mw
def __init__(self, dlg):
QDialog.__init__(self, dlg)
self.addonsDlg = dlg
self.mgr = dlg.mgr
self.mw = self.mgr.mw
self.form = aqt.forms.getaddons.Ui_Dialog()
self.form.setupUi(self)
b = self.form.buttonBox.addButton(
@ -160,11 +313,9 @@ class GetAddons(QDialog):
saveGeom(self, "getaddons")
def onBrowse(self):
openLink(aqt.appShared + "addons/")
openLink(aqt.appShared + "addons/?v=2.1")
def accept(self):
QDialog.accept(self)
# get codes
try:
ids = [int(n) for n in self.form.code.text().split()]
@ -172,18 +323,12 @@ class GetAddons(QDialog):
showWarning(_("Invalid code."))
return
errors = []
log, errs = self.mgr.downloadIds(ids)
self.mw.progress.start(immediate=True)
for n in ids:
ret = download(self.mw, n)
if ret[0] == "error":
errors.append(_("Error downloading %(id)s: %(error)s") % dict(id=n, error=ret[1]))
continue
data, fname = ret
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))
if log:
tooltip("\n".join(log), parent=self.addonsDlg)
if errs:
showWarning("\n".join(errs))
self.addonsDlg.redrawAddons()
QDialog.accept(self)

View File

@ -10,7 +10,7 @@ from anki.hooks import addHook, remHook
import aqt
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
thread = Downloader(code)
def onRecv():
@ -53,11 +53,11 @@ class Downloader(QThread):
client = AnkiRequestsClient()
try:
resp = client.get(
aqt.appShared + "download/%d" % self.code)
aqt.appShared + "download/%s?v=2.1" % self.code)
if resp.status_code == 200:
data = client.streamContent(resp)
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
else:
self.error = _("Error downloading: %s" % resp.status_code)

View File

@ -105,16 +105,17 @@ or your deck may have a problem.
<p>If that doesn't fix the problem, please copy the following<br>
into a bug report:""")
pluginText = _("""\
An error occurred in an add-on.<br><br>
Please start Anki while holding down the shift key, and see if<br>
the error goes away.<br><br>
If the error goes away, please report the issue on the add-on<br>
forum: %s
<br><br>If the error occurs even with add-ons disabled, please<br>
report the issue on our support site.
<p>An error occurred. Please start Anki while holding down the shift \
key, which will temporarily disable the add-ons you have installed.</p>
<p>If the problem occurs even with add-ons disabled, please report the \
issue on our support site.</p>
<p>If the issue only occurs when add-ons are enabled, plesae use the \
Tools&gt;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 "addon" in error:
if self.mw.addonManager.dirty:
txt = pluginText
else:
txt = stdText

104
designer/addons.ui Normal file
View 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>

View File

@ -79,14 +79,6 @@
<property name="title">
<string>&amp;Tools</string>
</property>
<widget class="QMenu" name="menuPlugins">
<property name="title">
<string>&amp;Add-ons</string>
</property>
<addaction name="actionDownloadSharedPlugin"/>
<addaction name="actionOpenPluginFolder"/>
<addaction name="separator"/>
</widget>
<addaction name="actionStudyDeck"/>
<addaction name="actionCreateFiltered"/>
<addaction name="separator"/>
@ -94,7 +86,7 @@
<addaction name="actionCheckMediaDatabase"/>
<addaction name="actionEmptyCards"/>
<addaction name="separator"/>
<addaction name="menuPlugins"/>
<addaction name="actionAdd_ons"/>
<addaction name="separator"/>
<addaction name="actionNoteTypes"/>
<addaction name="actionPreferences"/>
@ -237,6 +229,11 @@
<string>Ctrl+Shift+N</string>
</property>
</action>
<action name="actionAdd_ons">
<property name="text">
<string>Add-ons...</string>
</property>
</action>
</widget>
<resources>
<include location="icons.qrc"/>