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>
|
Erez Volk <erez.volk@gmail.com>
|
||||||
Aristotelis P. <glutanimate.com/contact>
|
Aristotelis P. <glutanimate.com/contact>
|
||||||
|
AMBOSS MD Inc. <https://www.amboss.com/>
|
||||||
|
|
||||||
********************
|
********************
|
||||||
|
|
||||||
|
@ -9,4 +9,4 @@ Categories=Education;Languages;KDE;Qt;
|
|||||||
Terminal=false
|
Terminal=false
|
||||||
Type=Application
|
Type=Application
|
||||||
Version=1.0
|
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"/>
|
<glob pattern="*.apkg"/>
|
||||||
</mime-type>
|
</mime-type>
|
||||||
|
|
||||||
|
<mime-type type="application/x-ankiaddon">
|
||||||
|
<comment>Anki 2.1 add-on package</comment>
|
||||||
|
<glob pattern="*.ankiaddon"/>
|
||||||
|
</mime-type>
|
||||||
|
|
||||||
</mime-info>
|
</mime-info>
|
||||||
|
@ -261,7 +261,7 @@ def parseArgs(argv):
|
|||||||
if isMac and len(argv) > 1 and argv[1].startswith("-psn"):
|
if isMac and len(argv) > 1 and argv[1].startswith("-psn"):
|
||||||
argv = [argv[0]]
|
argv = [argv[0]]
|
||||||
parser = argparse.ArgumentParser(description="Anki " + appVersion)
|
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("-b", "--base", help="path to base folder", default="")
|
||||||
parser.add_argument("-p", "--profile", help="profile name to load", default="")
|
parser.add_argument("-p", "--profile", help="profile name to load", default="")
|
||||||
parser.add_argument("-l", "--lang", help="interface language (en, de, etc)")
|
parser.add_argument("-l", "--lang", help="interface language (en, de, etc)")
|
||||||
|
@ -149,6 +149,7 @@ system. It's free and open source."
|
|||||||
"Arman High",
|
"Arman High",
|
||||||
"Arthur Milchior",
|
"Arthur Milchior",
|
||||||
"Rai (Michael Pokorny)",
|
"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
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import zipfile
|
import zipfile
|
||||||
from collections import defaultdict
|
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
|
from zipfile import ZipFile
|
||||||
|
|
||||||
import jsonschema
|
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:
|
class AddonManager:
|
||||||
|
|
||||||
ext = ".ankiaddon"
|
ext: str = ".ankiaddon"
|
||||||
_manifest_schema = {
|
_manifest_schema: dict = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"package": {"type": "string", "meta": False},
|
"package": {"type": "string", "meta": False},
|
||||||
@ -201,14 +209,16 @@ and have been disabled: %(found)s"
|
|||||||
return {}
|
return {}
|
||||||
return manifest
|
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
|
"""Install add-on from path or file-like object. Metadata is read
|
||||||
from the manifest file, with keys overriden by supplying a 'manifest'
|
from the manifest file, with keys overriden by supplying a 'manifest'
|
||||||
dictionary"""
|
dictionary"""
|
||||||
try:
|
try:
|
||||||
zfile = ZipFile(file)
|
zfile = ZipFile(file)
|
||||||
except zipfile.BadZipfile:
|
except zipfile.BadZipfile:
|
||||||
return False, "zip"
|
return AddonInstallationResult(success=False, errmsg="zip")
|
||||||
|
|
||||||
with zfile:
|
with zfile:
|
||||||
file_manifest = self.readManifestFile(zfile)
|
file_manifest = self.readManifestFile(zfile)
|
||||||
@ -216,7 +226,7 @@ and have been disabled: %(found)s"
|
|||||||
file_manifest.update(manifest)
|
file_manifest.update(manifest)
|
||||||
manifest = file_manifest
|
manifest = file_manifest
|
||||||
if not manifest:
|
if not manifest:
|
||||||
return False, "manifest"
|
return AddonInstallationResult(success=False, errmsg="manifest")
|
||||||
package = manifest["package"]
|
package = manifest["package"]
|
||||||
conflicts = manifest.get("conflicts", [])
|
conflicts = manifest.get("conflicts", [])
|
||||||
found_conflicts = self._disableConflicting(package, conflicts)
|
found_conflicts = self._disableConflicting(package, conflicts)
|
||||||
@ -230,7 +240,9 @@ and have been disabled: %(found)s"
|
|||||||
meta.update(manifest_meta)
|
meta.update(manifest_meta)
|
||||||
self.writeAddonMeta(package, 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):
|
def _install(self, dir, zfile):
|
||||||
# previously installed?
|
# previously installed?
|
||||||
@ -274,40 +286,33 @@ and have been disabled: %(found)s"
|
|||||||
# Processing local add-on files
|
# Processing local add-on files
|
||||||
######################################################################
|
######################################################################
|
||||||
|
|
||||||
def processPackages(self, paths):
|
def processPackages(
|
||||||
|
self, paths: List[str], parent: QWidget = None
|
||||||
|
) -> Tuple[List[str], List[str]]:
|
||||||
|
|
||||||
log = []
|
log = []
|
||||||
errs = []
|
errs = []
|
||||||
self.mw.progress.start(immediate=True)
|
|
||||||
|
self.mw.progress.start(immediate=True, parent=parent)
|
||||||
try:
|
try:
|
||||||
for path in paths:
|
for path in paths:
|
||||||
base = os.path.basename(path)
|
base = os.path.basename(path)
|
||||||
ret = self.install(path)
|
result = self.install(path)
|
||||||
if ret[0] is False:
|
|
||||||
if ret[1] == "zip":
|
if not result.success:
|
||||||
msg = _("Corrupt add-on file.")
|
errs.extend(
|
||||||
elif ret[1] == "manifest":
|
self._installationErrorReport(result, base, mode="local")
|
||||||
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)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
log.append(_("Installed %(name)s" % dict(name=ret[1])))
|
log.extend(
|
||||||
if ret[2]:
|
self._installationSuccessReport(result, base, mode="local")
|
||||||
log.append(
|
)
|
||||||
_("The following conflicting add-ons were disabled:")
|
|
||||||
+ " "
|
|
||||||
+ " ".join(ret[2])
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
self.mw.progress.finish()
|
self.mw.progress.finish()
|
||||||
|
|
||||||
return log, errs
|
return log, errs
|
||||||
|
|
||||||
# Downloading
|
# Downloading add-ons from AnkiWeb
|
||||||
######################################################################
|
######################################################################
|
||||||
|
|
||||||
def downloadIds(self, ids):
|
def downloadIds(self, ids):
|
||||||
@ -324,32 +329,67 @@ and have been disabled: %(found)s"
|
|||||||
data, fname = ret
|
data, fname = ret
|
||||||
fname = fname.replace("_", " ")
|
fname = fname.replace("_", " ")
|
||||||
name = os.path.splitext(fname)[0]
|
name = os.path.splitext(fname)[0]
|
||||||
ret = self.install(
|
result = self.install(
|
||||||
io.BytesIO(data),
|
io.BytesIO(data),
|
||||||
manifest={"package": str(n), "name": name, "mod": intTime()},
|
manifest={"package": str(n), "name": name, "mod": intTime()},
|
||||||
)
|
)
|
||||||
if ret[0] is False:
|
if not result.success:
|
||||||
if ret[1] == "zip":
|
errs.extend(self._installationErrorReport(result, n))
|
||||||
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)
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
log.append(_("Downloaded %(fname)s" % dict(fname=name)))
|
log.extend(self._installationSuccessReport(result, n))
|
||||||
if ret[2]:
|
|
||||||
log.append(
|
|
||||||
_("The following conflicting add-ons were disabled:")
|
|
||||||
+ " "
|
|
||||||
+ " ".join(ret[2])
|
|
||||||
)
|
|
||||||
|
|
||||||
self.mw.progress.finish()
|
self.mw.progress.finish()
|
||||||
return log, errs
|
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
|
# Updating
|
||||||
######################################################################
|
######################################################################
|
||||||
|
|
||||||
@ -626,7 +666,7 @@ class AddonsDialog(QDialog):
|
|||||||
def onGetAddons(self):
|
def onGetAddons(self):
|
||||||
GetAddons(self)
|
GetAddons(self)
|
||||||
|
|
||||||
def onInstallFiles(self, paths=None):
|
def onInstallFiles(self, paths: Optional[List[str]] = None, external: bool = False):
|
||||||
if not paths:
|
if not paths:
|
||||||
key = _("Packaged Anki Add-on") + " (*{})".format(self.mgr.ext)
|
key = _("Packaged Anki Add-on") + " (*{})".format(self.mgr.ext)
|
||||||
paths = getFile(
|
paths = getFile(
|
||||||
@ -635,17 +675,7 @@ class AddonsDialog(QDialog):
|
|||||||
if not paths:
|
if not paths:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
log, errs = self.mgr.processPackages(paths)
|
installAddonPackages(self.mgr, paths, parent=self)
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
self.redrawAddons()
|
self.redrawAddons()
|
||||||
|
|
||||||
@ -820,3 +850,64 @@ class ConfigEditor(QDialog):
|
|||||||
|
|
||||||
self.onClose()
|
self.onClose()
|
||||||
super().accept()
|
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 faulthandler
|
||||||
import gc
|
import gc
|
||||||
|
import os
|
||||||
import platform
|
import platform
|
||||||
import re
|
import re
|
||||||
import signal
|
import signal
|
||||||
@ -1021,6 +1022,14 @@ QTreeWidget {
|
|||||||
|
|
||||||
aqt.exporting.ExportDialog(self, did=did)
|
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
|
# Cramming
|
||||||
##########################################################################
|
##########################################################################
|
||||||
|
|
||||||
@ -1473,6 +1482,8 @@ will be lost. Continue?"""
|
|||||||
self.app.appMsg.connect(self.onAppMsg)
|
self.app.appMsg.connect(self.onAppMsg)
|
||||||
|
|
||||||
def onAppMsg(self, buf: str) -> Optional[QTimer]:
|
def onAppMsg(self, buf: str) -> Optional[QTimer]:
|
||||||
|
is_addon = buf.endswith(".ankiaddon")
|
||||||
|
|
||||||
if self.state == "startup":
|
if self.state == "startup":
|
||||||
# try again in a second
|
# try again in a second
|
||||||
return self.progress.timer(
|
return self.progress.timer(
|
||||||
@ -1483,7 +1494,11 @@ will be lost. Continue?"""
|
|||||||
if buf == "raise":
|
if buf == "raise":
|
||||||
return None
|
return None
|
||||||
self.pendingImport = buf
|
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():
|
if not self.interactiveState() or self.progress.busy():
|
||||||
# we can't raise the main window while in profile dialog, syncing, etc
|
# we can't raise the main window while in profile dialog, syncing, etc
|
||||||
if buf != "raise":
|
if buf != "raise":
|
||||||
@ -1507,8 +1522,13 @@ Please ensure a profile is open and Anki is not busy, then try again."""
|
|||||||
self.raise_()
|
self.raise_()
|
||||||
if buf == "raise":
|
if buf == "raise":
|
||||||
return None
|
return None
|
||||||
# import
|
|
||||||
self.handleImport(buf)
|
# import / add-on installation
|
||||||
|
if is_addon:
|
||||||
|
self.installAddon(buf)
|
||||||
|
else:
|
||||||
|
self.handleImport(buf)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# GC
|
# 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)
|
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."
|
"Show a small info window with an OK button."
|
||||||
if parent is False:
|
if parent is False:
|
||||||
parent = aqt.mw.app.activeWindow() or aqt.mw
|
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.setText(text)
|
||||||
mb.setIcon(icon)
|
mb.setIcon(icon)
|
||||||
mb.setWindowTitle(title)
|
mb.setWindowTitle(title)
|
||||||
b = mb.addButton(QMessageBox.Ok)
|
if customBtns:
|
||||||
b.setDefault(True)
|
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:
|
if help:
|
||||||
b = mb.addButton(QMessageBox.Help)
|
b = mb.addButton(QMessageBox.Help)
|
||||||
b.clicked.connect(lambda: openHelp(help))
|
b.clicked.connect(lambda: openHelp(help))
|
||||||
|
Loading…
Reference in New Issue
Block a user