studyctl 2.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.
- studyctl/__init__.py +3 -0
- studyctl/calendar.py +140 -0
- studyctl/cli/__init__.py +56 -0
- studyctl/cli/_config.py +128 -0
- studyctl/cli/_content.py +462 -0
- studyctl/cli/_lazy.py +35 -0
- studyctl/cli/_review.py +491 -0
- studyctl/cli/_schedule.py +125 -0
- studyctl/cli/_setup.py +164 -0
- studyctl/cli/_shared.py +83 -0
- studyctl/cli/_state.py +69 -0
- studyctl/cli/_sync.py +156 -0
- studyctl/cli/_web.py +228 -0
- studyctl/content/__init__.py +5 -0
- studyctl/content/markdown_converter.py +271 -0
- studyctl/content/models.py +31 -0
- studyctl/content/notebooklm_client.py +434 -0
- studyctl/content/splitter.py +159 -0
- studyctl/content/storage.py +105 -0
- studyctl/content/syllabus.py +416 -0
- studyctl/history.py +982 -0
- studyctl/maintenance.py +69 -0
- studyctl/mcp/__init__.py +1 -0
- studyctl/mcp/server.py +58 -0
- studyctl/mcp/tools.py +234 -0
- studyctl/pdf.py +89 -0
- studyctl/review_db.py +277 -0
- studyctl/review_loader.py +375 -0
- studyctl/scheduler.py +242 -0
- studyctl/services/__init__.py +6 -0
- studyctl/services/content.py +39 -0
- studyctl/services/review.py +127 -0
- studyctl/settings.py +367 -0
- studyctl/shared.py +425 -0
- studyctl/state.py +120 -0
- studyctl/sync.py +229 -0
- studyctl/tui/__main__.py +33 -0
- studyctl/tui/app.py +395 -0
- studyctl/tui/study_cards.py +396 -0
- studyctl/web/__init__.py +1 -0
- studyctl/web/app.py +68 -0
- studyctl/web/routes/__init__.py +1 -0
- studyctl/web/routes/artefacts.py +57 -0
- studyctl/web/routes/cards.py +86 -0
- studyctl/web/routes/courses.py +91 -0
- studyctl/web/routes/history.py +69 -0
- studyctl/web/server.py +260 -0
- studyctl/web/static/app.js +853 -0
- studyctl/web/static/icon-192.svg +4 -0
- studyctl/web/static/icon-512.svg +4 -0
- studyctl/web/static/index.html +50 -0
- studyctl/web/static/manifest.json +21 -0
- studyctl/web/static/style.css +657 -0
- studyctl/web/static/sw.js +14 -0
- studyctl-2.0.0.dist-info/METADATA +49 -0
- studyctl-2.0.0.dist-info/RECORD +58 -0
- studyctl-2.0.0.dist-info/WHEEL +4 -0
- studyctl-2.0.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""Interactive flashcard and quiz review tab for the studyctl TUI.
|
|
2
|
+
|
|
3
|
+
Provides keyboard-driven study with spaced repetition tracking
|
|
4
|
+
and optional voice output via study-speak.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import sqlite3
|
|
11
|
+
import time
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from textual.binding import Binding
|
|
17
|
+
from textual.containers import Center, Horizontal
|
|
18
|
+
from textual.reactive import reactive
|
|
19
|
+
from textual.widget import Widget
|
|
20
|
+
from textual.widgets import Button, Static
|
|
21
|
+
except ImportError as _exc:
|
|
22
|
+
raise ImportError("The TUI requires 'textual'. Install: pip install studyctl[tui]") from _exc
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from textual.app import ComposeResult
|
|
26
|
+
|
|
27
|
+
from studyctl.review_db import record_card_review, record_session
|
|
28
|
+
from studyctl.review_loader import (
|
|
29
|
+
Flashcard,
|
|
30
|
+
QuizQuestion,
|
|
31
|
+
ReviewResult,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class StudyMode(StrEnum):
|
|
38
|
+
"""Tracks whether the user is in a normal or retry study session."""
|
|
39
|
+
|
|
40
|
+
NORMAL = "normal"
|
|
41
|
+
RETRY = "retry"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CardPanel(Static):
|
|
45
|
+
"""Displays a flashcard or quiz question with flip support."""
|
|
46
|
+
|
|
47
|
+
revealed = reactive(False)
|
|
48
|
+
|
|
49
|
+
def __init__(self, **kwargs: object) -> None:
|
|
50
|
+
super().__init__(**kwargs)
|
|
51
|
+
self._front = ""
|
|
52
|
+
self._back = ""
|
|
53
|
+
|
|
54
|
+
def set_card(self, front: str, back: str) -> None:
|
|
55
|
+
self._front = front
|
|
56
|
+
self._back = back
|
|
57
|
+
self.revealed = False
|
|
58
|
+
self.update(self._front)
|
|
59
|
+
|
|
60
|
+
def flip(self) -> None:
|
|
61
|
+
self.revealed = not self.revealed
|
|
62
|
+
self.update(self._back if self.revealed else self._front)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class StudyCardsTab(Widget):
|
|
66
|
+
"""Interactive flashcard and quiz review widget."""
|
|
67
|
+
|
|
68
|
+
can_focus = True
|
|
69
|
+
|
|
70
|
+
DEFAULT_CSS = """
|
|
71
|
+
StudyCardsTab {
|
|
72
|
+
layout: vertical;
|
|
73
|
+
padding: 1 2;
|
|
74
|
+
}
|
|
75
|
+
#card-panel {
|
|
76
|
+
height: auto;
|
|
77
|
+
min-height: 5;
|
|
78
|
+
padding: 1 2;
|
|
79
|
+
border: round $accent;
|
|
80
|
+
margin: 1 0;
|
|
81
|
+
}
|
|
82
|
+
#card-panel.revealed {
|
|
83
|
+
border: round $success;
|
|
84
|
+
}
|
|
85
|
+
#score-bar {
|
|
86
|
+
height: 3;
|
|
87
|
+
dock: bottom;
|
|
88
|
+
margin-top: 1;
|
|
89
|
+
}
|
|
90
|
+
#progress-label {
|
|
91
|
+
text-align: center;
|
|
92
|
+
margin: 1 0;
|
|
93
|
+
}
|
|
94
|
+
#status-label {
|
|
95
|
+
text-align: center;
|
|
96
|
+
color: $text-muted;
|
|
97
|
+
}
|
|
98
|
+
#voice-label {
|
|
99
|
+
text-align: right;
|
|
100
|
+
color: $text-muted;
|
|
101
|
+
}
|
|
102
|
+
.score-btn {
|
|
103
|
+
margin: 0 1;
|
|
104
|
+
}
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
BINDINGS: ClassVar[list[Binding]] = [
|
|
108
|
+
Binding("space", "flip", "Flip / Submit"),
|
|
109
|
+
Binding("y", "mark_correct", "Correct"),
|
|
110
|
+
Binding("n", "mark_incorrect", "Incorrect"),
|
|
111
|
+
Binding("s", "skip_card", "Skip"),
|
|
112
|
+
Binding("h", "show_hint", "Hint"),
|
|
113
|
+
Binding("v", "toggle_voice", "Voice"),
|
|
114
|
+
Binding("r", "retry_wrong", "Retry Wrong"),
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
current_index = reactive(0)
|
|
118
|
+
voice_enabled = reactive(False)
|
|
119
|
+
_study_mode: reactive[StudyMode] = reactive(StudyMode.NORMAL, bindings=True)
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
cards: list[Flashcard | QuizQuestion],
|
|
124
|
+
course_name: str = "",
|
|
125
|
+
mode: str = "flashcards",
|
|
126
|
+
**kwargs: object,
|
|
127
|
+
) -> None:
|
|
128
|
+
super().__init__(**kwargs)
|
|
129
|
+
self._all_cards = list(cards)
|
|
130
|
+
self._cards = cards
|
|
131
|
+
self._course = course_name
|
|
132
|
+
self._mode = mode
|
|
133
|
+
self._result = ReviewResult(total=len(cards))
|
|
134
|
+
self._start_time = time.monotonic()
|
|
135
|
+
self._card_start_time = time.monotonic()
|
|
136
|
+
|
|
137
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
138
|
+
if action == "retry_wrong":
|
|
139
|
+
return bool(self._result.wrong_hashes) and self._study_mode is not StudyMode.RETRY
|
|
140
|
+
return True
|
|
141
|
+
|
|
142
|
+
def compose(self) -> ComposeResult:
|
|
143
|
+
yield Static(
|
|
144
|
+
f"[bold]{self._course}[/bold] — {self._mode} ({len(self._cards)} items)",
|
|
145
|
+
id="status-label",
|
|
146
|
+
)
|
|
147
|
+
voice_text = "Voice: ON" if self.voice_enabled else "Voice: OFF (v to toggle)"
|
|
148
|
+
yield Static(voice_text, id="voice-label")
|
|
149
|
+
yield CardPanel(id="card-panel")
|
|
150
|
+
yield Static("", id="progress-label")
|
|
151
|
+
with Center(), Horizontal(id="score-bar"):
|
|
152
|
+
yield Button(
|
|
153
|
+
"Know (y)",
|
|
154
|
+
variant="success",
|
|
155
|
+
id="btn-correct",
|
|
156
|
+
classes="score-btn",
|
|
157
|
+
)
|
|
158
|
+
yield Button(
|
|
159
|
+
"Don't Know (n)",
|
|
160
|
+
variant="error",
|
|
161
|
+
id="btn-incorrect",
|
|
162
|
+
classes="score-btn",
|
|
163
|
+
)
|
|
164
|
+
yield Button(
|
|
165
|
+
"Skip (s)",
|
|
166
|
+
variant="default",
|
|
167
|
+
id="btn-skip",
|
|
168
|
+
classes="score-btn",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def on_mount(self) -> None:
|
|
172
|
+
self.focus()
|
|
173
|
+
self._show_current_card()
|
|
174
|
+
|
|
175
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
176
|
+
if event.button.id == "btn-correct":
|
|
177
|
+
self.action_mark_correct()
|
|
178
|
+
elif event.button.id == "btn-incorrect":
|
|
179
|
+
self.action_mark_incorrect()
|
|
180
|
+
elif event.button.id == "btn-skip":
|
|
181
|
+
self.action_skip_card()
|
|
182
|
+
|
|
183
|
+
def _show_current_card(self) -> None:
|
|
184
|
+
if self.current_index >= len(self._cards):
|
|
185
|
+
self._show_summary()
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
card = self._cards[self.current_index]
|
|
189
|
+
panel = self.query_one("#card-panel", CardPanel)
|
|
190
|
+
self._card_start_time = time.monotonic()
|
|
191
|
+
|
|
192
|
+
if isinstance(card, Flashcard):
|
|
193
|
+
panel.set_card(
|
|
194
|
+
f"[bold]Q:[/bold] {card.front}\n\n[dim]Press Space to reveal answer[/dim]",
|
|
195
|
+
f"[bold]A:[/bold] {card.back}",
|
|
196
|
+
)
|
|
197
|
+
else:
|
|
198
|
+
# Quiz question
|
|
199
|
+
lines = [f"[bold]Q:[/bold] {card.question}\n"]
|
|
200
|
+
letters = "abcdefghij"
|
|
201
|
+
for j, opt in enumerate(card.options):
|
|
202
|
+
lines.append(f" [bold]{letters[j]})[/bold] {opt.text}")
|
|
203
|
+
lines.append("\n[dim]Press Space to reveal answer[/dim]")
|
|
204
|
+
panel.set_card(
|
|
205
|
+
"\n".join(lines),
|
|
206
|
+
self._format_quiz_answer(card),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Speak the question if voice enabled
|
|
210
|
+
if self.voice_enabled:
|
|
211
|
+
text = card.front if isinstance(card, Flashcard) else card.question
|
|
212
|
+
self._speak(text)
|
|
213
|
+
|
|
214
|
+
retry_tag = " (Retry)" if self._study_mode is StudyMode.RETRY else ""
|
|
215
|
+
progress = f"Card {self.current_index + 1}/{len(self._cards)}{retry_tag}"
|
|
216
|
+
if self._result.correct + self._result.incorrect > 0:
|
|
217
|
+
progress += f" | Score: {self._result.score_pct:.0f}%"
|
|
218
|
+
self.query_one("#progress-label", Static).update(progress)
|
|
219
|
+
|
|
220
|
+
def _format_quiz_answer(self, q: QuizQuestion) -> str:
|
|
221
|
+
letters = "abcdefghij"
|
|
222
|
+
correct_idx = next((i for i, o in enumerate(q.options) if o.is_correct), 0)
|
|
223
|
+
correct_opt = q.options[correct_idx]
|
|
224
|
+
lines = [f"[green bold]Answer: {letters[correct_idx]})[/green bold] {correct_opt.text}"]
|
|
225
|
+
if correct_opt.rationale:
|
|
226
|
+
lines.append(f"\n[dim]{correct_opt.rationale}[/dim]")
|
|
227
|
+
return "\n".join(lines)
|
|
228
|
+
|
|
229
|
+
def _record_answer(self, correct: bool) -> None:
|
|
230
|
+
card = self._cards[self.current_index]
|
|
231
|
+
elapsed_ms = int((time.monotonic() - self._card_start_time) * 1000)
|
|
232
|
+
|
|
233
|
+
if correct:
|
|
234
|
+
self._result.correct += 1
|
|
235
|
+
else:
|
|
236
|
+
self._result.incorrect += 1
|
|
237
|
+
self._result.wrong_hashes.add(card.card_hash)
|
|
238
|
+
|
|
239
|
+
# Record to DB (skip SM-2 during retry)
|
|
240
|
+
if self._study_mode is not StudyMode.RETRY:
|
|
241
|
+
card_type = "flashcard" if isinstance(card, Flashcard) else "quiz"
|
|
242
|
+
try:
|
|
243
|
+
record_card_review(
|
|
244
|
+
course=self._course,
|
|
245
|
+
card_type=card_type,
|
|
246
|
+
card_hash=card.card_hash,
|
|
247
|
+
correct=correct,
|
|
248
|
+
response_time_ms=elapsed_ms,
|
|
249
|
+
)
|
|
250
|
+
except (sqlite3.Error, OSError) as exc:
|
|
251
|
+
logger.debug("Failed to record card review: %s", exc)
|
|
252
|
+
|
|
253
|
+
self.current_index += 1
|
|
254
|
+
self._show_current_card()
|
|
255
|
+
|
|
256
|
+
def _show_summary(self) -> None:
|
|
257
|
+
duration = int(time.monotonic() - self._start_time)
|
|
258
|
+
attempted = self._result.correct + self._result.incorrect
|
|
259
|
+
pct = self._result.score_pct
|
|
260
|
+
|
|
261
|
+
if pct >= 80:
|
|
262
|
+
grade = "[green]Excellent[/green]"
|
|
263
|
+
elif pct >= 60:
|
|
264
|
+
grade = "[yellow]Good[/yellow]"
|
|
265
|
+
else:
|
|
266
|
+
grade = "[red]Needs review[/red]"
|
|
267
|
+
|
|
268
|
+
wrong_count = len(self._result.wrong_hashes)
|
|
269
|
+
summary = [
|
|
270
|
+
"[bold]Session Complete![/bold]",
|
|
271
|
+
"",
|
|
272
|
+
f" Score: {self._result.correct}/{attempted} ({pct:.0f}%) — {grade}",
|
|
273
|
+
f" Skipped: {self._result.skipped}",
|
|
274
|
+
f" Duration: {duration // 60}m {duration % 60}s",
|
|
275
|
+
]
|
|
276
|
+
if wrong_count and self._study_mode is not StudyMode.RETRY:
|
|
277
|
+
summary.append(
|
|
278
|
+
f"\n [yellow]{wrong_count} cards to review again — press r to retry[/yellow]"
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
panel = self.query_one("#card-panel", CardPanel)
|
|
282
|
+
panel.update("\n".join(summary))
|
|
283
|
+
|
|
284
|
+
# Hide score buttons
|
|
285
|
+
for btn_id in ("btn-correct", "btn-incorrect", "btn-skip"):
|
|
286
|
+
self.query_one(f"#{btn_id}", Button).display = False
|
|
287
|
+
|
|
288
|
+
hint = "[bold]Press q to return[/bold]"
|
|
289
|
+
if self._result.wrong_hashes and self._study_mode is not StudyMode.RETRY:
|
|
290
|
+
hint = "[bold]Press r to retry wrong, q to return[/bold]"
|
|
291
|
+
self.query_one("#progress-label", Static).update(hint)
|
|
292
|
+
|
|
293
|
+
# Record session
|
|
294
|
+
try:
|
|
295
|
+
record_session(
|
|
296
|
+
course=self._course,
|
|
297
|
+
mode=self._mode,
|
|
298
|
+
total=self._result.total,
|
|
299
|
+
correct=self._result.correct,
|
|
300
|
+
duration_seconds=duration,
|
|
301
|
+
)
|
|
302
|
+
except (sqlite3.Error, OSError) as exc:
|
|
303
|
+
logger.debug("Failed to record session: %s", exc)
|
|
304
|
+
|
|
305
|
+
def _speak(self, text: str) -> None:
|
|
306
|
+
"""Speak text via study-speak (non-blocking, best-effort)."""
|
|
307
|
+
try:
|
|
308
|
+
from agent_session_tools.speak import (
|
|
309
|
+
_get_tts_config,
|
|
310
|
+
_speak_kokoro,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
cfg = _get_tts_config()
|
|
314
|
+
voice = cfg.get("voice", "am_michael")
|
|
315
|
+
speed = cfg.get("speed", 1.0)
|
|
316
|
+
import threading
|
|
317
|
+
|
|
318
|
+
threading.Thread(
|
|
319
|
+
target=_speak_kokoro,
|
|
320
|
+
args=(text,),
|
|
321
|
+
kwargs={"voice": voice, "speed": speed},
|
|
322
|
+
daemon=True,
|
|
323
|
+
).start()
|
|
324
|
+
except (ImportError, OSError, RuntimeError) as exc:
|
|
325
|
+
logger.debug("Voice unavailable: %s", exc)
|
|
326
|
+
|
|
327
|
+
# --- Actions ---
|
|
328
|
+
|
|
329
|
+
def action_flip(self) -> None:
|
|
330
|
+
panel = self.query_one("#card-panel", CardPanel)
|
|
331
|
+
if not panel.revealed:
|
|
332
|
+
panel.flip()
|
|
333
|
+
panel.add_class("revealed")
|
|
334
|
+
if self.voice_enabled and self.current_index < len(self._cards):
|
|
335
|
+
card = self._cards[self.current_index]
|
|
336
|
+
text = card.back if isinstance(card, Flashcard) else ""
|
|
337
|
+
if text:
|
|
338
|
+
self._speak(text)
|
|
339
|
+
|
|
340
|
+
def action_mark_correct(self) -> None:
|
|
341
|
+
panel = self.query_one("#card-panel", CardPanel)
|
|
342
|
+
if panel.revealed:
|
|
343
|
+
panel.remove_class("revealed")
|
|
344
|
+
self._record_answer(correct=True)
|
|
345
|
+
|
|
346
|
+
def action_mark_incorrect(self) -> None:
|
|
347
|
+
panel = self.query_one("#card-panel", CardPanel)
|
|
348
|
+
if panel.revealed:
|
|
349
|
+
panel.remove_class("revealed")
|
|
350
|
+
self._record_answer(correct=False)
|
|
351
|
+
|
|
352
|
+
def action_skip_card(self) -> None:
|
|
353
|
+
self._result.skipped += 1
|
|
354
|
+
panel = self.query_one("#card-panel", CardPanel)
|
|
355
|
+
panel.remove_class("revealed")
|
|
356
|
+
self.current_index += 1
|
|
357
|
+
self._show_current_card()
|
|
358
|
+
|
|
359
|
+
def action_show_hint(self) -> None:
|
|
360
|
+
if self.current_index >= len(self._cards):
|
|
361
|
+
return
|
|
362
|
+
card = self._cards[self.current_index]
|
|
363
|
+
if isinstance(card, QuizQuestion) and card.hint:
|
|
364
|
+
self.notify(f"Hint: {card.hint}", title="Hint")
|
|
365
|
+
|
|
366
|
+
def action_toggle_voice(self) -> None:
|
|
367
|
+
self.voice_enabled = not self.voice_enabled
|
|
368
|
+
label = self.query_one("#voice-label", Static)
|
|
369
|
+
if self.voice_enabled:
|
|
370
|
+
label.update("[green]Voice: ON[/green]")
|
|
371
|
+
self.notify("Voice enabled")
|
|
372
|
+
else:
|
|
373
|
+
label.update("Voice: OFF (v to toggle)")
|
|
374
|
+
self.notify("Voice disabled")
|
|
375
|
+
|
|
376
|
+
def action_retry_wrong(self) -> None:
|
|
377
|
+
"""Retry only the incorrectly answered cards."""
|
|
378
|
+
if not self._result.wrong_hashes or self._study_mode is StudyMode.RETRY:
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
wrong = self._result.wrong_hashes
|
|
382
|
+
retry_cards = [c for c in self._all_cards if c.card_hash in wrong]
|
|
383
|
+
if not retry_cards:
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
self._cards = retry_cards
|
|
387
|
+
self._result = ReviewResult(total=len(retry_cards))
|
|
388
|
+
self._study_mode = StudyMode.RETRY
|
|
389
|
+
self.current_index = 0
|
|
390
|
+
self._start_time = time.monotonic()
|
|
391
|
+
|
|
392
|
+
# Re-show score buttons
|
|
393
|
+
for btn_id in ("btn-correct", "btn-incorrect", "btn-skip"):
|
|
394
|
+
self.query_one(f"#{btn_id}", Button).display = True
|
|
395
|
+
|
|
396
|
+
self._show_current_card()
|
studyctl/web/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Lightweight PWA web server for flashcard and quiz review."""
|
studyctl/web/app.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""FastAPI application factory for the study PWA.
|
|
2
|
+
|
|
3
|
+
Replaces the stdlib http.server with FastAPI + uvicorn.
|
|
4
|
+
Serves JSON API endpoints and static PWA files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from fastapi import FastAPI, Request
|
|
13
|
+
from fastapi.responses import FileResponse
|
|
14
|
+
from fastapi.staticfiles import StaticFiles
|
|
15
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from starlette.responses import Response
|
|
19
|
+
|
|
20
|
+
STATIC_DIR = Path(__file__).parent / "static"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|
24
|
+
"""Add security headers to all responses."""
|
|
25
|
+
|
|
26
|
+
async def dispatch(self, request: Request, call_next) -> Response: # type: ignore[override]
|
|
27
|
+
response = await call_next(request)
|
|
28
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
29
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
30
|
+
response.headers["X-XSS-Protection"] = "1; mode=block"
|
|
31
|
+
return response
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def create_app(study_dirs: list[str] | None = None) -> FastAPI:
|
|
35
|
+
"""Create and configure the FastAPI application.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
study_dirs: List of directory paths containing flashcard/quiz content.
|
|
39
|
+
"""
|
|
40
|
+
app = FastAPI(
|
|
41
|
+
title="Socratic Study Mentor",
|
|
42
|
+
docs_url=None,
|
|
43
|
+
redoc_url=None,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Store config on app state for route access
|
|
47
|
+
app.state.study_dirs = study_dirs or []
|
|
48
|
+
|
|
49
|
+
# Security headers
|
|
50
|
+
app.add_middleware(SecurityHeadersMiddleware)
|
|
51
|
+
|
|
52
|
+
# Register API routes
|
|
53
|
+
from studyctl.web.routes import artefacts, cards, courses, history
|
|
54
|
+
|
|
55
|
+
app.include_router(courses.router, prefix="/api")
|
|
56
|
+
app.include_router(cards.router, prefix="/api")
|
|
57
|
+
app.include_router(history.router, prefix="/api")
|
|
58
|
+
app.include_router(artefacts.router)
|
|
59
|
+
|
|
60
|
+
# Serve index.html at root
|
|
61
|
+
@app.get("/")
|
|
62
|
+
async def index() -> FileResponse:
|
|
63
|
+
return FileResponse(STATIC_DIR / "index.html")
|
|
64
|
+
|
|
65
|
+
# Mount static files LAST (catch-all)
|
|
66
|
+
app.mount("/", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
67
|
+
|
|
68
|
+
return app
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""FastAPI route modules."""
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Artefact serving routes with path traversal protection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path # noqa: TC003 — used at runtime for resolve/is_relative_to
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException
|
|
8
|
+
from fastapi.responses import FileResponse
|
|
9
|
+
|
|
10
|
+
from studyctl.settings import load_settings
|
|
11
|
+
|
|
12
|
+
router = APIRouter()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _validate_artefact_path(course: str, artefact_type: str, filename: str) -> Path:
|
|
16
|
+
"""Resolve and validate artefact path against directory traversal.
|
|
17
|
+
|
|
18
|
+
Ensures the resolved path is a child of content.base_path.
|
|
19
|
+
Raises 404 if path escapes the base or file doesn't exist.
|
|
20
|
+
"""
|
|
21
|
+
base = load_settings().content.base_path
|
|
22
|
+
resolved = (base / course / artefact_type / filename).resolve()
|
|
23
|
+
if not resolved.is_relative_to(base.resolve()):
|
|
24
|
+
raise HTTPException(status_code=404)
|
|
25
|
+
if not resolved.is_file():
|
|
26
|
+
raise HTTPException(status_code=404)
|
|
27
|
+
return resolved
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@router.get("/artefacts/{course}/{artefact_type}/{filename:path}")
|
|
31
|
+
def serve_artefact(course: str, artefact_type: str, filename: str) -> FileResponse:
|
|
32
|
+
"""Serve an artefact file (audio, video, slides, etc.) with path validation."""
|
|
33
|
+
path = _validate_artefact_path(course, artefact_type, filename)
|
|
34
|
+
return FileResponse(path)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.get("/api/artefacts/{course}")
|
|
38
|
+
def list_artefacts(course: str) -> list[dict]:
|
|
39
|
+
"""List all artefacts for a course grouped by type."""
|
|
40
|
+
base = load_settings().content.base_path
|
|
41
|
+
course_dir = base / course
|
|
42
|
+
if not course_dir.is_dir():
|
|
43
|
+
raise HTTPException(status_code=404, detail=f"Course '{course}' not found")
|
|
44
|
+
|
|
45
|
+
result = []
|
|
46
|
+
for type_dir in sorted(course_dir.iterdir()):
|
|
47
|
+
if not type_dir.is_dir() or type_dir.name.startswith("."):
|
|
48
|
+
continue
|
|
49
|
+
files = sorted(f.name for f in type_dir.iterdir() if f.is_file())
|
|
50
|
+
if files:
|
|
51
|
+
result.append(
|
|
52
|
+
{
|
|
53
|
+
"type": type_dir.name,
|
|
54
|
+
"files": files,
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
return result
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Card API routes — load cards/quizzes, record reviews."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from studyctl.review_db import record_card_review
|
|
9
|
+
from studyctl.review_loader import (
|
|
10
|
+
discover_directories,
|
|
11
|
+
find_content_dirs,
|
|
12
|
+
load_flashcards,
|
|
13
|
+
load_quizzes,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
router = APIRouter()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ReviewRequest(BaseModel):
|
|
20
|
+
"""POST /api/review request body."""
|
|
21
|
+
|
|
22
|
+
course: str
|
|
23
|
+
card_hash: str
|
|
24
|
+
correct: bool
|
|
25
|
+
card_type: str = "flashcard"
|
|
26
|
+
response_time_ms: int | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@router.get("/cards/{course}")
|
|
30
|
+
def get_cards(request: Request, course: str, mode: str = "flashcards") -> list[dict]:
|
|
31
|
+
"""Load flashcards or quiz questions for a course."""
|
|
32
|
+
courses = discover_directories(request.app.state.study_dirs)
|
|
33
|
+
match = next(((n, p) for n, p in courses if n == course), None)
|
|
34
|
+
if not match:
|
|
35
|
+
raise HTTPException(status_code=404, detail=f"Course '{course}' not found")
|
|
36
|
+
|
|
37
|
+
_, path = match
|
|
38
|
+
fc_dir, quiz_dir = find_content_dirs(path)
|
|
39
|
+
|
|
40
|
+
if mode == "flashcards" and fc_dir:
|
|
41
|
+
cards = load_flashcards(fc_dir)
|
|
42
|
+
return [
|
|
43
|
+
{
|
|
44
|
+
"type": "flashcard",
|
|
45
|
+
"front": c.front,
|
|
46
|
+
"back": c.back,
|
|
47
|
+
"hash": c.card_hash,
|
|
48
|
+
"source": c.source,
|
|
49
|
+
}
|
|
50
|
+
for c in cards
|
|
51
|
+
]
|
|
52
|
+
if mode == "quiz" and quiz_dir:
|
|
53
|
+
questions = load_quizzes(quiz_dir)
|
|
54
|
+
return [
|
|
55
|
+
{
|
|
56
|
+
"type": "quiz",
|
|
57
|
+
"question": q.question,
|
|
58
|
+
"options": [
|
|
59
|
+
{
|
|
60
|
+
"text": o.text,
|
|
61
|
+
"is_correct": o.is_correct,
|
|
62
|
+
"rationale": o.rationale,
|
|
63
|
+
}
|
|
64
|
+
for o in q.options
|
|
65
|
+
],
|
|
66
|
+
"hint": q.hint,
|
|
67
|
+
"hash": q.card_hash,
|
|
68
|
+
"source": q.source,
|
|
69
|
+
}
|
|
70
|
+
for q in questions
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
raise HTTPException(status_code=404, detail=f"No {mode} content for {course}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@router.post("/review")
|
|
77
|
+
def post_review(body: ReviewRequest) -> dict:
|
|
78
|
+
"""Record a single card review result."""
|
|
79
|
+
record_card_review(
|
|
80
|
+
course=body.course,
|
|
81
|
+
card_type=body.card_type,
|
|
82
|
+
card_hash=body.card_hash,
|
|
83
|
+
correct=body.correct,
|
|
84
|
+
response_time_ms=body.response_time_ms,
|
|
85
|
+
)
|
|
86
|
+
return {"ok": True}
|