Zendesk — export ticket as markdown v1.0.0

← Back to User Scripts

This userscript adds an export button to Zendesk ticket pages, positioned next to the ticket subject. When clicked, it copies the full conversation to the clipboard as markdown, ready to paste into an AI tool or a note.

The markdown output includes:

  • The ticket ID and subject as a heading
  • Each message with its author, timestamp, and content
  • An (internal note) label on internal agent notes
  • Links formatted as [text](url)

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

})();