@taazkareem/clickup-mcp-server 0.4.49 → 0.4.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,15 @@
1
1
  import axios from 'axios';
2
+ /**
3
+ * Service class for interacting with the ClickUp API.
4
+ * Handles all API requests and data transformations.
5
+ */
2
6
  export class ClickUpService {
3
7
  client;
4
8
  static instance;
5
- constructor(apiKey) {
9
+ clickupTeamId;
10
+ rateLimitRemaining = 100; // Default to lowest tier limit
11
+ rateLimitReset = 0;
12
+ constructor(apiKey, clickupTeamId) {
6
13
  this.client = axios.create({
7
14
  baseURL: 'https://api.clickup.com/api/v2',
8
15
  headers: {
@@ -10,13 +17,77 @@ export class ClickUpService {
10
17
  'Content-Type': 'application/json'
11
18
  }
12
19
  });
20
+ // Add response interceptor for rate limit handling
21
+ this.client.interceptors.response.use((response) => {
22
+ // Update rate limit info from headers
23
+ this.rateLimitRemaining = parseInt(response.headers['x-ratelimit-remaining'] || '100');
24
+ this.rateLimitReset = parseInt(response.headers['x-ratelimit-reset'] || '0');
25
+ return response;
26
+ }, async (error) => {
27
+ if (error.response?.status === 429) {
28
+ const resetTime = parseInt(error.response.headers['x-ratelimit-reset'] || '0');
29
+ const waitTime = Math.max(0, resetTime - Math.floor(Date.now() / 1000));
30
+ console.warn(`Rate limit exceeded. Waiting ${waitTime} seconds before retrying...`);
31
+ // Wait until rate limit resets
32
+ await new Promise(resolve => setTimeout(resolve, waitTime * 1000));
33
+ // Retry the request
34
+ return this.client.request(error.config);
35
+ }
36
+ throw error;
37
+ });
38
+ this.clickupTeamId = clickupTeamId;
39
+ }
40
+ /**
41
+ * Checks if we're close to hitting rate limits and waits if necessary.
42
+ * @private
43
+ */
44
+ async checkRateLimit() {
45
+ if (this.rateLimitRemaining <= 5) { // Buffer of 5 requests
46
+ const now = Math.floor(Date.now() / 1000);
47
+ const waitTime = Math.max(0, this.rateLimitReset - now);
48
+ if (waitTime > 0) {
49
+ console.warn(`Approaching rate limit. Waiting ${waitTime} seconds...`);
50
+ await new Promise(resolve => setTimeout(resolve, waitTime * 1000));
51
+ }
52
+ }
53
+ }
54
+ /**
55
+ * Makes an API request with rate limit handling.
56
+ * @private
57
+ * @param requestFn - Function that makes the actual API request
58
+ * @returns The API response
59
+ */
60
+ async makeRequest(requestFn) {
61
+ await this.checkRateLimit();
62
+ try {
63
+ return await requestFn();
64
+ }
65
+ catch (error) {
66
+ if (error.response?.status === 429) {
67
+ // Let the interceptor handle it
68
+ throw error;
69
+ }
70
+ throw error;
71
+ }
13
72
  }
14
- static initialize(apiKey) {
73
+ /**
74
+ * Initializes the ClickUpService singleton instance.
75
+ * @param apiKey - The ClickUp API key for authentication
76
+ * @param clickupTeamId - The team/workspace ID to operate on
77
+ * @returns The singleton instance of ClickUpService
78
+ * @throws Error if initialization fails
79
+ */
80
+ static initialize(apiKey, clickupTeamId) {
15
81
  if (!ClickUpService.instance) {
16
- ClickUpService.instance = new ClickUpService(apiKey);
82
+ ClickUpService.instance = new ClickUpService(apiKey, clickupTeamId);
17
83
  }
18
84
  return ClickUpService.instance;
19
85
  }
86
+ /**
87
+ * Gets the singleton instance of ClickUpService.
88
+ * @returns The singleton instance of ClickUpService
89
+ * @throws Error if service hasn't been initialized
90
+ */
20
91
  static getInstance() {
21
92
  if (!ClickUpService.instance) {
22
93
  throw new Error('ClickUpService not initialized. Call initialize() first.');
@@ -24,140 +95,626 @@ export class ClickUpService {
24
95
  return ClickUpService.instance;
25
96
  }
26
97
  // Tasks
27
- async getTasks(listId) {
28
- const response = await this.client.get(`/list/${listId}/task`);
29
- const tasks = response.data.tasks;
30
- const statuses = [...new Set(tasks.map((task) => task.status.status))];
31
- return { tasks, statuses };
98
+ /**
99
+ * Retrieves tasks from a specific list with optional filtering.
100
+ * Handles rate limiting automatically.
101
+ * @param listId - The ID of the list to fetch tasks from
102
+ * @param filters - Optional filters to apply to the task query
103
+ * @param filters.archived - Include archived tasks
104
+ * @param filters.page - Page number for pagination
105
+ * @param filters.order_by - Field to order tasks by
106
+ * @param filters.reverse - Reverse the order of tasks
107
+ * @param filters.subtasks - Include subtasks
108
+ * @param filters.statuses - Filter by specific statuses
109
+ * @param filters.include_closed - Include closed tasks
110
+ * @param filters.assignees - Filter by assignee IDs
111
+ * @param filters.due_date_gt - Tasks due after this timestamp
112
+ * @param filters.due_date_lt - Tasks due before this timestamp
113
+ * @param filters.date_created_gt - Tasks created after this timestamp
114
+ * @param filters.date_created_lt - Tasks created before this timestamp
115
+ * @param filters.date_updated_gt - Tasks updated after this timestamp
116
+ * @param filters.date_updated_lt - Tasks updated before this timestamp
117
+ * @param filters.custom_fields - Filter by custom field values
118
+ * @returns Object containing tasks array and available statuses
119
+ * @throws Error if the API request fails
120
+ */
121
+ async getTasks(listId, filters) {
122
+ return this.makeRequest(async () => {
123
+ const params = new URLSearchParams();
124
+ if (filters) {
125
+ if (filters.archived !== undefined)
126
+ params.append('archived', filters.archived.toString());
127
+ if (filters.page !== undefined)
128
+ params.append('page', filters.page.toString());
129
+ if (filters.order_by)
130
+ params.append('order_by', filters.order_by);
131
+ if (filters.reverse !== undefined)
132
+ params.append('reverse', filters.reverse.toString());
133
+ if (filters.subtasks !== undefined)
134
+ params.append('subtasks', filters.subtasks.toString());
135
+ if (filters.statuses)
136
+ params.append('statuses[]', filters.statuses.join(','));
137
+ if (filters.include_closed !== undefined)
138
+ params.append('include_closed', filters.include_closed.toString());
139
+ if (filters.assignees)
140
+ params.append('assignees[]', filters.assignees.join(','));
141
+ if (filters.due_date_gt)
142
+ params.append('due_date_gt', filters.due_date_gt.toString());
143
+ if (filters.due_date_lt)
144
+ params.append('due_date_lt', filters.due_date_lt.toString());
145
+ if (filters.date_created_gt)
146
+ params.append('date_created_gt', filters.date_created_gt.toString());
147
+ if (filters.date_created_lt)
148
+ params.append('date_created_lt', filters.date_created_lt.toString());
149
+ if (filters.date_updated_gt)
150
+ params.append('date_updated_gt', filters.date_updated_gt.toString());
151
+ if (filters.date_updated_lt)
152
+ params.append('date_updated_lt', filters.date_updated_lt.toString());
153
+ if (filters.custom_fields) {
154
+ Object.entries(filters.custom_fields).forEach(([key, value]) => {
155
+ params.append(`custom_fields[${key}]`, JSON.stringify(value));
156
+ });
157
+ }
158
+ }
159
+ const queryString = params.toString();
160
+ const url = `/list/${listId}/task${queryString ? `?${queryString}` : ''}`;
161
+ const response = await this.client.get(url);
162
+ const tasks = response.data.tasks;
163
+ const statuses = [...new Set(tasks
164
+ .filter((task) => task.status !== undefined)
165
+ .map((task) => task.status.status))];
166
+ return { tasks, statuses };
167
+ });
32
168
  }
169
+ /**
170
+ * Retrieves detailed information about a specific task.
171
+ * Handles rate limiting automatically.
172
+ */
33
173
  async getTask(taskId) {
34
- const response = await this.client.get(`/task/${taskId}`);
35
- return response.data;
174
+ return this.makeRequest(async () => {
175
+ const response = await this.client.get(`/task/${taskId}`);
176
+ return response.data;
177
+ });
36
178
  }
179
+ /**
180
+ * Creates a new task in a specified list.
181
+ * Handles rate limiting automatically.
182
+ */
37
183
  async createTask(listId, data) {
38
- const response = await this.client.post(`/list/${listId}/task`, data);
39
- return response.data;
184
+ return this.makeRequest(async () => {
185
+ const taskData = { ...data };
186
+ if (taskData.description && /[#*`\-\[\]>]/.test(taskData.description)) {
187
+ taskData.markdown_description = taskData.description;
188
+ delete taskData.description;
189
+ }
190
+ const response = await this.client.post(`/list/${listId}/task`, taskData);
191
+ return response.data;
192
+ });
40
193
  }
194
+ /**
195
+ * Creates multiple tasks in a list sequentially to avoid rate limits.
196
+ * Automatically handles rate limiting and retries.
197
+ */
41
198
  async createBulkTasks(listId, data) {
42
- const tasks = await Promise.all(data.tasks.map(taskData => this.createTask(listId, taskData)));
43
- return tasks;
199
+ const createdTasks = [];
200
+ for (const taskData of data.tasks) {
201
+ await this.makeRequest(async () => {
202
+ const processedTask = { ...taskData };
203
+ if (processedTask.description && /[#*`\-\[\]>]/.test(processedTask.description)) {
204
+ processedTask.markdown_description = processedTask.description;
205
+ delete processedTask.description;
206
+ }
207
+ const response = await this.client.post(`/list/${listId}/task`, processedTask);
208
+ createdTasks.push(response.data);
209
+ });
210
+ }
211
+ return createdTasks;
44
212
  }
213
+ /**
214
+ * Updates an existing task with new data.
215
+ * Handles rate limiting automatically.
216
+ */
45
217
  async updateTask(taskId, data) {
46
- const response = await this.client.put(`/task/${taskId}`, data);
47
- return response.data;
218
+ return this.makeRequest(async () => {
219
+ const response = await this.client.put(`/task/${taskId}`, data);
220
+ return response.data;
221
+ });
48
222
  }
223
+ /**
224
+ * Deletes a task from the workspace.
225
+ * Handles rate limiting automatically.
226
+ */
49
227
  async deleteTask(taskId) {
50
- await this.client.delete(`/task/${taskId}`);
228
+ return this.makeRequest(async () => {
229
+ await this.client.delete(`/task/${taskId}`);
230
+ });
51
231
  }
52
232
  // Lists
233
+ /**
234
+ * Gets all lists in a space.
235
+ * @param spaceId - ID of the space to get lists from
236
+ * @returns Promise resolving to array of ClickUpList objects
237
+ * @throws Error if the API request fails
238
+ */
53
239
  async getLists(spaceId) {
54
- const response = await this.client.get(`/space/${spaceId}/list`);
55
- return response.data.lists;
240
+ return this.makeRequest(async () => {
241
+ const response = await this.client.get(`/space/${spaceId}/list`);
242
+ return response.data.lists;
243
+ });
56
244
  }
57
- async getAllLists(teamId) {
58
- const response = await this.client.get(`/team/${teamId}/list`);
59
- return response.data.lists;
245
+ /**
246
+ * Gets all lists in the workspace.
247
+ * @param clickupTeamId - ID of the team/workspace
248
+ * @returns Promise resolving to array of ClickUpList objects
249
+ * @throws Error if the API request fails
250
+ */
251
+ async getAllLists(clickupTeamId) {
252
+ return this.makeRequest(async () => {
253
+ const response = await this.client.get(`/team/${clickupTeamId}/list`);
254
+ return response.data.lists;
255
+ });
60
256
  }
257
+ /**
258
+ * Gets a specific list by ID.
259
+ * @param listId - ID of the list to retrieve
260
+ * @returns Promise resolving to ClickUpList object
261
+ * @throws Error if the API request fails or list not found
262
+ */
61
263
  async getList(listId) {
62
- const response = await this.client.get(`/list/${listId}`);
63
- return response.data;
264
+ return this.makeRequest(async () => {
265
+ const response = await this.client.get(`/list/${listId}`);
266
+ return response.data;
267
+ });
64
268
  }
65
269
  // Spaces
66
- async getSpaces(teamId) {
67
- const response = await this.client.get(`/team/${teamId}/space`);
68
- return response.data.spaces;
270
+ async getSpaces(clickupTeamId) {
271
+ return this.makeRequest(async () => {
272
+ const response = await this.client.get(`/team/${clickupTeamId}/space`);
273
+ return response.data.spaces;
274
+ });
69
275
  }
70
276
  async getSpace(spaceId) {
71
- const response = await this.client.get(`/space/${spaceId}`);
72
- return response.data;
277
+ return this.makeRequest(async () => {
278
+ const response = await this.client.get(`/space/${spaceId}`);
279
+ return response.data;
280
+ });
73
281
  }
74
- async findSpaceByName(teamId, spaceName) {
75
- const spaces = await this.getSpaces(teamId);
282
+ async findSpaceByName(clickupTeamId, spaceName) {
283
+ const spaces = await this.getSpaces(clickupTeamId);
76
284
  return spaces.find(space => space.name.toLowerCase() === spaceName.toLowerCase()) || null;
77
285
  }
286
+ /**
287
+ * Creates a new list in a space.
288
+ * @param spaceId - ID of the space to create the list in
289
+ * @param data - List creation data (name, content, due date, etc.)
290
+ * @returns Promise resolving to the created ClickUpList
291
+ * @throws Error if the API request fails
292
+ */
78
293
  async createList(spaceId, data) {
79
- const response = await this.client.post(`/space/${spaceId}/list`, data);
80
- return response.data;
294
+ return this.makeRequest(async () => {
295
+ const response = await this.client.post(`/space/${spaceId}/list`, data);
296
+ return response.data;
297
+ });
81
298
  }
82
299
  // Folders
83
300
  async getFolders(spaceId) {
84
- const response = await this.client.get(`/space/${spaceId}/folder`);
85
- return response.data.folders;
301
+ return this.makeRequest(async () => {
302
+ const response = await this.client.get(`/space/${spaceId}/folder`);
303
+ return response.data.folders;
304
+ });
86
305
  }
87
306
  async getFolder(folderId) {
88
- const response = await this.client.get(`/folder/${folderId}`);
89
- return response.data;
307
+ return this.makeRequest(async () => {
308
+ const response = await this.client.get(`/folder/${folderId}`);
309
+ return response.data;
310
+ });
90
311
  }
91
312
  async createFolder(spaceId, data) {
92
- const response = await this.client.post(`/space/${spaceId}/folder`, data);
93
- return response.data;
313
+ return this.makeRequest(async () => {
314
+ const response = await this.client.post(`/space/${spaceId}/folder`, data);
315
+ return response.data;
316
+ });
94
317
  }
95
318
  async deleteFolder(folderId) {
96
- await this.client.delete(`/folder/${folderId}`);
319
+ return this.makeRequest(async () => {
320
+ await this.client.delete(`/folder/${folderId}`);
321
+ });
97
322
  }
323
+ /**
324
+ * Creates a new list in a folder.
325
+ * @param folderId - ID of the folder to create the list in
326
+ * @param data - List creation data (name, content, etc.)
327
+ * @returns Promise resolving to the created ClickUpList
328
+ * @throws Error if the API request fails
329
+ */
98
330
  async createListInFolder(folderId, data) {
99
- const response = await this.client.post(`/folder/${folderId}/list`, data);
100
- return response.data;
331
+ return this.makeRequest(async () => {
332
+ const response = await this.client.post(`/folder/${folderId}/list`, data);
333
+ return response.data;
334
+ });
101
335
  }
102
336
  async findFolderByName(spaceId, folderName) {
103
337
  const folders = await this.getFolders(spaceId);
104
338
  return folders.find(folder => folder.name.toLowerCase() === folderName.toLowerCase()) || null;
105
339
  }
106
340
  // Additional helper methods
341
+ /**
342
+ * Moves a task to a different list.
343
+ * Since direct task moving is not supported by the ClickUp API,
344
+ * this creates a new task in the target list and deletes the original.
345
+ *
346
+ * @param taskId - ID of the task to move
347
+ * @param listId - ID of the destination list
348
+ * @returns Promise resolving to the new task in its new location
349
+ * @throws Error if the API request fails
350
+ */
107
351
  async moveTask(taskId, listId) {
108
- const response = await this.client.post(`/task/${taskId}`, {
109
- list: listId
352
+ return this.makeRequest(async () => {
353
+ // Get the current task to copy all its data
354
+ const currentTask = await this.getTask(taskId);
355
+ // Get available statuses in the target list
356
+ const { statuses: targetStatuses } = await this.getTasks(listId);
357
+ // Check if current status exists in target list
358
+ const currentStatus = currentTask.status?.status;
359
+ const statusExists = currentStatus && targetStatuses.includes(currentStatus);
360
+ // Prepare the task data for the new location
361
+ const moveData = {
362
+ name: currentTask.name,
363
+ description: currentTask.description,
364
+ markdown_description: currentTask.description, // In case it contains markdown
365
+ status: statusExists ? currentStatus : undefined, // Only set status if it exists in target list
366
+ priority: currentTask.priority ? parseInt(currentTask.priority.id) : undefined,
367
+ due_date: currentTask.due_date ? parseInt(currentTask.due_date) : undefined,
368
+ start_date: currentTask.start_date ? parseInt(currentTask.start_date) : undefined,
369
+ assignees: currentTask.assignees?.map(a => a.id)
370
+ };
371
+ // Create a new task in the target list with the same data
372
+ const newTask = await this.createTask(listId, moveData);
373
+ // Delete the original task
374
+ await this.deleteTask(taskId);
375
+ // Return the new task
376
+ return newTask;
110
377
  });
111
- return response.data;
112
378
  }
113
- async findListByNameGlobally(teamId, listName) {
114
- const spaces = await this.getSpaces(teamId);
379
+ /**
380
+ * Duplicates a task to another list.
381
+ * Creates a new task with the same data in the target list.
382
+ *
383
+ * @param taskId - ID of the task to duplicate
384
+ * @param listId - ID of the destination list
385
+ * @returns Promise resolving to the new duplicate task
386
+ * @throws Error if the API request fails
387
+ */
388
+ async duplicateTask(taskId, listId) {
389
+ return this.makeRequest(async () => {
390
+ // Get the current task to copy all its data
391
+ const currentTask = await this.getTask(taskId);
392
+ // Get available statuses in the target list
393
+ const { statuses: targetStatuses } = await this.getTasks(listId);
394
+ // Check if current status exists in target list
395
+ const currentStatus = currentTask.status?.status;
396
+ const statusExists = currentStatus && targetStatuses.includes(currentStatus);
397
+ // Prepare the task data for duplication
398
+ const taskData = {
399
+ name: currentTask.name,
400
+ description: currentTask.description,
401
+ markdown_description: currentTask.description, // In case it contains markdown
402
+ status: statusExists ? currentStatus : undefined, // Only set status if it exists in target list
403
+ priority: currentTask.priority ? parseInt(currentTask.priority.id) : undefined,
404
+ due_date: currentTask.due_date ? parseInt(currentTask.due_date) : undefined,
405
+ start_date: currentTask.start_date ? parseInt(currentTask.start_date) : undefined,
406
+ assignees: currentTask.assignees?.map(a => a.id)
407
+ };
408
+ // Create a new task in the target list with the same data
409
+ const newTask = await this.createTask(listId, taskData);
410
+ // Return the new task
411
+ return newTask;
412
+ });
413
+ }
414
+ /**
415
+ * Deletes a list.
416
+ * @param listId - ID of the list to delete
417
+ * @returns Promise resolving when deletion is complete
418
+ * @throws Error if the API request fails
419
+ */
420
+ async deleteList(listId) {
421
+ return this.makeRequest(async () => {
422
+ await this.client.delete(`/list/${listId}`);
423
+ });
424
+ }
425
+ /**
426
+ * Updates an existing list.
427
+ * @param listId - ID of the list to update
428
+ * @param data - Partial list data to update
429
+ * @returns Promise resolving to the updated ClickUpList
430
+ * @throws Error if the API request fails
431
+ */
432
+ async updateList(listId, data) {
433
+ return this.makeRequest(async () => {
434
+ const response = await this.client.put(`/list/${listId}`, data);
435
+ return response.data;
436
+ });
437
+ }
438
+ /**
439
+ * Finds a list by name in a specific space.
440
+ * Performs case-insensitive matching.
441
+ * @param spaceId - ID of the space to search in
442
+ * @param listName - Name of the list to find
443
+ * @returns Promise resolving to ClickUpList object or null if not found
444
+ */
445
+ async findListByName(spaceId, listName) {
446
+ const lists = await this.getLists(spaceId);
447
+ return lists.find(list => list.name.toLowerCase() === listName.toLowerCase()) || null;
448
+ }
449
+ async findListByNameGlobally(listName) {
450
+ // First try the direct lists
451
+ const lists = await this.getAllLists(this.clickupTeamId);
452
+ const directList = lists.find(list => list.name.toLowerCase() === listName.toLowerCase());
453
+ if (directList)
454
+ return directList;
455
+ // If not found, search through folders
456
+ const hierarchy = await this.getWorkspaceHierarchy();
457
+ return this.findListByNameInHierarchy(hierarchy, listName);
458
+ }
459
+ /**
460
+ * Gets the complete workspace hierarchy as a tree structure.
461
+ * The tree consists of:
462
+ * - Root (Workspace)
463
+ * - Spaces
464
+ * - Lists (directly in space)
465
+ * - Folders
466
+ * - Lists (in folders)
467
+ *
468
+ * Each node in the tree contains:
469
+ * - id: Unique identifier
470
+ * - name: Display name
471
+ * - type: 'space' | 'folder' | 'list'
472
+ * - parent: Reference to parent node (except root)
473
+ * - children: Array of child nodes
474
+ * - data: Original ClickUp object data
475
+ *
476
+ * @returns Promise resolving to the complete workspace tree
477
+ * @throws Error if API requests fail
478
+ */
479
+ async getWorkspaceHierarchy() {
480
+ const spaces = await this.getSpaces(this.clickupTeamId);
481
+ const root = {
482
+ id: this.clickupTeamId,
483
+ name: 'Workspace',
484
+ type: 'workspace',
485
+ children: []
486
+ };
487
+ // Build the tree
115
488
  for (const space of spaces) {
116
- // Check lists in folders
489
+ const spaceNode = {
490
+ id: space.id,
491
+ name: space.name,
492
+ type: 'space',
493
+ children: [],
494
+ data: space
495
+ };
496
+ root.children.push(spaceNode);
497
+ // Add lists directly in the space
498
+ const spaceLists = await this.getLists(space.id);
499
+ for (const list of spaceLists) {
500
+ const listNode = {
501
+ id: list.id,
502
+ name: list.name,
503
+ type: 'list',
504
+ parent: spaceNode,
505
+ children: [],
506
+ data: list
507
+ };
508
+ spaceNode.children.push(listNode);
509
+ }
510
+ // Add folders and their lists
117
511
  const folders = await this.getFolders(space.id);
118
512
  for (const folder of folders) {
119
- const folderList = folder.lists?.find(list => list.name.toLowerCase() === listName.toLowerCase());
120
- if (folderList) {
121
- return { list: folderList, space, folder };
513
+ const folderNode = {
514
+ id: folder.id,
515
+ name: folder.name,
516
+ type: 'folder',
517
+ parent: spaceNode,
518
+ children: [],
519
+ data: folder
520
+ };
521
+ spaceNode.children.push(folderNode);
522
+ // Add lists in the folder
523
+ const folderLists = folder.lists || [];
524
+ for (const list of folderLists) {
525
+ const listNode = {
526
+ id: list.id,
527
+ name: list.name,
528
+ type: 'list',
529
+ parent: folderNode,
530
+ children: [],
531
+ data: list
532
+ };
533
+ folderNode.children.push(listNode);
122
534
  }
123
535
  }
124
- // Check lists directly in space
125
- const spaceLists = await this.getLists(space.id);
126
- const spaceList = spaceLists.find(list => list.name.toLowerCase() === listName.toLowerCase());
127
- if (spaceList) {
128
- return { list: spaceList, space };
129
- }
130
536
  }
131
- // Check lists without spaces
132
- const allLists = await this.getAllLists(teamId);
133
- const list = allLists.find(list => list.name.toLowerCase() === listName.toLowerCase());
134
- if (list) {
135
- return { list };
537
+ return { root };
538
+ }
539
+ /**
540
+ * Helper method to find a node in the workspace tree by name and type.
541
+ * Performs a case-insensitive search through the tree structure.
542
+ *
543
+ * @private
544
+ * @param node - The root node to start searching from
545
+ * @param name - The name to search for (case-insensitive)
546
+ * @param type - The type of node to find ('space', 'folder', or 'list')
547
+ * @returns Object containing:
548
+ * - node: The found WorkspaceNode
549
+ * - path: Full path to the node (e.g., "Space > Folder > List")
550
+ * Or null if no matching node is found
551
+ */
552
+ findNodeInTree(node, name, type) {
553
+ // Check current node if it's a WorkspaceNode
554
+ if ('type' in node && node.type === type && node.name.toLowerCase() === name.toLowerCase()) {
555
+ return {
556
+ node,
557
+ path: node.name
558
+ };
559
+ }
560
+ // Search children
561
+ for (const child of node.children) {
562
+ const result = this.findNodeInTree(child, name, type);
563
+ if (result) {
564
+ const path = node.type === 'workspace' ? result.path : `${node.name} > ${result.path}`;
565
+ return { node: result.node, path };
566
+ }
136
567
  }
137
568
  return null;
138
569
  }
139
- async findFolderByNameGlobally(teamId, folderName) {
140
- const spaces = await this.getSpaces(teamId);
141
- for (const space of spaces) {
142
- const folders = await this.getFolders(space.id);
143
- const folder = folders.find(folder => folder.name.toLowerCase() === folderName.toLowerCase());
144
- if (folder) {
145
- return { folder, space };
570
+ /**
571
+ * Finds a node by name and type in the workspace hierarchy.
572
+ * This is a high-level method that uses findNodeInTree internally.
573
+ *
574
+ * @param hierarchy - The workspace tree to search in
575
+ * @param name - Name of the space/folder/list to find (case-insensitive)
576
+ * @param type - Type of node to find ('space', 'folder', or 'list')
577
+ * @returns Object containing:
578
+ * - id: The ID of the found node
579
+ * - path: Full path to the node
580
+ * Or null if no matching node is found
581
+ */
582
+ findIDByNameInHierarchy(hierarchy, name, type) {
583
+ const result = this.findNodeInTree(hierarchy.root, name, type);
584
+ if (!result)
585
+ return null;
586
+ return { id: result.node.id, path: result.path };
587
+ }
588
+ /**
589
+ * Retrieves all tasks from the entire workspace using the tree structure.
590
+ * Traverses the workspace hierarchy tree and collects tasks from all lists.
591
+ * Uses recursive traversal to handle nested folders and lists efficiently.
592
+ *
593
+ * The process:
594
+ * 1. Gets the workspace hierarchy tree
595
+ * 2. Recursively processes each node:
596
+ * - If it's a list node, fetches and collects its tasks
597
+ * - If it has children, processes them recursively
598
+ * 3. Returns all collected tasks
599
+ *
600
+ * @returns Promise resolving to array of all tasks in the workspace
601
+ * @throws Error if API requests fail
602
+ */
603
+ async getAllTasksInWorkspace() {
604
+ const hierarchy = await this.getWorkspaceHierarchy();
605
+ const allTasks = [];
606
+ // Helper function to process a node
607
+ const processNode = async (node) => {
608
+ if (node.type === 'list') {
609
+ const { tasks } = await this.getTasks(node.id);
610
+ allTasks.push(...tasks);
611
+ }
612
+ // Process children recursively
613
+ for (const child of node.children) {
614
+ await processNode(child);
146
615
  }
616
+ };
617
+ // Process all spaces
618
+ for (const space of hierarchy.root.children) {
619
+ await processNode(space);
147
620
  }
148
- return null;
621
+ return allTasks;
149
622
  }
150
- async duplicateTask(taskId, listId) {
151
- const response = await this.client.post(`/task/${taskId}/duplicate`, {
152
- list: listId
153
- });
154
- return response.data;
623
+ /**
624
+ * Finds a list by name in the workspace hierarchy.
625
+ * This is a specialized version of findNodeInTree for lists.
626
+ *
627
+ * @param hierarchy - The workspace tree to search in
628
+ * @param listName - Name of the list to find (case-insensitive)
629
+ * @returns The found ClickUpList object or null if not found
630
+ */
631
+ findListByNameInHierarchy(hierarchy, listName) {
632
+ const result = this.findNodeInTree(hierarchy.root, listName, 'list');
633
+ if (!result)
634
+ return null;
635
+ return result.node.data;
155
636
  }
156
- async deleteList(listId) {
157
- await this.client.delete(`/list/${listId}`);
637
+ /**
638
+ * Helper method to find a space ID by name.
639
+ * Uses the tree structure for efficient lookup.
640
+ *
641
+ * @param spaceName - Name of the space to find (case-insensitive)
642
+ * @returns Promise resolving to the space ID or null if not found
643
+ */
644
+ async findSpaceIDByName(spaceName) {
645
+ const hierarchy = await this.getWorkspaceHierarchy();
646
+ const result = this.findIDByNameInHierarchy(hierarchy, spaceName, 'space');
647
+ return result?.id || null;
158
648
  }
159
- async updateList(listId, data) {
160
- const response = await this.client.put(`/list/${listId}`, data);
161
- return response.data;
649
+ /**
650
+ * Helper method to find a folder ID and its path by name.
651
+ * Uses the tree structure for efficient lookup.
652
+ *
653
+ * @param folderName - Name of the folder to find (case-insensitive)
654
+ * @returns Promise resolving to object containing:
655
+ * - id: The folder ID
656
+ * - spacePath: Full path including the parent space
657
+ * Or null if not found
658
+ */
659
+ async findFolderIDByName(folderName) {
660
+ const hierarchy = await this.getWorkspaceHierarchy();
661
+ const result = this.findNodeInTree(hierarchy.root, folderName, 'folder');
662
+ return result ? { id: result.node.id, spacePath: result.path } : null;
663
+ }
664
+ /**
665
+ * Helper method to find a list ID and its path by name.
666
+ * Uses the tree structure for efficient lookup.
667
+ *
668
+ * @param listName - Name of the list to find (case-insensitive)
669
+ * @returns Promise resolving to object containing:
670
+ * - id: The list ID
671
+ * - path: Full path including parent space and folder (if any)
672
+ * Or null if not found
673
+ */
674
+ async findListIDByName(listName) {
675
+ const hierarchy = await this.getWorkspaceHierarchy();
676
+ const result = this.findNodeInTree(hierarchy.root, listName, 'list');
677
+ return result ? { id: result.node.id, path: result.path } : null;
678
+ }
679
+ /**
680
+ * Helper method to find a task by name, optionally within a specific list.
681
+ * Uses case-insensitive matching and returns full path information.
682
+ *
683
+ * @param taskName - Name of the task to find (case-insensitive)
684
+ * @param listId - Optional: ID of the list to search in
685
+ * @param listName - Optional: Name of the list to search in (alternative to listId)
686
+ * @returns Promise resolving to object containing:
687
+ * - id: The task ID
688
+ * - path: Full path including space, folder (if any), list, and task name
689
+ * Or null if not found
690
+ */
691
+ async findTaskByName(taskName, listId, listName) {
692
+ // If listName is provided, get the listId first
693
+ if (!listId && listName) {
694
+ const result = await this.findListIDByName(listName);
695
+ if (!result)
696
+ return null;
697
+ listId = result.id;
698
+ }
699
+ // Get tasks from specific list or all tasks
700
+ const tasks = listId
701
+ ? (await this.getTasks(listId)).tasks
702
+ : await this.getAllTasksInWorkspace();
703
+ // Find matching task (case-insensitive)
704
+ const task = tasks.find(t => t.name.toLowerCase() === taskName.toLowerCase());
705
+ if (!task)
706
+ return null;
707
+ // Get the full path
708
+ const path = task.folder?.name
709
+ ? `${task.space.name} > ${task.folder.name} > ${task.list.name} > ${task.name}`
710
+ : `${task.space.name} > ${task.list.name} > ${task.name}`;
711
+ return { id: task.id, path };
712
+ }
713
+ async getTaskStatuses(listId) {
714
+ const response = await this.getTasks(listId);
715
+ const statuses = [...new Set(response.tasks
716
+ .filter((task) => task.status !== undefined)
717
+ .map((task) => task.status.status))];
718
+ return statuses;
162
719
  }
163
720
  }