ankiweb-api 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ankicli.py +214 -0
- ankiweb_api-1.0.0.dist-info/METADATA +574 -0
- ankiweb_api-1.0.0.dist-info/RECORD +14 -0
- ankiweb_api-1.0.0.dist-info/WHEEL +4 -0
- ankiweb_api-1.0.0.dist-info/entry_points.txt +4 -0
- ankiweb_api-1.0.0.dist-info/licenses/LICENSE +21 -0
- ankiweb_auth.py +148 -0
- ankiweb_client.py +405 -0
- ankiweb_engine/index.js +44894 -0
- ankiweb_proto.py +372 -0
- ankiweb_schema.py +49 -0
- make_test_addon.py +66 -0
- terminal_ankiweb.py +342 -0
- upload.py +195 -0
ankiweb_auth.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""AnkiWeb session login for read endpoints that need auth (e.g. list-mine).
|
|
2
|
+
|
|
3
|
+
Uploads keep using the upstream Node engine (dist/index.js). This module only
|
|
4
|
+
adds the login step needed to call authenticated *read* endpoints from Python.
|
|
5
|
+
|
|
6
|
+
Wire format from upstream proto/ankiweb.proto:
|
|
7
|
+
POST /svc/account/login
|
|
8
|
+
LoginRequest{username=1, password=2} -> LoginResponse{status=1, token=2}
|
|
9
|
+
status: 0 UNKNOWN, 1 AUTHENTICATED, 2 INVALID_USER, 3 INVALID_PASS
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
BASE = "https://ankiweb.net"
|
|
19
|
+
LOGIN_URL = f"{BASE}/svc/account/login"
|
|
20
|
+
|
|
21
|
+
LOGIN_STATUS = {
|
|
22
|
+
0: "UNKNOWN",
|
|
23
|
+
1: "AUTHENTICATED",
|
|
24
|
+
2: "INVALID_USER",
|
|
25
|
+
3: "INVALID_PASS",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AuthError(RuntimeError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _varint(n: int) -> bytes:
|
|
34
|
+
out = bytearray()
|
|
35
|
+
while True:
|
|
36
|
+
b = n & 0x7F
|
|
37
|
+
n >>= 7
|
|
38
|
+
if n:
|
|
39
|
+
out.append(b | 0x80)
|
|
40
|
+
else:
|
|
41
|
+
out.append(b)
|
|
42
|
+
return bytes(out)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _field_str(field: int, text: str) -> bytes:
|
|
46
|
+
raw = text.encode("utf-8")
|
|
47
|
+
return _varint((field << 3) | 2) + _varint(len(raw)) + raw
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _read_varint(buf: bytes, pos: int) -> tuple[int, int]:
|
|
51
|
+
shift = 0
|
|
52
|
+
result = 0
|
|
53
|
+
while True:
|
|
54
|
+
if pos >= len(buf):
|
|
55
|
+
raise ValueError("truncated varint")
|
|
56
|
+
b = buf[pos]
|
|
57
|
+
pos += 1
|
|
58
|
+
result |= (b & 0x7F) << shift
|
|
59
|
+
shift += 7
|
|
60
|
+
if not (b & 0x80):
|
|
61
|
+
return result, pos
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def encode_login_request(username: str, password: str) -> bytes:
|
|
65
|
+
return _field_str(1, username) + _field_str(2, password)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def decode_login_response(buf: bytes) -> tuple[int, str]:
|
|
69
|
+
status, token = 0, ""
|
|
70
|
+
pos = 0
|
|
71
|
+
while pos < len(buf):
|
|
72
|
+
key, pos = _read_varint(buf, pos)
|
|
73
|
+
field, wire = key >> 3, key & 7
|
|
74
|
+
if wire == 0:
|
|
75
|
+
v, pos = _read_varint(buf, pos)
|
|
76
|
+
if field == 1:
|
|
77
|
+
status = v
|
|
78
|
+
elif wire == 2:
|
|
79
|
+
ln, pos = _read_varint(buf, pos)
|
|
80
|
+
if field == 2:
|
|
81
|
+
token = buf[pos : pos + ln].decode("utf-8", "replace")
|
|
82
|
+
pos += ln
|
|
83
|
+
else:
|
|
84
|
+
raise ValueError(f"unexpected wire type {wire}")
|
|
85
|
+
return status, token
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def default_env_file():
|
|
89
|
+
"""Best-guess `.env` location: current directory first, then this file's dir.
|
|
90
|
+
|
|
91
|
+
Running from a clone (`./ankicli.py`) wants the repo's `.env`; running an
|
|
92
|
+
installed console script wants `./.env` from wherever the user invoked it,
|
|
93
|
+
since site-packages is not a sane place to keep credentials.
|
|
94
|
+
"""
|
|
95
|
+
import os as _os
|
|
96
|
+
from pathlib import Path as _Path
|
|
97
|
+
|
|
98
|
+
cwd_env = _Path.cwd() / ".env"
|
|
99
|
+
if cwd_env.is_file():
|
|
100
|
+
return cwd_env
|
|
101
|
+
return _Path(__file__).resolve().parent / ".env"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def load_dotenv(path) -> None:
|
|
105
|
+
"""Minimal .env loader (no dependency). Existing env vars win."""
|
|
106
|
+
from pathlib import Path as _Path
|
|
107
|
+
|
|
108
|
+
p = _Path(path)
|
|
109
|
+
if not p.is_file():
|
|
110
|
+
return
|
|
111
|
+
for raw in p.read_text(encoding="utf-8").splitlines():
|
|
112
|
+
line = raw.strip()
|
|
113
|
+
if not line or line.startswith("#"):
|
|
114
|
+
continue
|
|
115
|
+
if line.startswith("export "):
|
|
116
|
+
line = line[7:].strip()
|
|
117
|
+
if "=" not in line:
|
|
118
|
+
continue
|
|
119
|
+
key, val = line.split("=", 1)
|
|
120
|
+
import os as _os
|
|
121
|
+
|
|
122
|
+
_os.environ.setdefault(key.strip(), val.strip().strip('"').strip("'"))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def login(username: str, password: str, timeout: int = 30) -> str:
|
|
126
|
+
"""Return the raw `ankiweb` session cookie value."""
|
|
127
|
+
r = requests.post(
|
|
128
|
+
LOGIN_URL,
|
|
129
|
+
data=encode_login_request(username, password),
|
|
130
|
+
headers={
|
|
131
|
+
"Accept": "*/*",
|
|
132
|
+
"Content-Type": "application/octet-stream",
|
|
133
|
+
"Referer": f"{BASE}/account/login",
|
|
134
|
+
},
|
|
135
|
+
timeout=timeout,
|
|
136
|
+
)
|
|
137
|
+
if r.status_code != 200:
|
|
138
|
+
raise AuthError(f"login HTTP {r.status_code}: {r.text[:300]}")
|
|
139
|
+
status, _token = decode_login_response(r.content)
|
|
140
|
+
if status != 1:
|
|
141
|
+
raise AuthError(f"login failed: {LOGIN_STATUS.get(status, status)}")
|
|
142
|
+
cookie = r.cookies.get("ankiweb")
|
|
143
|
+
if not cookie:
|
|
144
|
+
m = re.search(r"ankiweb=([^;]+)", r.headers.get("Set-Cookie", ""))
|
|
145
|
+
cookie = m.group(1) if m else None
|
|
146
|
+
if not cookie:
|
|
147
|
+
raise AuthError("login succeeded but no ankiweb cookie in response")
|
|
148
|
+
return cookie
|
ankiweb_client.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""AnkiWeb full API client - typed methods for all 41 public /svc/* endpoints.
|
|
2
|
+
|
|
3
|
+
Decoded from AnkiWeb's own SvelteKit frontend bundle (protobuf-es definitions);
|
|
4
|
+
wire format matches the official web app exactly. No ad-hoc parsing.
|
|
5
|
+
|
|
6
|
+
client = AnkiWebClient() # auto-loads .env
|
|
7
|
+
client.login() # or AnkiWebClient(cookie=...)
|
|
8
|
+
client.shared_list_mine() # your shared add-ons & decks
|
|
9
|
+
client.shared_item_info(shared_id=805854302)
|
|
10
|
+
|
|
11
|
+
Endpoint reference: docs/API.md and docs/ENDPOINTS.md.
|
|
12
|
+
Regenerate the schema with: python3 tools/generate_schema.py
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import requests
|
|
23
|
+
|
|
24
|
+
from ankiweb_proto import decode, encode, ProtoError
|
|
25
|
+
from ankiweb_schema import (
|
|
26
|
+
ENDPOINTS,
|
|
27
|
+
SCHEMA,
|
|
28
|
+
has_message,
|
|
29
|
+
public_endpoints,
|
|
30
|
+
endpoint,
|
|
31
|
+
)
|
|
32
|
+
from ankiweb_auth import (
|
|
33
|
+
AuthError,
|
|
34
|
+
default_env_file,
|
|
35
|
+
load_dotenv as auth_load_dotenv,
|
|
36
|
+
login as auth_login,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
BASE = "https://ankiweb.net"
|
|
40
|
+
SHARED = f"{BASE}/shared/"
|
|
41
|
+
|
|
42
|
+
SCHEMA_REVISION = "2026-10-10"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AnkiWebError(RuntimeError):
|
|
46
|
+
"""Network-level or HTTP error from AnkiWeb."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NotFoundError(AnkiWebError):
|
|
50
|
+
"""Resource doesn't exist or isn't public."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ValidationError(ValueError):
|
|
54
|
+
"""Invalid input for a known endpoint."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_NO_AUTH_PATHS = frozenset({
|
|
58
|
+
"/svc/account/login",
|
|
59
|
+
"/svc/account/signup",
|
|
60
|
+
"/svc/account/reset-password",
|
|
61
|
+
"/svc/account/resend-verification",
|
|
62
|
+
"/svc/account/verify-email",
|
|
63
|
+
"/svc/account/get-account-status",
|
|
64
|
+
"/svc/shared/item-info",
|
|
65
|
+
"/svc/shared/list-addons",
|
|
66
|
+
"/svc/shared/list-decks",
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
_ALIASES = {
|
|
70
|
+
"login": "account_login",
|
|
71
|
+
"list_mine": "shared_list_mine",
|
|
72
|
+
"item_info": "shared_item_info",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _method_name(path: str) -> str:
|
|
77
|
+
parts = [p for p in path.split("/") if p and p not in ("svc", "")]
|
|
78
|
+
if len(parts) == 1:
|
|
79
|
+
return parts[0]
|
|
80
|
+
return "_".join(parts[-2:]).replace("-", "_")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class AnkiWebClient:
|
|
84
|
+
"""High-level AnkiWeb API client (41 public endpoints as typed methods)."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, username=None, password=None, env_file=None,
|
|
87
|
+
cookie=None, timeout=30):
|
|
88
|
+
self._cookie = cookie
|
|
89
|
+
self._timeout = timeout
|
|
90
|
+
self._username = username
|
|
91
|
+
self._password = password
|
|
92
|
+
self._env_file = Path(env_file) if env_file else default_env_file()
|
|
93
|
+
auth_load_dotenv(self._env_file)
|
|
94
|
+
|
|
95
|
+
def login(self, username=None, password=None) -> str:
|
|
96
|
+
"""Authenticate and cache session cookie. Returns the cookie."""
|
|
97
|
+
u = username or self._username or os.environ.get("ANKIWEB_USERNAME", "")
|
|
98
|
+
p = password or self._password or os.environ.get("ANKIWEB_PASSWORD", "")
|
|
99
|
+
if not u or not p:
|
|
100
|
+
raise AuthError("username/password required (ANKIWEB_USERNAME/PASSWORD or explicit)")
|
|
101
|
+
self._cookie = auth_login(u, p, timeout=self._timeout)
|
|
102
|
+
return self._cookie
|
|
103
|
+
|
|
104
|
+
def _ensure_auth(self, path: str):
|
|
105
|
+
if path in _NO_AUTH_PATHS:
|
|
106
|
+
return self._cookie
|
|
107
|
+
if not self._cookie:
|
|
108
|
+
self.login()
|
|
109
|
+
return self._cookie
|
|
110
|
+
|
|
111
|
+
def logout(self) -> None:
|
|
112
|
+
self._cookie = None
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def is_authenticated(self) -> bool:
|
|
116
|
+
return self._cookie is not None
|
|
117
|
+
|
|
118
|
+
def _request(self, method, path, request_obj=None, response_name=None, query=None) -> dict:
|
|
119
|
+
ep = endpoint(path)
|
|
120
|
+
if ep is None:
|
|
121
|
+
raise AnkiWebError(f"Unknown endpoint: {path}")
|
|
122
|
+
headers = {"Accept": "*/*", "Content-Type": "application/octet-stream"}
|
|
123
|
+
cookie = self._ensure_auth(path)
|
|
124
|
+
if cookie and path not in ("/svc/account/login", "/svc/account/signup",
|
|
125
|
+
"/svc/account/reset-password", "/svc/account/resend-verification"):
|
|
126
|
+
headers["Cookie"] = f"has_auth=1; ankiweb={cookie}"
|
|
127
|
+
headers["Referer"] = f"{BASE}/shared/mine"
|
|
128
|
+
body = b""
|
|
129
|
+
if request_obj is not None and ep["request"]:
|
|
130
|
+
if not has_message(ep["request"]):
|
|
131
|
+
raise ValidationError(f"Request type {ep['request']} not in schema")
|
|
132
|
+
body = encode(ep["request"], request_obj)
|
|
133
|
+
qs = ""
|
|
134
|
+
if query:
|
|
135
|
+
qs = "?" + "&".join(f"{k}={v}" for k, v in query.items())
|
|
136
|
+
url = f"{BASE}{path}{qs}"
|
|
137
|
+
try:
|
|
138
|
+
r = requests.request(method, url, data=body, headers=headers, timeout=self._timeout)
|
|
139
|
+
except requests.RequestException as e:
|
|
140
|
+
raise AnkiWebError(f"Network error: {e}") from e
|
|
141
|
+
if r.status_code in (401, 403):
|
|
142
|
+
self._cookie = None
|
|
143
|
+
raise AnkiWebError("Session expired or invalid credentials")
|
|
144
|
+
if r.status_code == 404:
|
|
145
|
+
raise NotFoundError(f"404 for {path}")
|
|
146
|
+
if r.status_code != 200:
|
|
147
|
+
raise AnkiWebError(f"HTTP {r.status_code}: {r.text[:300]}")
|
|
148
|
+
if response_name is None:
|
|
149
|
+
response_name = ep["response"]
|
|
150
|
+
if not response_name or not has_message(response_name):
|
|
151
|
+
return {"raw": r.content, "endpoint": path}
|
|
152
|
+
try:
|
|
153
|
+
return decode(response_name, r.content)
|
|
154
|
+
except ProtoError as e:
|
|
155
|
+
raise AnkiWebError(f"Decode failed: {e}") from e
|
|
156
|
+
|
|
157
|
+
def _call(self, path, request=None, query=None, response_as=None) -> dict:
|
|
158
|
+
ep = endpoint(path)
|
|
159
|
+
if ep is None:
|
|
160
|
+
raise AnkiWebError(f"Unknown endpoint: {path}")
|
|
161
|
+
req_name = ep["request"]
|
|
162
|
+
if req_name and request is None:
|
|
163
|
+
raise ValidationError(f"{path} requires a request object ({req_name})")
|
|
164
|
+
if req_name and not has_message(req_name):
|
|
165
|
+
raise ValidationError(f"Request type {req_name} not in schema")
|
|
166
|
+
return self._request(method=ep["method"], path=path, request_obj=request,
|
|
167
|
+
response_name=response_as or ep["response"], query=query)
|
|
168
|
+
# ================= account (12) =================
|
|
169
|
+
|
|
170
|
+
def account_login(self, username: str, password: str) -> dict:
|
|
171
|
+
"""POST /svc/account/login (no session). -> {status, token}."""
|
|
172
|
+
return self._call("/svc/account/login", {"username": username, "password": password})
|
|
173
|
+
|
|
174
|
+
def account_check_terms(self) -> dict:
|
|
175
|
+
"""POST /svc/account/check-terms -> {needs_to_confirm}."""
|
|
176
|
+
return self._call("/svc/account/check-terms", {})
|
|
177
|
+
|
|
178
|
+
def account_confirm_terms(self, agree_to_terms: bool = True) -> dict:
|
|
179
|
+
"""POST /svc/account/confirm-terms (empty response)."""
|
|
180
|
+
return self._call("/svc/account/confirm-terms", {"agree_to_terms": agree_to_terms})
|
|
181
|
+
|
|
182
|
+
def account_get_account_status(self) -> dict:
|
|
183
|
+
"""POST /svc/account/get-account-status -> {logged_in, redirect_to?}."""
|
|
184
|
+
return self._call("/svc/account/get-account-status", {})
|
|
185
|
+
|
|
186
|
+
def account_get_settings(self) -> dict:
|
|
187
|
+
"""POST /svc/account/get-settings -> {current_email}."""
|
|
188
|
+
return self._call("/svc/account/get-settings", {})
|
|
189
|
+
|
|
190
|
+
def account_update_settings(self, current_password="", new_password="", new_email="") -> dict:
|
|
191
|
+
"""POST /svc/account/update-settings -> {result enum}."""
|
|
192
|
+
return self._call("/svc/account/update-settings", {
|
|
193
|
+
"current_password": current_password, "new_password": new_password,
|
|
194
|
+
"new_email": new_email})
|
|
195
|
+
|
|
196
|
+
def account_signup(self, username: str, password: str) -> dict:
|
|
197
|
+
"""POST /svc/account/signup (no session) -> {status, ankiuser_login_token?}."""
|
|
198
|
+
return self._call("/svc/account/signup", {"username": username, "password": password})
|
|
199
|
+
|
|
200
|
+
def account_verify_email(self, token="") -> dict:
|
|
201
|
+
"""POST /svc/account/verify-email -> {token_verified, sending_status}."""
|
|
202
|
+
return self._call("/svc/account/verify-email", {"token": token} if token else {})
|
|
203
|
+
|
|
204
|
+
def account_resend_verification(self, hkey="") -> dict:
|
|
205
|
+
"""POST /svc/account/resend-verification -> {throttled}."""
|
|
206
|
+
return self._call("/svc/account/resend-verification", {"hkey": hkey} if hkey else {})
|
|
207
|
+
|
|
208
|
+
def account_reset_password(self, email_to="", check_token="", new_password=None) -> dict:
|
|
209
|
+
"""POST /svc/account/reset-password -> {send_email, token_ok, set_new_password}."""
|
|
210
|
+
req = {"email_to": email_to, "check_token": check_token}
|
|
211
|
+
if new_password is not None:
|
|
212
|
+
req["new_password"] = new_password
|
|
213
|
+
return self._call("/svc/account/reset-password", req)
|
|
214
|
+
|
|
215
|
+
def account_total_media(self) -> dict:
|
|
216
|
+
"""POST /svc/account/total-media -> {total_bytes}."""
|
|
217
|
+
return self._call("/svc/account/total-media", {})
|
|
218
|
+
|
|
219
|
+
def account_remove_account(self, current_password: str) -> dict:
|
|
220
|
+
"""POST /svc/account/remove-account - IRREVERSIBLE -> {status, media_removed}."""
|
|
221
|
+
return self._call("/svc/account/remove-account", {"current_password": current_password})
|
|
222
|
+
|
|
223
|
+
# ================= decks (8) =================
|
|
224
|
+
|
|
225
|
+
def decks_create_deck(self, name: str) -> dict:
|
|
226
|
+
"""POST /svc/decks/create-deck (empty response)."""
|
|
227
|
+
return self._call("/svc/decks/create-deck", {"name": name})
|
|
228
|
+
|
|
229
|
+
def decks_deck_list_info(self, minutes_west_of_utc=None) -> dict:
|
|
230
|
+
"""POST /svc/decks/deck-list-info -> {top_node, current_deck_id, sizes}."""
|
|
231
|
+
req = {} if minutes_west_of_utc is None else {"minutes_west_of_utc": minutes_west_of_utc}
|
|
232
|
+
return self._call("/svc/decks/deck-list-info", req)
|
|
233
|
+
|
|
234
|
+
def decks_deck_share_info(self, deck_id: int) -> dict:
|
|
235
|
+
"""POST /svc/decks/deck-share-info -> {metadata, is_large_user, share_count}."""
|
|
236
|
+
return self._call("/svc/decks/deck-share-info", {"deck_id": deck_id})
|
|
237
|
+
|
|
238
|
+
def decks_deck_share(self, metadata: dict, confirm_copyright: bool = True) -> dict:
|
|
239
|
+
"""POST /svc/decks/deck-share (empty response)."""
|
|
240
|
+
return self._call("/svc/decks/deck-share",
|
|
241
|
+
{"metadata": metadata, "confirm_copyright": confirm_copyright})
|
|
242
|
+
|
|
243
|
+
def decks_deck_share_state(self) -> dict:
|
|
244
|
+
"""POST /svc/decks/deck-share-state -> {state, shared_id?}."""
|
|
245
|
+
return self._call("/svc/decks/deck-share-state", {})
|
|
246
|
+
|
|
247
|
+
def decks_rename_deck(self, deck_id: int, new_name: str) -> dict:
|
|
248
|
+
"""POST /svc/decks/rename-deck -> {error}."""
|
|
249
|
+
return self._call("/svc/decks/rename-deck", {"deck_id": deck_id, "new_name": new_name})
|
|
250
|
+
|
|
251
|
+
def decks_remove_deck(self, deck_id: int) -> dict:
|
|
252
|
+
"""POST /svc/decks/remove-deck (empty response)."""
|
|
253
|
+
return self._call("/svc/decks/remove-deck", {"deck_id": deck_id})
|
|
254
|
+
|
|
255
|
+
def decks_select_deck(self, deck_id: int) -> dict:
|
|
256
|
+
"""POST /svc/decks/select-deck (empty response)."""
|
|
257
|
+
return self._call("/svc/decks/select-deck", {"deck_id": deck_id})
|
|
258
|
+
|
|
259
|
+
# ================= editor (3) + search (1) =================
|
|
260
|
+
|
|
261
|
+
def editor_get_info_for_adding(self) -> dict:
|
|
262
|
+
"""POST /svc/editor/get-info-for-adding -> {notetypes[], decks[], fields[]...}."""
|
|
263
|
+
return self._call("/svc/editor/get-info-for-adding", {})
|
|
264
|
+
|
|
265
|
+
def editor_get_note_info(self, note_id: int) -> dict:
|
|
266
|
+
"""POST /svc/editor/get-note-info -> {fields[], notetype_fields[], tags}."""
|
|
267
|
+
return self._call("/svc/editor/get-note-info", {"note_id": note_id})
|
|
268
|
+
|
|
269
|
+
def editor_get_notetype_fields(self, notetype_id: int) -> dict:
|
|
270
|
+
"""POST /svc/editor/get-notetype-fields -> {fields[]}."""
|
|
271
|
+
return self._call("/svc/editor/get-notetype-fields", {"notetype_id": notetype_id})
|
|
272
|
+
|
|
273
|
+
def editor_add_or_update(self, request: dict) -> dict:
|
|
274
|
+
"""POST /svc/editor/add-or-update. See SCHEMA['EditorAddOrUpdateRequest']."""
|
|
275
|
+
return self._call("/svc/editor/add-or-update", request)
|
|
276
|
+
|
|
277
|
+
def search_search(self, search: str) -> dict:
|
|
278
|
+
"""POST /svc/search/search -> {notes[], error}."""
|
|
279
|
+
return self._call("/svc/search/search", {"search": search})
|
|
280
|
+
# ================= shared: public reads (5) =================
|
|
281
|
+
|
|
282
|
+
def shared_item_info(self, shared_id=None, query=None) -> dict:
|
|
283
|
+
"""GET /svc/shared/item-info?sharedId=<id> (no auth) -> {available, missing, access_denied}."""
|
|
284
|
+
q = dict(query or {})
|
|
285
|
+
if shared_id is not None:
|
|
286
|
+
q.setdefault("sharedId", shared_id)
|
|
287
|
+
if "sharedId" not in q:
|
|
288
|
+
raise ValidationError("item-info requires query={'sharedId': <id>}")
|
|
289
|
+
return self._call("/svc/shared/item-info", query=q)
|
|
290
|
+
|
|
291
|
+
def item_info(self, addon_id: int) -> dict:
|
|
292
|
+
"""Alias of shared_item_info(shared_id=addon_id)."""
|
|
293
|
+
return self.shared_item_info(shared_id=addon_id)
|
|
294
|
+
|
|
295
|
+
def shared_list_addons(self) -> dict:
|
|
296
|
+
"""GET /svc/shared/list-addons (no auth) -> {rows[], user_upload_status}."""
|
|
297
|
+
return self._call("/svc/shared/list-addons")
|
|
298
|
+
|
|
299
|
+
def shared_list_decks(self) -> dict:
|
|
300
|
+
"""GET /svc/shared/list-decks (no auth) -> {rows[]}."""
|
|
301
|
+
return self._call("/svc/shared/list-decks")
|
|
302
|
+
|
|
303
|
+
def shared_items_by_author(self, shared_id: int) -> dict:
|
|
304
|
+
"""POST /svc/shared/items-by-author -> {items[{shared_id, title}]}."""
|
|
305
|
+
return self._call("/svc/shared/items-by-author", {"shared_id": shared_id})
|
|
306
|
+
|
|
307
|
+
def shared_get_addon_info(self, addon_id: int) -> dict:
|
|
308
|
+
"""POST /svc/shared/get-addon-info -> {addon{...}}."""
|
|
309
|
+
return self._call("/svc/shared/get-addon-info", {"addon_id": addon_id})
|
|
310
|
+
|
|
311
|
+
# ================= shared: account reads/writes (7) =================
|
|
312
|
+
|
|
313
|
+
def shared_list_mine(self) -> dict:
|
|
314
|
+
"""POST /svc/shared/list-mine (auth) -> {items[], reviews[], expired_decks[]}."""
|
|
315
|
+
return self._call("/svc/shared/list-mine", {})
|
|
316
|
+
|
|
317
|
+
def list_mine(self) -> dict:
|
|
318
|
+
"""Alias of shared_list_mine()."""
|
|
319
|
+
return self.shared_list_mine()
|
|
320
|
+
|
|
321
|
+
def shared_remove_item(self, shared_id: int) -> dict:
|
|
322
|
+
"""POST /svc/shared/remove-item (empty response)."""
|
|
323
|
+
return self._call("/svc/shared/remove-item", {"shared_id": shared_id})
|
|
324
|
+
|
|
325
|
+
def shared_get_own_review(self, shared_id: int) -> dict:
|
|
326
|
+
"""POST /svc/shared/get-own-review -> {review, shared_item_title, support_page?}."""
|
|
327
|
+
return self._call("/svc/shared/get-own-review", {"shared_id": shared_id})
|
|
328
|
+
|
|
329
|
+
def shared_set_own_review(self, shared_id: int, review: dict) -> dict:
|
|
330
|
+
"""POST /svc/shared/set-own-review (empty response). review={body, thumbs_up}."""
|
|
331
|
+
return self._call("/svc/shared/set-own-review",
|
|
332
|
+
{"shared_id": shared_id, "review": review})
|
|
333
|
+
|
|
334
|
+
def shared_reply_to_review(self, shared_id: int, hash: int, body: str) -> dict:
|
|
335
|
+
"""POST /svc/shared/reply-to-review (empty response)."""
|
|
336
|
+
return self._call("/svc/shared/reply-to-review",
|
|
337
|
+
{"shared_Id": shared_id, "hash": hash, "body": body})
|
|
338
|
+
|
|
339
|
+
def shared_report_copyright(self, shared_id: int) -> dict:
|
|
340
|
+
"""POST /svc/shared/report-copyright (empty response)."""
|
|
341
|
+
return self._call("/svc/shared/report-copyright", {"shared_id": shared_id})
|
|
342
|
+
|
|
343
|
+
def shared_upload_addon(self, info: dict, file: dict | None = None) -> dict:
|
|
344
|
+
"""POST /svc/shared/upload-addon -> {addon_id}. Also see upload.py / Action."""
|
|
345
|
+
req = {"info": info}
|
|
346
|
+
if file is not None:
|
|
347
|
+
req["file"] = file
|
|
348
|
+
return self._call("/svc/shared/upload-addon", req)
|
|
349
|
+
|
|
350
|
+
# ================= study (4) =================
|
|
351
|
+
|
|
352
|
+
def study_get_deck_limits(self) -> dict:
|
|
353
|
+
"""POST /svc/study/get-deck-limits -> {new, review, new_cards_ignore_review_limit, error}."""
|
|
354
|
+
return self._call("/svc/study/get-deck-limits", {})
|
|
355
|
+
|
|
356
|
+
def study_set_deck_limits(self, new: int, review: int,
|
|
357
|
+
new_cards_ignore_review_limit: bool = False) -> dict:
|
|
358
|
+
"""POST /svc/study/set-deck-limits (empty response)."""
|
|
359
|
+
return self._call("/svc/study/set-deck-limits", {
|
|
360
|
+
"new": new, "review": review,
|
|
361
|
+
"new_cards_ignore_review_limit": new_cards_ignore_review_limit})
|
|
362
|
+
|
|
363
|
+
def study_study_cards(self, answer=None, next_card_id=None) -> dict:
|
|
364
|
+
"""POST /svc/study/study-cards -> {sched_ver, cards[], new/learn/review_count}."""
|
|
365
|
+
req = {}
|
|
366
|
+
if answer is not None:
|
|
367
|
+
req["answer"] = answer
|
|
368
|
+
if next_card_id is not None:
|
|
369
|
+
req["next_card_id"] = next_card_id
|
|
370
|
+
return self._call("/svc/study/study-cards", req)
|
|
371
|
+
|
|
372
|
+
def study_upgrade_scheduler(self, v3: bool = True) -> dict:
|
|
373
|
+
"""POST /svc/study/upgrade-scheduler (empty response)."""
|
|
374
|
+
return self._call("/svc/study/upgrade-scheduler", {"v3": v3})
|
|
375
|
+
|
|
376
|
+
def __getattr__(self, name: str):
|
|
377
|
+
if name in _ALIASES:
|
|
378
|
+
return getattr(self, _ALIASES[name])
|
|
379
|
+
for ep in public_endpoints():
|
|
380
|
+
if _method_name(ep["path"]) == name:
|
|
381
|
+
path = ep["path"]
|
|
382
|
+
|
|
383
|
+
def caller(request=None, *, query=None, response_as=None, _path=path):
|
|
384
|
+
return self._call(_path, request, query=query, response_as=response_as)
|
|
385
|
+
|
|
386
|
+
caller.__name__ = name
|
|
387
|
+
caller.__doc__ = f"{ep['method']} {ep['path']} req={ep['request']} resp={ep['response']}"
|
|
388
|
+
return caller
|
|
389
|
+
raise AttributeError(f"'AnkiWebClient' has no method '{name}'")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
_client = None
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def get_client(**kwargs) -> AnkiWebClient:
|
|
396
|
+
"""Get or create the default client (uses .env if present)."""
|
|
397
|
+
global _client
|
|
398
|
+
if _client is None:
|
|
399
|
+
_client = AnkiWebClient(**kwargs)
|
|
400
|
+
return _client
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
__all__ = ["AnkiWebClient", "get_client", "AnkiWebError", "NotFoundError",
|
|
404
|
+
"AuthError", "ValidationError", "SCHEMA", "ENDPOINTS",
|
|
405
|
+
"SCHEMA_REVISION", "public_endpoints", "endpoint"]
|