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:
commit
5126a1e28a
@ -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/>
|
||||
|
||||
********************
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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)")
|
||||
|
@ -149,6 +149,7 @@ system. It's free and open source."
|
||||
"Arman High",
|
||||
"Arthur Milchior",
|
||||
"Rai (Michael Pokorny)",
|
||||
"AMBOSS MD Inc.",
|
||||
)
|
||||
)
|
||||
|
||||
|
211
qt/aqt/addons.py
211
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 <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
|
||||
|
@ -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
|
||||
|
@ -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))
|
||||
|
Loading…
Reference in New Issue
Block a user