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/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