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,397 @@
|
|
|
1
|
+
"""Manuscript management for Nexus CLI."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ManuscriptStatus:
|
|
14
|
+
"""Parsed .STATUS file for a manuscript."""
|
|
15
|
+
|
|
16
|
+
status: str = "unknown"
|
|
17
|
+
priority: str = "--"
|
|
18
|
+
progress: int = 0
|
|
19
|
+
next_action: str = ""
|
|
20
|
+
manuscript_type: str = "research"
|
|
21
|
+
target: str = ""
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_file(cls, path: Path) -> "ManuscriptStatus":
|
|
25
|
+
"""Parse a .STATUS file."""
|
|
26
|
+
if not path.exists():
|
|
27
|
+
return cls()
|
|
28
|
+
|
|
29
|
+
content = path.read_text()
|
|
30
|
+
result = cls()
|
|
31
|
+
|
|
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_action = line.split(":", 1)[1].strip()
|
|
45
|
+
elif line.startswith("type:"):
|
|
46
|
+
result.manuscript_type = line.split(":", 1)[1].strip()
|
|
47
|
+
elif line.startswith("target:"):
|
|
48
|
+
result.target = line.split(":", 1)[1].strip()
|
|
49
|
+
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class QuartoManuscript:
|
|
55
|
+
"""Parsed _quarto.yml for manuscript projects."""
|
|
56
|
+
|
|
57
|
+
title: str = ""
|
|
58
|
+
authors: list[str] = field(default_factory=list)
|
|
59
|
+
article_file: str = "index.qmd"
|
|
60
|
+
format_type: str = "manuscript"
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_file(cls, path: Path) -> Optional["QuartoManuscript"]:
|
|
64
|
+
"""Parse a _quarto.yml file."""
|
|
65
|
+
if not path.exists():
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
with open(path) as f:
|
|
70
|
+
data = yaml.safe_load(f) or {}
|
|
71
|
+
except Exception:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
result = cls()
|
|
75
|
+
|
|
76
|
+
# Get project type
|
|
77
|
+
project = data.get("project", {})
|
|
78
|
+
result.format_type = project.get("type", "default")
|
|
79
|
+
|
|
80
|
+
# Get manuscript settings
|
|
81
|
+
manuscript = data.get("manuscript", {})
|
|
82
|
+
result.article_file = manuscript.get("article", "index.qmd")
|
|
83
|
+
|
|
84
|
+
# Get title from various sources
|
|
85
|
+
result.title = data.get("title", "")
|
|
86
|
+
|
|
87
|
+
# Get authors
|
|
88
|
+
authors = data.get("author", [])
|
|
89
|
+
if isinstance(authors, list):
|
|
90
|
+
for author in authors:
|
|
91
|
+
if isinstance(author, dict):
|
|
92
|
+
name = author.get("name", "")
|
|
93
|
+
if name:
|
|
94
|
+
result.authors.append(name)
|
|
95
|
+
elif isinstance(author, str):
|
|
96
|
+
result.authors.append(author)
|
|
97
|
+
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class Manuscript:
|
|
103
|
+
"""A research manuscript."""
|
|
104
|
+
|
|
105
|
+
name: str
|
|
106
|
+
path: str
|
|
107
|
+
title: str = ""
|
|
108
|
+
status: str = "unknown"
|
|
109
|
+
progress: int = 0
|
|
110
|
+
target: str = ""
|
|
111
|
+
next_action: str = ""
|
|
112
|
+
authors: list[str] = field(default_factory=list)
|
|
113
|
+
format_type: str = "unknown" # quarto, latex, markdown
|
|
114
|
+
main_file: str = ""
|
|
115
|
+
word_count: int = 0
|
|
116
|
+
last_modified: datetime | None = None
|
|
117
|
+
|
|
118
|
+
def to_dict(self) -> dict:
|
|
119
|
+
"""Convert to dictionary."""
|
|
120
|
+
return {
|
|
121
|
+
"name": self.name,
|
|
122
|
+
"path": self.path,
|
|
123
|
+
"title": self.title or self.name,
|
|
124
|
+
"status": self.status,
|
|
125
|
+
"progress": self.progress,
|
|
126
|
+
"target": self.target,
|
|
127
|
+
"next_action": self.next_action,
|
|
128
|
+
"authors": self.authors,
|
|
129
|
+
"format_type": self.format_type,
|
|
130
|
+
"main_file": self.main_file,
|
|
131
|
+
"word_count": self.word_count,
|
|
132
|
+
"last_modified": self.last_modified.isoformat() if self.last_modified else None,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def status_emoji(self) -> str:
|
|
137
|
+
"""Get emoji for status."""
|
|
138
|
+
status_lower = self.status.lower()
|
|
139
|
+
if "complete" in status_lower or "published" in status_lower:
|
|
140
|
+
return "✅"
|
|
141
|
+
elif "review" in status_lower:
|
|
142
|
+
return "📬"
|
|
143
|
+
elif "revision" in status_lower:
|
|
144
|
+
return "✏️"
|
|
145
|
+
elif "draft" in status_lower:
|
|
146
|
+
return "📝"
|
|
147
|
+
elif "active" in status_lower:
|
|
148
|
+
return "🔥"
|
|
149
|
+
elif "paused" in status_lower or "hold" in status_lower:
|
|
150
|
+
return "⏸️"
|
|
151
|
+
elif "idea" in status_lower or "planning" in status_lower:
|
|
152
|
+
return "💡"
|
|
153
|
+
else:
|
|
154
|
+
return "📄"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class ManuscriptManager:
|
|
158
|
+
"""Manage research manuscripts."""
|
|
159
|
+
|
|
160
|
+
def __init__(self, manuscripts_dir: Path, templates_dir: Path | None = None):
|
|
161
|
+
"""Initialize manuscript manager.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
manuscripts_dir: Path to manuscripts directory (e.g., ~/projects/research)
|
|
165
|
+
templates_dir: Path to manuscript templates
|
|
166
|
+
"""
|
|
167
|
+
self.manuscripts_dir = Path(manuscripts_dir).expanduser()
|
|
168
|
+
self.templates_dir = Path(templates_dir).expanduser() if templates_dir else None
|
|
169
|
+
|
|
170
|
+
def exists(self) -> bool:
|
|
171
|
+
"""Check if manuscripts directory exists."""
|
|
172
|
+
return self.manuscripts_dir.exists()
|
|
173
|
+
|
|
174
|
+
def list_manuscripts(self, include_archived: bool = False) -> list[Manuscript]:
|
|
175
|
+
"""List all manuscripts in the manuscripts directory."""
|
|
176
|
+
if not self.exists():
|
|
177
|
+
return []
|
|
178
|
+
|
|
179
|
+
manuscripts = []
|
|
180
|
+
for ms_path in sorted(self.manuscripts_dir.iterdir()):
|
|
181
|
+
if not ms_path.is_dir():
|
|
182
|
+
continue
|
|
183
|
+
if ms_path.name.startswith("."):
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
manuscript = self._load_manuscript(ms_path)
|
|
187
|
+
if manuscript:
|
|
188
|
+
# Filter archived if requested
|
|
189
|
+
if not include_archived:
|
|
190
|
+
status_lower = manuscript.status.lower()
|
|
191
|
+
if "archive" in status_lower or "complete" in status_lower:
|
|
192
|
+
continue
|
|
193
|
+
manuscripts.append(manuscript)
|
|
194
|
+
|
|
195
|
+
# Sort by progress (in-progress first), then by name
|
|
196
|
+
manuscripts.sort(
|
|
197
|
+
key=lambda m: (
|
|
198
|
+
0 if m.status.lower() in ("active", "draft", "revision") else 1,
|
|
199
|
+
-m.progress,
|
|
200
|
+
m.name.lower(),
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return manuscripts
|
|
205
|
+
|
|
206
|
+
def get_manuscript(self, name: str) -> Manuscript | None:
|
|
207
|
+
"""Get a specific manuscript by name."""
|
|
208
|
+
ms_path = self.manuscripts_dir / name
|
|
209
|
+
if not ms_path.exists():
|
|
210
|
+
# Try case-insensitive and partial match
|
|
211
|
+
for p in self.manuscripts_dir.iterdir():
|
|
212
|
+
if p.name.lower() == name.lower():
|
|
213
|
+
ms_path = p
|
|
214
|
+
break
|
|
215
|
+
if name.lower() in p.name.lower():
|
|
216
|
+
ms_path = p
|
|
217
|
+
break
|
|
218
|
+
else:
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
return self._load_manuscript(ms_path)
|
|
222
|
+
|
|
223
|
+
def _load_manuscript(self, ms_path: Path) -> Manuscript | None:
|
|
224
|
+
"""Load manuscript data from a directory."""
|
|
225
|
+
if not ms_path.is_dir():
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
# Parse .STATUS file
|
|
229
|
+
status_file = ms_path / ".STATUS"
|
|
230
|
+
status = ManuscriptStatus.from_file(status_file)
|
|
231
|
+
|
|
232
|
+
# Determine format and get metadata
|
|
233
|
+
format_type = "unknown"
|
|
234
|
+
title = ""
|
|
235
|
+
authors = []
|
|
236
|
+
main_file = ""
|
|
237
|
+
|
|
238
|
+
# Check for Quarto manuscript
|
|
239
|
+
quarto_file = ms_path / "_quarto.yml"
|
|
240
|
+
if quarto_file.exists():
|
|
241
|
+
format_type = "quarto"
|
|
242
|
+
quarto_config = QuartoManuscript.from_file(quarto_file)
|
|
243
|
+
if quarto_config:
|
|
244
|
+
title = quarto_config.title
|
|
245
|
+
authors = quarto_config.authors
|
|
246
|
+
main_file = quarto_config.article_file
|
|
247
|
+
|
|
248
|
+
# Check _manuscript subfolder (Quarto manuscript format)
|
|
249
|
+
manuscript_subfolder = ms_path / "_manuscript"
|
|
250
|
+
if manuscript_subfolder.exists():
|
|
251
|
+
index_file = manuscript_subfolder / "index.qmd"
|
|
252
|
+
if index_file.exists():
|
|
253
|
+
main_file = str(index_file.relative_to(ms_path))
|
|
254
|
+
|
|
255
|
+
# Check for LaTeX
|
|
256
|
+
tex_files = list(ms_path.glob("*.tex"))
|
|
257
|
+
if tex_files and format_type == "unknown":
|
|
258
|
+
format_type = "latex"
|
|
259
|
+
# Find main tex file (usually named main.tex or same as folder)
|
|
260
|
+
for tf in tex_files:
|
|
261
|
+
if tf.stem in ("main", ms_path.name, "manuscript"):
|
|
262
|
+
main_file = tf.name
|
|
263
|
+
break
|
|
264
|
+
if not main_file and tex_files:
|
|
265
|
+
main_file = tex_files[0].name
|
|
266
|
+
|
|
267
|
+
# Check for plain markdown
|
|
268
|
+
if format_type == "unknown":
|
|
269
|
+
md_files = list(ms_path.glob("*.md"))
|
|
270
|
+
if md_files:
|
|
271
|
+
format_type = "markdown"
|
|
272
|
+
for mf in md_files:
|
|
273
|
+
if mf.stem in ("manuscript", "main", "README"):
|
|
274
|
+
main_file = mf.name
|
|
275
|
+
break
|
|
276
|
+
|
|
277
|
+
# Get last modified time
|
|
278
|
+
last_modified = None
|
|
279
|
+
try:
|
|
280
|
+
stat = ms_path.stat()
|
|
281
|
+
last_modified = datetime.fromtimestamp(stat.st_mtime)
|
|
282
|
+
except Exception:
|
|
283
|
+
pass
|
|
284
|
+
|
|
285
|
+
# Estimate word count from main file
|
|
286
|
+
word_count = 0
|
|
287
|
+
if main_file:
|
|
288
|
+
main_path = ms_path / main_file
|
|
289
|
+
if main_path.exists():
|
|
290
|
+
word_count = self._estimate_word_count(main_path)
|
|
291
|
+
|
|
292
|
+
return Manuscript(
|
|
293
|
+
name=ms_path.name,
|
|
294
|
+
path=str(ms_path),
|
|
295
|
+
title=title or self._title_from_name(ms_path.name),
|
|
296
|
+
status=status.status,
|
|
297
|
+
progress=status.progress,
|
|
298
|
+
target=status.target,
|
|
299
|
+
next_action=status.next_action,
|
|
300
|
+
authors=authors,
|
|
301
|
+
format_type=format_type,
|
|
302
|
+
main_file=main_file,
|
|
303
|
+
word_count=word_count,
|
|
304
|
+
last_modified=last_modified,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def _title_from_name(self, name: str) -> str:
|
|
308
|
+
"""Generate a title from folder name."""
|
|
309
|
+
# Convert folder name to title case
|
|
310
|
+
return name.replace("-", " ").replace("_", " ").title()
|
|
311
|
+
|
|
312
|
+
def _estimate_word_count(self, path: Path) -> int:
|
|
313
|
+
"""Estimate word count from a file."""
|
|
314
|
+
try:
|
|
315
|
+
content = path.read_text()
|
|
316
|
+
# Remove YAML frontmatter
|
|
317
|
+
if content.startswith("---"):
|
|
318
|
+
end = content.find("---", 3)
|
|
319
|
+
if end > 0:
|
|
320
|
+
content = content[end + 3 :]
|
|
321
|
+
# Remove code blocks
|
|
322
|
+
content = re.sub(r"```[\s\S]*?```", "", content)
|
|
323
|
+
# Remove inline code
|
|
324
|
+
content = re.sub(r"`[^`]+`", "", content)
|
|
325
|
+
# Remove LaTeX math
|
|
326
|
+
content = re.sub(r"\$\$[\s\S]*?\$\$", "", content)
|
|
327
|
+
content = re.sub(r"\$[^$]+\$", "", content)
|
|
328
|
+
# Count words
|
|
329
|
+
words = content.split()
|
|
330
|
+
return len(words)
|
|
331
|
+
except Exception:
|
|
332
|
+
return 0
|
|
333
|
+
|
|
334
|
+
def get_by_status(self, status: str) -> list[Manuscript]:
|
|
335
|
+
"""Get manuscripts with a specific status."""
|
|
336
|
+
all_manuscripts = self.list_manuscripts(include_archived=True)
|
|
337
|
+
status_lower = status.lower()
|
|
338
|
+
return [m for m in all_manuscripts if status_lower in m.status.lower()]
|
|
339
|
+
|
|
340
|
+
def get_active(self) -> list[Manuscript]:
|
|
341
|
+
"""Get actively worked manuscripts."""
|
|
342
|
+
all_manuscripts = self.list_manuscripts(include_archived=False)
|
|
343
|
+
return [m for m in all_manuscripts if m.status.lower() in ("active", "draft", "revision", "under review")]
|
|
344
|
+
|
|
345
|
+
def search(self, query: str) -> list[Manuscript]:
|
|
346
|
+
"""Search manuscripts by name or title."""
|
|
347
|
+
all_manuscripts = self.list_manuscripts(include_archived=True)
|
|
348
|
+
pattern = re.compile(query, re.IGNORECASE)
|
|
349
|
+
return [m for m in all_manuscripts if pattern.search(m.name) or pattern.search(m.title)]
|
|
350
|
+
|
|
351
|
+
def get_statistics(self) -> dict:
|
|
352
|
+
"""Get manuscript statistics."""
|
|
353
|
+
all_manuscripts = self.list_manuscripts(include_archived=True)
|
|
354
|
+
|
|
355
|
+
stats = {
|
|
356
|
+
"total": len(all_manuscripts),
|
|
357
|
+
"by_status": {},
|
|
358
|
+
"by_format": {},
|
|
359
|
+
"total_words": 0,
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
for m in all_manuscripts:
|
|
363
|
+
# Count by status
|
|
364
|
+
status = m.status.lower()
|
|
365
|
+
if status not in stats["by_status"]:
|
|
366
|
+
stats["by_status"][status] = 0
|
|
367
|
+
stats["by_status"][status] += 1
|
|
368
|
+
|
|
369
|
+
# Count by format
|
|
370
|
+
fmt = m.format_type
|
|
371
|
+
if fmt not in stats["by_format"]:
|
|
372
|
+
stats["by_format"][fmt] = 0
|
|
373
|
+
stats["by_format"][fmt] += 1
|
|
374
|
+
|
|
375
|
+
# Total words
|
|
376
|
+
stats["total_words"] += m.word_count
|
|
377
|
+
|
|
378
|
+
return stats
|
|
379
|
+
|
|
380
|
+
def get_deadlines(self) -> list[dict]:
|
|
381
|
+
"""Get manuscripts with deadlines/targets."""
|
|
382
|
+
all_manuscripts = self.list_manuscripts(include_archived=False)
|
|
383
|
+
deadlines = []
|
|
384
|
+
|
|
385
|
+
for m in all_manuscripts:
|
|
386
|
+
if m.target and m.status.lower() not in ("complete", "published", "archived"):
|
|
387
|
+
deadlines.append(
|
|
388
|
+
{
|
|
389
|
+
"name": m.name,
|
|
390
|
+
"title": m.title,
|
|
391
|
+
"target": m.target,
|
|
392
|
+
"status": m.status,
|
|
393
|
+
"progress": m.progress,
|
|
394
|
+
}
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
return deadlines
|