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/sync.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Sync engine — Obsidian → NotebookLM via notebooklm-py CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .pdf import md_to_pdf
|
|
11
|
+
from .settings import MIN_FILE_SIZE, SKIP_FILENAMES, SKIP_PATTERNS, SYNCABLE_EXTENSIONS, Topic
|
|
12
|
+
from .state import SyncState, file_hash
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _should_skip(path: Path) -> bool:
|
|
16
|
+
"""Filter out low-value files."""
|
|
17
|
+
# Skip by directory/file pattern
|
|
18
|
+
for part in path.parts:
|
|
19
|
+
if part in SKIP_PATTERNS or part.startswith("."):
|
|
20
|
+
return True
|
|
21
|
+
# Skip by filename
|
|
22
|
+
if path.name in SKIP_FILENAMES:
|
|
23
|
+
return True
|
|
24
|
+
# Skip tiny files (stubs, empty templates)
|
|
25
|
+
return path.stat().st_size < MIN_FILE_SIZE
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def find_sources(topic: Topic) -> list[Path]:
|
|
29
|
+
"""Find all syncable files for a topic, filtered for quality."""
|
|
30
|
+
sources = []
|
|
31
|
+
for base in topic.obsidian_paths:
|
|
32
|
+
if not base.exists():
|
|
33
|
+
continue
|
|
34
|
+
for ext in SYNCABLE_EXTENSIONS:
|
|
35
|
+
for p in sorted(base.rglob(f"*{ext}")):
|
|
36
|
+
if not _should_skip(p):
|
|
37
|
+
sources.append(p)
|
|
38
|
+
return sources
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def find_changed_sources(topic: Topic, state: SyncState) -> list[Path]:
|
|
42
|
+
"""Find sources that are new or changed since last sync."""
|
|
43
|
+
return [p for p in find_sources(topic) if state.needs_sync(p)]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _run_nlm(args: list[str], check: bool = True) -> subprocess.CompletedProcess:
|
|
47
|
+
"""Run a notebooklm CLI command."""
|
|
48
|
+
return subprocess.run(["notebooklm", *args], capture_output=True, text=True, check=check)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def ensure_notebook(topic: Topic, state: SyncState) -> str:
|
|
52
|
+
"""Get or create the NotebookLM notebook for a topic. Returns notebook_id."""
|
|
53
|
+
# Use pre-mapped ID if available
|
|
54
|
+
if topic.notebook_id:
|
|
55
|
+
state.set_notebook_id(topic.name, topic.notebook_id, topic.display_name)
|
|
56
|
+
state.save()
|
|
57
|
+
return topic.notebook_id
|
|
58
|
+
|
|
59
|
+
# Check state for previously created notebook
|
|
60
|
+
ts = state.get_topic(topic.name)
|
|
61
|
+
if ts.notebook_id:
|
|
62
|
+
return ts.notebook_id
|
|
63
|
+
|
|
64
|
+
# Check if notebook already exists by title
|
|
65
|
+
result = _run_nlm(["list", "--json"])
|
|
66
|
+
try:
|
|
67
|
+
notebooks = json.loads(result.stdout).get("notebooks", [])
|
|
68
|
+
except json.JSONDecodeError:
|
|
69
|
+
import sys
|
|
70
|
+
|
|
71
|
+
print(f"[studyctl] Failed to parse notebook list: {result.stdout[:200]}", file=sys.stderr)
|
|
72
|
+
notebooks = []
|
|
73
|
+
for nb in notebooks:
|
|
74
|
+
if nb["title"] == topic.display_name:
|
|
75
|
+
state.set_notebook_id(topic.name, nb["id"], nb["title"])
|
|
76
|
+
state.save()
|
|
77
|
+
return nb["id"]
|
|
78
|
+
|
|
79
|
+
# Create new notebook
|
|
80
|
+
result = _run_nlm(["create", topic.display_name, "--json"])
|
|
81
|
+
try:
|
|
82
|
+
data = json.loads(result.stdout)
|
|
83
|
+
except json.JSONDecodeError:
|
|
84
|
+
import sys
|
|
85
|
+
|
|
86
|
+
print(
|
|
87
|
+
f"[studyctl] Failed to parse create response: {result.stdout[:200]}",
|
|
88
|
+
file=sys.stderr,
|
|
89
|
+
)
|
|
90
|
+
data = {}
|
|
91
|
+
notebook_id = data.get("id") or data.get("notebook", {}).get("id", "")
|
|
92
|
+
state.set_notebook_id(topic.name, notebook_id, topic.display_name)
|
|
93
|
+
state.save()
|
|
94
|
+
return notebook_id
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def sync_source(
|
|
98
|
+
path: Path,
|
|
99
|
+
notebook_id: str,
|
|
100
|
+
topic_name: str,
|
|
101
|
+
state: SyncState,
|
|
102
|
+
pdf_dir: Path | None = None,
|
|
103
|
+
unique_name: str | None = None,
|
|
104
|
+
) -> str | None:
|
|
105
|
+
"""Sync a single file to NotebookLM. Converts .md to PDF first. Returns source_id or None."""
|
|
106
|
+
upload_path = path
|
|
107
|
+
if path.suffix == ".md" and pdf_dir:
|
|
108
|
+
pdf = md_to_pdf(path, pdf_dir, unique_name)
|
|
109
|
+
if pdf:
|
|
110
|
+
upload_path = pdf
|
|
111
|
+
|
|
112
|
+
result = _run_nlm(
|
|
113
|
+
["source", "add", str(upload_path), "--notebook", notebook_id, "--json"],
|
|
114
|
+
check=False,
|
|
115
|
+
)
|
|
116
|
+
if result.returncode != 0:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
data = json.loads(result.stdout)
|
|
121
|
+
except json.JSONDecodeError:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
source_id = data.get("source_id") or data.get("source", {}).get("id", "")
|
|
125
|
+
if not source_id:
|
|
126
|
+
return None
|
|
127
|
+
rel_path = str(path.relative_to(Path.home()))
|
|
128
|
+
state.record_sync(topic_name, rel_path, file_hash(path), source_id, notebook_id)
|
|
129
|
+
state.save()
|
|
130
|
+
return source_id
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def sync_topic(topic: Topic, state: SyncState, dry_run: bool = False, as_pdf: bool = True) -> dict:
|
|
134
|
+
"""Sync all changed sources for a topic. Returns summary."""
|
|
135
|
+
changed = find_changed_sources(topic, state)
|
|
136
|
+
total = len(find_sources(topic))
|
|
137
|
+
if not changed:
|
|
138
|
+
return {"topic": topic.name, "total": total, "changed": 0, "synced": 0, "failed": 0}
|
|
139
|
+
|
|
140
|
+
if dry_run:
|
|
141
|
+
return {
|
|
142
|
+
"topic": topic.name,
|
|
143
|
+
"total": total,
|
|
144
|
+
"changed": len(changed),
|
|
145
|
+
"synced": 0,
|
|
146
|
+
"failed": 0,
|
|
147
|
+
"dry_run": True,
|
|
148
|
+
"files": [str(p.name) for p in changed[:10]],
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
notebook_id = ensure_notebook(topic, state)
|
|
152
|
+
synced = failed = 0
|
|
153
|
+
|
|
154
|
+
# Build unique names for files with duplicate stems
|
|
155
|
+
name_map = _build_unique_names(changed)
|
|
156
|
+
|
|
157
|
+
with tempfile.TemporaryDirectory(prefix="studyctl-pdf-") as pdf_dir:
|
|
158
|
+
pdf_path = Path(pdf_dir) if as_pdf else None
|
|
159
|
+
for path in changed:
|
|
160
|
+
unique = name_map.get(str(path))
|
|
161
|
+
if sync_source(
|
|
162
|
+
path, notebook_id, topic.name, state, pdf_dir=pdf_path, unique_name=unique
|
|
163
|
+
):
|
|
164
|
+
synced += 1
|
|
165
|
+
else:
|
|
166
|
+
failed += 1
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
"topic": topic.name,
|
|
170
|
+
"total": total,
|
|
171
|
+
"changed": len(changed),
|
|
172
|
+
"synced": synced,
|
|
173
|
+
"failed": failed,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _build_unique_names(paths: list[Path]) -> dict[str, str]:
|
|
178
|
+
"""For files with duplicate stems, prefix with parent dir name.
|
|
179
|
+
|
|
180
|
+
e.g. two 'introduction.md' files become:
|
|
181
|
+
'the-software-designer-mindset--introduction'
|
|
182
|
+
'pythonic-patterns--introduction'
|
|
183
|
+
"""
|
|
184
|
+
from collections import Counter
|
|
185
|
+
|
|
186
|
+
stem_counts = Counter(p.stem for p in paths)
|
|
187
|
+
duplicated = {stem for stem, count in stem_counts.items() if count > 1}
|
|
188
|
+
|
|
189
|
+
name_map: dict[str, str] = {}
|
|
190
|
+
for p in paths:
|
|
191
|
+
if p.stem in duplicated:
|
|
192
|
+
# Walk up parents until we find a distinguishing name
|
|
193
|
+
# e.g. .../the-software-designer-mindset/study-notes/introduction.md
|
|
194
|
+
# → "the-software-designer-mindset--introduction"
|
|
195
|
+
parts = p.parts
|
|
196
|
+
for i in range(len(parts) - 2, 0, -1):
|
|
197
|
+
candidate = parts[i]
|
|
198
|
+
if candidate != "study-notes" and candidate != "lessons":
|
|
199
|
+
name_map[str(p)] = f"{candidate}--{p.stem}"
|
|
200
|
+
break
|
|
201
|
+
else:
|
|
202
|
+
name_map[str(p)] = f"{p.parent.name}--{p.stem}"
|
|
203
|
+
|
|
204
|
+
return name_map
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def generate_audio(topic: Topic, state: SyncState, instructions: str = "") -> str | None:
|
|
208
|
+
"""Generate an audio overview for a topic. Returns artifact_id or None."""
|
|
209
|
+
ts = state.get_topic(topic.name)
|
|
210
|
+
if not ts.notebook_id:
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
args = ["generate", "audio", "--notebook", ts.notebook_id, "--json"]
|
|
214
|
+
if instructions:
|
|
215
|
+
args.insert(2, instructions)
|
|
216
|
+
|
|
217
|
+
result = _run_nlm(args, check=False)
|
|
218
|
+
if result.returncode != 0:
|
|
219
|
+
return None
|
|
220
|
+
try:
|
|
221
|
+
return json.loads(result.stdout).get("task_id")
|
|
222
|
+
except json.JSONDecodeError:
|
|
223
|
+
import sys
|
|
224
|
+
|
|
225
|
+
print(
|
|
226
|
+
f"[studyctl] Failed to parse audio response: {result.stdout[:200]}",
|
|
227
|
+
file=sys.stderr,
|
|
228
|
+
)
|
|
229
|
+
return None
|
studyctl/tui/__main__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Entry point for ``python -m studyctl.tui`` and ``textual serve``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from studyctl.tui.app import StudyApp
|
|
10
|
+
|
|
11
|
+
config_path = Path.home() / ".config" / "studyctl" / "config.yaml"
|
|
12
|
+
study_dirs: list[str] = []
|
|
13
|
+
theme = ""
|
|
14
|
+
dyslexic = False
|
|
15
|
+
|
|
16
|
+
if config_path.exists():
|
|
17
|
+
try:
|
|
18
|
+
data = yaml.safe_load(config_path.read_text()) or {}
|
|
19
|
+
study_dirs = data.get("review", {}).get("directories", [])
|
|
20
|
+
tui_cfg = data.get("tui", {})
|
|
21
|
+
theme = tui_cfg.get("theme", "")
|
|
22
|
+
dyslexic = tui_cfg.get("dyslexic_friendly", False)
|
|
23
|
+
except Exception:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
app = StudyApp(
|
|
27
|
+
study_dirs=study_dirs,
|
|
28
|
+
theme_name=theme,
|
|
29
|
+
dyslexic_friendly=dyslexic,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if __name__ == "__main__":
|
|
33
|
+
app.run()
|
studyctl/tui/app.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Textual TUI application for studyctl study management.
|
|
2
|
+
|
|
3
|
+
Launch via ``studyctl tui``. Requires the ``tui`` extra::
|
|
4
|
+
|
|
5
|
+
pip install studyctl[tui]
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import ClassVar, NamedTuple
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from textual.app import App, ComposeResult
|
|
16
|
+
from textual.containers import Vertical
|
|
17
|
+
from textual.screen import ModalScreen
|
|
18
|
+
from textual.widgets import (
|
|
19
|
+
DataTable,
|
|
20
|
+
Footer,
|
|
21
|
+
Header,
|
|
22
|
+
OptionList,
|
|
23
|
+
Static,
|
|
24
|
+
TabbedContent,
|
|
25
|
+
TabPane,
|
|
26
|
+
)
|
|
27
|
+
from textual.widgets.option_list import Option
|
|
28
|
+
except ImportError as _exc:
|
|
29
|
+
raise ImportError(
|
|
30
|
+
"The TUI requires the 'textual' package. Install it with:\n pip install studyctl[tui]"
|
|
31
|
+
) from _exc
|
|
32
|
+
|
|
33
|
+
from studyctl.cli import TOPIC_KEYWORDS
|
|
34
|
+
from studyctl.history import (
|
|
35
|
+
get_study_session_stats,
|
|
36
|
+
list_concepts,
|
|
37
|
+
spaced_repetition_due,
|
|
38
|
+
struggle_topics,
|
|
39
|
+
)
|
|
40
|
+
from studyctl.review_loader import (
|
|
41
|
+
discover_directories,
|
|
42
|
+
find_content_dirs,
|
|
43
|
+
load_flashcards,
|
|
44
|
+
load_quizzes,
|
|
45
|
+
shuffle_items,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CourseInfo(NamedTuple):
|
|
50
|
+
"""Typed representation of a discovered course directory."""
|
|
51
|
+
|
|
52
|
+
name: str
|
|
53
|
+
path: Path
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _load_session_state() -> dict:
|
|
57
|
+
"""Load session state from the JSON file, returning defaults on failure."""
|
|
58
|
+
state_path = Path.home() / ".config" / "studyctl" / "session-state.json"
|
|
59
|
+
try:
|
|
60
|
+
return json.loads(state_path.read_text()) if state_path.exists() else {}
|
|
61
|
+
except (json.JSONDecodeError, OSError):
|
|
62
|
+
return {}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CoursePickerScreen(ModalScreen[CourseInfo | None]):
|
|
66
|
+
"""Modal overlay for selecting a course directory."""
|
|
67
|
+
|
|
68
|
+
BINDINGS: ClassVar[list[tuple[str, str, str]]] = [
|
|
69
|
+
("escape", "cancel", "Cancel"),
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
CSS = """
|
|
73
|
+
CoursePickerScreen {
|
|
74
|
+
align: center middle;
|
|
75
|
+
}
|
|
76
|
+
#course-picker-box {
|
|
77
|
+
width: 60;
|
|
78
|
+
max-height: 24;
|
|
79
|
+
border: round $accent;
|
|
80
|
+
background: $surface;
|
|
81
|
+
padding: 1 2;
|
|
82
|
+
}
|
|
83
|
+
#course-picker-title {
|
|
84
|
+
text-style: bold;
|
|
85
|
+
text-align: center;
|
|
86
|
+
margin-bottom: 1;
|
|
87
|
+
}
|
|
88
|
+
#course-picker {
|
|
89
|
+
max-height: 18;
|
|
90
|
+
}
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(self, courses: list[CourseInfo]) -> None:
|
|
94
|
+
super().__init__()
|
|
95
|
+
self._courses = courses
|
|
96
|
+
|
|
97
|
+
def compose(self) -> ComposeResult:
|
|
98
|
+
with Vertical(id="course-picker-box"):
|
|
99
|
+
yield Static("Select a course", id="course-picker-title")
|
|
100
|
+
yield OptionList(
|
|
101
|
+
*[Option(course.name) for course in self._courses],
|
|
102
|
+
id="course-picker",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
106
|
+
self.dismiss(self._courses[event.option_index])
|
|
107
|
+
|
|
108
|
+
def action_cancel(self) -> None:
|
|
109
|
+
self.dismiss(None)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class StudyApp(App):
|
|
113
|
+
"""Read-only study management dashboard."""
|
|
114
|
+
|
|
115
|
+
TITLE = "studyctl"
|
|
116
|
+
CSS = """
|
|
117
|
+
Screen {
|
|
118
|
+
background: $surface;
|
|
119
|
+
}
|
|
120
|
+
#dashboard-content {
|
|
121
|
+
padding: 1 2;
|
|
122
|
+
}
|
|
123
|
+
.section-header {
|
|
124
|
+
text-style: bold;
|
|
125
|
+
color: $accent;
|
|
126
|
+
margin-bottom: 1;
|
|
127
|
+
}
|
|
128
|
+
.info-line {
|
|
129
|
+
margin-bottom: 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* Dyslexic-friendly: wider spacing, more padding, clearer separation */
|
|
133
|
+
.dyslexic #dashboard-content {
|
|
134
|
+
padding: 2 4;
|
|
135
|
+
}
|
|
136
|
+
.dyslexic .section-header {
|
|
137
|
+
margin-bottom: 2;
|
|
138
|
+
}
|
|
139
|
+
.dyslexic .info-line {
|
|
140
|
+
margin-bottom: 1;
|
|
141
|
+
}
|
|
142
|
+
.dyslexic DataTable {
|
|
143
|
+
padding: 1 2;
|
|
144
|
+
}
|
|
145
|
+
.dyslexic #card-panel {
|
|
146
|
+
padding: 2 4;
|
|
147
|
+
min-height: 8;
|
|
148
|
+
margin: 2 1;
|
|
149
|
+
}
|
|
150
|
+
.dyslexic #progress-label {
|
|
151
|
+
margin: 2 0;
|
|
152
|
+
}
|
|
153
|
+
.dyslexic #status-label {
|
|
154
|
+
margin-bottom: 1;
|
|
155
|
+
}
|
|
156
|
+
.dyslexic .score-btn {
|
|
157
|
+
margin: 0 2;
|
|
158
|
+
}
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
BINDINGS: ClassVar[list[tuple[str, str, str]]] = [
|
|
162
|
+
("q", "quit", "Quit"),
|
|
163
|
+
("d", "show_tab('dashboard')", "Dashboard"),
|
|
164
|
+
("r", "show_tab('review')", "Review"),
|
|
165
|
+
("c", "show_tab('concepts')", "Concepts"),
|
|
166
|
+
("s", "show_tab('sessions')", "Sessions"),
|
|
167
|
+
("f", "start_flashcards", "Flashcards"),
|
|
168
|
+
("z", "start_quiz", "Quiz"),
|
|
169
|
+
("o", "toggle_dyslexic", "OpenDyslexic"),
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
def __init__(
|
|
173
|
+
self,
|
|
174
|
+
study_dirs: list[str] | None = None,
|
|
175
|
+
theme_name: str = "",
|
|
176
|
+
dyslexic_friendly: bool = False,
|
|
177
|
+
**kwargs: object,
|
|
178
|
+
) -> None:
|
|
179
|
+
super().__init__(**kwargs)
|
|
180
|
+
self._study_dirs = study_dirs or []
|
|
181
|
+
self._theme_name = theme_name
|
|
182
|
+
self._dyslexic_friendly = dyslexic_friendly
|
|
183
|
+
|
|
184
|
+
def compose(self) -> ComposeResult:
|
|
185
|
+
yield Header()
|
|
186
|
+
with TabbedContent(
|
|
187
|
+
"Dashboard",
|
|
188
|
+
"Review",
|
|
189
|
+
"Concepts",
|
|
190
|
+
"Sessions",
|
|
191
|
+
"StudyCards",
|
|
192
|
+
id="tabs",
|
|
193
|
+
):
|
|
194
|
+
with TabPane("Dashboard", id="dashboard"):
|
|
195
|
+
yield Vertical(
|
|
196
|
+
Static("", id="dashboard-content"),
|
|
197
|
+
id="dashboard-container",
|
|
198
|
+
)
|
|
199
|
+
with TabPane("Review", id="review"):
|
|
200
|
+
yield DataTable(id="review-table")
|
|
201
|
+
with TabPane("Concepts", id="concepts"):
|
|
202
|
+
yield DataTable(id="concepts-table")
|
|
203
|
+
with TabPane("Sessions", id="sessions"):
|
|
204
|
+
yield DataTable(id="sessions-table")
|
|
205
|
+
with TabPane("StudyCards", id="studycards"):
|
|
206
|
+
yield Vertical(
|
|
207
|
+
Static("", id="studycards-content"),
|
|
208
|
+
id="studycards-container",
|
|
209
|
+
)
|
|
210
|
+
yield Footer()
|
|
211
|
+
|
|
212
|
+
def on_mount(self) -> None:
|
|
213
|
+
if self._theme_name:
|
|
214
|
+
self.theme = self._theme_name
|
|
215
|
+
if self._dyslexic_friendly:
|
|
216
|
+
self.add_class("dyslexic")
|
|
217
|
+
self.notify(
|
|
218
|
+
"Dyslexic-friendly mode ON. For best results, set your "
|
|
219
|
+
"terminal font to OpenDyslexic: https://opendyslexic.org",
|
|
220
|
+
title="Accessibility",
|
|
221
|
+
timeout=8,
|
|
222
|
+
)
|
|
223
|
+
self._populate_dashboard()
|
|
224
|
+
self._populate_review()
|
|
225
|
+
self._populate_concepts()
|
|
226
|
+
self._populate_sessions()
|
|
227
|
+
self._populate_studycards()
|
|
228
|
+
|
|
229
|
+
def action_show_tab(self, tab_id: str) -> None:
|
|
230
|
+
tabs = self.query_one(TabbedContent)
|
|
231
|
+
tabs.active = tab_id
|
|
232
|
+
|
|
233
|
+
def action_toggle_dyslexic(self) -> None:
|
|
234
|
+
"""Toggle dyslexic-friendly mode (wider spacing)."""
|
|
235
|
+
self.toggle_class("dyslexic")
|
|
236
|
+
if self.has_class("dyslexic"):
|
|
237
|
+
self.notify(
|
|
238
|
+
"Dyslexic-friendly mode ON — wider spacing applied. "
|
|
239
|
+
"Set terminal font to OpenDyslexic for best results.",
|
|
240
|
+
title="Accessibility",
|
|
241
|
+
)
|
|
242
|
+
else:
|
|
243
|
+
self.notify("Dyslexic-friendly mode OFF")
|
|
244
|
+
|
|
245
|
+
# ------------------------------------------------------------------
|
|
246
|
+
# Tab population
|
|
247
|
+
# ------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
def _populate_dashboard(self) -> None:
|
|
250
|
+
state = _load_session_state()
|
|
251
|
+
energy = state.get("energy", "unknown")
|
|
252
|
+
topic = state.get("topic", "none")
|
|
253
|
+
|
|
254
|
+
due = spaced_repetition_due(TOPIC_KEYWORDS)
|
|
255
|
+
struggles = struggle_topics()
|
|
256
|
+
|
|
257
|
+
lines = [
|
|
258
|
+
"[bold]Study Dashboard[/bold]",
|
|
259
|
+
"",
|
|
260
|
+
f" Energy level: {energy}",
|
|
261
|
+
f" Current topic: {topic}",
|
|
262
|
+
f" Reviews due: {len(due)}",
|
|
263
|
+
f" Struggle areas: {len(struggles)}",
|
|
264
|
+
]
|
|
265
|
+
if struggles:
|
|
266
|
+
lines.append("")
|
|
267
|
+
lines.append("[bold]Top struggles:[/bold]")
|
|
268
|
+
for s in struggles[:5]:
|
|
269
|
+
lines.append(f" • {s['topic']} ({s['mentions']} mentions)")
|
|
270
|
+
|
|
271
|
+
widget = self.query_one("#dashboard-content", Static)
|
|
272
|
+
widget.update("\n".join(lines))
|
|
273
|
+
|
|
274
|
+
def _populate_review(self) -> None:
|
|
275
|
+
table = self.query_one("#review-table", DataTable)
|
|
276
|
+
table.add_columns("Topic", "Last Studied", "Days Ago", "Review Type")
|
|
277
|
+
|
|
278
|
+
for item in spaced_repetition_due(TOPIC_KEYWORDS):
|
|
279
|
+
table.add_row(
|
|
280
|
+
item["topic"],
|
|
281
|
+
item.get("last_studied") or "never",
|
|
282
|
+
str(item.get("days_ago") or "—"),
|
|
283
|
+
item.get("review_type", ""),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def _populate_concepts(self) -> None:
|
|
287
|
+
table = self.query_one("#concepts-table", DataTable)
|
|
288
|
+
table.add_columns("Name", "Domain", "Description")
|
|
289
|
+
|
|
290
|
+
for concept in list_concepts():
|
|
291
|
+
table.add_row(concept.name, concept.domain, concept.description or "")
|
|
292
|
+
|
|
293
|
+
def _populate_sessions(self) -> None:
|
|
294
|
+
table = self.query_one("#sessions-table", DataTable)
|
|
295
|
+
table.add_columns("Topic", "Sessions", "Total Min", "Avg Min")
|
|
296
|
+
|
|
297
|
+
for stat in get_study_session_stats():
|
|
298
|
+
table.add_row(
|
|
299
|
+
stat.get("topic") or "unknown",
|
|
300
|
+
str(stat.get("sessions", 0)),
|
|
301
|
+
str(round(stat.get("total_minutes") or 0)),
|
|
302
|
+
str(round(stat.get("avg_minutes") or 0)),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def _discover_courses(self) -> list[CourseInfo]:
|
|
306
|
+
"""Discover courses and wrap raw tuples as CourseInfo."""
|
|
307
|
+
return [CourseInfo(*t) for t in discover_directories(self._study_dirs)]
|
|
308
|
+
|
|
309
|
+
def _populate_studycards(self) -> None:
|
|
310
|
+
content = self.query_one("#studycards-content", Static)
|
|
311
|
+
courses = self._discover_courses()
|
|
312
|
+
|
|
313
|
+
if not courses:
|
|
314
|
+
content.update(
|
|
315
|
+
"[bold]Study Cards[/bold]\n\n"
|
|
316
|
+
" No courses found.\n\n"
|
|
317
|
+
" Configure directories in ~/.config/studyctl/config.yaml:\n"
|
|
318
|
+
" review:\n"
|
|
319
|
+
" directories:\n"
|
|
320
|
+
" - ~/Desktop/ZTM-DE/downloads\n"
|
|
321
|
+
" - ~/Desktop/Python/downloads\n\n"
|
|
322
|
+
" Or press [bold]f[/bold] for flashcards / [bold]z[/bold] for quiz"
|
|
323
|
+
" and select a directory."
|
|
324
|
+
)
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
lines = [
|
|
328
|
+
"[bold]Study Cards[/bold]\n",
|
|
329
|
+
f" Found {len(courses)} course(s):\n",
|
|
330
|
+
]
|
|
331
|
+
for course in courses:
|
|
332
|
+
fc_dir, quiz_dir = find_content_dirs(course.path)
|
|
333
|
+
fc_count = len(load_flashcards(fc_dir)) if fc_dir else 0
|
|
334
|
+
quiz_count = len(load_quizzes(quiz_dir)) if quiz_dir else 0
|
|
335
|
+
lines.append(
|
|
336
|
+
f" • [bold]{course.name}[/bold] — {fc_count} flashcards,"
|
|
337
|
+
f" {quiz_count} quiz questions"
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
lines.append("\n Press [bold]f[/bold] for flashcards / [bold]z[/bold] for quiz")
|
|
341
|
+
content.update("\n".join(lines))
|
|
342
|
+
|
|
343
|
+
def _launch_study(self, mode: str = "flashcards") -> None:
|
|
344
|
+
"""Launch interactive study session."""
|
|
345
|
+
courses = self._discover_courses()
|
|
346
|
+
if not courses:
|
|
347
|
+
self.notify(
|
|
348
|
+
"No courses found. Configure review.directories in config.yaml",
|
|
349
|
+
severity="error",
|
|
350
|
+
)
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
if len(courses) == 1:
|
|
354
|
+
self._start_session(courses[0], mode)
|
|
355
|
+
else:
|
|
356
|
+
self.push_screen(
|
|
357
|
+
CoursePickerScreen(courses),
|
|
358
|
+
lambda result: self._start_session(result, mode) if result else None,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
def _start_session(self, course: CourseInfo, mode: str) -> None:
|
|
362
|
+
"""Start a study session for the selected course."""
|
|
363
|
+
fc_dir, quiz_dir = find_content_dirs(course.path)
|
|
364
|
+
|
|
365
|
+
if mode == "flashcards" and fc_dir:
|
|
366
|
+
cards = shuffle_items(load_flashcards(fc_dir))
|
|
367
|
+
elif mode == "quiz" and quiz_dir:
|
|
368
|
+
cards = shuffle_items(load_quizzes(quiz_dir))
|
|
369
|
+
else:
|
|
370
|
+
self.notify(
|
|
371
|
+
f"No {mode} content found for {course.name}",
|
|
372
|
+
severity="warning",
|
|
373
|
+
)
|
|
374
|
+
return
|
|
375
|
+
|
|
376
|
+
if not cards:
|
|
377
|
+
self.notify(f"No {mode} cards loaded", severity="warning")
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
from studyctl.tui.study_cards import StudyCardsTab
|
|
381
|
+
|
|
382
|
+
# Replace studycards content with the interactive widget
|
|
383
|
+
container = self.query_one("#studycards-container", Vertical)
|
|
384
|
+
container.remove_children()
|
|
385
|
+
container.mount(StudyCardsTab(cards=cards, course_name=course.name, mode=mode))
|
|
386
|
+
|
|
387
|
+
# Switch to the tab
|
|
388
|
+
tabs = self.query_one(TabbedContent)
|
|
389
|
+
tabs.active = "studycards"
|
|
390
|
+
|
|
391
|
+
def action_start_flashcards(self) -> None:
|
|
392
|
+
self._launch_study("flashcards")
|
|
393
|
+
|
|
394
|
+
def action_start_quiz(self) -> None:
|
|
395
|
+
self._launch_study("quiz")
|