Zendesk — copy all tickets from filter v1.1.0

← Back to User Scripts

This userscript adds a copy button next to the Filter button on Zendesk filter pages. When clicked, it copies all visible tickets to the clipboard. Each ticket is formatted as a numbered Obsidian link: {N}. [[{ID} {Organisation} — {Title}]], with one ticket per line.

Script Content

// ==UserScript==
// @name         Zendesk: Copy All Tickets from Filter
// @namespace    https://www.timhilton.xyz/user-scripts
// @version      1.1.0
// @description  Adds a button to copy all tickets from a filter view in the same format as Copy Title of Ticket.
// @author       Tim Hilton using GitHub Copilot
// @match        https://*.zendesk.com/agent/filters/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    const LOG_PREFIX = '[Zendesk: Copy All Tickets from Filter]';
    console.debug(`${LOG_PREFIX} Script initialised`);

    const COPY_ICON_SVG = `

`;

    const TICK_ICON_SVG = `

    

`;

    const injectStyles = () => {
        const style = document.createElement('style');
        style.textContent = `
            .copy-all-tickets-button svg path {
                fill: currentColor;
            }
        `;
        document.head.appendChild(style);
    };

    const extractTicketData = (row) => {
        console.debug(`${LOG_PREFIX} Extracting ticket data from row`);
        
        // Find the ID cell
        const idCell = row.querySelector('[data-test-id="generic-table-cells-id"]');
        const ticketId = idCell ? idCell.textContent.trim().replace('#', '') : null;
        
        if (!ticketId) {
            console.debug(`${LOG_PREFIX} ❌ Could not find ticket ID in row`);
            return null;
        }
        
        // Find the Organisation cell - it's the cell with aria-label attribute that comes after the ID
        const cells = row.querySelectorAll('td');
        let organisation = 'Unknown Organisation';
        let subject = 'Unknown Subject';
        
        // Look for organisation - it's typically in a cell with aria-label attribute
        for (const cell of cells) {
            const ariaLabel = cell.getAttribute('aria-label');
            if (ariaLabel && ariaLabel !== 'Normal' && !ariaLabel.startsWith('views-ticket')) {
                // Check if this is the organisation cell (comes before subject)
                const sortableButton = cell.querySelector('[data-test-id="generic-table-cells-sortable"]');
                if (!sortableButton) {
                    organisation = ariaLabel;
                    break;
                }
            }
        }
        
        // Find the Subject cell
        const subjectCell = row.querySelector('[data-test-id="ticket-table-cells-subject"]');
        if (subjectCell) {
            const subjectLink = subjectCell.querySelector('a');
            subject = subjectLink ? subjectLink.textContent.trim() : subjectCell.textContent.trim();
        }
        
        if (organisation === 'Unknown Organisation') {
            console.debug(`${LOG_PREFIX} ⚠️ Could not find organisation for ticket ${ticketId}`);
        }
        
        if (subject === 'Unknown Subject') {
            console.debug(`${LOG_PREFIX} ⚠️ Could not find subject for ticket ${ticketId}`);
        }
        
        console.debug(`${LOG_PREFIX} Extracted: ${ticketId} ${organisation} — ${subject}`);
        return { ticketId, organisation, subject };
    };

    const handleCopyAllTickets = (button) => {
        console.debug(`${LOG_PREFIX} handleCopyAllTickets() called`);
        
        const table = document.querySelector('[data-test-id="generic-table"]');
        if (!table) {
            console.log(`${LOG_PREFIX} ❌ Could not find table`);
            return;
        }
        
        const rows = table.querySelectorAll('tbody [data-test-id="generic-table-row"]');
        console.debug(`${LOG_PREFIX} Found ${rows.length} ticket rows`);
        
        if (rows.length === 0) {
            console.log(`${LOG_PREFIX} ⏭️ No tickets found in filter`);
            return;
        }
        
        const tickets = [];
        for (const row of rows) {
            const ticketData = extractTicketData(row);
            if (ticketData) {
                const innerText = `${ticketData.ticketId} ${ticketData.organisation} — ${ticketData.subject}`;
                const formattedTicket = `${tickets.length + 1}. [[${innerText}]]`;
                tickets.push(formattedTicket);
            }
        }
        
        if (tickets.length === 0) {
            console.log(`${LOG_PREFIX} ❌ No valid tickets could be extracted`);
            return;
        }
        
        const clipboardText = tickets.join('\n');
        console.debug(`${LOG_PREFIX} Copying ${tickets.length} tickets to clipboard...`);
        
        navigator.clipboard.writeText(clipboardText).then(() => {
            console.log(`${LOG_PREFIX} 🎉 Successfully copied ${tickets.length} tickets to clipboard!`);
            button.innerHTML = TICK_ICON_SVG;
            setTimeout(() => {
                if (document.body.contains(button)) {
                    button.innerHTML = COPY_ICON_SVG;
                }
            }, 1000);
        }).catch((err) => {
            console.log(`${LOG_PREFIX} ❌ Failed to copy to clipboard: ${err}`);
        });
    };

    const setupCopyButton = () => {
        console.debug(`${LOG_PREFIX} setupCopyButton() called`);
        
        if (document.querySelector('.copy-all-tickets-button')) {
            console.debug(`${LOG_PREFIX} Copy button already exists, skipping`);
            return;
        }
        
        const filterButtonContainer = document.querySelector('[data-test-id="views_views-header-filter"]');
        if (!filterButtonContainer) {
            console.debug(`${LOG_PREFIX} Filter button container not found`);
            return;
        }
        console.debug(`${LOG_PREFIX} Filter button container found`);
        
        const button = document.createElement('button');
        button.className = 'StyledButton-sc-qe3ace-0 jgboyG sc-wyziqx-1 bpzhtQ copy-all-tickets-button';
        button.setAttribute('data-garden-id', 'buttons.button');
        button.setAttribute('data-garden-version', '9.14.2');
        button.setAttribute('type', 'button');
        button.innerHTML = COPY_ICON_SVG;
        
        button.style.marginLeft = '8px';
        button.style.cursor = 'pointer';
        
        button.addEventListener('click', () => handleCopyAllTickets(button));
        
        filterButtonContainer.appendChild(button);
        
        console.log(`${LOG_PREFIX} 🎉 Copy button added successfully!`);
    };

    injectStyles();

    let mutationCount = 0;
    const observer = new MutationObserver(() => {
        mutationCount++;
        console.debug(`${LOG_PREFIX} MutationObserver fired (#${mutationCount})`);
        setupCopyButton();
    });

    console.debug(`${LOG_PREFIX} Starting MutationObserver on document.body`);
    observer.observe(document.body, {
        childList: true,
        subtree: true,
    });
})();