289 lines
11 KiB
Python
289 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")
|
|
returnedId = data.get("id", "") if isinstance(data, dict) else ""
|
|
if returnedId and returnedId != task_id:
|
|
logger.warning(f"ClickUp download: requested task_id={task_id} but API returned id={returnedId}")
|
|
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)
|