Change Anki's version scheme; bump to 23.09 (#2640)

* Accept iterables as inputs to backend methods

* Shift add-on check to backend; use new endpoint

The new endpoint will return info on a suitable branch if found,
instead of returning all branches. This simplifies the frontend code,
and means that you can now drop support for certain versions without
it also remotely disabling the add-on for people who are running one of
the excluded versions, like in

https://forums.ankiweb.net/t/prevent-add-ons-from-being-disabled-remote-stealthily-surreptitiously/33427

* Bump version to 23.09

This changes Anki's version numbering system to year.month.patch, as
previously mentioned on https://forums.ankiweb.net/t/use-a-different-versioning-system-semver-perhaps/20046/5

This is shaping up to be a big release, with the introduction of FSRS and
image occlusion, and it seems like a good time to be finally updating the
version scheme as well. AnkiWeb has been updated to understand the new
format, and add-on authors will now specify version compatibility using
the full version number, as can be seen here:

https://ankiweb.net/shared/info/3918629684

* Shift update check to backend, and tidy up update.py

* Use the shared client for sync connections too
This commit is contained in:
Damien Elmes 2023-09-07 12:37:15 +10:00 committed by GitHub
parent 6d34d19808
commit ffd392de21
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 336 additions and 277 deletions

View File

@ -1 +1 @@
2.1.66
23.09

49
proto/anki/ankiweb.proto Normal file
View File

@ -0,0 +1,49 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
syntax = "proto3";
option java_multiple_files = true;
package anki.ankiweb;
service AnkiwebService {}
service BackendAnkiwebService {
// Fetch info on add-ons from AnkiWeb. A maximum of 25 can be queried at one
// time. If an add-on doesn't have a branch compatible with the provided
// version, that add-on will not be included in the returned list.
rpc GetAddonInfo(GetAddonInfoRequest) returns (GetAddonInfoResponse);
rpc CheckForUpdate(CheckForUpdateRequest) returns (CheckForUpdateResponse);
}
message GetAddonInfoRequest {
uint32 client_version = 1;
repeated uint32 addon_ids = 2;
}
message GetAddonInfoResponse {
repeated AddonInfo info = 1;
}
message AddonInfo {
uint32 id = 1;
int64 modified = 2;
uint32 min_version = 3;
uint32 max_version = 4;
}
message CheckForUpdateRequest {
uint32 version = 1;
string buildhash = 2;
string os = 3;
int64 install_id = 4;
uint32 last_message_id = 5;
}
message CheckForUpdateResponse {
optional string new_version = 1;
int64 current_time = 2;
optional string message = 3;
uint32 last_message_id = 4;
}

View File

@ -6,6 +6,7 @@ from __future__ import annotations
from typing import Any, Generator, Literal, Sequence, Union, cast
from anki import (
ankiweb_pb2,
card_rendering_pb2,
collection_pb2,
config_pb2,
@ -45,6 +46,8 @@ TtsVoice = card_rendering_pb2.AllTtsVoicesResponse.TtsVoice
GetImageForOcclusionResponse = image_occlusion_pb2.GetImageForOcclusionResponse
AddImageOcclusionNoteRequest = image_occlusion_pb2.AddImageOcclusionNoteRequest
GetImageOcclusionNoteResponse = image_occlusion_pb2.GetImageOcclusionNoteResponse
AddonInfo = ankiweb_pb2.AddonInfo
CheckForUpdateResponse = ankiweb_pb2.CheckForUpdateResponse
import copy
import os

View File

@ -304,14 +304,29 @@ def version_with_build() -> str:
return f"{version} ({buildhash})"
def point_version() -> int:
def int_version() -> int:
"""Anki's version as an integer in the form YYMMPP, e.g. 230900.
(year, month, patch).
In 2.1.x releases, this was just the last number."""
from anki.buildinfo import version
return int(version.rsplit(".", maxsplit=1)[-1])
try:
[year, month, patch] = version.split(".")
except ValueError:
[year, month] = version.split(".")
patch = "0"
year_num = int(year)
month_num = int(month)
patch_num = int(patch)
return year_num * 10_000 + month_num * 100 + patch_num
# keep the legacy alias around without a deprecation warning for now
pointVersion = point_version
# these two legacy aliases are provided without deprecation warnings, as add-ons that want to support
# old versions could not use the new name without catching cases where it doesn't exist
point_version = int_version
pointVersion = int_version
_deprecated_names = DeprecatedNamesMixinForModule(globals())
_deprecated_names.register_deprecated_aliases(

View File

@ -14,7 +14,7 @@ from concurrent.futures import Future
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import IO, Any, Callable, Iterable, Union
from typing import IO, Any, Callable, Iterable, Sequence, Union
from urllib.parse import parse_qs, urlparse
from zipfile import ZipFile
@ -28,6 +28,7 @@ import anki.utils
import aqt
import aqt.forms
import aqt.main
from anki.collection import AddonInfo
from anki.httpclient import HttpClient
from anki.lang import without_unicode_isolation
from aqt import gui_hooks
@ -92,18 +93,9 @@ class DownloadError:
DownloadLogEntry = tuple[int, Union[DownloadError, InstallError, InstallOk]]
@dataclass
class UpdateInfo:
id: int
suitable_branch_last_modified: int
current_branch_last_modified: int
current_branch_min_point_ver: int
current_branch_max_point_ver: int
ANKIWEB_ID_RE = re.compile(r"^\d+$")
current_point_version = anki.utils.point_version()
_current_version = anki.utils.int_version()
@dataclass
@ -113,8 +105,8 @@ class AddonMeta:
enabled: bool
installed_at: int
conflicts: list[str]
min_point_version: int
max_point_version: int
min_version: int
max_version: int
branch_index: int
human_version: str | None
update_enabled: bool
@ -131,11 +123,11 @@ class AddonMeta:
return None
def compatible(self) -> bool:
min = self.min_point_version
if min is not None and current_point_version < min:
min = self.min_version
if min is not None and _current_version < min:
return False
max = self.max_point_version
if max is not None and max < 0 and current_point_version > abs(max):
max = self.max_version
if max is not None and max < 0 and _current_version > abs(max):
return False
return True
@ -155,8 +147,8 @@ class AddonMeta:
enabled=not json_meta.get("disabled"),
installed_at=json_meta.get("mod", 0),
conflicts=json_meta.get("conflicts", []),
min_point_version=json_meta.get("min_point_version", 0) or 0,
max_point_version=json_meta.get("max_point_version", 0) or 0,
min_version=json_meta.get("min_point_version", 0) or 0,
max_version=json_meta.get("max_point_version", 0) or 0,
branch_index=json_meta.get("branch_index", 0) or 0,
human_version=json_meta.get("human_version"),
update_enabled=json_meta.get("update_enabled", True),
@ -191,9 +183,10 @@ class AddonManager:
"mod": {"type": "number", "meta": True},
# a list of other packages that conflict
"conflicts": {"type": "array", "items": {"type": "string"}, "meta": True},
# the minimum 2.1.x version this add-on supports
# x for anki 2.1.x; int_version() for more recent releases
"min_point_version": {"type": "number", "meta": True},
# if negative, abs(n) is the maximum 2.1.x version this add-on supports
# x for anki 2.1.x; int_version() for more recent releases
# if negative, abs(n) is the maximum version this add-on supports
# if positive, indicates version tested on, and is ignored
"max_point_version": {"type": "number", "meta": True},
# AnkiWeb sends this to indicate which branch the user downloaded.
@ -280,8 +273,8 @@ class AddonManager:
json_obj["disabled"] = not addon.enabled
json_obj["mod"] = addon.installed_at
json_obj["conflicts"] = addon.conflicts
json_obj["max_point_version"] = addon.max_point_version
json_obj["min_point_version"] = addon.min_point_version
json_obj["max_point_version"] = addon.max_version
json_obj["min_point_version"] = addon.min_version
json_obj["branch_index"] = addon.branch_index
if addon.human_version is not None:
json_obj["human_version"] = addon.human_version
@ -552,60 +545,39 @@ class AddonManager:
# Updating
######################################################################
def extract_update_info(self, items: list[dict]) -> list[UpdateInfo]:
def extract_one(item: dict) -> UpdateInfo:
id = item["id"]
meta = self.addon_meta(str(id))
branch_idx = meta.branch_index
return extract_update_info(current_point_version, branch_idx, item)
def update_supported_versions(self, items: list[AddonInfo]) -> None:
"""Adjust the supported version range after an update check.
return list(map(extract_one, items))
AnkiWeb will not have sent us any add-ons that don't support our
version, so this cannot disable add-ons that users are using. It
does allow the add-on author to mark an add-on as not supporting
a future release, causing the add-on to be disabled when the user
upgrades.
"""
def update_supported_versions(self, items: list[UpdateInfo]) -> None:
for item in items:
self.update_supported_version(item)
addon = self.addon_meta(str(item.id))
updated = False
def update_supported_version(self, item: UpdateInfo) -> None:
addon = self.addon_meta(str(item.id))
updated = False
is_latest = addon.is_latest(item.current_branch_last_modified)
# if max different to the stored value
cur_max = item.current_branch_max_point_ver
if addon.max_point_version != cur_max:
if is_latest:
addon.max_point_version = cur_max
if addon.max_version != item.max_version:
addon.max_version = item.max_version
updated = True
else:
# user is not up to date; only update if new version is stricter
if cur_max is not None and cur_max < addon.max_point_version:
addon.max_point_version = cur_max
updated = True
# if min different to the stored value
cur_min = item.current_branch_min_point_ver
if addon.min_point_version != cur_min:
if is_latest:
addon.min_point_version = cur_min
if addon.min_version != item.min_version:
addon.min_version = item.min_version
updated = True
else:
# user is not up to date; only update if new version is stricter
if cur_min is not None and cur_min > addon.min_point_version:
addon.min_point_version = cur_min
updated = True
if updated:
self.write_addon_meta(addon)
if updated:
self.write_addon_meta(addon)
def updates_required(self, items: list[UpdateInfo]) -> list[UpdateInfo]:
def get_updated_addons(self, items: list[AddonInfo]) -> list[AddonInfo]:
"""Return ids of add-ons requiring an update."""
need_update = []
for item in items:
addon = self.addon_meta(str(item.id))
# update if server mtime is newer
if not addon.is_latest(item.suitable_branch_last_modified):
if not addon.is_latest(item.modified):
need_update.append(item)
elif not addon.compatible() and item.suitable_branch_last_modified > 0:
elif not addon.compatible():
# Addon is currently disabled, and a suitable branch was found on the
# server. Ignore our stored mtime (which may have been set incorrectly
# in the past) and require an update.
@ -808,11 +780,11 @@ class AddonsDialog(QDialog):
return name
def compatible_string(self, addon: AddonMeta) -> str:
min = addon.min_point_version
if min is not None and min > current_point_version:
min = addon.min_version
if min is not None and min > _current_version:
return f"Anki >= 2.1.{min}"
else:
max = abs(addon.max_point_version)
max = abs(addon.max_version)
return f"Anki <= 2.1.{max}"
def should_grey(self, addon: AddonMeta) -> bool:
@ -1020,9 +992,7 @@ class GetAddons(QDialog):
def download_addon(client: HttpClient, id: int) -> DownloadOk | DownloadError:
"Fetch a single add-on from AnkiWeb."
try:
resp = client.get(
f"{aqt.appShared}download/{id}?v=2.1&p={current_point_version}"
)
resp = client.get(f"{aqt.appShared}download/{id}?v=2.1&p={_current_version}")
if resp.status_code != 200:
return DownloadError(status_code=resp.status_code)
@ -1230,13 +1200,11 @@ class ChooseAddonsToUpdateList(QListWidget):
self,
parent: QWidget,
mgr: AddonManager,
updated_addons: list[UpdateInfo],
updated_addons: list[AddonInfo],
) -> None:
QListWidget.__init__(self, parent)
self.mgr = mgr
self.updated_addons = sorted(
updated_addons, key=lambda addon: addon.suitable_branch_last_modified
)
self.updated_addons = sorted(updated_addons, key=lambda addon: addon.modified)
self.ignore_check_evt = False
self.setup()
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
@ -1256,7 +1224,7 @@ class ChooseAddonsToUpdateList(QListWidget):
addon_meta = self.mgr.addon_meta(str(addon_id))
update_enabled = addon_meta.update_enabled
addon_name = addon_meta.human_name()
update_timestamp = update_info.suitable_branch_last_modified
update_timestamp = update_info.modified
update_time = datetime.fromtimestamp(update_timestamp)
addon_label = f"{update_time:%Y-%m-%d} {addon_name}"
@ -1344,7 +1312,7 @@ class ChooseAddonsToUpdateList(QListWidget):
class ChooseAddonsToUpdateDialog(QDialog):
def __init__(
self, parent: QWidget, mgr: AddonManager, updated_addons: list[UpdateInfo]
self, parent: QWidget, mgr: AddonManager, updated_addons: list[AddonInfo]
) -> None:
QDialog.__init__(self, parent)
self.setWindowTitle(tr.addons_choose_update_window_title())
@ -1386,32 +1354,25 @@ class ChooseAddonsToUpdateDialog(QDialog):
return []
def fetch_update_info(client: HttpClient, ids: list[int]) -> list[dict]:
def fetch_update_info(ids: list[int]) -> list[AddonInfo]:
"""Fetch update info from AnkiWeb in one or more batches."""
all_info: list[dict] = []
all_info: list[AddonInfo] = []
while ids:
# get another chunk
chunk = ids[:25]
del ids[:25]
batch_results = _fetch_update_info_batch(client, map(str, chunk))
batch_results = _fetch_update_info_batch(chunk)
all_info.extend(batch_results)
return all_info
def _fetch_update_info_batch(
client: HttpClient, chunk: Iterable[str]
) -> Iterable[dict]:
"""Get update info from AnkiWeb.
Chunk must not contain more than 25 ids."""
resp = client.get(f"{aqt.appShared}updates/{','.join(chunk)}?v=3")
if resp.status_code == 200:
return resp.json()
else:
raise Exception(f"Unexpected response code from AnkiWeb: {resp.status_code}")
def _fetch_update_info_batch(chunk: Iterable[int]) -> Sequence[AddonInfo]:
return aqt.mw.backend.get_addon_info(
client_version=_current_version, addon_ids=chunk
)
def check_and_prompt_for_updates(
@ -1420,19 +1381,17 @@ def check_and_prompt_for_updates(
on_done: Callable[[list[DownloadLogEntry]], None],
requested_by_user: bool = True,
) -> None:
def on_updates_received(client: HttpClient, items: list[dict]) -> None:
handle_update_info(parent, mgr, client, items, on_done, requested_by_user)
def on_updates_received(items: list[AddonInfo]) -> None:
handle_update_info(parent, mgr, items, on_done, requested_by_user)
check_for_updates(mgr, on_updates_received)
def check_for_updates(
mgr: AddonManager, on_done: Callable[[HttpClient, list[dict]], None]
mgr: AddonManager, on_done: Callable[[list[AddonInfo]], None]
) -> None:
client = HttpClient()
def check() -> list[dict]:
return fetch_update_info(client, mgr.ankiweb_addons())
def check() -> list[AddonInfo]:
return fetch_update_info(mgr.ankiweb_addons())
def update_info_received(future: Future) -> None:
# if syncing/in profile screen, defer message delivery
@ -1451,66 +1410,36 @@ def check_for_updates(
else:
result = future.result()
on_done(client, result)
on_done(result)
mgr.mw.taskman.run_in_background(check, update_info_received)
def extract_update_info(
current_point_version: int, current_branch_idx: int, info_json: dict
) -> UpdateInfo:
"Process branches to determine the updated mod time and min/max versions."
branches = info_json["branches"]
try:
current = branches[current_branch_idx]
except IndexError:
current = branches[0]
last_mod = 0
for branch in branches:
if branch["minpt"] > current_point_version:
continue
if branch["maxpt"] < 0 and abs(branch["maxpt"]) < current_point_version:
continue
last_mod = branch["fmod"]
return UpdateInfo(
id=info_json["id"],
suitable_branch_last_modified=last_mod,
current_branch_last_modified=current["fmod"],
current_branch_min_point_ver=current["minpt"],
current_branch_max_point_ver=current["maxpt"],
)
def handle_update_info(
parent: QWidget,
mgr: AddonManager,
client: HttpClient,
items: list[dict],
items: list[AddonInfo],
on_done: Callable[[list[DownloadLogEntry]], None],
requested_by_user: bool = True,
) -> None:
update_info = mgr.extract_update_info(items)
mgr.update_supported_versions(update_info)
updated_addons = mgr.updates_required(update_info)
mgr.update_supported_versions(items)
updated_addons = mgr.get_updated_addons(items)
if not updated_addons:
on_done([])
return
prompt_to_update(parent, mgr, client, updated_addons, on_done, requested_by_user)
prompt_to_update(parent, mgr, updated_addons, on_done, requested_by_user)
def prompt_to_update(
parent: QWidget,
mgr: AddonManager,
client: HttpClient,
updated_addons: list[UpdateInfo],
updated_addons: list[AddonInfo],
on_done: Callable[[list[DownloadLogEntry]], None],
requested_by_user: bool = True,
) -> None:
client = HttpClient()
if not requested_by_user:
prompt_update = False
for addon in updated_addons:

View File

@ -37,10 +37,10 @@ from anki.utils import (
dev_mode,
ids2str,
int_time,
int_version,
is_lin,
is_mac,
is_win,
point_version,
split_fields,
)
from aqt import gui_hooks
@ -954,7 +954,7 @@ title="{}" {}>{}</button>""".format(
if on_done:
on_done()
if elap > 86_400 or self.pm.last_run_version != point_version():
if elap > 86_400 or self.pm.last_run_version != int_version():
check_and_prompt_for_updates(
self,
self.addonManager,
@ -1401,29 +1401,9 @@ title="{}" {}>{}</button>""".format(
##########################################################################
def setupAutoUpdate(self) -> None:
import aqt.update
from aqt.update import check_for_update
self.autoUpdate = aqt.update.LatestVersionFinder(self)
qconnect(self.autoUpdate.newVerAvail, self.newVerAvail)
qconnect(self.autoUpdate.newMsg, self.newMsg)
qconnect(self.autoUpdate.clockIsOff, self.clockIsOff)
self.autoUpdate.start()
def newVerAvail(self, ver: str) -> None:
if self.pm.meta.get("suppressUpdate", None) != ver:
aqt.update.askAndUpdate(self, ver)
def newMsg(self, data: dict) -> None:
aqt.update.showMessages(self, data)
def clockIsOff(self, diff: int) -> None:
if dev_mode:
print("clock is off; ignoring")
return
diffText = tr.qt_misc_second(count=diff)
warn = tr.qt_misc_in_order_to_ensure_your_collection(val="%s") % diffText
showWarning(warn)
self.app.closeAllWindows()
check_for_update()
# Timers
##########################################################################

View File

@ -20,7 +20,7 @@ from anki.collection import Collection
from anki.db import DB
from anki.lang import without_unicode_isolation
from anki.sync import SyncAuth
from anki.utils import int_time, is_mac, is_win, point_version
from anki.utils import int_time, int_version, is_mac, is_win
from aqt import appHelpSite, gui_hooks
from aqt.qt import *
from aqt.theme import Theme, WidgetStyle, theme_manager
@ -81,7 +81,7 @@ metaConf = dict(
updates=True,
created=int_time(),
id=random.randrange(0, 2**63),
lastMsg=-1,
lastMsg=0,
suppressUpdate=False,
firstRun=True,
defaultLang=None,
@ -134,7 +134,7 @@ class ProfileManager:
res = self._loadMeta()
self.firstRun = res.firstTime
self.last_run_version = self.meta.get("last_run_version", self.last_run_version)
self.meta["last_run_version"] = point_version()
self.meta["last_run_version"] = int_version()
return res
# -p profile provided on command line.

View File

@ -1,77 +1,75 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import time
from typing import Any
import requests
import aqt
from anki.utils import plat_desc, version_with_build
from aqt.main import AnkiQt
from anki.buildinfo import buildhash
from anki.collection import CheckForUpdateResponse, Collection
from anki.utils import dev_mode, int_time, int_version, plat_desc
from aqt.operations import QueryOp
from aqt.qt import *
from aqt.utils import openLink, showText, tr
from aqt.utils import openLink, show_warning, showText, tr
class LatestVersionFinder(QThread):
newVerAvail = pyqtSignal(str)
newMsg = pyqtSignal(dict)
clockIsOff = pyqtSignal(float)
def check_for_update() -> None:
from aqt import mw
def __init__(self, main: AnkiQt) -> None:
QThread.__init__(self)
self.main = main
self.config = main.pm.meta
def do_check(_col: Collection) -> CheckForUpdateResponse:
return mw.backend.check_for_update(
version=int_version(),
buildhash=buildhash,
os=plat_desc(),
install_id=mw.pm.meta["id"],
last_message_id=max(0, mw.pm.meta["lastMsg"]),
)
def _data(self) -> dict[str, Any]:
return {
"ver": version_with_build(),
"os": plat_desc(),
"id": self.config["id"],
"lm": self.config["lastMsg"],
"crt": self.config["created"],
}
def on_done(resp: CheckForUpdateResponse) -> None:
# is clock off?
if not dev_mode:
diff = abs(resp.current_time - int_time())
if diff > 300:
diff_text = tr.qt_misc_second(count=diff)
warn = (
tr.qt_misc_in_order_to_ensure_your_collection(val="%s") % diff_text
)
show_warning(warn)
mw.app.closeAllWindows()
return
# should we show a message?
if msg := resp.message:
showText(msg, parent=mw, type="html")
mw.pm.meta["lastMsg"] = resp.last_message_id
# has Anki been updated?
if ver := resp.new_version:
prompt_to_update(mw, ver)
def run(self) -> None:
if not self.config["updates"]:
return
d = self._data()
d["proto"] = 1
def on_fail(exc: Exception) -> None:
print(f"update check failed: {exc}")
try:
r = requests.post(aqt.appUpdate, data=d, timeout=60)
r.raise_for_status()
resp = r.json()
except:
# behind proxy, corrupt message, etc
print("update check failed")
return
if resp["msg"]:
self.newMsg.emit(resp) # type: ignore
if resp["ver"]:
self.newVerAvail.emit(resp["ver"]) # type: ignore
diff = resp["time"] - time.time()
if abs(diff) > 300:
self.clockIsOff.emit(diff) # type: ignore
QueryOp(parent=mw, op=do_check, success=on_done).failure(
on_fail
).run_in_background()
def askAndUpdate(mw: aqt.AnkiQt, ver: str) -> None:
baseStr = tr.qt_misc_anki_updatedanki_has_been_released(val=ver)
msg = QMessageBox(mw)
msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) # type: ignore
msg.setIcon(QMessageBox.Icon.Information)
msg.setText(baseStr + tr.qt_misc_would_you_like_to_download_it())
def prompt_to_update(mw: aqt.AnkiQt, ver: str) -> None:
msg = (
tr.qt_misc_anki_updatedanki_has_been_released(val=ver)
+ tr.qt_misc_would_you_like_to_download_it()
)
msgbox = QMessageBox(mw)
msgbox.setStandardButtons(
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
msgbox.setIcon(QMessageBox.Icon.Information)
msgbox.setText(msg)
button = QPushButton(tr.qt_misc_ignore_this_update())
msg.addButton(button, QMessageBox.ButtonRole.RejectRole)
msg.setDefaultButton(QMessageBox.StandardButton.Yes)
ret = msg.exec()
if msg.clickedButton() == button:
msgbox.addButton(button, QMessageBox.ButtonRole.RejectRole)
msgbox.setDefaultButton(QMessageBox.StandardButton.Yes)
ret = msgbox.exec()
if msgbox.clickedButton() == button:
# ignore this update
mw.pm.meta["suppressUpdate"] = ver
elif ret == QMessageBox.StandardButton.Yes:
openLink(aqt.appWebsiteDownloadSection)
def showMessages(mw: aqt.AnkiQt, data: dict) -> None:
showText(data["msg"], parent=mw, type="html")
mw.pm.meta["lastMsg"] = data["msgId"]

View File

@ -7,7 +7,7 @@ from zipfile import ZipFile
from mock import MagicMock
from aqt.addons import AddonManager, extract_update_info, package_name_valid
from aqt.addons import AddonManager, package_name_valid
def test_readMinimalManifest():
@ -69,33 +69,6 @@ def assertReadManifest(contents, expectedManifest, nameInZip="manifest.json"):
assert adm.readManifestFile(zfile) == expectedManifest
def test_update_info():
json_info = dict(
id=999,
branches=[
{"minpt": 0, "maxpt": -15, "fmod": 222},
{"minpt": 20, "maxpt": -25, "fmod": 333},
{"minpt": 30, "maxpt": 35, "fmod": 444},
],
)
r = extract_update_info(5, 0, json_info)
assert r.current_branch_max_point_ver == -15
assert r.suitable_branch_last_modified == 222
r = extract_update_info(5, 1, json_info)
assert r.current_branch_max_point_ver == -25
assert r.suitable_branch_last_modified == 222
r = extract_update_info(19, 1, json_info)
assert r.current_branch_max_point_ver == -25
assert r.suitable_branch_last_modified == 0
r = extract_update_info(20, 1, json_info)
assert r.current_branch_max_point_ver == -25
assert r.suitable_branch_last_modified == 333
def test_package_name_validation():
assert not package_name_valid("")
assert not package_name_valid("/")

View File

@ -33,6 +33,7 @@ syn.workspace = true
[dev-dependencies]
async-stream.workspace = true
reqwest = { workspace = true, features = ["native-tls"] }
wiremock.workspace = true
[dependencies]

View File

@ -124,7 +124,7 @@ fn build_method_arguments(input: &MessageDescriptor) -> String {
args.push("*".to_string());
}
for field in fields {
let arg = format!("{}: {}", field.name(), python_type(&field));
let arg = format!("{}: {}", field.name(), python_type(&field, false));
args.push(arg);
}
args.join(", ")
@ -150,14 +150,17 @@ fn maybe_destructured_output(output: &MessageDescriptor) -> (String, String) {
if output.fields().len() == 1 && !matches!(first_field.as_ref().unwrap().kind(), Kind::Enum(_))
{
let field = first_field.unwrap();
(format!("output.{}", field.name()), python_type(&field))
(
format!("output.{}", field.name()),
python_type(&field, true),
)
} else {
("output".into(), full_name_to_python(output.full_name()))
}
}
/// e.g. uint32 -> int; repeated bool -> Sequence[bool]
fn python_type(field: &FieldDescriptor) -> String {
fn python_type(field: &FieldDescriptor, output: bool) -> String {
let kind = match field.kind() {
Kind::Int32
| Kind::Int64
@ -177,11 +180,15 @@ fn python_type(field: &FieldDescriptor) -> String {
Kind::Enum(en) => format!("{}.V", full_name_to_python(en.full_name())),
};
if field.is_list() {
format!("Sequence[{}]", kind)
if output {
format!("Sequence[{}]", kind)
} else {
format!("Iterable[{}]", kind)
}
} else if field.is_map() {
let map_kind = field.kind();
let map_kind = map_kind.as_message().unwrap();
let map_kv: Vec<_> = map_kind.fields().map(|f| python_type(&f)).collect();
let map_kv: Vec<_> = map_kind.fields().map(|f| python_type(&f, output)).collect();
format!("Mapping[{}, {}]", map_kv[0], map_kv[1])
} else {
kind
@ -220,6 +227,7 @@ col.decks.all_config()
from typing import *
import anki
import anki.ankiweb_pb2
import anki.backend_pb2
import anki.card_rendering_pb2
import anki.cards_pb2

View File

@ -15,6 +15,7 @@ macro_rules! protobuf {
}
protobuf!(ankidroid, "ankidroid");
protobuf!(ankiweb, "ankiweb");
protobuf!(backend, "backend");
protobuf!(card_rendering, "card_rendering");
protobuf!(cards, "cards");

View File

@ -0,0 +1,72 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use std::time::Duration;
use anki_proto::ankiweb::CheckForUpdateRequest;
use anki_proto::ankiweb::CheckForUpdateResponse;
use anki_proto::ankiweb::GetAddonInfoRequest;
use anki_proto::ankiweb::GetAddonInfoResponse;
use prost::Message;
use super::Backend;
use crate::prelude::*;
use crate::services::BackendAnkiwebService;
fn service_url(service: &str) -> String {
format!("https://ankiweb.net/svc/{service}")
}
impl Backend {
fn post<I, O>(&self, service: &str, input: I) -> Result<O>
where
I: Message,
O: Message + Default,
{
self.runtime_handle().block_on(async move {
let out = self
.web_client()
.post(service_url(service))
.body(input.encode_to_vec())
.timeout(Duration::from_secs(60))
.send()
.await?
.error_for_status()?
.bytes()
.await?;
let out: O = O::decode(&out[..])?;
Ok(out)
})
}
}
impl BackendAnkiwebService for Backend {
fn get_addon_info(&self, input: GetAddonInfoRequest) -> Result<GetAddonInfoResponse> {
self.post("desktop/addon-info", input)
}
fn check_for_update(&self, input: CheckForUpdateRequest) -> Result<CheckForUpdateResponse> {
self.post("desktop/check-for-update", input)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn addon_info() -> Result<()> {
if std::env::var("ONLINE_TESTS").is_err() {
println!("test disabled; ONLINE_TESTS not set");
return Ok(());
}
let backend = Backend::new(I18n::template_only(), false);
let info = backend.get_addon_info(GetAddonInfoRequest {
client_version: 30,
addon_ids: vec![3918629684],
})?;
assert_eq!(info.info[0].min_version, 0);
assert_eq!(info.info[0].max_version, 49);
Ok(())
}
}

View File

@ -3,6 +3,7 @@
mod adding;
mod ankidroid;
mod ankiweb;
mod card_rendering;
mod collection;
mod config;
@ -20,6 +21,7 @@ use std::thread::JoinHandle;
use once_cell::sync::OnceCell;
use prost::Message;
use reqwest::Client;
use tokio::runtime;
use tokio::runtime::Runtime;
@ -40,6 +42,7 @@ pub struct Backend {
runtime: OnceCell<Runtime>,
state: Arc<Mutex<BackendState>>,
backup_task: Arc<Mutex<Option<JoinHandle<Result<()>>>>>,
web_client: OnceCell<Client>,
}
#[derive(Default)]
@ -73,6 +76,7 @@ impl Backend {
runtime: OnceCell::new(),
state: Arc::new(Mutex::new(BackendState::default())),
backup_task: Arc::new(Mutex::new(None)),
web_client: OnceCell::new(),
}
}
@ -118,6 +122,12 @@ impl Backend {
.clone()
}
fn web_client(&self) -> &Client {
// currently limited to http1, as nginx doesn't support http2 proxies
self.web_client
.get_or_init(|| Client::builder().http1_only().build().unwrap())
}
fn db_command(&self, input: &[u8]) -> Result<Vec<u8>> {
self.with_col(|col| db_command_bytes(col, input))
}

View File

@ -198,7 +198,7 @@ impl Backend {
(col.media()?, col.new_progress_handler())
};
let rt = self.runtime_handle();
let sync_fut = mgr.sync_media(progress, auth);
let sync_fut = mgr.sync_media(progress, auth, self.web_client().clone());
let abortable_sync = Abortable::new(sync_fut, abort_reg);
let result = rt.block_on(abortable_sync);
@ -238,7 +238,12 @@ impl Backend {
let (_guard, abort_reg) = self.sync_abort_handle()?;
let rt = self.runtime_handle();
let sync_fut = sync_login(input.username, input.password, input.endpoint);
let sync_fut = sync_login(
input.username,
input.password,
input.endpoint,
self.web_client().clone(),
);
let abortable_sync = Abortable::new(sync_fut, abort_reg);
let ret = match rt.block_on(abortable_sync) {
Ok(sync_result) => sync_result,
@ -276,7 +281,7 @@ impl Backend {
let rt = self.runtime_handle();
let time_at_check_begin = TimestampSecs::now();
let local = self.with_col(|col| col.sync_meta())?;
let mut client = HttpSyncClient::new(auth);
let mut client = HttpSyncClient::new(auth, self.web_client().clone());
let state = rt.block_on(online_sync_status_check(local, &mut client))?;
{
let mut guard = self.state.lock().unwrap();
@ -301,9 +306,10 @@ impl Backend {
let (_guard, abort_reg) = self.sync_abort_handle()?;
let rt = self.runtime_handle();
let client = self.web_client().clone();
let ret = self.with_col(|col| {
let sync_fut = col.normal_sync(auth.clone());
let sync_fut = col.normal_sync(auth.clone(), client.clone());
let abortable_sync = Abortable::new(sync_fut, abort_reg);
match rt.block_on(abortable_sync) {
@ -313,7 +319,7 @@ impl Backend {
col.storage.rollback_trx()?;
// and tell AnkiWeb to clean up
let _handle = std::thread::spawn(move || {
let _ = rt.block_on(sync_abort(auth));
let _ = rt.block_on(sync_abort(auth, client));
});
Err(AnkiError::Interrupted)
@ -353,11 +359,11 @@ impl Backend {
let mut builder = col_inner.as_builder();
let result = if upload {
let sync_fut = col_inner.full_upload(auth);
let sync_fut = col_inner.full_upload(auth, self.web_client().clone());
let abortable_sync = Abortable::new(sync_fut, abort_reg);
rt.block_on(abortable_sync)
} else {
let sync_fut = col_inner.full_download(auth);
let sync_fut = col_inner.full_download(auth, self.web_client().clone());
let abortable_sync = Abortable::new(sync_fut, abort_reg);
rt.block_on(abortable_sync)
};

View File

@ -11,6 +11,7 @@ use std::path::Path;
use std::path::PathBuf;
use anki_io::create_dir_all;
use reqwest::Client;
use crate::media::files::add_data_to_folder_uniquely;
use crate::media::files::mtime_as_i64;
@ -145,8 +146,9 @@ impl MediaManager {
self,
progress: ThrottlingProgressHandler<MediaSyncProgress>,
auth: SyncAuth,
client: Client,
) -> Result<()> {
let client = HttpSyncClient::new(auth);
let client = HttpSyncClient::new(auth, client);
let mut syncer = MediaSyncer::new(self, progress, client)?;
syncer.sync().await
}

View File

@ -5,6 +5,7 @@ use anki_io::atomic_rename;
use anki_io::new_tempfile_in_parent_of;
use anki_io::read_file;
use anki_io::write_file;
use reqwest::Client;
use crate::collection::CollectionBuilder;
use crate::prelude::*;
@ -17,8 +18,8 @@ use crate::sync::login::SyncAuth;
impl Collection {
/// Download collection from AnkiWeb. Caller must re-open afterwards.
pub async fn full_download(self, auth: SyncAuth) -> Result<()> {
self.full_download_with_server(HttpSyncClient::new(auth))
pub async fn full_download(self, auth: SyncAuth, client: Client) -> Result<()> {
self.full_download_with_server(HttpSyncClient::new(auth, client))
.await
}

View File

@ -1,6 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use reqwest::Client;
use tracing::debug;
use crate::collection::Collection;
@ -152,8 +153,12 @@ impl From<ClientSyncState> for SyncOutput {
}
impl Collection {
pub async fn normal_sync(&mut self, auth: SyncAuth) -> error::Result<SyncOutput> {
NormalSyncer::new(self, HttpSyncClient::new(auth))
pub async fn normal_sync(
&mut self,
auth: SyncAuth,
client: Client,
) -> error::Result<SyncOutput> {
NormalSyncer::new(self, HttpSyncClient::new(auth, client))
.sync()
.await
}

View File

@ -1,6 +1,8 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use reqwest::Client;
use crate::error;
use crate::sync::collection::protocol::EmptyInput;
use crate::sync::collection::protocol::SyncProtocol;
@ -21,8 +23,8 @@ pub struct FullSyncProgress {
pub total_bytes: usize,
}
pub async fn sync_abort(auth: SyncAuth) -> error::Result<()> {
HttpSyncClient::new(auth)
pub async fn sync_abort(auth: SyncAuth, client: Client) -> error::Result<()> {
HttpSyncClient::new(auth, client)
.abort(EmptyInput::request())
.await?
.json()

View File

@ -7,6 +7,7 @@ use std::future::Future;
use axum::http::StatusCode;
use once_cell::sync::Lazy;
use reqwest::Client;
use reqwest::Url;
use serde_json::json;
use tempfile::tempdir;
@ -106,7 +107,7 @@ where
endpoint: Some(endpoint),
io_timeout_secs: None,
};
let client = HttpSyncClient::new(auth);
let client = HttpSyncClient::new(auth, Client::new());
op(client).await
}

View File

@ -12,6 +12,7 @@ use axum::response::Response;
use flate2::write::GzEncoder;
use flate2::Compression;
use futures::StreamExt;
use reqwest::Client;
use tokio_util::io::ReaderStream;
use crate::collection::CollectionBuilder;
@ -32,8 +33,8 @@ pub const CORRUPT_MESSAGE: &str =
impl Collection {
/// Upload collection to AnkiWeb. Caller must re-open afterwards.
pub async fn full_upload(self, auth: SyncAuth) -> Result<()> {
self.full_upload_with_server(HttpSyncClient::new(auth))
pub async fn full_upload(self, auth: SyncAuth, client: Client) -> Result<()> {
self.full_upload_with_server(HttpSyncClient::new(auth, client))
.await
}

View File

@ -34,12 +34,12 @@ pub struct HttpSyncClient {
}
impl HttpSyncClient {
pub fn new(auth: SyncAuth) -> HttpSyncClient {
pub fn new(auth: SyncAuth, client: Client) -> HttpSyncClient {
let io_timeout = Duration::from_secs(auth.io_timeout_secs.unwrap_or(30) as u64);
HttpSyncClient {
sync_key: auth.hkey,
session_key: simple_session_id(),
client: Client::builder().http1_only().build().unwrap(),
client,
endpoint: auth
.endpoint
.unwrap_or_else(|| Url::try_from("https://sync.ankiweb.net/").unwrap()),

View File

@ -1,6 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use reqwest::Client;
use reqwest::Url;
use serde::Deserialize;
use serde::Serialize;
@ -34,13 +35,14 @@ pub async fn sync_login<S: Into<String>>(
username: S,
password: S,
endpoint: Option<String>,
client: Client,
) -> Result<SyncAuth> {
let auth = anki_proto::sync::SyncAuth {
endpoint,
..Default::default()
}
.try_into()?;
let client = HttpSyncClient::new(auth);
let client = HttpSyncClient::new(auth, client);
let resp = client
.host_key(
HostKeyRequest {