@griffinwork40/clickup-mcp-server 1.1.0 → 1.1.2
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/LICENSE +6 -0
- package/README.md +20 -1
- package/dist/index.js +277 -24
- package/dist/index.js.map +1 -1
- package/dist/utils.d.ts +67 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +456 -3
- package/dist/utils.js.map +1 -1
- package/package.json +2 -2
package/dist/utils.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared utility functions for ClickUp MCP server.
|
|
3
3
|
*/
|
|
4
|
-
import type { ClickUpTask, ClickUpList, ClickUpSpace, ClickUpFolder, ClickUpComment, ClickUpTimeEntry, PaginationInfo, TruncationInfo } from "./types.js";
|
|
4
|
+
import type { ClickUpTask, ClickUpList, ClickUpSpace, ClickUpFolder, ClickUpComment, ClickUpTimeEntry, PaginationInfo, TruncationInfo, ClickUpCustomField } from "./types.js";
|
|
5
5
|
/**
|
|
6
6
|
* Get ClickUp API token from environment
|
|
7
7
|
*/
|
|
@@ -63,6 +63,7 @@ export declare function getPagination(total: number | undefined, count: number,
|
|
|
63
63
|
export declare function generateTaskSummary(tasks: ClickUpTask[]): string;
|
|
64
64
|
/**
|
|
65
65
|
* Truncate response if it exceeds character limit with smart boundary detection
|
|
66
|
+
* Detects format and uses appropriate truncation strategy
|
|
66
67
|
*/
|
|
67
68
|
export declare function truncateResponse(content: string, itemCount: number, itemType?: string): {
|
|
68
69
|
content: string;
|
|
@@ -72,4 +73,69 @@ export declare function truncateResponse(content: string, itemCount: number, ite
|
|
|
72
73
|
* Format truncation information as text
|
|
73
74
|
*/
|
|
74
75
|
export declare function formatTruncationInfo(truncation: TruncationInfo | null): string;
|
|
76
|
+
/**
|
|
77
|
+
* Filter tasks by status names (client-side filtering)
|
|
78
|
+
*/
|
|
79
|
+
export declare function filterTasksByStatus(tasks: ClickUpTask[], statuses: string[]): ClickUpTask[];
|
|
80
|
+
/**
|
|
81
|
+
* Options for counting tasks by status
|
|
82
|
+
*/
|
|
83
|
+
export interface CountTasksOptions {
|
|
84
|
+
archived?: boolean;
|
|
85
|
+
include_closed?: boolean;
|
|
86
|
+
statuses?: string[];
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Result of counting tasks by status
|
|
90
|
+
*/
|
|
91
|
+
export interface CountTasksResult {
|
|
92
|
+
total: number;
|
|
93
|
+
by_status: Record<string, number>;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Count tasks in a list by status, handling pagination internally
|
|
97
|
+
*/
|
|
98
|
+
export declare function countTasksByStatus(listId: string, options?: CountTasksOptions): Promise<CountTasksResult>;
|
|
99
|
+
/**
|
|
100
|
+
* Options for exporting tasks to CSV
|
|
101
|
+
*/
|
|
102
|
+
export interface ExportCSVOptions {
|
|
103
|
+
archived?: boolean;
|
|
104
|
+
include_closed?: boolean;
|
|
105
|
+
statuses?: string[];
|
|
106
|
+
custom_fields?: string[];
|
|
107
|
+
include_standard_fields?: boolean;
|
|
108
|
+
add_phone_number_column?: boolean;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Escape CSV fields to handle commas, quotes, and newlines
|
|
112
|
+
*/
|
|
113
|
+
export declare function escapeCSV(value: any): string;
|
|
114
|
+
/**
|
|
115
|
+
* Normalize phone number to E.164 format
|
|
116
|
+
* E.164 format: ^\+?[1-9]\d{1,14}$
|
|
117
|
+
* Rules:
|
|
118
|
+
* - Optional + at the start
|
|
119
|
+
* - Must start with digit 1-9 (not 0)
|
|
120
|
+
* - Followed by 1-14 digits only
|
|
121
|
+
* - NO spaces, dashes, parentheses, dots, or other characters
|
|
122
|
+
* - NO extensions
|
|
123
|
+
*/
|
|
124
|
+
export declare function normalizePhoneToE164(phone: string | null | undefined): string;
|
|
125
|
+
/**
|
|
126
|
+
* Extract value from a custom field based on its type
|
|
127
|
+
*/
|
|
128
|
+
export declare function extractCustomFieldValue(field: ClickUpCustomField): string;
|
|
129
|
+
/**
|
|
130
|
+
* Get custom field value by name (prefers field with value if multiple exist)
|
|
131
|
+
*/
|
|
132
|
+
export declare function getCustomField(task: ClickUpTask, fieldName: string, preferredType?: string): string;
|
|
133
|
+
/**
|
|
134
|
+
* Convert task to CSV row array
|
|
135
|
+
*/
|
|
136
|
+
export declare function taskToCSVRow(task: ClickUpTask, fieldOrder: string[]): string[];
|
|
137
|
+
/**
|
|
138
|
+
* Export tasks to CSV format
|
|
139
|
+
*/
|
|
140
|
+
export declare function exportTasksToCSV(listId: string, options?: ExportCSVOptions): Promise<string>;
|
|
75
141
|
//# sourceMappingURL=utils.d.ts.map
|
package/dist/utils.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,KAAK,EACV,WAAW,EACX,WAAW,EACX,YAAY,EACZ,aAAa,EACb,cAAc,EACd,gBAAgB,EAChB,cAAc,EACd,cAAc,
|
|
1
|
+
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"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"}
|
package/dist/utils.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Shared utility functions for ClickUp MCP server.
|
|
3
3
|
*/
|
|
4
4
|
import axios, { AxiosError } from "axios";
|
|
5
|
-
import { API_BASE_URL, CHARACTER_LIMIT, DEFAULT_TIMEOUT } from "./constants.js";
|
|
5
|
+
import { API_BASE_URL, CHARACTER_LIMIT, DEFAULT_TIMEOUT, MAX_LIMIT } from "./constants.js";
|
|
6
6
|
/**
|
|
7
7
|
* Get ClickUp API token from environment
|
|
8
8
|
*/
|
|
@@ -293,9 +293,71 @@ export function generateTaskSummary(tasks) {
|
|
|
293
293
|
return lines.join("\n");
|
|
294
294
|
}
|
|
295
295
|
/**
|
|
296
|
-
* Truncate response
|
|
296
|
+
* Truncate JSON response by removing items from arrays
|
|
297
297
|
*/
|
|
298
|
-
|
|
298
|
+
function truncateJsonResponse(content, itemCount, itemType = "items") {
|
|
299
|
+
try {
|
|
300
|
+
const data = JSON.parse(content);
|
|
301
|
+
// Find the main array (tasks, conversations, etc.)
|
|
302
|
+
const arrayKey = Object.keys(data).find(key => Array.isArray(data[key]));
|
|
303
|
+
if (!arrayKey || !Array.isArray(data[arrayKey])) {
|
|
304
|
+
// If no array found, fall back to string truncation
|
|
305
|
+
throw new Error('No array found in JSON');
|
|
306
|
+
}
|
|
307
|
+
const items = data[arrayKey];
|
|
308
|
+
let keptItems = items.length;
|
|
309
|
+
// Remove items one by one until we're under the limit
|
|
310
|
+
while (JSON.stringify(data).length > CHARACTER_LIMIT && data[arrayKey].length > 1) {
|
|
311
|
+
data[arrayKey].pop();
|
|
312
|
+
keptItems = data[arrayKey].length;
|
|
313
|
+
}
|
|
314
|
+
const finalContent = JSON.stringify(data, null, 2);
|
|
315
|
+
// Check if even a single item exceeds the limit
|
|
316
|
+
if (finalContent.length > CHARACTER_LIMIT) {
|
|
317
|
+
// Single item is too large - truncate the item's description/content fields
|
|
318
|
+
if (data[arrayKey].length === 1 && typeof data[arrayKey][0] === 'object') {
|
|
319
|
+
const item = data[arrayKey][0];
|
|
320
|
+
// Truncate large text fields
|
|
321
|
+
for (const key of Object.keys(item)) {
|
|
322
|
+
if (typeof item[key] === 'string' && item[key].length > 10000) {
|
|
323
|
+
item[key] = item[key].substring(0, 10000) + '... [truncated]';
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// Retry serialization
|
|
327
|
+
const compactContent = JSON.stringify(data, null, 2);
|
|
328
|
+
if (compactContent.length <= CHARACTER_LIMIT) {
|
|
329
|
+
const truncation = {
|
|
330
|
+
truncated: true,
|
|
331
|
+
original_count: itemCount,
|
|
332
|
+
returned_count: 1,
|
|
333
|
+
truncation_message: `Large ${itemType} fields were truncated to fit size limits (${CHARACTER_LIMIT.toLocaleString()} chars).`
|
|
334
|
+
};
|
|
335
|
+
return { content: compactContent, truncation };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// Still too large - fall back to markdown truncation
|
|
339
|
+
return truncateMarkdownResponse(content, itemCount, itemType);
|
|
340
|
+
}
|
|
341
|
+
if (keptItems < items.length) {
|
|
342
|
+
const truncation = {
|
|
343
|
+
truncated: true,
|
|
344
|
+
original_count: items.length,
|
|
345
|
+
returned_count: keptItems,
|
|
346
|
+
truncation_message: `Response truncated from ${items.length} to ${keptItems} ${itemType} due to size limits (${CHARACTER_LIMIT.toLocaleString()} chars). Use pagination (offset/limit), add filters, or use response_mode='compact' to see more results.`
|
|
347
|
+
};
|
|
348
|
+
return { content: finalContent, truncation };
|
|
349
|
+
}
|
|
350
|
+
return { content: finalContent, truncation: null };
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
// If JSON parsing fails, fall back to string truncation
|
|
354
|
+
return truncateMarkdownResponse(content, itemCount, itemType);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Truncate markdown response if it exceeds character limit with smart boundary detection
|
|
359
|
+
*/
|
|
360
|
+
function truncateMarkdownResponse(content, itemCount, itemType = "items") {
|
|
299
361
|
if (content.length <= CHARACTER_LIMIT) {
|
|
300
362
|
return { content, truncation: null };
|
|
301
363
|
}
|
|
@@ -332,6 +394,21 @@ export function truncateResponse(content, itemCount, itemType = "items") {
|
|
|
332
394
|
};
|
|
333
395
|
return { content: finalContent, truncation };
|
|
334
396
|
}
|
|
397
|
+
/**
|
|
398
|
+
* Truncate response if it exceeds character limit with smart boundary detection
|
|
399
|
+
* Detects format and uses appropriate truncation strategy
|
|
400
|
+
*/
|
|
401
|
+
export function truncateResponse(content, itemCount, itemType = "items") {
|
|
402
|
+
if (content.length <= CHARACTER_LIMIT) {
|
|
403
|
+
return { content, truncation: null };
|
|
404
|
+
}
|
|
405
|
+
// Detect if content is JSON by checking first non-whitespace character
|
|
406
|
+
const trimmed = content.trimStart();
|
|
407
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
408
|
+
return truncateJsonResponse(content, itemCount, itemType);
|
|
409
|
+
}
|
|
410
|
+
return truncateMarkdownResponse(content, itemCount, itemType);
|
|
411
|
+
}
|
|
335
412
|
/**
|
|
336
413
|
* Format truncation information as text
|
|
337
414
|
*/
|
|
@@ -340,4 +417,380 @@ export function formatTruncationInfo(truncation) {
|
|
|
340
417
|
return "";
|
|
341
418
|
return `\n\n---\n⚠️ ${truncation.truncation_message}`;
|
|
342
419
|
}
|
|
420
|
+
/**
|
|
421
|
+
* Filter tasks by status names (client-side filtering)
|
|
422
|
+
*/
|
|
423
|
+
export function filterTasksByStatus(tasks, statuses) {
|
|
424
|
+
if (!statuses || statuses.length === 0) {
|
|
425
|
+
return tasks;
|
|
426
|
+
}
|
|
427
|
+
return tasks.filter(task => {
|
|
428
|
+
const taskStatus = task.status?.status;
|
|
429
|
+
return taskStatus && statuses.includes(taskStatus);
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Count tasks in a list by status, handling pagination internally
|
|
434
|
+
*/
|
|
435
|
+
export async function countTasksByStatus(listId, options = {}) {
|
|
436
|
+
const { archived = false, include_closed = false, statuses } = options;
|
|
437
|
+
const limit = 100; // Use max limit for efficiency
|
|
438
|
+
let offset = 0;
|
|
439
|
+
let allTasks = [];
|
|
440
|
+
let hasMore = true;
|
|
441
|
+
// Fetch all tasks with pagination
|
|
442
|
+
while (hasMore) {
|
|
443
|
+
const queryParams = {
|
|
444
|
+
archived,
|
|
445
|
+
include_closed,
|
|
446
|
+
page: Math.floor(offset / limit)
|
|
447
|
+
};
|
|
448
|
+
try {
|
|
449
|
+
const data = await makeApiRequest(`list/${listId}/task`, "GET", undefined, queryParams);
|
|
450
|
+
const tasks = data.tasks || [];
|
|
451
|
+
allTasks.push(...tasks);
|
|
452
|
+
hasMore = tasks.length === limit;
|
|
453
|
+
offset += limit;
|
|
454
|
+
}
|
|
455
|
+
catch (error) {
|
|
456
|
+
throw error;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
// Filter by status if specified
|
|
460
|
+
let filteredTasks = allTasks;
|
|
461
|
+
if (statuses && statuses.length > 0) {
|
|
462
|
+
filteredTasks = filterTasksByStatus(allTasks, statuses);
|
|
463
|
+
}
|
|
464
|
+
// Count by status
|
|
465
|
+
const byStatus = {};
|
|
466
|
+
for (const task of filteredTasks) {
|
|
467
|
+
const status = task.status?.status || "Unknown";
|
|
468
|
+
byStatus[status] = (byStatus[status] || 0) + 1;
|
|
469
|
+
}
|
|
470
|
+
return {
|
|
471
|
+
total: filteredTasks.length,
|
|
472
|
+
by_status: byStatus
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Escape CSV fields to handle commas, quotes, and newlines
|
|
477
|
+
*/
|
|
478
|
+
export function escapeCSV(value) {
|
|
479
|
+
if (value === null || value === undefined)
|
|
480
|
+
return '';
|
|
481
|
+
const stringValue = String(value);
|
|
482
|
+
// If contains comma, quote, or newline, wrap in quotes and escape quotes
|
|
483
|
+
if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n')) {
|
|
484
|
+
return `"${stringValue.replace(/"/g, '""')}"`;
|
|
485
|
+
}
|
|
486
|
+
return stringValue;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Normalize phone number to E.164 format
|
|
490
|
+
* E.164 format: ^\+?[1-9]\d{1,14}$
|
|
491
|
+
* Rules:
|
|
492
|
+
* - Optional + at the start
|
|
493
|
+
* - Must start with digit 1-9 (not 0)
|
|
494
|
+
* - Followed by 1-14 digits only
|
|
495
|
+
* - NO spaces, dashes, parentheses, dots, or other characters
|
|
496
|
+
* - NO extensions
|
|
497
|
+
*/
|
|
498
|
+
export function normalizePhoneToE164(phone) {
|
|
499
|
+
if (!phone)
|
|
500
|
+
return '';
|
|
501
|
+
let normalized = String(phone).trim();
|
|
502
|
+
if (!normalized)
|
|
503
|
+
return '';
|
|
504
|
+
// Remove extensions (x206, ext 123, extension 456, etc.)
|
|
505
|
+
// Match: x, ext, extension followed by optional space and digits
|
|
506
|
+
normalized = normalized.replace(/\s*(?:x|ext|extension)\s*\d+/i, '');
|
|
507
|
+
// Check if it starts with + (preserve it)
|
|
508
|
+
const hasPlus = normalized.startsWith('+');
|
|
509
|
+
if (hasPlus) {
|
|
510
|
+
normalized = normalized.substring(1);
|
|
511
|
+
}
|
|
512
|
+
// Remove all non-digit characters
|
|
513
|
+
normalized = normalized.replace(/\D/g, '');
|
|
514
|
+
// If empty after cleaning, return empty
|
|
515
|
+
if (!normalized)
|
|
516
|
+
return '';
|
|
517
|
+
// If number starts with 0, it's invalid for E.164 (must start with 1-9)
|
|
518
|
+
if (normalized.startsWith('0')) {
|
|
519
|
+
return '';
|
|
520
|
+
}
|
|
521
|
+
// Check if it already has a country code (starts with 1-9 and has 10+ digits)
|
|
522
|
+
// If it's 10 digits and starts with 1, assume it's US/Canada with country code
|
|
523
|
+
// If it's 10 digits and doesn't start with 1, assume it's missing country code
|
|
524
|
+
// If it's 11 digits and starts with 1, it already has country code
|
|
525
|
+
// If it's more than 11 digits, assume it already has country code
|
|
526
|
+
if (normalized.length === 10) {
|
|
527
|
+
// 10 digits without country code - add +1 for US/Canada
|
|
528
|
+
normalized = '1' + normalized;
|
|
529
|
+
}
|
|
530
|
+
else if (normalized.length === 11 && normalized.startsWith('1')) {
|
|
531
|
+
// Already has US/Canada country code
|
|
532
|
+
// Keep as is
|
|
533
|
+
}
|
|
534
|
+
else if (normalized.length < 10) {
|
|
535
|
+
// Too short to be a valid phone number
|
|
536
|
+
return '';
|
|
537
|
+
}
|
|
538
|
+
else if (normalized.length > 15) {
|
|
539
|
+
// Too long for E.164 (max 15 digits after country code)
|
|
540
|
+
return '';
|
|
541
|
+
}
|
|
542
|
+
// Add + prefix
|
|
543
|
+
normalized = '+' + normalized;
|
|
544
|
+
// Validate E.164 format: ^\+?[1-9]\d{1,14}$
|
|
545
|
+
// We already have the +, so check: [1-9]\d{1,14}
|
|
546
|
+
const e164Regex = /^\+[1-9]\d{1,14}$/;
|
|
547
|
+
if (!e164Regex.test(normalized)) {
|
|
548
|
+
return '';
|
|
549
|
+
}
|
|
550
|
+
return normalized;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Extract value from a custom field based on its type
|
|
554
|
+
*/
|
|
555
|
+
export function extractCustomFieldValue(field) {
|
|
556
|
+
if (!field || field.value === null || field.value === undefined || field.value === '')
|
|
557
|
+
return '';
|
|
558
|
+
// Check if this is a phone number field (by type or name)
|
|
559
|
+
const isPhoneField = field.type === 'phone' ||
|
|
560
|
+
field.type === 'phone_number' ||
|
|
561
|
+
(typeof field.name === 'string' && /phone/i.test(field.name));
|
|
562
|
+
// Handle different field types
|
|
563
|
+
if (field.type === 'email' || field.type === 'url' || field.type === 'text' || field.type === 'short_text') {
|
|
564
|
+
const value = String(field.value).trim();
|
|
565
|
+
// Normalize if it's a phone field
|
|
566
|
+
return isPhoneField ? normalizePhoneToE164(value) : value;
|
|
567
|
+
}
|
|
568
|
+
else if (field.type === 'phone' || field.type === 'phone_number') {
|
|
569
|
+
return normalizePhoneToE164(field.value);
|
|
570
|
+
}
|
|
571
|
+
else if (field.type === 'number' || field.type === 'currency') {
|
|
572
|
+
return String(field.value);
|
|
573
|
+
}
|
|
574
|
+
else if (field.type === 'date') {
|
|
575
|
+
return new Date(parseInt(field.value)).toISOString().split('T')[0];
|
|
576
|
+
}
|
|
577
|
+
else if (field.type === 'dropdown') {
|
|
578
|
+
return field.value?.label || field.value?.name || String(field.value || '');
|
|
579
|
+
}
|
|
580
|
+
else if (field.type === 'labels') {
|
|
581
|
+
return Array.isArray(field.value) ? field.value.map((v) => v.label || v.name || v).join('; ') : '';
|
|
582
|
+
}
|
|
583
|
+
else if (field.type === 'checklist') {
|
|
584
|
+
return Array.isArray(field.value) ? field.value.map((item) => item.name).join('; ') : '';
|
|
585
|
+
}
|
|
586
|
+
else if (field.type === 'checkbox') {
|
|
587
|
+
return field.value ? 'Yes' : 'No';
|
|
588
|
+
}
|
|
589
|
+
// Default: return trimmed string, normalize if it's a phone field
|
|
590
|
+
const value = String(field.value || '').trim();
|
|
591
|
+
return isPhoneField ? normalizePhoneToE164(value) : value;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Get custom field value by name (prefers field with value if multiple exist)
|
|
595
|
+
*/
|
|
596
|
+
export function getCustomField(task, fieldName, preferredType) {
|
|
597
|
+
if (!task.custom_fields)
|
|
598
|
+
return '';
|
|
599
|
+
// Find all fields with matching name
|
|
600
|
+
const matchingFields = task.custom_fields.filter(f => f.name === fieldName);
|
|
601
|
+
if (matchingFields.length === 0)
|
|
602
|
+
return '';
|
|
603
|
+
// If preferred type specified, try that first
|
|
604
|
+
if (preferredType) {
|
|
605
|
+
const typedField = matchingFields.find(f => f.type === preferredType && f.value);
|
|
606
|
+
if (typedField) {
|
|
607
|
+
return extractCustomFieldValue(typedField);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
// Prefer field with a value
|
|
611
|
+
const fieldWithValue = matchingFields.find(f => f.value !== null && f.value !== undefined && f.value !== '');
|
|
612
|
+
const field = fieldWithValue || matchingFields[0];
|
|
613
|
+
return extractCustomFieldValue(field);
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Convert task to CSV row array
|
|
617
|
+
*/
|
|
618
|
+
export function taskToCSVRow(task, fieldOrder) {
|
|
619
|
+
const row = [];
|
|
620
|
+
for (const fieldName of fieldOrder) {
|
|
621
|
+
let value = '';
|
|
622
|
+
// Standard fields
|
|
623
|
+
if (fieldName === 'Task ID') {
|
|
624
|
+
value = task.id;
|
|
625
|
+
}
|
|
626
|
+
else if (fieldName === 'Name') {
|
|
627
|
+
value = task.name;
|
|
628
|
+
}
|
|
629
|
+
else if (fieldName === 'Status') {
|
|
630
|
+
value = task.status?.status || '';
|
|
631
|
+
}
|
|
632
|
+
else if (fieldName === 'Date Created') {
|
|
633
|
+
value = task.date_created ? new Date(parseInt(task.date_created)).toISOString() : '';
|
|
634
|
+
}
|
|
635
|
+
else if (fieldName === 'Date Updated') {
|
|
636
|
+
value = task.date_updated ? new Date(parseInt(task.date_updated)).toISOString() : '';
|
|
637
|
+
}
|
|
638
|
+
else if (fieldName === 'URL') {
|
|
639
|
+
value = task.url || '';
|
|
640
|
+
}
|
|
641
|
+
else if (fieldName === 'Assignees') {
|
|
642
|
+
value = task.assignees?.map(a => a.username || a.email).join('; ') || '';
|
|
643
|
+
}
|
|
644
|
+
else if (fieldName === 'Creator') {
|
|
645
|
+
value = task.creator?.username || task.creator?.email || '';
|
|
646
|
+
}
|
|
647
|
+
else if (fieldName === 'Due Date') {
|
|
648
|
+
value = task.due_date ? new Date(parseInt(task.due_date)).toISOString() : '';
|
|
649
|
+
}
|
|
650
|
+
else if (fieldName === 'Priority') {
|
|
651
|
+
value = task.priority?.priority || '';
|
|
652
|
+
}
|
|
653
|
+
else if (fieldName === 'Description') {
|
|
654
|
+
value = task.description || task.text_content || '';
|
|
655
|
+
}
|
|
656
|
+
else if (fieldName === 'Tags') {
|
|
657
|
+
value = task.tags?.map(t => t.name).join('; ') || '';
|
|
658
|
+
}
|
|
659
|
+
else if (fieldName === 'phone_number') {
|
|
660
|
+
// Combined phone number field for ElevenLabs compatibility
|
|
661
|
+
// First check if a real phone_number custom field exists
|
|
662
|
+
const realPhoneNumberField = task.custom_fields?.find(f => f.name === 'phone_number');
|
|
663
|
+
if (realPhoneNumberField && (realPhoneNumberField.value !== null && realPhoneNumberField.value !== undefined && realPhoneNumberField.value !== '')) {
|
|
664
|
+
// Use the real phone_number field value (already normalized)
|
|
665
|
+
value = extractCustomFieldValue(realPhoneNumberField);
|
|
666
|
+
}
|
|
667
|
+
else {
|
|
668
|
+
// No real phone_number field, use synthetic logic
|
|
669
|
+
// Priority: Personal Phone > Biz Phone number > first phone field found
|
|
670
|
+
const personalPhone = getCustomField(task, 'Personal Phone');
|
|
671
|
+
const bizPhone = getCustomField(task, 'Biz Phone number');
|
|
672
|
+
if (personalPhone) {
|
|
673
|
+
value = personalPhone;
|
|
674
|
+
}
|
|
675
|
+
else if (bizPhone) {
|
|
676
|
+
value = bizPhone;
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
// Try to find any phone field
|
|
680
|
+
const phoneFields = task.custom_fields?.filter(f => f.type === 'phone' ||
|
|
681
|
+
f.type === 'phone_number' ||
|
|
682
|
+
(typeof f.name === 'string' && /phone/i.test(f.name))) || [];
|
|
683
|
+
if (phoneFields.length > 0) {
|
|
684
|
+
value = extractCustomFieldValue(phoneFields[0]);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
else {
|
|
690
|
+
// Custom field
|
|
691
|
+
value = getCustomField(task, fieldName);
|
|
692
|
+
}
|
|
693
|
+
row.push(escapeCSV(value));
|
|
694
|
+
}
|
|
695
|
+
return row;
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Export tasks to CSV format
|
|
699
|
+
*/
|
|
700
|
+
export async function exportTasksToCSV(listId, options = {}) {
|
|
701
|
+
const { archived = false, include_closed = false, statuses, custom_fields, include_standard_fields = true, add_phone_number_column = false } = options;
|
|
702
|
+
const limit = MAX_LIMIT;
|
|
703
|
+
let offset = 0;
|
|
704
|
+
let allTasks = [];
|
|
705
|
+
let hasMore = true;
|
|
706
|
+
// Step 1: Fetch all tasks with pagination
|
|
707
|
+
while (hasMore) {
|
|
708
|
+
const queryParams = {
|
|
709
|
+
archived,
|
|
710
|
+
include_closed,
|
|
711
|
+
page: Math.floor(offset / limit)
|
|
712
|
+
};
|
|
713
|
+
try {
|
|
714
|
+
const data = await makeApiRequest(`list/${listId}/task`, "GET", undefined, queryParams);
|
|
715
|
+
const tasks = data.tasks || [];
|
|
716
|
+
allTasks.push(...tasks);
|
|
717
|
+
hasMore = tasks.length === limit;
|
|
718
|
+
offset += limit;
|
|
719
|
+
}
|
|
720
|
+
catch (error) {
|
|
721
|
+
throw error;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
// Step 2: Filter by status if specified
|
|
725
|
+
if (statuses && statuses.length > 0) {
|
|
726
|
+
allTasks = filterTasksByStatus(allTasks, statuses);
|
|
727
|
+
}
|
|
728
|
+
if (allTasks.length === 0) {
|
|
729
|
+
return ''; // Return empty CSV if no tasks
|
|
730
|
+
}
|
|
731
|
+
// Note: The list endpoint already returns custom fields with values,
|
|
732
|
+
// so we don't need to fetch individual task details!
|
|
733
|
+
// Step 3: Build field order and headers
|
|
734
|
+
const standardFields = [
|
|
735
|
+
'Task ID',
|
|
736
|
+
'Name',
|
|
737
|
+
'Status',
|
|
738
|
+
'Date Created',
|
|
739
|
+
'Date Updated',
|
|
740
|
+
'URL',
|
|
741
|
+
'Assignees',
|
|
742
|
+
'Creator',
|
|
743
|
+
'Due Date',
|
|
744
|
+
'Priority',
|
|
745
|
+
'Description',
|
|
746
|
+
'Tags'
|
|
747
|
+
];
|
|
748
|
+
// Collect all custom field names from tasks
|
|
749
|
+
const allCustomFieldNames = new Set();
|
|
750
|
+
for (const task of allTasks) {
|
|
751
|
+
if (task.custom_fields) {
|
|
752
|
+
for (const field of task.custom_fields) {
|
|
753
|
+
allCustomFieldNames.add(field.name);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
// Determine which custom fields to include
|
|
758
|
+
const customFieldsToInclude = custom_fields && custom_fields.length > 0
|
|
759
|
+
? custom_fields.filter(name => allCustomFieldNames.has(name))
|
|
760
|
+
: Array.from(allCustomFieldNames);
|
|
761
|
+
// Build field order
|
|
762
|
+
const fieldOrder = [];
|
|
763
|
+
if (include_standard_fields) {
|
|
764
|
+
fieldOrder.push(...standardFields);
|
|
765
|
+
}
|
|
766
|
+
fieldOrder.push(...customFieldsToInclude);
|
|
767
|
+
// Add phone_number column if requested (for ElevenLabs compatibility)
|
|
768
|
+
if (add_phone_number_column) {
|
|
769
|
+
// Check if phone_number already exists (e.g., as a real custom field)
|
|
770
|
+
const phoneNumberIdx = fieldOrder.indexOf('phone_number');
|
|
771
|
+
if (phoneNumberIdx === -1) {
|
|
772
|
+
// phone_number doesn't exist, add our synthetic one
|
|
773
|
+
// Find Email index to insert after it
|
|
774
|
+
const emailIdx = fieldOrder.indexOf('Email');
|
|
775
|
+
if (emailIdx >= 0) {
|
|
776
|
+
fieldOrder.splice(emailIdx + 1, 0, 'phone_number');
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
// If no Email field, add at the end
|
|
780
|
+
fieldOrder.push('phone_number');
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
// If phone_number already exists, use the existing one (no duplicate needed)
|
|
784
|
+
}
|
|
785
|
+
// Step 4: Build CSV
|
|
786
|
+
const csvRows = [];
|
|
787
|
+
// Headers
|
|
788
|
+
csvRows.push(fieldOrder.map(escapeCSV).join(','));
|
|
789
|
+
// Data rows
|
|
790
|
+
for (const task of allTasks) {
|
|
791
|
+
const row = taskToCSVRow(task, fieldOrder);
|
|
792
|
+
csvRows.push(row.join(','));
|
|
793
|
+
}
|
|
794
|
+
return csvRows.join('\n');
|
|
795
|
+
}
|
|
343
796
|
//# sourceMappingURL=utils.js.map
|