MusicBrainz: Hotkeys for selected entities

Adds hotkeys to perform actions on selected entities. "A" = Artwork, "D" = Delete, "E" = Edit, "W" = Merge, "Q" = Aliases, "R" = Relationship Editor

// ==UserScript==
// @name         MusicBrainz: Hotkeys for selected entities
// @namespace    https://musicbrainz.org/user/chaban
// @version      1.5
// @description  Adds hotkeys to perform actions on selected entities. "A" = Artwork, "D" = Delete, "E" = Edit, "W" = Merge, "Q" = Aliases, "R" = Relationship Editor
// @tag          ai-created
// @author       chaban
// @license      MIT
// @match        *://*.musicbrainz.org/artist*
// @match        *://*.musicbrainz.org/area/*
// @match        *://*.musicbrainz.org/release-group/*
// @match        *://*.musicbrainz.org/label/*
// @match        *://*.musicbrainz.org/place/*
// @match        *://*.musicbrainz.org/isrc/*
// @match        *://*.musicbrainz.org/iswc/*
// @match        *://*.musicbrainz.org/report/*
// @match        *://*.musicbrainz.org/*/*/artists
// @match        *://*.musicbrainz.org/*/*/releases
// @match        *://*.musicbrainz.org/*/*/recordings
// @match        *://*.musicbrainz.org/*/*/release-groups
// @match        *://*.musicbrainz.org/*/*/events
// @match        *://*.musicbrainz.org/*/*/labels
// @match        *://*.musicbrainz.org/*/*/places
// @icon         https://musicbrainz.org/static/images/favicons/android-chrome-512x512.png
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    const entityTypes = {
        release: { actions: ['delete', 'edit', 'viewArtwork', 'aliases', 'edit-relationships'] },
        recording: { actions: ['delete', 'edit', 'aliases'] },
        work: { actions: ['edit', 'aliases'] },
        area: { actions: ['delete', 'edit', 'aliases'] },
        instrument: { actions: ['delete', 'edit', 'aliases'] },
        genre: { actions: ['delete', 'edit', 'aliases'] },
        'release-group': { actions: ['edit', 'aliases'] },
        event: { actions: ['edit', 'viewArtwork', 'aliases'] },
        place: { actions: ['edit', 'aliases'] },
        label: { actions: ['edit', 'aliases'] },
        series: { actions: ['edit', 'aliases'] }
    };

    /**
     * Extracts the entity type and MBID from the URL.
     * @param {string} url - The URL to extract from.
     * @returns {object|undefined} An object containing the entity type and MBID, or undefined if not detectable.
     */
    function extractEntityFromURL(url) {
        const entity = url.match(/([^/]+)\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:$|\/|\?)/i);
        return entity ? {
            type: entity[1],
            mbid: entity[2]
        } : undefined;
    }

    /**
     * Extracts the entity type and MBID from the link. Uses extractEntityFromURL
     * @param {HTMLAnchorElement} link The link element.
     * @returns {object|null} An object containing the entity type and MBID, or null if not detectable.
     */
    function extractEntityInfoFromLink(link) {
        if (!link || !link.href) {
            return null;
        }
        const entityInfo = extractEntityFromURL(link.href);
        return entityInfo && entityTypes[entityInfo.type] ? entityInfo : null;
    }

    /**
     * Opens pages based on action.
     * @param {NodeListOf<HTMLInputElement>} checkboxes - Checkboxes of entities.
     * @param {string} action - Type of action (edit, delete, viewArtwork, aliases).
     */
    function openPages(checkboxes, action) {
        checkboxes.forEach((checkbox, index) => {
            const row = checkbox.closest('tr');
            if (row) {
                const entityLink = row.querySelector('a[href]');
                const entityInfo = extractEntityInfoFromLink(entityLink);
                if (entityInfo && entityTypes[entityInfo.type].actions.includes(action) && entityInfo.mbid) {
                    let url = `/${entityInfo.type}/${entityInfo.mbid}/${action}`;
                    if (action === 'viewArtwork') {
                        url = entityInfo.type === 'release' ? `/release/${entityInfo.mbid}/cover-art` : `/event/${entityInfo.mbid}/event-art`;
                    }
                    setTimeout(() => {
                        window.open(url, '_blank');
                    }, index * 1000);
                }
            }
        });
    }

    /**
     * Checks if an input element or editable element has focus, excluding the entity selection checkboxes.
     * @returns {boolean} True if a non-checkbox input, textarea, select, or contenteditable element has focus.
     */
    function isInputFocused() {
        const activeElement = document.activeElement;
        if (!activeElement) return false;

        const tagName = activeElement.tagName.toLowerCase();

        if (tagName === 'input' && (activeElement.name === 'add-to-merge' || activeElement.parentElement.className === 'checkbox-cell') && activeElement.type === 'checkbox') {
            return false;
        }

        return (
            tagName === 'input' ||
            tagName === 'textarea' ||
            tagName === 'select' ||
            activeElement.isContentEditable
        );
    }

    /**
     * Handles the keydown event for triggering actions.
     * @param {KeyboardEvent} event - The keydown event.
     */
    function handleKeyDown(event) {
        if (event.ctrlKey || event.altKey || event.shiftKey || event.metaKey || event.isComposing || isInputFocused()) {
            return;
        }

        const checkedSelector = 'input[name="add-to-merge"]:checked';
        const checkboxes = document.querySelectorAll(checkedSelector);

        switch (event.key) {
            case 'w':
                if (checkboxes.length > 1) {
                    const container = document.querySelector('.list-merge-buttons-row-container');
                    if (container) {
                        const buttons = container.querySelectorAll('button');
                        if (buttons.length > 0) {
                            buttons[buttons.length - 1].click();
                        }
                    }
                }
                break;
            case 'd':
                if (checkboxes.length > 0) {
                    openPages(checkboxes, 'delete');
                }
                break;
            case 'e':
                if (checkboxes.length > 0) {
                    openPages(checkboxes, 'edit');
                }
                break;
            case 'a':
                if (checkboxes.length > 0) {
                    openPages(checkboxes, 'viewArtwork');
                }
                break;
            case 'q':
                if (checkboxes.length > 0) {
                    openPages(checkboxes, 'aliases');
                }
                break;
            case 'r':
                if (checkboxes.length > 0) {
                    openPages(checkboxes, 'edit-relationships');
                }
                break;
        }
    }

    document.addEventListener('keydown', handleKeyDown);
})();