Copy todoist task comments to clipboard latest version (currently v2.1.0)

← Back to User Scripts

Script Content

// ==UserScript==
// @name         Todoist: Copy Task Comments
// @namespace    https://www.timhilton.xyz/user-scripts
// @version      2.1.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 BG_DEFAULT = 'rgba(255,255,255,0.08)';
        const BG_HOVER   = 'rgba(255,255,255,0.18)';
        const BG_SUCCESS = 'rgba(111,207,140,0.15)';
        const BORDER_DEFAULT = 'rgba(255,255,255,0.22)';
        const BORDER_SUCCESS = 'rgba(111,207,140,0.45)';
        const COLOR_SUCCESS  = '#6fcf8c';

        const commonBtnStyle = [
            'font-size: 12px',
            `border: 1px solid ${BORDER_DEFAULT}`,
            `background: ${BG_DEFAULT}`,
            'color: inherit',
            'cursor: pointer',
            'line-height: 1.4',
            'transition: background 0.15s, color 0.15s, border-color 0.15s',
        ].join('; ') + ';';

        const mainBtn = document.createElement('button');
        mainBtn.type = 'button';
        mainBtn.textContent = 'Copy Comments';
        mainBtn.title = 'Copy comments (plain)';
        mainBtn.style.cssText = commonBtnStyle + 'padding: 3px 8px; border-right: none; border-radius: 4px 0 0 4px; min-width: 7.5em; text-align: center;';

        const dropBtn = document.createElement('button');
        dropBtn.type = 'button';
        dropBtn.textContent = '▾';
        dropBtn.title = 'More copy options';
        dropBtn.setAttribute('aria-label', 'More copy options');
        dropBtn.setAttribute('aria-haspopup', 'true');
        dropBtn.setAttribute('aria-expanded', 'false');
        dropBtn.style.cssText = commonBtnStyle + 'padding: 3px 6px; border-radius: 0 4px 4px 0;';

        let feedbackTimeout = null;
        let mainBtnHovered = false;

        function showCopyFeedback() {
            if (feedbackTimeout) clearTimeout(feedbackTimeout);
            mainBtn.textContent = '✓ Copied';
            mainBtn.style.background = BG_SUCCESS;
            mainBtn.style.color = COLOR_SUCCESS;
            mainBtn.style.borderColor = BORDER_SUCCESS;
            feedbackTimeout = setTimeout(() => {
                feedbackTimeout = null;
                mainBtn.textContent = 'Copy Comments';
                mainBtn.style.background = mainBtnHovered ? BG_HOVER : BG_DEFAULT;
                mainBtn.style.color = 'inherit';
                mainBtn.style.borderColor = BORDER_DEFAULT;
            }, 1500);
        }

        mainBtn.addEventListener('mouseenter', () => {
            mainBtnHovered = true;
            if (!feedbackTimeout) mainBtn.style.background = BG_HOVER;
        });
        mainBtn.addEventListener('mouseleave', () => {
            mainBtnHovered = false;
            if (!feedbackTimeout) mainBtn.style.background = BG_DEFAULT;
        });

        dropBtn.addEventListener('mouseenter', () => { dropBtn.style.background = BG_HOVER; });
        dropBtn.addEventListener('mouseleave', () => { dropBtn.style.background = BG_DEFAULT; });

        mainBtn.addEventListener('click', () => {
            performCopy(false, false);
            showCopyFeedback();
        });

        // 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.type = '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);
                showCopyFeedback();
                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, starting polling for header element`);
        let attempts = 0;
        const maxAttempts = 80; // e.g. ~20 seconds at 250ms intervals
        const intervalId = setInterval(() => {
            attempts += 1;
            if (tryInjectButton()) {
                clearInterval(intervalId);
                console.debug(`${LOG_PREFIX} Polling stopped after successful injection`);
                return;
            }
            if (attempts >= maxAttempts) {
                clearInterval(intervalId);
                console.debug(`${LOG_PREFIX} Polling stopped after reaching max attempts without finding header`);
            }
        }, 250);
    }
})();