Todoist Copy sub-tasks to clipboard latest version (currently v1.1.0)

← Back to User Scripts

Script Content

// ==UserScript==
// @name         Todoist: Copy Sub-tasks
// @namespace    https://www.timhilton.xyz/user-scripts
// @version      1.1.0
// @description  Adds a button for copying a Todoist task's incomplete sub-task titles to the clipboard as plain text, optionally with their descriptions
// @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]';
    const BUTTON_LABEL = 'Copy Sub-tasks';
    const DESCRIPTION_INDENT = '  ';
    const BLOCK_TAGS = new Set(['ADDRESS', 'BLOCKQUOTE', 'DD', 'DIV', 'DL', 'DT', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HR', 'LI', 'OL', 'P', 'PRE', 'TABLE', 'TD', 'TH', 'TR', 'UL']);

    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() : '';
    }

    // Todoist renders a sub-task's description as markdown, so a single description
    // can span several block elements (paragraphs, blockquotes, list items). Walk the
    // tree and turn each block boundary and 
into a line break, so the structure // survives as plain text. function extractLines(root) { const lines = []; let buffer = ''; function flush() { buffer.split('\n').forEach(line => { const normalised = line.replace(/\s+/g, ' ').trim(); if (normalised) lines.push(normalised); }); buffer = ''; } function walk(node) { if (node.nodeType === Node.TEXT_NODE) { buffer += node.textContent; return; } if (node.nodeType !== Node.ELEMENT_NODE) return; if (node.tagName === 'BR') { flush(); return; } const isBlock = BLOCK_TAGS.has(node.tagName); if (isBlock) flush(); node.childNodes.forEach(walk); if (isBlock) flush(); } walk(root); flush(); return lines; } function getDescriptionLines(item) { const description = item.querySelector('.task_list_item__content .task_description'); return description ? extractLines(description) : []; } function buildSubtasksText(includeDescriptions) { 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 blocks = items .filter(item => !isCompleted(item)) .map(item => { const title = getTitle(item); if (!title) return ''; let block = `- ${title}`; if (includeDescriptions) { const lines = getDescriptionLines(item); if (lines.length) { console.debug(`${LOG_PREFIX} Sub-task "${title}" has a ${lines.length} line description`); block += '\n' + lines.map(line => `${DESCRIPTION_INDENT}${line}`).join('\n'); } } return block; }) .filter(Boolean); console.debug(`${LOG_PREFIX} ${blocks.length} incomplete sub-task(s) after filtering`); return blocks.join('\n'); } function performCopy(includeDescriptions) { console.debug(`${LOG_PREFIX} Copy triggered (includeDescriptions: ${includeDescriptions})`); const text = buildSubtasksText(includeDescriptions); 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 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 wrapper = document.createElement('div'); wrapper.setAttribute('data-copy-subtasks-injected', 'true'); wrapper.style.cssText = 'position: relative; display: inline-flex; align-items: center; margin: 0 4px;'; 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 = BUTTON_LABEL; mainBtn.title = 'Copy incomplete sub-task titles (plain text)'; mainBtn.style.cssText = commonBtnStyle + 'padding: 3px 8px; border-right: none; border-radius: 4px 0 0 4px; min-width: 8.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 = BUTTON_LABEL; 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); 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 titles only', descriptions: false }, { label: 'Copy with descriptions', descriptions: 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.descriptions); 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-subtasks-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); } })();