Todoist Copy sub-tasks to clipboard latest version (currently v1.0.0)
← Back to User Scripts
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);
}
})();