From 7288a9b0634ea47fce00e22e0e3ce5ed29198bd4 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sat, 26 Aug 2017 15:14:20 +1000 Subject: [PATCH] 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 --- README.addons | 98 ------------ README.contributing | 6 +- aqt/addons.py | 379 ++++++++++++++++++++++++++++++-------------- aqt/downloader.py | 6 +- aqt/errors.py | 19 +-- designer/addons.ui | 104 ++++++++++++ designer/main.ui | 15 +- 7 files changed, 388 insertions(+), 239 deletions(-) delete mode 100644 README.addons create mode 100644 designer/addons.ui diff --git a/README.addons b/README.addons deleted file mode 100644 index 7ea921e18..000000000 --- a/README.addons +++ /dev/null @@ -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. diff --git a/README.contributing b/README.contributing index bc4d4f95f..da3110400 100644 --- a/README.contributing +++ b/README.contributing @@ -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 diff --git a/aqt/addons.py b/aqt/addons.py index 8db91811b..b37582b93 100644 --- a/aqt/addons.py +++ b/aqt/addons.py @@ -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(): + for dir in self.allAddons(): + meta = self.addonMeta(dir) + if meta.get("disabled"): + continue + self.dirty = True try: - __import__(file.replace(".py", "")) + __import__(dir) except: - traceback.print_exc() - for directory in self.directories(): - try: - __import__(directory) - except: - traceback.print_exc() - self.rebuildAddonsMenu() + 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. - # 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): - if path is None: - path = self.addonsFolder() - openFolder(path) + def _addonMetaPath(self, dir): + return os.path.join(self.addonsFolder(dir), "meta.json") - 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 addonMeta(self, dir): + path = self._addonMetaPath(dir) + try: + return json.load(open(path, encoding="utf8")) + except: + return dict() - 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 writeAddonMeta(self, dir, meta): + path = self._addonMetaPath(dir) + json.dump(meta, open(path, "w", encoding="utf8")) - def onAcceptEdit(self, path, frm): - open(path, "wb").write(frm.text.toPlainText().encode("utf8")) - showInfo(_("Edits saved. Please restart Anki.")) + def toggleEnabled(self, dir): + meta = self.addonMeta(dir) + meta['disabled'] = not meta.get("disabled") + self.writeAddonMeta(dir, meta) - def onRem(self, path): - if not askUser(_("Delete %s?") % os.path.basename(path)): - return - os.unlink(path) - self.rebuildAddonsMenu() - showInfo(_("Deleted. Please restart Anki.")) + def addonName(self, dir): + return self.addonMeta(dir).get("name", dir) - # Tools + # Installing and deleting add-ons ###################################################################### - 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"): - 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 + 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) diff --git a/aqt/downloader.py b/aqt/downloader.py index 3eae5c263..a88c55601 100644 --- a/aqt/downloader.py +++ b/aqt/downloader.py @@ -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) diff --git a/aqt/errors.py b/aqt/errors.py index b32369c30..d04dbed04 100644 --- a/aqt/errors.py +++ b/aqt/errors.py @@ -105,16 +105,17 @@ or your deck may have a problem.

If that doesn't fix the problem, please copy the following
into a bug report:""") pluginText = _("""\ -An error occurred in an add-on.

-Please start Anki while holding down the shift key, and see if
-the error goes away.

-If the error goes away, please report the issue on the add-on
-forum: %s -

If the error occurs even with add-ons disabled, please
-report the issue on our support site. +

An error occurred. Please start Anki while holding down the shift \ +key, which will temporarily disable the add-ons you have installed.

+ +

If the problem occurs even with add-ons disabled, please report the \ +issue on our support site.

+ +

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.

""") - pluginText %= "https://anki.tenderapp.com/discussions/add-ons" - if "addon" in error: + if self.mw.addonManager.dirty: txt = pluginText else: txt = stdText diff --git a/designer/addons.ui b/designer/addons.ui new file mode 100644 index 000000000..b962c7bb9 --- /dev/null +++ b/designer/addons.ui @@ -0,0 +1,104 @@ + + + Dialog + + + Qt::ApplicationModal + + + + 0 + 0 + 577 + 379 + + + + Add-ons + + + true + + + + + + + + Changes will take effect when Anki is restarted. + + + + + + + QAbstractItemView::ContiguousSelection + + + + + + + + + + + Get Add-ons... + + + + + + + Check for Updates + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + View Add-on Page + + + + + + + View Files + + + + + + + Toggle Enabled + + + + + + + Delete + + + + + + + + + + diff --git a/designer/main.ui b/designer/main.ui index 22beeba9c..1588875a2 100644 --- a/designer/main.ui +++ b/designer/main.ui @@ -79,14 +79,6 @@ &Tools - - - &Add-ons - - - - - @@ -94,7 +86,7 @@ - + @@ -237,6 +229,11 @@ Ctrl+Shift+N + + + Add-ons... + +