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/maintenance.py
ADDED
|
@@ -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}
|
studyctl/mcp/__init__.py
ADDED
|
@@ -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""
|
|
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
|