mirror of
https://github.com/ankitects/anki.git
synced 2025-09-24 08:46:37 -04:00
Merge pull request #283 from glutanimate/install-local-addons
Simplify installing add-ons from local files
This commit is contained in:
commit
4ab7c5b6d7
3 changed files with 198 additions and 31 deletions
208
aqt/addons.py
208
aqt/addons.py
|
@ -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)
|
||||||
|
|
14
aqt/utils.py
14
aqt/utils.py
|
@ -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)
|
||||||
|
|
|
@ -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