@alanse/clickup-multi-mcp-server 1.0.0
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.
- package/Dockerfile +38 -0
- package/LICENSE +21 -0
- package/README.md +470 -0
- package/build/config.js +237 -0
- package/build/index.js +87 -0
- package/build/logger.js +163 -0
- package/build/middleware/security.js +231 -0
- package/build/server.js +288 -0
- package/build/services/clickup/base.js +432 -0
- package/build/services/clickup/bulk.js +180 -0
- package/build/services/clickup/document.js +159 -0
- package/build/services/clickup/folder.js +136 -0
- package/build/services/clickup/index.js +76 -0
- package/build/services/clickup/list.js +191 -0
- package/build/services/clickup/tag.js +239 -0
- package/build/services/clickup/task/index.js +32 -0
- package/build/services/clickup/task/task-attachments.js +105 -0
- package/build/services/clickup/task/task-comments.js +114 -0
- package/build/services/clickup/task/task-core.js +604 -0
- package/build/services/clickup/task/task-custom-fields.js +107 -0
- package/build/services/clickup/task/task-search.js +986 -0
- package/build/services/clickup/task/task-service.js +104 -0
- package/build/services/clickup/task/task-tags.js +113 -0
- package/build/services/clickup/time.js +244 -0
- package/build/services/clickup/types.js +33 -0
- package/build/services/clickup/workspace.js +397 -0
- package/build/services/shared.js +61 -0
- package/build/sse_server.js +277 -0
- package/build/tools/documents.js +489 -0
- package/build/tools/folder.js +331 -0
- package/build/tools/index.js +16 -0
- package/build/tools/list.js +428 -0
- package/build/tools/member.js +106 -0
- package/build/tools/tag.js +833 -0
- package/build/tools/task/attachments.js +357 -0
- package/build/tools/task/attachments.types.js +9 -0
- package/build/tools/task/bulk-operations.js +338 -0
- package/build/tools/task/handlers.js +919 -0
- package/build/tools/task/index.js +30 -0
- package/build/tools/task/main.js +233 -0
- package/build/tools/task/single-operations.js +469 -0
- package/build/tools/task/time-tracking.js +575 -0
- package/build/tools/task/utilities.js +310 -0
- package/build/tools/task/workspace-operations.js +258 -0
- package/build/tools/tool-enhancer.js +37 -0
- package/build/tools/utils.js +12 -0
- package/build/tools/workspace-helper.js +44 -0
- package/build/tools/workspace.js +73 -0
- package/build/utils/color-processor.js +183 -0
- package/build/utils/concurrency-utils.js +248 -0
- package/build/utils/date-utils.js +542 -0
- package/build/utils/resolver-utils.js +135 -0
- package/build/utils/sponsor-service.js +93 -0
- package/build/utils/token-utils.js +49 -0
- package/package.json +77 -0
- package/smithery.yaml +23 -0
|
@@ -0,0 +1,986 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPDX-FileCopyrightText: © 2025 Talib Kareem <taazkareem@icloud.com>
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*
|
|
5
|
+
* ClickUp Task Service - Search Module
|
|
6
|
+
*
|
|
7
|
+
* Handles search and lookup operations for tasks in ClickUp, including:
|
|
8
|
+
* - Finding tasks by name
|
|
9
|
+
* - Global workspace task lookup
|
|
10
|
+
* - Task summaries and detailed task data
|
|
11
|
+
*/
|
|
12
|
+
import { isNameMatch } from '../../../utils/resolver-utils.js';
|
|
13
|
+
import { findListIDByName } from '../../../tools/list.js';
|
|
14
|
+
import { estimateTokensFromObject, wouldExceedTokenLimit } from '../../../utils/token-utils.js';
|
|
15
|
+
/**
|
|
16
|
+
* Search functionality for the TaskService
|
|
17
|
+
*
|
|
18
|
+
* This service handles all search and lookup operations for ClickUp tasks.
|
|
19
|
+
* It uses composition to access core functionality instead of inheritance.
|
|
20
|
+
*
|
|
21
|
+
* REFACTORED: Now uses composition instead of inheritance.
|
|
22
|
+
* Only depends on TaskServiceCore for base functionality.
|
|
23
|
+
*/
|
|
24
|
+
export class TaskServiceSearch {
|
|
25
|
+
constructor(core) {
|
|
26
|
+
this.core = core;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Find a task by name within a specific list
|
|
30
|
+
* @param listId The ID of the list to search in
|
|
31
|
+
* @param taskName The name of the task to find
|
|
32
|
+
* @returns The task if found, otherwise null
|
|
33
|
+
*/
|
|
34
|
+
async findTaskByName(listId, taskName) {
|
|
35
|
+
this.core.logOperation('findTaskByName', { listId, taskName });
|
|
36
|
+
try {
|
|
37
|
+
const tasks = await this.core.getTasks(listId);
|
|
38
|
+
return this.findTaskInArray(tasks, taskName);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
throw this.core.handleError(error, `Failed to find task by name: ${error instanceof Error ? error.message : String(error)}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Find a task by name from an array of tasks
|
|
46
|
+
* @param taskArray Array of tasks to search in
|
|
47
|
+
* @param name Name of the task to search for
|
|
48
|
+
* @param includeDetails Whether to add list context to task
|
|
49
|
+
* @returns The task that best matches the name, or null if no match
|
|
50
|
+
*/
|
|
51
|
+
findTaskInArray(taskArray, name, includeDetails = false) {
|
|
52
|
+
if (!taskArray || !Array.isArray(taskArray) || taskArray.length === 0 || !name) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
// Get match scores for each task
|
|
56
|
+
const taskMatchScores = taskArray
|
|
57
|
+
.map(task => {
|
|
58
|
+
const matchResult = isNameMatch(task.name, name);
|
|
59
|
+
return {
|
|
60
|
+
task,
|
|
61
|
+
matchResult,
|
|
62
|
+
// Parse the date_updated field as a number for sorting
|
|
63
|
+
updatedAt: task.date_updated ? parseInt(task.date_updated, 10) : 0
|
|
64
|
+
};
|
|
65
|
+
})
|
|
66
|
+
.filter(result => result.matchResult.isMatch);
|
|
67
|
+
if (taskMatchScores.length === 0) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
// First, try to find exact matches
|
|
71
|
+
const exactMatches = taskMatchScores
|
|
72
|
+
.filter(result => result.matchResult.exactMatch)
|
|
73
|
+
.sort((a, b) => {
|
|
74
|
+
// For exact matches with the same score, sort by most recently updated
|
|
75
|
+
if (b.matchResult.score === a.matchResult.score) {
|
|
76
|
+
return b.updatedAt - a.updatedAt;
|
|
77
|
+
}
|
|
78
|
+
return b.matchResult.score - a.matchResult.score;
|
|
79
|
+
});
|
|
80
|
+
// Get the best matches based on whether we have exact matches or need to fall back to fuzzy matches
|
|
81
|
+
const bestMatches = exactMatches.length > 0 ? exactMatches : taskMatchScores.sort((a, b) => {
|
|
82
|
+
// First sort by match score (highest first)
|
|
83
|
+
if (b.matchResult.score !== a.matchResult.score) {
|
|
84
|
+
return b.matchResult.score - a.matchResult.score;
|
|
85
|
+
}
|
|
86
|
+
// Then sort by most recently updated
|
|
87
|
+
return b.updatedAt - a.updatedAt;
|
|
88
|
+
});
|
|
89
|
+
// Get the best match
|
|
90
|
+
const bestMatch = bestMatches[0].task;
|
|
91
|
+
// If we need to include more details
|
|
92
|
+
if (includeDetails) {
|
|
93
|
+
// Include any additional details needed
|
|
94
|
+
}
|
|
95
|
+
return bestMatch;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Formats a task into a lightweight summary format
|
|
99
|
+
* @param task The task to format
|
|
100
|
+
* @returns A TaskSummary object
|
|
101
|
+
*/
|
|
102
|
+
formatTaskSummary(task) {
|
|
103
|
+
return {
|
|
104
|
+
id: task.id,
|
|
105
|
+
name: task.name,
|
|
106
|
+
status: task.status.status,
|
|
107
|
+
list: {
|
|
108
|
+
id: task.list.id,
|
|
109
|
+
name: task.list.name
|
|
110
|
+
},
|
|
111
|
+
due_date: task.due_date,
|
|
112
|
+
url: task.url,
|
|
113
|
+
priority: this.core.extractPriorityValue(task),
|
|
114
|
+
tags: task.tags.map(tag => ({
|
|
115
|
+
name: tag.name,
|
|
116
|
+
tag_bg: tag.tag_bg,
|
|
117
|
+
tag_fg: tag.tag_fg
|
|
118
|
+
}))
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Estimates token count for a task in JSON format
|
|
123
|
+
* @param task ClickUp task
|
|
124
|
+
* @returns Estimated token count
|
|
125
|
+
*/
|
|
126
|
+
estimateTaskTokens(task) {
|
|
127
|
+
return estimateTokensFromObject(task);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Get filtered tasks across the entire team/workspace using tags and other filters
|
|
131
|
+
* @param filters Task filters to apply including tags, list/folder/space filtering
|
|
132
|
+
* @returns Either a DetailedTaskResponse or WorkspaceTasksResponse depending on detail_level
|
|
133
|
+
*/
|
|
134
|
+
async getWorkspaceTasks(filters = {}) {
|
|
135
|
+
try {
|
|
136
|
+
this.core.logOperation('getWorkspaceTasks', { filters });
|
|
137
|
+
const params = this.core.buildTaskFilterParams(filters);
|
|
138
|
+
const response = await this.core.makeRequest(async () => {
|
|
139
|
+
return await this.core.client.get(`/team/${this.core.teamId}/task`, {
|
|
140
|
+
params
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
const tasks = response.data.tasks;
|
|
144
|
+
const totalCount = tasks.length; // Note: This is just the current page count
|
|
145
|
+
const hasMore = totalCount === 100; // ClickUp returns max 100 tasks per page
|
|
146
|
+
const nextPage = (filters.page || 0) + 1;
|
|
147
|
+
// If the estimated token count exceeds 50,000 or detail_level is 'summary',
|
|
148
|
+
// return summary format for efficiency and to avoid hitting token limits
|
|
149
|
+
const TOKEN_LIMIT = 50000;
|
|
150
|
+
// Estimate tokens for the full response
|
|
151
|
+
let tokensExceedLimit = false;
|
|
152
|
+
if (filters.detail_level !== 'summary' && tasks.length > 0) {
|
|
153
|
+
// We only need to check token count if detailed was requested
|
|
154
|
+
// For summary requests, we always return summary format
|
|
155
|
+
// First check with a sample task - if one task exceeds the limit, we definitely need summary
|
|
156
|
+
const sampleTask = tasks[0];
|
|
157
|
+
// Check if all tasks would exceed the token limit
|
|
158
|
+
const estimatedTokensPerTask = this.core.estimateTaskTokens(sampleTask);
|
|
159
|
+
const estimatedTotalTokens = estimatedTokensPerTask * tasks.length;
|
|
160
|
+
// Add 10% overhead for the response wrapper
|
|
161
|
+
tokensExceedLimit = estimatedTotalTokens * 1.1 > TOKEN_LIMIT;
|
|
162
|
+
// Double-check with more precise estimation if we're close to the limit
|
|
163
|
+
if (!tokensExceedLimit && estimatedTotalTokens * 1.1 > TOKEN_LIMIT * 0.8) {
|
|
164
|
+
// More precise check - build a representative sample and extrapolate
|
|
165
|
+
tokensExceedLimit = wouldExceedTokenLimit({ tasks, total_count: totalCount, has_more: hasMore, next_page: nextPage }, TOKEN_LIMIT);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Determine if we should return summary or detailed based on request and token limit
|
|
169
|
+
const shouldUseSummary = filters.detail_level === 'summary' || tokensExceedLimit;
|
|
170
|
+
this.core.logOperation('getWorkspaceTasks', {
|
|
171
|
+
totalTasks: tasks.length,
|
|
172
|
+
estimatedTokens: tasks.reduce((count, task) => count + this.core.estimateTaskTokens(task), 0),
|
|
173
|
+
usingDetailedFormat: !shouldUseSummary,
|
|
174
|
+
requestedFormat: filters.detail_level || 'auto'
|
|
175
|
+
});
|
|
176
|
+
if (shouldUseSummary) {
|
|
177
|
+
return {
|
|
178
|
+
summaries: tasks.map(task => this.core.formatTaskSummary(task)),
|
|
179
|
+
total_count: totalCount,
|
|
180
|
+
has_more: hasMore,
|
|
181
|
+
next_page: nextPage
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
tasks,
|
|
186
|
+
total_count: totalCount,
|
|
187
|
+
has_more: hasMore,
|
|
188
|
+
next_page: nextPage
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
this.core.logOperation('getWorkspaceTasks', { error: error.message, status: error.response?.status });
|
|
193
|
+
throw this.core.handleError(error, 'Failed to get workspace tasks');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Get task summaries for lightweight retrieval
|
|
198
|
+
* @param filters Task filters to apply
|
|
199
|
+
* @returns WorkspaceTasksResponse with task summaries
|
|
200
|
+
*/
|
|
201
|
+
async getTaskSummaries(filters = {}) {
|
|
202
|
+
return this.getWorkspaceTasks({ ...filters, detail_level: 'summary' });
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Get all views for a given list and identify the default "List" view ID
|
|
206
|
+
* @param listId The ID of the list to get views for
|
|
207
|
+
* @returns The ID of the default list view, or null if not found
|
|
208
|
+
*/
|
|
209
|
+
async getListViews(listId) {
|
|
210
|
+
try {
|
|
211
|
+
this.core.logOperation('getListViews', { listId });
|
|
212
|
+
const response = await this.core.makeRequest(async () => {
|
|
213
|
+
return await this.core.client.get(`/list/${listId}/view`);
|
|
214
|
+
});
|
|
215
|
+
// First try to get the default list view from required_views.list
|
|
216
|
+
if (response.data.required_views?.list?.id) {
|
|
217
|
+
this.core.logOperation('getListViews', {
|
|
218
|
+
listId,
|
|
219
|
+
foundDefaultView: response.data.required_views.list.id,
|
|
220
|
+
source: 'required_views.list'
|
|
221
|
+
});
|
|
222
|
+
return response.data.required_views.list.id;
|
|
223
|
+
}
|
|
224
|
+
// Fallback: look for a view with type "list" in the views array
|
|
225
|
+
const listView = response.data.views?.find(view => view.type?.toLowerCase() === 'list' ||
|
|
226
|
+
view.name?.toLowerCase().includes('list'));
|
|
227
|
+
if (listView?.id) {
|
|
228
|
+
this.core.logOperation('getListViews', {
|
|
229
|
+
listId,
|
|
230
|
+
foundDefaultView: listView.id,
|
|
231
|
+
source: 'views_array_fallback',
|
|
232
|
+
viewName: listView.name
|
|
233
|
+
});
|
|
234
|
+
return listView.id;
|
|
235
|
+
}
|
|
236
|
+
// If no specific list view found, use the first available view
|
|
237
|
+
if (response.data.views?.length > 0) {
|
|
238
|
+
const firstView = response.data.views[0];
|
|
239
|
+
this.core.logOperation('getListViews', {
|
|
240
|
+
listId,
|
|
241
|
+
foundDefaultView: firstView.id,
|
|
242
|
+
source: 'first_available_view',
|
|
243
|
+
viewName: firstView.name,
|
|
244
|
+
warning: 'No specific list view found, using first available view'
|
|
245
|
+
});
|
|
246
|
+
return firstView.id;
|
|
247
|
+
}
|
|
248
|
+
this.core.logOperation('getListViews', {
|
|
249
|
+
listId,
|
|
250
|
+
error: 'No views found for list',
|
|
251
|
+
responseData: response.data
|
|
252
|
+
});
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
this.core.logOperation('getListViews', {
|
|
257
|
+
listId,
|
|
258
|
+
error: error.message,
|
|
259
|
+
status: error.response?.status
|
|
260
|
+
});
|
|
261
|
+
throw this.core.handleError(error, `Failed to get views for list ${listId}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Retrieve tasks from a specific view, applying supported filters
|
|
266
|
+
* @param viewId The ID of the view to get tasks from
|
|
267
|
+
* @param filters Task filters to apply (only supported filters will be used)
|
|
268
|
+
* @returns Array of ClickUpTask objects from the view
|
|
269
|
+
*/
|
|
270
|
+
async getTasksFromView(viewId, filters = {}) {
|
|
271
|
+
try {
|
|
272
|
+
this.core.logOperation('getTasksFromView', { viewId, filters });
|
|
273
|
+
// Build query parameters for supported filters
|
|
274
|
+
const params = {};
|
|
275
|
+
// Map supported filters to query parameters
|
|
276
|
+
if (filters.subtasks !== undefined)
|
|
277
|
+
params.subtasks = filters.subtasks;
|
|
278
|
+
if (filters.include_closed !== undefined)
|
|
279
|
+
params.include_closed = filters.include_closed;
|
|
280
|
+
if (filters.archived !== undefined)
|
|
281
|
+
params.archived = filters.archived;
|
|
282
|
+
if (filters.page !== undefined)
|
|
283
|
+
params.page = filters.page;
|
|
284
|
+
if (filters.order_by)
|
|
285
|
+
params.order_by = filters.order_by;
|
|
286
|
+
if (filters.reverse !== undefined)
|
|
287
|
+
params.reverse = filters.reverse;
|
|
288
|
+
// Status filtering
|
|
289
|
+
if (filters.statuses && filters.statuses.length > 0) {
|
|
290
|
+
params.statuses = filters.statuses;
|
|
291
|
+
}
|
|
292
|
+
// Assignee filtering
|
|
293
|
+
if (filters.assignees && filters.assignees.length > 0) {
|
|
294
|
+
params.assignees = filters.assignees;
|
|
295
|
+
}
|
|
296
|
+
// Date filters
|
|
297
|
+
if (filters.date_created_gt)
|
|
298
|
+
params.date_created_gt = filters.date_created_gt;
|
|
299
|
+
if (filters.date_created_lt)
|
|
300
|
+
params.date_created_lt = filters.date_created_lt;
|
|
301
|
+
if (filters.date_updated_gt)
|
|
302
|
+
params.date_updated_gt = filters.date_updated_gt;
|
|
303
|
+
if (filters.date_updated_lt)
|
|
304
|
+
params.date_updated_lt = filters.date_updated_lt;
|
|
305
|
+
if (filters.due_date_gt)
|
|
306
|
+
params.due_date_gt = filters.due_date_gt;
|
|
307
|
+
if (filters.due_date_lt)
|
|
308
|
+
params.due_date_lt = filters.due_date_lt;
|
|
309
|
+
// Custom fields
|
|
310
|
+
if (filters.custom_fields) {
|
|
311
|
+
params.custom_fields = filters.custom_fields;
|
|
312
|
+
}
|
|
313
|
+
let allTasks = [];
|
|
314
|
+
let currentPage = filters.page || 0;
|
|
315
|
+
let hasMore = true;
|
|
316
|
+
const maxPages = 50; // Safety limit to prevent infinite loops
|
|
317
|
+
let pageCount = 0;
|
|
318
|
+
while (hasMore && pageCount < maxPages) {
|
|
319
|
+
const pageParams = { ...params, page: currentPage };
|
|
320
|
+
const response = await this.core.makeRequest(async () => {
|
|
321
|
+
return await this.core.client.get(`/view/${viewId}/task`, {
|
|
322
|
+
params: pageParams
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
const tasks = response.data.tasks || [];
|
|
326
|
+
allTasks = allTasks.concat(tasks);
|
|
327
|
+
// Check if there are more pages
|
|
328
|
+
hasMore = response.data.has_more === true && tasks.length > 0;
|
|
329
|
+
currentPage++;
|
|
330
|
+
pageCount++;
|
|
331
|
+
this.core.logOperation('getTasksFromView', {
|
|
332
|
+
viewId,
|
|
333
|
+
page: currentPage - 1,
|
|
334
|
+
tasksInPage: tasks.length,
|
|
335
|
+
totalTasksSoFar: allTasks.length,
|
|
336
|
+
hasMore
|
|
337
|
+
});
|
|
338
|
+
// If we're not paginating (original request had no page specified),
|
|
339
|
+
// only get the first page
|
|
340
|
+
if (filters.page === undefined && currentPage === 1) {
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (pageCount >= maxPages) {
|
|
345
|
+
this.core.logOperation('getTasksFromView', {
|
|
346
|
+
viewId,
|
|
347
|
+
warning: `Reached maximum page limit (${maxPages}) while fetching tasks`,
|
|
348
|
+
totalTasks: allTasks.length
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
this.core.logOperation('getTasksFromView', {
|
|
352
|
+
viewId,
|
|
353
|
+
totalTasks: allTasks.length,
|
|
354
|
+
totalPages: pageCount
|
|
355
|
+
});
|
|
356
|
+
return allTasks;
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
this.core.logOperation('getTasksFromView', {
|
|
360
|
+
viewId,
|
|
361
|
+
error: error.message,
|
|
362
|
+
status: error.response?.status
|
|
363
|
+
});
|
|
364
|
+
throw this.core.handleError(error, `Failed to get tasks from view ${viewId}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Get detailed task data
|
|
369
|
+
* @param filters Task filters to apply
|
|
370
|
+
* @returns DetailedTaskResponse with full task data
|
|
371
|
+
*/
|
|
372
|
+
async getTaskDetails(filters = {}) {
|
|
373
|
+
return this.getWorkspaceTasks({ ...filters, detail_level: 'detailed' });
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Unified method for finding tasks by ID or name with consistent handling of global lookup
|
|
377
|
+
*
|
|
378
|
+
* This method provides a single entry point for all task lookup operations:
|
|
379
|
+
* - Direct lookup by task ID (highest priority)
|
|
380
|
+
* - Lookup by task name within a specific list
|
|
381
|
+
* - Global lookup by task name across the entire workspace
|
|
382
|
+
*
|
|
383
|
+
* @param options Lookup options with the following parameters:
|
|
384
|
+
* - taskId: Optional task ID for direct lookup
|
|
385
|
+
* - customTaskId: Optional custom task ID for direct lookup
|
|
386
|
+
* - taskName: Optional task name to search for
|
|
387
|
+
* - listId: Optional list ID to scope the search
|
|
388
|
+
* - listName: Optional list name to scope the search
|
|
389
|
+
* - allowMultipleMatches: Whether to return all matches instead of throwing an error
|
|
390
|
+
* - useSmartDisambiguation: Whether to automatically select the most recently updated task
|
|
391
|
+
* - includeFullDetails: Whether to include full task details (true) or just task summaries (false)
|
|
392
|
+
* - includeListContext: Whether to include list/folder/space context with results
|
|
393
|
+
* - requireExactMatch: Whether to only consider exact name matches (true) or allow fuzzy matches (false)
|
|
394
|
+
* @returns Either a single task or an array of tasks depending on options
|
|
395
|
+
* @throws Error if task cannot be found or if multiple matches are found when not allowed
|
|
396
|
+
*/
|
|
397
|
+
async findTasks({ taskId, customTaskId, taskName, listId, listName, allowMultipleMatches = false, useSmartDisambiguation = true, includeFullDetails = true, includeListContext = false, requireExactMatch = false }) {
|
|
398
|
+
try {
|
|
399
|
+
this.core.logOperation('findTasks', {
|
|
400
|
+
taskId,
|
|
401
|
+
customTaskId,
|
|
402
|
+
taskName,
|
|
403
|
+
listId,
|
|
404
|
+
listName,
|
|
405
|
+
allowMultipleMatches,
|
|
406
|
+
useSmartDisambiguation,
|
|
407
|
+
requireExactMatch
|
|
408
|
+
});
|
|
409
|
+
// Check name-to-ID cache first if we have a task name
|
|
410
|
+
if (taskName && !taskId && !customTaskId) {
|
|
411
|
+
// Resolve list ID if we have a list name
|
|
412
|
+
let resolvedListId = listId;
|
|
413
|
+
if (listName && !listId) {
|
|
414
|
+
const listInfo = await findListIDByName(this.core.workspaceService, listName);
|
|
415
|
+
if (listInfo) {
|
|
416
|
+
resolvedListId = listInfo.id;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// Try to get cached task ID
|
|
420
|
+
const cachedTaskId = this.core.getCachedTaskId(taskName, resolvedListId);
|
|
421
|
+
if (cachedTaskId) {
|
|
422
|
+
this.core.logOperation('findTasks', {
|
|
423
|
+
message: 'Using cached task ID for name lookup',
|
|
424
|
+
taskName,
|
|
425
|
+
cachedTaskId
|
|
426
|
+
});
|
|
427
|
+
taskId = cachedTaskId;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
// Case 1: Direct task ID lookup (highest priority)
|
|
431
|
+
if (taskId) {
|
|
432
|
+
// Check if it looks like a custom ID
|
|
433
|
+
if (taskId.includes('-') && /^[A-Z]+\-\d+$/.test(taskId)) {
|
|
434
|
+
this.core.logOperation('findTasks', { detectedCustomId: taskId });
|
|
435
|
+
try {
|
|
436
|
+
// Try to get it as a custom ID first
|
|
437
|
+
let resolvedListId;
|
|
438
|
+
if (listId) {
|
|
439
|
+
resolvedListId = listId;
|
|
440
|
+
}
|
|
441
|
+
else if (listName) {
|
|
442
|
+
const listInfo = await findListIDByName(this.core.workspaceService, listName);
|
|
443
|
+
if (listInfo) {
|
|
444
|
+
resolvedListId = listInfo.id;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const foundTask = await this.core.getTaskByCustomId(taskId, resolvedListId);
|
|
448
|
+
return foundTask;
|
|
449
|
+
}
|
|
450
|
+
catch (error) {
|
|
451
|
+
// If it fails as a custom ID, try as a regular ID
|
|
452
|
+
this.core.logOperation('findTasks', {
|
|
453
|
+
message: `Failed to find task with custom ID "${taskId}", falling back to regular ID`,
|
|
454
|
+
error: error.message
|
|
455
|
+
});
|
|
456
|
+
return await this.core.getTask(taskId);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
// Regular task ID
|
|
460
|
+
return await this.core.getTask(taskId);
|
|
461
|
+
}
|
|
462
|
+
// Case 2: Explicit custom task ID lookup
|
|
463
|
+
if (customTaskId) {
|
|
464
|
+
let resolvedListId;
|
|
465
|
+
if (listId) {
|
|
466
|
+
resolvedListId = listId;
|
|
467
|
+
}
|
|
468
|
+
else if (listName) {
|
|
469
|
+
const listInfo = await findListIDByName(this.core.workspaceService, listName);
|
|
470
|
+
if (listInfo) {
|
|
471
|
+
resolvedListId = listInfo.id;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return await this.core.getTaskByCustomId(customTaskId, resolvedListId);
|
|
475
|
+
}
|
|
476
|
+
// Case 3: Task name lookup (requires either list context or global lookup)
|
|
477
|
+
if (taskName) {
|
|
478
|
+
// Case 3a: Task name + list context - search in specific list
|
|
479
|
+
if (listId || listName) {
|
|
480
|
+
let resolvedListId;
|
|
481
|
+
if (listId) {
|
|
482
|
+
resolvedListId = listId;
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
const listInfo = await findListIDByName(this.core.workspaceService, listName);
|
|
486
|
+
if (!listInfo) {
|
|
487
|
+
throw new Error(`List "${listName}" not found`);
|
|
488
|
+
}
|
|
489
|
+
resolvedListId = listInfo.id;
|
|
490
|
+
}
|
|
491
|
+
const foundTask = this.core.findTaskInArray(await this.core.getTasks(resolvedListId), taskName, includeListContext);
|
|
492
|
+
if (!foundTask) {
|
|
493
|
+
throw new Error(`Task "${taskName}" not found in list`);
|
|
494
|
+
}
|
|
495
|
+
// Cache the task name to ID mapping with list context
|
|
496
|
+
this.core.cacheTaskNameToId(taskName, foundTask.id, resolvedListId);
|
|
497
|
+
// If includeFullDetails is true and we need context not already in the task,
|
|
498
|
+
// get full details, otherwise return what we already have
|
|
499
|
+
if (includeFullDetails && (!foundTask.list || !foundTask.list.name || !foundTask.status)) {
|
|
500
|
+
return await this.core.getTask(foundTask.id);
|
|
501
|
+
}
|
|
502
|
+
return foundTask;
|
|
503
|
+
}
|
|
504
|
+
// Case 3b: Task name without list context - global lookup across workspace
|
|
505
|
+
// Get lightweight task summaries for efficient first-pass filtering
|
|
506
|
+
this.core.logOperation('findTasks', {
|
|
507
|
+
message: `Starting global task search for "${taskName}"`,
|
|
508
|
+
includeFullDetails,
|
|
509
|
+
useSmartDisambiguation,
|
|
510
|
+
requireExactMatch
|
|
511
|
+
});
|
|
512
|
+
// Use statuses parameter to get both open and closed tasks
|
|
513
|
+
// Include additional filters to ensure we get as many tasks as possible
|
|
514
|
+
const response = await this.getTaskSummaries({
|
|
515
|
+
include_closed: true,
|
|
516
|
+
include_archived_lists: true,
|
|
517
|
+
include_closed_lists: true,
|
|
518
|
+
subtasks: true
|
|
519
|
+
});
|
|
520
|
+
if (!this.core.workspaceService) {
|
|
521
|
+
throw new Error("Workspace service required for global task lookup");
|
|
522
|
+
}
|
|
523
|
+
// Create an index to efficiently look up list context information
|
|
524
|
+
const hierarchy = await this.core.workspaceService.getWorkspaceHierarchy();
|
|
525
|
+
const listContextMap = new Map();
|
|
526
|
+
// Function to recursively build list context map
|
|
527
|
+
function buildListContextMap(nodes, spaceId, spaceName, folderId, folderName) {
|
|
528
|
+
for (const node of nodes) {
|
|
529
|
+
if (node.type === 'space') {
|
|
530
|
+
// Process space children
|
|
531
|
+
if (node.children) {
|
|
532
|
+
buildListContextMap(node.children, node.id, node.name);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
else if (node.type === 'folder') {
|
|
536
|
+
// Process folder children
|
|
537
|
+
if (node.children) {
|
|
538
|
+
buildListContextMap(node.children, spaceId, spaceName, node.id, node.name);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
else if (node.type === 'list') {
|
|
542
|
+
// Add list context to map
|
|
543
|
+
listContextMap.set(node.id, {
|
|
544
|
+
listId: node.id,
|
|
545
|
+
listName: node.name,
|
|
546
|
+
spaceId: spaceId,
|
|
547
|
+
spaceName: spaceName,
|
|
548
|
+
folderId,
|
|
549
|
+
folderName
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
// Build the context map
|
|
555
|
+
buildListContextMap(hierarchy.root.children);
|
|
556
|
+
// Find tasks that match the provided name with scored match results
|
|
557
|
+
const initialMatches = [];
|
|
558
|
+
// Process task summaries to find initial matches
|
|
559
|
+
let taskCount = 0;
|
|
560
|
+
let matchesFound = 0;
|
|
561
|
+
// Add additional logging to debug task matching
|
|
562
|
+
this.core.logOperation('findTasks', {
|
|
563
|
+
total_tasks_in_response: response.summaries.length,
|
|
564
|
+
search_term: taskName,
|
|
565
|
+
requireExactMatch
|
|
566
|
+
});
|
|
567
|
+
for (const taskSummary of response.summaries) {
|
|
568
|
+
taskCount++;
|
|
569
|
+
// Use isNameMatch for consistent matching behavior with scoring
|
|
570
|
+
const matchResult = isNameMatch(taskSummary.name, taskName);
|
|
571
|
+
const isMatch = matchResult.isMatch;
|
|
572
|
+
// For debugging, log every 20th task or any task with a similar name
|
|
573
|
+
if (taskCount % 20 === 0 || taskSummary.name.toLowerCase().includes(taskName.toLowerCase()) ||
|
|
574
|
+
taskName.toLowerCase().includes(taskSummary.name.toLowerCase())) {
|
|
575
|
+
this.core.logOperation('findTasks:matching', {
|
|
576
|
+
task_name: taskSummary.name,
|
|
577
|
+
search_term: taskName,
|
|
578
|
+
list_name: taskSummary.list?.name || 'Unknown list',
|
|
579
|
+
is_match: isMatch,
|
|
580
|
+
match_score: matchResult.score,
|
|
581
|
+
match_reason: matchResult.reason || 'no-match'
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
if (isMatch) {
|
|
585
|
+
matchesFound++;
|
|
586
|
+
// Get list context information
|
|
587
|
+
const listContext = listContextMap.get(taskSummary.list.id);
|
|
588
|
+
if (listContext) {
|
|
589
|
+
// Store task summary and context with match score
|
|
590
|
+
initialMatches.push({
|
|
591
|
+
id: taskSummary.id,
|
|
592
|
+
task: taskSummary,
|
|
593
|
+
listContext,
|
|
594
|
+
matchScore: matchResult.score,
|
|
595
|
+
matchReason: matchResult.reason || 'unknown'
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
this.core.logOperation('findTasks', {
|
|
601
|
+
globalSearch: true,
|
|
602
|
+
searchTerm: taskName,
|
|
603
|
+
tasksSearched: taskCount,
|
|
604
|
+
matchesFound: matchesFound,
|
|
605
|
+
validMatchesWithContext: initialMatches.length
|
|
606
|
+
});
|
|
607
|
+
// Handle the no matches case
|
|
608
|
+
if (initialMatches.length === 0) {
|
|
609
|
+
throw new Error(`Task "${taskName}" not found in any list across your workspace. Please check the task name and try again.`);
|
|
610
|
+
}
|
|
611
|
+
// Sort matches by match score first (higher is better), then by update time
|
|
612
|
+
initialMatches.sort((a, b) => {
|
|
613
|
+
// First sort by match score (highest first)
|
|
614
|
+
if (b.matchScore !== a.matchScore) {
|
|
615
|
+
return b.matchScore - a.matchScore;
|
|
616
|
+
}
|
|
617
|
+
// Try to get the date_updated from the task
|
|
618
|
+
const aDate = a.task.date_updated ? parseInt(a.task.date_updated, 10) : 0;
|
|
619
|
+
const bDate = b.task.date_updated ? parseInt(b.task.date_updated, 10) : 0;
|
|
620
|
+
// For equal scores, sort by most recently updated
|
|
621
|
+
return bDate - aDate;
|
|
622
|
+
});
|
|
623
|
+
// Handle the single match case - we can return early if we don't need full details
|
|
624
|
+
if (initialMatches.length === 1 && !useSmartDisambiguation && !includeFullDetails) {
|
|
625
|
+
const match = initialMatches[0];
|
|
626
|
+
if (includeListContext) {
|
|
627
|
+
return {
|
|
628
|
+
...match.task,
|
|
629
|
+
list: {
|
|
630
|
+
id: match.listContext.listId,
|
|
631
|
+
name: match.listContext.listName
|
|
632
|
+
},
|
|
633
|
+
folder: match.listContext.folderId ? {
|
|
634
|
+
id: match.listContext.folderId,
|
|
635
|
+
name: match.listContext.folderName
|
|
636
|
+
} : undefined,
|
|
637
|
+
space: {
|
|
638
|
+
id: match.listContext.spaceId,
|
|
639
|
+
name: match.listContext.spaceName
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
return match.task;
|
|
644
|
+
}
|
|
645
|
+
// Handle the exact match case - if there's an exact or very good match, prefer it over others
|
|
646
|
+
// This is our key improvement to prefer exact matches over update time
|
|
647
|
+
const bestMatchScore = initialMatches[0].matchScore;
|
|
648
|
+
if (bestMatchScore >= 80) { // 80+ is an exact match or case-insensitive exact match
|
|
649
|
+
// If there's a single best match with score 80+, use it directly
|
|
650
|
+
const exactMatches = initialMatches.filter(m => m.matchScore >= 80);
|
|
651
|
+
if (exactMatches.length === 1 && !allowMultipleMatches) {
|
|
652
|
+
this.core.logOperation('findTasks', {
|
|
653
|
+
message: `Found single exact match with score ${exactMatches[0].matchScore}, prioritizing over other matches`,
|
|
654
|
+
matchReason: exactMatches[0].matchReason
|
|
655
|
+
});
|
|
656
|
+
// If we don't need details, return early
|
|
657
|
+
if (!includeFullDetails) {
|
|
658
|
+
const match = exactMatches[0];
|
|
659
|
+
if (includeListContext) {
|
|
660
|
+
return {
|
|
661
|
+
...match.task,
|
|
662
|
+
list: {
|
|
663
|
+
id: match.listContext.listId,
|
|
664
|
+
name: match.listContext.listName
|
|
665
|
+
},
|
|
666
|
+
folder: match.listContext.folderId ? {
|
|
667
|
+
id: match.listContext.folderId,
|
|
668
|
+
name: match.listContext.folderName
|
|
669
|
+
} : undefined,
|
|
670
|
+
space: {
|
|
671
|
+
id: match.listContext.spaceId,
|
|
672
|
+
name: match.listContext.spaceName
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
return match.task;
|
|
677
|
+
}
|
|
678
|
+
// Otherwise, get the full details
|
|
679
|
+
const fullTask = await this.core.getTask(exactMatches[0].id);
|
|
680
|
+
if (includeListContext) {
|
|
681
|
+
const match = exactMatches[0];
|
|
682
|
+
// Enhance task with context information
|
|
683
|
+
fullTask.list = {
|
|
684
|
+
...fullTask.list,
|
|
685
|
+
name: match.listContext.listName
|
|
686
|
+
};
|
|
687
|
+
if (match.listContext.folderId) {
|
|
688
|
+
fullTask.folder = {
|
|
689
|
+
id: match.listContext.folderId,
|
|
690
|
+
name: match.listContext.folderName
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
fullTask.space = {
|
|
694
|
+
id: match.listContext.spaceId,
|
|
695
|
+
name: match.listContext.spaceName
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
return fullTask;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
// For multiple matches or when we need details, fetch full task info
|
|
702
|
+
const fullMatches = [];
|
|
703
|
+
const matchScoreMap = new Map(); // To preserve match scores
|
|
704
|
+
try {
|
|
705
|
+
// Process in sequence for better reliability
|
|
706
|
+
for (const match of initialMatches) {
|
|
707
|
+
const fullTask = await this.core.getTask(match.id);
|
|
708
|
+
matchScoreMap.set(fullTask.id, match.matchScore);
|
|
709
|
+
if (includeListContext) {
|
|
710
|
+
// Enhance task with context information
|
|
711
|
+
fullTask.list = {
|
|
712
|
+
...fullTask.list,
|
|
713
|
+
name: match.listContext.listName
|
|
714
|
+
};
|
|
715
|
+
if (match.listContext.folderId) {
|
|
716
|
+
fullTask.folder = {
|
|
717
|
+
id: match.listContext.folderId,
|
|
718
|
+
name: match.listContext.folderName
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
fullTask.space = {
|
|
722
|
+
id: match.listContext.spaceId,
|
|
723
|
+
name: match.listContext.spaceName
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
fullMatches.push(fullTask);
|
|
727
|
+
}
|
|
728
|
+
// Sort matches - first by match score, then by update time
|
|
729
|
+
if (fullMatches.length > 1) {
|
|
730
|
+
fullMatches.sort((a, b) => {
|
|
731
|
+
// First sort by match score (highest first)
|
|
732
|
+
const aScore = matchScoreMap.get(a.id) || 0;
|
|
733
|
+
const bScore = matchScoreMap.get(b.id) || 0;
|
|
734
|
+
if (aScore !== bScore) {
|
|
735
|
+
return bScore - aScore;
|
|
736
|
+
}
|
|
737
|
+
// For equal scores, sort by update time
|
|
738
|
+
const aDate = parseInt(a.date_updated || '0', 10);
|
|
739
|
+
const bDate = parseInt(b.date_updated || '0', 10);
|
|
740
|
+
return bDate - aDate; // Most recent first
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
catch (error) {
|
|
745
|
+
this.core.logOperation('findTasks', {
|
|
746
|
+
error: error.message,
|
|
747
|
+
message: "Failed to get detailed task information"
|
|
748
|
+
});
|
|
749
|
+
// If detailed fetch fails, use the summaries with context info
|
|
750
|
+
// This fallback ensures we still return something useful
|
|
751
|
+
if (allowMultipleMatches) {
|
|
752
|
+
return initialMatches.map(match => ({
|
|
753
|
+
...match.task,
|
|
754
|
+
list: {
|
|
755
|
+
id: match.listContext.listId,
|
|
756
|
+
name: match.listContext.listName
|
|
757
|
+
},
|
|
758
|
+
folder: match.listContext.folderId ? {
|
|
759
|
+
id: match.listContext.folderId,
|
|
760
|
+
name: match.listContext.folderName
|
|
761
|
+
} : undefined,
|
|
762
|
+
space: {
|
|
763
|
+
id: match.listContext.spaceId,
|
|
764
|
+
name: match.listContext.spaceName
|
|
765
|
+
}
|
|
766
|
+
}));
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
// For single result, return the first match (best match score)
|
|
770
|
+
const match = initialMatches[0];
|
|
771
|
+
return {
|
|
772
|
+
...match.task,
|
|
773
|
+
list: {
|
|
774
|
+
id: match.listContext.listId,
|
|
775
|
+
name: match.listContext.listName
|
|
776
|
+
},
|
|
777
|
+
folder: match.listContext.folderId ? {
|
|
778
|
+
id: match.listContext.folderId,
|
|
779
|
+
name: match.listContext.folderName
|
|
780
|
+
} : undefined,
|
|
781
|
+
space: {
|
|
782
|
+
id: match.listContext.spaceId,
|
|
783
|
+
name: match.listContext.spaceName
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
// After finding the task in global search, cache the mapping
|
|
789
|
+
if (initialMatches.length === 1 || useSmartDisambiguation) {
|
|
790
|
+
const bestMatch = fullMatches[0];
|
|
791
|
+
this.core.cacheTaskNameToId(taskName, bestMatch.id, bestMatch.list?.id);
|
|
792
|
+
return bestMatch;
|
|
793
|
+
}
|
|
794
|
+
// Return results based on options
|
|
795
|
+
if (fullMatches.length === 1 || useSmartDisambiguation) {
|
|
796
|
+
return fullMatches[0]; // Return best match (sorted by score then update time)
|
|
797
|
+
}
|
|
798
|
+
else if (allowMultipleMatches) {
|
|
799
|
+
return fullMatches; // Return all matches
|
|
800
|
+
}
|
|
801
|
+
else {
|
|
802
|
+
// Format error message for multiple matches
|
|
803
|
+
const matchesInfo = fullMatches.map(task => {
|
|
804
|
+
const listName = task.list?.name || "Unknown list";
|
|
805
|
+
const folderName = task.folder?.name;
|
|
806
|
+
const spaceName = task.space?.name || "Unknown space";
|
|
807
|
+
const updateTime = task.date_updated
|
|
808
|
+
? new Date(parseInt(task.date_updated, 10)).toLocaleString()
|
|
809
|
+
: "Unknown date";
|
|
810
|
+
const matchScore = matchScoreMap.get(task.id) || 0;
|
|
811
|
+
const matchQuality = matchScore >= 100 ? "Exact match" :
|
|
812
|
+
matchScore >= 80 ? "Case-insensitive exact match" :
|
|
813
|
+
matchScore >= 70 ? "Text match ignoring emojis" :
|
|
814
|
+
matchScore >= 50 ? "Contains search term" :
|
|
815
|
+
"Partial match";
|
|
816
|
+
const location = `list "${listName}"${folderName ? ` (folder: "${folderName}")` : ''} (space: "${spaceName}")`;
|
|
817
|
+
return `- "${task.name}" in ${location} - Updated ${updateTime} - Match quality: ${matchQuality} (${matchScore}/100)`;
|
|
818
|
+
}).join('\n');
|
|
819
|
+
throw new Error(`Multiple tasks found with name "${taskName}":\n${matchesInfo}\n\nPlease provide list context to disambiguate, use the exact task name with requireExactMatch=true, or set allowMultipleMatches to true.`);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
// No valid lookup parameters provided
|
|
823
|
+
throw new Error("At least one of taskId, customTaskId, or taskName must be provided");
|
|
824
|
+
}
|
|
825
|
+
catch (error) {
|
|
826
|
+
if (error.message?.includes('Task "') && error.message?.includes('not found')) {
|
|
827
|
+
throw error;
|
|
828
|
+
}
|
|
829
|
+
if (error.message?.includes('Multiple tasks found')) {
|
|
830
|
+
throw error;
|
|
831
|
+
}
|
|
832
|
+
// Unexpected errors
|
|
833
|
+
throw this.core.handleError(error, `Error finding task: ${error.message}`);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Update a task by name within a specific list
|
|
838
|
+
* @param listId The ID of the list containing the task
|
|
839
|
+
* @param taskName The name of the task to update
|
|
840
|
+
* @param updateData The data to update the task with
|
|
841
|
+
* @returns The updated task
|
|
842
|
+
*/
|
|
843
|
+
async updateTaskByName(listId, taskName, updateData) {
|
|
844
|
+
this.core.logOperation('updateTaskByName', { listId, taskName, ...updateData });
|
|
845
|
+
try {
|
|
846
|
+
const task = await this.findTaskByName(listId, taskName);
|
|
847
|
+
if (!task) {
|
|
848
|
+
throw new Error(`Task "${taskName}" not found in list ${listId}`);
|
|
849
|
+
}
|
|
850
|
+
return await this.core.updateTask(task.id, updateData);
|
|
851
|
+
}
|
|
852
|
+
catch (error) {
|
|
853
|
+
throw this.core.handleError(error, `Failed to update task by name: ${error instanceof Error ? error.message : String(error)}`);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Global task search by name across all lists
|
|
858
|
+
* This is a specialized method that uses getWorkspaceTasks to search all lists at once
|
|
859
|
+
* which is more efficient than searching list by list
|
|
860
|
+
*
|
|
861
|
+
* @param taskName The name to search for
|
|
862
|
+
* @returns The best matching task or null if no match found
|
|
863
|
+
*/
|
|
864
|
+
async findTaskByNameGlobally(taskName) {
|
|
865
|
+
this.core.logOperation('findTaskByNameGlobally', { taskName });
|
|
866
|
+
// Use a static cache for task data to avoid redundant API calls
|
|
867
|
+
// This dramatically reduces API usage across multiple task lookups
|
|
868
|
+
if (!this.constructor.hasOwnProperty('_taskCache')) {
|
|
869
|
+
Object.defineProperty(this.constructor, '_taskCache', {
|
|
870
|
+
value: {
|
|
871
|
+
tasks: [],
|
|
872
|
+
lastFetch: 0,
|
|
873
|
+
cacheTTL: 60000, // 1 minute cache TTL
|
|
874
|
+
},
|
|
875
|
+
writable: true
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
const cache = this.constructor._taskCache;
|
|
879
|
+
const now = Date.now();
|
|
880
|
+
try {
|
|
881
|
+
// Use cached tasks if available and not expired
|
|
882
|
+
let tasks = [];
|
|
883
|
+
if (cache.tasks.length > 0 && (now - cache.lastFetch) < cache.cacheTTL) {
|
|
884
|
+
this.core.logOperation('findTaskByNameGlobally', {
|
|
885
|
+
usedCache: true,
|
|
886
|
+
cacheAge: now - cache.lastFetch,
|
|
887
|
+
taskCount: cache.tasks.length
|
|
888
|
+
});
|
|
889
|
+
tasks = cache.tasks;
|
|
890
|
+
}
|
|
891
|
+
else {
|
|
892
|
+
// Get tasks using a single efficient workspace-wide API call
|
|
893
|
+
const response = await this.getWorkspaceTasks({
|
|
894
|
+
include_closed: true,
|
|
895
|
+
detail_level: 'detailed'
|
|
896
|
+
});
|
|
897
|
+
tasks = 'tasks' in response ? response.tasks : [];
|
|
898
|
+
// Update cache
|
|
899
|
+
cache.tasks = tasks;
|
|
900
|
+
cache.lastFetch = now;
|
|
901
|
+
this.core.logOperation('findTaskByNameGlobally', {
|
|
902
|
+
usedCache: false,
|
|
903
|
+
fetchedTaskCount: tasks.length
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
// Map tasks to include match scores and updated time for sorting
|
|
907
|
+
const taskMatches = tasks.map(task => {
|
|
908
|
+
const matchResult = isNameMatch(task.name, taskName);
|
|
909
|
+
return {
|
|
910
|
+
task,
|
|
911
|
+
matchResult,
|
|
912
|
+
updatedAt: task.date_updated ? parseInt(task.date_updated, 10) : 0
|
|
913
|
+
};
|
|
914
|
+
}).filter(result => result.matchResult.isMatch);
|
|
915
|
+
this.core.logOperation('findTaskByNameGlobally', {
|
|
916
|
+
taskCount: tasks.length,
|
|
917
|
+
matchCount: taskMatches.length,
|
|
918
|
+
taskName
|
|
919
|
+
});
|
|
920
|
+
if (taskMatches.length === 0) {
|
|
921
|
+
return null;
|
|
922
|
+
}
|
|
923
|
+
// First try exact matches
|
|
924
|
+
const exactMatches = taskMatches
|
|
925
|
+
.filter(result => result.matchResult.exactMatch)
|
|
926
|
+
.sort((a, b) => {
|
|
927
|
+
// For exact matches with the same score, sort by most recently updated
|
|
928
|
+
if (b.matchResult.score === a.matchResult.score) {
|
|
929
|
+
return b.updatedAt - a.updatedAt;
|
|
930
|
+
}
|
|
931
|
+
return b.matchResult.score - a.matchResult.score;
|
|
932
|
+
});
|
|
933
|
+
// Get the best matches based on whether we have exact matches or need to fall back to fuzzy matches
|
|
934
|
+
const bestMatches = exactMatches.length > 0 ? exactMatches : taskMatches.sort((a, b) => {
|
|
935
|
+
// First sort by match score (highest first)
|
|
936
|
+
if (b.matchResult.score !== a.matchResult.score) {
|
|
937
|
+
return b.matchResult.score - a.matchResult.score;
|
|
938
|
+
}
|
|
939
|
+
// Then sort by most recently updated
|
|
940
|
+
return b.updatedAt - a.updatedAt;
|
|
941
|
+
});
|
|
942
|
+
// Log the top matches for debugging
|
|
943
|
+
const topMatches = bestMatches.slice(0, 3).map(match => ({
|
|
944
|
+
taskName: match.task.name,
|
|
945
|
+
score: match.matchResult.score,
|
|
946
|
+
reason: match.matchResult.reason,
|
|
947
|
+
updatedAt: match.updatedAt,
|
|
948
|
+
list: match.task.list?.name || 'Unknown list'
|
|
949
|
+
}));
|
|
950
|
+
this.core.logOperation('findTaskByNameGlobally', { topMatches });
|
|
951
|
+
// Return the best match
|
|
952
|
+
return bestMatches[0].task;
|
|
953
|
+
}
|
|
954
|
+
catch (error) {
|
|
955
|
+
this.core.logOperation('findTaskByNameGlobally', { error: error.message });
|
|
956
|
+
// If there's an error (like rate limit), try to use cached data even if expired
|
|
957
|
+
if (cache.tasks.length > 0) {
|
|
958
|
+
this.core.logOperation('findTaskByNameGlobally', {
|
|
959
|
+
message: 'Using expired cache due to API error',
|
|
960
|
+
cacheAge: now - cache.lastFetch
|
|
961
|
+
});
|
|
962
|
+
// Perform the same matching logic with cached data
|
|
963
|
+
const taskMatches = cache.tasks
|
|
964
|
+
.map(task => {
|
|
965
|
+
const matchResult = isNameMatch(task.name, taskName);
|
|
966
|
+
return {
|
|
967
|
+
task,
|
|
968
|
+
matchResult,
|
|
969
|
+
updatedAt: task.date_updated ? parseInt(task.date_updated, 10) : 0
|
|
970
|
+
};
|
|
971
|
+
})
|
|
972
|
+
.filter(result => result.matchResult.isMatch)
|
|
973
|
+
.sort((a, b) => {
|
|
974
|
+
if (b.matchResult.score !== a.matchResult.score) {
|
|
975
|
+
return b.matchResult.score - a.matchResult.score;
|
|
976
|
+
}
|
|
977
|
+
return b.updatedAt - a.updatedAt;
|
|
978
|
+
});
|
|
979
|
+
if (taskMatches.length > 0) {
|
|
980
|
+
return taskMatches[0].task;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return null;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|