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/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]
|