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/cli/_setup.py ADDED
@@ -0,0 +1,164 @@
1
+ """Setup wizard — first-run configuration for studyctl.
2
+
3
+ Guides users through the essential configuration questions one at a time.
4
+ Designed to be AuDHD-friendly: clear steps, no overwhelming walls of text,
5
+ sensible defaults so pressing Enter always works.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import subprocess
12
+ from pathlib import Path
13
+
14
+ import click
15
+ import yaml
16
+
17
+ from studyctl.cli._shared import console
18
+ from studyctl.settings import CONFIG_DIR
19
+
20
+
21
+ def _validate_path(value: str) -> Path:
22
+ """Expand user and return a Path; raise UsageError if obviously invalid."""
23
+ p = Path(value).expanduser()
24
+ return p
25
+
26
+
27
+ @click.command(name="setup")
28
+ def setup() -> None:
29
+ """First-run setup wizard — configure studyctl in under 2 minutes."""
30
+
31
+ # ------------------------------------------------------------------
32
+ # Banner
33
+ # ------------------------------------------------------------------
34
+ console.print()
35
+ console.print("[bold cyan]studyctl setup[/bold cyan]")
36
+ console.print(
37
+ "studyctl is your AuDHD study pipeline: it syncs Obsidian notes to "
38
+ "NotebookLM, tracks spaced-repetition reviews, and schedules focused "
39
+ "study sessions.\n"
40
+ )
41
+ console.print("[dim]Press Enter to accept the default shown in brackets.[/dim]\n")
42
+
43
+ config: dict = {}
44
+
45
+ # ------------------------------------------------------------------
46
+ # Step 1 — Study materials directory
47
+ # ------------------------------------------------------------------
48
+ console.print("[bold]Step 1 of 5[/bold] Where do you store course materials (PDFs, slides)?")
49
+ materials_raw = click.prompt(
50
+ "Study materials path",
51
+ default="~/study-materials",
52
+ )
53
+ materials_path = _validate_path(materials_raw)
54
+ config["content"] = {"base_path": str(materials_raw)}
55
+ console.print(f" [dim]Will use: {materials_path}[/dim]\n")
56
+
57
+ # ------------------------------------------------------------------
58
+ # Step 2 — AI coding assistant / MCP registration
59
+ # ------------------------------------------------------------------
60
+ console.print("[bold]Step 2 of 5[/bold] Do you use an AI coding assistant?")
61
+ console.print(" [dim](Claude Code, Kiro, Gemini CLI, etc.)[/dim]")
62
+ has_ai = click.confirm(" Use an AI assistant?", default=True)
63
+ if has_ai:
64
+ assistant = click.prompt(
65
+ " Which one",
66
+ default="claude-code",
67
+ type=click.Choice(
68
+ ["claude-code", "kiro", "gemini-cli", "other"],
69
+ case_sensitive=False,
70
+ ),
71
+ show_choices=True,
72
+ )
73
+ config["ai_assistant"] = assistant
74
+ console.print(
75
+ f" [dim]Noted. Run 'studyctl config init' to install agent definitions "
76
+ f"for {assistant}.[/dim]\n"
77
+ )
78
+ else:
79
+ console.print(" [dim]No problem — studyctl works fine as a standalone CLI.[/dim]\n")
80
+
81
+ # ------------------------------------------------------------------
82
+ # Step 3 — NotebookLM
83
+ # ------------------------------------------------------------------
84
+ console.print("[bold]Step 3 of 5[/bold] Do you use Google NotebookLM?")
85
+ console.print(" [dim](Optional — enables audio overview generation from your notes)[/dim]")
86
+ use_nlm = click.confirm(" Enable NotebookLM integration?", default=False)
87
+ config["notebooklm"] = {"enabled": use_nlm}
88
+ if use_nlm:
89
+ console.print(
90
+ " [dim]NotebookLM enabled. You'll need a Google account"
91
+ " with NotebookLM access.[/dim]\n"
92
+ )
93
+ else:
94
+ console.print(" [dim]Skipped — you can enable this later in config.yaml.[/dim]\n")
95
+
96
+ # ------------------------------------------------------------------
97
+ # Step 4 — Obsidian vault
98
+ # ------------------------------------------------------------------
99
+ console.print("[bold]Step 4 of 5[/bold] Where is your Obsidian vault?")
100
+ console.print(" [dim](Optional — enables note sync and spaced repetition)[/dim]")
101
+ use_obsidian = click.confirm(" Configure Obsidian integration?", default=True)
102
+ if use_obsidian:
103
+ obsidian_raw = click.prompt(" Obsidian vault path", default="~/Obsidian")
104
+ obsidian_path = _validate_path(obsidian_raw)
105
+ config["obsidian_base"] = str(obsidian_raw)
106
+ if not obsidian_path.exists():
107
+ console.print(
108
+ f" [yellow]Note: {obsidian_path} does not exist yet. "
109
+ f"Create it before running sync.[/yellow]\n"
110
+ )
111
+ else:
112
+ console.print(f" [green]Found vault at {obsidian_path}[/green]\n")
113
+ else:
114
+ console.print(" [dim]Skipped — you can set obsidian_base in config.yaml later.[/dim]\n")
115
+
116
+ # ------------------------------------------------------------------
117
+ # Step 5 — Write config
118
+ # ------------------------------------------------------------------
119
+ console.print("[bold]Step 5 of 5[/bold] Writing configuration...")
120
+
121
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
122
+ config_path = CONFIG_DIR / "config.yaml"
123
+
124
+ # Preserve any existing keys by loading first, then merging
125
+ existing: dict = {}
126
+ if config_path.exists():
127
+ with contextlib.suppress(yaml.YAMLError):
128
+ existing = yaml.safe_load(config_path.read_text()) or {}
129
+
130
+ # New values take precedence over existing
131
+ merged = {**existing, **config}
132
+ # Nested dicts: merge content and notebooklm sub-keys
133
+ for key in ("content", "notebooklm"):
134
+ if key in existing and key in config and isinstance(existing[key], dict):
135
+ merged[key] = {**existing[key], **config[key]}
136
+
137
+ config_path.write_text(yaml.dump(merged, default_flow_style=False, sort_keys=False))
138
+
139
+ console.print(f"\n[bold green]Configuration saved to {config_path}[/bold green]")
140
+
141
+ # ------------------------------------------------------------------
142
+ # Offer to launch web UI
143
+ # ------------------------------------------------------------------
144
+ console.print()
145
+ launch = click.confirm("Launch the studyctl web UI now?", default=False)
146
+ if launch:
147
+ console.print("[dim]Starting web UI on http://localhost:8000 ...[/dim]")
148
+ try:
149
+ subprocess.Popen(
150
+ ["studyctl", "web"],
151
+ stdout=subprocess.DEVNULL,
152
+ stderr=subprocess.DEVNULL,
153
+ )
154
+ console.print(
155
+ "[green]Web UI started. Open http://localhost:8000 in your browser.[/green]"
156
+ )
157
+ except FileNotFoundError:
158
+ console.print("[yellow]Could not launch web UI. Run 'studyctl web' manually.[/yellow]")
159
+ else:
160
+ console.print("\n[bold]You're all set.[/bold] Next steps:")
161
+ console.print(" studyctl config show — review your configuration")
162
+ console.print(" studyctl review — see what topics are due for review")
163
+ console.print(" studyctl web — open the web UI")
164
+ console.print(" studyctl --help — explore all commands")
@@ -0,0 +1,83 @@
1
+ """Shared CLI utilities — console, helpers, constants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from rich.console import Console
9
+
10
+ from studyctl.settings import Topic, get_topics
11
+
12
+ console = Console()
13
+
14
+ # Topic keywords for session DB queries
15
+ TOPIC_KEYWORDS = {
16
+ "python": [
17
+ "python",
18
+ "pattern",
19
+ "dataclass",
20
+ "protocol",
21
+ "abc",
22
+ "strategy",
23
+ "bridge",
24
+ "decorator",
25
+ ],
26
+ "sql": ["sql", "query", "join", "index", "postgresql", "athena", "redshift", "window function"],
27
+ "data-engineering": [
28
+ "spark",
29
+ "glue",
30
+ "pipeline",
31
+ "etl",
32
+ "airflow",
33
+ "dbt",
34
+ "kafka",
35
+ "partition",
36
+ "dag",
37
+ ],
38
+ "aws-analytics": ["sagemaker", "athena", "redshift", "lake formation", "emr", "glue catalog"],
39
+ }
40
+
41
+
42
+ def get_topic(name: str) -> Topic | None:
43
+ """Find a topic by name (exact or substring match)."""
44
+ for t in get_topics():
45
+ if t.name == name or name in t.name:
46
+ return t
47
+ return None
48
+
49
+
50
+ def offer_agent_install(flag: bool | None) -> None:
51
+ """Offer to install AI agent definitions after config init.
52
+
53
+ Args:
54
+ flag: True = install, False = skip, None = ask interactively.
55
+ """
56
+ # Find install-agents.sh relative to the package
57
+ candidate = Path(__file__).resolve().parent.parent
58
+ for _ in range(6):
59
+ script = candidate / "scripts" / "install-agents.sh"
60
+ if script.exists():
61
+ break
62
+ candidate = candidate.parent
63
+ else:
64
+ return # Script not found — skip silently (pip install, not git clone)
65
+
66
+ if flag is None:
67
+ console.print("\n[bold cyan]Agent Installation[/bold cyan]")
68
+ console.print(
69
+ "The study mentor agents can be installed for detected AI tools\n"
70
+ "(Claude Code, Kiro CLI, Gemini, OpenCode, Amp).\n"
71
+ )
72
+ reply = input("Install agent definitions now? [Y/n] ").strip().lower()
73
+ flag = reply in ("", "y", "yes")
74
+
75
+ if flag:
76
+ console.print("[dim]Running install-agents.sh...[/dim]")
77
+ result = subprocess.run(["bash", str(script)], capture_output=True, text=True)
78
+ if result.returncode == 0:
79
+ for line in result.stdout.strip().splitlines():
80
+ console.print(f" {line}")
81
+ else:
82
+ console.print("[yellow]Agent install had issues — run manually:[/yellow]")
83
+ console.print(f" bash {script}")
studyctl/cli/_state.py ADDED
@@ -0,0 +1,69 @@
1
+ """State commands — cross-machine state sync."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from studyctl.cli._shared import console
8
+ from studyctl.shared import init_config, pull_state, push_state
9
+ from studyctl.shared import sync_status as shared_sync_status
10
+
11
+
12
+ @click.group(name="state")
13
+ def state_group() -> None:
14
+ """Cross-machine state sync (via Obsidian vault)."""
15
+
16
+
17
+ @state_group.command(name="push")
18
+ @click.argument("remote", required=False)
19
+ def state_push(remote: str | None) -> None:
20
+ """Push local progress and sync state to remote machine(s)."""
21
+ try:
22
+ pushed = push_state(remote)
23
+ except FileNotFoundError as e:
24
+ console.print(f"[red]{e}[/red]")
25
+ console.print("Run 'studyctl state init' first")
26
+ raise SystemExit(1) from None
27
+ if pushed:
28
+ for f in pushed:
29
+ console.print(f"[green]\u2713[/green] {f}")
30
+ else:
31
+ console.print("[dim]Everything up to date (or no remotes reachable)[/dim]")
32
+
33
+
34
+ @state_group.command(name="pull")
35
+ @click.argument("remote", required=False)
36
+ def state_pull(remote: str | None) -> None:
37
+ """Pull progress and sync state from remote machine(s)."""
38
+ try:
39
+ pulled = pull_state(remote)
40
+ except FileNotFoundError as e:
41
+ console.print(f"[red]{e}[/red]")
42
+ raise SystemExit(1) from None
43
+ if pulled:
44
+ for f in pulled:
45
+ console.print(f"[green]\u2713[/green] {f}")
46
+ else:
47
+ console.print("[dim]Everything up to date (or no remotes reachable)[/dim]")
48
+
49
+
50
+ @state_group.command(name="status")
51
+ def state_status_cmd() -> None:
52
+ """Check sync config and remote connectivity."""
53
+ info = shared_sync_status()
54
+ if not info["configured"]:
55
+ console.print("[red]Not configured.[/red] Run: studyctl state init")
56
+ console.print(f"Config: {info['config_path']}")
57
+ return
58
+ console.print(f"Local machine: [bold]{info['local']}[/bold]")
59
+ for name, r in info["remotes"].items():
60
+ status = "[green]reachable[/green]" if r["reachable"] else "[red]unreachable[/red]"
61
+ console.print(f" {name} ({r['host']}): {status}")
62
+
63
+
64
+ @state_group.command(name="init")
65
+ def state_init() -> None:
66
+ """Create default sync config."""
67
+ path = init_config()
68
+ console.print(f"[green]\u2713[/green] Config at {path}")
69
+ console.print("Edit remotes to match your machines, then run 'studyctl state status'")
studyctl/cli/_sync.py ADDED
@@ -0,0 +1,156 @@
1
+ """Sync commands — Obsidian to NotebookLM sync, audio generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ from rich.table import Table
7
+
8
+ from studyctl.cli._shared import console, get_topic
9
+ from studyctl.maintenance import dedup_notebook, find_duplicates
10
+ from studyctl.settings import get_topics
11
+ from studyctl.state import SyncState
12
+ from studyctl.sync import find_changed_sources, find_sources, generate_audio, sync_topic
13
+
14
+
15
+ @click.command()
16
+ @click.argument("topic_name", required=False)
17
+ @click.option("--all", "sync_all", is_flag=True, help="Sync all topics")
18
+ @click.option("--dry-run", is_flag=True, help="Show what would be synced")
19
+ def sync(topic_name: str | None, sync_all: bool, dry_run: bool) -> None:
20
+ """Sync Obsidian course notes to NotebookLM notebooks."""
21
+ state = SyncState()
22
+ topics = get_topics() if sync_all else ([get_topic(topic_name)] if topic_name else [])
23
+ topics = [t for t in topics if t]
24
+
25
+ if not topics:
26
+ console.print("[red]Specify a topic name or use --all[/red]")
27
+ console.print("Topics: " + ", ".join(t.name for t in get_topics()))
28
+ raise SystemExit(1)
29
+
30
+ for topic in topics:
31
+ result = sync_topic(topic, state, dry_run=dry_run)
32
+ prefix = "[dim]DRY RUN[/dim] " if dry_run else ""
33
+ nb = topic.notebook_id[:8] + "..." if topic.notebook_id else "[yellow]new[/yellow]"
34
+ if result["changed"] == 0:
35
+ console.print(f"{prefix}[dim]{topic.display_name} ({nb}): up to date[/dim]")
36
+ else:
37
+ changed = result["changed"]
38
+ total = result["total"]
39
+ synced = result["synced"]
40
+ failed = result["failed"]
41
+ console.print(
42
+ f"{prefix}[bold]{topic.display_name}[/bold] → {nb}: "
43
+ f"{changed}/{total} to sync, {synced} done, {failed} failed"
44
+ )
45
+ if dry_run and result.get("files"):
46
+ for f in result["files"]:
47
+ console.print(f" [dim] {f}[/dim]")
48
+ if result["changed"] > 10:
49
+ console.print(f" [dim] ... and {result['changed'] - 10} more[/dim]")
50
+
51
+
52
+ @click.command()
53
+ @click.argument("topic_name", required=False)
54
+ def status(topic_name: str | None) -> None:
55
+ """Show sync status for topics."""
56
+ state = SyncState()
57
+ topics = [get_topic(topic_name)] if topic_name else get_topics()
58
+ topics = [t for t in topics if t]
59
+
60
+ table = Table(title="Study Pipeline Status")
61
+ table.add_column("Topic", style="bold cyan")
62
+ table.add_column("Notebook", style="dim")
63
+ table.add_column("Sources", justify="right")
64
+ table.add_column("Changed", justify="right")
65
+ table.add_column("Last Sync", style="dim")
66
+
67
+ for topic in topics:
68
+ ts = state.get_topic(topic.name)
69
+ total = len(find_sources(topic))
70
+ changed = len(find_changed_sources(topic, state))
71
+ nb = ts.notebook_id[:8] + "..." if ts.notebook_id else "[red]not created[/red]"
72
+ synced_count = len(ts.sources)
73
+ last = ts.last_sync[:10] if ts.last_sync else "never"
74
+ table.add_row(
75
+ topic.display_name,
76
+ nb,
77
+ f"{synced_count}/{total}",
78
+ str(changed) if changed else "[green]0[/green]",
79
+ last,
80
+ )
81
+
82
+ console.print(table)
83
+
84
+
85
+ @click.command()
86
+ @click.argument("topic_name")
87
+ @click.option("--instructions", "-i", default="", help="Custom instructions for audio generation")
88
+ def audio(topic_name: str, instructions: str) -> None:
89
+ """Generate a NotebookLM audio overview for a topic."""
90
+ topic = get_topic(topic_name)
91
+ if not topic:
92
+ console.print(f"[red]Unknown topic: {topic_name}[/red]")
93
+ raise SystemExit(1)
94
+
95
+ state = SyncState()
96
+ ts = state.get_topic(topic.name)
97
+ if not ts.notebook_id:
98
+ console.print("[red]Notebook not created yet. Run 'studyctl sync' first.[/red]")
99
+ raise SystemExit(1)
100
+
101
+ console.print(f"Generating audio for [bold]{topic.display_name}[/bold]...")
102
+ task_id = generate_audio(topic, state, instructions)
103
+ if task_id:
104
+ console.print(f"[green]\u2713[/green] Audio generation started (task: {task_id})")
105
+ console.print(f" Check status: notebooklm artifact list --notebook {ts.notebook_id}")
106
+ else:
107
+ console.print("[red]Failed to start audio generation[/red]")
108
+
109
+
110
+ @click.command()
111
+ def topics() -> None:
112
+ """List configured study topics."""
113
+ for topic in get_topics():
114
+ console.print(f"[bold cyan]{topic.name}[/bold cyan] \u2014 {topic.display_name}")
115
+ for p in topic.obsidian_paths:
116
+ exists = "\u2713" if p.exists() else "\u2717"
117
+ console.print(f" {exists} {p}")
118
+
119
+
120
+ @click.command()
121
+ @click.argument("topic_name", required=False)
122
+ @click.option("--all", "dedup_all", is_flag=True, help="Dedup all topic notebooks")
123
+ @click.option("--dry-run", is_flag=True, help="Show duplicates without removing")
124
+ def dedup(topic_name: str | None, dedup_all: bool, dry_run: bool) -> None:
125
+ """Remove duplicate sources from NotebookLM notebooks."""
126
+ state = SyncState()
127
+ topics = get_topics() if dedup_all else ([get_topic(topic_name)] if topic_name else [])
128
+ topics = [t for t in topics if t]
129
+
130
+ if not topics:
131
+ console.print("[red]Specify a topic or use --all[/red]")
132
+ raise SystemExit(1)
133
+
134
+ for topic in topics:
135
+ ts = state.get_topic(topic.name)
136
+ if not ts.notebook_id:
137
+ console.print(f"[dim]{topic.display_name}: no notebook[/dim]")
138
+ continue
139
+
140
+ dupes = find_duplicates(ts.notebook_id)
141
+ if not dupes:
142
+ console.print(f"[dim]{topic.display_name}: no duplicates[/dim]")
143
+ continue
144
+
145
+ total_dupes = sum(len(v) - 1 for v in dupes.values())
146
+ name = topic.display_name
147
+ console.print(f"[bold]{name}[/bold]: {total_dupes} duplicates across {len(dupes)} titles")
148
+ for title, sources in dupes.items():
149
+ console.print(
150
+ f" {len(sources)}x {title}"
151
+ + (" [dim](keeping newest)[/dim]" if not dry_run else "")
152
+ )
153
+
154
+ if not dry_run:
155
+ result = dedup_notebook(ts.notebook_id)
156
+ console.print(f" [green]\u2713[/green] Removed {result['removed']} duplicates")