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.
Files changed (58) hide show
  1. studyctl/__init__.py +3 -0
  2. studyctl/calendar.py +140 -0
  3. studyctl/cli/__init__.py +56 -0
  4. studyctl/cli/_config.py +128 -0
  5. studyctl/cli/_content.py +462 -0
  6. studyctl/cli/_lazy.py +35 -0
  7. studyctl/cli/_review.py +491 -0
  8. studyctl/cli/_schedule.py +125 -0
  9. studyctl/cli/_setup.py +164 -0
  10. studyctl/cli/_shared.py +83 -0
  11. studyctl/cli/_state.py +69 -0
  12. studyctl/cli/_sync.py +156 -0
  13. studyctl/cli/_web.py +228 -0
  14. studyctl/content/__init__.py +5 -0
  15. studyctl/content/markdown_converter.py +271 -0
  16. studyctl/content/models.py +31 -0
  17. studyctl/content/notebooklm_client.py +434 -0
  18. studyctl/content/splitter.py +159 -0
  19. studyctl/content/storage.py +105 -0
  20. studyctl/content/syllabus.py +416 -0
  21. studyctl/history.py +982 -0
  22. studyctl/maintenance.py +69 -0
  23. studyctl/mcp/__init__.py +1 -0
  24. studyctl/mcp/server.py +58 -0
  25. studyctl/mcp/tools.py +234 -0
  26. studyctl/pdf.py +89 -0
  27. studyctl/review_db.py +277 -0
  28. studyctl/review_loader.py +375 -0
  29. studyctl/scheduler.py +242 -0
  30. studyctl/services/__init__.py +6 -0
  31. studyctl/services/content.py +39 -0
  32. studyctl/services/review.py +127 -0
  33. studyctl/settings.py +367 -0
  34. studyctl/shared.py +425 -0
  35. studyctl/state.py +120 -0
  36. studyctl/sync.py +229 -0
  37. studyctl/tui/__main__.py +33 -0
  38. studyctl/tui/app.py +395 -0
  39. studyctl/tui/study_cards.py +396 -0
  40. studyctl/web/__init__.py +1 -0
  41. studyctl/web/app.py +68 -0
  42. studyctl/web/routes/__init__.py +1 -0
  43. studyctl/web/routes/artefacts.py +57 -0
  44. studyctl/web/routes/cards.py +86 -0
  45. studyctl/web/routes/courses.py +91 -0
  46. studyctl/web/routes/history.py +69 -0
  47. studyctl/web/server.py +260 -0
  48. studyctl/web/static/app.js +853 -0
  49. studyctl/web/static/icon-192.svg +4 -0
  50. studyctl/web/static/icon-512.svg +4 -0
  51. studyctl/web/static/index.html +50 -0
  52. studyctl/web/static/manifest.json +21 -0
  53. studyctl/web/static/style.css +657 -0
  54. studyctl/web/static/sw.js +14 -0
  55. studyctl-2.0.0.dist-info/METADATA +49 -0
  56. studyctl-2.0.0.dist-info/RECORD +58 -0
  57. studyctl-2.0.0.dist-info/WHEEL +4 -0
  58. studyctl-2.0.0.dist-info/entry_points.txt +3 -0
studyctl/sync.py ADDED
@@ -0,0 +1,229 @@
1
+ """Sync engine — Obsidian → NotebookLM via notebooklm-py CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ from .pdf import md_to_pdf
11
+ from .settings import MIN_FILE_SIZE, SKIP_FILENAMES, SKIP_PATTERNS, SYNCABLE_EXTENSIONS, Topic
12
+ from .state import SyncState, file_hash
13
+
14
+
15
+ def _should_skip(path: Path) -> bool:
16
+ """Filter out low-value files."""
17
+ # Skip by directory/file pattern
18
+ for part in path.parts:
19
+ if part in SKIP_PATTERNS or part.startswith("."):
20
+ return True
21
+ # Skip by filename
22
+ if path.name in SKIP_FILENAMES:
23
+ return True
24
+ # Skip tiny files (stubs, empty templates)
25
+ return path.stat().st_size < MIN_FILE_SIZE
26
+
27
+
28
+ def find_sources(topic: Topic) -> list[Path]:
29
+ """Find all syncable files for a topic, filtered for quality."""
30
+ sources = []
31
+ for base in topic.obsidian_paths:
32
+ if not base.exists():
33
+ continue
34
+ for ext in SYNCABLE_EXTENSIONS:
35
+ for p in sorted(base.rglob(f"*{ext}")):
36
+ if not _should_skip(p):
37
+ sources.append(p)
38
+ return sources
39
+
40
+
41
+ def find_changed_sources(topic: Topic, state: SyncState) -> list[Path]:
42
+ """Find sources that are new or changed since last sync."""
43
+ return [p for p in find_sources(topic) if state.needs_sync(p)]
44
+
45
+
46
+ def _run_nlm(args: list[str], check: bool = True) -> subprocess.CompletedProcess:
47
+ """Run a notebooklm CLI command."""
48
+ return subprocess.run(["notebooklm", *args], capture_output=True, text=True, check=check)
49
+
50
+
51
+ def ensure_notebook(topic: Topic, state: SyncState) -> str:
52
+ """Get or create the NotebookLM notebook for a topic. Returns notebook_id."""
53
+ # Use pre-mapped ID if available
54
+ if topic.notebook_id:
55
+ state.set_notebook_id(topic.name, topic.notebook_id, topic.display_name)
56
+ state.save()
57
+ return topic.notebook_id
58
+
59
+ # Check state for previously created notebook
60
+ ts = state.get_topic(topic.name)
61
+ if ts.notebook_id:
62
+ return ts.notebook_id
63
+
64
+ # Check if notebook already exists by title
65
+ result = _run_nlm(["list", "--json"])
66
+ try:
67
+ notebooks = json.loads(result.stdout).get("notebooks", [])
68
+ except json.JSONDecodeError:
69
+ import sys
70
+
71
+ print(f"[studyctl] Failed to parse notebook list: {result.stdout[:200]}", file=sys.stderr)
72
+ notebooks = []
73
+ for nb in notebooks:
74
+ if nb["title"] == topic.display_name:
75
+ state.set_notebook_id(topic.name, nb["id"], nb["title"])
76
+ state.save()
77
+ return nb["id"]
78
+
79
+ # Create new notebook
80
+ result = _run_nlm(["create", topic.display_name, "--json"])
81
+ try:
82
+ data = json.loads(result.stdout)
83
+ except json.JSONDecodeError:
84
+ import sys
85
+
86
+ print(
87
+ f"[studyctl] Failed to parse create response: {result.stdout[:200]}",
88
+ file=sys.stderr,
89
+ )
90
+ data = {}
91
+ notebook_id = data.get("id") or data.get("notebook", {}).get("id", "")
92
+ state.set_notebook_id(topic.name, notebook_id, topic.display_name)
93
+ state.save()
94
+ return notebook_id
95
+
96
+
97
+ def sync_source(
98
+ path: Path,
99
+ notebook_id: str,
100
+ topic_name: str,
101
+ state: SyncState,
102
+ pdf_dir: Path | None = None,
103
+ unique_name: str | None = None,
104
+ ) -> str | None:
105
+ """Sync a single file to NotebookLM. Converts .md to PDF first. Returns source_id or None."""
106
+ upload_path = path
107
+ if path.suffix == ".md" and pdf_dir:
108
+ pdf = md_to_pdf(path, pdf_dir, unique_name)
109
+ if pdf:
110
+ upload_path = pdf
111
+
112
+ result = _run_nlm(
113
+ ["source", "add", str(upload_path), "--notebook", notebook_id, "--json"],
114
+ check=False,
115
+ )
116
+ if result.returncode != 0:
117
+ return None
118
+
119
+ try:
120
+ data = json.loads(result.stdout)
121
+ except json.JSONDecodeError:
122
+ return None
123
+
124
+ source_id = data.get("source_id") or data.get("source", {}).get("id", "")
125
+ if not source_id:
126
+ return None
127
+ rel_path = str(path.relative_to(Path.home()))
128
+ state.record_sync(topic_name, rel_path, file_hash(path), source_id, notebook_id)
129
+ state.save()
130
+ return source_id
131
+
132
+
133
+ def sync_topic(topic: Topic, state: SyncState, dry_run: bool = False, as_pdf: bool = True) -> dict:
134
+ """Sync all changed sources for a topic. Returns summary."""
135
+ changed = find_changed_sources(topic, state)
136
+ total = len(find_sources(topic))
137
+ if not changed:
138
+ return {"topic": topic.name, "total": total, "changed": 0, "synced": 0, "failed": 0}
139
+
140
+ if dry_run:
141
+ return {
142
+ "topic": topic.name,
143
+ "total": total,
144
+ "changed": len(changed),
145
+ "synced": 0,
146
+ "failed": 0,
147
+ "dry_run": True,
148
+ "files": [str(p.name) for p in changed[:10]],
149
+ }
150
+
151
+ notebook_id = ensure_notebook(topic, state)
152
+ synced = failed = 0
153
+
154
+ # Build unique names for files with duplicate stems
155
+ name_map = _build_unique_names(changed)
156
+
157
+ with tempfile.TemporaryDirectory(prefix="studyctl-pdf-") as pdf_dir:
158
+ pdf_path = Path(pdf_dir) if as_pdf else None
159
+ for path in changed:
160
+ unique = name_map.get(str(path))
161
+ if sync_source(
162
+ path, notebook_id, topic.name, state, pdf_dir=pdf_path, unique_name=unique
163
+ ):
164
+ synced += 1
165
+ else:
166
+ failed += 1
167
+
168
+ return {
169
+ "topic": topic.name,
170
+ "total": total,
171
+ "changed": len(changed),
172
+ "synced": synced,
173
+ "failed": failed,
174
+ }
175
+
176
+
177
+ def _build_unique_names(paths: list[Path]) -> dict[str, str]:
178
+ """For files with duplicate stems, prefix with parent dir name.
179
+
180
+ e.g. two 'introduction.md' files become:
181
+ 'the-software-designer-mindset--introduction'
182
+ 'pythonic-patterns--introduction'
183
+ """
184
+ from collections import Counter
185
+
186
+ stem_counts = Counter(p.stem for p in paths)
187
+ duplicated = {stem for stem, count in stem_counts.items() if count > 1}
188
+
189
+ name_map: dict[str, str] = {}
190
+ for p in paths:
191
+ if p.stem in duplicated:
192
+ # Walk up parents until we find a distinguishing name
193
+ # e.g. .../the-software-designer-mindset/study-notes/introduction.md
194
+ # → "the-software-designer-mindset--introduction"
195
+ parts = p.parts
196
+ for i in range(len(parts) - 2, 0, -1):
197
+ candidate = parts[i]
198
+ if candidate != "study-notes" and candidate != "lessons":
199
+ name_map[str(p)] = f"{candidate}--{p.stem}"
200
+ break
201
+ else:
202
+ name_map[str(p)] = f"{p.parent.name}--{p.stem}"
203
+
204
+ return name_map
205
+
206
+
207
+ def generate_audio(topic: Topic, state: SyncState, instructions: str = "") -> str | None:
208
+ """Generate an audio overview for a topic. Returns artifact_id or None."""
209
+ ts = state.get_topic(topic.name)
210
+ if not ts.notebook_id:
211
+ return None
212
+
213
+ args = ["generate", "audio", "--notebook", ts.notebook_id, "--json"]
214
+ if instructions:
215
+ args.insert(2, instructions)
216
+
217
+ result = _run_nlm(args, check=False)
218
+ if result.returncode != 0:
219
+ return None
220
+ try:
221
+ return json.loads(result.stdout).get("task_id")
222
+ except json.JSONDecodeError:
223
+ import sys
224
+
225
+ print(
226
+ f"[studyctl] Failed to parse audio response: {result.stdout[:200]}",
227
+ file=sys.stderr,
228
+ )
229
+ return None
@@ -0,0 +1,33 @@
1
+ """Entry point for ``python -m studyctl.tui`` and ``textual serve``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import yaml
8
+
9
+ from studyctl.tui.app import StudyApp
10
+
11
+ config_path = Path.home() / ".config" / "studyctl" / "config.yaml"
12
+ study_dirs: list[str] = []
13
+ theme = ""
14
+ dyslexic = False
15
+
16
+ if config_path.exists():
17
+ try:
18
+ data = yaml.safe_load(config_path.read_text()) or {}
19
+ study_dirs = data.get("review", {}).get("directories", [])
20
+ tui_cfg = data.get("tui", {})
21
+ theme = tui_cfg.get("theme", "")
22
+ dyslexic = tui_cfg.get("dyslexic_friendly", False)
23
+ except Exception:
24
+ pass
25
+
26
+ app = StudyApp(
27
+ study_dirs=study_dirs,
28
+ theme_name=theme,
29
+ dyslexic_friendly=dyslexic,
30
+ )
31
+
32
+ if __name__ == "__main__":
33
+ app.run()
studyctl/tui/app.py ADDED
@@ -0,0 +1,395 @@
1
+ """Textual TUI application for studyctl study management.
2
+
3
+ Launch via ``studyctl tui``. Requires the ``tui`` extra::
4
+
5
+ pip install studyctl[tui]
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import ClassVar, NamedTuple
13
+
14
+ try:
15
+ from textual.app import App, ComposeResult
16
+ from textual.containers import Vertical
17
+ from textual.screen import ModalScreen
18
+ from textual.widgets import (
19
+ DataTable,
20
+ Footer,
21
+ Header,
22
+ OptionList,
23
+ Static,
24
+ TabbedContent,
25
+ TabPane,
26
+ )
27
+ from textual.widgets.option_list import Option
28
+ except ImportError as _exc:
29
+ raise ImportError(
30
+ "The TUI requires the 'textual' package. Install it with:\n pip install studyctl[tui]"
31
+ ) from _exc
32
+
33
+ from studyctl.cli import TOPIC_KEYWORDS
34
+ from studyctl.history import (
35
+ get_study_session_stats,
36
+ list_concepts,
37
+ spaced_repetition_due,
38
+ struggle_topics,
39
+ )
40
+ from studyctl.review_loader import (
41
+ discover_directories,
42
+ find_content_dirs,
43
+ load_flashcards,
44
+ load_quizzes,
45
+ shuffle_items,
46
+ )
47
+
48
+
49
+ class CourseInfo(NamedTuple):
50
+ """Typed representation of a discovered course directory."""
51
+
52
+ name: str
53
+ path: Path
54
+
55
+
56
+ def _load_session_state() -> dict:
57
+ """Load session state from the JSON file, returning defaults on failure."""
58
+ state_path = Path.home() / ".config" / "studyctl" / "session-state.json"
59
+ try:
60
+ return json.loads(state_path.read_text()) if state_path.exists() else {}
61
+ except (json.JSONDecodeError, OSError):
62
+ return {}
63
+
64
+
65
+ class CoursePickerScreen(ModalScreen[CourseInfo | None]):
66
+ """Modal overlay for selecting a course directory."""
67
+
68
+ BINDINGS: ClassVar[list[tuple[str, str, str]]] = [
69
+ ("escape", "cancel", "Cancel"),
70
+ ]
71
+
72
+ CSS = """
73
+ CoursePickerScreen {
74
+ align: center middle;
75
+ }
76
+ #course-picker-box {
77
+ width: 60;
78
+ max-height: 24;
79
+ border: round $accent;
80
+ background: $surface;
81
+ padding: 1 2;
82
+ }
83
+ #course-picker-title {
84
+ text-style: bold;
85
+ text-align: center;
86
+ margin-bottom: 1;
87
+ }
88
+ #course-picker {
89
+ max-height: 18;
90
+ }
91
+ """
92
+
93
+ def __init__(self, courses: list[CourseInfo]) -> None:
94
+ super().__init__()
95
+ self._courses = courses
96
+
97
+ def compose(self) -> ComposeResult:
98
+ with Vertical(id="course-picker-box"):
99
+ yield Static("Select a course", id="course-picker-title")
100
+ yield OptionList(
101
+ *[Option(course.name) for course in self._courses],
102
+ id="course-picker",
103
+ )
104
+
105
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
106
+ self.dismiss(self._courses[event.option_index])
107
+
108
+ def action_cancel(self) -> None:
109
+ self.dismiss(None)
110
+
111
+
112
+ class StudyApp(App):
113
+ """Read-only study management dashboard."""
114
+
115
+ TITLE = "studyctl"
116
+ CSS = """
117
+ Screen {
118
+ background: $surface;
119
+ }
120
+ #dashboard-content {
121
+ padding: 1 2;
122
+ }
123
+ .section-header {
124
+ text-style: bold;
125
+ color: $accent;
126
+ margin-bottom: 1;
127
+ }
128
+ .info-line {
129
+ margin-bottom: 0;
130
+ }
131
+
132
+ /* Dyslexic-friendly: wider spacing, more padding, clearer separation */
133
+ .dyslexic #dashboard-content {
134
+ padding: 2 4;
135
+ }
136
+ .dyslexic .section-header {
137
+ margin-bottom: 2;
138
+ }
139
+ .dyslexic .info-line {
140
+ margin-bottom: 1;
141
+ }
142
+ .dyslexic DataTable {
143
+ padding: 1 2;
144
+ }
145
+ .dyslexic #card-panel {
146
+ padding: 2 4;
147
+ min-height: 8;
148
+ margin: 2 1;
149
+ }
150
+ .dyslexic #progress-label {
151
+ margin: 2 0;
152
+ }
153
+ .dyslexic #status-label {
154
+ margin-bottom: 1;
155
+ }
156
+ .dyslexic .score-btn {
157
+ margin: 0 2;
158
+ }
159
+ """
160
+
161
+ BINDINGS: ClassVar[list[tuple[str, str, str]]] = [
162
+ ("q", "quit", "Quit"),
163
+ ("d", "show_tab('dashboard')", "Dashboard"),
164
+ ("r", "show_tab('review')", "Review"),
165
+ ("c", "show_tab('concepts')", "Concepts"),
166
+ ("s", "show_tab('sessions')", "Sessions"),
167
+ ("f", "start_flashcards", "Flashcards"),
168
+ ("z", "start_quiz", "Quiz"),
169
+ ("o", "toggle_dyslexic", "OpenDyslexic"),
170
+ ]
171
+
172
+ def __init__(
173
+ self,
174
+ study_dirs: list[str] | None = None,
175
+ theme_name: str = "",
176
+ dyslexic_friendly: bool = False,
177
+ **kwargs: object,
178
+ ) -> None:
179
+ super().__init__(**kwargs)
180
+ self._study_dirs = study_dirs or []
181
+ self._theme_name = theme_name
182
+ self._dyslexic_friendly = dyslexic_friendly
183
+
184
+ def compose(self) -> ComposeResult:
185
+ yield Header()
186
+ with TabbedContent(
187
+ "Dashboard",
188
+ "Review",
189
+ "Concepts",
190
+ "Sessions",
191
+ "StudyCards",
192
+ id="tabs",
193
+ ):
194
+ with TabPane("Dashboard", id="dashboard"):
195
+ yield Vertical(
196
+ Static("", id="dashboard-content"),
197
+ id="dashboard-container",
198
+ )
199
+ with TabPane("Review", id="review"):
200
+ yield DataTable(id="review-table")
201
+ with TabPane("Concepts", id="concepts"):
202
+ yield DataTable(id="concepts-table")
203
+ with TabPane("Sessions", id="sessions"):
204
+ yield DataTable(id="sessions-table")
205
+ with TabPane("StudyCards", id="studycards"):
206
+ yield Vertical(
207
+ Static("", id="studycards-content"),
208
+ id="studycards-container",
209
+ )
210
+ yield Footer()
211
+
212
+ def on_mount(self) -> None:
213
+ if self._theme_name:
214
+ self.theme = self._theme_name
215
+ if self._dyslexic_friendly:
216
+ self.add_class("dyslexic")
217
+ self.notify(
218
+ "Dyslexic-friendly mode ON. For best results, set your "
219
+ "terminal font to OpenDyslexic: https://opendyslexic.org",
220
+ title="Accessibility",
221
+ timeout=8,
222
+ )
223
+ self._populate_dashboard()
224
+ self._populate_review()
225
+ self._populate_concepts()
226
+ self._populate_sessions()
227
+ self._populate_studycards()
228
+
229
+ def action_show_tab(self, tab_id: str) -> None:
230
+ tabs = self.query_one(TabbedContent)
231
+ tabs.active = tab_id
232
+
233
+ def action_toggle_dyslexic(self) -> None:
234
+ """Toggle dyslexic-friendly mode (wider spacing)."""
235
+ self.toggle_class("dyslexic")
236
+ if self.has_class("dyslexic"):
237
+ self.notify(
238
+ "Dyslexic-friendly mode ON — wider spacing applied. "
239
+ "Set terminal font to OpenDyslexic for best results.",
240
+ title="Accessibility",
241
+ )
242
+ else:
243
+ self.notify("Dyslexic-friendly mode OFF")
244
+
245
+ # ------------------------------------------------------------------
246
+ # Tab population
247
+ # ------------------------------------------------------------------
248
+
249
+ def _populate_dashboard(self) -> None:
250
+ state = _load_session_state()
251
+ energy = state.get("energy", "unknown")
252
+ topic = state.get("topic", "none")
253
+
254
+ due = spaced_repetition_due(TOPIC_KEYWORDS)
255
+ struggles = struggle_topics()
256
+
257
+ lines = [
258
+ "[bold]Study Dashboard[/bold]",
259
+ "",
260
+ f" Energy level: {energy}",
261
+ f" Current topic: {topic}",
262
+ f" Reviews due: {len(due)}",
263
+ f" Struggle areas: {len(struggles)}",
264
+ ]
265
+ if struggles:
266
+ lines.append("")
267
+ lines.append("[bold]Top struggles:[/bold]")
268
+ for s in struggles[:5]:
269
+ lines.append(f" • {s['topic']} ({s['mentions']} mentions)")
270
+
271
+ widget = self.query_one("#dashboard-content", Static)
272
+ widget.update("\n".join(lines))
273
+
274
+ def _populate_review(self) -> None:
275
+ table = self.query_one("#review-table", DataTable)
276
+ table.add_columns("Topic", "Last Studied", "Days Ago", "Review Type")
277
+
278
+ for item in spaced_repetition_due(TOPIC_KEYWORDS):
279
+ table.add_row(
280
+ item["topic"],
281
+ item.get("last_studied") or "never",
282
+ str(item.get("days_ago") or "—"),
283
+ item.get("review_type", ""),
284
+ )
285
+
286
+ def _populate_concepts(self) -> None:
287
+ table = self.query_one("#concepts-table", DataTable)
288
+ table.add_columns("Name", "Domain", "Description")
289
+
290
+ for concept in list_concepts():
291
+ table.add_row(concept.name, concept.domain, concept.description or "")
292
+
293
+ def _populate_sessions(self) -> None:
294
+ table = self.query_one("#sessions-table", DataTable)
295
+ table.add_columns("Topic", "Sessions", "Total Min", "Avg Min")
296
+
297
+ for stat in get_study_session_stats():
298
+ table.add_row(
299
+ stat.get("topic") or "unknown",
300
+ str(stat.get("sessions", 0)),
301
+ str(round(stat.get("total_minutes") or 0)),
302
+ str(round(stat.get("avg_minutes") or 0)),
303
+ )
304
+
305
+ def _discover_courses(self) -> list[CourseInfo]:
306
+ """Discover courses and wrap raw tuples as CourseInfo."""
307
+ return [CourseInfo(*t) for t in discover_directories(self._study_dirs)]
308
+
309
+ def _populate_studycards(self) -> None:
310
+ content = self.query_one("#studycards-content", Static)
311
+ courses = self._discover_courses()
312
+
313
+ if not courses:
314
+ content.update(
315
+ "[bold]Study Cards[/bold]\n\n"
316
+ " No courses found.\n\n"
317
+ " Configure directories in ~/.config/studyctl/config.yaml:\n"
318
+ " review:\n"
319
+ " directories:\n"
320
+ " - ~/Desktop/ZTM-DE/downloads\n"
321
+ " - ~/Desktop/Python/downloads\n\n"
322
+ " Or press [bold]f[/bold] for flashcards / [bold]z[/bold] for quiz"
323
+ " and select a directory."
324
+ )
325
+ return
326
+
327
+ lines = [
328
+ "[bold]Study Cards[/bold]\n",
329
+ f" Found {len(courses)} course(s):\n",
330
+ ]
331
+ for course in courses:
332
+ fc_dir, quiz_dir = find_content_dirs(course.path)
333
+ fc_count = len(load_flashcards(fc_dir)) if fc_dir else 0
334
+ quiz_count = len(load_quizzes(quiz_dir)) if quiz_dir else 0
335
+ lines.append(
336
+ f" • [bold]{course.name}[/bold] — {fc_count} flashcards,"
337
+ f" {quiz_count} quiz questions"
338
+ )
339
+
340
+ lines.append("\n Press [bold]f[/bold] for flashcards / [bold]z[/bold] for quiz")
341
+ content.update("\n".join(lines))
342
+
343
+ def _launch_study(self, mode: str = "flashcards") -> None:
344
+ """Launch interactive study session."""
345
+ courses = self._discover_courses()
346
+ if not courses:
347
+ self.notify(
348
+ "No courses found. Configure review.directories in config.yaml",
349
+ severity="error",
350
+ )
351
+ return
352
+
353
+ if len(courses) == 1:
354
+ self._start_session(courses[0], mode)
355
+ else:
356
+ self.push_screen(
357
+ CoursePickerScreen(courses),
358
+ lambda result: self._start_session(result, mode) if result else None,
359
+ )
360
+
361
+ def _start_session(self, course: CourseInfo, mode: str) -> None:
362
+ """Start a study session for the selected course."""
363
+ fc_dir, quiz_dir = find_content_dirs(course.path)
364
+
365
+ if mode == "flashcards" and fc_dir:
366
+ cards = shuffle_items(load_flashcards(fc_dir))
367
+ elif mode == "quiz" and quiz_dir:
368
+ cards = shuffle_items(load_quizzes(quiz_dir))
369
+ else:
370
+ self.notify(
371
+ f"No {mode} content found for {course.name}",
372
+ severity="warning",
373
+ )
374
+ return
375
+
376
+ if not cards:
377
+ self.notify(f"No {mode} cards loaded", severity="warning")
378
+ return
379
+
380
+ from studyctl.tui.study_cards import StudyCardsTab
381
+
382
+ # Replace studycards content with the interactive widget
383
+ container = self.query_one("#studycards-container", Vertical)
384
+ container.remove_children()
385
+ container.mount(StudyCardsTab(cards=cards, course_name=course.name, mode=mode))
386
+
387
+ # Switch to the tab
388
+ tabs = self.query_one(TabbedContent)
389
+ tabs.active = "studycards"
390
+
391
+ def action_start_flashcards(self) -> None:
392
+ self._launch_study("flashcards")
393
+
394
+ def action_start_quiz(self) -> None:
395
+ self._launch_study("quiz")