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/shared.py ADDED
@@ -0,0 +1,425 @@
1
+ """Cross-machine state sync using agent-session-tools infrastructure.
2
+
3
+ Uses the existing session-sync merge logic (SQLite + rsync) rather than
4
+ reinventing sync. The Mac Mini acts as the hub — all machines push/pull to it.
5
+
6
+ Config lives at ~/.config/studyctl/config.yaml
7
+
8
+ Host schema:
9
+ hosts:
10
+ macmini:
11
+ hostname: Andys-Mac-Mini
12
+ ip_address:
13
+ primary: 192.168.125.22
14
+ secondary: 192.168.125.12 # optional, fallback for wifi
15
+ user: ataylor
16
+ state_json: ~/.config/studyctl/state.json
17
+ sessions_db: ~/.config/studyctl/sessions.db
18
+
19
+ Local machine is auto-detected by matching socket.gethostname() against
20
+ the hostname field in each host entry.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import socket
26
+ import subprocess
27
+ from pathlib import Path
28
+
29
+ import yaml
30
+
31
+ from .settings import load_settings
32
+
33
+ CONFIG_PATH = Path.home() / ".config" / "studyctl" / "config.yaml"
34
+
35
+
36
+ def _get_default_user() -> str:
37
+ """Get default sync user lazily (avoids import-time os.getlogin failure)."""
38
+ return load_settings().sync_user
39
+
40
+
41
+ def _load_config() -> dict:
42
+ if not CONFIG_PATH.exists():
43
+ return {}
44
+ return yaml.safe_load(CONFIG_PATH.read_text()) or {}
45
+
46
+
47
+ def _resolve_hosts(config: dict) -> tuple[str | None, dict, dict[str, dict]]:
48
+ """Resolve local and remote hosts from unified hosts config.
49
+
50
+ Returns:
51
+ (local_name, local_host_config, remote_hosts_dict)
52
+ """
53
+ hosts = config.get("hosts", {})
54
+
55
+ # Auto-detect local machine by hostname
56
+ current_hostname = socket.gethostname().split(".")[0]
57
+ local_name: str | None = None
58
+ local_config: dict = {}
59
+ remotes: dict[str, dict] = {}
60
+
61
+ for name, host in hosts.items():
62
+ if host.get("hostname") == current_hostname:
63
+ local_name = name
64
+ local_config = host
65
+ else:
66
+ remotes[name] = host
67
+
68
+ return local_name, local_config, remotes
69
+
70
+
71
+ def _get_host_ip(host_config: dict) -> str:
72
+ """Get the primary IP address for a host."""
73
+ ip = host_config.get("ip_address", {})
74
+ if isinstance(ip, dict):
75
+ return ip.get("primary", "")
76
+ return str(ip) if ip else ""
77
+
78
+
79
+ def _get_host_ips(host_config: dict) -> list[str]:
80
+ """Get all IP addresses for a host (primary first, then secondary)."""
81
+ ip = host_config.get("ip_address", {})
82
+ if isinstance(ip, dict):
83
+ ips = []
84
+ if ip.get("primary"):
85
+ ips.append(ip["primary"])
86
+ if ip.get("secondary"):
87
+ ips.append(ip["secondary"])
88
+ return ips
89
+ return [str(ip)] if ip else []
90
+
91
+
92
+ def _rsync_with_fallback(
93
+ args_template: list[str], host_config: dict, user: str
94
+ ) -> subprocess.CompletedProcess:
95
+ """Run rsync trying primary IP, falling back to secondary."""
96
+ ips = _get_host_ips(host_config)
97
+ last_result = None
98
+ for ip in ips:
99
+ # Replace {dest} placeholder with actual user@ip
100
+ args = [a.replace("{HOST}", f"{user}@{ip}") for a in args_template]
101
+ last_result = subprocess.run(args, capture_output=True, text=True)
102
+ if last_result.returncode == 0:
103
+ return last_result
104
+ # Return last failure if all IPs failed
105
+ return last_result or subprocess.CompletedProcess(args_template, 1)
106
+
107
+
108
+ def push_state(remote: str | None = None) -> list[str]:
109
+ """Push studyctl state + sessions DB to remote machine(s).
110
+
111
+ Uses rsync for state.json and session-sync for the sessions DB
112
+ (which handles intelligent merging, FTS rebuild, etc.)
113
+ """
114
+ config = _load_config()
115
+ if not config:
116
+ raise FileNotFoundError(f"No config at {CONFIG_PATH}. Run 'studyctl state init'.")
117
+
118
+ _, local_config, remotes = _resolve_hosts(config)
119
+ if remote:
120
+ remotes = {remote: remotes[remote]} if remote in remotes else {}
121
+
122
+ pushed = []
123
+ state_json = Path(local_config.get("state_json", "~/.config/studyctl/state.json")).expanduser()
124
+
125
+ for name, r in remotes.items():
126
+ user = r.get("user", _get_default_user())
127
+ remote_state = r.get("state_json", "~/.config/studyctl/state.json")
128
+
129
+ # Push state.json via rsync (with IP fallback)
130
+ if state_json.exists():
131
+ result = _rsync_with_fallback(
132
+ ["rsync", "-az", str(state_json), f"{{HOST}}:{remote_state}"],
133
+ r,
134
+ user,
135
+ )
136
+ if result.returncode == 0:
137
+ pushed.append(f"state.json → {name}")
138
+
139
+ # Push sessions DB via session-sync (handles merge)
140
+ sessions_db = Path(local_config.get("sessions_db", "")).expanduser()
141
+ if sessions_db.exists():
142
+ remote_db = r.get("sessions_db", "")
143
+ if remote_db:
144
+ ip = _get_host_ip(r)
145
+ dest = f"{user}@{ip}:{remote_db}"
146
+ result = subprocess.run(
147
+ ["session-sync", "push", dest],
148
+ capture_output=True,
149
+ text=True,
150
+ )
151
+ if result.returncode == 0:
152
+ pushed.append(f"sessions.db → {name}")
153
+
154
+ return pushed
155
+
156
+
157
+ def pull_state(remote: str | None = None) -> list[str]:
158
+ """Pull state from remote machine(s). Sessions DB uses merge logic."""
159
+ config = _load_config()
160
+ if not config:
161
+ raise FileNotFoundError(f"No config at {CONFIG_PATH}")
162
+
163
+ _, local_config, remotes = _resolve_hosts(config)
164
+ if remote:
165
+ remotes = {remote: remotes[remote]} if remote in remotes else {}
166
+
167
+ pulled = []
168
+ state_json = Path(local_config.get("state_json", "~/.config/studyctl/state.json")).expanduser()
169
+ state_json.parent.mkdir(parents=True, exist_ok=True)
170
+
171
+ for name, r in remotes.items():
172
+ user = r.get("user", _get_default_user())
173
+ remote_state = r.get("state_json", "~/.config/studyctl/state.json")
174
+
175
+ # Pull state.json (with IP fallback)
176
+ result = _rsync_with_fallback(
177
+ ["rsync", "-az", "--update", f"{{HOST}}:{remote_state}", str(state_json)],
178
+ r,
179
+ user,
180
+ )
181
+ if result.returncode == 0:
182
+ pulled.append(f"state.json ← {name}")
183
+
184
+ # Pull + merge sessions DB
185
+ remote_db = r.get("sessions_db", "")
186
+ if remote_db:
187
+ ip = _get_host_ip(r)
188
+ src = f"{user}@{ip}:{remote_db}"
189
+ result = subprocess.run(
190
+ ["session-sync", "pull", src],
191
+ capture_output=True,
192
+ text=True,
193
+ )
194
+ if result.returncode == 0:
195
+ pulled.append(f"sessions.db ← {name} (merged)")
196
+
197
+ return pulled
198
+
199
+
200
+ def sync_status() -> dict:
201
+ """Check config and connectivity."""
202
+ config = _load_config()
203
+ if not config:
204
+ return {"configured": False, "config_path": str(CONFIG_PATH)}
205
+
206
+ local_name, _, remotes = _resolve_hosts(config)
207
+
208
+ status: dict = {
209
+ "configured": True,
210
+ "local": local_name or "unknown",
211
+ "remotes": {},
212
+ }
213
+ for name, r in remotes.items():
214
+ ips = _get_host_ips(r)
215
+ user = r.get("user", _get_default_user())
216
+ reachable = False
217
+ connected_ip = ""
218
+
219
+ for ip in ips:
220
+ result = subprocess.run(
221
+ [
222
+ "ssh",
223
+ "-o",
224
+ "ConnectTimeout=3",
225
+ "-o",
226
+ "BatchMode=yes",
227
+ f"{user}@{ip}",
228
+ "echo ok",
229
+ ],
230
+ capture_output=True,
231
+ text=True,
232
+ )
233
+ if result.returncode == 0:
234
+ reachable = True
235
+ connected_ip = ip
236
+ break
237
+
238
+ status["remotes"][name] = {
239
+ "host": connected_ip or (ips[0] if ips else "?"),
240
+ "reachable": reachable,
241
+ }
242
+ return status
243
+
244
+
245
+ def init_interactive_config(console: object) -> Path:
246
+ """Run interactive configuration wizard asking core setup questions.
247
+
248
+ Asks about:
249
+ 1. Knowledge bridging — leverage familiar topics for analogies
250
+ 2. NotebookLM integration
251
+ 3. Obsidian vault path
252
+ """
253
+ from rich.console import Console
254
+ from rich.panel import Panel
255
+
256
+ if not isinstance(console, Console):
257
+ console = Console()
258
+
259
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
260
+
261
+ # Load existing config or start fresh
262
+ existing: dict = {}
263
+ if CONFIG_PATH.exists():
264
+ existing = yaml.safe_load(CONFIG_PATH.read_text()) or {}
265
+ console.print(f"[dim]Existing config found at {CONFIG_PATH} — updating.[/dim]\n")
266
+
267
+ console.print(
268
+ Panel(
269
+ "[bold]Socratic Study Mentor — Interactive Setup[/bold]\n\n"
270
+ "This will configure your study environment.\n"
271
+ "Press Enter to accept defaults shown in [dim]brackets[/dim].",
272
+ title="🧠 studyctl config init",
273
+ border_style="cyan",
274
+ )
275
+ )
276
+
277
+ # ── Question 1: Knowledge bridging ──────────────────────────────────────
278
+ console.print("\n[bold cyan]1/3 — Knowledge Bridging[/bold cyan]")
279
+ console.print(
280
+ "The study mentor can draw analogies between topics you already know well\n"
281
+ "and new topics you're studying (e.g. networking → data engineering).\n"
282
+ )
283
+
284
+ use_bridging = _prompt_yn(
285
+ "Do you want to leverage a topic you are already very familiar with\n"
286
+ " so we can draw comparisons with an area you already know well\n"
287
+ " to topics you are studying?",
288
+ default=True,
289
+ )
290
+
291
+ knowledge_domains: dict = existing.get("knowledge_domains", {})
292
+ if use_bridging:
293
+ current_primary = knowledge_domains.get("primary", "")
294
+ primary_domain = _prompt_text(
295
+ " What is your primary area of expertise?",
296
+ default=current_primary or "networking",
297
+ )
298
+ knowledge_domains["primary"] = primary_domain
299
+
300
+ console.print(
301
+ f"\n [dim]Great — the mentor will use {primary_domain} analogies"
302
+ " to teach new concepts.[/dim]"
303
+ )
304
+ console.print(
305
+ " [dim]You can add specific anchor concepts later with: studyctl bridge add[/dim]\n"
306
+ )
307
+ else:
308
+ knowledge_domains = {}
309
+ console.print(" [dim]Skipped — no knowledge bridging configured.[/dim]\n")
310
+
311
+ # ── Question 2: NotebookLM integration ──────────────────────────────────
312
+ console.print("[bold cyan]2/3 — Google NotebookLM Integration[/bold cyan]")
313
+ console.print(
314
+ "NotebookLM can be used as a knowledge source — sync your notes into\n"
315
+ "notebooks for AI-generated audio overviews and enhanced study sessions.\n"
316
+ )
317
+
318
+ use_notebooklm = _prompt_yn(
319
+ "Do you want to integrate with Google's NotebookLM to use new and\n"
320
+ " existing Notebooks as a source of knowledge?",
321
+ default=bool(existing.get("notebooklm", {}).get("enabled")),
322
+ )
323
+
324
+ notebooklm_config: dict = existing.get("notebooklm", {})
325
+ if use_notebooklm:
326
+ notebooklm_config["enabled"] = True
327
+ console.print("\n [dim]NotebookLM enabled. Map notebooks to topics via:[/dim]")
328
+ console.print(
329
+ " [dim] studyctl sync <topic> — syncs Obsidian notes to a NotebookLM notebook[/dim]"
330
+ )
331
+ console.print(" [dim] Requires: uv pip install 'studyctl[notebooklm]'[/dim]\n")
332
+ else:
333
+ notebooklm_config["enabled"] = False
334
+ console.print(" [dim]Skipped — NotebookLM integration disabled.[/dim]\n")
335
+
336
+ # ── Question 3: Obsidian vault ──────────────────────────────────────────
337
+ console.print("[bold cyan]3/3 — Obsidian Vault Integration[/bold cyan]")
338
+ console.print(
339
+ "The study mentor can read your Obsidian vault for study notes,\n"
340
+ "course materials, and knowledge base content.\n"
341
+ )
342
+
343
+ current_obsidian = str(existing.get("obsidian_base", "~/Obsidian"))
344
+ use_obsidian = _prompt_yn(
345
+ "Do you want to integrate with an existing Obsidian vault for sources\n of information?",
346
+ default=True,
347
+ )
348
+
349
+ obsidian_base = current_obsidian
350
+ if use_obsidian:
351
+ obsidian_base = _prompt_text(
352
+ " Base path of your Obsidian vault",
353
+ default=current_obsidian,
354
+ )
355
+ resolved = Path(obsidian_base).expanduser()
356
+ if resolved.exists():
357
+ console.print(f" [green]✓ Found vault at {resolved}[/green]\n")
358
+ else:
359
+ console.print(
360
+ f" [yellow]⚠ Path {resolved} does not exist yet — "
361
+ f"you can create it later.[/yellow]\n"
362
+ )
363
+ else:
364
+ obsidian_base = ""
365
+ console.print(" [dim]Skipped — no Obsidian vault configured.[/dim]\n")
366
+
367
+ # ── Write config ────────────────────────────────────────────────────────
368
+ config = dict(existing)
369
+ if obsidian_base:
370
+ config["obsidian_base"] = obsidian_base
371
+ elif "obsidian_base" in config:
372
+ del config["obsidian_base"]
373
+
374
+ if knowledge_domains:
375
+ config["knowledge_domains"] = knowledge_domains
376
+ elif "knowledge_domains" in config:
377
+ del config["knowledge_domains"]
378
+
379
+ config["notebooklm"] = notebooklm_config
380
+
381
+ # Ensure topics key exists
382
+ config.setdefault("topics", [])
383
+
384
+ CONFIG_PATH.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
385
+ return CONFIG_PATH
386
+
387
+
388
+ def _prompt_yn(question: str, default: bool = False) -> bool:
389
+ """Prompt for yes/no with a default."""
390
+ suffix = " [Y/n] " if default else " [y/N] "
391
+ reply = input(question + suffix).strip().lower()
392
+ if not reply:
393
+ return default
394
+ return reply in ("y", "yes")
395
+
396
+
397
+ def _prompt_text(question: str, default: str = "") -> str:
398
+ """Prompt for text input with a default."""
399
+ suffix = f" [{default}] " if default else " "
400
+ reply = input(question + suffix).strip()
401
+ return reply or default
402
+
403
+
404
+ def init_config() -> Path:
405
+ """Create default config file with unified hosts schema."""
406
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
407
+ if CONFIG_PATH.exists():
408
+ return CONFIG_PATH
409
+
410
+ hostname = socket.gethostname().split(".")[0]
411
+ default = {
412
+ "hosts": {
413
+ hostname.lower().replace(" ", "-"): {
414
+ "hostname": hostname,
415
+ "ip_address": {
416
+ "primary": "",
417
+ },
418
+ "user": _get_default_user(),
419
+ "state_json": "~/.config/studyctl/state.json",
420
+ "sessions_db": "~/.config/studyctl/sessions.db",
421
+ },
422
+ },
423
+ }
424
+ CONFIG_PATH.write_text(yaml.dump(default, default_flow_style=False, sort_keys=False))
425
+ return CONFIG_PATH
studyctl/state.py ADDED
@@ -0,0 +1,120 @@
1
+ """Sync state — tracks what's been synced to prevent duplication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from dataclasses import asdict, dataclass, field
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .settings import get_state_dir, get_state_file
13
+
14
+
15
+ @dataclass
16
+ class SyncedSource:
17
+ """A file that has been synced to NotebookLM."""
18
+
19
+ path: str # Relative to home
20
+ content_hash: str
21
+ source_id: str # NotebookLM source ID
22
+ synced_at: str
23
+ notebook_id: str
24
+
25
+
26
+ @dataclass
27
+ class TopicState:
28
+ """State for a single topic/notebook."""
29
+
30
+ topic_name: str
31
+ notebook_id: str | None = None
32
+ notebook_title: str = ""
33
+ sources: dict[str, SyncedSource] = field(default_factory=dict) # path → SyncedSource
34
+ last_sync: str = ""
35
+ last_audio_generated: str = ""
36
+
37
+
38
+ class SyncState:
39
+ """Persistent state for the sync pipeline."""
40
+
41
+ def __init__(self) -> None:
42
+ self._data: dict[str, Any] = {"version": 1, "topics": {}}
43
+ self._load()
44
+
45
+ def _load(self) -> None:
46
+ if get_state_file().exists():
47
+ try:
48
+ self._data = json.loads(get_state_file().read_text())
49
+ except json.JSONDecodeError:
50
+ import sys
51
+
52
+ print(
53
+ f"[studyctl] Corrupt state file {get_state_file()}, using defaults",
54
+ file=sys.stderr,
55
+ )
56
+
57
+ def save(self) -> None:
58
+ import os
59
+
60
+ get_state_dir().mkdir(parents=True, exist_ok=True)
61
+ get_state_file().write_text(json.dumps(self._data, indent=2) + "\n")
62
+ os.chmod(get_state_file(), 0o600)
63
+
64
+ def get_topic(self, name: str) -> TopicState:
65
+ raw = self._data.setdefault("topics", {}).get(name, {})
66
+ sources = {}
67
+ for path, s in raw.get("sources", {}).items():
68
+ sources[path] = SyncedSource(**s)
69
+ return TopicState(
70
+ topic_name=name,
71
+ notebook_id=raw.get("notebook_id"),
72
+ notebook_title=raw.get("notebook_title", ""),
73
+ sources=sources,
74
+ last_sync=raw.get("last_sync", ""),
75
+ last_audio_generated=raw.get("last_audio_generated", ""),
76
+ )
77
+
78
+ def set_topic(self, state: TopicState) -> None:
79
+ self._data.setdefault("topics", {})[state.topic_name] = {
80
+ "notebook_id": state.notebook_id,
81
+ "notebook_title": state.notebook_title,
82
+ "sources": {p: asdict(s) for p, s in state.sources.items()},
83
+ "last_sync": state.last_sync,
84
+ "last_audio_generated": state.last_audio_generated,
85
+ }
86
+
87
+ def set_notebook_id(self, topic_name: str, notebook_id: str, title: str) -> None:
88
+ ts = self.get_topic(topic_name)
89
+ ts.notebook_id = notebook_id
90
+ ts.notebook_title = title
91
+ self.set_topic(ts)
92
+
93
+ def record_sync(
94
+ self, topic_name: str, path: str, content_hash: str, source_id: str, notebook_id: str
95
+ ) -> None:
96
+ ts = self.get_topic(topic_name)
97
+ ts.sources[path] = SyncedSource(
98
+ path=path,
99
+ content_hash=content_hash,
100
+ source_id=source_id,
101
+ synced_at=datetime.now(UTC).isoformat(),
102
+ notebook_id=notebook_id,
103
+ )
104
+ ts.last_sync = datetime.now(UTC).isoformat()
105
+ self.set_topic(ts)
106
+
107
+ def needs_sync(self, path: Path) -> bool:
108
+ """Check if a file has changed since last sync."""
109
+ rel = str(path.relative_to(Path.home()))
110
+ current_hash = file_hash(path)
111
+ for topic_data in self._data.get("topics", {}).values():
112
+ for synced_path, source in topic_data.get("sources", {}).items():
113
+ if synced_path == rel and source.get("content_hash") == current_hash:
114
+ return False
115
+ return True
116
+
117
+
118
+ def file_hash(path: Path) -> str:
119
+ """SHA256 of file content."""
120
+ return hashlib.sha256(path.read_bytes()).hexdigest()[:16]