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.
@@ -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