Ability to install add-ons from local add-on packages

Adds a new button to the add-on dialog that allows users to select
and install add-ons from local files.
Introduces APKX, a zip-based and manifest-backed filetype for
Anki add-on packages.
This commit is contained in:
Glutanimate 2019-02-18 07:17:14 +01:00
parent 85955722c7
commit 4a21c2013f
2 changed files with 85 additions and 15 deletions

View file

@ -10,7 +10,7 @@ from send2trash import send2trash
from aqt.qt import * from aqt.qt import *
from aqt.utils import showInfo, openFolder, isWin, openLink, \ from aqt.utils import showInfo, openFolder, isWin, openLink, \
askUser, restoreGeom, saveGeom, showWarning, tooltip askUser, restoreGeom, saveGeom, showWarning, tooltip, getFile
from zipfile import ZipFile from zipfile import ZipFile
import aqt.forms import aqt.forms
import aqt import aqt
@ -100,27 +100,52 @@ When loading '%(name)s':
# Installing and deleting add-ons # Installing and deleting add-ons
###################################################################### ######################################################################
def install(self, sid, data, fname): def installDownload(self, sid, data, name):
try: try:
z = ZipFile(io.BytesIO(data)) zfile = ZipFile(io.BytesIO(data))
except zipfile.BadZipfile: except zipfile.BadZipfile:
showWarning(_("The download was corrupt. Please try again.")) showWarning(_("The download was corrupt. Please try again."))
return return False
mod = intTime()
self.install(sid, zfile, name, mod)
name = os.path.splitext(fname)[0] def installLocal(self, path):
try:
zfile = ZipFile(path)
except zipfile.BadZipfile:
return False, _("Corrupt add-on archive.")
try:
with zfile.open("manifest.json") as f:
info = json.loads(f.read())
package = info["package"]
name = info["name"]
mod = info.get("mod", None)
assert isinstance(package, str) and isinstance(name, str)
assert isinstance(mod, int) or mod is None
except (KeyError, json.decoder.JSONDecodeError, AssertionError):
# raised for missing manifest, invalid json, missing/invalid keys
return False, _("Invalid add-on manifest.")
self.install(package, zfile, name, mod)
return name, None
def install(self, dir, zfile, name, mod):
# previously installed? # previously installed?
meta = self.addonMeta(sid) meta = self.addonMeta(dir)
base = self.addonsFolder(sid) base = self.addonsFolder(dir)
if os.path.exists(base): if os.path.exists(base):
self.backupUserFiles(sid) self.backupUserFiles(dir)
self.deleteAddon(sid) self.deleteAddon(dir)
os.mkdir(base) os.mkdir(base)
self.restoreUserFiles(sid) self.restoreUserFiles(dir)
# extract # extract
for n in z.namelist(): for n in zfile.namelist():
if n.endswith("/"): if n.endswith("/"):
# folder; ignore # folder; ignore
continue continue
@ -129,16 +154,35 @@ When loading '%(name)s':
# skip existing user files # skip existing user files
if os.path.exists(path) and n.startswith("user_files/"): if os.path.exists(path) and n.startswith("user_files/"):
continue continue
z.extract(n, base) zfile.extract(n, base)
# update metadata # update metadata
meta['name'] = name meta['name'] = name
meta['mod'] = intTime() if mod is not None: # allow packages to skip updating mod
self.writeAddonMeta(sid, meta) meta['mod'] = mod
self.writeAddonMeta(dir, meta)
def deleteAddon(self, dir): def deleteAddon(self, dir):
send2trash(self.addonsFolder(dir)) send2trash(self.addonsFolder(dir))
# Processing local add-on files
######################################################################
def processAPKX(self, paths):
log = []
errs = []
self.mw.progress.start(immediate=True)
for path in paths:
base = os.path.basename(path)
name, error = self.installLocal(path)
if error:
errs.append(_("Error installing <i>%(base)s</i>: %(error)s"
% dict(base=base, error=error)))
else:
log.append(_("Installed %(name)s" % dict(name=name)))
self.mw.progress.finish()
return log, errs
# Downloading # Downloading
###################################################################### ######################################################################
@ -153,8 +197,8 @@ When loading '%(name)s':
continue continue
data, fname = ret data, fname = ret
fname = fname.replace("_", " ") fname = fname.replace("_", " ")
self.install(str(n), data, fname)
name = os.path.splitext(fname)[0] name = os.path.splitext(fname)[0]
self.installDownload(str(n), data, name)
log.append(_("Downloaded %(fname)s" % dict(fname=name))) log.append(_("Downloaded %(fname)s" % dict(fname=name)))
self.mw.progress.finish() self.mw.progress.finish()
return log, errs return log, errs
@ -296,6 +340,7 @@ class AddonsDialog(QDialog):
f = self.form = aqt.forms.addons.Ui_Dialog() f = self.form = aqt.forms.addons.Ui_Dialog()
f.setupUi(self) f.setupUi(self)
f.getAddons.clicked.connect(self.onGetAddons) f.getAddons.clicked.connect(self.onGetAddons)
f.installFromFile.clicked.connect(self.onInstallFiles)
f.checkForUpdates.clicked.connect(self.onCheckForUpdates) f.checkForUpdates.clicked.connect(self.onCheckForUpdates)
f.toggleEnabled.clicked.connect(self.onToggleEnabled) f.toggleEnabled.clicked.connect(self.onToggleEnabled)
f.viewPage.clicked.connect(self.onViewPage) f.viewPage.clicked.connect(self.onViewPage)
@ -385,6 +430,24 @@ class AddonsDialog(QDialog):
def onGetAddons(self): def onGetAddons(self):
GetAddons(self) GetAddons(self)
def onInstallFiles(self, paths=None):
if not paths:
key = (_("Packaged Anki Add-on") + " (*.apkx)")
paths = getFile(self, _("Install Add-on(s)"), None, key,
key="addons", multi=True)
if not paths:
return False
log, errs = self.mgr.processAPKX(paths)
if log:
tooltip("<br>".join(log), parent=self)
if errs:
msg = _("Please report this to the respective add-on author(s).")
showWarning("<br><br>".join(errs + [msg]), parent=self)
self.redrawAddons()
def onCheckForUpdates(self): def onCheckForUpdates(self):
updated = self.mgr.checkForUpdates() updated = self.mgr.checkForUpdates()
if not updated: if not updated:

View file

@ -47,6 +47,13 @@
</property> </property>
</widget> </widget>
</item> </item>
<item>
<widget class="QPushButton" name="installFromFile">
<property name="text">
<string>Install from file...</string>
</property>
</widget>
</item>
<item> <item>
<widget class="QPushButton" name="checkForUpdates"> <widget class="QPushButton" name="checkForUpdates">
<property name="text"> <property name="text">