Compare commits
No commits in common. "9b674027a0ef05576b34c6e0e2068a245b8262e0" and "25104938915cc60d40a537c88c289cf5746ab206" have entirely different histories.
9b674027a0
...
2510493891
5 changed files with 10 additions and 154 deletions
|
|
@ -15,15 +15,6 @@ from modules.connectors.connectorProviderBase import ProviderConnector, ServiceA
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
class ConnectorResolver:
|
||||||
"""Resolves connectionId → ProviderConnector (with fresh token) → ServiceAdapter."""
|
"""Resolves connectionId → ProviderConnector (with fresh token) → ServiceAdapter."""
|
||||||
|
|
||||||
|
|
@ -88,16 +79,9 @@ class ConnectorResolver:
|
||||||
if not providerClass:
|
if not providerClass:
|
||||||
raise ValueError(f"No ProviderConnector registered for authority: {authorityStr}")
|
raise ValueError(f"No ProviderConnector registered for authority: {authorityStr}")
|
||||||
|
|
||||||
resolved_id = _connection_uuid(connection)
|
token = self._security.getFreshToken(connectionId)
|
||||||
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:
|
if not token or not token.tokenAccess:
|
||||||
raise ValueError(
|
raise ValueError(f"No valid token for connection {connectionId}")
|
||||||
f"No valid token for connection {resolved_id}"
|
|
||||||
+ (f" (ref: {connectionId})" if connectionId != resolved_id else "")
|
|
||||||
)
|
|
||||||
|
|
||||||
return providerClass(connection, token.tokenAccess)
|
return providerClass(connection, token.tokenAccess)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ Path conventions (leading slash, ``ServiceAdapter`` paths always start with
|
||||||
/{addressBookId}/{contactId} -- single contact (.vcf download)
|
/{addressBookId}/{contactId} -- single contact (.vcf download)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
@ -392,115 +391,8 @@ class KdriveAdapter(ServiceAdapter):
|
||||||
return DownloadResult()
|
return DownloadResult()
|
||||||
return DownloadResult(data=content, fileName=fileName, mimeType=mimeType)
|
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:
|
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
|
||||||
"""Upload a file to kDrive.
|
return {"error": "kDrive upload not yet implemented"}
|
||||||
|
|
||||||
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(
|
async def search(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -78,8 +78,8 @@ CLICKUP_NODES = [
|
||||||
{"name": "page", "type": "int", "required": False, "frontendType": "number",
|
{"name": "page", "type": "int", "required": False, "frontendType": "number",
|
||||||
"description": t("Seite"), "default": 0},
|
"description": t("Seite"), "default": 0},
|
||||||
{"name": "listId", "type": "str", "required": False, "frontendType": "clickupList",
|
{"name": "listId", "type": "str", "required": False, "frontendType": "clickupList",
|
||||||
"frontendOptions": {"dependsOn": "connectionReference", "patchTeamId": True},
|
"frontendOptions": {"dependsOn": "connectionReference"},
|
||||||
"description": t("Liste (optional — schränkt die Suche ein)")},
|
"description": t("In dieser Liste suchen")},
|
||||||
{"name": "includeClosed", "type": "bool", "required": False, "frontendType": "checkbox",
|
{"name": "includeClosed", "type": "bool", "required": False, "frontendType": "checkbox",
|
||||||
"description": t("Erledigte einbeziehen"), "default": False},
|
"description": t("Erledigte einbeziehen"), "default": False},
|
||||||
{"name": "fullTaskData", "type": "bool", "required": False, "frontendType": "checkbox",
|
{"name": "fullTaskData", "type": "bool", "required": False, "frontendType": "checkbox",
|
||||||
|
|
@ -106,7 +106,7 @@ CLICKUP_NODES = [
|
||||||
"description": t("ClickUp-Verbindung")},
|
"description": t("ClickUp-Verbindung")},
|
||||||
{"name": "pathQuery", "type": "str", "required": True, "frontendType": "clickupList",
|
{"name": "pathQuery", "type": "str", "required": True, "frontendType": "clickupList",
|
||||||
"frontendOptions": {"dependsOn": "connectionReference"},
|
"frontendOptions": {"dependsOn": "connectionReference"},
|
||||||
"description": t("Liste")},
|
"description": t("Pfad zur Liste")},
|
||||||
{"name": "page", "type": "int", "required": False, "frontendType": "number",
|
{"name": "page", "type": "int", "required": False, "frontendType": "number",
|
||||||
"description": t("Seite"), "default": 0},
|
"description": t("Seite"), "default": 0},
|
||||||
{"name": "includeClosed", "type": "bool", "required": False, "frontendType": "checkbox",
|
{"name": "includeClosed", "type": "bool", "required": False, "frontendType": "checkbox",
|
||||||
|
|
@ -151,9 +151,11 @@ CLICKUP_NODES = [
|
||||||
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
|
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
|
||||||
"frontendOptions": {"authority": "clickup"},
|
"frontendOptions": {"authority": "clickup"},
|
||||||
"description": t("ClickUp-Verbindung")},
|
"description": t("ClickUp-Verbindung")},
|
||||||
{"name": "pathQuery", "type": "str", "required": True, "frontendType": "clickupList",
|
{"name": "pathQuery", "type": "str", "required": False, "frontendType": "clickupList",
|
||||||
"frontendOptions": {"dependsOn": "connectionReference"},
|
"frontendOptions": {"dependsOn": "connectionReference"},
|
||||||
"description": t("Liste")},
|
"description": t("Pfad zur Liste")},
|
||||||
|
{"name": "listId", "type": "str", "required": False, "frontendType": "text",
|
||||||
|
"description": t("Listen-ID")},
|
||||||
{"name": "name", "type": "str", "required": True, "frontendType": "text",
|
{"name": "name", "type": "str", "required": True, "frontendType": "text",
|
||||||
"description": t("Name")},
|
"description": t("Name")},
|
||||||
{"name": "description", "type": "str", "required": False, "frontendType": "textarea",
|
{"name": "description", "type": "str", "required": False, "frontendType": "textarea",
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,7 @@ router = APIRouter(
|
||||||
|
|
||||||
|
|
||||||
def _getUserConnection(interface, connection_id: str, user_id: str) -> Optional[UserConnection]:
|
def _getUserConnection(interface, connection_id: str, user_id: str) -> Optional[UserConnection]:
|
||||||
"""Resolve by UUID or connection:authority:username (same as browse / workflow)."""
|
|
||||||
try:
|
try:
|
||||||
if hasattr(interface, "getUserConnectionById"):
|
|
||||||
conn = interface.getUserConnectionById(connection_id)
|
|
||||||
if conn is not None:
|
|
||||||
return conn
|
|
||||||
connections = interface.getUserConnections(user_id)
|
connections = interface.getUserConnections(user_id)
|
||||||
for conn in connections:
|
for conn in connections:
|
||||||
if conn.id == connection_id:
|
if conn.id == connection_id:
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
# 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") == ""
|
|
||||||
Loading…
Reference in a new issue