From 85955722c745ee11a21fa8c79a524c5cea3bbc89 Mon Sep 17 00:00:00 2001 From: Glutanimate Date: Mon, 18 Feb 2019 07:10:43 +0100 Subject: [PATCH 1/7] Extend getFile with support for multi-file selection --- aqt/utils.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/aqt/utils.py b/aqt/utils.py index 1221ce8b7..fdfafb5ab 100644 --- a/aqt/utils.py +++ b/aqt/utils.py @@ -239,7 +239,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: @@ -248,20 +248,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) From 4a21c2013ff81869fb523265b15c01a6bbd92c2a Mon Sep 17 00:00:00 2001 From: Glutanimate Date: Mon, 18 Feb 2019 07:17:14 +0100 Subject: [PATCH 2/7] Ability to install add-ons from local add-on packages Adds a new button to the add-on dialog that allows users to select and install add-ons from local files. Introduces APKX, a zip-based and manifest-backed filetype for Anki add-on packages. --- aqt/addons.py | 93 ++++++++++++++++++++++++++++++++++++++-------- designer/addons.ui | 7 ++++ 2 files changed, 85 insertions(+), 15 deletions(-) diff --git a/aqt/addons.py b/aqt/addons.py index 5a3868245..2f8bf51ef 100644 --- a/aqt/addons.py +++ b/aqt/addons.py @@ -10,7 +10,7 @@ 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 @@ -100,27 +100,52 @@ When loading '%(name)s': # Installing and deleting add-ons ###################################################################### - def install(self, sid, data, fname): + def installDownload(self, sid, data, name): try: - z = ZipFile(io.BytesIO(data)) + zfile = ZipFile(io.BytesIO(data)) except zipfile.BadZipfile: showWarning(_("The download was corrupt. Please try again.")) - return + return False + + mod = intTime() + + self.install(sid, zfile, name, mod) - name = os.path.splitext(fname)[0] + def installLocal(self, path): + try: + zfile = ZipFile(path) + except zipfile.BadZipfile: + return False, _("Corrupt add-on archive.") + + try: + with zfile.open("manifest.json") as f: + info = json.loads(f.read()) + package = info["package"] + name = info["name"] + mod = info.get("mod", None) + assert isinstance(package, str) and isinstance(name, str) + assert isinstance(mod, int) or mod is None + except (KeyError, json.decoder.JSONDecodeError, AssertionError): + # raised for missing manifest, invalid json, missing/invalid keys + return False, _("Invalid add-on manifest.") + + self.install(package, zfile, name, mod) + return name, None + + def install(self, dir, zfile, name, mod): # previously installed? - meta = self.addonMeta(sid) - base = self.addonsFolder(sid) + meta = self.addonMeta(dir) + 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 +154,35 @@ When loading '%(name)s': # skip existing user files if os.path.exists(path) and n.startswith("user_files/"): continue - z.extract(n, base) + zfile.extract(n, base) # update metadata meta['name'] = name - meta['mod'] = intTime() - self.writeAddonMeta(sid, meta) + if mod is not None: # allow packages to skip updating mod + meta['mod'] = mod + self.writeAddonMeta(dir, meta) def deleteAddon(self, dir): send2trash(self.addonsFolder(dir)) + # Processing local add-on files + ###################################################################### + + def processAPKX(self, paths): + log = [] + errs = [] + self.mw.progress.start(immediate=True) + for path in paths: + base = os.path.basename(path) + name, error = self.installLocal(path) + if error: + errs.append(_("Error installing %(base)s: %(error)s" + % dict(base=base, error=error))) + else: + log.append(_("Installed %(name)s" % dict(name=name))) + self.mw.progress.finish() + return log, errs + # Downloading ###################################################################### @@ -153,8 +197,8 @@ When loading '%(name)s': continue data, fname = ret fname = fname.replace("_", " ") - self.install(str(n), data, fname) name = os.path.splitext(fname)[0] + self.installDownload(str(n), data, name) log.append(_("Downloaded %(fname)s" % dict(fname=name))) self.mw.progress.finish() return log, errs @@ -296,6 +340,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) @@ -385,6 +430,24 @@ class AddonsDialog(QDialog): def onGetAddons(self): GetAddons(self) + def onInstallFiles(self, paths=None): + if not paths: + key = (_("Packaged Anki Add-on") + " (*.apkx)") + paths = getFile(self, _("Install Add-on(s)"), None, key, + key="addons", multi=True) + if not paths: + return False + + log, errs = self.mgr.processAPKX(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: 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... + + + From cc0dc812ad7f1dbc31637fda3d072aaad6ecb02e Mon Sep 17 00:00:00 2001 From: Glutanimate Date: Mon, 18 Feb 2019 07:17:53 +0100 Subject: [PATCH 3/7] Implement drag-and-drop support for installing APKX-packaged add-ons --- aqt/addons.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/aqt/addons.py b/aqt/addons.py index 2f8bf51ef..f44519b6b 100644 --- a/aqt/addons.py +++ b/aqt/addons.py @@ -348,9 +348,27 @@ 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() self.show() + def dragEnterEvent(self, event): + mime = event.mimeData() + if not mime.hasUrls(): + return None + urls = mime.urls() + if all(url.toLocalFile().endswith(".apkx") 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 redrawAddons(self): self.addons = [(self.annotatedName(d), d) for d in self.mgr.allAddons()] self.addons.sort() From 280da9aee4a6153b1a829b907b806d80026fca99 Mon Sep 17 00:00:00 2001 From: Glutanimate Date: Mon, 18 Feb 2019 07:18:14 +0100 Subject: [PATCH 4/7] Fix linebreaks in add-on installation tooltips and warnings --- aqt/addons.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/aqt/addons.py b/aqt/addons.py index f44519b6b..10ead2704 100644 --- a/aqt/addons.py +++ b/aqt/addons.py @@ -476,9 +476,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() @@ -534,9 +534,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) From 2ed61c9c99abaab32304e078e08acce71158b36a Mon Sep 17 00:00:00 2001 From: Glutanimate Date: Fri, 22 Feb 2019 10:17:56 +0100 Subject: [PATCH 5/7] Rename .apkx to .ankiaddon --- aqt/addons.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/aqt/addons.py b/aqt/addons.py index 10ead2704..52f5a633f 100644 --- a/aqt/addons.py +++ b/aqt/addons.py @@ -21,6 +21,8 @@ from anki.sync import AnkiRequestsClient class AddonManager: + ext = ".ankiaddon" + def __init__(self, mw): self.mw = mw self.dirty = False @@ -168,7 +170,7 @@ When loading '%(name)s': # Processing local add-on files ###################################################################### - def processAPKX(self, paths): + def processPackages(self, paths): log = [] errs = [] self.mw.progress.start(immediate=True) @@ -357,7 +359,8 @@ class AddonsDialog(QDialog): if not mime.hasUrls(): return None urls = mime.urls() - if all(url.toLocalFile().endswith(".apkx") for url in urls): + ext = self.mgr.ext + if all(url.toLocalFile().endswith(ext) for url in urls): event.acceptProposedAction() def dropEvent(self, event): @@ -450,13 +453,13 @@ class AddonsDialog(QDialog): def onInstallFiles(self, paths=None): if not paths: - key = (_("Packaged Anki Add-on") + " (*.apkx)") + 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.processAPKX(paths) + log, errs = self.mgr.processPackages(paths) if log: tooltip("
".join(log), parent=self) From 8fceccf4b7614c20a7411bbaf9dfdfed6434ef87 Mon Sep 17 00:00:00 2001 From: Glutanimate Date: Fri, 22 Feb 2019 17:04:07 +0100 Subject: [PATCH 6/7] Refactor: Add manifest schema, unify install paths, use context manager Sets the foundation for more elaborate additions to the manifest. Manifest files are still only being read for local imports, but with this commit that could be easily changed in the future. --- aqt/addons.py | 96 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 57 insertions(+), 39 deletions(-) diff --git a/aqt/addons.py b/aqt/addons.py index 52f5a633f..16fa2614c 100644 --- a/aqt/addons.py +++ b/aqt/addons.py @@ -22,6 +22,12 @@ 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} + } def __init__(self, mw): self.mw = mw @@ -102,42 +108,49 @@ When loading '%(name)s': # Installing and deleting add-ons ###################################################################### - def installDownload(self, sid, data, name): - try: - zfile = ZipFile(io.BytesIO(data)) - except zipfile.BadZipfile: - showWarning(_("The download was corrupt. Please try again.")) - return False - - mod = intTime() - - self.install(sid, zfile, name, mod) - - def installLocal(self, path): - try: - zfile = ZipFile(path) - except zipfile.BadZipfile: - return False, _("Corrupt add-on archive.") - + def _readManifestFile(self, zfile): try: with zfile.open("manifest.json") as f: - info = json.loads(f.read()) - package = info["package"] - name = info["name"] - mod = info.get("mod", None) - assert isinstance(package, str) and isinstance(name, str) - assert isinstance(mod, int) or mod is None + 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 False, _("Invalid add-on manifest.") + 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: + return False, "zip" - self.install(package, zfile, name, mod) + with zfile: + manifest = manifest or self._readManifestFile(zfile) + if not manifest: + return False, "manifest" + package = manifest["package"] + 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) - return name, None + return True, meta["name"] - def install(self, dir, zfile, name, mod): + def _install(self, dir, zfile): # previously installed? - meta = self.addonMeta(dir) base = self.addonsFolder(dir) if os.path.exists(base): self.backupUserFiles(dir) @@ -158,12 +171,6 @@ When loading '%(name)s': continue zfile.extract(n, base) - # update metadata - meta['name'] = name - if mod is not None: # allow packages to skip updating mod - meta['mod'] = mod - self.writeAddonMeta(dir, meta) - def deleteAddon(self, dir): send2trash(self.addonsFolder(dir)) @@ -176,12 +183,16 @@ When loading '%(name)s': self.mw.progress.start(immediate=True) for path in paths: base = os.path.basename(path) - name, error = self.installLocal(path) - if error: + ret = self.install(path) + if ret[0] is False: + if 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=error))) + % dict(base=base, error=msg))) else: - log.append(_("Installed %(name)s" % dict(name=name))) + log.append(_("Installed %(name)s" % dict(name=ret[1]))) self.mw.progress.finish() return log, errs @@ -200,7 +211,14 @@ When loading '%(name)s': data, fname = ret fname = fname.replace("_", " ") name = os.path.splitext(fname)[0] - self.installDownload(str(n), data, name) + ret = self.install(io.BytesIO(data), + manifest={"package": str(n), "name": name, + "mod": intTime()}) + if ret[0] is False: + 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 From 512be4fc2c7474daec04240b7e3e838075525e2c Mon Sep 17 00:00:00 2001 From: Glutanimate Date: Fri, 22 Feb 2019 21:14:42 +0100 Subject: [PATCH 7/7] Implement add-on conflict handling via manifests "conflicts" value Only enabled for locally installed packages for now --- aqt/addons.py | 64 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/aqt/addons.py b/aqt/addons.py index 16fa2614c..a2b03a7ff 100644 --- a/aqt/addons.py +++ b/aqt/addons.py @@ -5,6 +5,7 @@ import io import json import re import zipfile +from collections import defaultdict import markdown from send2trash import send2trash @@ -26,7 +27,8 @@ class AddonManager: _manifest_schema = { "package": {"type": str, "req": True, "meta": False}, "name": {"type": str, "req": True, "meta": True}, - "mod": {"type": int, "req": False, "meta": True} + "mod": {"type": int, "req": False, "meta": True}, + "conflicts": {"type": list, "req": False, "meta": True} } def __init__(self, mw): @@ -97,14 +99,61 @@ 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 ###################################################################### @@ -138,6 +187,9 @@ When loading '%(name)s': 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) @@ -185,7 +237,9 @@ When loading '%(name)s': base = os.path.basename(path) ret = self.install(path) if ret[0] is False: - if ret[1] == "zip": + if ret[1] == "conflicts": + continue + elif ret[1] == "zip": msg = _("Corrupt add-on file.") elif ret[1] == "manifest": msg = _("Invalid add-on manifest.") @@ -215,6 +269,8 @@ When loading '%(name)s': 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":