Copy Sub-tasks v1.0.0

← Back to User Scripts

Copies the titles of all incomplete sub-tasks on a Todoist task to the clipboard as plain text, one per line.

Script Content

// ==UserScript==
// @name         Todoist: Copy Sub-tasks
// @namespace    https://www.timhilton.xyz/user-scripts
// @version      1.0.0
// @description  Adds a button for copying a Todoist task's incomplete sub-task titles to the clipboard as plain text
// @author       Tim Hilton using Claude Code
// @match        https://app.todoist.com/app/task/*
// @grant        GM_setClipboard
// ==/UserScript==

(function() {
    'use strict';

    const LOG_PREFIX = '[Todoist: Copy Sub-tasks]';

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

    function isCompleted(item) {
        const checkbox = item.querySelector('[role="checkbox"]');
        return !!checkbox && checkbox.getAttribute('aria-checked') === 'true';
    }

    function getTitle(item) {
        const content = item.querySelector('.task_list_item__content .task_content');
        return content ? content.textContent.trim() : '';
    }

    function buildSubtasksText() {
        const panel = document.querySelector('#task-detail-subtasks-panel');
        const items = panel ? Array.from(panel.querySelectorAll('.task_list_item')) : [];
        console.debug(`${LOG_PREFIX} Found ${items.length} sub-task item(s) in panel`);

        const titles = items
            .filter(item => !isCompleted(item))
            .map(getTitle)
            .filter(Boolean);

        console.debug(`${LOG_PREFIX} ${titles.length} incomplete sub-task(s) after filtering`);
        return titles.join('\n');
    }

    function performCopy() {
        const text = buildSubtasksText();
        console.debug(`${LOG_PREFIX} Copying ${text.length} chars to clipboard`);
        GM_setClipboard(text);
        console.log(`${LOG_PREFIX} ✅ Copied ${text.length} chars to clipboard`);
    }

    function createCopyButton() {
        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 btn = document.createElement('button');
        btn.type = 'button';
        btn.textContent = 'Copy Sub-tasks';
        btn.title = 'Copy incomplete sub-task titles (plain text)';
        btn.setAttribute('data-copy-subtasks-injected', 'true');
        btn.style.cssText = [
            'font-size: 12px',
            `border: 1px solid ${BORDER_DEFAULT}`,
            `background: ${BG_DEFAULT}`,
            'color: inherit',
            'cursor: pointer',
            'line-height: 1.4',
            'padding: 3px 8px',
            'border-radius: 4px',
            'margin: 0 4px',
            'transition: background 0.15s, color 0.15s, border-color 0.15s',
        ].join('; ') + ';';

        let feedbackTimeout = null;
        let hovered = false;

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

        btn.addEventListener('click', () => {
            performCopy();

            if (feedbackTimeout) clearTimeout(feedbackTimeout);
            btn.textContent = '✓ Copied';
            btn.style.background = BG_SUCCESS;
            btn.style.color = COLOR_SUCCESS;
            btn.style.borderColor = BORDER_SUCCESS;
            feedbackTimeout = setTimeout(() => {
                feedbackTimeout = null;
                btn.textContent = 'Copy Sub-tasks';
                btn.style.background = hovered ? BG_HOVER : BG_DEFAULT;
                btn.style.color = 'inherit';
                btn.style.borderColor = BORDER_DEFAULT;
            }, 1500);
        });

        return btn;
    }

    function tryInjectButton() {
        const headerDiv = document.querySelector('div[data-testid="task-detail-default-header"]');
        if (!headerDiv) {
            return false;
        }
        if (headerDiv.querySelector('[data-copy-subtasks-injected]')) {
            console.debug(`${LOG_PREFIX} Button already injected, skipping`);
            return true;
        }
        headerDiv.appendChild(createCopyButton());
        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);
    }
})();