Merge pull request #410 from glutanimate/ankiaddon-filetype-handling

Make .ankiaddon files installable via the CLI, registering Anki as their default file type handler
This commit is contained in:
Damien Elmes 2020-01-04 12:19:39 +10:00 committed by GitHub
commit 5126a1e28a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 202 additions and 68 deletions

View File

@ -20,6 +20,7 @@ support site, it would be great if you could add your name below as well.
Erez Volk <erez.volk@gmail.com>
Aristotelis P. <glutanimate.com/contact>
AMBOSS MD Inc. <https://www.amboss.com/>
********************

View File

@ -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;

View File

@ -11,4 +11,9 @@
<glob pattern="*.apkg"/>
</mime-type>
<mime-type type="application/x-ankiaddon">
<comment>Anki 2.1 add-on package</comment>
<glob pattern="*.ankiaddon"/>
</mime-type>
</mime-info>

View File

@ -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)")

View File

@ -149,6 +149,7 @@ system. It's free and open source."
"Arman High",
"Arthur Milchior",
"Rai (Michael Pokorny)",
"AMBOSS MD Inc.",
)
)

View File

@ -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 <i>%(base)s</i>: %(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 <i>%(id)s</i>: %(error)s")
else:
template = _("Error installing <i>%(base)s</i>: %(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 = "<br>".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("<br><br>".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 = ",<br>".join(f"<b>{os.path.basename(p)}</b>" for p in paths)
q = _(
"<b>Important</b>: As add-ons are programs downloaded from the internet, "
"they are potentially malicious."
"<b>You should only install add-ons you trust.</b><br><br>"
"Are you sure you want to proceed with the installation of the "
"following add-on(s)?<br><br>%(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 = "<br>".join(log)
if external:
log_html += "<br><br>" + _(
"<b>Please restart Anki to complete the installation.</b>"
)
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(
"<br><br>".join(errs + [msg]),
parent=parent,
textFormat="rich",
title=_("Add-on installation error"),
)
return not errs

View File

@ -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

View File

@ -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))