nexus-cli 0.3.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.
- nexus/__init__.py +8 -0
- nexus/cli.py +1914 -0
- nexus/integrations/__init__.py +0 -0
- nexus/knowledge/__init__.py +13 -0
- nexus/knowledge/search.py +233 -0
- nexus/knowledge/vault.py +662 -0
- nexus/research/__init__.py +12 -0
- nexus/research/pdf.py +497 -0
- nexus/research/zotero.py +521 -0
- nexus/teaching/__init__.py +14 -0
- nexus/teaching/courses.py +388 -0
- nexus/teaching/quarto.py +385 -0
- nexus/utils/__init__.py +0 -0
- nexus/utils/config.py +157 -0
- nexus/writing/__init__.py +12 -0
- nexus/writing/bibliography.py +339 -0
- nexus/writing/manuscript.py +397 -0
- nexus_cli-0.3.0.dist-info/METADATA +369 -0
- nexus_cli-0.3.0.dist-info/RECORD +21 -0
- nexus_cli-0.3.0.dist-info/WHEEL +4 -0
- nexus_cli-0.3.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Course management for Nexus CLI."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class CourseStatus:
|
|
12
|
+
"""Parsed .STATUS file for a course."""
|
|
13
|
+
|
|
14
|
+
status: str = "unknown"
|
|
15
|
+
priority: str = "--"
|
|
16
|
+
progress: int = 0
|
|
17
|
+
next: str = ""
|
|
18
|
+
course_type: str = "teaching"
|
|
19
|
+
week: int | None = None
|
|
20
|
+
target: str = ""
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_file(cls, path: Path) -> "CourseStatus":
|
|
24
|
+
"""Parse a .STATUS file."""
|
|
25
|
+
if not path.exists():
|
|
26
|
+
return cls()
|
|
27
|
+
|
|
28
|
+
content = path.read_text()
|
|
29
|
+
result = cls()
|
|
30
|
+
|
|
31
|
+
# Parse YAML-like frontmatter
|
|
32
|
+
for line in content.split("\n"):
|
|
33
|
+
line = line.strip()
|
|
34
|
+
if line.startswith("status:"):
|
|
35
|
+
result.status = line.split(":", 1)[1].strip()
|
|
36
|
+
elif line.startswith("priority:"):
|
|
37
|
+
result.priority = line.split(":", 1)[1].strip()
|
|
38
|
+
elif line.startswith("progress:"):
|
|
39
|
+
try:
|
|
40
|
+
result.progress = int(line.split(":", 1)[1].strip())
|
|
41
|
+
except ValueError:
|
|
42
|
+
pass
|
|
43
|
+
elif line.startswith("next:"):
|
|
44
|
+
result.next = line.split(":", 1)[1].strip()
|
|
45
|
+
elif line.startswith("type:"):
|
|
46
|
+
result.course_type = line.split(":", 1)[1].strip()
|
|
47
|
+
elif line.startswith("week:"):
|
|
48
|
+
try:
|
|
49
|
+
result.week = int(line.split(":", 1)[1].strip())
|
|
50
|
+
except ValueError:
|
|
51
|
+
pass
|
|
52
|
+
elif line.startswith("target:"):
|
|
53
|
+
result.target = line.split(":", 1)[1].strip()
|
|
54
|
+
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class QuartoConfig:
|
|
60
|
+
"""Parsed _quarto.yml configuration."""
|
|
61
|
+
|
|
62
|
+
title: str = ""
|
|
63
|
+
subtitle: str = ""
|
|
64
|
+
author: str = ""
|
|
65
|
+
formats: list[str] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_file(cls, path: Path) -> "QuartoConfig":
|
|
69
|
+
"""Parse a _quarto.yml file."""
|
|
70
|
+
if not path.exists():
|
|
71
|
+
return cls()
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
with open(path) as f:
|
|
75
|
+
data = yaml.safe_load(f) or {}
|
|
76
|
+
except Exception:
|
|
77
|
+
return cls()
|
|
78
|
+
|
|
79
|
+
result = cls()
|
|
80
|
+
|
|
81
|
+
# Get project metadata
|
|
82
|
+
project = data.get("project", {})
|
|
83
|
+
book = data.get("book", {})
|
|
84
|
+
website = data.get("website", {})
|
|
85
|
+
|
|
86
|
+
result.title = book.get("title") or website.get("title") or project.get("title") or data.get("title", "")
|
|
87
|
+
result.subtitle = book.get("subtitle") or data.get("subtitle", "")
|
|
88
|
+
result.author = book.get("author") or data.get("author", "")
|
|
89
|
+
|
|
90
|
+
# Get format types
|
|
91
|
+
if "format" in data:
|
|
92
|
+
fmt = data["format"]
|
|
93
|
+
if isinstance(fmt, dict):
|
|
94
|
+
result.formats = list(fmt.keys())
|
|
95
|
+
elif isinstance(fmt, str):
|
|
96
|
+
result.formats = [fmt]
|
|
97
|
+
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class Course:
|
|
103
|
+
"""A teaching course."""
|
|
104
|
+
|
|
105
|
+
name: str
|
|
106
|
+
path: str
|
|
107
|
+
title: str = ""
|
|
108
|
+
status: str = "unknown"
|
|
109
|
+
progress: int = 0
|
|
110
|
+
week: int | None = None
|
|
111
|
+
next_action: str = ""
|
|
112
|
+
formats: list[str] = field(default_factory=list)
|
|
113
|
+
lecture_count: int = 0
|
|
114
|
+
assignment_count: int = 0
|
|
115
|
+
|
|
116
|
+
def to_dict(self) -> dict:
|
|
117
|
+
"""Convert to dictionary."""
|
|
118
|
+
return {
|
|
119
|
+
"name": self.name,
|
|
120
|
+
"path": self.path,
|
|
121
|
+
"title": self.title or self.name,
|
|
122
|
+
"status": self.status,
|
|
123
|
+
"progress": self.progress,
|
|
124
|
+
"week": self.week,
|
|
125
|
+
"next_action": self.next_action,
|
|
126
|
+
"formats": self.formats,
|
|
127
|
+
"lecture_count": self.lecture_count,
|
|
128
|
+
"assignment_count": self.assignment_count,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class Lecture:
|
|
134
|
+
"""A course lecture."""
|
|
135
|
+
|
|
136
|
+
name: str
|
|
137
|
+
path: str
|
|
138
|
+
course: str
|
|
139
|
+
week: int | None = None
|
|
140
|
+
title: str = ""
|
|
141
|
+
format: str = "qmd"
|
|
142
|
+
|
|
143
|
+
def to_dict(self) -> dict:
|
|
144
|
+
"""Convert to dictionary."""
|
|
145
|
+
return {
|
|
146
|
+
"name": self.name,
|
|
147
|
+
"path": self.path,
|
|
148
|
+
"course": self.course,
|
|
149
|
+
"week": self.week,
|
|
150
|
+
"title": self.title or self.name,
|
|
151
|
+
"format": self.format,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class CourseManager:
|
|
156
|
+
"""Manage teaching courses."""
|
|
157
|
+
|
|
158
|
+
def __init__(self, courses_dir: Path, materials_dir: Path | None = None):
|
|
159
|
+
"""Initialize course manager.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
courses_dir: Path to courses directory (e.g., ~/projects/teaching)
|
|
163
|
+
materials_dir: Path to teaching materials (e.g., ~/Documents/Teaching)
|
|
164
|
+
"""
|
|
165
|
+
self.courses_dir = Path(courses_dir).expanduser()
|
|
166
|
+
self.materials_dir = Path(materials_dir).expanduser() if materials_dir else None
|
|
167
|
+
|
|
168
|
+
def exists(self) -> bool:
|
|
169
|
+
"""Check if courses directory exists."""
|
|
170
|
+
return self.courses_dir.exists()
|
|
171
|
+
|
|
172
|
+
def list_courses(self) -> list[Course]:
|
|
173
|
+
"""List all courses in the courses directory."""
|
|
174
|
+
if not self.exists():
|
|
175
|
+
return []
|
|
176
|
+
|
|
177
|
+
courses = []
|
|
178
|
+
for course_path in sorted(self.courses_dir.iterdir()):
|
|
179
|
+
if not course_path.is_dir():
|
|
180
|
+
continue
|
|
181
|
+
if course_path.name.startswith("."):
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
course = self._load_course(course_path)
|
|
185
|
+
if course:
|
|
186
|
+
courses.append(course)
|
|
187
|
+
|
|
188
|
+
return courses
|
|
189
|
+
|
|
190
|
+
def get_course(self, name: str) -> Course | None:
|
|
191
|
+
"""Get a specific course by name."""
|
|
192
|
+
course_path = self.courses_dir / name
|
|
193
|
+
if not course_path.exists():
|
|
194
|
+
# Try case-insensitive match
|
|
195
|
+
for p in self.courses_dir.iterdir():
|
|
196
|
+
if p.name.lower() == name.lower():
|
|
197
|
+
course_path = p
|
|
198
|
+
break
|
|
199
|
+
else:
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
return self._load_course(course_path)
|
|
203
|
+
|
|
204
|
+
def _load_course(self, course_path: Path) -> Course | None:
|
|
205
|
+
"""Load course data from a directory."""
|
|
206
|
+
if not course_path.is_dir():
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
# Parse .STATUS file
|
|
210
|
+
status_file = course_path / ".STATUS"
|
|
211
|
+
status = CourseStatus.from_file(status_file)
|
|
212
|
+
|
|
213
|
+
# Parse _quarto.yml
|
|
214
|
+
quarto_file = course_path / "_quarto.yml"
|
|
215
|
+
quarto = QuartoConfig.from_file(quarto_file)
|
|
216
|
+
|
|
217
|
+
# Count lectures
|
|
218
|
+
lecture_count = self._count_lectures(course_path)
|
|
219
|
+
|
|
220
|
+
# Count assignments
|
|
221
|
+
assignment_count = self._count_assignments(course_path)
|
|
222
|
+
|
|
223
|
+
return Course(
|
|
224
|
+
name=course_path.name,
|
|
225
|
+
path=str(course_path),
|
|
226
|
+
title=quarto.title or course_path.name,
|
|
227
|
+
status=status.status,
|
|
228
|
+
progress=status.progress,
|
|
229
|
+
week=status.week,
|
|
230
|
+
next_action=status.next,
|
|
231
|
+
formats=quarto.formats,
|
|
232
|
+
lecture_count=lecture_count,
|
|
233
|
+
assignment_count=assignment_count,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def _count_lectures(self, course_path: Path) -> int:
|
|
237
|
+
"""Count lecture files in a course."""
|
|
238
|
+
count = 0
|
|
239
|
+
for pattern in ["lectures/*.qmd", "slides/*.qmd", "weeks/*.qmd"]:
|
|
240
|
+
count += len(list(course_path.glob(pattern)))
|
|
241
|
+
# Also count week-* files in root
|
|
242
|
+
count += len(list(course_path.glob("week-*.qmd")))
|
|
243
|
+
return count
|
|
244
|
+
|
|
245
|
+
def _count_assignments(self, course_path: Path) -> int:
|
|
246
|
+
"""Count assignment files in a course."""
|
|
247
|
+
count = 0
|
|
248
|
+
for pattern in ["assignments/*.qmd", "homework/*.qmd", "labs/*.qmd"]:
|
|
249
|
+
count += len(list(course_path.glob(pattern)))
|
|
250
|
+
return count
|
|
251
|
+
|
|
252
|
+
def list_lectures(self, course_name: str) -> list[Lecture]:
|
|
253
|
+
"""List all lectures for a course."""
|
|
254
|
+
course = self.get_course(course_name)
|
|
255
|
+
if not course:
|
|
256
|
+
return []
|
|
257
|
+
|
|
258
|
+
course_path = Path(course.path)
|
|
259
|
+
lectures = []
|
|
260
|
+
|
|
261
|
+
# Find lecture files in various locations
|
|
262
|
+
lecture_patterns = [
|
|
263
|
+
("lectures", "lectures/*.qmd"),
|
|
264
|
+
("slides", "slides/*.qmd"),
|
|
265
|
+
("weeks", "weeks/*.qmd"),
|
|
266
|
+
("root", "week-*.qmd"),
|
|
267
|
+
("root", "_week-*.qmd"),
|
|
268
|
+
]
|
|
269
|
+
|
|
270
|
+
for location, pattern in lecture_patterns:
|
|
271
|
+
for qmd_file in sorted(course_path.glob(pattern)):
|
|
272
|
+
lecture = self._parse_lecture(qmd_file, course_name)
|
|
273
|
+
if lecture:
|
|
274
|
+
lectures.append(lecture)
|
|
275
|
+
|
|
276
|
+
return lectures
|
|
277
|
+
|
|
278
|
+
def _parse_lecture(self, qmd_path: Path, course_name: str) -> Lecture | None:
|
|
279
|
+
"""Parse a lecture file."""
|
|
280
|
+
name = qmd_path.stem
|
|
281
|
+
|
|
282
|
+
# Try to extract week number from filename
|
|
283
|
+
week = None
|
|
284
|
+
week_match = re.search(r"week[-_]?(\d+)", name, re.IGNORECASE)
|
|
285
|
+
if week_match:
|
|
286
|
+
week = int(week_match.group(1))
|
|
287
|
+
|
|
288
|
+
# Try to extract title from file
|
|
289
|
+
title = ""
|
|
290
|
+
try:
|
|
291
|
+
content = qmd_path.read_text()
|
|
292
|
+
# Check YAML frontmatter
|
|
293
|
+
if content.startswith("---"):
|
|
294
|
+
end = content.find("---", 3)
|
|
295
|
+
if end > 0:
|
|
296
|
+
frontmatter = content[3:end]
|
|
297
|
+
for line in frontmatter.split("\n"):
|
|
298
|
+
if line.strip().startswith("title:"):
|
|
299
|
+
title = line.split(":", 1)[1].strip().strip("\"'")
|
|
300
|
+
break
|
|
301
|
+
except Exception:
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
return Lecture(
|
|
305
|
+
name=name,
|
|
306
|
+
path=str(qmd_path),
|
|
307
|
+
course=course_name,
|
|
308
|
+
week=week,
|
|
309
|
+
title=title or name.replace("-", " ").replace("_", " ").title(),
|
|
310
|
+
format="qmd",
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def get_current_week_lectures(self, course_name: str) -> list[Lecture]:
|
|
314
|
+
"""Get lectures for the current week."""
|
|
315
|
+
course = self.get_course(course_name)
|
|
316
|
+
if not course or not course.week:
|
|
317
|
+
return []
|
|
318
|
+
|
|
319
|
+
lectures = self.list_lectures(course_name)
|
|
320
|
+
return [l for l in lectures if l.week == course.week]
|
|
321
|
+
|
|
322
|
+
def search_lectures(self, query: str) -> list[Lecture]:
|
|
323
|
+
"""Search for lectures across all courses."""
|
|
324
|
+
results = []
|
|
325
|
+
pattern = re.compile(query, re.IGNORECASE)
|
|
326
|
+
|
|
327
|
+
for course in self.list_courses():
|
|
328
|
+
for lecture in self.list_lectures(course.name):
|
|
329
|
+
if pattern.search(lecture.name) or pattern.search(lecture.title):
|
|
330
|
+
results.append(lecture)
|
|
331
|
+
|
|
332
|
+
return results
|
|
333
|
+
|
|
334
|
+
def get_syllabus(self, course_name: str) -> str | None:
|
|
335
|
+
"""Get the syllabus content for a course."""
|
|
336
|
+
course = self.get_course(course_name)
|
|
337
|
+
if not course:
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
course_path = Path(course.path)
|
|
341
|
+
|
|
342
|
+
# Try common syllabus locations
|
|
343
|
+
syllabus_files = [
|
|
344
|
+
"syllabus.qmd",
|
|
345
|
+
"syllabus.md",
|
|
346
|
+
"_syllabus.qmd",
|
|
347
|
+
"00-syllabus.qmd",
|
|
348
|
+
"docs/syllabus.qmd",
|
|
349
|
+
]
|
|
350
|
+
|
|
351
|
+
for filename in syllabus_files:
|
|
352
|
+
syllabus_path = course_path / filename
|
|
353
|
+
if syllabus_path.exists():
|
|
354
|
+
return syllabus_path.read_text()
|
|
355
|
+
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
def get_materials(self, course_name: str) -> list[dict]:
|
|
359
|
+
"""List teaching materials for a course."""
|
|
360
|
+
course = self.get_course(course_name)
|
|
361
|
+
if not course:
|
|
362
|
+
return []
|
|
363
|
+
|
|
364
|
+
materials = []
|
|
365
|
+
course_path = Path(course.path)
|
|
366
|
+
|
|
367
|
+
# Collect different types of materials
|
|
368
|
+
material_types = {
|
|
369
|
+
"lectures": ["lectures/*.qmd", "slides/*.qmd"],
|
|
370
|
+
"assignments": ["assignments/*.qmd", "homework/*.qmd"],
|
|
371
|
+
"labs": ["labs/*.qmd"],
|
|
372
|
+
"exams": ["exams/*.qmd", "midterm*.qmd", "final*.qmd"],
|
|
373
|
+
"resources": ["resources/*", "handouts/*"],
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
for mat_type, patterns in material_types.items():
|
|
377
|
+
for pattern in patterns:
|
|
378
|
+
for file_path in course_path.glob(pattern):
|
|
379
|
+
materials.append(
|
|
380
|
+
{
|
|
381
|
+
"type": mat_type,
|
|
382
|
+
"name": file_path.stem,
|
|
383
|
+
"path": str(file_path),
|
|
384
|
+
"format": file_path.suffix[1:] if file_path.suffix else "unknown",
|
|
385
|
+
}
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
return materials
|