support min/max add-on versions in download and ankiaddon manifest

This commit is contained in:
Damien Elmes 2020-01-24 17:25:52 +10:00
parent f6ef553ba5
commit b4c8eaf4bb

View File

@ -13,6 +13,7 @@ from collections import defaultdict
from concurrent.futures import Future from concurrent.futures import Future
from dataclasses import dataclass from dataclasses import dataclass
from typing import IO, Any, Callable, Dict, Iterable, List, Optional, Tuple, Union from typing import IO, Any, Callable, Dict, Iterable, List, Optional, Tuple, Union
from urllib.parse import parse_qs, urlparse
from zipfile import ZipFile from zipfile import ZipFile
import jsonschema import jsonschema
@ -47,6 +48,7 @@ from aqt.utils import (
class InstallOk: class InstallOk:
name: str name: str
conflicts: List[str] conflicts: List[str]
compatible: bool
@dataclass @dataclass
@ -58,6 +60,9 @@ class InstallError:
class DownloadOk: class DownloadOk:
data: bytes data: bytes
filename: str filename: str
min_point_version: Optional[int]
max_point_version: Optional[int]
package_index: Optional[int]
@dataclass @dataclass
@ -76,12 +81,13 @@ DownloadLogEntry = Tuple[int, Union[DownloadError, InstallError, InstallOk]]
class UpdateInfo: class UpdateInfo:
id: int id: int
last_updated: int last_updated: int
min_point_version: Optional[int]
max_point_version: Optional[int] max_point_version: Optional[int]
ANKIWEB_ID_RE = re.compile(r"^\d+$") ANKIWEB_ID_RE = re.compile(r"^\d+$")
pointVersion = anki.utils.pointVersion() current_point_version = anki.utils.pointVersion()
@dataclass @dataclass
@ -91,7 +97,9 @@ class AddonMeta:
enabled: bool enabled: bool
installed_at: int installed_at: int
conflicts: List[str] conflicts: List[str]
min_point_version: Optional[int]
max_point_version: Optional[int] max_point_version: Optional[int]
package_index: Optional[int]
def human_name(self) -> str: def human_name(self) -> str:
return self.provided_name or self.dir_name return self.provided_name or self.dir_name
@ -104,20 +112,26 @@ class AddonMeta:
return None return None
def compatible(self) -> bool: def compatible(self) -> bool:
if self.max_point_version is None: min = self.min_point_version
return True if min is not None and current_point_version < min:
return pointVersion <= self.max_point_version return False
max = self.max_point_version
if max is not None and current_point_version > max:
return False
return True
@staticmethod
def addon_meta(dir_name: str, json_meta: Dict[str, Any]) -> AddonMeta: def from_json_meta(dir_name: str, json_meta: Dict[str, Any]) -> AddonMeta:
return AddonMeta( return AddonMeta(
dir_name=dir_name, dir_name=dir_name,
provided_name=json_meta.get("name"), provided_name=json_meta.get("name"),
enabled=not json_meta.get("disabled"), enabled=not json_meta.get("disabled"),
installed_at=json_meta.get("mod", 0), installed_at=json_meta.get("mod", 0),
conflicts=json_meta.get("conflicts", []), conflicts=json_meta.get("conflicts", []),
max_point_version=json_meta.get("max_point_version"), min_point_version=json_meta.get("min_point_version"),
) max_point_version=json_meta.get("max_point_version"),
package_index=json_meta.get("package_index"),
)
# fixme: this class should not have any GUI code in it # fixme: this class should not have any GUI code in it
@ -131,6 +145,9 @@ class AddonManager:
"name": {"type": "string", "meta": True}, "name": {"type": "string", "meta": True},
"mod": {"type": "number", "meta": True}, "mod": {"type": "number", "meta": True},
"conflicts": {"type": "array", "items": {"type": "string"}, "meta": True}, "conflicts": {"type": "array", "items": {"type": "string"}, "meta": True},
"min_point_version": {"type": "number", "meta": True},
"max_point_version": {"type": "number", "meta": True},
"package_index": {"type": "number", "meta": True},
}, },
"required": ["package", "name"], "required": ["package", "name"],
} }
@ -196,7 +213,7 @@ When loading '%(name)s':
def addon_meta(self, dir_name: str) -> AddonMeta: def addon_meta(self, dir_name: str) -> AddonMeta:
"""Get info about an installed add-on.""" """Get info about an installed add-on."""
json_obj = self.addonMeta(dir_name) json_obj = self.addonMeta(dir_name)
return addon_meta(dir_name, json_obj) return AddonMeta.from_json_meta(dir_name, json_obj)
def write_addon_meta(self, addon: AddonMeta) -> None: def write_addon_meta(self, addon: AddonMeta) -> None:
# preserve any unknown attributes # preserve any unknown attributes
@ -208,13 +225,15 @@ When loading '%(name)s':
json_obj["mod"] = addon.installed_at json_obj["mod"] = addon.installed_at
json_obj["conflicts"] = addon.conflicts json_obj["conflicts"] = addon.conflicts
json_obj["max_point_version"] = addon.max_point_version json_obj["max_point_version"] = addon.max_point_version
json_obj["min_point_version"] = addon.min_point_version
json_obj["package_index"] = addon.package_index
self.writeAddonMeta(addon.dir_name, json_obj) self.writeAddonMeta(addon.dir_name, json_obj)
def _addonMetaPath(self, dir): def _addonMetaPath(self, dir):
return os.path.join(self.addonsFolder(dir), "meta.json") return os.path.join(self.addonsFolder(dir), "meta.json")
# in new code, use addon_meta() instead # in new code, use self.addon_meta() instead
def addonMeta(self, dir: str) -> Dict[str, Any]: def addonMeta(self, dir: str) -> Dict[str, Any]:
path = self._addonMetaPath(dir) path = self._addonMetaPath(dir)
try: try:
@ -347,7 +366,11 @@ and have been disabled: %(found)s"
meta.update(manifest_meta) meta.update(manifest_meta)
self.writeAddonMeta(package, meta) self.writeAddonMeta(package, meta)
return InstallOk(name=meta["name"], conflicts=found_conflicts) meta2 = self.addon_meta(package)
return InstallOk(
name=meta["name"], conflicts=found_conflicts, compatible=meta2.compatible()
)
def _install(self, dir, zfile): def _install(self, dir, zfile):
# previously installed? # previously installed?
@ -459,6 +482,11 @@ and have been disabled: %(found)s"
+ ", ".join(self.addonName(f) for f in result.conflicts) + ", ".join(self.addonName(f) for f in result.conflicts)
) )
if not result.compatible:
strings.append(
_("This add-on is not compatible with your version of Anki.")
)
return strings return strings
# Updating # Updating
@ -648,10 +676,18 @@ class AddonsDialog(QDialog):
if not addon.enabled: if not addon.enabled:
return name + " " + _("(disabled)") return name + " " + _("(disabled)")
elif not addon.compatible(): elif not addon.compatible():
return name + " " + _("(not compatible)") return name + " " + _("(requires %s)") % self.compatible_string(addon)
return name return name
def compatible_string(self, addon: AddonMeta) -> str:
min = addon.min_point_version
if min is not None and min > current_point_version:
return f"Anki >= 2.1.{min}"
else:
max = addon.max_point_version
return f"Anki <= 2.1.{max}"
def should_grey(self, addon: AddonMeta): def should_grey(self, addon: AddonMeta):
return not addon.enabled or not addon.compatible() return not addon.enabled or not addon.compatible()
@ -839,7 +875,9 @@ class GetAddons(QDialog):
def download_addon(client: HttpClient, id: int) -> Union[DownloadOk, DownloadError]: def download_addon(client: HttpClient, id: int) -> Union[DownloadOk, DownloadError]:
"Fetch a single add-on from AnkiWeb." "Fetch a single add-on from AnkiWeb."
try: try:
resp = client.get(aqt.appShared + f"download/{id}?v=2.1&p={pointVersion}") resp = client.get(
aqt.appShared + f"download/{id}?v=2.1&p={current_point_version}"
)
if resp.status_code != 200: if resp.status_code != 200:
return DownloadError(status_code=resp.status_code) return DownloadError(status_code=resp.status_code)
@ -849,11 +887,47 @@ def download_addon(client: HttpClient, id: int) -> Union[DownloadOk, DownloadErr
"attachment; filename=(.+)", resp.headers["content-disposition"] "attachment; filename=(.+)", resp.headers["content-disposition"]
).group(1) ).group(1)
return DownloadOk(data=data, filename=fname) meta = extract_meta_from_download_url(resp.url)
return DownloadOk(
data=data,
filename=fname,
min_point_version=meta.min_point_version,
max_point_version=meta.max_point_version,
package_index=meta.package_index,
)
except Exception as e: except Exception as e:
return DownloadError(exception=e) return DownloadError(exception=e)
@dataclass
class ExtractedDownloadMeta:
min_point_version: Optional[int] = None
max_point_version: Optional[int] = None
package_index: Optional[int] = None
def extract_meta_from_download_url(url: str) -> ExtractedDownloadMeta:
urlobj = urlparse(url)
query = parse_qs(urlobj.query)
meta = ExtractedDownloadMeta()
min = query.get("minpt")
if min is not None:
meta.min_point_version = int(min[0])
max = query.get("maxpt")
if max is not None:
meta.max_point_version = int(max[0])
pkgidx = query.get("pkgidx")
if pkgidx is not None:
meta.package_index = int(pkgidx[0])
return meta
def download_log_to_html(log: List[DownloadLogEntry]) -> str: def download_log_to_html(log: List[DownloadLogEntry]) -> str:
return "<br>".join(map(describe_log_entry, log)) return "<br>".join(map(describe_log_entry, log))
@ -899,10 +973,15 @@ def download_and_install_addon(
fname = result.filename.replace("_", " ") fname = result.filename.replace("_", " ")
name = os.path.splitext(fname)[0] name = os.path.splitext(fname)[0]
result2 = mgr.install( manifest = {"package": str(id), "name": name, "mod": intTime()}
io.BytesIO(result.data), if result.min_point_version is not None:
manifest={"package": str(id), "name": name, "mod": intTime()}, manifest["min_point_version"] = result.min_point_version
) if result.max_point_version is not None:
manifest["max_point_version"] = result.max_point_version
if result.package_index is not None:
manifest["package_index"] = result.package_index
result2 = mgr.install(io.BytesIO(result.data), manifest=manifest,)
return (id, result2) return (id, result2)
@ -1018,7 +1097,10 @@ def _fetch_update_info_batch(
def json_update_info_to_native(json_obj: List[Dict]) -> Iterable[UpdateInfo]: def json_update_info_to_native(json_obj: List[Dict]) -> Iterable[UpdateInfo]:
def from_json(d: Dict[str, Any]) -> UpdateInfo: def from_json(d: Dict[str, Any]) -> UpdateInfo:
return UpdateInfo( return UpdateInfo(
id=d["id"], last_updated=d["updated"], max_point_version=d["maxver"] id=d["id"],
last_updated=d["updated"],
max_point_version=d["maxver"],
min_point_version=d.get("minver"),
) )
return map(from_json, json_obj) return map(from_json, json_obj)