Zendesk — export ticket as markdown latest version (currently v1.0.0)
← Back to User Scripts
Script Content
// ==UserScript==
// @name Zendesk: Export Ticket as Markdown
// @namespace https://www.timhilton.xyz/user-scripts
// @version 1.0.0
// @description Adds a button to export all conversation from a Zendesk ticket to the clipboard as markdown, for use with AI tools.
// @author Tim Hilton using GitHub Copilot
// @match https://*.zendesk.com/*
// @grant none
// ==/UserScript==
(function() {
'use strict';
const LOG_PREFIX = '[Zendesk: Export Ticket as Markdown]';
console.debug(`${LOG_PREFIX} Script initialised`);
const URL_PATTERN = /\/agent\/tickets\/(\d+)/;
const EXPORT_ICON_SVG = `
`;
const TICK_ICON_SVG = `
`;
let activeObserver = null;
const nodeToMarkdown = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
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 'br': return '\n';
case 'p': return childrenText() + '\n\n';
case 'div': {
const inner = childrenText();
if (inner.length === 0) return '';
return inner.endsWith('\n') ? inner : inner + '\n';
}
case 'h1': return '# ' + childrenText().trim() + '\n\n';
case 'h2': return '## ' + childrenText().trim() + '\n\n';
case 'h3': 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': {
const href = node.getAttribute('href') || '';
const text = childrenText();
if (!href || href === text) return text;
return `[${text}](${href})`;
}
case 'code': return '`' + node.textContent + '`';
case 'pre': {
const codeEl = node.querySelector('code');
const text = (codeEl || node).textContent;
return '```\n' + text.replace(/^\n/, '').replace(/\n$/, '') + '\n```\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 'ol': {
const items = Array.from(node.children)
.filter(el => el.tagName.toLowerCase() === 'li')
.map((li, idx) => `${idx + 1}. ${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 'table':
case 'tbody':
case 'thead':
case 'tr': return childrenText();
case 'td':
case 'th': return childrenText().trim() + ' ';
default: return childrenText();
}
};
const extractMessageMarkdown = (article) => {
const messageContent = article.querySelector('[data-test-id="omni-log-message-content"]');
if (!messageContent) {
console.debug(`${LOG_PREFIX} No message content found in article`);
return '';
}
return Array.from(messageContent.childNodes)
.map(nodeToMarkdown)
.join('')
.replace(/\n{3,}/g, '\n\n')
.trim();
};
const buildMarkdown = (ticketId, subject) => {
console.debug(`${LOG_PREFIX} Building markdown for ticket ${ticketId}`);
const articles = document.querySelectorAll('article[data-test-id="omni-log-comment-item"]');
console.debug(`${LOG_PREFIX} Found ${articles.length} conversation item(s)`);
if (articles.length === 0) {
console.log(`${LOG_PREFIX} ⏭️ No conversation items found`);
return null;
}
const lines = [`# ${ticketId}: ${subject}`, ''];
for (const article of articles) {
const userLink = article.querySelector('[data-test-id="omni-log-comment-user-link"]');
const senderDiv = article.querySelector('[data-test-id="omni-log-item-sender"]');
const author = userLink
? userLink.textContent.trim()
: (senderDiv ? senderDiv.textContent.trim() : 'Unknown');
const timeEl = article.querySelector('[data-test-id="timestamp-relative"]');
const timestamp = timeEl ? timeEl.textContent.trim() : '';
const isInternal = !!article.querySelector('[data-test-id="omni-log-internal-note-tag"]');
const headerParts = [`**${author}**`];
if (timestamp) headerParts.push(timestamp);
if (isInternal) headerParts.push('*(internal note)*');
lines.push(`## ${headerParts.join(' — ')}`);
lines.push('');
const message = extractMessageMarkdown(article);
if (message) {
lines.push(message);
lines.push('');
}
lines.push('---');
lines.push('');
}
return lines.join('\n').trim();
};
const handleExportClick = (button) => {
console.debug(`${LOG_PREFIX} Export button clicked`);
const subjectInput = document.querySelector('[data-test-id="omni-header-subject"]');
const subject = subjectInput ? subjectInput.value : 'Unknown Subject';
const ticketIdMatch = window.location.href.match(/tickets\/(\d+)/);
const ticketId = ticketIdMatch ? ticketIdMatch[1] : 'Unknown';
console.debug(`${LOG_PREFIX} Exporting ticket ${ticketId}: ${subject}`);
const markdown = buildMarkdown(ticketId, subject);
if (!markdown) {
return;
}
console.debug(`${LOG_PREFIX} Copying ${markdown.length} chars to clipboard`);
navigator.clipboard.writeText(markdown).then(() => {
console.log(`${LOG_PREFIX} 🎉 Successfully exported ticket to clipboard!`);
button.innerHTML = TICK_ICON_SVG;
setTimeout(() => {
if (document.body.contains(button)) {
button.innerHTML = EXPORT_ICON_SVG;
}
}, 1500);
}).catch((err) => {
console.log(`${LOG_PREFIX} ❌ Failed to copy to clipboard: ${err}`);
});
};
const injectStyles = () => {
if (document.getElementById('export-ticket-markdown-styles')) {
return;
}
const style = document.createElement('style');
style.id = 'export-ticket-markdown-styles';
style.textContent = `
.export-ticket-markdown-button svg path {
fill: currentColor;
}
.export-ticket-markdown-button:hover {
background-color: rgba(38, 148, 214, 0.08);
}
`;
document.head.appendChild(style);
};
const setupExportButton = () => {
console.debug(`${LOG_PREFIX} setupExportButton() called`);
if (document.querySelector('.export-ticket-markdown-button')) {
console.debug(`${LOG_PREFIX} Export button already exists, skipping`);
return;
}
const titleInput = document.querySelector('[data-test-id="omni-header-subject"]');
if (!titleInput) {
console.debug(`${LOG_PREFIX} Title input not found`);
return;
}
console.debug(`${LOG_PREFIX} Title input found`);
const thirdAncestor = titleInput.parentElement?.parentElement?.parentElement;
if (!thirdAncestor) {
console.debug(`${LOG_PREFIX} Third ancestor not found`);
return;
}
const button = document.createElement('button');
button.className = 'export-ticket-markdown-button';
button.innerHTML = EXPORT_ICON_SVG;
button.title = 'Export ticket conversation as markdown';
button.setAttribute('data-test-id', 'export-ticket-markdown-button');
button.style.cssText = 'width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; cursor: pointer; padding: 0 10px; border-radius: 4px; background: transparent; border: none; color: inherit;';
button.addEventListener('click', () => handleExportClick(button));
thirdAncestor.after(button);
console.log(`${LOG_PREFIX} 🎉 Export button added successfully!`);
if (activeObserver) {
activeObserver.disconnect();
activeObserver = null;
}
};
const cleanup = () => {
console.debug(`${LOG_PREFIX} Cleaning up`);
if (activeObserver) {
activeObserver.disconnect();
activeObserver = null;
}
const existing = document.querySelector('.export-ticket-markdown-button');
if (existing) {
existing.remove();
}
};
const handleNavigation = (url) => {
console.debug(`${LOG_PREFIX} Handling navigation to ${url}`);
cleanup();
const match = url.match(URL_PATTERN);
if (!match) {
console.debug(`${LOG_PREFIX} URL does not match pattern, skipping`);
return;
}
const ticketId = match[1];
console.debug(`${LOG_PREFIX} ✅ URL matches, ticket ID: ${ticketId}`);
setTimeout(() => {
const container = document.querySelector('[data-test-id="workspace-content"]') || document.body;
activeObserver = new MutationObserver(() => {
setupExportButton();
});
activeObserver.observe(container, { childList: true, subtree: true });
setupExportButton();
}, 100);
};
injectStyles();
if ('navigation' in window) {
navigation.addEventListener('navigate', (event) => {
console.debug(`${LOG_PREFIX} Navigation API: navigate event to ${event.destination.url}`);
handleNavigation(event.destination.url);
});
console.debug(`${LOG_PREFIX} Navigation API listener registered`);
} else {
console.debug(`${LOG_PREFIX} Navigation API not available, using URL polling fallback`);
let lastUrl = location.href;
setInterval(() => {
if (location.href !== lastUrl) {
console.debug(`${LOG_PREFIX} URL change detected`);
lastUrl = location.href;
handleNavigation(location.href);
}
}, 500);
}
console.debug(`${LOG_PREFIX} Checking initial URL`);
handleNavigation(location.href);
})();