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
@@ -0,0 +1,91 @@
1
+ """Course API routes — list courses, sources, stats, due, wrong."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, Request
6
+
7
+ from studyctl.review_db import get_course_stats, get_due_cards, get_wrong_hashes
8
+ from studyctl.review_loader import (
9
+ discover_directories,
10
+ find_content_dirs,
11
+ load_flashcards,
12
+ load_quizzes,
13
+ )
14
+
15
+ router = APIRouter()
16
+
17
+
18
+ def _get_dirs(request: Request) -> list[str]:
19
+ return request.app.state.study_dirs
20
+
21
+
22
+ @router.get("/courses")
23
+ def list_courses(request: Request) -> list[dict]:
24
+ """List all courses with card counts and review stats."""
25
+ courses = discover_directories(_get_dirs(request))
26
+ result = []
27
+ for name, path in courses:
28
+ fc_dir, quiz_dir = find_content_dirs(path)
29
+ fc_count = len(load_flashcards(fc_dir)) if fc_dir else 0
30
+ quiz_count = len(load_quizzes(quiz_dir)) if quiz_dir else 0
31
+ due = len(get_due_cards(name))
32
+ stats = get_course_stats(name)
33
+ result.append(
34
+ {
35
+ "name": name,
36
+ "flashcard_count": fc_count,
37
+ "quiz_count": quiz_count,
38
+ "due_count": due,
39
+ "total_reviews": stats.get("total_reviews", 0),
40
+ "mastered": stats.get("mastered", 0),
41
+ }
42
+ )
43
+ return result
44
+
45
+
46
+ @router.get("/sources/{course}")
47
+ def list_sources(request: Request, course: str, mode: str = "flashcards") -> list[str]:
48
+ """List unique source names for a course (flat string array for app.js compat)."""
49
+ courses = discover_directories(_get_dirs(request))
50
+ match = next(((n, p) for n, p in courses if n == course), None)
51
+ if not match:
52
+ return []
53
+ _, path = match
54
+ fc_dir, quiz_dir = find_content_dirs(path)
55
+ sources: set[str] = set()
56
+ if mode == "flashcards" and fc_dir:
57
+ for c in load_flashcards(fc_dir):
58
+ if c.source:
59
+ sources.add(c.source)
60
+ elif mode == "quiz" and quiz_dir:
61
+ for q in load_quizzes(quiz_dir):
62
+ if q.source:
63
+ sources.add(q.source)
64
+ return sorted(sources)
65
+
66
+
67
+ @router.get("/stats/{course}")
68
+ def course_stats(course: str) -> dict:
69
+ """Get review statistics for a course."""
70
+ return get_course_stats(course)
71
+
72
+
73
+ @router.get("/due/{course}")
74
+ def due_cards(course: str) -> list[dict]:
75
+ """Get cards due for review."""
76
+ cards = get_due_cards(course)
77
+ return [
78
+ {
79
+ "card_hash": c.card_hash,
80
+ "ease_factor": c.ease_factor,
81
+ "interval_days": c.interval_days,
82
+ "next_review": c.next_review,
83
+ }
84
+ for c in cards
85
+ ]
86
+
87
+
88
+ @router.get("/wrong/{course}")
89
+ def wrong_cards(course: str) -> list[str]:
90
+ """Get card hashes answered incorrectly in the most recent session."""
91
+ return list(get_wrong_hashes(course))
@@ -0,0 +1,69 @@
1
+ """History API routes — review sessions and session recording."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+
7
+ from fastapi import APIRouter
8
+ from pydantic import BaseModel
9
+
10
+ from studyctl.review_db import ensure_tables, record_session
11
+ from studyctl.settings import get_db_path
12
+
13
+ router = APIRouter()
14
+
15
+
16
+ class SessionRequest(BaseModel):
17
+ """POST /api/session request body."""
18
+
19
+ course: str
20
+ mode: str = "flashcards"
21
+ total: int
22
+ correct: int
23
+ duration_seconds: int | None = None
24
+
25
+
26
+ @router.get("/history")
27
+ def get_history() -> list[dict]:
28
+ """Return recent review sessions for the history view."""
29
+ db_path = get_db_path()
30
+ if not db_path.exists():
31
+ return []
32
+
33
+ ensure_tables(db_path)
34
+ conn = sqlite3.connect(db_path)
35
+ conn.execute("PRAGMA journal_mode=WAL")
36
+ conn.execute("PRAGMA busy_timeout=5000")
37
+ try:
38
+ rows = conn.execute(
39
+ "SELECT course, mode, total, correct, duration_seconds, "
40
+ "started_at, finished_at "
41
+ "FROM review_sessions ORDER BY started_at DESC LIMIT 20"
42
+ ).fetchall()
43
+ finally:
44
+ conn.close()
45
+
46
+ return [
47
+ {
48
+ "course": r[0],
49
+ "mode": r[1],
50
+ "total": r[2],
51
+ "correct": r[3],
52
+ "duration": r[4],
53
+ "date": r[5][:10] if r[5] else None,
54
+ }
55
+ for r in rows
56
+ ]
57
+
58
+
59
+ @router.post("/session")
60
+ def post_session(body: SessionRequest) -> dict:
61
+ """Record a complete review session."""
62
+ record_session(
63
+ course=body.course,
64
+ mode=body.mode,
65
+ total=body.total,
66
+ correct=body.correct,
67
+ duration_seconds=body.duration_seconds,
68
+ )
69
+ return {"ok": True}
studyctl/web/server.py ADDED
@@ -0,0 +1,260 @@
1
+ """Minimal web server for the study PWA — no external dependencies.
2
+
3
+ Serves static files and JSON API endpoints using only stdlib http.server.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ from functools import partial
11
+ from http.server import HTTPServer, SimpleHTTPRequestHandler
12
+ from pathlib import Path
13
+ from urllib.parse import parse_qs, urlparse
14
+
15
+ from studyctl.review_db import (
16
+ get_course_stats,
17
+ get_due_cards,
18
+ record_card_review,
19
+ record_session,
20
+ )
21
+ from studyctl.review_loader import (
22
+ discover_directories,
23
+ find_content_dirs,
24
+ load_flashcards,
25
+ load_quizzes,
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ STATIC_DIR = Path(__file__).parent / "static"
31
+
32
+
33
+ class StudyHandler(SimpleHTTPRequestHandler):
34
+ """Handle static files + /api/* JSON endpoints."""
35
+
36
+ def __init__(self, *args, study_dirs=None, **kwargs) -> None: # type: ignore[override]
37
+ self._study_dirs: list[str] = study_dirs or []
38
+ super().__init__(*args, directory=str(STATIC_DIR), **kwargs)
39
+
40
+ def do_GET(self) -> None:
41
+ parsed = urlparse(self.path)
42
+ path = parsed.path
43
+
44
+ if path == "/api/courses":
45
+ self._handle_courses()
46
+ elif path.startswith("/api/cards/"):
47
+ course = path.split("/api/cards/", 1)[1]
48
+ qs = parse_qs(parsed.query)
49
+ mode = qs.get("mode", ["flashcards"])[0]
50
+ self._handle_cards(course, mode)
51
+ elif path.startswith("/api/sources/"):
52
+ course = path.split("/api/sources/", 1)[1]
53
+ qs = parse_qs(parsed.query)
54
+ mode = qs.get("mode", ["flashcards"])[0]
55
+ self._handle_sources(course, mode)
56
+ elif path.startswith("/api/stats/"):
57
+ course = path.split("/api/stats/", 1)[1]
58
+ self._handle_stats(course)
59
+ elif path == "/api/history":
60
+ self._handle_history()
61
+ else:
62
+ # Serve static files; route / to index.html
63
+ if path == "/":
64
+ self.path = "/index.html"
65
+ super().do_GET()
66
+
67
+ def do_POST(self) -> None:
68
+ parsed = urlparse(self.path)
69
+ if parsed.path == "/api/review":
70
+ length = int(self.headers.get("Content-Length", 0))
71
+ body = json.loads(self.rfile.read(length)) if length else {}
72
+ self._handle_review(body)
73
+ elif parsed.path == "/api/session":
74
+ length = int(self.headers.get("Content-Length", 0))
75
+ body = json.loads(self.rfile.read(length)) if length else {}
76
+ self._handle_session(body)
77
+ else:
78
+ self._json_response({"error": "not found"}, 404)
79
+
80
+ def _handle_courses(self) -> None:
81
+ courses = discover_directories(self._study_dirs)
82
+ result = []
83
+ for name, path in courses:
84
+ fc_dir, quiz_dir = find_content_dirs(path)
85
+ fc_count = len(load_flashcards(fc_dir)) if fc_dir else 0
86
+ quiz_count = len(load_quizzes(quiz_dir)) if quiz_dir else 0
87
+ due = len(get_due_cards(name))
88
+ stats = get_course_stats(name)
89
+ result.append(
90
+ {
91
+ "name": name,
92
+ "flashcard_count": fc_count,
93
+ "quiz_count": quiz_count,
94
+ "due_count": due,
95
+ "total_reviews": stats.get("total_reviews", 0),
96
+ "mastered": stats.get("mastered", 0),
97
+ }
98
+ )
99
+ self._json_response(result)
100
+
101
+ def _handle_cards(self, course_name: str, mode: str) -> None:
102
+ courses = discover_directories(self._study_dirs)
103
+ match = next(((n, p) for n, p in courses if n == course_name), None)
104
+ if not match:
105
+ self._json_response({"error": f"Course '{course_name}' not found"}, 404)
106
+ return
107
+
108
+ _, path = match
109
+ fc_dir, quiz_dir = find_content_dirs(path)
110
+
111
+ if mode == "flashcards" and fc_dir:
112
+ cards = load_flashcards(fc_dir)
113
+ self._json_response(
114
+ [
115
+ {
116
+ "type": "flashcard",
117
+ "front": c.front,
118
+ "back": c.back,
119
+ "hash": c.card_hash,
120
+ "source": c.source,
121
+ }
122
+ for c in cards
123
+ ]
124
+ )
125
+ elif mode == "quiz" and quiz_dir:
126
+ questions = load_quizzes(quiz_dir)
127
+ self._json_response(
128
+ [
129
+ {
130
+ "type": "quiz",
131
+ "question": q.question,
132
+ "options": [
133
+ {
134
+ "text": o.text,
135
+ "is_correct": o.is_correct,
136
+ "rationale": o.rationale,
137
+ }
138
+ for o in q.options
139
+ ],
140
+ "hint": q.hint,
141
+ "hash": q.card_hash,
142
+ "source": q.source,
143
+ }
144
+ for q in questions
145
+ ]
146
+ )
147
+ else:
148
+ self._json_response({"error": f"No {mode} content for {course_name}"}, 404)
149
+
150
+ def _handle_sources(self, course_name: str, mode: str) -> None:
151
+ """Return unique source names for filtering by chapter."""
152
+ courses = discover_directories(self._study_dirs)
153
+ match = next(((n, p) for n, p in courses if n == course_name), None)
154
+ if not match:
155
+ self._json_response([])
156
+ return
157
+ _, path = match
158
+ fc_dir, quiz_dir = find_content_dirs(path)
159
+ sources: set[str] = set()
160
+ if mode == "flashcards" and fc_dir:
161
+ for c in load_flashcards(fc_dir):
162
+ if c.source:
163
+ sources.add(c.source)
164
+ elif mode == "quiz" and quiz_dir:
165
+ for q in load_quizzes(quiz_dir):
166
+ if q.source:
167
+ sources.add(q.source)
168
+ self._json_response(sorted(sources))
169
+
170
+ def _handle_stats(self, course_name: str) -> None:
171
+ stats = get_course_stats(course_name)
172
+ self._json_response(stats)
173
+
174
+ def _handle_history(self) -> None:
175
+ """Return recent review sessions for the home page."""
176
+ import sqlite3
177
+
178
+ from studyctl.review_db import _get_db, ensure_tables
179
+
180
+ path = _get_db()
181
+ if not path.exists():
182
+ self._json_response([])
183
+ return
184
+ ensure_tables(path)
185
+ conn = sqlite3.connect(path)
186
+ rows = conn.execute(
187
+ "SELECT course, mode, total, correct, duration_seconds, "
188
+ "started_at, finished_at FROM review_sessions "
189
+ "ORDER BY started_at DESC LIMIT 20"
190
+ ).fetchall()
191
+ conn.close()
192
+ self._json_response(
193
+ [
194
+ {
195
+ "course": r[0],
196
+ "mode": r[1],
197
+ "total": r[2],
198
+ "correct": r[3],
199
+ "duration": r[4],
200
+ "date": r[5][:10] if r[5] else None,
201
+ }
202
+ for r in rows
203
+ ]
204
+ )
205
+
206
+ def _handle_review(self, body: dict) -> None:
207
+ try:
208
+ record_card_review(
209
+ course=body["course"],
210
+ card_type=body.get("card_type", "flashcard"),
211
+ card_hash=body["card_hash"],
212
+ correct=body["correct"],
213
+ response_time_ms=body.get("response_time_ms"),
214
+ )
215
+ self._json_response({"ok": True})
216
+ except (KeyError, Exception) as exc:
217
+ self._json_response({"error": str(exc)}, 400)
218
+
219
+ def _handle_session(self, body: dict) -> None:
220
+ try:
221
+ record_session(
222
+ course=body["course"],
223
+ mode=body.get("mode", "flashcards"),
224
+ total=body["total"],
225
+ correct=body["correct"],
226
+ duration_seconds=body.get("duration_seconds"),
227
+ )
228
+ self._json_response({"ok": True})
229
+ except (KeyError, Exception) as exc:
230
+ self._json_response({"error": str(exc)}, 400)
231
+
232
+ def _json_response(self, data: object, status: int = 200) -> None:
233
+ body = json.dumps(data).encode()
234
+ self.send_response(status)
235
+ self.send_header("Content-Type", "application/json")
236
+ self.send_header("Content-Length", str(len(body)))
237
+ self.send_header("Access-Control-Allow-Origin", "*")
238
+ self.end_headers()
239
+ self.wfile.write(body)
240
+
241
+ def log_message(self, fmt: str, *args: object) -> None:
242
+ logger.info(fmt, *args)
243
+
244
+
245
+ def serve(
246
+ host: str = "localhost",
247
+ port: int = 8567,
248
+ study_dirs: list[str] | None = None,
249
+ ) -> None:
250
+ """Start the study PWA web server."""
251
+ handler = partial(StudyHandler, study_dirs=study_dirs)
252
+ server = HTTPServer((host, port), handler)
253
+ print(f"Study PWA serving at http://{host}:{port}")
254
+ print("Press Ctrl+C to stop")
255
+ try:
256
+ server.serve_forever()
257
+ except KeyboardInterrupt:
258
+ print("\nStopped.")
259
+ finally:
260
+ server.server_close()