MusicBrainz: Add search link for barcode

Searches for existing releases in "Add release" edits by barcode, highlights and adds a search link on match

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        MusicBrainz: Add search link for barcode
// @namespace   https://musicbrainz.org/user/chaban
// @description Searches for existing releases in "Add release" edits by barcode, highlights and adds a search link on match
// @version     3.2.1
// @tag         ai-created
// @author      chaban
// @license     MIT
// @match       *://*.musicbrainz.org/edit/*
// @match       *://*.musicbrainz.org/search/edits*
// @match       *://*.musicbrainz.org/*/*/edits
// @match       *://*.musicbrainz.org/*/*/open_edits
// @match       *://*.musicbrainz.org/user/*/edits*
// @connect     musicbrainz.org
// @icon        https://musicbrainz.org/static/images/favicons/android-chrome-512x512.png
// @grant       GM_xmlhttpRequest
// @require https://update.greasyfork.org/scripts/552392/1689509/MusicBrainz%20API%20Module.js
// ==/UserScript==

/**
 * @file This script scans MusicBrainz edit pages for barcodes within "add-release" edits.
 * If a barcode is found to be associated with multiple releases on MusicBrainz,
 * the script highlights it and adds a convenient link to search for that barcode.
 */

(function() {
    'use strict';

    /**
     * Configuration object to centralize all constants.
     * @readonly
     * @namespace
     * @property {RegExp} BARCODE_REGEX - Regular expression to identify barcodes in text.
     * @property {string} TARGET_SELECTOR - CSS selector for the tables containing release information.
     * @property {string} USER_AGENT - The base user agent string for API requests.
     * @property {string} SEARCH_LINK_CLASS - CSS class for the generated search links.
     * @property {string} PROCESSED_BARCODE_SPAN_CLASS - CSS class for the spans that wrap found barcodes.
     */
    const Config = {
        BARCODE_REGEX: /(\b\d{8,14}\b)/g,
        TARGET_SELECTOR: '.add-release',
        USER_AGENT: 'UserJS.BarcodeLink',
        SEARCH_LINK_CLASS: 'mb-barcode-search-link',
        PROCESSED_BARCODE_SPAN_CLASS: 'mb-barcode-processed',
    };

    /**
     * Scans the DOM for barcode elements, wraps them in spans, and stores their references.
     * @namespace
     */
    const DOMScanner = {
        /**
         * A map where keys are barcode strings and values are arrays of the span elements that contain them.
         * @private
         * @type {Map<string, HTMLSpanElement[]>}
         */
        _barcodeToSpansMap: new Map(),

        /**
         * A set of all unique barcode strings found on the page.
         * @private
         * @type {Set<string>}
         */
        _uniqueBarcodes: new Set(),

        /**
         * Recursively finds barcodes in text nodes within a given node,
         * wraps them in spans, and stores references for later use.
         * @param {Node} node - The DOM node to process.
         */
        collectBarcodesAndCreateSpans: function(node) {
            if (node.nodeType === Node.TEXT_NODE) {
                // Skip nodes that are already part of a processed span
                if (node.parentNode?.classList.contains(Config.PROCESSED_BARCODE_SPAN_CLASS)) return;

                const originalText = node.textContent;
                const matches = [...originalText.matchAll(Config.BARCODE_REGEX)];
                if (matches.length === 0) return;

                const fragment = document.createDocumentFragment();
                let lastIndex = 0;

                for (const match of matches) {
                    const barcode = match[0];
                    // Append any text that came before the barcode match
                    if (match.index > lastIndex) {
                        fragment.appendChild(document.createTextNode(originalText.substring(lastIndex, match.index)));
                    }

                    // Create a span for the barcode
                    const barcodeSpan = document.createElement('span');
                    barcodeSpan.textContent = barcode;
                    barcodeSpan.classList.add(Config.PROCESSED_BARCODE_SPAN_CLASS);

                    // Store a reference to the span for this barcode
                    this._barcodeToSpansMap.has(barcode) ? this._barcodeToSpansMap.get(barcode).push(barcodeSpan) : this._barcodeToSpansMap.set(barcode, [barcodeSpan]);
                    this._uniqueBarcodes.add(barcode);

                    fragment.appendChild(barcodeSpan);
                    lastIndex = match.index + barcode.length;
                }

                // Append any remaining text after the last barcode match
                if (lastIndex < originalText.length) {
                    fragment.appendChild(document.createTextNode(originalText.substring(lastIndex)));
                }
                // Replace the original text node with the new fragment
                node.parentNode.replaceChild(fragment, node);

            } else if (node.nodeType === Node.ELEMENT_NODE && !['SCRIPT', 'STYLE'].includes(node.tagName)) {
                // Recursively process child nodes
                Array.from(node.childNodes).forEach(child => this.collectBarcodesAndCreateSpans(child));
            }
        },

        /**
         * Returns the set of unique barcodes found on the page.
         * @returns {Set<string>} A set of unique barcode strings.
         */
        getUniqueBarcodes: function() { return this._uniqueBarcodes; },

        /**
         * Returns a map of barcodes to their corresponding span elements.
         * @returns {Map<string, HTMLSpanElement[]>}
         */
        getBarcodeSpansMap: function() { return this._barcodeToSpansMap; }
    };

    /**
     * Main application logic for the userscript.
     * @namespace
     */
    const BarcodeLinkerApp = {
        /**
         * Initializes the application by starting the main processing function.
         */
        init: function() {
            this.processAddReleaseTables();
        },

        /**
         * Scans all "Add release" tables, finds barcodes, queries the MusicBrainz API,
         * and updates the DOM to highlight duplicates and add search links.
         */
        processAddReleaseTables: async function() {
            // 1. Scan the DOM for barcodes
            document.querySelectorAll(Config.TARGET_SELECTOR).forEach(table => DOMScanner.collectBarcodesAndCreateSpans(table));

            const uniqueBarcodes = DOMScanner.getUniqueBarcodes();
            if (uniqueBarcodes.size === 0) return;

            // 2. Prepare and execute the API search
            const mbApi = new MusicBrainzAPI({
                user_agent: `${Config.USER_AGENT}/${GM.info.script.version} ( ${GM.info.script.namespace} )`
            });
            const combinedQuery = Array.from(uniqueBarcodes).map(b => `barcode:${b}`).join(' OR ');

            try {
                // Fetch all matching releases in a single, paginated request
                const allReleases = await mbApi.search('release', combinedQuery, 100, [], true);

                if (allReleases.length > 0) {
                    // 3. Group the API results by barcode
                    const releasesByBarcode = new Map();
                    allReleases.forEach(release => {
                        if (release.barcode) {
                            releasesByBarcode.has(release.barcode) ? releasesByBarcode.get(release.barcode).push(release) : releasesByBarcode.set(release.barcode, [release]);
                        }
                    });

                    // 4. Update the DOM for barcodes with multiple releases
                    for (const [barcode, releases] of releasesByBarcode.entries()) {
                        if (releases.length > 1) {
                            const spans = DOMScanner.getBarcodeSpansMap().get(barcode);
                            if (spans) {
                                const searchUrl = `//musicbrainz.org/search?type=release&method=advanced&query=barcode:${barcode}`;
                                spans.forEach(span => {
                                    span.style.backgroundColor = 'yellow';
                                    span.title = `Multiple MusicBrainz releases found for barcode: ${barcode}`;
                                    const link = document.createElement('a');
                                    link.href = searchUrl;
                                    link.target = '_blank';
                                    link.textContent = 'Search';
                                    link.className = Config.SEARCH_LINK_CLASS;
                                    // Append the link in parentheses after the barcode text
                                    span.append(' (', link, ')');
                                });
                            }
                        }
                    }
                }
            } catch (error) {
                console.error(`[${GM.info.script.name}] Failed to fetch barcode data:`, error);
            }
        }
    };

    // Run the script
    BarcodeLinkerApp.init();

})();