Harmony: Copy tracklist to clipboard

Copies the tracklist in MusicBrainz track parser compatible format to clipboard when clicking on track header

// ==UserScript==
// @name        Harmony: Copy tracklist to clipboard
// @namespace   https://musicbrainz.org/user/chaban
// @description Copies the tracklist in MusicBrainz track parser compatible format to clipboard when clicking on track header
// @tag         ai-created
// @version     2.1
// @author      chaban
// @license     MIT
// @match       *://harmony.pulsewidth.org.uk/release
// @grant       none
// @icon        https://harmony.pulsewidth.org.uk/harmony-logo.svg
// ==/UserScript==

(function() {
    'use strict';

    const TOOLTIP_DISPLAY_DURATION = 2000;

    /**
     * Displays a temporary tooltip message near specific coordinates.
     * @param {number} clientX The X coordinate of the cursor.
     * @param {number} clientY The Y coordinate of the cursor.
     * @param {string} message The message to display.
     * @param {string} type 'success' or 'error' to determine styling.
     */
    function showTooltipAtCursor(clientX, clientY, message, type) {
        let tooltip = document.createElement('div');
        tooltip.textContent = message;
        tooltip.style.cssText = `
            position: fixed;
            background-color: ${type === 'success' ? '#4CAF50' : '#f44336'};
            color: white;
            padding: 5px 10px;
            border-radius: 4px;
            font-size: 12px;
            z-index: 10000;
            opacity: 0;
            transition: opacity 0.3s ease-in-out;
            pointer-events: none;
            white-space: nowrap;
        `;

        document.body.appendChild(tooltip);

        tooltip.style.left = `${clientX - (tooltip.offsetWidth / 2)}px`;
        tooltip.style.top = `${clientY - tooltip.offsetHeight - 10}px`;

        if (parseFloat(tooltip.style.left) < 5) {
            tooltip.style.left = '5px';
        }
        if (parseFloat(tooltip.style.left) + tooltip.offsetWidth > window.innerWidth - 5) {
            tooltip.style.left = `${window.innerWidth - tooltip.offsetWidth - 5}px`;
        }

        if (parseFloat(tooltip.style.top) < 5) {
            tooltip.style.top = `${clientY + 10}px`;
        }

        setTimeout(() => {
            tooltip.style.opacity = '1';
        }, 10);

        setTimeout(() => {
            tooltip.style.opacity = '0';
            tooltip.addEventListener('transitionend', () => tooltip.remove());
        }, TOOLTIP_DISPLAY_DURATION);
    }

    /**
     * Helper function to get clean text content by removing specified selectors.
     * This function is now defined once in the outer scope.
     * @param {HTMLElement} element The HTML element to extract text from.
     * @param {string[]} selectorsToRemove An array of CSS selectors for elements to remove.
     * @returns {string} The cleaned text content.
     */
    const getCleanText = (element, selectorsToRemove) => {
        if (!element) return '';
        const tempDiv = document.createElement('div');
        tempDiv.innerHTML = element.innerHTML;
        Array.from(tempDiv.querySelectorAll(selectorsToRemove.join(','))).forEach(el => el.remove());
        return tempDiv.textContent.trim();
    };

    /**
     * Extracts tracklist data from a given table and formats it for MusicBrainz parser.
     * @param {HTMLTableElement} table The tracklist table element.
     * @param {MouseEvent} event The click event for tooltip positioning.
     * @returns {string[]|null} An array of formatted tracklist strings, or null if an error occurs.
     */
    function processTracklistTable(table, event) {
        const headerRow = table.querySelector('thead tr');
        if (!headerRow) {
            showTooltipAtCursor(event.clientX, event.clientY, 'Table headers not found!', 'error');
            return null;
        }

        const columnHeaderMap = {
            trackNum: 'Track',
            title: 'Title',
            artist: 'Artists',
            length: 'Length'
        };

        const columnIndices = {};
        const headers = Array.from(headerRow.querySelectorAll('th'));
        for (const [key, headerText] of Object.entries(columnHeaderMap)) {
            const foundIndex = headers.findIndex(th => th.textContent.trim() === headerText);
            if (foundIndex !== -1) {
                columnIndices[key] = foundIndex;
            } else {
                showTooltipAtCursor(event.clientX, event.clientY, `Column "${headerText}" not found in this table!`, 'error');
                return null;
            }
        }

        const tracklistLines = [];
        const rows = Array.from(table.querySelectorAll('tbody tr'));

        for (const row of rows) {
            const trackNumberCell = row.children[columnIndices.trackNum];
            const titleCell = row.children[columnIndices.title];
            const artistCell = row.children[columnIndices.artist];
            const lengthCell = row.children[columnIndices.length];

            const trackNumber = trackNumberCell ? trackNumberCell.textContent.trim() : '';
            const trackTitle = getCleanText(titleCell, ['ul.alt-values']);

            let trackArtist = '';
            const artistCreditSpan = artistCell ? artistCell.querySelector('.artist-credit') : null;
            if (artistCreditSpan) {
                trackArtist = getCleanText(artistCreditSpan, ['ul.alt-values']);
            }

            const fullLength = getCleanText(lengthCell, ['ul.alt-values']);
            const trackLength = fullLength.split('.')[0];

            let formattedLine = `${trackNumber}. ${trackTitle}`;
            if (trackArtist) {
                formattedLine += ` - ${trackArtist}`;
            }
            formattedLine += ` (${trackLength})`;

            tracklistLines.push(formattedLine);
        }
        return tracklistLines;
    }

    /**
     * Main function to extract and copy tracklist data.
     * @param {MouseEvent} event The click event that triggered this function.
     * @returns {void}
     */
    async function extractAndCopyTracklist(event) {
        const clickedTrackHeader = event.target;
        const table = clickedTrackHeader.closest('table.tracklist');

        if (!table) {
            showTooltipAtCursor(event.clientX, event.clientY, 'Tracklist table not found!', 'error');
            return;
        }

        const tracklistLines = processTracklistTable(table, event);

        if (tracklistLines && tracklistLines.length > 0) {
            const clipboardContent = tracklistLines.join('\n');
            try {
                await navigator.clipboard.writeText(clipboardContent);
                console.log('Tracklist copied to clipboard successfully!', clipboardContent);
                showTooltipAtCursor(event.clientX, event.clientY, 'Tracklist copied!', 'success');
                clickedTrackHeader.style.color = 'green';
                setTimeout(() => clickedTrackHeader.style.color = 'blue', TOOLTIP_DISPLAY_DURATION);
            } catch (err) {
                console.error('Failed to copy tracklist to clipboard:', err);
                showTooltipAtCursor(event.clientX, event.clientY, 'Failed to copy!', 'error');
                clickedTrackHeader.style.color = 'red';
                setTimeout(() => clickedTrackHeader.style.color = 'blue', TOOLTIP_DISPLAY_DURATION);
            }
        } else {
            showTooltipAtCursor(event.clientX, event.clientY, 'No tracks found in this table!', 'error');
        }
    }

    const initializeScript = () => {
        const allTrackHeaders = document.querySelectorAll('table.tracklist th');

        allTrackHeaders.forEach(header => {
            if (header.textContent.trim() === 'Track') {
                header.addEventListener('click', extractAndCopyTracklist);
                header.style.cursor = 'pointer';
                header.style.textDecoration = 'underline';
                header.style.color = 'blue';
                header.title = 'Click to copy this tracklist';
            }
        });
    };

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initializeScript);
    } else {
        initializeScript();
    }
})();