ClickUp MCP Server
by v4lheru
Verified
- src
- services
import axios, { AxiosInstance } from 'axios';
import {
ClickUpTask,
ClickUpList,
ClickUpSpace,
ClickUpFolder,
CreateTaskData,
UpdateTaskData,
CreateListData,
CreateFolderData,
WorkspaceNode,
WorkspaceTree,
MoveTaskData,
TaskPriority
} from '../types/clickup.js';
/**
* Service class for interacting with the ClickUp API.
* Handles all API requests and data transformations.
*/
export class ClickUpService {
private client: AxiosInstance;
private static instance: ClickUpService;
private clickupTeamId: string;
private rateLimitRemaining: number = 100; // Default to lowest tier limit
private rateLimitReset: number = 0;
private constructor(apiKey: string, clickupTeamId: string) {
this.client = axios.create({
baseURL: 'https://api.clickup.com/api/v2',
headers: {
'Authorization': apiKey,
'Content-Type': 'application/json'
}
});
// Add response interceptor for rate limit handling
this.client.interceptors.response.use(
(response) => {
// Update rate limit info from headers
this.rateLimitRemaining = parseInt(response.headers['x-ratelimit-remaining'] || '100');
this.rateLimitReset = parseInt(response.headers['x-ratelimit-reset'] || '0');
return response;
},
async (error) => {
if (error.response?.status === 429) {
const resetTime = parseInt(error.response.headers['x-ratelimit-reset'] || '0');
const waitTime = Math.max(0, resetTime - Math.floor(Date.now() / 1000));
console.warn(`Rate limit exceeded. Waiting ${waitTime} seconds before retrying...`);
// Wait until rate limit resets
await new Promise(resolve => setTimeout(resolve, waitTime * 1000));
// Retry the request
return this.client.request(error.config);
}
throw error;
}
);
this.clickupTeamId = clickupTeamId;
}
/**
* Checks if we're close to hitting rate limits and waits if necessary.
* @private
*/
private async checkRateLimit(): Promise<void> {
if (this.rateLimitRemaining <= 5) { // Buffer of 5 requests
const now = Math.floor(Date.now() / 1000);
const waitTime = Math.max(0, this.rateLimitReset - now);
if (waitTime > 0) {
console.warn(`Approaching rate limit. Waiting ${waitTime} seconds...`);
await new Promise(resolve => setTimeout(resolve, waitTime * 1000));
}
}
}
/**
* Makes an API request with rate limit handling.
* @private
* @param requestFn - Function that makes the actual API request
* @returns The API response
*/
private async makeRequest<T>(requestFn: () => Promise<T>): Promise<T> {
await this.checkRateLimit();
return await requestFn();
}
/**
* Initializes the ClickUpService singleton instance.
* @param apiKey - The ClickUp API key for authentication
* @param clickupTeamId - The team/workspace ID to operate on
* @returns The singleton instance of ClickUpService
* @throws Error if initialization fails
*/
public static initialize(apiKey: string, clickupTeamId: string): ClickUpService {
if (!ClickUpService.instance) {
ClickUpService.instance = new ClickUpService(apiKey, clickupTeamId);
}
return ClickUpService.instance;
}
/**
* Gets the singleton instance of ClickUpService.
* @returns The singleton instance of ClickUpService
* @throws Error if service hasn't been initialized
*/
public static getInstance(): ClickUpService {
if (!ClickUpService.instance) {
throw new Error('ClickUpService not initialized. Call initialize() first.');
}
return ClickUpService.instance;
}
/**
* Gets the team/workspace ID that was set during initialization.
* @returns The team/workspace ID
*/
public getTeamId(): string {
return this.clickupTeamId;
}
// Tasks
/**
* Retrieves tasks from a specific list with optional filtering.
* Handles rate limiting automatically.
* @param listId - The ID of the list to fetch tasks from
* @param filters - Optional filters to apply to the task query
* @param filters.archived - Include archived tasks
* @param filters.page - Page number for pagination
* @param filters.order_by - Field to order tasks by
* @param filters.reverse - Reverse the order of tasks
* @param filters.subtasks - Include subtasks
* @param filters.statuses - Filter by specific statuses
* @param filters.include_closed - Include closed tasks
* @param filters.assignees - Filter by assignee IDs
* @param filters.due_date_gt - Tasks due after this timestamp
* @param filters.due_date_lt - Tasks due before this timestamp
* @param filters.date_created_gt - Tasks created after this timestamp
* @param filters.date_created_lt - Tasks created before this timestamp
* @param filters.date_updated_gt - Tasks updated after this timestamp
* @param filters.date_updated_lt - Tasks updated before this timestamp
* @param filters.custom_fields - Filter by custom field values
* @returns Object containing tasks array and available statuses
* @throws Error if the API request fails
*/
async getTasks(listId: string, filters?: {
archived?: boolean;
page?: number;
order_by?: string;
reverse?: boolean;
subtasks?: boolean;
statuses?: string[];
include_closed?: boolean;
assignees?: string[];
due_date_gt?: number;
due_date_lt?: number;
date_created_gt?: number;
date_created_lt?: number;
date_updated_gt?: number;
date_updated_lt?: number;
custom_fields?: Record<string, any>;
}): Promise<{ tasks: ClickUpTask[]; statuses: string[] }> {
return this.makeRequest(async () => {
const params = new URLSearchParams();
if (filters) {
if (filters.archived !== undefined) params.append('archived', filters.archived.toString());
if (filters.page !== undefined) params.append('page', filters.page.toString());
if (filters.order_by) params.append('order_by', filters.order_by);
if (filters.reverse !== undefined) params.append('reverse', filters.reverse.toString());
if (filters.subtasks !== undefined) params.append('subtasks', filters.subtasks.toString());
if (filters.statuses) params.append('statuses[]', filters.statuses.join(','));
if (filters.include_closed !== undefined) params.append('include_closed', filters.include_closed.toString());
if (filters.assignees) params.append('assignees[]', filters.assignees.join(','));
if (filters.due_date_gt) params.append('due_date_gt', filters.due_date_gt.toString());
if (filters.due_date_lt) params.append('due_date_lt', filters.due_date_lt.toString());
if (filters.date_created_gt) params.append('date_created_gt', filters.date_created_gt.toString());
if (filters.date_created_lt) params.append('date_created_lt', filters.date_created_lt.toString());
if (filters.date_updated_gt) params.append('date_updated_gt', filters.date_updated_gt.toString());
if (filters.date_updated_lt) params.append('date_updated_lt', filters.date_updated_lt.toString());
if (filters.custom_fields) {
Object.entries(filters.custom_fields).forEach(([key, value]) => {
params.append(`custom_fields[${key}]`, JSON.stringify(value));
});
}
}
const queryString = params.toString();
const url = `/list/${listId}/task${queryString ? `?${queryString}` : ''}`;
const response = await this.client.get(url);
const tasks = response.data.tasks;
const statuses = [...new Set(tasks
.filter((task: ClickUpTask) => task.status !== undefined)
.map((task: ClickUpTask) => task.status!.status))] as string[];
return { tasks, statuses };
});
}
/**
* Retrieves detailed information about a specific task.
* Handles rate limiting automatically.
*/
async getTask(taskId: string): Promise<ClickUpTask> {
return this.makeRequest(async () => {
const response = await this.client.get(`/task/${taskId}`);
return response.data;
});
}
/**
* Creates a new task in a specified list.
* Handles rate limiting automatically.
*/
async createTask(listId: string, data: CreateTaskData): Promise<ClickUpTask> {
return this.makeRequest(async () => {
const taskData = { ...data };
// If markdown_description is provided, it takes precedence
if (taskData.markdown_description) {
// Ensure we don't send both to avoid confusion
delete taskData.description;
} else if (taskData.description) {
// Only use description as-is, don't auto-convert to markdown
taskData.description = taskData.description.trim();
}
const response = await this.client.post(`/list/${listId}/task`, taskData);
return response.data;
});
}
/**
* Creates multiple tasks in a list sequentially to avoid rate limits.
* Automatically handles rate limiting and retries.
*/
async createBulkTasks(listId: string, data: { tasks: CreateTaskData[] }): Promise<ClickUpTask[]> {
const createdTasks: ClickUpTask[] = [];
for (const taskData of data.tasks) {
await this.makeRequest(async () => {
const processedTask = { ...taskData };
// If markdown_description is provided, it takes precedence
if (processedTask.markdown_description) {
// Ensure we don't send both to avoid confusion
delete processedTask.description;
} else if (processedTask.description) {
// Only use description as-is, don't auto-convert to markdown
processedTask.description = processedTask.description.trim();
}
const response = await this.client.post(`/list/${listId}/task`, processedTask);
createdTasks.push(response.data);
});
}
return createdTasks;
}
/**
* Updates an existing task with new data.
* Handles rate limiting automatically.
*/
async updateTask(taskId: string, data: UpdateTaskData): Promise<ClickUpTask> {
return this.makeRequest(async () => {
const updateData = { ...data };
// If markdown_description is provided, it takes precedence
if (updateData.markdown_description) {
// Ensure we don't send both to avoid confusion
delete updateData.description;
} else if (updateData.description) {
// Only use description as-is, don't auto-convert to markdown
updateData.description = updateData.description.trim();
}
// Handle null priority explicitly
if (updateData.priority === null) {
updateData.priority = null;
}
const response = await this.client.put(`/task/${taskId}`, updateData);
return response.data;
});
}
/**
* Deletes a task from the workspace.
* Handles rate limiting automatically.
*/
async deleteTask(taskId: string): Promise<void> {
return this.makeRequest(async () => {
await this.client.delete(`/task/${taskId}`);
});
}
// Lists
/**
* Gets all lists in a space.
* @param spaceId - ID of the space to get lists from
* @returns Promise resolving to array of ClickUpList objects
* @throws Error if the API request fails
*/
async getLists(spaceId: string): Promise<ClickUpList[]> {
return this.makeRequest(async () => {
const response = await this.client.get(`/space/${spaceId}/list`);
return response.data.lists;
});
}
/**
* Gets all lists in the workspace.
* @param clickupTeamId - ID of the team/workspace
* @returns Promise resolving to array of ClickUpList objects
* @throws Error if the API request fails
*/
async getAllLists(clickupTeamId: string): Promise<ClickUpList[]> {
return this.makeRequest(async () => {
const response = await this.client.get(`/team/${clickupTeamId}/list`);
return response.data.lists;
});
}
/**
* Gets a specific list by ID.
* @param listId - ID of the list to retrieve
* @returns Promise resolving to ClickUpList object
* @throws Error if the API request fails or list not found
*/
async getList(listId: string): Promise<ClickUpList> {
return this.makeRequest(async () => {
const response = await this.client.get(`/list/${listId}`);
return response.data;
});
}
// Spaces
async getSpaces(clickupTeamId: string): Promise<ClickUpSpace[]> {
return this.makeRequest(async () => {
const response = await this.client.get(`/team/${clickupTeamId}/space`);
return response.data.spaces;
});
}
async getSpace(spaceId: string): Promise<ClickUpSpace> {
return this.makeRequest(async () => {
const response = await this.client.get(`/space/${spaceId}`);
return response.data;
});
}
async findSpaceByName(clickupTeamId: string, spaceName: string): Promise<ClickUpSpace | null> {
const spaces = await this.getSpaces(clickupTeamId);
return spaces.find(space => space.name.toLowerCase() === spaceName.toLowerCase()) || null;
}
/**
* Creates a new list in a space.
* Note: ClickUp API requires lists to be in folders, so this will:
* 1. Create a default folder if none specified
* 2. Create the list within that folder
* @param spaceId - ID of the space to create the list in
* @param data - List creation data (name, content, due date, etc.)
* @returns Promise resolving to the created ClickUpList
* @throws Error if the API request fails
*/
async createList(spaceId: string, data: CreateListData): Promise<ClickUpList> {
return this.makeRequest(async () => {
// First, get or create a default folder
const folders = await this.getFolders(spaceId);
let defaultFolder = folders.find(f => f.name === 'Default Lists');
if (!defaultFolder) {
// Create a default folder if none exists
defaultFolder = await this.createFolder(spaceId, {
name: 'Default Lists'
});
}
// Create the list within the default folder
const response = await this.client.post(`/folder/${defaultFolder.id}/list`, data);
return response.data;
});
}
// Folders
async getFolders(spaceId: string): Promise<ClickUpFolder[]> {
return this.makeRequest(async () => {
const response = await this.client.get(`/space/${spaceId}/folder`);
return response.data.folders;
});
}
async getFolder(folderId: string): Promise<ClickUpFolder> {
return this.makeRequest(async () => {
const response = await this.client.get(`/folder/${folderId}`);
return response.data;
});
}
/**
* Updates an existing folder with new data.
* @param folderId - ID of the folder to update
* @param data - Data to update the folder with (name, override_statuses)
* @returns Promise resolving to the updated ClickUpFolder
* @throws Error if the API request fails
*/
async updateFolder(folderId: string, data: { name?: string; override_statuses?: boolean }): Promise<ClickUpFolder> {
return this.makeRequest(async () => {
const response = await this.client.put(`/folder/${folderId}`, data);
return response.data;
});
}
async deleteFolder(folderId: string): Promise<void> {
return this.makeRequest(async () => {
await this.client.delete(`/folder/${folderId}`);
});
}
/**
* Creates a new list in a folder.
* @param folderId - ID of the folder to create the list in
* @param data - List creation data (name, content, etc.)
* @returns Promise resolving to the created ClickUpList
* @throws Error if the API request fails
*/
async createListInFolder(folderId: string, data: CreateListData): Promise<ClickUpList> {
return this.makeRequest(async () => {
const response = await this.client.post(`/folder/${folderId}/list`, data);
return response.data;
});
}
async findFolderByName(spaceId: string, folderName: string): Promise<ClickUpFolder | null> {
const folders = await this.getFolders(spaceId);
return folders.find(folder => folder.name.toLowerCase() === folderName.toLowerCase()) || null;
}
/**
* Creates a new folder in a space.
* @param spaceId - ID of the space to create the folder in
* @param data - Folder creation data (name, override_statuses)
* @returns Promise resolving to the created ClickUpFolder
* @throws Error if the API request fails
*/
async createFolder(spaceId: string, data: CreateFolderData): Promise<ClickUpFolder> {
return this.makeRequest(async () => {
const response = await this.client.post(`/space/${spaceId}/folder`, data);
return response.data;
});
}
// Additional helper methods
/**
* Moves a task to a different list.
* Since direct task moving is not supported by the ClickUp API,
* this creates a new task in the target list and deletes the original.
*
* @param taskId - ID of the task to move
* @param listId - ID of the destination list
* @returns Promise resolving to the new task in its new location
* @throws Error if the API request fails
*/
async moveTask(taskId: string, listId: string): Promise<ClickUpTask> {
return this.makeRequest(async () => {
// Get the current task to copy all its data
const currentTask = await this.getTask(taskId);
// Get available statuses in the target list
const { statuses: targetStatuses } = await this.getTasks(listId);
// Check if current status exists in target list
const currentStatus = currentTask.status?.status;
const statusExists = currentStatus && targetStatuses.includes(currentStatus);
// Prepare the task data for the new location
const moveData: MoveTaskData = {
name: currentTask.name,
description: currentTask.description,
markdown_description: currentTask.description, // In case it contains markdown
status: statusExists ? currentStatus : undefined, // Only set status if it exists in target list
priority: currentTask.priority ? (parseInt(currentTask.priority.id) as TaskPriority) : undefined,
due_date: currentTask.due_date ? parseInt(currentTask.due_date) : undefined,
start_date: currentTask.start_date ? parseInt(currentTask.start_date) : undefined,
assignees: currentTask.assignees?.map(a => a.id)
};
// Create a new task in the target list with the same data
const newTask = await this.createTask(listId, moveData);
// Delete the original task
await this.deleteTask(taskId);
// Return the new task
return newTask;
});
}
/**
* Duplicates a task to another list.
* Creates a new task with the same data in the target list.
*
* @param taskId - ID of the task to duplicate
* @param listId - ID of the destination list
* @returns Promise resolving to the new duplicate task
* @throws Error if the API request fails
*/
async duplicateTask(taskId: string, listId: string): Promise<ClickUpTask> {
return this.makeRequest(async () => {
// Get the current task to copy all its data
const currentTask = await this.getTask(taskId);
// Get available statuses in the target list
const { statuses: targetStatuses } = await this.getTasks(listId);
// Check if current status exists in target list
const currentStatus = currentTask.status?.status;
const statusExists = currentStatus && targetStatuses.includes(currentStatus);
// Prepare the task data for duplication
const taskData: CreateTaskData = {
name: currentTask.name,
description: currentTask.description,
markdown_description: currentTask.description, // In case it contains markdown
status: statusExists ? currentStatus : undefined, // Only set status if it exists in target list
priority: currentTask.priority ? (parseInt(currentTask.priority.id) as TaskPriority) : undefined,
due_date: currentTask.due_date ? parseInt(currentTask.due_date) : undefined,
start_date: currentTask.start_date ? parseInt(currentTask.start_date) : undefined,
assignees: currentTask.assignees?.map(a => a.id)
};
// Create a new task in the target list with the same data
const newTask = await this.createTask(listId, taskData);
// Return the new task
return newTask;
});
}
/**
* Deletes a list.
* @param listId - ID of the list to delete
* @returns Promise resolving when deletion is complete
* @throws Error if the API request fails
*/
async deleteList(listId: string): Promise<void> {
return this.makeRequest(async () => {
await this.client.delete(`/list/${listId}`);
});
}
/**
* Updates an existing list.
* @param listId - ID of the list to update
* @param data - Partial list data to update
* @returns Promise resolving to the updated ClickUpList
* @throws Error if the API request fails
*/
async updateList(listId: string, data: Partial<CreateListData>): Promise<ClickUpList> {
return this.makeRequest(async () => {
const response = await this.client.put(`/list/${listId}`, data);
return response.data;
});
}
/**
* Finds a list by name in a specific space.
* Performs case-insensitive matching.
* @param spaceId - ID of the space to search in
* @param listName - Name of the list to find
* @returns Promise resolving to ClickUpList object or null if not found
*/
async findListByName(spaceId: string, listName: string): Promise<ClickUpList | null> {
const lists = await this.getLists(spaceId);
return lists.find(list => list.name.toLowerCase() === listName.toLowerCase()) || null;
}
async findListByNameGlobally(listName: string): Promise<ClickUpList | null> {
// First try the direct lists
const lists = await this.getAllLists(this.clickupTeamId);
const directList = lists.find(list => list.name.toLowerCase() === listName.toLowerCase());
if (directList) return directList;
// If not found, search through folders
const hierarchy = await this.getWorkspaceHierarchy();
return this.findListByNameInHierarchy(hierarchy, listName);
}
/**
* Gets the complete workspace hierarchy as a tree structure.
* The tree consists of:
* - Root (Workspace)
* - Spaces
* - Lists (directly in space)
* - Folders
* - Lists (in folders)
*
* Each node in the tree contains:
* - id: Unique identifier
* - name: Display name
* - type: 'space' | 'folder' | 'list'
* - parent: Reference to parent node (except root)
* - children: Array of child nodes
* - data: Original ClickUp object data
*
* @returns Promise resolving to the complete workspace tree
* @throws Error if API requests fail
*/
async getWorkspaceHierarchy(): Promise<WorkspaceTree> {
const spaces = await this.getSpaces(this.clickupTeamId);
const root: WorkspaceTree['root'] = {
id: this.clickupTeamId,
name: 'Workspace',
type: 'workspace',
children: []
};
// Build the tree
for (const space of spaces) {
const spaceNode: WorkspaceNode = {
id: space.id,
name: space.name,
type: 'space',
children: [],
data: space
};
root.children.push(spaceNode);
// Add lists directly in the space
const spaceLists = await this.getLists(space.id);
for (const list of spaceLists) {
const listNode: WorkspaceNode = {
id: list.id,
name: list.name,
type: 'list',
parent: spaceNode,
children: [],
data: list
};
spaceNode.children.push(listNode);
}
// Add folders and their lists
const folders = await this.getFolders(space.id);
for (const folder of folders) {
const folderNode: WorkspaceNode = {
id: folder.id,
name: folder.name,
type: 'folder',
parent: spaceNode,
children: [],
data: folder
};
spaceNode.children.push(folderNode);
// Add lists in the folder
const folderLists = folder.lists || [];
for (const list of folderLists) {
const listNode: WorkspaceNode = {
id: list.id,
name: list.name,
type: 'list',
parent: folderNode,
children: [],
data: list
};
folderNode.children.push(listNode);
}
}
}
return { root };
}
/**
* Helper method to find a node in the workspace tree by name and type.
* Performs a case-insensitive search through the tree structure.
*
* @private
* @param node - The root node to start searching from
* @param name - The name to search for (case-insensitive)
* @param type - The type of node to find ('space', 'folder', or 'list')
* @returns Object containing:
* - node: The found WorkspaceNode
* - path: Full path to the node (e.g., "Space > Folder > List")
* Or null if no matching node is found
*/
private findNodeInTree(
node: WorkspaceNode | WorkspaceTree['root'],
name: string,
type: 'space' | 'folder' | 'list'
): { node: WorkspaceNode; path: string } | null {
// Check current node if it's a WorkspaceNode
if ('type' in node && node.type === type && node.name.toLowerCase() === name.toLowerCase()) {
return {
node,
path: node.name
};
}
// Search children
for (const child of node.children) {
const result = this.findNodeInTree(child, name, type);
if (result) {
const path = node.type === 'workspace' ? result.path : `${node.name} > ${result.path}`;
return { node: result.node, path };
}
}
return null;
}
/**
* Finds a node by name and type in the workspace hierarchy.
* This is a high-level method that uses findNodeInTree internally.
*
* @param hierarchy - The workspace tree to search in
* @param name - Name of the space/folder/list to find (case-insensitive)
* @param type - Type of node to find ('space', 'folder', or 'list')
* @returns Object containing:
* - id: The ID of the found node
* - path: Full path to the node
* Or null if no matching node is found
*/
findIDByNameInHierarchy(
hierarchy: WorkspaceTree,
name: string,
type: 'space' | 'folder' | 'list'
): { id: string; path: string } | null {
const result = this.findNodeInTree(hierarchy.root, name, type);
if (!result) return null;
return { id: result.node.id, path: result.path };
}
/**
* Retrieves all tasks from the entire workspace using the tree structure.
* Traverses the workspace hierarchy tree and collects tasks from all lists.
* Uses recursive traversal to handle nested folders and lists efficiently.
*
* The process:
* 1. Gets the workspace hierarchy tree
* 2. Recursively processes each node:
* - If it's a list node, fetches and collects its tasks
* - If it has children, processes them recursively
* 3. Returns all collected tasks
*
* @returns Promise resolving to array of all tasks in the workspace
* @throws Error if API requests fail
*/
async getAllTasksInWorkspace(): Promise<ClickUpTask[]> {
const hierarchy = await this.getWorkspaceHierarchy();
const allTasks: ClickUpTask[] = [];
// Helper function to process a node
const processNode = async (node: WorkspaceNode) => {
if (node.type === 'list') {
const { tasks } = await this.getTasks(node.id);
allTasks.push(...tasks);
}
// Process children recursively
for (const child of node.children) {
await processNode(child);
}
};
// Process all spaces
for (const space of hierarchy.root.children) {
await processNode(space);
}
return allTasks;
}
/**
* Finds a list by name in the workspace hierarchy.
* This is a specialized version of findNodeInTree for lists.
*
* @param hierarchy - The workspace tree to search in
* @param listName - Name of the list to find (case-insensitive)
* @returns The found ClickUpList object or null if not found
*/
findListByNameInHierarchy(hierarchy: WorkspaceTree, listName: string): ClickUpList | null {
const result = this.findNodeInTree(hierarchy.root, listName, 'list');
if (!result) return null;
return result.node.data as ClickUpList;
}
/**
* Helper method to find a space ID by name.
* Uses the tree structure for efficient lookup.
*
* @param spaceName - Name of the space to find (case-insensitive)
* @returns Promise resolving to the space ID or null if not found
*/
async findSpaceIDByName(spaceName: string): Promise<string | null> {
const hierarchy = await this.getWorkspaceHierarchy();
const result = this.findIDByNameInHierarchy(hierarchy, spaceName, 'space');
return result?.id || null;
}
/**
* Helper method to find a folder ID and its path by name.
* Uses the tree structure for efficient lookup.
*
* @param folderName - Name of the folder to find (case-insensitive)
* @returns Promise resolving to object containing:
* - id: The folder ID
* - spacePath: Full path including the parent space
* Or null if not found
*/
async findFolderIDByName(folderName: string): Promise<{ id: string; spacePath: string } | null> {
const hierarchy = await this.getWorkspaceHierarchy();
const result = this.findNodeInTree(hierarchy.root, folderName, 'folder');
return result ? { id: result.node.id, spacePath: result.path } : null;
}
/**
* Helper method to find a list ID and its path by name.
* Uses the tree structure for efficient lookup.
*
* @param listName - Name of the list to find (case-insensitive)
* @returns Promise resolving to object containing:
* - id: The list ID
* - path: Full path including parent space and folder (if any)
* Or null if not found
*/
async findListIDByName(listName: string): Promise<{ id: string; path: string } | null> {
const hierarchy = await this.getWorkspaceHierarchy();
const result = this.findNodeInTree(hierarchy.root, listName, 'list');
return result ? { id: result.node.id, path: result.path } : null;
}
/**
* Helper method to find a task by name, optionally within a specific list.
* Uses case-insensitive matching and returns full path information.
*
* @param taskName - Name of the task to find (case-insensitive)
* @param listId - Optional: ID of the list to search in
* @param listName - Optional: Name of the list to search in (alternative to listId)
* @returns Promise resolving to object containing:
* - id: The task ID
* - path: Full path including space, folder (if any), list, and task name
* Or null if not found
*/
async findTaskByName(
taskName: string,
listId?: string,
listName?: string
): Promise<{ id: string; path: string } | null> {
// If listName is provided, get the listId first
if (!listId && listName) {
const result = await this.findListIDByName(listName);
if (!result) return null;
listId = result.id;
}
// Get tasks from specific list or all tasks
const tasks = listId
? (await this.getTasks(listId)).tasks
: await this.getAllTasksInWorkspace();
// Find matching task (case-insensitive)
const task = tasks.find(t => t.name.toLowerCase() === taskName.toLowerCase());
if (!task) return null;
// Get the full path
const path = task.folder?.name
? `${task.space.name} > ${task.folder.name} > ${task.list.name} > ${task.name}`
: `${task.space.name} > ${task.list.name} > ${task.name}`;
return { id: task.id, path };
}
}