@hauptsache.net/clickup-mcp 1.6.2 → 1.7.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/README.md +38 -2
- package/dist/cli.js +3 -1
- package/dist/clickup-text.d.ts +83 -2
- package/dist/clickup-text.d.ts.map +1 -1
- package/dist/clickup-text.js +343 -36
- package/dist/shared/attachments.d.ts +112 -0
- package/dist/shared/attachments.d.ts.map +1 -0
- package/dist/shared/attachments.js +268 -0
- package/dist/shared/config.d.ts +2 -0
- package/dist/shared/config.d.ts.map +1 -1
- package/dist/shared/config.js +44 -0
- package/dist/shared/image-processing.d.ts +5 -0
- package/dist/shared/image-processing.d.ts.map +1 -1
- package/dist/shared/image-processing.js +1 -0
- package/dist/tools/task-write-tools.d.ts.map +1 -1
- package/dist/tools/task-write-tools.js +354 -4
- package/package.json +3 -2
package/dist/clickup-text.js
CHANGED
|
@@ -5,6 +5,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.convertClickUpTextItemsToToolCallResult = convertClickUpTextItemsToToolCallResult;
|
|
7
7
|
exports.convertMarkdownToToolCallResult = convertMarkdownToToolCallResult;
|
|
8
|
+
exports.buildImageFragment = buildImageFragment;
|
|
9
|
+
exports.parseClickUpTaskUrl = parseClickUpTaskUrl;
|
|
10
|
+
exports.normalizeImageDestinations = normalizeImageDestinations;
|
|
11
|
+
exports.collectMarkdownImageSources = collectMarkdownImageSources;
|
|
12
|
+
exports.rewriteMarkdownImageUrls = rewriteMarkdownImageUrls;
|
|
8
13
|
exports.convertMarkdownToClickUpBlocks = convertMarkdownToClickUpBlocks;
|
|
9
14
|
const data_uri_1 = require("./shared/data-uri");
|
|
10
15
|
const unified_1 = require("unified");
|
|
@@ -31,6 +36,18 @@ function extractThumbnailsFromDataAttachment(attributes) {
|
|
|
31
36
|
return {};
|
|
32
37
|
}
|
|
33
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Render an image reference as markdown, escaping whatever would break the syntax.
|
|
41
|
+
*
|
|
42
|
+
* Reading and writing use the same markdown here on purpose: it lets an agent feed a
|
|
43
|
+
* comment it just read straight back into editComment without losing the images.
|
|
44
|
+
*/
|
|
45
|
+
function toMarkdownImage(alt, url) {
|
|
46
|
+
const safeAlt = alt.replace(/[\[\]\r\n]/g, " ").trim();
|
|
47
|
+
// Angle brackets let a URL with spaces or parentheses survive the round trip
|
|
48
|
+
const safeUrl = /[\s()]/.test(url) ? `<${url}>` : url;
|
|
49
|
+
return ``;
|
|
50
|
+
}
|
|
34
51
|
/**
|
|
35
52
|
* Process an array of ClickUp text items into a structured content format
|
|
36
53
|
* that includes both text and images in their original sequence
|
|
@@ -45,11 +62,24 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
|
|
|
45
62
|
// Track current formatting state to avoid unnecessary close/reopen
|
|
46
63
|
let activeBold = false;
|
|
47
64
|
let activeItalic = false;
|
|
65
|
+
let activeStrike = false;
|
|
48
66
|
let activeCode = false;
|
|
67
|
+
// ClickUp emits one '\n' fragment with a code-block attribute per code LINE;
|
|
68
|
+
// consecutive ones belong to the same fenced block when read back as markdown.
|
|
69
|
+
let inCodeBlock = false;
|
|
70
|
+
let currentFenceLang = '';
|
|
71
|
+
const closeCodeFence = () => {
|
|
72
|
+
if (inCodeBlock) {
|
|
73
|
+
currentTextBlock += '```\n';
|
|
74
|
+
inCodeBlock = false;
|
|
75
|
+
currentFenceLang = '';
|
|
76
|
+
}
|
|
77
|
+
};
|
|
49
78
|
for (let i = 0; i < textItems.length; i++) {
|
|
50
79
|
const item = textItems[i];
|
|
51
80
|
// Handle image items
|
|
52
81
|
if (item.type === "image" && item.image && item.image.url) {
|
|
82
|
+
closeCodeFence();
|
|
53
83
|
const imageFileName = item.image.name || item.image.title || "image";
|
|
54
84
|
const imageUrl = item.image.url;
|
|
55
85
|
const altText = item.text || imageFileName;
|
|
@@ -80,8 +110,10 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
|
|
|
80
110
|
}
|
|
81
111
|
continue;
|
|
82
112
|
}
|
|
83
|
-
//
|
|
84
|
-
|
|
113
|
+
// Reference the image in the same markdown syntax the write tools accept, so a
|
|
114
|
+
// comment read here can be handed back to editComment unchanged and keep its
|
|
115
|
+
// images - an existing ClickUp attachment URL is re-embedded without re-uploading.
|
|
116
|
+
currentTextBlock += `\n${toMarkdownImage(altText, imageUrl)}`;
|
|
85
117
|
// Get working thumbnail URLs from data-attachment if available
|
|
86
118
|
const extractedThumbnails = extractThumbnailsFromDataAttachment(item.attributes);
|
|
87
119
|
// Determine best thumbnail URLs (prefer extracted over API thumbnails)
|
|
@@ -114,6 +146,27 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
|
|
|
114
146
|
else if (typeof item.text === "string") {
|
|
115
147
|
// Check if this is a newline with block formatting (header, blockquote, list)
|
|
116
148
|
if (item.text === '\n' && item.attributes) {
|
|
149
|
+
// Code block formatting: append this line to the open fence (or open one).
|
|
150
|
+
// The attribute value carries the language, either directly or nested as
|
|
151
|
+
// {'code-block': lang}; 'plain' means no language.
|
|
152
|
+
if (item.attributes['code-block']) {
|
|
153
|
+
const rawLang = item.attributes['code-block'];
|
|
154
|
+
const lang = typeof rawLang === 'string' ? rawLang : rawLang?.['code-block'] ?? '';
|
|
155
|
+
const fenceLang = lang === 'plain' ? '' : lang;
|
|
156
|
+
if (inCodeBlock && fenceLang !== currentFenceLang) {
|
|
157
|
+
closeCodeFence();
|
|
158
|
+
}
|
|
159
|
+
if (!inCodeBlock) {
|
|
160
|
+
currentTextBlock += '```' + fenceLang + '\n';
|
|
161
|
+
inCodeBlock = true;
|
|
162
|
+
currentFenceLang = fenceLang;
|
|
163
|
+
}
|
|
164
|
+
currentTextBlock += currentLine + '\n';
|
|
165
|
+
currentLine = "";
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
// Any other line terminator ends a code block
|
|
169
|
+
closeCodeFence();
|
|
117
170
|
// Header formatting
|
|
118
171
|
if (item.attributes.header) {
|
|
119
172
|
const level = item.attributes.header;
|
|
@@ -144,17 +197,8 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
|
|
|
144
197
|
break;
|
|
145
198
|
}
|
|
146
199
|
}
|
|
147
|
-
// Code block formatting
|
|
148
|
-
else if (item.attributes['code-block']) {
|
|
149
|
-
// Wrap the current line in code block markers
|
|
150
|
-
currentLine = '```\n' + currentLine + '\n```';
|
|
151
|
-
}
|
|
152
200
|
// Add formatted line to text block
|
|
153
|
-
currentTextBlock += currentLine;
|
|
154
|
-
// Add newline unless it's code block (already has newlines)
|
|
155
|
-
if (!item.attributes['code-block']) {
|
|
156
|
-
currentTextBlock += '\n';
|
|
157
|
-
}
|
|
201
|
+
currentTextBlock += currentLine + '\n';
|
|
158
202
|
currentLine = ""; // Reset for next line
|
|
159
203
|
continue;
|
|
160
204
|
}
|
|
@@ -163,15 +207,18 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
|
|
|
163
207
|
// Determine current and next formatting state
|
|
164
208
|
const hasBold = item.attributes?.bold === true;
|
|
165
209
|
const hasItalic = item.attributes?.italic === true;
|
|
210
|
+
const hasStrike = item.attributes?.strike === true;
|
|
166
211
|
const hasLink = item.attributes?.link;
|
|
167
212
|
// Look ahead to next non-newline block
|
|
168
213
|
let nextHasBold = false;
|
|
169
214
|
let nextHasItalic = false;
|
|
215
|
+
let nextHasStrike = false;
|
|
170
216
|
for (let j = i + 1; j < textItems.length; j++) {
|
|
171
217
|
const nextItem = textItems[j];
|
|
172
218
|
if (nextItem.text !== '\n' || !nextItem.attributes) {
|
|
173
219
|
nextHasBold = nextItem.attributes?.bold === true;
|
|
174
220
|
nextHasItalic = nextItem.attributes?.italic === true;
|
|
221
|
+
nextHasStrike = nextItem.attributes?.strike === true;
|
|
175
222
|
break;
|
|
176
223
|
}
|
|
177
224
|
}
|
|
@@ -181,14 +228,20 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
|
|
|
181
228
|
prefix += "**";
|
|
182
229
|
if (hasItalic && !activeItalic)
|
|
183
230
|
prefix += "*";
|
|
231
|
+
if (hasStrike && !activeStrike)
|
|
232
|
+
prefix += "~~";
|
|
184
233
|
// Build suffix (close formatting that won't continue)
|
|
185
234
|
let suffix = "";
|
|
235
|
+
if (hasStrike && !nextHasStrike)
|
|
236
|
+
suffix += "~~";
|
|
186
237
|
if (hasItalic && !nextHasItalic)
|
|
187
238
|
suffix += "*";
|
|
188
239
|
if (hasBold && !nextHasBold)
|
|
189
240
|
suffix += "**";
|
|
190
241
|
// Close formatting that's active but not in this block
|
|
191
242
|
let closingPrefix = "";
|
|
243
|
+
if (activeStrike && !hasStrike)
|
|
244
|
+
closingPrefix += "~~";
|
|
192
245
|
if (activeBold && !hasBold)
|
|
193
246
|
closingPrefix += "**";
|
|
194
247
|
if (activeItalic && !hasItalic)
|
|
@@ -197,6 +250,7 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
|
|
|
197
250
|
// Update state
|
|
198
251
|
activeBold = hasBold && nextHasBold;
|
|
199
252
|
activeItalic = hasItalic && nextHasItalic;
|
|
253
|
+
activeStrike = hasStrike && nextHasStrike;
|
|
200
254
|
// Link formatting (wraps everything)
|
|
201
255
|
if (hasLink) {
|
|
202
256
|
formattedText = `[${formattedText}](${hasLink})`;
|
|
@@ -208,6 +262,7 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
|
|
|
208
262
|
// Add to current line (not text block yet)
|
|
209
263
|
if (item.text === '\n') {
|
|
210
264
|
// Plain newline without formatting
|
|
265
|
+
closeCodeFence();
|
|
211
266
|
currentTextBlock += currentLine + '\n';
|
|
212
267
|
currentLine = "";
|
|
213
268
|
}
|
|
@@ -215,12 +270,18 @@ async function convertClickUpTextItemsToToolCallResult(textItems) {
|
|
|
215
270
|
currentLine += formattedText;
|
|
216
271
|
}
|
|
217
272
|
}
|
|
273
|
+
// Task mentions render as the task URL so the reference survives the round trip:
|
|
274
|
+
// writing that URL back through addComment/editComment regenerates the mention.
|
|
275
|
+
else if (item.type === "task_mention" && item.task_mention?.task_id) {
|
|
276
|
+
currentLine += `https://app.clickup.com/t/${item.task_mention.task_id}`;
|
|
277
|
+
}
|
|
218
278
|
// Handle other types of items like bookmarks or whatever clickup can think of
|
|
219
279
|
else {
|
|
220
280
|
currentTextBlock += JSON.stringify(item);
|
|
221
281
|
}
|
|
222
282
|
}
|
|
223
283
|
// Add any remaining text
|
|
284
|
+
closeCodeFence();
|
|
224
285
|
if (currentLine) {
|
|
225
286
|
currentTextBlock += currentLine;
|
|
226
287
|
}
|
|
@@ -288,9 +349,9 @@ function convertMarkdownToToolCallResult(markdownText, attachments) {
|
|
|
288
349
|
// Check if this image URL exists in our attachments
|
|
289
350
|
const attachment = attachmentMap.get(imageUrl);
|
|
290
351
|
if (attachment) {
|
|
291
|
-
//
|
|
352
|
+
// Keep the markdown syntax, so the reference stays usable in a write call
|
|
292
353
|
const imageFileName = altText || "image";
|
|
293
|
-
currentTextBlock += `\
|
|
354
|
+
currentTextBlock += `\n${toMarkdownImage(imageFileName, imageUrl)}`;
|
|
294
355
|
// Only create image_metadata if we have at least one thumbnail (never use original image)
|
|
295
356
|
if (attachment.thumbnail_large || attachment.thumbnail_medium || attachment.thumbnail_small) {
|
|
296
357
|
// Push accumulated text (including image URL) as a text block
|
|
@@ -380,14 +441,132 @@ function extractFileTypeFromUrl(url) {
|
|
|
380
441
|
return null;
|
|
381
442
|
return filename.substring(lastDot + 1);
|
|
382
443
|
}
|
|
444
|
+
/**
|
|
445
|
+
* Build the image fragment ClickUp needs to render an inline image in a comment.
|
|
446
|
+
* `title`/`text` carry the caption; the rest is copied straight from the upload response.
|
|
447
|
+
*/
|
|
448
|
+
function buildImageFragment(attachment, caption) {
|
|
449
|
+
const label = caption || attachment.name || 'image';
|
|
450
|
+
const fragment = {
|
|
451
|
+
type: 'image',
|
|
452
|
+
text: label,
|
|
453
|
+
image: {
|
|
454
|
+
id: attachment.id,
|
|
455
|
+
name: attachment.name,
|
|
456
|
+
title: label,
|
|
457
|
+
extension: attachment.extension,
|
|
458
|
+
url: attachment.url,
|
|
459
|
+
thumbnail_small: attachment.thumbnail_small,
|
|
460
|
+
thumbnail_medium: attachment.thumbnail_medium,
|
|
461
|
+
thumbnail_large: attachment.thumbnail_large,
|
|
462
|
+
width: attachment.width,
|
|
463
|
+
height: attachment.height,
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
if (caption) {
|
|
467
|
+
fragment.attributes = { alt: caption };
|
|
468
|
+
}
|
|
469
|
+
return fragment;
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Matches a plain ClickUp task URL, with or without the team segment:
|
|
473
|
+
* https://app.clickup.com/t/86cb3t6t2 or https://app.clickup.com/t/4500611/86cb3t6t2
|
|
474
|
+
*
|
|
475
|
+
* Deliberately narrow: custom task IDs (PREFIX-123) and URLs carrying a query or
|
|
476
|
+
* fragment (e.g. ?comment=... deep links) do NOT match, because a mention would
|
|
477
|
+
* either not resolve or lose the anchor - those stay ordinary links.
|
|
478
|
+
*/
|
|
479
|
+
const CLICKUP_TASK_URL_PATTERN = /^https?:\/\/app\.clickup\.com\/t\/(?:\d+\/)?([a-z0-9]{6,12})\/?$/;
|
|
480
|
+
/**
|
|
481
|
+
* Extract the task ID from a ClickUp task URL, or null if it is not one.
|
|
482
|
+
*/
|
|
483
|
+
function parseClickUpTaskUrl(url) {
|
|
484
|
+
const match = url.match(CLICKUP_TASK_URL_PATTERN);
|
|
485
|
+
return match ? match[1] : null;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Wrap image destinations that contain spaces in angle brackets.
|
|
489
|
+
*
|
|
490
|
+
* CommonMark rejects a bare destination with spaces, so ``
|
|
491
|
+
* is not an image at all - it would silently stay literal text and never be uploaded.
|
|
492
|
+
* Screenshot filenames have spaces constantly ("Screenshot 2026-07-27 at 14.30.png"),
|
|
493
|
+
* so normalising to the `<...>` form is what makes the obvious thing work.
|
|
494
|
+
*/
|
|
495
|
+
function normalizeImageDestinations(markdown) {
|
|
496
|
+
return markdown.replace(/!\[([^\]]*)\]\(([^)\n]*)\)/g, (match, alt, inner) => {
|
|
497
|
+
const trimmed = inner.trim();
|
|
498
|
+
// Already bracketed, or nothing to fix
|
|
499
|
+
if (trimmed.startsWith('<') || trimmed.includes('>')) {
|
|
500
|
+
return match;
|
|
501
|
+
}
|
|
502
|
+
// Split off an optional markdown title: dest "title" / 'title'
|
|
503
|
+
const titleMatch = trimmed.match(/^(.*?)(\s+(?:"[^"]*"|'[^']*'))$/s);
|
|
504
|
+
const dest = titleMatch ? titleMatch[1] : trimmed;
|
|
505
|
+
const title = titleMatch ? titleMatch[2] : '';
|
|
506
|
+
if (!dest || !/\s/.test(dest)) {
|
|
507
|
+
return match;
|
|
508
|
+
}
|
|
509
|
+
return ``;
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Collect every image reference in a markdown document, in document order.
|
|
514
|
+
* Callers use this to know what needs uploading before converting.
|
|
515
|
+
*/
|
|
516
|
+
function collectMarkdownImageSources(markdown) {
|
|
517
|
+
const images = [];
|
|
518
|
+
try {
|
|
519
|
+
const tree = (0, unified_1.unified)()
|
|
520
|
+
.use(remark_parse_1.default)
|
|
521
|
+
.use(remark_gfm_1.default)
|
|
522
|
+
.parse(markdown);
|
|
523
|
+
const visit = (nodes) => {
|
|
524
|
+
for (const node of nodes) {
|
|
525
|
+
if (node.type === 'image' && typeof node.url === 'string') {
|
|
526
|
+
images.push({ src: node.url, alt: typeof node.alt === 'string' ? node.alt : '' });
|
|
527
|
+
}
|
|
528
|
+
else if (Array.isArray(node.children)) {
|
|
529
|
+
visit(node.children);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
visit(tree.children);
|
|
534
|
+
}
|
|
535
|
+
catch (error) {
|
|
536
|
+
console.error('Failed to collect markdown images:', error);
|
|
537
|
+
}
|
|
538
|
+
return images;
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Replace image sources in markdown with their uploaded ClickUp URLs.
|
|
542
|
+
*
|
|
543
|
+
* Used for task descriptions: `markdown_description` renders `` directly,
|
|
544
|
+
* so descriptions need no fragment handling - only the URL has to be swapped.
|
|
545
|
+
* Images without an upload keep their original source untouched.
|
|
546
|
+
*/
|
|
547
|
+
function rewriteMarkdownImageUrls(markdown, attachmentsBySrc) {
|
|
548
|
+
if (attachmentsBySrc.size === 0) {
|
|
549
|
+
return markdown;
|
|
550
|
+
}
|
|
551
|
+
return markdown.replace(/!\[([^\]]*)\]\(\s*(<[^>]*>|[^)\s]+)([^)]*)\)/g, (match, alt, rawSrc, trailing) => {
|
|
552
|
+
const src = rawSrc.startsWith('<') && rawSrc.endsWith('>') ? rawSrc.slice(1, -1) : rawSrc;
|
|
553
|
+
const attachment = attachmentsBySrc.get(src);
|
|
554
|
+
if (!attachment) {
|
|
555
|
+
return match;
|
|
556
|
+
}
|
|
557
|
+
return ``;
|
|
558
|
+
});
|
|
559
|
+
}
|
|
383
560
|
/**
|
|
384
561
|
* Convert markdown text to ClickUp comment blocks format using remark
|
|
385
|
-
* Supports: headers, bold, italic, code, links, lists, blockquotes, code blocks
|
|
562
|
+
* Supports: headers, bold, italic, code, links, lists, blockquotes, code blocks, images
|
|
386
563
|
*
|
|
387
564
|
* @param markdown The markdown text to convert
|
|
565
|
+
* @param attachmentsBySrc Uploaded attachments keyed by the markdown `src` they came from.
|
|
566
|
+
* Images without an entry degrade to a link so their information is not lost.
|
|
388
567
|
* @returns Array of ClickUp comment blocks
|
|
389
568
|
*/
|
|
390
|
-
function convertMarkdownToClickUpBlocks(markdown) {
|
|
569
|
+
function convertMarkdownToClickUpBlocks(markdown, attachmentsBySrc) {
|
|
391
570
|
const blocks = [];
|
|
392
571
|
try {
|
|
393
572
|
// Parse the entire markdown document using remark with GFM support (for task lists)
|
|
@@ -396,7 +575,7 @@ function convertMarkdownToClickUpBlocks(markdown) {
|
|
|
396
575
|
.use(remark_gfm_1.default)
|
|
397
576
|
.parse(markdown);
|
|
398
577
|
// Walk the tree recursively
|
|
399
|
-
walkMdastNodes(tree.children, {}, blocks);
|
|
578
|
+
walkMdastNodes(tree.children, {}, blocks, 0, attachmentsBySrc);
|
|
400
579
|
}
|
|
401
580
|
catch (error) {
|
|
402
581
|
console.error('Failed to parse markdown:', error);
|
|
@@ -412,20 +591,20 @@ function convertMarkdownToClickUpBlocks(markdown) {
|
|
|
412
591
|
* @param blocks Output array to append ClickUp blocks to
|
|
413
592
|
* @param depth Nesting depth for lists (0 = top level, 1 = first nest, etc.)
|
|
414
593
|
*/
|
|
415
|
-
function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0) {
|
|
594
|
+
function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0, attachmentsBySrc) {
|
|
416
595
|
for (let i = 0; i < nodes.length; i++) {
|
|
417
596
|
const node = nodes[i];
|
|
418
597
|
const currentAttrs = { ...inheritedAttrs };
|
|
419
598
|
switch (node.type) {
|
|
420
599
|
case 'heading':
|
|
421
600
|
// Process heading content with inline formatting
|
|
422
|
-
walkPhrasingContent(node.children, currentAttrs, blocks);
|
|
601
|
+
walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
|
|
423
602
|
// Add newline with header attribute
|
|
424
603
|
blocks.push({ text: '\n', attributes: { header: node.depth } });
|
|
425
604
|
break;
|
|
426
605
|
case 'paragraph':
|
|
427
606
|
// Process paragraph content with inline formatting
|
|
428
|
-
walkPhrasingContent(node.children, currentAttrs, blocks);
|
|
607
|
+
walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
|
|
429
608
|
// Add newline unless it's the last node
|
|
430
609
|
if (i < nodes.length - 1) {
|
|
431
610
|
blocks.push({ text: '\n', attributes: {} });
|
|
@@ -437,7 +616,7 @@ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0) {
|
|
|
437
616
|
const blockquoteChildren = node.children;
|
|
438
617
|
for (const child of blockquoteChildren) {
|
|
439
618
|
if (child.type === 'paragraph') {
|
|
440
|
-
walkPhrasingContent(child.children, currentAttrs, blocks);
|
|
619
|
+
walkPhrasingContent(child.children, currentAttrs, blocks, attachmentsBySrc);
|
|
441
620
|
blocks.push({ text: '\n', attributes: { blockquote: {} } });
|
|
442
621
|
}
|
|
443
622
|
// Note: Other child types (heading, list) are not supported by ClickUp blockquotes
|
|
@@ -457,7 +636,7 @@ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0) {
|
|
|
457
636
|
for (const itemChild of listItem.children) {
|
|
458
637
|
if (itemChild.type === 'paragraph') {
|
|
459
638
|
// Process paragraph content with inline formatting
|
|
460
|
-
walkPhrasingContent(itemChild.children, currentAttrs, blocks);
|
|
639
|
+
walkPhrasingContent(itemChild.children, currentAttrs, blocks, attachmentsBySrc);
|
|
461
640
|
// Add newline with list formatting and optional indent
|
|
462
641
|
const listAttrs = {
|
|
463
642
|
list: { list: finalListType }
|
|
@@ -470,7 +649,7 @@ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0) {
|
|
|
470
649
|
}
|
|
471
650
|
else if (itemChild.type === 'list') {
|
|
472
651
|
// Nested list - recursively process with increased depth
|
|
473
|
-
walkMdastNodes([itemChild], currentAttrs, blocks, depth + 1);
|
|
652
|
+
walkMdastNodes([itemChild], currentAttrs, blocks, depth + 1, attachmentsBySrc);
|
|
474
653
|
}
|
|
475
654
|
}
|
|
476
655
|
}
|
|
@@ -479,34 +658,148 @@ function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0) {
|
|
|
479
658
|
// Code block
|
|
480
659
|
const codeNode = node;
|
|
481
660
|
if (codeNode.value) {
|
|
482
|
-
blocks
|
|
483
|
-
blocks.push({
|
|
484
|
-
text: '\n',
|
|
485
|
-
attributes: { 'code-block': { 'code-block': codeNode.lang || 'plain' } }
|
|
486
|
-
});
|
|
661
|
+
pushCodeBlockLines(blocks, codeNode.value, codeNode.lang || 'plain');
|
|
487
662
|
}
|
|
488
663
|
break;
|
|
489
664
|
case 'thematicBreak':
|
|
490
665
|
// Horizontal rule - just add a line break
|
|
491
666
|
blocks.push({ text: '\n', attributes: {} });
|
|
492
667
|
break;
|
|
668
|
+
case 'table': {
|
|
669
|
+
// ClickUp comments cannot render tables (there is no table fragment in the
|
|
670
|
+
// comment format - unknown attributes are stored but render as plain text).
|
|
671
|
+
// Re-render the table as an aligned pipe table inside a code block so the
|
|
672
|
+
// information survives and stays readable in monospace.
|
|
673
|
+
const tableText = serializeTableAsAlignedPipes(node);
|
|
674
|
+
if (tableText) {
|
|
675
|
+
pushCodeBlockLines(blocks, tableText, 'plain');
|
|
676
|
+
}
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
493
679
|
default:
|
|
494
680
|
// For any other block-level nodes, try to process children
|
|
495
681
|
if ('children' in node && Array.isArray(node.children)) {
|
|
496
|
-
walkMdastNodes(node.children, currentAttrs, blocks, depth);
|
|
682
|
+
walkMdastNodes(node.children, currentAttrs, blocks, depth, attachmentsBySrc);
|
|
683
|
+
}
|
|
684
|
+
else if ('value' in node && typeof node.value === 'string') {
|
|
685
|
+
// Last-resort safety net: never drop text content silently
|
|
686
|
+
blocks.push({ text: node.value, attributes: {} });
|
|
497
687
|
}
|
|
498
688
|
break;
|
|
499
689
|
}
|
|
500
690
|
}
|
|
501
691
|
}
|
|
692
|
+
/**
|
|
693
|
+
* Emit a (possibly multi-line) code block the way ClickUp's Quill-based format
|
|
694
|
+
* expects it: block attributes apply per line, so EVERY line needs its own '\n'
|
|
695
|
+
* fragment carrying the code-block attribute. A single text fragment with embedded
|
|
696
|
+
* newlines renders only its last line as code - the rest degrades to plain text.
|
|
697
|
+
*/
|
|
698
|
+
function pushCodeBlockLines(blocks, code, lang) {
|
|
699
|
+
for (const line of code.split('\n')) {
|
|
700
|
+
if (line) {
|
|
701
|
+
blocks.push({ text: line, attributes: {} });
|
|
702
|
+
}
|
|
703
|
+
blocks.push({
|
|
704
|
+
text: '\n',
|
|
705
|
+
attributes: { 'code-block': { 'code-block': lang } }
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Serialize phrasing content back to compact markdown for use inside a code block.
|
|
711
|
+
* Inline formatting markers are kept so nothing is lost, even though a code block
|
|
712
|
+
* renders them literally.
|
|
713
|
+
*/
|
|
714
|
+
function serializePhrasingToMarkdown(nodes) {
|
|
715
|
+
let out = '';
|
|
716
|
+
for (const node of nodes) {
|
|
717
|
+
switch (node.type) {
|
|
718
|
+
case 'text':
|
|
719
|
+
out += node.value;
|
|
720
|
+
break;
|
|
721
|
+
case 'strong':
|
|
722
|
+
out += `**${serializePhrasingToMarkdown(node.children)}**`;
|
|
723
|
+
break;
|
|
724
|
+
case 'emphasis':
|
|
725
|
+
out += `*${serializePhrasingToMarkdown(node.children)}*`;
|
|
726
|
+
break;
|
|
727
|
+
case 'delete':
|
|
728
|
+
out += `~~${serializePhrasingToMarkdown(node.children)}~~`;
|
|
729
|
+
break;
|
|
730
|
+
case 'inlineCode':
|
|
731
|
+
out += `\`${node.value}\``;
|
|
732
|
+
break;
|
|
733
|
+
case 'link':
|
|
734
|
+
out += `[${serializePhrasingToMarkdown(node.children)}](${node.url})`;
|
|
735
|
+
break;
|
|
736
|
+
case 'image':
|
|
737
|
+
out += node.alt || node.url;
|
|
738
|
+
break;
|
|
739
|
+
case 'break':
|
|
740
|
+
out += ' ';
|
|
741
|
+
break;
|
|
742
|
+
default:
|
|
743
|
+
if ('value' in node && typeof node.value === 'string') {
|
|
744
|
+
out += node.value;
|
|
745
|
+
}
|
|
746
|
+
else if ('children' in node && Array.isArray(node.children)) {
|
|
747
|
+
out += serializePhrasingToMarkdown(node.children);
|
|
748
|
+
}
|
|
749
|
+
break;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
return out;
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Render an mdast table as a column-aligned pipe table string.
|
|
756
|
+
*/
|
|
757
|
+
function serializeTableAsAlignedPipes(table) {
|
|
758
|
+
const rows = table.children.map((row) => row.children.map((cell) => serializePhrasingToMarkdown(cell.children).replace(/\|/g, '\\|').trim()));
|
|
759
|
+
if (rows.length === 0) {
|
|
760
|
+
return '';
|
|
761
|
+
}
|
|
762
|
+
const colCount = Math.max(...rows.map((r) => r.length));
|
|
763
|
+
const widths = [];
|
|
764
|
+
for (let col = 0; col < colCount; col++) {
|
|
765
|
+
widths[col] = Math.max(3, ...rows.map((r) => (r[col] ?? '').length));
|
|
766
|
+
}
|
|
767
|
+
const renderRow = (cells) => '| ' + widths.map((w, col) => (cells[col] ?? '').padEnd(w)).join(' | ') + ' |';
|
|
768
|
+
const lines = [renderRow(rows[0])];
|
|
769
|
+
lines.push('| ' + widths.map((w) => '-'.repeat(w)).join(' | ') + ' |');
|
|
770
|
+
for (const row of rows.slice(1)) {
|
|
771
|
+
lines.push(renderRow(row));
|
|
772
|
+
}
|
|
773
|
+
return lines.join('\n');
|
|
774
|
+
}
|
|
502
775
|
/**
|
|
503
776
|
* Recursively walk phrasing content (inline nodes) and build ClickUp blocks
|
|
504
777
|
* Accumulates formatting attributes from parent nodes
|
|
505
778
|
*/
|
|
506
|
-
function walkPhrasingContent(nodes, inheritedAttrs, blocks) {
|
|
779
|
+
function walkPhrasingContent(nodes, inheritedAttrs, blocks, attachmentsBySrc) {
|
|
507
780
|
for (const node of nodes) {
|
|
508
781
|
const currentAttrs = { ...inheritedAttrs };
|
|
509
782
|
switch (node.type) {
|
|
783
|
+
case 'image': {
|
|
784
|
+
// An image node has neither `value` nor `children`, so without this case it
|
|
785
|
+
// would fall through to `default` and vanish silently.
|
|
786
|
+
const attachment = attachmentsBySrc?.get(node.url);
|
|
787
|
+
const caption = node.alt || '';
|
|
788
|
+
if (attachment) {
|
|
789
|
+
blocks.push(buildImageFragment(attachment, caption));
|
|
790
|
+
}
|
|
791
|
+
else {
|
|
792
|
+
// Nothing was uploaded for this source - degrade to a link rather than
|
|
793
|
+
// dropping the reference, so the information survives.
|
|
794
|
+
const label = caption || node.url;
|
|
795
|
+
const isEmbeddable = /^https?:\/\//i.test(node.url);
|
|
796
|
+
blocks.push({
|
|
797
|
+
text: label,
|
|
798
|
+
attributes: isEmbeddable ? { ...currentAttrs, link: node.url } : currentAttrs,
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
break;
|
|
802
|
+
}
|
|
510
803
|
case 'text':
|
|
511
804
|
// Plain text node
|
|
512
805
|
if (node.value) {
|
|
@@ -519,12 +812,17 @@ function walkPhrasingContent(nodes, inheritedAttrs, blocks) {
|
|
|
519
812
|
case 'strong':
|
|
520
813
|
// Bold text - recurse with bold attribute
|
|
521
814
|
currentAttrs.bold = true;
|
|
522
|
-
walkPhrasingContent(node.children, currentAttrs, blocks);
|
|
815
|
+
walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
|
|
523
816
|
break;
|
|
524
817
|
case 'emphasis':
|
|
525
818
|
// Italic text - recurse with italic attribute
|
|
526
819
|
currentAttrs.italic = true;
|
|
527
|
-
walkPhrasingContent(node.children, currentAttrs, blocks);
|
|
820
|
+
walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
|
|
821
|
+
break;
|
|
822
|
+
case 'delete':
|
|
823
|
+
// GFM strikethrough (~~text~~) - ClickUp renders this via the strike attribute
|
|
824
|
+
currentAttrs.strike = true;
|
|
825
|
+
walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
|
|
528
826
|
break;
|
|
529
827
|
case 'inlineCode':
|
|
530
828
|
// Inline code
|
|
@@ -536,11 +834,20 @@ function walkPhrasingContent(nodes, inheritedAttrs, blocks) {
|
|
|
536
834
|
});
|
|
537
835
|
}
|
|
538
836
|
break;
|
|
539
|
-
case 'link':
|
|
540
|
-
//
|
|
837
|
+
case 'link': {
|
|
838
|
+
// A link to a ClickUp task becomes a real task mention, matching what the
|
|
839
|
+
// ClickUp UI does when a task URL is pasted. The mention renders the live
|
|
840
|
+
// task name, so any custom link text is intentionally replaced by it.
|
|
841
|
+
const mentionedTaskId = parseClickUpTaskUrl(node.url);
|
|
842
|
+
if (mentionedTaskId) {
|
|
843
|
+
blocks.push({ type: 'task_mention', task_mention: { task_id: mentionedTaskId } });
|
|
844
|
+
break;
|
|
845
|
+
}
|
|
846
|
+
// Ordinary link - recurse with link attribute
|
|
541
847
|
currentAttrs.link = node.url;
|
|
542
|
-
walkPhrasingContent(node.children, currentAttrs, blocks);
|
|
848
|
+
walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
|
|
543
849
|
break;
|
|
850
|
+
}
|
|
544
851
|
case 'break':
|
|
545
852
|
// Line break - add as plain text
|
|
546
853
|
blocks.push({ text: '\n', attributes: {} });
|
|
@@ -555,7 +862,7 @@ function walkPhrasingContent(nodes, inheritedAttrs, blocks) {
|
|
|
555
862
|
}
|
|
556
863
|
else if ('children' in node && Array.isArray(node.children)) {
|
|
557
864
|
// Recurse into children for other container nodes
|
|
558
|
-
walkPhrasingContent(node.children, currentAttrs, blocks);
|
|
865
|
+
walkPhrasingContent(node.children, currentAttrs, blocks, attachmentsBySrc);
|
|
559
866
|
}
|
|
560
867
|
break;
|
|
561
868
|
}
|