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