From e3b7096db520f85a1f9cbd6125cc414b46b30cd7 Mon Sep 17 00:00:00 2001 From: Glutanimate Date: Fri, 3 Jan 2020 17:57:33 +0100 Subject: [PATCH] Extend CLI with the ability to install .ankiaddon packages Allows Anki to register a mime-type handler for .ankiaddon files Other small collateral changes: + fix positioning issues with some prompts and progress dialog + add prompt titles where they were missing + add type annotations for AddonManager installation methods + explicitly import os in main (used to be imported via aqt.qt) --- qt/aqt/__init__.py | 2 +- qt/aqt/addons.py | 89 +++++++++++++++++++++++++++++++++++++--------- qt/aqt/main.py | 25 +++++++++++-- 3 files changed, 96 insertions(+), 20 deletions(-) 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/addons.py b/qt/aqt/addons.py index 31f74f252..23a42a00d 100644 --- a/qt/aqt/addons.py +++ b/qt/aqt/addons.py @@ -7,7 +7,7 @@ import os import re import zipfile from collections import defaultdict -from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple +from typing import IO, Any, Callable, Dict, List, NamedTuple, Optional, Tuple, Union from zipfile import ZipFile import jsonschema @@ -209,7 +209,9 @@ and have been disabled: %(found)s" return {} return manifest - def install(self, file, manifest=None) -> AddonInstallationResult: + 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""" @@ -284,11 +286,14 @@ and have been disabled: %(found)s" # Processing local add-on files ###################################################################### - def processPackages(self, paths) -> Tuple[List[str], List[str]]: + 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) @@ -661,7 +666,7 @@ class AddonsDialog(QDialog): def onGetAddons(self): GetAddons(self) - def onInstallFiles(self, paths=None): + def onInstallFiles(self, paths: List[str] = None, external: bool = False): if not paths: key = _("Packaged Anki Add-on") + " (*{})".format(self.mgr.ext) paths = getFile( @@ -670,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() @@ -855,3 +850,65 @@ 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_str = ",
".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 " + f"following add-on(s)?

{names_str}" + ) + if ( + not showInfo( + q, + parent=parent, + title=_("Install Anki add-on"), + type="warning", + customBtns=[QMessageBox.No, QMessageBox.Yes], + ) + == QMessageBox.Yes + ): + tooltip(_("Add-on installation aborted"), parent=parent) + 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..d759cf34a 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,13 @@ 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 +1481,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 +1493,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 +1521,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