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/__init__.py
ADDED
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
|
studyctl/cli/__init__.py
ADDED
|
@@ -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"]
|
studyctl/cli/_config.py
ADDED
|
@@ -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]")
|