diff --git a/aqt/addons.py b/aqt/addons.py
index fd529d7bb..665a6aea2 100644
--- a/aqt/addons.py
+++ b/aqt/addons.py
@@ -5,12 +5,13 @@ import io
import json
import re
import zipfile
+from collections import defaultdict
import markdown
from send2trash import send2trash
from aqt.qt import *
from aqt.utils import showInfo, openFolder, isWin, openLink, \
- askUser, restoreGeom, saveGeom, showWarning, tooltip
+ askUser, restoreGeom, saveGeom, showWarning, tooltip, getFile
from zipfile import ZipFile
import aqt.forms
import aqt
@@ -21,6 +22,15 @@ from anki.sync import AnkiRequestsClient
class AddonManager:
+ ext = ".ankiaddon"
+ # todo?: use jsonschema package
+ _manifest_schema = {
+ "package": {"type": str, "req": True, "meta": False},
+ "name": {"type": str, "req": True, "meta": True},
+ "mod": {"type": int, "req": False, "meta": True},
+ "conflicts": {"type": list, "req": False, "meta": True}
+ }
+
def __init__(self, mw):
self.mw = mw
self.dirty = False
@@ -89,38 +99,120 @@ When loading '%(name)s':
with open(path, "w", encoding="utf8") as f:
json.dump(meta, f)
- def toggleEnabled(self, dir):
+ def isEnabled(self, dir):
meta = self.addonMeta(dir)
- meta['disabled'] = not meta.get("disabled")
+ return not meta.get('disabled')
+
+ def toggleEnabled(self, dir, enable=None):
+ meta = self.addonMeta(dir)
+ enabled = enable if enable is not None else meta.get("disabled")
+ if enabled is True and not self._checkConflicts(dir):
+ return False
+ meta['disabled'] = not enabled
self.writeAddonMeta(dir, meta)
def addonName(self, dir):
return self.addonMeta(dir).get("name", dir)
+ # Conflict resolution
+ ######################################################################
+
+ def addonConflicts(self, dir):
+ return self.addonMeta(dir).get("conflicts", [])
+
+ def allAddonConflicts(self):
+ all_conflicts = defaultdict(list)
+ for dir in self.allAddons():
+ if not self.isEnabled(dir):
+ continue
+ conflicts = self.addonConflicts(dir)
+ for other_dir in conflicts:
+ all_conflicts[other_dir].append(dir)
+ return all_conflicts
+
+ def _checkConflicts(self, dir, name=None, conflicts=None):
+ name = name or self.addonName(dir)
+ conflicts = conflicts or self.addonConflicts(dir)
+
+ installed = self.allAddons()
+ found = [d for d in conflicts if d in installed and self.isEnabled(d)]
+ found.extend(self.allAddonConflicts().get(dir, []))
+ if not found:
+ return True
+
+ addons = "\n".join(self.addonName(f) for f in found)
+ ret = askUser(_("""\
+The following add-on(s) are incompatible with %(name)s \
+and will have to be disabled to proceed:\n\n%(found)s\n\n\
+Are you sure you want to continue?"""
+ % dict(name=name, found=addons)))
+ if not ret:
+ return False
+
+ for package in found:
+ self.toggleEnabled(package, enable=False)
+
+ return True
+
# Installing and deleting add-ons
######################################################################
- def install(self, sid, data, fname):
+ def _readManifestFile(self, zfile):
try:
- z = ZipFile(io.BytesIO(data))
+ with zfile.open("manifest.json") as f:
+ data = json.loads(f.read())
+ manifest = {} # build new manifest from recognized keys
+ for key, attrs in self._manifest_schema.items():
+ if not attrs["req"] and key not in data:
+ continue
+ val = data[key]
+ assert isinstance(val, attrs["type"])
+ manifest[key] = val
+ except (KeyError, json.decoder.JSONDecodeError, AssertionError):
+ # raised for missing manifest, invalid json, missing/invalid keys
+ return {}
+ return manifest
+
+ def install(self, file, manifest=None):
+ """Install add-on from path or file-like object. Metadata is read
+ from the manifest file by default, but this may me bypassed
+ by supplying a 'manifest' dictionary"""
+ try:
+ zfile = ZipFile(file)
except zipfile.BadZipfile:
- showWarning(_("The download was corrupt. Please try again."))
- return
+ return False, "zip"
+
+ with zfile:
+ manifest = manifest or self._readManifestFile(zfile)
+ if not manifest:
+ return False, "manifest"
+ package = manifest["package"]
+ conflicts = manifest.get("conflicts", [])
+ if not self._checkConflicts(package, manifest["name"], conflicts):
+ return False, "conflicts"
+ meta = self.addonMeta(package)
+ self._install(package, zfile)
+
+ schema = self._manifest_schema
+ manifest_meta = {k: v for k, v in manifest.items()
+ if k in schema and schema[k]["meta"]}
+ meta.update(manifest_meta)
+ self.writeAddonMeta(package, meta)
- name = os.path.splitext(fname)[0]
+ return True, meta["name"]
+ def _install(self, dir, zfile):
# previously installed?
- meta = self.addonMeta(sid)
- base = self.addonsFolder(sid)
+ base = self.addonsFolder(dir)
if os.path.exists(base):
- self.backupUserFiles(sid)
- self.deleteAddon(sid)
+ self.backupUserFiles(dir)
+ self.deleteAddon(dir)
os.mkdir(base)
- self.restoreUserFiles(sid)
+ self.restoreUserFiles(dir)
# extract
- for n in z.namelist():
+ for n in zfile.namelist():
if n.endswith("/"):
# folder; ignore
continue
@@ -129,16 +221,35 @@ When loading '%(name)s':
# skip existing user files
if os.path.exists(path) and n.startswith("user_files/"):
continue
- z.extract(n, base)
-
- # update metadata
- meta['name'] = name
- meta['mod'] = intTime()
- self.writeAddonMeta(sid, meta)
+ zfile.extract(n, base)
def deleteAddon(self, dir):
send2trash(self.addonsFolder(dir))
+ # Processing local add-on files
+ ######################################################################
+
+ def processPackages(self, paths):
+ log = []
+ errs = []
+ self.mw.progress.start(immediate=True)
+ for path in paths:
+ base = os.path.basename(path)
+ ret = self.install(path)
+ if ret[0] is False:
+ if ret[1] == "conflicts":
+ continue
+ elif ret[1] == "zip":
+ msg = _("Corrupt add-on file.")
+ elif ret[1] == "manifest":
+ msg = _("Invalid add-on manifest.")
+ errs.append(_("Error installing %(base)s: %(error)s"
+ % dict(base=base, error=msg)))
+ else:
+ log.append(_("Installed %(name)s" % dict(name=ret[1])))
+ self.mw.progress.finish()
+ return log, errs
+
# Downloading
######################################################################
@@ -153,8 +264,17 @@ When loading '%(name)s':
continue
data, fname = ret
fname = fname.replace("_", " ")
- self.install(str(n), data, fname)
name = os.path.splitext(fname)[0]
+ ret = self.install(io.BytesIO(data),
+ manifest={"package": str(n), "name": name,
+ "mod": intTime()})
+ if ret[0] is False:
+ if ret[1] == "conflicts":
+ continue
+ if ret[1] == "zip":
+ showWarning(_("The download was corrupt. Please try again."))
+ elif ret[1] == "manifest":
+ showWarning(_("Invalid add-on manifest."))
log.append(_("Downloaded %(fname)s" % dict(fname=name)))
self.mw.progress.finish()
return log, errs
@@ -296,6 +416,7 @@ class AddonsDialog(QDialog):
f = self.form = aqt.forms.addons.Ui_Dialog()
f.setupUi(self)
f.getAddons.clicked.connect(self.onGetAddons)
+ f.installFromFile.clicked.connect(self.onInstallFiles)
f.checkForUpdates.clicked.connect(self.onCheckForUpdates)
f.toggleEnabled.clicked.connect(self.onToggleEnabled)
f.viewPage.clicked.connect(self.onViewPage)
@@ -303,10 +424,29 @@ class AddonsDialog(QDialog):
f.delete_2.clicked.connect(self.onDelete)
f.config.clicked.connect(self.onConfig)
self.form.addonList.currentRowChanged.connect(self._onAddonItemSelected)
+ self.setAcceptDrops(True)
self.redrawAddons()
restoreGeom(self, "addons")
self.show()
+ def dragEnterEvent(self, event):
+ mime = event.mimeData()
+ if not mime.hasUrls():
+ return None
+ urls = mime.urls()
+ ext = self.mgr.ext
+ if all(url.toLocalFile().endswith(ext) for url in urls):
+ event.acceptProposedAction()
+
+ def dropEvent(self, event):
+ mime = event.mimeData()
+ paths = []
+ for url in mime.urls():
+ path = url.toLocalFile()
+ if os.path.exists(path):
+ paths.append(path)
+ self.onInstallFiles(paths)
+
def reject(self):
saveGeom(self, "addons")
return QDialog.reject(self)
@@ -390,6 +530,24 @@ class AddonsDialog(QDialog):
def onGetAddons(self):
GetAddons(self)
+ def onInstallFiles(self, paths=None):
+ if not paths:
+ key = (_("Packaged Anki Add-on") + " (*{})".format(self.mgr.ext))
+ paths = getFile(self, _("Install Add-on(s)"), None, key,
+ key="addons", multi=True)
+ if not paths:
+ return False
+
+ log, errs = self.mgr.processPackages(paths)
+
+ if log:
+ tooltip("
".join(log), parent=self)
+ if errs:
+ msg = _("Please report this to the respective add-on author(s).")
+ showWarning("
".join(errs + [msg]), parent=self)
+
+ self.redrawAddons()
+
def onCheckForUpdates(self):
updated = self.mgr.checkForUpdates()
if not updated:
@@ -400,9 +558,9 @@ class AddonsDialog(QDialog):
"\n" + "\n".join(names)):
log, errs = self.mgr.downloadIds(updated)
if log:
- tooltip("\n".join(log), parent=self)
+ tooltip("
".join(log), parent=self)
if errs:
- showWarning("\n".join(errs), parent=self)
+ showWarning("
".join(errs), parent=self)
self.redrawAddons()
@@ -458,9 +616,9 @@ class GetAddons(QDialog):
log, errs = self.mgr.downloadIds(ids)
if log:
- tooltip("\n".join(log), parent=self.addonsDlg)
+ tooltip("
".join(log), parent=self.addonsDlg)
if errs:
- showWarning("\n".join(errs))
+ showWarning("
".join(errs))
self.addonsDlg.redrawAddons()
QDialog.accept(self)
diff --git a/aqt/utils.py b/aqt/utils.py
index 0e6168417..ee74071f7 100644
--- a/aqt/utils.py
+++ b/aqt/utils.py
@@ -245,7 +245,7 @@ def getTag(parent, deck, question, tags="user", **kwargs):
# File handling
######################################################################
-def getFile(parent, title, cb, filter="*.*", dir=None, key=None):
+def getFile(parent, title, cb, filter="*.*", dir=None, key=None, multi=False):
"Ask the user for a file."
assert not dir or not key
if not dir:
@@ -254,20 +254,22 @@ def getFile(parent, title, cb, filter="*.*", dir=None, key=None):
else:
dirkey = None
d = QFileDialog(parent)
- d.setFileMode(QFileDialog.ExistingFile)
+ mode = QFileDialog.ExistingFiles if multi else QFileDialog.ExistingFile
+ d.setFileMode(mode)
if os.path.exists(dir):
d.setDirectory(dir)
d.setWindowTitle(title)
d.setNameFilter(filter)
ret = []
def accept():
- file = str(list(d.selectedFiles())[0])
+ files = list(d.selectedFiles())
if dirkey:
- dir = os.path.dirname(file)
+ dir = os.path.dirname(files[0])
aqt.mw.pm.profile[dirkey] = dir
+ result = files if multi else files[0]
if cb:
- cb(file)
- ret.append(file)
+ cb(result)
+ ret.append(result)
d.accepted.connect(accept)
if key:
restoreState(d, key)
diff --git a/designer/addons.ui b/designer/addons.ui
index eb7144608..0c620349c 100644
--- a/designer/addons.ui
+++ b/designer/addons.ui
@@ -47,6 +47,13 @@
+ -
+
+
+ Install from file...
+
+
+
-