mirror of
https://github.com/ankitects/anki.git
synced 2025-09-20 15:02:21 -04:00
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:
parent
85955722c7
commit
4a21c2013f
2 changed files with 85 additions and 15 deletions
|
@ -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:
|
||||||
|
|
|
@ -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">
|
||||||
|
|
Loading…
Reference in a new issue