Copy Task Comments v2.0.0

← Back to User Scripts

Copies all comments on a Todoist task to the clipboard as markdown, with options to include the comment author and/or timestamp.

Script Content

// ==UserScript==
// @name         Todoist: Copy Task Comments
// @namespace    https://www.timhilton.xyz/user-scripts
// @version      2.0.0
// @description  Adds a button for copying todoist task comments to clipboard as markdown
// @author       Tim Hilton using GitHub Copilot
// @match        https://app.todoist.com/app/task/*
// @grant        GM_setClipboard
// ==/UserScript==

(function() {
    'use strict';

    const LOG_PREFIX = '[Todoist: Copy Task Comments]';

    console.debug(`${LOG_PREFIX} Script initialized`);

    function nodeToMarkdown(node) {
        if (node.nodeType === Node.TEXT_NODE) {
            if (/^\s*\n\s*$/.test(node.textContent)) return '';
            return node.textContent;
        }
        if (node.nodeType !== Node.ELEMENT_NODE) {
            return '';
        }

        const tag = node.tagName.toLowerCase();
        const childrenText = () => Array.from(node.childNodes).map(nodeToMarkdown).join('');

        switch (tag) {
            case 'p': return childrenText() + '\n\n';
            case 'h1': return '# ' + childrenText().trim() + '\n\n';
            case 'h2': return '## ' + childrenText().trim() + '\n\n';
            case 'h3': return '### ' + childrenText().trim() + '\n\n';
            case 'h4': return '#### ' + childrenText().trim() + '\n\n';
            case 'h5': return '##### ' + childrenText().trim() + '\n\n';
            case 'h6': return '###### ' + childrenText().trim() + '\n\n';
            case 'strong':
            case 'b': return '**' + childrenText() + '**';
            case 'em':
            case 'i': return '*' + childrenText() + '*';
            case 'del':
            case 's': return '~~' + childrenText() + '~~';
            case 'a': return '[' + childrenText() + '](' + (node.getAttribute('href') || '') + ')';
            case 'code': return '`' + node.textContent + '`';
            case 'pre': {
                const codeEl = node.querySelector('code');
                const text = (codeEl || node).textContent;
                const normalized = text.replace(/^\n/, '').replace(/\n$/, '');
                return '```\n' + normalized + '\n```\n\n';
            }
            case 'ol': {
                const items = Array.from(node.children)
                    .filter(el => el.tagName.toLowerCase() === 'li')
                    .map((li, i) => `${i + 1}. ${nodeToMarkdown(li).trim()}`)
                    .join('\n');
                return items + '\n\n';
            }
            case 'ul': {
                const items = Array.from(node.children)
                    .filter(el => el.tagName.toLowerCase() === 'li')
                    .map(li => `- ${nodeToMarkdown(li).trim()}`)
                    .join('\n');
                return items + '\n\n';
            }
            case 'li': return childrenText();
            case 'blockquote': {
                const inner = childrenText().trim();
                return inner.split('\n').map(line => line ? '> ' + line : '>').join('\n') + '\n\n';
            }
            case 'br': return '\n';
            default: return childrenText();
        }
    }

    function commentToMarkdown(noteContent) {
        return Array.from(noteContent.childNodes)
            .map(nodeToMarkdown)
            .join('')
            .trim();
    }

    function buildCommentsText(includeUser, includeTimestamp) {
        const commentItems = document.querySelectorAll('article[data-testid="comment-item"]');
        console.debug(`${LOG_PREFIX} Found ${commentItems.length} comment article(s) on page`);

        const commentTexts = Array.from(commentItems).map((item, index) => {
            const noteContent = item.querySelector('.note_content');
            if (!noteContent) {
                console.debug(`${LOG_PREFIX} Comment #${index + 1}: no .note_content element found, skipping`);
                return null;
            }

            const content = commentToMarkdown(noteContent);
            console.debug(`${LOG_PREFIX} Comment #${index + 1}: converted to ${content.length} chars of markdown`);
            let header = '';

            if (includeUser || includeTimestamp) {
                const parts = [];
                if (includeUser) {
                    const userEl = item.querySelector('.user_name');
                    if (userEl) {
                        parts.push(`**${userEl.textContent.trim()}**`);
                    } else {
                        console.debug(`${LOG_PREFIX} Comment #${index + 1}: no .user_name element found`);
                    }
                }
                if (includeTimestamp) {
                    const timeEl = item.querySelector('a.time');
                    if (timeEl) {
                        parts.push(`*${timeEl.textContent.trim()}*`);
                    } else {
                        console.debug(`${LOG_PREFIX} Comment #${index + 1}: no a.time element found`);
                    }
                }
                if (parts.length > 0) {
                    header = parts.join(' — ') + '\n\n';
                }
            }

            return header + content;
        }).filter(Boolean);

        console.debug(`${LOG_PREFIX} Assembled ${commentTexts.length} comment(s) for clipboard`);
        return commentTexts.join('\n\n');
    }

    function performCopy(includeUser, includeTimestamp) {
        console.debug(`${LOG_PREFIX} Copy triggered (includeUser: ${includeUser}, includeTimestamp: ${includeTimestamp})`);
        const text = buildCommentsText(includeUser, includeTimestamp);
        console.debug(`${LOG_PREFIX} Copying ${text.length} chars to clipboard`);
        GM_setClipboard(text);
        console.log(`${LOG_PREFIX} ✅ Copied ${text.length} chars to clipboard`);
    }

    function createCopyWidget() {
        const wrapper = document.createElement('div');
        wrapper.setAttribute('data-copy-comments-injected', 'true');
        wrapper.style.cssText = 'position: relative; display: inline-flex; align-items: center; margin: 0 4px;';

        const mainBtn = document.createElement('button');
        mainBtn.textContent = 'Copy';
        mainBtn.title = 'Copy comments (plain)';
        mainBtn.style.cssText = 'font-size: 12px; padding: 2px 6px; border: 1px solid rgba(255,255,255,0.35); border-right: none; border-radius: 3px 0 0 3px; background: transparent; color: inherit; cursor: pointer; line-height: 1.4;';
        mainBtn.addEventListener('click', () => performCopy(false, false));

        const dropBtn = document.createElement('button');
        dropBtn.textContent = '▾';
        dropBtn.title = 'More copy options';
        dropBtn.setAttribute('aria-haspopup', 'true');
        dropBtn.setAttribute('aria-expanded', 'false');
        dropBtn.style.cssText = 'font-size: 12px; padding: 2px 5px; border: 1px solid rgba(255,255,255,0.35); border-radius: 0 3px 3px 0; background: transparent; color: inherit; cursor: pointer; line-height: 1.4;';

        // Menu is appended to document.body (not inside the widget) so it is never
        // clipped by an ancestor's overflow:hidden on the Todoist header.
        const menu = document.createElement('div');
        menu.style.cssText = 'position: fixed; background: #282828; color: #fff; border: 1px solid rgba(255,255,255,0.15); border-radius: 4px; min-width: 190px; z-index: 9999; box-shadow: 0 4px 12px rgba(0,0,0,0.5);';
        menu.setAttribute('role', 'menu');

        const options = [
            { label: 'Copy (plain)',                  user: false, ts: false },
            { label: 'Copy with user',                user: true,  ts: false },
            { label: 'Copy with timestamp',           user: false, ts: true  },
            { label: 'Copy with user & timestamp',    user: true,  ts: true  },
        ];

        options.forEach(opt => {
            const item = document.createElement('button');
            item.textContent = opt.label;
            item.setAttribute('role', 'menuitem');
            item.style.cssText = 'display: block; width: 100%; text-align: left; padding: 7px 12px; border: none; background: transparent; color: #fff; cursor: pointer; font-size: 12px;';
            item.addEventListener('mouseenter', () => { item.style.background = 'rgba(255,255,255,0.12)'; });
            item.addEventListener('mouseleave', () => { item.style.background = 'transparent'; });
            item.addEventListener('click', () => {
                performCopy(opt.user, opt.ts);
                closeMenu();
            });
            menu.appendChild(item);
        });

        function positionMenu() {
            const rect = dropBtn.getBoundingClientRect();
            menu.style.top = `${rect.bottom + 4}px`;
            menu.style.right = `${document.documentElement.clientWidth - rect.right}px`;
            menu.style.left = 'auto';
        }

        function onDocumentClick(e) {
            if (!wrapper.contains(e.target) && !menu.contains(e.target)) {
                console.debug(`${LOG_PREFIX} Outside click detected, closing dropdown`);
                closeMenu();
            }
        }

        function onKeyDown(e) {
            if (e.key === 'Escape') {
                console.debug(`${LOG_PREFIX} Escape pressed, closing dropdown`);
                closeMenu();
            }
        }

        function openMenu() {
            positionMenu();
            document.body.appendChild(menu);
            dropBtn.setAttribute('aria-expanded', 'true');
            document.addEventListener('click', onDocumentClick);
            document.addEventListener('keydown', onKeyDown);
            console.debug(`${LOG_PREFIX} Dropdown menu opened`);
        }

        function closeMenu() {
            if (menu.parentNode) {
                menu.remove();
            }
            dropBtn.setAttribute('aria-expanded', 'false');
            document.removeEventListener('click', onDocumentClick);
            document.removeEventListener('keydown', onKeyDown);
        }

        dropBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            if (menu.parentNode) {
                closeMenu();
            } else {
                openMenu();
            }
        });

        wrapper.appendChild(mainBtn);
        wrapper.appendChild(dropBtn);
        return wrapper;
    }

    function tryInjectButton() {
        const headerDiv = document.querySelector('div[data-testid="task-detail-default-header"]');
        if (!headerDiv) {
            return false;
        }
        if (headerDiv.querySelector('[data-copy-comments-injected]')) {
            console.debug(`${LOG_PREFIX} Button already injected, skipping`);
            return true;
        }
        headerDiv.appendChild(createCopyWidget());
        console.log(`${LOG_PREFIX} ✅ Copy button injected into task header`);
        return true;
    }

    if (!tryInjectButton()) {
        console.debug(`${LOG_PREFIX} Header not found on init, setting up MutationObserver`);
        const observer = new MutationObserver(function() {
            if (tryInjectButton()) {
                observer.disconnect();
                console.debug(`${LOG_PREFIX} MutationObserver disconnected after successful injection`);
            }
        });
        observer.observe(document.body || document.documentElement, { childList: true, subtree: true });
        console.debug(`${LOG_PREFIX} MutationObserver active, watching for task header element`);
    }
})();