@hauptsache.net/clickup-mcp
Version:
Search, create, and retrieve tasks, add comments, and track time through natural language commands.
564 lines (563 loc) • 25.1 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.convertClickUpTextItemsToToolCallResult = convertClickUpTextItemsToToolCallResult;
exports.convertMarkdownToToolCallResult = convertMarkdownToToolCallResult;
exports.convertMarkdownToClickUpBlocks = convertMarkdownToClickUpBlocks;
const data_uri_1 = require("./shared/data-uri");
const unified_1 = require("unified");
const remark_parse_1 = __importDefault(require("remark-parse"));
const remark_gfm_1 = __importDefault(require("remark-gfm"));
/**
* Extract thumbnail URLs from data-attachment attribute JSON
* ClickUp API sometimes has broken thumbnail URLs, but data-attachment contains working ones
*/
function extractThumbnailsFromDataAttachment(attributes) {
if (!attributes || !attributes['data-attachment']) {
return {};
}
try {
const attachmentData = JSON.parse(attributes['data-attachment']);
return {
thumbnail_large: attachmentData.thumbnail_large,
thumbnail_medium: attachmentData.thumbnail_medium,
thumbnail_small: attachmentData.thumbnail_small,
};
}
catch (error) {
console.error('Error parsing data-attachment:', error);
return {};
}
}
/**
* Process an array of ClickUp text items into a structured content format
* that includes both text and images in their original sequence
*
* @param textItems Array of text items from ClickUp API
* @returns Promise resolving to an array of content blocks (text and images)
*/
async function convertClickUpTextItemsToToolCallResult(textItems) {
const contentBlocks = [];
let currentTextBlock = "";
let currentLine = ""; // Track current line separately for block formatting
// Track current formatting state to avoid unnecessary close/reopen
let activeBold = false;
let activeItalic = false;
let activeCode = false;
for (let i = 0; i < textItems.length; i++) {
const item = textItems[i];
// Handle image items
if (item.type === "image" && item.image && item.image.url) {
const imageFileName = item.image.name || item.image.title || "image";
const imageUrl = item.image.url;
const altText = item.text || imageFileName;
if (imageUrl.startsWith("data:")) {
const parsedData = (0, data_uri_1.parseDataUri)(imageUrl);
currentTextBlock += `\nImage: ${imageFileName} - [inline image data]`;
if (currentTextBlock.trim()) {
contentBlocks.push({
type: "text",
text: currentTextBlock.trim(),
});
}
currentTextBlock = "";
if (parsedData) {
contentBlocks.push({
type: "image_metadata",
urls: [],
alt: altText,
inlineData: parsedData,
});
}
else {
console.error(`Unable to parse inline image data for ${imageFileName}`);
contentBlocks.push({
type: "text",
text: `[Image "${altText}" omitted: unsupported inline data URI]`,
});
}
continue;
}
// Add image URL reference inline to current text block
currentTextBlock += `\nImage: ${imageFileName} - ${imageUrl}`;
// Get working thumbnail URLs from data-attachment if available
const extractedThumbnails = extractThumbnailsFromDataAttachment(item.attributes);
// Determine best thumbnail URLs (prefer extracted over API thumbnails)
const thumbnail_large = extractedThumbnails.thumbnail_large || item.image.thumbnail_large;
const thumbnail_medium = extractedThumbnails.thumbnail_medium || item.image.thumbnail_medium;
const thumbnail_small = extractedThumbnails.thumbnail_small || item.image.thumbnail_small;
// Only create image_metadata if we have at least one thumbnail (never use original image)
if (thumbnail_large || thumbnail_medium || thumbnail_small) {
// Push accumulated text (including image URL) as a text block
if (currentTextBlock.trim()) {
contentBlocks.push({
type: "text",
text: currentTextBlock.trim(),
});
}
// Reset current text block after pushing it
currentTextBlock = "";
// Create URLs array with largest to smallest preference, filter out undefined
const urls = [thumbnail_large, thumbnail_medium, thumbnail_small].filter(Boolean);
// Add image_metadata block for lazy loading
contentBlocks.push({
type: "image_metadata",
urls: urls,
alt: altText,
});
}
// If no thumbnails, just treat as a file reference (already added to currentTextBlock)
}
// Handle text items
else if (typeof item.text === "string") {
// Check if this is a newline with block formatting (header, blockquote, list)
if (item.text === '\n' && item.attributes) {
// Header formatting
if (item.attributes.header) {
const level = item.attributes.header;
currentLine = '#'.repeat(level) + ' ' + currentLine;
}
// Blockquote formatting
else if (item.attributes.blockquote) {
currentLine = '> ' + currentLine;
}
// List formatting
else if (item.attributes.list) {
const listType = item.attributes.list.list;
const indent = item.attributes.indent || 0;
// Add indentation (2 spaces per level) for nested lists
const indentStr = ' '.repeat(indent);
switch (listType) {
case 'bullet':
currentLine = indentStr + '- ' + currentLine;
break;
case 'ordered':
currentLine = indentStr + '1. ' + currentLine;
break;
case 'checked':
currentLine = indentStr + '- [x] ' + currentLine;
break;
case 'unchecked':
currentLine = indentStr + '- [ ] ' + currentLine;
break;
}
}
// Code block formatting
else if (item.attributes['code-block']) {
// Wrap the current line in code block markers
currentLine = '```\n' + currentLine + '\n```';
}
// Add formatted line to text block
currentTextBlock += currentLine;
// Add newline unless it's code block (already has newlines)
if (!item.attributes['code-block']) {
currentTextBlock += '\n';
}
currentLine = ""; // Reset for next line
continue;
}
// Regular text with inline formatting
let formattedText = item.text;
// Determine current and next formatting state
const hasBold = item.attributes?.bold === true;
const hasItalic = item.attributes?.italic === true;
const hasLink = item.attributes?.link;
// Look ahead to next non-newline block
let nextHasBold = false;
let nextHasItalic = false;
for (let j = i + 1; j < textItems.length; j++) {
const nextItem = textItems[j];
if (nextItem.text !== '\n' || !nextItem.attributes) {
nextHasBold = nextItem.attributes?.bold === true;
nextHasItalic = nextItem.attributes?.italic === true;
break;
}
}
// Build prefix (open new formatting)
let prefix = "";
if (hasBold && !activeBold)
prefix += "**";
if (hasItalic && !activeItalic)
prefix += "*";
// Build suffix (close formatting that won't continue)
let suffix = "";
if (hasItalic && !nextHasItalic)
suffix += "*";
if (hasBold && !nextHasBold)
suffix += "**";
// Close formatting that's active but not in this block
let closingPrefix = "";
if (activeBold && !hasBold)
closingPrefix += "**";
if (activeItalic && !hasItalic)
closingPrefix += "*";
formattedText = closingPrefix + prefix + formattedText + suffix;
// Update state
activeBold = hasBold && nextHasBold;
activeItalic = hasItalic && nextHasItalic;
// Link formatting (wraps everything)
if (hasLink) {
formattedText = `[${formattedText}](${hasLink})`;
}
// Code formatting
if (item.attributes?.code) {
formattedText = `\`${formattedText}\``;
}
// Add to current line (not text block yet)
if (item.text === '\n') {
// Plain newline without formatting
currentTextBlock += currentLine + '\n';
currentLine = "";
}
else {
currentLine += formattedText;
}
}
// Handle other types of items like bookmarks or whatever clickup can think of
else {
currentTextBlock += JSON.stringify(item);
}
}
// Add any remaining text
if (currentLine) {
currentTextBlock += currentLine;
}
if (currentTextBlock.trim()) {
contentBlocks.push({
type: "text",
text: currentTextBlock.trim(),
});
}
return contentBlocks;
}
/**
* Splits markdown text at image references and converts them to image blocks
* @param markdownText The markdown text to process
* @param attachments Array of attachments from the Clickup API
* @returns Array of content blocks (text and images)
*/
function convertMarkdownToToolCallResult(markdownText, attachments) {
const contentBlocks = [];
let currentTextBlock = "";
// Create a map of attachment URLs to their full info for easy lookup
const attachmentMap = new Map();
if (attachments && Array.isArray(attachments)) {
for (const attachment of attachments) {
attachmentMap.set(attachment.url, attachment);
}
}
// Regular expression to match markdown image syntax: 
const imageRegex = /!\[([^\]]*)\]\(([^\)]+)\)/g;
let lastIndex = 0;
let match;
while ((match = imageRegex.exec(markdownText)) !== null) {
const [fullMatch, altText, imageUrl] = match;
// Add text before the image reference to the current text block
currentTextBlock += markdownText.substring(lastIndex, match.index);
if (imageUrl.startsWith("data:")) {
const imageFileName = altText || "image";
const parsedData = (0, data_uri_1.parseDataUri)(imageUrl);
currentTextBlock += `\nImage: ${imageFileName} - [inline image data]`;
if (currentTextBlock.trim()) {
contentBlocks.push({
type: "text",
text: currentTextBlock.trim(),
});
}
currentTextBlock = "";
if (parsedData) {
contentBlocks.push({
type: "image_metadata",
urls: [],
alt: altText || imageFileName,
inlineData: parsedData,
});
}
else {
console.error(`Unable to parse inline image data for ${imageFileName}`);
contentBlocks.push({
type: "text",
text: `[Image "${altText || imageFileName}" omitted: unsupported inline data URI]`,
});
}
lastIndex = match.index + fullMatch.length;
continue;
}
// Check if this image URL exists in our attachments
const attachment = attachmentMap.get(imageUrl);
if (attachment) {
// Add image URL reference inline to current text block
const imageFileName = altText || "image";
currentTextBlock += `\nImage: ${imageFileName} - ${imageUrl}`;
// Only create image_metadata if we have at least one thumbnail (never use original image)
if (attachment.thumbnail_large || attachment.thumbnail_medium || attachment.thumbnail_small) {
// Push accumulated text (including image URL) as a text block
if (currentTextBlock.trim()) {
contentBlocks.push({
type: "text",
text: currentTextBlock.trim(),
});
}
// Reset current text block after pushing it
currentTextBlock = "";
// Create URLs array with largest to smallest preference, filter out undefined
const urls = [attachment.thumbnail_large, attachment.thumbnail_medium, attachment.thumbnail_small].filter(Boolean);
// Add image_metadata block for lazy loading
contentBlocks.push({
type: "image_metadata",
urls: urls,
alt: altText || imageFileName,
});
}
// If no thumbnails, just treat as a file reference (already added to currentTextBlock)
}
else {
// If the image URL doesn't match any attachment, keep the original markdown in the current text block
currentTextBlock += fullMatch;
console.error(`Image URL ${imageUrl} not found in attachments`, attachmentMap);
}
lastIndex = match.index + fullMatch.length;
}
// Add any remaining text after the last image
currentTextBlock += markdownText.substring(lastIndex);
// Process non-image attachments that weren't referenced in markdown
const referencedUrls = new Set();
const imageMatches = markdownText.matchAll(/!\[([^\]]*)\]\(([^\)]+)\)/g);
for (const match of imageMatches) {
referencedUrls.add(match[2]);
}
// Add non-image files inline to the current text block
if (attachments && Array.isArray(attachments)) {
for (const attachment of attachments) {
if (!referencedUrls.has(attachment.url)) {
// Determine if this is an image based on URL or type
const isImage = attachment.thumbnail_large ||
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(attachment.url);
if (!isImage) {
// This is a non-image file - add inline to current text block
const fileName = extractFileNameFromUrl(attachment.url) || "file";
const fileType = extractFileTypeFromUrl(attachment.url);
const fileTypeText = fileType ? ` (${fileType.toUpperCase()})` : "";
currentTextBlock += `\nFile: ${fileName}${fileTypeText} - ${attachment.url}`;
}
}
}
}
// Add any remaining text (including file references) as final text block
if (currentTextBlock.trim()) {
contentBlocks.push({
type: "text",
text: currentTextBlock.trim(),
});
}
return contentBlocks;
}
/**
* Extract filename from URL
*/
function extractFileNameFromUrl(url) {
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const filename = pathname.split('/').pop();
return filename && filename !== '' ? filename : null;
}
catch {
return null;
}
}
/**
* Extract file extension from URL
*/
function extractFileTypeFromUrl(url) {
const filename = extractFileNameFromUrl(url);
if (!filename)
return null;
const lastDot = filename.lastIndexOf('.');
if (lastDot === -1)
return null;
return filename.substring(lastDot + 1);
}
/**
* Convert markdown text to ClickUp comment blocks format using remark
* Supports: headers, bold, italic, code, links, lists, blockquotes, code blocks
*
* @param markdown The markdown text to convert
* @returns Array of ClickUp comment blocks
*/
function convertMarkdownToClickUpBlocks(markdown) {
const blocks = [];
try {
// Parse the entire markdown document using remark with GFM support (for task lists)
const tree = (0, unified_1.unified)()
.use(remark_parse_1.default)
.use(remark_gfm_1.default)
.parse(markdown);
// Walk the tree recursively
walkMdastNodes(tree.children, {}, blocks);
}
catch (error) {
console.error('Failed to parse markdown:', error);
// Fallback to plain text
return [{ text: markdown, attributes: {} }];
}
return blocks;
}
/**
* Recursively walk mdast nodes and convert to ClickUp blocks
* @param nodes Array of mdast nodes to process
* @param inheritedAttrs Formatting attributes inherited from parent nodes
* @param blocks Output array to append ClickUp blocks to
* @param depth Nesting depth for lists (0 = top level, 1 = first nest, etc.)
*/
function walkMdastNodes(nodes, inheritedAttrs, blocks, depth = 0) {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const currentAttrs = { ...inheritedAttrs };
switch (node.type) {
case 'heading':
// Process heading content with inline formatting
walkPhrasingContent(node.children, currentAttrs, blocks);
// Add newline with header attribute
blocks.push({ text: '\n', attributes: { header: node.depth } });
break;
case 'paragraph':
// Process paragraph content with inline formatting
walkPhrasingContent(node.children, currentAttrs, blocks);
// Add newline unless it's the last node
if (i < nodes.length - 1) {
blocks.push({ text: '\n', attributes: {} });
}
break;
case 'blockquote':
// ClickUp limitation: Blockquotes only support paragraph content, not headers or lists
// For complex blockquote content (headers, lists), only paragraph text is preserved
const blockquoteChildren = node.children;
for (const child of blockquoteChildren) {
if (child.type === 'paragraph') {
walkPhrasingContent(child.children, currentAttrs, blocks);
blocks.push({ text: '\n', attributes: { blockquote: {} } });
}
// Note: Other child types (heading, list) are not supported by ClickUp blockquotes
// and will be silently skipped, preserving only inline paragraph content
}
break;
case 'list':
const listNode = node;
const listType = listNode.ordered ? 'ordered' : 'bullet';
for (const item of listNode.children) {
const listItem = item;
// Check if it's a checkbox item
const isChecked = listItem.checked === true;
const isUnchecked = listItem.checked === false;
const finalListType = isChecked ? 'checked' : isUnchecked ? 'unchecked' : listType;
// Process list item content
for (const itemChild of listItem.children) {
if (itemChild.type === 'paragraph') {
// Process paragraph content with inline formatting
walkPhrasingContent(itemChild.children, currentAttrs, blocks);
// Add newline with list formatting and optional indent
const listAttrs = {
list: { list: finalListType }
};
// Add indent for nested lists (depth 0 = no indent, depth 1+ = indented)
if (depth > 0) {
listAttrs.indent = depth;
}
blocks.push({ text: '\n', attributes: listAttrs });
}
else if (itemChild.type === 'list') {
// Nested list - recursively process with increased depth
walkMdastNodes([itemChild], currentAttrs, blocks, depth + 1);
}
}
}
break;
case 'code':
// Code block
const codeNode = node;
if (codeNode.value) {
blocks.push({ text: codeNode.value, attributes: {} });
blocks.push({
text: '\n',
attributes: { 'code-block': { 'code-block': codeNode.lang || 'plain' } }
});
}
break;
case 'thematicBreak':
// Horizontal rule - just add a line break
blocks.push({ text: '\n', attributes: {} });
break;
default:
// For any other block-level nodes, try to process children
if ('children' in node && Array.isArray(node.children)) {
walkMdastNodes(node.children, currentAttrs, blocks, depth);
}
break;
}
}
}
/**
* Recursively walk phrasing content (inline nodes) and build ClickUp blocks
* Accumulates formatting attributes from parent nodes
*/
function walkPhrasingContent(nodes, inheritedAttrs, blocks) {
for (const node of nodes) {
const currentAttrs = { ...inheritedAttrs };
switch (node.type) {
case 'text':
// Plain text node
if (node.value) {
blocks.push({
text: node.value,
attributes: Object.keys(currentAttrs).length > 0 ? currentAttrs : {}
});
}
break;
case 'strong':
// Bold text - recurse with bold attribute
currentAttrs.bold = true;
walkPhrasingContent(node.children, currentAttrs, blocks);
break;
case 'emphasis':
// Italic text - recurse with italic attribute
currentAttrs.italic = true;
walkPhrasingContent(node.children, currentAttrs, blocks);
break;
case 'inlineCode':
// Inline code
if (node.value) {
currentAttrs.code = true;
blocks.push({
text: node.value,
attributes: currentAttrs
});
}
break;
case 'link':
// Link - recurse with link attribute
currentAttrs.link = node.url;
walkPhrasingContent(node.children, currentAttrs, blocks);
break;
case 'break':
// Line break - add as plain text
blocks.push({ text: '\n', attributes: {} });
break;
default:
// For any other node types, try to extract text if available
if ('value' in node && typeof node.value === 'string') {
blocks.push({
text: node.value,
attributes: Object.keys(currentAttrs).length > 0 ? currentAttrs : {}
});
}
else if ('children' in node && Array.isArray(node.children)) {
// Recurse into children for other container nodes
walkPhrasingContent(node.children, currentAttrs, blocks);
}
break;
}
}
}