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,521 @@
1
+ """Zotero library integration for Nexus CLI."""
2
+
3
+ import sqlite3
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass
9
+ class ZoteroItem:
10
+ """A Zotero library item."""
11
+
12
+ item_id: int
13
+ key: str
14
+ item_type: str
15
+ title: str
16
+ authors: list[str] = field(default_factory=list)
17
+ date: str = ""
18
+ abstract: str = ""
19
+ doi: str = ""
20
+ url: str = ""
21
+ tags: list[str] = field(default_factory=list)
22
+ collections: list[str] = field(default_factory=list)
23
+
24
+ def to_dict(self) -> dict:
25
+ """Convert to dictionary."""
26
+ return {
27
+ "item_id": self.item_id,
28
+ "key": self.key,
29
+ "item_type": self.item_type,
30
+ "title": self.title,
31
+ "authors": self.authors,
32
+ "date": self.date,
33
+ "abstract": self.abstract[:200] + "..." if len(self.abstract) > 200 else self.abstract,
34
+ "doi": self.doi,
35
+ "url": self.url,
36
+ "tags": self.tags,
37
+ "collections": self.collections,
38
+ }
39
+
40
+ def citation_apa(self) -> str:
41
+ """Generate APA-style citation."""
42
+ # Format authors
43
+ if not self.authors:
44
+ author_str = "Unknown"
45
+ elif len(self.authors) == 1:
46
+ author_str = self.authors[0]
47
+ elif len(self.authors) == 2:
48
+ author_str = f"{self.authors[0]} & {self.authors[1]}"
49
+ elif len(self.authors) <= 5:
50
+ author_str = ", ".join(self.authors[:-1]) + f", & {self.authors[-1]}"
51
+ else:
52
+ author_str = f"{self.authors[0]} et al."
53
+
54
+ # Format year
55
+ year = self.date[:4] if self.date else "n.d."
56
+
57
+ return f"{author_str} ({year}). {self.title}."
58
+
59
+ def citation_bibtex(self) -> str:
60
+ """Generate BibTeX citation."""
61
+ # Create cite key from first author and year
62
+ if self.authors:
63
+ first_author = self.authors[0].split()[-1].lower()
64
+ else:
65
+ first_author = "unknown"
66
+ year = self.date[:4] if self.date else "0000"
67
+ cite_key = f"{first_author}{year}"
68
+
69
+ # Map item types
70
+ bibtex_type = {
71
+ "journalArticle": "article",
72
+ "book": "book",
73
+ "bookSection": "incollection",
74
+ "conferencePaper": "inproceedings",
75
+ "thesis": "phdthesis",
76
+ "report": "techreport",
77
+ }.get(self.item_type, "misc")
78
+
79
+ lines = [f"@{bibtex_type}{{{cite_key},"]
80
+ lines.append(f" title = {{{self.title}}},")
81
+ if self.authors:
82
+ lines.append(f" author = {{{' and '.join(self.authors)}}},")
83
+ if self.date:
84
+ lines.append(f" year = {{{year}}},")
85
+ if self.doi:
86
+ lines.append(f" doi = {{{self.doi}}},")
87
+ if self.url:
88
+ lines.append(f" url = {{{self.url}}},")
89
+ lines.append("}")
90
+
91
+ return "\n".join(lines)
92
+
93
+
94
+ class ZoteroClient:
95
+ """Client for querying Zotero SQLite database."""
96
+
97
+ # Field IDs from Zotero schema
98
+ FIELD_TITLE = 1
99
+ FIELD_ABSTRACT = 2
100
+ FIELD_DATE = 6
101
+ FIELD_URL = 13
102
+ FIELD_DOI = 59
103
+
104
+ def __init__(self, db_path: Path, storage_path: Path | None = None):
105
+ """Initialize Zotero client.
106
+
107
+ Args:
108
+ db_path: Path to zotero.sqlite
109
+ storage_path: Path to Zotero storage folder (for attachments)
110
+ """
111
+ self.db_path = Path(db_path).expanduser()
112
+ self.storage_path = Path(storage_path).expanduser() if storage_path else None
113
+
114
+ def exists(self) -> bool:
115
+ """Check if database exists."""
116
+ return self.db_path.exists()
117
+
118
+ def _connect(self) -> sqlite3.Connection:
119
+ """Get database connection."""
120
+ # Connect in read-only mode to avoid locking issues
121
+ conn = sqlite3.connect(f"file:{self.db_path}?mode=ro", uri=True)
122
+ conn.row_factory = sqlite3.Row
123
+ return conn
124
+
125
+ def count(self) -> int:
126
+ """Count total library items (excluding attachments, notes, annotations)."""
127
+ if not self.exists():
128
+ return 0
129
+
130
+ conn = self._connect()
131
+ try:
132
+ cursor = conn.execute("""
133
+ SELECT COUNT(*)
134
+ FROM items i
135
+ JOIN itemTypes it ON i.itemTypeID = it.itemTypeID
136
+ WHERE it.typeName NOT IN ('attachment', 'annotation', 'note')
137
+ """)
138
+ return cursor.fetchone()[0]
139
+ finally:
140
+ conn.close()
141
+
142
+ def _get_field_value(self, conn: sqlite3.Connection, item_id: int, field_id: int) -> str:
143
+ """Get a field value for an item."""
144
+ cursor = conn.execute(
145
+ """
146
+ SELECT idv.value
147
+ FROM itemData id
148
+ JOIN itemDataValues idv ON id.valueID = idv.valueID
149
+ WHERE id.itemID = ? AND id.fieldID = ?
150
+ """,
151
+ (item_id, field_id),
152
+ )
153
+ row = cursor.fetchone()
154
+ return row[0] if row else ""
155
+
156
+ def _get_authors(self, conn: sqlite3.Connection, item_id: int) -> list[str]:
157
+ """Get authors for an item."""
158
+ cursor = conn.execute(
159
+ """
160
+ SELECT c.firstName, c.lastName
161
+ FROM itemCreators ic
162
+ JOIN creators c ON ic.creatorID = c.creatorID
163
+ JOIN creatorTypes ct ON ic.creatorTypeID = ct.creatorTypeID
164
+ WHERE ic.itemID = ? AND ct.creatorType = 'author'
165
+ ORDER BY ic.orderIndex
166
+ """,
167
+ (item_id,),
168
+ )
169
+
170
+ authors = []
171
+ for row in cursor:
172
+ first = row["firstName"] or ""
173
+ last = row["lastName"] or ""
174
+ if first and last:
175
+ authors.append(f"{first} {last}")
176
+ elif last:
177
+ authors.append(last)
178
+ elif first:
179
+ authors.append(first)
180
+
181
+ return authors
182
+
183
+ def _get_tags(self, conn: sqlite3.Connection, item_id: int) -> list[str]:
184
+ """Get tags for an item."""
185
+ cursor = conn.execute(
186
+ """
187
+ SELECT t.name
188
+ FROM itemTags it
189
+ JOIN tags t ON it.tagID = t.tagID
190
+ WHERE it.itemID = ?
191
+ """,
192
+ (item_id,),
193
+ )
194
+ return [row["name"] for row in cursor]
195
+
196
+ def _get_collections(self, conn: sqlite3.Connection, item_id: int) -> list[str]:
197
+ """Get collection names for an item."""
198
+ cursor = conn.execute(
199
+ """
200
+ SELECT c.collectionName
201
+ FROM collectionItems ci
202
+ JOIN collections c ON ci.collectionID = c.collectionID
203
+ WHERE ci.itemID = ?
204
+ """,
205
+ (item_id,),
206
+ )
207
+ return [row["collectionName"] for row in cursor]
208
+
209
+ def _build_item(self, conn: sqlite3.Connection, row: sqlite3.Row) -> ZoteroItem:
210
+ """Build a ZoteroItem from a database row."""
211
+ item_id = row["itemID"]
212
+
213
+ return ZoteroItem(
214
+ item_id=item_id,
215
+ key=row["key"],
216
+ item_type=row["typeName"],
217
+ title=self._get_field_value(conn, item_id, self.FIELD_TITLE),
218
+ authors=self._get_authors(conn, item_id),
219
+ date=self._get_field_value(conn, item_id, self.FIELD_DATE),
220
+ abstract=self._get_field_value(conn, item_id, self.FIELD_ABSTRACT),
221
+ doi=self._get_field_value(conn, item_id, self.FIELD_DOI),
222
+ url=self._get_field_value(conn, item_id, self.FIELD_URL),
223
+ tags=self._get_tags(conn, item_id),
224
+ collections=self._get_collections(conn, item_id),
225
+ )
226
+
227
+ def search(
228
+ self,
229
+ query: str,
230
+ limit: int = 20,
231
+ item_type: str | None = None,
232
+ tag: str | None = None,
233
+ ) -> list[ZoteroItem]:
234
+ """Search the Zotero library.
235
+
236
+ Args:
237
+ query: Search query (searches title, authors, abstract)
238
+ limit: Maximum results to return
239
+ item_type: Filter by item type (e.g., 'journalArticle')
240
+ tag: Filter by tag
241
+
242
+ Returns:
243
+ List of matching ZoteroItems
244
+ """
245
+ if not self.exists():
246
+ return []
247
+
248
+ conn = self._connect()
249
+ try:
250
+ # Build the search query
251
+ # Search in title and abstract via itemDataValues
252
+ sql = """
253
+ SELECT DISTINCT i.itemID, i.key, it.typeName
254
+ FROM items i
255
+ JOIN itemTypes it ON i.itemTypeID = it.itemTypeID
256
+ LEFT JOIN itemData id ON i.itemID = id.itemID
257
+ LEFT JOIN itemDataValues idv ON id.valueID = idv.valueID
258
+ LEFT JOIN itemCreators ic ON i.itemID = ic.itemID
259
+ LEFT JOIN creators c ON ic.creatorID = c.creatorID
260
+ WHERE it.typeName NOT IN ('attachment', 'annotation', 'note')
261
+ AND (
262
+ idv.value LIKE ?
263
+ OR c.firstName LIKE ?
264
+ OR c.lastName LIKE ?
265
+ )
266
+ """
267
+ params: list = [f"%{query}%", f"%{query}%", f"%{query}%"]
268
+
269
+ if item_type:
270
+ sql += " AND it.typeName = ?"
271
+ params.append(item_type)
272
+
273
+ if tag:
274
+ sql += """
275
+ AND i.itemID IN (
276
+ SELECT it2.itemID FROM itemTags it2
277
+ JOIN tags t ON it2.tagID = t.tagID
278
+ WHERE t.name LIKE ?
279
+ )
280
+ """
281
+ params.append(f"%{tag}%")
282
+
283
+ sql += f" ORDER BY i.dateModified DESC LIMIT {limit}"
284
+
285
+ cursor = conn.execute(sql, params)
286
+ items = []
287
+ for row in cursor:
288
+ items.append(self._build_item(conn, row))
289
+
290
+ return items
291
+ finally:
292
+ conn.close()
293
+
294
+ def get(self, key: str) -> ZoteroItem | None:
295
+ """Get a specific item by key.
296
+
297
+ Args:
298
+ key: Zotero item key (e.g., 'ABC12345')
299
+
300
+ Returns:
301
+ ZoteroItem or None if not found
302
+ """
303
+ if not self.exists():
304
+ return None
305
+
306
+ conn = self._connect()
307
+ try:
308
+ cursor = conn.execute(
309
+ """
310
+ SELECT i.itemID, i.key, it.typeName
311
+ FROM items i
312
+ JOIN itemTypes it ON i.itemTypeID = it.itemTypeID
313
+ WHERE i.key = ?
314
+ """,
315
+ (key,),
316
+ )
317
+ row = cursor.fetchone()
318
+ if not row:
319
+ return None
320
+ return self._build_item(conn, row)
321
+ finally:
322
+ conn.close()
323
+
324
+ def recent(self, limit: int = 20) -> list[ZoteroItem]:
325
+ """Get recently modified items.
326
+
327
+ Args:
328
+ limit: Maximum results
329
+
330
+ Returns:
331
+ List of recent ZoteroItems
332
+ """
333
+ if not self.exists():
334
+ return []
335
+
336
+ conn = self._connect()
337
+ try:
338
+ cursor = conn.execute(
339
+ """
340
+ SELECT i.itemID, i.key, it.typeName
341
+ FROM items i
342
+ JOIN itemTypes it ON i.itemTypeID = it.itemTypeID
343
+ WHERE it.typeName NOT IN ('attachment', 'annotation', 'note')
344
+ ORDER BY i.dateModified DESC
345
+ LIMIT ?
346
+ """,
347
+ (limit,),
348
+ )
349
+
350
+ items = []
351
+ for row in cursor:
352
+ items.append(self._build_item(conn, row))
353
+
354
+ return items
355
+ finally:
356
+ conn.close()
357
+
358
+ def by_tag(self, tag: str, limit: int = 50) -> list[ZoteroItem]:
359
+ """Get items with a specific tag.
360
+
361
+ Args:
362
+ tag: Tag name
363
+ limit: Maximum results
364
+
365
+ Returns:
366
+ List of matching ZoteroItems
367
+ """
368
+ if not self.exists():
369
+ return []
370
+
371
+ conn = self._connect()
372
+ try:
373
+ cursor = conn.execute(
374
+ """
375
+ SELECT DISTINCT i.itemID, i.key, it.typeName
376
+ FROM items i
377
+ JOIN itemTypes it ON i.itemTypeID = it.itemTypeID
378
+ JOIN itemTags itg ON i.itemID = itg.itemID
379
+ JOIN tags t ON itg.tagID = t.tagID
380
+ WHERE t.name LIKE ?
381
+ AND it.typeName NOT IN ('attachment', 'annotation', 'note')
382
+ ORDER BY i.dateModified DESC
383
+ LIMIT ?
384
+ """,
385
+ (f"%{tag}%", limit),
386
+ )
387
+
388
+ items = []
389
+ for row in cursor:
390
+ items.append(self._build_item(conn, row))
391
+
392
+ return items
393
+ finally:
394
+ conn.close()
395
+
396
+ def tags(self, limit: int = 100) -> list[tuple[str, int]]:
397
+ """Get all tags with counts.
398
+
399
+ Args:
400
+ limit: Maximum tags to return
401
+
402
+ Returns:
403
+ List of (tag_name, count) tuples
404
+ """
405
+ if not self.exists():
406
+ return []
407
+
408
+ conn = self._connect()
409
+ try:
410
+ cursor = conn.execute(
411
+ """
412
+ SELECT t.name, COUNT(it.itemID) as cnt
413
+ FROM tags t
414
+ JOIN itemTags it ON t.tagID = it.tagID
415
+ GROUP BY t.tagID
416
+ ORDER BY cnt DESC
417
+ LIMIT ?
418
+ """,
419
+ (limit,),
420
+ )
421
+ return [(row["name"], row["cnt"]) for row in cursor]
422
+ finally:
423
+ conn.close()
424
+
425
+ def collections(self) -> list[tuple[str, int]]:
426
+ """Get all collections with item counts.
427
+
428
+ Returns:
429
+ List of (collection_name, count) tuples
430
+ """
431
+ if not self.exists():
432
+ return []
433
+
434
+ conn = self._connect()
435
+ try:
436
+ cursor = conn.execute("""
437
+ SELECT c.collectionName, COUNT(ci.itemID) as cnt
438
+ FROM collections c
439
+ LEFT JOIN collectionItems ci ON c.collectionID = ci.collectionID
440
+ GROUP BY c.collectionID
441
+ ORDER BY c.collectionName
442
+ """)
443
+ return [(row["collectionName"], row["cnt"]) for row in cursor]
444
+ finally:
445
+ conn.close()
446
+
447
+ def by_collection(self, collection_name: str, limit: int = 50) -> list[ZoteroItem]:
448
+ """Get items in a specific collection.
449
+
450
+ Args:
451
+ collection_name: Collection name
452
+ limit: Maximum results
453
+
454
+ Returns:
455
+ List of ZoteroItems in the collection
456
+ """
457
+ if not self.exists():
458
+ return []
459
+
460
+ conn = self._connect()
461
+ try:
462
+ cursor = conn.execute(
463
+ """
464
+ SELECT DISTINCT i.itemID, i.key, it.typeName
465
+ FROM items i
466
+ JOIN itemTypes it ON i.itemTypeID = it.itemTypeID
467
+ JOIN collectionItems ci ON i.itemID = ci.itemID
468
+ JOIN collections c ON ci.collectionID = c.collectionID
469
+ WHERE c.collectionName LIKE ?
470
+ AND it.typeName NOT IN ('attachment', 'annotation', 'note')
471
+ ORDER BY i.dateModified DESC
472
+ LIMIT ?
473
+ """,
474
+ (f"%{collection_name}%", limit),
475
+ )
476
+
477
+ items = []
478
+ for row in cursor:
479
+ items.append(self._build_item(conn, row))
480
+
481
+ return items
482
+ finally:
483
+ conn.close()
484
+
485
+ def get_attachment_path(self, key: str) -> Path | None:
486
+ """Get the file path for an attachment.
487
+
488
+ Args:
489
+ key: Zotero item key
490
+
491
+ Returns:
492
+ Path to the attachment file, or None
493
+ """
494
+ if not self.exists() or not self.storage_path:
495
+ return None
496
+
497
+ conn = self._connect()
498
+ try:
499
+ # Get attachment info
500
+ cursor = conn.execute(
501
+ """
502
+ SELECT ia.path
503
+ FROM items i
504
+ JOIN itemAttachments ia ON i.itemID = ia.itemID
505
+ WHERE i.key = ?
506
+ """,
507
+ (key,),
508
+ )
509
+ row = cursor.fetchone()
510
+ if not row or not row["path"]:
511
+ return None
512
+
513
+ path = row["path"]
514
+ # Handle storage: prefix
515
+ if path.startswith("storage:"):
516
+ filename = path[8:]
517
+ return self.storage_path / key / filename
518
+
519
+ return Path(path)
520
+ finally:
521
+ conn.close()
@@ -0,0 +1,14 @@
1
+ """Teaching domain - Courses, materials, Quarto."""
2
+
3
+ from nexus.teaching.courses import Course, CourseManager, CourseStatus, Lecture
4
+ from nexus.teaching.quarto import QuartoBuildResult, QuartoManager, QuartoProject
5
+
6
+ __all__ = [
7
+ "Course",
8
+ "CourseManager",
9
+ "CourseStatus",
10
+ "Lecture",
11
+ "QuartoBuildResult",
12
+ "QuartoManager",
13
+ "QuartoProject",
14
+ ]