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.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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);