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
@@ -0,0 +1,491 @@
1
+ """Review commands — spaced repetition, progress, teachback, bridges."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+ from rich.table import Table
9
+
10
+ from studyctl.cli._shared import TOPIC_KEYWORDS, console
11
+ from studyctl.history import (
12
+ get_bridges,
13
+ get_teachback_history,
14
+ record_bridge,
15
+ record_teachback,
16
+ spaced_repetition_due,
17
+ struggle_topics,
18
+ )
19
+
20
+
21
+ @click.command()
22
+ def review() -> None:
23
+ """Check what's due for spaced repetition review."""
24
+ due = spaced_repetition_due(TOPIC_KEYWORDS)
25
+ if not due:
26
+ console.print("[green]Nothing due for review[/green]")
27
+ return
28
+
29
+ table = Table(title="Spaced Repetition \u2014 Due for Review")
30
+ table.add_column("Topic", style="bold cyan")
31
+ table.add_column("Last Studied")
32
+ table.add_column("Days Ago", justify="right")
33
+ table.add_column("Review Type", style="yellow")
34
+
35
+ for item in due:
36
+ days = str(item["days_ago"]) if item["days_ago"] is not None else "never"
37
+ last = item["last_studied"] or "never"
38
+ table.add_row(item["topic"], last, days, item["review_type"])
39
+
40
+ console.print(table)
41
+
42
+
43
+ @click.command()
44
+ @click.option("--days", "-d", default=30, help="Look back N days")
45
+ def struggles(days: int) -> None:
46
+ """Find topics you keep asking about (potential struggle areas)."""
47
+ topics = struggle_topics(days=days)
48
+ if not topics:
49
+ console.print("[dim]No recurring struggle topics found[/dim]")
50
+ return
51
+
52
+ console.print("[bold]Topics appearing in 3+ sessions (potential struggle areas):[/bold]\n")
53
+ for t in topics:
54
+ bar = "\u2588" * min(t["mentions"], 20)
55
+ console.print(f" [cyan]{t['topic']:20s}[/cyan] {bar} ({t['mentions']} mentions)")
56
+
57
+
58
+ @click.command()
59
+ @click.option("--days", "-d", default=30, help="Look back period in days.")
60
+ def wins(days: int) -> None:
61
+ """Show your learning wins \u2014 concepts you've mastered."""
62
+ from studyctl.history import get_progress_summary, get_wins
63
+
64
+ summary = get_progress_summary()
65
+ if not summary:
66
+ console.print("[dim]No progress data yet. Use your study mentor to start tracking![/dim]")
67
+ return
68
+
69
+ total = summary.get("total", 0)
70
+ mastered = summary.get("mastered", 0)
71
+ confident = summary.get("confident", 0)
72
+ learning = summary.get("learning", 0)
73
+ struggling = summary.get("struggling", 0)
74
+
75
+ console.print("\n[bold]\U0001f4ca Progress Overview[/bold]")
76
+ console.print(
77
+ f" \U0001f3c6 Mastered: {mastered} "
78
+ f"\u2705 Confident: {confident} "
79
+ f"\U0001f4d6 Learning: {learning} "
80
+ f"\U0001f527 Struggling: {struggling} "
81
+ f"({total} total)"
82
+ )
83
+
84
+ recent = get_wins(days=days)
85
+ if recent:
86
+ console.print(f"\n[bold green]\U0001f389 Wins in the last {days} days:[/bold green]")
87
+ for w in recent:
88
+ emoji = "\U0001f3c6" if w["confidence"] == "mastered" else "\u2705"
89
+ console.print(
90
+ f" {emoji} [bold]{w['concept']}[/bold] ({w['topic']}) "
91
+ f"\u2014 {w['session_count']} sessions"
92
+ )
93
+ else:
94
+ console.print(f"\n[dim]No new wins in the last {days} days. Keep going! \U0001f4aa[/dim]")
95
+
96
+
97
+ @click.command()
98
+ @click.argument("concept")
99
+ @click.option("--topic", "-t", required=True, help="Study topic.")
100
+ @click.option(
101
+ "--confidence",
102
+ "-c",
103
+ type=click.Choice(["struggling", "learning", "confident", "mastered"]),
104
+ required=True,
105
+ help="Current confidence level.",
106
+ )
107
+ @click.option("--notes", "-n", default=None, help="Optional notes.")
108
+ def progress(concept: str, topic: str, confidence: str, notes: str | None) -> None:
109
+ """Record progress on a concept."""
110
+ from studyctl.history import record_progress
111
+
112
+ if record_progress(topic, concept, confidence, notes=notes):
113
+ emoji = {
114
+ "struggling": "\U0001f527",
115
+ "learning": "\U0001f4d6",
116
+ "confident": "\u2705",
117
+ "mastered": "\U0001f3c6",
118
+ }
119
+ console.print(
120
+ f"{emoji.get(confidence, '\U0001f4dd')} Recorded: "
121
+ f"[bold]{concept}[/bold] ({topic}) \u2192 {confidence}"
122
+ )
123
+ else:
124
+ console.print("[red]Failed to record progress. Check your session database path.[/red]")
125
+
126
+
127
+ @click.command()
128
+ def resume() -> None:
129
+ """Show where you left off \u2014 last session summary for quick context reload."""
130
+ from studyctl.history import (
131
+ check_medication_window,
132
+ get_last_session_summary,
133
+ get_study_streaks,
134
+ )
135
+
136
+ summary = get_last_session_summary()
137
+ if not summary:
138
+ console.print("[dim]No sessions found. Start a study session to begin tracking![/dim]")
139
+ return
140
+
141
+ console.print("[bold]Where you left off:[/bold]\n")
142
+
143
+ source = summary["source"].replace("_", " ").title()
144
+ updated = summary.get("updated") or summary["started"]
145
+ if updated:
146
+ updated = updated[:16].replace("T", " ")
147
+ console.print(f" Last session: [cyan]{source}[/cyan] ({updated})")
148
+
149
+ if summary["topics_covered"]:
150
+ topics_str = ", ".join(summary["topics_covered"])
151
+ console.print(f" Topics: [bold]{topics_str}[/bold]")
152
+
153
+ if summary["last_message_preview"]:
154
+ preview = summary["last_message_preview"]
155
+ if len(preview) > 150:
156
+ preview = preview[:150] + "..."
157
+ console.print(f" Context: [dim]{preview}[/dim]")
158
+
159
+ if summary["concepts_in_progress"]:
160
+ console.print("\n[bold]In progress:[/bold]")
161
+ for c in summary["concepts_in_progress"]:
162
+ emoji = "\U0001f527" if c["confidence"] == "struggling" else "\U0001f4d6"
163
+ console.print(f" {emoji} {c['concept']} ({c['topic']}) \u2014 {c['confidence']}")
164
+
165
+ streak_data = get_study_streaks()
166
+ if streak_data["current_streak"] > 0:
167
+ console.print(
168
+ f"\n Streak: [bold green]{streak_data['current_streak']} days[/bold green]"
169
+ f" (best: {streak_data['longest_streak']})"
170
+ f" | This week: {streak_data['sessions_this_week']} sessions"
171
+ )
172
+
173
+ # Medication window (if configured)
174
+ raw_config = {}
175
+ config_path = Path.home() / ".config" / "studyctl" / "config.yaml"
176
+ if config_path.exists():
177
+ import yaml
178
+
179
+ raw_config = yaml.safe_load(config_path.read_text()) or {}
180
+ med_config = raw_config.get("medication")
181
+ if med_config:
182
+ med = check_medication_window(med_config)
183
+ if med:
184
+ phase_emoji = {
185
+ "onset": "\U0001f48a",
186
+ "peak": "\U0001f9e0",
187
+ "tapering": "\U0001f4c9",
188
+ "worn_off": "\U0001f634",
189
+ }
190
+ emoji = phase_emoji.get(med["phase"], "\U0001f48a")
191
+ console.print(
192
+ f"\n {emoji} Meds: [bold]{med['phase']}[/bold] \u2014 {med['recommendation']}"
193
+ )
194
+
195
+
196
+ @click.command()
197
+ def streaks() -> None:
198
+ """Show your study streak and consistency stats."""
199
+ from studyctl.history import get_study_streaks
200
+
201
+ data = get_study_streaks()
202
+ if not data.get("last_session_date"):
203
+ console.print("[dim]No study sessions found yet.[/dim]")
204
+ return
205
+
206
+ console.print("\n[bold]Study Consistency[/bold]\n")
207
+
208
+ current = data["current_streak"]
209
+ longest = data["longest_streak"]
210
+ fire = "\U0001f525" if current >= 3 else ""
211
+ console.print(f" Current streak: [bold green]{current} days[/bold green] {fire}")
212
+ console.print(f" Longest streak: [bold]{longest} days[/bold]")
213
+ console.print(f" Study days (last 90): [bold]{data['total_days']}[/bold]")
214
+ console.print(f" Sessions this week: [bold]{data['sessions_this_week']}[/bold]")
215
+ console.print(f" Last session: {data['last_session_date']}")
216
+
217
+ consistency = data["total_days"] / 90 * 100
218
+ bar_len = int(consistency / 5)
219
+ bar = "\u2588" * bar_len + "\u2591" * (20 - bar_len)
220
+ console.print(f"\n Consistency: [{bar}] {consistency:.0f}%")
221
+
222
+ if current == 0:
223
+ console.print(
224
+ "\n [dim]No session today or yesterday. Start one to keep your streak going![/dim]"
225
+ )
226
+
227
+
228
+ @click.command("progress-map")
229
+ def progress_map() -> None:
230
+ """Show a visual progress map of all tracked concepts."""
231
+ from studyctl.history import get_progress_for_map
232
+
233
+ entries = get_progress_for_map()
234
+ if not entries:
235
+ console.print(
236
+ "[dim]No progress data yet."
237
+ " Use your study mentor and 'studyctl progress' to start tracking![/dim]"
238
+ )
239
+ return
240
+
241
+ by_topic: dict[str, list[dict]] = {}
242
+ for entry in entries:
243
+ by_topic.setdefault(entry["topic"], []).append(entry)
244
+
245
+ conf_style = {
246
+ "mastered": ("\U0001f3c6", "bold green"),
247
+ "confident": ("\u2705", "green"),
248
+ "learning": ("\U0001f4d6", "yellow"),
249
+ "struggling": ("\U0001f527", "red"),
250
+ }
251
+
252
+ console.print("\n[bold]Progress Map[/bold]\n")
253
+
254
+ for topic, concepts in sorted(by_topic.items()):
255
+ console.print(f" [bold cyan]{topic}[/bold cyan]")
256
+ for c in concepts:
257
+ emoji, style = conf_style.get(c["confidence"], ("\U0001f4dd", "dim"))
258
+ sessions = c["session_count"]
259
+ console.print(
260
+ f" {emoji} [{style}]{c['concept']}[/{style}]"
261
+ f" \u2014 {c['confidence']} ({sessions} sessions)"
262
+ )
263
+ console.print()
264
+
265
+ console.print("[bold]Mermaid diagram (paste into any Mermaid renderer):[/bold]\n")
266
+ console.print("```mermaid")
267
+ console.print("graph TD")
268
+ for topic, concepts in sorted(by_topic.items()):
269
+ topic_id = topic.replace(" ", "_").replace("-", "_")
270
+ console.print(f' {topic_id}["{topic}"]')
271
+ for c in concepts:
272
+ concept_id = f"{topic_id}_{c['concept'].replace(' ', '_').replace('-', '_')}"
273
+ conf = c["confidence"]
274
+ console.print(f' {topic_id} --> {concept_id}["{c["concept"]}"]')
275
+ console.print(f" class {concept_id} {conf}")
276
+ console.print()
277
+ console.print(" classDef mastered fill:#10b981,color:#fff")
278
+ console.print(" classDef confident fill:#3b82f6,color:#fff")
279
+ console.print(" classDef learning fill:#f59e0b,color:#000")
280
+ console.print(" classDef struggling fill:#ef4444,color:#fff")
281
+ console.print("```")
282
+
283
+
284
+ @click.command()
285
+ @click.argument("concept")
286
+ @click.option("--topic", "-t", required=True, help="Study topic.")
287
+ @click.option(
288
+ "--score",
289
+ "-s",
290
+ required=True,
291
+ help="Comma-separated scores: accuracy,own_words,structure,depth,transfer (each 1-4).",
292
+ )
293
+ @click.option(
294
+ "--type",
295
+ "review_type",
296
+ type=click.Choice(["micro", "structured", "transfer", "full"]),
297
+ required=True,
298
+ help="Type of teach-back review.",
299
+ )
300
+ @click.option("--angle", "-a", default=None, help="Question angle used (e.g. bloom_apply).")
301
+ @click.option("--notes", "-n", default=None, help="Optional notes.")
302
+ def teachback(
303
+ concept: str,
304
+ topic: str,
305
+ score: str,
306
+ review_type: str,
307
+ angle: str | None,
308
+ notes: str | None,
309
+ ) -> None:
310
+ """Record a teach-back score for a concept.
311
+
312
+ Example: studyctl teachback "Spark partitioning" -t spark --score "3,3,4,3,2" --type structured
313
+ """
314
+ parts = score.split(",")
315
+ if len(parts) != 5:
316
+ console.print(
317
+ "[red]Score must be 5 comma-separated values"
318
+ " (accuracy,own_words,structure,depth,transfer)[/red]"
319
+ )
320
+ raise SystemExit(1)
321
+
322
+ try:
323
+ scores = tuple(int(p.strip()) for p in parts)
324
+ except ValueError:
325
+ console.print("[red]Each score must be an integer 1-4[/red]")
326
+ raise SystemExit(1) from None
327
+
328
+ for s in scores:
329
+ if not 1 <= s <= 4:
330
+ console.print("[red]Each score must be between 1 and 4[/red]")
331
+ raise SystemExit(1)
332
+
333
+ if record_teachback(concept, topic, scores, review_type, angle=angle, notes=notes): # type: ignore[arg-type]
334
+ total = sum(scores)
335
+ if total >= 18:
336
+ label = "Mastery demonstrated"
337
+ style = "bold green"
338
+ elif total >= 14:
339
+ label = "Solid understanding"
340
+ style = "green"
341
+ elif total >= 9:
342
+ label = "Partial understanding"
343
+ style = "yellow"
344
+ else:
345
+ label = "Memorised, not understood"
346
+ style = "red"
347
+
348
+ console.print(
349
+ f"[{style}]{label}[/{style}] \u2014 [bold]{concept}[/bold] ({topic}): {total}/20"
350
+ )
351
+ a, o, s, d, t = scores
352
+ console.print(f" Accuracy: {a} Own Words: {o} Structure: {s} Depth: {d} Transfer: {t}")
353
+ else:
354
+ console.print("[red]Failed to record teach-back. Check your session database.[/red]")
355
+
356
+
357
+ @click.command("teachback-history")
358
+ @click.argument("concept")
359
+ @click.option("--topic", "-t", default=None, help="Filter by topic.")
360
+ def teachback_history_cmd(concept: str, topic: str | None) -> None:
361
+ """Show teach-back score progression for a concept."""
362
+ history = get_teachback_history(concept, topic)
363
+ if not history:
364
+ console.print(f"[dim]No teach-back history for '{concept}'[/dim]")
365
+ return
366
+
367
+ table = Table(title=f"Teach-Back History: {concept}")
368
+ table.add_column("Date", style="dim")
369
+ table.add_column("Type")
370
+ table.add_column("Total", justify="right", style="bold")
371
+ table.add_column("A", justify="center")
372
+ table.add_column("O", justify="center")
373
+ table.add_column("S", justify="center")
374
+ table.add_column("D", justify="center")
375
+ table.add_column("T", justify="center")
376
+ table.add_column("Angle", style="dim")
377
+
378
+ for entry in history:
379
+ total = entry["total_score"]
380
+ if total >= 18:
381
+ total_style = "[bold green]"
382
+ elif total >= 14:
383
+ total_style = "[green]"
384
+ elif total >= 9:
385
+ total_style = "[yellow]"
386
+ else:
387
+ total_style = "[red]"
388
+ total_str = f"{total_style}{total}[/]"
389
+
390
+ date = entry["created_at"][:10] if entry["created_at"] else "?"
391
+ table.add_row(
392
+ date,
393
+ entry["review_type"],
394
+ total_str,
395
+ str(entry["score_accuracy"] or ""),
396
+ str(entry["score_own_words"] or ""),
397
+ str(entry["score_structure"] or ""),
398
+ str(entry["score_depth"] or ""),
399
+ str(entry["score_transfer"] or ""),
400
+ entry["question_angle"] or "",
401
+ )
402
+
403
+ console.print(table)
404
+
405
+
406
+ # --- Knowledge bridges ---
407
+
408
+
409
+ @click.group(name="bridge")
410
+ def bridge_group() -> None:
411
+ """Manage knowledge bridges between domains."""
412
+
413
+
414
+ @bridge_group.command(name="add")
415
+ @click.argument("source")
416
+ @click.argument("target")
417
+ @click.option("--source-domain", "-s", required=True, help="Source domain (e.g. networking).")
418
+ @click.option("--target-domain", "-t", required=True, help="Target domain (e.g. spark).")
419
+ @click.option("--mapping", "-m", default=None, help="Why they map (structural similarity).")
420
+ @click.option(
421
+ "--quality",
422
+ "-q",
423
+ type=click.Choice(["proposed", "validated", "effective", "misleading", "rejected"]),
424
+ default="validated",
425
+ help="Bridge quality.",
426
+ )
427
+ def bridge_add(
428
+ source: str,
429
+ target: str,
430
+ source_domain: str,
431
+ target_domain: str,
432
+ mapping: str | None,
433
+ quality: str,
434
+ ) -> None:
435
+ """Add a knowledge bridge between two concepts.
436
+
437
+ Example: studyctl bridge add "ECMP load balancing" "Spark partition distribution"
438
+ -s networking -t spark -m "distribute work across parallel processors"
439
+ """
440
+ if record_bridge(source, source_domain, target, target_domain, mapping, quality, "student"):
441
+ console.print(
442
+ f"[green]Bridge added:[/green] "
443
+ f"[bold]{source}[/bold] ({source_domain}) "
444
+ f"-> [bold]{target}[/bold] ({target_domain})"
445
+ )
446
+ else:
447
+ console.print("[red]Failed to add bridge. Check your session database.[/red]")
448
+
449
+
450
+ @bridge_group.command(name="list")
451
+ @click.option("--source-domain", "-s", default=None, help="Filter by source domain.")
452
+ @click.option("--target-domain", "-t", default=None, help="Filter by target domain.")
453
+ @click.option("--quality", "-q", default=None, help="Filter by quality.")
454
+ def bridge_list(source_domain: str | None, target_domain: str | None, quality: str | None) -> None:
455
+ """List knowledge bridges."""
456
+ bridges = get_bridges(target_domain=target_domain, source_domain=source_domain, quality=quality)
457
+ if not bridges:
458
+ console.print("[dim]No bridges found. Use 'studyctl bridge add' to create some.[/dim]")
459
+ return
460
+
461
+ table = Table(title="Knowledge Bridges")
462
+ table.add_column("Source", style="cyan")
463
+ table.add_column("Domain", style="dim")
464
+ table.add_column("Target", style="bold")
465
+ table.add_column("Domain", style="dim")
466
+ table.add_column("Quality")
467
+ table.add_column("Used", justify="right")
468
+ table.add_column("Helpful", justify="right")
469
+
470
+ quality_style = {
471
+ "effective": "bold green",
472
+ "validated": "green",
473
+ "proposed": "yellow",
474
+ "misleading": "red",
475
+ "rejected": "dim red",
476
+ }
477
+
478
+ for b in bridges:
479
+ q = b["quality"]
480
+ style = quality_style.get(q, "dim")
481
+ table.add_row(
482
+ b["source_concept"],
483
+ b["source_domain"],
484
+ b["target_concept"],
485
+ b["target_domain"],
486
+ f"[{style}]{q}[/{style}]",
487
+ str(b["times_used"]),
488
+ str(b["times_helpful"]),
489
+ )
490
+
491
+ console.print(table)
@@ -0,0 +1,125 @@
1
+ """Schedule commands — job management and calendar blocks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timedelta
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from studyctl.cli._shared import TOPIC_KEYWORDS, console
11
+ from studyctl.scheduler import (
12
+ Job,
13
+ install_all,
14
+ install_job,
15
+ list_jobs,
16
+ remove_all,
17
+ remove_job,
18
+ )
19
+
20
+
21
+ @click.group(name="schedule")
22
+ def schedule_group() -> None:
23
+ """Manage scheduled jobs (launchd on macOS, cron on Linux)."""
24
+
25
+
26
+ @schedule_group.command(name="install")
27
+ @click.option("--username", "-u", help="Username for paths (default: current user)")
28
+ def schedule_install(username: str | None) -> None:
29
+ """Install all scheduled jobs."""
30
+ installed = install_all(username)
31
+ for name in installed:
32
+ console.print(f"[green]\u2713[/green] Installed {name}")
33
+ if not installed:
34
+ console.print("[dim]No jobs installed[/dim]")
35
+
36
+
37
+ @schedule_group.command(name="remove")
38
+ def schedule_remove() -> None:
39
+ """Remove all scheduled jobs."""
40
+ removed = remove_all()
41
+ for name in removed:
42
+ console.print(f"[green]\u2713[/green] Removed {name}")
43
+
44
+
45
+ @schedule_group.command(name="list")
46
+ def schedule_list() -> None:
47
+ """List active scheduled jobs."""
48
+ jobs = list_jobs()
49
+ if not jobs:
50
+ console.print("[dim]No studyctl jobs scheduled[/dim]")
51
+ console.print("Run: studyctl schedule install")
52
+ return
53
+ for j in jobs:
54
+ console.print(f" {j['name']}: {j.get('status', j.get('cron', '?'))}")
55
+
56
+
57
+ @schedule_group.command(name="add")
58
+ @click.argument("name")
59
+ @click.argument("command")
60
+ @click.argument("schedule")
61
+ @click.option("--username", "-u", help="Username for paths")
62
+ def schedule_add(name: str, command: str, schedule: str, username: str | None) -> None:
63
+ """Add a custom scheduled job.
64
+
65
+ Example: studyctl schedule add my-backup "~/scripts/backup.sh" "daily 3am"
66
+ """
67
+ job = Job(name=name, command=command, schedule=schedule)
68
+ if install_job(job, username):
69
+ console.print(f"[green]\u2713[/green] Added {name} ({schedule})")
70
+ else:
71
+ console.print(f"[red]Failed to add {name}[/red]")
72
+
73
+
74
+ @schedule_group.command(name="delete")
75
+ @click.argument("name")
76
+ def schedule_delete(name: str) -> None:
77
+ """Remove a specific scheduled job."""
78
+ job = Job(name=name, command="", schedule="")
79
+ if remove_job(job):
80
+ console.print(f"[green]\u2713[/green] Removed {name}")
81
+
82
+
83
+ @click.command("schedule-blocks")
84
+ @click.option("--start", "-s", default=None, help="Start time (HH:MM, default: next hour).")
85
+ @click.option("--gap", "-g", default=10, help="Minutes between sessions.")
86
+ @click.option("--output", "-o", default=None, type=click.Path(), help="Output directory.")
87
+ @click.option("--open/--no-open", "open_file", default=True, help="Open .ics file after creation.")
88
+ def schedule_blocks(start: str | None, gap: int, output: str | None, open_file: bool) -> None:
89
+ """Create calendar time blocks from spaced repetition schedule."""
90
+ from studyctl.calendar import schedule_reviews, write_ics
91
+ from studyctl.history import spaced_repetition_due
92
+
93
+ due = spaced_repetition_due(TOPIC_KEYWORDS)
94
+ if not due:
95
+ console.print("[green]Nothing due for review! \N{PARTY POPPER}[/green]")
96
+ return
97
+
98
+ start_time = None
99
+ if start:
100
+ now = datetime.now()
101
+ h, m = start.split(":")
102
+ start_time = now.replace(hour=int(h), minute=int(m), second=0, microsecond=0)
103
+ if start_time < now:
104
+ start_time += timedelta(days=1)
105
+
106
+ events = schedule_reviews(due, start_time=start_time, gap_minutes=gap)
107
+ output_dir = Path(output) if output else None
108
+ path = write_ics(events, output_dir=output_dir)
109
+
110
+ console.print(f"\n[bold]\N{CALENDAR} Created {len(events)} study blocks:[/bold]")
111
+ for evt in events:
112
+ t = evt["start"].strftime("%H:%M")
113
+ console.print(
114
+ f" {t} \u2014 {evt['topic']} ({evt['review_type']}, {evt['duration_min']}min)"
115
+ )
116
+ console.print(f"\n[dim]Saved to: {path}[/dim]")
117
+
118
+ if open_file:
119
+ import platform
120
+ import subprocess
121
+
122
+ if platform.system() == "Darwin":
123
+ subprocess.run(["open", str(path)], check=False)
124
+ elif platform.system() == "Linux":
125
+ subprocess.run(["xdg-open", str(path)], check=False)