""" SharePoint operations method module. Handles SharePoint document operations using the SharePoint service. """ import logging import re import json from typing import Dict, Any, List, Optional from datetime import datetime, UTC, timedelta, timezone import urllib import aiohttp import asyncio from modules.workflows.methods.methodBase import MethodBase, action from modules.datamodels.datamodelChat import ActionResult, ActionDocument logger = logging.getLogger(__name__) class MethodSharepoint(MethodBase): """SharePoint operations methods.""" def __init__(self, services): super().__init__(services) self.name = "sharepoint" self.description = "SharePoint operations methods" def _format_timestamp_for_filename(self) -> str: """Format current timestamp as YYYYMMDD-hhmmss for filenames.""" return datetime.now(UTC).strftime("%Y%m%d-%H%M%S") def _getMicrosoftConnection(self, connectionReference: str) -> Optional[Dict[str, Any]]: """Get Microsoft connection from connection reference and configure SharePoint service""" try: userConnection = self.services.chat.getUserConnectionFromConnectionReference(connectionReference) if not userConnection: logger.warning(f"No user connection found for reference: {connectionReference}") return None if userConnection.authority.value != "msft": logger.warning(f"Connection {userConnection.id} is not Microsoft (authority: {userConnection.authority.value})") return None # Check if connection is active or pending (pending means OAuth in progress) if userConnection.status.value not in ["active", "pending"]: logger.warning(f"Connection {userConnection.id} status is not active/pending: {userConnection.status.value}") return None # Configure SharePoint service with the UserConnection if not self.services.sharepoint.setAccessTokenFromConnection(userConnection): logger.warning(f"Failed to configure SharePoint service with connection {userConnection.id}") return None logger.info(f"Successfully configured SharePoint service with Microsoft connection: {userConnection.id}, status: {userConnection.status.value}, externalId: {userConnection.externalId}") return { "id": userConnection.id, "userConnection": userConnection, "scopes": ["Sites.ReadWrite.All", "Files.ReadWrite.All", "User.Read"] # SharePoint scopes } except Exception as e: logger.error(f"Error getting Microsoft connection: {str(e)}") return None async def _discoverSharePointSites(self, limit: Optional[int] = None) -> List[Dict[str, Any]]: """ Discover SharePoint sites accessible to the user via Microsoft Graph API Parameters: limit (Optional[int]): Limit number of sites to return (for optimization when only hostname is needed) Returns: List[Dict[str, Any]]: List of SharePoint site information """ try: # Query Microsoft Graph to get sites the user has access to endpoint = "sites?search=*" if limit: endpoint += f"&$top={limit}" result = await self._makeGraphApiCall(endpoint) if "error" in result: logger.error(f"Error discovering SharePoint sites: {result['error']}") return [] sites = result.get("value", []) if limit: sites = sites[:limit] logger.info(f"Discovered {len(sites)} SharePoint sites" + (f" (limited to {limit})" if limit else "")) # Process and return site information processedSites = [] for site in sites: siteInfo = { "id": site.get("id"), "displayName": site.get("displayName"), "name": site.get("name"), "webUrl": site.get("webUrl"), "description": site.get("description"), "createdDateTime": site.get("createdDateTime"), "lastModifiedDateTime": site.get("lastModifiedDateTime") } processedSites.append(siteInfo) logger.debug(f"Site: {siteInfo['displayName']} - {siteInfo['webUrl']}") return processedSites except Exception as e: logger.error(f"Error discovering SharePoint sites: {str(e)}") return [] def _extractHostnameFromWebUrl(self, webUrl: str) -> Optional[str]: """Extract hostname from SharePoint webUrl (e.g., https://pcuster.sharepoint.com)""" try: if not webUrl: return None parsed = urllib.parse.urlparse(webUrl) return parsed.hostname except Exception as e: logger.error(f"Error extracting hostname from webUrl '{webUrl}': {str(e)}") return None def _extractSiteFromStandardPath(self, pathQuery: str) -> Optional[Dict[str, str]]: """ Extract site name from Microsoft-standard server-relative path. Delegates to SharePoint service. """ return self.services.sharepoint.extractSiteFromStandardPath(pathQuery) async def _getSiteByStandardPath(self, sitePath: str) -> Optional[Dict[str, Any]]: """ Get SharePoint site directly by Microsoft-standard path. Delegates to SharePoint service. """ return await self.services.sharepoint.getSiteByStandardPath(sitePath) def _filterSitesByHint(self, sites: List[Dict[str, Any]], siteHint: str) -> List[Dict[str, Any]]: """ Filter discovered sites by a human-entered site hint. Delegates to SharePoint service. """ return self.services.sharepoint.filterSitesByHint(sites, siteHint) def _parseSearchQuery(self, searchQuery: str) -> tuple[str, str, str, dict]: """ Parse searchQuery to extract path, search terms, search type, and search options. CRITICAL: NEVER convert words to paths! Words stay as search terms. - "root document lesson" → fileQuery="root document lesson" (NOT "/root/document/lesson") - "root, gose" → fileQuery="root, gose" (NOT "/root/gose") - "druckersteuerung eskalation logobject" → fileQuery="druckersteuerung eskalation logobject" Parameters: searchQuery (str): Enhanced search query with options: - "budget" -> pathQuery="*", fileQuery="budget", searchType="all", options={} - "root document lesson" -> pathQuery="*", fileQuery="root document lesson", searchType="all", options={} - "root, gose" -> pathQuery="*", fileQuery="root, gose", searchType="all", options={} - "/Documents:budget" -> pathQuery="/Documents", fileQuery="budget", searchType="all", options={} - "files:budget" -> pathQuery="*", fileQuery="budget", searchType="files", options={} - "folders:DELTA" -> pathQuery="*", fileQuery="DELTA", searchType="folders", options={} - "exact:\"Operations 2025\"" -> exact phrase matching - "regex:^Operations.*2025$" -> regex pattern matching - "case:DELTA" -> case-sensitive search - "and:DELTA AND 2025 Mars AND Group" -> all AND terms must be present Returns: tuple[str, str, str, dict]: (pathQuery, fileQuery, searchType, searchOptions) """ try: if not searchQuery or not searchQuery.strip() or searchQuery.strip() == "*": return "*", "*", "all", {} searchQuery = searchQuery.strip() searchOptions = {} # CRITICAL: Do NOT convert space-separated or comma-separated words to paths! # "root document lesson" should stay as "root document lesson", NOT "/root/document/lesson" # "root, gose" should stay as "root, gose", NOT "/root/gose" # Check for search type specification (files:, folders:, all:) FIRST searchType = "all" # Default if searchQuery.startswith(("files:", "folders:", "all:")): typeParts = searchQuery.split(':', 1) searchType = typeParts[0].strip() searchQuery = typeParts[1].strip() # Extract optional site hint tokens: support "site=Name" or leading "site:Name" def _extractSiteHint(q: str) -> tuple[str, Optional[str]]: try: qStrip = q.strip() # Leading form: site:KM LayerFinance ... if qStrip.lower().startswith("site:"): after = qStrip[5:].lstrip() # site name until next space or end if ' ' in after: siteName, rest = after.split(' ', 1) else: siteName, rest = after, '' return rest.strip(), siteName.strip() # Inline key=value form anywhere m = re.search(r"\bsite=([^;\s]+)", qStrip, flags=re.IGNORECASE) if m: siteName = m.group(1).strip() # remove the token from query qNew = re.sub(r"\bsite=[^;\s]+;?", "", qStrip, flags=re.IGNORECASE).strip() return qNew, siteName except Exception: pass return q, None searchQuery, extractedSite = _extractSiteHint(searchQuery) if extractedSite: searchOptions["site_hint"] = extractedSite logger.info(f"Extracted site hint: '{extractedSite}'") # Extract name="..." if present (for quoted multi-word names) nameMatch = re.search(r"name=\"([^\"]+)\"", searchQuery) if nameMatch: searchQuery = nameMatch.group(1) logger.info(f"Extracted name from quotes: '{searchQuery}'") # Check for search mode specification (exact:, regex:, case:, and:) if searchQuery.startswith(("exact:", "regex:", "case:", "and:")): modeParts = searchQuery.split(':', 1) mode = modeParts[0].strip() searchQuery = modeParts[1].strip() if mode == "exact": searchOptions["exact_match"] = True # Remove quotes if present if searchQuery.startswith('"') and searchQuery.endswith('"'): searchQuery = searchQuery[1:-1] elif mode == "regex": searchOptions["regex_match"] = True elif mode == "case": searchOptions["case_sensitive"] = True elif mode == "and": searchOptions["and_terms"] = True # Check if it contains path:search format # Microsoft-standard paths: /sites/SiteName/Path:files:.pdf if ':' in searchQuery: # For Microsoft-standard paths (/sites/...), find the colon that separates path from search if searchQuery.startswith('/sites/'): # Find the colon that separates path from search (after the full path) # Look for pattern: /sites/SiteName/Path/...:files:.pdf # We need to find the colon that's followed by search type or file extension colonPositions = [] for i, char in enumerate(searchQuery): if char == ':': colonPositions.append(i) # If we have colons, find the one that's followed by search type or file extension splitPos = None if colonPositions: for pos in colonPositions: afterColon = searchQuery[pos+1:pos+10].strip().lower() # Check if this colon is followed by search type or looks like a file extension if afterColon.startswith(('files:', 'folders:', 'all:', '.')) or afterColon == '': splitPos = pos break # If no clear split found, use the last colon if splitPos is None and colonPositions: splitPos = colonPositions[-1] if splitPos: pathPart = searchQuery[:splitPos].strip() searchPart = searchQuery[splitPos+1:].strip() else: # Fallback: split on first colon parts = searchQuery.split(':', 1) pathPart = parts[0].strip() searchPart = parts[1].strip() else: # Regular path:search format - split on first colon parts = searchQuery.split(':', 1) pathPart = parts[0].strip() searchPart = parts[1].strip() # Check if searchPart starts with search type (files:, folders:, all:) if searchPart.startswith(("files:", "folders:", "all:")): typeParts = searchPart.split(':', 1) searchType = typeParts[0].strip() # Update searchType searchPart = typeParts[1].strip() if len(typeParts) > 1 else "" # Handle path part if not pathPart or pathPart == "*": pathQuery = "*" elif pathPart.startswith('/'): pathQuery = pathPart else: pathQuery = f"/Documents/{pathPart}" # Handle search part if not searchPart or searchPart == "*": fileQuery = "*" else: fileQuery = searchPart return pathQuery, fileQuery, searchType, searchOptions # No colon - check if it looks like a path elif searchQuery.startswith('/'): # It's a path only return searchQuery, "*", searchType, searchOptions else: # It's a search term only - keep words as-is, do NOT convert to paths # "root document lesson" stays as "root document lesson" # "root, gose" stays as "root, gose" return "*", searchQuery, searchType, searchOptions except Exception as e: logger.error(f"Error parsing searchQuery '{searchQuery}': {str(e)}") raise ValueError(f"Failed to parse searchQuery '{searchQuery}': {str(e)}") def _resolvePathQuery(self, pathQuery: str) -> List[str]: """ Resolve pathQuery into a list of search paths for SharePoint operations. Parameters: pathQuery (str): Query string that can contain: - Direct paths (e.g., "/Documents/Project1") - Wildcards (e.g., "/Documents/*") - Multiple paths separated by semicolons (e.g., "/Docs; /Files") - Single word relative paths (e.g., "Project1" -> resolved to default folder) - Empty string or "*" for global search - Space-separated words are treated as search terms, NOT folder paths Returns: List[str]: List of resolved paths """ try: if not pathQuery or not pathQuery.strip() or pathQuery.strip() == "*": return ["*"] # Global search across all sites # Split by semicolon to handle multiple paths rawPaths = [path.strip() for path in pathQuery.split(';') if path.strip()] resolvedPaths = [] for rawPath in rawPaths: # Handle wildcards - return as-is if '*' in rawPath: resolvedPaths.append(rawPath) # Handle absolute paths elif rawPath.startswith('/'): resolvedPaths.append(rawPath) # Handle single word relative paths - prepend default folder # BUT NOT space-separated words (those are search terms, not paths) elif ' ' not in rawPath: resolvedPaths.append(f"/Documents/{rawPath}") else: # Check if this looks like a path (has path separators) or search terms if '\\' in rawPath or '/' in rawPath: # This looks like a path with spaces in folder names - treat as valid path resolvedPaths.append(rawPath) logger.info(f"Path with spaces '{rawPath}' treated as valid folder path") else: # Space-separated words without path separators are search terms # Return as "*" to search globally logger.info(f"Space-separated words '{rawPath}' treated as search terms, not folder path") resolvedPaths.append("*") # Remove duplicates while preserving order seen = set() uniquePaths = [] for path in resolvedPaths: if path not in seen: seen.add(path) uniquePaths.append(path) logger.info(f"Resolved pathQuery '{pathQuery}' to {len(uniquePaths)} paths: {uniquePaths}") return uniquePaths except Exception as e: logger.error(f"Error resolving pathQuery '{pathQuery}': {str(e)}") raise ValueError(f"Failed to resolve pathQuery '{pathQuery}': {str(e)}") def _parseSiteUrl(self, siteUrl: str) -> Dict[str, str]: """Parse SharePoint site URL to extract hostname and site path""" try: parsed = urllib.parse.urlparse(siteUrl) hostname = parsed.hostname path = parsed.path.strip('/') return { "hostname": hostname, "sitePath": path } except Exception as e: logger.error(f"Error parsing site URL {siteUrl}: {str(e)}") return {"hostname": "", "sitePath": ""} def _cleanSearchQuery(self, query: str) -> str: """ Clean search query to make it compatible with Graph API KQL syntax. Removes path-like syntax and invalid KQL constructs. Parameters: query (str): Raw search query that may contain paths and invalid syntax Returns: str: Cleaned query suitable for Graph API search endpoint """ if not query or not query.strip(): return "" query = query.strip() # Handle patterns like: "Company Share/Freigegebene Dokumente/.../expenses:files:.pdf" # Extract the search term and file extension # First, extract file extension if present (format: :files:.pdf or just .pdf at the end) fileExtension = "" if ':files:' in query.lower() or ':folders:' in query.lower(): # Extract extension after the type filter extMatch = re.search(r':(?:files|folders):(\.\w+)', query, re.IGNORECASE) if extMatch: fileExtension = extMatch.group(1) # Remove the type filter part query = re.sub(r':(?:files|folders):\.?\w*', '', query, flags=re.IGNORECASE) elif query.endswith(('.pdf', '.doc', '.docx', '.xls', '.xlsx', '.txt', '.csv', '.ppt', '.pptx')): # Extract extension from end extMatch = re.search(r'(\.\w+)$', query) if extMatch: fileExtension = extMatch.group(1) query = query[:-len(fileExtension)] # Extract search term: get the last segment after the last slash (filename part) queryNormalized = query.replace('\\', '/') if '/' in queryNormalized: # Extract the last segment (usually the filename/search term) lastSegment = queryNormalized.split('/')[-1] # Remove any remaining colons or type filters if ':' in lastSegment: lastSegment = lastSegment.split(':')[0] searchTerm = lastSegment.strip() else: # No path separators, use the query as-is but remove type filters if ':' in query: searchTerm = query.split(':')[0].strip() else: searchTerm = query.strip() # Remove any remaining type filters or invalid syntax searchTerm = re.sub(r':(?:files|folders|all):?', '', searchTerm, flags=re.IGNORECASE) searchTerm = searchTerm.strip() # If we have a file extension, include it in the search term # Note: Graph API search endpoint may not support filetype: syntax # So we include the extension as part of the search term or filter results after if fileExtension: extWithoutDot = fileExtension.lstrip('.') # Try simple approach: add extension as search term # If this doesn't work, we'll filter results after search if searchTerm: # Include extension in search - Graph API will search in filename searchTerm = f"{searchTerm} {extWithoutDot}" else: searchTerm = extWithoutDot # Final cleanup: remove any remaining invalid characters for KQL # Keep alphanumeric, spaces, hyphens, underscores, dots, and common search operators searchTerm = re.sub(r'[^\w\s\-\.\*]', ' ', searchTerm) searchTerm = ' '.join(searchTerm.split()) # Normalize whitespace return searchTerm if searchTerm else "*" async def _makeGraphApiCall(self, endpoint: str, method: str = "GET", data: bytes = None) -> Dict[str, Any]: """Make a Microsoft Graph API call with timeout and detailed logging""" try: if not hasattr(self.services, 'sharepoint') or not self.services.sharepoint._target.accessToken: return {"error": "SharePoint service not configured with access token"} headers = { "Authorization": f"Bearer {self.services.sharepoint._target.accessToken}", "Content-Type": "application/json" if data and method != "PUT" else "application/octet-stream" if data else "application/json" } url = f"https://graph.microsoft.com/v1.0/{endpoint}" logger.info(f"Making Graph API call: {method} {url}") # Set timeout to 30 seconds timeout = aiohttp.ClientTimeout(total=30) async with aiohttp.ClientSession(timeout=timeout) as session: if method == "GET": logger.debug(f"Starting GET request to {url}") async with session.get(url, headers=headers) as response: logger.info(f"Graph API response: {response.status}") if response.status == 200: result = await response.json() logger.debug(f"Graph API success: {len(str(result))} characters response") return result else: errorText = await response.text() logger.error(f"Graph API call failed: {response.status} - {errorText}") return {"error": f"API call failed: {response.status} - {errorText}"} elif method == "PUT": logger.debug(f"Starting PUT request to {url}") async with session.put(url, headers=headers, data=data) as response: logger.info(f"Graph API response: {response.status}") if response.status in [200, 201]: result = await response.json() logger.debug(f"Graph API success: {len(str(result))} characters response") return result else: errorText = await response.text() logger.error(f"Graph API call failed: {response.status} - {errorText}") return {"error": f"API call failed: {response.status} - {errorText}"} elif method == "POST": logger.debug(f"Starting POST request to {url}") async with session.post(url, headers=headers, data=data) as response: logger.info(f"Graph API response: {response.status}") if response.status in [200, 201]: result = await response.json() logger.debug(f"Graph API success: {len(str(result))} characters response") return result else: errorText = await response.text() logger.error(f"Graph API call failed: {response.status} - {errorText}") return {"error": f"API call failed: {response.status} - {errorText}"} except asyncio.TimeoutError: logger.error(f"Graph API call timed out after 30 seconds: {endpoint}") return {"error": f"API call timed out after 30 seconds: {endpoint}"} except Exception as e: logger.error(f"Error making Graph API call: {str(e)}") return {"error": f"Error making Graph API call: {str(e)}"} async def _getSiteId(self, hostname: str, sitePath: str) -> str: """Get SharePoint site ID from hostname and site path""" try: endpoint = f"sites/{hostname}:/{sitePath}" result = await self._makeGraphApiCall(endpoint) if "error" in result: logger.error(f"Error getting site ID: {result['error']}") return "" return result.get("id", "") except Exception as e: logger.error(f"Error getting site ID: {str(e)}") return "" async def _parseDocumentListForFoundDocuments(self, documentList: Any) -> tuple[Optional[List[Dict[str, Any]]], Optional[List[Dict[str, Any]]], Optional[str]]: """ Parse documentList to extract foundDocuments and site information. Parameters: documentList: Document list (can be list, DocumentReferenceList, or string) Returns: tuple: (foundDocuments, sites, errorMessage) - foundDocuments: List of found documents from findDocumentPath result - sites: List of site dictionaries with id, displayName, webUrl - errorMessage: Error message if parsing failed, None otherwise """ try: if isinstance(documentList, str): documentList = [documentList] # Resolve documentList to get actual documents from modules.datamodels.datamodelDocref import DocumentReferenceList if isinstance(documentList, DocumentReferenceList): docRefList = documentList elif isinstance(documentList, list): docRefList = DocumentReferenceList.from_string_list(documentList) else: docRefList = DocumentReferenceList(references=[]) chatDocuments = self.services.chat.getChatDocumentsFromDocumentList(docRefList) if not chatDocuments: return None, None, "No documents found for the provided document list" firstDocument = chatDocuments[0] fileData = self.services.chat.getFileData(firstDocument.fileId) if not fileData: return None, None, None # No fileData, but not an error (might be regular file) try: resultData = json.loads(fileData) foundDocuments = resultData.get("foundDocuments", []) # If no foundDocuments, check if it's a listDocuments result (has listResults) if not foundDocuments and "listResults" in resultData: logger.info(f"documentList contains listResults from listDocuments, converting to foundDocuments format") listResults = resultData.get("listResults", []) foundDocuments = [] siteIdFromList = None siteNameFromList = None for listResult in listResults: siteResults = listResult.get("siteResults", []) for siteResult in siteResults: items = siteResult.get("items", []) # Extract site info from first item if available if items and not siteIdFromList: siteNameFromList = items[0].get("siteName") for item in items: # Convert listDocuments item format to foundDocuments format if item.get("type") == "file": foundDoc = { "id": item.get("id"), "name": item.get("name"), "type": "file", "siteName": item.get("siteName"), "siteId": None, # Will be determined from site discovery "webUrl": item.get("webUrl"), "fullPath": item.get("webUrl", ""), "parentPath": item.get("parentPath", "") } foundDocuments.append(foundDoc) # Discover sites to get siteId if we have siteName if foundDocuments and siteNameFromList and not siteIdFromList: logger.info(f"Discovering sites to find siteId for '{siteNameFromList}'") allSites = await self._discoverSharePointSites() matchingSites = self._filterSitesByHint(allSites, siteNameFromList) if matchingSites: siteIdFromList = matchingSites[0].get("id") # Update all foundDocuments with siteId for doc in foundDocuments: doc["siteId"] = siteIdFromList logger.info(f"Found siteId '{siteIdFromList}' for site '{siteNameFromList}'") logger.info(f"Converted {len(foundDocuments)} files from listResults format") if not foundDocuments: return None, None, None # No foundDocuments, but not an error # Extract site information from foundDocuments firstDoc = foundDocuments[0] siteName = firstDoc.get("siteName") siteId = firstDoc.get("siteId") # If siteId is missing (from listDocuments conversion), discover sites to find it if siteName and not siteId: logger.info(f"Site ID missing, discovering sites to find siteId for '{siteName}'") allSites = await self._discoverSharePointSites() matchingSites = self._filterSitesByHint(allSites, siteName) if matchingSites: siteId = matchingSites[0].get("id") logger.info(f"Found siteId '{siteId}' for site '{siteName}'") sites = None if siteName and siteId: sites = [{ "id": siteId, "displayName": siteName, "webUrl": firstDoc.get("webUrl", "") }] logger.info(f"Using specific site from documentList: {siteName} (ID: {siteId})") elif siteName: # Try to get site by name allSites = await self._discoverSharePointSites() matchingSites = self._filterSitesByHint(allSites, siteName) if matchingSites: sites = [{ "id": matchingSites[0].get("id"), "displayName": siteName, "webUrl": matchingSites[0].get("webUrl", "") }] logger.info(f"Found site by name: {siteName} (ID: {sites[0]['id']})") else: return None, None, f"Site '{siteName}' not found. Cannot determine target site." else: return None, None, "Site information missing from documentList. Cannot determine target site." return foundDocuments, sites, None except json.JSONDecodeError as e: return None, None, f"Invalid JSON in documentList: {str(e)}" except Exception as e: return None, None, f"Error processing documentList: {str(e)}" except Exception as e: logger.error(f"Error parsing documentList: {str(e)}") return None, None, f"Error parsing documentList: {str(e)}" async def _resolveSitesFromPathQuery(self, pathQuery: str) -> tuple[List[Dict[str, Any]], Optional[str]]: """ Resolve sites from pathQuery using SharePoint service helper methods. Parameters: pathQuery (str): Path query string Returns: tuple: (sites, errorMessage) - sites: List of site dictionaries - errorMessage: Error message if resolution failed, None otherwise """ try: # Validate pathQuery format isValid, errorMsg = self.services.sharepoint.validatePathQuery(pathQuery) if not isValid: return [], errorMsg # Resolve sites using service helper sites = await self.services.sharepoint.resolveSitesFromPathQuery(pathQuery) if not sites: return [], "No SharePoint sites found or accessible" return sites, None except Exception as e: logger.error(f"Error resolving sites from pathQuery '{pathQuery}': {str(e)}") return [], f"Error resolving sites from pathQuery: {str(e)}" @action async def findDocumentPath(self, parameters: Dict[str, Any]) -> ActionResult: """ GENERAL: - Purpose: Find documents and folders by name/path across sites. - Input requirements: connectionReference (required); searchQuery (required); optional site, maxResults. - Output format: JSON with found items and paths. Parameters: - connectionReference (str, required): Microsoft connection label. - site (str, optional): Site hint. - searchQuery (str, required): Search terms or path. - maxResults (int, optional): Maximum items to return. Default: 1000. """ import time operationId = None try: # Init progress logger workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}" operationId = f"sharepoint_find_{workflowId}_{int(time.time())}" # Start progress tracking parentOperationId = parameters.get('parentOperationId') self.services.chat.progressLogStart( operationId, "Find Document Path", "SharePoint Search", f"Query: {parameters.get('searchQuery', '*')}", parentOperationId=parentOperationId ) connectionReference = parameters.get("connectionReference") site = parameters.get("site") searchQuery = parameters.get("searchQuery", "*") maxResults = parameters.get("maxResults", 1000) if not connectionReference: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="Connection reference is required") # Parse searchQuery to extract path, search terms, search type, and options pathQuery, fileQuery, searchType, searchOptions = self._parseSearchQuery(searchQuery) logger.debug(f"Parsed searchQuery '{searchQuery}' -> pathQuery='{pathQuery}', fileQuery='{fileQuery}', searchType='{searchType}'") self.services.chat.progressLogUpdate(operationId, 0.2, "Getting Microsoft connection") connection = self._getMicrosoftConnection(connectionReference) if not connection: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="No valid Microsoft connection found for the provided connection reference") # Extract site name from pathQuery if it contains Microsoft-standard path (/sites/SiteName/...) siteFromPath = None directSite = None if pathQuery and pathQuery.startswith('/sites/'): parsedPath = self._extractSiteFromStandardPath(pathQuery) if parsedPath: siteFromPath = parsedPath.get("siteName") logger.info(f"Extracted site from Microsoft-standard pathQuery '{pathQuery}': '{siteFromPath}'") # Try to get site directly by path (optimization - no need to load all 60 sites) directSite = await self._getSiteByStandardPath(siteFromPath) if directSite: logger.info(f"Got site directly by standard path - no need to discover all sites") sites = [directSite] else: logger.warning(f"Could not get site directly, falling back to site discovery") directSite = None else: logger.warning(f"Failed to parse site from standard pathQuery '{pathQuery}'") # If we didn't get the site directly, use discovery and filtering if not directSite: # Determine which site hint to use (priority: site parameter > site from pathQuery > site_hint from searchOptions) siteHintToUse = site or siteFromPath or searchOptions.get("site_hint") # Discover SharePoint sites - use targeted approach when site hint is available self.services.chat.progressLogUpdate(operationId, 0.3, "Discovering SharePoint sites") if siteHintToUse: # When site hint is available, discover all sites first, then filter allSites = await self._discoverSharePointSites() if not allSites: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="No SharePoint sites found or accessible") sites = self._filterSitesByHint(allSites, siteHintToUse) logger.info(f"Filtered sites by site hint '{siteHintToUse}' -> {len(sites)} sites") if not sites: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error=f"No SharePoint sites found matching '{siteHintToUse}'") else: # No site hint - discover all sites sites = await self._discoverSharePointSites() if not sites: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="No SharePoint sites found or accessible") # Resolve path query into search paths searchPaths = self._resolvePathQuery(pathQuery) self.services.chat.progressLogUpdate(operationId, 0.5, f"Searching across {len(sites)} site(s)") try: # Search across all discovered sites foundDocuments = [] allSitesSearched = [] # Handle different search approaches based on search type if searchType == "folders" and fileQuery and fileQuery.strip() != "" and fileQuery.strip() != "*": # Use unified search for folders - this is global and searches all sites try: # Use Microsoft Graph Search API syntax (simple term search only) terms = [t for t in fileQuery.split() if t.strip()] if len(terms) > 1: # Multiple terms: search for ALL terms (AND) - more specific results queryString = " AND ".join(terms) else: # Single term: search for the term queryString = terms[0] if terms else fileQuery logger.info(f"Using unified search for folders: {queryString}") payload = { "requests": [ { "entityTypes": ["driveItem"], "query": {"queryString": queryString}, "from": 0, "size": 50 } ] } logger.info(f"Using unified search API for folders with queryString: {queryString}") # Use global search endpoint (site-specific search not available) unifiedResult = await self._makeGraphApiCall( "search/query", method="POST", data=json.dumps(payload).encode("utf-8") ) if "error" in unifiedResult: logger.warning(f"Unified search failed: {unifiedResult['error']}") items = [] else: # Flatten hits -> driveItem resources items = [] for container in (unifiedResult.get("value", []) or []): for hitsContainer in (container.get("hitsContainers", []) or []): for hit in (hitsContainer.get("hits", []) or []): resource = hit.get("resource") if resource: items.append(resource) logger.info(f"Unified search returned {len(items)} items (pre-filter)") # Apply our improved folder detection logic folderItems = [] for item in items: resource = item # Use the same detection logic as our test isFolder = self.services.sharepoint.detectFolderType(resource) if isFolder: folderItems.append(item) items = folderItems logger.info(f"Filtered to {len(items)} folders using improved detection logic") # Process unified search results - extract site information from webUrl for item in items: itemName = item.get("name", "") webUrl = item.get("webUrl", "") # Extract site information from webUrl siteName = "Unknown Site" siteId = "unknown" if webUrl and '/sites/' in webUrl: try: # Extract site name from URL: https://pcuster.sharepoint.com/sites/SiteName/... urlParts = webUrl.split('/sites/') if len(urlParts) > 1: sitePath = urlParts[1].split('/')[0] # Find matching site from discovered sites # First try to match by site name (URL path) for site in sites: if site.get("name") == sitePath: siteName = site.get("displayName", sitePath) siteId = site.get("id", "unknown") break else: # If no match by name, try to match by displayName for site in sites: if site.get("displayName") == sitePath: siteName = site.get("displayName", sitePath) siteId = site.get("id", "unknown") break else: # If no exact match, use the site path as site name siteName = sitePath # Try to find a site with similar name for site in sites: if sitePath.lower() in site.get("name", "").lower() or sitePath.lower() in site.get("displayName", "").lower(): siteName = site.get("displayName", sitePath) siteId = site.get("id", "unknown") break except Exception as e: logger.warning(f"Error extracting site info from URL {webUrl}: {e}") # Use improved folder detection logic isFolder = self.services.sharepoint.detectFolderType(item) itemType = "folder" if isFolder else "file" itemPath = item.get("parentReference", {}).get("path", "") logger.debug(f"Processing {itemType}: '{itemName}' at path: '{itemPath}'") # Simple filtering like test file - just check search type if searchType == "files" and isFolder: continue # Skip folders when searching for files elif searchType == "folders" and not isFolder: continue # Skip files when searching for folders # Simple approach like test file - no complex filtering logger.debug(f"Item '{itemName}' found - adding to results") # Create result with full path information for proper action chaining parentPath = item.get("parentReference", {}).get("path", "") # Extract the full SharePoint path from webUrl or parentReference fullPath = "" if webUrl: # Extract path from webUrl: https://pcuster.sharepoint.com/sites/SSSRESYNachfolge/Freigegebene%20Dokumente/General/Eskalation%20LogObject/Druckersteuerung if '/sites/' in webUrl: pathPart = webUrl.split('/sites/')[1] # Decode URL encoding and convert to backslash format decodedPath = urllib.parse.unquote(pathPart) fullPath = "\\" + decodedPath.replace('/', '\\') elif parentPath: # Use parentReference path if available fullPath = parentPath.replace('/', '\\') docInfo = { "id": item.get("id"), "name": item.get("name"), "type": "folder" if isFolder else "file", "siteName": siteName, "siteId": siteId, "webUrl": webUrl, "fullPath": fullPath, "parentPath": parentPath } foundDocuments.append(docInfo) logger.info(f"Found {len(foundDocuments)} documents from unified search") except Exception as e: logger.error(f"Error performing unified folder search: {str(e)}") # Fallback to site-by-site search pass # If no unified search was performed or it failed, fall back to site-by-site search if not foundDocuments: # Use simple approach like test file - no complex filtering siteScopedSites = sites for site in siteScopedSites: siteId = site["id"] siteName = site["displayName"] siteUrl = site["webUrl"] logger.info(f"Searching in site: {siteName} ({siteUrl})") # Check if pathQuery contains a specific folder path (not just /sites/SiteName) folderPath = None if pathQuery and pathQuery.startswith('/sites/'): parsedPath = self._extractSiteFromStandardPath(pathQuery) if parsedPath: innerPath = parsedPath.get("innerPath", "") if innerPath and innerPath.strip(): # Remove leading slash if present folderPath = innerPath.lstrip('/') # Generic approach: Try to find the folder, if it fails, remove first segment # This works for all languages because we test the actual API response # In SharePoint Graph API, /drive/root already points to the default document library, # so library names in paths should be removed pathSegments = [s for s in folderPath.split('/') if s.strip()] if len(pathSegments) > 1: # Try with first segment removed (first segment is likely the document library) testPath = '/'.join(pathSegments[1:]) # Quick test: try to get folder info (this is fast and doesn't require full search) testEndpoint = f"sites/{siteId}/drive/root:/{urllib.parse.quote(testPath, safe='')}:" testResult = await self._makeGraphApiCall(testEndpoint) if testResult and "error" not in testResult: # Path without first segment works - first segment was likely the document library folderPath = testPath logger.info(f"Removed document library name '{pathSegments[0]}' from folder path (tested via API)") else: # Keep original path - first segment is not a document library logger.info(f"Keeping original folder path '{folderPath}' (first segment is not a document library)") elif len(pathSegments) == 1: # Only one segment - likely the document library itself, use root folderPath = None logger.info(f"Only one segment '{pathSegments[0]}' found, likely document library - using root") if folderPath: logger.info(f"Extracted folder path from pathQuery: '{folderPath}'") else: logger.info(f"Folder path resolved to root (only document library in path)") # Use Microsoft Graph API for this specific site # Handle empty or wildcard queries if not fileQuery or fileQuery.strip() == "" or fileQuery.strip() == "*": # For wildcard/empty queries, list all items if folderPath: # List items in specific folder encodedPath = urllib.parse.quote(folderPath, safe='') endpoint = f"sites/{siteId}/drive/root:/{encodedPath}:/children" logger.info(f"Listing items in folder: '{folderPath}'") else: # List all items in the drive root endpoint = f"sites/{siteId}/drive/root/children" # Make the API call to list items listResult = await self._makeGraphApiCall(endpoint) if "error" in listResult: logger.warning(f"List failed for site {siteName}: {listResult['error']}") continue # Process list results for this site items = listResult.get("value", []) logger.info(f"Retrieved {len(items)} items from site {siteName}") else: # For files, use regular search API # Clean the query: remove path-like syntax and invalid KQL syntax searchQuery = self._cleanSearchQuery(fileQuery) # URL-encode the query parameter encodedQuery = urllib.parse.quote(searchQuery, safe='') if folderPath: # Search in specific folder encodedPath = urllib.parse.quote(folderPath, safe='') endpoint = f"sites/{siteId}/drive/root:/{encodedPath}:/search(q='{encodedQuery}')" logger.info(f"Searching in folder '{folderPath}' with query: '{searchQuery}' (encoded: '{encodedQuery}')") else: # Search in drive root endpoint = f"sites/{siteId}/drive/root/search(q='{encodedQuery}')" logger.info(f"Using search API for files with query: '{searchQuery}' (encoded: '{encodedQuery}')") # Make the search API call (files) searchResult = await self._makeGraphApiCall(endpoint) if "error" in searchResult: logger.warning(f"Search failed for site {siteName}: {searchResult['error']}") continue # Process search results for this site (files) items = searchResult.get("value", []) logger.info(f"Retrieved {len(items)} items from site {siteName}") siteDocuments = [] for item in items: itemName = item.get("name", "") # Use improved folder detection logic isFolder = self.services.sharepoint.detectFolderType(item) itemType = "folder" if isFolder else "file" itemPath = item.get("parentReference", {}).get("path", "") logger.debug(f"Processing {itemType}: '{itemName}' at path: '{itemPath}'") # Simple filtering like test file - just check search type if searchType == "files" and isFolder: continue # Skip folders when searching for files elif searchType == "folders" and not isFolder: continue # Skip files when searching for folders # Simple approach like test file - no complex filtering logger.debug(f"Item '{itemName}' found - adding to results") # Create result with full path information for proper action chaining webUrl = item.get("webUrl", "") parentPath = item.get("parentReference", {}).get("path", "") # Extract the full SharePoint path from webUrl or parentReference fullPath = "" if webUrl: # Extract path from webUrl: https://pcuster.sharepoint.com/sites/SSSRESYNachfolge/Freigegebene%20Dokumente/General/Eskalation%20LogObject/Druckersteuerung if '/sites/' in webUrl: pathPart = webUrl.split('/sites/')[1] # Decode URL encoding and convert to backslash format decodedPath = urllib.parse.unquote(pathPart) fullPath = "\\" + decodedPath.replace('/', '\\') elif parentPath: # Use parentReference path if available fullPath = parentPath.replace('/', '\\') docInfo = { "id": item.get("id"), "name": item.get("name"), "type": "folder" if isFolder else "file", "siteName": siteName, "siteId": siteId, "webUrl": webUrl, "fullPath": fullPath, "parentPath": parentPath } siteDocuments.append(docInfo) foundDocuments.extend(siteDocuments) allSitesSearched.append({ "siteName": siteName, "siteUrl": siteUrl, "siteId": siteId, "documentsFound": len(siteDocuments) }) logger.info(f"Found {len(siteDocuments)} documents in site {siteName}") # Limit total results to maxResults if len(foundDocuments) > maxResults: foundDocuments = foundDocuments[:maxResults] logger.info(f"Limited results to {maxResults} items") self.services.chat.progressLogUpdate(operationId, 0.9, f"Found {len(foundDocuments)} document(s)") resultData = { "searchQuery": searchQuery, "totalResults": len(foundDocuments), "maxResults": maxResults, "foundDocuments": foundDocuments, "timestamp": self.services.utils.timestampGetUtc() } except Exception as e: logger.error(f"Error searching SharePoint: {str(e)}") if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error=str(e)) # Use default JSON format for output outputExtension = ".json" # Default outputMimeType = "application/json" # Default validationMetadata = { "actionType": "sharepoint.findDocumentPath", "searchQuery": searchQuery, "maxResults": maxResults, "totalResults": len(foundDocuments), "hasResults": len(foundDocuments) > 0 } self.services.chat.progressLogFinish(operationId, True) return ActionResult( success=True, documents=[ ActionDocument( documentName=f"sharepoint_find_path_{self._format_timestamp_for_filename()}{outputExtension}", documentData=json.dumps(resultData, indent=2), mimeType=outputMimeType, validationMetadata=validationMetadata ) ] ) except Exception as e: logger.error(f"Error finding document path: {str(e)}") if operationId: try: self.services.chat.progressLogFinish(operationId, False) except: pass return ActionResult.isFailure(error=str(e)) @action async def readDocuments(self, parameters: Dict[str, Any]) -> ActionResult: """ GENERAL: - Purpose: Read documents from SharePoint and extract content/metadata. - Input requirements: connectionReference (required); documentList or pathQuery (required); includeMetadata (optional). - Output format: Standardized ActionDocument format (documentName, documentData, mimeType). - Binary files (PDFs, etc.) are Base64-encoded in documentData. - Text files are stored as plain text in documentData. - Returns ActionResult with documents list for template processing. Parameters: - connectionReference (str, required): Microsoft connection label. - documentList (list, optional): Document list reference(s) containing findDocumentPath result. - pathQuery (str, optional): Direct path query if no documentList (e.g., /sites/SiteName/FolderPath). - includeMetadata (bool, optional): Include metadata. Default: True. Returns: - ActionResult with documents: List[ActionDocument] where each ActionDocument contains: - documentName: File name - documentData: Base64-encoded content (binary files) or plain text (text files) - mimeType: MIME type (e.g., application/pdf, text/plain) """ import time operationId = None try: # Init progress logger workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}" operationId = f"sharepoint_read_{workflowId}_{int(time.time())}" # Start progress tracking parentOperationId = parameters.get('parentOperationId') self.services.chat.progressLogStart( operationId, "Read Documents", "SharePoint Document Reading", "Processing document list", parentOperationId=parentOperationId ) documentList = parameters.get("documentList") pathQuery = parameters.get("pathQuery", "*") connectionReference = parameters.get("connectionReference") includeMetadata = parameters.get("includeMetadata", True) # Validate connection reference if not connectionReference: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="Connection reference is required") # Require either documentList or pathQuery if not documentList and (not pathQuery or pathQuery.strip() == "" or pathQuery.strip() == "*"): if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="Either documentList or pathQuery is required") # Get connection first self.services.chat.progressLogUpdate(operationId, 0.2, "Getting Microsoft connection") connection = self._getMicrosoftConnection(connectionReference) if not connection: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="No valid Microsoft connection found for the provided connection reference") # Parse documentList to extract foundDocuments and site information sharePointFileIds = None sites = None if documentList: foundDocuments, sites, errorMsg = await self._parseDocumentListForFoundDocuments(documentList) if errorMsg: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error=errorMsg) if foundDocuments: # Extract SharePoint file IDs from foundDocuments sharePointFileIds = [doc.get("id") for doc in foundDocuments if doc.get("type") == "file"] if not sharePointFileIds: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="No files found in documentList from findDocumentPath result") logger.info(f"Extracted {len(sharePointFileIds)} SharePoint file IDs from documentList") # If we have SharePoint file IDs from documentList (findDocumentPath result), read them directly if sharePointFileIds and sites: # Read SharePoint files directly using their IDs readResults = [] siteId = sites[0]['id'] self.services.chat.progressLogUpdate(operationId, 0.5, f"Reading {len(sharePointFileIds)} file(s) from SharePoint") for idx, fileId in enumerate(sharePointFileIds): try: self.services.chat.progressLogUpdate(operationId, 0.5 + (idx * 0.3 / len(sharePointFileIds)), f"Reading file {idx + 1}/{len(sharePointFileIds)}") # Get file info from SharePoint endpoint = f"sites/{siteId}/drive/items/{fileId}" fileInfo = await self._makeGraphApiCall(endpoint) if "error" in fileInfo: logger.warning(f"Failed to get file info for {fileId}: {fileInfo['error']}") continue # Get file content using SharePoint service (handles binary data correctly) fileName = fileInfo.get("name", f"file_{fileId}") fileContent = await self.services.sharepoint.downloadFile(siteId, fileId) # Create result document resultItem = { "fileId": fileId, "fileName": fileName, "sharepointFileId": fileId, "siteName": sites[0]['displayName'], "siteUrl": sites[0]['webUrl'], "size": fileInfo.get("size", 0), "createdDateTime": fileInfo.get("createdDateTime"), "lastModifiedDateTime": fileInfo.get("lastModifiedDateTime"), "webUrl": fileInfo.get("webUrl") } # Add content if available if fileContent: resultItem["content"] = fileContent # Add metadata if requested if includeMetadata: resultItem["metadata"] = { "mimeType": fileInfo.get("file", {}).get("mimeType"), "downloadUrl": fileInfo.get("@microsoft.graph.downloadUrl"), "createdBy": fileInfo.get("createdBy", {}), "lastModifiedBy": fileInfo.get("lastModifiedBy", {}), "parentReference": fileInfo.get("parentReference", {}) } readResults.append(resultItem) except Exception as e: logger.error(f"Error reading file {fileId}: {str(e)}") continue if not readResults: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="No files could be read from documentList") # Convert read results to ActionDocument objects # IMPORTANT: For binary files (PDFs), store Base64-encoded content directly in documentData # The system will create FileData and ChatDocument automatically self.services.chat.progressLogUpdate(operationId, 0.8, f"Processing {len(readResults)} document(s)") from modules.datamodels.datamodelChat import ActionDocument import base64 actionDocuments = [] for resultItem in readResults: fileContent = resultItem.get("content") fileName = resultItem.get("fileName", f"file_{resultItem.get('fileId')}") # Determine MIME type from metadata or file extension mimeType = "application/octet-stream" if resultItem.get("metadata", {}).get("mimeType"): mimeType = resultItem["metadata"]["mimeType"] elif fileName: if fileName.endswith('.pdf'): mimeType = "application/pdf" elif fileName.endswith('.txt'): mimeType = "text/plain" elif fileName.endswith('.json'): mimeType = "application/json" # For binary files (PDFs, etc.), store Base64-encoded content directly # The GenerationService will detect PDF mimeType and handle base64 decoding if fileContent and isinstance(fileContent, bytes): # Encode binary content as Base64 string base64Content = base64.b64encode(fileContent).decode('utf-8') validationMetadata = { "actionType": "sharepoint.readDocuments", "fileName": fileName, "sharepointFileId": resultItem.get("sharepointFileId"), "siteName": resultItem.get("siteName"), "mimeType": mimeType, "contentType": "binary", "size": len(fileContent), "includeMetadata": includeMetadata } actionDoc = ActionDocument( documentName=fileName, documentData=base64Content, # Base64 string for binary files mimeType=mimeType, validationMetadata=validationMetadata ) actionDocuments.append(actionDoc) logger.info(f"Stored binary file {fileName} ({len(fileContent)} bytes) as Base64 in ActionDocument") elif fileContent: # Text content - store directly in documentData validationMetadata = { "actionType": "sharepoint.readDocuments", "fileName": fileName, "sharepointFileId": resultItem.get("sharepointFileId"), "siteName": resultItem.get("siteName"), "mimeType": mimeType, "contentType": "text", "includeMetadata": includeMetadata } actionDoc = ActionDocument( documentName=fileName, documentData=fileContent if isinstance(fileContent, str) else str(fileContent), mimeType=mimeType, validationMetadata=validationMetadata ) actionDocuments.append(actionDoc) else: # No content - store metadata only docData = { "fileName": fileName, "sharepointFileId": resultItem.get("sharepointFileId"), "siteName": resultItem.get("siteName"), "siteUrl": resultItem.get("siteUrl"), "size": resultItem.get("size"), "createdDateTime": resultItem.get("createdDateTime"), "lastModifiedDateTime": resultItem.get("lastModifiedDateTime"), "webUrl": resultItem.get("webUrl") } if resultItem.get("metadata"): docData["metadata"] = resultItem["metadata"] validationMetadata = { "actionType": "sharepoint.readDocuments", "fileName": fileName, "sharepointFileId": resultItem.get("sharepointFileId"), "siteName": resultItem.get("siteName"), "mimeType": mimeType, "contentType": "metadata_only", "includeMetadata": includeMetadata } actionDoc = ActionDocument( documentName=fileName, documentData=json.dumps(docData, indent=2), mimeType=mimeType, validationMetadata=validationMetadata ) actionDocuments.append(actionDoc) # Return success with action documents self.services.chat.progressLogUpdate(operationId, 0.9, f"Read {len(actionDocuments)} document(s)") self.services.chat.progressLogFinish(operationId, True) return ActionResult.isSuccess(documents=actionDocuments) # If no sites from documentList, try pathQuery fallback if not sites and pathQuery and pathQuery.strip() != "" and pathQuery.strip() != "*": sites, errorMsg = await self._resolveSitesFromPathQuery(pathQuery) if errorMsg: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error=errorMsg) # If still no sites, return error if not sites: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="Either documentList must contain findDocumentPath result with file information, or pathQuery must be provided. Use findDocumentPath first to get file paths, or provide pathQuery directly.") # This should never be reached if logic above is correct if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="Unexpected error: could not process documentList or pathQuery") except Exception as e: logger.error(f"Error reading SharePoint documents: {str(e)}") if operationId: try: self.services.chat.progressLogFinish(operationId, False) except: pass # Don't fail on progress logging errors return ActionResult( success=False, error=str(e) ) @action async def uploadDocument(self, parameters: Dict[str, Any]) -> ActionResult: """ GENERAL: - Purpose: Upload documents to SharePoint. Only to choose this action with a connectionReference - Input requirements: connectionReference (required); documentList (required); pathQuery (optional). - Output format: JSON with upload status and file info. Parameters: - connectionReference (str, required): Microsoft connection label. - documentList (list, required): Document reference(s) to upload. File names are taken from the documents. - pathQuery (str, optional): Direct upload target path if documentList doesn't contain findDocumentPath result (e.g., /sites/SiteName/FolderPath). """ import time operationId = None try: # Init progress logger workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}" operationId = f"sharepoint_upload_{workflowId}_{int(time.time())}" # Start progress tracking parentOperationId = parameters.get('parentOperationId') self.services.chat.progressLogStart( operationId, "Upload Document", "SharePoint Upload", "Processing document list", parentOperationId=parentOperationId ) connectionReference = parameters.get("connectionReference") documentList = parameters.get("documentList") pathQuery = parameters.get("pathQuery") if isinstance(documentList, str): documentList = [documentList] if not connectionReference: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="Connection reference is required") if not documentList: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="Document list is required") # Parse documentList to extract folder path and site information uploadPath, sites, filesToUpload, errorMsg = await self._parseDocumentListForFolder(documentList) if errorMsg: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error=errorMsg) # If no folder path found from documentList, use pathQuery if provided if not uploadPath and pathQuery and pathQuery.strip() != "" and pathQuery.strip() != "*": uploadPath = pathQuery logger.info(f"Using pathQuery for upload path: {uploadPath}") # Resolve sites from pathQuery sites, errorMsg = await self._resolveSitesFromPathQuery(pathQuery) if errorMsg: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error=errorMsg) # Validate required parameters if not uploadPath: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="Either documentList must contain findDocumentPath result with folder information, or pathQuery must be provided. Use findDocumentPath first to get upload folder, or provide pathQuery directly.") if not sites: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="Site information missing. Cannot determine target site for upload.") if not filesToUpload: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="No files to upload found in documentList.") # Get connection self.services.chat.progressLogUpdate(operationId, 0.3, "Getting Microsoft connection") connection = self._getMicrosoftConnection(connectionReference) if not connection: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="No valid Microsoft connection found for the provided connection reference") # Process upload paths uploadPaths = [] if uploadPath.startswith('01PPXICCB') or uploadPath.startswith('01'): # It's a folder ID - use it directly uploadPaths = [uploadPath] logger.info(f"Using folder ID directly for upload: {uploadPath}") else: # It's a path - resolve it normally uploadPaths = self._resolvePathQuery(uploadPath) # Process each document upload uploadResults = [] # Extract file names from documents fileNames = [doc.fileName for doc in filesToUpload] logger.info(f"Using file names from documentList: {fileNames}") self.services.chat.progressLogUpdate(operationId, 0.5, f"Uploading {len(filesToUpload)} document(s)") # Process upload paths # Process each document upload uploadResults = [] # Extract file names from documents fileNames = [doc.fileName for doc in filesToUpload] logger.info(f"Using file names from documentList: {fileNames}") self.services.chat.progressLogUpdate(operationId, 0.5, f"Uploading {len(filesToUpload)} document(s)") for i, (chatDocument, fileName) in enumerate(zip(filesToUpload, fileNames)): try: fileId = chatDocument.fileId fileData = self.services.chat.getFileData(fileId) if not fileData: logger.warning(f"File data not found for fileId: {fileId}") uploadResults.append({ "fileName": fileName, "fileId": fileId, "error": "File data not found", "uploadStatus": "failed" }) continue # Upload to the first available site (or could be made configurable) uploadSuccessful = False for site in sites: siteId = site["id"] siteName = site["displayName"] siteUrl = site["webUrl"] # Use the first upload path or default to Documents uploadPath = uploadPaths[0] if uploadPaths else "/Documents" # Handle wildcard paths - replace with default Documents folder if uploadPath == "*": uploadPath = "/Documents" logger.warning(f"Wildcard path '*' detected, using default '/Documents' folder for upload") # Check if uploadPath is a folder ID or a regular path if uploadPath.startswith('01PPXICCB') or uploadPath.startswith('01'): # It's a folder ID - use the folder-specific upload endpoint uploadEndpoint = f"sites/{siteId}/drive/items/{uploadPath}:/{fileName}:/content" logger.info(f"Using folder ID upload endpoint: {uploadEndpoint}") else: # It's a regular path - use the root-based upload endpoint uploadPath = uploadPath.rstrip('/') + '/' + fileName uploadPathClean = uploadPath.lstrip('/') uploadEndpoint = f"sites/{siteId}/drive/root:/{uploadPathClean}:/content" logger.info(f"Using path-based upload endpoint: {uploadEndpoint}") # Upload endpoint for small files (< 4MB) if len(fileData) < 4 * 1024 * 1024: # 4MB # Upload the file uploadResult = await self._makeGraphApiCall( uploadEndpoint, method="PUT", data=fileData ) if "error" not in uploadResult: uploadResults.append({ "fileName": fileName, "fileId": fileId, "uploadStatus": "success", "siteName": siteName, "siteUrl": siteUrl, "uploadPath": uploadPath, "uploadEndpoint": uploadEndpoint, "sharepointFileId": uploadResult.get("id"), "webUrl": uploadResult.get("webUrl"), "size": uploadResult.get("size"), "createdDateTime": uploadResult.get("createdDateTime") }) uploadSuccessful = True break else: logger.warning(f"Upload failed to site {siteName}: {uploadResult['error']}") else: # For large files, we would need to implement resumable upload logger.warning(f"File too large ({len(fileData)} bytes) for site {siteName}") continue if not uploadSuccessful: uploadResults.append({ "fileName": fileName, "fileId": fileId, "error": f"File too large ({len(fileData)} bytes) or upload failed to all sites. Files larger than 4MB require resumable upload (not implemented).", "uploadStatus": "failed" }) except Exception as e: logger.error(f"Error uploading document {fileName}: {str(e)}") uploadResults.append({ "fileName": fileName, "fileId": fileId, "error": str(e), "uploadStatus": "failed" }) # Update progress for each file self.services.chat.progressLogUpdate(operationId, 0.5 + (i * 0.4 / len(filesToUpload)), f"Uploaded {i + 1}/{len(filesToUpload)} file(s)") # Create result data resultData = { "connectionReference": connectionReference, "uploadPath": uploadPath, "documentList": documentList, "fileNames": fileNames, "sitesAvailable": len(sites), "uploadResults": uploadResults, "connection": { "id": connection["id"], "authority": "microsoft", "reference": connectionReference }, "timestamp": self.services.utils.timestampGetUtc() } # Use default JSON format for output outputExtension = ".json" # Default outputMimeType = "application/json" # Default validationMetadata = { "actionType": "sharepoint.uploadDocument", "connectionReference": connectionReference, "uploadPath": uploadPath, "fileNames": fileNames, "uploadCount": len(uploadResults), "successfulUploads": len([r for r in uploadResults if r.get("uploadStatus") == "success"]), "failedUploads": len([r for r in uploadResults if r.get("uploadStatus") == "failed"]) } successfulUploads = len([r for r in uploadResults if r.get("uploadStatus") == "success"]) self.services.chat.progressLogUpdate(operationId, 0.9, f"Uploaded {successfulUploads}/{len(uploadResults)} file(s)") self.services.chat.progressLogFinish(operationId, successfulUploads > 0) return ActionResult( success=True, documents=[ ActionDocument( documentName=f"sharepoint_upload_{self._format_timestamp_for_filename()}{outputExtension}", documentData=json.dumps(resultData, indent=2), mimeType=outputMimeType, validationMetadata=validationMetadata ) ] ) except Exception as e: logger.error(f"Error uploading to SharePoint: {str(e)}") if operationId: try: self.services.chat.progressLogFinish(operationId, False) except: pass return ActionResult( success=False, error=str(e) ) @action async def listDocuments(self, parameters: Dict[str, Any]) -> ActionResult: """ GENERAL: - Purpose: List documents and folders in SharePoint paths across sites. - Input requirements: connectionReference (required); documentList (required); includeSubfolders (optional). - Output format: JSON with folder items and metadata. Parameters: - connectionReference (str, required): Microsoft connection label. - documentList (list, required): Document list reference(s) containing findDocumentPath result. - includeSubfolders (bool, optional): Include one level of subfolders. Default: False. """ import time operationId = None try: # Init progress logger workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}" operationId = f"sharepoint_list_{workflowId}_{int(time.time())}" # Start progress tracking parentOperationId = parameters.get('parentOperationId') self.services.chat.progressLogStart( operationId, "List Documents", "SharePoint Listing", "Processing document list", parentOperationId=parentOperationId ) connectionReference = parameters.get("connectionReference") documentList = parameters.get("documentList") pathQuery = parameters.get("pathQuery", "*") if isinstance(documentList, str): documentList = [documentList] includeSubfolders = parameters.get("includeSubfolders", False) # Default to False for better UX if not connectionReference: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="Connection reference is required") # Require either documentList or pathQuery if not documentList and (not pathQuery or pathQuery.strip() == "" or pathQuery.strip() == "*"): if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="Either documentList or pathQuery is required") # Parse documentList to extract folder path and site information listQuery, sites, _, errorMsg = await self._parseDocumentListForFolder(documentList) if errorMsg: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error=errorMsg) # If no folder path found from documentList, use pathQuery if provided if not listQuery and pathQuery and pathQuery.strip() != "" and pathQuery.strip() != "*": listQuery = pathQuery logger.info(f"Using pathQuery for list query: {listQuery}") # Resolve sites from pathQuery sites, errorMsg = await self._resolveSitesFromPathQuery(pathQuery) if errorMsg: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error=errorMsg) # Validate required parameters if not listQuery: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="Either documentList must contain findDocumentPath result with folder information, or pathQuery must be provided. Use findDocumentPath first to get folder path, or provide pathQuery directly.") if not sites: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="Site information missing. Cannot determine target site for list operation.") # Get connection self.services.chat.progressLogUpdate(operationId, 0.2, "Getting Microsoft connection") connection = self._getMicrosoftConnection(connectionReference) if not connection: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="No valid Microsoft connection found for the provided connection reference") logger.info(f"Starting SharePoint listDocuments for listQuery: {listQuery}") logger.debug(f"Connection ID: {connection['id']}") self.services.chat.progressLogUpdate(operationId, 0.3, "Processing folder path") # Parse listQuery to extract path, search terms, search type, and options pathQuery, fileQuery, searchType, searchOptions = self._parseSearchQuery(listQuery) # Check if listQuery is a folder ID (starts with 01PPXICCB...) if listQuery.startswith('01PPXICCB') or listQuery.startswith('01'): # Direct folder ID - use it directly folderPaths = [listQuery] logger.info(f"Using direct folder ID: {listQuery}") else: # Remove site prefix from pathQuery before resolving (it's only for site filtering) pathQueryForResolve = pathQuery # Microsoft-standard path: /sites/SiteName/Path -> /Path if pathQuery.startswith('/sites/'): parsedPath = self._extractSiteFromStandardPath(pathQuery) if parsedPath: innerPath = parsedPath.get("innerPath", "") pathQueryForResolve = '/' + innerPath if innerPath else '/' else: pathQueryForResolve = '/' # Remove first path segment if it looks like a document library name # In SharePoint Graph API, /drive/root already points to the default document library, # so library names in paths should be removed # Generic approach: if path has multiple segments, store original for fallback pathSegments = [s for s in pathQueryForResolve.split('/') if s.strip()] if len(pathSegments) > 1: # Path has multiple segments - first might be a library name # Store original for potential fallback originalPath = pathQueryForResolve # Try without first segment (assuming it's a library name) pathQueryForResolve = '/' + '/'.join(pathSegments[1:]) logger.info(f"Removed first path segment (potential library name), path changed from '{originalPath}' to '{pathQueryForResolve}'") elif len(pathSegments) == 1: # Only one segment - if it's a common library-like name, use root firstSegmentLower = pathSegments[0].lower() libraryIndicators = ['document', 'dokument', 'shared', 'freigegeben', 'library', 'bibliothek'] if any(indicator in firstSegmentLower for indicator in libraryIndicators): pathQueryForResolve = '/' logger.info(f"First segment '{pathSegments[0]}' appears to be a library name, using root") # Resolve path query into folder paths folderPaths = self._resolvePathQuery(pathQueryForResolve) logger.info(f"Resolved folder paths: {folderPaths}") # Process each folder path across all sites listResults = [] self.services.chat.progressLogUpdate(operationId, 0.5, f"Listing {len(folderPaths)} folder(s) across {len(sites)} site(s)") for folderPath in folderPaths: try: folderResults = [] for site in sites: siteId = site["id"] siteName = site["displayName"] siteUrl = site["webUrl"] logger.info(f"Listing folder {folderPath} in site: {siteName}") # Determine the endpoint based on folder path if folderPath in ["/", ""] or folderPath == "*": # Root folder endpoint = f"sites/{siteId}/drive/root/children" elif folderPath.startswith('01PPXICCB') or folderPath.startswith('01'): # Direct folder ID endpoint = f"sites/{siteId}/drive/items/{folderPath}/children" else: # Specific folder path - remove leading slash if present and URL encode folderPathClean = folderPath.lstrip('/') # URL encode the path for Graph API (spaces and special characters need encoding) folderPathEncoded = urllib.parse.quote(folderPathClean, safe='/') endpoint = f"sites/{siteId}/drive/root:/{folderPathEncoded}:/children" # Make the API call to list folder contents apiResult = await self._makeGraphApiCall(endpoint) if "error" in apiResult: logger.warning(f"Failed to list folder {folderPath} in site {siteName}: {apiResult['error']}") continue # Process the results items = apiResult.get("value", []) processedItems = [] for item in items: # Use improved folder detection logic isFolder = self.services.sharepoint.detectFolderType(item) itemInfo = { "id": item.get("id"), "name": item.get("name"), "size": item.get("size", 0), "createdDateTime": item.get("createdDateTime"), "lastModifiedDateTime": item.get("lastModifiedDateTime"), "webUrl": item.get("webUrl"), "type": "folder" if isFolder else "file", "siteName": siteName, "siteUrl": siteUrl } # Add file-specific information if "file" in item: itemInfo.update({ "mimeType": item["file"].get("mimeType"), "downloadUrl": item.get("@microsoft.graph.downloadUrl") }) # Add folder-specific information if "folder" in item: itemInfo.update({ "childCount": item["folder"].get("childCount", 0) }) processedItems.append(itemInfo) # If include subfolders is enabled, get ONLY direct subfolder contents (1 level deep only) if includeSubfolders: folderItems = [item for item in processedItems if item['type'] == 'folder'] logger.info(f"Including subfolders - processing {len(folderItems)} folders") subfolderCount = 0 maxSubfolders = 10 # Limit to prevent infinite loops for item in processedItems[:]: # Use slice to avoid modifying list during iteration if item["type"] == "folder" and subfolderCount < maxSubfolders: subfolderCount += 1 subfolderPath = f"{folderPath.rstrip('/')}/{item['name']}" subfolderEndpoint = f"sites/{siteId}/drive/items/{item['id']}/children" logger.debug(f"Getting contents of subfolder: {item['name']}") subfolderResult = await self._makeGraphApiCall(subfolderEndpoint) if "error" not in subfolderResult: subfolderItems = subfolderResult.get("value", []) logger.debug(f"Found {len(subfolderItems)} items in subfolder {item['name']}") for subfolderItem in subfolderItems: # Use improved folder detection logic for subfolder items subfolderIsFolder = self.services.sharepoint.detectFolderType(subfolderItem) # Only add files and direct subfolders, NO RECURSION subfolderItemInfo = { "id": subfolderItem.get("id"), "name": subfolderItem.get("name"), "size": subfolderItem.get("size", 0), "createdDateTime": subfolderItem.get("createdDateTime"), "lastModifiedDateTime": subfolderItem.get("lastModifiedDateTime"), "webUrl": subfolderItem.get("webUrl"), "type": "folder" if subfolderIsFolder else "file", "parentPath": subfolderPath, "siteName": siteName, "siteUrl": siteUrl } if "file" in subfolderItem: subfolderItemInfo.update({ "mimeType": subfolderItem["file"].get("mimeType"), "downloadUrl": subfolderItem.get("@microsoft.graph.downloadUrl") }) processedItems.append(subfolderItemInfo) else: logger.warning(f"Failed to get contents of subfolder {item['name']}: {subfolderResult.get('error')}") elif subfolderCount >= maxSubfolders: logger.warning(f"Reached maximum subfolder limit ({maxSubfolders}), skipping remaining folders") break logger.info(f"Processed {subfolderCount} subfolders, total items: {len(processedItems)}") folderResults.append({ "siteName": siteName, "siteUrl": siteUrl, "itemCount": len(processedItems), "items": processedItems }) listResults.append({ "folderPath": folderPath, "sitesProcessed": len(folderResults), "siteResults": folderResults }) except Exception as e: logger.error(f"Error listing folder {folderPath}: {str(e)}") listResults.append({ "folderPath": folderPath, "error": str(e), "siteResults": [] }) totalItems = sum(len(result.get("siteResults", [])) for result in listResults) self.services.chat.progressLogUpdate(operationId, 0.9, f"Found {totalItems} item(s)") # Create result data resultData = { "pathQuery": listQuery, "includeSubfolders": includeSubfolders, "sitesSearched": len(sites), "listResults": listResults, "timestamp": self.services.utils.timestampGetUtc() } # Use default JSON format for output outputExtension = ".json" # Default outputMimeType = "application/json" # Default validationMetadata = { "actionType": "sharepoint.listDocuments", "pathQuery": listQuery, "includeSubfolders": includeSubfolders, "sitesSearched": len(sites), "folderCount": len(listResults), "totalItems": totalItems } self.services.chat.progressLogFinish(operationId, True) return ActionResult( success=True, documents=[ ActionDocument( documentName=f"sharepoint_document_list_{self._format_timestamp_for_filename()}{outputExtension}", documentData=json.dumps(resultData, indent=2), mimeType=outputMimeType, validationMetadata=validationMetadata ) ] ) except Exception as e: logger.error(f"Error listing SharePoint documents: {str(e)}") if operationId: try: self.services.chat.progressLogFinish(operationId, False) except: pass return ActionResult( success=False, error=str(e) ) @action async def analyzeFolderUsage(self, parameters: Dict[str, Any]) -> ActionResult: """ GENERAL: - Purpose: Analyze usage intensity of folders and files in SharePoint. - Input requirements: connectionReference (required); documentList (required); optional startDateTime, endDateTime, interval. - Output format: JSON with usage analytics grouped by time intervals. Parameters: - connectionReference (str, required): Microsoft connection label. - documentList (list, required): Document list reference(s) containing findDocumentPath result. - startDateTime (str, optional): Start date/time in ISO format (e.g., "2025-11-01T00:00:00Z"). Default: 30 days ago. - endDateTime (str, optional): End date/time in ISO format (e.g., "2025-11-30T23:59:59Z"). Default: current time. - interval (str, optional): Time interval for grouping activities. Options: "day", "week", "month". Default: "day". """ import time operationId = None try: # Init progress logger workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}" operationId = f"sharepoint_usage_{workflowId}_{int(time.time())}" # Start progress tracking parentOperationId = parameters.get('parentOperationId') self.services.chat.progressLogStart( operationId, "Analyze Folder Usage", "SharePoint Analytics", "Processing document list", parentOperationId=parentOperationId ) connectionReference = parameters.get("connectionReference") documentList = parameters.get("documentList") pathQuery = parameters.get("pathQuery") if isinstance(documentList, str): documentList = [documentList] startDateTime = parameters.get("startDateTime") endDateTime = parameters.get("endDateTime") interval = parameters.get("interval", "day") if not connectionReference: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="Connection reference is required") # Require either documentList or pathQuery if not documentList and (not pathQuery or pathQuery.strip() == "" or pathQuery.strip() == "*"): if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="Either documentList or pathQuery is required") # Resolve folder/item information from documentList or pathQuery siteId = None driveId = None itemId = None folderPath = None folderName = None if documentList: foundDocuments, sites, errorMsg = await self._parseDocumentListForFoundDocuments(documentList) if errorMsg: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error=errorMsg) if not foundDocuments: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="No documents found in documentList") # Get siteId from first document (all should be from same site) firstItem = foundDocuments[0] siteId = firstItem.get("siteId") if not siteId: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="Site ID missing from documentList") # Get drive ID (needed for analytics) driveId = await self.services.sharepoint.getDriveId(siteId) if not driveId: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="Could not determine drive ID for the site") # If no items from documentList, try pathQuery fallback if not foundDocuments and pathQuery and pathQuery.strip() != "" and pathQuery.strip() != "*": sites, errorMsg = await self._resolveSitesFromPathQuery(pathQuery) if errorMsg: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error=errorMsg) if sites: siteId = sites[0].get("id") # Parse pathQuery to find the folder/item pathQueryParsed, fileQuery, searchType, searchOptions = self._parseSearchQuery(pathQuery) # Extract folder path from pathQuery folderPath = '/' if pathQueryParsed and pathQueryParsed.startswith('/sites/'): parsedPath = self._extractSiteFromStandardPath(pathQueryParsed) if parsedPath: innerPath = parsedPath.get("innerPath", "") folderPath = '/' + innerPath if innerPath else '/' elif pathQueryParsed: folderPath = pathQueryParsed # Get drive ID driveId = await self.services.sharepoint.getDriveId(siteId) if not driveId: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="Could not determine drive ID for the site") # Get folder/item by path folderInfo = await self.services.sharepoint.getFolderByPath(siteId, folderPath.lstrip('/')) if not folderInfo: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error=f"Folder or file not found at path: {folderPath}") # Add pathQuery item to foundDocuments for processing foundDocuments = [{ "id": folderInfo.get("id"), "name": folderInfo.get("name", ""), "type": "folder" if folderInfo.get("folder") else "file", "siteId": siteId, "fullPath": folderPath, "webUrl": folderInfo.get("webUrl", "") }] if not siteId or not driveId: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="Either documentList must contain findDocumentPath result with folder information, or pathQuery must be provided. Use findDocumentPath first to get folder path, or provide pathQuery directly.") self.services.chat.progressLogUpdate(operationId, 0.2, "Getting Microsoft connection") # Get Microsoft connection connection = self._getMicrosoftConnection(connectionReference) if not connection: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="No valid Microsoft connection found for the provided connection reference") # Set access token if not self.services.sharepoint.setAccessTokenFromConnection(connection): if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="Failed to set SharePoint access token") # Process all items from documentList or pathQuery # IMPORTANT: Only analyze FOLDERS, not files (action is "analyzeFolderUsage") itemsToAnalyze = [] if foundDocuments: for item in foundDocuments: itemId = item.get("id") itemType = item.get("type", "").lower() # Only process folders, skip files and site-level items if itemId and itemType == "folder": itemsToAnalyze.append({ "id": itemId, "name": item.get("name", ""), "type": itemType, "path": item.get("fullPath", ""), "webUrl": item.get("webUrl", "") }) if not itemsToAnalyze: if operationId: self.services.chat.progressLogFinish(operationId, False) return ActionResult.isFailure(error="No valid folders found in documentList to analyze. Note: This action only analyzes folders, not files.") self.services.chat.progressLogUpdate(operationId, 0.4, f"Analyzing {len(itemsToAnalyze)} folder(s)") # Analyze each item allAnalytics = [] totalActivities = 0 uniqueUsers = set() activityTypes = {} # Compute actual date range values (getFolderUsageAnalytics will set defaults if None) # We need to compute them here to store in output, since getFolderUsageAnalytics modifies them actualStartDateTime = startDateTime actualEndDateTime = endDateTime if not actualEndDateTime: actualEndDateTime = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z') if not actualStartDateTime: startDate = datetime.now(timezone.utc) - timedelta(days=30) actualStartDateTime = startDate.isoformat().replace('+00:00', 'Z') for idx, item in enumerate(itemsToAnalyze): progress = 0.4 + (idx / len(itemsToAnalyze)) * 0.5 self.services.chat.progressLogUpdate(operationId, progress, f"Analyzing folder {item['name']} ({idx+1}/{len(itemsToAnalyze)})") # Get usage analytics for this folder analyticsResult = await self.services.sharepoint.getFolderUsageAnalytics( siteId=siteId, driveId=driveId, itemId=item["id"], startDateTime=startDateTime, endDateTime=endDateTime, interval=interval ) if "error" in analyticsResult: logger.warning(f"Failed to get analytics for item {item['name']} ({item['id']}): {analyticsResult['error']}") # Continue with other items even if one fails itemAnalytics = { "itemId": item["id"], "itemName": item["name"], "itemType": item["type"], "itemPath": item["path"], "error": analyticsResult.get("error", "Unknown error") } else: # Process analytics for this item itemActivities = 0 itemUsers = set() itemActivityTypes = {} if "value" in analyticsResult: for intervalData in analyticsResult["value"]: activities = intervalData.get("activities", []) for activity in activities: itemActivities += 1 totalActivities += 1 action = activity.get("action", {}) actionType = action.get("verb", "unknown") itemActivityTypes[actionType] = itemActivityTypes.get(actionType, 0) + 1 activityTypes[actionType] = activityTypes.get(actionType, 0) + 1 actor = activity.get("actor", {}) userPrincipalName = actor.get("userPrincipalName", "") if userPrincipalName: itemUsers.add(userPrincipalName) uniqueUsers.add(userPrincipalName) itemAnalytics = { "itemId": item["id"], "itemName": item["name"], "itemType": item["type"], "itemPath": item["path"], "webUrl": item["webUrl"], "analytics": analyticsResult, "summary": { "totalActivities": itemActivities, "uniqueUsers": len(itemUsers), "activityTypes": itemActivityTypes } } # Include note if analytics are not available if "note" in analyticsResult: itemAnalytics["note"] = analyticsResult["note"] allAnalytics.append(itemAnalytics) self.services.chat.progressLogUpdate(operationId, 0.9, "Processing analytics data") # Process and format analytics data resultData = { "siteId": siteId, "driveId": driveId, "startDateTime": actualStartDateTime, # Store computed date range (not None) "endDateTime": actualEndDateTime, # Store computed date range (not None) "interval": interval, "itemsAnalyzed": len(itemsToAnalyze), "foldersAnalyzed": len([item for item in allAnalytics if item.get("itemType") == "folder"]), "items": allAnalytics, "summary": { "totalActivities": totalActivities, "uniqueUsers": len(uniqueUsers), "activityTypes": activityTypes }, "note": f"Analyzed {len(itemsToAnalyze)} folder(s) from {actualStartDateTime} to {actualEndDateTime}. " + f"Found {totalActivities} total activities across {len(uniqueUsers)} unique user(s)." + (f" Note: {len([item for item in allAnalytics if 'error' in item])} folder(s) had errors or no analytics data available." if any('error' in item for item in allAnalytics) else ""), "timestamp": self.services.utils.timestampGetUtc() } self.services.chat.progressLogUpdate(operationId, 0.95, f"Found {totalActivities} total activities across {len(itemsToAnalyze)} folder(s)") validationMetadata = { "actionType": "sharepoint.analyzeFolderUsage", "itemsAnalyzed": len(itemsToAnalyze), "interval": interval, "totalActivities": totalActivities, "uniqueUsers": len(uniqueUsers) } self.services.chat.progressLogFinish(operationId, True) return ActionResult( success=True, documents=[ ActionDocument( documentName=f"sharepoint_usage_analysis_{self._format_timestamp_for_filename()}.json", documentData=json.dumps(resultData, indent=2), mimeType="application/json", validationMetadata=validationMetadata ) ] ) except Exception as e: logger.error(f"Error analyzing folder usage: {str(e)}") if operationId: try: self.services.chat.progressLogFinish(operationId, False) except: pass return ActionResult( success=False, error=str(e) ) @action async def findSiteByUrl(self, parameters: Dict[str, Any]) -> ActionResult: """ Find SharePoint site by hostname and site path. Parameters: - connectionReference (str, required): Microsoft connection label. - hostname (str, required): SharePoint hostname (e.g., "example.sharepoint.com") - sitePath (str, required): Site path (e.g., "SteeringBPM" or "/sites/SteeringBPM") Returns: - ActionResult with ActionDocument containing site information (id, displayName, name, webUrl) """ try: connectionReference = parameters.get("connectionReference") if not connectionReference: return ActionResult.isFailure(error="connectionReference parameter is required") hostname = parameters.get("hostname") if not hostname: return ActionResult.isFailure(error="hostname parameter is required") sitePath = parameters.get("sitePath") if not sitePath: return ActionResult.isFailure(error="sitePath parameter is required") # Get Microsoft connection connection = self._getMicrosoftConnection(connectionReference) if not connection: return ActionResult.isFailure(error="No valid Microsoft connection found for the provided connection reference") # Find site by URL siteInfo = await self.services.sharepoint.findSiteByUrl( hostname=hostname, sitePath=sitePath ) if not siteInfo: return ActionResult.isFailure(error=f"Site not found: {hostname}:/sites/{sitePath}") logger.info(f"Found SharePoint site: {siteInfo.get('displayName')} (ID: {siteInfo.get('id')})") # Generate filename workflowContext = self.services.chat.getWorkflowContext() if hasattr(self.services, 'chat') else None filename = self._generateMeaningfulFileName( "sharepoint_site", "json", workflowContext, "findSiteByUrl" ) validationMetadata = self._createValidationMetadata( "findSiteByUrl", hostname=hostname, sitePath=sitePath, siteId=siteInfo.get("id") ) document = ActionDocument( documentName=filename, documentData=json.dumps(siteInfo, indent=2), mimeType="application/json", validationMetadata=validationMetadata ) return ActionResult.isSuccess(documents=[document]) except Exception as e: errorMsg = f"Error finding SharePoint site: {str(e)}" logger.error(errorMsg) return ActionResult.isFailure(error=errorMsg) @action async def downloadFileByPath(self, parameters: Dict[str, Any]) -> ActionResult: """ Download file from SharePoint by exact file path. Parameters: - connectionReference (str, required): Microsoft connection label. - siteId (str, required): SharePoint site ID (from findSiteByUrl result) or document reference containing site info - filePath (str, required): Full file path relative to site root (e.g., "/General/50 Docs hosted by SELISE/file.xlsx") Returns: - ActionResult with ActionDocument containing file content as base64-encoded bytes """ try: connectionReference = parameters.get("connectionReference") if not connectionReference: return ActionResult.isFailure(error="connectionReference parameter is required") siteIdParam = parameters.get("siteId") if not siteIdParam: return ActionResult.isFailure(error="siteId parameter is required") filePath = parameters.get("filePath") if not filePath: return ActionResult.isFailure(error="filePath parameter is required") # Extract siteId from document if it's a reference siteId = None if isinstance(siteIdParam, str): # Try to parse from document reference from modules.datamodels.datamodelDocref import DocumentReferenceList try: docList = DocumentReferenceList.from_string_list([siteIdParam]) chatDocuments = self.services.chat.getChatDocumentsFromDocumentList(docList) if chatDocuments and len(chatDocuments) > 0: siteInfoJson = json.loads(chatDocuments[0].documentData) siteId = siteInfoJson.get("id") except: pass if not siteId: # Assume it's the site ID directly siteId = siteIdParam else: siteId = siteIdParam if not siteId: return ActionResult.isFailure(error="Could not extract siteId from parameter") # Get Microsoft connection connection = self._getMicrosoftConnection(connectionReference) if not connection: return ActionResult.isFailure(error="No valid Microsoft connection found for the provided connection reference") # Download file fileContent = await self.services.sharepoint.downloadFileByPath( siteId=siteId, filePath=filePath ) if fileContent is None: return ActionResult.isFailure(error=f"File not found or could not be downloaded: {filePath}") logger.info(f"Downloaded file from SharePoint: {filePath} ({len(fileContent)} bytes)") # Generate filename from filePath import os fileName = os.path.basename(filePath) or "downloaded_file" workflowContext = self.services.chat.getWorkflowContext() if hasattr(self.services, 'chat') else None filename = self._generateMeaningfulFileName( fileName.split('.')[0] if '.' in fileName else fileName, fileName.split('.')[-1] if '.' in fileName else "bin", workflowContext, "downloadFileByPath" ) # Encode as base64 import base64 fileBase64 = base64.b64encode(fileContent).decode('utf-8') validationMetadata = self._createValidationMetadata( "downloadFileByPath", siteId=siteId, filePath=filePath, fileSize=len(fileContent) ) document = ActionDocument( documentName=filename, documentData=fileBase64, mimeType="application/octet-stream", validationMetadata=validationMetadata ) return ActionResult.isSuccess(documents=[document]) except Exception as e: errorMsg = f"Error downloading file from SharePoint: {str(e)}" logger.error(errorMsg) return ActionResult.isFailure(error=errorMsg) @action async def copyFile(self, parameters: Dict[str, Any]) -> ActionResult: """ Copy file within SharePoint. Parameters: - connectionReference (str, required): Microsoft connection label. - siteId (str, required): SharePoint site ID (from findSiteByUrl result) or document reference containing site info - sourceFolder (str, required): Source folder path relative to site root - sourceFile (str, required): Source file name - destFolder (str, required): Destination folder path relative to site root - destFile (str, required): Destination file name Returns: - ActionResult with ActionDocument containing copy result """ try: connectionReference = parameters.get("connectionReference") if not connectionReference: return ActionResult.isFailure(error="connectionReference parameter is required") siteIdParam = parameters.get("siteId") if not siteIdParam: return ActionResult.isFailure(error="siteId parameter is required") sourceFolder = parameters.get("sourceFolder") if not sourceFolder: return ActionResult.isFailure(error="sourceFolder parameter is required") sourceFile = parameters.get("sourceFile") if not sourceFile: return ActionResult.isFailure(error="sourceFile parameter is required") destFolder = parameters.get("destFolder") if not destFolder: return ActionResult.isFailure(error="destFolder parameter is required") destFile = parameters.get("destFile") if not destFile: return ActionResult.isFailure(error="destFile parameter is required") # Extract siteId from document if it's a reference siteId = None if isinstance(siteIdParam, str): from modules.datamodels.datamodelDocref import DocumentReferenceList try: docList = DocumentReferenceList.from_string_list([siteIdParam]) chatDocuments = self.services.chat.getChatDocumentsFromDocumentList(docList) if chatDocuments and len(chatDocuments) > 0: siteInfoJson = json.loads(chatDocuments[0].documentData) siteId = siteInfoJson.get("id") except: pass if not siteId: siteId = siteIdParam else: siteId = siteIdParam if not siteId: return ActionResult.isFailure(error="Could not extract siteId from parameter") # Get Microsoft connection connection = self._getMicrosoftConnection(connectionReference) if not connection: return ActionResult.isFailure(error="No valid Microsoft connection found for the provided connection reference") # Copy file await self.services.sharepoint.copyFileAsync( siteId=siteId, sourceFolder=sourceFolder, sourceFile=sourceFile, destFolder=destFolder, destFile=destFile ) logger.info(f"Copied file in SharePoint: {sourceFolder}/{sourceFile} -> {destFolder}/{destFile}") # Generate filename workflowContext = self.services.chat.getWorkflowContext() if hasattr(self.services, 'chat') else None filename = self._generateMeaningfulFileName( "file_copy_result", "json", workflowContext, "copyFile" ) result = { "success": True, "siteId": siteId, "sourcePath": f"{sourceFolder}/{sourceFile}", "destPath": f"{destFolder}/{destFile}" } validationMetadata = self._createValidationMetadata( "copyFile", siteId=siteId, sourcePath=f"{sourceFolder}/{sourceFile}", destPath=f"{destFolder}/{destFile}" ) document = ActionDocument( documentName=filename, documentData=json.dumps(result, indent=2), mimeType="application/json", validationMetadata=validationMetadata ) return ActionResult.isSuccess(documents=[document]) except Exception as e: # Handle file not found gracefully if "itemNotFound" in str(e) or "404" in str(e): logger.warning(f"File not found for copy: {parameters.get('sourceFolder')}/{parameters.get('sourceFile')}") # Return success with skipped status workflowContext = self.services.chat.getWorkflowContext() if hasattr(self.services, 'chat') else None filename = self._generateMeaningfulFileName( "file_copy_result", "json", workflowContext, "copyFile" ) result = { "success": True, "skipped": True, "reason": "File not found (may not exist yet)" } validationMetadata = self._createValidationMetadata( "copyFile", skipped=True ) document = ActionDocument( documentName=filename, documentData=json.dumps(result, indent=2), mimeType="application/json", validationMetadata=validationMetadata ) return ActionResult.isSuccess(documents=[document]) errorMsg = f"Error copying file in SharePoint: {str(e)}" logger.error(errorMsg) return ActionResult.isFailure(error=errorMsg) @action async def uploadFile(self, parameters: Dict[str, Any]) -> ActionResult: """ Upload raw file content (bytes) to SharePoint. Parameters: - connectionReference (str, required): Microsoft connection label. - siteId (str, required): SharePoint site ID (from findSiteByUrl result) or document reference containing site info - folderPath (str, required): Folder path relative to site root - fileName (str, required): File name - content (str, required): Document reference containing file content as base64-encoded bytes Returns: - ActionResult with ActionDocument containing upload result """ try: connectionReference = parameters.get("connectionReference") if not connectionReference: return ActionResult.isFailure(error="connectionReference parameter is required") siteIdParam = parameters.get("siteId") if not siteIdParam: return ActionResult.isFailure(error="siteId parameter is required") folderPath = parameters.get("folderPath") if not folderPath: return ActionResult.isFailure(error="folderPath parameter is required") fileName = parameters.get("fileName") if not fileName: return ActionResult.isFailure(error="fileName parameter is required") contentParam = parameters.get("content") if not contentParam: return ActionResult.isFailure(error="content parameter is required") # Extract siteId from document if it's a reference siteId = None if isinstance(siteIdParam, str): from modules.datamodels.datamodelDocref import DocumentReferenceList try: docList = DocumentReferenceList.from_string_list([siteIdParam]) chatDocuments = self.services.chat.getChatDocumentsFromDocumentList(docList) if chatDocuments and len(chatDocuments) > 0: siteInfoJson = json.loads(chatDocuments[0].documentData) siteId = siteInfoJson.get("id") except: pass if not siteId: siteId = siteIdParam else: siteId = siteIdParam if not siteId: return ActionResult.isFailure(error="Could not extract siteId from parameter") # Get file content from document from modules.datamodels.datamodelDocref import DocumentReferenceList docList = DocumentReferenceList.from_string_list([contentParam] if isinstance(contentParam, str) else contentParam) chatDocuments = self.services.chat.getChatDocumentsFromDocumentList(docList) if not chatDocuments or len(chatDocuments) == 0: return ActionResult.isFailure(error="Could not get file content from document reference") fileContentBase64 = chatDocuments[0].documentData # Decode base64 import base64 try: fileContent = base64.b64decode(fileContentBase64) except Exception as e: return ActionResult.isFailure(error=f"Could not decode base64 file content: {str(e)}") # Get Microsoft connection connection = self._getMicrosoftConnection(connectionReference) if not connection: return ActionResult.isFailure(error="No valid Microsoft connection found for the provided connection reference") # Upload file uploadResult = await self.services.sharepoint.uploadFile( siteId=siteId, folderPath=folderPath, fileName=fileName, content=fileContent ) if "error" in uploadResult: return ActionResult.isFailure(error=f"Upload failed: {uploadResult['error']}") logger.info(f"Uploaded file to SharePoint: {folderPath}/{fileName} ({len(fileContent)} bytes)") # Generate filename workflowContext = self.services.chat.getWorkflowContext() if hasattr(self.services, 'chat') else None filename = self._generateMeaningfulFileName( "file_upload_result", "json", workflowContext, "uploadFile" ) result = { "success": True, "siteId": siteId, "filePath": f"{folderPath}/{fileName}", "fileSize": len(fileContent), "uploadResult": uploadResult } validationMetadata = self._createValidationMetadata( "uploadFile", siteId=siteId, filePath=f"{folderPath}/{fileName}", fileSize=len(fileContent) ) document = ActionDocument( documentName=filename, documentData=json.dumps(result, indent=2), mimeType="application/json", validationMetadata=validationMetadata ) return ActionResult.isSuccess(documents=[document]) except Exception as e: errorMsg = f"Error uploading file to SharePoint: {str(e)}" logger.error(errorMsg) return ActionResult.isFailure(error=errorMsg)