Coverage for src/local_deep_research/research_library/routes/zotero_routes.py: 27%
105 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +0000
1"""
2Web routes for the Zotero integration.
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.
8"""
10import threading
12from flask import Blueprint, current_app, jsonify, render_template, session
13from loguru import logger
15from ...database.session_passwords import session_password_store
16from ...database.thread_local_session import thread_cleanup
17from ...security import sanitize_error_for_client
18from ...web.auth.decorators import login_required
19from ..utils import handle_api_error
20from ..zotero import (
21 ZoteroAuthError,
22 ZoteroError,
23 ZoteroSyncService,
24 ZoteroTransientError,
25)
27zotero_bp = Blueprint("zotero", __name__, url_prefix="/library")
30def _db_password() -> str | None:
31 """Resolve the current session's database password."""
32 return session_password_store.get_session_password(
33 session["username"], session.get("session_id")
34 )
37def _zotero_error_status(exc: ZoteroError) -> int:
38 """Map a Zotero client error to the matching HTTP status."""
39 if isinstance(exc, ZoteroAuthError):
40 return 401
41 if isinstance(exc, ZoteroTransientError):
42 return 503
43 return 400
46def _session_expired_response():
47 """401 for a logged-in session whose cached DB password has expired.
49 Every endpoint below opens the user's encrypted database, so without the
50 password the request cannot succeed — tell the client to re-authenticate
51 instead of failing with a generic 500.
52 """
53 return jsonify(
54 {
55 "success": False,
56 "error": "Session expired — please sign in again.",
57 }
58 ), 401
61@zotero_bp.route("/zotero")
62@login_required
63def zotero_page():
64 """Render the Zotero integration page."""
65 return render_template("pages/zotero.html", active_page="zotero")
68@zotero_bp.route("/api/zotero/config", methods=["GET"])
69@login_required
70def get_config():
71 """Return a non-secret summary of the Zotero configuration."""
72 password = _db_password() # gitleaks:allow
73 if not password:
74 return _session_expired_response()
75 try:
76 cfg = ZoteroSyncService(session["username"], password).get_config()
77 return jsonify(
78 {
79 "success": True,
80 "enabled": cfg.enabled,
81 "configured": cfg.is_configured,
82 "library_type": cfg.library_type,
83 "library_id": cfg.library_id,
84 "collection_keys": cfg.collection_keys,
85 "import_tags": cfg.import_tags,
86 "import_items_without_pdf": cfg.import_items_without_pdf,
87 "import_annotations": cfg.import_annotations,
88 "pdf_storage_mode": cfg.pdf_storage_mode,
89 "auto_sync_enabled": cfg.auto_sync_enabled,
90 "sync_interval_minutes": cfg.sync_interval_minutes,
91 "use_local_api": cfg.use_local_api,
92 "has_api_key": bool(cfg.api_key),
93 }
94 )
95 except Exception as e:
96 return handle_api_error("getting Zotero config", e)
99@zotero_bp.route("/api/zotero/test", methods=["POST"])
100@login_required
101def test_connection():
102 """Validate the configured Zotero credentials."""
103 password = _db_password() # gitleaks:allow
104 if not password:
105 return _session_expired_response()
106 try:
107 result = ZoteroSyncService(
108 session["username"], password
109 ).test_connection()
110 return jsonify(result), (200 if result.get("success") else 400)
111 except Exception as e:
112 return handle_api_error("testing Zotero connection", e)
115@zotero_bp.route("/api/zotero/collections", methods=["GET"])
116@login_required
117def list_collections():
118 """List the user's Zotero collections (key + name)."""
119 password = _db_password() # gitleaks:allow
120 if not password:
121 return _session_expired_response()
122 try:
123 collections = ZoteroSyncService(
124 session["username"], password
125 ).list_collections()
126 return jsonify({"success": True, "collections": collections})
127 except ZoteroError as e:
128 # Zotero-side rejections carry actionable, static messages (bad
129 # library ID, revoked key, …) — surface them instead of a 500,
130 # with a status code matching the failure class.
131 return jsonify(
132 {"success": False, "error": sanitize_error_for_client(str(e))}
133 ), _zotero_error_status(e)
134 except Exception as e:
135 return handle_api_error("listing Zotero collections", e)
138@zotero_bp.route("/api/zotero/groups", methods=["GET"])
139@login_required
140def list_groups():
141 """List the groups the configured API key can access (id + name)."""
142 password = _db_password() # gitleaks:allow
143 if not password:
144 return _session_expired_response()
145 try:
146 groups = ZoteroSyncService(session["username"], password).list_groups()
147 return jsonify({"success": True, "groups": groups})
148 except ZoteroError as e:
149 return jsonify(
150 {"success": False, "error": sanitize_error_for_client(str(e))}
151 ), _zotero_error_status(e)
152 except Exception as e:
153 return handle_api_error("listing Zotero groups", e)
156@zotero_bp.route("/api/zotero/sync", methods=["POST"])
157@login_required
158def sync_now():
159 """Trigger a background sync of the configured collections.
161 Runs in a daemon thread (with an app context) using the current
162 session's password, and returns immediately. Progress is observable via
163 the ``/api/zotero/status`` endpoint.
164 """
165 username = session["username"]
166 password = _db_password() # gitleaks:allow
167 if not password:
168 return _session_expired_response()
170 cfg = ZoteroSyncService(username, password).get_config()
171 if not cfg.is_configured:
172 return jsonify(
173 {
174 "success": False,
175 "error": "Zotero is not enabled/configured. Set your API key "
176 "and library ID in Settings first.",
177 }
178 ), 400
180 # Fast feedback if a sync (manual OR scheduled) is already in flight.
181 # The authoritative guard is the per-user lock inside sync_all(), which
182 # serialises both entry points; this is just a best-effort early exit.
183 if ZoteroSyncService.is_user_syncing(username):
184 return jsonify(
185 {
186 "success": True,
187 "message": "A Zotero sync is already running.",
188 "already_running": True,
189 }
190 )
192 app = current_app._get_current_object()
194 def _run():
195 # thread_cleanup() releases the thread-local DB session + cached
196 # credentials when this daemon thread exits — without it each manual
197 # sync would strand a pooled connection and the plaintext password.
198 with app.app_context(), thread_cleanup():
199 try:
200 # Manual syncs re-examine previously skipped items so
201 # settings changes apply without waiting for the items to
202 # change in Zotero (scheduled syncs stay cheap).
203 result = ZoteroSyncService(username, password).sync_all(
204 reprocess_skipped=True
205 )
206 logger.info(f"Zotero manual sync finished: {result}")
207 except Exception:
208 logger.exception("Zotero manual sync failed")
210 threading.Thread(
211 target=_run, name=f"zotero-sync-{username}", daemon=True
212 ).start()
213 return jsonify({"success": True, "message": "Zotero sync started."})
216@zotero_bp.route("/api/zotero/status", methods=["GET"])
217@login_required
218def get_status():
219 """Return stored sync state for all configured collections."""
220 password = _db_password() # gitleaks:allow
221 if not password:
222 return _session_expired_response()
223 try:
224 status = ZoteroSyncService(session["username"], password).get_status()
225 return jsonify(
226 {
227 "success": True,
228 "collections": status,
229 # Live counters while a sync runs (None when idle) — lets
230 # the page render a real progress bar during long imports.
231 "progress": ZoteroSyncService.get_sync_progress(
232 session["username"]
233 ),
234 }
235 )
236 except Exception as e:
237 return handle_api_error("getting Zotero status", e)