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/scheduler.py ADDED
@@ -0,0 +1,242 @@
1
+ """Cross-platform scheduled job management (macOS launchd + Linux systemd/cron)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ import subprocess
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from textwrap import dedent
10
+
11
+ JOBS_DIR = Path.home() / ".config" / "studyctl" / "jobs"
12
+
13
+
14
+ @dataclass
15
+ class Job:
16
+ name: str
17
+ command: str # Uses ~ for portability
18
+ schedule: str # Human-readable: "every 2h", "daily 7am"
19
+ description: str = ""
20
+
21
+
22
+ DEFAULT_JOBS = [
23
+ Job(
24
+ "session-export", "~/.local/bin/session-export", "every 2h", "Extract agent sessions to DB"
25
+ ),
26
+ Job(
27
+ "studyctl-sync", "~/.local/bin/studyctl sync --all", "daily 7am", "Sync notes to NotebookLM"
28
+ ),
29
+ Job("studyctl-push", "~/.local/bin/studyctl state push", "every 4h", "Push state to hub"),
30
+ ]
31
+
32
+
33
+ def _is_macos() -> bool:
34
+ return platform.system() == "Darwin"
35
+
36
+
37
+ # ── macOS launchd ──────────────────────────────────────────────────────────
38
+
39
+
40
+ def _launchd_plist(job: Job, username: str) -> str:
41
+ home = f"/Users/{username}" if _is_macos() else f"/home/{username}"
42
+ cmd_parts = job.command.replace("~", home).split()
43
+ args_xml = "\n ".join(f"<string>{a}</string>" for a in cmd_parts)
44
+
45
+ hour_tpl = "<key>Hour</key><integer>{h}</integer>"
46
+ min_tpl = "<key>Minute</key><integer>{m}</integer>"
47
+
48
+ def _cal_dict(h: int, m: int = 0) -> str:
49
+ return f" <dict>{hour_tpl.format(h=h)}{min_tpl.format(m=m)}</dict>"
50
+
51
+ cal_key = " <key>StartCalendarInterval</key>\n"
52
+ if "every 2h" in job.schedule:
53
+ entries = "\n".join(_cal_dict(h) for h in range(8, 23, 2))
54
+ schedule = f"{cal_key} <array>\n{entries}\n </array>"
55
+ elif "every 4h" in job.schedule:
56
+ entries = "\n".join(_cal_dict(h, 30) for h in range(8, 23, 4))
57
+ schedule = f"{cal_key} <array>\n{entries}\n </array>"
58
+ elif "daily" in job.schedule:
59
+ hour = 7
60
+ if "am" in job.schedule:
61
+ hour = int(job.schedule.split("daily")[1].strip().replace("am", "").strip())
62
+ schedule = (
63
+ f"{cal_key}"
64
+ " <dict>\n"
65
+ f" {hour_tpl.format(h=hour)}\n"
66
+ f" {min_tpl.format(m=0)}\n"
67
+ " </dict>"
68
+ )
69
+ else:
70
+ schedule = " <key>StartInterval</key>\n <integer>3600</integer>"
71
+
72
+ return dedent(f"""\
73
+ <?xml version="1.0" encoding="UTF-8"?>
74
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
75
+ <plist version="1.0">
76
+ <dict>
77
+ <key>Label</key>
78
+ <string>com.studyctl.{job.name}</string>
79
+ <key>ProgramArguments</key>
80
+ <array>
81
+ {args_xml}
82
+ </array>
83
+ {schedule}
84
+ <key>StandardOutPath</key>
85
+ <string>{home}/.local/share/studyctl/logs/{job.name}.log</string>
86
+ <key>StandardErrorPath</key>
87
+ <string>{home}/.local/share/studyctl/logs/{job.name}.err</string>
88
+ <key>EnvironmentVariables</key>
89
+ <dict>
90
+ <key>PATH</key>
91
+ <string>{home}/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
92
+ </dict>
93
+ </dict>
94
+ </plist>""")
95
+
96
+
97
+ def _launchd_install(job: Job, username: str) -> bool:
98
+ label = f"com.studyctl.{job.name}"
99
+ plist_dir = Path.home() / "Library" / "LaunchAgents"
100
+ plist_dir.mkdir(parents=True, exist_ok=True)
101
+ plist_path = plist_dir / f"{label}.plist"
102
+
103
+ plist_path.write_text(_launchd_plist(job, username))
104
+ subprocess.run(["launchctl", "bootout", f"gui/{_uid()}/{label}"], capture_output=True)
105
+ result = subprocess.run(
106
+ ["launchctl", "bootstrap", f"gui/{_uid()}", str(plist_path)], capture_output=True
107
+ )
108
+ return result.returncode == 0
109
+
110
+
111
+ def _launchd_remove(job: Job) -> bool:
112
+ label = f"com.studyctl.{job.name}"
113
+ subprocess.run(["launchctl", "bootout", f"gui/{_uid()}/{label}"], capture_output=True)
114
+ plist = Path.home() / "Library" / "LaunchAgents" / f"{label}.plist"
115
+ if plist.exists():
116
+ plist.unlink()
117
+ return True
118
+
119
+
120
+ def _launchd_list() -> list[dict]:
121
+ result = subprocess.run(["launchctl", "list"], capture_output=True, text=True)
122
+ jobs = []
123
+ for line in result.stdout.splitlines():
124
+ if "com.studyctl." in line:
125
+ parts = line.split()
126
+ jobs.append(
127
+ {
128
+ "name": parts[2].replace("com.studyctl.", ""),
129
+ "status": parts[0],
130
+ "label": parts[2],
131
+ }
132
+ )
133
+ return jobs
134
+
135
+
136
+ def _uid() -> int:
137
+ import os
138
+
139
+ return os.getuid()
140
+
141
+
142
+ # ── Linux cron ─────────────────────────────────────────────────────────────
143
+
144
+
145
+ def _cron_expression(job: Job) -> str:
146
+ if "every 2h" in job.schedule:
147
+ return "0 8-22/2 * * *"
148
+ elif "every 4h" in job.schedule:
149
+ return "30 8-22/4 * * *"
150
+ elif "daily" in job.schedule:
151
+ hour = 7
152
+ if "am" in job.schedule:
153
+ hour = int(job.schedule.split("daily")[1].strip().replace("am", "").strip())
154
+ return f"0 {hour} * * *"
155
+ return "0 * * * *"
156
+
157
+
158
+ def _cron_line(job: Job) -> str:
159
+ home = str(Path.home())
160
+ cmd = job.command.replace("~", home)
161
+ log = f"{home}/.local/share/studyctl/logs/{job.name}.log"
162
+ path_dirs = [
163
+ f"{home}/.local/bin",
164
+ "/opt/homebrew/bin",
165
+ "/usr/local/bin",
166
+ "/usr/bin",
167
+ ]
168
+ path = "PATH=" + ":".join(path_dirs)
169
+ cron = _cron_expression(job)
170
+ return f"{cron} {path} {cmd} >> {log} 2>&1 # studyctl:{job.name}"
171
+
172
+
173
+ def _cron_install(job: Job) -> bool:
174
+ marker = f"# studyctl:{job.name}"
175
+ result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
176
+ existing = result.stdout if result.returncode == 0 else ""
177
+ # Remove old entry
178
+ lines = [line for line in existing.splitlines() if marker not in line]
179
+ lines.append(_cron_line(job))
180
+ subprocess.run(["crontab", "-"], input="\n".join(lines) + "\n", text=True, check=True)
181
+ return True
182
+
183
+
184
+ def _cron_remove(job: Job) -> bool:
185
+ marker = f"# studyctl:{job.name}"
186
+ result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
187
+ if result.returncode != 0:
188
+ return True
189
+ lines = [line for line in result.stdout.splitlines() if marker not in line]
190
+ subprocess.run(["crontab", "-"], input="\n".join(lines) + "\n", text=True, check=True)
191
+ return True
192
+
193
+
194
+ def _cron_list() -> list[dict]:
195
+ result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
196
+ if result.returncode != 0:
197
+ return []
198
+ jobs = []
199
+ for line in result.stdout.splitlines():
200
+ if "# studyctl:" in line:
201
+ name = line.split("# studyctl:")[1].strip()
202
+ jobs.append({"name": name, "cron": line.split("#")[0].strip()})
203
+ return jobs
204
+
205
+
206
+ # ── Public API ─────────────────────────────────────────────────────────────
207
+
208
+
209
+ def install_job(job: Job, username: str | None = None) -> bool:
210
+ Path.home().joinpath(".local/share/studyctl/logs").mkdir(parents=True, exist_ok=True)
211
+ username = username or Path.home().name
212
+ if _is_macos():
213
+ return _launchd_install(job, username)
214
+ return _cron_install(job)
215
+
216
+
217
+ def remove_job(job: Job) -> bool:
218
+ if _is_macos():
219
+ return _launchd_remove(job)
220
+ return _cron_remove(job)
221
+
222
+
223
+ def list_jobs() -> list[dict]:
224
+ if _is_macos():
225
+ return _launchd_list()
226
+ return _cron_list()
227
+
228
+
229
+ def install_all(username: str | None = None) -> list[str]:
230
+ installed = []
231
+ for job in DEFAULT_JOBS:
232
+ if install_job(job, username):
233
+ installed.append(job.name)
234
+ return installed
235
+
236
+
237
+ def remove_all() -> list[str]:
238
+ removed = []
239
+ for job in DEFAULT_JOBS:
240
+ if remove_job(job):
241
+ removed.append(job.name)
242
+ return removed
@@ -0,0 +1,6 @@
1
+ """Service layer for studyctl.
2
+
3
+ Framework-agnostic business logic that bridges CLI, web, and MCP
4
+ interfaces to the underlying data layer. No click, fastapi, or other
5
+ framework imports belong here.
6
+ """
@@ -0,0 +1,39 @@
1
+ """Content pipeline service layer.
2
+
3
+ Framework-agnostic wrappers for content pipeline operations. Used by
4
+ CLI, future FastAPI web UI, and MCP server.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ from studyctl.content import storage
12
+
13
+ if TYPE_CHECKING:
14
+ from pathlib import Path
15
+
16
+
17
+ def list_courses(base_path: Path) -> list[dict]:
18
+ """List all courses under the content base path."""
19
+ return storage.list_courses(base_path)
20
+
21
+
22
+ def get_course(base_path: Path, slug: str) -> Path:
23
+ """Get or create a course directory with standard subdirs."""
24
+ return storage.get_course_dir(base_path, slug)
25
+
26
+
27
+ def slugify_title(title: str) -> str:
28
+ """Convert a book/course title to a filesystem-safe slug."""
29
+ return storage.slugify(title)
30
+
31
+
32
+ def get_metadata(course_dir: Path) -> dict:
33
+ """Load course metadata (notebook IDs, syllabus state, generation history)."""
34
+ return storage.load_course_metadata(course_dir)
35
+
36
+
37
+ def save_metadata(course_dir: Path, metadata: dict) -> None:
38
+ """Save course metadata atomically."""
39
+ storage.save_course_metadata(course_dir, metadata)
@@ -0,0 +1,127 @@
1
+ """Service layer for review operations.
2
+
3
+ Thin wrapper functions that delegate to :mod:`studyctl.review_db` and
4
+ :mod:`studyctl.review_loader`. This module is the bridge between
5
+ consumer interfaces (CLI, web, MCP) and the data layer.
6
+
7
+ Rules enforced by design:
8
+ - NO framework imports (no click, no fastapi, no textual).
9
+ - All functions are pure delegation with minimal orchestration.
10
+ - Type annotations on every public function.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import TYPE_CHECKING
16
+
17
+ from studyctl import review_db, review_loader
18
+
19
+ if TYPE_CHECKING:
20
+ from pathlib import Path
21
+
22
+ from studyctl.review_db import CardProgress
23
+ from studyctl.review_loader import Flashcard, QuizQuestion
24
+
25
+
26
+ def get_cards(
27
+ course: str,
28
+ directory: Path,
29
+ ) -> tuple[list[Flashcard], list[QuizQuestion]]:
30
+ """Load flashcards and quiz questions for a course directory.
31
+
32
+ Uses :func:`review_loader.find_content_dirs` to locate the
33
+ ``flashcards/`` and ``quizzes/`` sub-directories, then delegates
34
+ to :func:`review_loader.load_flashcards` and
35
+ :func:`review_loader.load_quizzes`.
36
+
37
+ Args:
38
+ course: Course identifier (used for logging context, not filtering).
39
+ directory: Root directory that contains flashcard/quiz content.
40
+
41
+ Returns:
42
+ A tuple of (flashcards, quiz_questions). Either list may be
43
+ empty if no content is found.
44
+ """
45
+ fc_dir, quiz_dir = review_loader.find_content_dirs(directory)
46
+
47
+ flashcards: list[Flashcard] = []
48
+ quizzes: list[QuizQuestion] = []
49
+
50
+ if fc_dir is not None:
51
+ flashcards = review_loader.load_flashcards(fc_dir)
52
+ if quiz_dir is not None:
53
+ quizzes = review_loader.load_quizzes(quiz_dir)
54
+
55
+ return flashcards, quizzes
56
+
57
+
58
+ def record_review(
59
+ course: str,
60
+ card_type: str,
61
+ card_hash: str,
62
+ correct: bool,
63
+ response_time_ms: int | None = None,
64
+ ) -> None:
65
+ """Record a single card review result.
66
+
67
+ Delegates to :func:`review_db.record_card_review` which handles
68
+ SM-2 interval calculation and persistence.
69
+
70
+ Args:
71
+ course: Course identifier.
72
+ card_type: Either ``"flashcard"`` or ``"quiz"``.
73
+ card_hash: Stable hash identifying the card.
74
+ correct: Whether the answer was correct.
75
+ response_time_ms: Optional response time in milliseconds.
76
+ """
77
+ review_db.record_card_review(
78
+ course=course,
79
+ card_type=card_type,
80
+ card_hash=card_hash,
81
+ correct=correct,
82
+ response_time_ms=response_time_ms,
83
+ )
84
+
85
+
86
+ def get_stats(course: str) -> dict:
87
+ """Get summary statistics for a course.
88
+
89
+ Returns a dict with keys: ``total_reviews``, ``unique_cards``,
90
+ ``due_today``, ``mastered``.
91
+
92
+ Args:
93
+ course: Course identifier.
94
+
95
+ Returns:
96
+ Statistics dictionary. Returns zeroed stats if no reviews exist.
97
+ """
98
+ return review_db.get_course_stats(course=course)
99
+
100
+
101
+ def get_due(course: str) -> list[CardProgress]:
102
+ """Get cards that are due for spaced-repetition review.
103
+
104
+ Args:
105
+ course: Course identifier.
106
+
107
+ Returns:
108
+ List of :class:`~studyctl.review_db.CardProgress` entries
109
+ whose ``next_review`` date is today or earlier, ordered by
110
+ earliest due first.
111
+ """
112
+ return review_db.get_due_cards(course=course)
113
+
114
+
115
+ def get_wrong(course: str) -> set[str]:
116
+ """Get card hashes that were answered incorrectly in the most recent session.
117
+
118
+ Useful for "retry wrong answers" flows where the consumer wants to
119
+ re-present only the cards the learner got wrong.
120
+
121
+ Args:
122
+ course: Course identifier.
123
+
124
+ Returns:
125
+ Set of card hash strings. Empty set if no sessions exist.
126
+ """
127
+ return review_db.get_wrong_hashes(course=course)