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/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
|
+
"""
|