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/cli/_content.py
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""Content commands -- PDF splitting, NotebookLM integration, syllabus workflow.
|
|
2
|
+
|
|
3
|
+
Absorbed from pdf-by-chapters. All commands are under ``studyctl content``.
|
|
4
|
+
Heavy imports (pymupdf, notebooklm-py) are deferred to function bodies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _resolve_pdfs(source: Path) -> list[Path]:
|
|
20
|
+
"""Resolve source to a list of PDF paths (single file or directory glob)."""
|
|
21
|
+
if source.is_dir():
|
|
22
|
+
pdfs = sorted(source.glob("*.pdf"))
|
|
23
|
+
if not pdfs:
|
|
24
|
+
raise click.ClickException(f"No PDF files found in {source}")
|
|
25
|
+
return pdfs
|
|
26
|
+
if not source.is_file():
|
|
27
|
+
raise click.ClickException(f"'{source}' does not exist")
|
|
28
|
+
return [source]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _get_notebook_id(notebook_id: str | None) -> str:
|
|
32
|
+
"""Resolve notebook ID from option or raise."""
|
|
33
|
+
if not notebook_id:
|
|
34
|
+
raise click.ClickException(
|
|
35
|
+
"No notebook ID. Use -n/--notebook-id or set NOTEBOOK_ID env var."
|
|
36
|
+
)
|
|
37
|
+
return notebook_id
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_chapter_range(raw: str) -> tuple[int, int]:
|
|
41
|
+
"""Parse a chapter range string like '1-3' into (start, end)."""
|
|
42
|
+
try:
|
|
43
|
+
parts = raw.split("-")
|
|
44
|
+
start, end = int(parts[0]), int(parts[1])
|
|
45
|
+
except (ValueError, IndexError):
|
|
46
|
+
raise click.ClickException(f"Invalid chapter range '{raw}'. Use format: 1-3") from None
|
|
47
|
+
if start < 1 or end < start:
|
|
48
|
+
raise click.ClickException(
|
|
49
|
+
f"Invalid range: start must be >= 1 and <= end (got {start}-{end})"
|
|
50
|
+
)
|
|
51
|
+
return (start, end)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@click.group(name="content")
|
|
55
|
+
def content_group() -> None:
|
|
56
|
+
"""Content pipeline -- PDF splitting, NotebookLM, and syllabus workflow."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Core commands
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@content_group.command()
|
|
65
|
+
@click.argument("source", type=click.Path(exists=True, path_type=Path))
|
|
66
|
+
@click.option("-o", "--output-dir", type=click.Path(path_type=Path), default="./chapters")
|
|
67
|
+
@click.option("-l", "--level", default=1, help="TOC level to split on (1=top-level).")
|
|
68
|
+
@click.option("--ranges", default=None, help="Page ranges for PDFs without TOC, e.g. '1-30,31-60'.")
|
|
69
|
+
def split(source: Path, output_dir: Path, level: int, ranges: str | None) -> None:
|
|
70
|
+
"""Split a PDF into per-chapter files by TOC bookmarks."""
|
|
71
|
+
from studyctl.content.splitter import (
|
|
72
|
+
sanitize_filename,
|
|
73
|
+
split_pdf_by_chapters,
|
|
74
|
+
split_pdf_by_ranges,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
for pdf_path in _resolve_pdfs(source):
|
|
78
|
+
book_name = sanitize_filename(pdf_path.stem)
|
|
79
|
+
if ranges:
|
|
80
|
+
paths = split_pdf_by_ranges(pdf_path, output_dir, book_name, ranges)
|
|
81
|
+
else:
|
|
82
|
+
paths = split_pdf_by_chapters(pdf_path, output_dir, book_name, level=level)
|
|
83
|
+
console.print(f"[green]\u2713[/green] {len(paths)} chapters written to {output_dir}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@content_group.command()
|
|
87
|
+
@click.argument("source", type=click.Path(exists=True, path_type=Path))
|
|
88
|
+
@click.option("-o", "--output-dir", type=click.Path(path_type=Path), default="./chapters")
|
|
89
|
+
@click.option("-l", "--level", default=1, help="TOC level to split on.")
|
|
90
|
+
@click.option("-n", "--notebook-id", envvar="NOTEBOOK_ID", default=None)
|
|
91
|
+
def process(source: Path, output_dir: Path, level: int, notebook_id: str | None) -> None:
|
|
92
|
+
"""Split PDFs by chapter, upload to NotebookLM, and show summary."""
|
|
93
|
+
from studyctl.content.notebooklm_client import upload_chapters
|
|
94
|
+
from studyctl.content.splitter import sanitize_filename, split_pdf_by_chapters
|
|
95
|
+
|
|
96
|
+
for pdf_path in _resolve_pdfs(source):
|
|
97
|
+
book_name = sanitize_filename(pdf_path.stem)
|
|
98
|
+
chapter_paths = split_pdf_by_chapters(pdf_path, output_dir, book_name, level=level)
|
|
99
|
+
console.print(f"[green]\u2713[/green] Split into {len(chapter_paths)} chapters")
|
|
100
|
+
|
|
101
|
+
nid = _get_notebook_id(notebook_id)
|
|
102
|
+
result = asyncio.run(upload_chapters(nid, chapter_paths))
|
|
103
|
+
console.print(
|
|
104
|
+
f"[green]\u2713[/green] Uploaded {result.chapters} chapters "
|
|
105
|
+
f"to notebook {result.id[:8]}..."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@content_group.command("list")
|
|
110
|
+
@click.option("-n", "--notebook-id", envvar="NOTEBOOK_ID", default=None)
|
|
111
|
+
def list_cmd(notebook_id: str | None) -> None:
|
|
112
|
+
"""List notebooks, or sources within a notebook."""
|
|
113
|
+
from studyctl.content.notebooklm_client import list_notebooks, list_sources
|
|
114
|
+
|
|
115
|
+
if notebook_id:
|
|
116
|
+
sources = asyncio.run(list_sources(notebook_id))
|
|
117
|
+
table = Table(title=f"Sources in {notebook_id[:8]}...")
|
|
118
|
+
table.add_column("ID", style="dim")
|
|
119
|
+
table.add_column("Title")
|
|
120
|
+
for s in sources:
|
|
121
|
+
table.add_row(s.id[:8] + "...", s.title)
|
|
122
|
+
console.print(table)
|
|
123
|
+
else:
|
|
124
|
+
notebooks = asyncio.run(list_notebooks())
|
|
125
|
+
table = Table(title="NotebookLM Notebooks")
|
|
126
|
+
table.add_column("ID", style="dim")
|
|
127
|
+
table.add_column("Title", style="bold")
|
|
128
|
+
table.add_column("Sources", justify="right")
|
|
129
|
+
for nb in notebooks:
|
|
130
|
+
table.add_row(nb.id[:8] + "...", nb.title, str(nb.sources_count))
|
|
131
|
+
console.print(table)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@content_group.command()
|
|
135
|
+
@click.option("-n", "--notebook-id", envvar="NOTEBOOK_ID", required=True)
|
|
136
|
+
@click.option("-c", "--chapters", required=True, help="Chapter range, e.g. '1-3'.")
|
|
137
|
+
@click.option("--no-audio", is_flag=True, help="Skip audio generation.")
|
|
138
|
+
@click.option("--no-video", is_flag=True, help="Skip video generation.")
|
|
139
|
+
@click.option("-t", "--timeout", default=900, help="Timeout in seconds (default: 900).")
|
|
140
|
+
def generate(
|
|
141
|
+
notebook_id: str,
|
|
142
|
+
chapters: str,
|
|
143
|
+
no_audio: bool,
|
|
144
|
+
no_video: bool,
|
|
145
|
+
timeout: int,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Generate audio/video overviews for a chapter range."""
|
|
148
|
+
from studyctl.content.notebooklm_client import generate_for_chapters
|
|
149
|
+
|
|
150
|
+
start, end = _parse_chapter_range(chapters)
|
|
151
|
+
types = []
|
|
152
|
+
if not no_audio:
|
|
153
|
+
types.append("audio")
|
|
154
|
+
if not no_video:
|
|
155
|
+
types.append("video")
|
|
156
|
+
|
|
157
|
+
if not types:
|
|
158
|
+
raise click.ClickException("Nothing to generate (both audio and video disabled).")
|
|
159
|
+
|
|
160
|
+
console.print(f"Generating {', '.join(types)} for chapters {start}-{end}...")
|
|
161
|
+
asyncio.run(generate_for_chapters(notebook_id, start, end, types=types, timeout=timeout))
|
|
162
|
+
console.print("[green]\u2713[/green] Generation complete")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@content_group.command()
|
|
166
|
+
@click.option("-n", "--notebook-id", envvar="NOTEBOOK_ID", required=True)
|
|
167
|
+
@click.option("-o", "--output-dir", type=click.Path(path_type=Path), default="./overviews")
|
|
168
|
+
@click.option("-c", "--chapters", default=None, help="Chapter range label for filenames.")
|
|
169
|
+
def download(notebook_id: str, output_dir: Path, chapters: str | None) -> None:
|
|
170
|
+
"""Download audio and video artifacts from a notebook."""
|
|
171
|
+
from studyctl.content.notebooklm_client import download_artifacts
|
|
172
|
+
|
|
173
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
paths = asyncio.run(download_artifacts(notebook_id, output_dir, chapters))
|
|
175
|
+
for p in paths:
|
|
176
|
+
console.print(f"[green]\u2713[/green] {p.name}")
|
|
177
|
+
if not paths:
|
|
178
|
+
console.print("[dim]No artifacts to download[/dim]")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@content_group.command("delete")
|
|
182
|
+
@click.option("-n", "--notebook-id", envvar="NOTEBOOK_ID", required=True)
|
|
183
|
+
@click.confirmation_option(prompt="Are you sure you want to delete this notebook?")
|
|
184
|
+
def delete_cmd(notebook_id: str) -> None:
|
|
185
|
+
"""Delete a notebook and all its contents."""
|
|
186
|
+
from studyctl.content.notebooklm_client import delete_notebook
|
|
187
|
+
|
|
188
|
+
asyncio.run(delete_notebook(notebook_id))
|
|
189
|
+
console.print(f"[green]\u2713[/green] Deleted notebook {notebook_id[:8]}...")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
# Syllabus workflow
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@content_group.command()
|
|
198
|
+
@click.option("-n", "--notebook-id", envvar="NOTEBOOK_ID", required=True)
|
|
199
|
+
@click.option("-o", "--output-dir", type=click.Path(path_type=Path), default="./chapters")
|
|
200
|
+
@click.option("-m", "--max-chapters", default=2, help="Max chapters per episode.")
|
|
201
|
+
@click.option("-b", "--book-name", default=None, help="Book name for state file.")
|
|
202
|
+
@click.option("--force", is_flag=True, help="Overwrite existing syllabus.")
|
|
203
|
+
@click.option("--no-audio", is_flag=True, help="Skip audio generation.")
|
|
204
|
+
@click.option("--no-video", is_flag=True, help="Skip video generation.")
|
|
205
|
+
def syllabus(
|
|
206
|
+
notebook_id: str,
|
|
207
|
+
output_dir: Path,
|
|
208
|
+
max_chapters: int,
|
|
209
|
+
book_name: str | None,
|
|
210
|
+
force: bool,
|
|
211
|
+
no_audio: bool,
|
|
212
|
+
no_video: bool,
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Generate a podcast syllabus and save as a plan."""
|
|
215
|
+
from studyctl.content.notebooklm_client import create_syllabus, list_sources
|
|
216
|
+
from studyctl.content.syllabus import (
|
|
217
|
+
build_fixed_size_chunks,
|
|
218
|
+
build_prompt,
|
|
219
|
+
has_non_pending_chunks,
|
|
220
|
+
map_sources_to_chapters,
|
|
221
|
+
parse_syllabus_response,
|
|
222
|
+
read_state,
|
|
223
|
+
write_state,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
resolved_book_name = book_name or output_dir.resolve().name
|
|
227
|
+
state_path = output_dir / f".{resolved_book_name}-syllabus.json"
|
|
228
|
+
|
|
229
|
+
# Check for existing state
|
|
230
|
+
existing = read_state(state_path)
|
|
231
|
+
if existing and has_non_pending_chunks(existing) and not force:
|
|
232
|
+
console.print(
|
|
233
|
+
"[yellow]Syllabus already exists with in-progress chunks.[/yellow]\n"
|
|
234
|
+
"Use --force to overwrite."
|
|
235
|
+
)
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
# Get sources and build chunks
|
|
239
|
+
sources = asyncio.run(list_sources(notebook_id))
|
|
240
|
+
source_map = map_sources_to_chapters(sources)
|
|
241
|
+
chunks = build_fixed_size_chunks(source_map, max_chapters=max_chapters)
|
|
242
|
+
|
|
243
|
+
# Generate syllabus via NotebookLM chat
|
|
244
|
+
prompt = build_prompt(chunks)
|
|
245
|
+
console.print(f"Generating syllabus for {len(chunks)} episodes...")
|
|
246
|
+
response = asyncio.run(create_syllabus(notebook_id, prompt))
|
|
247
|
+
state = parse_syllabus_response(response, chunks, resolved_book_name)
|
|
248
|
+
|
|
249
|
+
# Configure artifact types
|
|
250
|
+
types = []
|
|
251
|
+
if not no_audio:
|
|
252
|
+
types.append("audio")
|
|
253
|
+
if not no_video:
|
|
254
|
+
types.append("video")
|
|
255
|
+
state.artifact_types = types
|
|
256
|
+
|
|
257
|
+
write_state(state, state_path)
|
|
258
|
+
|
|
259
|
+
# Display syllabus table
|
|
260
|
+
table = Table(title=f"Syllabus: {resolved_book_name}")
|
|
261
|
+
table.add_column("#", justify="right", style="dim")
|
|
262
|
+
table.add_column("Title", style="bold")
|
|
263
|
+
table.add_column("Chapters", style="cyan")
|
|
264
|
+
table.add_column("Status")
|
|
265
|
+
for chunk in state.chunks.values():
|
|
266
|
+
ch_str = ", ".join(str(c) for c in chunk.chapters)
|
|
267
|
+
table.add_row(str(chunk.episode), chunk.title, ch_str, chunk.status.value)
|
|
268
|
+
console.print(table)
|
|
269
|
+
console.print(f"\nState saved to {state_path}")
|
|
270
|
+
console.print(f"Next: Run [bold]studyctl content autopilot -o {output_dir}[/bold]")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@content_group.command("autopilot")
|
|
274
|
+
@click.option("-o", "--output-dir", type=click.Path(path_type=Path), default="./chapters")
|
|
275
|
+
@click.option("-b", "--book-name", default=None, help="Book name for state file.")
|
|
276
|
+
@click.option("-t", "--timeout", default=900, help="Timeout per episode in seconds.")
|
|
277
|
+
def autopilot(output_dir: Path, book_name: str | None, timeout: int) -> None:
|
|
278
|
+
"""Generate the next pending episode from the syllabus."""
|
|
279
|
+
from studyctl.content.notebooklm_client import (
|
|
280
|
+
download_episode_audio,
|
|
281
|
+
start_chunk_generation,
|
|
282
|
+
)
|
|
283
|
+
from studyctl.content.syllabus import (
|
|
284
|
+
ChunkStatus,
|
|
285
|
+
get_next_chunk,
|
|
286
|
+
read_state,
|
|
287
|
+
write_state,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
resolved_book_name = book_name or output_dir.resolve().name
|
|
291
|
+
state_path = output_dir / f".{resolved_book_name}-syllabus.json"
|
|
292
|
+
|
|
293
|
+
state = read_state(state_path)
|
|
294
|
+
if not state:
|
|
295
|
+
raise click.ClickException(
|
|
296
|
+
f"No syllabus state found at {state_path}. Run 'studyctl content syllabus' first."
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
chunk = get_next_chunk(state)
|
|
300
|
+
if not chunk:
|
|
301
|
+
console.print("[green]All episodes complete![/green]")
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
console.print(
|
|
305
|
+
f"Episode {chunk.episode}: [bold]{chunk.title}[/bold] "
|
|
306
|
+
f"(chapters {', '.join(str(c) for c in chunk.chapters)})"
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# Start generation
|
|
310
|
+
chunk.status = ChunkStatus.GENERATING
|
|
311
|
+
write_state(state, state_path)
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
asyncio.run(start_chunk_generation(state.notebook_id, chunk, timeout=timeout))
|
|
315
|
+
# Download audio
|
|
316
|
+
downloads_dir = output_dir / "downloads" / "audio"
|
|
317
|
+
downloads_dir.mkdir(parents=True, exist_ok=True)
|
|
318
|
+
asyncio.run(download_episode_audio(state.notebook_id, chunk, downloads_dir))
|
|
319
|
+
chunk.status = ChunkStatus.COMPLETED
|
|
320
|
+
console.print(f"[green]\u2713[/green] Episode {chunk.episode} complete")
|
|
321
|
+
except Exception as exc:
|
|
322
|
+
chunk.status = ChunkStatus.FAILED
|
|
323
|
+
console.print(f"[red]Episode {chunk.episode} failed: {exc}[/red]")
|
|
324
|
+
finally:
|
|
325
|
+
write_state(state, state_path)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@content_group.command("status")
|
|
329
|
+
@click.option("-o", "--output-dir", type=click.Path(path_type=Path), default="./chapters")
|
|
330
|
+
@click.option("-b", "--book-name", default=None, help="Book name for state file.")
|
|
331
|
+
def status_cmd(output_dir: Path, book_name: str | None) -> None:
|
|
332
|
+
"""Show syllabus progress for chunked generation."""
|
|
333
|
+
from studyctl.content.syllabus import read_state
|
|
334
|
+
|
|
335
|
+
resolved_book_name = book_name or output_dir.resolve().name
|
|
336
|
+
state_path = output_dir / f".{resolved_book_name}-syllabus.json"
|
|
337
|
+
|
|
338
|
+
state = read_state(state_path)
|
|
339
|
+
if not state:
|
|
340
|
+
raise click.ClickException(
|
|
341
|
+
f"No syllabus state at {state_path}. Run 'studyctl content syllabus' first."
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
table = Table(title=f"Syllabus: {state.book_name}")
|
|
345
|
+
table.add_column("#", justify="right", style="dim")
|
|
346
|
+
table.add_column("Title", style="bold")
|
|
347
|
+
table.add_column("Chapters", style="cyan")
|
|
348
|
+
table.add_column("Status")
|
|
349
|
+
|
|
350
|
+
status_style = {
|
|
351
|
+
"pending": "dim",
|
|
352
|
+
"generating": "yellow",
|
|
353
|
+
"completed": "green",
|
|
354
|
+
"failed": "red",
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
for chunk in state.chunks.values():
|
|
358
|
+
ch_str = ", ".join(str(c) for c in chunk.chapters)
|
|
359
|
+
style = status_style.get(chunk.status.value, "")
|
|
360
|
+
status_text = f"[{style}]{chunk.status.value}[/{style}]" if style else chunk.status.value
|
|
361
|
+
table.add_row(str(chunk.episode), chunk.title, ch_str, status_text)
|
|
362
|
+
|
|
363
|
+
console.print(table)
|
|
364
|
+
|
|
365
|
+
completed = sum(1 for c in state.chunks.values() if c.status.value == "completed")
|
|
366
|
+
total = len(state.chunks)
|
|
367
|
+
console.print(f"\nProgress: {completed}/{total} episodes complete")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
# Obsidian integration
|
|
372
|
+
# ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@content_group.command("from-obsidian")
|
|
376
|
+
@click.argument("source_dir", type=click.Path(exists=True, path_type=Path))
|
|
377
|
+
@click.option("-o", "--output-dir", type=click.Path(path_type=Path), default=None)
|
|
378
|
+
@click.option("--name", "notebook_name", default=None, help="Notebook name.")
|
|
379
|
+
@click.option("-n", "--notebook-id", envvar="NOTEBOOK_ID", default=None)
|
|
380
|
+
@click.option("--no-generate", is_flag=True, help="Upload only, skip artifact generation.")
|
|
381
|
+
@click.option("--no-audio", is_flag=True, help="Skip audio generation.")
|
|
382
|
+
@click.option("--no-download", is_flag=True, help="Skip artifact download.")
|
|
383
|
+
@click.option("--no-quiz", is_flag=True, help="Skip quiz generation.")
|
|
384
|
+
@click.option("--no-flashcards", is_flag=True, help="Skip flashcard generation.")
|
|
385
|
+
@click.option("--skip-convert", is_flag=True, help="Skip PDF conversion, use existing PDFs.")
|
|
386
|
+
@click.option("-s", "--subdir", default=None, help="Subdirectory within source.")
|
|
387
|
+
def from_obsidian(
|
|
388
|
+
source_dir: Path,
|
|
389
|
+
output_dir: Path | None,
|
|
390
|
+
notebook_name: str | None,
|
|
391
|
+
notebook_id: str | None,
|
|
392
|
+
no_generate: bool,
|
|
393
|
+
no_audio: bool,
|
|
394
|
+
no_download: bool,
|
|
395
|
+
no_quiz: bool,
|
|
396
|
+
no_flashcards: bool,
|
|
397
|
+
skip_convert: bool,
|
|
398
|
+
subdir: str | None,
|
|
399
|
+
) -> None:
|
|
400
|
+
"""Convert Obsidian markdown to PDFs and upload to NotebookLM."""
|
|
401
|
+
from studyctl.content.markdown_converter import convert_directory
|
|
402
|
+
from studyctl.content.notebooklm_client import (
|
|
403
|
+
generate_for_chapters,
|
|
404
|
+
upload_chapters,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
actual_dir = source_dir / subdir if subdir else source_dir
|
|
408
|
+
if not actual_dir.is_dir():
|
|
409
|
+
raise click.ClickException(f"Directory not found: {actual_dir}")
|
|
410
|
+
|
|
411
|
+
out = output_dir or source_dir / "downloads"
|
|
412
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
413
|
+
name = notebook_name or source_dir.name.replace("-", " ").replace("_", " ").title()
|
|
414
|
+
|
|
415
|
+
# Step 1: Convert markdown to PDFs
|
|
416
|
+
if not skip_convert:
|
|
417
|
+
console.print(f"Converting markdown in {actual_dir}...")
|
|
418
|
+
pdf_dir = out / "pdfs"
|
|
419
|
+
convert_directory(actual_dir, pdf_dir)
|
|
420
|
+
console.print(f"[green]\u2713[/green] PDFs written to {pdf_dir}")
|
|
421
|
+
else:
|
|
422
|
+
pdf_dir = out / "pdfs"
|
|
423
|
+
|
|
424
|
+
# Step 2: Upload
|
|
425
|
+
pdf_files = sorted(pdf_dir.glob("*.pdf")) if pdf_dir.is_dir() else []
|
|
426
|
+
if not pdf_files:
|
|
427
|
+
console.print("[yellow]No PDFs to upload[/yellow]")
|
|
428
|
+
return
|
|
429
|
+
|
|
430
|
+
nid = notebook_id
|
|
431
|
+
if not nid:
|
|
432
|
+
console.print(f"Creating notebook: [bold]{name}[/bold]")
|
|
433
|
+
result = asyncio.run(upload_chapters(nid, pdf_files, title=name))
|
|
434
|
+
nid = result.id
|
|
435
|
+
console.print(
|
|
436
|
+
f"[green]\u2713[/green] Uploaded {result.chapters} files to notebook {nid[:8]}..."
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
if no_generate:
|
|
440
|
+
return
|
|
441
|
+
|
|
442
|
+
# Step 3: Generate artifacts
|
|
443
|
+
types = []
|
|
444
|
+
if not no_audio:
|
|
445
|
+
types.append("audio")
|
|
446
|
+
if not no_quiz:
|
|
447
|
+
types.append("quiz")
|
|
448
|
+
if not no_flashcards:
|
|
449
|
+
types.append("flashcards")
|
|
450
|
+
|
|
451
|
+
if types:
|
|
452
|
+
console.print(f"Generating {', '.join(types)}...")
|
|
453
|
+
asyncio.run(generate_for_chapters(nid, 1, len(pdf_files), types=types))
|
|
454
|
+
console.print("[green]\u2713[/green] Generation complete")
|
|
455
|
+
|
|
456
|
+
if not no_download:
|
|
457
|
+
from studyctl.content.notebooklm_client import download_artifacts
|
|
458
|
+
|
|
459
|
+
console.print("Downloading artifacts...")
|
|
460
|
+
paths = asyncio.run(download_artifacts(nid, out))
|
|
461
|
+
for p in paths:
|
|
462
|
+
console.print(f" [green]\u2713[/green] {p.name}")
|
studyctl/cli/_lazy.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""LazyGroup — defers command module imports until invoked.
|
|
2
|
+
|
|
3
|
+
Keeps CLI startup fast even with many command modules. Essential once
|
|
4
|
+
content commands (Phase 1) bring heavy deps like pymupdf.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LazyGroup(click.Group):
|
|
15
|
+
"""Click group that lazy-loads subcommands from dotted import paths."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, *args, lazy_subcommands: dict[str, str] | None = None, **kwargs):
|
|
18
|
+
super().__init__(*args, **kwargs)
|
|
19
|
+
self._lazy_subcommands = lazy_subcommands or {}
|
|
20
|
+
|
|
21
|
+
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
22
|
+
base = super().list_commands(ctx)
|
|
23
|
+
lazy = sorted(self._lazy_subcommands.keys())
|
|
24
|
+
return base + lazy
|
|
25
|
+
|
|
26
|
+
def get_command(self, ctx: click.Context, cmd_name: str) -> click.BaseCommand | None: # type: ignore[override]
|
|
27
|
+
if cmd_name in self._lazy_subcommands:
|
|
28
|
+
return self._resolve(cmd_name)
|
|
29
|
+
return super().get_command(ctx, cmd_name)
|
|
30
|
+
|
|
31
|
+
def _resolve(self, cmd_name: str) -> click.BaseCommand: # type: ignore[return-value]
|
|
32
|
+
import_path = self._lazy_subcommands[cmd_name]
|
|
33
|
+
modname, attr_name = import_path.rsplit(":", 1)
|
|
34
|
+
mod = importlib.import_module(modname)
|
|
35
|
+
return getattr(mod, attr_name)
|