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,69 @@
1
+ """NotebookLM notebook maintenance — dedup, cleanup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ from collections import defaultdict
8
+
9
+
10
+ def _run_nlm(args: list[str], check: bool = True) -> subprocess.CompletedProcess:
11
+ return subprocess.run(["notebooklm", *args], capture_output=True, text=True, check=check)
12
+
13
+
14
+ def find_duplicates(notebook_id: str) -> dict[str, list[dict]]:
15
+ """Find duplicate sources by title in a notebook. Returns {title: [sources]}."""
16
+ result = _run_nlm(["source", "list", "--notebook", notebook_id, "--json"])
17
+ try:
18
+ sources = json.loads(result.stdout).get("sources", [])
19
+ except json.JSONDecodeError:
20
+ import sys
21
+
22
+ print(f"[studyctl] Failed to parse source list: {result.stdout[:200]}", file=sys.stderr)
23
+ return {}
24
+
25
+ by_title: dict[str, list[dict]] = defaultdict(list)
26
+ for s in sources:
27
+ by_title[s["title"]].append(s)
28
+
29
+ return {t: srcs for t, srcs in by_title.items() if len(srcs) > 1}
30
+
31
+
32
+ def dedup_notebook(notebook_id: str, dry_run: bool = False) -> dict:
33
+ """Remove TRUE duplicates (same title, same source origin).
34
+
35
+ For legitimate duplicates (same filename from different dirs),
36
+ we keep all of them — the sync engine should have given them
37
+ unique names. This only removes exact duplicates from re-syncing.
38
+
39
+ Strategy: group by title. If >2 copies, keep 2 (could be legit
40
+ from different dirs). If all have identical source metadata,
41
+ keep only 1.
42
+ """
43
+ result = _run_nlm(["source", "list", "--notebook", notebook_id, "--json"])
44
+ try:
45
+ sources = json.loads(result.stdout).get("sources", [])
46
+ except json.JSONDecodeError:
47
+ import sys
48
+
49
+ print(f"[studyctl] Failed to parse source list: {result.stdout[:200]}", file=sys.stderr)
50
+ return {"duplicates": 0, "removed": 0, "titles": []}
51
+
52
+ by_title: dict[str, list[dict]] = defaultdict(list)
53
+ for s in sources:
54
+ by_title[s["title"]].append(s)
55
+
56
+ removed = 0
57
+ titles = []
58
+ for title, srcs in by_title.items():
59
+ if len(srcs) <= 1:
60
+ continue
61
+ # Keep the last one, delete earlier duplicates
62
+ to_delete = srcs[:-1]
63
+ for s in to_delete:
64
+ if not dry_run:
65
+ _run_nlm(["source", "delete", s["id"], "-n", notebook_id, "-y"], check=False)
66
+ removed += 1
67
+ titles.append(title)
68
+
69
+ return {"duplicates": removed, "removed": removed, "titles": titles}
@@ -0,0 +1 @@
1
+ """MCP server for studyctl — exposes study tools to AI coding assistants."""
studyctl/mcp/server.py ADDED
@@ -0,0 +1,58 @@
1
+ """FastMCP v1 server for studyctl.
2
+
3
+ Provides study tools to AI coding assistants via stdio transport.
4
+ Register with: ``claude mcp add studyctl-mcp``
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sqlite3
10
+ from contextlib import asynccontextmanager
11
+ from dataclasses import dataclass
12
+
13
+ from mcp.server.fastmcp import FastMCP
14
+
15
+ from studyctl.settings import Settings, get_db_path, load_settings
16
+
17
+
18
+ @dataclass
19
+ class AppState:
20
+ """Shared state available to all tools via server context."""
21
+
22
+ db: sqlite3.Connection
23
+ settings: Settings
24
+
25
+
26
+ @asynccontextmanager
27
+ async def lifespan(server: FastMCP):
28
+ """Initialize shared DB connection and settings for tool lifetime."""
29
+ db_path = get_db_path()
30
+ db_path.parent.mkdir(parents=True, exist_ok=True)
31
+ db = sqlite3.connect(db_path)
32
+ db.execute("PRAGMA journal_mode=WAL")
33
+ db.execute("PRAGMA busy_timeout=5000")
34
+
35
+ from studyctl.review_db import ensure_tables
36
+
37
+ ensure_tables(db_path)
38
+
39
+ settings = load_settings()
40
+ yield AppState(db=db, settings=settings)
41
+ db.close()
42
+
43
+
44
+ mcp = FastMCP("studyctl", lifespan=lifespan)
45
+
46
+ # Register tools from tools module
47
+ from studyctl.mcp.tools import register_tools # noqa: E402
48
+
49
+ register_tools(mcp)
50
+
51
+
52
+ def main() -> None:
53
+ """Entry point for studyctl-mcp command."""
54
+ mcp.run(transport="stdio")
55
+
56
+
57
+ if __name__ == "__main__":
58
+ main()
studyctl/mcp/tools.py ADDED
@@ -0,0 +1,234 @@
1
+ """MCP tool implementations for studyctl.
2
+
3
+ Each tool is registered via ``register_tools(mcp)`` and uses the
4
+ lifespan AppState for shared DB/settings access.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from mcp.server.fastmcp import FastMCP # noqa: TC002 — used at runtime as param type
15
+ from mcp.server.fastmcp.exceptions import ToolError
16
+
17
+ from studyctl.review_db import (
18
+ get_course_stats,
19
+ get_due_cards,
20
+ record_card_review,
21
+ )
22
+ from studyctl.review_loader import (
23
+ discover_directories,
24
+ find_content_dirs,
25
+ load_flashcards,
26
+ load_quizzes,
27
+ )
28
+ from studyctl.settings import load_settings
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ def register_tools(mcp: FastMCP) -> None:
34
+ """Register all studyctl MCP tools on the server."""
35
+
36
+ @mcp.tool()
37
+ def list_courses() -> dict[str, Any]:
38
+ """List all available study courses with card counts and review stats.
39
+
40
+ Returns courses discovered from the review.directories config.
41
+ Each course has: name, card_count, quiz_count, due_count.
42
+ """
43
+ raw_config = {}
44
+ config_path = Path.home() / ".config" / "studyctl" / "config.yaml"
45
+ if config_path.exists():
46
+ import yaml
47
+
48
+ raw_config = yaml.safe_load(config_path.read_text()) or {}
49
+ study_dirs = raw_config.get("review", {}).get("directories", [])
50
+
51
+ courses = discover_directories(study_dirs)
52
+ result = []
53
+ for name, path in courses:
54
+ fc_dir, quiz_dir = find_content_dirs(path)
55
+ fc_count = len(load_flashcards(fc_dir)) if fc_dir else 0
56
+ quiz_count = len(load_quizzes(quiz_dir)) if quiz_dir else 0
57
+ due = len(get_due_cards(name))
58
+ result.append(
59
+ {
60
+ "name": name,
61
+ "card_count": fc_count,
62
+ "quiz_count": quiz_count,
63
+ "due_count": due,
64
+ }
65
+ )
66
+ return {"courses": result}
67
+
68
+ @mcp.tool()
69
+ def get_study_context(course: str) -> dict[str, Any]:
70
+ """Get current study state for a course — due cards, stats, weak areas.
71
+
72
+ Use this to understand where the student is before starting a session.
73
+
74
+ Args:
75
+ course: Course name (as returned by list_courses).
76
+ """
77
+ stats = get_course_stats(course)
78
+ due = get_due_cards(course)
79
+ return {
80
+ "due_cards": len(due),
81
+ "total_reviews": stats.get("total_reviews", 0),
82
+ "unique_cards": stats.get("unique_cards", 0),
83
+ "mastered": stats.get("mastered", 0),
84
+ "due_today": stats.get("due_today", 0),
85
+ }
86
+
87
+ @mcp.tool()
88
+ def record_study_progress(course: str, card_hash: str, correct: bool) -> dict[str, str]:
89
+ """Record a review result for a single card.
90
+
91
+ Args:
92
+ course: Course name.
93
+ card_hash: The card's hash identifier.
94
+ correct: Whether the student answered correctly.
95
+ """
96
+ record_card_review(
97
+ course=course,
98
+ card_type="flashcard",
99
+ card_hash=card_hash,
100
+ correct=correct,
101
+ )
102
+ return {"status": "recorded"}
103
+
104
+ @mcp.tool()
105
+ def generate_flashcards(course: str, chapter: int, content: str) -> dict[str, Any]:
106
+ """Save agent-generated flashcards to a course directory.
107
+
108
+ The content parameter should be a JSON string with the flashcard data:
109
+ {"title": "Chapter N", "cards": [{"front": "...", "back": "..."}, ...]}
110
+
111
+ Validates the JSON structure before writing.
112
+
113
+ Args:
114
+ course: Course slug (directory name under content.base_path).
115
+ chapter: Chapter number (used in filename).
116
+ content: JSON string with flashcard data.
117
+ """
118
+ try:
119
+ data = json.loads(content)
120
+ except json.JSONDecodeError as exc:
121
+ raise ToolError(f"Invalid JSON: {exc}") from exc
122
+
123
+ # Validate structure
124
+ if not isinstance(data, dict) or "cards" not in data:
125
+ raise ToolError("JSON must have a 'cards' array")
126
+ if not isinstance(data["cards"], list):
127
+ raise ToolError("'cards' must be a list")
128
+ for i, card in enumerate(data["cards"]):
129
+ if not isinstance(card, dict):
130
+ raise ToolError(f"Card {i} must be an object")
131
+ if "front" not in card or "back" not in card:
132
+ raise ToolError(f"Card {i} missing 'front' or 'back'")
133
+
134
+ settings = load_settings()
135
+ base = settings.content.base_path
136
+ course_dir = base / course / "flashcards"
137
+ course_dir.mkdir(parents=True, exist_ok=True)
138
+
139
+ filename = f"ch{chapter:02d}-flashcards.json"
140
+ path = course_dir / filename
141
+ path.write_text(json.dumps(data, indent=2))
142
+ logger.info("Wrote %d flashcards to %s", len(data["cards"]), path)
143
+ return {"path": str(path), "count": len(data["cards"])}
144
+
145
+ @mcp.tool()
146
+ def generate_quiz(course: str, chapter: int, content: str) -> dict[str, Any]:
147
+ """Save agent-generated quiz questions to a course directory.
148
+
149
+ The content parameter should be a JSON string with quiz data:
150
+ {"title": "Chapter N Quiz", "questions": [{"question": "...",
151
+ "answerOptions": [{"text": "...", "isCorrect": true}, ...]}]}
152
+
153
+ Validates the JSON structure before writing.
154
+
155
+ Args:
156
+ course: Course slug (directory name under content.base_path).
157
+ chapter: Chapter number (used in filename).
158
+ content: JSON string with quiz data.
159
+ """
160
+ try:
161
+ data = json.loads(content)
162
+ except json.JSONDecodeError as exc:
163
+ raise ToolError(f"Invalid JSON: {exc}") from exc
164
+
165
+ if not isinstance(data, dict) or "questions" not in data:
166
+ raise ToolError("JSON must have a 'questions' array")
167
+ if not isinstance(data["questions"], list):
168
+ raise ToolError("'questions' must be a list")
169
+ for i, q in enumerate(data["questions"]):
170
+ if not isinstance(q, dict):
171
+ raise ToolError(f"Question {i} must be an object")
172
+ if "question" not in q:
173
+ raise ToolError(f"Question {i} missing 'question' field")
174
+ if "answerOptions" not in q:
175
+ raise ToolError(f"Question {i} missing 'answerOptions'")
176
+
177
+ settings = load_settings()
178
+ base = settings.content.base_path
179
+ course_dir = base / course / "quizzes"
180
+ course_dir.mkdir(parents=True, exist_ok=True)
181
+
182
+ filename = f"ch{chapter:02d}-quiz.json"
183
+ path = course_dir / filename
184
+ path.write_text(json.dumps(data, indent=2))
185
+ logger.info("Wrote %d questions to %s", len(data["questions"]), path)
186
+ return {"path": str(path), "count": len(data["questions"])}
187
+
188
+ @mcp.tool()
189
+ def get_chapter_text(course: str, chapter: int) -> dict[str, str]:
190
+ """Extract text from a chapter PDF for LLM processing.
191
+
192
+ Requires pymupdf. Returns the chapter title and full text content.
193
+
194
+ Args:
195
+ course: Course slug.
196
+ chapter: Chapter number (1-indexed).
197
+ """
198
+ try:
199
+ import pymupdf
200
+ except ImportError:
201
+ raise ToolError(
202
+ "pymupdf not installed. Install with: uv pip install 'studyctl[content]'"
203
+ ) from None
204
+
205
+ settings = load_settings()
206
+ chapters_dir = settings.content.base_path / course / "chapters"
207
+ if not chapters_dir.is_dir():
208
+ raise ToolError(
209
+ f"No chapters directory for course '{course}'. Run 'studyctl content split' first."
210
+ )
211
+
212
+ # Find chapter PDF by number prefix
213
+ pattern = f"*ch{chapter:02d}*" if chapter < 100 else f"*{chapter}*"
214
+ matches = sorted(chapters_dir.glob(f"{pattern}.pdf"))
215
+ if not matches:
216
+ # Try broader match
217
+ all_pdfs = sorted(chapters_dir.glob("*.pdf"))
218
+ if chapter <= len(all_pdfs):
219
+ matches = [all_pdfs[chapter - 1]]
220
+ else:
221
+ raise ToolError(
222
+ f"Chapter {chapter} not found in {chapters_dir}. "
223
+ f"Available: {len(all_pdfs)} PDFs."
224
+ )
225
+
226
+ pdf_path = matches[0]
227
+ doc = pymupdf.open(str(pdf_path))
228
+ text = ""
229
+ for page in doc:
230
+ text += page.get_text()
231
+ doc.close()
232
+
233
+ title = pdf_path.stem.replace("_", " ").replace("-", " ").title()
234
+ return {"title": title, "text": text}
studyctl/pdf.py ADDED
@@ -0,0 +1,89 @@
1
+ """Markdown → PDF conversion with mermaid diagram rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ MERMAID_BLOCK = re.compile(r"```mermaid\s*\n(.*?)```", re.DOTALL)
11
+
12
+
13
+ def _render_mermaid(code: str, output_path: Path) -> bool:
14
+ """Render a mermaid code block to PNG."""
15
+ with tempfile.NamedTemporaryFile(suffix=".mmd", mode="w", delete=False) as f:
16
+ f.write(code)
17
+ mmd_path = f.name
18
+ result = subprocess.run(
19
+ [
20
+ "npx",
21
+ "-y",
22
+ "@mermaid-js/mermaid-cli",
23
+ "-i",
24
+ mmd_path,
25
+ "-o",
26
+ str(output_path),
27
+ "-b",
28
+ "white",
29
+ "-q",
30
+ ],
31
+ capture_output=True,
32
+ text=True,
33
+ )
34
+ Path(mmd_path).unlink(missing_ok=True)
35
+ return result.returncode == 0 and output_path.exists()
36
+
37
+
38
+ def _preprocess_mermaid(md_content: str, work_dir: Path) -> str:
39
+ """Replace mermaid code blocks with rendered PNG image references."""
40
+ counter = 0
41
+
42
+ def replace_block(match: re.Match) -> str:
43
+ nonlocal counter
44
+ counter += 1
45
+ code = match.group(1).strip()
46
+ png_path = work_dir / f"mermaid_{counter}.png"
47
+ if _render_mermaid(code, png_path):
48
+ return f"![diagram]({png_path})"
49
+ # Fallback: keep as code block if rendering fails
50
+ return match.group(0)
51
+
52
+ return MERMAID_BLOCK.sub(replace_block, md_content)
53
+
54
+
55
+ def md_to_pdf(md_path: Path, pdf_dir: Path, unique_name: str | None = None) -> Path | None:
56
+ """Convert markdown to PDF, rendering mermaid diagrams as images."""
57
+ stem = unique_name or md_path.stem
58
+ pdf_path = pdf_dir / (stem + ".pdf")
59
+ content = md_path.read_text()
60
+
61
+ has_mermaid = "```mermaid" in content
62
+
63
+ with tempfile.TemporaryDirectory(prefix="studyctl-mermaid-") as mermaid_dir:
64
+ if has_mermaid:
65
+ processed = _preprocess_mermaid(content, Path(mermaid_dir))
66
+ # Write processed markdown to temp file
67
+ tmp_md = Path(mermaid_dir) / md_path.name
68
+ tmp_md.write_text(processed)
69
+ source = tmp_md
70
+ else:
71
+ source = md_path
72
+
73
+ result = subprocess.run(
74
+ [
75
+ "pandoc",
76
+ str(source),
77
+ "-o",
78
+ str(pdf_path),
79
+ "--pdf-engine=xelatex",
80
+ "-V",
81
+ "geometry:margin=1in",
82
+ ],
83
+ capture_output=True,
84
+ text=True,
85
+ )
86
+
87
+ if result.returncode == 0 and pdf_path.exists():
88
+ return pdf_path
89
+ return None