glossa-cli 0.1.0__3-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.
glossa_cli/cli.py ADDED
@@ -0,0 +1,477 @@
1
+ """Glossa command-line interface.
2
+
3
+ Subcommands:
4
+ glossa login — sign in (browser OAuth)
5
+ glossa logout — sign out
6
+ glossa analyze — CEFR-leveled word/phrase breakdown of arbitrary text
7
+ glossa add — add a word/phrase to your vocabulary
8
+ glossa set — change a word's state (saved / learning / known)
9
+ glossa list — list your vocabulary
10
+ glossa review — interactive FSRS review session
11
+ glossa lang — show or set your default learning language
12
+ """
13
+
14
+ import asyncio
15
+ import json
16
+ import os
17
+ import sys
18
+ from collections.abc import Awaitable, Callable
19
+ from typing import Any
20
+
21
+ import httpx
22
+ import typer
23
+ from rich.console import Console
24
+ from rich.table import Table
25
+
26
+ from glossa_cli import config
27
+ from glossa_cli.auth import clear_credentials
28
+ from glossa_cli.client import GlossaApiError, GlossaClient
29
+
30
+ app = typer.Typer(
31
+ help="Glossa vocabulary CLI — analyze text and manage your vocabulary.",
32
+ no_args_is_help=True,
33
+ add_completion=False,
34
+ )
35
+ console = Console()
36
+ err_console = Console(stderr=True)
37
+
38
+ LANGUAGES = {"en", "fr", "pt-PT"}
39
+ STATUSES = {"saved", "learning", "known"}
40
+ RATINGS = {1: "Again", 2: "Hard", 3: "Good", 4: "Easy"}
41
+
42
+
43
+ # ── Shared helpers ─────────────────────────────────────────────────────────────
44
+
45
+
46
+ def _format_error(exc: Exception) -> str:
47
+ if isinstance(exc, GlossaApiError):
48
+ if exc.status_code == 401:
49
+ return (
50
+ "Glossa rejected your credentials. Generate a token in "
51
+ "Glossa → Settings → Tokens and export it as GLOSSA_API_TOKEN, "
52
+ "or omit it to use the browser sign-in flow."
53
+ )
54
+ if (
55
+ isinstance(exc.detail, dict)
56
+ and exc.detail.get("error") == "multiple_languages"
57
+ ):
58
+ enabled = exc.detail.get("enabled") or []
59
+ joined = ", ".join(enabled) if enabled else "(none)"
60
+ return (
61
+ "You have multiple learning languages enabled: "
62
+ f"{joined}.\nPick one for this command, or set a default:\n"
63
+ " --lang <code> # one-off override\n"
64
+ " export GLOSSA_LANG=<code> # session\n"
65
+ " glossa lang set <code> # persistent"
66
+ )
67
+ if exc.status_code == 404:
68
+ return f"Not found: {exc.detail}"
69
+ return f"Glossa API error ({exc.status_code}): {exc.detail}"
70
+ if isinstance(exc, httpx.HTTPError):
71
+ return (
72
+ f"Could not reach Glossa at {os.environ.get('GLOSSA_API_URL', '')!r}: {exc}. "
73
+ "Is the backend running and GLOSSA_API_URL correct?"
74
+ )
75
+ return f"Glossa error: {exc}"
76
+
77
+
78
+ def _run(fn: Callable[[GlossaClient], Awaitable[Any]]) -> Any:
79
+ """Open an authenticated client, run *fn*, and surface errors as exit code 1."""
80
+
81
+ async def _wrap() -> Any:
82
+ async with GlossaClient() as client:
83
+ return await fn(client)
84
+
85
+ try:
86
+ return asyncio.run(_wrap())
87
+ except (GlossaApiError, RuntimeError, httpx.HTTPError) as exc:
88
+ err_console.print(f"[red]{_format_error(exc)}[/red]")
89
+ raise typer.Exit(1) from exc
90
+
91
+
92
+ def _validate_lang(language_code: str | None) -> None:
93
+ if language_code is not None and language_code not in LANGUAGES:
94
+ err_console.print(
95
+ f"[red]Unknown language '{language_code}'. Choose from: "
96
+ f"{', '.join(sorted(LANGUAGES))}.[/red]"
97
+ )
98
+ raise typer.Exit(2)
99
+
100
+
101
+ def _validate_status(status: str) -> None:
102
+ if status not in STATUSES:
103
+ err_console.print(
104
+ f"[red]Invalid status '{status}'. Choose from: "
105
+ f"{', '.join(sorted(STATUSES))}.[/red]"
106
+ )
107
+ raise typer.Exit(2)
108
+
109
+
110
+ # ── Commands ───────────────────────────────────────────────────────────────────
111
+
112
+
113
+ @app.command()
114
+ def login(
115
+ force: bool = typer.Option(
116
+ False, "--force", help="Clear cached credentials and sign in again."
117
+ ),
118
+ ) -> None:
119
+ """Sign in to Glossa. Run this first — opens a browser to authorise you."""
120
+ using_token = bool(os.environ.get("GLOSSA_API_TOKEN"))
121
+
122
+ if force and not using_token and clear_credentials():
123
+ console.print("[dim]Cleared saved credentials.[/dim]")
124
+
125
+ # Opening the client runs the OAuth device flow (printing an auth URL) when
126
+ # there's no usable token; the tiny authenticated call then confirms it worked.
127
+ _run(lambda c: c.reviews_due(limit=1))
128
+
129
+ if using_token:
130
+ console.print("[green]✓ Signed in with your GLOSSA_API_TOKEN.[/green]")
131
+ else:
132
+ console.print(
133
+ "[green]✓ Signed in.[/green] You're ready to use Glossa — try "
134
+ "[bold]glossa list[/bold]."
135
+ )
136
+
137
+
138
+ @app.command()
139
+ def logout() -> None:
140
+ """Sign out — remove cached OAuth credentials.
141
+
142
+ Idempotent: if nothing's cached, says so and exits 0. If a
143
+ ``GLOSSA_API_TOKEN`` is set in your environment, the CLI still uses it
144
+ until you unset it — `logout` warns you about that.
145
+ """
146
+ if os.environ.get("GLOSSA_API_TOKEN"):
147
+ err_console.print(
148
+ "[yellow]Note: GLOSSA_API_TOKEN is set in your environment — the "
149
+ "CLI will keep using it until you unset it.[/yellow]"
150
+ )
151
+ if clear_credentials():
152
+ console.print(
153
+ "[green]✓ Signed out.[/green] Cached credentials removed from "
154
+ "~/.config/glossa/credentials.json."
155
+ )
156
+ else:
157
+ console.print("[dim]Already signed out — no cached credentials.[/dim]")
158
+
159
+
160
+ @app.command()
161
+ def analyze(
162
+ text: str | None = typer.Argument(
163
+ None, help="Text to analyze. Omit to read --file or stdin."
164
+ ),
165
+ file: str | None = typer.Option(None, "--file", "-f", help="Read text from this file."),
166
+ as_json: bool = typer.Option(False, "--json", help="Print the raw JSON response."),
167
+ ) -> None:
168
+ """Analyze text and show its vocabulary breakdown (CEFR levels, phrases).
169
+
170
+ Language is auto-detected from the text.
171
+ """
172
+ if file is not None:
173
+ try:
174
+ with open(file, encoding="utf-8") as fh:
175
+ content = fh.read()
176
+ except OSError as exc:
177
+ err_console.print(f"[red]Cannot read {file}: {exc}[/red]")
178
+ raise typer.Exit(2) from exc
179
+ elif text is not None:
180
+ content = text
181
+ elif not sys.stdin.isatty():
182
+ content = sys.stdin.read()
183
+ else:
184
+ err_console.print("[red]No text provided. Pass TEXT, --file, or pipe via stdin.[/red]")
185
+ raise typer.Exit(2)
186
+
187
+ if not content.strip():
188
+ err_console.print("[red]Empty input.[/red]")
189
+ raise typer.Exit(2)
190
+
191
+ result = _run(lambda c: c.analyze_text(content))
192
+
193
+ if as_json:
194
+ console.print_json(json.dumps(result))
195
+ return
196
+
197
+ words = result.get("words", [])
198
+ table = Table(title="Vocabulary breakdown")
199
+ table.add_column("Lemma", style="bold")
200
+ table.add_column("POS")
201
+ table.add_column("Count", justify="right")
202
+ table.add_column("CEFR")
203
+ table.add_column("Type")
204
+ for w in words:
205
+ kind = w.get("type", "word")
206
+ if kind == "phrase" and w.get("pattern_type"):
207
+ kind = f"phrase · {w['pattern_type']}"
208
+ table.add_row(
209
+ w.get("lemma", ""),
210
+ w.get("part_of_speech", "") or "",
211
+ str(w.get("occurrences", "")),
212
+ w.get("cefr_level") or "—",
213
+ kind,
214
+ )
215
+ console.print(table)
216
+ console.print(
217
+ f"[dim]{result.get('unique_lemmas', 0)} unique lemmas · "
218
+ f"{result.get('unique_phrases', 0)} phrases · "
219
+ f"{result.get('total_tokens', 0)} tokens[/dim]"
220
+ )
221
+
222
+
223
+ @app.command()
224
+ def add(
225
+ lemma: str = typer.Argument(..., help="Dictionary form, e.g. 'serendipity' or 'break the ice'."), # noqa: E501
226
+ pos: str | None = typer.Option(None, "--pos", help="NOUN, VERB, ADJ, or ADV."),
227
+ status: str = typer.Option("learning", "--status", "-s", help="saved, learning, or known."),
228
+ translation: str | None = typer.Option(None, "--translation", "-t", help="Your translation."),
229
+ lang: str | None = typer.Option(
230
+ None, "--lang", "-l", hidden=True,
231
+ help="Override the language (en/fr/pt-PT). Usually inferred from your account.",
232
+ ),
233
+ ) -> None:
234
+ """Add a word or phrase to your vocabulary."""
235
+ _validate_status(status)
236
+ language_code = config.resolve_language(lang)
237
+ _validate_lang(language_code)
238
+ result = _run(
239
+ lambda c: c.add_word(
240
+ lemma=lemma,
241
+ language_code=language_code,
242
+ part_of_speech=pos,
243
+ status=status,
244
+ translation=translation,
245
+ )
246
+ )
247
+ _print_vocab_item(result, action="Saved")
248
+
249
+
250
+ @app.command(name="set")
251
+ def set_status(
252
+ lemma: str = typer.Argument(..., help="The word/phrase to update (or pass --id instead)."),
253
+ status: str = typer.Argument(..., help="New state: saved, learning, or known."),
254
+ pos: str | None = typer.Option(None, "--pos", help="NOUN, VERB, ADJ, or ADV."),
255
+ translation: str | None = typer.Option(None, "--translation", "-t", help="Your translation."),
256
+ entry_id: int | None = typer.Option(
257
+ None, "--id", help="Update a specific lexical_entry_id (from `glossa list`) precisely."
258
+ ),
259
+ lang: str | None = typer.Option(
260
+ None, "--lang", "-l", hidden=True,
261
+ help="Override the language (en/fr/pt-PT). Usually inferred from your account.",
262
+ ),
263
+ ) -> None:
264
+ """Change a word's state. By default upserts by lemma; --id targets one sense."""
265
+ _validate_status(status)
266
+ if entry_id is not None:
267
+ result = _run(
268
+ lambda c: c.update_status(
269
+ lexical_entry_id=entry_id, status=status, user_translation=translation
270
+ )
271
+ )
272
+ else:
273
+ language_code = config.resolve_language(lang)
274
+ _validate_lang(language_code)
275
+ result = _run(
276
+ lambda c: c.add_word(
277
+ lemma=lemma,
278
+ language_code=language_code,
279
+ part_of_speech=pos,
280
+ status=status,
281
+ translation=translation,
282
+ )
283
+ )
284
+ _print_vocab_item(result, action="Updated")
285
+
286
+
287
+ @app.command(name="list")
288
+ def list_words(
289
+ status: str | None = typer.Option(
290
+ None, "--status", "-s", help="Filter: saved, learning, or known."
291
+ ),
292
+ as_json: bool = typer.Option(False, "--json", help="Print the raw JSON response."),
293
+ ) -> None:
294
+ """List your vocabulary across all enabled languages."""
295
+ if status is not None:
296
+ _validate_status(status)
297
+ items = _run(lambda c: c.list_vocabulary(status=status))
298
+
299
+ if as_json:
300
+ console.print_json(json.dumps(items))
301
+ return
302
+
303
+ if not items:
304
+ console.print("[dim]No matching words.[/dim]")
305
+ return
306
+
307
+ table = Table(title="Your vocabulary")
308
+ table.add_column("ID", justify="right", style="dim")
309
+ table.add_column("Lemma", style="bold")
310
+ table.add_column("POS")
311
+ table.add_column("Lang")
312
+ table.add_column("Status")
313
+ table.add_column("CEFR")
314
+ table.add_column("Next review")
315
+ for it in items:
316
+ table.add_row(
317
+ str(it.get("lexical_entry_id", "")),
318
+ it.get("lemma") or "",
319
+ it.get("part_of_speech") or "",
320
+ it.get("language_code") or "",
321
+ it.get("status") or "",
322
+ it.get("cefr_level") or "—",
323
+ _fmt_dt(it.get("next_review")),
324
+ )
325
+ console.print(table)
326
+ console.print(f"[dim]{len(items)} word(s)[/dim]")
327
+
328
+
329
+ @app.command()
330
+ def review(
331
+ limit: int = typer.Option(50, "--limit", help="Max cards to fetch (1–200)."),
332
+ ) -> None:
333
+ """Interactive FSRS review of words that are due."""
334
+ cards = _run(lambda c: c.reviews_due(limit=limit))
335
+
336
+ if not cards:
337
+ console.print("[green]Nothing due for review — you're all caught up![/green]")
338
+ return
339
+
340
+ console.print(
341
+ f"[bold]{len(cards)}[/bold] card(s) due. Rate each: "
342
+ r"1 Again 2 Hard 3 Good 4 Easy (q to quit)" + "\n"
343
+ )
344
+
345
+ reviewed = 0
346
+ for idx, card in enumerate(cards, start=1):
347
+ console.print(
348
+ f"[dim]{idx}/{len(cards)}[/dim] [bold]{card.get('lemma', '')}[/bold] "
349
+ f"[dim]({card.get('part_of_speech', '')})[/dim]"
350
+ )
351
+ if card.get("context_sentence"):
352
+ console.print(f" [italic dim]{card['context_sentence']}[/italic dim]")
353
+
354
+ rating = _prompt_rating()
355
+ if rating is None:
356
+ break
357
+
358
+ result = _run(
359
+ lambda c, cid=card["id"], r=rating: c.submit_review(user_vocabulary_id=cid, rating=r)
360
+ )
361
+ reviewed += 1
362
+ console.print(
363
+ f" [green]✓ {RATINGS[rating]}[/green] — next review "
364
+ f"{_fmt_dt(result.get('next_review'))}\n"
365
+ )
366
+
367
+ console.print(f"[bold]Reviewed {reviewed} of {len(cards)} card(s).[/bold]")
368
+
369
+
370
+ # ── `glossa lang` sub-app ─────────────────────────────────────────────────────
371
+
372
+ lang_app = typer.Typer(
373
+ help="Show or set your default learning language (for `add` and `set`).",
374
+ invoke_without_command=True,
375
+ no_args_is_help=False,
376
+ )
377
+ app.add_typer(lang_app, name="lang")
378
+
379
+
380
+ @lang_app.callback(invoke_without_command=True)
381
+ def _lang_default(ctx: typer.Context) -> None:
382
+ """Show the effective default language and where it comes from.
383
+
384
+ Called when `glossa lang` is invoked without a subcommand. With a
385
+ subcommand (`set` / `unset`) Typer dispatches there instead.
386
+ """
387
+ if ctx.invoked_subcommand is not None:
388
+ return
389
+
390
+ flag_value: str | None = None # no per-call flag in this context
391
+ env_value = os.environ.get("GLOSSA_LANG", "").strip() or None
392
+ file_value = config.get_default_language()
393
+ effective = config.resolve_language(flag_value)
394
+
395
+ if effective is None:
396
+ console.print(
397
+ "[dim]No default language set.[/dim]\n"
398
+ "Glossa will infer the language from your account when you run "
399
+ "[bold]glossa add[/bold] / [bold]glossa set[/bold]. If you have "
400
+ "multiple enabled, set a default:\n"
401
+ " [bold]glossa lang set <code>[/bold] # e.g. en, fr, pt-PT"
402
+ )
403
+ return
404
+
405
+ source = "GLOSSA_LANG env" if env_value else (
406
+ "~/.config/glossa/config.json" if file_value else "fallback"
407
+ )
408
+ console.print(f"Default language: [bold]{effective}[/bold] [dim]({source})[/dim]")
409
+ if env_value and file_value and env_value != file_value:
410
+ console.print(
411
+ f"[dim]Note: GLOSSA_LANG={env_value} overrides the persisted "
412
+ f"default ({file_value}).[/dim]"
413
+ )
414
+
415
+
416
+ @lang_app.command("set")
417
+ def lang_set(
418
+ code: str = typer.Argument(..., help="Language code: en, fr, or pt-PT."),
419
+ ) -> None:
420
+ """Persist a default learning language to ~/.config/glossa/config.json."""
421
+ _validate_lang(code)
422
+ config.set_default_language(code)
423
+ console.print(
424
+ f"[green]✓ Default language set to [bold]{code}[/bold].[/green] "
425
+ f"[dim](~/.config/glossa/config.json)[/dim]"
426
+ )
427
+
428
+
429
+ @lang_app.command("unset")
430
+ def lang_unset() -> None:
431
+ """Remove the persisted default language."""
432
+ if config.unset_default_language():
433
+ console.print("[green]✓ Default language removed.[/green]")
434
+ else:
435
+ console.print("[dim]No default language was set.[/dim]")
436
+
437
+
438
+ # ── Output helpers ─────────────────────────────────────────────────────────────
439
+
440
+
441
+ def _prompt_rating() -> int | None:
442
+ """Prompt until a valid 1–4 rating or 'q' is entered. Returns None on quit."""
443
+ while True:
444
+ raw = typer.prompt(" Rating (1-4, q to quit)").strip().lower()
445
+ if raw in ("q", "quit"):
446
+ return None
447
+ if raw.isdigit() and int(raw) in RATINGS:
448
+ return int(raw)
449
+ err_console.print(" [red]Enter 1, 2, 3, 4, or q.[/red]")
450
+
451
+
452
+ def _print_vocab_item(item: dict, action: str) -> None:
453
+ bits = [f"[green]{action}[/green] [bold]{item.get('lemma', '')}[/bold]"]
454
+ if item.get("part_of_speech"):
455
+ bits.append(f"({item['part_of_speech']})")
456
+ bits.append(f"→ status [bold]{item.get('status', '')}[/bold]")
457
+ if item.get("cefr_level"):
458
+ bits.append(f"· CEFR {item['cefr_level']}")
459
+ console.print(" ".join(bits))
460
+ if item.get("next_review"):
461
+ console.print(f"[dim]Next review: {_fmt_dt(item['next_review'])}[/dim]")
462
+
463
+
464
+ def _fmt_dt(value: str | None) -> str:
465
+ if not value:
466
+ return "—"
467
+ # ISO timestamps render fine trimmed to the minute.
468
+ return value.replace("T", " ")[:16]
469
+
470
+
471
+ def main() -> None:
472
+ """Console-script entry point."""
473
+ app()
474
+
475
+
476
+ if __name__ == "__main__":
477
+ main()
glossa_cli/client.py ADDED
@@ -0,0 +1,184 @@
1
+ """Thin httpx client for the Glossa REST API.
2
+
3
+ Vendored from ``backend/glossa_mcp/client.py`` and extended with the vocabulary
4
+ and review endpoints the CLI needs. Authentication is resolved in this order:
5
+
6
+ 1. ``GLOSSA_API_TOKEN`` env var — a static ``glossa_*`` API token generated in
7
+ Glossa Settings → Tokens. Useful for scripting / CI.
8
+ 2. OAuth 2.0 device-code flow (RFC 8628) — the first time there are no stored
9
+ credentials, a URL is printed to stderr so the user can approve in a browser.
10
+ Subsequent runs load the saved access/refresh tokens from
11
+ ``~/.config/glossa/credentials.json`` and refresh automatically.
12
+
13
+ Set ``GLOSSA_API_URL`` (default ``https://api.glossa.pro``) to point at a
14
+ different instance (e.g. ``http://localhost:8000`` for local dev).
15
+ """
16
+
17
+ import os
18
+ from typing import Any
19
+
20
+ import httpx
21
+
22
+ from glossa_cli.auth import AuthError, ensure_token
23
+
24
+ DEFAULT_TIMEOUT_SECONDS = 30
25
+ DEFAULT_BASE_URL = "https://api.glossa.pro"
26
+
27
+
28
+ class GlossaApiError(Exception):
29
+ """Raised when the API responds with an error status.
30
+
31
+ ``detail`` preserves the parsed body of FastAPI's ``detail`` field — either
32
+ a string (most errors) or a dict (structured errors like
33
+ ``{"error": "multiple_languages", "enabled": [...]}``). The CLI inspects
34
+ the dict shape to render actionable messages.
35
+ """
36
+
37
+ def __init__(self, status_code: int, detail: "str | dict") -> None:
38
+ super().__init__(f"HTTP {status_code}: {detail}")
39
+ self.status_code = status_code
40
+ self.detail = detail
41
+
42
+
43
+ class GlossaClient:
44
+ """Async client that authenticates with a static API token **or** OAuth.
45
+
46
+ Call ``await client.ensure_auth()`` before the first request — or just
47
+ use the async context manager which calls it for you.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ base_url: str | None = None,
53
+ token: str | None = None,
54
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
55
+ ) -> None:
56
+ self.base_url = (base_url or os.environ.get("GLOSSA_API_URL", DEFAULT_BASE_URL)).rstrip("/")
57
+ self._static_token = token or os.environ.get("GLOSSA_API_TOKEN", "")
58
+ self._timeout = timeout
59
+ self._client: httpx.AsyncClient | None = None
60
+
61
+ async def ensure_auth(self) -> None:
62
+ """Resolve the bearer token (static env var or OAuth) and open the httpx client."""
63
+ if self._client is not None:
64
+ return # already initialised
65
+
66
+ if not self.base_url:
67
+ raise RuntimeError(
68
+ "GLOSSA_API_URL is not set. Export it (e.g. "
69
+ "https://api.glossa.pro, or http://localhost:8000 for local dev)."
70
+ )
71
+
72
+ if self._static_token:
73
+ bearer = self._static_token
74
+ else:
75
+ try:
76
+ bearer = await ensure_token(self.base_url)
77
+ except AuthError as exc:
78
+ raise RuntimeError(str(exc)) from exc
79
+
80
+ self._client = httpx.AsyncClient(
81
+ base_url=self.base_url,
82
+ timeout=self._timeout,
83
+ headers={"Authorization": f"Bearer {bearer}"},
84
+ )
85
+
86
+ async def aclose(self) -> None:
87
+ if self._client is not None:
88
+ await self._client.aclose()
89
+ self._client = None
90
+
91
+ async def __aenter__(self) -> "GlossaClient":
92
+ await self.ensure_auth()
93
+ return self
94
+
95
+ async def __aexit__(self, *exc: object) -> None:
96
+ await self.aclose()
97
+
98
+ async def _request(
99
+ self, method: str, path: str, json: dict | None = None
100
+ ) -> Any:
101
+ if self._client is None:
102
+ await self.ensure_auth()
103
+ resp = await self._client.request(method, path, json=json) # type: ignore[union-attr]
104
+ if resp.is_success:
105
+ if resp.status_code == 204 or not resp.content:
106
+ return None
107
+ return resp.json()
108
+ try:
109
+ payload = resp.json()
110
+ detail = payload.get("detail", resp.text) if isinstance(payload, dict) else resp.text
111
+ except ValueError:
112
+ detail = resp.text or resp.reason_phrase
113
+ # Preserve structured detail (dict) as-is so the CLI can branch on it;
114
+ # fall back to a string for plain-text errors.
115
+ raise GlossaApiError(
116
+ resp.status_code, detail if isinstance(detail, dict) else str(detail)
117
+ )
118
+
119
+ # ── Text analysis ─────────────────────────────────────────────────────────
120
+
121
+ async def analyze_text(
122
+ self, content: str, language_code: str | None = None
123
+ ) -> Any:
124
+ body: dict[str, Any] = {"content": content}
125
+ if language_code is not None:
126
+ body["language_code"] = language_code
127
+ return await self._request("POST", "/api/articles/analyze", body)
128
+
129
+ # ── Vocabulary ──────────────────────────────────────────────────────────────
130
+
131
+ async def add_word(
132
+ self,
133
+ lemma: str,
134
+ language_code: str | None = None,
135
+ part_of_speech: str | None = None,
136
+ status: str = "learning",
137
+ translation: str | None = None,
138
+ ) -> Any:
139
+ body: dict[str, Any] = {"lemma": lemma, "status": status}
140
+ if language_code is not None:
141
+ body["language_code"] = language_code
142
+ if part_of_speech is not None:
143
+ body["part_of_speech"] = part_of_speech
144
+ if translation is not None:
145
+ body["user_translation"] = translation
146
+ return await self._request("POST", "/api/vocabulary/add", body)
147
+
148
+ async def list_vocabulary(
149
+ self, status: str | None = None, language_code: str | None = None
150
+ ) -> Any:
151
+ params: dict[str, str] = {}
152
+ if status is not None:
153
+ params["status"] = status
154
+ if language_code is not None:
155
+ params["language_code"] = language_code
156
+ query = ("?" + "&".join(f"{k}={v}" for k, v in params.items())) if params else ""
157
+ return await self._request("GET", f"/api/vocabulary{query}")
158
+
159
+ async def update_status(
160
+ self,
161
+ lexical_entry_id: int,
162
+ status: str,
163
+ user_translation: str | None = None,
164
+ ) -> Any:
165
+ body: dict[str, Any] = {
166
+ "lexical_entry_id": lexical_entry_id,
167
+ "status": status,
168
+ }
169
+ if user_translation is not None:
170
+ body["user_translation"] = user_translation
171
+ return await self._request("POST", "/api/vocabulary/update", body)
172
+
173
+ async def list_learning_words(self, language_code: str | None = None) -> Any:
174
+ params = f"?language_code={language_code}" if language_code else ""
175
+ return await self._request("GET", f"/api/extension/learning-words{params}")
176
+
177
+ # ── Reviews (FSRS) ────────────────────────────────────────────────────────
178
+
179
+ async def reviews_due(self, limit: int = 50) -> Any:
180
+ return await self._request("GET", f"/api/reviews/due?limit={limit}")
181
+
182
+ async def submit_review(self, user_vocabulary_id: int, rating: int) -> Any:
183
+ body = {"user_vocabulary_id": user_vocabulary_id, "rating": rating}
184
+ return await self._request("POST", "/api/reviews/submit", body)