OpenRAG 0.5.0.dev35__py3-none-any.whl → 0.5.0.dev37__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.
api/connectors.py CHANGED
@@ -1,100 +1,498 @@
1
- from typing import Any, Dict, List, Optional
1
+ from typing import Any
2
2
 
3
3
  from fastapi import Depends, Request
4
- from pydantic import BaseModel
5
4
  from fastapi.responses import JSONResponse, PlainTextResponse
6
- from connectors.sharepoint.utils import is_valid_sharepoint_url
5
+ from pydantic import BaseModel
6
+
7
7
  from config.settings import get_index_name
8
- from utils.logging_config import get_logger
9
- from utils.telemetry import TelemetryClient, Category, MessageId
10
- from dependencies import get_connector_service, get_session_manager, get_current_user
8
+ from connectors.sharepoint.utils import is_valid_sharepoint_url
9
+ from dependencies import (
10
+ get_connector_service,
11
+ get_current_user,
12
+ get_session_manager,
13
+ require_permission,
14
+ )
11
15
  from session_manager import User
16
+ from utils.logging_config import get_logger
17
+ from utils.telemetry import Category, MessageId, TelemetryClient
12
18
 
13
19
  logger = get_logger(__name__)
14
20
 
15
21
 
22
+ def _connector_sync_should_replace(connector_type: str) -> bool:
23
+ """Return True for connector types where sync should replace existing indexed files."""
24
+ return connector_type in ["google_drive", "sharepoint", "onedrive"]
25
+
26
+
16
27
  async def get_synced_file_ids_for_connector(
17
28
  connector_type: str,
18
29
  user_id: str,
19
30
  session_manager,
20
31
  jwt_token: str = None,
21
- ) -> tuple:
32
+ ) -> tuple[list[str], list[str], str]:
22
33
  """
23
- Query OpenSearch for unique document_id values where connector_type matches.
24
- Returns tuple of (file_ids, filenames) - use file_ids if available, else filenames as fallback.
25
-
26
- Note: Langflow-ingested files may not have document_id stored. In that case,
27
- filenames are returned for filename-based filtering during sync.
34
+ Query OpenSearch for unique file IDs where connector_type matches.
35
+
36
+ Returns a 3-tuple ``(file_ids, filenames, id_field)``:
37
+
38
+ - ``file_ids``: connector source IDs to use for orphan detection and sync.
39
+ Comes from the ``connector_file_id`` field when chunks were indexed via
40
+ ``ConnectorFileProcessor`` (non-Langflow path); falls back to ``document_id``
41
+ for Langflow-indexed chunks where ``document_id`` already holds the connector
42
+ source ID.
43
+ - ``filenames``: unique filenames as a fallback when ``file_ids`` is empty.
44
+ - ``id_field``: the OpenSearch field name that ``file_ids`` came from
45
+ (``"connector_file_id"`` or ``"document_id"``). Callers must pass this to
46
+ ``delete_orphan_documents`` so deletions target the correct field.
28
47
  """
29
48
  try:
30
49
  opensearch_client = session_manager.get_user_opensearch_client(user_id, jwt_token)
31
-
32
- # Query for both document_id and filename aggregations
50
+
33
51
  query_body = {
34
52
  "size": 0,
35
- "query": {
36
- "term": {
37
- "connector_type": connector_type
38
- }
39
- },
53
+ "query": {"term": {"connector_type": connector_type}},
40
54
  "aggs": {
41
- "unique_document_ids": {
42
- "terms": {
43
- "field": "document_id",
44
- "size": 10000
45
- }
55
+ "unique_connector_file_ids": {
56
+ "terms": {"field": "connector_file_id", "size": 10000}
46
57
  },
47
- "unique_filenames": {
48
- "terms": {
49
- "field": "filename",
50
- "size": 10000
51
- }
52
- }
53
- }
58
+ "unique_document_ids": {"terms": {"field": "document_id", "size": 10000}},
59
+ "unique_filenames": {"terms": {"field": "filename", "size": 10000}},
60
+ },
54
61
  }
55
-
56
- result = await opensearch_client.search(
57
- index=get_index_name(),
58
- body=query_body
59
- )
60
-
61
- # Get document_ids (preferred - these are the actual connector file IDs)
62
- doc_id_buckets = result.get("aggregations", {}).get("unique_document_ids", {}).get("buckets", [])
63
- file_ids = [bucket["key"] for bucket in doc_id_buckets if bucket["key"]]
64
-
65
- # Get filenames as fallback
66
- filename_buckets = result.get("aggregations", {}).get("unique_filenames", {}).get("buckets", [])
67
- filenames = [bucket["key"] for bucket in filename_buckets if bucket["key"]]
68
-
62
+
63
+ result = await opensearch_client.search(index=get_index_name(), body=query_body)
64
+
65
+ # Prefer connector_file_id — these are set by ConnectorFileProcessor (non-Langflow)
66
+ # and hold the actual connector source IDs (e.g. SharePoint GUIDs), not SHA hashes.
67
+ connector_file_id_buckets = (
68
+ result.get("aggregations", {}).get("unique_connector_file_ids", {}).get("buckets", [])
69
+ )
70
+ connector_file_ids = [b["key"] for b in connector_file_id_buckets if b["key"]]
71
+
72
+ if connector_file_ids:
73
+ file_ids = connector_file_ids
74
+ id_field = "connector_file_id"
75
+ else:
76
+ # Langflow path: document_id already holds the connector source ID.
77
+ doc_id_buckets = (
78
+ result.get("aggregations", {}).get("unique_document_ids", {}).get("buckets", [])
79
+ )
80
+ file_ids = [b["key"] for b in doc_id_buckets if b["key"]]
81
+ id_field = "document_id"
82
+
83
+ filename_buckets = (
84
+ result.get("aggregations", {}).get("unique_filenames", {}).get("buckets", [])
85
+ )
86
+ filenames = [b["key"] for b in filename_buckets if b["key"]]
87
+
69
88
  logger.debug(
70
89
  "Found synced files for connector",
71
90
  connector_type=connector_type,
72
91
  file_ids_count=len(file_ids),
92
+ id_field=id_field,
73
93
  filenames_count=len(filenames),
74
94
  )
75
-
76
- return file_ids, filenames
77
-
95
+
96
+ return file_ids, filenames, id_field
97
+
78
98
  except Exception as e:
79
99
  logger.error(
80
100
  "Failed to get synced file IDs",
81
101
  connector_type=connector_type,
82
102
  error=str(e),
83
103
  )
84
- return [], []
104
+ return [], [], "document_id"
85
105
 
86
106
 
107
+ async def get_synced_id_to_filename_map(
108
+ connector_type: str,
109
+ user_id: str,
110
+ session_manager,
111
+ jwt_token: str | None = None,
112
+ ) -> dict[str, str]:
113
+ """Return a {document_id: filename} map for files ingested under this connector_type.
114
+
115
+ Uses a sub-aggregation so each document_id is paired with its top filename in
116
+ a single OpenSearch round trip.
117
+ """
118
+ try:
119
+ opensearch_client = session_manager.get_user_opensearch_client(user_id, jwt_token)
120
+
121
+ query_body = {
122
+ "size": 0,
123
+ "query": {"term": {"connector_type": connector_type}},
124
+ "aggs": {
125
+ "by_document_id": {
126
+ "terms": {"field": "document_id", "size": 10000},
127
+ "aggs": {
128
+ "top_filename": {"terms": {"field": "filename", "size": 1}},
129
+ },
130
+ }
131
+ },
132
+ }
133
+
134
+ result = await opensearch_client.search(index=get_index_name(), body=query_body)
135
+ buckets = result.get("aggregations", {}).get("by_document_id", {}).get("buckets", [])
136
+
137
+ mapping: dict[str, str] = {}
138
+ for bucket in buckets:
139
+ doc_id = bucket.get("key")
140
+ if not doc_id:
141
+ continue
142
+ fn_buckets = bucket.get("top_filename", {}).get("buckets", [])
143
+ mapping[doc_id] = fn_buckets[0]["key"] if fn_buckets else ""
144
+ return mapping
145
+ except Exception as e:
146
+ logger.error(
147
+ "Failed to build id→filename map",
148
+ connector_type=connector_type,
149
+ error=str(e),
150
+ )
151
+ return {}
152
+
153
+
154
+ async def compute_orphans_for_connector_type(
155
+ connector_type: str,
156
+ user_id: str,
157
+ connector_service,
158
+ session_manager,
159
+ jwt_token: str | None,
160
+ existing_file_ids: list[str],
161
+ id_to_filename: dict[str, str] | None = None,
162
+ ) -> list[dict[str, str]] | None:
163
+ """Compute orphan documents (ingested but no longer present at the source)
164
+ for this connector_type without deleting them.
165
+
166
+ Returns a list of {"document_id", "filename"} dicts. Returns None when strict
167
+ gating aborts the pass (unauthenticated connection or listing exception) so
168
+ callers can distinguish "no orphans" from "could not determine safely".
169
+ """
170
+ if not existing_file_ids:
171
+ return []
172
+
173
+ connections = await connector_service.connection_manager.list_connections(
174
+ user_id=user_id, connector_type=connector_type
175
+ )
176
+ active = [c for c in connections if c.is_active]
177
+ if not active:
178
+ logger.info(
179
+ "Skipping orphan compute — no active connections",
180
+ connector_type=connector_type,
181
+ )
182
+ return None
183
+
184
+ remote_ids: set = set()
185
+ for conn in active:
186
+ try:
187
+ connector = await connector_service.get_connector(conn.connection_id)
188
+ if not connector or not connector.is_authenticated:
189
+ logger.info(
190
+ "Skipping orphan compute — connection unauthenticated",
191
+ connector_type=connector_type,
192
+ connection_id=conn.connection_id,
193
+ )
194
+ return None
195
+
196
+ # Drive the per-id existence check via cfg.file_ids when the
197
+ # connector supports it (SharePoint / OneDrive / Google Drive).
198
+ # The flat default of list_files() only returns the *root* listing
199
+ # (e.g. /drive/root/children for SharePoint, files-only, no folder
200
+ # traversal), so any folder-internal file in OpenSearch would be
201
+ # absent from remote_ids and wrongly flagged as an orphan.
202
+ # _list_selected_files iterates each id via _get_file_metadata_by_id
203
+ # and silently drops missing ids, so the resulting `remote_ids` is
204
+ # exactly "the subset of existing_file_ids that still exists at
205
+ # source" — which is what orphan detection actually needs.
206
+ cfg = getattr(connector, "cfg", None)
207
+ scoped_listing = cfg is not None and bool(existing_file_ids)
208
+
209
+ original_file_ids = None
210
+ original_folder_ids = None
211
+ if scoped_listing:
212
+ original_file_ids = getattr(cfg, "file_ids", None)
213
+ original_folder_ids = getattr(cfg, "folder_ids", None)
214
+ cfg.file_ids = list(existing_file_ids)
215
+ cfg.folder_ids = None
216
+
217
+ try:
218
+ page_token = None
219
+ while True:
220
+ page = await connector.list_files(page_token=page_token)
221
+ for f in page.get("files", []):
222
+ fid = f.get("id")
223
+ if fid:
224
+ remote_ids.add(fid)
225
+ page_token = page.get("nextPageToken") or page.get("next_page_token")
226
+ if not page_token:
227
+ break
228
+ finally:
229
+ if scoped_listing:
230
+ cfg.file_ids = original_file_ids
231
+ cfg.folder_ids = original_folder_ids
232
+ except Exception as e:
233
+ logger.warning(
234
+ "Skipping orphan compute — listing failed",
235
+ connector_type=connector_type,
236
+ connection_id=conn.connection_id,
237
+ error=str(e),
238
+ )
239
+ return None
240
+
241
+ orphan_ids = [fid for fid in existing_file_ids if fid not in remote_ids]
242
+ if not orphan_ids:
243
+ return []
244
+
245
+ fn_map = id_to_filename or {}
246
+ return [{"document_id": fid, "filename": fn_map.get(fid, "")} for fid in orphan_ids]
247
+
248
+
249
+ async def delete_orphan_documents(
250
+ orphan_ids: list[str],
251
+ user_id: str,
252
+ session_manager,
253
+ jwt_token: str | None,
254
+ id_field: str = "document_id",
255
+ ) -> int:
256
+ """Delete OpenSearch chunks for the given orphan IDs. Returns the number of
257
+ chunks deleted (0 on failure).
258
+
259
+ ``id_field`` must match the OpenSearch field that ``orphan_ids`` came from —
260
+ either ``"connector_file_id"`` (ConnectorFileProcessor / non-Langflow path)
261
+ or ``"document_id"`` (Langflow path, where document_id holds the connector
262
+ source ID). Pass the value returned as the third element of
263
+ ``get_synced_file_ids_for_connector()``.
264
+ """
265
+ if not orphan_ids:
266
+ return 0
267
+ from .documents import delete_chunks_by_document_ids
268
+
269
+ try:
270
+ opensearch_client = session_manager.get_user_opensearch_client(user_id, jwt_token)
271
+ return await delete_chunks_by_document_ids(
272
+ orphan_ids, opensearch_client, get_index_name(), field=id_field
273
+ )
274
+ except Exception as e:
275
+ logger.error(
276
+ "Orphan delete failed",
277
+ orphan_count=len(orphan_ids),
278
+ id_field=id_field,
279
+ error=str(e),
280
+ )
281
+ return 0
282
+
283
+
284
+ async def reconcile_orphans_for_connector_type(
285
+ connector_type: str,
286
+ user_id: str,
287
+ connector_service,
288
+ session_manager,
289
+ jwt_token: str | None,
290
+ existing_file_ids: list[str],
291
+ id_field: str = "document_id",
292
+ ) -> list[str]:
293
+ """Compute and delete orphans for a connector type. Thin wrapper around
294
+ compute_orphans_for_connector_type + delete_orphan_documents preserved for
295
+ callers that perform sync immediately after reconcile.
296
+
297
+ ``id_field`` must match the OpenSearch field that ``existing_file_ids`` came
298
+ from. Pass the value returned as the third element of
299
+ ``get_synced_file_ids_for_connector()``.
300
+
301
+ Returns the list of orphan file IDs that were deleted (or []).
302
+ """
303
+ orphans = await compute_orphans_for_connector_type(
304
+ connector_type=connector_type,
305
+ user_id=user_id,
306
+ connector_service=connector_service,
307
+ session_manager=session_manager,
308
+ jwt_token=jwt_token,
309
+ existing_file_ids=existing_file_ids,
310
+ )
311
+ if not orphans:
312
+ return []
313
+
314
+ orphan_ids = [o["document_id"] for o in orphans]
315
+ deleted = await delete_orphan_documents(
316
+ orphan_ids=orphan_ids,
317
+ user_id=user_id,
318
+ session_manager=session_manager,
319
+ jwt_token=jwt_token,
320
+ id_field=id_field,
321
+ )
322
+ logger.info(
323
+ "Orphan reconcile complete",
324
+ connector_type=connector_type,
325
+ orphan_count=len(orphan_ids),
326
+ deleted_chunks=deleted,
327
+ id_field=id_field,
328
+ )
329
+ return orphan_ids
330
+
87
331
 
88
332
  class ConnectorSyncBody(BaseModel):
89
- max_files: Optional[int] = None
90
- selected_files: Optional[List[Any]] = None
333
+ max_files: int | None = None
334
+ selected_files: list[Any] | None = None
91
335
  # When True, ingest ALL files from the connector (bypasses the existing-files gate).
92
336
  # Used by direct-sync providers like IBM COS on initial ingest.
93
337
  sync_all: bool = False
94
338
  # When set, only ingest files from these buckets (IBM COS specific).
95
- bucket_filter: Optional[List[str]] = None
339
+ bucket_filter: list[str] | None = None
96
340
  # Per-request ingest options from the connector upload UI (overrides saved Knowledge for this sync).
97
- settings: Optional[Dict[str, Any]] = None
341
+ settings: dict[str, Any] | None = None
342
+ # When True, files whose filename already exists in the index are replaced
343
+ # rather than failing. Set by the provider upload UI after the user confirms
344
+ # overwrite in the duplicate dialog.
345
+ replace_duplicates: bool = False
346
+
347
+
348
+ class ConnectorCheckDuplicatesBody(BaseModel):
349
+ connection_id: str | None = None
350
+ selected_files: list[Any] | None = None
351
+
352
+
353
+ async def connector_check_duplicates(
354
+ connector_type: str,
355
+ body: ConnectorCheckDuplicatesBody,
356
+ connector_service=Depends(get_connector_service),
357
+ session_manager=Depends(get_session_manager),
358
+ user: User = Depends(require_permission("connectors:use")),
359
+ ):
360
+ """Check if any of the selected files or folders contain files that already exist in the index"""
361
+ selected_files_raw = body.selected_files
362
+ if not selected_files_raw:
363
+ return JSONResponse({"duplicate_names": []})
364
+
365
+ try:
366
+ jwt_token = user.jwt_token
367
+ # Get all active connections for this connector type and user
368
+ connections = await connector_service.connection_manager.list_connections(
369
+ user_id=user.user_id, connector_type=connector_type
370
+ )
371
+ active_connections = [conn for conn in connections if conn.is_active]
372
+
373
+ # If connection_id is provided, find it, otherwise find the first working connection
374
+ working_connection = None
375
+ if body.connection_id:
376
+ for conn in active_connections:
377
+ if conn.connection_id == body.connection_id:
378
+ working_connection = conn
379
+ break
380
+
381
+ if not working_connection:
382
+ for conn in active_connections:
383
+ try:
384
+ connector = await connector_service.get_connector(conn.connection_id)
385
+ if connector and await connector.authenticate():
386
+ working_connection = conn
387
+ break
388
+ except Exception:
389
+ continue
390
+
391
+ if not working_connection:
392
+ return JSONResponse(
393
+ {"error": f"No working {connector_type} connections found"},
394
+ status_code=404,
395
+ )
396
+
397
+ connector = await connector_service.get_connector(working_connection.connection_id)
398
+ if not connector:
399
+ return JSONResponse(
400
+ {"error": f"Connection '{working_connection.connection_id}' not found"},
401
+ status_code=404,
402
+ )
403
+
404
+ # Get list of file IDs to expand
405
+ file_ids = [f.get("id") for f in selected_files_raw if f.get("id")]
406
+
407
+ expanded_files_info = []
408
+
409
+ # Expand files (handling folders) if possible
410
+ if file_ids and hasattr(connector, "cfg"):
411
+ original_file_ids = getattr(connector.cfg, "file_ids", None)
412
+ original_folder_ids = getattr(connector.cfg, "folder_ids", None)
413
+ try:
414
+ connector.cfg.file_ids = file_ids
415
+ connector.cfg.folder_ids = None
416
+
417
+ result = await connector.list_files()
418
+ expanded_files = result.get("files", [])
419
+ for f in expanded_files:
420
+ expanded_files_info.append(
421
+ {
422
+ "name": f.get("name", ""),
423
+ "mimeType": f.get("mime_type") or f.get("mimeType") or "",
424
+ }
425
+ )
426
+ except Exception as e:
427
+ logger.error("Failed to expand files in duplicate check", error=str(e))
428
+ finally:
429
+ connector.cfg.file_ids = original_file_ids
430
+ connector.cfg.folder_ids = original_folder_ids
431
+
432
+ # If expansion returned nothing, fall back to non-folders in selected_files_raw
433
+ if not expanded_files_info:
434
+ for f in selected_files_raw:
435
+ if not f.get("isFolder"):
436
+ expanded_files_info.append(
437
+ {"name": f.get("name", ""), "mimeType": f.get("mimeType") or ""}
438
+ )
439
+
440
+ if not expanded_files_info:
441
+ return JSONResponse({"duplicate_names": []})
442
+
443
+ # Process and clean names using clean_connector_filename
444
+ from utils.file_utils import clean_connector_filename, get_filename_aliases
445
+
446
+ cleaned_names = []
447
+ for f in expanded_files_info:
448
+ cleaned_name = clean_connector_filename(f["name"], f["mimeType"])
449
+ cleaned_names.append(cleaned_name)
450
+
451
+ # Build candidate filenames (including aliases like .txt / .md mapping)
452
+ all_candidates = set()
453
+ for name in cleaned_names:
454
+ all_candidates.update(get_filename_aliases(name))
455
+
456
+ if not all_candidates:
457
+ return JSONResponse({"duplicate_names": []})
458
+
459
+ # Query OpenSearch in one batch
460
+ opensearch_client = session_manager.get_user_opensearch_client(user.user_id, jwt_token)
461
+
462
+ query_body = {
463
+ "size": 10000,
464
+ "query": {"terms": {"filename": list(all_candidates)}},
465
+ "_source": ["filename"],
466
+ }
467
+
468
+ existing_filenames = set()
469
+ try:
470
+ response = await opensearch_client.search(index=get_index_name(), body=query_body)
471
+ hits = response.get("hits", {}).get("hits", [])
472
+ for hit in hits:
473
+ fn = hit.get("_source", {}).get("filename")
474
+ if fn:
475
+ existing_filenames.add(fn)
476
+ except Exception as search_err:
477
+ if "index_not_found_exception" in str(search_err):
478
+ # Index doesn't exist yet, so no duplicates can exist
479
+ return JSONResponse({"duplicate_names": [], "total_files": len(cleaned_names)})
480
+ raise
481
+
482
+ # Check which of the cleaned names have duplicates (based on existing_filenames matching any alias)
483
+ duplicate_names = []
484
+ for name in cleaned_names:
485
+ aliases = get_filename_aliases(name)
486
+ if any(alias in existing_filenames for alias in aliases):
487
+ duplicate_names.append(name)
488
+
489
+ return JSONResponse(
490
+ {"duplicate_names": list(set(duplicate_names)), "total_files": len(cleaned_names)}
491
+ )
492
+
493
+ except Exception:
494
+ logger.exception("[CONNECTOR] Error checking duplicates")
495
+ return JSONResponse({"error": "An internal error has occurred."}, status_code=500)
98
496
 
99
497
 
100
498
  async def list_connectors(
@@ -117,7 +515,7 @@ async def connector_sync(
117
515
  body: ConnectorSyncBody,
118
516
  connector_service=Depends(get_connector_service),
119
517
  session_manager=Depends(get_session_manager),
120
- user: User = Depends(get_current_user),
518
+ user: User = Depends(require_permission("connectors:use")),
121
519
  ):
122
520
  """Sync files from all active connections of a connector type"""
123
521
  max_files = body.max_files
@@ -134,7 +532,9 @@ async def connector_sync(
134
532
  file_infos = selected_files_raw
135
533
 
136
534
  try:
137
- await TelemetryClient.send_event(Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_SYNC_START)
535
+ await TelemetryClient.send_event(
536
+ Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_SYNC_START
537
+ )
138
538
  logger.debug(
139
539
  "Starting connector sync",
140
540
  connector_type=connector_type,
@@ -195,10 +595,11 @@ async def connector_sync(
195
595
  "Starting sync with working connection",
196
596
  connection_id=working_connection.connection_id,
197
597
  )
198
-
598
+
199
599
  if selected_files:
200
600
  # Explicit files selected (e.g., from file picker) - sync those specific files
201
601
  from .documents import _ensure_index_exists
602
+
202
603
  await _ensure_index_exists(jwt_token)
203
604
  task_id = await connector_service.sync_specific_files(
204
605
  working_connection.connection_id,
@@ -207,6 +608,7 @@ async def connector_sync(
207
608
  jwt_token=jwt_token,
208
609
  file_infos=file_infos,
209
610
  ingest_settings=body.settings,
611
+ replace_duplicates=body.replace_duplicates,
210
612
  )
211
613
  elif body.sync_all or body.bucket_filter:
212
614
  # Full ingest: discover and ingest all files (or files from specific buckets).
@@ -236,7 +638,10 @@ async def connector_sync(
236
638
 
237
639
  if not all_file_ids:
238
640
  return JSONResponse(
239
- {"status": "no_files", "message": "No files found in the selected buckets."},
641
+ {
642
+ "status": "no_files",
643
+ "message": "No files found in the selected buckets.",
644
+ },
240
645
  status_code=200,
241
646
  )
242
647
  task_id = await connector_service.sync_specific_files(
@@ -257,7 +662,11 @@ async def connector_sync(
257
662
  else:
258
663
  # No files specified - sync only files already in OpenSearch for this connector
259
664
  # This ensures deleted files stay deleted
260
- existing_file_ids, existing_filenames = await get_synced_file_ids_for_connector(
665
+ (
666
+ existing_file_ids,
667
+ existing_filenames,
668
+ id_field,
669
+ ) = await get_synced_file_ids_for_connector(
261
670
  connector_type=connector_type,
262
671
  user_id=user.user_id,
263
672
  session_manager=session_manager,
@@ -273,19 +682,34 @@ async def connector_sync(
273
682
  status_code=200,
274
683
  )
275
684
 
276
- # If we have document_ids (connector file IDs), use sync_specific_files
685
+ # If we have connector file IDs, use sync_specific_files
277
686
  # Otherwise, use filename filtering with sync_connector_files
278
687
  if existing_file_ids:
279
688
  logger.info(
280
- "Syncing specific files by document_id",
689
+ "Syncing specific files by connector file ID",
281
690
  connector_type=connector_type,
282
691
  file_count=len(existing_file_ids),
692
+ id_field=id_field,
283
693
  )
694
+ # Reconcile orphans (files deleted at the source) before re-syncing.
695
+ # Strict gating: skip when sync is capped — we'd see a partial remote
696
+ # listing and delete legitimate files.
697
+ if body.max_files is None:
698
+ await reconcile_orphans_for_connector_type(
699
+ connector_type=connector_type,
700
+ user_id=user.user_id,
701
+ connector_service=connector_service,
702
+ session_manager=session_manager,
703
+ jwt_token=jwt_token,
704
+ existing_file_ids=existing_file_ids,
705
+ id_field=id_field,
706
+ )
284
707
  task_id = await connector_service.sync_specific_files(
285
708
  working_connection.connection_id,
286
709
  user.user_id,
287
710
  existing_file_ids,
288
711
  jwt_token=jwt_token,
712
+ replace_duplicates=_connector_sync_should_replace(connector_type),
289
713
  )
290
714
  else:
291
715
  # Fallback: use filename filtering (for Langflow-ingested files without document_id)
@@ -300,9 +724,12 @@ async def connector_sync(
300
724
  max_files=None,
301
725
  jwt_token=jwt_token,
302
726
  filename_filter=set(existing_filenames),
727
+ replace_duplicates=_connector_sync_should_replace(connector_type),
303
728
  )
304
729
  task_ids = [task_id]
305
- await TelemetryClient.send_event(Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_SYNC_COMPLETE)
730
+ await TelemetryClient.send_event(
731
+ Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_SYNC_COMPLETE
732
+ )
306
733
  return JSONResponse(
307
734
  {
308
735
  "task_ids": task_ids,
@@ -315,7 +742,9 @@ async def connector_sync(
315
742
 
316
743
  except Exception as e:
317
744
  logger.error("Connector sync failed", error=str(e))
318
- await TelemetryClient.send_event(Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_SYNC_FAILED)
745
+ await TelemetryClient.send_event(
746
+ Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_SYNC_FAILED
747
+ )
319
748
  return JSONResponse({"error": f"Sync failed: {str(e)}"}, status_code=500)
320
749
 
321
750
 
@@ -334,25 +763,31 @@ async def connector_status(
334
763
  # Get the connector for each connection and verify authentication
335
764
  connection_details = {}
336
765
  verified_active_connections = []
337
-
766
+
338
767
  for connection in connections:
339
768
  try:
340
769
  connector = await connector_service._get_connector(connection.connection_id)
341
770
  if connector is not None:
342
771
  # Actually verify the connection by trying to authenticate
343
772
  is_authenticated = await connector.authenticate()
344
-
773
+
345
774
  # Get base URL if available (for SharePoint/OneDrive connectors)
346
775
  base_url = None
347
- if hasattr(connector, 'base_url'):
776
+ if hasattr(connector, "base_url"):
348
777
  base_url = connector.base_url
349
- logger.debug(f"connector_status: Got base_url from connector.base_url: {base_url}")
350
- elif hasattr(connector, 'sharepoint_url'):
778
+ logger.debug(
779
+ f"connector_status: Got base_url from connector.base_url: {base_url}"
780
+ )
781
+ elif hasattr(connector, "sharepoint_url"):
351
782
  base_url = connector.sharepoint_url # Backward compatibility
352
- logger.debug(f"connector_status: Got base_url from connector.sharepoint_url: {base_url}")
783
+ logger.debug(
784
+ f"connector_status: Got base_url from connector.sharepoint_url: {base_url}"
785
+ )
353
786
  else:
354
- logger.debug(f"connector_status: Connector has no base_url or sharepoint_url attribute")
355
-
787
+ logger.debug(
788
+ "connector_status: Connector has no base_url or sharepoint_url attribute"
789
+ )
790
+
356
791
  connection_details[connection.connection_id] = {
357
792
  "client_id": connector.get_client_id(),
358
793
  "is_authenticated": is_authenticated,
@@ -391,8 +826,13 @@ async def connector_status(
391
826
  "connection_id": conn.connection_id,
392
827
  "name": conn.name,
393
828
  "client_id": connection_details.get(conn.connection_id, {}).get("client_id"),
394
- "is_active": conn.is_active and connection_details.get(conn.connection_id, {}).get("is_authenticated", False),
395
- "is_authenticated": connection_details.get(conn.connection_id, {}).get("is_authenticated", False),
829
+ "is_active": conn.is_active
830
+ and connection_details.get(conn.connection_id, {}).get(
831
+ "is_authenticated", False
832
+ ),
833
+ "is_authenticated": connection_details.get(conn.connection_id, {}).get(
834
+ "is_authenticated", False
835
+ ),
396
836
  "base_url": connection_details.get(conn.connection_id, {}).get("base_url"),
397
837
  "created_at": conn.created_at.isoformat(),
398
838
  "last_sync": conn.last_sync.isoformat() if conn.last_sync else None,
@@ -422,10 +862,10 @@ async def connector_webhook(
422
862
  config=temp_config,
423
863
  )
424
864
  try:
425
- await TelemetryClient.send_event(Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_WEBHOOK_RECV)
426
- temp_connector = connector_service.connection_manager._create_connector(
427
- temp_connection
865
+ await TelemetryClient.send_event(
866
+ Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_WEBHOOK_RECV
428
867
  )
868
+ temp_connector = connector_service.connection_manager._create_connector(temp_connection)
429
869
  validation_response = temp_connector.handle_webhook_validation(
430
870
  request.method, dict(request.headers), dict(request.query_params)
431
871
  )
@@ -460,32 +900,22 @@ async def connector_webhook(
460
900
 
461
901
  # Extract channel/subscription ID using connector-specific method
462
902
  try:
463
- temp_connector = connector_service.connection_manager._create_connector(
464
- temp_connection
465
- )
903
+ temp_connector = connector_service.connection_manager._create_connector(temp_connection)
466
904
  channel_id = temp_connector.extract_webhook_channel_id(payload, headers)
467
905
  except (NotImplementedError, ValueError):
468
906
  channel_id = None
469
907
 
470
908
  if not channel_id:
471
- logger.warning(
472
- "No channel ID found in webhook", connector_type=connector_type
473
- )
909
+ logger.warning("No channel ID found in webhook", connector_type=connector_type)
474
910
  return JSONResponse({"status": "ignored", "reason": "no_channel_id"})
475
911
 
476
912
  # Find the specific connection for this webhook
477
- connection = (
478
- await connector_service.connection_manager.get_connection_by_webhook_id(
479
- channel_id
480
- )
913
+ connection = await connector_service.connection_manager.get_connection_by_webhook_id(
914
+ channel_id
481
915
  )
482
916
  if not connection or not connection.is_active:
483
- logger.info(
484
- "Unknown webhook channel, will auto-expire", channel_id=channel_id
485
- )
486
- return JSONResponse(
487
- {"status": "ignored_unknown_channel", "channel_id": channel_id}
488
- )
917
+ logger.info("Unknown webhook channel, will auto-expire", channel_id=channel_id)
918
+ return JSONResponse({"status": "ignored_unknown_channel", "channel_id": channel_id})
489
919
 
490
920
  # Process webhook for the specific connection
491
921
  try:
@@ -496,9 +926,7 @@ async def connector_webhook(
496
926
  "Could not get connector for connection",
497
927
  connection_id=connection.connection_id,
498
928
  )
499
- return JSONResponse(
500
- {"status": "error", "reason": "connector_not_found"}
501
- )
929
+ return JSONResponse({"status": "error", "reason": "connector_not_found"})
502
930
 
503
931
  # Let the connector handle the webhook and return affected file IDs
504
932
  affected_files = await connector.handle_webhook(payload)
@@ -569,15 +997,16 @@ async def connector_webhook(
569
997
 
570
998
  except Exception as e:
571
999
  logger.error("Webhook processing failed", error=str(e))
572
- await TelemetryClient.send_event(Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_WEBHOOK_FAILED)
573
- return JSONResponse(
574
- {"error": f"Webhook processing failed: {str(e)}"}, status_code=500
1000
+ await TelemetryClient.send_event(
1001
+ Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_WEBHOOK_FAILED
575
1002
  )
1003
+ return JSONResponse({"error": f"Webhook processing failed: {str(e)}"}, status_code=500)
1004
+
576
1005
 
577
1006
  async def connector_disconnect(
578
1007
  connector_type: str,
579
1008
  connector_service=Depends(get_connector_service),
580
- user: User = Depends(get_current_user),
1009
+ user: User = Depends(require_permission("connectors:delete:own")),
581
1010
  ):
582
1011
  """Disconnect a connector by deleting its connection"""
583
1012
 
@@ -599,7 +1028,7 @@ async def connector_disconnect(
599
1028
  try:
600
1029
  # Get the connector to cleanup any subscriptions
601
1030
  connector = await connector_service._get_connector(connection.connection_id)
602
- if connector and hasattr(connector, 'cleanup_subscription'):
1031
+ if connector and hasattr(connector, "cleanup_subscription"):
603
1032
  subscription_id = connection.config.get("webhook_channel_id")
604
1033
  if subscription_id:
605
1034
  try:
@@ -653,21 +1082,24 @@ async def connector_disconnect(
653
1082
 
654
1083
  # ---------------------------------------------------------------------------
655
1084
 
1085
+
656
1086
  async def sync_all_connectors(
657
1087
  connector_service=Depends(get_connector_service),
658
1088
  session_manager=Depends(get_session_manager),
659
- user: User = Depends(get_current_user),
1089
+ user: User = Depends(require_permission("connectors:use")),
660
1090
  ):
661
1091
  """
662
1092
  Sync files from all active cloud connector connections.
663
1093
  """
664
1094
  try:
665
- await TelemetryClient.send_event(Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_SYNC_START)
1095
+ await TelemetryClient.send_event(
1096
+ Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_SYNC_START
1097
+ )
666
1098
  jwt_token = user.jwt_token
667
1099
 
668
1100
  # Cloud connector types to sync
669
1101
  cloud_connector_types = ["google_drive", "onedrive", "sharepoint", "ibm_cos", "aws_s3"]
670
-
1102
+
671
1103
  all_task_ids = []
672
1104
  synced_connectors = []
673
1105
  skipped_connectors = []
@@ -676,13 +1108,17 @@ async def sync_all_connectors(
676
1108
  for connector_type in cloud_connector_types:
677
1109
  try:
678
1110
  # First, get existing file IDs/filenames from OpenSearch for this connector type
679
- existing_file_ids, existing_filenames = await get_synced_file_ids_for_connector(
1111
+ (
1112
+ existing_file_ids,
1113
+ existing_filenames,
1114
+ id_field,
1115
+ ) = await get_synced_file_ids_for_connector(
680
1116
  connector_type=connector_type,
681
1117
  user_id=user.user_id,
682
1118
  session_manager=session_manager,
683
1119
  jwt_token=jwt_token,
684
1120
  )
685
-
1121
+
686
1122
  if not existing_file_ids and not existing_filenames:
687
1123
  logger.debug(
688
1124
  "No existing files in OpenSearch for connector type, skipping",
@@ -727,18 +1163,32 @@ async def sync_all_connectors(
727
1163
  )
728
1164
  continue
729
1165
 
730
- # Sync using document_ids if available, else use filename filter
1166
+ # Sync using connector file IDs if available, else use filename filter
731
1167
  if existing_file_ids:
732
1168
  logger.info(
733
- "Syncing specific files by document_id",
1169
+ "Syncing specific files by connector file ID",
734
1170
  connector_type=connector_type,
735
1171
  file_count=len(existing_file_ids),
1172
+ id_field=id_field,
1173
+ )
1174
+ # Reconcile orphans (files deleted at the source) before re-syncing.
1175
+ # sync_all_connectors has no caps or filters, so gating reduces
1176
+ # to the strict checks inside the helper.
1177
+ await reconcile_orphans_for_connector_type(
1178
+ connector_type=connector_type,
1179
+ user_id=user.user_id,
1180
+ connector_service=connector_service,
1181
+ session_manager=session_manager,
1182
+ jwt_token=jwt_token,
1183
+ existing_file_ids=existing_file_ids,
1184
+ id_field=id_field,
736
1185
  )
737
1186
  task_id = await connector_service.sync_specific_files(
738
1187
  working_connection.connection_id,
739
1188
  user.user_id,
740
1189
  existing_file_ids,
741
1190
  jwt_token=jwt_token,
1191
+ replace_duplicates=_connector_sync_should_replace(connector_type),
742
1192
  )
743
1193
  else:
744
1194
  # Fallback: use filename filtering
@@ -753,15 +1203,18 @@ async def sync_all_connectors(
753
1203
  max_files=None,
754
1204
  jwt_token=jwt_token,
755
1205
  filename_filter=set(existing_filenames),
1206
+ replace_duplicates=_connector_sync_should_replace(connector_type),
756
1207
  )
757
-
1208
+
758
1209
  all_task_ids.append(task_id)
759
1210
  synced_connectors.append(connector_type)
760
1211
  logger.info(
761
1212
  "Started sync for connector type",
762
1213
  connector_type=connector_type,
763
1214
  task_id=task_id,
764
- file_count=len(existing_file_ids) if existing_file_ids else len(existing_filenames),
1215
+ file_count=len(existing_file_ids)
1216
+ if existing_file_ids
1217
+ else len(existing_filenames),
765
1218
  )
766
1219
 
767
1220
  except Exception as e:
@@ -787,7 +1240,9 @@ async def sync_all_connectors(
787
1240
  status_code=404,
788
1241
  )
789
1242
 
790
- await TelemetryClient.send_event(Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_SYNC_COMPLETE)
1243
+ await TelemetryClient.send_event(
1244
+ Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_SYNC_COMPLETE
1245
+ )
791
1246
  return JSONResponse(
792
1247
  {
793
1248
  "task_ids": all_task_ids,
@@ -802,10 +1257,143 @@ async def sync_all_connectors(
802
1257
 
803
1258
  except Exception as e:
804
1259
  logger.error("Sync all connectors failed", error=str(e))
805
- await TelemetryClient.send_event(Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_SYNC_FAILED)
1260
+ await TelemetryClient.send_event(
1261
+ Category.CONNECTOR_OPERATIONS, MessageId.ORB_CONN_SYNC_FAILED
1262
+ )
806
1263
  return JSONResponse({"error": f"Sync failed: {str(e)}"}, status_code=500)
807
1264
 
808
1265
 
1266
+ CLOUD_CONNECTOR_TYPES = ["google_drive", "onedrive", "sharepoint", "ibm_cos", "aws_s3"]
1267
+
1268
+
1269
+ async def _preview_orphans_for_connector_type(
1270
+ connector_type: str,
1271
+ user_id: str,
1272
+ connector_service,
1273
+ session_manager,
1274
+ jwt_token: str | None,
1275
+ ) -> tuple[list[dict[str, str]] | None, int]:
1276
+ """Helper: compute orphans (no deletion) + return total synced count.
1277
+
1278
+ Returns (orphans, synced_count). `orphans` is None when strict gating aborts
1279
+ (so the caller can surface a "couldn't determine" state); [] when no orphans.
1280
+ """
1281
+ existing_file_ids, existing_filenames, _ = await get_synced_file_ids_for_connector(
1282
+ connector_type=connector_type,
1283
+ user_id=user_id,
1284
+ session_manager=session_manager,
1285
+ jwt_token=jwt_token,
1286
+ )
1287
+
1288
+ synced_count = len(existing_file_ids) if existing_file_ids else len(existing_filenames)
1289
+ if not existing_file_ids:
1290
+ # No document_ids to diff against (e.g. Langflow-only ingest). Filename-only
1291
+ # fallback can't detect orphans safely — surface empty list.
1292
+ return [], synced_count
1293
+
1294
+ id_to_filename = await get_synced_id_to_filename_map(
1295
+ connector_type=connector_type,
1296
+ user_id=user_id,
1297
+ session_manager=session_manager,
1298
+ jwt_token=jwt_token,
1299
+ )
1300
+
1301
+ orphans = await compute_orphans_for_connector_type(
1302
+ connector_type=connector_type,
1303
+ user_id=user_id,
1304
+ connector_service=connector_service,
1305
+ session_manager=session_manager,
1306
+ jwt_token=jwt_token,
1307
+ existing_file_ids=existing_file_ids,
1308
+ id_to_filename=id_to_filename,
1309
+ )
1310
+ return orphans, synced_count
1311
+
1312
+
1313
+ async def connector_sync_preview(
1314
+ connector_type: str,
1315
+ connector_service=Depends(get_connector_service),
1316
+ session_manager=Depends(get_session_manager),
1317
+ user: User = Depends(require_permission("connectors:use")),
1318
+ ):
1319
+ """Preview the impact of syncing a connector type without performing any
1320
+ deletion or ingest. Returns the list of orphan files (present in OpenSearch
1321
+ but no longer at the source) by filename, plus the total synced count.
1322
+ """
1323
+ try:
1324
+ orphans, synced_count = await _preview_orphans_for_connector_type(
1325
+ connector_type=connector_type,
1326
+ user_id=user.user_id,
1327
+ connector_service=connector_service,
1328
+ session_manager=session_manager,
1329
+ jwt_token=user.jwt_token,
1330
+ )
1331
+ return JSONResponse(
1332
+ {
1333
+ "connector_type": connector_type,
1334
+ "synced_count": synced_count,
1335
+ "orphans": orphans or [],
1336
+ "orphans_available": orphans is not None,
1337
+ },
1338
+ status_code=200,
1339
+ )
1340
+ except Exception as e:
1341
+ logger.error("Sync preview failed", connector_type=connector_type, error=str(e))
1342
+ return JSONResponse({"error": f"Sync preview failed: {str(e)}"}, status_code=500)
1343
+
1344
+
1345
+ async def connectors_sync_all_preview(
1346
+ connector_service=Depends(get_connector_service),
1347
+ session_manager=Depends(get_session_manager),
1348
+ user: User = Depends(require_permission("connectors:use")),
1349
+ ):
1350
+ """Preview the impact of sync-all-connectors across every cloud connector
1351
+ type. Returns orphan filenames grouped by connector_type plus a per-type
1352
+ synced count.
1353
+ """
1354
+ try:
1355
+ orphans_by_type: dict[str, list[dict[str, str]]] = {}
1356
+ synced_count_by_type: dict[str, int] = {}
1357
+ orphans_available_by_type: dict[str, bool] = {}
1358
+
1359
+ for connector_type in CLOUD_CONNECTOR_TYPES:
1360
+ try:
1361
+ orphans, synced_count = await _preview_orphans_for_connector_type(
1362
+ connector_type=connector_type,
1363
+ user_id=user.user_id,
1364
+ connector_service=connector_service,
1365
+ session_manager=session_manager,
1366
+ jwt_token=user.jwt_token,
1367
+ )
1368
+ except Exception as e:
1369
+ logger.warning(
1370
+ "Sync-all preview: per-connector failure",
1371
+ connector_type=connector_type,
1372
+ error=str(e),
1373
+ )
1374
+ orphans, synced_count = None, 0
1375
+
1376
+ # Only include connector types that have something synced.
1377
+ if synced_count == 0 and not orphans:
1378
+ continue
1379
+
1380
+ synced_count_by_type[connector_type] = synced_count
1381
+ orphans_by_type[connector_type] = orphans or []
1382
+ orphans_available_by_type[connector_type] = orphans is not None
1383
+
1384
+ return JSONResponse(
1385
+ {
1386
+ "orphans_by_type": orphans_by_type,
1387
+ "synced_count_by_type": synced_count_by_type,
1388
+ "orphans_available_by_type": orphans_available_by_type,
1389
+ },
1390
+ status_code=200,
1391
+ )
1392
+ except Exception as e:
1393
+ logger.error("Sync-all preview failed", error=str(e))
1394
+ return JSONResponse({"error": f"Sync-all preview failed: {str(e)}"}, status_code=500)
1395
+
1396
+
809
1397
  async def connector_token(
810
1398
  connector_type: str,
811
1399
  connection_id: str,
@@ -826,7 +1414,9 @@ async def connector_token(
826
1414
  connector = await connector_service._get_connector(connection_id)
827
1415
  if not connector:
828
1416
  return JSONResponse(
829
- {"error": f"Connector not available - authentication may have failed for {url_connector_type}"},
1417
+ {
1418
+ "error": f"Connector not available - authentication may have failed for {url_connector_type}"
1419
+ },
830
1420
  status_code=404,
831
1421
  )
832
1422
 
@@ -858,7 +1448,10 @@ async def connector_token(
858
1448
  try:
859
1449
  if connector.oauth.creds.expiry:
860
1450
  import time
861
- expires_in = max(0, int(connector.oauth.creds.expiry.timestamp() - time.time()))
1451
+
1452
+ expires_in = max(
1453
+ 0, int(connector.oauth.creds.expiry.timestamp() - time.time())
1454
+ )
862
1455
  except Exception:
863
1456
  expires_in = None
864
1457
 
@@ -905,12 +1498,114 @@ async def connector_token(
905
1498
  return JSONResponse({"access_token": access_token, "expires_in": None})
906
1499
  except ValueError as e:
907
1500
  # Typical when acquire_token_silent fails (e.g., needs re-auth)
908
- return JSONResponse({"error": f"Failed to get access token: {str(e)}"}, status_code=401)
1501
+ return JSONResponse(
1502
+ {"error": f"Failed to get access token: {str(e)}"}, status_code=401
1503
+ )
909
1504
  except Exception as e:
910
1505
  return JSONResponse({"error": f"Authentication error: {str(e)}"}, status_code=500)
911
1506
 
912
- return JSONResponse({"error": "Token not available for this connector type"}, status_code=400)
1507
+ return JSONResponse(
1508
+ {"error": "Token not available for this connector type"}, status_code=400
1509
+ )
913
1510
 
914
1511
  except Exception as e:
915
1512
  logger.error("Error getting connector token", exc_info=True)
916
1513
  return JSONResponse({"error": str(e)}, status_code=500)
1514
+
1515
+
1516
+ async def browse_connection_files(
1517
+ connector_type: str,
1518
+ connection_id: str,
1519
+ connector_service=Depends(get_connector_service),
1520
+ session_manager=Depends(get_session_manager),
1521
+ user: User = Depends(get_current_user),
1522
+ bucket: str | None = None,
1523
+ search: str | None = None,
1524
+ page_token: str | None = None,
1525
+ max_files: int = 100,
1526
+ ):
1527
+ """
1528
+ Browse remote files in a connector with ingestion status.
1529
+
1530
+ Lists files from the remote source (e.g., S3 bucket) and marks each
1531
+ as ingested or not by cross-referencing with OpenSearch.
1532
+ """
1533
+ try:
1534
+ connector = await connector_service.get_connector(connection_id)
1535
+ if not connector:
1536
+ return JSONResponse(
1537
+ {"error": "Connection not found or connector unavailable"},
1538
+ status_code=404,
1539
+ )
1540
+
1541
+ if not await connector.authenticate():
1542
+ return JSONResponse(
1543
+ {"error": "Connector authentication failed"},
1544
+ status_code=401,
1545
+ )
1546
+
1547
+ # Temporarily override bucket filter if specified
1548
+ original_buckets = None
1549
+ if bucket and hasattr(connector, "bucket_names"):
1550
+ original_buckets = connector.bucket_names
1551
+ connector.bucket_names = [bucket]
1552
+
1553
+ try:
1554
+ files_result = await connector.list_files(page_token=page_token, max_files=max_files)
1555
+ finally:
1556
+ if original_buckets is not None:
1557
+ connector.bucket_names = original_buckets
1558
+
1559
+ remote_files = files_result.get("files", [])
1560
+ next_page_token = files_result.get("next_page_token")
1561
+
1562
+ # Filter by filename search if provided
1563
+ if search:
1564
+ search_lower = search.lower()
1565
+ remote_files = [f for f in remote_files if search_lower in f.get("name", "").lower()]
1566
+
1567
+ # Get already-ingested file IDs from OpenSearch
1568
+ ingested_ids, ingested_filenames, _ = await get_synced_file_ids_for_connector(
1569
+ connector_type=connector_type,
1570
+ user_id=user.user_id,
1571
+ session_manager=session_manager,
1572
+ jwt_token=user.jwt_token,
1573
+ )
1574
+ ingested_set = set(ingested_ids) | set(ingested_filenames)
1575
+
1576
+ # Merge ingestion status into remote file list
1577
+ enriched_files = []
1578
+ for f in remote_files:
1579
+ is_ingested = f.get("id", "") in ingested_set or f.get("name", "") in ingested_set
1580
+ enriched_files.append(
1581
+ {
1582
+ "id": f.get("id", ""),
1583
+ "name": f.get("name", ""),
1584
+ "bucket": f.get("bucket", ""),
1585
+ "key": f.get("key", ""),
1586
+ "size": f.get("size", 0),
1587
+ "modified_time": f.get("modified_time", ""),
1588
+ "is_ingested": is_ingested,
1589
+ }
1590
+ )
1591
+
1592
+ return JSONResponse(
1593
+ {
1594
+ "files": enriched_files,
1595
+ "next_page_token": next_page_token,
1596
+ "total_remote": len(enriched_files),
1597
+ "total_ingested": sum(1 for f in enriched_files if f["is_ingested"]),
1598
+ }
1599
+ )
1600
+
1601
+ except Exception as e:
1602
+ logger.error(
1603
+ "Failed to browse connection files",
1604
+ connector_type=connector_type,
1605
+ connection_id=connection_id,
1606
+ error=str(e),
1607
+ )
1608
+ return JSONResponse(
1609
+ {"error": f"Failed to browse files: {str(e)}"},
1610
+ status_code=500,
1611
+ )