Merge pull request #283 from glutanimate/install-local-addons

Simplify installing add-ons from local files
This commit is contained in:
Damien Elmes 2019-02-24 15:08:32 +10:00 committed by GitHub
commit 4ab7c5b6d7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 198 additions and 31 deletions

View file

@ -5,12 +5,13 @@ import io
import json import json
import re import re
import zipfile import zipfile
from collections import defaultdict
import markdown import markdown
from send2trash import send2trash 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
@ -21,6 +22,15 @@ from anki.sync import AnkiRequestsClient
class AddonManager: class AddonManager:
ext = ".ankiaddon"
# todo?: use jsonschema package
_manifest_schema = {
"package": {"type": str, "req": True, "meta": False},
"name": {"type": str, "req": True, "meta": True},
"mod": {"type": int, "req": False, "meta": True},
"conflicts": {"type": list, "req": False, "meta": True}
}
def __init__(self, mw): def __init__(self, mw):
self.mw = mw self.mw = mw
self.dirty = False self.dirty = False
@ -89,38 +99,120 @@ When loading '%(name)s':
with open(path, "w", encoding="utf8") as f: with open(path, "w", encoding="utf8") as f:
json.dump(meta, f) json.dump(meta, f)
def toggleEnabled(self, dir): def isEnabled(self, dir):
meta = self.addonMeta(dir) meta = self.addonMeta(dir)
meta['disabled'] = not meta.get("disabled") return not meta.get('disabled')
def toggleEnabled(self, dir, enable=None):
meta = self.addonMeta(dir)
enabled = enable if enable is not None else meta.get("disabled")
if enabled is True and not self._checkConflicts(dir):
return False
meta['disabled'] = not enabled
self.writeAddonMeta(dir, meta) self.writeAddonMeta(dir, meta)
def addonName(self, dir): def addonName(self, dir):
return self.addonMeta(dir).get("name", dir) return self.addonMeta(dir).get("name", dir)
# Conflict resolution
######################################################################
def addonConflicts(self, dir):
return self.addonMeta(dir).get("conflicts", [])
def allAddonConflicts(self):
all_conflicts = defaultdict(list)
for dir in self.allAddons():
if not self.isEnabled(dir):
continue
conflicts = self.addonConflicts(dir)
for other_dir in conflicts:
all_conflicts[other_dir].append(dir)
return all_conflicts
def _checkConflicts(self, dir, name=None, conflicts=None):
name = name or self.addonName(dir)
conflicts = conflicts or self.addonConflicts(dir)
installed = self.allAddons()
found = [d for d in conflicts if d in installed and self.isEnabled(d)]
found.extend(self.allAddonConflicts().get(dir, []))
if not found:
return True
addons = "\n".join(self.addonName(f) for f in found)
ret = askUser(_("""\
The following add-on(s) are incompatible with %(name)s \
and will have to be disabled to proceed:\n\n%(found)s\n\n\
Are you sure you want to continue?"""
% dict(name=name, found=addons)))
if not ret:
return False
for package in found:
self.toggleEnabled(package, enable=False)
return True
# Installing and deleting add-ons # Installing and deleting add-ons
###################################################################### ######################################################################
def install(self, sid, data, fname): def _readManifestFile(self, zfile):
try: try:
z = ZipFile(io.BytesIO(data)) with zfile.open("manifest.json") as f:
data = json.loads(f.read())
manifest = {} # build new manifest from recognized keys
for key, attrs in self._manifest_schema.items():
if not attrs["req"] and key not in data:
continue
val = data[key]
assert isinstance(val, attrs["type"])
manifest[key] = val
except (KeyError, json.decoder.JSONDecodeError, AssertionError):
# raised for missing manifest, invalid json, missing/invalid keys
return {}
return manifest
def install(self, file, manifest=None):
"""Install add-on from path or file-like object. Metadata is read
from the manifest file by default, but this may me bypassed
by supplying a 'manifest' dictionary"""
try:
zfile = ZipFile(file)
except zipfile.BadZipfile: except zipfile.BadZipfile:
showWarning(_("The download was corrupt. Please try again.")) return False, "zip"
return
with zfile:
manifest = manifest or self._readManifestFile(zfile)
if not manifest:
return False, "manifest"
package = manifest["package"]
conflicts = manifest.get("conflicts", [])
if not self._checkConflicts(package, manifest["name"], conflicts):
return False, "conflicts"
meta = self.addonMeta(package)
self._install(package, zfile)
schema = self._manifest_schema
manifest_meta = {k: v for k, v in manifest.items()
if k in schema and schema[k]["meta"]}
meta.update(manifest_meta)
self.writeAddonMeta(package, meta)
name = os.path.splitext(fname)[0] return True, meta["name"]
def _install(self, dir, zfile):
# previously installed? # previously installed?
meta = self.addonMeta(sid) base = self.addonsFolder(dir)
base = self.addonsFolder(sid)
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 +221,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
meta['name'] = name
meta['mod'] = intTime()
self.writeAddonMeta(sid, meta)
def deleteAddon(self, dir): def deleteAddon(self, dir):
send2trash(self.addonsFolder(dir)) send2trash(self.addonsFolder(dir))
# Processing local add-on files
######################################################################
def processPackages(self, paths):
log = []
errs = []
self.mw.progress.start(immediate=True)
for path in paths:
base = os.path.basename(path)
ret = self.install(path)
if ret[0] is False:
if ret[1] == "conflicts":
continue
elif ret[1] == "zip":
msg = _("Corrupt add-on file.")
elif ret[1] == "manifest":
msg = _("Invalid add-on manifest.")
errs.append(_("Error installing <i>%(base)s</i>: %(error)s"
% dict(base=base, error=msg)))
else:
log.append(_("Installed %(name)s" % dict(name=ret[1])))
self.mw.progress.finish()
return log, errs
# Downloading # Downloading
###################################################################### ######################################################################
@ -153,8 +264,17 @@ 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]
ret = self.install(io.BytesIO(data),
manifest={"package": str(n), "name": name,
"mod": intTime()})
if ret[0] is False:
if ret[1] == "conflicts":
continue
if ret[1] == "zip":
showWarning(_("The download was corrupt. Please try again."))
elif ret[1] == "manifest":
showWarning(_("Invalid add-on manifest."))
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 +416,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)
@ -303,10 +424,29 @@ class AddonsDialog(QDialog):
f.delete_2.clicked.connect(self.onDelete) f.delete_2.clicked.connect(self.onDelete)
f.config.clicked.connect(self.onConfig) f.config.clicked.connect(self.onConfig)
self.form.addonList.currentRowChanged.connect(self._onAddonItemSelected) self.form.addonList.currentRowChanged.connect(self._onAddonItemSelected)
self.setAcceptDrops(True)
self.redrawAddons() self.redrawAddons()
restoreGeom(self, "addons") restoreGeom(self, "addons")
self.show() self.show()
def dragEnterEvent(self, event):
mime = event.mimeData()
if not mime.hasUrls():
return None
urls = mime.urls()
ext = self.mgr.ext
if all(url.toLocalFile().endswith(ext) for url in urls):
event.acceptProposedAction()
def dropEvent(self, event):
mime = event.mimeData()
paths = []
for url in mime.urls():
path = url.toLocalFile()
if os.path.exists(path):
paths.append(path)
self.onInstallFiles(paths)
def reject(self): def reject(self):
saveGeom(self, "addons") saveGeom(self, "addons")
return QDialog.reject(self) return QDialog.reject(self)
@ -390,6 +530,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") + " (*{})".format(self.mgr.ext))
paths = getFile(self, _("Install Add-on(s)"), None, key,
key="addons", multi=True)
if not paths:
return False
log, errs = self.mgr.processPackages(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:
@ -400,9 +558,9 @@ class AddonsDialog(QDialog):
"\n" + "\n".join(names)): "\n" + "\n".join(names)):
log, errs = self.mgr.downloadIds(updated) log, errs = self.mgr.downloadIds(updated)
if log: if log:
tooltip("\n".join(log), parent=self) tooltip("<br>".join(log), parent=self)
if errs: if errs:
showWarning("\n".join(errs), parent=self) showWarning("<br><br>".join(errs), parent=self)
self.redrawAddons() self.redrawAddons()
@ -458,9 +616,9 @@ class GetAddons(QDialog):
log, errs = self.mgr.downloadIds(ids) log, errs = self.mgr.downloadIds(ids)
if log: if log:
tooltip("\n".join(log), parent=self.addonsDlg) tooltip("<br>".join(log), parent=self.addonsDlg)
if errs: if errs:
showWarning("\n".join(errs)) showWarning("<br><br>".join(errs))
self.addonsDlg.redrawAddons() self.addonsDlg.redrawAddons()
QDialog.accept(self) QDialog.accept(self)

View file

@ -245,7 +245,7 @@ def getTag(parent, deck, question, tags="user", **kwargs):
# File handling # File handling
###################################################################### ######################################################################
def getFile(parent, title, cb, filter="*.*", dir=None, key=None): def getFile(parent, title, cb, filter="*.*", dir=None, key=None, multi=False):
"Ask the user for a file." "Ask the user for a file."
assert not dir or not key assert not dir or not key
if not dir: if not dir:
@ -254,20 +254,22 @@ def getFile(parent, title, cb, filter="*.*", dir=None, key=None):
else: else:
dirkey = None dirkey = None
d = QFileDialog(parent) d = QFileDialog(parent)
d.setFileMode(QFileDialog.ExistingFile) mode = QFileDialog.ExistingFiles if multi else QFileDialog.ExistingFile
d.setFileMode(mode)
if os.path.exists(dir): if os.path.exists(dir):
d.setDirectory(dir) d.setDirectory(dir)
d.setWindowTitle(title) d.setWindowTitle(title)
d.setNameFilter(filter) d.setNameFilter(filter)
ret = [] ret = []
def accept(): def accept():
file = str(list(d.selectedFiles())[0]) files = list(d.selectedFiles())
if dirkey: if dirkey:
dir = os.path.dirname(file) dir = os.path.dirname(files[0])
aqt.mw.pm.profile[dirkey] = dir aqt.mw.pm.profile[dirkey] = dir
result = files if multi else files[0]
if cb: if cb:
cb(file) cb(result)
ret.append(file) ret.append(result)
d.accepted.connect(accept) d.accepted.connect(accept)
if key: if key:
restoreState(d, key) restoreState(d, key)

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