gateway/modules/connectors/providerClickup/connectorClickup.py
2026-04-21 00:50:36 +02:00

286 lines
11 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""ClickUp ProviderConnector — virtual paths for teams → lists → tasks (table rows).
Path convention (leading slash, no trailing slash except root):
/ — authorized workspaces (teams)
/team/{teamId} — spaces in the workspace
/team/{teamId}/space/{spaceId} — folders + folderless lists
/team/{teamId}/space/{spaceId}/folder/{folderId} — lists in folder
/team/{teamId}/list/{listId} — tasks in list (rows)
/team/{teamId}/list/{listId}/task/{taskId} — single task (download = JSON)
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any, Dict, List, Optional
from modules.connectors.connectorProviderBase import (
ProviderConnector,
ServiceAdapter,
DownloadResult,
)
from modules.datamodels.datamodelDataSource import ExternalEntry
from modules.serviceCenter.services.serviceClickup.mainServiceClickup import ClickupService
logger = logging.getLogger(__name__)
# type metadata for ExternalEntry.metadata["cuType"]
_CU_TEAM = "team"
_CU_SPACE = "space"
_CU_FOLDER = "folder"
_CU_LIST = "list"
_CU_TASK = "task"
def _norm(path: str) -> str:
p = (path or "").strip() or "/"
if not p.startswith("/"):
p = "/" + p
if p != "/" and p.endswith("/"):
p = p.rstrip("/")
return p
class ClickupListsAdapter(ServiceAdapter):
"""Maps ClickUp hierarchy + list tasks to browse/download/upload/search."""
def __init__(self, access_token: str):
self._token = access_token
# Minimal service instance for API calls (no ServiceCenter context)
self._svc = ClickupService(context=None, get_service=lambda _: None)
self._svc.setAccessToken(access_token)
async def browse(
self,
path: str,
filter: Optional[str] = None,
limit: Optional[int] = None,
) -> List[ExternalEntry]:
p = _norm(path)
out: List[ExternalEntry] = []
if p == "/":
data = await self._svc.getAuthorizedTeams()
if isinstance(data, dict) and data.get("error"):
logger.warning(f"ClickUp browse root: {data.get('error')}")
return []
teams = data.get("teams", []) if isinstance(data, dict) else []
for t in teams:
tid = str(t.get("id", ""))
name = t.get("name") or tid
out.append(
ExternalEntry(
name=name,
path=f"/team/{tid}",
isFolder=True,
metadata={"cuType": _CU_TEAM, "id": tid, "raw": t},
)
)
return out
m = re.match(r"^/team/([^/]+)$", p)
if m:
team_id = m.group(1)
data = await self._svc.getSpaces(team_id)
if isinstance(data, dict) and data.get("error"):
return []
spaces = data.get("spaces", []) if isinstance(data, dict) else []
for s in spaces:
sid = str(s.get("id", ""))
name = s.get("name") or sid
out.append(
ExternalEntry(
name=name,
path=f"/team/{team_id}/space/{sid}",
isFolder=True,
metadata={"cuType": _CU_SPACE, "id": sid, "raw": s},
)
)
return out
m = re.match(r"^/team/([^/]+)/space/([^/]+)$", p)
if m:
team_id, space_id = m.group(1), m.group(2)
folders_r = await self._svc.getFolders(space_id)
lists_r = await self._svc.getFolderlessLists(space_id)
if isinstance(folders_r, dict) and not folders_r.get("error"):
for f in folders_r.get("folders", []) or []:
fid = str(f.get("id", ""))
name = f.get("name") or fid
out.append(
ExternalEntry(
name=name,
path=f"/team/{team_id}/space/{space_id}/folder/{fid}",
isFolder=True,
metadata={"cuType": _CU_FOLDER, "id": fid, "raw": f},
)
)
if isinstance(lists_r, dict) and not lists_r.get("error"):
for lst in lists_r.get("lists", []) or []:
lid = str(lst.get("id", ""))
name = lst.get("name") or lid
out.append(
ExternalEntry(
name=name,
path=f"/team/{team_id}/list/{lid}",
isFolder=True,
metadata={"cuType": _CU_LIST, "id": lid, "raw": lst},
)
)
return out
m = re.match(r"^/team/([^/]+)/space/([^/]+)/folder/([^/]+)$", p)
if m:
team_id, _space_id, folder_id = m.group(1), m.group(2), m.group(3)
data = await self._svc.getListsInFolder(folder_id)
if isinstance(data, dict) and data.get("error"):
return []
for lst in data.get("lists", []) or []:
lid = str(lst.get("id", ""))
name = lst.get("name") or lid
out.append(
ExternalEntry(
name=name,
path=f"/team/{team_id}/list/{lid}",
isFolder=True,
metadata={"cuType": _CU_LIST, "id": lid, "raw": lst},
)
)
return out
m = re.match(r"^/team/([^/]+)/list/([^/]+)$", p)
if m:
team_id, list_id = m.group(1), m.group(2)
page = 0
while True:
data = await self._svc.getTasksInList(list_id, page=page)
if isinstance(data, dict) and data.get("error"):
break
tasks = data.get("tasks", []) if isinstance(data, dict) else []
for task in tasks:
tid = str(task.get("id", ""))
name = task.get("name") or tid
out.append(
ExternalEntry(
name=name,
path=f"/team/{team_id}/list/{list_id}/task/{tid}",
isFolder=False,
metadata={
"cuType": _CU_TASK,
"id": tid,
"task": task,
},
)
)
if len(tasks) < 100:
break
if limit is not None and len(out) >= int(limit):
break
page += 1
if limit is not None:
out = out[: max(1, int(limit))]
return out
m = re.match(r"^/team/([^/]+)/list/([^/]+)/task/([^/]+)$", p)
if m:
team_id, list_id, task_id = m.group(1), m.group(2), m.group(3)
out.append(
ExternalEntry(
name=f"task-{task_id}.json",
path=p,
isFolder=False,
metadata={"cuType": _CU_TASK, "id": task_id, "listId": list_id, "teamId": team_id},
)
)
return out
logger.warning(f"ClickUp browse: unsupported path {p}")
return []
async def download(self, path: str) -> Any:
p = _norm(path)
m = re.match(r"^/team/([^/]+)/list/([^/]+)/task/([^/]+)$", p)
if not m:
return b""
task_id = m.group(3)
data = await self._svc.getTask(task_id)
if isinstance(data, dict) and data.get("error"):
return json.dumps(data).encode("utf-8")
payload = json.dumps(data, indent=2).encode("utf-8")
return DownloadResult(data=payload, fileName=f"task-{task_id}.json", mimeType="application/json")
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
"""Upload attachment to a task. Path must be .../list/{listId}/task/{taskId}."""
p = _norm(path)
m = re.match(r"^/team/([^/]+)/list/([^/]+)/task/([^/]+)$", p)
if not m:
return {"error": "Path must be /team/{teamId}/list/{listId}/task/{taskId} for upload"}
task_id = m.group(3)
return await self._svc.uploadTaskAttachment(task_id, data, fileName)
async def search(
self,
query: str,
path: Optional[str] = None,
limit: Optional[int] = None,
) -> List[ExternalEntry]:
base = _norm(path or "/")
team_id: Optional[str] = None
mt = re.match(r"^/team/([^/]+)", base)
if mt:
team_id = mt.group(1)
if not team_id:
teams = await self._svc.getAuthorizedTeams()
if not isinstance(teams, dict) or teams.get("error"):
return []
tl = teams.get("teams") or []
if not tl:
return []
team_id = str(tl[0].get("id", ""))
out: List[ExternalEntry] = []
page = 0
while True:
data = await self._svc.searchTeamTasks(team_id, query=query, page=page)
if isinstance(data, dict) and data.get("error"):
break
tasks = data.get("tasks", []) if isinstance(data, dict) else []
for task in tasks:
tid = str(task.get("id", ""))
name = task.get("name") or tid
list_obj = task.get("list") or {}
lid = str(list_obj.get("id", "")) if list_obj else ""
if not lid:
continue
out.append(
ExternalEntry(
name=name,
path=f"/team/{team_id}/list/{lid}/task/{tid}",
isFolder=False,
metadata={"cuType": _CU_TASK, "id": tid, "task": task},
)
)
if len(tasks) < 25:
break
if limit is not None and len(out) >= int(limit):
break
page += 1
if limit is not None:
out = out[: max(1, int(limit))]
return out
class ClickupConnector(ProviderConnector):
"""One ClickUp connection → clickup virtual file service."""
def getAvailableServices(self) -> List[str]:
return ["clickup"]
def getServiceAdapter(self, service: str) -> ServiceAdapter:
if service != "clickup":
raise ValueError(f"ClickUp only supports 'clickup' service, got '{service}'")
return ClickupListsAdapter(self.accessToken)