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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """studyctl — AuDHD study pipeline CLI."""
2
+
3
+ __version__ = "1.0.0"
studyctl/calendar.py ADDED
@@ -0,0 +1,140 @@
1
+ """Calendar time-blocking via ICS file generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime, timedelta
6
+ from pathlib import Path
7
+ from uuid import uuid4
8
+
9
+ # Spaced repetition review types with suggested durations (minutes)
10
+ REVIEW_DURATIONS: dict[str, int] = {
11
+ "5-min recall quiz": 10,
12
+ "10-min Socratic review": 15,
13
+ "15-min deep review": 20,
14
+ "Apply to new problem": 30,
15
+ "Teach-back session": 30,
16
+ }
17
+
18
+
19
+ def _ics_dt(dt: datetime) -> str:
20
+ """Format datetime as ICS DTSTART/DTEND value with UTC suffix."""
21
+ if dt.tzinfo is not None:
22
+ dt = dt.astimezone(UTC)
23
+ return dt.strftime("%Y%m%dT%H%M%SZ")
24
+ return dt.strftime("%Y%m%dT%H%M%S")
25
+
26
+
27
+ def _escape(text: str) -> str:
28
+ """Escape text for ICS fields."""
29
+ return text.replace("\\", "\\\\").replace(",", "\\,").replace(";", "\\;").replace("\n", "\\n")
30
+
31
+
32
+ def generate_event(
33
+ topic: str,
34
+ review_type: str,
35
+ start: datetime,
36
+ duration_min: int | None = None,
37
+ ) -> str:
38
+ """Generate a single VEVENT block."""
39
+ dur = duration_min or REVIEW_DURATIONS.get(review_type, 20)
40
+ end = start + timedelta(minutes=dur)
41
+ uid = f"{uuid4()}@studyctl"
42
+ now = datetime.now(UTC)
43
+
44
+ return (
45
+ "BEGIN:VEVENT\r\n"
46
+ f"UID:{uid}\r\n"
47
+ f"DTSTAMP:{_ics_dt(now)}\r\n"
48
+ f"DTSTART:{_ics_dt(start)}\r\n"
49
+ f"DTEND:{_ics_dt(end)}\r\n"
50
+ f"SUMMARY:{_escape(f'Study: {topic} ({review_type})')}\r\n"
51
+ f"DESCRIPTION:{_escape(f'Spaced repetition: {review_type} for {topic}')}\r\n"
52
+ "STATUS:CONFIRMED\r\n"
53
+ "BEGIN:VALARM\r\n"
54
+ "TRIGGER:-PT5M\r\n"
55
+ "ACTION:DISPLAY\r\n"
56
+ f"DESCRIPTION:{_escape(f'Study time: {topic}')}\r\n"
57
+ "END:VALARM\r\n"
58
+ "END:VEVENT\r\n"
59
+ )
60
+
61
+
62
+ def generate_ics(events: list[dict]) -> str:
63
+ """Generate a complete .ics file from a list of event dicts.
64
+
65
+ Each dict: {topic, review_type, start, duration_min?}
66
+ """
67
+ lines = (
68
+ "BEGIN:VCALENDAR\r\n"
69
+ "VERSION:2.0\r\n"
70
+ "PRODID:-//studyctl//Socratic Study Mentor//EN\r\n"
71
+ "CALSCALE:GREGORIAN\r\n"
72
+ "METHOD:PUBLISH\r\n"
73
+ )
74
+ for evt in events:
75
+ lines += generate_event(
76
+ topic=evt["topic"],
77
+ review_type=evt["review_type"],
78
+ start=evt["start"],
79
+ duration_min=evt.get("duration_min"),
80
+ )
81
+ lines += "END:VCALENDAR\r\n"
82
+ return lines
83
+
84
+
85
+ def schedule_reviews(
86
+ due_items: list[dict],
87
+ start_time: datetime | None = None,
88
+ gap_minutes: int = 10,
89
+ ) -> list[dict]:
90
+ """Convert spaced repetition due items into scheduled events.
91
+
92
+ Args:
93
+ due_items: From history.spaced_repetition_due() — [{topic, review_type, ...}]
94
+ start_time: When to start scheduling (default: next hour)
95
+ gap_minutes: Gap between sessions
96
+
97
+ Returns:
98
+ List of event dicts ready for generate_ics()
99
+ """
100
+ if not start_time:
101
+ now = datetime.now()
102
+ start_time = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
103
+
104
+ events = []
105
+ current = start_time
106
+ for item in due_items:
107
+ review_type = item.get("review_type", "15-min deep review")
108
+ duration = REVIEW_DURATIONS.get(review_type, 20)
109
+ events.append(
110
+ {
111
+ "topic": item["topic"],
112
+ "review_type": review_type,
113
+ "start": current,
114
+ "duration_min": duration,
115
+ }
116
+ )
117
+ current += timedelta(minutes=duration + gap_minutes)
118
+ return events
119
+
120
+
121
+ def write_ics(
122
+ events: list[dict],
123
+ output_dir: Path | None = None,
124
+ ) -> Path:
125
+ """Write events to an .ics file.
126
+
127
+ Args:
128
+ events: Event dicts from schedule_reviews()
129
+ output_dir: Directory to write to (default: ~/Downloads)
130
+
131
+ Returns:
132
+ Path to the written .ics file
133
+ """
134
+ output_dir = output_dir or Path.home() / "Downloads"
135
+ output_dir.mkdir(parents=True, exist_ok=True)
136
+
137
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M")
138
+ path = output_dir / f"study-blocks-{timestamp}.ics"
139
+ path.write_text(generate_ics(events))
140
+ return path
@@ -0,0 +1,56 @@
1
+ """studyctl CLI — sync, plan, and schedule study sessions.
2
+
3
+ Split into submodules with LazyGroup for fast startup.
4
+ Commands are only imported when invoked.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import click
10
+
11
+ from studyctl.cli._lazy import LazyGroup
12
+
13
+
14
+ @click.group(
15
+ cls=LazyGroup,
16
+ lazy_subcommands={
17
+ # _sync.py — Obsidian/NotebookLM sync
18
+ "sync": "studyctl.cli._sync:sync",
19
+ "status": "studyctl.cli._sync:status",
20
+ "audio": "studyctl.cli._sync:audio",
21
+ "topics": "studyctl.cli._sync:topics",
22
+ "dedup": "studyctl.cli._sync:dedup",
23
+ # _state.py — cross-machine state
24
+ "state": "studyctl.cli._state:state_group",
25
+ # _setup.py — first-run setup wizard
26
+ "setup": "studyctl.cli._setup:setup",
27
+ # _config.py — configuration
28
+ "config": "studyctl.cli._config:config_group",
29
+ # _schedule.py — job scheduling + calendar
30
+ "schedule": "studyctl.cli._schedule:schedule_group",
31
+ "schedule-blocks": "studyctl.cli._schedule:schedule_blocks",
32
+ # _review.py — spaced repetition, progress, teachback, bridges
33
+ "review": "studyctl.cli._review:review",
34
+ "struggles": "studyctl.cli._review:struggles",
35
+ "wins": "studyctl.cli._review:wins",
36
+ "progress": "studyctl.cli._review:progress",
37
+ "resume": "studyctl.cli._review:resume",
38
+ "streaks": "studyctl.cli._review:streaks",
39
+ "progress-map": "studyctl.cli._review:progress_map",
40
+ "teachback": "studyctl.cli._review:teachback",
41
+ "teachback-history": "studyctl.cli._review:teachback_history_cmd",
42
+ "bridge": "studyctl.cli._review:bridge_group",
43
+ # _content.py — content pipeline (pdf splitting, NotebookLM, syllabus)
44
+ "content": "studyctl.cli._content:content_group",
45
+ # _web.py — web UI, TUI, docs
46
+ "web": "studyctl.cli._web:web",
47
+ "tui": "studyctl.cli._web:tui",
48
+ "docs": "studyctl.cli._web:docs_group",
49
+ },
50
+ )
51
+ @click.version_option()
52
+ def cli() -> None:
53
+ """studyctl — AuDHD study pipeline: Obsidian\u2192NotebookLM sync and study management."""
54
+
55
+
56
+ __all__ = ["cli"]
@@ -0,0 +1,128 @@
1
+ """Config commands — configuration management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ from rich.table import Table
7
+
8
+ from studyctl.cli._shared import console, offer_agent_install
9
+ from studyctl.shared import init_interactive_config
10
+
11
+
12
+ @click.group(name="config")
13
+ def config_group() -> None:
14
+ """Manage studyctl configuration."""
15
+
16
+
17
+ @config_group.command(name="init")
18
+ @click.option(
19
+ "--install-agents/--no-install-agents",
20
+ default=None,
21
+ help="Install AI agent definitions after config (auto-detects available tools).",
22
+ )
23
+ def config_init(install_agents: bool | None) -> None:
24
+ """Interactive setup — configure knowledge bridging, NotebookLM, and Obsidian integration."""
25
+ path = init_interactive_config(console)
26
+ console.print(f"\n[bold green]\u2713 Configuration saved to {path}[/bold green]")
27
+
28
+ # Offer to install agents
29
+ offer_agent_install(install_agents)
30
+
31
+ console.print("\nNext steps:")
32
+ console.print(" 1. Add study topics: studyctl topics")
33
+ console.print(" 2. Start a session: /agent socratic-mentor (Claude Code)")
34
+ console.print(" kiro-cli chat --agent study-mentor (Kiro)")
35
+
36
+
37
+ @config_group.command(name="show")
38
+ def config_show() -> None:
39
+ """Display current configuration."""
40
+ from studyctl.settings import _CONFIG_PATH, load_settings
41
+
42
+ settings = load_settings()
43
+ config_path = _CONFIG_PATH
44
+
45
+ if not config_path.exists():
46
+ console.print("[red]No config file found.[/red] Run: studyctl config init")
47
+ return
48
+
49
+ console.print(f"[bold]Configuration[/bold] \u2014 {config_path}\n")
50
+
51
+ # Core settings
52
+ table = Table(title="Core Settings")
53
+ table.add_column("Setting", style="cyan")
54
+ table.add_column("Value")
55
+ table.add_column("Status", justify="center")
56
+
57
+ # Obsidian
58
+ obsidian_path = settings.obsidian_base
59
+ obsidian_exists = obsidian_path.exists()
60
+ table.add_row(
61
+ "Obsidian vault",
62
+ str(obsidian_path),
63
+ "[green]\u2713[/green]" if obsidian_exists else "[red]\u2717[/red]",
64
+ )
65
+
66
+ # Session DB
67
+ db_exists = settings.session_db.exists()
68
+ table.add_row(
69
+ "Session database",
70
+ str(settings.session_db),
71
+ "[green]\u2713[/green]" if db_exists else "[dim]\u2014[/dim]",
72
+ )
73
+
74
+ # State dir
75
+ state_exists = settings.state_dir.exists()
76
+ table.add_row(
77
+ "State directory",
78
+ str(settings.state_dir),
79
+ "[green]\u2713[/green]" if state_exists else "[dim]\u2014[/dim]",
80
+ )
81
+
82
+ # Knowledge domains
83
+ kd = settings.knowledge_domains
84
+ if kd.primary:
85
+ table.add_row("Knowledge bridging", f"Primary: {kd.primary}", "[green]\u2713[/green]")
86
+ else:
87
+ table.add_row("Knowledge bridging", "Not configured", "[dim]\u2014[/dim]")
88
+
89
+ # NotebookLM
90
+ nlm_enabled = settings.notebooklm.enabled
91
+ table.add_row(
92
+ "NotebookLM",
93
+ "Enabled" if nlm_enabled else "Disabled",
94
+ "[green]\u2713[/green]" if nlm_enabled else "[dim]\u2014[/dim]",
95
+ )
96
+
97
+ # Sync
98
+ if settings.sync_remote:
99
+ table.add_row("Sync remote", settings.sync_remote, "[green]\u2713[/green]")
100
+ else:
101
+ table.add_row("Sync remote", "Not configured", "[dim]\u2014[/dim]")
102
+
103
+ console.print(table)
104
+
105
+ # Topics
106
+ if settings.topics:
107
+ topics_table = Table(title="\nStudy Topics")
108
+ topics_table.add_column("Name", style="bold")
109
+ topics_table.add_column("Slug", style="dim")
110
+ topics_table.add_column("Path")
111
+ topics_table.add_column("Notebook", style="dim")
112
+ topics_table.add_column("Tags")
113
+
114
+ for t in settings.topics:
115
+ path_str = str(t.obsidian_path)
116
+ path_str = (
117
+ f"[green]{path_str}[/green]"
118
+ if t.obsidian_path.exists()
119
+ else f"[red]{path_str}[/red]"
120
+ )
121
+
122
+ nb = t.notebook_id[:12] + "\u2026" if t.notebook_id else "[dim]\u2014[/dim]"
123
+ tags = ", ".join(t.tags) if t.tags else "[dim]\u2014[/dim]"
124
+ topics_table.add_row(t.name, t.slug, path_str, nb, tags)
125
+
126
+ console.print(topics_table)
127
+ else:
128
+ console.print("\n[dim]No topics configured. Add topics to config.yaml.[/dim]")