diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 78417243b..a08766288 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -20,6 +20,7 @@ support site, it would be great if you could add your name below as well. Erez Volk Aristotelis P. +AMBOSS MD Inc. ******************** diff --git a/qt/anki.desktop b/qt/anki.desktop index fb4ac4f83..374a4b5df 100644 --- a/qt/anki.desktop +++ b/qt/anki.desktop @@ -9,4 +9,4 @@ Categories=Education;Languages;KDE;Qt; Terminal=false Type=Application Version=1.0 -MimeType=application/x-apkg;application/x-anki; +MimeType=application/x-apkg;application/x-anki;application/x-ankiaddon; diff --git a/qt/anki.xml b/qt/anki.xml index 09fdc9c69..5bfd2ca0f 100644 --- a/qt/anki.xml +++ b/qt/anki.xml @@ -11,4 +11,9 @@ + + Anki 2.1 add-on package + + + diff --git a/qt/aqt/__init__.py b/qt/aqt/__init__.py index 83d69e3a3..1400a6b71 100644 --- a/qt/aqt/__init__.py +++ b/qt/aqt/__init__.py @@ -261,7 +261,7 @@ def parseArgs(argv): if isMac and len(argv) > 1 and argv[1].startswith("-psn"): argv = [argv[0]] parser = argparse.ArgumentParser(description="Anki " + appVersion) - parser.usage = "%(prog)s [OPTIONS] [file to import]" + parser.usage = "%(prog)s [OPTIONS] [file to import/add-on to install]" parser.add_argument("-b", "--base", help="path to base folder", default="") parser.add_argument("-p", "--profile", help="profile name to load", default="") parser.add_argument("-l", "--lang", help="interface language (en, de, etc)") diff --git a/qt/aqt/about.py b/qt/aqt/about.py index 41e180725..53952e311 100644 --- a/qt/aqt/about.py +++ b/qt/aqt/about.py @@ -149,6 +149,7 @@ system. It's free and open source." "Arman High", "Arthur Milchior", "Rai (Michael Pokorny)", + "AMBOSS MD Inc.", ) ) diff --git a/qt/aqt/addons.py b/qt/aqt/addons.py index 72d949a44..1bac9266b 100644 --- a/qt/aqt/addons.py +++ b/qt/aqt/addons.py @@ -3,10 +3,11 @@ # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import io import json +import os import re import zipfile from collections import defaultdict -from typing import Any, Callable, Dict, Optional +from typing import IO, Any, Callable, Dict, List, NamedTuple, Optional, Tuple, Union from zipfile import ZipFile import jsonschema @@ -37,10 +38,17 @@ from aqt.utils import ( ) +class AddonInstallationResult(NamedTuple): + success: bool + errmsg: Optional[str] = None + name: Optional[str] = None + conflicts: Optional[List[str]] = None + + class AddonManager: - ext = ".ankiaddon" - _manifest_schema = { + ext: str = ".ankiaddon" + _manifest_schema: dict = { "type": "object", "properties": { "package": {"type": "string", "meta": False}, @@ -201,14 +209,16 @@ and have been disabled: %(found)s" return {} return manifest - def install(self, file, manifest=None): + def install( + self, file: Union[IO, str], manifest: dict = None + ) -> AddonInstallationResult: """Install add-on from path or file-like object. Metadata is read from the manifest file, with keys overriden by supplying a 'manifest' dictionary""" try: zfile = ZipFile(file) except zipfile.BadZipfile: - return False, "zip" + return AddonInstallationResult(success=False, errmsg="zip") with zfile: file_manifest = self.readManifestFile(zfile) @@ -216,7 +226,7 @@ and have been disabled: %(found)s" file_manifest.update(manifest) manifest = file_manifest if not manifest: - return False, "manifest" + return AddonInstallationResult(success=False, errmsg="manifest") package = manifest["package"] conflicts = manifest.get("conflicts", []) found_conflicts = self._disableConflicting(package, conflicts) @@ -230,7 +240,9 @@ and have been disabled: %(found)s" meta.update(manifest_meta) self.writeAddonMeta(package, meta) - return True, meta["name"], found_conflicts + return AddonInstallationResult( + success=True, name=meta["name"], conflicts=found_conflicts + ) def _install(self, dir, zfile): # previously installed? @@ -274,40 +286,33 @@ and have been disabled: %(found)s" # Processing local add-on files ###################################################################### - def processPackages(self, paths): + def processPackages( + self, paths: List[str], parent: QWidget = None + ) -> Tuple[List[str], List[str]]: + log = [] errs = [] - self.mw.progress.start(immediate=True) + + self.mw.progress.start(immediate=True, parent=parent) try: for path in paths: base = os.path.basename(path) - 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.") - else: - msg = "Unknown error: {}".format(ret[1]) - errs.append( - _( - "Error installing %(base)s: %(error)s" - % dict(base=base, error=msg) - ) + result = self.install(path) + + if not result.success: + errs.extend( + self._installationErrorReport(result, base, mode="local") ) else: - log.append(_("Installed %(name)s" % dict(name=ret[1]))) - if ret[2]: - log.append( - _("The following conflicting add-ons were disabled:") - + " " - + " ".join(ret[2]) - ) + log.extend( + self._installationSuccessReport(result, base, mode="local") + ) finally: self.mw.progress.finish() + return log, errs - # Downloading + # Downloading add-ons from AnkiWeb ###################################################################### def downloadIds(self, ids): @@ -324,32 +329,67 @@ and have been disabled: %(found)s" data, fname = ret fname = fname.replace("_", " ") name = os.path.splitext(fname)[0] - ret = self.install( + result = self.install( io.BytesIO(data), manifest={"package": str(n), "name": name, "mod": intTime()}, ) - if ret[0] is False: - if ret[1] == "zip": - msg = _("Corrupt add-on file.") - elif ret[1] == "manifest": - msg = _("Invalid add-on manifest.") - else: - msg = "Unknown error: {}".format(ret[1]) - errs.append( - _("Error downloading %(id)s: %(error)s") % dict(id=n, error=msg) - ) + if not result.success: + errs.extend(self._installationErrorReport(result, n)) else: - log.append(_("Downloaded %(fname)s" % dict(fname=name))) - if ret[2]: - log.append( - _("The following conflicting add-ons were disabled:") - + " " - + " ".join(ret[2]) - ) + log.extend(self._installationSuccessReport(result, n)) self.mw.progress.finish() return log, errs + # Installation messaging + ###################################################################### + + def _installationErrorReport( + self, result: AddonInstallationResult, base: str, mode="download" + ) -> List[str]: + + messages = { + "zip": _("Corrupt add-on file."), + "manifest": _("Invalid add-on manifest."), + } + + if result.errmsg: + msg = messages.get( + result.errmsg, _("Unknown error: {}".format(result.errmsg)) + ) + else: + msg = _("Unknown error") + + if mode == "download": # preserve old format strings for i18n + template = _("Error downloading %(id)s: %(error)s") + else: + template = _("Error installing %(base)s: %(error)s") + + name = result.name or base + + return [template % dict(base=name, id=name, error=msg)] + + def _installationSuccessReport( + self, result: AddonInstallationResult, base: str, mode="download" + ) -> List[str]: + + if mode == "download": # preserve old format strings for i18n + template = _("Downloaded %(fname)s") + else: + template = _("Installed %(name)s") + + name = result.name or base + strings = [template % dict(name=name, fname=name)] + + if result.conflicts: + strings.append( + _("The following conflicting add-ons were disabled:") + + " " + + " ".join(result.conflicts) + ) + + return strings + # Updating ###################################################################### @@ -626,7 +666,7 @@ class AddonsDialog(QDialog): def onGetAddons(self): GetAddons(self) - def onInstallFiles(self, paths=None): + def onInstallFiles(self, paths: Optional[List[str]] = None, external: bool = False): if not paths: key = _("Packaged Anki Add-on") + " (*{})".format(self.mgr.ext) paths = getFile( @@ -635,17 +675,7 @@ class AddonsDialog(QDialog): if not paths: return False - log, errs = self.mgr.processPackages(paths) - - if log: - log_html = "
".join(log) - if len(log) == 1: - tooltip(log_html, parent=self) - else: - showInfo(log_html, parent=self, textFormat="rich") - if errs: - msg = _("Please report this to the respective add-on author(s).") - showWarning("

".join(errs + [msg]), parent=self, textFormat="rich") + installAddonPackages(self.mgr, paths, parent=self) self.redrawAddons() @@ -820,3 +850,64 @@ class ConfigEditor(QDialog): self.onClose() super().accept() + + +# .ankiaddon installation wizard +###################################################################### + + +def installAddonPackages( + addonsManager: AddonManager, + paths: List[str], + parent: QWidget = None, + external: bool = False, +) -> bool: + + if external: + names = ",
".join(f"{os.path.basename(p)}" for p in paths) + q = _( + "Important: As add-ons are programs downloaded from the internet, " + "they are potentially malicious." + "You should only install add-ons you trust.

" + "Are you sure you want to proceed with the installation of the " + "following add-on(s)?

%(names)s" + ) % dict(names=names) + if ( + not showInfo( + q, + parent=parent, + title=_("Install Anki add-on"), + type="warning", + customBtns=[QMessageBox.No, QMessageBox.Yes], + ) + == QMessageBox.Yes + ): + return False + + log, errs = addonsManager.processPackages(paths, parent=parent) + + if log: + log_html = "
".join(log) + if external: + log_html += "

" + _( + "Please restart Anki to complete the installation." + ) + if len(log) == 1: + tooltip(log_html, parent=parent) + else: + showInfo( + log_html, + parent=parent, + textFormat="rich", + title=_("Installation complete"), + ) + if errs: + msg = _("Please report this to the respective add-on author(s).") + showWarning( + "

".join(errs + [msg]), + parent=parent, + textFormat="rich", + title=_("Add-on installation error"), + ) + + return not errs diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 2ae1039e7..2cc6cf7ad 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -4,6 +4,7 @@ import faulthandler import gc +import os import platform import re import signal @@ -1021,6 +1022,14 @@ QTreeWidget { aqt.exporting.ExportDialog(self, did=did) + # Installing add-ons from CLI / mimetype handler + ########################################################################## + + def installAddon(self, path): + from aqt.addons import installAddonPackages + + installAddonPackages(self.addonManager, [path], external=True, parent=self) + # Cramming ########################################################################## @@ -1473,6 +1482,8 @@ will be lost. Continue?""" self.app.appMsg.connect(self.onAppMsg) def onAppMsg(self, buf: str) -> Optional[QTimer]: + is_addon = buf.endswith(".ankiaddon") + if self.state == "startup": # try again in a second return self.progress.timer( @@ -1483,7 +1494,11 @@ will be lost. Continue?""" if buf == "raise": return None self.pendingImport = buf - return tooltip(_("Deck will be imported when a profile is opened.")) + if is_addon: + msg = _("Add-on will be installed when a profile is opened.") + else: + msg = _("Deck will be imported when a profile is opened.") + return tooltip(msg) if not self.interactiveState() or self.progress.busy(): # we can't raise the main window while in profile dialog, syncing, etc if buf != "raise": @@ -1507,8 +1522,13 @@ Please ensure a profile is open and Anki is not busy, then try again.""" self.raise_() if buf == "raise": return None - # import - self.handleImport(buf) + + # import / add-on installation + if is_addon: + self.installAddon(buf) + else: + self.handleImport(buf) + return None # GC diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py index cca97d8b0..6b22c8f27 100644 --- a/qt/aqt/utils.py +++ b/qt/aqt/utils.py @@ -50,7 +50,15 @@ def showCritical(text, parent=None, help="", title="Anki", textFormat=None): return showInfo(text, parent, help, "critical", title=title, textFormat=textFormat) -def showInfo(text, parent=False, help="", type="info", title="Anki", textFormat=None): +def showInfo( + text, + parent=False, + help="", + type="info", + title="Anki", + textFormat=None, + customBtns=None, +): "Show a small info window with an OK button." if parent is False: parent = aqt.mw.app.activeWindow() or aqt.mw @@ -70,8 +78,16 @@ def showInfo(text, parent=False, help="", type="info", title="Anki", textFormat= mb.setText(text) mb.setIcon(icon) mb.setWindowTitle(title) - b = mb.addButton(QMessageBox.Ok) - b.setDefault(True) + if customBtns: + default = None + for btn in customBtns: + b = mb.addButton(btn) + if not default: + default = b + mb.setDefaultButton(default) + else: + b = mb.addButton(QMessageBox.Ok) + b.setDefault(True) if help: b = mb.addButton(QMessageBox.Help) b.clicked.connect(lambda: openHelp(help))