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,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()
|