Coverage for src/local_deep_research/web/routers/zotero.py: 93%
120 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""
2Routes for the Zotero integration (FastAPI).
4Exposes a small page plus JSON endpoints to test the connection, list the
5user's Zotero collections, trigger a manual sync, and read sync status.
6Credentials and options live in user settings (category ``zotero``); these
7routes never accept or return the API key.
9Ported from the Flask ``research_library/routes/zotero_routes.py`` blueprint
10(feature #4723). ``ZoteroSyncService`` is framework-free (opens the user's
11encrypted DB via ``get_user_db_session(username, password)``), so the manual
12sync runs in a daemon thread that needs only ``thread_cleanup()`` — no Flask
13app context.
14"""
16import threading
18from fastapi import APIRouter, Depends, Request
19from fastapi.responses import JSONResponse
20from loguru import logger
22from ...database.session_passwords import session_password_store
23from ...database.thread_local_session import thread_cleanup
24from ...research_library.utils import handle_api_error
25from ...research_library.zotero import (
26 client_safe_zotero_message,
27 ZoteroAuthError,
28 ZoteroError,
29 ZoteroSyncService,
30 ZoteroTransientError,
31)
32from ...security import scrub_error
33from ..dependencies.auth import require_auth
34from ..template_config import templates
35from typing import Annotated
37router = APIRouter(prefix="/library", tags=["zotero"])
40def _zotero_error_status(exc: ZoteroError) -> int:
41 """Map a Zotero client error to the matching HTTP status."""
42 if isinstance(exc, ZoteroAuthError):
43 return 401
44 if isinstance(exc, ZoteroTransientError):
45 return 503
46 return 400
49def _zotero_error_response(exc: ZoteroError) -> JSONResponse:
50 """Map a Zotero failure to a client-safe body and a status code.
52 The message is never ``str(exc)``. ``client_safe_zotero_message`` looks
53 the failure text up in the package's allowlist of author-written
54 diagnostics and returns THAT constant, or the fallback below — so the
55 guidance a user actually needs survives (the HTTP 400 "your library ID is
56 a username, not the numeric ID" hint is the one Zotero misconfiguration
57 that is unguessable from a generic message) while nothing exception-
58 derived reaches the response.
59 """
60 if isinstance(exc, ZoteroAuthError):
61 fallback = "Zotero authentication failed. Check the configured API key."
62 elif isinstance(exc, ZoteroTransientError):
63 fallback = "Zotero is temporarily unavailable. Please try again later."
64 else:
65 fallback = "Zotero request failed. Check the integration settings."
66 # Log the text as well as the class. Every ``ZoteroError`` in the
67 # research_library.zotero package is built from author-written strings
68 # whose only interpolations are an HTTP status, a retry count, an item
69 # count, the pagination cap or ``type(exc).__name__`` — no upstream
70 # response body, no filesystem path, no API key (the client reduces a
71 # transport failure to its exception TYPE before raising). So the text is
72 # safe here, and it is the only server-side record of WHICH failure
73 # produced a generic fallback body. It goes through ``scrub_error``
74 # anyway (a no-op on this text) so this catch site matches the
75 # repo-wide idiom. No traceback: this runs on the request path, where
76 # the caller's frames hold the SQLCipher password.
77 safe_msg = scrub_error(exc)
78 logger.warning(f"Zotero request failed ({type(exc).__name__}): {safe_msg}")
79 return JSONResponse(
80 {"success": False, "error": client_safe_zotero_message(exc, fallback)},
81 status_code=_zotero_error_status(exc),
82 )
85def _db_password(request: Request, username: str) -> str | None:
86 """Resolve the current session's database password."""
87 return session_password_store.get_session_password(
88 username, request.session.get("session_id")
89 )
92def _session_expired_response() -> JSONResponse:
93 """401 for a logged-in session whose cached DB password has expired.
95 Every endpoint below opens the user's encrypted database, so without the
96 password the request cannot succeed — tell the client to re-authenticate
97 instead of failing with a generic 500.
98 """
99 return JSONResponse(
100 {
101 "success": False,
102 "error": "Session expired — please sign in again.",
103 },
104 status_code=401,
105 )
108@router.get("/zotero")
109def zotero_page(
110 request: Request, username: Annotated[str, Depends(require_auth)]
111):
112 """Render the Zotero integration page."""
113 return templates.TemplateResponse(
114 request=request,
115 name="pages/zotero.html",
116 context={"active_page": "zotero"},
117 )
120@router.get("/api/zotero/config")
121def get_config(
122 request: Request, username: Annotated[str, Depends(require_auth)]
123):
124 """Return a non-secret summary of the Zotero configuration."""
125 password = _db_password(request, username) # gitleaks:allow
126 if not password:
127 return _session_expired_response()
128 try:
129 cfg = ZoteroSyncService(username, password).get_config()
130 return {
131 "success": True,
132 "enabled": cfg.enabled,
133 "configured": cfg.is_configured,
134 "library_type": cfg.library_type,
135 "library_id": cfg.library_id,
136 "collection_keys": cfg.collection_keys,
137 "import_tags": cfg.import_tags,
138 "import_items_without_pdf": cfg.import_items_without_pdf,
139 "import_annotations": cfg.import_annotations,
140 "pdf_storage_mode": cfg.pdf_storage_mode,
141 "auto_sync_enabled": cfg.auto_sync_enabled,
142 "sync_interval_minutes": cfg.sync_interval_minutes,
143 "use_local_api": cfg.use_local_api,
144 "has_api_key": bool(cfg.api_key),
145 }
146 except Exception as e:
147 return handle_api_error("getting Zotero config", e)
150@router.post("/api/zotero/test")
151def test_connection(
152 request: Request, username: Annotated[str, Depends(require_auth)]
153):
154 """Validate the configured Zotero credentials."""
155 password = _db_password(request, username) # gitleaks:allow
156 if not password:
157 return _session_expired_response()
158 try:
159 result = ZoteroSyncService(username, password).test_connection()
160 # Rebuild the body from an explicit key allowlist rather than
161 # forwarding the service's dict: a field added to the service result
162 # later (a config echo, a resolved id) would otherwise ship to the
163 # browser silently. The `error` value is the service's own curated
164 # text — every failure branch of `test_connection` returns either a
165 # module constant from `CLIENT_SAFE_ZOTERO_MESSAGES` or its own
166 # literal, and none of them is derived from an exception.
167 if not result.get("success"):
168 return JSONResponse(
169 {
170 "success": False,
171 "error": result.get("error")
172 or (
173 "Zotero connection failed. Check the configured API "
174 "key and library settings."
175 ),
176 },
177 status_code=400,
178 )
179 return JSONResponse(
180 {
181 "success": True,
182 "library_version": result.get("library_version"),
183 "collection_count": result.get("collection_count"),
184 },
185 status_code=200,
186 )
187 except Exception as e:
188 return handle_api_error("testing Zotero connection", e)
191def _not_configured_response() -> JSONResponse:
192 """400 when Zotero credentials aren't set.
194 Endpoints that reach out to the Zotero API (rather than just reading local
195 settings/state) must not surface an opaque 500 when the integration is
196 simply unconfigured — that is a client-state problem, not a server error.
197 Mirrors the ``is_configured`` guard in ``sync_now``.
198 """
199 return JSONResponse(
200 {
201 "success": False,
202 "error": "Zotero is not enabled/configured. Set your API key "
203 "and library ID in Settings first.",
204 },
205 status_code=400,
206 )
209@router.get("/api/zotero/collections")
210def list_collections(
211 request: Request, username: Annotated[str, Depends(require_auth)]
212):
213 """List the user's Zotero collections (key + name)."""
214 password = _db_password(request, username) # gitleaks:allow
215 if not password:
216 return _session_expired_response()
217 try:
218 service = ZoteroSyncService(username, password)
219 if not service.get_config().is_configured:
220 return _not_configured_response()
221 return {"success": True, "collections": service.list_collections()}
222 except ZoteroError as e:
223 return _zotero_error_response(e)
224 except Exception as e:
225 return handle_api_error("listing Zotero collections", e)
228@router.get("/api/zotero/groups")
229def list_groups(
230 request: Request, username: Annotated[str, Depends(require_auth)]
231):
232 """List the groups the configured API key can access (id + name)."""
233 password = _db_password(request, username) # gitleaks:allow
234 if not password:
235 return _session_expired_response()
236 try:
237 service = ZoteroSyncService(username, password)
238 if not service.get_config().is_configured:
239 return _not_configured_response()
240 return {"success": True, "groups": service.list_groups()}
241 except ZoteroError as e:
242 return _zotero_error_response(e)
243 except Exception as e:
244 return handle_api_error("listing Zotero groups", e)
247@router.post("/api/zotero/sync")
248def sync_now(request: Request, username: Annotated[str, Depends(require_auth)]):
249 """Trigger a background sync of the configured collections.
251 Runs in a daemon thread using the current session's password and returns
252 immediately. Progress is observable via the ``/api/zotero/status``
253 endpoint.
254 """
255 password = _db_password(request, username) # gitleaks:allow
256 if not password:
257 return _session_expired_response()
259 try:
260 cfg = ZoteroSyncService(username, password).get_config()
261 except Exception as e:
262 return handle_api_error("starting Zotero sync", e)
263 if not cfg.is_configured:
264 return JSONResponse(
265 {
266 "success": False,
267 "error": "Zotero is not enabled/configured. Set your API key "
268 "and library ID in Settings first.",
269 },
270 status_code=400,
271 )
273 # Fast feedback if a sync (manual OR scheduled) is already in flight.
274 # The authoritative guard is the per-user lock inside sync_all(), which
275 # serialises both entry points; this is just a best-effort early exit.
276 if ZoteroSyncService.is_user_syncing(username):
277 return {
278 "success": True,
279 "message": "A Zotero sync is already running.",
280 "already_running": True,
281 }
283 def _run():
284 # thread_cleanup() releases the thread-local DB session + cached
285 # credentials when this daemon thread exits — without it each manual
286 # sync would strand a pooled connection and the plaintext password.
287 with thread_cleanup():
288 try:
289 # Manual syncs re-examine previously skipped items so settings
290 # changes apply without waiting for the items to change in
291 # Zotero (scheduled syncs stay cheap).
292 result = ZoteroSyncService(username, password).sync_all(
293 reprocess_skipped=True
294 )
295 logger.info(f"Zotero manual sync finished: {result}")
296 except Exception as exc:
297 # Not logger.exception: this closure's own `password` and
298 # the sync code's `cfg.api_key` are both live further down
299 # this call stack, and an attached traceback would render
300 # them under loguru's diagnose. Same pattern as
301 # ZoteroSyncService._sync_one's bare-Exception arm
302 # (sync_service.py) -- scrub the known secrets into a local,
303 # then log without the traceback chain.
304 safe_msg = scrub_error(exc, password, cfg.api_key)
305 logger.warning(f"Zotero manual sync failed: {safe_msg}")
307 threading.Thread(
308 target=_run, name=f"zotero-sync-{username}", daemon=True
309 ).start()
310 return {"success": True, "message": "Zotero sync started."}
313@router.get("/api/zotero/status")
314def get_status(
315 request: Request, username: Annotated[str, Depends(require_auth)]
316):
317 """Return stored sync state for all configured collections."""
318 password = _db_password(request, username) # gitleaks:allow
319 if not password:
320 return _session_expired_response()
321 try:
322 status = ZoteroSyncService(username, password).get_status()
323 return {
324 "success": True,
325 "collections": status,
326 # Live counters while a sync runs (None when idle) — lets the
327 # page render a real progress bar during long imports.
328 "progress": ZoteroSyncService.get_sync_progress(username),
329 }
330 except Exception as e:
331 return handle_api_error("getting Zotero status", e)