diff --git a/modules/connectors/connectorResolver.py b/modules/connectors/connectorResolver.py index a85002a4..a8b9fd23 100644 --- a/modules/connectors/connectorResolver.py +++ b/modules/connectors/connectorResolver.py @@ -15,6 +15,15 @@ from modules.connectors.connectorProviderBase import ProviderConnector, ServiceA logger = logging.getLogger(__name__) +def _connection_uuid(connection: Any) -> str: + """Resolve UserConnection primary key (tokens are stored by UUID, not reference string).""" + if connection is None: + return "" + if isinstance(connection, dict): + return str(connection.get("id") or "").strip() + return str(getattr(connection, "id", None) or "").strip() + + class ConnectorResolver: """Resolves connectionId → ProviderConnector (with fresh token) → ServiceAdapter.""" @@ -79,9 +88,16 @@ class ConnectorResolver: if not providerClass: raise ValueError(f"No ProviderConnector registered for authority: {authorityStr}") - token = self._security.getFreshToken(connectionId) + resolved_id = _connection_uuid(connection) + if not resolved_id: + raise ValueError(f"Connection {connectionId} has no id") + + token = self._security.getFreshToken(resolved_id) if not token or not token.tokenAccess: - raise ValueError(f"No valid token for connection {connectionId}") + raise ValueError( + f"No valid token for connection {resolved_id}" + + (f" (ref: {connectionId})" if connectionId != resolved_id else "") + ) return providerClass(connection, token.tokenAccess) diff --git a/modules/connectors/providerInfomaniak/connectorInfomaniak.py b/modules/connectors/providerInfomaniak/connectorInfomaniak.py index dfdc8bab..9aa3ea9c 100644 --- a/modules/connectors/providerInfomaniak/connectorInfomaniak.py +++ b/modules/connectors/providerInfomaniak/connectorInfomaniak.py @@ -31,6 +31,7 @@ Path conventions (leading slash, ``ServiceAdapter`` paths always start with /{addressBookId}/{contactId} -- single contact (.vcf download) """ +import json import logging import re from datetime import datetime, timedelta, timezone @@ -391,8 +392,115 @@ class KdriveAdapter(ServiceAdapter): return DownloadResult() return DownloadResult(data=content, fileName=fileName, mimeType=mimeType) + async def _createDirectory(self, driveId: str, parentId: str, name: str) -> Optional[str]: + """Create a single directory and return its ID. + + If the directory already exists (409), lists the parent to find + the existing folder's ID -- kDrive directory creation is not + idempotent. + """ + url = f"{_API_BASE}/3/drive/{driveId}/files/{parentId}/directory" + headers = { + "Authorization": f"Bearer {self._token}", + "Content-Type": "application/json", + } + body = json.dumps({"name": name}) + result = await _http.request("POST", url, headers=headers, data=body) + + if isinstance(result, dict) and not result.get("error"): + data = _unwrapData(result) + if isinstance(data, dict) and data.get("id"): + return str(data["id"]) + + errorStr = str(result.get("error", "")) if isinstance(result, dict) else "" + if "already_exists" in errorStr or "409" in errorStr: + children = await self._listChildren(driveId, fileId=parentId, limit=1000) + for child in children: + if child.isFolder and child.name == name: + return (child.metadata or {}).get("id") or child.path.strip("/").split("/")[-1] + + logger.warning("kDrive mkdir %s/%s in %s failed: %s", driveId, name, parentId, result) + return None + + async def _ensureDirectoryPath(self, driveId: str, parentId: str, pathSegments: List[str]) -> Optional[str]: + """Walk *pathSegments* and create each level that does not exist yet. + + Returns the numeric folder ID of the deepest directory, or + ``None`` if any step fails. + """ + currentId = parentId + for segment in pathSegments: + folderId = await self._createDirectory(driveId, currentId, segment) + if not folderId: + return None + currentId = folderId + return currentId + async def upload(self, path: str, data: bytes, fileName: str) -> dict: - return {"error": "kDrive upload not yet implemented"} + """Upload a file to kDrive. + + Path formats: + /{driveId} -> upload to drive root + /{driveId}/{folderId} -> upload into folder by numeric ID + /{driveId}/{folderId}/Sub/Path -> create Sub/Path under folderId, then upload + /{driveId}/Some/Human/Path -> create path from drive root (id 1), then upload + + Directories are created step-by-step via the v3 mkdir endpoint; + existing directories are reused (idempotent). File upload uses + the v3 upload endpoint (max 1 GB). + """ + segments = [s for s in (path or "").strip("/").split("/") if s] + if not segments: + return {"error": "Upload path must include at least a drive ID"} + driveId = segments[0] + + targetDirId: Optional[str] = None + if len(segments) > 1: + subSegments = segments[1:] + numericPrefix: List[str] = [] + nameSegments: List[str] = [] + for i, seg in enumerate(subSegments): + if seg.isdigit() and not nameSegments: + numericPrefix.append(seg) + else: + nameSegments = subSegments[i:] + break + + parentId = numericPrefix[-1] if numericPrefix else "1" + + if nameSegments and nameSegments[-1] == fileName: + nameSegments = nameSegments[:-1] + + if nameSegments: + targetDirId = await self._ensureDirectoryPath(driveId, parentId, nameSegments) + if not targetDirId: + return {"error": f"Failed to create directory path: {'/'.join(nameSegments)}"} + else: + targetDirId = parentId + + params = [ + f"file_name={quote(fileName)}", + f"total_size={len(data)}", + "conflict=version", + ] + if targetDirId: + params.append(f"directory_id={targetDirId}") + + endpoint = f"/3/drive/{driveId}/upload?{'&'.join(params)}" + url = f"{_API_BASE.rstrip('/')}/{endpoint.lstrip('/')}" + headers = { + "Authorization": f"Bearer {self._token}", + "Content-Type": "application/octet-stream", + } + + result = await _http.request( + "POST", url, headers=headers, data=data, + timeout=aiohttp.ClientTimeout(total=120), + ) + if isinstance(result, dict) and result.get("error"): + return result + unwrapped = _unwrapData(result) if isinstance(result, dict) else result + return unwrapped if isinstance(unwrapped, dict) else {"data": unwrapped} async def search( self, diff --git a/modules/features/graphicalEditor/nodeDefinitions/clickup.py b/modules/features/graphicalEditor/nodeDefinitions/clickup.py index c1981097..77710a64 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/clickup.py +++ b/modules/features/graphicalEditor/nodeDefinitions/clickup.py @@ -78,8 +78,8 @@ CLICKUP_NODES = [ {"name": "page", "type": "int", "required": False, "frontendType": "number", "description": t("Seite"), "default": 0}, {"name": "listId", "type": "str", "required": False, "frontendType": "clickupList", - "frontendOptions": {"dependsOn": "connectionReference"}, - "description": t("In dieser Liste suchen")}, + "frontendOptions": {"dependsOn": "connectionReference", "patchTeamId": True}, + "description": t("Liste (optional — schränkt die Suche ein)")}, {"name": "includeClosed", "type": "bool", "required": False, "frontendType": "checkbox", "description": t("Erledigte einbeziehen"), "default": False}, {"name": "fullTaskData", "type": "bool", "required": False, "frontendType": "checkbox", @@ -106,7 +106,7 @@ CLICKUP_NODES = [ "description": t("ClickUp-Verbindung")}, {"name": "pathQuery", "type": "str", "required": True, "frontendType": "clickupList", "frontendOptions": {"dependsOn": "connectionReference"}, - "description": t("Pfad zur Liste")}, + "description": t("Liste")}, {"name": "page", "type": "int", "required": False, "frontendType": "number", "description": t("Seite"), "default": 0}, {"name": "includeClosed", "type": "bool", "required": False, "frontendType": "checkbox", @@ -151,11 +151,9 @@ CLICKUP_NODES = [ {"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection", "frontendOptions": {"authority": "clickup"}, "description": t("ClickUp-Verbindung")}, - {"name": "pathQuery", "type": "str", "required": False, "frontendType": "clickupList", + {"name": "pathQuery", "type": "str", "required": True, "frontendType": "clickupList", "frontendOptions": {"dependsOn": "connectionReference"}, - "description": t("Pfad zur Liste")}, - {"name": "listId", "type": "str", "required": False, "frontendType": "text", - "description": t("Listen-ID")}, + "description": t("Liste")}, {"name": "name", "type": "str", "required": True, "frontendType": "text", "description": t("Name")}, {"name": "description", "type": "str", "required": False, "frontendType": "textarea", diff --git a/modules/routes/routeClickup.py b/modules/routes/routeClickup.py index c3f4b976..7a869a9f 100644 --- a/modules/routes/routeClickup.py +++ b/modules/routes/routeClickup.py @@ -29,7 +29,12 @@ router = APIRouter( def _getUserConnection(interface, connection_id: str, user_id: str) -> Optional[UserConnection]: + """Resolve by UUID or connection:authority:username (same as browse / workflow).""" try: + if hasattr(interface, "getUserConnectionById"): + conn = interface.getUserConnectionById(connection_id) + if conn is not None: + return conn connections = interface.getUserConnections(user_id) for conn in connections: if conn.id == connection_id: diff --git a/tests/unit/connectors/test_connectorResolver.py b/tests/unit/connectors/test_connectorResolver.py new file mode 100644 index 00000000..0ef82e81 --- /dev/null +++ b/tests/unit/connectors/test_connectorResolver.py @@ -0,0 +1,17 @@ +# Copyright (c) 2025 Patrick Motsch +from types import SimpleNamespace + +from modules.connectors.connectorResolver import _connection_uuid + + +def test_connection_uuid_from_model(): + conn = SimpleNamespace(id="uuid-123") + assert _connection_uuid(conn) == "uuid-123" + + +def test_connection_uuid_from_dict(): + assert _connection_uuid({"id": "uuid-456"}) == "uuid-456" + + +def test_connection_uuid_from_reference_string_returns_empty(): + assert _connection_uuid("connection:clickup:Stephan Schellworth") == ""