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/settings.py ADDED
@@ -0,0 +1,367 @@
1
+ """Centralized configuration loader for studyctl.
2
+
3
+ Loads from ~/.config/studyctl/config.yaml with sensible defaults.
4
+ All configuration types, topic mapping, and path resolution live here.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ import yaml
14
+
15
+ CONFIG_DIR = Path.home() / ".config" / "studyctl"
16
+ DEFAULT_DB = CONFIG_DIR / "sessions.db"
17
+
18
+ _CONFIG_PATH = Path(os.environ.get("STUDYCTL_CONFIG", CONFIG_DIR / "config.yaml"))
19
+
20
+ # File extensions we sync as sources
21
+ SYNCABLE_EXTENSIONS = {".md", ".pdf", ".txt"}
22
+
23
+ # Skip patterns -- files/dirs that are never worth syncing
24
+ SKIP_PATTERNS = {
25
+ ".space",
26
+ ".checkpoint.json",
27
+ "def.json",
28
+ ".obsidian",
29
+ "node_modules",
30
+ "__pycache__",
31
+ }
32
+
33
+ # Files that are low-value noise (Obsidian metadata, empty templates, etc.)
34
+ SKIP_FILENAMES = {
35
+ "Courses.md", # Index file, not content
36
+ }
37
+
38
+ # Minimum file size to sync (skip empty/stub files)
39
+ MIN_FILE_SIZE = 100 # bytes
40
+
41
+
42
+ def _get_username() -> str:
43
+ """Get current username safely (works in cron, CI, and non-interactive environments)."""
44
+ try:
45
+ return os.getlogin()
46
+ except OSError:
47
+ import getpass
48
+
49
+ return getpass.getuser()
50
+
51
+
52
+ @dataclass
53
+ class TopicConfig:
54
+ """Configuration for a single study topic."""
55
+
56
+ name: str
57
+ slug: str
58
+ obsidian_path: Path
59
+ notebook_id: str = ""
60
+ tags: list[str] = field(default_factory=list)
61
+
62
+
63
+ @dataclass
64
+ class KnowledgeDomain:
65
+ """Configuration for a knowledge domain used in concept bridging."""
66
+
67
+ domain: str
68
+ anchors: list[str] = field(default_factory=list)
69
+
70
+
71
+ @dataclass
72
+ class KnowledgeDomainsConfig:
73
+ """Configuration for the knowledge bridging system."""
74
+
75
+ primary: str = "networking"
76
+ anchors: list[dict[str, str | int]] = field(default_factory=list)
77
+ secondary: list[KnowledgeDomain] = field(default_factory=list)
78
+
79
+
80
+ @dataclass
81
+ class NotebookLMConfig:
82
+ """Configuration for Google NotebookLM integration."""
83
+
84
+ enabled: bool = False
85
+
86
+
87
+ @dataclass
88
+ class ContentConfig:
89
+ """Configuration for the content pipeline (pdf-by-chapters absorption)."""
90
+
91
+ base_path: Path = field(default_factory=lambda: Path.home() / "study-materials")
92
+ notebooklm_timeout: int = 900
93
+ inter_episode_gap: int = 30
94
+ default_types: list[str] = field(default_factory=lambda: ["audio"])
95
+ pandoc_path: str = "pandoc"
96
+
97
+
98
+ @dataclass
99
+ class Settings:
100
+ """Application settings loaded from config file."""
101
+
102
+ obsidian_base: Path = field(default_factory=lambda: Path.home() / "Obsidian")
103
+ session_db: Path = field(
104
+ default_factory=lambda: Path.home() / ".config" / "studyctl" / "sessions.db"
105
+ )
106
+ state_dir: Path = field(default_factory=lambda: Path.home() / ".local" / "share" / "studyctl")
107
+ topics: list[TopicConfig] = field(default_factory=list)
108
+ sync_remote: str = ""
109
+ sync_user: str = field(default_factory=lambda: _get_username())
110
+ knowledge_domains: KnowledgeDomainsConfig = field(default_factory=KnowledgeDomainsConfig)
111
+ notebooklm: NotebookLMConfig = field(default_factory=NotebookLMConfig)
112
+ content: ContentConfig = field(default_factory=ContentConfig)
113
+
114
+
115
+ def load_settings() -> Settings:
116
+ """Load settings from config file, falling back to defaults."""
117
+ settings = Settings()
118
+ if not _CONFIG_PATH.exists():
119
+ return settings
120
+
121
+ with open(_CONFIG_PATH) as f:
122
+ raw = yaml.safe_load(f) or {}
123
+
124
+ if "obsidian_base" in raw:
125
+ settings.obsidian_base = Path(raw["obsidian_base"]).expanduser()
126
+ if "session_db" in raw:
127
+ settings.session_db = Path(raw["session_db"]).expanduser()
128
+ if "state_dir" in raw:
129
+ settings.state_dir = Path(raw["state_dir"]).expanduser()
130
+ if "sync_remote" in raw:
131
+ settings.sync_remote = raw["sync_remote"]
132
+ if "sync_user" in raw:
133
+ settings.sync_user = raw["sync_user"]
134
+
135
+ for t in raw.get("topics", []):
136
+ obsidian_path = Path(t.get("obsidian_path", "")).expanduser()
137
+ if not obsidian_path.is_absolute():
138
+ obsidian_path = settings.obsidian_base / t.get("obsidian_path", "")
139
+ settings.topics.append(
140
+ TopicConfig(
141
+ name=t["name"],
142
+ slug=t["slug"],
143
+ obsidian_path=obsidian_path,
144
+ notebook_id=t.get("notebook_id", ""),
145
+ tags=t.get("tags", []),
146
+ )
147
+ )
148
+
149
+ # Knowledge domains configuration
150
+ kd = raw.get("knowledge_domains", {})
151
+ if kd:
152
+ settings.knowledge_domains = KnowledgeDomainsConfig(
153
+ primary=kd.get("primary", "networking"),
154
+ anchors=kd.get("anchors", []),
155
+ secondary=[
156
+ KnowledgeDomain(
157
+ domain=s.get("domain", ""),
158
+ anchors=s.get("anchors", []),
159
+ )
160
+ for s in kd.get("secondary", [])
161
+ ],
162
+ )
163
+
164
+ # NotebookLM configuration
165
+ nlm = raw.get("notebooklm", {})
166
+ if nlm:
167
+ settings.notebooklm = NotebookLMConfig(
168
+ enabled=bool(nlm.get("enabled", False)),
169
+ )
170
+
171
+ # Content pipeline configuration
172
+ ct = raw.get("content", {})
173
+ if ct:
174
+ settings.content = ContentConfig(
175
+ base_path=Path(ct.get("base_path", "~/study-materials")).expanduser(),
176
+ notebooklm_timeout=ct.get("notebooklm_timeout", 900),
177
+ inter_episode_gap=ct.get("inter_episode_gap", 30),
178
+ default_types=ct.get("default_types", ["audio"]),
179
+ pandoc_path=ct.get("pandoc_path", "pandoc"),
180
+ )
181
+
182
+ return settings
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # Topic mapping (previously in config.py)
187
+ # ---------------------------------------------------------------------------
188
+
189
+
190
+ @dataclass
191
+ class Topic:
192
+ """A study topic maps to one NotebookLM notebook."""
193
+
194
+ name: str
195
+ display_name: str
196
+ notebook_id: str | None # Pre-mapped to existing NotebookLM notebook
197
+ obsidian_paths: list[Path]
198
+ tags: list[str] = field(default_factory=list)
199
+
200
+
201
+ def get_topics() -> list[Topic]:
202
+ """Load topics from settings, falling back to defaults (without notebook IDs)."""
203
+ settings = load_settings()
204
+ if settings.topics:
205
+ return [
206
+ Topic(
207
+ name=t.slug,
208
+ display_name=t.name,
209
+ notebook_id=t.notebook_id or None,
210
+ obsidian_paths=[t.obsidian_path],
211
+ tags=t.tags,
212
+ )
213
+ for t in settings.topics
214
+ ]
215
+
216
+ # Compute paths from settings for defaults
217
+ obsidian_base = settings.obsidian_base
218
+ obsidian_courses = obsidian_base / "Personal" / "2-Areas" / "Study" / "Courses"
219
+ obsidian_mentoring = obsidian_base / "Personal" / "2-Areas" / "Study" / "Mentoring"
220
+
221
+ return [
222
+ Topic(
223
+ name="python",
224
+ display_name="Python Study",
225
+ notebook_id=None,
226
+ obsidian_paths=[obsidian_courses / "ArjanCodes", obsidian_mentoring / "Python"],
227
+ tags=["python", "patterns", "oop", "architecture"],
228
+ ),
229
+ Topic(
230
+ name="sql",
231
+ display_name="SQL & Database Design",
232
+ notebook_id=None,
233
+ obsidian_paths=[obsidian_courses / "DataCamp", obsidian_mentoring / "Databases"],
234
+ tags=["sql", "postgresql", "athena", "redshift", "database"],
235
+ ),
236
+ Topic(
237
+ name="data-engineering",
238
+ display_name="Data Engineering",
239
+ notebook_id=None,
240
+ obsidian_paths=[
241
+ obsidian_courses / "ZTM" / "transcripts" / "data-engineering-bootcamp",
242
+ obsidian_mentoring / "Data-Engineering",
243
+ ],
244
+ tags=["etl", "spark", "glue", "airflow", "dbt", "pipeline", "lakehouse"],
245
+ ),
246
+ Topic(
247
+ name="aws-analytics",
248
+ display_name="AWS Analytics Services",
249
+ notebook_id=None,
250
+ obsidian_paths=[
251
+ obsidian_courses / "ZTM" / "Ai-Engineering-Aws-Sagemaker",
252
+ obsidian_mentoring / "AWS",
253
+ ],
254
+ tags=["athena", "redshift", "glue", "sagemaker", "lake-formation", "emr"],
255
+ ),
256
+ ]
257
+
258
+
259
+ # ---------------------------------------------------------------------------
260
+ # Path helpers (previously in config.py / config_path.py)
261
+ # ---------------------------------------------------------------------------
262
+
263
+
264
+ def get_db_path() -> Path:
265
+ """Get sessions.db path from config, or use default."""
266
+ config_file = _CONFIG_PATH
267
+ if config_file.exists():
268
+ try:
269
+ data = yaml.safe_load(config_file.read_text()) or {}
270
+ # Support both old 'database.path' key and new 'session_db' key
271
+ db_str = data.get("session_db", "")
272
+ if not db_str:
273
+ db_str = data.get("database", {}).get("path", "")
274
+ if db_str:
275
+ return Path(db_str).expanduser()
276
+ except Exception:
277
+ pass
278
+ return DEFAULT_DB
279
+
280
+
281
+ def get_state_dir() -> Path:
282
+ """Get state directory from settings."""
283
+ return load_settings().state_dir
284
+
285
+
286
+ def get_state_file() -> Path:
287
+ """Get state file path from settings."""
288
+ return get_state_dir() / "state.json"
289
+
290
+
291
+ def generate_default_config() -> str:
292
+ """Generate a default config YAML with comments."""
293
+ return """\
294
+ # studyctl configuration
295
+ # Location: ~/.config/studyctl/config.yaml
296
+
297
+ # Base path to your Obsidian vault
298
+ obsidian_base: ~/Obsidian
299
+
300
+ # Path to the AI session database
301
+ session_db: ~/.config/studyctl/sessions.db
302
+
303
+ # State directory for sync tracking
304
+ state_dir: ~/.local/share/studyctl
305
+
306
+ # Remote sync configuration (optional)
307
+ # sync_remote: your-remote-host
308
+ # sync_user: your-username
309
+
310
+ # Study topics
311
+ # Each topic maps to an Obsidian directory and optionally a NotebookLM notebook
312
+ topics:
313
+ - name: Python
314
+ slug: python
315
+ obsidian_path: 2-Areas/Study/Python
316
+ # notebook_id: your-notebooklm-notebook-id
317
+ tags: [python, programming]
318
+
319
+ - name: SQL
320
+ slug: sql
321
+ obsidian_path: 2-Areas/Study/SQL
322
+ tags: [sql, databases]
323
+
324
+ - name: Data Engineering
325
+ slug: data-engineering
326
+ obsidian_path: 2-Areas/Study/Data-Engineering
327
+ tags: [data-engineering, spark, glue]
328
+
329
+ - name: AWS Analytics
330
+ slug: aws-analytics
331
+ obsidian_path: 2-Areas/Study/AWS-Analytics
332
+ tags: [aws, analytics, redshift, athena]
333
+
334
+ # Medication timing (optional — for ADHD stimulant medication awareness)
335
+ # Uncomment to enable medication-aware session recommendations
336
+ # medication:
337
+ # dose_time: "08:00" # When you take your medication (24h format)
338
+ # onset_minutes: 30 # Minutes until meds kick in
339
+ # peak_hours: 4 # Hours of peak effectiveness
340
+ # duration_hours: 8 # Total duration before wearing off
341
+
342
+ # Google NotebookLM integration (optional)
343
+ # Run 'studyctl config init' for interactive setup
344
+ # notebooklm:
345
+ # enabled: true
346
+
347
+ # Knowledge domains for concept bridging (optional)
348
+ # Run 'studyctl config init' for interactive setup
349
+ # knowledge_domains:
350
+ # primary: networking
351
+ # anchors:
352
+ # - concept: "ECMP load balancing"
353
+ # comfort: 10
354
+ # - concept: "BGP route propagation"
355
+ # comfort: 9
356
+ # secondary:
357
+ # - domain: cooking
358
+ # anchors: ["mise en place", "flavour balancing"]
359
+
360
+ # Content pipeline (studyctl content commands)
361
+ # content:
362
+ # base_path: ~/study-materials # Where course directories are stored
363
+ # notebooklm_timeout: 900 # Timeout for generation (seconds)
364
+ # inter_episode_gap: 30 # Seconds between episode generations
365
+ # default_types: [audio] # Default artifact types to generate
366
+ # pandoc_path: pandoc # Path to pandoc binary
367
+ """