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
nexus/research/zotero.py
ADDED
|
@@ -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
|
+
]
|