Coverage for src/local_deep_research/research_library/zotero/client.py: 83%

363 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-19 23:35 +0000

1""" 

2Thin client for the Zotero Web API (https://www.zotero.org/support/dev/web_api/v3). 

3 

4Uses the project's :class:`SafeSession` so every request is SSRF-validated 

5and response sizes are bounded, rather than pulling in ``pyzotero`` (which 

6would bypass those protections). Only the small slice of the API needed for 

7import / incremental sync is implemented: 

8 

9- list collections (for the UI picker) 

10- list item *versions* for a collection or the whole library 

11- fetch full item data for a batch of keys 

12- fetch an item's child attachments 

13- download an attachment's file bytes 

14 

15The sync service uses the ``/items/top?format=versions`` endpoint, which 

16returns the FULL ``{itemKey: version}`` map of the current top-level items. 

17Reconciling that map against its mapping table lets the sync detect additions, 

18metadata changes (version bump) and removals (key gone) — and, unlike a 

19``?since=`` delta, removals *from the collection* too. Full item data is then 

20fetched only for the keys that actually changed. Pure attachment edits that 

21don't bump the parent item's version are not detected by this approach — 

22acceptable for v1. 

23""" 

24 

25import re 

26import time 

27from dataclasses import dataclass, field 

28from html import unescape 

29from typing import Dict, List, Optional, Tuple 

30from urllib.parse import quote 

31 

32import requests 

33from loguru import logger 

34 

35from ...security import SafeSession 

36 

37 

38def _html_to_text(html: str) -> str: 

39 """Minimal HTML→text for Zotero note bodies (which are stored as HTML).""" 

40 if not html: 40 ↛ 41line 40 didn't jump to line 41 because the condition on line 40 was never true

41 return "" 

42 # Turn block-ish tags into newlines, drop the rest, unescape entities. 

43 text = re.sub(r"(?i)<(br|/p|/div|/li|/h[1-6])\s*>", "\n", html) 

44 text = re.sub(r"<[^>]+>", "", text) 

45 text = unescape(text) 

46 # Collapse runs of blank lines / trailing spaces. 

47 lines = [ln.strip() for ln in text.splitlines()] 

48 return "\n".join(ln for ln in lines if ln).strip() 

49 

50 

51ZOTERO_API_BASE = "https://api.zotero.org" 

52# Zotero 7 desktop's read-only local API (no API key; can serve local files). 

53ZOTERO_LOCAL_API_BASE = "http://localhost:23119/api" 

54ZOTERO_API_VERSION = "3" 

55 

56# Zotero caps multi-key item requests at 50 keys. 

57_MAX_KEYS_PER_REQUEST = 50 

58# Pagination page size for list endpoints. 

59_PAGE_LIMIT = 100 

60# Hard cap on paginated requests per call. Bounds memory and guards against a 

61# server that returns full pages forever (ignoring `start`) — without it the 

62# while-True paginators below could loop unbounded. 10k pages * 100 = 1M items. 

63_MAX_PAGES = 10_000 

64# Cap retries on transient errors / explicit Backoff|Retry-After headers. 

65_MAX_RETRIES = 4 

66_MAX_BACKOFF_SECONDS = 60 

67 

68 

69class ZoteroError(Exception): 

70 """Base error for Zotero API interactions.""" 

71 

72 

73class ZoteroAuthError(ZoteroError): 

74 """Raised on authentication / authorization failures (HTTP 403).""" 

75 

76 

77class ZoteroTransientError(ZoteroError): 

78 """Raised when a request fails for a transient reason (network error, or 

79 HTTP 429/5xx) after retries are exhausted. 

80 

81 Distinct from a plain :class:`ZoteroError` so per-item callers 

82 (``download_attachment`` / ``get_attachment_fulltext``) can tell a genuine 

83 "this item has no file / no indexed text" (404 → ``None``) apart from a 

84 temporary failure. A transient failure must NOT be recorded as "no PDF", 

85 otherwise a single rate-limit blip during a bulk sync would permanently 

86 downgrade the item to metadata-only until its Zotero version next changes. 

87 """ 

88 

89 

90@dataclass 

91class ZoteroAttachment: 

92 """A child attachment of a Zotero item.""" 

93 

94 key: str 

95 version: int 

96 content_type: Optional[str] 

97 link_mode: Optional[str] 

98 filename: Optional[str] 

99 title: Optional[str] 

100 

101 @property 

102 def is_downloadable_pdf(self) -> bool: 

103 """True if this is a stored PDF we can fetch via the API. 

104 

105 ``linked_file`` attachments point at a file on the user's local disk 

106 and are not retrievable through the Web API. 

107 """ 

108 return (self.content_type or "").lower() == "application/pdf" and ( 

109 self.link_mode or "" 

110 ) in ("imported_file", "imported_url") 

111 

112 

113@dataclass 

114class ZoteroItem: 

115 """A top-level Zotero item plus its (lazily attached) children.""" 

116 

117 key: str 

118 version: int 

119 item_type: str 

120 data: Dict = field(default_factory=dict) 

121 attachments: List[ZoteroAttachment] = field(default_factory=list) 

122 

123 @property 

124 def title(self) -> str: 

125 return (self.data.get("title") or "").strip() 

126 

127 def best_pdf_attachment(self) -> Optional[ZoteroAttachment]: 

128 """Return the first downloadable PDF attachment, if any.""" 

129 for att in self.attachments: 

130 if att.is_downloadable_pdf: 130 ↛ 129line 130 didn't jump to line 129 because the condition on line 130 was always true

131 return att 

132 return None 

133 

134 @property 

135 def is_standalone_pdf_attachment(self) -> bool: 

136 """A top-level stored PDF with no bibliographic parent. 

137 

138 This is what Zotero creates when a PDF is dropped straight into the 

139 library without "Retrieve Metadata" / "Create Parent Item" — very 

140 common in real libraries. It carries no metadata beyond a filename, 

141 but the PDF itself is fully retrievable, so it is importable as its 

142 own document. ``linked_file`` attachments are excluded (the Web API 

143 cannot serve them). 

144 """ 

145 return ( 

146 self.item_type == "attachment" 

147 and not self.data.get("parentItem") 

148 and (self.data.get("contentType") or "").lower() 

149 == "application/pdf" 

150 and (self.data.get("linkMode") or "").lower() 

151 in ("imported_file", "imported_url") 

152 ) 

153 

154 def as_attachment(self) -> ZoteroAttachment: 

155 """View this standalone attachment item as its own attachment.""" 

156 return ZoteroAttachment( 

157 key=self.key, 

158 version=self.version, 

159 content_type=self.data.get("contentType"), 

160 link_mode=self.data.get("linkMode"), 

161 filename=self.data.get("filename"), 

162 title=self.data.get("title") or self.data.get("filename"), 

163 ) 

164 

165 

166def _sleep_for_backoff(headers, attempt: int) -> None: 

167 """Honor Zotero ``Backoff`` / ``Retry-After`` headers (capped).""" 

168 raw = headers.get("Backoff") or headers.get("Retry-After") 

169 delay: float 

170 if raw: 

171 try: 

172 delay = float(raw) 

173 except (TypeError, ValueError): 

174 delay = 2.0 * attempt 

175 else: 

176 delay = min(2.0 * attempt, _MAX_BACKOFF_SECONDS) 

177 delay = min(max(delay, 0.0), _MAX_BACKOFF_SECONDS) 

178 if delay > 0: 

179 logger.debug(f"Zotero: backing off {delay:.1f}s (attempt {attempt})") 

180 time.sleep(delay) 

181 

182 

183class ZoteroClient: 

184 """Minimal Zotero Web API client scoped to one library.""" 

185 

186 def __init__( 

187 self, 

188 api_key: str, 

189 library_type: str, 

190 library_id: str, 

191 timeout: int = 30, 

192 local: bool = False, 

193 ): 

194 """Initialize the client. 

195 

196 Args: 

197 api_key: Zotero API key with read access (not needed when local). 

198 library_type: ``"user"`` or ``"group"``. 

199 library_id: Numeric user or group id. 

200 timeout: Per-request timeout in seconds. 

201 local: Talk to the desktop Zotero local API on localhost:23119 

202 (no API key; can serve local/linked files) instead of the 

203 cloud Web API. 

204 """ 

205 if library_type not in ("user", "group"): 

206 raise ValueError(f"Invalid library_type: {library_type!r}") 

207 if not api_key and not local: 

208 raise ValueError("Zotero API key is required") 

209 if not str(library_id).strip(): 

210 raise ValueError("Zotero library_id is required") 

211 

212 self.api_key = api_key 

213 self.library_type = library_type 

214 self.library_id = str(library_id).strip() 

215 self.timeout = timeout 

216 self.local = local 

217 self._base = ZOTERO_LOCAL_API_BASE if local else ZOTERO_API_BASE 

218 self._prefix = ( 

219 f"/{'users' if library_type == 'user' else 'groups'}" 

220 f"/{quote(self.library_id, safe='')}" 

221 ) 

222 # The local API is on the loopback interface; allow it explicitly. 

223 self.session = SafeSession(allow_localhost=local) 

224 headers = {"Zotero-API-Version": ZOTERO_API_VERSION} 

225 # The local desktop API needs no key and is reached over plaintext HTTP 

226 # on the loopback; never attach the key there (it would put a real 

227 # credential on the wire for no benefit). 

228 if api_key and not local: 

229 headers["Zotero-API-Key"] = self.api_key 

230 self.session.headers.update(headers) 

231 

232 # -- lifecycle --------------------------------------------------------- 

233 

234 def close(self) -> None: 

235 if getattr(self, "session", None) is not None: 

236 try: 

237 self.session.close() 

238 except Exception: 

239 logger.debug("Error closing Zotero session", exc_info=True) 

240 finally: 

241 self.session = None # type: ignore[assignment] 

242 

243 def __enter__(self) -> "ZoteroClient": 

244 return self 

245 

246 def __exit__(self, exc_type, exc_val, exc_tb) -> bool: 

247 self.close() 

248 return False 

249 

250 # -- low-level request ------------------------------------------------- 

251 

252 def _request( 

253 self, 

254 path: str, 

255 params: Optional[Dict] = None, 

256 *, 

257 stream: bool = False, 

258 allow_redirects: bool = True, 

259 prefixed: bool = True, 

260 ): 

261 """Perform a GET against the Zotero API with retry/backoff. 

262 

263 ``path`` is appended to the library prefix (e.g. ``/collections``) 

264 unless ``prefixed=False`` (for non-library endpoints like 

265 ``/keys/current`` or ``/users/{id}/groups``). 

266 Returns the ``requests.Response``. Raises :class:`ZoteroAuthError` 

267 on 403 and :class:`ZoteroError` on other non-2xx responses. 

268 

269 When ``allow_redirects=False``, a 3xx response is returned as-is 

270 (not raised) so the caller can follow the redirect itself — used by 

271 ``download_attachment`` to avoid forwarding the API key header to the 

272 storage host. 

273 """ 

274 prefix = self._prefix if prefixed else "" 

275 url = f"{self._base}{prefix}{path}" 

276 last_exc: Optional[Exception] = None 

277 for attempt in range(1, _MAX_RETRIES + 1): 277 ↛ 352line 277 didn't jump to line 352 because the loop on line 277 didn't complete

278 try: 

279 response = self.session.get( 

280 url, 

281 params=params, 

282 timeout=self.timeout, 

283 stream=stream, 

284 allow_redirects=allow_redirects, 

285 ) 

286 except ( 

287 requests.exceptions.Timeout, 

288 requests.exceptions.ConnectionError, 

289 ) as exc: 

290 # Transient network errors — worth retrying. 

291 last_exc = exc 

292 logger.warning( 

293 f"Zotero request error (attempt {attempt}): " 

294 f"{type(exc).__name__}" 

295 ) 

296 if attempt == _MAX_RETRIES: 

297 raise ZoteroTransientError( 

298 f"Zotero request failed: {type(exc).__name__}" 

299 ) from exc 

300 time.sleep(min(2.0 * attempt, _MAX_BACKOFF_SECONDS)) 

301 continue 

302 except Exception as exc: 

303 # Non-transient (e.g. SafeSession SSRF/oversize ValueError, or 

304 # a malformed-URL error) — do NOT retry; fail fast. 

305 raise ZoteroError( 

306 f"Zotero request failed: {type(exc).__name__}" 

307 ) from exc 

308 

309 status = response.status_code 

310 if status == 403: 

311 raise ZoteroAuthError( 

312 "Zotero rejected the API key (HTTP 403). Check the key " 

313 "and that it has read access to this library." 

314 ) 

315 if status == 404: 

316 raise ZoteroError( 

317 "Zotero resource not found (HTTP 404). Check the library " 

318 "id / collection key." 

319 ) 

320 if status in (429, 500, 502, 503, 504): 

321 logger.warning( 

322 f"Zotero transient HTTP {status} (attempt {attempt})" 

323 ) 

324 if attempt == _MAX_RETRIES: 

325 raise ZoteroTransientError( 

326 f"Zotero request failed after {attempt} attempts " 

327 f"(HTTP {status})" 

328 ) 

329 _sleep_for_backoff(response.headers, attempt) 

330 continue 

331 # When the caller manages redirects itself, hand back the 3xx. 

332 if not allow_redirects and 300 <= status < 400: 

333 return response 

334 if status == 400: 

335 # The most common cause by far: a username (or other 

336 # non-numeric value) in the library-ID setting. 

337 raise ZoteroError( 

338 "Zotero rejected the request (HTTP 400). This usually " 

339 "means the library ID is not the NUMERIC userID / group " 

340 "ID from zotero.org/settings/keys (e.g. a username was " 

341 "entered instead)." 

342 ) 

343 if not (200 <= status < 300): 343 ↛ 344line 343 didn't jump to line 344 because the condition on line 343 was never true

344 raise ZoteroError(f"Zotero request failed (HTTP {status})") 

345 

346 # Honor a Backoff header even on success (server under load). 

347 if response.headers.get("Backoff"): 347 ↛ 348line 347 didn't jump to line 348 because the condition on line 347 was never true

348 _sleep_for_backoff(response.headers, attempt) 

349 return response 

350 

351 # Unreachable, but keeps type-checkers happy. 

352 raise ZoteroError( 

353 f"Zotero request failed: {type(last_exc).__name__ if last_exc else 'unknown'}" 

354 ) 

355 

356 @staticmethod 

357 def _library_version(response) -> int: 

358 try: 

359 return int(response.headers.get("Last-Modified-Version", "0")) 

360 except (TypeError, ValueError): 

361 return 0 

362 

363 # -- public API -------------------------------------------------------- 

364 

365 def test_connection(self) -> int: 

366 """Validate credentials by reading the library version. 

367 

368 Returns the current library version. Raises on failure. 

369 """ 

370 response = self._request( 

371 "/items/top", params={"format": "versions", "limit": 1} 

372 ) 

373 return self._library_version(response) 

374 

375 def get_current_key_info(self) -> Dict: 

376 """Return ``/keys/current``: the key's owner ``userID`` + access map. 

377 

378 Cloud API only (the local API has no key); returns ``{}`` in local 

379 mode or when the response isn't JSON. Used to resolve the numeric 

380 userID when the user pasted a username into the library-ID field. 

381 """ 

382 if self.local: 

383 return {} 

384 response = self._request("/keys/current", prefixed=False) 

385 try: 

386 return response.json() or {} 

387 except ValueError: 

388 return {} 

389 

390 def get_collections(self) -> List[Dict]: 

391 """List all collections in the library (paginated). 

392 

393 Returns a list of ``{"key", "name", "parentCollection"}`` dicts. 

394 """ 

395 collections: List[Dict] = [] 

396 start = 0 

397 pages = 0 

398 while True: 

399 pages += 1 

400 if pages > _MAX_PAGES: 

401 raise ZoteroError( 

402 f"Zotero: pagination exceeded safety cap ({_MAX_PAGES} " 

403 "pages) — aborting to avoid a runaway loop" 

404 ) 

405 response = self._request( 

406 "/collections", 

407 params={ 

408 "format": "json", 

409 "limit": _PAGE_LIMIT, 

410 "start": start, 

411 }, 

412 ) 

413 page = response.json() 

414 if not page: 

415 break 

416 for entry in page: 

417 data = entry.get("data", {}) 

418 collections.append( 

419 { 

420 "key": entry.get("key"), 

421 "name": data.get("name", ""), 

422 "parentCollection": data.get("parentCollection", False), 

423 } 

424 ) 

425 if len(page) < _PAGE_LIMIT: 

426 break 

427 start += _PAGE_LIMIT 

428 return collections 

429 

430 def get_groups(self) -> List[Dict]: 

431 """List the groups this API key can access (id + name + type). 

432 

433 Resolves the key's owner via ``/keys/current`` then reads 

434 ``/users/{userID}/groups`` — both non-library-scoped endpoints. Lets 

435 the UI offer a group picker instead of making the user hunt for a 

436 numeric group id on zotero.org. 

437 """ 

438 if self.local: 

439 # The local API has no key endpoint / auth; group discovery isn't 

440 # applicable. 

441 return [] 

442 info = self._request("/keys/current", prefixed=False).json() 

443 user_id = info.get("userID") if isinstance(info, dict) else None 

444 if not user_id: 

445 return [] 

446 

447 groups: List[Dict] = [] 

448 start = 0 

449 pages = 0 

450 while True: 

451 pages += 1 

452 if pages > _MAX_PAGES: 452 ↛ 453line 452 didn't jump to line 453 because the condition on line 452 was never true

453 raise ZoteroError( 

454 f"Zotero: pagination exceeded safety cap ({_MAX_PAGES} " 

455 "pages) — aborting to avoid a runaway loop" 

456 ) 

457 response = self._request( 

458 f"/users/{quote(str(user_id), safe='')}/groups", 

459 params={ 

460 "format": "json", 

461 "limit": _PAGE_LIMIT, 

462 "start": start, 

463 }, 

464 prefixed=False, 

465 ) 

466 page = response.json() 

467 if not page: 467 ↛ 468line 467 didn't jump to line 468 because the condition on line 467 was never true

468 break 

469 for entry in page: 

470 data = entry.get("data", {}) 

471 groups.append( 

472 { 

473 "id": str(entry.get("id") or data.get("id") or ""), 

474 "name": data.get("name", ""), 

475 "type": data.get("type", ""), 

476 } 

477 ) 

478 if len(page) < _PAGE_LIMIT: 478 ↛ 480line 478 didn't jump to line 480 because the condition on line 478 was always true

479 break 

480 start += _PAGE_LIMIT 

481 return groups 

482 

483 def get_collection_name(self, collection_key: str) -> Optional[str]: 

484 """Return a single collection's display name, or None if missing.""" 

485 try: 

486 response = self._request( 

487 f"/collections/{quote(collection_key, safe='')}", 

488 params={"format": "json"}, 

489 ) 

490 except ZoteroError: 

491 return None 

492 data = response.json().get("data", {}) 

493 return data.get("name") 

494 

495 def get_top_item_versions( 

496 self, 

497 collection_key: Optional[str] = None, # gitleaks:allow 

498 ) -> Tuple[Dict[str, int], int]: 

499 """Get ``{itemKey: version}`` for current top-level items. 

500 

501 Scoped to ``collection_key`` when given, else the whole library. 

502 Also returns the library version (from ``Last-Modified-Version``). 

503 

504 This is the FULL current set (not a ``?since=`` delta) on purpose: the 

505 sync service reconciles it against its mapping table to detect both 

506 deletions AND items removed from the collection (which a library-level 

507 ``/deleted`` feed would miss). To make that reconciliation safe, this 

508 method raises if the paginated result is INCOMPLETE relative to the 

509 ``Total-Results`` header — otherwise a truncated response would look 

510 like a batch of deletions to the caller. 

511 """ 

512 if collection_key: 

513 path = f"/collections/{quote(collection_key, safe='')}/items/top" 

514 else: 

515 path = "/items/top" 

516 

517 versions: Dict[str, int] = {} 

518 library_version = 0 

519 start = 0 

520 pages = 0 

521 expected_total: Optional[int] = None 

522 while True: 

523 pages += 1 

524 if pages > _MAX_PAGES: 524 ↛ 525line 524 didn't jump to line 525 because the condition on line 524 was never true

525 raise ZoteroError( 

526 f"Zotero: pagination exceeded safety cap ({_MAX_PAGES} " 

527 "pages) — aborting to avoid a runaway loop" 

528 ) 

529 response = self._request( 

530 path, 

531 params={ 

532 "format": "versions", 

533 "limit": _PAGE_LIMIT, 

534 "start": start, 

535 }, 

536 ) 

537 library_version = max( 

538 library_version, self._library_version(response) 

539 ) 

540 # Total-Results is the authoritative item count; capture it once. 

541 if expected_total is None: 541 ↛ 547line 541 didn't jump to line 547 because the condition on line 541 was always true

542 total = response.headers.get("Total-Results") 

543 try: 

544 expected_total = int(total) if total is not None else None 

545 except (TypeError, ValueError): 

546 expected_total = None 

547 page = response.json() or {} 

548 # ``format=versions`` returns a JSON object {key: version}. 

549 for key, version in page.items(): 

550 try: 

551 versions[key] = int(version) 

552 except (TypeError, ValueError): 

553 versions[key] = 0 

554 # A short page is the authoritative end-of-data signal for both 

555 # list and object (``format=versions``) responses. 

556 if len(page) < _PAGE_LIMIT: 

557 break 

558 # When Total-Results is known, stop one round-trip early. Do NOT 

559 # fall back to len(versions) when the header is absent — that makes 

560 # the check a tautology and would stop after a single full page. 

561 if expected_total is not None and len(versions) >= expected_total: 561 ↛ 562line 561 didn't jump to line 562 because the condition on line 561 was never true

562 break 

563 start += _PAGE_LIMIT 

564 

565 # Completeness guard: if the server told us how many items exist and we 

566 # ended up with fewer (a truncated / short-circuited page), refuse to 

567 # return a partial set — the caller treats "absent" keys as deletions, 

568 # so a partial map would silently delete real documents. 

569 if expected_total is not None and len(versions) < expected_total: 

570 raise ZoteroError( 

571 "Zotero returned an incomplete version map " 

572 f"({len(versions)}/{expected_total} items); aborting so " 

573 "missing items are not mistaken for deletions" 

574 ) 

575 return versions, library_version 

576 

577 def get_items(self, item_keys: List[str]) -> List[ZoteroItem]: 

578 """Fetch full data for the given top-level item keys (batched).""" 

579 items: List[ZoteroItem] = [] 

580 for batch_start in range(0, len(item_keys), _MAX_KEYS_PER_REQUEST): 

581 batch = item_keys[batch_start : batch_start + _MAX_KEYS_PER_REQUEST] 

582 if not batch: 582 ↛ 583line 582 didn't jump to line 583 because the condition on line 582 was never true

583 continue 

584 response = self._request( 

585 "/items", 

586 params={ 

587 "format": "json", 

588 "itemKey": ",".join(batch), 

589 "limit": _MAX_KEYS_PER_REQUEST, 

590 }, 

591 ) 

592 for entry in response.json() or []: 

593 data = entry.get("data", {}) 

594 items.append( 

595 ZoteroItem( 

596 key=entry.get("key"), 

597 version=int(entry.get("version", 0) or 0), 

598 item_type=data.get("itemType", ""), 

599 data=data, 

600 ) 

601 ) 

602 return items 

603 

604 def _get_children_raw(self, item_key: str) -> List[Dict]: 

605 """Fetch all raw child entries of an item/attachment (paginated).""" 

606 entries: List[Dict] = [] 

607 start = 0 

608 pages = 0 

609 while True: 

610 pages += 1 

611 if pages > _MAX_PAGES: 611 ↛ 612line 611 didn't jump to line 612 because the condition on line 611 was never true

612 raise ZoteroError( 

613 f"Zotero: pagination exceeded safety cap ({_MAX_PAGES} " 

614 "pages) — aborting to avoid a runaway loop" 

615 ) 

616 response = self._request( 

617 f"/items/{quote(item_key, safe='')}/children", 

618 params={ 

619 "format": "json", 

620 "limit": _PAGE_LIMIT, 

621 "start": start, 

622 }, 

623 ) 

624 page = response.json() or [] 

625 entries.extend(page) 

626 if len(page) < _PAGE_LIMIT: 626 ↛ 628line 626 didn't jump to line 628 because the condition on line 626 was always true

627 break 

628 start += _PAGE_LIMIT 

629 return entries 

630 

631 def get_children(self, item_key: str) -> List[ZoteroAttachment]: 

632 """Fetch an item's child attachments.""" 

633 attachments: List[ZoteroAttachment] = [] 

634 for entry in self._get_children_raw(item_key): 

635 data = entry.get("data", {}) 

636 if data.get("itemType") != "attachment": 

637 continue 

638 attachments.append( 

639 ZoteroAttachment( 

640 key=entry.get("key"), 

641 version=int(entry.get("version", 0) or 0), 

642 content_type=data.get("contentType"), 

643 link_mode=data.get("linkMode"), 

644 filename=data.get("filename"), 

645 title=data.get("title"), 

646 ) 

647 ) 

648 return attachments 

649 

650 def get_notes(self, item_key: str) -> List[str]: 

651 """Return the plain text of an item's child notes (HTML stripped).""" 

652 notes: List[str] = [] 

653 for entry in self._get_children_raw(item_key): 

654 data = entry.get("data", {}) 

655 if data.get("itemType") != "note": 

656 continue 

657 text = _html_to_text(data.get("note") or "") 

658 if text: 658 ↛ 653line 658 didn't jump to line 653 because the condition on line 658 was always true

659 notes.append(text) 

660 return notes 

661 

662 def get_annotations(self, attachment_key: str) -> List[str]: 

663 """Return an attachment's annotations as text (highlight + comment). 

664 

665 Image/area annotations carry no text (only position), so they're 

666 skipped. 

667 """ 

668 out: List[str] = [] 

669 for entry in self._get_children_raw(attachment_key): 

670 data = entry.get("data", {}) 

671 if data.get("itemType") != "annotation": 671 ↛ 672line 671 didn't jump to line 672 because the condition on line 671 was never true

672 continue 

673 parts = [] 

674 highlighted = (data.get("annotationText") or "").strip() 

675 comment = (data.get("annotationComment") or "").strip() 

676 if highlighted: 

677 parts.append(highlighted) 

678 if comment: 

679 parts.append(f"[note] {comment}") 

680 if parts: 

681 out.append(" — ".join(parts)) 

682 return out 

683 

684 def download_attachment(self, attachment_key: str) -> Optional[bytes]: 

685 """Download an attachment's file bytes, or None on failure. 

686 

687 Zotero's ``/items/{key}/file`` endpoint typically answers with a 302 

688 to the actual storage location (Zotero's S3, or a WebDAV/external host 

689 for self-hosted storage). We must NOT follow that redirect with the 

690 authenticated session: ``requests`` only strips the standard 

691 ``Authorization`` header on a cross-host redirect, so our custom 

692 ``Zotero-API-Key`` header would otherwise be forwarded verbatim to the 

693 storage host — a credential leak. Instead we fetch with 

694 ``allow_redirects=False`` and follow the ``Location`` ourselves using a 

695 fresh, credential-free :class:`SafeSession` (which still SSRF-validates 

696 the target). 

697 """ 

698 try: 

699 response = self._request( 

700 f"/items/{quote(attachment_key, safe='')}/file", 

701 allow_redirects=False, 

702 ) 

703 except ZoteroTransientError: 

704 # Temporary (429/5xx/network): let the caller skip this item and 

705 # retry next sync rather than silently import it metadata-only. 

706 raise 

707 except ZoteroError: 

708 logger.warning( 

709 f"Zotero: could not download attachment {attachment_key}" 

710 ) 

711 return None 

712 

713 if 300 <= response.status_code < 400: 

714 location = (response.headers.get("Location") or "").strip() 

715 if not location: 715 ↛ 716line 715 didn't jump to line 716 because the condition on line 715 was never true

716 logger.warning( 

717 f"Zotero: attachment {attachment_key} redirect had no " 

718 "Location header" 

719 ) 

720 return None 

721 if location.lower().startswith("file:"): 

722 # The local API redirects to a file:// path on disk. Only honor 

723 # it in local mode (never follow a file:// redirect from the 

724 # cloud API). 

725 if not self.local: 

726 logger.warning( 

727 "Zotero: ignoring file:// redirect from the cloud API" 

728 ) 

729 return None 

730 return self._read_local_file(location) 

731 return self._fetch_file_unauthenticated(location) 

732 

733 # Some configurations serve the file directly (200, no redirect). 

734 content = response.content 

735 return content or None 

736 

737 @staticmethod 

738 def _read_local_file(file_url: str) -> Optional[bytes]: 

739 """Read bytes from a ``file://`` URL (local API attachment on disk). 

740 

741 Only PDF content is honored: the redirect target comes from whatever 

742 answers on the local API port, so restricting reads to files with a 

743 PDF magic number keeps this from acting as a generic local-file 

744 disclosure primitive (defense in depth — the sync layer discards 

745 non-PDF bodies anyway). 

746 """ 

747 from urllib.parse import urlparse 

748 from urllib.request import url2pathname 

749 

750 try: 

751 path = url2pathname(urlparse(file_url).path) 

752 with open(path, "rb") as fh: 

753 data = fh.read() 

754 except OSError: 

755 logger.warning("Zotero: could not read local attachment file") 

756 return None 

757 if not data: 757 ↛ 758line 757 didn't jump to line 758 because the condition on line 757 was never true

758 return None 

759 if b"%PDF" not in data[:1024]: 

760 logger.warning("Zotero: local attachment is not a PDF; ignoring it") 

761 return None 

762 return data 

763 

764 def _fetch_file_unauthenticated(self, url: str) -> Optional[bytes]: 

765 """Fetch a storage URL with a fresh session that carries no Zotero 

766 credentials. SafeSession SSRF-validates the URL (and any further 

767 redirects). 

768 

769 Raises :class:`ZoteroTransientError` on a transient failure (network 

770 error or HTTP 429/5xx from the storage host) so the caller retries next 

771 sync rather than importing the item metadata-only; returns None only 

772 for a genuine "no file here" (other non-2xx / empty body). 

773 """ 

774 try: 

775 with SafeSession() as session: 

776 response = session.get(url, timeout=self.timeout) 

777 except ( 

778 requests.exceptions.Timeout, 

779 requests.exceptions.ConnectionError, 

780 ) as exc: 

781 raise ZoteroTransientError( 

782 f"Zotero storage fetch failed: {type(exc).__name__}" 

783 ) from exc 

784 except Exception as exc: 

785 logger.warning( 

786 f"Zotero: storage download failed ({type(exc).__name__})" 

787 ) 

788 return None 

789 if response.status_code in (429, 500, 502, 503, 504): 

790 raise ZoteroTransientError( 

791 f"Zotero storage returned HTTP {response.status_code}" 

792 ) 

793 if not (200 <= response.status_code < 300): 793 ↛ 794line 793 didn't jump to line 794 because the condition on line 793 was never true

794 logger.warning( 

795 f"Zotero: storage download returned HTTP {response.status_code}" 

796 ) 

797 return None 

798 content = response.content 

799 return content or None 

800 

801 def get_attachment_fulltext(self, attachment_key: str) -> Optional[str]: 

802 """Return Zotero's already-extracted full text for an attachment. 

803 

804 Uses ``GET /items/{key}/fulltext``; returns None when Zotero has no 

805 indexed text for it (HTTP 404) or the content is empty. Lets the sync 

806 skip downloading + re-parsing the PDF in text-only mode. 

807 """ 

808 try: 

809 response = self._request( 

810 f"/items/{quote(attachment_key, safe='')}/fulltext" 

811 ) 

812 except ZoteroTransientError: 

813 # Temporary failure — don't conflate with "no indexed text"; let 

814 # the caller retry next sync. 

815 raise 

816 except ZoteroError: 

817 return None 

818 try: 

819 data = response.json() 

820 except Exception: 

821 return None 

822 if not isinstance(data, dict): 822 ↛ 823line 822 didn't jump to line 823 because the condition on line 822 was never true

823 return None 

824 content = data.get("content") 

825 return content or None