studylife-cli 1.3.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.
- studylife_cli/__init__.py +0 -0
- studylife_cli/_version.py +24 -0
- studylife_cli/cli.py +546 -0
- studylife_cli/client.py +169 -0
- studylife_cli/credentials.py +54 -0
- studylife_cli/login.py +246 -0
- studylife_cli/models.py +109 -0
- studylife_cli-1.3.0.dist-info/METADATA +98 -0
- studylife_cli-1.3.0.dist-info/RECORD +12 -0
- studylife_cli-1.3.0.dist-info/WHEEL +4 -0
- studylife_cli-1.3.0.dist-info/entry_points.txt +2 -0
- studylife_cli-1.3.0.dist-info/licenses/LICENSE +661 -0
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '1.3.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 3, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
studylife_cli/cli.py
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
"""`studylife` - command-line client for a self-hosted StudyLife instance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as json_module
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from studylife_cli.client import ApiError, StudyLifeClient
|
|
14
|
+
from studylife_cli.credentials import Credentials, clear_credentials, load_credentials
|
|
15
|
+
from studylife_cli.login import DEFAULT_CLIENT_ID, LoginError, login_and_save
|
|
16
|
+
from studylife_cli.models import CourseGoal, Note, Session
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(no_args_is_help=True, add_completion=False)
|
|
19
|
+
notes_app = typer.Typer(no_args_is_help=True, help="Manage notes.")
|
|
20
|
+
sessions_app = typer.Typer(no_args_is_help=True, help="Manage study sessions.")
|
|
21
|
+
goals_app = typer.Typer(no_args_is_help=True, help="Manage course goals.")
|
|
22
|
+
courses_app = typer.Typer(no_args_is_help=True, help="Browse courses.")
|
|
23
|
+
programs_app = typer.Typer(no_args_is_help=True, help="Browse study programs.")
|
|
24
|
+
webhooks_app = typer.Typer(no_args_is_help=True, help="Manage webhooks.")
|
|
25
|
+
app.add_typer(notes_app, name="notes")
|
|
26
|
+
app.add_typer(sessions_app, name="sessions")
|
|
27
|
+
app.add_typer(goals_app, name="goals")
|
|
28
|
+
app.add_typer(courses_app, name="courses")
|
|
29
|
+
app.add_typer(programs_app, name="programs")
|
|
30
|
+
app.add_typer(webhooks_app, name="webhooks")
|
|
31
|
+
|
|
32
|
+
# StudyLife content (course icons, note text) is arbitrary Unicode, but Python's stdout/stderr
|
|
33
|
+
# on Windows default to the console's legacy OEM/ANSI codepage (e.g. cp1252) unless overridden -
|
|
34
|
+
# printing anything outside that codepage then crashes the whole command with a
|
|
35
|
+
# UnicodeEncodeError instead of just rendering oddly. errors="replace" trades a perfect glyph
|
|
36
|
+
# for "never crashes on real StudyLife content" on whatever terminal this happens to run in.
|
|
37
|
+
if sys.platform == "win32":
|
|
38
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
39
|
+
if hasattr(_stream, "reconfigure"):
|
|
40
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
41
|
+
|
|
42
|
+
# Also disables rich's separate "legacy Windows console" writer (a different code path, used on
|
|
43
|
+
# terminals it can't detect ANSI/VT support for), which encodes through the same codepage
|
|
44
|
+
# independently of sys.stdout and would otherwise still crash even with the reconfigure above.
|
|
45
|
+
console = Console(legacy_windows=False)
|
|
46
|
+
error_console = Console(stderr=True, legacy_windows=False)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class CliState:
|
|
51
|
+
as_json: bool
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Click/Typer options declared on the root app's callback only parse when given BEFORE the
|
|
55
|
+
# subcommand (`studylife --json notes list`), not after (`studylife notes list --json`) - each
|
|
56
|
+
# subcommand has its own parser that doesn't know about the parent's options. Re-declaring the
|
|
57
|
+
# same flag on every leaf command and OR-ing it with the root-level one (see _use_json below)
|
|
58
|
+
# makes both orders work, matching how most people instinctively type it.
|
|
59
|
+
JSON_OPTION = typer.Option(False, "--json", help="Print machine-readable JSON instead of a table.")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _use_json(ctx: typer.Context, local: bool) -> bool:
|
|
63
|
+
state: CliState = ctx.obj
|
|
64
|
+
return state.as_json or local
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Table-view field whitelists, one per resource type shown by a list/search/history command -
|
|
68
|
+
# the full field set (used unconditionally by --json) is often too wide for a terminal (a note's
|
|
69
|
+
# content, a session's course_color, a course's full topic list). Deliberately not applied to
|
|
70
|
+
# create/edit/delete confirmations (_confirm) or to single-item "get" views - those already show
|
|
71
|
+
# one thing at a time and benefit from seeing everything.
|
|
72
|
+
NOTE_TABLE_COLUMNS = ["id", "title", "course_id", "updated_at"]
|
|
73
|
+
SESSION_TABLE_COLUMNS = ["id", "course_name", "start_time", "end_time", "topic", "is_completed"]
|
|
74
|
+
COURSE_GOAL_TABLE_COLUMNS = ["course_id", "course_name", "target_date", "grade", "tag"]
|
|
75
|
+
COURSE_TABLE_COLUMNS = ["id", "name", "code", "semester", "ects"]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _print_version_and_exit(value: bool) -> None:
|
|
79
|
+
if not value:
|
|
80
|
+
return
|
|
81
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
console.print(version("studylife-cli"))
|
|
85
|
+
except PackageNotFoundError:
|
|
86
|
+
# Editable/uninstalled checkout (e.g. `uv run` before a build has ever run) - hatch-vcs
|
|
87
|
+
# only writes package metadata as part of a real build/install, not on a bare source tree.
|
|
88
|
+
console.print("unknown (not installed from a built package)")
|
|
89
|
+
raise typer.Exit()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.callback()
|
|
93
|
+
def main(
|
|
94
|
+
ctx: typer.Context,
|
|
95
|
+
as_json: bool = typer.Option(
|
|
96
|
+
False, "--json", help="Print machine-readable JSON instead of a table."
|
|
97
|
+
),
|
|
98
|
+
version: bool = typer.Option(
|
|
99
|
+
False,
|
|
100
|
+
"--version",
|
|
101
|
+
callback=_print_version_and_exit,
|
|
102
|
+
is_eager=True,
|
|
103
|
+
help="Show the installed version and exit.",
|
|
104
|
+
),
|
|
105
|
+
) -> None:
|
|
106
|
+
ctx.obj = CliState(as_json=as_json)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _require_client(ctx: typer.Context) -> StudyLifeClient:
|
|
110
|
+
credentials = load_credentials()
|
|
111
|
+
if credentials is None:
|
|
112
|
+
error_console.print(
|
|
113
|
+
"Not logged in - run [bold]studylife login <instance-url>[/bold] first."
|
|
114
|
+
)
|
|
115
|
+
raise typer.Exit(1)
|
|
116
|
+
return StudyLifeClient(credentials.instance_url, credentials.api_key)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _print(
|
|
120
|
+
as_json: bool, rows: list[dict[str, object]], title: str, columns: list[str] | None = None
|
|
121
|
+
) -> None:
|
|
122
|
+
"""columns restricts which fields the TABLE view shows (a resource can have far more fields
|
|
123
|
+
than fit a terminal - e.g. a note's full content, or a session's course_color) - --json always
|
|
124
|
+
returns every field regardless, since a table's readability limit doesn't apply there."""
|
|
125
|
+
if as_json:
|
|
126
|
+
print(json_module.dumps(rows, indent=2, default=str))
|
|
127
|
+
return
|
|
128
|
+
if not rows:
|
|
129
|
+
console.print(f"No {title.lower()}.")
|
|
130
|
+
return
|
|
131
|
+
display_rows = [{k: row.get(k) for k in columns} for row in rows] if columns else rows
|
|
132
|
+
table = Table(title=title)
|
|
133
|
+
for key in display_rows[0]:
|
|
134
|
+
table.add_column(key)
|
|
135
|
+
for row in display_rows:
|
|
136
|
+
table.add_row(*(str(value) if value is not None else "" for value in row.values()))
|
|
137
|
+
console.print(table)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _confirm(as_json: bool, payload: dict[str, object], message: str) -> None:
|
|
141
|
+
"""Reports the outcome of a create/edit/delete command - the full affected resource (or, for
|
|
142
|
+
a delete, just its id) as JSON with --json, otherwise the same short human message every
|
|
143
|
+
mutation command already printed."""
|
|
144
|
+
if as_json:
|
|
145
|
+
print(json_module.dumps(payload, indent=2, default=str))
|
|
146
|
+
else:
|
|
147
|
+
console.print(message)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _run(ctx: typer.Context, fn: object) -> object:
|
|
151
|
+
"""Calls fn() against a fresh client, translating ApiError/network failures into a clean
|
|
152
|
+
exit instead of a raw traceback - the client is opened/closed per invocation since this is
|
|
153
|
+
a short-lived CLI process, not a long-running one."""
|
|
154
|
+
client = _require_client(ctx)
|
|
155
|
+
try:
|
|
156
|
+
return fn(client)
|
|
157
|
+
except ApiError as exc:
|
|
158
|
+
error_console.print(f"[red]{exc}[/red]")
|
|
159
|
+
raise typer.Exit(1) from exc
|
|
160
|
+
finally:
|
|
161
|
+
client.close()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@app.command()
|
|
165
|
+
def login(
|
|
166
|
+
instance_url: str = typer.Argument(
|
|
167
|
+
..., help="Base URL of your StudyLife instance, e.g. https://studylife.example.com"
|
|
168
|
+
),
|
|
169
|
+
client_id: str = typer.Option(
|
|
170
|
+
DEFAULT_CLIENT_ID, help="ClientId this CLI was registered under via studylife-developers."
|
|
171
|
+
),
|
|
172
|
+
) -> None:
|
|
173
|
+
"""Log in via your browser and store the resulting credential locally."""
|
|
174
|
+
try:
|
|
175
|
+
credentials: Credentials = login_and_save(instance_url, client_id=client_id)
|
|
176
|
+
except LoginError as exc:
|
|
177
|
+
error_console.print(f"[red]Login failed: {exc}[/red]")
|
|
178
|
+
raise typer.Exit(1) from exc
|
|
179
|
+
console.print(f"[green]Logged in[/green] to {credentials.instance_url}.")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@app.command()
|
|
183
|
+
def logout() -> None:
|
|
184
|
+
"""Remove the locally stored credential."""
|
|
185
|
+
clear_credentials()
|
|
186
|
+
console.print("Logged out.")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# -- Notes --------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@notes_app.command("list")
|
|
193
|
+
def notes_list(ctx: typer.Context, as_json: bool = JSON_OPTION) -> None:
|
|
194
|
+
"""List notes. Table view shows a few fields - use --json for every field."""
|
|
195
|
+
notes = _run(ctx, lambda c: c.list_notes())
|
|
196
|
+
_print(
|
|
197
|
+
_use_json(ctx, as_json),
|
|
198
|
+
[n.model_dump(mode="json") for n in notes],
|
|
199
|
+
"Notes",
|
|
200
|
+
columns=NOTE_TABLE_COLUMNS,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@notes_app.command("search")
|
|
205
|
+
def notes_search(ctx: typer.Context, query: str, as_json: bool = JSON_OPTION) -> None:
|
|
206
|
+
"""Search notes by title/content."""
|
|
207
|
+
notes = _run(ctx, lambda c: c.search_notes(query))
|
|
208
|
+
_print(
|
|
209
|
+
_use_json(ctx, as_json),
|
|
210
|
+
[n.model_dump(mode="json") for n in notes],
|
|
211
|
+
"Notes",
|
|
212
|
+
columns=NOTE_TABLE_COLUMNS,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@notes_app.command("create")
|
|
217
|
+
def notes_create(
|
|
218
|
+
ctx: typer.Context,
|
|
219
|
+
title: str,
|
|
220
|
+
content: str,
|
|
221
|
+
course_id: int | None = typer.Option(None, help="Course to attach this note to."),
|
|
222
|
+
as_json: bool = JSON_OPTION,
|
|
223
|
+
) -> None:
|
|
224
|
+
"""Create a note."""
|
|
225
|
+
note = _run(
|
|
226
|
+
ctx, lambda c: c.create_note(Note(title=title, content=content, course_id=course_id))
|
|
227
|
+
)
|
|
228
|
+
_confirm(_use_json(ctx, as_json), note.model_dump(mode="json"), f"Created note {note.id}.")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@notes_app.command("edit")
|
|
232
|
+
def notes_edit(
|
|
233
|
+
ctx: typer.Context,
|
|
234
|
+
note_id: int,
|
|
235
|
+
title: str,
|
|
236
|
+
content: str,
|
|
237
|
+
course_id: int | None = typer.Option(None),
|
|
238
|
+
as_json: bool = JSON_OPTION,
|
|
239
|
+
) -> None:
|
|
240
|
+
"""Edit a note. Replaces title/content/course_id entirely (not a partial patch)."""
|
|
241
|
+
note = _run(
|
|
242
|
+
ctx,
|
|
243
|
+
lambda c: c.update_note(note_id, Note(title=title, content=content, course_id=course_id)),
|
|
244
|
+
)
|
|
245
|
+
_confirm(_use_json(ctx, as_json), note.model_dump(mode="json"), f"Updated note {note.id}.")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@notes_app.command("delete")
|
|
249
|
+
def notes_delete(ctx: typer.Context, note_id: int, as_json: bool = JSON_OPTION) -> None:
|
|
250
|
+
"""Delete a note."""
|
|
251
|
+
_run(ctx, lambda c: c.delete_note(note_id))
|
|
252
|
+
_confirm(_use_json(ctx, as_json), {"deleted": note_id}, f"Deleted note {note_id}.")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# -- Sessions -----------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@sessions_app.command("list")
|
|
259
|
+
def sessions_list(ctx: typer.Context, as_json: bool = JSON_OPTION) -> None:
|
|
260
|
+
"""List sessions. Unbounded - use `sessions history` for a long-term window."""
|
|
261
|
+
sessions = _run(ctx, lambda c: c.list_sessions())
|
|
262
|
+
_print(
|
|
263
|
+
_use_json(ctx, as_json),
|
|
264
|
+
[s.model_dump(mode="json") for s in sessions],
|
|
265
|
+
"Sessions",
|
|
266
|
+
columns=SESSION_TABLE_COLUMNS,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@sessions_app.command("history")
|
|
271
|
+
def sessions_history(
|
|
272
|
+
ctx: typer.Context,
|
|
273
|
+
days: int | None = typer.Option(None),
|
|
274
|
+
only_completed: bool | None = typer.Option(None, "--only-completed/--all"),
|
|
275
|
+
as_json: bool = JSON_OPTION,
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Long-term session history, default 1 year of completed sessions."""
|
|
278
|
+
sessions = _run(ctx, lambda c: c.session_history(days=days, only_completed=only_completed))
|
|
279
|
+
_print(
|
|
280
|
+
_use_json(ctx, as_json),
|
|
281
|
+
[s.model_dump(mode="json") for s in sessions],
|
|
282
|
+
"Session history",
|
|
283
|
+
columns=SESSION_TABLE_COLUMNS,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@sessions_app.command("create")
|
|
288
|
+
def sessions_create(
|
|
289
|
+
ctx: typer.Context,
|
|
290
|
+
course_id: int = typer.Argument(..., help="Course id (see `studylife courses list`)."),
|
|
291
|
+
start: str = typer.Argument(..., help="ISO 8601 start time, e.g. 2026-08-30T14:00:00"),
|
|
292
|
+
end: str = typer.Argument(..., help="ISO 8601 end time, e.g. 2026-08-30T15:00:00"),
|
|
293
|
+
topic: str | None = typer.Option(None),
|
|
294
|
+
notes: str | None = typer.Option(None),
|
|
295
|
+
completed: bool = typer.Option(False),
|
|
296
|
+
as_json: bool = JSON_OPTION,
|
|
297
|
+
) -> None:
|
|
298
|
+
"""Create a study session."""
|
|
299
|
+
session = _run(
|
|
300
|
+
ctx,
|
|
301
|
+
lambda c: c.create_session(
|
|
302
|
+
Session(
|
|
303
|
+
course_id=course_id,
|
|
304
|
+
start_time=start,
|
|
305
|
+
end_time=end,
|
|
306
|
+
topic=topic,
|
|
307
|
+
notes=notes,
|
|
308
|
+
is_completed=completed,
|
|
309
|
+
)
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
_confirm(
|
|
313
|
+
_use_json(ctx, as_json), session.model_dump(mode="json"), f"Created session {session.id}."
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@sessions_app.command("edit")
|
|
318
|
+
def sessions_edit(
|
|
319
|
+
ctx: typer.Context,
|
|
320
|
+
session_id: int,
|
|
321
|
+
course_id: int = typer.Argument(...),
|
|
322
|
+
start: str = typer.Argument(...),
|
|
323
|
+
end: str = typer.Argument(...),
|
|
324
|
+
topic: str | None = typer.Option(None),
|
|
325
|
+
notes: str | None = typer.Option(None),
|
|
326
|
+
completed: bool = typer.Option(False),
|
|
327
|
+
as_json: bool = JSON_OPTION,
|
|
328
|
+
) -> None:
|
|
329
|
+
"""Edit a session. Replaces every field entirely (not a partial patch)."""
|
|
330
|
+
session = _run(
|
|
331
|
+
ctx,
|
|
332
|
+
lambda c: c.update_session(
|
|
333
|
+
session_id,
|
|
334
|
+
Session(
|
|
335
|
+
course_id=course_id,
|
|
336
|
+
start_time=start,
|
|
337
|
+
end_time=end,
|
|
338
|
+
topic=topic,
|
|
339
|
+
notes=notes,
|
|
340
|
+
is_completed=completed,
|
|
341
|
+
),
|
|
342
|
+
),
|
|
343
|
+
)
|
|
344
|
+
_confirm(
|
|
345
|
+
_use_json(ctx, as_json), session.model_dump(mode="json"), f"Updated session {session.id}."
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@sessions_app.command("delete")
|
|
350
|
+
def sessions_delete(ctx: typer.Context, session_id: int, as_json: bool = JSON_OPTION) -> None:
|
|
351
|
+
"""Delete a session."""
|
|
352
|
+
_run(ctx, lambda c: c.delete_session(session_id))
|
|
353
|
+
_confirm(_use_json(ctx, as_json), {"deleted": session_id}, f"Deleted session {session_id}.")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# -- Course goals -------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@goals_app.command("list")
|
|
360
|
+
def goals_list(ctx: typer.Context, as_json: bool = JSON_OPTION) -> None:
|
|
361
|
+
"""List course goals."""
|
|
362
|
+
goals = _run(ctx, lambda c: c.list_course_goals())
|
|
363
|
+
_print(
|
|
364
|
+
_use_json(ctx, as_json),
|
|
365
|
+
[g.model_dump(mode="json") for g in goals],
|
|
366
|
+
"Course goals",
|
|
367
|
+
columns=COURSE_GOAL_TABLE_COLUMNS,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@goals_app.command("set")
|
|
372
|
+
def goals_set(
|
|
373
|
+
ctx: typer.Context,
|
|
374
|
+
course_id: int,
|
|
375
|
+
target_date: str | None = typer.Option(None, help="ISO 8601 date, e.g. 2026-12-31."),
|
|
376
|
+
completion_note: str | None = typer.Option(None),
|
|
377
|
+
completed_at: str | None = typer.Option(None, help="ISO 8601 date, once completed."),
|
|
378
|
+
grade: float | None = typer.Option(None, help="German grading, 1.0 (best) to 5.0 (failed)."),
|
|
379
|
+
completed_topics: str = typer.Option("", help="Comma-separated topic names."),
|
|
380
|
+
tag: str | None = typer.Option(None),
|
|
381
|
+
as_json: bool = JSON_OPTION,
|
|
382
|
+
) -> None:
|
|
383
|
+
"""Set (create or fully replace) the goal for a course. Every field not passed is cleared,
|
|
384
|
+
not left as-is - this mirrors the server's own full-replace PUT semantics."""
|
|
385
|
+
goal = _run(
|
|
386
|
+
ctx,
|
|
387
|
+
lambda c: c.save_course_goal(
|
|
388
|
+
course_id,
|
|
389
|
+
CourseGoal(
|
|
390
|
+
course_id=course_id,
|
|
391
|
+
target_date=target_date,
|
|
392
|
+
completion_note=completion_note,
|
|
393
|
+
completed_at=completed_at,
|
|
394
|
+
grade=grade,
|
|
395
|
+
completed_topics=completed_topics,
|
|
396
|
+
tag=tag,
|
|
397
|
+
),
|
|
398
|
+
),
|
|
399
|
+
)
|
|
400
|
+
_confirm(
|
|
401
|
+
_use_json(ctx, as_json),
|
|
402
|
+
goal.model_dump(mode="json"),
|
|
403
|
+
f"Saved goal for course {goal.course_id}.",
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
@goals_app.command("delete")
|
|
408
|
+
def goals_delete(ctx: typer.Context, course_id: int, as_json: bool = JSON_OPTION) -> None:
|
|
409
|
+
"""Delete a course's goal."""
|
|
410
|
+
_run(ctx, lambda c: c.delete_course_goal(course_id))
|
|
411
|
+
_confirm(
|
|
412
|
+
_use_json(ctx, as_json), {"deleted": course_id}, f"Deleted goal for course {course_id}."
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# -- Timer ----------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@app.command()
|
|
420
|
+
def timer(ctx: typer.Context, as_json: bool = JSON_OPTION) -> None:
|
|
421
|
+
"""Show the current timer state."""
|
|
422
|
+
state = _run(ctx, lambda c: c.get_timer_state())
|
|
423
|
+
_print(_use_json(ctx, as_json), [state.model_dump(mode="json")], "Timer state")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# -- Courses / study programs --------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@courses_app.command("list")
|
|
430
|
+
def courses_list(ctx: typer.Context, as_json: bool = JSON_OPTION) -> None:
|
|
431
|
+
"""List courses in the active study program."""
|
|
432
|
+
courses = _run(ctx, lambda c: c.list_courses())
|
|
433
|
+
_print(
|
|
434
|
+
_use_json(ctx, as_json),
|
|
435
|
+
[c.model_dump(mode="json") for c in courses],
|
|
436
|
+
"Courses",
|
|
437
|
+
columns=COURSE_TABLE_COLUMNS,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@programs_app.command("list")
|
|
442
|
+
def programs_list(ctx: typer.Context, as_json: bool = JSON_OPTION) -> None:
|
|
443
|
+
"""List study programs (built-in and custom)."""
|
|
444
|
+
programs = _run(ctx, lambda c: c.list_study_programs())
|
|
445
|
+
_print(_use_json(ctx, as_json), [p.model_dump(mode="json") for p in programs], "Study programs")
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
@programs_app.command("get")
|
|
449
|
+
def programs_get(ctx: typer.Context, program_id: int, as_json: bool = JSON_OPTION) -> None:
|
|
450
|
+
"""Get a custom study program's detail (course groups/ECTS quotas). Only applies to custom
|
|
451
|
+
programs - the built-in program has no id and no detail endpoint."""
|
|
452
|
+
program = _run(ctx, lambda c: c.get_study_program(program_id))
|
|
453
|
+
_print(_use_json(ctx, as_json), [program.model_dump(mode="json")], "Study program")
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# -- Webhooks ---------------------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
@webhooks_app.command("list")
|
|
460
|
+
def webhooks_list(ctx: typer.Context, as_json: bool = JSON_OPTION) -> None:
|
|
461
|
+
"""List registered webhooks."""
|
|
462
|
+
webhooks = _run(ctx, lambda c: c.list_webhooks())
|
|
463
|
+
_print(_use_json(ctx, as_json), [w.model_dump(mode="json") for w in webhooks], "Webhooks")
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
@webhooks_app.command("create")
|
|
467
|
+
def webhooks_create(
|
|
468
|
+
ctx: typer.Context, target_url: str, events: list[str], as_json: bool = JSON_OPTION
|
|
469
|
+
) -> None:
|
|
470
|
+
"""Register a webhook. events is one or more event type names, e.g. session.completed."""
|
|
471
|
+
webhook = _run(ctx, lambda c: c.create_webhook(target_url, events))
|
|
472
|
+
_confirm(
|
|
473
|
+
_use_json(ctx, as_json), webhook.model_dump(mode="json"), f"Created webhook {webhook.id}."
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@webhooks_app.command("delete")
|
|
478
|
+
def webhooks_delete(ctx: typer.Context, webhook_id: str, as_json: bool = JSON_OPTION) -> None:
|
|
479
|
+
"""Delete a webhook."""
|
|
480
|
+
_run(ctx, lambda c: c.delete_webhook(webhook_id))
|
|
481
|
+
_confirm(_use_json(ctx, as_json), {"deleted": webhook_id}, f"Deleted webhook {webhook_id}.")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# -- Verb-first aliases --------------------------------------------------------------
|
|
485
|
+
#
|
|
486
|
+
# Every command above reads as "resource verb" (`studylife notes list`). Some people reach for
|
|
487
|
+
# "verb resource" instead (`studylife list notes`) - both are registered here for exactly the
|
|
488
|
+
# same underlying functions (a Typer @x.command() decorator hands back the plain function
|
|
489
|
+
# unchanged, so re-registering it under a second Typer app is just a second entry pointing at
|
|
490
|
+
# the same code, not a copy of it - no logic is duplicated, and a fix to one applies to both).
|
|
491
|
+
# Search/history/timer already read naturally as a bare verb (only one resource each supports
|
|
492
|
+
# them), so those get a single top-level command instead of a one-item dispatch group.
|
|
493
|
+
list_app = typer.Typer(
|
|
494
|
+
no_args_is_help=True, help="List any resource (alias for `<resource> list`)."
|
|
495
|
+
)
|
|
496
|
+
create_app = typer.Typer(
|
|
497
|
+
no_args_is_help=True, help="Create a resource (alias for `<resource> create`)."
|
|
498
|
+
)
|
|
499
|
+
edit_app = typer.Typer(no_args_is_help=True, help="Edit a resource (alias for `<resource> edit`).")
|
|
500
|
+
delete_app = typer.Typer(
|
|
501
|
+
no_args_is_help=True, help="Delete a resource (alias for `<resource> delete`)."
|
|
502
|
+
)
|
|
503
|
+
get_app = typer.Typer(
|
|
504
|
+
no_args_is_help=True, help="Get a single resource (alias for `<resource> get`)."
|
|
505
|
+
)
|
|
506
|
+
set_app = typer.Typer(no_args_is_help=True, help="Set a resource (alias for `<resource> set`).")
|
|
507
|
+
|
|
508
|
+
list_app.command("notes")(notes_list)
|
|
509
|
+
list_app.command("sessions")(sessions_list)
|
|
510
|
+
list_app.command("goals")(goals_list)
|
|
511
|
+
list_app.command("courses")(courses_list)
|
|
512
|
+
list_app.command("programs")(programs_list)
|
|
513
|
+
list_app.command("webhooks")(webhooks_list)
|
|
514
|
+
app.add_typer(list_app, name="list")
|
|
515
|
+
|
|
516
|
+
create_app.command("notes")(notes_create)
|
|
517
|
+
create_app.command("sessions")(sessions_create)
|
|
518
|
+
create_app.command("webhooks")(webhooks_create)
|
|
519
|
+
app.add_typer(create_app, name="create")
|
|
520
|
+
|
|
521
|
+
edit_app.command("notes")(notes_edit)
|
|
522
|
+
edit_app.command("sessions")(sessions_edit)
|
|
523
|
+
app.add_typer(edit_app, name="edit")
|
|
524
|
+
|
|
525
|
+
delete_app.command("notes")(notes_delete)
|
|
526
|
+
delete_app.command("sessions")(sessions_delete)
|
|
527
|
+
delete_app.command("goals")(goals_delete)
|
|
528
|
+
delete_app.command("webhooks")(webhooks_delete)
|
|
529
|
+
app.add_typer(delete_app, name="delete")
|
|
530
|
+
|
|
531
|
+
get_app.command("programs")(programs_get)
|
|
532
|
+
app.add_typer(get_app, name="get")
|
|
533
|
+
|
|
534
|
+
set_app.command("goals")(goals_set)
|
|
535
|
+
app.add_typer(set_app, name="set")
|
|
536
|
+
|
|
537
|
+
app.command("search")(notes_search)
|
|
538
|
+
app.command("history")(sessions_history)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def run() -> None:
|
|
542
|
+
app()
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
if __name__ == "__main__":
|
|
546
|
+
sys.exit(run())
|