@taazkareem/clickup-mcp-server 0.6.4 → 0.6.6
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/README.md +2 -2
- package/build/config.js +2 -3
- package/build/index.js +17 -33
- package/build/logger.js +5 -6
- package/build/server.js +32 -17
- package/build/services/clickup/base.js +156 -45
- package/build/services/clickup/bulk.js +10 -22
- package/build/services/clickup/tag.js +51 -2
- package/build/services/clickup/task/task-core.js +76 -11
- package/build/services/clickup/task/task-search.js +378 -25
- package/build/services/clickup/workspace.js +14 -12
- package/build/tools/tag.js +88 -36
- package/build/tools/task/attachments.js +15 -9
- package/build/tools/task/handlers.js +253 -136
- package/build/tools/task/main.js +9 -33
- package/build/tools/task/single-operations.js +9 -7
- package/build/tools/task/utilities.js +59 -12
- package/build/utils/date-utils.js +7 -4
- package/build/utils/resolver-utils.js +102 -29
- package/package.json +1 -1
|
@@ -23,6 +23,8 @@ export class TaskServiceCore extends BaseClickUpService {
|
|
|
23
23
|
tasks: new Map(),
|
|
24
24
|
lists: new Map()
|
|
25
25
|
};
|
|
26
|
+
// Cache for task name to ID mapping
|
|
27
|
+
this.nameToIdCache = new Map();
|
|
26
28
|
// Cache TTL in milliseconds (5 minutes)
|
|
27
29
|
this.CACHE_TTL = 5 * 60 * 1000;
|
|
28
30
|
if (workspaceService) {
|
|
@@ -155,24 +157,40 @@ export class TaskServiceCore extends BaseClickUpService {
|
|
|
155
157
|
try {
|
|
156
158
|
return await this.makeRequest(async () => {
|
|
157
159
|
const response = await this.client.post(`/list/${listId}/task`, taskData);
|
|
158
|
-
|
|
160
|
+
// Handle both JSON and text responses
|
|
161
|
+
const data = response.data;
|
|
162
|
+
if (typeof data === 'string') {
|
|
163
|
+
// If we got a text response, try to extract task ID from common patterns
|
|
164
|
+
const idMatch = data.match(/task.*?(\w{9})/i);
|
|
165
|
+
if (idMatch) {
|
|
166
|
+
// If we found an ID, fetch the full task details
|
|
167
|
+
return await this.getTask(idMatch[1]);
|
|
168
|
+
}
|
|
169
|
+
throw new ClickUpServiceError('Received unexpected text response from API', ErrorCode.UNKNOWN, data);
|
|
170
|
+
}
|
|
171
|
+
return data;
|
|
159
172
|
});
|
|
160
173
|
}
|
|
161
174
|
catch (error) {
|
|
162
|
-
throw this.handleError(error,
|
|
175
|
+
throw this.handleError(error, 'Failed to create task');
|
|
163
176
|
}
|
|
164
177
|
}
|
|
165
178
|
/**
|
|
166
|
-
* Get a
|
|
179
|
+
* Get a task by its ID
|
|
167
180
|
* @param taskId The ID of the task to retrieve
|
|
168
|
-
* @returns The task
|
|
181
|
+
* @returns The task
|
|
169
182
|
*/
|
|
170
183
|
async getTask(taskId) {
|
|
171
184
|
this.logOperation('getTask', { taskId });
|
|
172
185
|
try {
|
|
173
186
|
return await this.makeRequest(async () => {
|
|
174
187
|
const response = await this.client.get(`/task/${taskId}`);
|
|
175
|
-
|
|
188
|
+
// Handle both JSON and text responses
|
|
189
|
+
const data = response.data;
|
|
190
|
+
if (typeof data === 'string') {
|
|
191
|
+
throw new ClickUpServiceError('Received unexpected text response from API', ErrorCode.UNKNOWN, data);
|
|
192
|
+
}
|
|
193
|
+
return data;
|
|
176
194
|
});
|
|
177
195
|
}
|
|
178
196
|
catch (error) {
|
|
@@ -180,22 +198,27 @@ export class TaskServiceCore extends BaseClickUpService {
|
|
|
180
198
|
}
|
|
181
199
|
}
|
|
182
200
|
/**
|
|
183
|
-
* Get all tasks in a list
|
|
201
|
+
* Get all tasks in a list
|
|
184
202
|
* @param listId The ID of the list to get tasks from
|
|
185
203
|
* @param filters Optional filters to apply
|
|
186
|
-
* @returns
|
|
204
|
+
* @returns Array of tasks
|
|
187
205
|
*/
|
|
188
206
|
async getTasks(listId, filters = {}) {
|
|
189
207
|
this.logOperation('getTasks', { listId, filters });
|
|
190
208
|
try {
|
|
191
209
|
return await this.makeRequest(async () => {
|
|
192
210
|
const params = this.buildTaskFilterParams(filters);
|
|
193
|
-
const response = await this.client.get(`/list/${listId}/task
|
|
194
|
-
|
|
211
|
+
const response = await this.client.get(`/list/${listId}/task`, { params });
|
|
212
|
+
// Handle both JSON and text responses
|
|
213
|
+
const data = response.data;
|
|
214
|
+
if (typeof data === 'string') {
|
|
215
|
+
throw new ClickUpServiceError('Received unexpected text response from API', ErrorCode.UNKNOWN, data);
|
|
216
|
+
}
|
|
217
|
+
return Array.isArray(data) ? data : data.tasks || [];
|
|
195
218
|
});
|
|
196
219
|
}
|
|
197
220
|
catch (error) {
|
|
198
|
-
throw this.handleError(error, `Failed to get tasks
|
|
221
|
+
throw this.handleError(error, `Failed to get tasks for list ${listId}`);
|
|
199
222
|
}
|
|
200
223
|
}
|
|
201
224
|
/**
|
|
@@ -247,7 +270,18 @@ export class TaskServiceCore extends BaseClickUpService {
|
|
|
247
270
|
try {
|
|
248
271
|
return await this.makeRequest(async () => {
|
|
249
272
|
const response = await this.client.put(`/task/${taskId}`, updateData);
|
|
250
|
-
|
|
273
|
+
// Handle both JSON and text responses
|
|
274
|
+
const data = response.data;
|
|
275
|
+
if (typeof data === 'string') {
|
|
276
|
+
// If we got a text response, try to extract task ID from common patterns
|
|
277
|
+
const idMatch = data.match(/task.*?(\w{9})/i);
|
|
278
|
+
if (idMatch) {
|
|
279
|
+
// If we found an ID, fetch the full task details
|
|
280
|
+
return await this.getTask(idMatch[1]);
|
|
281
|
+
}
|
|
282
|
+
throw new ClickUpServiceError('Received unexpected text response from API', ErrorCode.UNKNOWN, data);
|
|
283
|
+
}
|
|
284
|
+
return data;
|
|
251
285
|
});
|
|
252
286
|
}
|
|
253
287
|
catch (error) {
|
|
@@ -436,4 +470,35 @@ export class TaskServiceCore extends BaseClickUpService {
|
|
|
436
470
|
throw error;
|
|
437
471
|
}
|
|
438
472
|
}
|
|
473
|
+
/**
|
|
474
|
+
* Try to get a task ID from the name cache
|
|
475
|
+
* @param taskName The name of the task
|
|
476
|
+
* @param listId Optional list ID for context
|
|
477
|
+
* @returns The cached task ID if found and not expired, otherwise null
|
|
478
|
+
*/
|
|
479
|
+
getCachedTaskId(taskName, listId) {
|
|
480
|
+
const cached = this.nameToIdCache.get(taskName);
|
|
481
|
+
if (cached && Date.now() - cached.validatedAt < this.CACHE_TTL) {
|
|
482
|
+
// If listId is provided, ensure it matches the cached context
|
|
483
|
+
if (!listId || cached.listId === listId) {
|
|
484
|
+
this.logger.debug('Using cached task ID for name', { taskName, cachedId: cached.taskId });
|
|
485
|
+
return cached.taskId;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Cache a task name to ID mapping
|
|
492
|
+
* @param taskName The name of the task
|
|
493
|
+
* @param taskId The ID of the task
|
|
494
|
+
* @param listId Optional list ID for context
|
|
495
|
+
*/
|
|
496
|
+
cacheTaskNameToId(taskName, taskId, listId) {
|
|
497
|
+
this.nameToIdCache.set(taskName, {
|
|
498
|
+
taskId,
|
|
499
|
+
validatedAt: Date.now(),
|
|
500
|
+
listId
|
|
501
|
+
});
|
|
502
|
+
this.logger.debug('Cached task name to ID mapping', { taskName, taskId, listId });
|
|
503
|
+
}
|
|
439
504
|
}
|
|
@@ -27,23 +27,65 @@ export class TaskServiceSearch extends TaskServiceCore {
|
|
|
27
27
|
this.logOperation('findTaskByName', { listId, taskName });
|
|
28
28
|
try {
|
|
29
29
|
const tasks = await this.getTasks(listId);
|
|
30
|
-
|
|
31
|
-
// Exact match
|
|
32
|
-
let match = tasks.find(task => task.name === taskName);
|
|
33
|
-
if (match)
|
|
34
|
-
return match;
|
|
35
|
-
// Case-insensitive match
|
|
36
|
-
match = tasks.find(task => task.name.toLowerCase() === taskName.toLowerCase());
|
|
37
|
-
if (match)
|
|
38
|
-
return match;
|
|
39
|
-
// Substring match
|
|
40
|
-
match = tasks.find(task => task.name.toLowerCase().includes(taskName.toLowerCase()));
|
|
41
|
-
return match || null;
|
|
30
|
+
return this.findTaskInArray(tasks, taskName);
|
|
42
31
|
}
|
|
43
32
|
catch (error) {
|
|
44
33
|
throw this.handleError(error, `Failed to find task by name: ${error instanceof Error ? error.message : String(error)}`);
|
|
45
34
|
}
|
|
46
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Find a task by name from an array of tasks
|
|
38
|
+
* @param taskArray Array of tasks to search in
|
|
39
|
+
* @param name Name of the task to search for
|
|
40
|
+
* @param includeDetails Whether to add list context to task
|
|
41
|
+
* @returns The task that best matches the name, or null if no match
|
|
42
|
+
*/
|
|
43
|
+
findTaskInArray(taskArray, name, includeDetails = false) {
|
|
44
|
+
if (!taskArray || !Array.isArray(taskArray) || taskArray.length === 0 || !name) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
// Get match scores for each task
|
|
48
|
+
const taskMatchScores = taskArray
|
|
49
|
+
.map(task => {
|
|
50
|
+
const matchResult = isNameMatch(task.name, name);
|
|
51
|
+
return {
|
|
52
|
+
task,
|
|
53
|
+
matchResult,
|
|
54
|
+
// Parse the date_updated field as a number for sorting
|
|
55
|
+
updatedAt: task.date_updated ? parseInt(task.date_updated, 10) : 0
|
|
56
|
+
};
|
|
57
|
+
})
|
|
58
|
+
.filter(result => result.matchResult.isMatch);
|
|
59
|
+
if (taskMatchScores.length === 0) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
// First, try to find exact matches
|
|
63
|
+
const exactMatches = taskMatchScores
|
|
64
|
+
.filter(result => result.matchResult.exactMatch)
|
|
65
|
+
.sort((a, b) => {
|
|
66
|
+
// For exact matches with the same score, sort by most recently updated
|
|
67
|
+
if (b.matchResult.score === a.matchResult.score) {
|
|
68
|
+
return b.updatedAt - a.updatedAt;
|
|
69
|
+
}
|
|
70
|
+
return b.matchResult.score - a.matchResult.score;
|
|
71
|
+
});
|
|
72
|
+
// Get the best matches based on whether we have exact matches or need to fall back to fuzzy matches
|
|
73
|
+
const bestMatches = exactMatches.length > 0 ? exactMatches : taskMatchScores.sort((a, b) => {
|
|
74
|
+
// First sort by match score (highest first)
|
|
75
|
+
if (b.matchResult.score !== a.matchResult.score) {
|
|
76
|
+
return b.matchResult.score - a.matchResult.score;
|
|
77
|
+
}
|
|
78
|
+
// Then sort by most recently updated
|
|
79
|
+
return b.updatedAt - a.updatedAt;
|
|
80
|
+
});
|
|
81
|
+
// Get the best match
|
|
82
|
+
const bestMatch = bestMatches[0].task;
|
|
83
|
+
// If we need to include more details
|
|
84
|
+
if (includeDetails) {
|
|
85
|
+
// Include any additional details needed
|
|
86
|
+
}
|
|
87
|
+
return bestMatch;
|
|
88
|
+
}
|
|
47
89
|
/**
|
|
48
90
|
* Formats a task into a lightweight summary format
|
|
49
91
|
* @param task The task to format
|
|
@@ -177,10 +219,11 @@ export class TaskServiceSearch extends TaskServiceCore {
|
|
|
177
219
|
* - useSmartDisambiguation: Whether to automatically select the most recently updated task
|
|
178
220
|
* - includeFullDetails: Whether to include full task details (true) or just task summaries (false)
|
|
179
221
|
* - includeListContext: Whether to include list/folder/space context with results
|
|
222
|
+
* - requireExactMatch: Whether to only consider exact name matches (true) or allow fuzzy matches (false)
|
|
180
223
|
* @returns Either a single task or an array of tasks depending on options
|
|
181
224
|
* @throws Error if task cannot be found or if multiple matches are found when not allowed
|
|
182
225
|
*/
|
|
183
|
-
async findTasks({ taskId, customTaskId, taskName, listId, listName, allowMultipleMatches = false, useSmartDisambiguation =
|
|
226
|
+
async findTasks({ taskId, customTaskId, taskName, listId, listName, allowMultipleMatches = false, useSmartDisambiguation = true, includeFullDetails = true, includeListContext = false, requireExactMatch = false }) {
|
|
184
227
|
try {
|
|
185
228
|
this.logOperation('findTasks', {
|
|
186
229
|
taskId,
|
|
@@ -189,8 +232,30 @@ export class TaskServiceSearch extends TaskServiceCore {
|
|
|
189
232
|
listId,
|
|
190
233
|
listName,
|
|
191
234
|
allowMultipleMatches,
|
|
192
|
-
useSmartDisambiguation
|
|
235
|
+
useSmartDisambiguation,
|
|
236
|
+
requireExactMatch
|
|
193
237
|
});
|
|
238
|
+
// Check name-to-ID cache first if we have a task name
|
|
239
|
+
if (taskName && !taskId && !customTaskId) {
|
|
240
|
+
// Resolve list ID if we have a list name
|
|
241
|
+
let resolvedListId = listId;
|
|
242
|
+
if (listName && !listId) {
|
|
243
|
+
const listInfo = await findListIDByName(this.workspaceService, listName);
|
|
244
|
+
if (listInfo) {
|
|
245
|
+
resolvedListId = listInfo.id;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// Try to get cached task ID
|
|
249
|
+
const cachedTaskId = this.getCachedTaskId(taskName, resolvedListId);
|
|
250
|
+
if (cachedTaskId) {
|
|
251
|
+
this.logOperation('findTasks', {
|
|
252
|
+
message: 'Using cached task ID for name lookup',
|
|
253
|
+
taskName,
|
|
254
|
+
cachedTaskId
|
|
255
|
+
});
|
|
256
|
+
taskId = cachedTaskId;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
194
259
|
// Case 1: Direct task ID lookup (highest priority)
|
|
195
260
|
if (taskId) {
|
|
196
261
|
// Check if it looks like a custom ID
|
|
@@ -252,10 +317,12 @@ export class TaskServiceSearch extends TaskServiceCore {
|
|
|
252
317
|
}
|
|
253
318
|
resolvedListId = listInfo.id;
|
|
254
319
|
}
|
|
255
|
-
const foundTask = await this.
|
|
320
|
+
const foundTask = this.findTaskInArray(await this.getTasks(resolvedListId), taskName, includeListContext);
|
|
256
321
|
if (!foundTask) {
|
|
257
322
|
throw new Error(`Task "${taskName}" not found in list`);
|
|
258
323
|
}
|
|
324
|
+
// Cache the task name to ID mapping with list context
|
|
325
|
+
this.cacheTaskNameToId(taskName, foundTask.id, resolvedListId);
|
|
259
326
|
// If includeFullDetails is true and we need context not already in the task,
|
|
260
327
|
// get full details, otherwise return what we already have
|
|
261
328
|
if (includeFullDetails && (!foundTask.list || !foundTask.list.name || !foundTask.status)) {
|
|
@@ -265,7 +332,20 @@ export class TaskServiceSearch extends TaskServiceCore {
|
|
|
265
332
|
}
|
|
266
333
|
// Case 3b: Task name without list context - global lookup across workspace
|
|
267
334
|
// Get lightweight task summaries for efficient first-pass filtering
|
|
268
|
-
|
|
335
|
+
this.logOperation('findTasks', {
|
|
336
|
+
message: `Starting global task search for "${taskName}"`,
|
|
337
|
+
includeFullDetails,
|
|
338
|
+
useSmartDisambiguation,
|
|
339
|
+
requireExactMatch
|
|
340
|
+
});
|
|
341
|
+
// Use statuses parameter to get both open and closed tasks
|
|
342
|
+
// Include additional filters to ensure we get as many tasks as possible
|
|
343
|
+
const response = await this.getTaskSummaries({
|
|
344
|
+
include_closed: true,
|
|
345
|
+
include_archived_lists: true,
|
|
346
|
+
include_closed_lists: true,
|
|
347
|
+
subtasks: true
|
|
348
|
+
});
|
|
269
349
|
if (!this.workspaceService) {
|
|
270
350
|
throw new Error("Workspace service required for global task lookup");
|
|
271
351
|
}
|
|
@@ -302,27 +382,73 @@ export class TaskServiceSearch extends TaskServiceCore {
|
|
|
302
382
|
}
|
|
303
383
|
// Build the context map
|
|
304
384
|
buildListContextMap(hierarchy.root.children);
|
|
305
|
-
// Find tasks that match the provided name
|
|
385
|
+
// Find tasks that match the provided name with scored match results
|
|
306
386
|
const initialMatches = [];
|
|
307
387
|
// Process task summaries to find initial matches
|
|
388
|
+
let taskCount = 0;
|
|
389
|
+
let matchesFound = 0;
|
|
390
|
+
// Add additional logging to debug task matching
|
|
391
|
+
this.logOperation('findTasks', {
|
|
392
|
+
total_tasks_in_response: response.summaries.length,
|
|
393
|
+
search_term: taskName,
|
|
394
|
+
requireExactMatch
|
|
395
|
+
});
|
|
308
396
|
for (const taskSummary of response.summaries) {
|
|
309
|
-
|
|
397
|
+
taskCount++;
|
|
398
|
+
// Use isNameMatch for consistent matching behavior with scoring
|
|
399
|
+
const matchResult = isNameMatch(taskSummary.name, taskName);
|
|
400
|
+
const isMatch = matchResult.isMatch;
|
|
401
|
+
// For debugging, log every 20th task or any task with a similar name
|
|
402
|
+
if (taskCount % 20 === 0 || taskSummary.name.toLowerCase().includes(taskName.toLowerCase()) ||
|
|
403
|
+
taskName.toLowerCase().includes(taskSummary.name.toLowerCase())) {
|
|
404
|
+
this.logOperation('findTasks:matching', {
|
|
405
|
+
task_name: taskSummary.name,
|
|
406
|
+
search_term: taskName,
|
|
407
|
+
list_name: taskSummary.list?.name || 'Unknown list',
|
|
408
|
+
is_match: isMatch,
|
|
409
|
+
match_score: matchResult.score,
|
|
410
|
+
match_reason: matchResult.reason || 'no-match'
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
if (isMatch) {
|
|
414
|
+
matchesFound++;
|
|
310
415
|
// Get list context information
|
|
311
416
|
const listContext = listContextMap.get(taskSummary.list.id);
|
|
312
417
|
if (listContext) {
|
|
313
|
-
// Store task summary and context
|
|
418
|
+
// Store task summary and context with match score
|
|
314
419
|
initialMatches.push({
|
|
315
420
|
id: taskSummary.id,
|
|
316
421
|
task: taskSummary,
|
|
317
|
-
listContext
|
|
422
|
+
listContext,
|
|
423
|
+
matchScore: matchResult.score,
|
|
424
|
+
matchReason: matchResult.reason || 'unknown'
|
|
318
425
|
});
|
|
319
426
|
}
|
|
320
427
|
}
|
|
321
428
|
}
|
|
429
|
+
this.logOperation('findTasks', {
|
|
430
|
+
globalSearch: true,
|
|
431
|
+
searchTerm: taskName,
|
|
432
|
+
tasksSearched: taskCount,
|
|
433
|
+
matchesFound: matchesFound,
|
|
434
|
+
validMatchesWithContext: initialMatches.length
|
|
435
|
+
});
|
|
322
436
|
// Handle the no matches case
|
|
323
437
|
if (initialMatches.length === 0) {
|
|
324
438
|
throw new Error(`Task "${taskName}" not found in any list across your workspace. Please check the task name and try again.`);
|
|
325
439
|
}
|
|
440
|
+
// Sort matches by match score first (higher is better), then by update time
|
|
441
|
+
initialMatches.sort((a, b) => {
|
|
442
|
+
// First sort by match score (highest first)
|
|
443
|
+
if (b.matchScore !== a.matchScore) {
|
|
444
|
+
return b.matchScore - a.matchScore;
|
|
445
|
+
}
|
|
446
|
+
// Try to get the date_updated from the task
|
|
447
|
+
const aDate = a.task.date_updated ? parseInt(a.task.date_updated, 10) : 0;
|
|
448
|
+
const bDate = b.task.date_updated ? parseInt(b.task.date_updated, 10) : 0;
|
|
449
|
+
// For equal scores, sort by most recently updated
|
|
450
|
+
return bDate - aDate;
|
|
451
|
+
});
|
|
326
452
|
// Handle the single match case - we can return early if we don't need full details
|
|
327
453
|
if (initialMatches.length === 1 && !useSmartDisambiguation && !includeFullDetails) {
|
|
328
454
|
const match = initialMatches[0];
|
|
@@ -345,12 +471,70 @@ export class TaskServiceSearch extends TaskServiceCore {
|
|
|
345
471
|
}
|
|
346
472
|
return match.task;
|
|
347
473
|
}
|
|
474
|
+
// Handle the exact match case - if there's an exact or very good match, prefer it over others
|
|
475
|
+
// This is our key improvement to prefer exact matches over update time
|
|
476
|
+
const bestMatchScore = initialMatches[0].matchScore;
|
|
477
|
+
if (bestMatchScore >= 80) { // 80+ is an exact match or case-insensitive exact match
|
|
478
|
+
// If there's a single best match with score 80+, use it directly
|
|
479
|
+
const exactMatches = initialMatches.filter(m => m.matchScore >= 80);
|
|
480
|
+
if (exactMatches.length === 1 && !allowMultipleMatches) {
|
|
481
|
+
this.logOperation('findTasks', {
|
|
482
|
+
message: `Found single exact match with score ${exactMatches[0].matchScore}, prioritizing over other matches`,
|
|
483
|
+
matchReason: exactMatches[0].matchReason
|
|
484
|
+
});
|
|
485
|
+
// If we don't need details, return early
|
|
486
|
+
if (!includeFullDetails) {
|
|
487
|
+
const match = exactMatches[0];
|
|
488
|
+
if (includeListContext) {
|
|
489
|
+
return {
|
|
490
|
+
...match.task,
|
|
491
|
+
list: {
|
|
492
|
+
id: match.listContext.listId,
|
|
493
|
+
name: match.listContext.listName
|
|
494
|
+
},
|
|
495
|
+
folder: match.listContext.folderId ? {
|
|
496
|
+
id: match.listContext.folderId,
|
|
497
|
+
name: match.listContext.folderName
|
|
498
|
+
} : undefined,
|
|
499
|
+
space: {
|
|
500
|
+
id: match.listContext.spaceId,
|
|
501
|
+
name: match.listContext.spaceName
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
return match.task;
|
|
506
|
+
}
|
|
507
|
+
// Otherwise, get the full details
|
|
508
|
+
const fullTask = await this.getTask(exactMatches[0].id);
|
|
509
|
+
if (includeListContext) {
|
|
510
|
+
const match = exactMatches[0];
|
|
511
|
+
// Enhance task with context information
|
|
512
|
+
fullTask.list = {
|
|
513
|
+
...fullTask.list,
|
|
514
|
+
name: match.listContext.listName
|
|
515
|
+
};
|
|
516
|
+
if (match.listContext.folderId) {
|
|
517
|
+
fullTask.folder = {
|
|
518
|
+
id: match.listContext.folderId,
|
|
519
|
+
name: match.listContext.folderName
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
fullTask.space = {
|
|
523
|
+
id: match.listContext.spaceId,
|
|
524
|
+
name: match.listContext.spaceName
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
return fullTask;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
348
530
|
// For multiple matches or when we need details, fetch full task info
|
|
349
531
|
const fullMatches = [];
|
|
532
|
+
const matchScoreMap = new Map(); // To preserve match scores
|
|
350
533
|
try {
|
|
351
534
|
// Process in sequence for better reliability
|
|
352
535
|
for (const match of initialMatches) {
|
|
353
536
|
const fullTask = await this.getTask(match.id);
|
|
537
|
+
matchScoreMap.set(fullTask.id, match.matchScore);
|
|
354
538
|
if (includeListContext) {
|
|
355
539
|
// Enhance task with context information
|
|
356
540
|
fullTask.list = {
|
|
@@ -370,9 +554,16 @@ export class TaskServiceSearch extends TaskServiceCore {
|
|
|
370
554
|
}
|
|
371
555
|
fullMatches.push(fullTask);
|
|
372
556
|
}
|
|
373
|
-
// Sort by update time
|
|
557
|
+
// Sort matches - first by match score, then by update time
|
|
374
558
|
if (fullMatches.length > 1) {
|
|
375
559
|
fullMatches.sort((a, b) => {
|
|
560
|
+
// First sort by match score (highest first)
|
|
561
|
+
const aScore = matchScoreMap.get(a.id) || 0;
|
|
562
|
+
const bScore = matchScoreMap.get(b.id) || 0;
|
|
563
|
+
if (aScore !== bScore) {
|
|
564
|
+
return bScore - aScore;
|
|
565
|
+
}
|
|
566
|
+
// For equal scores, sort by update time
|
|
376
567
|
const aDate = parseInt(a.date_updated || '0', 10);
|
|
377
568
|
const bDate = parseInt(b.date_updated || '0', 10);
|
|
378
569
|
return bDate - aDate; // Most recent first
|
|
@@ -404,7 +595,7 @@ export class TaskServiceSearch extends TaskServiceCore {
|
|
|
404
595
|
}));
|
|
405
596
|
}
|
|
406
597
|
else {
|
|
407
|
-
// For single result, return the first match
|
|
598
|
+
// For single result, return the first match (best match score)
|
|
408
599
|
const match = initialMatches[0];
|
|
409
600
|
return {
|
|
410
601
|
...match.task,
|
|
@@ -423,9 +614,15 @@ export class TaskServiceSearch extends TaskServiceCore {
|
|
|
423
614
|
};
|
|
424
615
|
}
|
|
425
616
|
}
|
|
617
|
+
// After finding the task in global search, cache the mapping
|
|
618
|
+
if (initialMatches.length === 1 || useSmartDisambiguation) {
|
|
619
|
+
const bestMatch = fullMatches[0];
|
|
620
|
+
this.cacheTaskNameToId(taskName, bestMatch.id, bestMatch.list?.id);
|
|
621
|
+
return bestMatch;
|
|
622
|
+
}
|
|
426
623
|
// Return results based on options
|
|
427
624
|
if (fullMatches.length === 1 || useSmartDisambiguation) {
|
|
428
|
-
return fullMatches[0]; // Return
|
|
625
|
+
return fullMatches[0]; // Return best match (sorted by score then update time)
|
|
429
626
|
}
|
|
430
627
|
else if (allowMultipleMatches) {
|
|
431
628
|
return fullMatches; // Return all matches
|
|
@@ -439,10 +636,16 @@ export class TaskServiceSearch extends TaskServiceCore {
|
|
|
439
636
|
const updateTime = task.date_updated
|
|
440
637
|
? new Date(parseInt(task.date_updated, 10)).toLocaleString()
|
|
441
638
|
: "Unknown date";
|
|
639
|
+
const matchScore = matchScoreMap.get(task.id) || 0;
|
|
640
|
+
const matchQuality = matchScore >= 100 ? "Exact match" :
|
|
641
|
+
matchScore >= 80 ? "Case-insensitive exact match" :
|
|
642
|
+
matchScore >= 70 ? "Text match ignoring emojis" :
|
|
643
|
+
matchScore >= 50 ? "Contains search term" :
|
|
644
|
+
"Partial match";
|
|
442
645
|
const location = `list "${listName}"${folderName ? ` (folder: "${folderName}")` : ''} (space: "${spaceName}")`;
|
|
443
|
-
return `- "${task.name}" in ${location} - Updated ${updateTime}`;
|
|
646
|
+
return `- "${task.name}" in ${location} - Updated ${updateTime} - Match quality: ${matchQuality} (${matchScore}/100)`;
|
|
444
647
|
}).join('\n');
|
|
445
|
-
throw new Error(`Multiple tasks found with name "${taskName}":\n${matchesInfo}\n\nPlease provide list context to disambiguate or set allowMultipleMatches to true.`);
|
|
648
|
+
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.`);
|
|
446
649
|
}
|
|
447
650
|
}
|
|
448
651
|
// No valid lookup parameters provided
|
|
@@ -459,4 +662,154 @@ export class TaskServiceSearch extends TaskServiceCore {
|
|
|
459
662
|
throw this.handleError(error, `Error finding task: ${error.message}`);
|
|
460
663
|
}
|
|
461
664
|
}
|
|
665
|
+
/**
|
|
666
|
+
* Update a task by name within a specific list
|
|
667
|
+
* @param listId The ID of the list containing the task
|
|
668
|
+
* @param taskName The name of the task to update
|
|
669
|
+
* @param updateData The data to update the task with
|
|
670
|
+
* @returns The updated task
|
|
671
|
+
*/
|
|
672
|
+
async updateTaskByName(listId, taskName, updateData) {
|
|
673
|
+
this.logOperation('updateTaskByName', { listId, taskName, ...updateData });
|
|
674
|
+
try {
|
|
675
|
+
const task = await this.findTaskByName(listId, taskName);
|
|
676
|
+
if (!task) {
|
|
677
|
+
throw new Error(`Task "${taskName}" not found in list ${listId}`);
|
|
678
|
+
}
|
|
679
|
+
return await this.updateTask(task.id, updateData);
|
|
680
|
+
}
|
|
681
|
+
catch (error) {
|
|
682
|
+
throw this.handleError(error, `Failed to update task by name: ${error instanceof Error ? error.message : String(error)}`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Global task search by name across all lists
|
|
687
|
+
* This is a specialized method that uses getWorkspaceTasks to search all lists at once
|
|
688
|
+
* which is more efficient than searching list by list
|
|
689
|
+
*
|
|
690
|
+
* @param taskName The name to search for
|
|
691
|
+
* @returns The best matching task or null if no match found
|
|
692
|
+
*/
|
|
693
|
+
async findTaskByNameGlobally(taskName) {
|
|
694
|
+
this.logOperation('findTaskByNameGlobally', { taskName });
|
|
695
|
+
// Use a static cache for task data to avoid redundant API calls
|
|
696
|
+
// This dramatically reduces API usage across multiple task lookups
|
|
697
|
+
if (!this.constructor.hasOwnProperty('_taskCache')) {
|
|
698
|
+
Object.defineProperty(this.constructor, '_taskCache', {
|
|
699
|
+
value: {
|
|
700
|
+
tasks: [],
|
|
701
|
+
lastFetch: 0,
|
|
702
|
+
cacheTTL: 60000, // 1 minute cache TTL
|
|
703
|
+
},
|
|
704
|
+
writable: true
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
const cache = this.constructor._taskCache;
|
|
708
|
+
const now = Date.now();
|
|
709
|
+
try {
|
|
710
|
+
// Use cached tasks if available and not expired
|
|
711
|
+
let tasks = [];
|
|
712
|
+
if (cache.tasks.length > 0 && (now - cache.lastFetch) < cache.cacheTTL) {
|
|
713
|
+
this.logOperation('findTaskByNameGlobally', {
|
|
714
|
+
usedCache: true,
|
|
715
|
+
cacheAge: now - cache.lastFetch,
|
|
716
|
+
taskCount: cache.tasks.length
|
|
717
|
+
});
|
|
718
|
+
tasks = cache.tasks;
|
|
719
|
+
}
|
|
720
|
+
else {
|
|
721
|
+
// Get tasks using a single efficient workspace-wide API call
|
|
722
|
+
const response = await this.getWorkspaceTasks({
|
|
723
|
+
include_closed: true,
|
|
724
|
+
detail_level: 'detailed'
|
|
725
|
+
});
|
|
726
|
+
tasks = 'tasks' in response ? response.tasks : [];
|
|
727
|
+
// Update cache
|
|
728
|
+
cache.tasks = tasks;
|
|
729
|
+
cache.lastFetch = now;
|
|
730
|
+
this.logOperation('findTaskByNameGlobally', {
|
|
731
|
+
usedCache: false,
|
|
732
|
+
fetchedTaskCount: tasks.length
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
// Map tasks to include match scores and updated time for sorting
|
|
736
|
+
const taskMatches = tasks.map(task => {
|
|
737
|
+
const matchResult = isNameMatch(task.name, taskName);
|
|
738
|
+
return {
|
|
739
|
+
task,
|
|
740
|
+
matchResult,
|
|
741
|
+
updatedAt: task.date_updated ? parseInt(task.date_updated, 10) : 0
|
|
742
|
+
};
|
|
743
|
+
}).filter(result => result.matchResult.isMatch);
|
|
744
|
+
this.logOperation('findTaskByNameGlobally', {
|
|
745
|
+
taskCount: tasks.length,
|
|
746
|
+
matchCount: taskMatches.length,
|
|
747
|
+
taskName
|
|
748
|
+
});
|
|
749
|
+
if (taskMatches.length === 0) {
|
|
750
|
+
return null;
|
|
751
|
+
}
|
|
752
|
+
// First try exact matches
|
|
753
|
+
const exactMatches = taskMatches
|
|
754
|
+
.filter(result => result.matchResult.exactMatch)
|
|
755
|
+
.sort((a, b) => {
|
|
756
|
+
// For exact matches with the same score, sort by most recently updated
|
|
757
|
+
if (b.matchResult.score === a.matchResult.score) {
|
|
758
|
+
return b.updatedAt - a.updatedAt;
|
|
759
|
+
}
|
|
760
|
+
return b.matchResult.score - a.matchResult.score;
|
|
761
|
+
});
|
|
762
|
+
// Get the best matches based on whether we have exact matches or need to fall back to fuzzy matches
|
|
763
|
+
const bestMatches = exactMatches.length > 0 ? exactMatches : taskMatches.sort((a, b) => {
|
|
764
|
+
// First sort by match score (highest first)
|
|
765
|
+
if (b.matchResult.score !== a.matchResult.score) {
|
|
766
|
+
return b.matchResult.score - a.matchResult.score;
|
|
767
|
+
}
|
|
768
|
+
// Then sort by most recently updated
|
|
769
|
+
return b.updatedAt - a.updatedAt;
|
|
770
|
+
});
|
|
771
|
+
// Log the top matches for debugging
|
|
772
|
+
const topMatches = bestMatches.slice(0, 3).map(match => ({
|
|
773
|
+
taskName: match.task.name,
|
|
774
|
+
score: match.matchResult.score,
|
|
775
|
+
reason: match.matchResult.reason,
|
|
776
|
+
updatedAt: match.updatedAt,
|
|
777
|
+
list: match.task.list?.name || 'Unknown list'
|
|
778
|
+
}));
|
|
779
|
+
this.logOperation('findTaskByNameGlobally', { topMatches });
|
|
780
|
+
// Return the best match
|
|
781
|
+
return bestMatches[0].task;
|
|
782
|
+
}
|
|
783
|
+
catch (error) {
|
|
784
|
+
this.logOperation('findTaskByNameGlobally', { error: error.message });
|
|
785
|
+
// If there's an error (like rate limit), try to use cached data even if expired
|
|
786
|
+
if (cache.tasks.length > 0) {
|
|
787
|
+
this.logOperation('findTaskByNameGlobally', {
|
|
788
|
+
message: 'Using expired cache due to API error',
|
|
789
|
+
cacheAge: now - cache.lastFetch
|
|
790
|
+
});
|
|
791
|
+
// Perform the same matching logic with cached data
|
|
792
|
+
const taskMatches = cache.tasks
|
|
793
|
+
.map(task => {
|
|
794
|
+
const matchResult = isNameMatch(task.name, taskName);
|
|
795
|
+
return {
|
|
796
|
+
task,
|
|
797
|
+
matchResult,
|
|
798
|
+
updatedAt: task.date_updated ? parseInt(task.date_updated, 10) : 0
|
|
799
|
+
};
|
|
800
|
+
})
|
|
801
|
+
.filter(result => result.matchResult.isMatch)
|
|
802
|
+
.sort((a, b) => {
|
|
803
|
+
if (b.matchResult.score !== a.matchResult.score) {
|
|
804
|
+
return b.matchResult.score - a.matchResult.score;
|
|
805
|
+
}
|
|
806
|
+
return b.updatedAt - a.updatedAt;
|
|
807
|
+
});
|
|
808
|
+
if (taskMatches.length > 0) {
|
|
809
|
+
return taskMatches[0].task;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
return null;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
462
815
|
}
|