MusicBrainz: Relationship Editor Batch Remove

Adds a toggle to batch remove/restore relationships. Shift+Click: Same Type. Ctrl+Click: Same Target. Ctrl+Shift+Click: Same Type & Target.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         MusicBrainz: Relationship Editor Batch Remove
// @namespace    https://musicbrainz.org/user/chaban
// @version      1.1.0
// @description  Adds a toggle to batch remove/restore relationships. Shift+Click: Same Type. Ctrl+Click: Same Target. Ctrl+Shift+Click: Same Type & Target.
// @tag          ai-created
// @author       chaban
// @license      MIT
// @match        *://*.musicbrainz.org/release/*/edit-relationships
// @match        *://*.musicbrainz.org/area/*/edit
// @match        *://*.musicbrainz.org/artist/*/edit
// @match        *://*.musicbrainz.org/event/*/edit
// @match        *://*.musicbrainz.org/instrument/*/edit
// @match        *://*.musicbrainz.org/label/*/edit
// @match        *://*.musicbrainz.org/place/*/edit
// @match        *://*.musicbrainz.org/recording/*/edit
// @match        *://*.musicbrainz.org/release-group/*/edit
// @match        *://*.musicbrainz.org/series/*/edit
// @match        *://*.musicbrainz.org/work/*/edit
// @match        *://*.musicbrainz.org/url/*/edit
// @match        *://*.musicbrainz.eu/release/*/edit-relationships
// @match        *://*.musicbrainz.eu/area/*/edit
// @match        *://*.musicbrainz.eu/artist/*/edit
// @match        *://*.musicbrainz.eu/event/*/edit
// @match        *://*.musicbrainz.eu/instrument/*/edit
// @match        *://*.musicbrainz.eu/label/*/edit
// @match        *://*.musicbrainz.eu/place/*/edit
// @match        *://*.musicbrainz.eu/recording/*/edit
// @match        *://*.musicbrainz.eu/release-group/*/edit
// @match        *://*.musicbrainz.eu/series/*/edit
// @match        *://*.musicbrainz.eu/work/*/edit
// @match        *://*.musicbrainz.eu/url/*/edit
// @icon         https://musicbrainz.org/static/images/favicons/android-chrome-512x512.png
// @grant        none
// @run-at       document-idle
// ==/UserScript==

'use strict';

const DEBUG = false;
const SCRIPT_NAME = GM.info.script.name;

/**
 * Injects CSS to provide visual feedback when modifier keys are held.
 * - Ctrl Only (Target Match): Orange outline
 * - Shift Only (Type Match): Blue outline
 * - Ctrl + Shift (Specific Match): Yellow outline
 */
function addGlobalStyle() {
    const style = document.createElement('style');
    style.type = 'text/css';
    style.textContent = `
        body.ctrl-is-down:not(.shift-is-down) .rel-editor-table .remove-item { background-color: #ffe0b2 !important; outline: 2px solid #ff9800; }
        body.shift-is-down:not(.ctrl-is-down) .rel-editor-table .remove-item { background-color: #bbdefb !important; outline: 2px solid #2196f3; }
        body.ctrl-is-down.shift-is-down .rel-editor-table .remove-item { background-color: #ffc !important; outline: 2px solid #cc0; }
    `;
    document.head.appendChild(style);
}

/**
 * Event handler to toggle CSS classes on the body based on modifier keys.
 * Used for the visual feedback styling defined in addGlobalStyle.
 * * @param {KeyboardEvent} event - The keydown or keyup event.
 */
function toggleModifierClasses(event) {
    if (event.key === 'Control') document.body.classList.toggle('ctrl-is-down', event.type === 'keydown');
    if (event.key === 'Shift') document.body.classList.toggle('shift-is-down', event.type === 'keydown');
}

/**
 * Traverses the internal React Fiber tree starting from a DOM element to retrieve its props.
 * This allows access to the internal 'relationship' and 'source' objects bound to the UI component,
 * bypassing the need for fragile DOM parsing or ID scraping.
 *
 * @param {HTMLElement} element - The DOM element (button) that was clicked.
 * @returns {Object|null} The React props object containing { relationship, source, dispatch } or null if not found.
 */
function getReactProps(element) {
    const key = Object.keys(element).find(k => k.startsWith('__reactFiber'));
    if (!key) return null;

    let fiber = element[key];

    while (fiber) {
        const props = fiber.memoizedProps || fiber.props;
        if (props && props.relationship && props.source) {
            return props;
        }
        fiber = fiber.return;
    }
    return null;
}

/**
 * Resolves which entity in a relationship is the "Target" (the entity being linked TO).
 * MusicBrainz relationships are stored as { entity0, entity1 }, not source/target.
 * This function compares IDs against the current source to find the other entity.
 *
 * @param {Object} rel - The relationship object from MusicBrainz state.
 * @param {Object} source - The source entity object currently being edited.
 * @returns {Object|null} The target entity object, or null if data is malformed.
 */
function resolveTarget(rel, source) {
    // 1. Return explicit target if available (some contexts provide this)
    if (rel.target) return rel.target;

    // 2. Safety check for malformed data
    if (!rel.entity0 || !rel.entity1) return null;

    // 3. Compare IDs to find the one that isn't the source
    if (rel.entity0.id !== source.id) return rel.entity0;
    if (rel.entity1.id !== source.id) return rel.entity1;

    // 4. Edge Case: Self-Link (Source ID == Target ID)
    // If both IDs match the source, return entity1 as the default valid target reference.
    return rel.entity1;
}

/**
 * Main click handler for the batch remove functionality.
 * * Logic Flow:
 * 1. Validates the click target (must be a remove button) and modifier keys.
 * 2. Retrieves the specific relationship data via React Fiber props (getReactProps).
 * 3. Identifies the "Master" relationship (the one clicked) and its context (Source Entity Type).
 * 4. Harvests all visible relationships from the MB state tree, filtering strictly by Source Entity Type.
 * 5. Filters the harvested list against the user's criteria (Link Type match or Target Entity match).
 * 6. Dispatches 'remove-relationship' actions to the remaining matches.
 * * @param {MouseEvent} event - The click event triggered on the content area.
 */
function handleBatchToggle(event) {
    const target = event.target;

    // 1. Basic Validation
    if (!target.matches('.icon.remove-item')) return;

    const matchType = event.shiftKey;
    const matchTarget = event.ctrlKey;

    // Only proceed if a modifier key is held
    if (!matchType && !matchTarget) return;

    event.preventDefault();
    event.stopPropagation();
    event.stopImmediatePropagation();

    // --- STEP 1: GET MASTER DATA ---
    const props = getReactProps(target);

    if (!props) return;

    const masterRel = props.relationship;
    const masterSource = props.source;
    const dispatch = props.dispatch;

    if (!dispatch) return;

    // Safe Resolve of Target Entity
    const masterTargetEntity = resolveTarget(masterRel, masterSource);

    // Abort if we can't identify the target to prevent accidental removals
    if (!masterTargetEntity) {
        if (DEBUG) console.warn(`[${SCRIPT_NAME}] Target entity could not be resolved. Aborting.`);
        return;
    }

    const masterTargetGid = masterTargetEntity.gid;
    const masterLinkTypeId = masterRel.linkTypeID;

    if (DEBUG) {
        console.log(`[${SCRIPT_NAME}] Target: ${masterTargetEntity.name} (GID: ${masterTargetGid})`);
        console.log(`[${SCRIPT_NAME}] Source: ${masterSource.entityType} (ID: ${masterSource.id})`);
    }

    // --- STEP 2: HARVEST & SCOPE ---
    const { relationshipEditor, tree: wbt } = MB;
    const candidates = [];

    // Strategy A: Full Editor (Release/Release Group - Tree Structure)
    if (wbt && relationshipEditor && relationshipEditor.state.relationshipsBySource) {
        for (const [source, targetTypeGroups] of wbt.iterate(relationshipEditor.state.relationshipsBySource)) {
            // Strict Scope Check
            // We only collect relationships from the same Source Entity Type (e.g. only 'work' or only 'recording').
            // This prevents ID collisions where two different entities share a relationship ID.
            if (source.entityType !== masterSource.entityType) continue;

            for (const [, linkTypeGroups] of wbt.iterate(targetTypeGroups)) {
                for (const linkTypeGroup of wbt.iterate(linkTypeGroups)) {
                    if (linkTypeGroup.phraseGroups) {
                        for (const phraseGroup of wbt.iterate(linkTypeGroup.phraseGroups)) {
                            if (phraseGroup.relationships) {
                                for (const rel of wbt.iterate(phraseGroup.relationships)) {
                                    candidates.push({ rel, source });
                                }
                            }
                        }
                    }
                }
            }
        }
    }
    // Strategy B: Mini Editor (Edit Artist/Recording/Work - Flat Array)
    // On individual edit pages, the source entity holds the list directly.
    else if (masterSource && masterSource.relationships) {
        masterSource.relationships.forEach(rel => {
            candidates.push({ rel, source: masterSource });
        });
    }

    // --- STEP 3: FILTER MATCHES ---
    const relsToToggle = candidates.filter(({ rel, source }) => {
        const targetEntity = resolveTarget(rel, source);
        // If target is missing or malformed on a candidate, skip it safely
        if (!targetEntity || !targetEntity.gid) return false;

        let isMatch = true;
        // Check Shift: Link Type Match
        if (matchType && rel.linkTypeID !== masterLinkTypeId) isMatch = false;
        // Check Ctrl: Target Entity Match
        if (matchTarget && targetEntity.gid !== masterTargetGid) isMatch = false;

        return isMatch;
    });

    if (DEBUG) console.log(`[${SCRIPT_NAME}] Found ${relsToToggle.length} items.`);

    // --- STEP 4: ACTION ---
    // Determine Toggle Direction:
    // - If ALL selected items are removed (Status 3), we restore them.
    // - If ANY selected item is active, we remove the active ones.
    const areAllRemoved = relsToToggle.every(({ rel }) => rel._status === 3);

    let changeCount = 0;
    relsToToggle.forEach(({ rel }) => {
        const isRemoved = (rel._status === 3);
        const shouldAct = areAllRemoved ? isRemoved : !isRemoved;

        if (shouldAct) {
            dispatch({ type: 'remove-relationship', relationship: rel });
            changeCount++;
        }
    });

    if (DEBUG) console.log(`[${SCRIPT_NAME}] Processed ${changeCount} items.`);
}

/**
 * Initializes the script: adds styles and event listeners.
 */
function setup() {
    addGlobalStyle();

    document.addEventListener('keydown', toggleModifierClasses);
    document.addEventListener('keyup', toggleModifierClasses);

    // Clear modifiers on blur to prevent "stuck" keys when Alt-Tabbing
    window.addEventListener('blur', () => document.body.classList.remove('ctrl-is-down', 'shift-is-down'));

    const content = document.getElementById('content');
    if (content) {
        content.addEventListener('click', handleBatchToggle, true);
    }
}

// Wait for the MB relationship editor to be fully initialized.
const initInterval = setInterval(() => {
    if (Object.keys((window.MB?.linkedEntities?.link_type_tree) ?? {}).length) {
        clearInterval(initInterval);
        setup();
    }
}, 250);