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
studyctl/review_db.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Spaced repetition tracking for flashcard and quiz reviews.
|
|
2
|
+
|
|
3
|
+
Stores per-card review results in the sessions.db SQLite database.
|
|
4
|
+
Uses a simplified SM-2 algorithm for scheduling next reviews.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sqlite3
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import UTC, datetime, timedelta
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .settings import get_db_path
|
|
15
|
+
|
|
16
|
+
# SM-2 simplified intervals: correct → double interval, wrong → reset to 1
|
|
17
|
+
MIN_EASE = 1.3
|
|
18
|
+
DEFAULT_EASE = 2.5
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_db() -> Path:
|
|
22
|
+
"""Get sessions.db path from studyctl config."""
|
|
23
|
+
try:
|
|
24
|
+
return get_db_path()
|
|
25
|
+
except Exception:
|
|
26
|
+
return Path.home() / ".config" / "studyctl" / "sessions.db"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _connect(db_path: Path) -> sqlite3.Connection:
|
|
30
|
+
"""Open a SQLite connection with WAL mode and busy timeout."""
|
|
31
|
+
conn = sqlite3.connect(db_path)
|
|
32
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
33
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
34
|
+
return conn
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def ensure_tables(db_path: Path | None = None) -> None:
|
|
38
|
+
"""Create card_reviews and review_sessions tables if they don't exist."""
|
|
39
|
+
path = db_path or _get_db()
|
|
40
|
+
if not path.exists():
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
with _connect(path) as conn:
|
|
44
|
+
conn.execute("""
|
|
45
|
+
CREATE TABLE IF NOT EXISTS card_reviews (
|
|
46
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
47
|
+
course TEXT NOT NULL,
|
|
48
|
+
card_type TEXT NOT NULL,
|
|
49
|
+
card_hash TEXT NOT NULL,
|
|
50
|
+
correct BOOLEAN NOT NULL,
|
|
51
|
+
reviewed_at TEXT NOT NULL,
|
|
52
|
+
ease_factor REAL DEFAULT 2.5,
|
|
53
|
+
interval_days INTEGER DEFAULT 1,
|
|
54
|
+
next_review TEXT,
|
|
55
|
+
response_time_ms INTEGER
|
|
56
|
+
)
|
|
57
|
+
""")
|
|
58
|
+
conn.execute("""
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_card_reviews_next
|
|
60
|
+
ON card_reviews(course, next_review)
|
|
61
|
+
""")
|
|
62
|
+
conn.execute("""
|
|
63
|
+
CREATE INDEX IF NOT EXISTS idx_card_reviews_hash
|
|
64
|
+
ON card_reviews(card_hash)
|
|
65
|
+
""")
|
|
66
|
+
conn.execute("""
|
|
67
|
+
CREATE TABLE IF NOT EXISTS review_sessions (
|
|
68
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
69
|
+
course TEXT NOT NULL,
|
|
70
|
+
mode TEXT NOT NULL,
|
|
71
|
+
total INTEGER NOT NULL,
|
|
72
|
+
correct INTEGER NOT NULL,
|
|
73
|
+
duration_seconds INTEGER,
|
|
74
|
+
started_at TEXT NOT NULL,
|
|
75
|
+
finished_at TEXT
|
|
76
|
+
)
|
|
77
|
+
""")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def record_card_review(
|
|
81
|
+
course: str,
|
|
82
|
+
card_type: str,
|
|
83
|
+
card_hash: str,
|
|
84
|
+
correct: bool,
|
|
85
|
+
response_time_ms: int | None = None,
|
|
86
|
+
db_path: Path | None = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Record a single card review and update spaced repetition schedule."""
|
|
89
|
+
path = db_path or _get_db()
|
|
90
|
+
ensure_tables(path)
|
|
91
|
+
|
|
92
|
+
with _connect(path) as conn:
|
|
93
|
+
now = datetime.now(UTC).isoformat()
|
|
94
|
+
|
|
95
|
+
# Get previous review for this card
|
|
96
|
+
row = conn.execute(
|
|
97
|
+
"SELECT ease_factor, interval_days FROM card_reviews "
|
|
98
|
+
"WHERE card_hash = ? ORDER BY reviewed_at DESC LIMIT 1",
|
|
99
|
+
(card_hash,),
|
|
100
|
+
).fetchone()
|
|
101
|
+
|
|
102
|
+
if row:
|
|
103
|
+
ease, interval = row
|
|
104
|
+
else:
|
|
105
|
+
ease, interval = DEFAULT_EASE, 1
|
|
106
|
+
|
|
107
|
+
# SM-2 simplified update
|
|
108
|
+
if correct:
|
|
109
|
+
interval = max(1, int(interval * ease))
|
|
110
|
+
ease = min(ease + 0.1, 3.0)
|
|
111
|
+
else:
|
|
112
|
+
interval = 1
|
|
113
|
+
ease = max(ease - 0.2, MIN_EASE)
|
|
114
|
+
|
|
115
|
+
next_review = (datetime.now(UTC) + timedelta(days=interval)).strftime("%Y-%m-%d")
|
|
116
|
+
|
|
117
|
+
conn.execute(
|
|
118
|
+
"INSERT INTO card_reviews "
|
|
119
|
+
"(course, card_type, card_hash, correct, reviewed_at, "
|
|
120
|
+
"ease_factor, interval_days, next_review, response_time_ms) "
|
|
121
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
122
|
+
(
|
|
123
|
+
course,
|
|
124
|
+
card_type,
|
|
125
|
+
card_hash,
|
|
126
|
+
correct,
|
|
127
|
+
now,
|
|
128
|
+
ease,
|
|
129
|
+
interval,
|
|
130
|
+
next_review,
|
|
131
|
+
response_time_ms,
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def record_session(
|
|
137
|
+
course: str,
|
|
138
|
+
mode: str,
|
|
139
|
+
total: int,
|
|
140
|
+
correct: int,
|
|
141
|
+
duration_seconds: int | None = None,
|
|
142
|
+
db_path: Path | None = None,
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Record a complete review session."""
|
|
145
|
+
path = db_path or _get_db()
|
|
146
|
+
ensure_tables(path)
|
|
147
|
+
|
|
148
|
+
with _connect(path) as conn:
|
|
149
|
+
now = datetime.now(UTC).isoformat()
|
|
150
|
+
conn.execute(
|
|
151
|
+
"INSERT INTO review_sessions "
|
|
152
|
+
"(course, mode, total, correct, duration_seconds, started_at, finished_at) "
|
|
153
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
154
|
+
(course, mode, total, correct, duration_seconds, now, now),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class CardProgress:
|
|
160
|
+
card_hash: str
|
|
161
|
+
last_correct: bool
|
|
162
|
+
ease_factor: float
|
|
163
|
+
interval_days: int
|
|
164
|
+
next_review: str
|
|
165
|
+
review_count: int
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_due_cards(course: str, db_path: Path | None = None) -> list[CardProgress]:
|
|
169
|
+
"""Get cards due for review (next_review <= today)."""
|
|
170
|
+
path = db_path or _get_db()
|
|
171
|
+
if not path.exists():
|
|
172
|
+
return []
|
|
173
|
+
|
|
174
|
+
ensure_tables(path)
|
|
175
|
+
with _connect(path) as conn:
|
|
176
|
+
today = datetime.now(UTC).strftime("%Y-%m-%d")
|
|
177
|
+
|
|
178
|
+
rows = conn.execute(
|
|
179
|
+
"""
|
|
180
|
+
WITH latest AS (
|
|
181
|
+
SELECT card_hash, correct, ease_factor, interval_days, next_review,
|
|
182
|
+
COUNT(*) OVER (PARTITION BY card_hash) as review_count,
|
|
183
|
+
ROW_NUMBER() OVER (PARTITION BY card_hash ORDER BY reviewed_at DESC) as rn
|
|
184
|
+
FROM card_reviews
|
|
185
|
+
WHERE course = ?
|
|
186
|
+
)
|
|
187
|
+
SELECT card_hash, correct, ease_factor, interval_days, next_review, review_count
|
|
188
|
+
FROM latest
|
|
189
|
+
WHERE rn = 1 AND next_review <= ?
|
|
190
|
+
ORDER BY next_review ASC
|
|
191
|
+
""",
|
|
192
|
+
(course, today),
|
|
193
|
+
).fetchall()
|
|
194
|
+
|
|
195
|
+
return [
|
|
196
|
+
CardProgress(
|
|
197
|
+
card_hash=r[0],
|
|
198
|
+
last_correct=bool(r[1]),
|
|
199
|
+
ease_factor=r[2],
|
|
200
|
+
interval_days=r[3],
|
|
201
|
+
next_review=r[4],
|
|
202
|
+
review_count=r[5],
|
|
203
|
+
)
|
|
204
|
+
for r in rows
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def get_wrong_hashes(course: str, db_path: Path | None = None) -> set[str]:
|
|
209
|
+
"""Get card hashes that were incorrect in the most recent session."""
|
|
210
|
+
path = db_path or _get_db()
|
|
211
|
+
if not path.exists():
|
|
212
|
+
return set()
|
|
213
|
+
|
|
214
|
+
ensure_tables(path)
|
|
215
|
+
with _connect(path) as conn:
|
|
216
|
+
# Find the most recent session's reviewed_at range
|
|
217
|
+
last_session = conn.execute(
|
|
218
|
+
"SELECT started_at FROM review_sessions "
|
|
219
|
+
"WHERE course = ? ORDER BY started_at DESC LIMIT 1",
|
|
220
|
+
(course,),
|
|
221
|
+
).fetchone()
|
|
222
|
+
|
|
223
|
+
if not last_session:
|
|
224
|
+
return set()
|
|
225
|
+
|
|
226
|
+
rows = conn.execute(
|
|
227
|
+
"SELECT DISTINCT card_hash FROM card_reviews "
|
|
228
|
+
"WHERE course = ? AND correct = 0 AND reviewed_at >= ?",
|
|
229
|
+
(course, last_session[0]),
|
|
230
|
+
).fetchall()
|
|
231
|
+
|
|
232
|
+
return {r[0] for r in rows}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def get_course_stats(course: str, db_path: Path | None = None) -> dict:
|
|
236
|
+
"""Get summary statistics for a course."""
|
|
237
|
+
path = db_path or _get_db()
|
|
238
|
+
if not path.exists():
|
|
239
|
+
return {"total_reviews": 0, "unique_cards": 0, "due_today": 0, "mastered": 0}
|
|
240
|
+
|
|
241
|
+
ensure_tables(path)
|
|
242
|
+
with _connect(path) as conn:
|
|
243
|
+
today = datetime.now(UTC).strftime("%Y-%m-%d")
|
|
244
|
+
|
|
245
|
+
total = conn.execute(
|
|
246
|
+
"SELECT COUNT(*) FROM card_reviews WHERE course = ?", (course,)
|
|
247
|
+
).fetchone()[0]
|
|
248
|
+
|
|
249
|
+
unique = conn.execute(
|
|
250
|
+
"SELECT COUNT(DISTINCT card_hash) FROM card_reviews WHERE course = ?", (course,)
|
|
251
|
+
).fetchone()[0]
|
|
252
|
+
|
|
253
|
+
due = conn.execute(
|
|
254
|
+
"SELECT COUNT(DISTINCT card_hash) FROM card_reviews "
|
|
255
|
+
"WHERE course = ? AND next_review <= ?",
|
|
256
|
+
(course, today),
|
|
257
|
+
).fetchone()[0]
|
|
258
|
+
|
|
259
|
+
# Mastered = interval > 30 days
|
|
260
|
+
mastered = conn.execute(
|
|
261
|
+
"""
|
|
262
|
+
SELECT COUNT(DISTINCT card_hash) FROM card_reviews cr1
|
|
263
|
+
WHERE course = ? AND interval_days > 30
|
|
264
|
+
AND reviewed_at = (
|
|
265
|
+
SELECT MAX(reviewed_at) FROM card_reviews cr2
|
|
266
|
+
WHERE cr2.card_hash = cr1.card_hash
|
|
267
|
+
)
|
|
268
|
+
""",
|
|
269
|
+
(course,),
|
|
270
|
+
).fetchone()[0]
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
"total_reviews": total,
|
|
274
|
+
"unique_cards": unique,
|
|
275
|
+
"due_today": due,
|
|
276
|
+
"mastered": mastered,
|
|
277
|
+
}
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""Self-contained flashcard and quiz JSON loader.
|
|
2
|
+
|
|
3
|
+
Reads the same JSON format generated by pdf-by-chapters from-obsidian.
|
|
4
|
+
No cross-package imports -- the JSON format IS the contract.
|
|
5
|
+
|
|
6
|
+
Flashcard JSON shape
|
|
7
|
+
--------------------
|
|
8
|
+
::
|
|
9
|
+
|
|
10
|
+
{
|
|
11
|
+
"title": str, # REQUIRED -- deck/chapter display name
|
|
12
|
+
"cards": [ # REQUIRED -- list of card objects
|
|
13
|
+
{
|
|
14
|
+
"front": str, # REQUIRED -- question or prompt
|
|
15
|
+
"back": str # REQUIRED -- answer or explanation
|
|
16
|
+
},
|
|
17
|
+
...
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
Files matched by glob: ``*flashcards.json``
|
|
22
|
+
|
|
23
|
+
Quiz JSON shape
|
|
24
|
+
---------------
|
|
25
|
+
::
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
"title": str, # REQUIRED -- quiz display name
|
|
29
|
+
"questions": [ # REQUIRED -- list of question objects
|
|
30
|
+
{
|
|
31
|
+
"question": str, # REQUIRED -- the question text
|
|
32
|
+
"hint": str, # optional -- hint shown on request
|
|
33
|
+
"answerOptions": [ # REQUIRED -- list of option objects (>=2)
|
|
34
|
+
{
|
|
35
|
+
"text": str, # REQUIRED -- option display text
|
|
36
|
+
"isCorrect": bool, # optional -- true for correct answer
|
|
37
|
+
# (default false)
|
|
38
|
+
"rationale": str # optional -- explanation shown after
|
|
39
|
+
# answering
|
|
40
|
+
},
|
|
41
|
+
...
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
...
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
Files matched by glob: ``*quiz.json``
|
|
49
|
+
|
|
50
|
+
Validation behaviour
|
|
51
|
+
--------------------
|
|
52
|
+
Files that fail JSON parsing or are missing required keys are skipped with a
|
|
53
|
+
warning logged via ``logging.getLogger(__name__)``. This keeps the loader
|
|
54
|
+
resilient to partial / in-progress content while giving clear diagnostics.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
import hashlib
|
|
60
|
+
import json
|
|
61
|
+
import logging
|
|
62
|
+
import random
|
|
63
|
+
from dataclasses import dataclass, field
|
|
64
|
+
from pathlib import Path
|
|
65
|
+
|
|
66
|
+
logger = logging.getLogger(__name__)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class Flashcard:
|
|
71
|
+
front: str
|
|
72
|
+
back: str
|
|
73
|
+
source: str = ""
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def card_hash(self) -> str:
|
|
77
|
+
"""Stable hash for spaced repetition tracking.
|
|
78
|
+
|
|
79
|
+
Truncated to 16 hex chars (64 bits) because:
|
|
80
|
+
- Display-friendly in logs, TUI, and DB columns
|
|
81
|
+
- Sufficient collision resistance for a study app (~4 billion cards
|
|
82
|
+
before a 50% birthday-collision probability)
|
|
83
|
+
- MUST NOT be changed: existing SM-2 spaced repetition history in
|
|
84
|
+
sessions.db is keyed on this value; altering the length would
|
|
85
|
+
orphan all stored review progress
|
|
86
|
+
"""
|
|
87
|
+
return hashlib.sha256(self.front.encode()).hexdigest()[:16]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class QuizOption:
|
|
92
|
+
text: str
|
|
93
|
+
is_correct: bool
|
|
94
|
+
rationale: str = ""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class QuizQuestion:
|
|
99
|
+
question: str
|
|
100
|
+
options: list[QuizOption]
|
|
101
|
+
hint: str = ""
|
|
102
|
+
source: str = ""
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def card_hash(self) -> str:
|
|
106
|
+
"""Stable hash for spaced repetition tracking.
|
|
107
|
+
|
|
108
|
+
Truncated to 16 hex chars (64 bits) because:
|
|
109
|
+
- Display-friendly in logs, TUI, and DB columns
|
|
110
|
+
- Sufficient collision resistance for a study app (~4 billion cards
|
|
111
|
+
before a 50% birthday-collision probability)
|
|
112
|
+
- MUST NOT be changed: existing SM-2 spaced repetition history in
|
|
113
|
+
sessions.db is keyed on this value; altering the length would
|
|
114
|
+
orphan all stored review progress
|
|
115
|
+
"""
|
|
116
|
+
return hashlib.sha256(self.question.encode()).hexdigest()[:16]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class ReviewResult:
|
|
121
|
+
total: int = 0
|
|
122
|
+
correct: int = 0
|
|
123
|
+
incorrect: int = 0
|
|
124
|
+
skipped: int = 0
|
|
125
|
+
wrong_hashes: set[str] = field(default_factory=set)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def score_pct(self) -> float:
|
|
129
|
+
attempted = self.correct + self.incorrect
|
|
130
|
+
return (self.correct / attempted * 100) if attempted > 0 else 0.0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def load_flashcards(directory: Path) -> list[Flashcard]:
|
|
134
|
+
"""Load all flashcard JSON files from a directory."""
|
|
135
|
+
cards: list[Flashcard] = []
|
|
136
|
+
for path in sorted(directory.glob("*flashcards.json")):
|
|
137
|
+
try:
|
|
138
|
+
data = json.loads(path.read_text())
|
|
139
|
+
except json.JSONDecodeError as exc:
|
|
140
|
+
logger.warning("Skipping %s: invalid JSON (%s)", path.name, exc)
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
if not isinstance(data, dict):
|
|
144
|
+
logger.warning(
|
|
145
|
+
"Skipping %s: expected JSON object, got %s", path.name, type(data).__name__
|
|
146
|
+
)
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
if "cards" not in data:
|
|
150
|
+
logger.warning("Skipping %s: missing required 'cards' key", path.name)
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
if not isinstance(data["cards"], list):
|
|
154
|
+
logger.warning(
|
|
155
|
+
"Skipping %s: 'cards' must be a list, got %s",
|
|
156
|
+
path.name,
|
|
157
|
+
type(data["cards"]).__name__,
|
|
158
|
+
)
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
source = data.get("title", path.stem)
|
|
162
|
+
for i, card in enumerate(data["cards"]):
|
|
163
|
+
if not isinstance(card, dict):
|
|
164
|
+
logger.warning(
|
|
165
|
+
"Skipping card %d in %s: expected object, got %s",
|
|
166
|
+
i,
|
|
167
|
+
path.name,
|
|
168
|
+
type(card).__name__,
|
|
169
|
+
)
|
|
170
|
+
continue
|
|
171
|
+
if "front" not in card:
|
|
172
|
+
logger.warning(
|
|
173
|
+
"Skipping card %d in %s: missing required 'front' field", i, path.name
|
|
174
|
+
)
|
|
175
|
+
continue
|
|
176
|
+
if "back" not in card:
|
|
177
|
+
logger.warning(
|
|
178
|
+
"Skipping card %d in %s: missing required 'back' field", i, path.name
|
|
179
|
+
)
|
|
180
|
+
continue
|
|
181
|
+
try:
|
|
182
|
+
cards.append(Flashcard(front=card["front"], back=card["back"], source=source))
|
|
183
|
+
except (TypeError, ValueError) as exc:
|
|
184
|
+
logger.warning("Skipping card %d in %s: %s", i, path.name, exc)
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
return cards
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def load_quizzes(directory: Path) -> list[QuizQuestion]:
|
|
191
|
+
"""Load all quiz JSON files from a directory."""
|
|
192
|
+
questions: list[QuizQuestion] = []
|
|
193
|
+
for path in sorted(directory.glob("*quiz.json")):
|
|
194
|
+
try:
|
|
195
|
+
data = json.loads(path.read_text())
|
|
196
|
+
except json.JSONDecodeError as exc:
|
|
197
|
+
logger.warning("Skipping %s: invalid JSON (%s)", path.name, exc)
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
if not isinstance(data, dict):
|
|
201
|
+
logger.warning(
|
|
202
|
+
"Skipping %s: expected JSON object, got %s", path.name, type(data).__name__
|
|
203
|
+
)
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
if "questions" not in data:
|
|
207
|
+
logger.warning("Skipping %s: missing required 'questions' key", path.name)
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
if not isinstance(data["questions"], list):
|
|
211
|
+
logger.warning(
|
|
212
|
+
"Skipping %s: 'questions' must be a list, got %s",
|
|
213
|
+
path.name,
|
|
214
|
+
type(data["questions"]).__name__,
|
|
215
|
+
)
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
source = data.get("title", path.stem)
|
|
219
|
+
for i, q in enumerate(data["questions"]):
|
|
220
|
+
if not isinstance(q, dict):
|
|
221
|
+
logger.warning(
|
|
222
|
+
"Skipping question %d in %s: expected object, got %s",
|
|
223
|
+
i,
|
|
224
|
+
path.name,
|
|
225
|
+
type(q).__name__,
|
|
226
|
+
)
|
|
227
|
+
continue
|
|
228
|
+
if "question" not in q:
|
|
229
|
+
logger.warning(
|
|
230
|
+
"Skipping question %d in %s: missing required 'question' field", i, path.name
|
|
231
|
+
)
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
answer_options = q.get("answerOptions", [])
|
|
235
|
+
if not isinstance(answer_options, list):
|
|
236
|
+
logger.warning(
|
|
237
|
+
"Skipping question %d in %s: 'answerOptions' must be a list, got %s",
|
|
238
|
+
i,
|
|
239
|
+
path.name,
|
|
240
|
+
type(answer_options).__name__,
|
|
241
|
+
)
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
options: list[QuizOption] = []
|
|
245
|
+
for j, opt in enumerate(answer_options):
|
|
246
|
+
if not isinstance(opt, dict):
|
|
247
|
+
logger.warning(
|
|
248
|
+
"Skipping option %d of question %d in %s: expected object, got %s",
|
|
249
|
+
j,
|
|
250
|
+
i,
|
|
251
|
+
path.name,
|
|
252
|
+
type(opt).__name__,
|
|
253
|
+
)
|
|
254
|
+
continue
|
|
255
|
+
if "text" not in opt:
|
|
256
|
+
logger.warning(
|
|
257
|
+
"Skipping option %d of question %d in %s: missing required 'text' field",
|
|
258
|
+
j,
|
|
259
|
+
i,
|
|
260
|
+
path.name,
|
|
261
|
+
)
|
|
262
|
+
continue
|
|
263
|
+
options.append(
|
|
264
|
+
QuizOption(
|
|
265
|
+
text=opt["text"],
|
|
266
|
+
is_correct=opt.get("isCorrect", False),
|
|
267
|
+
rationale=opt.get("rationale", ""),
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
questions.append(
|
|
273
|
+
QuizQuestion(
|
|
274
|
+
question=q["question"],
|
|
275
|
+
options=options,
|
|
276
|
+
hint=q.get("hint", ""),
|
|
277
|
+
source=source,
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
except (TypeError, ValueError) as exc:
|
|
281
|
+
logger.warning("Skipping question %d in %s: %s", i, path.name, exc)
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
return questions
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
_GENERIC_DIR_NAMES = {"downloads", "content", "data", "files", "output", "generated"}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _course_name(directory: Path) -> str:
|
|
291
|
+
"""Derive a display name from a directory path.
|
|
292
|
+
|
|
293
|
+
Uses the parent name when the directory has a generic name like 'downloads'.
|
|
294
|
+
"""
|
|
295
|
+
name = directory.name
|
|
296
|
+
if name.lower() in _GENERIC_DIR_NAMES:
|
|
297
|
+
return directory.parent.name
|
|
298
|
+
return name
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def discover_directories(config_dirs: list[str] | None = None) -> list[tuple[str, Path]]:
|
|
302
|
+
"""Discover course directories with flashcard/quiz content.
|
|
303
|
+
|
|
304
|
+
Returns list of (course_name, directory_path) tuples.
|
|
305
|
+
Searches configured directories and their subdirectories.
|
|
306
|
+
"""
|
|
307
|
+
if not config_dirs:
|
|
308
|
+
return []
|
|
309
|
+
|
|
310
|
+
courses: list[tuple[str, Path]] = []
|
|
311
|
+
for dir_str in config_dirs:
|
|
312
|
+
base = Path(dir_str).expanduser()
|
|
313
|
+
if not base.is_dir():
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
# Check if this directory itself has content
|
|
317
|
+
if _has_review_content(base):
|
|
318
|
+
courses.append((_course_name(base), base))
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
# Check subdirectories (e.g. downloads/flashcards/)
|
|
322
|
+
downloads = base / "downloads"
|
|
323
|
+
if downloads.is_dir() and _has_review_content(downloads):
|
|
324
|
+
courses.append((_course_name(base), downloads))
|
|
325
|
+
continue
|
|
326
|
+
|
|
327
|
+
# Check immediate children
|
|
328
|
+
for child in sorted(base.iterdir()):
|
|
329
|
+
if child.is_dir() and _has_review_content(child):
|
|
330
|
+
courses.append((_course_name(child), child))
|
|
331
|
+
|
|
332
|
+
return courses
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _has_review_content(directory: Path) -> bool:
|
|
336
|
+
"""Check if a directory has flashcard or quiz JSON files."""
|
|
337
|
+
fc_dir = directory / "flashcards"
|
|
338
|
+
quiz_dir = directory / "quizzes"
|
|
339
|
+
|
|
340
|
+
if fc_dir.is_dir() and list(fc_dir.glob("*flashcards.json")):
|
|
341
|
+
return True
|
|
342
|
+
if quiz_dir.is_dir() and list(quiz_dir.glob("*quiz.json")):
|
|
343
|
+
return True
|
|
344
|
+
if list(directory.glob("*flashcards.json")):
|
|
345
|
+
return True
|
|
346
|
+
return bool(list(directory.glob("*quiz.json")))
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def find_content_dirs(directory: Path) -> tuple[Path | None, Path | None]:
|
|
350
|
+
"""Find flashcard and quiz subdirectories within a directory."""
|
|
351
|
+
fc_dir: Path | None = None
|
|
352
|
+
quiz_dir: Path | None = None
|
|
353
|
+
|
|
354
|
+
fc_sub = directory / "flashcards"
|
|
355
|
+
if fc_sub.is_dir() and list(fc_sub.glob("*-flashcards.json")):
|
|
356
|
+
fc_dir = fc_sub
|
|
357
|
+
elif list(directory.glob("*flashcards.json")):
|
|
358
|
+
fc_dir = directory
|
|
359
|
+
|
|
360
|
+
quiz_sub = directory / "quizzes"
|
|
361
|
+
if quiz_sub.is_dir() and list(quiz_sub.glob("*-quiz.json")):
|
|
362
|
+
quiz_dir = quiz_sub
|
|
363
|
+
elif list(directory.glob("*quiz.json")):
|
|
364
|
+
quiz_dir = directory
|
|
365
|
+
|
|
366
|
+
return fc_dir, quiz_dir
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def shuffle_items(items: list, enabled: bool = True) -> list:
|
|
370
|
+
"""Shuffle a list if enabled."""
|
|
371
|
+
if enabled:
|
|
372
|
+
result = list(items)
|
|
373
|
+
random.shuffle(result)
|
|
374
|
+
return result
|
|
375
|
+
return items
|