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

})();