anki/aqt/addons.py
Damien Elmes 7288a9b063 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
2017-08-26 15:14:20 +10:00

335 lines
10 KiB
Python

# Copyright: Damien Elmes <anki@ichi2.net>
# -*- coding: utf-8 -*-
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import io
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
from zipfile import ZipFile
import aqt.forms
import aqt
from aqt.downloader import download
from anki.lang import _
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.actionAdd_ons.triggered.connect(self.onAddonsDialog)
sys.path.insert(0, self.addonsFolder())
if not self.mw.safeMode:
self.loadAddons()
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 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 dir in self.allAddons():
meta = self.addonMeta(dir)
if meta.get("disabled"):
continue
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.
When loading '%(name)s':
%(traceback)s
""") % dict(name=meta.get("name", dir), traceback=traceback.format_exc()))
def onAddonsDialog(self):
AddonsDialog(self)
# Metadata
######################################################################
def _addonMetaPath(self, dir):
return os.path.join(self.addonsFolder(dir), "meta.json")
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
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
continue
# 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, 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(
_("Browse"), QDialogButtonBox.ActionRole)
b.clicked.connect(self.onBrowse)
restoreGeom(self, "getaddons", adjustSize=True)
self.exec_()
saveGeom(self, "getaddons")
def onBrowse(self):
openLink(aqt.appShared + "addons/?v=2.1")
def accept(self):
# get codes
try:
ids = [int(n) for n in self.form.code.text().split()]
except ValueError:
showWarning(_("Invalid code."))
return
log, errs = self.mgr.downloadIds(ids)
if log:
tooltip("\n".join(log), parent=self.addonsDlg)
if errs:
showWarning("\n".join(errs))
self.addonsDlg.redrawAddons()
QDialog.accept(self)