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
terminal_ankiweb.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Terminal helpers for AnkiWeb shared items — functionality upstream lacks.
|
|
2
|
+
|
|
3
|
+
Upstream (danny900714/upload-anki-addon, src/ankiweb.ts) covers:
|
|
4
|
+
login + upload-addon (POST svc/account/login, POST svc/shared/upload-addon)
|
|
5
|
+
|
|
6
|
+
This module covers the public, no-auth endpoints (reverse-engineered, see README-TERMINAL.md):
|
|
7
|
+
GET /shared/download/<id>?v=2.1&p=<ver> -> 303 -> /svc/shared/download-addon/<id>?t=..&minpt=..&maxpt=..&bidx=..
|
|
8
|
+
GET /svc/shared/item-info?sharedId=<id> -> protobuf ItemInfoResponse (public, max-age=600)
|
|
9
|
+
|
|
10
|
+
Only dependency: requests.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
from urllib.parse import urlparse, parse_qs
|
|
18
|
+
|
|
19
|
+
import requests
|
|
20
|
+
|
|
21
|
+
BASE = "https://ankiweb.net"
|
|
22
|
+
SHARED = f"{BASE}/shared/"
|
|
23
|
+
ITEM_INFO_URL = f"{BASE}/svc/shared/item-info"
|
|
24
|
+
LIST_MINE_URL = f"{BASE}/svc/shared/list-mine"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AnkiWebError(RuntimeError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NotFoundError(AnkiWebError):
|
|
32
|
+
"""The id does not exist, or its item is not publicly visible."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def download_addon(
|
|
36
|
+
addon_id: int, dest: str | None = None, point_version: int = 250902, timeout: int = 60
|
|
37
|
+
) -> dict:
|
|
38
|
+
"""Download .ankiaddon via /shared/download/<id> (follows 303 to svc).
|
|
39
|
+
|
|
40
|
+
Returns dict with path, filename, final_url, t/minpt/maxpt/bidx, size.
|
|
41
|
+
No auth required. Raises AnkiWebError on HTTP != 200 (e.g. 404:
|
|
42
|
+
'No add-on with that id.' or 'Your version of Anki is too old.').
|
|
43
|
+
"""
|
|
44
|
+
url = f"{SHARED}download/{addon_id}?v=2.1&p={point_version}"
|
|
45
|
+
with requests.get(url, timeout=timeout, stream=True, allow_redirects=True) as r:
|
|
46
|
+
if r.status_code != 200:
|
|
47
|
+
body = ""
|
|
48
|
+
try:
|
|
49
|
+
body = r.text[:300]
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
raise AnkiWebError(f"download HTTP {r.status_code} for id {addon_id}: {body}")
|
|
53
|
+
final_url = r.url
|
|
54
|
+
cd = r.headers.get("Content-Disposition", "")
|
|
55
|
+
m = re.search(r"filename=(.+)", cd)
|
|
56
|
+
filename = (m.group(1).strip() if m else f"{addon_id}.ankiaddon").strip('"')
|
|
57
|
+
q = parse_qs(urlparse(final_url).query)
|
|
58
|
+
meta = {
|
|
59
|
+
"addon_id": addon_id,
|
|
60
|
+
"filename": filename,
|
|
61
|
+
"final_url": final_url,
|
|
62
|
+
"t": int(q.get("t", ["0"])[0]),
|
|
63
|
+
"minpt": int(q.get("minpt", ["0"])[0]),
|
|
64
|
+
"maxpt": int(q.get("maxpt", ["0"])[0]),
|
|
65
|
+
"bidx": int(q.get("bidx", ["0"])[0]),
|
|
66
|
+
"size": int(r.headers.get("Content-Length", "0") or 0),
|
|
67
|
+
}
|
|
68
|
+
if dest is None:
|
|
69
|
+
dest = filename
|
|
70
|
+
with open(dest, "wb") as f:
|
|
71
|
+
for chunk in r.iter_content(65536):
|
|
72
|
+
f.write(chunk)
|
|
73
|
+
meta["path"] = dest
|
|
74
|
+
return meta
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------- generic protobuf scan
|
|
78
|
+
# item-info schema lives in AnkiWeb's SvelteKit JS (chunks/frontend.*.mjs),
|
|
79
|
+
# so we decode generically: walk length-delimited fields, collect strings +
|
|
80
|
+
# varints, then pick out title/description/size/timestamps heuristically.
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _varint_decode(buf: bytes, pos: int) -> tuple[int, int]:
|
|
84
|
+
shift = 0
|
|
85
|
+
result = 0
|
|
86
|
+
while True:
|
|
87
|
+
if pos >= len(buf):
|
|
88
|
+
raise ValueError("truncated varint")
|
|
89
|
+
b = buf[pos]
|
|
90
|
+
pos += 1
|
|
91
|
+
result |= (b & 0x7F) << shift
|
|
92
|
+
shift += 7
|
|
93
|
+
if not (b & 0x80):
|
|
94
|
+
return result, pos
|
|
95
|
+
if shift >= 70:
|
|
96
|
+
raise ValueError("varint too long")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _walk(buf: bytes, depth: int = 0, max_depth: int = 6):
|
|
100
|
+
"""Yield (depth, field_no, wire, value), recursing into nested messages.
|
|
101
|
+
|
|
102
|
+
Always attempts recursion on wire-type-2 payloads: a container message can
|
|
103
|
+
itself be valid UTF-8 (e.g. ItemInfo wrapping a title), so "looks like text"
|
|
104
|
+
is not a safe test for "is a message". Callers pick fields by exact
|
|
105
|
+
(depth, field_no), so over-walking is harmless.
|
|
106
|
+
"""
|
|
107
|
+
pos = 0
|
|
108
|
+
while pos < len(buf):
|
|
109
|
+
key, pos = _varint_decode(buf, pos)
|
|
110
|
+
field, wire = key >> 3, key & 7
|
|
111
|
+
if wire == 0:
|
|
112
|
+
v, pos = _varint_decode(buf, pos)
|
|
113
|
+
yield (depth, field, wire, v)
|
|
114
|
+
elif wire == 2:
|
|
115
|
+
ln, pos = _varint_decode(buf, pos)
|
|
116
|
+
v = buf[pos : pos + ln]
|
|
117
|
+
pos += ln
|
|
118
|
+
yield (depth, field, wire, v)
|
|
119
|
+
if depth < max_depth and ln >= 2:
|
|
120
|
+
# Yield incrementally: a payload can parse as a message for a
|
|
121
|
+
# few fields and then hit an invalid wire type. Keep what we
|
|
122
|
+
# already decoded instead of discarding the whole subtree.
|
|
123
|
+
it = _walk(v, depth + 1, max_depth)
|
|
124
|
+
while True:
|
|
125
|
+
try:
|
|
126
|
+
yield next(it)
|
|
127
|
+
except StopIteration:
|
|
128
|
+
break
|
|
129
|
+
except Exception:
|
|
130
|
+
break # not a nested message past this point
|
|
131
|
+
else:
|
|
132
|
+
raise ValueError(f"unsupported wire type {wire}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _is_printable_text(b: bytes) -> bool:
|
|
136
|
+
# No length cap: add-on descriptions are legitimately 20KB+ READMEs, and a
|
|
137
|
+
# 5000-byte cutoff silently dropped them (the description then read back as
|
|
138
|
+
# empty and looked like AnkiWeb had wiped it). UTF-8 decodability plus the
|
|
139
|
+
# printable ratio are what actually distinguish text from binary payloads.
|
|
140
|
+
if not b:
|
|
141
|
+
return False
|
|
142
|
+
try:
|
|
143
|
+
s = b.decode("utf-8")
|
|
144
|
+
except UnicodeDecodeError:
|
|
145
|
+
return False
|
|
146
|
+
if not s.strip():
|
|
147
|
+
return False
|
|
148
|
+
# mostly printable?
|
|
149
|
+
pr = sum(1 for c in s if c.isprintable() or c in "\n\r\t")
|
|
150
|
+
return pr / max(len(s), 1) > 0.9
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def fetch_item_info_raw(
|
|
154
|
+
shared_id: int, timeout: int = 30, cache_bust: str | None = None
|
|
155
|
+
) -> bytes:
|
|
156
|
+
"""GET svc/shared/item-info?sharedId=<id>. No auth. Returns raw protobuf bytes.
|
|
157
|
+
|
|
158
|
+
AnkiWeb serves this with `max-age=600`, so a stable URL returns up to 10
|
|
159
|
+
minutes of stale data -- after an upload, reads looked like the server had
|
|
160
|
+
silently wiped a long description. Append the same `cb` cache-buster the
|
|
161
|
+
web frontend uses to force a fresh read. Pass `cache_bust` to reuse the
|
|
162
|
+
exact value, so callers can echo the URL they actually read.
|
|
163
|
+
"""
|
|
164
|
+
if cache_bust is None:
|
|
165
|
+
cache_bust = str(int(time.time() * 1000))
|
|
166
|
+
r = requests.get(
|
|
167
|
+
ITEM_INFO_URL,
|
|
168
|
+
params={
|
|
169
|
+
"sharedId": shared_id, # camelCase required; shared_id -> 400
|
|
170
|
+
"cb": cache_bust,
|
|
171
|
+
},
|
|
172
|
+
headers={"Accept": "*/*", "Cache-Control": "no-cache"},
|
|
173
|
+
timeout=timeout,
|
|
174
|
+
)
|
|
175
|
+
if r.status_code != 400 and r.status_code != 404 and r.status_code != 200:
|
|
176
|
+
raise AnkiWebError(f"item-info HTTP {r.status_code}: {r.text[:300]}")
|
|
177
|
+
if r.status_code in (400, 404):
|
|
178
|
+
raise NotFoundError(f"item-info HTTP {r.status_code} for id {shared_id}: {r.text[:200]}")
|
|
179
|
+
# A missing/private id still returns 200, but with an empty response message
|
|
180
|
+
# (no ItemInfo payload). Anything under ~4 bytes carries no fields.
|
|
181
|
+
if len(r.content) <= 3:
|
|
182
|
+
raise NotFoundError(
|
|
183
|
+
f"no shared item {shared_id} (empty response; wrong id, or not public)"
|
|
184
|
+
)
|
|
185
|
+
return r.content
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def parse_item_info(buf: bytes) -> dict:
|
|
189
|
+
"""Extract title/description/tags/size/versions from an ItemInfoResponse.
|
|
190
|
+
|
|
191
|
+
Field numbers verified against fixtures/ on 2026-10:
|
|
192
|
+
ItemInfo @ depth1: title=f5, tags=f6, size=f7, last_updated=f8,
|
|
193
|
+
description=f9, support_url=f12, items_shared_by_user=f13
|
|
194
|
+
AddonVersion @ depth3: description=f1 ("2.1.45+", "2.1.0-2.1.44")
|
|
195
|
+
|
|
196
|
+
Every field is addressed by exact (depth, field_no). Heuristic matching
|
|
197
|
+
by content/length is deliberately avoided: reviews live at f1/f4 and are
|
|
198
|
+
often far longer than the title, and review ids are plausible varints.
|
|
199
|
+
"""
|
|
200
|
+
strings: list[tuple[int, int, bytes]] = [] # (depth, field, raw)
|
|
201
|
+
varints_at: list[tuple[int, int, int, int]] = [] # (depth, field, wire, varint)
|
|
202
|
+
for d, f, w, v in _walk(buf):
|
|
203
|
+
if w == 2 and isinstance(v, bytes) and _is_printable_text(v):
|
|
204
|
+
strings.append((d, f, v))
|
|
205
|
+
elif w == 0 and isinstance(v, int):
|
|
206
|
+
varints_at.append((d, f, w, v))
|
|
207
|
+
|
|
208
|
+
def txt(raw: bytes) -> str:
|
|
209
|
+
return raw.decode("utf-8", "replace")
|
|
210
|
+
|
|
211
|
+
# Exact positions (stable across ids observed 2026-10):
|
|
212
|
+
# ItemInfo @ depth1: title=f5, tags=f6, description=f9, support_url=f12
|
|
213
|
+
# AddonVersion @ depth3: description=f1 ("2.1.45+", "2.1.0-2.1.44")
|
|
214
|
+
# Reviews share depth1 (f1) / depth2 (f4) — never use longest-string fallback.
|
|
215
|
+
def at(depth: int, field: int) -> list[str]:
|
|
216
|
+
return [txt(r) for d, f, r in strings if d == depth and f == field]
|
|
217
|
+
|
|
218
|
+
titles = at(1, 5)
|
|
219
|
+
title = max(titles, key=len).strip() if titles else ""
|
|
220
|
+
descs = at(1, 9)
|
|
221
|
+
if descs:
|
|
222
|
+
description = max(descs, key=len)
|
|
223
|
+
else:
|
|
224
|
+
fall = [txt(r) for _d, _f, r in strings if "<" in txt(r)]
|
|
225
|
+
description = max(fall, key=len) if fall else ""
|
|
226
|
+
tags = at(1, 6)
|
|
227
|
+
tag_str = max(tags, key=len).strip() if tags else ""
|
|
228
|
+
support = at(1, 12)
|
|
229
|
+
support_url = support[0].strip() if support else ""
|
|
230
|
+
versions = sorted({t.strip() for t in at(3, 1) if "." in t})
|
|
231
|
+
if not versions: # fallback for tiny items (e.g. private test addon)
|
|
232
|
+
versions = sorted(
|
|
233
|
+
{
|
|
234
|
+
txt(r).strip()
|
|
235
|
+
for d, f, r in strings
|
|
236
|
+
if d >= 2 and "." in txt(r)
|
|
237
|
+
and re.fullmatch(r"[\d.\-]+\+?", txt(r).strip())
|
|
238
|
+
}
|
|
239
|
+
)
|
|
240
|
+
# Scalars by exact position (magic-number guessing picked up review ids
|
|
241
|
+
# as "size" on busy add-ons, e.g. AnkiConnect reporting 67 MB):
|
|
242
|
+
# ItemInfo @ depth1: size=f7, last_updated=f8, items_shared_by_user=f13
|
|
243
|
+
def vat(depth: int, field: int) -> list[int]:
|
|
244
|
+
return [v for d, f, w, v in varints_at if d == depth and f == field]
|
|
245
|
+
|
|
246
|
+
size = vat(1, 7)[0] if vat(1, 7) else 0
|
|
247
|
+
last_updated = vat(1, 8)[0] if vat(1, 8) else 0
|
|
248
|
+
shared_by = vat(1, 13)[0] if vat(1, 13) else 0
|
|
249
|
+
times = sorted({v for _d, _f, _w, v in varints_at if 1577836800 <= v <= 2051222400})
|
|
250
|
+
return {
|
|
251
|
+
"title": title,
|
|
252
|
+
"description": description,
|
|
253
|
+
"tags": tag_str,
|
|
254
|
+
"support_url": support_url,
|
|
255
|
+
"versions": versions,
|
|
256
|
+
"last_updated": last_updated,
|
|
257
|
+
"size": size,
|
|
258
|
+
"items_shared_by_user": shared_by,
|
|
259
|
+
"timestamps_seen": times,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def parse_list_mine(buf: bytes) -> dict:
|
|
264
|
+
"""Decode ListMineResponse (schema from AnkiWeb's frontend chunk):
|
|
265
|
+
|
|
266
|
+
ListMineResponse{ items=1[]OwnItem, reviews=2[], expired_decks=3[] }
|
|
267
|
+
OwnItem{ id=1, title=2, thumbs_up=3, thumbs_down=4, mtime=5,
|
|
268
|
+
downloads=6, max_supported_version=7(opt int32) }
|
|
269
|
+
"""
|
|
270
|
+
items: list[dict] = []
|
|
271
|
+
reviews = 0
|
|
272
|
+
for d, f, w, v in _walk(buf):
|
|
273
|
+
if d != 0:
|
|
274
|
+
continue
|
|
275
|
+
if f == 2 and w == 2:
|
|
276
|
+
reviews += 1
|
|
277
|
+
continue
|
|
278
|
+
if f != 1 or w != 2:
|
|
279
|
+
continue
|
|
280
|
+
item: dict = {"id": 0, "title": "", "thumbs_up": 0, "thumbs_down": 0,
|
|
281
|
+
"mtime": 0, "downloads": 0, "max_supported_version": None}
|
|
282
|
+
for _d, id_, fw, wv in _walk(v):
|
|
283
|
+
if _d != 0:
|
|
284
|
+
continue
|
|
285
|
+
if id_ == 1 and fw == 0:
|
|
286
|
+
item["id"] = int(wv)
|
|
287
|
+
elif id_ == 2 and fw == 2 and isinstance(wv, bytes):
|
|
288
|
+
item["title"] = wv.decode("utf-8", "replace")
|
|
289
|
+
elif id_ == 3 and fw == 0:
|
|
290
|
+
item["thumbs_up"] = int(wv)
|
|
291
|
+
elif id_ == 4 and fw == 0:
|
|
292
|
+
item["thumbs_down"] = int(wv)
|
|
293
|
+
elif id_ == 5 and fw == 0:
|
|
294
|
+
item["mtime"] = int(wv)
|
|
295
|
+
elif id_ == 6 and fw == 0:
|
|
296
|
+
item["downloads"] = int(wv)
|
|
297
|
+
elif id_ == 7 and fw == 0:
|
|
298
|
+
# proto int32: wire gives unsigned; reinterpret signed
|
|
299
|
+
item["max_supported_version"] = (
|
|
300
|
+
int(wv) - (1 << 32) if int(wv) & 0x80000000 else int(wv)
|
|
301
|
+
)
|
|
302
|
+
items.append(item)
|
|
303
|
+
return {"items": items, "review_count": reviews}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def fetch_list_mine(cookie: str, timeout: int = 30) -> dict:
|
|
307
|
+
"""POST svc/shared/list-mine with your session cookie (empty body)."""
|
|
308
|
+
r = requests.post(
|
|
309
|
+
LIST_MINE_URL,
|
|
310
|
+
data=b"",
|
|
311
|
+
headers={
|
|
312
|
+
"Accept": "*/*",
|
|
313
|
+
"Content-Type": "application/octet-stream",
|
|
314
|
+
"Cookie": f"has_auth=1; ankiweb={cookie}",
|
|
315
|
+
"Referer": f"{BASE}/shared/mine",
|
|
316
|
+
},
|
|
317
|
+
timeout=timeout,
|
|
318
|
+
)
|
|
319
|
+
if r.status_code in (401, 403):
|
|
320
|
+
raise AnkiWebError(
|
|
321
|
+
f"list-mine HTTP {r.status_code}: session not valid; re-check credentials"
|
|
322
|
+
)
|
|
323
|
+
if r.status_code != 200:
|
|
324
|
+
raise AnkiWebError(f"list-mine HTTP {r.status_code}: {r.text[:300]}")
|
|
325
|
+
return parse_list_mine(r.content)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def fetch_item_info(shared_id: int, timeout: int = 30) -> dict:
|
|
329
|
+
# One cache-buster for the read, echoed back as info["cache_bust"] so
|
|
330
|
+
# callers can print the exact verification URL that was just fetched.
|
|
331
|
+
cache_bust = str(int(time.time() * 1000))
|
|
332
|
+
raw = fetch_item_info_raw(shared_id, timeout, cache_bust=cache_bust)
|
|
333
|
+
info = parse_item_info(raw)
|
|
334
|
+
# Defensive: a real ItemInfo always carries a title or a description.
|
|
335
|
+
if not info["title"] and not info["description"]:
|
|
336
|
+
raise NotFoundError(
|
|
337
|
+
f"no usable metadata for id {shared_id} "
|
|
338
|
+
f"(wrong id, deleted, or not public)"
|
|
339
|
+
)
|
|
340
|
+
info["shared_id"] = shared_id
|
|
341
|
+
info["cache_bust"] = cache_bust
|
|
342
|
+
return info
|
upload.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Terminal wrapper around the forked upstream uploader (no reinvented wheel).
|
|
3
|
+
|
|
4
|
+
Upload engine: node dist/index.js from danny900714/upload-anki-addon
|
|
5
|
+
(upstream fork remote). This script only maps CLI args -> INPUT_* env vars
|
|
6
|
+
(GitHub Actions convention) and execs node. Metadata-only updates:
|
|
7
|
+
pass `--addon-file -` for a branch to skip its file (upstream convention).
|
|
8
|
+
|
|
9
|
+
Download/info engine: terminal_ankiweb.py (new functionality upstream lacks).
|
|
10
|
+
|
|
11
|
+
Examples:
|
|
12
|
+
export ANKIWEB_USERNAME='you@example.com' ANKIWEB_PASSWORD='...'
|
|
13
|
+
# update file + description for test addon 805854302:
|
|
14
|
+
./upload.py --addon-id 805854302 --title "my title" \\
|
|
15
|
+
--description-file DESCRIPTION.md \\
|
|
16
|
+
--branches "2.1.1-25.09.4+" --addon-files ./my.ankiaddon
|
|
17
|
+
# metadata-only (description/tags/title, no file):
|
|
18
|
+
./upload.py --addon-id 805854302 --title "my title" \\
|
|
19
|
+
--description "new text" --branches "2.1.1-25.09.4+" --addon-files -
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import os
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from ankiweb_auth import default_env_file, load_dotenv as load_env_file
|
|
31
|
+
|
|
32
|
+
HERE = Path(__file__).resolve().parent
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def find_engine() -> Path | None:
|
|
36
|
+
"""Locate the Node upload engine (dist/index.js).
|
|
37
|
+
|
|
38
|
+
Order: $ANKIWEB_ENGINE, a repo checkout's dist/, then the copy bundled into
|
|
39
|
+
the installed wheel. Keeps `./upload.py` in a clone working unchanged while
|
|
40
|
+
letting `uv tool install` ship the engine as package data.
|
|
41
|
+
"""
|
|
42
|
+
import os
|
|
43
|
+
|
|
44
|
+
override = os.environ.get("ANKIWEB_ENGINE", "")
|
|
45
|
+
if override:
|
|
46
|
+
cand = Path(override).expanduser()
|
|
47
|
+
return cand if cand.is_file() else None
|
|
48
|
+
|
|
49
|
+
local = HERE / "dist" / "index.js"
|
|
50
|
+
if local.is_file():
|
|
51
|
+
return local
|
|
52
|
+
|
|
53
|
+
# Installed layout: this module and ankiweb_engine/ sit side by side in
|
|
54
|
+
# site-packages, so anchor on this file rather than importlib.resources
|
|
55
|
+
# (ankiweb_engine is not an importable package).
|
|
56
|
+
bundled = HERE / "ankiweb_engine" / "index.js"
|
|
57
|
+
if bundled.is_file():
|
|
58
|
+
return bundled
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_dotenv(path: Path) -> None:
|
|
63
|
+
"""Minimal .env loader (no dependency). Existing env vars win.
|
|
64
|
+
|
|
65
|
+
Shared with ankicli.py via ankiweb_auth.load_dotenv.
|
|
66
|
+
"""
|
|
67
|
+
load_env_file(path)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def validate_branch(branch: str) -> None:
|
|
71
|
+
"""Catch bad notation before spending a network round-trip.
|
|
72
|
+
|
|
73
|
+
Mirrors upstream parseBranchNotation rules: exactly MIN-MAX, each side a
|
|
74
|
+
2.1.x patch or major[.minor[.patch]], trailing '+' on max = open-ended.
|
|
75
|
+
"""
|
|
76
|
+
parts = branch.strip().split("-")
|
|
77
|
+
if len(parts) != 2:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"branch must be MIN-MAX (e.g. '2.1.1-25.09.4+'), got {branch!r}"
|
|
80
|
+
)
|
|
81
|
+
for ver in parts:
|
|
82
|
+
v = ver.strip()
|
|
83
|
+
if v.endswith("+"):
|
|
84
|
+
v = v[:-1]
|
|
85
|
+
if v.startswith("2.1."):
|
|
86
|
+
if not v[4:].isdigit():
|
|
87
|
+
raise ValueError(f"invalid 2.1.x version in branch {branch!r}")
|
|
88
|
+
else:
|
|
89
|
+
segs = v.split(".")
|
|
90
|
+
if len(segs) > 3 or not all(s.isdigit() and int(s) < 100 for s in segs):
|
|
91
|
+
raise ValueError(f"invalid version in branch {branch!r}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def build_env(args) -> dict:
|
|
95
|
+
load_dotenv(Path(args.env_file).expanduser() if args.env_file else default_env_file())
|
|
96
|
+
env = dict(os.environ)
|
|
97
|
+
def inp(name: str, value: str):
|
|
98
|
+
# @actions/core only replaces spaces with underscores, so hyphenated
|
|
99
|
+
# inputs (addon-files, description-file) are read as INPUT_ADDON-FILES.
|
|
100
|
+
# GitHub Actions uses the same hyphenated form. Set both for safety.
|
|
101
|
+
base = name.upper()
|
|
102
|
+
env[f"INPUT_{base}"] = value
|
|
103
|
+
env[f"INPUT_{base.replace('-', '_')}"] = value
|
|
104
|
+
username = args.username or os.environ.get("ANKIWEB_USERNAME", "")
|
|
105
|
+
password = args.password or os.environ.get("ANKIWEB_PASSWORD", "")
|
|
106
|
+
missing = [n for n, v in (("ANKIWEB_USERNAME", username), ("ANKIWEB_PASSWORD", password)) if not v]
|
|
107
|
+
if missing:
|
|
108
|
+
sys.exit(
|
|
109
|
+
f"error: missing {' and '.join(missing)}\n"
|
|
110
|
+
f" set them in {HERE / '.env'} (copy .env.example), export them, "
|
|
111
|
+
f"or pass --username/--password"
|
|
112
|
+
)
|
|
113
|
+
inp("username", username)
|
|
114
|
+
inp("password", password)
|
|
115
|
+
inp("addon-id", str(args.addon_id or ""))
|
|
116
|
+
inp("title", args.title)
|
|
117
|
+
inp("tags", args.tags or "")
|
|
118
|
+
inp("support-page", args.support_page or "")
|
|
119
|
+
inp("branches", "\n".join(args.branches))
|
|
120
|
+
inp("addon-files", "\n".join(args.addon_files))
|
|
121
|
+
|
|
122
|
+
if args.description and args.description_file:
|
|
123
|
+
print("warning: both --description and --description-file given; --description wins (upstream rule)", file=sys.stderr)
|
|
124
|
+
if args.description:
|
|
125
|
+
inp("description", args.description)
|
|
126
|
+
inp("description-file", "")
|
|
127
|
+
elif args.description_file:
|
|
128
|
+
p = Path(args.description_file)
|
|
129
|
+
if not p.is_file():
|
|
130
|
+
sys.exit(f"error: description file not found: {p}")
|
|
131
|
+
inp("description", "")
|
|
132
|
+
inp("description-file", str(p))
|
|
133
|
+
else:
|
|
134
|
+
sys.exit("error: one of --description / --description-file is required")
|
|
135
|
+
out = os.environ.get("GITHUB_OUTPUT", "")
|
|
136
|
+
if out:
|
|
137
|
+
env["GITHUB_OUTPUT"] = out
|
|
138
|
+
return env
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main() -> int:
|
|
142
|
+
ap = argparse.ArgumentParser(description="Upload addon via forked upstream action")
|
|
143
|
+
ap.add_argument("--addon-id", default="", help="numeric AnkiWeb id; omit to create new")
|
|
144
|
+
ap.add_argument("--title", required=True, help="<80 chars")
|
|
145
|
+
ap.add_argument("--tags", default="")
|
|
146
|
+
ap.add_argument("--support-page", default="")
|
|
147
|
+
ap.add_argument("--branches", nargs="+", required=True,
|
|
148
|
+
help='e.g. "2.1.1-2.1.66" "23.10-25.09.4+" (one per branch)')
|
|
149
|
+
ap.add_argument("--addon-files", nargs="+", required=True,
|
|
150
|
+
help=".ankiaddon paths in branch order; use - to skip a branch file")
|
|
151
|
+
ap.add_argument("--description", default="")
|
|
152
|
+
ap.add_argument("--description-file", default="")
|
|
153
|
+
ap.add_argument("--username", default="", help="default: $ANKIWEB_USERNAME")
|
|
154
|
+
ap.add_argument("--password", default="", help="default: $ANKIWEB_PASSWORD")
|
|
155
|
+
ap.add_argument("--env-file", default="", help="default: ./.env next to this script")
|
|
156
|
+
ap.add_argument("--node", default="node")
|
|
157
|
+
ap.add_argument("--dry-run", action="store_true", help="print INPUT_* mapping, don't run node")
|
|
158
|
+
args = ap.parse_args()
|
|
159
|
+
|
|
160
|
+
if len(args.branches) != len(args.addon_files):
|
|
161
|
+
sys.exit("error: --branches and --addon-files must have the same length")
|
|
162
|
+
for b in args.branches:
|
|
163
|
+
try:
|
|
164
|
+
validate_branch(b)
|
|
165
|
+
except ValueError as e:
|
|
166
|
+
sys.exit(f"error: {e}")
|
|
167
|
+
for f in args.addon_files:
|
|
168
|
+
if f != "-" and not Path(f).is_file():
|
|
169
|
+
sys.exit(f"error: addon file not found: {f}")
|
|
170
|
+
if len(args.title) >= 80:
|
|
171
|
+
sys.exit("error: --title must be fewer than 80 characters")
|
|
172
|
+
if args.support_page and not args.support_page.startswith("http"):
|
|
173
|
+
sys.exit("error: --support-page must start with http")
|
|
174
|
+
|
|
175
|
+
env = build_env(args)
|
|
176
|
+
if args.dry_run:
|
|
177
|
+
for k in sorted(k for k in env if k.startswith("INPUT_")):
|
|
178
|
+
v = env[k]
|
|
179
|
+
masked = any(s in k for s in ("PASSWORD", "USERNAME"))
|
|
180
|
+
print(f"{k}={'(secret)' if masked else v[:200]}")
|
|
181
|
+
print(f"engine: {HERE/'dist/index.js'}")
|
|
182
|
+
return 0
|
|
183
|
+
|
|
184
|
+
entry = find_engine()
|
|
185
|
+
if entry is None:
|
|
186
|
+
sys.exit(
|
|
187
|
+
"error: upload engine not found (dist/index.js).\n"
|
|
188
|
+
" Run from a clone of the repo, or set ANKIWEB_ENGINE=/path/to/dist/index.js"
|
|
189
|
+
)
|
|
190
|
+
r = subprocess.run([args.node, str(entry)], env=env)
|
|
191
|
+
return r.returncode
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
raise SystemExit(main())
|