Copy todoist task comments to clipboard latest version (currently v2.0.0)
← Back to User Scripts
Script Content
// ==UserScript==
// @name Todoist: Copy Task Comments
// @namespace https://www.timhilton.xyz/user-scripts
// @version 2.0.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 mainBtn = document.createElement('button');
mainBtn.textContent = 'Copy';
mainBtn.title = 'Copy comments (plain)';
mainBtn.style.cssText = 'font-size: 12px; padding: 2px 6px; border: 1px solid rgba(255,255,255,0.35); border-right: none; border-radius: 3px 0 0 3px; background: transparent; color: inherit; cursor: pointer; line-height: 1.4;';
mainBtn.addEventListener('click', () => performCopy(false, false));
const dropBtn = document.createElement('button');
dropBtn.textContent = '▾';
dropBtn.title = 'More copy options';
dropBtn.setAttribute('aria-haspopup', 'true');
dropBtn.setAttribute('aria-expanded', 'false');
dropBtn.style.cssText = 'font-size: 12px; padding: 2px 5px; border: 1px solid rgba(255,255,255,0.35); border-radius: 0 3px 3px 0; background: transparent; color: inherit; cursor: pointer; line-height: 1.4;';
// 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.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);
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, setting up MutationObserver`);
const observer = new MutationObserver(function() {
if (tryInjectButton()) {
observer.disconnect();
console.debug(`${LOG_PREFIX} MutationObserver disconnected after successful injection`);
}
});
observer.observe(document.body || document.documentElement, { childList: true, subtree: true });
console.debug(`${LOG_PREFIX} MutationObserver active, watching for task header element`);
}
})();