diff --git a/application/api/connector/routes.py b/application/api/connector/routes.py index 1008dc09..3c5d8b53 100644 --- a/application/api/connector/routes.py +++ b/application/api/connector/routes.py @@ -205,12 +205,13 @@ class ConnectorsCallback(Resource): @connectors_ns.route("/api/connectors/files") class ConnectorFiles(Resource): @api.expect(api.model("ConnectorFilesModel", { - "provider": fields.String(required=True), - "session_token": fields.String(required=True), - "folder_id": fields.String(required=False), - "limit": fields.Integer(required=False), + "provider": fields.String(required=True), + "session_token": fields.String(required=True), + "folder_id": fields.String(required=False), + "limit": fields.Integer(required=False), "page_token": fields.String(required=False), - "search_query": fields.String(required=False) + "search_query": fields.String(required=False), + "shared": fields.Boolean(required=False) })) @api.doc(description="List files from a connector provider (supports pagination and search)") def post(self): @@ -222,6 +223,7 @@ class ConnectorFiles(Resource): limit = data.get('limit', 10) page_token = data.get('page_token') search_query = data.get('search_query') + shared = data.get('shared', False) if not provider or not session_token: return make_response(jsonify({"success": False, "error": "provider and session_token are required"}), 400) @@ -240,7 +242,8 @@ class ConnectorFiles(Resource): 'list_only': True, 'session_token': session_token, 'folder_id': folder_id, - 'page_token': page_token + 'page_token': page_token, + 'shared': shared } if search_query: input_config['search_query'] = search_query diff --git a/application/parser/connectors/share_point/loader.py b/application/parser/connectors/share_point/loader.py index 753477da..d6362f64 100644 --- a/application/parser/connectors/share_point/loader.py +++ b/application/parser/connectors/share_point/loader.py @@ -73,6 +73,12 @@ class SharePointLoader(BaseConnectorLoader): logging.error(f"Failed to refresh token: {e}") raise ValueError("Failed to refresh access token") + def _get_item_url(self, item_ref: str) -> str: + if ':' in item_ref: + drive_id, item_id = item_ref.split(':', 1) + return f"{self.GRAPH_API_BASE}/drives/{drive_id}/items/{item_id}" + return f"{self.GRAPH_API_BASE}/me/drive/items/{item_ref}" + def _process_file(self, file_metadata: Dict[str, Any], load_content: bool = True) -> Optional[Document]: try: drive_item_id = file_metadata.get('id') @@ -126,9 +132,17 @@ class SharePointLoader(BaseConnectorLoader): load_content = not list_only page_token = inputs.get('page_token') search_query = inputs.get('search_query') + shared = inputs.get('shared', False) self.next_page_token = None - if file_ids: + if shared and not file_ids and not folder_id: + documents = self._list_shared_items( + limit=limit, + load_content=load_content, + page_token=page_token, + search_query=search_query + ) + elif file_ids: for file_id in file_ids: try: doc = self._load_file_by_id(file_id, load_content=load_content) @@ -161,7 +175,7 @@ class SharePointLoader(BaseConnectorLoader): self._ensure_valid_token() try: - url = f"{self.GRAPH_API_BASE}/me/drive/items/{file_id}" + url = self._get_item_url(file_id) params = {'$select': 'id,name,file,createdDateTime,lastModifiedDateTime,size'} response = requests.get(url, headers=self._get_headers(), params=params) response.raise_for_status() @@ -193,13 +207,17 @@ class SharePointLoader(BaseConnectorLoader): documents: List[Document] = [] try: - url = f"{self.GRAPH_API_BASE}/me/drive/items/{parent_id}/children" + url = f"{self._get_item_url(parent_id)}/children" params = {'$top': min(100, limit) if limit else 100, '$select': 'id,name,file,folder,createdDateTime,lastModifiedDateTime,size'} if page_token: params['$skipToken'] = page_token if search_query: - search_url = f"{self.GRAPH_API_BASE}/me/drive/search(q='{search_query}')" + if ':' in parent_id: + drive_id = parent_id.split(':', 1)[0] + search_url = f"{self.GRAPH_API_BASE}/drives/{drive_id}/root/search(q='{search_query}')" + else: + search_url = f"{self.GRAPH_API_BASE}/me/drive/search(q='{search_query}')" response = requests.get(search_url, headers=self._get_headers(), params=params) else: response = requests.get(url, headers=self._get_headers(), params=params) @@ -248,11 +266,94 @@ class SharePointLoader(BaseConnectorLoader): logging.error(f"Error listing items under parent {parent_id}: {e}") return documents + def _list_shared_items(self, limit: int = 100, load_content: bool = False, + page_token: Optional[str] = None, + search_query: Optional[str] = None) -> List[Document]: + self._ensure_valid_token() + documents: List[Document] = [] + + try: + url = f"{self.GRAPH_API_BASE}/me/drive/sharedWithMe" + params = {'$top': min(100, limit) if limit else 100} + if page_token: + params['$skipToken'] = page_token + + response = requests.get(url, headers=self._get_headers(), params=params) + response.raise_for_status() + results = response.json() + + for item in results.get('value', []): + remote = item.get('remoteItem', {}) + drive_id = remote.get('parentReference', {}).get('driveId') + item_id = remote.get('id') + name = remote.get('name', item.get('name', 'Unknown')) + + if not drive_id or not item_id: + continue + + if search_query and search_query.lower() not in name.lower(): + continue + + composite_id = f"{drive_id}:{item_id}" + + if 'folder' in remote: + doc_metadata = { + 'file_name': name, + 'mime_type': 'folder', + 'size': remote.get('size'), + 'created_time': remote.get('createdDateTime'), + 'modified_time': remote.get('lastModifiedDateTime'), + 'source': 'share_point', + 'is_folder': True + } + documents.append(Document(text="", doc_id=composite_id, extra_info=doc_metadata)) + elif 'file' in remote: + mime_type = remote.get('file', {}).get('mimeType', 'application/octet-stream') + if mime_type not in self.SUPPORTED_MIME_TYPES: + continue + + doc_metadata = { + 'file_name': name, + 'mime_type': mime_type, + 'size': remote.get('size'), + 'created_time': remote.get('createdDateTime'), + 'modified_time': remote.get('lastModifiedDateTime'), + 'source': 'share_point' + } + + if load_content: + content = self._download_file_content(composite_id) + if content is None: + continue + documents.append(Document(text=content, doc_id=composite_id, extra_info=doc_metadata)) + else: + documents.append(Document(text="", doc_id=composite_id, extra_info=doc_metadata)) + + if limit and len(documents) >= limit: + break + + next_link = results.get('@odata.nextLink') + if next_link: + from urllib.parse import urlparse, parse_qs + parsed = urlparse(next_link) + query_params = parse_qs(parsed.query) + skiptoken_list = query_params.get('$skiptoken') + self.next_page_token = skiptoken_list[0] if skiptoken_list else None + else: + self.next_page_token = None + + return documents + + except Exception as e: + logging.error(f"Error listing shared items: {e}") + return documents + + def _download_file_content(self, file_id: str) -> Optional[str]: self._ensure_valid_token() try: - url = f"{self.GRAPH_API_BASE}/me/drive/items/{file_id}/content" + url = f"{self._get_item_url(file_id)}/content" response = requests.get(url, headers=self._get_headers()) response.raise_for_status() @@ -297,7 +398,7 @@ class SharePointLoader(BaseConnectorLoader): def _download_single_file(self, file_id: str, local_dir: str) -> bool: try: - url = f"{self.GRAPH_API_BASE}/me/drive/items/{file_id}" + url = self._get_item_url(file_id) params = {'$select': 'id,name,file'} response = requests.get(url, headers=self._get_headers(), params=params) response.raise_for_status() @@ -314,7 +415,7 @@ class SharePointLoader(BaseConnectorLoader): os.makedirs(local_dir, exist_ok=True) full_path = os.path.join(local_dir, file_name) - download_url = f"{self.GRAPH_API_BASE}/me/drive/items/{file_id}/content" + download_url = f"{self._get_item_url(file_id)}/content" download_response = requests.get(download_url, headers=self._get_headers()) download_response.raise_for_status() @@ -331,7 +432,7 @@ class SharePointLoader(BaseConnectorLoader): try: os.makedirs(local_dir, exist_ok=True) - url = f"{self.GRAPH_API_BASE}/me/drive/items/{folder_id}/children" + url = f"{self._get_item_url(folder_id)}/children" params = {'$top': 1000} while url: @@ -415,7 +516,7 @@ class SharePointLoader(BaseConnectorLoader): for folder_id in folder_ids: try: - url = f"{self.GRAPH_API_BASE}/me/drive/items/{folder_id}" + url = self._get_item_url(folder_id) params = {'$select': 'id,name'} response = requests.get(url, headers=self._get_headers(), params=params) response.raise_for_status() diff --git a/frontend/src/components/FilePicker.tsx b/frontend/src/components/FilePicker.tsx index e31edf79..3ba2ec70 100644 --- a/frontend/src/components/FilePicker.tsx +++ b/frontend/src/components/FilePicker.tsx @@ -92,9 +92,11 @@ export const FilePicker: React.FC = ({ const [authError, setAuthError] = useState(''); const [isConnected, setIsConnected] = useState(false); const [userEmail, setUserEmail] = useState(''); + const [shared, setShared] = useState(false); const scrollContainerRef = useRef(null); const searchTimeoutRef = useRef | null>(null); + const abortControllerRef = useRef(null); const isFolder = (file: CloudFile) => { return ( @@ -110,7 +112,13 @@ export const FilePicker: React.FC = ({ folderId: string | null, pageToken?: string, searchQuery = '', + isShared = false, ) => { + // Cancel any in-flight request so stale responses never overwrite new state + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + setIsLoading(true); const apiHost = import.meta.env.VITE_API_HOST; @@ -119,20 +127,25 @@ export const FilePicker: React.FC = ({ } try { + const body: Record = { + provider: provider, + session_token: sessionToken, + folder_id: folderId, + limit: 10, + page_token: pageToken, + search_query: searchQuery, + }; + if (isShared) { + body.shared = true; + } const response = await fetch(`${apiHost}/api/connectors/files`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, - body: JSON.stringify({ - provider: provider, - session_token: sessionToken, - folder_id: folderId, - limit: 10, - page_token: pageToken, - search_query: searchQuery, - }), + body: JSON.stringify(body), + signal: controller.signal, }); const data = await response.json(); @@ -149,12 +162,15 @@ export const FilePicker: React.FC = ({ } } } catch (err) { + if ((err as Error).name === 'AbortError') return; console.error('Error loading files:', err); if (!pageToken) { setFiles([]); } } finally { - setIsLoading(false); + if (!controller.signal.aborted) { + setIsLoading(false); + } } }, [token, provider], @@ -207,7 +223,7 @@ export const FilePicker: React.FC = ({ name: getProviderConfig(provider).rootName, }, ]); - loadCloudFiles(sessionToken, null, undefined, ''); + loadCloudFiles(sessionToken, null, undefined, '', false); } else { removeSessionToken(provider); setIsConnected(false); @@ -242,6 +258,7 @@ export const FilePicker: React.FC = ({ currentFolderId, nextPageToken, searchQuery, + shared, ); } } @@ -253,6 +270,7 @@ export const FilePicker: React.FC = ({ searchQuery, provider, loadCloudFiles, + shared, ]); useEffect(() => { @@ -268,6 +286,7 @@ export const FilePicker: React.FC = ({ if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); } + abortControllerRef.current?.abort(); }; }, []); @@ -281,7 +300,7 @@ export const FilePicker: React.FC = ({ searchTimeoutRef.current = setTimeout(() => { const sessionToken = getSessionToken(provider); if (sessionToken) { - loadCloudFiles(sessionToken, currentFolderId, undefined, query); + loadCloudFiles(sessionToken, currentFolderId, undefined, query, shared); } }, 300); }; @@ -299,7 +318,7 @@ export const FilePicker: React.FC = ({ const sessionToken = getSessionToken(provider); if (sessionToken) { - loadCloudFiles(sessionToken, folderId, undefined, ''); + loadCloudFiles(sessionToken, folderId, undefined, '', shared); } }; @@ -315,7 +334,25 @@ export const FilePicker: React.FC = ({ const sessionToken = getSessionToken(provider); if (sessionToken) { - loadCloudFiles(sessionToken, newFolderId, undefined, ''); + loadCloudFiles(sessionToken, newFolderId, undefined, '', shared); + } + }; + + const handleTabSwitch = (isShared: boolean) => { + if (isShared === shared) return; + setShared(isShared); + setFiles([]); + setCurrentFolderId(null); + setSearchQuery(''); + setNextPageToken(null); + setHasMoreFiles(false); + const rootName = isShared + ? t('filePicker.sharedWithMe') + : getProviderConfig(provider).rootName; + setFolderPath([{ id: null, name: rootName }]); + const sessionToken = getSessionToken(provider); + if (sessionToken) { + loadCloudFiles(sessionToken, null, undefined, '', isShared); } }; @@ -350,7 +387,7 @@ export const FilePicker: React.FC = ({ if (data.session_token) { setSessionToken(provider, data.session_token); - loadCloudFiles(data.session_token, null); + loadCloudFiles(data.session_token, null, undefined, '', shared); } }} onError={(error) => { @@ -394,9 +431,32 @@ export const FilePicker: React.FC = ({ /> {isConnected && ( -
+
- {/* Breadcrumb navigation */} + {provider === 'share_point' && ( +
+ + +
+ )}
{folderPath.map((path, index) => ( @@ -443,7 +503,7 @@ export const FilePicker: React.FC = ({
-
+
= ({ - {files.map((file, index) => ( - { - if (isFolder(file)) { - handleFolderClick(file.id, file.name); - } else { - handleFileSelect(file.id, false); - } - }} - > - -
{ - e.stopPropagation(); - handleFileSelect(file.id, isFolder(file)); + {isLoading && files.length === 0 + ? Array.from({ length: 5 }).map((_, i) => ( + + +
+ + +
+ + +
+ + +
+ + + )) + : files.map((file, index) => ( + { + if (isFolder(file)) { + handleFolderClick(file.id, file.name); + } else { + handleFileSelect(file.id, false); + } }} > - {(isFolder(file) - ? selectedFolders - : selectedFiles - ).includes(file.id) && ( - Selected - )} -
-
- -
-
- {isFolder(file) -
- {file.name} -
-
- - {formatDate(file.modifiedTime)} - - - {file.size ? formatBytes(file.size) : '-'} - - - ))} + +
{ + e.stopPropagation(); + handleFileSelect(file.id, isFolder(file)); + }} + > + {(isFolder(file) + ? selectedFolders + : selectedFiles + ).includes(file.id) && ( + Selected + )} +
+
+ +
+
+ {isFolder(file) +
+ {file.name} +
+
+ + {formatDate(file.modifiedTime)} + + + {file.size ? formatBytes(file.size) : '-'} + + + ))} + {isLoading && files.length > 0 && + Array.from({ length: 3 }).map((_, i) => ( + + +
+ + +
+ + +
+ + +
+ + + ))} - - {isLoading && ( -
-
-
- Loading more files... -
-
- )} } diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index 4ff7a2a6..9f43f9ee 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -671,7 +671,10 @@ "itemsSelected": "{{count}} ausgewählt", "name": "Name", "lastModified": "Zuletzt geändert", - "size": "Größe" + "size": "Größe", + "myFiles": "Meine Dateien", + "sharedWithMe": "Mit mir geteilt", + "loadingMore": "Weitere Dateien laden..." }, "actionButtons": { "openNewChat": "Neuen Chat öffnen", diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index ad188a18..982c5a29 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -694,7 +694,10 @@ "itemsSelected": "{{count}} selected", "name": "Name", "lastModified": "Last Modified", - "size": "Size" + "size": "Size", + "myFiles": "My Files", + "sharedWithMe": "Shared with Me", + "loadingMore": "Loading more files..." }, "actionButtons": { "openNewChat": "Open New Chat", diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index af5fa8d2..f365dd33 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -693,7 +693,10 @@ "itemsSelected": "{{count}} seleccionados", "name": "Nombre", "lastModified": "Última modificación", - "size": "Tamaño" + "size": "Tamaño", + "myFiles": "Mis archivos", + "sharedWithMe": "Compartido conmigo", + "loadingMore": "Cargando más archivos..." }, "actionButtons": { "openNewChat": "Abrir nuevo chat", diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index e3071a6e..79eb3313 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -693,7 +693,10 @@ "itemsSelected": "{{count}} 件選択済み", "name": "名前", "lastModified": "最終更新日", - "size": "サイズ" + "size": "サイズ", + "myFiles": "マイファイル", + "sharedWithMe": "共有アイテム", + "loadingMore": "さらに読み込み中..." }, "actionButtons": { "openNewChat": "新しいチャットを開く", diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index 399c0d51..1fdb7601 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -693,7 +693,10 @@ "itemsSelected": "{{count}} выбрано", "name": "Имя", "lastModified": "Последнее изменение", - "size": "Размер" + "size": "Размер", + "myFiles": "Мои файлы", + "sharedWithMe": "Доступные мне", + "loadingMore": "Загрузка файлов..." }, "actionButtons": { "openNewChat": "Открыть новый чат", diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index b56dc6ad..8a537e23 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -693,7 +693,10 @@ "itemsSelected": "已選擇 {{count}} 項", "name": "名稱", "lastModified": "最後修改", - "size": "大小" + "size": "大小", + "myFiles": "我的檔案", + "sharedWithMe": "與我共用", + "loadingMore": "載入更多檔案..." }, "actionButtons": { "openNewChat": "開啟新聊天", diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index bc54795b..991ff944 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -693,7 +693,10 @@ "itemsSelected": "已选择 {{count}} 项", "name": "名称", "lastModified": "最后修改", - "size": "大小" + "size": "大小", + "myFiles": "我的文件", + "sharedWithMe": "与我共享", + "loadingMore": "加载更多文件..." }, "actionButtons": { "openNewChat": "打开新聊天",