// ==UserScript== // @name VZ: MusicBrainz - Show All Entity Data In A Consolidated View With Filtering And Multi-Sorting Capabilities // @namespace https://github.com/vzell/mb-userscripts // @version 9.99.674+2026-06-18 // @description Consolidation tool to accumulate paginated and non-paginated (tables with subheadings) MusicBrainz table lists (Events, Recordings, Releases, Works, etc.) into a single view with real-time filtering and sorting // @author vzell // @tag AI generated // @homepageURL https://github.com/vzell/mb-userscripts // @supportURL https://github.com/vzell/mb-userscripts/issues // @downloadURL https://raw.githubusercontent.com/vzell/mb-userscripts/master/ShowAllEntityData.user.js // @updateURL https://raw.githubusercontent.com/vzell/mb-userscripts/master/ShowAllEntityData.user.js // @icon https://www.google.com/s2/favicons?sz=64&domain=musicbrainz.org // @require https://cdn.jsdelivr.net/npm/@jaames/iro@5 // @require https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js // @require https://raw.githubusercontent.com/vzell/mb-userscripts/master/lib/VZ_MBLibrary.user.js // @include /^https?:\/\/(?:[^\/]+\.)?musicbrainz\.org\/(?:artist|release-group|release|work|recording|label|series|place|area|instrument|event|collection)\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?:\?.*)?$/ // @include /^https?:\/\/(?:[^\/]+\.)?musicbrainz\.org\/(?:artist|release-group|release|work|recording|label|series|place|area|instrument|event)\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/(?:aliases|releases|recordings|works|events|relationships|discids|fingerprints|performances|places|artists|labels|tags|users|collections|ratings)(?:\?.*)?$/ // @match *://*.musicbrainz.org/search?query=* // @match *://*.musicbrainz.org/user/*/subscriptions/* // @match *://*.musicbrainz.org/user/*/subscribers // @match *://*.musicbrainz.org/user/*/collections // @match *://*.musicbrainz.org/user/*/ratings/* // @match *://*.musicbrainz.org/user/*/ratings // @match *://*.musicbrainz.org/user/*/tags* // @match *://*.musicbrainz.org/tags* // @match *://*.musicbrainz.org/user/*/tag/* // @match *://*.musicbrainz.org/tag/* // @match *://*.musicbrainz.org/cdtoc/* // @match *://*.musicbrainz.org/taglookup* // @match *://*.musicbrainz.org/artist-credit/* // @connect raw.githubusercontent.com // @connect coverartarchive.org // @connect eventartarchive.org // @connect archive.org // @connect *.archive.org // @connect * // @grant GM_xmlhttpRequest // @grant GM_info // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @license MIT // ==/UserScript== /* * A userscript which accumulates paginated and non-paginated (tables with subheadings) MusicBrainz table lists (Events, * Recordings, Releases, Works, etc.) into a single view with real-time multi-column sorting and filtering. * * This script has been created by giving the right facts and asking the right questions initially to Gemini. When * Gemini gots stuck, I asked ChatGPT for help, until I got everything right. Later when the script increased in size * and evolved, I switched to Claude and only now and then asked the other two for help. * * NOTICE: This script has only been tested with Tampermonkey (>=v5.4.1) on Vivaldi, Chrome, Firefox, Opera and Brave. */ /* * This script uses functionality originally included in the following user scripts, thx to the original authors... * */ // ============================================================================================================================================ // @name mb. SUPER MIND CONTROL โ…ก X TURBO // @description musicbrainz.org power-ups: RELEASE_CLONER. copy/paste releases / DOUBLE_CLICK_SUBMIT / CONTROL_ENTER_SUBMIT / TRACKLIST_TOOLS. searchโ†’replace, track length parser, remove recording relationships, set selected recording dates / LAST_SEEN_EDIT. handy for subscribed entities / COOL_SEARCH_LINKS / COPY_TOC / ROW_HIGHLIGHTER / SPOT_CAA / SPOT_AC / RECORDING_LENGTH_COLUMN / RELEASE_EVENT_COLUMN / WARN_NEW_WINDOW / SERVER_SWITCH / TAG_TOOLS / USER_STATS / EASY_DATE. paste full dates in one go / STATIC_MENU / SLOW_DOWN_RETRY / CENTER_FLAGS / RATINGS_ON_TOP / HIDE_RATINGS / UNLINK_ENTITY_HEADER / MARK_PENDING_EDIT_MEDIUMS // @version 2026.1.9 // @author jesus2099 // @licence CC-BY-NC-SA-4.0; https://creativecommons.org/licenses/by-nc-sa/4.0/ // @licence GPL-3.0-or-later; http://www.gnu.org/licenses/gpl-3.0.txt // @downloadURL https://github.com/jesus2099/konami-command/raw/master/mb_SUPER-MIND-CONTROL-II-X-TURBO.user.js // // uses just: RELEASE_EVENT_COLUMN ==> Displays release dates in label relationships page // ============================================================================================================================================ // @name mb. FUNKEY ILLUSTRATED RECORDS // @description musicbrainz.org: CAA front cover art archive pictures/images (release groups and releases) Big illustrated discography and/or inline everywhere possible without cluttering the pages // @version 2026.1.12 // @author jesus2099 // @licence CC-BY-NC-SA-4.0; https://creativecommons.org/licenses/by-nc-sa/4.0/ // @licence GPL-3.0-or-later; http://www.gnu.org/licenses/gpl-3.0.txt // @downloadURL https://github.com/jesus2099/konami-command/raw/master/mb_FUNKEY-ILLUSTRATED-RECORDS.user.js // ============================================================================================================================================ // @name MusicBrainz: Expand/collapse release groups // @description See what's inside a release group without having to follow its URL. Also adds convenient edit links for it. // @version 2022.1.6.1 // @author Michael Wiencek // @license GPL // @downloadURL https://raw.githubusercontent.com/murdos/musicbrainz-userscripts/master/expand-collapse-release-groups.user.js // ============================================================================================================================================ // @name Display shortcut for relationships on MusicBrainz // @description Display icon shortcut for relationships of release-group, release, recording and work: e.g. Amazon, Discogs, Wikipedia, ... links. This allows to access some relationships without opening the entity page. // @version 2026.1.21 // @author Aurelien Mino // @licence GPL (http://www.gnu.org/copyleft/gpl.html) // @downloadURL https://raw.github.com/murdos/musicbrainz-userscripts/master/mb_relationship_shortcuts.user.js // ============================================================================================================================================ // @name MusicBrainz: Highlight identical barcodes and toggle merge checkboxes // @description Highlights sets of identical barcodes and toggles checkboxes for merging on click // @version 1.4.0 // @author chaban // @license MIT // @downloadURL https://update.greasyfork.org/scripts/536998/MusicBrainz%3A%20Highlight%20identical%20barcodes%20and%20toggle%20merge%20checkboxes.user.js // ============================================================================================================================================ // @name mb.unicodechars // @description Ctrl+M on MusicBrainz input text or textarea controls shows context menu for unicode characters. Just click on the menu line to send the character or close with Escape key. // @version 0.10.4 // @author Smeulf // ============================================================================================================================================ // @name MusicBrainz: add release(group) links from level above // @description add release(group) links from an artist, label or series page // @version 0.9 // @author RandomMushroom128 // @license GPL // // this script uses some code from "MusicBrainz: Expand/collapse release groups" (https://raw.githubusercontent.com/murdos/musicbrainz-userscripts/master/expand-collapse-release-groups.user.js) which is also GPL licensed // ============================================================================================================================================ // @name MusicBrainz Magic Tagger Button // @description Automatically enable the green tagger button on MusicBrainz.org depending on whether Picard is running. // @version 0.7.14 // @author Philipp Wolfer // @license MIT // @downloadURL https://raw.githubusercontent.com/phw/musicbrainz-magic-tagger-button/main/mb-magic-tagger-button.user.js // // MusicBrainz Magic Tagger Button - Copyright (c) 2021-2025 Philipp Wolfer // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. // ============================================================================================================================================ (function() { 'use strict'; const SCRIPT_BASE_NAME = "ShowAllEntityData"; // SCRIPT_ID is derived from SCRIPT_BASE_NAME: CamelCase โ†’ kebab-case, lower-cased, prepend "vz-mb-" const SCRIPT_ID = 'vz-mb-' + SCRIPT_BASE_NAME.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, ''); const SCRIPT_NAME = (typeof GM_info !== 'undefined' && GM_info.script) ? GM_info.script.name : SCRIPT_BASE_NAME; // Remote URLs for changelog and help text. // The changelog is fetched and the GM menu item registered by VZ_MBLibrary // (via remoteConfig passed to the constructor below). // The help URL is only used lazily by showAppHelp() via Lib.fetchCachedText(). const REMOTE_BASE = 'https://raw.githubusercontent.com/vzell/mb-userscripts/master/'; const REMOTE_HELP_URL = REMOTE_BASE + SCRIPT_BASE_NAME + '_HELP.txt'; const REMOTE_CHANGELOG_URL = REMOTE_BASE + SCRIPT_BASE_NAME + '_CHANGELOG.json'; const REMOTE_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour const CACHE_KEY_HELP = SCRIPT_BASE_NAME.toLowerCase() + '-remote-help-text'; const CACHE_KEY_CHANGELOG = SCRIPT_BASE_NAME.toLowerCase() + '-remote-changelog'; // CONFIG SCHEMA const configSchema = { // ============================================================ // GENERIC SECTION // ============================================================ divider_: { type: 'divider', label: '๐Ÿ› ๏ธ GENERIC SETTINGS' }, sa_enable_debug_logging: { label: "Enable debug logging", type: "checkbox", default: false, description: "Enable debug logging in the browser developer console" }, sa_enable_tooltip_debug: { label: "Enable tooltip debug logging", type: "checkbox", default: false, description: "Enable detailed console logging for the bigbox tooltip pipeline " + "(column index resolution, cell value extraction, spec rendering). " + "Uses Lib.debug('tooltips', ...) โ€” requires 'Enable debug logging' " + "to also be active." }, // ============================================================ // EXPERIMENTAL FEATURES SECTION // ============================================================ divider_experimental: { type: 'divider', label: '๐Ÿ”ฌ EXPERIMENTAL FEATURES' }, sa_enable_event_parts_extractor: { label: "Enable 'Event Parts' synthetic column extractor", type: "checkbox", default: true, description: "Parse the recording 'Comment' field as structured live-performance metadata, splitting it into synthetic columns: Event-Type, Event-Date, Event-Detail, Event-Venue, Event-Venue-Detail, Event-City, Event-State, Event-Country, Event-Additional-Info. Event-Additional-Info captures any text after a ';' in the last location segment (e.g. 'USA; intro' โ†’ Country='USA', Additional-Info='intro'). Only active on pages that declare the 'eventParts' extractor (e.g. Work-Recordings pages). Disable to suppress these extra columns entirely." }, // ============================================================ // PAGE HEADER AND BODY CONFIGURATION SECTION // ============================================================ divider_page_header: { type: 'divider', label: '๐Ÿท๏ธ PAGE HEADER AND BODY CONFIGURATION' }, sa_enable_h1_comment_span_relocation_on_initial_page: { label: "Relocate H1 comment-span alias block (initial page)", type: "checkbox", default: true, description: "On the original MusicBrainz entity page (before any 'Show all' button is pressed), move the alias block from the h1's into a dedicated italic line injected below the page title. Only affects release-group and event pages where MusicBrainz injects an alias marker directly inside the h1 element. Standard disambiguation comments (e.g. '(live)') that contain no are never touched." }, sa_enable_h1_comment_span_relocation_on_final_page: { label: "Relocate H1 comment-span alias block (final rendered page)", type: "checkbox", default: true, description: "After the consolidated table has been rendered (i.e. after a 'Show all' button completes), re-apply the H1 comment-span relocation so the alias line remains visible below the page title on the final page view. Same structural rules as the initial-page variant: only acts when an alias marker is present inside the h1's ." }, sa_enable_legal_name_relocation_on_final_page: { label: "Relocate legal name below subheader (final rendered page)", type: "checkbox", default: true, description: "On artist-releasegroups pages (e.g. /artist/โ€ฆ), after the consolidated table is rendered, move the 'Legal name: โ€ฆ' paragraph from its default location (further down the page) to immediately below the subheader paragraph that contains the status display. Only fires when the Legal name paragraph is present on the page." }, sa_enable_h2_section_relocation_on_final_page: { label: "Relocate trailing h2 sections before data table (final rendered page)", type: "checkbox", default: true, description: "After the consolidated table is rendered, move any h2 sections that MusicBrainz renders after the data-table h2 (e.g. 'Relationships', 'Related works') to immediately before the data h2. Each section โ€” the h2 heading plus all sibling elements up to the next h2 โ€” is relocated as a unit, preserving the original section order. This keeps all non-data sections grouped above the data table for easier navigation." }, // ============================================================ // COLLABSABLE SIDEBAR SECTION // ============================================================ divider_collabsable_sidebar: { type: 'divider', label: 'โญฒ COLLABSABLE SIDEBAR' }, sa_collabsable_sidebar: { label: "Collabsable sidebar", type: "checkbox", default: true, description: "Render sidebar collabsable" }, sa_sidebar_collapsed: { label: "Start with sidebar collapsed", type: "checkbox", default: false, description: "Automatically collapse the sidebar when the consolidated view is first rendered, freeing up horizontal space immediately. Only takes effect when 'Collabsable sidebar' is also enabled." }, // ============================================================ // OVERFLOW TABLES SECTION // ============================================================ divider_overflow_tables: { type: 'divider', label: 'โ‰ก OVERFLOW TABLES' }, sa_render_overflow_tables_in_new_tab: { label: "Render overflow tables in a new browser tab", type: "checkbox", default: true, description: "Render incomplete sections on pages like 'Artist-Relastionships' in a new browser tab. These are currently limited to 100 rows by default MusicBrainz." }, // ============================================================ // TOOLTIPS SECTION // ============================================================ divider_tooltips: { type: 'divider', label: '๐Ÿ’ฌ TOOLTIPS' }, sa_enable_count_stat_tooltip: { label: 'Enable rich row-count tooltips on h2/h3 count spans', type: 'checkbox', default: true, description: 'When enabled, hovering over the row-count stat span in h2/h3 headers ' + '(e.g. "(42 of 124)") shows a rich custom tooltip that describes the ' + 'active filter combination with color-coded filter expressions ' + '(global filter in gold, sub-table filter in green, column filters in blue). ' + 'When disabled, the standard h2/h3 collapse/uncollapse title tooltip is ' + 'shown instead and the custom tooltip system is not initialised.' }, sa_count_stat_tooltip_bg: { label: 'Row-count tooltip โ€” background color', type: 'color_picker', default: '#1e1e2e', description: 'Background color of the rich row-count stat tooltip box. Default: dark navy (#1e1e2e).' }, sa_count_stat_tooltip_color: { label: 'Row-count tooltip โ€” foreground (text) color', type: 'color_picker', default: '#cdd6f4', description: 'Text color of the rich row-count stat tooltip box. Default: soft white (#cdd6f4).' }, sa_ui_row_count_color: { label: 'Row-count stat tooltip: count text color', type: 'color_picker', default: '#111111', description: 'Text color of the numeric count pills rendered inside the rich H2/H3 row-count ' + 'hover tooltips (the colored inline spans produced by _mbttCount, e.g. the "109" ' + 'in "There are 109 rows total โ€ฆ"). ' + 'This does NOT affect the (N) / (N of M) span on the H2/H3 line itself โ€” ' + 'that span always inherits its color from the header element. ' + 'Default: dark black (#111111).' }, sa_ui_row_count_bg: { label: 'Row-count stat tooltip: count background color', type: 'color_picker', default: '#e8e8e8', description: 'Background color of the numeric count pills rendered inside the rich H2/H3 row-count ' + 'hover tooltips (the colored inline spans produced by _mbttCount, e.g. the "109" ' + 'in "There are 109 rows total โ€ฆ"). ' + 'This does NOT affect the (N) / (N of M) span on the H2/H3 line itself โ€” ' + 'that span always inherits its background from the header element. ' + 'Default: light grey (#e8e8e8).' }, // ============================================================ // NUMERIC COLUMN ALIGNMENT SECTION // ============================================================ divider_numeric_alignment: { type: 'divider', label: '๐Ÿ”ข NUMERIC COLUMN ALIGNMENT' }, sa_enable_numeric_alignment: { label: 'Enable numeric column alignment', type: 'checkbox', default: true, description: 'When enabled, columns declared in a page definition\'s ' + '`features.integerColumns` receive specialised alignment styling. ' + 'Columns with align \'L\', \'R\', or \'C\' are wrapped in a ' + 'centered inline-block span with the declared text direction. ' + 'Columns with align \':\' (colon-split) are split at the first ' + '\'::\' character so that the separator is vertically centred ' + 'across all rows (e.g. track lengths like "4:32.000"). ' + 'When disabled, all integerColumns cells are left unstyled.' }, // ============================================================ // OPTIONAL COLUMN REMOVAL FROM FINAL RENDERED PAGE SECTION // ============================================================ divider_column_removal: { type: 'divider', label: '๐Ÿงฎ OPTIONAL COLUMN REMOVAL FROM FINAL RENDERED PAGE' }, sa_remove_rating: { label: "Remove Rating column", type: "checkbox", default: false, description: "Remove the Rating column from the final rendered tables" }, sa_remove_checkbox_cell: { label: "Remove Checkbox-Cell column", type: "checkbox", default: true, description: "Remove the Checkbox-Cell column from the final rendered tables" }, // ============================================================ // KEYBOARD SHORTCUTS SECTION // ============================================================ divider_keyboard_shortcuts: { type: 'divider', label: '๐ŸŽน KEYBOARD SHORTCUTS' }, sa_enable_keyboard_shortcuts: { label: 'Enable Keyboard Shortcuts', type: 'checkbox', default: true, description: 'Enable keyboard shortcuts and show the "โŒจ๏ธ Shortcuts" help button' }, sa_enable_keyboard_shortcut_tooltip: { label: 'Enable Keyboard Shortcut Tooltip', type: 'checkbox', default: true, description: 'Enable keyboard shortcut tooltip for the prefix shortcut map' }, sa_keyboard_shortcut_prefix: { label: "Keyboard Shortcut Prefix", type: "keyboard_shortcut", default: "Ctrl+M", description: "Keyboard shortcut prefix key combination (expects a second key press to be complete, e.g. Ctrl+M, Ctrl+., Alt+X, Ctrl+Shift+,)" }, sa_enable_direct_ctrl_char_shortcuts: { label: 'Enable Direct Ctrl+Letter Shortcuts', type: 'checkbox', default: false, description: 'When enabled, direct Ctrl+ shortcuts (Ctrl+S, Ctrl+L, Ctrl+R, etc.) fire globally at all times. ' + 'When disabled (default), ALL Ctrl+ shortcuts are suppressed everywhere โ€” ' + 'Ctrl+2 and Ctrl+3 (number keys, not letters) are never affected. ' + 'When a column filter is focused, Ctrl+โ†‘/โ†“ and Ctrl+# are also never affected. ' + 'Use the Keyboard Shortcut Prefix (default: Ctrl+M) and a second key instead of blocked Ctrl+letter shortcuts.' }, // ---- Configurable direct shortcuts ---- // Every entry below controls a single-chord shortcut (no prefix second-key needed). // Use the ๐ŸŽน Capture button to record a new combination. Changes take effect after Save. sa_shortcut_save_to_disk: { label: "Shortcut: Save to Disk", type: "keyboard_shortcut", default: "Ctrl+S", description: "Save the current table data to disk as Gzipped JSON (default: Ctrl+S)" }, sa_shortcut_load_from_disk: { label: "Shortcut: Load from Disk", type: "keyboard_shortcut", default: "Ctrl+L", description: "Open the 'Load from Disk' dialog (default: Ctrl+L)" }, sa_shortcut_auto_resize: { label: "Shortcut: Toggle Auto-Resize Columns", type: "keyboard_shortcut", default: "Ctrl+R", description: "Toggle automatic column-width optimisation (default: Ctrl+R; also available as prefix-mode sub-key r)" }, sa_shortcut_open_visible_columns: { label: "Shortcut: Open Visible Columns Menu", type: "keyboard_shortcut", default: "Ctrl+V", description: "Open the Visible Columns menu (default: Ctrl+V)" }, sa_toggle_barcode_highlighting: { label: "Shortcut: Toggle Barcode Highlighting", type: "keyboard_shortcut", default: "Ctrl+B", description: "Toggle barcode row highlighting on/off โ€” emulates clicking the barcode highlight button (default: Ctrl+B)" }, sa_shortcut_open_density: { label: "Shortcut: Open Density Menu", type: "keyboard_shortcut", default: "Ctrl+D", description: "Open the table Density (row-spacing) menu (default: Ctrl+D)" }, sa_shortcut_open_statistics: { label: "Shortcut: Open Statistics Menu", type: "keyboard_shortcut", default: "Ctrl+I", description: "Open the page Statistics menu (default: Ctrl+I)" }, sa_shortcut_open_export: { label: "Shortcut: Open Export Menu", type: "keyboard_shortcut", default: "Ctrl+E", description: "Open the Export menu (CSV / JSON / Org-Mode / HTML) (default: Ctrl+E)" }, sa_shortcut_show_shortcuts_help: { label: "Shortcut: Show Keyboard Shortcuts Help", type: "keyboard_shortcut", default: "Ctrl+K", description: "Open the keyboard shortcuts help dialog (default: Ctrl+K)" }, sa_shortcut_open_settings: { label: "Shortcut: Open Settings", type: "keyboard_shortcut", default: "Ctrl+,", description: "Open the Settings dialog (default: Ctrl+,)" }, sa_shortcut_focus_global_filter: { label: "Shortcut: Focus Global Filter", type: "keyboard_shortcut", default: "Ctrl+G", description: "Move keyboard focus to the global filter input (default: Ctrl+G)" }, sa_shortcut_focus_column_filter: { label: "Shortcut: Focus First Column Filter", type: "keyboard_shortcut", default: "Ctrl+C", description: "Focus the first column filter of the next table/sub-table, cycling through all tables (default: Ctrl+C)" }, sa_shortcut_clear_filters: { label: "Shortcut: Clear All Filters", type: "keyboard_shortcut", default: "Ctrl+Shift+G", description: "Clear every active filter (global + all column filters) at once (default: Ctrl+Shift+G)" }, sa_shortcut_toggle_h2: { label: "Shortcut: Toggle h2 Headers", type: "keyboard_shortcut", default: "Ctrl+2", description: "Toggle collapse / expand of all h2 section headers (default: Ctrl+2)" }, sa_shortcut_toggle_h3: { label: "Shortcut: Toggle h3 Headers", type: "keyboard_shortcut", default: "Ctrl+3", description: "Toggle collapse / expand of all h3 type headers (sub-tables) (default: Ctrl+3)" }, // ---- Column-filter-focused shortcuts ---- // These shortcuts are only active when a column filter input has focus. // They operate on the header cell of the column whose filter field is focused. sa_shortcut_col_sort_asc: { label: "Shortcut: Sort Ascending (column filter focused)", type: "keyboard_shortcut", default: "Ctrl+ArrowUp", description: "When a column filter field has focus: sort the column ascending โ€” " + "emulates clicking the โ–ฒ sort icon in the column header (default: Ctrl+โ†‘)" }, sa_shortcut_col_sort_desc: { label: "Shortcut: Sort Descending (column filter focused)", type: "keyboard_shortcut", default: "Ctrl+ArrowDown", description: "When a column filter field has focus: sort the column descending โ€” " + "emulates clicking the โ–ผ sort icon in the column header (default: Ctrl+โ†“)" }, sa_shortcut_col_unsort: { label: "Shortcut: Restore Original Sort Order (column filter focused)", type: "keyboard_shortcut", default: "Ctrl+#", description: "When a column filter field has focus: restore the original (unsorted) row order โ€” " + "emulates clicking the โ‡… sort icon in the column header (default: Ctrl+#). " + "The '#' key is Shift+3 on US/UK keyboards; the browser always reports e.key='#' " + "so no shifted-key ambiguity arises and isShortcutEvent() matches it directly." }, sa_shortcut_col_toggle_collapse: { label: "Shortcut: Toggle Multi-Row Cell Collapse (column filter focused)", type: "keyboard_shortcut", default: "Ctrl+O", description: "When a column filter field has focus: toggle collapse / expand of multi-row cells in that column โ€” " + "emulates clicking the โ–ถ/โ—€ collapse-toggle icon in the column header (default: Ctrl+O)" }, sa_shortcut_col_unique_dropdown: { label: "Shortcut: Show Unique Values Dropdown (column filter focused)", type: "keyboard_shortcut", default: "Ctrl+Q", description: "When a column filter field has focus: open the unique-values dropdown for that column โ€” " + "emulates clicking the ๐Ÿ“Š icon in the column header (default: Ctrl+Q)" }, sa_shortcut_col_toggle_caa: { label: "Shortcut: Toggle CAA Cover Art Images (column filter focused)", type: "keyboard_shortcut", default: "Ctrl+A", description: "When a column filter field has focus: toggle CAA cover art images for the enclosing sub-table / single table โ€” " + "emulates clicking the 'Toggle CAA cover art images' button for that table (default: Ctrl+A)" }, // ============================================================ // TABLE FILTER CONFIGURATION SECTION // ============================================================ divider_filter_colors: { type: 'divider', label: '๐ŸŽจ TABLE FILTER CONFIGURATION' }, sa_fn_edit_pinned_filter_list: { label: 'Edit Pinned Filter List', type: 'function', default: '_openEditPinnedFilterListFromSettings', description: 'Open the "Edit Pinned Filter List" dialog to add, edit, or remove ' + 'entries from the persistent pinned filter expression list.' }, sa_pre_filter_highlight_color: { label: "Global Prefilter Highlight Color", type: "color_picker", default: "#008000", description: "Text color for global prefilter matches" }, sa_pre_filter_highlight_bg: { label: "Global Prefilter Highlight Background", type: "color_picker", default: "#FFFFE0", description: "Background color for global prefilter matches" }, sa_global_filter_highlight_color: { label: "Global Filter Highlight Color", type: "color_picker", default: "red", description: "Text color for global filter matches" }, sa_global_filter_highlight_bg: { label: "Global Filter Highlight Background", type: "color_picker", default: "#FFD700", description: "Background color for global filter matches" }, sa_column_filter_highlight_color: { label: "Column Filter Highlight Color", type: "color_picker", default: "red", description: "Text color for column filter matches" }, sa_column_filter_highlight_bg: { label: "Column Filter Highlight Background", type: "color_picker", default: "#add8e6", description: "Background color for column filter matches" }, sa_col_filter_focus_bg: { label: "Column Filter Focus Background", type: "color_picker", default: "#fffde7", description: "Background color of a column filter input while it has keyboard focus" }, sa_col_filter_active_bg: { label: "Column Filter Active Background", type: "color_picker", default: "#fff9c4", description: "Background color kept on a column filter input after losing focus when it still contains a filter string" }, sa_filter_focus_prefix: { label: "Filter Focus Prefix", type: "text", default: "๐Ÿ” ", description: "Decorative prefix prepended to any filter field (global or column) while it has focus (stripped before the value is used as a filter string)" }, // ============================================================ // UNIQUE COLUMN VALUES DROP DOWN CONFIGURATION SECTION // ============================================================ divider_unique_column_values_drop_down: { type: 'divider', label: '#โ‚ UNIQUE COLUMN VALUES DROP DOWN CONFIGURATION' }, sa_uniq_quickfilter_highlight_color: { label: "Unique-Values Quickfilter Highlight Color", type: "color_picker", default: "#c0392b", description: "Text color used to highlight the matching substring inside unique-values dropdown items" }, sa_uniq_quickfilter_highlight_bg: { label: "Unique-Values Quickfilter Highlight Background", type: "color_picker", default: "#ffeaa7", description: "Background color used to highlight the matching substring inside unique-values dropdown items" }, sa_uniq_count_color: { label: "Unique-Values Count Badge Text Color", type: "color_picker", default: "#111111", description: "Text color for the (n) occurrence count badge rendered in front of each unique-values dropdown entry" }, sa_uniq_count_bg: { label: "Unique-Values Count Badge Background Color", type: "color_picker", default: "#fffacd", description: "Background color for the (n) occurrence count badge rendered in front of each unique-values dropdown entry" }, // ============================================================ // THRESHOLD SECTION // ============================================================ divider_thresholds: { type: 'divider', label: 'ฮฃ THRESHOLD SETTINGS' }, sa_max_page: { label: "Max Page Warning", type: "number", default: 50, description: "Warning threshold for page fetching" }, sa_auto_expand: { label: "Auto-Expand Rows in Multi-Table page types", type: "number", default: 50, description: "Row count threshold to auto-expand tables of 'album'/'official' category in multi-table page types" }, // ============================================================ // PERFORMANCE SETTINGS SECTION // ============================================================ divider_performance: { type: 'divider', label: 'โšก PERFORMANCE SETTINGS' }, sa_filter_debounce_delay: { label: "Filter debounce delay (ms)", type: "number", default: 300, min: 0, max: 2000, description: "Minimum delay before applying filter after typing stops. For large row sets the actual delay scales automatically with row count (0.05 ms/row above 500 rows, capped at 1000 ms) so this setting acts as a floor, not a fixed override." }, sa_sort_chunk_size: { label: "Sort chunk size", type: "number", default: 5000, min: 1000, max: 50000, description: "Rows to process at once when sorting large tables" }, sa_render_threshold: { label: "Large Dataset Threshold", type: "number", default: 5000, description: "Row count threshold to prompt the 'Save or Render' dialog (0 to disable)" }, sa_render_warning_threshold: { label: "Render Warning Threshold", type: "number", default: 10000, description: "Row count above which a confirmation dialog warns about potentially slow rendering before proceeding (0 to disable). Checked after the large-dataset threshold dialog." }, sa_chunked_render_threshold: { label: "Chunked Rendering Threshold", type: "number", default: 1000, description: "Row count to trigger progressive chunked rendering (0 to always use simple render)" }, sa_sort_progress_threshold: { label: "Show sort progress above (rows)", type: "number", default: 10000, min: 1000, max: 100000, description: "Show progress indicator when sorting tables with more than this many rows" }, // ============================================================ // UI FEATURES SECTION // ============================================================ divider_ui_features: { type: 'divider', label: '๐ŸŽจ UI FEATURES' }, sa_enable_column_visibility: { label: 'Enable Column Visibility Toggle', type: 'checkbox', default: true, description: 'Show/hide the "๐Ÿ‘๏ธ Visible Columns" button for toggling column visibility' }, sa_enable_density_control: { label: 'Enable Table Density Control', type: 'checkbox', default: true, description: 'Show/hide the "๐Ÿ“ Density" button for adjusting table spacing' }, // ============================================================ // TABLE STICKINESS CONFIGURATION SECTION // ============================================================ divider_table_stickiness: { type: 'divider', label: '๐Ÿ“Œ TABLE STICKINESS' }, sa_enable_sticky_headers: { label: 'Enable Sticky Headers', type: 'checkbox', default: true, description: 'Keep table column headers and the column filter row visible ' + 'when scrolling vertically (thead sticks to the top of the viewport).' }, sa_enable_sticky_columns: { label: 'Enable Sticky Columns', type: 'checkbox', default: true, description: "Keep the first table column (or the column named in the page " + "definition's stickyColumn feature) visible when scrolling horizontally. " + "Works independently of 'Enable Sticky Headers'; both features are " + "compatible and active simultaneously when both are enabled." }, // ============================================================ // UI APPEARANCE SECTION // Condensed pipe-separated config strings for every interactive // UI element in the script. Format is documented per entry. // ============================================================ divider_ui_appearance: { type: 'divider', label: '๐Ÿ–Œ๏ธ ELEMENT UI STYLES' }, // --- H1 action bar: base shape shared by all buttons --- sa_ui_action_btn_style: { label: 'Action button base style', type: 'popup_dialog', fields: ['fontSize', 'padding', 'height', 'borderRadius'], default: '0.8em|2px 8px|24px|6px', description: 'Base style for all h1 action-bar buttons: fontSize|padding|height|borderRadius' }, sa_ui_stop_btn_style: { label: 'Stop button colors', type: 'popup_dialog', fields: ['bg', 'color', 'border'], colorFields: ['bg', 'color'], default: '#f44336|white|1px solid #d32f2f', description: 'Stop button: bg|color|border' }, sa_ui_settings_btn_style: { label: 'Settings โš™๏ธ button colors', type: 'popup_dialog', fields: ['bg', 'color', 'border'], colorFields: ['bg', 'color'], default: '#607D8B|white|1px solid #546E7A', description: 'Settings button: bg|color|border' }, sa_ui_help_btn_style: { label: 'Help โ“ button colors', type: 'popup_dialog', fields: ['bg', 'color', 'border'], colorFields: ['bg', 'color'], default: '#78909C|white|1px solid #607D8B', description: 'Application-help button: bg|color|border' }, // --- Button-group separator dividers --- sa_ui_button_divider_style: { label: 'Button divider style', type: 'popup_dialog', fields: ['color', 'margin'], colorFields: ['color'], default: '#999|0 4px', description: 'Pipe separator between button groups: color|margin' }, // --- Global filter input (large input in the H2 bar) --- sa_global_filter_border_idle: { label: 'Global filter border โ€” idle (empty)', type: 'color_picker', default: '#000000', description: 'Border color of the global filter input when the field is empty (no active filter)' }, sa_global_filter_border_active: { label: 'Global filter border โ€” active (has filter text)', type: 'color_picker', default: 'orange', description: 'Border color of the global filter input while it holds a valid filter string' }, // --- Sub-table filter input border colors --- sa_subtable_filter_border_idle: { label: 'Sub-table filter border โ€” idle (empty)', type: 'color_picker', default: '#cccccc', description: 'Border color of a sub-table filter input when the field is empty (no active filter)' }, sa_subtable_filter_border_active: { label: 'Sub-table filter border โ€” active (has filter text)', type: 'color_picker', default: '#008000', description: 'Border color of a sub-table filter input while it holds a valid filter string' }, // --- Shared error border color for all filter inputs with Rx mode --- sa_filter_border_error: { label: 'Filter border โ€” error (invalid regexp) โ€” all filter types', type: 'color_picker', default: '#cc0000', description: 'Border color applied to any filter input (global, sub-table, or column) when the Rx checkbox is on and the entered expression is not a valid regular expression. The border width is also increased to 4 px to make the error color clearly visible.' }, sa_global_filter_initial_width: { label: 'Global filter initial width (px)', type: 'number', default: 500, min: 100, max: 2000, description: 'Initial width in pixels of the global filter input; the field can be widened by dragging the resize handle at its right edge' }, sa_subtable_filter_initial_width: { label: 'Sub-table filter initial width (px)', type: 'number', default: 320, min: 100, max: 2000, description: 'Initial width in pixels of each sub-table filter input; the field can be widened by dragging the resize handle at its right edge' }, sa_ui_global_filter_input_style: { label: 'Global filter input style', type: 'popup_dialog', fields: ['fontSize', 'padding', 'border', 'borderRadius', 'width', 'height'], default: '1em|2px 6px|2px solid #000|3px|500px|24px', description: 'Global filter input: fontSize|padding|border|borderRadius|width|height (border color is overridden by the three "Global filter border" color pickers; initial width is overridden by "Global filter initial width")' }, // --- Pre-load filter input (small input in the H1 controls bar) --- sa_ui_prefilter_input_style: { label: 'Pre-load filter input style', type: 'popup_dialog', fields: ['fontSize', 'padding', 'border', 'borderRadius', 'width', 'height'], default: '1em|2px 4px|1px solid #ccc|3px|150px|24px', description: 'Pre-load filter input: fontSize|padding|border|borderRadius|width|height' }, // --- Per-column filter inputs (thead row) --- sa_ui_column_filter_input_style: { label: 'Column filter input style', type: 'popup_dialog', fields: ['fontSize', 'padding'], default: '1em|1px 18px 1px 4px', description: 'Per-column filter inputs: fontSize|padding' }, // --- Sub-table control buttons (Clear Filters / Show all N) --- sa_ui_subtable_btn_style: { label: 'Sub-table button style', type: 'popup_dialog', fields: ['fontSize', 'padding', 'borderRadius', 'bg', 'border', 'bgHover'], colorFields: ['bg', 'bgHover'], default: '0.8em|2px 6px|4px|#f0f0f0|1px solid #ccc|#e0e0e0', description: 'mb-subtable-clear-btn / mb-show-all-subtable-btn: fontSize|padding|borderRadius|bg|border|bgHover' }, sa_ui_show_all_subtable_btn_bg: { label: '"Show all N rows" button initial background color', type: 'color_picker', default: '#FFE0B2', description: 'Background color of the "Show all N rows" overflow sub-table button before it has been clicked. ' + 'After clicking the button the background changes to sa_ui_show_all_subtable_btn_bg_clicked.' }, sa_ui_show_all_subtable_btn_bg_clicked: { label: '"Show all N rows" button clicked background color', type: 'color_picker', default: '#CCFFCC', description: 'Background color of the "Show all N rows" overflow sub-table button after it has been clicked ' + '(opening the full listing in a new tab). Provides visual confirmation that the button was activated.' }, // --- Filter-bar utility buttons (Prefilter toggle, Toggle highlighting, Clear filters) --- sa_ui_filter_bar_btn_style: { label: 'Filter bar utility button style', type: 'popup_dialog', fields: ['fontSize', 'padding', 'borderRadius', 'bg', 'border'], colorFields: ['bg'], default: '0.8em|2px 6px|4px|#f0f0f0|1px solid #ccc', description: 'Filter-bar utility buttons (prefilter toggle, highlight toggle, clear buttons): fontSize|padding|borderRadius|bg|border' }, // --- Checkboxes and their labels in filter bars --- sa_ui_checkbox_style: { label: 'Filter bar checkbox style', type: 'popup_dialog', fields: ['fontSize', 'marginRight'], default: '0.8em|2px', description: 'Checkboxes (Cc / Rx / Ex) in filter bars: fontSize|marginRight' }, // --- H2 / H3 section header colors --- sa_ui_h2_bg: { label: 'H2 section-header background color', type: 'color_picker', default: '#ffd787', description: 'Background color for collapsible H2 section headers (.mb-toggle-h2) when the cursor is NOT hovering. The existing grey hover background is preserved. Default is a light orange.' }, sa_ui_h3_bg: { label: 'H3 sub-table-header background color', type: 'color_picker', default: '#f7dfdf', description: 'Background color for collapsible H3 sub-table headers (.mb-toggle-h3) in multi-table mode when the cursor is NOT hovering. The existing grey hover background is preserved. Default is a light green.' }, sa_ui_h2_hover_bg: { label: 'H2 section-header hover background color', type: 'color_picker', default: '#f99f50', description: 'Background color applied to H2 section headers when the cursor hovers over them (.mb-toggle-h2:hover). Default is the original MusicBrainz light grey (#f9f9f9). Takes precedence over the non-hover H2 background (sa_ui_h2_bg).' }, sa_ui_h2_artist_rgs_global_bg: { label: 'Artist RGs โ€” global super-header background color', type: 'color_picker', default: '#1b5e20', description: 'Background color for the synthetic global super-header h2 injected above the "Official Artist Discography" section when the "๐Ÿงฎ Artist RGs" or "๐Ÿงฎ VA RGs" two-pass button is used. This header carries the global filter, global CAA toggle, and global master-toggle that act across ALL sub-sections. Default is dark green (#1b5e20) to visually distinguish it from the section-level h2 headers.' }, sa_ui_h3_hover_bg: { label: 'H3 sub-table-header hover background color', type: 'color_picker', default: '#eb7231', description: 'Background color applied to H3 sub-table headers when the cursor hovers over them (.mb-toggle-h3:hover). Default is the original MusicBrainz light grey (#f9f9f9). Takes precedence over the non-hover H3 background (sa_ui_h3_bg).' }, // --- Table column-header (thead) row colors --- sa_ui_thead_th_bg: { label: 'Table header-row background color', type: 'color_picker', default: '#bababa', description: 'Background color for all table column-header cells (table.tbl thead th). The default matches the original MusicBrainz grey used before the consolidated view is rendered.' }, sa_ui_thead_th_color: { label: 'Table header-row text color', type: 'color_picker', default: '#333333', description: 'Foreground (text) color for all table column-header cells (table.tbl thead th). The default matches the dark grey text used on the original MusicBrainz entity pages.' }, sa_ui_thead_filter_row_bg: { label: 'Table column-filter row background color', type: 'color_picker', default: '#d1d1d1', description: 'Background color for the per-column filter input row (table.tbl thead tr.mb-col-filter-row th). Slightly lighter than the main header row.' }, sa_ui_thead_th_extracted_bg: { label: 'Extracted column header background color', type: 'color_picker', default: '#b8c8b8', description: 'Background color for extracted table column-header cells โ€” columns split or ' + 'injected from original columns via columnExtractors (e.g. Country, Date, Place, ' + 'Area, MB-Name, Comment, CAA, EAA, Video, Cancelled, Primary Alias, etc.). ' + 'The default (#b8c8b8) is a darker, greenish grey โ€” visually distinct from the ' + 'standard header background โ€” to make extracted columns immediately recognisable. ' + 'Also used as the row-tint color for extracted-column rows in the Statistics panel ' + 'Table Details section.' }, sa_ui_thead_th_derived_bg: { label: 'Derived column header background color', type: 'color_picker', default: '#c8c8b0', description: 'Background color for derived table column-header cells โ€” columns further extracted ' + 'from already-extracted columns via syntheticColumnExtractors (e.g. DD, MM, YYYY, ' + 'Day, Month derived from the Date column; Event-Type, Event-Date, etc. derived from ' + 'the Comment column). ' + 'The default (#c8c8b0) is a warm sandy beige โ€” similar brightness to the extracted ' + 'color but with a warm hue โ€” so the two synthetic tiers are visually distinct. ' + 'Also used as the row-tint color for derived-column rows in the Statistics panel ' + 'Table Details section.' }, // ============================================================ // RELATIONSHIPS COLUMN SECTION // ============================================================ divider_relationships_col: { type: 'divider', label: '๐Ÿ”— RELATIONSHIPS COLUMN' }, sa_enable_relationships_column: { label: 'Enable Relationships column', type: 'checkbox', default: true, description: 'When enabled and the page type declares injectedColumns:["Relationships"], ' + 'a synthetic "Relationships" column is injected and populated ' + 'asynchronously with favicon icon links from the MusicBrainz Web Service ' + '(url-rels for each release-group or release). ' + 'Applies to: artist-releasegroups, artist-releases, label-releases, ' + 'releasegroup-releases. Disable to suppress the column entirely. ' + 'Adapted from "Display shortcut for relationships on MusicBrainz" ' + 'by Aurelien Mino ' }, sa_rels_idb_enable: { label: 'Enable IndexedDB Relationships WS2 data cache', type: 'checkbox', default: true, description: 'Cache MusicBrainz WS2 relationship data in IndexedDB so that ' + 'the same page does not re-fetch from the network on every visit. ' + 'Cached entries are keyed by entity type + MBID and expire after ' + '7 days. Disable to always fetch fresh data from the network. ' + 'Uses the same IndexedDB database as the artwork cache ' + '(vz-mb-saed-art-cache, store: rel-ws2).' }, sa_rel_completion_toast_duration: { label: 'Relationships load-complete toast duration (seconds)', type: 'number', default: 8, min: 0, max: 60, description: 'When all Relationships column cells have been populated ' + 'with favicon icon links, a small non-intrusive toast notification ' + 'appears in the bottom-right corner confirming completion. ' + 'It dismisses automatically after this many seconds, or immediately ' + 'on click. Set to 0 to disable the notification entirely.' }, sa_enable_relationship_debug: { label: 'Enable Relationships column debug logging', type: 'checkbox', default: false, description: 'Enable detailed console logging for the Relationships column pipeline: ' + 'MBID extraction per row, WS2 fetch requests and responses, icon class ' + 'resolution, favicon probing, and cell population. ' + 'Uses Lib.debug(\'relationships\', ...) โ€” requires \'Enable debug logging\' ' + 'to also be active. ' }, // Configurable lookup tables for the Relationships column. // Each entry populates one REL_* constant at startup. sa_rel_discography_mappings: { label: 'Discography entry domain โ†’ favicon URL', type: 'table', table_name: 'Discography Entry Favicons', columns: ['Domain', 'Favicon URL'], description: 'Domain โ†’ custom favicon URL overrides for "discography entry" ' + 'relationships. When a release-group or release has a discography ' + 'entry URL whose domain matches an entry here, the specified icon ' + 'is used instead of fetching favicon.ico. ' + 'Populates REL_DISCOGRAPHY_MAPPINGS.' }, sa_rel_url_icon_classes: { label: 'Relation type โ†’ icon CSS class', type: 'table', table_name: 'URL Relation Type Icons', columns: ['Relation Type', 'Icon Class'], description: 'Maps MB URL relation type strings (e.g. "discogs", "allmusic") ' + 'to MusicBrainz stylesheet CSS class suffixes ' + '(e.g. "discogs", "allmusic"). ' + 'Populates REL_URL_ICON_CLASSES.' }, sa_rel_other_db_classes: { label: 'Other-database domain โ†’ icon CSS class', type: 'table', table_name: 'Other Database Icons', columns: ['Domain', 'Icon Class'], description: 'Maps partial domain strings for "other database" entries ' + '(e.g. "rateyourmusic.com") to MBS CSS class suffixes. ' + 'Populates REL_OTHER_DB_CLASSES.' }, sa_rel_streaming_classes: { label: 'Streaming service URL pattern โ†’ icon CSS class', type: 'table', table_name: 'Streaming Service Icons', columns: ['URL Pattern', 'Icon Class'], description: 'Maps partial URL strings for streaming and download services ' + '(e.g. "open.spotify.com") to MBS CSS class suffixes. ' + 'Populates REL_STREAMING_CLASSES.' }, sa_rel_tooltip_bg: { label: 'Relationships rich tooltip: background color', type: 'color_picker', default: '#ffffff', description: 'Background color of the rich HTML tooltip that appears when hovering ' + 'a Relationships column icon while a filter is active and matches. ' + 'Default: white (#ffffff). Applies to both the rich URL-list panel ' + 'and the plain-text anchor title panel shown to its right.' }, sa_rel_tooltip_color: { label: 'Relationships rich tooltip: text color', type: 'color_picker', default: '#000000', description: 'Text color of the rich HTML tooltip that appears when hovering ' + 'a Relationships column icon while a filter is active and matches. ' + 'Default: black (#000000). Applies to both the rich URL-list panel ' + 'and the plain-text anchor title panel shown to its right.' }, sa_ui_thead_th_injected_bg: { label: 'Injected column header background color', type: 'color_picker', default: '#b8b8d0', description: 'Background color for injected table column-header cells โ€” columns ' + 'populated asynchronously via external API calls (e.g. "Relationships"). ' + 'Default #b8b8d0 (cool blue-grey), distinct from extracted (#b8c8b8) ' + 'and derived (#c8c8b0). Also used as the row-tint color in the ' + 'Statistics panel Table Details section and as the td loading background.' }, // ============================================================ // COLUMN RESIZE FEATURE SECTION // ============================================================ divider_column_resize: { type: 'divider', label: 'โ†”๏ธ COLUMN RESIZE FEATURE' }, sa_enable_column_resizing: { label: 'Enable Column Resizing', type: 'checkbox', default: true, description: 'Enable manual column resizing with mouse drag and the "โ†”๏ธ Resize" button' }, sa_auto_resize_columns: { label: 'Auto-resize columns on load', type: 'checkbox', default: false, description: 'When enabled, automatically triggers the "โ†”๏ธ Resize" button ' + '(same as clicking it manually) immediately after the table is rendered, ' + 'so columns are fitted to their content on every page load without manual clicks.' }, sa_auto_resize_columns_threshold: { label: 'Auto-resize columns threshold (rows)', type: 'number', default: 10000, description: 'Maximum number of rows for which the auto-resize-on-load feature ' + 'will run. When the final rendered table contains more rows than this ' + 'value, auto-resize is skipped to avoid a slow measurement pass on large ' + 'datasets. Set to 0 to disable the threshold (always resize). ' + 'Only has effect when "Auto-resize columns on load" is enabled.' }, // ============================================================ // EXPORT SECTION // ============================================================ divider_export: { type: 'divider', label: '๐Ÿ“ค EXPORT' }, sa_enable_export: { label: 'Enable Export', type: 'checkbox', default: true, description: 'Show/hide the "๐Ÿ’พ Export" button for exporting data to different formats (CSV/JSON/Org-Mode/HTML)' }, sa_export_strip_caa_glyphs: { label: 'Strip CAA/EAA collapse glyphs on export', type: 'checkbox', default: false, description: 'When enabled, removes collapse/uncollapse symbols in artwork columns (e.g. "โ–ถ๐Ÿ”ต", "โ–ถโš ๏ธ", "โ–ถโš โŸณ") from exported cell text. Only the thumbnail-count number is preserved.' }, sa_export_strip_title_glyphs: { label: 'Strip title-prefix collapse glyphs on export', type: 'checkbox', default: false, description: 'When enabled, removes leading collapse/uncollapse symbols (e.g. "โ–ถ") that appear in front of entity titles before export.' }, sa_export_convert_multirow_glyphs: { label: 'Convert multi-row collapse glyphs on export', type: 'checkbox', default: false, description: 'When enabled, converts multi-row collapse icons (e.g. "โ–ถ2โ–ค") to a bracketed count suffix (e.g. " (2)") appended to the cell text. The glyph and rack icon are removed; only the item count is kept.' }, sa_export_include_uniq_counts: { label: 'Include unique-value counts in exported column headers', type: 'checkbox', default: false, description: 'When enabled, appends the unique-value count badge to each exported column header in parentheses, e.g. "Label (7)".' }, sa_export_include_sort_state: { label: 'Include sort-state glyph in exported column headers', type: 'checkbox', default: true, description: 'When enabled (default), preserves the active sort-state glyph (โ–ฒ or โ–ผ) in exported column headers. When disabled, the glyph and priority superscript are stripped, leaving only the plain column name.' }, // ============================================================ // STATISTICS PANEL SECTION // ============================================================ divider_stats_panel: { type: 'divider', label: '๐Ÿ“Š STATISTICS PANEL' }, sa_enable_stats_panel: { label: 'Enable Quick Stats Panel', type: 'checkbox', default: true, description: 'Show/hide the "๐Ÿ“Š Statistics" button for displaying table statistics' }, sa_stats_panel_width: { label: 'Statistics panel max width (px)', type: 'number', default: 860, min: 400, max: 1600, description: 'Maximum pixel width of the ๐Ÿ“Š Statistics panel. The actual width is also capped at 92 vw so the panel always fits the viewport. Default: 860 px.' }, sa_stats_panel_max_height: { label: 'Statistics panel max height (vh)', type: 'number', default: 82, min: 30, max: 98, description: 'Maximum height of the ๐Ÿ“Š Statistics panel as a percentage of the viewport height. Default: 82 vh.' }, // ============================================================ // LOAD AND SAVE DATA TO/FROM DISK SECTION // ============================================================ divider_save_load: { type: 'divider', label: '๐Ÿ’พ LOAD AND SAVE DATA TO/FROM DISK' }, sa_enable_save_load: { label: 'Enable Save/Load to Disk', type: 'checkbox', default: true, description: 'Show/hide the "๐Ÿ’พ Save to Disk" and "๐Ÿ“‚ Load from Disk" buttons for disk persistence of page data' }, // --- Filename construction options --- sa_filename_prefix: { label: 'Save filename prefix', type: 'text', default: 'MB-', description: 'Prefix prepended to every generated save filename (e.g. "MB-"). ' + 'The full filename format is: -()--()_.json.gz' }, sa_filename_include_detail: { label: 'Include detail segment in filename', type: 'checkbox', default: true, description: 'When enabled, the detail segment (e.g. a multi-button label slug or filtered-relationship label) ' + 'is included inside the page-section portion of the filename. When disabled the detail segment is omitted.' }, sa_filename_include_row_count: { label: 'Include row count in filename', type: 'checkbox', default: true, description: 'When enabled, the row count is appended in parentheses after the page-section portion ' + 'of the filename, e.g. "-(1329)". When disabled the row-count segment is omitted entirely.' }, sa_filename_include_timestamp: { label: 'Include timestamp in filename', type: 'checkbox', default: true, description: 'When enabled, an ISO-8601 timestamp (e.g. "_2026-03-13T11-54-51") is appended at the end of the ' + 'filename, separated from the rest by an underscore. When disabled the timestamp is omitted.' }, sa_load_history_limit: { label: 'Load Filter History โ†’ max stored entries', type: 'number', default: 50, min: 0, max: 200, description: 'Maximum number of filter expressions (with their checkbox states) to persist in GM storage using an LRU strategy. Oldest entries are evicted first. Set to 0 to disable history entirely.' }, sa_load_history_dropdown_size: { label: 'Load Filter History โ†’ visible rows in dropdown', type: 'number', default: 10, min: 3, max: 50, description: 'How many history entries are shown at once in the filter-history dropdown panel (the panel is scrollable). This does not affect how many entries are stored โ€” see "max stored entries" above.' }, sa_ld_dialog_width: { label: 'Load dialog width (px)', type: 'number', default: 600, min: 300, max: 1400, description: 'Initial pixel width of the "๐Ÿ“‚ Load from Disk" dialog. The dialog is also resizable by dragging its edges.' }, sa_ld_dialog_min_width: { label: 'Load dialog minimum width (px)', type: 'number', default: 640, min: 280, max: 1200, description: 'Minimum width of the "๐Ÿ“‚ Load from Disk" dialog in pixels. Prevents the dialog from being resized narrower than this value. Default: 640 px.' }, sa_ld_dialog_header_font_size: { label: 'Load dialog header description font size', type: 'text', default: '1.00em', description: 'Font size of the description paragraph below the "๐Ÿ“‚ Load from Disk" dialog title (e.g. 0.9em, 14px, 1rem)' }, sa_ld_status_font_size: { label: 'Load dialog status message font size', type: 'text', default: '0.95em', description: 'Font size for the load-, filter- and render-status message containers inside the "๐Ÿ“‚ Load from Disk" dialog (e.g. 0.9em, 14px, 1rem)' }, // --- H1 action bar: Save / Load button colour overrides --- sa_ui_save_btn_style: { label: 'Save button colors', type: 'popup_dialog', fields: ['bg', 'color', 'border', 'bgHover'], colorFields: ['bg', 'color', 'bgHover'], default: '#4CAF50|white|1px solid #45a049|#45a049', description: 'Save-to-Disk button: bg|color|border|bgHover' }, sa_ui_load_btn_style: { label: 'Load button colors', type: 'popup_dialog', fields: ['bg', 'color', 'border', 'bgHover'], colorFields: ['bg', 'color', 'bgHover'], default: '#2196F3|white|1px solid #0b7dda|#0b7dda', description: 'Load-from-Disk button: bg|color|border|bgHover' }, // --- Download / export notification popup --- sa_ui_download_notification_font_size: { label: 'Download notification message font size', type: 'text', default: '1.2em', description: 'Font size of the message text inside the "Save to Disk" / export download notification popup (e.g. 0.9em, 14px, 1rem)' }, sa_dialog_save_export_autoclose_ms: { label: 'Save / Export dialog auto-close delay (ms)', type: 'number', default: 1800, min: 0, max: 30000, description: 'How long (in milliseconds) the "Save to Disk" and "Export Table Data" dialogs remain open after a download has been initiated before auto-closing. ' + 'Set to 0 to disable auto-close entirely (the dialog must then be dismissed manually via the ร— close button or the Escape key). ' + 'Range: 0 โ€“ 30 000 ms (0 โ€“ 30 s). Default: 1800 ms (1.8 s).' }, sa_ld_auto_resize_after_load: { label: 'Auto-resize columns after loading from disk', type: 'checkbox', default: false, description: 'When enabled, automatically triggers the "โ†”๏ธ Resize" button ' + '(equivalent to clicking it manually) immediately after table data has been ' + 'loaded and rendered from a disk file. ' + 'This fits every column to its optimal content width so the table is ' + 'immediately readable without a manual resize step. ' + 'Applies only when the column is not already in a resized state ' + '(i.e. it will never undo an existing manual resize). ' + 'Has no effect when column resizing is disabled ' + '("โ†”๏ธ Resize" button setting must be enabled).' }, // ============================================================ // EXPAND RELEASE AND RELEASE GROUPS SECTION // Adapted from "MusicBrainz: Expand/collapse release groups" // by Michael Wiencek โ€” injected post-render into the SA table. // ============================================================ divider_expand_rgs: { type: 'divider', label: '๐Ÿ” EXPAND RELEASE AND RELEASE GROUPS' }, sa_enable_expand_rgs: { label: 'Enable Expand Release and Release Groups', type: 'checkbox', default: true, description: 'After the consolidated table is rendered, inject inline โ–ถ/โ–ผ expand/collapse buttons ' + 'on release-group and release rows so their contents can be browsed inline without ' + 'leaving the page. Data is fetched lazily from the MusicBrainz Web Service on first ' + 'click. Active on artist, label, release-group, and series pages ' + '(adapted from the "MusicBrainz: Expand/collapse release groups" userscript by Michael Wiencek).' }, // ============================================================ // BARCODE HIGHLIGHT SECTION // Adapted from "MusicBrainz: Highlight identical barcodes and // toggle merge checkboxes" by chaban โ€” applied post-render to // the SA-consolidated Barcode columns. // ============================================================ divider_barcode_highlight: { type: 'divider', label: '๐Ÿ”– BARCODE HIGHLIGHT' }, sa_enable_barcode_highlight: { label: 'Enable Barcode Highlight', type: 'checkbox', default: true, description: 'After the consolidated table is rendered, scan every Barcode column for ' + 'repeated values and colour-code identical barcodes with matching background colours. ' + 'Clicking a highlighted barcode cell toggles the merge checkboxes for the entire ' + 'group (adapted from the "MusicBrainz: Highlight identical barcodes and toggle merge ' + 'checkboxes" userscript by chaban).' }, // ============================================================ // ARTIST ROLE COLOURS (used in tooltip for place-events "Artists" column) divider_artist_role_colours: { type: 'divider', label: '๐ŸŽจ ARTIST ROLE COLOURS' }, sa_ui_artist_role_main_performer_color: { label: 'Main performer role colour', type: 'color_picker', default: '#57ff5a', description: 'Colour used to highlight the "(main performer)" role label in the rich HTML tooltip of the Artists column on place-events pages. Default: bright green (#57ff5a).' }, sa_ui_artist_role_guest_performer_color: { label: 'Guest performer role colour', type: 'color_picker', default: '#e07000', description: 'Colour used to highlight the "(guest performer)" role label in the rich HTML tooltip of the Artists column on place-events pages. Default: orange (#e07000).' }, // CAA/EAA ILLUSTRATED DISCOGRAPHY SECTION // Adapted from "mb. FUNKEY ILLUSTRATED RECORDS" by jesus2099 // (CC-BY-NC-SA-4.0 / GPL-3.0-or-later). // // Original: https://github.com/jesus2099/konami-command // // Applied post-render to SA-consolidated tables that contain a // "CAA" or "EAA" column. // // Mapping from jesus2099's original variables: // pics_settings.big โ†’ sa_caa_pics_big // pics_settings.small โ†’ sa_caa_pics_small // var colour โ†’ sa_caa_highlight_colour // maxHeight: "125px" โ†’ sa_caa_big_max_height // "/front-250" (big img) โ†’ sa_caa_big_img_size // "/front-250" (small img) โ†’ sa_caa_small_img_size // ============================================================ divider_caa_pics: { type: 'divider', label: '๐Ÿ–ผ๏ธ CAA/EAA ILLUSTRATED DISCOGRAPHY' }, sa_enable_caa_pics: { label: 'Enable CAA/EAA Illustrated Discography', type: 'checkbox', default: true, description: 'After the consolidated table is rendered, load cover art thumbnails into CAA/EAA ' + 'column icons (small pics) and display a hoverable big-picture strip above each table ' + 'with a CAA/EAA column (big pics). ' + 'Adapted from the "mb. FUNKEY ILLUSTRATED RECORDS" userscript by jesus2099 ' + '(CC-BY-NC-SA-4.0 / GPL-3.0-or-later). ' + 'Both small and big pics can be toggled independently below.' }, sa_caa_pics_small: { label: 'Enable small CAA/EAA thumbnails', type: 'checkbox', default: true, description: 'Load cover/event art thumbnails into the background-image of every caa-icon / eaa-icon / ' + 'artwork-icon span found inside the CAA and EAA columns. ' + 'Maps to jesus2099\'s pics_settings.small.' }, sa_caa_pics_big: { label: 'Enable big CAA/EAA picture strip', type: 'checkbox', default: true, description: 'Display a strip of large cover art images directly above each table that has a ' + 'CAA or EAA column. Hovering a table row highlights its cover art in the strip; ' + 'hovering a cover art image in the strip highlights the matching row link. ' + 'Maps to jesus2099\'s pics_settings.big.' }, sa_caa_pics_initially_collapsed: { label: 'Start with CAA/EAA picture strip collapsed', type: 'checkbox', default: true, description: 'When enabled, the big CAA/EAA picture strip is hidden immediately after rendering. ' + 'Cover/event art images are still downloaded in the background so that clicking the ' + 'CAA/EAA toggle button in the header reveals them instantly without a new network ' + 'round-trip. The toggle button always shows the first available cover art ' + 'thumbnail and the total image count for that table.' }, sa_caa_pics_inline: { label: 'Enable inline CAA/EAA thumbnails in Release/Title column', type: 'checkbox', default: true, description: 'For page types with a configured addCAA/addEAA column (Release, Title, Release groups, ' + 'Release title/Event), render a small cover/event-art thumbnail directly inside that column ' + 'cell, between the โ–ถ expand button and the entity title. A fixed-width ' + 'placeholder is inserted in every row so that titles stay aligned regardless ' + 'of whether artwork is available. Images are fetched asynchronously from the ' + 'Cover/Event Art Archive in the background.' }, sa_caa_small_img_size: { label: 'Small CAA/EAA image fetch size (250 / 500 / 1200)', type: 'number', default: 250, min: 250, max: 1200, description: 'Size suffix appended to the coverartarchive.org URL when fetching small ' + 'CAA/EAA thumbnails (e.g. โ€ฆ/front-250). Valid values are 250, 500, and 1200. ' + 'Maps to the hardcoded "/front-250" in jesus2099\'s loadCaaIcon().' }, sa_caa_big_img_size: { label: 'Big CAA/EAA image fetch size (250 / 500 / 1200)', type: 'number', default: 250, min: 250, max: 1200, description: 'Size suffix appended to the coverartarchive.org/eventartarchive.org URL when fetching big ' + 'CAA/EAA images in the picture strip above the table (e.g. โ€ฆ/front-250). ' + 'Valid values are 250, 500, and 1200. ' + 'Maps to the hardcoded "/front-250" in jesus2099\'s big-pics loop.' }, sa_caa_big_max_height: { label: 'Big CAA/EAA image max display height (px)', type: 'number', default: 125, min: 50, max: 600, description: 'Maximum display height in pixels for images in the big-picture strip above ' + 'the table. Maps to jesus2099\'s hardcoded maxHeight: "125px".' }, sa_caa_highlight_colour: { label: 'CAA/EAA hover highlight colour', type: 'color_picker', default: '#ffff00', description: 'Background colour applied to a release/release-group/event link when its big cover art image is ' + 'hovered, and the border colour applied to the big image when the matching table ' + 'row is hovered. Maps to jesus2099\'s var colour = "yellow".' }, sa_caa_fetch_concurrency: { label: 'CAA/EAA request concurrency limit', type: 'number', default: 4, min: 1, max: 20, description: 'Maximum number of simultaneous Cover/Event Art Archive requests (both image loads and ' + 'JSON API calls). All CAA/EAA requests are serialised through a shared FIFO queue ' + 'that enforces this limit. Firing too many requests simultaneously causes the ' + 'CAA/EAA CDN to return responses without the required CORS header, which the browser ' + 'blocks entirely. A value of 4 stays safely below the typical browser per-host ' + 'connection limit (6) and avoids triggering CDN rate-limiting. Increase if your ' + 'network is fast and you see thumbnails loading slowly on very large tables; ' + 'decrease if CORS errors still appear in the console.' }, sa_caa_hover_preview: { label: 'Enable hover preview of artwork', type: 'checkbox', default: true, description: 'When hovering over a cover/event art thumbnail โ€” either the artwork icon in the ' + 'CAA/EAA column or an inline thumbnail in the Release/Title column โ€” display a ' + 'floating popup showing the full image at the big-picture strip size ' + '(sa_caa_big_img_size). The popup is positioned to the right of the thumbnail ' + '(or to the left when near the viewport edge) and disappears on mouse-out.' }, // ============================================================ // ART ARCHIVE INDEXEDDB CACHE SECTION // ============================================================ divider_art_idb: { type: 'divider', label: '๐Ÿ—„๏ธ ART ARCHIVE INDEXEDDB CACHE' }, sa_art_idb_enable: { label: 'Enable IndexedDB art image/metadata cache', type: 'checkbox', default: true, description: 'Cache CAA/EAA artwork image blobs and archive JSON metadata in IndexedDB so ' + 'they survive page reloads without repeat network requests. On the first ' + 'access each image is fetched via GM_xmlhttpRequest (CORS-bypass) and stored ' + 'as a raw Blob; subsequent loads โ€” including the "Load from Disk" path โ€” are ' + 'served instantly from IDB with no network round-trip. Archive JSON metadata ' + '(image counts + thumbnail lists from coverartarchive.org / eventartarchive.org) ' + 'is cached separately with its own TTL. A per-session in-memory Map provides ' + 'an additional zero-cost shortcut for URLs seen multiple times within the same ' + 'page load (e.g. sort/filter re-renders). The browser\'s IndexedDB can be ' + 'inspected or cleared via DevTools โ†’ Application โ†’ Storage โ†’ IndexedDB โ†’ ' + '"vz-mb-saed-art-cache".' }, sa_art_idb_image_ttl_days: { label: 'Image blob cache TTL (days)', type: 'number', default: 30, min: 1, max: 365, description: 'Maximum age in days for a cached artwork image blob before it is considered ' + 'expired and re-fetched from the network. Expired entries are transparently ' + 'overwritten on next access; a background sweep (requestIdleCallback) removes ' + 'remaining stale records after the initial render completes.' }, sa_art_idb_metadata_ttl_days: { label: 'Metadata (JSON) cache TTL (days)', type: 'number', default: 7, min: 1, max: 90, description: 'Maximum age in days for a cached archive JSON metadata record (image count + ' + 'thumbnails list from coverartarchive.org / eventartarchive.org) before it is ' + 're-fetched from the network. Shorter TTL means count badges and multi-row ' + 'art cells reflect recent archive changes sooner; longer TTL reduces API call ' + 'volume on pages with many entities.' }, sa_caa_completion_toast_duration: { label: 'CAA load-complete toast duration (seconds)', type: 'number', default: 10, min: 0, max: 60, description: 'When all CAA/EAA artwork images (icon column, inline Title-column thumbnails, ' + 'and big-picture strip) have finished loading, a small non-intrusive toast ' + 'notification appears in the bottom-right corner confirming completion. ' + 'It dismisses automatically after this many seconds, or immediately on click. ' + 'Set to 0 to disable the notification entirely.' }, // ============================================================ // RELEASE EVENTS COLUMN SECTION // ============================================================ divider_release_events_col: { type: 'divider', label: '๐Ÿ“… RELEASE EVENTS COLUMN' }, sa_enable_release_events_column: { label: 'Enable Release events column', type: 'checkbox', default: true, description: 'When enabled and the page type declares injectedColumns:["Release events"], ' + 'a synthetic "Release events" column is injected and populated ' + 'with country flags and dates from the MusicBrainz Web Service. ' + 'One WS2 call per page entity (not per row). ' + 'Adapted from jesus2099 SUPERMIND CONTROL II X TURBO.' }, sa_enable_release_events_debug: { label: 'Enable Release events column debug logging', type: 'checkbox', default: false, description: 'Enable detailed console logging for the Release events column pipeline. ' + 'Uses Lib.debug(\'release-events\', ...).' }, // ============================================================ // RESOURCE TIMING API SECTION // // Controls the cache-hint indicator feature that probes the // browser's Resource Timing API after each CAA/EAA image load // to classify whether the image was served from memory cache, // disk cache, or the network. // // Classification heuristic: // transferSize === 0 AND encodedBodySize > 0 โ†’ memory cache (๐ŸŸข) // transferSize > 0 โ†’ network (๐Ÿ”ต) // both === 0 AND duration < 5 ms โ†’ disk cache (๐ŸŸก) // both === 0 AND duration >= 5 ms โ†’ network (๐Ÿ”ต) // no PerformanceResourceEntry found โ†’ unavailable (โš ๏ธ) // // IMPORTANT LIMITATION โ€” WHY ๐ŸŸก IS NEVER SEEN FOR CAA/EAA: // coverartarchive.org and eventartarchive.org serve their images // via a non-cacheable 302 redirect to archive.org. The browser // MUST re-fetch this redirect on every page load, so the // PerformanceResourceEntry duration always includes a full network // RTT (~2โ€“4 s), keeping it well above the 5 ms disk-cache threshold. // Even if the final image bytes are already in the browser's disk // cache, ๐ŸŸก will not appear because the redirect itself is never // cached. The only reliably detectable cached state is ๐ŸŸข (memory // cache), which appears when the same image URL is loaded twice in // the same page session (e.g. bigbox strip and inline thumbnail both // request the same /front-250 URL for the same release GUID). // โš ๏ธ only appears when the Performance buffer is full or the API // is absent. // ============================================================ divider_resource_timing: { type: 'divider', label: 'โฑ๏ธ RESOURCE TIMING API' }, sa_rt_enable: { label: 'Enable Resource Timing cache-hint indicators', type: 'checkbox', default: true, description: 'After each CAA/EAA image loads, probe the browser\'s Resource Timing API to ' + 'classify the load source: ๐ŸŸข memory cache, ๐ŸŸก disk cache, ๐Ÿ”ต network, โš ๏ธ no data. ' + 'Important limitation: coverartarchive.org and eventartarchive.org serve images via ' + 'a non-cacheable 302 redirect to archive.org. The browser must re-fetch this redirect ' + 'on every page load, so the timing entry always shows a 2โ€“4 s network duration even ' + 'when the final image bytes are already cached. As a result ๐ŸŸก (disk cache) is ' + 'NEVER observable for CAA/EAA archive URLs โ€” you will only ever see ๐ŸŸข (memory, ' + 'when the same image is loaded twice in the same session) or ๐Ÿ”ต (network/redirect). ' + 'โš ๏ธ appears only when the Performance buffer is full or the API is absent. ' + 'The three display locations can each be toggled independently below.' }, sa_rt_show_icon_column: { label: 'Show cache-hint in CAA/EAA icon column', type: 'checkbox', default: true, description: 'Display the cache-hint emoji (๐ŸŸข/๐ŸŸก/๐Ÿ”ต/โš ๏ธ) as a superscript above the image-count ' + 'badge in the CAA/EAA artwork icon column cell. The indicator is placed between the ' + 'artwork anchor and the count badge, styled as a small superscript so it reads as ' + 'ใ€Œicon [๐ŸŸข] Nใ€ in a vertical stack. Requires "Enable Resource Timing" above.' }, sa_rt_show_bigbox: { label: 'Show cache-hint overlay on big-picture strip images', type: 'checkbox', default: true, description: 'Overlay the cache-hint emoji in the top-left corner of each image in the big ' + 'CAA/EAA picture strip above the table. The badge has a semi-transparent dark ' + 'background so it remains legible over both light and dark cover art. ' + 'Requires "Enable Resource Timing" above.' }, sa_rt_show_inline: { label: 'Show cache-hint overlay on inline thumbnails', type: 'checkbox', default: true, description: 'Overlay the cache-hint emoji in the top-left corner of each inline cover/event-art ' + 'thumbnail rendered inside the Release / Title column. ' + 'Requires "Enable Resource Timing" above.' }, // ============================================================ // UNICODE CHARACTER PICKER SECTION // ============================================================ divider_unicode_char_picker: { type: 'divider', label: '๐Ÿ”ค UNICODE CHARACTER PICKER' }, sa_unicode_char_picker: { label: "Enable Unicode Character Picker", type: "checkbox", default: true, description: "Enable the Unicode character picker popup in any focused text input or textarea (default: Ctrl+U). " + "Adapted from the 'mb.unicodechars' userscript by Smeulf." }, sa_shortcut_unicode_chars: { label: "Shortcut: Unicode Character Picker", type: "keyboard_shortcut", default: "Ctrl+U", description: "Open the Unicode character picker popup in any focused text input or textarea (default: Ctrl+U). " + "Only active when 'Enable Unicode Character Picker' is enabled. " + "Adapted from the 'mb.unicodechars' userscript by Smeulf." }, // Configurable lookup table for the Unicode Character Picker. // Each entry populates one Unicode code symbol with its descriptive name at startup. // The 'Default' column marks the entry that is pre-highlighted when the picker opens. // The 'Examples' column is purely informational and shown in the settings UI only. // Changes take effect after saving settings and reloading the page. sa_unicode_char_picker_mappings: { label: 'Unicode Character Picker Mapping: Unicode code symbol โ†’ Name', type: 'table', table_name: 'Unicode Code Symbols', columns: ['Unicode Code Symbol', 'Name', 'Default', 'Examples'], description: 'Catalogue of Unicode characters offered by the picker. ' + 'Each row defines one entry: the character(s) to insert, a human-readable name ' + 'shown in the picker list, whether this row is pre-highlighted when the menu opens, ' + 'and optional usage examples for reference (the Examples column is display-only and ' + 'not used by the picker itself). ' + 'Set "Default" to "true" on exactly one row (the others can be "false" or empty). ' + 'The cursor is always placed after the first character of a pair (e.g. between ' + '\u201C and \u201D so the user can type inside the quotes). ' + 'Populates SA_UNICODE_CHARS. Changes take effect after saving and reloading.' }, // ============================================================ // PICARD TAGGER SECTION // ============================================================ // The Picard tagger integration in this section is based on the // "MusicBrainz Magic Tagger Button" userscript by Philipp Wolfer // (https://github.com/phw/musicbrainz-magic-tagger-button, MIT licence). divider_picard_tagger: { type: 'divider', label: '๐ŸŽต PICARD TAGGER' }, sa_enable_picard_tagger: { label: 'Enable Picard Tagger column', type: 'checkbox', default: true, description: 'Inject a "Picard" column into every rendered table that lets you send ' + 'individual releases or release-groups directly to MusicBrainz Picard with ' + 'one click. The column is only visible on pages whose URL matches the ' + 'supported page types (releases, release-groups, recordings, series, ' + 'collections, cdtoc, taglookup, search, artist/*/releases). ' + 'Picard must be running locally with browser integration enabled. ' + 'Based on the "MusicBrainz Magic Tagger Button" userscript by Philipp Wolfer.' }, sa_picard_tagger_default_port: { label: 'Picard default port', type: 'number', default: 8000, description: 'The port Picard listens on by default (usually 8000). ' + 'The script probes ports from this value up to the max port below.' }, sa_picard_tagger_max_port: { label: 'Picard max probe port', type: 'number', default: 8010, description: 'Upper bound for port probing. Set equal to the default port to ' + 'suppress range probing and only try the single default port.' }, sa_picard_tagger_host: { label: 'Picard host', type: 'text', default: '127.0.0.1', description: 'IP address or hostname where Picard is running. Change this if ' + 'Picard runs on a different machine on your local network.' } }; //-------------------------------------------------------------------------------- // Initialize VZ-MBLibrary (Logger + Settings + Changelog) // Use a ref object to avoid circular dependency during initialization const settings = {}; const remoteConfig = { changelogUrl: REMOTE_CHANGELOG_URL, cacheKeyChangelog: CACHE_KEY_CHANGELOG, cacheTtlMs: REMOTE_CACHE_TTL_MS }; const Lib = (typeof VZ_MBLibrary !== 'undefined') ? new VZ_MBLibrary(SCRIPT_ID, SCRIPT_NAME, configSchema, null, () => { // Dynamic check: returns current value of debug setting return settings.sa_enable_debug_logging ?? false; }, remoteConfig) : { settings: {}, info: console.log, debug: console.log, error: console.error, warn: console.warn, time: console.time, timeEnd: console.timeEnd }; // Get version information dynamically const scriptVersion = (typeof GM_info !== 'undefined' && GM_info.script) ? GM_info.script.version : 'unknown'; const libVersion = (Lib && Lib.version) ? Lib.version : 'unknown'; // Copy settings reference so the callback can access them Object.assign(settings, Lib.settings); //-------------------------------------------------------------------------------- // Check if we just reloaded to fix the filter issue โ€” dialog shown later, after buttons are in DOM const reloadFlag = sessionStorage.getItem('mb_show_all_reload_pending'); if (reloadFlag) { sessionStorage.removeItem('mb_show_all_reload_pending'); } const currentUrl = new URL(window.location.href); const basePath = currentUrl.origin + currentUrl.pathname; const path = currentUrl.pathname; const params = currentUrl.searchParams; const isFilteredRelationshipPage = params.has('link_type_id'); Lib.info('init', `Userscript (${scriptVersion}) loaded with external library (${libVersion}) active on MusicBrainz page: ${currentUrl}`); Lib.debug('init', `URL: ${currentUrl}`); Lib.debug('init', `URL basepath: ${basePath}`); Lib.debug('init', `URL path: ${path}`); Lib.debug('init', `Query parameters: ${params}`); Lib.debug('init', `Has "link_type_id": ${isFilteredRelationshipPage}`); // --- ColumnDataExtractor: Column Extraction & Transformation Registry --- // // Each named function receives the *source* element from the fetched page // and returns an ordered array of freshly-created elements โ€” one per // synthetic column declared in the corresponding `columnExtractors` descriptor. // // Contract: // extractorFn(sourceCell: HTMLTableCellElement): HTMLTableCellElement[] // // The returned array MUST have the same length as the `syntheticColumns` array // in the associated descriptor; extra elements are ignored, missing ones yield // an empty placeholder so the table row stays structurally consistent. // // Adding a new extractor: // 1. Add a function here with a descriptive camelCase name. // 2. Reference it by that name string in the `columnExtractors` array inside // the relevant pageDefinitions `features` object. // 3. Declare the synthetic column header names in `syntheticColumns`. /** * Shared base extraction helper for tagCount_* extractors. * * Handles the common fields present in all tag-value-entity cell structures: * Tag count โ€” leading integer in the first direct text node ("26 -"). * Name โ€” entity link, optionally preceded by a country-flag span. * The flag span (if any) and the link are cloned together into * tdName so the flag renders alongside the entity name. * Comment โ€” text of the inside the first span.comment child of * the (not nested deeper), or empty if absent. * * @param {HTMLTableCellElement|null} sourceCell * @returns {[HTMLTableCellElement, HTMLTableCellElement, HTMLTableCellElement]} * [tdName, tdCount, tdComment] */ function _tagCountBase(sourceCell) { const tdName = document.createElement('td'); const tdCount = document.createElement('td'); const tdComment = document.createElement('td'); if (!sourceCell) return [tdName, tdCount, tdComment]; // โ”€โ”€ Tag count โ€” leading integer in first direct text node โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let _rawLeading = ''; for (const node of sourceCell.childNodes) { if (node.nodeType === Node.TEXT_NODE) { _rawLeading += node.nodeValue; break; } } const _countMatch = _rawLeading.match(/^\s*(\d[\d,]*)/); tdCount.textContent = _countMatch ? _countMatch[1].replace(/,/g, '') : ''; tdCount.style.fontVariantNumeric = 'tabular-nums'; // โ”€โ”€ Name โ€” entity link, with optional preceding country-flag span โ”€โ”€โ”€โ”€ // Detect a flag span as a direct child of the that precedes the . // Structure: โ€ฆ const _flagSpan = Array.from(sourceCell.children).find( el => el.tagName === 'SPAN' && /\bflag\b/.test(el.className) && el.querySelector('a') ); if (_flagSpan) { // Clone the entire flag+link span โ€” flag sprite + entity link in one. tdName.appendChild(_flagSpan.cloneNode(true)); } else { // Standard: find the first top-level (entity link). const _a = sourceCell.querySelector(':scope > a, :scope > span:not(.comment) > a'); if (_a) { const _aClone = _a.cloneNode(true); _aClone.querySelectorAll('.comment').forEach(el => el.remove()); tdName.appendChild(_aClone); } } // โ”€โ”€ Comment โ€” inside the first direct span.comment โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Use :scope > span.comment to avoid matching artist comment spans // that are nested inside (for Artists columns). const _commentSpan = sourceCell.querySelector(':scope > span.comment'); const _commentBdi = _commentSpan ? _commentSpan.querySelector('bdi') : null; tdComment.textContent = _commentBdi ? _commentBdi.textContent.replace(/\s+/g, ' ').trim() : ''; return [tdName, tdCount, tdComment]; } const ColumnDataExtractor = { /** * splitCountryDate โ€” splits a "Country/Date" cell into separate Country and * Date cells. Source structure: .release-event > (.release-country + * .release-date), repeated once per release event. * * Each release event becomes one
  • item inside a
      , producing true * multi-row cells instead of the former comma-separated flat layout. A * single-event cell is still wrapped in
      • so that the structure is * consistent and compatible with the collapsableColumns mechanism * (initCollapsableColumns inspects `ul > li` count to decide whether to add * the โ–ถ/โ—€ toggle โ€” cells with only one
      • are left un-toggled). * * Synthetic columns: ['Country', 'Date'] */ splitCountryDate(sourceCell) { const tdC = document.createElement('td'); const tdD = document.createElement('td'); if (sourceCell) { const events = Array.from(sourceCell.querySelectorAll('.release-event')); if (events.length > 0) { const ulC = document.createElement('ul'); const ulD = document.createElement('ul'); events.forEach((ev) => { const countrySpan = ev.querySelector('.release-country'); const dateSpan = ev.querySelector('.release-date'); if (countrySpan) { const li = document.createElement('li'); const flagImg = countrySpan.querySelector('img')?.outerHTML || ''; const abbr = countrySpan.querySelector('abbr'); const countryCode = abbr ? abbr.textContent.trim() : ''; const countryFull = abbr?.getAttribute('title') || ''; const countryHref = countrySpan.querySelector('a')?.getAttribute('href') || '#'; const spanContainer = document.createElement('span'); spanContainer.className = countrySpan.className; if (countryFull && countryCode) { spanContainer.innerHTML = `${flagImg} ${countryFull} (${countryCode})`; } else { spanContainer.innerHTML = countrySpan.innerHTML; } li.appendChild(spanContainer); ulC.appendChild(li); } if (dateSpan) { const li = document.createElement('li'); // Clone the date span and strip any chaban-userscript // https://community.metabrainz.org/u/chaban day-of-week indicator () before reading the date text. Without this, // dateSpan.textContent would include the abbreviated weekday (e.g. "Wed", "Tue", "Sat") // appended directly to the date string, e.g. "1995-11-22Wed" instead of "1995-11-22". const dateClone = dateSpan.cloneNode(true); dateClone.querySelectorAll('.mb-day-of-week').forEach(el => el.remove()); li.textContent = dateClone.textContent.trim(); ulD.appendChild(li); } }); if (ulC.hasChildNodes()) tdC.appendChild(ulC); if (ulD.hasChildNodes()) tdD.appendChild(ulD); } } return [tdC, tdD]; }, /** * splitLocation โ€” splits a "Location" cell (venue / city / country) into * three separate cells: Place, Area, and Country. * Place โ† links whose href contains '/place/' * Area โ† links whose href contains '/area/' but NOT wrapped in a .flag span * Country โ† links whose href contains '/area/' wrapped in a .flag span * * Multi-row aware: when the source cell contains a
        • structure (i.e. * the Location column was already wrapped by renderMultiRowCell or inherited * a multi-row layout from MusicBrainz), each
        • is processed independently * and the results are placed into parallel
          • lists in the three output * cells โ€” one
          • per source
          • for each synthetic column. This mirrors * splitCountryDate's per-event
          • approach so that the three split columns * stay row-aligned with the source. * * Synthetic columns: ['Place', 'Area', 'Country'] */ splitLocation(sourceCell) { const tdP = document.createElement('td'); const tdA = document.createElement('td'); const tdC = document.createElement('td'); if (!sourceCell) return [tdP, tdA, tdC]; /** * Process one DOM node (a single
          • content or the whole flat cell) * and append the extracted Place / Area / Country fragments to the * provided containers (either
          • or elements). */ const _processNode = (node, containerP, containerA, containerC) => { node.querySelectorAll('a').forEach(a => { const href = a.getAttribute('href'); const clonedA = a.cloneNode(true); if (href && href.includes('/place/')) { containerP.appendChild(clonedA); } else if (href && href.includes('/area/')) { const flagSpan = a.closest('.flag'); if (flagSpan) { const flagImg = flagSpan.querySelector('img')?.outerHTML || ''; const abbr = flagSpan.querySelector('abbr'); const countryCode = abbr ? abbr.textContent.trim() : ''; const countryFull = abbr?.getAttribute('title') || ''; const countryHref = a.getAttribute('href') || '#'; const span = document.createElement('span'); span.className = flagSpan.className; if (countryFull && countryCode) { span.innerHTML = `${flagImg} ${countryFull} (${countryCode})`; } else { span.innerHTML = flagSpan.innerHTML; } containerC.appendChild(span); } else { if (containerA.hasChildNodes()) containerA.appendChild(document.createTextNode(', ')); containerA.appendChild(clonedA); } } }); }; const sourceLis = Array.from(sourceCell.querySelectorAll(':scope > ul > li')); if (sourceLis.length > 0) { // โ”€โ”€ Multi-row path: build parallel
            • lists โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const ulP = document.createElement('ul'); const ulA = document.createElement('ul'); const ulC = document.createElement('ul'); sourceLis.forEach(li => { const liP = document.createElement('li'); const liA = document.createElement('li'); const liC = document.createElement('li'); _processNode(li, liP, liA, liC); ulP.appendChild(liP); ulA.appendChild(liA); ulC.appendChild(liC); }); if (ulP.querySelector('li a')) tdP.appendChild(ulP); if (ulA.querySelector('li a')) tdA.appendChild(ulA); if (ulC.querySelector('li a')) tdC.appendChild(ulC); } else { // โ”€โ”€ Flat path: original behaviour for non-multi-row cells โ”€โ”€โ”€โ”€โ”€โ”€ _processNode(sourceCell, tdP, tdA, tdC); } return [tdP, tdA, tdC]; }, /** * splitArea โ€” splits an "Area" cell into MB-Area and Country cells. * The country is identified by a .flag span; all remaining sibling nodes go * to MB-Area. Leading/trailing comma separators adjacent to the country node * are stripped from both output cells. * Synthetic columns: ['MB-Area', 'Country'] */ splitArea(sourceCell) { const tdArea = document.createElement('td'); const tdCountry = document.createElement('td'); /** Remove leading/trailing comma-or-whitespace text nodes from a cell. */ const trimCell = (cell) => { const isTrimTarget = (n) => n.nodeType === Node.TEXT_NODE && (n.textContent.trim() === ',' || !n.textContent.trim()); while (cell.firstChild && isTrimTarget(cell.firstChild)) cell.removeChild(cell.firstChild); while (cell.lastChild && isTrimTarget(cell.lastChild)) cell.removeChild(cell.lastChild); }; if (sourceCell) { const nodes = Array.from(sourceCell.childNodes); const countryNodeIndex = nodes.findIndex(n => n.nodeType === Node.ELEMENT_NODE && (n.classList.contains('flag') || n.querySelector('.flag')) ); nodes.forEach((n, idx) => { if (idx === countryNodeIndex) { tdCountry.appendChild(n.cloneNode(true)); } else { const isCommaSep = n.nodeType === Node.TEXT_NODE && n.textContent.trim() === ','; const isAdjacent = (idx === countryNodeIndex - 1 || idx === countryNodeIndex + 1); if (isCommaSep && isAdjacent) return; // skip dangling comma tdArea.appendChild(n.cloneNode(true)); } }); trimCell(tdArea); trimCell(tdCountry); } return [tdArea, tdCountry]; }, /** * sumTracks โ€” sums the numeric parts of a "Tracks" cell whose content is a * '+'-separated list such as "9 + 7 + 8 + 10 + 11 + 9 + 10 + 12". * Returns a single right-aligned cell containing the integer total. * Synthetic columns: ['Total Tracks'] */ sumTracks(sourceCell) { const tdTotal = document.createElement('td'); if (sourceCell) { const text = sourceCell.textContent || ''; const nums = text.split('+').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n)); const total = nums.reduce((acc, n) => acc + n, 0); if (nums.length > 0) { tdTotal.textContent = String(total); tdTotal.style.cssText = 'text-align:right; font-variant-numeric:tabular-nums;'; } } return [tdTotal]; }, /** * extractFormatTypes โ€” strips the leading numeric quantity factor (e.g. "8ร—", * "2x") from each media-type token in a "Format" cell and converts the * " + " separator between distinct types to ", ". * * Input examples โ†’ Output examples * "8ร—12\" Vinyl" โ†’ "12\" Vinyl" * "2xCD-R" โ†’ "CD-R" * "2xCD-R + DVD" โ†’ "CD-R, DVD" * * The quantity prefix pattern is: one-or-more digits followed by a lowercase * ASCII 'x' or the Unicode multiplication sign 'ร—' (U+00D7). Tokens with no * prefix are kept verbatim (e.g. a bare "DVD" remains "DVD"). * * Synthetic columns: ['Format Types'] */ extractFormatTypes(sourceCell) { const tdTypes = document.createElement('td'); if (sourceCell) { const text = sourceCell.textContent || ''; const types = text .split(' + ') .map(part => part.trim().replace(/^\d+[x\u00D7]/, '').trim()) .filter(t => t.length > 0); tdTypes.textContent = types.join(', '); } return [tdTypes]; }, /** * video โ€” extracts the MusicBrainz video-indicator span from a recording * title cell into a dedicated synthetic "Video" column, and removes it from * the source cell so the title column stays uncluttered. * * Source structure (optional): * โ€ฆiconโ€ฆ * * The span is moved (not copied) out of the source cell: the original DOM * node is removed so both the title column and the Video column always * reflect the real state without duplication. * * Sortability and dropdown filtering: * An invisible
            • Name (โ€ฆ)
            • /** * Name_Comment โ€” extracts Name and Comment from a tag-value entity cell. * Mirrors tagCount_Name_Comment without the Tag count field. * Synthetic columns: ['Name', 'Comment'] */ Name_Comment(sourceCell) { const [tdName, , tdComment] = _tagCountBase(sourceCell); return [tdName, tdComment]; }, /** * Name_Date_Comment โ€” extracts Name, Date and Comment from a tag-value * Events cell. Mirrors tagCount_Name_Date_Comment without Tag count. * Synthetic columns: ['Name', 'Date', 'Comment'] */ Name_Date_Comment(sourceCell) { const [tdName, , tdComment] = _tagCountBase(sourceCell); const tdDate = document.createElement('td'); if (sourceCell) { let _pastLink = false; for (const node of sourceCell.childNodes) { if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'A') { _pastLink = true; continue; } if (_pastLink && node.nodeType === Node.TEXT_NODE) { const _m = node.nodeValue.match(/\(\s*([\d\-]+)\s*\)/); if (_m) { tdDate.textContent = _m[1].trim(); break; } } } } return [tdName, tdDate, tdComment]; }, /** * Name_Comment_Artists โ€” extracts Name, Comment and Artists from a * tag-value Releases/Release groups/Recordings cell. * Mirrors tagCount_Name_Comment_Artists without Tag count. * Synthetic columns: ['Name', 'Comment', 'Artists'] */ Name_Comment_Artists(sourceCell) { const [tdName, , tdComment] = _tagCountBase(sourceCell); const tdArtists = document.createElement('td'); if (sourceCell) { const _allBdi = Array.from(sourceCell.querySelectorAll(':scope > bdi')); if (_allBdi.length > 0) { tdArtists.appendChild(_allBdi[_allBdi.length - 1].cloneNode(true)); } } return [tdName, tdComment, tdArtists]; }, /** * Collection_Editor โ€” splits a release-collections cell of the form * Collection Name by * EditorName * into two separate cells: Collection (the collection link) and Editor * (the editor avatar + username link). * * Synthetic columns: ['Collection', 'Editor'] */ Collection_Editor(sourceCell) { const tdCollection = document.createElement('td'); const tdEditor = document.createElement('td'); if (sourceCell) { // All anchors in the cell: [0] = collection link, [1] = editor link const _links = Array.from(sourceCell.querySelectorAll('a[href]')); // Collection link: href contains /collection/ const _collLink = _links.find(a => a.getAttribute('href')?.includes('/collection/')); if (_collLink) { tdCollection.appendChild(_collLink.cloneNode(true)); } // Editor link: href contains /user/ const _editorLink = _links.find(a => a.getAttribute('href')?.includes('/user/')); if (_editorLink) { tdEditor.appendChild(_editorLink.cloneNode(true)); } } return [tdCollection, tdEditor]; } }; // --- SyntheticColumnDataExtractor: Second-Pass Extraction Registry --- // // Each named function receives a element that was produced by a first-pass // ColumnDataExtractor and returns an ordered array of freshly-created elements โ€” // one per synthetic column declared in the corresponding `syntheticColumnExtractors` // descriptor. // // Contract (identical to ColumnDataExtractor): // extractorFn(sourceCell: HTMLTableCellElement): HTMLTableCellElement[] // // The returned array MUST have the same length as the `syntheticColumns` array in the // associated descriptor; extra elements are ignored, missing ones yield an empty // placeholder so the table row stays structurally consistent. // // Unlike ColumnDataExtractor, which resolves its source column by DOM index (`colIdx`), // SyntheticColumnDataExtractor functions receive the source via a name-keyed map // built per-row from the primary extractor outputs โ€” no `colIdx` bookkeeping is needed. // // Adding a new synthetic extractor: // 1. Add a function here with a descriptive camelCase name. // 2. Reference it by that name string in the `syntheticColumnExtractors` array inside // the relevant pageDefinitions `features` object. // 3. Declare the synthetic column header names in `syntheticColumns`. const SyntheticColumnDataExtractor = { /** * dateParts โ€” parses a partial or complete ISO 8601 date string extracted from a * synthetic Date cell and produces separate columns for the numeric day, numeric * month, numeric year, weekday name, and month name. * * This is the `SyntheticColumnDataExtractor` counterpart to the identically-named * function in `ColumnDataExtractor`. Use this version when the source cell is a * *secondary* cell produced by a prior extractor (e.g. the 'Date' output of * `splitCountryDate`). Use the `ColumnDataExtractor` version when the source is a * *primary* (original DOM) column such as the native "Date" column on event pages. * * The source cell may contain either plain text or a
              • multi-row structure * (as produced by splitCountryDate when a release has multiple release events). * When multiple events are present, only the first non-empty
              • is used so that * the output columns always contain scalar values suitable for sorting and filtering. * * Supported input formats (partial dates are handled gracefully): * 'YYYY' โ†’ YYYY filled; DD, MM, Day, Month left empty * 'YYYY-MM' โ†’ YYYY and MM filled; Month derived; DD and Day left empty * 'YYYY-MM-DD' โ†’ all five columns filled; Day computed via Date constructor * * The Date constructor is used only for the day-of-week calculation and is guarded * against invalid calendar dates (e.g. 'YYYY-02-30') by round-tripping the result. * * Synthetic columns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] * * @param {HTMLTableCellElement} sourceCell Synthetic Date (e.g. from splitCountryDate). * @returns {HTMLTableCellElement[]} Five synthetic elements in declaration order. */ dateParts(sourceCell) { const tdDD = document.createElement('td'); const tdMM = document.createElement('td'); const tdYYYY = document.createElement('td'); const tdDay = document.createElement('td'); const tdMonth = document.createElement('td'); if (!sourceCell) return [tdDD, tdMM, tdYYYY, tdDay, tdMonth]; // Prefer the first
              • when the cell carries a multi-row
                  structure // (produced by splitCountryDate for release events with multiple dates). const firstLi = sourceCell.querySelector('ul > li'); const rawText = firstLi ? firstLi.textContent.trim() : sourceCell.textContent.trim(); if (!rawText) return [tdDD, tdMM, tdYYYY, tdDay, tdMonth]; const MONTH_NAMES = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ]; const DAY_NAMES = [ 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday' ]; const parts = rawText.split('-'); const year = parts[0] ? parseInt(parts[0], 10) : NaN; const month = parts[1] ? parseInt(parts[1], 10) : NaN; const day = parts[2] ? parseInt(parts[2], 10) : NaN; if (!isNaN(year) && year > 0) { tdYYYY.textContent = String(year); tdYYYY.style.fontVariantNumeric = 'tabular-nums'; } if (!isNaN(month) && month >= 1 && month <= 12) { tdMM.textContent = String(month); tdMM.style.fontVariantNumeric = 'tabular-nums'; tdMonth.textContent = MONTH_NAMES[month - 1]; } if (!isNaN(day) && day >= 1 && day <= 31) { tdDD.textContent = String(day); tdDD.style.fontVariantNumeric = 'tabular-nums'; } // Compute weekday name only when year + month + day are all present. // Round-trip the Date result to guard against invalid calendar dates // (e.g. 'YYYY-02-30') that the Date constructor silently normalises. if (!isNaN(year) && !isNaN(month) && !isNaN(day)) { const d = new Date(year, month - 1, day); if (!isNaN(d.getTime()) && d.getFullYear() === year && d.getMonth() === month - 1 && d.getDate() === day) { tdDay.textContent = DAY_NAMES[d.getDay()]; } } return [tdDD, tdMM, tdYYYY, tdDay, tdMonth]; }, /** * eventParts โ€” parses a MusicBrainz recording "Comment" field that encodes * live-performance metadata as a free-form comma/colon-delimited string and * splits it into nine dedicated synthetic columns. * * Expected source format (all segments are optional): * [Event-Type][, Event-Date][, Event-Detail][: Venue[, Venue-Detail][, City][, State][, Country[; Additional-Info]]] * * Location Parsing Rules: * - USA / Canada / UK: * - 5 parts: [Venue, Venue-Detail, City, State, Country] * - 4 parts: [Venue, City, State, Country] (Venue-Detail empty) * - Default: Right-to-Left fallback (Country, City, Venue, Venue-Detail) * Additional-Info: if the last location segment contains '; extra text', * the country is split at the first ';' โ€” the trimmed left part goes into * Event-Country, the trimmed right part into Event-Additional-Info. */ eventParts(sourceCell) { const tds = Array.from({ length: 9 }, () => document.createElement('td')); if (!sourceCell) return tds; // Normalise Unicode NON-BREAKING HYPHEN (U+2010) โ†’ ASCII '-' const raw = sourceCell.textContent.trim().replace(/\u2010/g, '-'); if (!raw) return tds; const colonIdx = raw.indexOf(': '); const prePart = colonIdx !== -1 ? raw.slice(0, colonIdx).trim() : raw; const postPart = colonIdx !== -1 ? raw.slice(colonIdx + 2).trim() : ''; // โ”€โ”€ Pre-colon extraction (Type, Date, Detail) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const preParts = prePart.split(', ').map(s => s.trim()).filter(s => s.length > 0); if (preParts.length > 0) tds[0].textContent = preParts[0]; // Event-Type const DATE_RE = /^\d{4}(?:-\d{2}(?:-\d{2})?)?$/; if (preParts.length > 1) { if (DATE_RE.test(preParts[1])) { tds[1].textContent = preParts[1]; // Event-Date if (preParts.length > 2) tds[2].textContent = preParts[2]; // Event-Detail } else { tds[2].textContent = preParts[1]; // Event-Detail } } // โ”€โ”€ Post-colon (Location) extraction โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if (postPart) { const loc = postPart.split(', ').map(s => s.trim()).filter(s => s.length > 0); const n = loc.length; const countryRaw = n >= 1 ? loc[n - 1] : ''; const _semiIdx = countryRaw.indexOf(';'); const country = _semiIdx !== -1 ? countryRaw.slice(0, _semiIdx).trim() : countryRaw; const additionalInfo = _semiIdx !== -1 ? countryRaw.slice(_semiIdx + 1).trim() : ''; if (country === 'USA' || country === 'Canada' || country === 'UK') { tds[7].textContent = country; // Country if (n >= 2) tds[6].textContent = loc[n - 2]; // State if (n >= 3) tds[5].textContent = loc[n - 3]; // City if (n === 4) { tds[3].textContent = loc[0]; // Event-Venue // tds[4] (Event-Venue-Detail) remains empty } else if (n >= 5) { tds[3].textContent = loc[0]; // Event-Venue tds[4].textContent = loc[1]; // Event-Venue-Detail } } else { // Default Right-to-Left fallback if (n >= 1) tds[7].textContent = country; // Country (stripped of '; โ€ฆ' suffix) if (n >= 2) tds[5].textContent = loc[n - 2]; // City if (n >= 3) tds[3].textContent = loc[n - 3]; // Event-Venue if (n >= 4) tds[4].textContent = loc[n - 4]; // Event-Venue-Detail } if (additionalInfo) tds[8].textContent = additionalInfo; // Event-Additional-Info } return tds; }, /** * splitCountryDate โ€” splits a "Release events" cell (populated by * initReleaseEventsColumn / _rePopulateCell) into separate "Release country" * and "Release date" cells. * * Source structure: a whose content is a
                    where * each
                  • encodes one release event as plain text in one of two formats: * * With country: "XX YYYY-MM-DD" (country code + two spaces + ISO date) * Without country: "YYYY-MM-DD" (date only, no country prefix) * * The two-space separator is produced by _rePopulateCell: * li.textContent = `${codes[0]} ${ev.date || '\u00a0'}`; * * For multi-event cells each
                  • produces one parallel
                  • in the two * output cells, preserving row alignment โ€” the same approach used by * ColumnDataExtractor.splitCountryDate for native Country/Date columns. * A single-event cell is also wrapped in
                    • for structural consistency. * * When the source cell is empty or has no
                        content, both output cells * are returned empty. * * Synthetic columns: ['Release country', 'Release date'] * * @param {HTMLTableCellElement} sourceCell mb-re-cell populated by initReleaseEventsColumn. * @returns {HTMLTableCellElement[]} Two synthetic elements: [Release country, Release date]. */ splitCountryDate(sourceCell) { const tdCountry = document.createElement('td'); const tdDate = document.createElement('td'); if (!sourceCell) return [tdCountry, tdDate]; const liItems = Array.from(sourceCell.querySelectorAll('ul > li')); if (!liItems.length) return [tdCountry, tdDate]; // Two-space separator used by _rePopulateCell between country code and date. // Each
                      • is stamped with: classList='flag flag-XX', title='Name (XX)', // textContent='XX YYYY-MM-DD' (or 'ย ' when no date is known). const SEP = ' '; const ulCountry = document.createElement('ul'); const ulDate = document.createElement('ul'); ulCountry.style.cssText = 'list-style:none;margin:0;padding:0;'; ulDate.style.cssText = 'list-style:none;margin:0;padding:0;'; liItems.forEach(li => { const raw = li.textContent.trim(); // โ”€โ”€ Extract date portion from textContent โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Split on the two-space separator; accept the right-hand side as // the date only when the left-hand side is a 2-letter country code. let date = ''; const sepIdx = raw.indexOf(SEP); if (sepIdx !== -1) { const prefix = raw.slice(0, sepIdx).trim(); const suffix = raw.slice(sepIdx + SEP.length).trim(); date = /^[A-Z]{2}$/.test(prefix) ? suffix : raw; } else { date = raw; } // โ”€โ”€ Build country cell content from flag class + title attr โ”€โ”€โ”€ // _rePopulateCell stamps li.classList = 'flag flag-XX' and // li.title = 'United States (US)'. Use the title as visible text // so the full country name (not just the 2-letter code) is shown. const liC = document.createElement('li'); const flagClass = Array.from(li.classList).find(c => c.startsWith('flag-')); if (flagClass) { const fullTitle = li.title || flagClass.replace('flag-', ''); const span = document.createElement('span'); span.classList.add('flag', flagClass); span.title = fullTitle; span.textContent = fullTitle; liC.appendChild(span); } // No flag class โ†’ country cell stays empty (event has no area data). // โ”€โ”€ Build date cell โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const liD = document.createElement('li'); // Suppress the non-breaking space placeholder so sort/filter treat // the date cell as empty rather than as a non-empty string. liD.textContent = (date === '\u00a0' || date === '') ? '' : date; ulCountry.appendChild(liC); ulDate.appendChild(liD); }); if (ulCountry.hasChildNodes()) tdCountry.appendChild(ulCountry); if (ulDate.hasChildNodes()) tdDate.appendChild(ulDate); return [tdCountry, tdDate]; } }; /** * Derives the runtime extractor descriptor list from a merged activeDefinition object. * * Canonical form: features.columnExtractors is an array of descriptor objects: * { sourceColumn: string, extractor: string, syntheticColumns: string[] } * * Legacy form: features.splitCD / splitLocation / splitArea boolean flags are * automatically translated so any page definitions not yet migrated keep working. * * Each returned descriptor gains a `colIdx` property initialised to -1; the actual * column index is filled in per-page during the header-scanning pass inside the fetch loop. * * @param {object} def - Merged activeDefinition (the resolved page definition object) * @returns {Array<{sourceColumn: string, extractor: string, syntheticColumns: string[], colIdx: number}>} */ function buildActiveColumnExtractors(def) { const features = def?.features || {}; const result = []; // โ”€โ”€ Canonical columnExtractors declarations (preferred form) โ”€โ”€ if (Array.isArray(features.columnExtractors)) { features.columnExtractors.forEach(entry => result.push({ ...entry, colIdx: -1 })); } // โ”€โ”€ Legacy boolean-flag translations โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Only add the translated entry when not already covered by an explicit // columnExtractors entry targeting the same source column. if (features.splitCD && !result.some(e => e.sourceColumn === 'Country/Date')) { result.push({ sourceColumn: 'Country/Date', extractor: 'splitCountryDate', syntheticColumns: ['Country', 'Date'], colIdx: -1 }); } if (features.splitLocation && !result.some(e => e.sourceColumn === 'Location')) { result.push({ sourceColumn: 'Location', extractor: 'splitLocation', syntheticColumns: ['Place', 'Area', 'Country'], colIdx: -1 }); } if (features.splitArea && !result.some(e => e.sourceColumn === 'Area')) { result.push({ sourceColumn: 'Area', extractor: 'splitArea', syntheticColumns: ['MB-Area', 'Country'], colIdx: -1 }); } return result; } /** * Derives the runtime synthetic-column extractor descriptor list from a merged * activeDefinition object. * * Synthetic column extractors run in a second pass, after all primary * columnExtractors have produced their output cells. Each descriptor's * `sourceColumn` names one of those output synthetic columns; the extractor * function receives the corresponding and returns further synthetic s. * * Unlike primary column extractors, no `colIdx` is maintained โ€” the source cell * is located by column name from a per-row map built from primary extractor * outputs, so no header-scanning step is required for this registry. * * @param {object} def - Merged activeDefinition (the resolved page definition object) * @returns {Array<{sourceColumn: string, extractor: string, syntheticColumns: string[]}>} */ function buildActiveSyntheticColumnExtractors(def) { const features = def?.features || {}; if (!Array.isArray(features.syntheticColumnExtractors)) return []; // Shallow-clone each entry so mutations at call-sites do not affect pageDefinitions. // When sa_enable_event_parts_extractor is disabled, strip any 'eventParts' entries // so the synthetic Event-* columns are not created at all. return features.syntheticColumnExtractors .filter(entry => { if (entry.extractor === 'eventParts' && !Lib.settings.sa_enable_event_parts_extractor) { Lib.debug('init', 'buildActiveSyntheticColumnExtractors: eventParts extractor suppressed' + ' (sa_enable_event_parts_extractor = false)'); return false; } return true; }) .map(entry => ({ ...entry })); } /** * Derives the runtime injected-column extractor descriptor list from a merged * activeDefinition object. * * Injected-column extractors run in a third pass, AFTER injected columns * (e.g. 'Release events') have been populated asynchronously by * initReleaseEventsColumn(). Each descriptor's `sourceColumn` names one of * those populated injected columns; the extractor function receives the * corresponding and returns further synthetic s (e.g. splitting * 'Release events' into 'Release country' + 'Release date'). * * Chaining is supported: a later descriptor may name a synthetic column * produced by an earlier descriptor in the same list (e.g. 'Release date' * produced by splitCountryDate can feed a subsequent dateParts extractor). * * Unlike primary column extractors, no `colIdx` is maintained โ€” source cells * are located by column name from a per-row map built during applyInjectedColumnExtractors(). * * @param {object} def - Merged activeDefinition (the resolved page definition object) * @returns {Array<{sourceColumn: string, extractor: string, syntheticColumns: string[]}>} */ function buildActiveInjectedColumnExtractors(def) { const features = def?.features || {}; if (!Array.isArray(features.injectedColumnExtractors)) return []; // Shallow-clone each entry so mutations at call-sites do not affect pageDefinitions. return features.injectedColumnExtractors.map(entry => ({ ...entry })); } /** * Builds the activeReleaseEventColumns list from the page definition. * Returns a non-empty array when the definition declares 'Release events' * in its injectedColumns feature and the setting is enabled. * @param {Object} def activeDefinition * @returns {{ colName: string }[]} */ function buildActiveReleaseEventColumns(def) { if (!Lib.settings.sa_enable_release_events_column) return []; const cols = def?.features?.injectedColumns; if (!Array.isArray(cols)) return []; return cols .filter(c => c === 'Release events') .map(colName => ({ colName })); } /** * Derives the runtime injected-column descriptor list from activeDefinition. * Reads features.injectedColumns (array of column name strings) and resolves * entityType + WS2 inc options from pageType. * Returns [] when sa_enable_relationships_column is false. * @param {Object} def * @returns {Array<{colName:string,entityType:string,incOptions:string[]}>} */ /** * Derives the runtime injected-column descriptor list from a merged * activeDefinition object. * * Maps the pageType to the correct WS2 entity-type path segment and the * corresponding `inc` options required by the MusicBrainz WS2 API: * * artist-releasegroups โ†’ entity 'release-group', inc: url-rels + release-group-rels * artist-works โ†’ entity 'work', inc: url-rels + artist-rels * all others โ†’ entity 'release', inc: url-rels * * Only columns named 'Relationships' are currently supported. * * @param {object} def - Merged activeDefinition (the resolved page definition object). * @returns {Array<{colName: string, entityType: string, incOptions: string[]}>} */ function buildActiveInjectedColumns(def) { const cols = def?.features?.injectedColumns; if (!Array.isArray(cols) || cols.length === 0) return []; if (!Lib.settings.sa_enable_relationships_column) return []; const _pType = def.type || ''; let _et, _inc; if (_pType === 'artist-releasegroups') { _et = 'release-group'; _inc = ['url-rels', 'release-group-rels']; } else if (_pType === 'artist-works') { _et = 'work'; _inc = ['url-rels', 'artist-rels']; } else { _et = 'release'; _inc = ['url-rels']; } return cols .filter(colName => colName === 'Relationships') .map(colName => ({ colName, entityType: _et, incOptions: _inc })); } /** * Derives the runtime eraser descriptor list from a merged activeDefinition object. * * Each descriptor in features.columnErasers has the shape: * { sourceColumn: string, erasers: string[] } * * Supported eraser symbols: * 'โ–ถ' โ€” removes โ–ถ\n * 'โž•' โ€” removes โž•\n * 'jesus2099' โ€” removes the wrapping anchor that contains a * (cover-art icon injected * by the jesus2099 "mb. SUPER MIND CONTROL" userscript); detection is * class-based and independent of the span's style attribute or href value * * Each returned descriptor gains a `colIdx` property initialised to -1; the actual * column index is filled in per-page during the header-scanning pass inside the fetch loop. * * @param {object} def - Merged activeDefinition (the resolved page definition object) * @returns {Array<{sourceColumn: string, erasers: string[], colIdx: number}>} */ function buildActiveColumnErasers(def) { const features = def?.features || {}; if (!Array.isArray(features.columnErasers)) return []; return features.columnErasers.map(entry => ({ ...entry, colIdx: -1 })); } /** * Derives the runtime column-header-eraser token list from a merged * activeDefinition object. * * `columnHeaderErasers` is a top-level (not inside `features`) array of * eraser tokens on the page definition, e.g. `['โ–ด/โ–พ']`. It is placed at * the definition root rather than inside `features` because it describes a * structural DOM transformation that runs before any column-feature logic, * not a per-column data transformation. * * Currently supported token: * 'โ–ด/โ–พ' โ€” Strip sort-link wrappers from every `` in the reference * table's `` before the header-scanning pass. See * `applyColumnHeaderErasers()` for the exact transformation. * * @param {object} def - Merged activeDefinition object. * @returns {string[]} Array of eraser tokens (may be empty). */ function buildActiveColumnHeaderErasers(def) { if (!Array.isArray(def?.columnHeaderErasers)) return []; return [...def.columnHeaderErasers]; } /** * Cleans sort-link wrappers from every `` in the reference table's * `` so that the subsequent header-scanning pass reads plain column * names instead of link+arrow markup. * * Called only when the active page definition carries the `'โ–ด/โ–พ'` eraser * token in `columnHeaderErasers` (e.g. the 'collections-releases' pageType). * * Transformation rules applied to each ``: * * Single-link header โ€” one `` child: * Before: Release โ–ด/โ–พ * After: Release * Rule: Set th.textContent to the trimmed text of the first text node * inside the `` (stripping the arrow ``). * * Multi-link header โ€” several `` children interleaved with text nodes: * Before: Country โ–ด/โ–พ/ * Date โ–ด/โ–พ * After: Country/Date * Rule: Walk all child nodes of the ``. For each `` child, * collect the first text node inside it (trimmed). For each * TEXT_NODE child of the `` itself, collect its trimmed * content. Concatenate all collected parts without extra spaces. * * Header with no `` children โ€” left untouched. * * The mutation is performed on the live `referenceTable` DOM so that the * immediately following `referenceTable.querySelectorAll('thead th')` loop * reads the cleaned text via `th.textContent`. * * @param {HTMLTableElement} referenceTable - The first `table.tbl` on the page. * @param {string[]} eraserTokens - Active token list from * `buildActiveColumnHeaderErasers()`. */ function applyColumnHeaderErasers(referenceTable, eraserTokens) { if (!eraserTokens.includes('โ–ด/โ–พ')) return; referenceTable.querySelectorAll('thead th').forEach(th => { const _links = Array.from(th.querySelectorAll(':scope > a')); if (_links.length === 0) return; // no link โ€” leave as-is if (_links.length === 1) { // Single-link: extract first text node inside the const _a = _links[0]; let _text = ''; for (const _child of _a.childNodes) { if (_child.nodeType === Node.TEXT_NODE) { const _t = _child.textContent.trim(); if (_t) { _text = _t; break; } } } th.textContent = _text; } else { // Multi-link: interleave link-text with literal text nodes in const _parts = []; for (const _child of th.childNodes) { if (_child.nodeType === Node.ELEMENT_NODE && _child.tagName === 'A') { // Extract first text node inside this for (const _inner of _child.childNodes) { if (_inner.nodeType === Node.TEXT_NODE) { const _t = _inner.textContent.trim(); if (_t) { _parts.push(_t); break; } } } } else if (_child.nodeType === Node.TEXT_NODE) { const _t = _child.textContent.trim(); if (_t) _parts.push(_t); } } th.textContent = _parts.join(''); } Lib.debug('parse', `applyColumnHeaderErasers: cleaned โ†’ "${th.textContent}"`); }); } /** * Removes configured marker elements from a data row's cells in-place. * * For each eraser descriptor whose column index has been resolved (`colIdx !== -1`), * the target cell is cleaned according to two distinct strategies, each driven by the * contents of `entry.erasers`: * * 1. **Text-content erasure** (symbols โ–ถ, โž•, โ€ฆ): * All descendants of the cell are inspected; a span is removed when its * trimmed text content matches one of the non-sentinel eraser symbols. * Detection is text-content-based (style-attribute-independent), so future * MusicBrainz style changes do not break erasure as long as the symbol glyph is stable. * * 2. **jesus2099 caa-icon anchor erasure** (sentinel string `'jesus2099'`): * All descendants of the cell are inspected; an anchor is removed when it * contains at least one child . * This covers both the background-image variant and the plain variant of the * cover-art icon injected by the jesus2099 "mb. SUPER MIND CONTROL" userscript. * * Must be called BEFORE active column extractors run on the same row so that extractor * output is based on the already-cleaned cell content. * * @param {HTMLTableRowElement} row - The imported data row to mutate in-place. * @param {Array<{sourceColumn: string, erasers: string[], colIdx: number}>} eraserDescriptors * - Runtime eraser list built by buildActiveColumnErasers(), with colIdx already resolved. */ function applyColumnErasers(row, eraserDescriptors) { if (!eraserDescriptors.length) return; for (const entry of eraserDescriptors) { if (entry.colIdx === -1) { Lib.warn('extract', `columnEraser: sourceColumn "${entry.sourceColumn}" was not found in table headers โ€” erasers [${entry.erasers.join(', ')}] skipped`); continue; } const cell = row.cells[entry.colIdx]; if (!cell) { Lib.warn('extract', `columnEraser: colIdx ${entry.colIdx} out of range for sourceColumn "${entry.sourceColumn}" (row has ${row.cells.length} cells)`); continue; } // Partition erasers into glyph-symbol erasers and the named sentinels const textErasers = entry.erasers.filter(e => e !== 'jesus2099' && e !== 'wiencek'); const eraseJesus2099 = entry.erasers.includes('jesus2099'); const eraseWiencek = entry.erasers.includes('wiencek'); let removedCount = 0; // Strategy 1: text-content-based span erasure (โ–ถ, โž•, โ€ฆ) if (textErasers.length) { cell.querySelectorAll('span').forEach(span => { const symbol = span.textContent.trim(); if (textErasers.includes(symbol)) { span.remove(); removedCount++; Lib.debug('extract', `columnEraser: removed span "${symbol}" from column "${entry.sourceColumn}" (colIdx=${entry.colIdx})`); } }); } // Strategy 2: jesus2099 caa-icon anchor erasure // Removes the full wrapper that contains // a (with or without // a background-image style attribute set by the jesus2099 userscript). if (eraseJesus2099) { cell.querySelectorAll('a').forEach(anchor => { if (anchor.querySelector('span.caa-icon.jesus2099userjs154481')) { anchor.remove(); removedCount++; Lib.debug('extract', `columnEraser: removed jesus2099 caa-icon anchor from column "${entry.sourceColumn}" (colIdx=${entry.colIdx})`); } }); } // Strategy 3: wiencek suggested-work / work div erasure // Removes injected
                        and
                        containers // added by Michael Wiencek's "MusicBrainz: Expand/collapse release groups" // userscript on Artist-Recordings pages. These divs carry inline style attributes // and hold "Suggested work:" hints or "live recording of" relationship text that // clutters the Name cell in the consolidated table. // // Matched containers: //
                        โ€ฆ
                        โ€” orange "Looking for matching workโ€ฆ" / green "Suggested work: โ€ฆ" hint //
                        โ€ฆ
                        โ€” "live recording of
                        โ€ฆ" relationship line if (eraseWiencek) { cell.querySelectorAll('div.suggested-work, div.work').forEach(div => { div.remove(); removedCount++; Lib.debug('extract', `columnEraser: removed wiencek div.${div.className.trim().split(/\s+/)[0]} from column "${entry.sourceColumn}" (colIdx=${entry.colIdx})`); }); } if (removedCount === 0) { Lib.debug('extract', `columnEraser: no matching elements found in column "${entry.sourceColumn}" (colIdx=${entry.colIdx}), erasers=[${entry.erasers.join(', ')}]`); } } } /** * Derives the runtime render-multi-row-cell descriptor list from a merged * activeDefinition object. * * `features.renderMultiRowCell` is an array of column-header name strings * (e.g. ['Label', 'Catalog#']). For each named column the pipeline will * convert its comma-separated cell content into a
                        • multi-row * structure so that entries are visually stacked rather than written in a * single line. * * Each returned descriptor gains a `colIdx` property initialised to -1; * the actual column index is filled in per-page during the header-scanning * pass inside the fetch loop. * * @param {object} def - Merged activeDefinition (the resolved page definition object) * @returns {Array<{columnName: string, colIdx: number}>} */ function buildActiveRenderMultiRowCols(def) { const features = def?.features || {}; if (!Array.isArray(features.renderMultiRowCell)) return []; return features.renderMultiRowCell.map(columnName => ({ columnName, colIdx: -1 })); } /** * Converts `
                            `-based tag/genre lists into a proper `` * so the standard fetch/filter/sort pipeline can process them. * * This function is called as a DOM pre-processing step for pageTypes that * carry a `features.listToTable` array (e.g. 'tags', 'artist-tags'). * * Three DOM structures are supported, detected automatically for each * `sectionId` entry in the `listToTable` array: * * โ”€โ”€ Structure A: div-wrapped (user tags pages /user//tags) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ * * Before: *

                            Genres

                            *
                            *
                              *
                            • * rock * 15 *
                            • โ€ฆ *
                            *
                            * * After: *

                            Genres

                            *
                            โ€ฆ
                            * * Detection : `document.getElementById(sectionId)` succeeds. * Replacement: the entire `
                            ` is replaced by the bare ``. * Column name: derived from the `
                              ` class attribute (see below). * * โ”€โ”€ Structure B: bare ul (entity tags pages /entity//tags) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ * * Before: *

                              Genres

                              *
                                *
                              • * rock * 15 *
                              • โ€ฆ *
                              * * After: *

                              Genres

                              *
                            โ€ฆ
                            * * Detection : no `
                            ` found; fall back to scanning for * `
                              ` elements whose class contains `-list` * (e.g. sectionId "genres" โ†’ look for class "genre-list"). * Replacement: the `
                                ` itself is replaced by the bare ``. * Column name: derived from the `
                                  ` class attribute (see below). * * โ”€โ”€ Structure C: h2+ul (/area//users, /tag//, * /user/.../tag//, /subscribers) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ * * Triggered when `sectionId === ''` AND the current page path matches one of: * - `/area//users` (pageType 'area-users') * - `/tag//` (pageType 'tag-value-entity') * - `/user/.../tag//` (pageType 'user-tag-value-entity') * - `/subscribers` (pageType 'editor-subscribers') * For all other pageTypes with `sectionId === ''`, Structure D is used. * * area-users variant: * Before: *

                                  Users

                                  *

                                  โ€ฆ

                                  * * After: *

                                  Users

                                  *
                                โ€ฆ
                                * Column name: full h2 text (e.g. "Users"). * * user-subscribers variant: * Before: *

                                Subscribers

                                *

                                โ€ฆ

                                * * After: *

                                Subscribers

                                * โ€ฆ
                                * Column name: full h2 text (e.g. "Subscribers"). * * tag-value-entity variant: * Before: *

                                Labels tagged as "country"

                                *

                                โ€ฆ

                                * * After: *

                                Labels tagged as "country"

                                * โ€ฆ
                                * Column name: first word of the h2 text content before "tagged" * (e.g. "Labels tagged as โ€ฆ" โ†’ "Labels"). * * Detection : sectionId is '' AND path matches /area//users OR * /tag//. * Scans `div#content` (or body) for every `

                                ` and walks * its next element siblings (up to 5 steps) until a `
                                  `. * Table layout: single column (full `
                                • ` inner content cloned). * Replacement: the `
                                    ` itself is replaced by the bare ``. * * โ”€โ”€ Structure D: h2 + h3+ul sections (tag value pages) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ * * Triggered when `sectionId === ''` AND the page path contains `/tag/` * (i.e. pageType is 'user-tag-value' or 'tag-value'). * * Before: *

                                    Entities tagged as "handwritten"

                                    *

                                    โ€ฆ

                                    *

                                    Areas

                                    * *

                                    Artists

                                    *
                                      โ€ฆ
                                    * * After: *

                                    Entities tagged as "handwritten"

                                    *

                                    Areas

                                    *
                                    โ€ฆ
                                    *

                                    Artists

                                    * โ€ฆ
                                    * * Detection : sectionId is '' AND path contains /tag/. * Finds every `

                                    ` inside `div#content` and walks its * next element siblings (up to 5 steps) until a `
                                      `. * Column name: text content of the preceding `

                                      ` (e.g. "Areas"). * Table layout: single column (the full `
                                    • ` inner content). * Replacement: the `
                                        ` itself is replaced by the bare ``. * * โ”€โ”€ Column-name derivation (Structures A and B) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ * The first column name is extracted from the `
                                          ` class attribute: * the substring before the literal "-list" suffix is capitalised. * e.g. `
                                            ` โ†’ "Genre" * `
                                              ` โ†’ "Tag" * The second column is always "Tag count". * * @param {object} def - The active merged pageDefinition object. * @param {Document} [docContext=document] - DOM document to operate on. * Pass a fetched DOMParser document to apply * the conversion to remote pages in the fetch loop. */ /** * Converts a plural MusicBrainz entity-type label to its singular form. * Used by applyListToTable (Structure D column names) and renderGroupedTable * (first-column header patching for tag-value multi-table mode). * * Rules: * "Series" โ†’ "Series" (already singular; explicit exception) * "โ€ฆ groups" โ†’ "โ€ฆ group" (e.g. "Release groups" โ†’ "Release group") * "โ€ฆ events" โ†’ "โ€ฆ event" (e.g. "Release events" โ†’ "Release event") * everything else โ†’ strip trailing "s" (e.g. "Artists" โ†’ "Artist") * * @param {string} plural * @returns {string} */ function _toSingular(plural) { if (plural === 'Series') return 'Series'; if (/ groups?$/i.test(plural)) return plural.replace(/ groups?$/i, ' group'); if (/ events?$/i.test(plural)) return plural.replace(/ events?$/i, ' event'); return plural.replace(/s$/i, ''); } /** * Converts `
                                                ` list elements on the live page (or a fetched document) into * `
                                        ` elements so the standard fetch / filter / sort pipeline * can process them. Called for every entry in `features.listToTable`. * * Supports seven distinct DOM structures, selected by path and sectionId: * A) div-wrapped โ€” `
                                          โ€ฆ
                                        ` * B) bare-ul โ€” `
                                          โ€ฆ
                                        ` * C) h2+ul โ€” area-users, tag-value-entity, user-tag-value-entity, subscribers * D) h3+ul โ€” one-segment tag pages (/tag/VALUE, /user/USERNAME/tag/VALUE) * E) h3+ul (popular-tags) โ€” /tags page after renameH2ToH3 has renamed the * original h2 headings to h3 * F) h3+ul (artist-credit) โ€” /artist-credit/ overview page; converts the * h3+ul pairs under

                                        Uses

                                        into one-column * tables (singular h3 text as column header). * The
                                          is not touched. * G) bare-ul (artist-credit-entity) โ€” /artist-credit// sub-page; * a plain
                                            without id or class is converted to * a one-column table whose header is derived from * the URL last path segment (e.g. "release-group" * โ†’ "Release group"). * * Structures A and B produce two-column tables (Name | Tag count). * Structures C, D, E, F, and G produce single-column tables whose header is the * singular form of the section heading (via `_toSingular()` or URL derivation). * * Must run AFTER `applyRenameH2ToH3` / `applyInsertH2` and BEFORE maxPage * determination so that `parseDocumentForTables` finds the resulting * `
                                        ` elements. * * @param {object} def - The active merged pageDefinition object. * @param {Document|Element} [docContext=document] - DOM context to search; defaults * to the live document. Pass a fetched document for paginated XHR pages. */ function applyListToTable(def, docContext = document) { const sections = def?.features?.listToTable; if (!Array.isArray(sections) || sections.length === 0) return; // Derive the page path once for Structure C/D detection. const _currentPath = window.location.pathname; sections.forEach(sectionId => { if (sectionId === '') { const _root = docContext.getElementById('content') || docContext.body; // Classify the path once for all Structure C/D branches. // Order matters: two-segment /tag// must be tested // BEFORE one-segment /tag/ because the latter matches both. const _isAreaUsers = _currentPath.match(/\/area\/[a-f0-9-]{36}\/users/); const _isTagValueEntity = _currentPath.match(/\/tag\/[^/]+\/.+/); // /tag// (two-segment) const _isUserTagValueEntity = _currentPath.match(/\/user\/[^/]+\/tag\/[^/]+\/.+/); // /user/.../tag// const _isTagValue = !_isTagValueEntity && _currentPath.match(/\/tag\//); // one-segment only const _isSubscribers = _currentPath.includes('/subscribers'); const _isEntityCollections = _currentPath.match(/\/(?:release|release-group)\/[a-f0-9-]{36}\/collections/); // โ”€โ”€ Structure C: h2+ul (/area//users, /tag//, // /user/.../tag//, /subscribers, // /release|release-group//collections) โ”€ if (_isAreaUsers || _isTagValueEntity || _isUserTagValueEntity || _isSubscribers || _isEntityCollections) { Array.from(_root.querySelectorAll('h2')).forEach(_h2 => { let _next = _h2.nextElementSibling; let _steps = 0; let _ul = null; while (_next && _steps < 5) { if (_next.tagName === 'UL') { _ul = _next; break; } _next = _next.nextElementSibling; _steps++; } if (!_ul) return; // Column name derivation: // area-users: full h2 text (e.g. "Users") // subscribers: singular of h2 text (e.g. "Subscribers" โ†’ "Subscriber") // tag-value-entity: first word before "tagged" in the h2 text // e.g. "Labels tagged as ยซcountryยป" โ†’ "Labels" const _h2Text = _h2.textContent.trim(); let _colName; if (_isUserTagValueEntity) { // e.g. "Release groups vzell tagged as ยซgigยป" // โ†’ take everything before "tagged", strip the trailing // username, then singularise: "Release groups" โ†’ "Release group". const _beforeTagged = _h2Text.split(/\s+tagged\b/i)[0].trim(); const _words = _beforeTagged.split(/\s+/); const _plural = (_words.length > 1 ? _words.slice(0, -1) : _words).join(' ') || _h2Text; _colName = _toSingular(_plural); } else if (_isTagValueEntity) { // e.g. "Release groups tagged as ยซgigยป" // โ†’ everything before "tagged", then singularise: "Release groups" โ†’ "Release group". const _beforeTagged = _h2Text.split(/\s+tagged\b/i)[0].trim(); _colName = _toSingular(_beforeTagged || _h2Text); } else if (_isSubscribers) { // "Subscribers" โ†’ "Subscriber" _colName = _toSingular(_h2Text); } else { _colName = _h2Text; } const _table = docContext.createElement('table'); _table.className = 'tbl'; // thead โ€” single column const _thead = docContext.createElement('thead'); const _hr = docContext.createElement('tr'); const _th = docContext.createElement('th'); _th.textContent = _colName; _hr.appendChild(_th); _thead.appendChild(_hr); _table.appendChild(_thead); // tbody โ€” one row per
                                      • , single
                                      • with full li content const _tbody = docContext.createElement('tbody'); Array.from(_ul.querySelectorAll(':scope > li')).forEach(li => { const _tr = docContext.createElement('tr'); if (li.className) _tr.className = li.className; const _td = docContext.createElement('td'); Array.from(li.childNodes).forEach(n => _td.appendChild(n.cloneNode(true))); _tr.appendChild(_td); _tbody.appendChild(_tr); }); _table.appendChild(_tbody); _ul.parentNode.replaceChild(_table, _ul); Lib.debug('init', `applyListToTable: converted h2="${_colName}" ul โ†’ table` + ` (${_tbody.rows.length} rows, structure="h2-ul").`); }); return; // Structure C handled all h2+ul pairs for this entry } // โ”€โ”€ Structure D: h3+ul sections on one-segment tag pages โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Triggered when path is /tag/ (no entity segment) โ€” // pageTypes 'user-tag-value' and 'tag-value'. // Walks every

                                        and converts its sibling
                                          into a table. // Column name: singular form of the h3 text (e.g. "Release groups" โ†’ "Release group"). // Exception: "Series" stays "Series" (already singular). if (_isTagValue) { Array.from(_root.querySelectorAll('h3')).forEach(_h3 => { let _next = _h3.nextElementSibling; let _steps = 0; let _ul = null; while (_next && _steps < 5) { if (_next.tagName === 'UL') { _ul = _next; break; } _next = _next.nextElementSibling; _steps++; } if (!_ul) return; const _h3Text = _h3.textContent.trim(); const _colName = _toSingular(_h3Text); const _table = docContext.createElement('table'); _table.className = 'tbl'; // Store the per-group column name so renderGroupedTable can // patch the cloned templateHead for each group independently. _table.dataset.mbOriginalColName = _colName; const _thead = docContext.createElement('thead'); const _hr = docContext.createElement('tr'); const _th = docContext.createElement('th'); _th.textContent = _colName; _hr.appendChild(_th); _thead.appendChild(_hr); _table.appendChild(_thead); const _tbody = docContext.createElement('tbody'); Array.from(_ul.querySelectorAll(':scope > li')).forEach(li => { const _tr = docContext.createElement('tr'); if (li.className) _tr.className = li.className; const _td = docContext.createElement('td'); // Clone the full li content (may have span.flag + a) Array.from(li.childNodes).forEach(n => _td.appendChild(n.cloneNode(true))); _tr.appendChild(_td); _tbody.appendChild(_tr); }); _table.appendChild(_tbody); _ul.parentNode.replaceChild(_table, _ul); Lib.debug('init', `applyListToTable: converted h3="${_h3Text}" ul โ†’ table` + ` (${_tbody.rows.length} rows, col="${_colName}", structure="h3-ul").`); }); return; // Structure D handled all h3+ul pairs for this entry } // โ”€โ”€ Structure E: popular-tags /tags โ€” h3+ul pairs (after renameH2ToH3) โ”€โ”€ // Triggered for the /tags page (pageType 'popular-tags'). // renameH2ToH3 has already demoted the original

                                          section headings // ("Genres", "Other Tags") to

                                          . Each

                                          is paired with the next // sibling
                                            and converted to a 2-column // (singular h3 text | Tag count), matching the // Structure A/B layout used by user-tags pages. const _isPopularTags = _currentPath.match(/^\/tags/); if (_isPopularTags) { // Walk both h3 (live DOM, after renameH2ToH3) and h2 (fetched docs, not yet renamed). Array.from(_root.querySelectorAll('h3, h2')).forEach(_h3 => { let _next = _h3.nextElementSibling, _steps = 0, _ul = null; while (_next && _steps < 5) { if (_next.tagName === 'UL') { _ul = _next; break; } _next = _next.nextElementSibling; _steps++; } if (!_ul) return; const _h3Text = _h3.textContent.trim(); const _colName = _toSingular(_h3Text); const _table = docContext.createElement('table'); _table.className = 'tbl'; _table.dataset.mbOriginalColName = _colName; const _thead = docContext.createElement('thead'); const _hr = docContext.createElement('tr'); [_colName, 'Tag count'].forEach(label => { const _th = docContext.createElement('th'); _th.textContent = label; _hr.appendChild(_th); }); _thead.appendChild(_hr); _table.appendChild(_thead); const _tbody = docContext.createElement('tbody'); Array.from(_ul.querySelectorAll(':scope > li')).forEach(li => { const _tr = docContext.createElement('tr'); if (li.className) _tr.className = li.className; const _td1 = docContext.createElement('td'); const _a = li.querySelector('a'); if (_a) _td1.appendChild(_a.cloneNode(true)); _tr.appendChild(_td1); const _td2 = docContext.createElement('td'); const _votes = li.querySelector('.tag-vote-buttons'); if (_votes) _td2.appendChild(_votes.cloneNode(true)); _tr.appendChild(_td2); _tbody.appendChild(_tr); }); _table.appendChild(_tbody); _ul.parentNode.replaceChild(_table, _ul); Lib.debug('init', `applyListToTable: converted h3="${_h3Text}" ul \u2192 table` + ` (${_tbody.rows.length} rows, col="${_colName}", structure="popular-tags-h3-ul").`); }); return; // Structure E handled all h3+ul pairs for this entry } // โ”€โ”€ Structure H: user-ratings /user//ratings โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Triggered for pageType 'user-ratings' and 'user-ratings-type'. // Both page types share the same

                                            +
                                              DOM structure: // user-ratings โ€” multiple h2+ul pairs (one per entity type); // renameH2ToH3 has already demoted them to

                                              // user-ratings-type โ€” single

                                              +
                                                pair (one entity type, no // renameH2ToH3 needed; Structure H handles both // h2 and h3 via its querySelector) // Each

                                                +
                                                  (or

                                                  +
                                                    ) pair is converted to either: // 2-col table โ€” Rating | // for sections with no "by" in the list (e.g. Artists, Works) // 3-col table โ€” Rating | | Artist // for sections where any
                                                  • has " by" or " by" // (e.g. Recordings, Release groups) // The trailing "View all ratings"
                                                  • (overview page only) is preserved // as the last row so renderGroupedTable can convert it into an overflow // button; entity-specific pages have no such row. // dataset.mbColHeaders (JSON) stores the column names for this group // so renderGroupedTable can build a per-group thead of the correct width. if (pageType === 'user-ratings' || pageType === 'user-ratings-type') { Array.from(_root.querySelectorAll('h3, h2')).forEach(_h3 => { let _next = _h3.nextElementSibling, _steps = 0, _ul = null; while (_next && _steps < 5) { if (_next.tagName === 'UL') { _ul = _next; break; } if (_next.tagName === 'H3' || _next.tagName === 'H2') break; _next = _next.nextElementSibling; _steps++; } if (!_ul) return; const _h3Text = _h3.textContent.trim(); // "Artist ratings" โ†’ "Artist", "Release group ratings" โ†’ "Release group" const _entityType = _h3Text.replace(/\s+ratings$/i, '').trim() || _h3Text; // 3-col when any li html contains " by" or " by" const _has3Cols = /(<\/span>|<\/a>)\s+by\b/i.test(_ul.innerHTML); const _colHeaders = _has3Cols ? ['Rating', _entityType, 'Artist'] : ['Rating', _entityType]; const _table = docContext.createElement('table'); _table.className = 'tbl'; _table.dataset.mbOriginalColName = _entityType; _table.dataset.mbEntityFeaturesKey = _entityType + 's'; _table.dataset.mbColHeaders = JSON.stringify(_colHeaders); // thead const _thead = docContext.createElement('thead'); const _hr = docContext.createElement('tr'); _colHeaders.forEach(label => { const _th = docContext.createElement('th'); _th.textContent = label; _hr.appendChild(_th); }); _thead.appendChild(_hr); _table.appendChild(_thead); // tbody โ€” one row per
                                                  • const _tbody = docContext.createElement('tbody'); Array.from(_ul.querySelectorAll(':scope > li')).forEach(li => { const _tr = docContext.createElement('tr'); if (li.className) _tr.className = li.className; const _ratingSpan = li.querySelector('span.inline-rating'); // "View all ratings" li: no inline-rating, text starts with "View all" const _isViewAll = !_ratingSpan && /^\s*view all ratings/i.test(li.textContent.trim()); if (_isViewAll) { // Put the link in the first cell; pad remaining cells empty const _td0 = docContext.createElement('td'); Array.from(li.childNodes).forEach(n => _td0.appendChild(n.cloneNode(true))); _tr.appendChild(_td0); _colHeaders.slice(1).forEach(() => _tr.appendChild(docContext.createElement('td'))); } else { // Rating cell: numeric value from .current-user-rating const _td0 = docContext.createElement('td'); const _ratingEl = _ratingSpan ? _ratingSpan.querySelector('.current-user-rating') : null; _td0.textContent = _ratingEl ? _ratingEl.textContent.trim() : ''; _tr.appendChild(_td0); // Collect child nodes after the inline-rating span and separator const _afterNodes = []; let _pastRating = !_ratingSpan; let _pastSep = !_ratingSpan; for (const node of li.childNodes) { if (!_pastRating) { if (node === _ratingSpan) _pastRating = true; continue; } if (!_pastSep) { // Skip the " - " separator text node if (node.nodeType === Node.TEXT_NODE) { _pastSep = true; continue; } } _afterNodes.push(node); } if (!_has3Cols) { // 2-column: entity cell gets all remaining content const _td1 = docContext.createElement('td'); _afterNodes.forEach(n => _td1.appendChild(n.cloneNode(true))); _tr.appendChild(_td1); } else { // 3-column: split on the " by " text node const _td1 = docContext.createElement('td'); const _td2 = docContext.createElement('td'); let _inArtist = false; for (const node of _afterNodes) { if (!_inArtist && node.nodeType === Node.TEXT_NODE && /\bby\b/i.test(node.textContent)) { _inArtist = true; continue; // skip the "by" text itself } (_inArtist ? _td2 : _td1).appendChild(node.cloneNode(true)); } _tr.appendChild(_td1); _tr.appendChild(_td2); } } _tbody.appendChild(_tr); }); _table.appendChild(_tbody); _ul.parentNode.replaceChild(_table, _ul); Lib.debug('init', `applyListToTable: ${pageType}: converted h3="${_h3Text}" ul โ†’ table` + ` (${_tbody.rows.length} rows, cols="${_colHeaders.join(' | ')}", structure="user-ratings").`); }); return; // Structure H handled all h3+ul pairs } // โ”€โ”€ Structure I: ratings-entity ///ratings โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Triggered for pageType 'ratings-entity'. // Finds

                                                    Ratings

                                                    and the immediately following

                                            so they appear as a // caption-like block above the data rows. if (pageType === 'ratings-entity') { Array.from(_root.querySelectorAll('h2')).forEach(_h2 => { if (_h2.textContent.trim().toLowerCase() !== 'ratings') return; let _next = _h2.nextElementSibling, _steps = 0, _ul = null; while (_next && _steps < 5) { if (_next.tagName === 'UL') { _ul = _next; break; } if (_next.tagName === 'H2') break; _next = _next.nextElementSibling; _steps++; } if (!_ul) return; // Build 2-column table: Rating | User const _table = docContext.createElement('table'); _table.className = 'tbl'; const _thead = docContext.createElement('thead'); const _hr = docContext.createElement('tr'); ['Rating', 'User'].forEach(col => { const _th = docContext.createElement('th'); _th.textContent = col; _hr.appendChild(_th); }); _thead.appendChild(_hr); _table.appendChild(_thead); const _tbody = docContext.createElement('tbody'); Array.from(_ul.querySelectorAll(':scope > li')).forEach(li => { const _tr = docContext.createElement('tr'); // Rating cell: numeric value from .current-rating const _td0 = docContext.createElement('td'); const _ratingEl = li.querySelector('.current-rating'); _td0.textContent = _ratingEl ? _ratingEl.textContent.trim() : ''; _tr.appendChild(_td0); // User cell: the /user/ link (avatar + bdi username) const _td1 = docContext.createElement('td'); const _userLink = li.querySelector('a[href*="/user/"]'); if (_userLink) _td1.appendChild(_userLink.cloneNode(true)); _tr.appendChild(_td1); _tbody.appendChild(_tr); }); _table.appendChild(_tbody); // Replace
                                              with
                                            _ul.parentNode.replaceChild(_table, _ul); // Move the private-rating notice and average-rating widget (if // present) from after the table to between

                                            and

                                            . // Collect sibling nodes after the table until the next

                                            . // Stop after the first found // (that is the average-rating widget โ€” the last element of the block). const _infoNodes = []; let _cur = _table.nextSibling; while (_cur) { const _nextCur = _cur.nextSibling; if (_cur.nodeType === Node.ELEMENT_NODE && _cur.tagName === 'H2') break; _infoNodes.push(_cur); if (_cur.nodeType === Node.ELEMENT_NODE && _cur.classList && _cur.classList.contains('inline-rating')) { break; // average-rating span โ€” last node of the block } _cur = _nextCur; } _infoNodes.forEach(n => _table.parentNode.insertBefore(n, _table)); Lib.debug('init', `applyListToTable: ${pageType}: converted h2="Ratings" ul โ†’ table ` + `(${_tbody.rows.length} rows, cols="Rating | User", ` + `${_infoNodes.length} info node(s) moved before table).`); }); return; // Structure I handled } // โ”€โ”€ Structure F: artist-credit overview pages โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Triggered for pageType 'artist-credit' (/artist-credit/). // Each

                                            under

                                            Uses

                                            (e.g. "Release groups", "Releases", // "Recordings", "Tracks") is paired with the next sibling
                                              and // converted to a one-column

                                            . // Column name: singular form of the h3 text // (e.g. "Release groups" โ†’ "Release group"). // dataset.mbOriginalColName is set so renderGroupedTable can patch // the cloned templateHead for each group independently. // The
                                              above

                                              Uses

                                              is // intentionally skipped โ€” it is a plain informational list. if (pageType === 'artist-credit') { Array.from(_root.querySelectorAll('h3')).forEach(_h3 => { let _next = _h3.nextElementSibling; let _steps = 0; let _ul = null; while (_next && _steps < 5) { if (_next.tagName === 'UL') { _ul = _next; break; } _next = _next.nextElementSibling; _steps++; } if (!_ul) return; const _h3Text = _h3.textContent.trim(); const _colName = _toSingular(_h3Text); const _table = docContext.createElement('table'); _table.className = 'tbl'; // Store the per-group column name so renderGroupedTable // can patch the cloned templateHead for each group. _table.dataset.mbOriginalColName = _colName; const _thead = docContext.createElement('thead'); const _hr = docContext.createElement('tr'); const _th = docContext.createElement('th'); _th.textContent = _colName; _hr.appendChild(_th); _thead.appendChild(_hr); _table.appendChild(_thead); const _tbody = docContext.createElement('tbody'); Array.from(_ul.querySelectorAll(':scope > li')).forEach(li => { const _tr = docContext.createElement('tr'); if (li.className) _tr.className = li.className; const _td = docContext.createElement('td'); Array.from(li.childNodes).forEach(n => _td.appendChild(n.cloneNode(true))); _tr.appendChild(_td); _tbody.appendChild(_tr); }); _table.appendChild(_tbody); _ul.parentNode.replaceChild(_table, _ul); Lib.debug('init', `applyListToTable: artist-credit: converted h3="${_h3Text}" ul โ†’ table` + ` (${_tbody.rows.length} rows, col="${_colName}",` + ` structure="artist-credit-h3-ul").`); }); return; // Structure F handled all h3+ul pairs for this entry } // โ”€โ”€ Structure G: artist-credit entity sub-pages โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Triggered for pageType 'artist-credit-entity' // (/artist-credit//, e.g. /artist-credit/1488075/recording). // The page may present its full entity list as a plain
                                                without // id or class attribute. The column name is derived from the last // URL path segment with hyphens replaced by spaces and the first // character capitalised (e.g. "release-group" โ†’ "Release group"). if (pageType === 'artist-credit-entity') { const _entitySlug = _currentPath.split('/').filter(Boolean).pop() || ''; const _colName = _entitySlug .replace(/-/g, ' ') .replace(/^\w/, c => c.toUpperCase()); // Find a plain
                                                  that has neither an id nor a class โ€” // the artist-credit-artists list has an id and is on the // overview page, so it cannot appear here. const _ul = Array.from(_root.querySelectorAll('ul')) .find(u => !u.id && !u.className); if (!_ul) { Lib.debug('init', `applyListToTable: artist-credit-entity: no plain
                                                    found` + ` for entity="${_colName}" โ€” skipping (page may use` + ` a native
                                            already).`); return; } const _table = docContext.createElement('table'); _table.className = 'tbl'; const _thead = docContext.createElement('thead'); const _hr = docContext.createElement('tr'); const _th = docContext.createElement('th'); _th.textContent = _colName; _hr.appendChild(_th); _thead.appendChild(_hr); _table.appendChild(_thead); const _tbody = docContext.createElement('tbody'); Array.from(_ul.querySelectorAll(':scope > li')).forEach(li => { const _tr = docContext.createElement('tr'); if (li.className) _tr.className = li.className; const _td = docContext.createElement('td'); Array.from(li.childNodes).forEach(n => _td.appendChild(n.cloneNode(true))); _tr.appendChild(_td); _tbody.appendChild(_tr); }); _table.appendChild(_tbody); _ul.parentNode.replaceChild(_table, _ul); Lib.debug('init', `applyListToTable: artist-credit-entity: converted plain ul โ†’ table` + ` (${_tbody.rows.length} rows, col="${_colName}",` + ` entity="${_entitySlug}", structure="artist-credit-entity-ul").`); return; // Structure G handled } Lib.debug('init', `applyListToTable: sectionId='' but path "${_currentPath}" matches no known structure โ€” skipping.`); return; } // โ”€โ”€ Locate the
                                              and its replacement target โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // sectionId e.g. "genres" โ†’ singular "genre" โ†’ class "genre-list" const _singular = sectionId.replace(/s$/i, ''); const _ulClassName = `${_singular}-list`; let _ul = null; // the
                                                to convert let _replaceTarget = null; // the node to replace in the parent DOM const _div = docContext.getElementById(sectionId); if (_div) { // Structure A: div-wrapped _ul = _div.querySelector(':scope > ul'); if (!_ul) { Lib.debug('init', `applyListToTable: no direct
                                                  inside #${sectionId} โ€” skipping.`); return; } _replaceTarget = _div; // replace the entire
                                                  } else { // Structure B: bare ul โ€” scan for
                                                    _ul = docContext.querySelector(`ul.${_ulClassName}`); if (!_ul) { Lib.debug('init', `applyListToTable: no
                                                    and no
                                                      found โ€” skipping.`); return; } _replaceTarget = _ul; // replace the
                                                        itself } // โ”€โ”€ Derive first-column name from the
                                                          class โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Extract the type substring before "-list" and capitalise it. // e.g. "genre-list" โ†’ "genre" โ†’ "Genre"; "tag-list" โ†’ "Tag". const _classMatch = (_ul.className || '').match(/(\S+)-list/); const _typeRaw = _classMatch ? _classMatch[1] : _singular; const _colName = _typeRaw.charAt(0).toUpperCase() + _typeRaw.slice(1); // โ”€โ”€ Build the replacement
                                            โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const _table = document.createElement('table'); _table.className = 'tbl'; // thead const _thead = document.createElement('thead'); const _hr = document.createElement('tr'); [_colName, 'Tag count'].forEach(label => { const _th = document.createElement('th'); _th.textContent = label; _hr.appendChild(_th); }); _thead.appendChild(_hr); _table.appendChild(_thead); // tbody const _tbody = document.createElement('tbody'); Array.from(_ul.querySelectorAll(':scope > li')).forEach(li => { const _tr = document.createElement('tr'); if (li.className) _tr.className = li.className; // preserve odd/even // First cell: the tag link const _td1 = document.createElement('td'); const _a = li.querySelector('a'); if (_a) _td1.appendChild(_a.cloneNode(true)); _tr.appendChild(_td1); // Second cell: .tag-vote-buttons span (contains .tag-count) const _td2 = document.createElement('td'); const _votes = li.querySelector('.tag-vote-buttons'); if (_votes) _td2.appendChild(_votes.cloneNode(true)); _tr.appendChild(_td2); _tbody.appendChild(_tr); }); _table.appendChild(_tbody); // โ”€โ”€ Swap target node with the new
                                            โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ _replaceTarget.parentNode.replaceChild(_table, _replaceTarget); Lib.debug('init', `applyListToTable: converted "${_ulClassName}" โ†’ table` + ` (${_tbody.rows.length} rows, col="${_colName}",` + ` structure="${_div ? 'div-wrapped' : 'bare-ul'}").`); }); } /** * Renames every `

                                            ` element in the page body to `

                                            `, preserving all * attributes and child nodes. * * This is a DOM pre-processing step for pageTypes that carry * `features.renameH2ToH3: true` (e.g. 'artist-tags'). Entity tag pages * use `

                                            ` elements to separate their tag sections (e.g. "Genres", * "Other tags"), but the script's multi-table rendering infrastructure * expects `

                                            ` siblings to label individual sub-tables. Promoting all * existing `

                                            ` headers down to `

                                            ` before the subsequent * `insertH2()` call creates a fresh `

                                            ` anchor makes the full * collapsible-header and filter-container machinery work correctly. * * Operates on `document` directly (the live page DOM). * Skips the `

                                            ` (entity title) โ€” only `

                                            ` nodes are touched. * * @param {object} def - The active merged pageDefinition object. */ function applyRenameH2ToH3(def) { if (!def?.features?.renameH2ToH3) return; const _h2s = Array.from(document.querySelectorAll('h2')); if (_h2s.length === 0) { Lib.debug('init', 'applyRenameH2ToH3: no

                                            elements found โ€” nothing to rename.'); return; } _h2s.forEach(_h2 => { const _h3 = document.createElement('h3'); // Copy all attributes Array.from(_h2.attributes).forEach(attr => _h3.setAttribute(attr.name, attr.value)); // Move all child nodes while (_h2.firstChild) _h3.appendChild(_h2.firstChild); _h2.parentNode.replaceChild(_h3, _h2); }); Lib.debug('init', `applyRenameH2ToH3: renamed ${_h2s.length}

                                            element(s) to

                                            .`); } /** * Inserts a new `

                                            ` element with the text supplied by * `features.insertH2` immediately after the `
                                            ` container * in the page body. * * This is a DOM pre-processing step for pageTypes that carry * `features.insertH2: ` (e.g. 'artist-tags'). After * `applyRenameH2ToH3()` has demoted the existing section headings to * `

                                            `, there is no `

                                            ` left on the page for the script's * collapsible-header, filter-container, and row-count infrastructure to * attach to. This function injects a synthetic `

                                            ` that acts as that * anchor. * * Placement rule: the new `

                                            ` is inserted as the next sibling of the * last `
                                            ` element found inside `div#page` (the main * MusicBrainz content area). If no tabs `
                                            ` is found the `

                                            ` is * prepended to `div#page` as a fallback. * * Operates on `document` directly (the live page DOM). * * @param {object} def - The active merged pageDefinition object. */ function applyInsertH2(def) { const _text = def?.features?.insertH2; if (!_text) return; // โ”€โ”€ Idempotency guard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // On disk-load the pre-processing block calls applyInsertH2 even when // the user is on the final rendered page (where startFetchingProcess // already called it). Inserting a second identical

                                            shifts the // allH2s sequence that renderGroupedTable uses to locate targetHeader, // which can cause targetHeader to resolve to the duplicate instead of // the intended anchor โ€” breaking group insertion order. // Guard: if any existing

                                            in the page already carries the exact // same text, skip the insertion entirely. const _existing = Array.from(document.querySelectorAll('h2')) .find(h => h.textContent.trim() === _text.trim()); if (_existing) { Lib.debug('init', `applyInsertH2:

                                            "${_text}"

                                            already present โ€” skipping (idempotency).`); return; } const _h2 = document.createElement('h2'); _h2.textContent = _text; // Prefer insertion right after the last
                                            block. const _tabsDivs = document.querySelectorAll('div#page div.tabs, div#content div.tabs, div.tabs'); if (_tabsDivs.length > 0) { const _tabs = _tabsDivs[_tabsDivs.length - 1]; _tabs.insertAdjacentElement('afterend', _h2); Lib.debug('init', `applyInsertH2: inserted

                                            "${_text}"

                                            after
                                            .`); return; } // Second preference: insert immediately before the first

                                            in the content // area. After renameH2ToH3() has run, the original section headings (e.g. // "Genres", "Other Tags") are now

                                            elements. Any script-injected nodes // that sat between

                                            and those headings (e.g. #mb-status-displays-wrapper, // an intro

                                            ) should remain ABOVE the new

                                            anchor โ€” inserting // beforebegin the first

                                            achieves this regardless of how many such nodes // exist. Falls through to the after-h1 path when no

                                            is present yet. const _contentRoot = document.getElementById('content') || document.getElementById('page') || document.body; const _firstH3 = _contentRoot.querySelector('h3'); if (_firstH3) { _firstH3.insertAdjacentElement('beforebegin', _h2); Lib.debug('init', `applyInsertH2: inserted

                                            "${_text}"

                                            before first

                                            in content area.`); return; } // Third preference: after the first

                                            (no

                                            siblings yet). const _firstH1 = _contentRoot.querySelector('h1'); if (_firstH1) { _firstH1.insertAdjacentElement('afterend', _h2); Lib.debug('init', `applyInsertH2: inserted

                                            "${_text}"

                                            after first

                                            in content area.`); return; } // Last-resort fallback: prepend to content root. _contentRoot.prepend(_h2); Lib.debug('init', `applyInsertH2: inserted

                                            "${_text}"

                                            as first child of content root (last-resort fallback).`); } /** * Inserts a new `

                                            ` element with the text from `features.insertPrependH2` * immediately BEFORE the first existing `

                                            ` found inside `div#content` * (or `div#page` / body as fallback). * * Used by the 'search' pageType to prepend a "Searchform" section heading * before the search-results heading so the form can be wrapped in its own * collapsible section. * * @param {object} def - The active merged pageDefinition object. */ function applyInsertPrependH2(def) { const _text = def?.features?.insertPrependH2; if (!_text) return; const _root = document.getElementById('content') || document.getElementById('page') || document.body; const _existingH2 = _root.querySelector('h2'); const _h2 = document.createElement('h2'); _h2.textContent = _text; if (_existingH2) { _existingH2.parentNode.insertBefore(_h2, _existingH2); Lib.debug('init', `applyInsertPrependH2: inserted

                                            "${_text}"

                                            before existing h2.`); } else { _root.prepend(_h2); Lib.debug('init', `applyInsertPrependH2: inserted

                                            "${_text}"

                                            as first child (no existing h2 found).`); } } /** * Clicks the "Show all tags." anchor on entity tag pages so that hidden * tags (score โ‰ค 0, downvoted) are exposed in the `

                                            * * `mb-ic-left` min-width is initially ''. A second pass โ€” * `finalizeColonAlignedColumns` โ€” sets `min-width:Nch` so colons line * up across all rows. Values with no ':' are treated as pure right parts. * * Idempotency: `cell.dataset.mbIntColStyled = '1'` prevents double-wrapping. * * @param {HTMLTableRowElement} row * - The fully assembled data row (original + synthetic cells appended). * @param {Array<{sourceColumn: string, align: string, colIdx: number}>} descriptors * - Runtime list built by buildActiveIntegerColumns(), with colIdx resolved. */ function applyIntegerColumnStyling(row, descriptors) { if (!descriptors.length) return; for (const entry of descriptors) { if (entry.colIdx === -1) continue; // column not present on this page const cell = row.cells[entry.colIdx]; if (!cell) continue; // Idempotent: skip cells already styled (safe across re-render cycles) if (cell.dataset.mbIntColStyled === '1') continue; const _isSplit = entry.align !== 'L' && entry.align !== 'R' && entry.align !== 'C'; if (_isSplit) { // โ”€โ”€ Split-character alignment (pass 1: structure only, no widths) โ”€โ”€โ”€ // The split char is centred in the column; left part grows right to // meet it; right part grows left away from it. const splitChar = entry.align; const rawText = cell.textContent.trim(); // Use lastIndexOf so that '[H:]M:S' always splits at the M/S boundary, // and 'N.M' splits at the only separator โ€” lastIndexOf handles both. const splitIdx = rawText.lastIndexOf(splitChar); const leftText = splitIdx !== -1 ? rawText.slice(0, splitIdx) : ''; const rightText = splitIdx !== -1 ? rawText.slice(splitIdx + 1) : rawText; cell.textContent = ''; // clear // Center the inline-block wrapper โ€” this puts ':' at the column center cell.style.textAlign = 'center'; // Outer wrapper: inline-block so the three spans sit on one line // and the whole unit is centred by the td's text-align:center const wrap = document.createElement('span'); wrap.className = 'mb-ic-wrap'; wrap.style.cssText = 'display:inline-block; white-space:nowrap;'; const spanLeft = document.createElement('span'); spanLeft.className = 'mb-ic-left'; spanLeft.style.cssText = 'display:inline-block; text-align:right;' + 'font-variant-numeric:tabular-nums;'; // min-width set to '' here; finalizeSplitAlignedColumns sets it to Nch spanLeft.textContent = leftText; const spanSep = document.createElement('span'); spanSep.className = 'mb-ic-sep'; spanSep.textContent = splitChar; const spanRight = document.createElement('span'); spanRight.className = 'mb-ic-right'; spanRight.style.cssText = 'display:inline-block; text-align:left;' + 'font-variant-numeric:tabular-nums;'; spanRight.textContent = rightText; wrap.appendChild(spanLeft); wrap.appendChild(spanSep); wrap.appendChild(spanRight); cell.appendChild(wrap); } else { // โ”€โ”€ Standard centered inline-block + L / R / C โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const contentAlignMap = { L: 'left', R: 'right', C: 'center' }; const contentAlign = contentAlignMap[entry.align] || 'center'; // Centre the inline-block anchor within the column cell.style.textAlign = 'center'; // Wrap content in an inline-block span. // mb-ic-val class lets finalizeRLCColumnWidths compute uniform min-width. const span = document.createElement('span'); span.className = 'mb-ic-val'; span.style.cssText = 'display:inline-block;' + `text-align:${contentAlign};` + 'font-variant-numeric:tabular-nums;'; // Move all child nodes into the span while (cell.firstChild) span.appendChild(cell.firstChild); cell.appendChild(span); } cell.dataset.mbIntColStyled = '1'; } } /** * finalizeColonAlignedColumns โ€” pass 2 (post-collection, called once per * render cycle, just before renderFinalTable / renderGroupedTable). * * For every integerColumns descriptor with `align: ':'`: * 1. Iterates every collected row and reads the text length of its * `.mb-ic-left` span in the target column. * 2. Determines the maximum left-part character count across all rows. * 3. Sets `min-width:Nch` on every `.mb-ic-left` span in that column so * that the `:` separator aligns vertically for all values regardless of * the width of the left part. * * Works for both single-table pages (pass `allRows`) and multi-table pages * (pass `groupedRows.flatMap(g => g.rows)`). * * Must be called AFTER all rows have been imported (so the full set is known) * and BEFORE the rows are inserted into the DOM (so widths are set before * first paint). Idempotent: re-calling on the same rows is safe because * min-width is simply overwritten with the same computed value. * * @param {HTMLTableRowElement[]} rows * - Flat array of all collected data rows for this render cycle. * @param {Array<{sourceColumn: string, align: string, colIdx: number}>} descriptors * - Runtime list from buildActiveIntegerColumns(), with colIdx resolved. */ function finalizeSplitAlignedColumns(rows, descriptors) { const splitDescs = descriptors.filter( e => e.colIdx !== -1 && e.align !== 'L' && e.align !== 'R' && e.align !== 'C' ); if (!splitDescs.length || !rows.length) return; for (const entry of splitDescs) { // Pass 1: find the max left AND right character counts in this column. // Setting min-width on BOTH spans makes every .mb-ic-wrap the same total // width, so `text-align:center` on the
                                            itself is given * `text-align:center` so the inner span is centered inside the column, while * the inner span receives the per-column content alignment (left / right / * center). When `align` is absent it defaults to 'C' (centered). * * Each returned descriptor gains a `colIdx` property initialised to -1; the * actual column index is filled in during the header-scanning pass. * `colIdx` MUST be resolved against the FINAL column list (original headers * minus excluded + all synthetic column names) so that synthetic columns such * as DD / MM / YYYY are correctly addressed. * * @param {object} def - Merged activeDefinition (the resolved page definition object) * @returns {Array<{sourceColumn: string, align: string, colIdx: number}>} */ function buildActiveIntegerColumns(def) { const features = def?.features || {}; if (!Array.isArray(features.integerColumns)) return []; return features.integerColumns.map(entry => ({ sourceColumn: entry.sourceColumn, // Valid values: 'L' (left), 'R' (right), 'C' (center, default), // ':' (colon-split decimal alignment โ€” left part right-aligned, // right part left-aligned, separator ':' centred between them). // 'L','R','C' = standard alignment; any other single char = split-align separator align: (['L', 'R', 'C'].includes(entry.align) || (typeof entry.align === 'string' && entry.align.length === 1 && !['L','R','C'].includes(entry.align))) ? entry.align : 'C', colIdx: -1 })); } /** * Applies the centered inline-block + content-alignment technique to every * cell in a data row whose column index matches an integerColumns descriptor. * Must be called AFTER all synthetic cells have been appended to the row. * * Technique โ€” "centered inline-block + R/L/C content": * โ€ข The itself gets `text-align:center` so the inner is * horizontally centred inside the column. * โ€ข A `` * wraps the cell's original content and carries the per-column alignment. * โ€ข `font-variant-numeric:tabular-nums` ensures digit columns align on * decimal points when right-aligned. * โ€ข `min-width` is set via `--mb-int-col-min-width` (default 2ch) so * callers can override per-column widths via CSS. * * For align ':' (colon-split / decimal-separator alignment): * Splits the cell's text content at the FIRST ':' and produces three child * spans inside a `display:inline-block` wrapper: * * * * left part โ† text-align:right * : โ† visual center * right part โ† text-align:left * * places the separator at an // identical horizontal position across all rows regardless of value length. let maxLeftLen = 0; let maxRightLen = 0; for (const row of rows) { const cell = row.cells[entry.colIdx]; if (!cell) continue; const ls = cell.querySelector('.mb-ic-left'); const rs = cell.querySelector('.mb-ic-right'); if (ls) maxLeftLen = Math.max(maxLeftLen, ls.textContent.length); if (rs) maxRightLen = Math.max(maxRightLen, rs.textContent.length); } if (maxLeftLen === 0 && maxRightLen === 0) continue; // Pass 2: apply uniform min-width to both left and right spans const minWL = maxLeftLen > 0 ? `${maxLeftLen}ch` : ''; const minWR = maxRightLen > 0 ? `${maxRightLen}ch` : ''; for (const row of rows) { const cell = row.cells[entry.colIdx]; if (!cell) continue; const ls = cell.querySelector('.mb-ic-left'); const rs = cell.querySelector('.mb-ic-right'); if (ls && minWL) ls.style.minWidth = minWL; if (rs && minWR) rs.style.minWidth = minWR; } Lib.debug('render', `finalizeSplitAlignedColumns: column "${entry.sourceColumn}"(splitChar='${entry.align}') โ†’ left=${maxLeftLen}ch right=${maxRightLen}ch across ${rows.length} rows`); } } /** * finalizeRLCColumnWidths โ€” pass 2 for L / R / C integer columns. * * For each L/R/C integerColumns descriptor, scans all collected rows to * find the maximum `.mb-ic-val` text length in that column, then sets * `min-width:Nch` on every value span so all values occupy the same * horizontal block. Without this pass a right-aligned '86' and '124' * would sit at different positions even though both spans are centred * in the , because the spans auto-size to content width. * * @param {HTMLTableRowElement[]} rows * @param {Array<{sourceColumn: string, align: string, colIdx: number}>} descriptors */ function finalizeRLCColumnWidths(rows, descriptors) { const rlcDescs = descriptors.filter( e => e.colIdx !== -1 && (e.align === 'L' || e.align === 'R' || e.align === 'C') ); if (!rlcDescs.length || !rows.length) return; for (const entry of rlcDescs) { let maxLen = 0; for (const row of rows) { const cell = row.cells[entry.colIdx]; if (!cell) continue; const span = cell.querySelector('.mb-ic-val'); if (!span) continue; const len = span.textContent.trim().length; if (len > maxLen) maxLen = len; } if (maxLen === 0) continue; const minW = `${maxLen}ch`; for (const row of rows) { const cell = row.cells[entry.colIdx]; if (!cell) continue; const span = cell.querySelector('.mb-ic-val'); if (span) span.style.minWidth = minW; } Lib.debug('render', `finalizeRLCColumnWidths: column "${entry.sourceColumn}"(align='${entry.align}') โ†’ min-width=${maxLen}ch across ${rows.length} rows`); } } /** * applyRenderMultiRowCells โ€” converts comma-separated cell content into a *
                                            • multi-row structure for every column declared in the page * definition's `features.renderMultiRowCell` array. * * Splitting strategy: * The function walks the top-level child nodes of each target
                                            . * Separators are detected in two complementary ways: * * 1. Element-separated cells (e.g. Catalog#): a text node whose entire * content matches /^\s*,\s*$/ (only a comma with optional surrounding * whitespace) is a separator. Nodes between two such separators are * collected into a single
                                          • item. * * 2. Plain-text cells (e.g. Relationship types): MusicBrainz renders some * columns as a single text node โ€” e.g. `instrument (as "lead guitar"), * instrument (as "rhythm guitar")`. Here no standalone comma text node * exists. The function finds every comma that sits at parenthesis * depth 0 (not inside `(โ€ฆ)`) and splits on those positions, creating * one synthetic text node per part. Commas inside parentheses โ€” e.g. * `vocal (as "soprano, alto")` โ€” are left intact. * * Leading and trailing whitespace-only text nodes within each group are * dropped before appending to the
                                          • . * * - Cells with zero logical rows (empty cells) are left unchanged. * - All non-empty cells โ€” including those with exactly one logical row โ€” * are wrapped in a
                                            • so that initCollapsableColumns, * testRowMatch (hasSingleRow โ‰ก lis.length === 1), and the statistics * panel see a uniform structure regardless of how many entries the cell * contains. This matches the behaviour of splitCountryDate, which also * always wraps even single-event cells in
                                              • . * - Cells with โ‰ฅ2 logical rows additionally receive a โ–ถ/โ—€ cell toggle * from initCollapsableColumns (single-
                                              • cells are unaffected by it). * * This function is compatible with the collapsableColumns mechanism: if the * same column names are also declared in `features.collapsableColumns`, the * resulting
                                                • structure will be picked up by initCollapsableColumns() * and given a โ–ถ/โ—€ expand/collapse toggle automatically. * * Must be called AFTER applyColumnErasers and BEFORE column extractors run * so that the transformed cell content is based on already-cleaned data. * * @param {HTMLTableRowElement} row - The imported data row to mutate in-place. * @param {Array<{columnName: string, colIdx: number}>} descriptors * - Runtime descriptor list built by buildActiveRenderMultiRowCols(), * with colIdx already resolved during header scanning. */ function applyRenderMultiRowCells(row, descriptors) { if (!descriptors.length) return; for (const entry of descriptors) { if (entry.colIdx === -1) { Lib.warn('extract', `renderMultiRowCell: column "${entry.columnName}" was not found in table headers โ€” skipped`); continue; } const td = row.cells[entry.colIdx]; if (!td) { Lib.warn('extract', `renderMultiRowCell: colIdx ${entry.colIdx} out of range for column "${entry.columnName}" (row has ${row.cells.length} cells)`); continue; } // โ”€โ”€ Split top-level childNodes on comma separator text nodes โ”€โ”€โ”€โ”€โ”€โ”€ const children = Array.from(td.childNodes); const groups = []; let current = []; for (const node of children) { if (node.nodeType !== Node.TEXT_NODE) { current.push(node); continue; } // Text node: locate every top-level comma (not inside parentheses) const text = node.textContent; let depth = 0; const splits = []; for (let i = 0; i < text.length; i++) { const ch = text[i]; if (ch === '(') depth++; else if (ch === ')') depth--; else if (ch === ',' && depth === 0) splits.push(i); } if (splits.length === 0) { // No top-level commas โ€” ordinary text node current.push(node); } else { // Split into groups at each top-level comma let s = 0; for (const sp of splits) { const part = text.slice(s, sp); if (part.trim()) current.push(document.createTextNode(part)); groups.push(current); current = []; s = sp + 1; } const tail = text.slice(s); if (tail.trim()) current.push(document.createTextNode(tail)); } } groups.push(current); // โ”€โ”€ Trim leading/trailing whitespace-only text nodes per group โ”€โ”€โ”€โ”€ const nonEmptyGroups = groups.map(g => { const trimmed = [...g]; while (trimmed.length && trimmed[0].nodeType === Node.TEXT_NODE && !trimmed[0].textContent.trim()) { trimmed.shift(); } while (trimmed.length && trimmed[trimmed.length - 1].nodeType === Node.TEXT_NODE && !trimmed[trimmed.length - 1].textContent.trim()) { trimmed.pop(); } return trimmed; }).filter(g => g.length > 0); // โ”€โ”€ Skip only truly empty cells; always wrap non-empty ones โ”€โ”€โ”€โ”€โ”€โ”€ // Even a single logical row must be wrapped in
                                                  • so that // initCollapsableColumns, testRowMatch (hasSingleRow check via // lis.length === 1), and the statistics panel all see a consistent // ul > li structure โ€” matching the behaviour of splitCountryDate, // which always wraps even single-event cells. // initCollapsableColumns adds a โ–ถ/โ—€ toggle only when lis.length โ‰ฅ 2, // so single-
                                                  • cells are left un-toggled, which is correct. if (nonEmptyGroups.length < 1) continue; const ul = document.createElement('ul'); nonEmptyGroups.forEach(group => { const li = document.createElement('li'); group.forEach(node => li.appendChild(node.cloneNode(true))); ul.appendChild(li); }); td.innerHTML = ''; td.appendChild(ul); Lib.debug('extract', `renderMultiRowCell: column "${entry.columnName}" (colIdx=${entry.colIdx}) โ€” wrapped into ${nonEmptyGroups.length} li row(s)`); } } // --- Configuration: Page Definitions --- // There are different types of MusicBrainz pages // | Page type | multiple tables | paginated | table header | // |------------------------------+---------------------------+-----------+--------------------------------------| // | Artist-Releasegroups | native | x | h3, not repeating on paginated pages | // | Releasegroup-Releases | single table, subgrouping | x | h3, repeating on paginated pages | // | Collections-Releasegroups | native | x | h3, repeating on paginated pages | // | Place-Performances, ... | single table, subgrouping | | h3, repeating on single page | // | Events | single table | x | h3, | // | Search | single table | x | p.pageselector-results | // Define all supported page types, their detection logic, and specific UI configurations here. const pageDefinitions = [ // TagLookup pages { type: 'taglookup', match: (path) => path.includes('/taglookup'), buttons: [ { label: 'Show all Releases for Tags' } ], tableMode: 'single', features: { columnExtractors: [ { sourceColumn: 'Name', extractor: 'caa', syntheticColumns: ['CAA'] }, { sourceColumn: 'Country/Date', extractor: 'splitCountryDate', syntheticColumns: ['Country', 'Date'] }, { sourceColumn: 'Tracks', extractor: 'sumTracks', syntheticColumns: ['Total Tracks'] }, { sourceColumn: 'Format', extractor: 'extractFormatTypes', syntheticColumns: ['Format Types'] } ], syntheticColumnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], injectedColumns: [ 'Relationships' ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'}, {sourceColumn: 'Total Tracks', align: 'R'} ], renderMultiRowCell: [ 'Label', 'Catalog#' ], collapsableColumns: [ 'Country/Date', 'Country', 'Date', 'Label', 'Catalog#', 'CAA' ], tooltipColumns: [ 'MB-Name', 'italic:Comment', 'Artist', '---', ['Format', '(', 'Tracks', ')'], 'Country/Date', ['Label', '-', 'Catalog#'], 'Barcode', 'Language', 'Type', 'Status' ], addCAA: 'Name', extractMainColumn: 'Name', stickyColumn: 'Name', insertH2: 'Releases', // Inject a "Searchform" h2 before the existing (single) h2 on the page // so the collapsible-section infrastructure can wrap the search form. insertPrependH2: 'Searchform' } }, // CDtoc pages { type: 'cdtoc', match: (path) => path.includes('/cdtoc'), buttons: [ { label: 'Show all CDToc attached to Releases' } ], tableMode: 'single', features: { columnExtractors: [ { sourceColumn: 'Country/Date', extractor: 'splitCountryDate', syntheticColumns: ['Country', 'Date'] } ], syntheticColumnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], injectedColumns: [ 'Relationships' ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'} ], renderMultiRowCell: [ 'Label', 'Catalog#' ], collapsableColumns: [ 'Country/Date', 'Country', 'Date', 'Label', 'Catalog#' ], tooltipColumns: [ 'MB-Name', 'italic:Comment', 'Artist', '---', 'Format', 'Country/Date', ['Label', '-', 'Catalog#'], 'Barcode' ], addCAA: 'Title', extractMainColumn: 'Title', stickyColumn: 'Title' } }, // Artist credits per entity (/artist-credit//, // e.g. /artist-credit/1488075/release-group). // Must come before 'artist-credit' because its match is narrower // (two path segments after /artist-credit/ instead of one). { type: 'artist-credit-entity', match: (path) => path.match(/\/artist-credit\/[^/]+\/.+/), buttons: [ { label: 'Show all Artist-Credit Entities' } ], entityFeatures: { 'Release groups': { injectedColumns: [ 'Relationships' ], addCAA: 'Release group', extractMainColumn: 'Release group', stickyColumn: 'Release group' }, 'Releases': { injectedColumns: [ 'Relationships' ], addCAA: 'Release', extractMainColumn: 'Release', stickyColumn: 'Release' }, 'Recordings': { columnExtractors: [ { sourceColumn: 'Recording', extractor: 'video', syntheticColumns: ['Video'] } ], syntheticColumnExtractors: [ { sourceColumn: 'Comment', extractor: 'eventParts', syntheticColumns: ['Event-Type', 'Event-Date', 'Event-Detail', 'Event-Venue', 'Event-Venue-Detail', 'Event-City', 'Event-State', 'Event-Country', 'Event-Additional-Info'] } ], extractMainColumn: 'Recording', stickyColumn: 'Recording' } }, features: { // Empty sectionId triggers Structure G in applyListToTable: // a plain
                                                      without id or class is converted to a one-column // table whose header equals the last URL path segment, capitalised // with hyphens replaced by spaces (e.g. "release-group" โ†’ // "Release group"). The entityFeatures key is the plural form // (e.g. "Release groups") so that resolveEntityFeaturesFromH2 // can match the correct CAA/EAA configuration. listToTable: [ '' ] }, tableMode: 'single' }, // Artist credit overview pages (/artist-credit/, // e.g. /artist-credit/1488075 โ†’ 'Artist credit "Bruce Springsteen & The E Street Band"'). { type: 'artist-credit', match: (path) => path.match(/\/artist-credit\//), buttons: [ { label: 'Show all Artist-Credit Uses' } ], entityFeatures: { 'Release groups': { injectedColumns: [ 'Relationships' ], addCAA: 'Release group', extractMainColumn: 'Release group', stickyColumn: 'Release group' }, 'Releases': { injectedColumns: [ 'Relationships' ], addCAA: 'Release', extractMainColumn: 'Release', stickyColumn: 'Release' }, 'Recordings': { columnExtractors: [ { sourceColumn: 'Recording', extractor: 'video', syntheticColumns: ['Video'] } ], syntheticColumnExtractors: [ { sourceColumn: 'Comment', extractor: 'eventParts', syntheticColumns: ['Event-Type', 'Event-Date', 'Event-Detail', 'Event-Venue', 'Event-Venue-Detail', 'Event-City', 'Event-State', 'Event-Country', 'Event-Additional-Info'] } ], extractMainColumn: 'Recording', stickyColumn: 'Recording' }, }, features: { // Inserts

                                                      Artist list

                                                      after
                                                      and // before

                                                      This artist credit is composed ofโ€ฆ

                                                      . // The
                                                        below it is left as-is; // only the h3+ul pairs under

                                                        Uses

                                                        are converted. insertH2: 'Artist list', // Empty sectionId triggers Structure F in applyListToTable: // each

                                                        +
                                                          pair under

                                                          Uses

                                                          is converted to a // one-column whose first-column header is // the singular form of the h3 text (e.g. "Release groups" โ†’ // "Release group"). The

                                                          Uses

                                                          element stays in place // and becomes the targetHeader anchor for renderGroupedTable. listToTable: [ '' ] }, tableMode: 'multi' }, // Subscriptions pages โ€” a single consolidated type with per-button // virtualPath values that replace the last URL path segment on click, // allowing all five subscription types from one unified page definition. { type: 'user-subscriptions', match: (path) => path.match(/\/user\/.*\/subscriptions/), buttons: [ { label: '๐Ÿงฎ Artist subscriptions', virtualPath: '/artist' }, { label: '๐Ÿงฎ Collection subscriptions', virtualPath: '/collection' }, { label: '๐Ÿงฎ Label subscriptions', virtualPath: '/label' }, { label: '๐Ÿงฎ Series subscriptions', virtualPath: '/series' }, { label: '๐Ÿงฎ Editor subscriptions', virtualPath: '/editor' } ], features: { extractMainColumn: 'Name', stickyColumn: 'Name' }, tableMode: 'single' }, // Subscriber pages (e.g. /user/vzell/subscribers) { type: 'editor-subscribers', match: (path) => path.includes('/subscribers'), buttons: [ { label: 'Show all Editor Subscribers for User' } ], features: { // Empty sectionId triggers Structure C in applyListToTable: the // function scans for

                                                          โ€ฆ
                                                            pairs in the content area and uses // the h2 text as the column name (e.g. "Subscribers"). listToTable: [ '' ] }, tableMode: 'single' }, // Entity tags sub-pages (///tags e.g. /artist//tags) // must come before the generic 'tags' entry because its match is narrower. { type: 'artist-tags', match: (path) => path.match(/\/artist\/[a-f0-9-]{36}\/tags/), buttons: [ { label: 'Show all Tags for Artist' } ], tableMode: 'multi', features: { // Click \"Show all tags.\" before processing so hidden zero-score // and downvoted tags are exposed in the
                                                              lists. showAllTags: true, // Remove the \"all-tags\" info/form container after rendering since // the converted table replaces the ul and the form is no longer needed. removeSelector: 'div.all-tags', renameH2ToH3: true, insertH2: 'Artist tags', listToTable: [ 'genres', 'tags' ] } }, { type: 'releasegroup-tags', match: (path) => path.match(/\/release-group\/[a-f0-9-]{36}\/tags/), buttons: [ { label: 'Show all Tags for Releasegroup' } ], tableMode: 'multi', features: { showAllTags: true, removeSelector: 'div.all-tags', renameH2ToH3: true, insertH2: 'Releasegroup tags', listToTable: [ 'genres', 'tags' ] } }, { type: 'release-tags', match: (path) => path.match(/\/release\/[a-f0-9-]{36}\/tags/), buttons: [ { label: 'Show all Tags for Release' } ], tableMode: 'multi', features: { showAllTags: true, removeSelector: 'div.all-tags', renameH2ToH3: true, insertH2: 'Release tags', listToTable: [ 'genres', 'tags' ] } }, { type: 'recording-tags', match: (path) => path.match(/\/recording\/[a-f0-9-]{36}\/tags/), buttons: [ { label: 'Show all Tags for Recording' } ], tableMode: 'multi', features: { showAllTags: true, removeSelector: 'div.all-tags', renameH2ToH3: true, insertH2: 'Recording tags', listToTable: [ 'genres', 'tags' ] } }, { type: 'work-tags', match: (path) => path.match(/\/work\/[a-f0-9-]{36}\/tags/), buttons: [ { label: 'Show all Tags for Work' } ], tableMode: 'multi', features: { showAllTags: true, removeSelector: 'div.all-tags', renameH2ToH3: true, insertH2: 'Work tags', listToTable: [ 'genres', 'tags' ] } }, { type: 'label-tags', match: (path) => path.match(/\/label\/[a-f0-9-]{36}\/tags/), buttons: [ { label: 'Show all Tags for Label' } ], tableMode: 'multi', features: { showAllTags: true, removeSelector: 'div.all-tags', renameH2ToH3: true, insertH2: 'Label tags', listToTable: [ 'genres', 'tags' ] } }, { type: 'series-tags', match: (path) => path.match(/\/series\/[a-f0-9-]{36}\/tags/), buttons: [ { label: 'Show all Tags for Series' } ], tableMode: 'multi', features: { showAllTags: true, removeSelector: 'div.all-tags', renameH2ToH3: true, insertH2: 'Series tags', listToTable: [ 'genres', 'tags' ] } }, { type: 'place-tags', match: (path) => path.match(/\/place\/[a-f0-9-]{36}\/tags/), buttons: [ { label: 'Show all Tags for Place' } ], tableMode: 'multi', features: { showAllTags: true, removeSelector: 'div.all-tags', renameH2ToH3: true, insertH2: 'Place tags', listToTable: [ 'genres', 'tags' ] } }, { type: 'area-tags', match: (path) => path.match(/\/area\/[a-f0-9-]{36}\/tags/), buttons: [ { label: 'Show all Tags for Area' } ], tableMode: 'multi', features: { showAllTags: true, removeSelector: 'div.all-tags', renameH2ToH3: true, insertH2: 'Area tags', listToTable: [ 'genres', 'tags' ] } }, { type: 'instrument-tags', match: (path) => path.match(/\/instrument\/[a-f0-9-]{36}\/tags/), buttons: [ { label: 'Show all Tags for Instrument' } ], tableMode: 'multi', features: { showAllTags: true, removeSelector: 'div.all-tags', renameH2ToH3: true, insertH2: 'Instrument tags', listToTable: [ 'genres', 'tags' ] } }, // User tags pages (/user//tags e.g. /user/vzell/tags, /user/vzell/tags?show_downvoted=1) // // Native MusicBrainz DOM structure (no div#content โ€” uses div#page directly): //

                                                              vzell's tagsโ€ฆ

                                                              //
                                                              โ€ฆ
                                                              //

                                                              Tags vzell upvoted

                                                              โ† correct targetHeader (native h2) //
                                                              โ€ฆ //
                                                              โ† sub-wrapper (becomes initial container) //

                                                              Genres

                                                              //
                                                                โ€ฆ
                                                              //

                                                              Other tags

                                                              //
                                                                โ€ฆ
                                                              //
                                                              // // Bug: after applyListToTable converts the
                                                                s, `container` is set to // `div#all-tags` (parentNode of the first table.tbl, since no div#content // exists). The h2 targetHeader is a SIBLING of div#all-tags, not inside it. // After the cleanup pass empties div#all-tags, all new h3/table pairs are // inserted after the h2 (outside div#all-tags) via lastInsertedElement.after(). // The master-toggle click handler's `container.querySelectorAll('table.tbl')` // scopes to the now-empty div#all-tags โ†’ finds nothing โ†’ no visual effect. // Fix: renderGroupedTable re-roots `container` to `targetHeader.parentNode` // when targetHeader is outside the initial container (see re-root block there). { type: 'user-tags', match: (path, params) => path.match(/\/user\/[^/]+\/tags/), buttons: [ { label: 'Tags upvoted', params: { show_downvoted: '0' } }, { label: 'Tags downvoted', params: { show_downvoted: '1' } } ], features: { listToTable: [ 'genres', 'tags' ], // Remove the vote/sort form (style="margin-top:1em") after rendering // since the two buttons above replace its function. removeSelector: 'form[style*="margin-top"]' }, tableMode: 'multi' }, // Entity ratings pages (///ratings e.g. /work//ratings) // DOM structure (Structure I): //

                                                                Ratings

                                                                // // [

                                                                N private rating not listed.

                                                                Average rating: ] // // The private-rating/average-rating block (if present) is moved by Structure I // to appear between the

                                                                and the rendered table. { type: 'ratings-entity', match: (path) => path.match(/\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\/ratings$/), buttons: [ { label: 'Show all Ratings', labelFromPathEntity: true } ], features: { listToTable: [ '' ], integerColumns: [ { sourceColumn: 'Rating', align: 'C' } ] }, tableMode: 'single' }, // Entity-specific user ratings pages (/user//ratings/) // Triggered by the "View all ratings" overflow button on user-ratings sub-tables. // URL examples: /user/vzell/ratings/recording, /user/vzell/ratings/event // The entity slug (last path segment, singular) is mapped to the plural // entityFeatures key via resolveEntityFeaturesFromH2 [user-ratings-type]. // Column structure mirrors the corresponding sub-table on the user-ratings page. { type: 'user-ratings-type', match: (path) => path.match(/\/user\/[^/]+\/ratings\/[^/]+$/), buttons: [ { label: 'Show Ratings', labelFromPath: true } ], features: { listToTable: [ '' ], integerColumns: [ { sourceColumn: 'Rating', align: 'C' } ] }, entityFeatures: { 'Artists': { columnExtractors: [ { sourceColumn: 'Artist', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] }, 'Events': { columnExtractors: [ { sourceColumn: 'Event', extractor: 'Name_Date_Comment', syntheticColumns: ['Name', 'Date', 'Comment'] }, { sourceColumn: 'Event', extractor: 'cancelledEvent', syntheticColumns: ['Cancelled'] }, { sourceColumn: 'Event', extractor: 'primaryAlias', syntheticColumns: ['Primary Alias'] }, ], syntheticColumnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'} ], addEAA: 'Event', tooltipColumns: [ 'Name', ['(', 'Comment', ')'], '---', 'Date' ] }, 'Labels': { columnExtractors: [ { sourceColumn: 'Label', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] }, 'Places': { columnExtractors: [ { sourceColumn: 'Place', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] }, 'Release groups': { columnExtractors: [ { sourceColumn: 'Release group', extractor: 'Name_Comment_Artists', syntheticColumns: ['Name', 'Comment', 'Artist'] } ], injectedColumns: [ 'Relationships' ], addCAA: 'Release group', tooltipColumns: [ 'Name', 'Artist' ] }, 'Recordings': { columnExtractors: [ { sourceColumn: 'Recording', extractor: 'video', syntheticColumns: ['Video'] }, { sourceColumn: 'Recording', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ], syntheticColumnExtractors: [ { sourceColumn: 'Comment', extractor: 'eventParts', syntheticColumns: ['Event-Type', 'Event-Date', 'Event-Detail', 'Event-Venue', 'Event-Venue-Detail', 'Event-City', 'Event-State', 'Event-Country', 'Event-Additional-Info'] } ], }, 'Works': { columnExtractors: [ { sourceColumn: 'Work', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] } }, tableMode: 'single' }, // User ratings pages (/user//ratings e.g. /user/vzell/ratings) // Page structure (after renameH2ToH3 + insertH2): //

                                                                Ratings

                                                                โ† inserted by insertH2 //

                                                                Artist ratings

                                                                โ†’ 2-col table: Rating | Artist //

                                                                Recording ratings

                                                                โ†’ 3-col table: Rating | Recording | Artist //

                                                                Release group ratings

                                                                โ†’ 3-col table: Rating | Release group | Artist //

                                                                Work ratings

                                                                โ†’ 2-col table: Rating | Work // (any section containing " by" or " by" gets 3 columns) // // Processing pipeline: // 1. renameH2ToH3 โ€” demotes existing

                                                                section headings to

                                                                // 2. insertH2 โ€” inserts new

                                                                Ratings

                                                                anchor after .tabs // 3. listToTable โ€” Structure H: walks

                                                                +
                                                                  pairs, builds 2-col or // 3-col tables; "View all ratings" li becomes an overflow // button in renderGroupedTable { type: 'user-ratings', match: (path) => path.match(/\/user\/[^/]+\/ratings$/), buttons: [ { label: 'Show Ratings for User' } ], features: { renameH2ToH3: true, insertH2: 'Ratings', listToTable: [ '' ], integerColumns: [ { sourceColumn: 'Rating', align: 'C' } ], extractMainColumn: null, stickyColumn: null, }, entityFeatures: { 'Artists': { columnExtractors: [ { sourceColumn: 'Artist', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] }, 'Events': { columnExtractors: [ { sourceColumn: 'Event', extractor: 'Name_Date_Comment', syntheticColumns: ['Name', 'Date', 'Comment'] }, { sourceColumn: 'Event', extractor: 'cancelledEvent', syntheticColumns: ['Cancelled'] }, { sourceColumn: 'Event', extractor: 'primaryAlias', syntheticColumns: ['Primary Alias'] }, ], syntheticColumnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'}, { sourceColumn: 'Rating', align: 'C' } ], addEAA: 'Event', tooltipColumns: [ 'Name', ['(', 'Comment', ')'], '---', 'Date' ] }, 'Labels': { columnExtractors: [ { sourceColumn: 'Label', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] }, 'Places': { columnExtractors: [ { sourceColumn: 'Place', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] }, 'Release groups': { columnExtractors: [ { sourceColumn: 'Release group', extractor: 'Name_Comment_Artists', syntheticColumns: ['Name', 'Comment', 'Artist'] } ], injectedColumns: [ 'Relationships' ], addCAA: 'Release group', tooltipColumns: [ 'Name', 'Artist' ] }, 'Recordings': { columnExtractors: [ { sourceColumn: 'Recording', extractor: 'video', syntheticColumns: ['Video'] }, { sourceColumn: 'Recording', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ], syntheticColumnExtractors: [ { sourceColumn: 'Comment', extractor: 'eventParts', syntheticColumns: ['Event-Type', 'Event-Date', 'Event-Detail', 'Event-Venue', 'Event-Venue-Detail', 'Event-City', 'Event-State', 'Event-Country', 'Event-Additional-Info'] } ], }, 'Works': { columnExtractors: [ { sourceColumn: 'Work', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] } }, tableMode: 'multi' }, // Most popular tags in MusicBrainz (/tags) // Page structure: //

                                                                  โ€ฆ

                                                                  //

                                                                  โ€ฆ

                                                                  //

                                                                  Genres

                                                                    โ€ฆ
                                                                  //

                                                                  Other Tags

                                                                    โ€ฆ
                                                                  // // Processing pipeline: // 1. renameH2ToH3 โ€” demotes existing

                                                                  (Genres / Other Tags) to

                                                                  // 2. insertH2 โ€” inserts new

                                                                  Popular tags

                                                                  anchor // 3. listToTable โ€” Structure E: walks

                                                                  +
                                                                    pairs, builds 2-col // tables (singular(h3 text) | Tag count) from each { type: 'popular-tags', match: (path, params) => path.match(/^\/tags/), buttons: [ { label: 'Show most popular tags', params: { show_list: '1' } } ], features: { renameH2ToH3: true, insertH2: 'Popular tags', listToTable: [ '' ], integerColumns: [ { sourceColumn: 'Tag count', align: 'R' } ], extractMainColumn: null, stickyColumn: null, // Remove the "Show as a list/cloud instead." link from the intro // paragraph โ€” the script's own button replaces that function. removeSelector: 'a[href*="tags?show_list"]' }, tableMode: 'multi' }, // User tag value entity pages (/user//tag// // e.g. /user/vzell/tag/gig/event) โ€” must come before user-tag-value // (same /user/.*/tag/ prefix, but has an additional entity segment). { type: 'user-tag-value-entity', match: (path, params) => path.match(/\/user\/[^/]+\/tag\/[^/]+\/.+/), buttons: [ { label: 'Tag for Entities upvoted', params: { show_downvoted: '0' } }, { label: 'Tag for Entities downvoted', params: { show_downvoted: '1' } } ], entityFeatures: { 'Areas': { columnExtractors: [ { sourceColumn: 'Area', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] }, 'Artists': { columnExtractors: [ { sourceColumn: 'Artist', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] }, 'Events': { columnExtractors: [ { sourceColumn: 'Event', extractor: 'Name_Date_Comment', syntheticColumns: ['Name', 'Date', 'Comment'] }, { sourceColumn: 'Event', extractor: 'cancelledEvent', syntheticColumns: ['Cancelled'] }, { sourceColumn: 'Event', extractor: 'primaryAlias', syntheticColumns: ['Primary Alias'] }, ], syntheticColumnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'} ], addEAA: 'Event', tooltipColumns: [ 'Name', ['(', 'Comment', ')'], 'Date' ] }, 'Instruments': { columnExtractors: [ { sourceColumn: 'Instrument', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] }, 'Labels': { columnExtractors: [ { sourceColumn: 'Label', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] }, 'Places': { columnExtractors: [ { sourceColumn: 'Place', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] }, 'Release groups': { columnExtractors: [ { sourceColumn: 'Release group', extractor: 'Name_Comment_Artists', syntheticColumns: ['Name', 'Comment', 'Artist'] } ], injectedColumns: [ 'Relationships' ], addCAA: 'Release group', tooltipColumns: [ 'Name', ['(', 'Comment', ')'], 'Artist' ] }, 'Releases': { columnExtractors: [ { sourceColumn: 'Release', extractor: 'Name_Comment_Artists', syntheticColumns: ['Name', 'Comment', 'Artist'] } ], injectedColumns: [ 'Relationships' ], addCAA: 'Release', tooltipColumns: [ 'Name', ['(', 'Comment', ')'], 'Artist' ] }, 'Recordings': { columnExtractors: [ { sourceColumn: 'Recording', extractor: 'Name_Comment_Artists', syntheticColumns: ['Name', 'Comment', 'Artist'] } ] }, 'Series': { columnExtractors: [ { sourceColumn: 'Series', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] }, 'Works': { columnExtractors: [ { sourceColumn: 'Work', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] } }, features: { // Empty sectionId triggers Structure C in applyListToTable: the // function scans for

                                                                    โ€ฆ
                                                                      pairs in the content area and uses // the h2 text up to "tagged" minus the trailing username, then // singularises it as the column name, // e.g. "Release groups vzell tagged as ยซgigยป" โ†’ "Release group". listToTable: [ '' ], // Remove the vote/sort form after rendering since the two buttons // above replace its function and the form is no longer needed. removeSelector: 'form[style*="margin-top"]' }, tableMode: 'single' }, // User tag pages (/user//tag/ e.g. /user/vzell/tag/handwritten) // Must come before the generic tag-value entry (narrower match). { type: 'user-tag-value', match: (path, params) => path.match(/\/user\/.*\/tag\//), buttons: [ { label: 'Tag for Entities upvoted', params: { show_downvoted: '0' } }, { label: 'Tag for Entities downvoted', params: { show_downvoted: '1' } } ], entityFeatures: { 'Areas': { columnExtractors: [ { sourceColumn: 'Area', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] }, 'Artists': { columnExtractors: [ { sourceColumn: 'Artist', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] }, 'Events': { columnExtractors: [ { sourceColumn: 'Event', extractor: 'Name_Date_Comment', syntheticColumns: ['Name', 'Date', 'Comment'] } ], syntheticColumnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'} ], addEAA: 'Event', tooltipColumns: [ 'Name', ['(', 'Comment', ')'], 'Date' ] }, 'Instruments': { columnExtractors: [ { sourceColumn: 'Instrument', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] }, 'Labels': { columnExtractors: [ { sourceColumn: 'Label', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] }, 'Places': { columnExtractors: [ { sourceColumn: 'Place', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] }, 'Release groups': { columnExtractors: [ { sourceColumn: 'Release group', extractor: 'Name_Comment_Artists', syntheticColumns: ['Name', 'Comment', 'Artist'] } ], injectedColumns: [ 'Relationships' ], addCAA: 'Release group', tooltipColumns: [ 'Name', ['(', 'Comment' , ')'], 'Artist' ] }, 'Releases': { columnExtractors: [ { sourceColumn: 'Release', extractor: 'Name_Comment_Artists', syntheticColumns: ['Name', 'Comment', 'Artist'] } ], injectedColumns: [ 'Relationships' ], addCAA: 'Release', tooltipColumns: [ 'Name', ['(', 'Comment' , ')'], 'Artist' ] }, 'Recordings': { columnExtractors: [ { sourceColumn: 'Recording', extractor: 'Name_Comment_Artists', syntheticColumns: ['Name', 'Comment', 'Artist'] } ] }, 'Series': { columnExtractors: [ { sourceColumn: 'Series', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] }, 'Works': { columnExtractors: [ { sourceColumn: 'Work', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] } }, features: { // Empty sectionId triggers Structure D in applyListToTable. listToTable: [ '' ], // Remove the vote/sort form after rendering since the two buttons // above replace its function and the form is no longer needed. removeSelector: 'form:has(select[name="show_downvoted"])' }, tableMode: 'multi' }, // Tag pages per entity (/tag//s e.g. /tag/country/labels) โ€” must come before // the generic tag-value entry because its match is narrower (two path segments after /tag/ instead of one). { type: 'tag-value-entity', match: (path) => path.match(/\/tag\/[^/]+\/.+/), buttons: [ { label: 'Show all Entities tagged' } ], entityFeatures: { 'Areas': { columnExtractors: [ { sourceColumn: 'Area', extractor: 'tagCount_Name_Comment', syntheticColumns: ['Name', 'Tag count', 'Comment'] } ], integerColumns: [ {sourceColumn: 'Tag count', align: 'R'} ] }, 'Artists': { columnExtractors: [ { sourceColumn: 'Artist', extractor: 'tagCount_Name_Comment', syntheticColumns: ['Name', 'Tag count', 'Comment'] } ], integerColumns: [ {sourceColumn: 'Tag count', align: 'R'} ] }, 'Events': { columnExtractors: [ { sourceColumn: 'Event', extractor: 'tagCount_Name_Date_Comment', syntheticColumns: ['Name', 'Tag count', 'Date', 'Comment'] } ], syntheticColumnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], integerColumns: [ {sourceColumn: 'Tag count', align: 'R'}, {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'} ], addEAA: 'Event', tooltipColumns: [ 'Name', ['(', 'Comment', ')'], '---', 'Date', 'Tag count' ] }, 'Instruments': { columnExtractors: [ { sourceColumn: 'Instrument', extractor: 'tagCount_Name_Comment', syntheticColumns: ['Name', 'Tag count', 'Comment'] } ], integerColumns: [ {sourceColumn: 'Tag count', align: 'R'} ] }, 'Labels': { columnExtractors: [ { sourceColumn: 'Label', extractor: 'tagCount_Name_Comment', syntheticColumns: ['Name', 'Tag count', 'Comment'] } ], integerColumns: [ {sourceColumn: 'Tag count', align: 'R'} ] }, 'Places': { columnExtractors: [ { sourceColumn: 'Place', extractor: 'tagCount_Name_Comment', syntheticColumns: ['Name', 'Tag count', 'Comment'] } ], integerColumns: [ {sourceColumn: 'Tag count', align: 'R'} ] }, 'Release groups': { columnExtractors: [ { sourceColumn: 'Release group', extractor: 'tagCount_Name_Comment_Artists', syntheticColumns: ['Name', 'Tag count', 'Comment', 'Artist'] } ], injectedColumns: [ 'Relationships' ], integerColumns: [ {sourceColumn: 'Tag count', align: 'R'} ], addCAA: 'Release group', tooltipColumns: [ 'Name', 'Artist', '---', ['Tag count'] ] }, 'Releases': { columnExtractors: [ { sourceColumn: 'Release', extractor: 'tagCount_Name_Comment_Artists', syntheticColumns: ['Name', 'Tag count', 'Comment', 'Artist'] } ], injectedColumns: [ 'Relationships' ], integerColumns: [ {sourceColumn: 'Tag count', align: 'R'} ], addCAA: 'Release', tooltipColumns: [ 'Name', 'Artist', '---', ['Tag count'] ] }, 'Recordings': { columnExtractors: [ { sourceColumn: 'Recording', extractor: 'tagCount_Name_Comment_Artists', syntheticColumns: ['Name', 'Tag count', 'Comment', 'Artist'] } ], integerColumns: [ {sourceColumn: 'Tag count', align: 'R'} ] }, 'Series': { columnExtractors: [ { sourceColumn: 'Series', extractor: 'tagCount_Name_Comment', syntheticColumns: ['Name', 'Tag count', 'Comment'] } ], integerColumns: [ {sourceColumn: 'Tag count', align: 'R'} ] }, 'Works': { columnExtractors: [ { sourceColumn: 'Work', extractor: 'tagCount_Name_Comment', syntheticColumns: ['Name', 'Tag count', 'Comment'] } ], integerColumns: [ {sourceColumn: 'Tag count', align: 'R'} ] } }, features: { // Empty sectionId triggers Structure C in applyListToTable: the // function scans for

                                                                      โ€ฆ
                                                                        pairs in the content area and uses // the h2 text up to "tagged", then singularises it as the column name // (e.g. "Release groups tagged as ยซgigยป" โ†’ "Release group"). listToTable: [ '' ] }, tableMode: 'single' }, // Tag pages (/tag/ e.g. /tag/country) { type: 'tag-value', match: (path) => path.match(/\/tag\//), buttons: [ { label: 'Show all Entities tagged' } ], entityFeatures: { 'Areas': { columnExtractors: [ { sourceColumn: 'Area', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] }, 'Artists': { columnExtractors: [ { sourceColumn: 'Artist', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] }, 'Events': { columnExtractors: [ { sourceColumn: 'Event', extractor: 'Name_Date_Comment', syntheticColumns: ['Name', 'Date', 'Comment'] } ], syntheticColumnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'} ], addEAA: 'Event', tooltipColumns: [ 'Name', ['(', 'Comment', ')'], '---', 'Date' ] }, 'Instruments': { columnExtractors: [ { sourceColumn: 'Instrument', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] }, 'Labels': { columnExtractors: [ { sourceColumn: 'Label', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] }, 'Places': { columnExtractors: [ { sourceColumn: 'Place', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] }, 'Release groups': { columnExtractors: [ { sourceColumn: 'Release group', extractor: 'Name_Comment_Artists', syntheticColumns: ['Name', 'Comment', 'Artist'] } ], injectedColumns: [ 'Relationships' ], addCAA: 'Release group', tooltipColumns: [ 'Name', 'Artist' ] }, 'Releases': { columnExtractors: [ { sourceColumn: 'Release', extractor: 'Name_Comment_Artists', syntheticColumns: ['Name', 'Comment', 'Artist'] } ], injectedColumns: [ 'Relationships' ], addCAA: 'Release', tooltipColumns: [ 'Name', 'Artist' ] }, 'Recordings': { columnExtractors: [ { sourceColumn: 'Recording', extractor: 'Name_Comment_Artists', syntheticColumns: ['Name', 'Comment', 'Artist'] } ] }, 'Series': { columnExtractors: [ { sourceColumn: 'Series', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] }, 'Works': { columnExtractors: [ { sourceColumn: 'Work', extractor: 'Name_Comment', syntheticColumns: ['Name', 'Comment'] } ] } }, features: { // Empty sectionId triggers Structure D in applyListToTable. listToTable: [ '' ] }, tableMode: 'multi' }, // Collections pages { type: 'user-collections', match: (path) => path.match(/\/user\/.*\/collections/), buttons: [ { label: 'Show all Collections for User' } ], tableMode: 'multi', features: { // The user-collections page has multiple

                                                                        -headed siblings // (one per collection category, e.g. "Own collections", "Subscribed // collections"). groupByH3 tells the fetch loop to iterate all tables // in tablesToProcess and walk backwards for each table's

                                                                        label // instead of using the single-table generic else-branch. groupByH3: true } }, { type: 'release-collections', match: (path) => path.match(/\/release\/[a-f0-9-]{36}\/collections/), buttons: [ { label: 'Show all Collections for Release' } ], features: { // Empty sectionId triggers Structure C in applyListToTable: the // function scans for

                                                                        โ€ฆ
                                                                          pairs in the content area and uses // the h2 text as the column name (e.g. "Collections"). listToTable: [ '' ], // Split the combined "CollectionName by Editor" cell into two columns. columnExtractors: [ { sourceColumn: 'Collections', extractor: 'Collection_Editor', syntheticColumns: ['Collection', 'Editor'] } ] }, tableMode: 'single' }, { type: 'releasegroup-collections', match: (path) => path.match(/\/release-group\/[a-f0-9-]{36}\/collections/), buttons: [ { label: 'Show all Collections for Release Group' } ], features: { // Empty sectionId triggers Structure C in applyListToTable: the // function scans for

                                                                          โ€ฆ
                                                                            pairs in the content area and uses // the h2 text as the column name (e.g. "Collections"). listToTable: [ '' ], // Split the combined "CollectionName by Editor" cell into two columns. columnExtractors: [ { sourceColumn: 'Collections', extractor: 'Collection_Editor', syntheticColumns: ['Collection', 'Editor'] } ] }, tableMode: 'single' }, { type: 'collections-releases', match: (path) => path.match(/\/collection\/[a-f0-9-]{36}/), // labelFromH2: true instructs the button-creation code to read the

                                                                            // header text that precedes the table and substitute it into the label so // the button reads "Show all for Collections" dynamically. // Collections pages can contain Releases, Events, Works, Recordings, etc. buttons: [ { label: 'Show all Releases for Collection', labelFromH2: true } ], // columnHeaderErasers: list of eraser tokens applied to

                                                          cells before // the header-scanning pass reads column names. Currently supports 'โ–ด/โ–พ' // which extracts only the text from link(s) inside a element changes width, so the * sticky column tracks manual and auto-resize operations correctly. * * The sticky style is applied to every rows (header + filter row) and every row. The z-index is * set to 101 for header cells (above the sticky thead at 100) and to 1 for body * cells (above sibling cells but below the header). * * Safe to call multiple times on the same table (idempotent via data attribute). * * @param {HTMLTableElement} table */ /** * Normalise the interior of every `` in `table`. * * **What this does (DOM structure only โ€” no style mutations)** * * For each `span.comment`: * 1. Remove pure-whitespace text-node children (`\n ` around ``). * 2. Move a leading non-empty text node (Variant A's `(`) into ``. * 3. Move a trailing non-empty text node (Variant A's `)`) into ``, * eliminating the bidi-boundary soft-wrap opportunity after ``. * 4. Trim trailing whitespace from the last text node inside `` (Variant B * emits `\n ` before ``; that collapses to a space and creates a * wrap point within the bdi content, e.g. "reissue)" breaking to a new line). * * For each `` row * in `table` according to the row's current visual position. * * MusicBrainz's own stylesheet drives the actual colours via those classes, so * we never hard-code any colour values โ€” the page theme always stays in sync. * * Calling this after every render and filter/sort operation ensures: * - Sorting never leaves adjacent rows with the same stripe (rows are * re-ordered but keep their old class if not corrected). * - Sticky column cells read `getComputedStyle(tr).backgroundColor` (the * actual rendered colour) and therefore get the right opaque background. * - Injected column cells (`. // So we read getComputedStyle on the CELL (not the row), but first clear // any previous inline background so the CSS class rule wins the cascade. // // Hover-highlight fix: because we bake the zebra background as an inline // cell.style.background, MusicBrainz's native tr:hover > td rule (and any // author-sheet tbody tr:hover rule) cannot override it โ€” inline styles win // the cascade unconditionally even against !important in a stylesheet. // We compensate by attaching mouseenter/mouseleave handlers on the : // on enter we swap the sticky cell's inline background to match its // non-sticky siblings' hover colour (read via getComputedStyle at event // time, when the browser has already applied the :hover rules to siblings); // on leave we restore the original zebra background stored in // data-mb-sticky-bg. The attribute is refreshed on every _apply() call // so re-runs after zebra re-striping always have the current rest colour. // Row hover highlight colour โ€” read once, shared by all row handlers. const hoverBgColor = Lib.settings.sa_ui_row_hover_bg || '#e2e2e2'; table.querySelectorAll('tbody tr').forEach(tr => { const cell = tr.cells[stickyIdx]; if (!cell) return; cell.style.position = 'sticky'; cell.style.left = leftPx; cell.style.zIndex = '1'; // โ”€โ”€ Snapshot rest-state backgrounds for every cell in this row โ”€โ”€ // // MusicBrainz's native `tr:hover > td` CSS rule is frequently // defeated by inline style.background values on
                                                          , concatenating // them with any literal text nodes between them (e.g. produces "Country/Date" // from two links separated by a "/" text node). columnHeaderErasers: [ 'โ–ด/โ–พ' ], // entityFeatures maps each possible H2 entity-type heading to its own // feature set. When the page is activated, resolveSeriesEntityFeatures() // reads the live H2 text and picks the matching entry. If no match is // found the fallback first-key feature set is used so the page still works // for unrecognised entity types. entityFeatures: { 'Areas': { columnExtractors: [ { sourceColumn: 'Area', extractor: 'splitArea', syntheticColumns: ['MB-Area', 'Country'] } ], extractMainColumn: 'Area', stickyColumn: 'Area' }, 'Artists': { columnExtractors: [ { sourceColumn: 'Area', extractor: 'splitArea', syntheticColumns: ['MB-Area', 'Country'] }, { sourceColumn: 'Begin area', extractor: 'splitArea', syntheticColumns: ['MB-Begin area', 'Begin country'] }, { sourceColumn: 'End area', extractor: 'splitArea', syntheticColumns: ['MB-End area', 'End country'] }, { sourceColumn: 'Begin', extractor: 'dateParts', syntheticColumns: ['B-DD', 'B-MM', 'B-YYYY', 'B-Day', 'B-Month'] }, { sourceColumn: 'End', extractor: 'dateParts', syntheticColumns: ['E-DD', 'E-MM', 'E-YYYY', 'E-Day', 'E-Month'] } ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'}, {sourceColumn: 'B-DD', align: 'R'}, {sourceColumn: 'B-MM', align: 'R'}, {sourceColumn: 'B-YYYY', align: 'C'}, {sourceColumn: 'E-DD', align: 'R'}, {sourceColumn: 'E-MM', align: 'R'}, {sourceColumn: 'E-YYYY', align: 'C'} ], extractMainColumn: 'Artist', stickyColumn: 'Artist' }, 'Events': { columnExtractors: [ { sourceColumn: 'Event', extractor: 'cancelledEvent', syntheticColumns: ['Cancelled'] }, { sourceColumn: 'Event', extractor: 'caa', syntheticColumns: ['EAA'] }, { sourceColumn: 'Location', extractor: 'splitLocation', syntheticColumns: ['Place', 'Area', 'Country'] }, { sourceColumn: 'Event', extractor: 'primaryAlias', syntheticColumns: ['Primary Alias'] }, { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], collapsableColumns: [ 'Artists', 'Location', 'EAA', 'Place', 'Area', 'Country' ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'} ], tooltipColumns: [ 'MB-Name', 'italic:Comment', 'Primary Alias', '---', 'Artists', 'Location', ['Date', '(', 'Time', ')'], 'Cancelled' ], addEAA: 'Event', extractMainColumn: 'Event', stickyColumn: 'Event' }, 'Genres': { extractMainColumn: 'Genre', stickyColumn: 'Genre' }, 'Instruments': { extractMainColumn: 'Instrument', stickyColumn: 'Instrument' }, 'Labels': { columnExtractors: [ { sourceColumn: 'Area', extractor: 'splitArea', syntheticColumns: ['MB-Area', 'Country'] }, { sourceColumn: 'Begin', extractor: 'dateParts', syntheticColumns: ['B-DD', 'B-MM', 'B-YYYY', 'B-Day', 'B-Month'] }, { sourceColumn: 'End', extractor: 'dateParts', syntheticColumns: ['E-DD', 'E-MM', 'E-YYYY', 'E-Day', 'E-Month'] } ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'}, {sourceColumn: 'B-DD', align: 'R'}, {sourceColumn: 'B-MM', align: 'R'}, {sourceColumn: 'B-YYYY', align: 'C'}, {sourceColumn: 'E-DD', align: 'R'}, {sourceColumn: 'E-MM', align: 'R'}, {sourceColumn: 'E-YYYY', align: 'C'} ], extractMainColumn: 'Label', stickyColumn: 'Label' }, 'Recordings': { columnExtractors: [ { sourceColumn: 'Name', extractor: 'video', syntheticColumns: ['Video'] } ], integerColumns: [ {sourceColumn: 'Length', align: ':'} ], extractMainColumn: 'Name', stickyColumn: 'Name' }, 'Releases': { columnExtractors: [ { sourceColumn: 'Release', extractor: 'caa', syntheticColumns: ['CAA'] }, { sourceColumn: 'Country/Date', extractor: 'splitCountryDate', syntheticColumns: ['Country', 'Date'] }, { sourceColumn: 'Tracks', extractor: 'sumTracks', syntheticColumns: ['Total Tracks'] }, { sourceColumn: 'Format', extractor: 'extractFormatTypes', syntheticColumns: ['Format Types'] } ], syntheticColumnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], injectedColumns: [ 'Relationships' ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'}, {sourceColumn: 'Total Tracks', align: 'R'} ], renderMultiRowCell: [ 'Label', 'Catalog#' ], collapsableColumns: [ 'Country/Date', 'Country', 'Date', 'Label', 'Catalog#', 'CAA' ], tooltipColumns: [ 'MB-Name', 'italic:Comment', 'Artist', '---', ['Format', '(', 'Tracks', ')'], 'Country/Date', ['Label', '-', 'Catalog#'], 'Barcode' ], addCAA: 'Release', extractMainColumn: 'Release', stickyColumn: 'Release' }, 'Release groups': { columnExtractors: [ { sourceColumn: 'Title', extractor: 'caa', syntheticColumns: ['CAA'] } ], injectedColumns: [ 'Relationships' ], integerColumns: [ {sourceColumn: 'Releases', align: 'R'}, {sourceColumn: 'Year', align: 'C'} ], collapsableColumns: [ 'CAA' ], tooltipColumns: [ 'Title', 'Artist', '---', ['Year', '(', 'Releases', ')'] ], addCAA: 'Title', extractMainColumn: 'Title', stickyColumn: 'Title', // Release-groups collection pages have native h3-grouped sub-tables // (one per release type, just like artist-releasegroups). Setting // tableMode here overrides the collections-releases base 'single' so // the multi-table render path (renderGroupedTable) is used instead of // renderFinalTable. columnHeaderErasers: ['โ–ด/โ–พ'] on the base // definition continues to apply per fetched page because it is read // from activeDefinition.columnHeaderErasers which is set at button- // click time from the (still-current) baseDef. tableMode: 'multi' }, 'Series': { integerColumns: [ {sourceColumn: 'Number of entities', align: 'R'} ], extractMainColumn: 'Series', stickyColumn: 'Series' }, 'Works': { collapsableColumns: [ 'Authors', 'Recording artists', 'Other artists', 'ISWC', 'Attributes' ], extractMainColumn: 'Work', stickyColumn: 'Work' } }, tableMode: 'single' }, // Search pages { type: 'search', match: (path) => path.includes('/search'), // labelFromType: true reads ?type= from the URL and appends the entity // type to the button label dynamically, e.g. "Show all Search Results for Releases". buttons: [ { label: 'Show all Search Results', labelFromType: true } ], tableMode: 'single', rowTargetSelector: 'p.pageselector-results', // Specific target for Search pages features: { transformToH2: true, // New flag to trigger

                                                          transformation // Inject a "Searchform" h2 before the existing (single) h2 on the page // so the collapsible-section infrastructure can wrap the search form. insertPrependH2: 'Searchform' }, // Per-entity feature sets resolved from ?type= URL parameter via // resolveEntityFeaturesFromH2 (which has a special branch for pageType 'search'). // Keys are the plural, title-cased entity-type labels produced by // _urlTypeToEntityFeatureKey() from the raw URL type value. entityFeatures: { 'Releases': { columnExtractors: [ { sourceColumn: 'Name', extractor: 'caa', syntheticColumns: ['CAA'] }, { sourceColumn: 'Country/Date', extractor: 'splitCountryDate', syntheticColumns: ['Country', 'Date'] }, { sourceColumn: 'Tracks', extractor: 'sumTracks', syntheticColumns: ['Total Tracks'] }, { sourceColumn: 'Format', extractor: 'extractFormatTypes', syntheticColumns: ['Format Types'] } ], syntheticColumnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], injectedColumns: [ 'Relationships' ], integerColumns: [ { sourceColumn: 'DD', align: 'R' }, { sourceColumn: 'MM', align: 'R' }, { sourceColumn: 'YYYY', align: 'C' }, { sourceColumn: 'Total Tracks', align: 'R' } ], collapsableColumns: [ 'Country/Date', 'Country', 'Date', 'CAA', 'Label', 'Catalog#' ], tooltipColumns: [ 'MB-Name', 'italic:Comment', 'Artist', '---', ['Format', '(', 'Tracks', ')'], 'Country/Date', ['Label', '-', 'Catalog#'], 'Barcode', 'Language', 'Type', 'Status' ], renderMultiRowCell: [ 'Label', 'Catalog#' ], addCAA: 'Name', extractMainColumn: 'Name', stickyColumn: 'Name' }, 'Events': { columnExtractors: [ { sourceColumn: 'Name', extractor: 'cancelledEvent', syntheticColumns: ['Cancelled'] }, { sourceColumn: 'Name', extractor: 'caa', syntheticColumns: ['EAA'] }, { sourceColumn: 'Location', extractor: 'splitLocation', syntheticColumns: ['Place', 'Area', 'Country'] }, { sourceColumn: 'Name', extractor: 'primaryAlias', syntheticColumns: ['Primary Alias'] }, { sourceColumn: 'Name', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], collapsableColumns: [ 'Artists', 'Location', 'EAA', 'Place', 'Area', 'Country' ], integerColumns: [ { sourceColumn: 'DD', align: 'R' }, { sourceColumn: 'MM', align: 'R' }, { sourceColumn: 'YYYY', align: 'C' } ], tooltipColumns: [ 'MB-Name', 'italic:Comment', 'Primary Alias', '---', 'Artists', 'Location', ['Date', '(', 'Time', ')'], 'Cancelled' ], addEAA: 'Name', extractMainColumn: 'Name', stickyColumn: 'Name' }, 'Release groups': { columnExtractors: [ { sourceColumn: 'Release group', extractor: 'caa', syntheticColumns: ['CAA'] } ], tooltipColumns: [ 'Release group', 'Artist', 'Type' ], injectedColumns: [ 'Relationships' ], collapsableColumns: [ 'CAA' ], addCAA: 'Release group', extractMainColumn: 'Release group', stickyColumn: 'Release group' }, 'Recordings': { columnExtractors: [ { sourceColumn: 'Name', extractor: 'video', syntheticColumns: ['Video'] } ], collapsableColumns: [ 'ISRCs', 'Release' ], integerColumns: [ { sourceColumn: 'Medium', align: 'R' }, { sourceColumn: 'Track', align: 'R' }, { sourceColumn: 'Length', align: ':' } ], addCAA: 'Release', extractMainColumn: 'Name', stickyColumn: 'Name' }, 'Artists': { columnExtractors: [ { sourceColumn: 'Begin', extractor: 'dateParts', syntheticColumns: ['B-DD', 'B-MM', 'B-YYYY', 'B-Day', 'B-Month'] }, { sourceColumn: 'End', extractor: 'dateParts', syntheticColumns: ['E-DD', 'E-MM', 'E-YYYY', 'E-Day', 'E-Month'] } ], integerColumns: [ { sourceColumn: 'B-DD', align: 'R' }, { sourceColumn: 'B-MM', align: 'R' }, { sourceColumn: 'B-YYYY', align: 'C' }, { sourceColumn: 'E-DD', align: 'R' }, { sourceColumn: 'E-MM', align: 'R' }, { sourceColumn: 'E-YYYY', align: 'C' } ], extractMainColumn: 'Name', stickyColumn: 'Name' }, 'Places': { columnExtractors: [ { sourceColumn: 'Area', extractor: 'splitArea', syntheticColumns: ['MB-Area', 'Country'] }, { sourceColumn: 'Begin', extractor: 'dateParts', syntheticColumns: ['B-DD', 'B-MM', 'B-YYYY', 'B-Day', 'B-Month'] }, { sourceColumn: 'End', extractor: 'dateParts', syntheticColumns: ['E-DD', 'E-MM', 'E-YYYY', 'E-Day', 'E-Month'] } ], integerColumns: [ { sourceColumn: 'B-DD', align: 'R' }, { sourceColumn: 'B-MM', align: 'R' }, { sourceColumn: 'B-YYYY', align: 'C' }, { sourceColumn: 'E-DD', align: 'R' }, { sourceColumn: 'E-MM', align: 'R' }, { sourceColumn: 'E-YYYY', align: 'C' } ], extractMainColumn: 'Name', stickyColumn: 'Name' }, 'Areas': { columnExtractors: [ { sourceColumn: 'Name', extractor: 'splitArea', syntheticColumns: ['Area', 'Country'] }, { sourceColumn: 'Begin', extractor: 'dateParts', syntheticColumns: ['B-DD', 'B-MM', 'B-YYYY', 'B-Day', 'B-Month'] }, { sourceColumn: 'End', extractor: 'dateParts', syntheticColumns: ['E-DD', 'E-MM', 'E-YYYY', 'E-Day', 'E-Month'] } ], integerColumns: [ { sourceColumn: 'B-DD', align: 'R' }, { sourceColumn: 'B-MM', align: 'R' }, { sourceColumn: 'B-YYYY', align: 'C' }, { sourceColumn: 'E-DD', align: 'R' }, { sourceColumn: 'E-MM', align: 'R' }, { sourceColumn: 'E-YYYY', align: 'C' } ], stickyColumn: 'Name' }, 'Labels': { columnExtractors: [ { sourceColumn: 'Begin', extractor: 'dateParts', syntheticColumns: ['B-DD', 'B-MM', 'B-YYYY', 'B-Day', 'B-Month'] }, { sourceColumn: 'End', extractor: 'dateParts', syntheticColumns: ['E-DD', 'E-MM', 'E-YYYY', 'E-Day', 'E-Month'] } ], integerColumns: [ { sourceColumn: 'B-DD', align: 'R' }, { sourceColumn: 'B-MM', align: 'R' }, { sourceColumn: 'B-YYYY', align: 'C' }, { sourceColumn: 'E-DD', align: 'R' }, { sourceColumn: 'E-MM', align: 'R' }, { sourceColumn: 'E-YYYY', align: 'C' } ], extractMainColumn: 'Name', stickyColumn: 'Name' }, 'Series': { integerColumns: [ { sourceColumn: 'Number of entities', align: 'R' } ], extractMainColumn: 'Name', stickyColumn: 'Name' }, 'CD stubs': { integerColumns: [ { sourceColumn: 'Tracks', align: 'R' } ], stickyColumn: 'CD stub' }, 'Works': { collapsableColumns: [ 'Authors', 'Recording artists', 'Other artists', 'ISWC' ], extractMainColumn: 'Name', stickyColumn: 'Name' }, 'Tags': { stickyColumn: 'Name' }, 'Instruments': { extractMainColumn: 'Name', stickyColumn: 'Name' }, 'Annotations': { collapsableColumns: [ 'Annotation' ], injectedColumns: [ 'Relationships' ], addCAA: 'Name', extractMainColumn: 'Name', stickyColumn: 'Name' }, 'Editors': { stickyColumn: 'Name' } } }, // Instrument pages { type: 'instrument-artists', match: (path) => path.match(/\/instrument\/[a-f0-9-]{36}\/artists/), buttons: [ { label: 'Show all Artists for Instrument' } ], features: { columnExtractors: [ { sourceColumn: 'Area', extractor: 'splitArea', syntheticColumns: ['MB-Area', 'Country'] } ], renderMultiRowCell: [ 'Relationship types' ], collapsableColumns: [ 'Relationship types' ], extractMainColumn: 'Artist', // Specific header stickyColumn: 'Artist' }, tableMode: 'single' }, { type: 'instrument-releases', match: (path) => path.match(/\/instrument\/[a-f0-9-]{36}\/releases/), buttons: [ { label: 'Show all Releases for Instrument' } ], features: { columnExtractors: [ { sourceColumn: 'Country/Date', extractor: 'splitCountryDate', syntheticColumns: ['Country', 'Date'] }, { sourceColumn: 'Tracks', extractor: 'sumTracks', syntheticColumns: ['Total Tracks'] } ], syntheticColumnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], injectedColumns: [ 'Relationships' ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'}, {sourceColumn: 'Total Tracks', align: 'R'} ], renderMultiRowCell: [ 'Label', 'Catalog#', 'Relationship types' ], collapsableColumns: [ 'Country/Date' ,'Country', 'Date', 'Label', 'Catalog#', 'Relationship types' ], addCAA: 'Release', extractMainColumn: 'Release', stickyColumn: 'Release' }, tableMode: 'single' }, { type: 'instrument-recordings', match: (path) => path.match(/\/instrument\/[a-f0-9-]{36}\/recordings/), buttons: [ { label: 'Show all Recordings for Instrument' } ], features: { columnExtractors: [ { sourceColumn: 'Name', extractor: 'video', syntheticColumns: ['Video'] } ], syntheticColumnExtractors: [ { sourceColumn: 'Comment', extractor: 'eventParts', syntheticColumns: ['Event-Type', 'Event-Date', 'Event-Detail', 'Event-Venue', 'Event-Venue-Detail', 'Event-City', 'Event-State', 'Event-Country', 'Event-Additional-Info'] } ], renderMultiRowCell: [ 'Relationship types' ], collapsableColumns: [ 'ISRCs', 'Relationship types' ], integerColumns: [ { sourceColumn: 'Length', align: ':' } ], extractMainColumn: 'Name', stickyColumn: 'Name' }, tableMode: 'single' }, { type: 'instrument-aliases', match: (path) => path.match(/\/instrument\/[a-f0-9-]{36}\/aliases/), buttons: [ { label: 'Show all Aliases for Instrument' } ], // extractMainColumn: 'Locale' was intentionally removed for all *-aliases page definitions. // The Locale column on alias tables contains plain locale text (e.g. "English United States") // followed by a primary role indicator, NOT an entity link. // Extracting it as MB-Name / Comment incorrectly polluted those synthetic columns. // (Bug fix: v9.99.35) //features: { // extractMainColumn: 'Locale' // Specific header //}, features: { columnExtractors: [ { sourceColumn: 'Begin date', extractor: 'dateParts', syntheticColumns: ['B-DD', 'B-MM', 'B-YYYY', 'B-Day', 'B-Month'] }, { sourceColumn: 'End date', extractor: 'dateParts', syntheticColumns: ['E-DD', 'E-MM', 'E-YYYY', 'E-Day', 'E-Month'] } ], integerColumns: [ { sourceColumn: 'B-DD', align: 'R' }, { sourceColumn: 'B-MM', align: 'R' }, { sourceColumn: 'B-YYYY', align: 'C' }, { sourceColumn: 'E-DD', align: 'R' }, { sourceColumn: 'E-MM', align: 'R' }, { sourceColumn: 'E-YYYY', align: 'C' } ] }, tableMode: 'single' }, // Area pages { type: 'area-artists', match: (path) => path.match(/\/area\/[a-f0-9-]{36}\/artists/), buttons: [ { label: 'Show all Artists for Area' } ], features: { columnExtractors: [ { sourceColumn: 'Area', extractor: 'splitArea', syntheticColumns: ['MB-Area', 'Country'] }, { sourceColumn: 'Begin area', extractor: 'splitArea', syntheticColumns: ['MB-Begin area', 'Begin country'] }, { sourceColumn: 'End area', extractor: 'splitArea', syntheticColumns: ['MB-End area', 'End country'] }, { sourceColumn: 'Begin', extractor: 'dateParts', syntheticColumns: ['B-DD', 'B-MM', 'B-YYYY', 'B-Day', 'B-Month'] }, { sourceColumn: 'End', extractor: 'dateParts', syntheticColumns: ['E-DD', 'E-MM', 'E-YYYY', 'E-Day', 'E-Month'] } ], integerColumns: [ {sourceColumn: 'B-DD', align: 'R'}, {sourceColumn: 'B-MM', align: 'R'}, {sourceColumn: 'B-YYYY', align: 'C'}, {sourceColumn: 'E-DD', align: 'R'}, {sourceColumn: 'E-MM', align: 'R'}, {sourceColumn: 'E-YYYY', align: 'C'} ], extractMainColumn: 'Artist', stickyColumn: 'Artist' }, tableMode: 'single' }, { type: 'area-events', match: (path) => path.match(/\/area\/[a-f0-9-]{36}\/events/), buttons: [ { label: 'Show all Events for Area' } ], features: { columnExtractors: [ { sourceColumn: 'Event', extractor: 'cancelledEvent', syntheticColumns: ['Cancelled'] }, { sourceColumn: 'Event', extractor: 'caa', syntheticColumns: ['EAA'] }, { sourceColumn: 'Location', extractor: 'splitLocation', syntheticColumns: ['Place', 'Area', 'Country'] }, { sourceColumn: 'Event', extractor: 'primaryAlias', syntheticColumns: ['Primary Alias'] }, { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], collapsableColumns: [ 'Artists', 'Location', 'EAA', 'Place', 'Area', 'Country' ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'} ], tooltipColumns: [ 'MB-Name', 'italic:Comment', 'Primary Alias', 'Type', '---', 'Artists', 'Location', ['Date', '(', 'Time', ')'], 'Cancelled' ], addEAA: 'Event', extractMainColumn: 'Event', stickyColumn: 'Event' }, tableMode: 'single' }, { type: 'area-labels', match: (path) => path.match(/\/area\/[a-f0-9-]{36}\/labels/), buttons: [ { label: 'Show all Labels for Area' } ], features: { columnExtractors: [ { sourceColumn: 'Area', extractor: 'splitArea', syntheticColumns: ['MB-Area', 'Country'] }, { sourceColumn: 'Begin', extractor: 'dateParts', syntheticColumns: ['B-DD', 'B-MM', 'B-YYYY', 'B-Day', 'B-Month'] }, { sourceColumn: 'End', extractor: 'dateParts', syntheticColumns: ['E-DD', 'E-MM', 'E-YYYY', 'E-Day', 'E-Month'] } ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'}, {sourceColumn: 'B-DD', align: 'R'}, {sourceColumn: 'B-MM', align: 'R'}, {sourceColumn: 'B-YYYY', align: 'C'}, {sourceColumn: 'E-DD', align: 'R'}, {sourceColumn: 'E-MM', align: 'R'}, {sourceColumn: 'E-YYYY', align: 'C'} ], extractMainColumn: 'Label', stickyColumn: 'Label' }, tableMode: 'single' }, { type: 'area-users', match: (path) => path.match(/\/area\/[a-f0-9-]{36}\/users/), buttons: [ { label: 'Show all Users for Area' } ], features: { // Empty sectionId triggers Structure C in applyListToTable: the // function scans for

                                                          โ€ฆ
                                                            pairs in the content area and uses // the h2 text as the column name (e.g. "Users"). listToTable: [ '' ] }, tableMode: 'single' }, { type: 'area-releases-filtered', match: (path) => path.match(/\/area\/[a-f0-9-]{36}\/releases/) && params.has('link_type_id'), buttons: [ { label: 'Show all Release Relationships for Area (specialized)', targetHeader: 'Relationships', tableMode: 'single', non_paginated: false, features: { injectedColumns: [ 'Relationships' ], columnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'} ], addCAA: 'Title', extractMainColumn: 'Title', stickyColumn: 'Title' } } ] }, { type: 'area-releases', match: (path) => path.match(/\/area\/[a-f0-9-]{36}\/releases/) && !params.has('link_type_id'), buttons: [ { label: 'Show all Releases for Area', // "Area-Relases" pages have a paginated "Releases" and multi-table "Relationships" section // The 'targetHeader' parameter is used to distinguish them targetHeader: 'Releases', tableMode: 'single', features: { columnExtractors: [ { sourceColumn: 'Country/Date', extractor: 'splitCountryDate', syntheticColumns: ['Country', 'Date'] }, { sourceColumn: 'Tracks', extractor: 'sumTracks', syntheticColumns: ['Total Tracks'] }, ], syntheticColumnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], injectedColumns: [ 'Relationships' ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'}, {sourceColumn: 'Total Tracks', align: 'R'} ], renderMultiRowCell: [ 'Label', 'Catalog#' ], collapsableColumns: [ 'Country/Date' ,'Country', 'Date', 'Label', 'Catalog#' ], addCAA: 'Release', extractMainColumn: 'Release', stickyColumn: 'Release' } }, { label: 'Show all Release Relationships for Area', // "Area-Relases" pages have a paginated "Releases" and multi-table "Relationships" section // The 'targetHeader' parameter is used to distinguish them targetHeader: 'Relationships', tableMode: 'multi', non_paginated: true, features: { injectedColumns: [ 'Relationships' ], columnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'} ], addCAA: 'Title', extractMainColumn: 'Title', stickyColumn: 'Title' } } ] }, { type: 'area-places', match: (path) => path.match(/\/area\/[a-f0-9-]{36}\/places/), buttons: [ { label: 'Show all Places for Area' } ], features: { columnExtractors: [ { sourceColumn: 'Area', extractor: 'splitArea', syntheticColumns: ['MB-Area', 'Country'] } ], extractMainColumn: 'Place', stickyColumn: 'Place' }, tableMode: 'single' }, { type: 'area-aliases', match: (path) => path.match(/\/area\/[a-f0-9-]{36}\/aliases/), buttons: [ { label: 'Show all Aliases for Area' } ], features: { columnExtractors: [ { sourceColumn: 'Begin date', extractor: 'dateParts', syntheticColumns: ['B-DD', 'B-MM', 'B-YYYY', 'B-Day', 'B-Month'] }, { sourceColumn: 'End date', extractor: 'dateParts', syntheticColumns: ['E-DD', 'E-MM', 'E-YYYY', 'E-Day', 'E-Month'] } ], integerColumns: [ { sourceColumn: 'B-DD', align: 'R' }, { sourceColumn: 'B-MM', align: 'R' }, { sourceColumn: 'B-YYYY', align: 'C' }, { sourceColumn: 'E-DD', align: 'R' }, { sourceColumn: 'E-MM', align: 'R' }, { sourceColumn: 'E-YYYY', align: 'C' } ] }, tableMode: 'single' }, { type: 'area-recordings-filtered', match: (path, params) => path.match(/\/area\/[a-f0-9-]{36}\/recordings/) && params.has('link_type_id'), buttons: [ { label: 'Show all Recordings for Area (specialized)' } ], features: { columnExtractors: [ { sourceColumn: 'Title', extractor: 'video', syntheticColumns: ['Video'] }, { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], syntheticColumnExtractors: [ { sourceColumn: 'Comment', extractor: 'eventParts', syntheticColumns: ['Event-Type', 'Event-Date', 'Event-Detail', 'Event-Venue', 'Event-Venue-Detail', 'Event-City', 'Event-State', 'Event-Country', 'Event-Additional-Info'] } ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'}, {sourceColumn: 'Length', align: ':'} ], extractMainColumn: 'Title', stickyColumn: 'Title' }, tableMode: 'single' // Paginated single list }, { type: 'area-recordings', match: (path, params) => path.match(/\/area\/[a-f0-9-]{36}\/recordings/) && !params.has('link_type_id'), buttons: [ { label: 'Show all Recordings for Area' } ], features: { columnExtractors: [ { sourceColumn: 'Title', extractor: 'video', syntheticColumns: ['Video'] }, { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], syntheticColumnExtractors: [ { sourceColumn: 'Comment', extractor: 'eventParts', syntheticColumns: ['Event-Type', 'Event-Date', 'Event-Detail', 'Event-Venue', 'Event-Venue-Detail', 'Event-City', 'Event-State', 'Event-Country', 'Event-Additional-Info'] } ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'}, {sourceColumn: 'Length', align: ':'} ], extractMainColumn: 'Title', stickyColumn: 'Title' }, tableMode: 'multi', non_paginated: true }, { type: 'area-works-filtered', match: (path, params) => path.match(/\/area\/[a-f0-9-]{36}\/works/) && params.has('link_type_id'), buttons: [ { label: 'Show all Works for Area (specialized)' } ], features: { columnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'} ], extractMainColumn: 'Title', stickyColumn: 'Title' }, tableMode: 'single' }, { type: 'area-works', match: (path, params) => path.match(/\/area\/[a-f0-9-]{36}\/works/) && !params.has('link_type_id'), buttons: [ { label: 'Show all Works for Area' } ], tableMode: 'multi', features: { columnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'} ], extractMainColumn: 'Title', stickyColumn: 'Title' }, non_paginated: true }, // Place pages { type: 'place-aliases', match: (path) => path.match(/\/place\/[a-f0-9-]{36}\/aliases/), buttons: [ { label: 'Show all Aliases for Place' } ], features: { columnExtractors: [ { sourceColumn: 'Begin date', extractor: 'dateParts', syntheticColumns: ['B-DD', 'B-MM', 'B-YYYY', 'B-Day', 'B-Month'] }, { sourceColumn: 'End date', extractor: 'dateParts', syntheticColumns: ['E-DD', 'E-MM', 'E-YYYY', 'E-Day', 'E-Month'] } ], integerColumns: [ { sourceColumn: 'B-DD', align: 'R' }, { sourceColumn: 'B-MM', align: 'R' }, { sourceColumn: 'B-YYYY', align: 'C' }, { sourceColumn: 'E-DD', align: 'R' }, { sourceColumn: 'E-MM', align: 'R' }, { sourceColumn: 'E-YYYY', align: 'C' } ] }, tableMode: 'single' }, { type: 'place-events', match: (path) => path.match(/\/place\/[a-f0-9-]{36}\/events/), buttons: [ { label: 'Show all Events for Place' } ], features: { columnExtractors: [ { sourceColumn: 'Event', extractor: 'cancelledEvent', syntheticColumns: ['Cancelled'] }, { sourceColumn: 'Event', extractor: 'caa', syntheticColumns: ['EAA'] }, { sourceColumn: 'Event', extractor: 'primaryAlias', syntheticColumns: ['Primary Alias'] }, { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], collapsableColumns: [ 'Artists', 'EAA' ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'} ], tooltipColumns: [ 'MB-Name', 'italic:Comment', 'Primary Alias', 'Type', '---', 'Artists', ['Date', '(', 'Time', ')'], 'Cancelled' ], addEAA: 'Event', extractMainColumn: 'Event', stickyColumn: 'Event' }, tableMode: 'single' }, { type: 'place-performances-filtered', match: (path, params) => path.match(/\/place\/[a-f0-9-]{36}\/performances/) && params.has('link_type_id'), buttons: [ { label: 'Show all Performances for Place (specialized)' } ], features: { columnExtractors: [ { sourceColumn: 'Title', extractor: 'video', syntheticColumns: ['Video'] }, { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], injectedColumns: [ 'Release events', 'Relationships' ], injectedColumnExtractors: [ { sourceColumn: 'Release events', extractor: 'splitCountryDate', syntheticColumns: ['Release country', 'Release date'] }, { sourceColumn: 'Release date', extractor: 'dateParts', syntheticColumns: ['R-DD', 'R-MM', 'R-YYYY', 'R-Day', 'R-Month'] } ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'}, {sourceColumn: 'R-DD', align: 'R'}, {sourceColumn: 'R-MM', align: 'R'}, {sourceColumn: 'R-YYYY', align: 'C'}, {sourceColumn: 'Length', align: ':'} ], collapsableColumns: [ 'Release events' ], addCAA: 'Title', extractMainColumn: 'Title', stickyColumn: 'Title' }, tableMode: 'single' }, { type: 'place-performances', match: (path, params) => path.match(/\/place\/[a-f0-9-]{36}\/performances/) && !params.has('link_type_id'), buttons: [ { label: 'Show all Performances for Place' } ], features: { columnExtractors: [ { sourceColumn: 'Title', extractor: 'video', syntheticColumns: ['Video'] }, { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], injectedColumns: [ 'Release events', 'Relationships' ], injectedColumnExtractors: [ { sourceColumn: 'Release events', extractor: 'splitCountryDate', syntheticColumns: ['Release country', 'Release date'] }, { sourceColumn: 'Release date', extractor: 'dateParts', syntheticColumns: ['R-DD', 'R-MM', 'R-YYYY', 'R-Day', 'R-Month'] } ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'}, {sourceColumn: 'R-DD', align: 'R'}, {sourceColumn: 'R-MM', align: 'R'}, {sourceColumn: 'R-YYYY', align: 'C'}, {sourceColumn: 'Length', align: ':'} ], collapsableColumns: [ 'Release events' ], addCAA: 'Title', extractMainColumn: 'Title', stickyColumn: 'Title' }, tableMode: 'multi', non_paginated: true }, // Series pages { type: 'series-aliases', match: (path) => path.match(/\/series\/[a-f0-9-]{36}\/aliases/), buttons: [ { label: 'Show all Aliases for Series' } ], features: { columnExtractors: [ { sourceColumn: 'Begin date', extractor: 'dateParts', syntheticColumns: ['B-DD', 'B-MM', 'B-YYYY', 'B-Day', 'B-Month'] }, { sourceColumn: 'End date', extractor: 'dateParts', syntheticColumns: ['E-DD', 'E-MM', 'E-YYYY', 'E-Day', 'E-Month'] } ], integerColumns: [ { sourceColumn: 'B-DD', align: 'R' }, { sourceColumn: 'B-MM', align: 'R' }, { sourceColumn: 'B-YYYY', align: 'C' }, { sourceColumn: 'E-DD', align: 'R' }, { sourceColumn: 'E-MM', align: 'R' }, { sourceColumn: 'E-YYYY', align: 'C' } ] }, tableMode: 'single' }, { type: 'series-releases', match: (path) => path.includes('/series'), // labelFromH2: true instructs the button-creation code to read the

                                                            // header text that precedes the table and substitute it into the label so // the button reads "Show all for Series" dynamically. // Series pages can contain Releases, Events, Works, Recordings, etc. buttons: [ { label: 'Show all Releases for Series', labelFromH2: true } ], // entityFeatures maps each possible H2 entity-type heading to its own // feature set. When the page is activated, resolveSeriesEntityFeatures() // reads the live H2 text and picks the matching entry. If no match is // found the fallback 'Releases' feature set is used so the page still // works for unrecognised entity types. entityFeatures: { 'Releases': { columnErasers: [ { sourceColumn: 'Release', erasers: ['โ–ถ', 'โž•'] } ], columnExtractors: [ { sourceColumn: 'Country/Date', extractor: 'splitCountryDate', syntheticColumns: ['Country', 'Date'] }, { sourceColumn: 'Tracks', extractor: 'sumTracks', syntheticColumns: ['Total Tracks'] }, { sourceColumn: 'Format', extractor: 'extractFormatTypes', syntheticColumns: ['Format Types'] } ], syntheticColumnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], injectedColumns: [ 'Relationships' ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'}, {sourceColumn: 'Total Tracks', align: 'R'} ], collapsableColumns: [ 'Country/Date', 'Country', 'Date', 'CAA' ], tooltipColumns: [ 'MB-Name', 'italic:Comment', 'Artist', '---', ['Format', '(', 'Tracks', ')'], 'Country/Date', ['Label', '-', 'Catalog#'], 'Barcode' ], addCAA: 'Release', extractMainColumn: 'Release', stickyColumn: 'Release' }, 'Events': { columnExtractors: [ { sourceColumn: 'Event', extractor: 'cancelledEvent', syntheticColumns: ['Cancelled'] }, { sourceColumn: 'Event', extractor: 'caa', syntheticColumns: ['EAA'] }, { sourceColumn: 'Location', extractor: 'splitLocation', syntheticColumns: ['Place', 'Area', 'Country'] }, { sourceColumn: 'Event', extractor: 'primaryAlias', syntheticColumns: ['Primary Alias'] }, { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], collapsableColumns: [ 'Artists', 'Location', 'EAA', 'Place', 'Area', 'Country' ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'} ], tooltipColumns: [ 'MB-Name', 'italic:Comment', 'Primary Alias', '---', 'Artists', 'Location', ['Date', '(', 'Time', ')'], 'Cancelled' ], addEAA: 'Event', extractMainColumn: 'Event', stickyColumn: 'Event' }, 'Release groups': { columnErasers: [ { sourceColumn: 'Title', erasers: ['โ–ถ', 'jesus2099'] } ], columnExtractors: [ { sourceColumn: 'Title', extractor: 'caa', syntheticColumns: ['CAA'] } ], tooltipColumns: [ 'Title', 'Artist', '---', ['Type', 'Year', '(', 'Releases', ')'] ], injectedColumns: [ 'Relationships' ], integerColumns: [ {sourceColumn: 'Releases', align: 'R'}, {sourceColumn: '#', align: 'R'}, {sourceColumn: 'Year', align: 'C'} ], collapsableColumns: [ 'CAA' ], addCAA: 'Title', extractMainColumn: 'Title', stickyColumn: 'Title' }, 'Recordings': { columnExtractors: [ { sourceColumn: 'Name', extractor: 'video', syntheticColumns: ['Video'] } ], integerColumns: [ {sourceColumn: 'Length', align: ':'} ], extractMainColumn: 'Name', stickyColumn: 'Name' }, 'Works': { extractMainColumn: 'Title', stickyColumn: 'Title' } }, tableMode: 'single' }, // Label pages { type: 'label-aliases', match: (path) => path.match(/\/label\/[a-f0-9-]{36}\/aliases/), buttons: [ { label: 'Show all Aliases for Label' } ], features: { columnExtractors: [ { sourceColumn: 'Begin date', extractor: 'dateParts', syntheticColumns: ['B-DD', 'B-MM', 'B-YYYY', 'B-Day', 'B-Month'] }, { sourceColumn: 'End date', extractor: 'dateParts', syntheticColumns: ['E-DD', 'E-MM', 'E-YYYY', 'E-Day', 'E-Month'] } ], integerColumns: [ { sourceColumn: 'B-DD', align: 'R' }, { sourceColumn: 'B-MM', align: 'R' }, { sourceColumn: 'B-YYYY', align: 'C' }, { sourceColumn: 'E-DD', align: 'R' }, { sourceColumn: 'E-MM', align: 'R' }, { sourceColumn: 'E-YYYY', align: 'C' } ] }, tableMode: 'single' }, { type: 'label-relationships-filtered', match: (path, params) => path.match(/\/label\/[a-f0-9-]{36}\/relationships/) && params.has('link_type_id'), buttons: [ { label: 'Show all Relationships for Label (specialized)' } ], features: { columnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], injectedColumns: [ 'Release events', 'Relationships' ], injectedColumnExtractors: [ { sourceColumn: 'Release events', extractor: 'splitCountryDate', syntheticColumns: ['Release country', 'Release date'] }, { sourceColumn: 'Release date', extractor: 'dateParts', syntheticColumns: ['R-DD', 'R-MM', 'R-YYYY', 'R-Day', 'R-Month'] } ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'}, {sourceColumn: 'R-DD', align: 'R'}, {sourceColumn: 'R-MM', align: 'R'}, {sourceColumn: 'R-YYYY', align: 'C'} ], collapsableColumns: [ 'Release events' ], addCAA: 'Title', extractMainColumn: 'Title', stickyColumn: 'Title' }, tableMode: 'multi', non_paginated: true }, { type: 'label-relationships', match: (path, params) => path.match(/\/label\/[a-f0-9-]{36}\/relationships/) && !params.has('link_type_id'), buttons: [ { label: 'Show all Relationships for Label' } ], features: { columnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], injectedColumns: [ 'Release events', 'Relationships' ], injectedColumnExtractors: [ { sourceColumn: 'Release events', extractor: 'splitCountryDate', syntheticColumns: ['Release country', 'Release date'] }, { sourceColumn: 'Release date', extractor: 'dateParts', syntheticColumns: ['R-DD', 'R-MM', 'R-YYYY', 'R-Day', 'R-Month'] } ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'}, {sourceColumn: 'R-DD', align: 'R'}, {sourceColumn: 'R-MM', align: 'R'}, {sourceColumn: 'R-YYYY', align: 'C'} ], collapsableColumns: [ 'Release events' ], addCAA: 'Title', extractMainColumn: 'Title', stickyColumn: 'Title' }, tableMode: 'multi', non_paginated: true }, { type: 'label-releases', match: (path) => path.includes('/label'), buttons: [ { label: 'Show all Releases for Label' } ], features: { columnErasers: [ { sourceColumn: 'Release', erasers: ['โ–ถ', 'โž•'] } ], columnExtractors: [ { sourceColumn: 'Release', extractor: 'caa', syntheticColumns: ['CAA'] }, { sourceColumn: 'Country/Date', extractor: 'splitCountryDate', syntheticColumns: ['Country', 'Date'] }, { sourceColumn: 'Tracks', extractor: 'sumTracks', syntheticColumns: ['Total Tracks'] }, { sourceColumn: 'Format', extractor: 'extractFormatTypes', syntheticColumns: ['Format Types'] } ], syntheticColumnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'}, {sourceColumn: 'Total Tracks', align: 'R'} ], injectedColumns: [ 'Relationships' ], collapsableColumns: [ 'Country/Date' ,'Country', 'Date', 'CAA' ], tooltipColumns: [ 'MB-Name', 'italic:Comment', 'Artist', '---', ['Format', '(', 'Tracks', ')'], 'Country/Date', 'Catalog#', 'Barcode' ], addCAA: 'Release', extractMainColumn: 'Release', stickyColumn: 'Release' }, tableMode: 'single' }, // Work pages { type: 'work-aliases', match: (path) => path.match(/\/work\/[a-f0-9-]{36}\/aliases/), buttons: [ { label: 'Show all Aliases for Work' } ], features: { columnExtractors: [ { sourceColumn: 'Begin date', extractor: 'dateParts', syntheticColumns: ['B-DD', 'B-MM', 'B-YYYY', 'B-Day', 'B-Month'] }, { sourceColumn: 'End date', extractor: 'dateParts', syntheticColumns: ['E-DD', 'E-MM', 'E-YYYY', 'E-Day', 'E-Month'] } ], integerColumns: [ { sourceColumn: 'B-DD', align: 'R' }, { sourceColumn: 'B-MM', align: 'R' }, { sourceColumn: 'B-YYYY', align: 'C' }, { sourceColumn: 'E-DD', align: 'R' }, { sourceColumn: 'E-MM', align: 'R' }, { sourceColumn: 'E-YYYY', align: 'C' } ] }, tableMode: 'single' }, { type: 'work-recordings-filtered', match: (path, params) => path.match(/\/work\/[a-f0-9-]{36}/) && params.has('link_type_id'), buttons: [ { label: 'Show all Recordings for Work (specialized)' } ], features: { columnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] }, { sourceColumn: 'Title', extractor: 'video', syntheticColumns: ['Video'] } ], syntheticColumnExtractors: [ { sourceColumn: 'Comment', extractor: 'eventParts', syntheticColumns: ['Event-Type', 'Event-Date', 'Event-Detail', 'Event-Venue', 'Event-Venue-Detail', 'Event-City', 'Event-State', 'Event-Country', 'Event-Additional-Info'] } ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'}, {sourceColumn: 'Length', align: ':'} ], extractMainColumn: 'Title', stickyColumn: 'Title' }, tableMode: 'single' }, { type: 'work-recordings', match: (path, params) => path.match(/\/work\/[a-f0-9-]{36}/) && !params.has('link_type_id'), buttons: [ { label: 'Show all Recordings for Work' } ], features: { columnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] }, { sourceColumn: 'Title', extractor: 'video', syntheticColumns: ['Video'] } ], syntheticColumnExtractors: [ { sourceColumn: 'Comment', extractor: 'eventParts', syntheticColumns: ['Event-Type', 'Event-Date', 'Event-Detail', 'Event-Venue', 'Event-Venue-Detail', 'Event-City', 'Event-State', 'Event-Country', 'Event-Additional-Info'] } ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'}, {sourceColumn: 'Length', align: ':'} ], extractMainColumn: 'Title', stickyColumn: 'Title' }, tableMode: 'multi', non_paginated: true }, // Artist pages { type: 'artist-relationships-filtered', // Check for link_type_id to identify the paginated "See all" view. This MUST come before the general 'artist-relationships' match. match: (path, params) => path.match(/\/artist\/[a-f0-9-]{36}\/relationships/) && params.has('link_type_id'), buttons: [ { label: 'Show all Relationships for Artist (specialized)' } ], features: { columnErasers: [ { sourceColumn: 'Title', erasers: ['โ–ถ', 'โž•'] } ], columnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] }, { sourceColumn: 'Title', extractor: 'video', syntheticColumns: ['Video'] }, { sourceColumn: 'Title', extractor: 'caa', syntheticColumns: ['CAA'] } ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'}, {sourceColumn: 'Length', align: ':'} ], collapsableColumns: [ 'CAA' ], tooltipColumns: [ 'Title', 'Artist', '---', 'Date', 'Attributes' ], addCAA: 'Title', extractMainColumn: 'Title', stickyColumn: 'Title' }, tableMode: 'single' }, { type: 'artist-relationships', // Only match if NO link_type_id is present (the overview page) match: (path, params) => path.match(/\/artist\/[a-f0-9-]{36}\/relationships/) && !params.has('link_type_id'), buttons: [ { label: 'Show all Relationships for Artist' } ], features: { columnErasers: [ { sourceColumn: 'Title', erasers: ['โ–ถ', 'โž•'] } ], columnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] }, { sourceColumn: 'Title', extractor: 'video', syntheticColumns: ['Video'] }, { sourceColumn: 'Title', extractor: 'caa', syntheticColumns: ['CAA'] } ], injectedColumns: [ 'Relationships' ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'}, {sourceColumn: 'Length', align: ':'} ], collapsableColumns: [ 'CAA' ], tooltipColumns: [ 'Title', 'Artist', '---', 'Length', 'Date', 'Credited as', 'Attributes' ], addCAA: 'Title', extractMainColumn: 'Title', stickyColumn: 'Title' }, tableMode: 'multi', non_paginated: true }, { type: 'artist-aliases', match: (path) => path.match(/\/artist\/[a-f0-9-]{36}\/aliases/), buttons: [ { label: 'Show all Aliases for Artist', targetHeader: 'Aliases', tableMode: 'single' }, { label: 'Show all Artist Credits for Artist', targetHeader: 'Artist credits', tableMode: 'single' } ], features: { columnExtractors: [ { sourceColumn: 'Begin date', extractor: 'dateParts', syntheticColumns: ['B-DD', 'B-MM', 'B-YYYY', 'B-Day', 'B-Month'] }, { sourceColumn: 'End date', extractor: 'dateParts', syntheticColumns: ['E-DD', 'E-MM', 'E-YYYY', 'E-Day', 'E-Month'] } ], integerColumns: [ { sourceColumn: 'B-DD', align: 'R' }, { sourceColumn: 'B-MM', align: 'R' }, { sourceColumn: 'B-YYYY', align: 'C' }, { sourceColumn: 'E-DD', align: 'R' }, { sourceColumn: 'E-MM', align: 'R' }, { sourceColumn: 'E-YYYY', align: 'C' } ] }, }, { type: 'artist-releasegroups', // Root artist page (Official/Non-Official views handled by specific buttons on the final rendered page) match: (path, params) => path.match(/\/artist\/[a-f0-9-]{36}$/) && !path.endsWith('/releases'), buttons: [ // These two narrow buttons (official only) are superseded by the // per-view toggle buttons injected after the full render: "Official // Discography", "Non-Official Discography", "Complete" and // "Complete (merged)". Kept here (commented) for reference only. // { label: '๐Ÿงฎ Official RGs', params: { all: '0', va: '0' } }, // { label: '๐Ÿงฎ Official VA RGs', params: { all: '0', va: '1' } }, { label: '๐Ÿงฎ Artist RGs', params: { all: '1', va: '0' } }, { label: '๐Ÿงฎ Various Artists RGs', params: { all: '1', va: '1' } } ], features: { columnErasers: [ { sourceColumn: 'Title', erasers: ['โ–ถ', 'jesus2099'] } ], columnExtractors: [ { sourceColumn: 'Title', extractor: 'caa', syntheticColumns: ['CAA'] }, { sourceColumn: 'Title', extractor: 'primaryAlias', syntheticColumns: ['Primary Alias'] } ], injectedColumns: [ 'Relationships' ], integerColumns: [ {sourceColumn: 'Year', align: 'C'}, {sourceColumn: 'Releases', align: 'R'} ], collapsableColumns: [ 'CAA' ], tooltipColumns: [ 'Title', 'Artist', '---', ['Year', '(', 'Releases', ')'] ], addCAA: 'Title', extractMainColumn: 'Title', stickyColumn: 'Title' }, tableMode: 'multi' // native tables, h3 headers }, { subType: 'artist-releasegroups', match: (path, params) => path.match(/\/artist\/[a-f0-9-]{36}$/) && !path.endsWith('/releases'), buttons: [ { mainLabel: '๐Ÿงฎ Artist RGs', pre_fetch_type: 'Official', params: { all: '0', va: '0' } }, { mainLabel: '๐Ÿงฎ Various Artists RGs', pre_fetch_type: 'Official', params: { all: '0', va: '1' } }, ] }, { type: 'artist-releases', // Artist Releases page (Official/VA views handled by specific buttons) match: (path, params) => path.match(/\/artist\/[a-f0-9-]{36}\/releases$/), buttons: [ { label: '๐Ÿงฎ Artist releases', params: { va: '0' } }, { label: '๐Ÿงฎ VA releases', params: { va: '1' } } ], features: { columnExtractors: [ { sourceColumn: 'Release', extractor: 'caa', syntheticColumns: ['CAA'] }, { sourceColumn: 'Country/Date', extractor: 'splitCountryDate', syntheticColumns: ['Country', 'Date'] }, { sourceColumn: 'Tracks', extractor: 'sumTracks', syntheticColumns: ['Total Tracks'] }, ], syntheticColumnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'}, {sourceColumn: 'Total Tracks', align: 'R'} ], injectedColumns: [ 'Relationships' ], renderMultiRowCell: [ 'Label', 'Catalog#' ], collapsableColumns: [ 'Country/Date' ,'Country', 'Date', 'Label', 'Catalog#', 'CAA' ], tooltipColumns: [ 'MB-Name', 'italic:Comment', 'Artist', '---', ['Format', '(', 'Tracks', ')'], 'Country/Date', ['Label', '-', 'Catalog#'], 'Barcode' ], addCAA: 'Release', extractMainColumn: 'Release', stickyColumn: 'Release' }, tableMode: 'single' }, { type: 'artist-recordings', match: (path, params) => path.match(/\/artist\/[a-f0-9-]{36}\/recordings$/), buttons: [ { label: 'โŠš All recordings', params: { all: '1' } }, { label: 'โ™ซ Standalone', params: { standalone: '1' } }, { label: '๐ŸŽฌ Video', params: { video: '1' } } ], features: { columnErasers: [ { sourceColumn: 'Release groups', erasers: ['โ–ถ', 'โž•', 'jesus2099'] }, { sourceColumn: 'Name', erasers: ['wiencek'] } ], columnExtractors: [ { sourceColumn: 'Name', extractor: 'video', syntheticColumns: ['Video'] } ], syntheticColumnExtractors: [ { sourceColumn: 'Comment', extractor: 'eventParts', syntheticColumns: ['Event-Type', 'Event-Date', 'Event-Detail', 'Event-Venue', 'Event-Venue-Detail', 'Event-City', 'Event-State', 'Event-Country', 'Event-Additional-Info'] } ], integerColumns: [ {sourceColumn: 'Length', align: ':'} ], collapsableColumns: [ 'Release groups', 'CAA' ], tooltipColumns: [ 'Release groups', 'Name', 'italic:Comment', 'Artist', '---', ['Length', '-', 'Video'], 'ISRCs' ], addCAA: 'Release groups', extractMainColumn: 'Name', stickyColumn: 'Name' }, tableMode: 'single' }, { type: 'artist-works', match: (path) => path.includes('/works'), buttons: [ { label: 'Show all Works for Artist' } ], features: { collapsableColumns: [ 'Authors', 'Recording artists', 'Other artists', 'ISWC', 'Lyrics languages', 'Attributes' ], extractMainColumn: 'Work', stickyColumn: 'Work' }, tableMode: 'single' }, // ReleaseGroups pages { type: 'releasegroup-aliases', match: (path) => path.match(/\/release-group\/[a-f0-9-]{36}\/aliases/), buttons: [ { label: 'Show all Aliases for Releasegroup' } ], features: { columnExtractors: [ { sourceColumn: 'Begin date', extractor: 'dateParts', syntheticColumns: ['B-DD', 'B-MM', 'B-YYYY', 'B-Day', 'B-Month'] }, { sourceColumn: 'End date', extractor: 'dateParts', syntheticColumns: ['E-DD', 'E-MM', 'E-YYYY', 'E-Day', 'E-Month'] } ], integerColumns: [ { sourceColumn: 'B-DD', align: 'R' }, { sourceColumn: 'B-MM', align: 'R' }, { sourceColumn: 'B-YYYY', align: 'C' }, { sourceColumn: 'E-DD', align: 'R' }, { sourceColumn: 'E-MM', align: 'R' }, { sourceColumn: 'E-YYYY', align: 'C' } ] }, tableMode: 'single' }, { type: 'releasegroup-releases', match: (path) => path.includes('/release-group/'), buttons: [ { label: 'Show all Releases for ReleaseGroup' } ], features: { columnErasers: [ { sourceColumn: 'Release', erasers: ['โ–ถ', 'โž•'] } ], columnExtractors: [ { sourceColumn: 'Release', extractor: 'caa', syntheticColumns: ['CAA'] }, { sourceColumn: 'Country/Date', extractor: 'splitCountryDate', syntheticColumns: ['Country', 'Date'] }, { sourceColumn: 'Tracks', extractor: 'sumTracks', syntheticColumns: ['Total Tracks'] }, { sourceColumn: 'Format', extractor: 'extractFormatTypes', syntheticColumns: ['Format Types'] } ], syntheticColumnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'}, {sourceColumn: 'Total Tracks', align: 'R'} ], injectedColumns: [ 'Relationships' ], renderMultiRowCell: [ 'Label', 'Catalog#' ], collapsableColumns: [ 'Country/Date' ,'Country', 'Date', 'Label', 'Catalog#', 'CAA' ], tooltipColumns: [ 'MB-Name', 'italic:Comment', 'Artist', '---', ['Format', '(', 'Tracks', ')'], 'Country/Date', ['Label', '-', 'Catalog#'], 'Barcode' ], addCAA: 'Release', extractMainColumn: 'Release', stickyColumn: 'Release' }, tableMode: 'multi', non_paginated: false }, // Release pages { type: 'release-aliases', match: (path) => path.match(/\/release\/[a-f0-9-]{36}\/aliases/), buttons: [ { label: 'Show all Aliases for Release' } ], features: { columnExtractors: [ { sourceColumn: 'Begin date', extractor: 'dateParts', syntheticColumns: ['B-DD', 'B-MM', 'B-YYYY', 'B-Day', 'B-Month'] }, { sourceColumn: 'End date', extractor: 'dateParts', syntheticColumns: ['E-DD', 'E-MM', 'E-YYYY', 'E-Day', 'E-Month'] } ], integerColumns: [ { sourceColumn: 'B-DD', align: 'R' }, { sourceColumn: 'B-MM', align: 'R' }, { sourceColumn: 'B-YYYY', align: 'C' }, { sourceColumn: 'E-DD', align: 'R' }, { sourceColumn: 'E-MM', align: 'R' }, { sourceColumn: 'E-YYYY', align: 'C' } ] }, tableMode: 'single' }, { type: 'release-discids', match: (path) => path.match(/\/release\/[a-f0-9-]{36}\/discids/), buttons: [ { label: 'Show all Disc IDs for Release' } ], tableMode: 'multi', non_paginated: false }, // Recording pages { type: 'recording-aliases', match: (path) => path.match(/\/recording\/[a-f0-9-]{36}\/aliases/), buttons: [ { label: 'Show all Aliases for Recording' } ], features: { columnExtractors: [ { sourceColumn: 'Begin date', extractor: 'dateParts', syntheticColumns: ['B-DD', 'B-MM', 'B-YYYY', 'B-Day', 'B-Month'] }, { sourceColumn: 'End date', extractor: 'dateParts', syntheticColumns: ['E-DD', 'E-MM', 'E-YYYY', 'E-Day', 'E-Month'] } ], integerColumns: [ { sourceColumn: 'B-DD', align: 'R' }, { sourceColumn: 'B-MM', align: 'R' }, { sourceColumn: 'B-YYYY', align: 'C' }, { sourceColumn: 'E-DD', align: 'R' }, { sourceColumn: 'E-MM', align: 'R' }, { sourceColumn: 'E-YYYY', align: 'C' } ] }, tableMode: 'single' }, { type: 'recording-fingerprints', match: (path) => path.match(/\/recording\/[a-f0-9-]{36}\/fingerprints/), buttons: [ { label: 'Show all Fingerprints for Recording' } ], tableMode: 'single' //rowTargetSelector: '.acoustid-fingerprints table.tbl' }, { type: 'recording-releases', match: (path) => path.includes('/recording'), buttons: [ { label: 'Show all Releases for Recording' } ], features: { columnErasers: [ { sourceColumn: 'Release title', erasers: ['โ–ถ', 'jesus2099'] } ], columnExtractors: [ { sourceColumn: 'Country/Date', extractor: 'splitCountryDate', syntheticColumns: ['Country', 'Date'] } ], syntheticColumnExtractors: [ { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], injectedColumns: [ 'Relationships' ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'}, {sourceColumn: 'Length', align: ':'}, {sourceColumn: '#', align: '.'} ], renderMultiRowCell: [ 'Label', 'Catalog#' ], collapsableColumns: [ 'Country/Date' ,'Country', 'Date', 'Label', 'Catalog#', 'CAA' ], tooltipColumns: [ 'Release title', 'italic:Comment', 'Release Artist', 'Release group type', '---', ['#', 'Title', '(', 'Length', ')'], 'Track artist', 'Country/Date', ['Label', '-', 'Catalog#'] ], addCAA: 'Release title', extractMainColumn: 'Release title', stickyColumn: 'Title' }, tableMode: 'multi', non_paginated: false }, // Event pages { type: 'event-aliases', match: (path) => path.match(/\/event\/[a-f0-9-]{36}\/aliases/), buttons: [ { label: 'Show all Aliases for Event' } ], features: { columnExtractors: [ { sourceColumn: 'Begin date', extractor: 'dateParts', syntheticColumns: ['B-DD', 'B-MM', 'B-YYYY', 'B-Day', 'B-Month'] }, { sourceColumn: 'End date', extractor: 'dateParts', syntheticColumns: ['E-DD', 'E-MM', 'E-YYYY', 'E-Day', 'E-Month'] } ], integerColumns: [ { sourceColumn: 'B-DD', align: 'R' }, { sourceColumn: 'B-MM', align: 'R' }, { sourceColumn: 'B-YYYY', align: 'C' }, { sourceColumn: 'E-DD', align: 'R' }, { sourceColumn: 'E-MM', align: 'R' }, { sourceColumn: 'E-YYYY', align: 'C' } ] }, tableMode: 'single' }, { type: 'artist-events', match: (path) => path.includes('/events'), buttons: [ { label: 'Show all Events for Artist' } ], features: { columnExtractors: [ { sourceColumn: 'Event', extractor: 'cancelledEvent', syntheticColumns: ['Cancelled'] }, { sourceColumn: 'Event', extractor: 'caa', syntheticColumns: ['EAA'] }, { sourceColumn: 'Location', extractor: 'splitLocation', syntheticColumns: ['Place', 'Area', 'Country'] }, { sourceColumn: 'Event', extractor: 'primaryAlias', syntheticColumns: ['Primary Alias'] }, { sourceColumn: 'Date', extractor: 'dateParts', syntheticColumns: ['DD', 'MM', 'YYYY', 'Day', 'Month'] } ], collapsableColumns: [ 'Location', 'EAA', 'Place', 'Area', 'Country' ], integerColumns: [ {sourceColumn: 'DD', align: 'R'}, {sourceColumn: 'MM', align: 'R'}, {sourceColumn: 'YYYY', align: 'C'} ], tooltipColumns: [ 'MB-Name', 'italic:Comment', 'Primary Alias', 'Type', '---', 'Role', 'Location', ['Date', '(', 'Time', ')'], 'Cancelled' ], addEAA: 'Event', extractMainColumn: 'Event', stickyColumn: 'Event' }, tableMode: 'single' } ]; //-------------------------------------------------------------------------------- // Initialize prefix-shortcut Emacs-style handler for action button selection and function shortcuts // Press prefix key, release, then press 1-9/a-z/A-Z/special chars to select button or call function let ctrlMModeActive = false; let ctrlMModeTimeout; let ctrlMFunctionMap = {}; // Will be populated after functions are defined let ctrlMTooltipElement = null; let _colFilterTableIndex = -1; // module-level cycling state for focus-column-filter shortcut let _lastFocusedColFilterInput = null; // last .mb-col-filter-input that received focus โ€” used by prefix-mode o/q/a shortcuts /** * Returns true when one of the script's modal dialogs is currently in the DOM. * Used to suppress direct Ctrl+letter shortcuts while a dialog is open so that * dialog keyboard navigation (Escape, Tab, arrows) is never shadowed. * @returns {boolean} */ function isSpecialDialogOpen() { return !!( document.getElementById('mb-shortcuts-help') || document.getElementById('mb-app-help-dialog') || document.getElementById('mb-stats-panel') || document.getElementById('sa-export-dialog-overlay') || document.getElementById('sa-save-dialog-overlay') || document.getElementById('sa-load-dialog-overlay') || document.getElementById(`${SCRIPT_ID}-settings-overlay`) ); } /** * Parse a shortcut prefix string such as "Ctrl+M", "Ctrl+.", "Alt+Shift+X" * into its component parts. * @param {string} str - The shortcut string to parse * @returns {{ ctrl: boolean, meta: boolean, alt: boolean, shift: boolean, key: string }} */ function parsePrefixShortcut(str) { const parts = (str || 'Ctrl+M').trim().split('+'); let key = parts.pop().trim(); // A trailing '+' (e.g. "Ctrl++") means the actual key character is '+' if (key === '') key = '+'; const mods = parts.map(p => p.trim().toLowerCase()); return { ctrl: mods.includes('ctrl'), meta: mods.includes('meta') || mods.includes('cmd') || mods.includes('super'), alt: mods.includes('alt'), shift: mods.includes('shift'), key: key }; } /** * Returns the display string for the configured prefix shortcut (e.g. "Ctrl+M"). * Falls back to "Ctrl+M" when the setting is not yet available. * @returns {string} */ function getPrefixDisplay() { if (typeof Lib !== 'undefined' && Lib.settings && Lib.settings.sa_keyboard_shortcut_prefix) { return Lib.settings.sa_keyboard_shortcut_prefix; } return 'Ctrl+M'; } /** * Returns true when a keyboard event matches the configured prefix shortcut. * When "Ctrl" appears in the prefix it matches BOTH Ctrl and Meta/Cmd keys, * preserving cross-platform (Mac/Windows/Linux) compatibility. * @param {KeyboardEvent} e * @returns {boolean} */ function isPrefixKeyEvent(e) { const p = parsePrefixShortcut(getPrefixDisplay()); // Ctrl in the config: match either Ctrl or Meta/Cmd (Mac) const ctrlMatch = p.ctrl ? (e.ctrlKey || e.metaKey) : (!e.ctrlKey && !e.metaKey); const altMatch = p.alt ? e.altKey : !e.altKey; const shiftMatch = p.shift ? e.shiftKey : !e.shiftKey; const keyMatch = e.key.toLowerCase() === p.key.toLowerCase(); return ctrlMatch && altMatch && shiftMatch && keyMatch; } /** * Returns true when a keyboard event matches a configured single-chord shortcut. * Mirrors isPrefixKeyEvent logic but reads an arbitrary setting key. * "Ctrl" in the stored value matches both Ctrl and Meta/Cmd for cross-platform compat. * @param {KeyboardEvent} e * @param {string} settingKey - The configSchema key to read (e.g. 'sa_shortcut_open_export') * @param {string} fallback - Default shortcut string when the setting is absent * @returns {boolean} */ function isShortcutEvent(e, settingKey, fallback) { const raw = (typeof Lib !== 'undefined' && Lib.settings && Lib.settings[settingKey]) ? Lib.settings[settingKey] : fallback; const p = parsePrefixShortcut(raw); const ctrlMatch = p.ctrl ? (e.ctrlKey || e.metaKey) : (!e.ctrlKey && !e.metaKey); const altMatch = p.alt ? e.altKey : !e.altKey; const shiftMatch = p.shift ? e.shiftKey : !e.shiftKey; const keyMatch = e.key.toLowerCase() === p.key.toLowerCase(); return ctrlMatch && altMatch && shiftMatch && keyMatch; } /** * Returns the display string for a configured single-chord shortcut. * Falls back to the supplied default when the setting is not yet available. * @param {string} settingKey - The configSchema key to read * @param {string} fallback - Value to return when setting is absent * @returns {string} */ function getShortcutDisplay(settingKey, fallback) { if (typeof Lib !== 'undefined' && Lib.settings && Lib.settings[settingKey]) { return Lib.settings[settingKey]; } return fallback; } /** * Builds a keyboard shortcut hint string for button tooltips. * Always includes the prefix-mode form (e.g. "Ctrl+M, then S"). * Appends the direct shortcut (e.g. "or Ctrl+S") only when * `sa_enable_direct_ctrl_char_shortcuts` is on, or when the shortcut is not * a Ctrl+ key (e.g. Ctrl+,) and is therefore never suppressed. * @param {string} settingKey configSchema key for the direct shortcut * @param {string} fallback default direct shortcut string * @param {string} prefixKey single key label used after the prefix (e.g. 'S', ',') * @returns {string} e.g. "Ctrl+M, then S" or "Ctrl+M, then S, or Ctrl+S" */ function buildShortcutHint(settingKey, fallback, prefixKey) { const directKey = getShortcutDisplay(settingKey, fallback); const prefixHint = `${getPrefixDisplay()}, then ${prefixKey}`; const p = parsePrefixShortcut(directKey); const isBlockedLetter = p.ctrl && !p.alt && !p.shift && p.key.length === 1 && p.key.toLowerCase() >= 'a' && p.key.toLowerCase() <= 'z'; const directOn = typeof Lib !== 'undefined' && Lib.settings && Lib.settings.sa_enable_direct_ctrl_char_shortcuts; return (!isBlockedLetter || directOn) ? `${prefixHint}, or ${directKey}` : prefixHint; } /** * Displays a floating tooltip listing all Ctrl+M prefix-mode shortcuts. * Shows numbered button shortcuts (1โ€“9 / aโ€“z) and named function shortcuts * from ctrlMFunctionMap. Positions the tooltip in the upper-right corner of the * page content area, avoiding overlap with the sidebar. * No-ops when the tooltip is disabled in settings or when Lib is unavailable. * @param {HTMLButtonElement[]} actionButtons - The action buttons shown in the h1 bar * @param {string[]} buttonKeys - Parallel array of key labels ('1','2',โ€ฆ,'a','b',โ€ฆ) for each button */ function showCtrlMTooltip(actionButtons, buttonKeys) { if (typeof Lib === 'undefined' || !Lib.settings.sa_enable_keyboard_shortcut_tooltip) { return; // Tooltip disabled in settings or Lib not available } // Remove existing tooltip if any hideCtrlMTooltip(); const contentDiv = document.getElementById('content'); const sidebarDiv = document.getElementById('sidebar'); if (!contentDiv) return; // Create tooltip element ctrlMTooltipElement = document.createElement('div'); ctrlMTooltipElement.id = 'mb-ctrl-m-tooltip'; ctrlMTooltipElement.style.cssText = ` position: fixed; background-color: #f0f0f0; border: 1px solid #999; border-radius: 4px; padding: 8px 12px; font-size: 0.75em; max-width: 250px; z-index: 10000; box-shadow: 0 2px 8px rgba(0,0,0,0.15); line-height: 1.4; `; // Build tooltip content let tooltipHTML = '' + getPrefixDisplay() + ' Shortcuts:
                                                            '; // Action buttons if (actionButtons.length > 0) { tooltipHTML += 'Buttons:
                                                            '; for (let i = 0; i < Math.min(actionButtons.length, 9); i++) { const key = buttonKeys[i]; const text = actionButtons[i].textContent.trim().substring(0, 20); tooltipHTML += `
                                                            ${key}: ${text}${actionButtons[i].textContent.trim().length > 20 ? '...' : ''}
                                                            `; } if (actionButtons.length > 9) { tooltipHTML += `
                                                            + ${actionButtons.length - 9} more (a-${String.fromCharCode(97 + Math.min(actionButtons.length - 10, 25))})
                                                            `; } tooltipHTML += '
                                                            '; } // Split entries: column-context functions (o/q/a with colContext:true) vs regular const _colCtxEntries = Object.entries(ctrlMFunctionMap).filter(([, e]) => e.colContext); const _regularEntries = Object.entries(ctrlMFunctionMap).filter(([, e]) => !e.colContext); const _colFilterFocused = !!( (document.activeElement && document.activeElement.matches('.mb-col-filter-input')) || _lastFocusedColFilterInput ); // Regular function shortcuts tooltipHTML += 'Functions:
                                                            '; for (const [key, entry] of _regularEntries) { tooltipHTML += `
                                                            ${key}: ${entry.description}
                                                            `; } // Column-context shortcuts (o/q/a) โ€” shown separately with focus status if (_colCtxEntries.length > 0) { const _ccColor = _colFilterFocused ? '#006600' : '#999999'; const _ccStatus = _colFilterFocused ? 'โœ“ col filter active' : 'โš  focus col filter first'; tooltipHTML += `
                                                            Col-filter context (${_ccStatus}):
                                                            `; for (const [key, entry] of _colCtxEntries) { tooltipHTML += `
                                                            ${key}: ${entry.description}
                                                            `; } } ctrlMTooltipElement.innerHTML = tooltipHTML; document.body.appendChild(ctrlMTooltipElement); // Position in upper right of content div, not overlapping sidebar setTimeout(() => { if (contentDiv && sidebarDiv) { const contentRect = contentDiv.getBoundingClientRect(); const sidebarRect = sidebarDiv.getBoundingClientRect(); const tooltipRect = ctrlMTooltipElement.getBoundingClientRect(); // Position in upper right, respecting sidebar let left = Math.min(contentRect.right - tooltipRect.width - 10, window.innerWidth - tooltipRect.width - 10); left = Math.max(left, contentRect.left + 10); // Don't go too far left if (sidebarDiv) { // Ensure doesn't overlap sidebar left = Math.min(left, sidebarRect.left - tooltipRect.width - 10); } ctrlMTooltipElement.style.left = left + 'px'; ctrlMTooltipElement.style.top = (contentRect.top + 10) + 'px'; } }, 0); } /** * Removes the Ctrl+M prefix-mode tooltip from the DOM if it is currently visible. * Safe to call even when no tooltip is present. */ function hideCtrlMTooltip() { if (ctrlMTooltipElement) { ctrlMTooltipElement.remove(); ctrlMTooltipElement = null; } } // Register in the capture phase (third arg = true) so our handler runs BEFORE // any bubbling-phase listener โ€” including the jesus2099 "mb. SUPER MIND CONTROL" // script that opens its mbunicodecharsMenu on Ctrl+M in the bubbling phase. // Calling stopImmediatePropagation() when we match the prefix key prevents all // subsequent capture listeners on document AND the entire bubbling phase from // seeing the event, so no other handler can claim the same key combo. document.addEventListener('keydown', (e) => { // Prefix key: enter prefix mode for button selection and function shortcuts if (isPrefixKeyEvent(e)) { e.preventDefault(); // Stop other listeners (including other userscripts) from seeing this // event, regardless of whether they registered in capture or bubble phase. e.stopImmediatePropagation(); // If already in mode, exit if (ctrlMModeActive) { ctrlMModeActive = false; clearTimeout(ctrlMModeTimeout); hideCtrlMTooltip(); if (typeof Lib !== 'undefined' && Lib.debug) { Lib.debug('shortcuts', `Exited ${getPrefixDisplay()} mode`); } else { console.log(`[VZ-${SCRIPT_BASE_NAME}] Exited ${getPrefixDisplay()} mode`); } return; } // Get available action buttons const actionButtons = Array.from(document.querySelectorAll('button')) .filter(btn => btn.textContent.includes('Show all') || btn.textContent.includes('๐Ÿงฎ')); // Enter prefix mode ctrlMModeActive = true; // Build list of available keys for action buttons (1-9, a-z, A-Z, special) let buttonKeys = []; if (actionButtons.length > 0) { for (let i = 0; i < actionButtons.length && i < 9; i++) { buttonKeys.push((i + 1).toString()); } for (let i = 9; i < actionButtons.length && i < 35; i++) { buttonKeys.push(String.fromCharCode(97 + (i - 9))); // a-z } } // Show tooltip if enabled if (typeof Lib !== 'undefined' && Lib.settings.sa_enable_keyboard_shortcut_tooltip) { showCtrlMTooltip(actionButtons, buttonKeys); } // Log helpful message with available buttons if (typeof Lib !== 'undefined' && Lib.debug) { if (buttonKeys.length > 0) { Lib.debug('shortcuts', `Entered ${getPrefixDisplay()} mode. ${actionButtons.length} action button(s): ${buttonKeys.join(', ')}`); actionButtons.forEach((btn, idx) => { const key = buttonKeys[idx] || '?'; Lib.debug('shortcuts', ` ${key}: ${btn.textContent.trim()}`); }); } Lib.debug('shortcuts', 'Function shortcuts: r=Resize, i=Statistics, s=Save, d=Density, v=Visible, e=Export, l=Load, k=Shortcuts Help, h=App Help, ,=Settings, o=' + (ctrlMFunctionMap['o'] && ctrlMFunctionMap['o'].description === 'Stop fetching' ? 'Stop' : 'Toggle Multi-Row Collapse') + ', q=Unique Values Dropdown, a=CAA Toggle'); Lib.debug('shortcuts', 'Press any key or Escape to cancel'); } else { if (buttonKeys.length > 0) { console.log(`[VZ-${SCRIPT_BASE_NAME}] Entered ${getPrefixDisplay()} mode. ${actionButtons.length} action button(s): ${buttonKeys.join(', ')}`); actionButtons.forEach((btn, idx) => { const key = buttonKeys[idx] || '?'; console.log(`[VZ-${SCRIPT_BASE_NAME}] ${key}: ${btn.textContent.trim()}`); }); } console.log(`[VZ-${SCRIPT_BASE_NAME}] Function shortcuts: r=Resize, i=Statistics, s=Save, d=Density, v=Visible, e=Export, l=Load, k=Shortcuts Help, h=App Help, ,=Settings, o=` + (ctrlMFunctionMap['o'] && ctrlMFunctionMap['o'].description === 'Stop fetching' ? 'Stop' : 'Toggle Multi-Row Collapse') + `, q=Unique Values Dropdown, a=CAA Toggle`); } // Auto-exit after 5 seconds clearTimeout(ctrlMModeTimeout); ctrlMModeTimeout = setTimeout(() => { ctrlMModeActive = false; hideCtrlMTooltip(); if (typeof Lib !== 'undefined' && Lib.debug) { Lib.debug('shortcuts', `Exited ${getPrefixDisplay()} mode (timeout)`); } }, 5000); return; } // If in prefix mode and a single character key is pressed (no modifiers) if (ctrlMModeActive && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey) { const key = e.key.toLowerCase(); const keyOriginal = e.key; // Extended valid key range: 1-9, a-z, A-Z, and special characters const validCharacters = '123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ,;.:-_+*<>#\'?!%&/()='; const isValidKey = validCharacters.includes(keyOriginal); if (!isValidKey) { return; // Not a recognized key } e.preventDefault(); // Check if it's a function shortcut (non-numeric) if (ctrlMFunctionMap[key]) { const funcEntry = ctrlMFunctionMap[key]; // Call the function if it exists if (typeof funcEntry.fn === 'function') { funcEntry.fn(); if (typeof Lib !== 'undefined' && Lib.debug) { Lib.debug('shortcuts', `Function "${funcEntry.description}" triggered via ${getPrefixDisplay()} then '${keyOriginal}'`); } else { console.log(`[VZ-${SCRIPT_BASE_NAME}] Function "${funcEntry.description}" triggered`); } } else { if (typeof Lib !== 'undefined' && Lib.warn) { Lib.warn('shortcuts', `Function "${funcEntry.description}" not available`); } else { console.warn(`[VZ-${SCRIPT_BASE_NAME}] Function not available`); } } ctrlMModeActive = false; clearTimeout(ctrlMModeTimeout); hideCtrlMTooltip(); return; } // Check if it's a numeric action button shortcut let buttonIndex = -1; if (keyOriginal >= '1' && keyOriginal <= '9') { buttonIndex = parseInt(keyOriginal) - 1; } else if (key >= 'a' && key <= 'z') { buttonIndex = 9 + (key.charCodeAt(0) - 97); // a=9, b=10, etc. } if (buttonIndex >= 0) { const actionButtons = Array.from(document.querySelectorAll('button')) .filter(btn => btn.textContent.includes('Show all') || btn.textContent.includes('๐Ÿงฎ')); if (buttonIndex < actionButtons.length) { const selectedButton = actionButtons[buttonIndex]; selectedButton.click(); if (typeof Lib !== 'undefined' && Lib.debug) { Lib.debug('shortcuts', `Action button ${buttonIndex + 1} selected via ${getPrefixDisplay()} then '${keyOriginal}': "${selectedButton.textContent.trim()}"`); } else { console.log(`[VZ-${SCRIPT_BASE_NAME}] Action button ${buttonIndex + 1} clicked: "${selectedButton.textContent.trim()}"`); } } else { if (typeof Lib !== 'undefined' && Lib.warn) { Lib.warn('shortcuts', `No action button at position ${buttonIndex + 1} (${actionButtons.length} available)`); } else { console.warn(`[VZ-${SCRIPT_BASE_NAME}] No action button at position ${buttonIndex + 1}`); } } } ctrlMModeActive = false; clearTimeout(ctrlMModeTimeout); hideCtrlMTooltip(); return; } // Escape key exits prefix mode without selecting if (e.key === 'Escape' && ctrlMModeActive) { e.preventDefault(); ctrlMModeActive = false; clearTimeout(ctrlMModeTimeout); hideCtrlMTooltip(); if (typeof Lib !== 'undefined' && Lib.debug) { Lib.debug('shortcuts', `Exited ${getPrefixDisplay()} mode (Escape pressed)`); } else { console.log(`[VZ-${SCRIPT_BASE_NAME}] Exited ${getPrefixDisplay()} mode`); } return; } // Any other key with modifiers exits prefix mode if (ctrlMModeActive && (e.ctrlKey || e.metaKey || e.altKey) && e.key !== 'Escape') { ctrlMModeActive = false; clearTimeout(ctrlMModeTimeout); hideCtrlMTooltip(); } }, { capture: true }); /** * Debounce utility function - delays execution until after wait milliseconds have elapsed * since the last time the debounced function was invoked. * @param {Function} func - The function to debounce * @param {number|()=>number} wait - Milliseconds to delay, or a factory function called on * each invocation to get the current delay (enables adaptive/dynamic debounce intervals). * @param {boolean} immediate - If true, trigger the function on the leading edge instead of trailing * @returns {Function} The debounced function */ function debounce(func, wait, immediate) { let timeout; return function executedFunction(...args) { const context = this; const delay = typeof wait === 'function' ? wait() : wait; const later = function() { timeout = null; if (!immediate) func.apply(context, args); }; const callNow = immediate && !timeout; clearTimeout(timeout); timeout = setTimeout(later, delay); if (callNow) func.apply(context, args); }; } /** * Optimized sorting for large arrays using a stable, in-place sort with chunking * @param {Array} array - Array to sort * @param {Function} compareFn - Comparison function * @param {Function} progressCallback - Optional callback for progress updates (percent) * @returns {Promise} Sorted array */ async function sortLargeArray(array, compareFn, progressCallback) { const size = array.length; // For small arrays, use native sort if (size < 1000) { array.sort(compareFn); return array; } // For medium arrays, use native sort with yield if (size < 5000) { await new Promise(resolve => setTimeout(resolve, 0)); // Yield to UI array.sort(compareFn); return array; } // For large arrays, use Tim Sort (merge sort variant) with chunking // This provides stable O(n log n) performance with better cache locality const chunkSize = Math.min(Lib.settings.sa_sort_chunk_size || 5000, size); // Step 1: Sort chunks const numChunks = Math.ceil(size / chunkSize); for (let i = 0; i < numChunks; i++) { const start = i * chunkSize; const end = Math.min(start + chunkSize, size); const chunk = array.slice(start, end); chunk.sort(compareFn); // Copy sorted chunk back for (let j = 0; j < chunk.length; j++) { array[start + j] = chunk[j]; } if (progressCallback) { const progress = Math.round((i + 1) / numChunks * 50); // First 50% is chunk sorting progressCallback(progress); } // Yield to UI every chunk if (i % 3 === 0) { await new Promise(resolve => setTimeout(resolve, 0)); } } // Step 2: Merge sorted chunks let currentSize = chunkSize; let mergeStep = 0; const maxMergeSteps = Math.ceil(Math.log2(numChunks)); while (currentSize < size) { for (let start = 0; start < size; start += currentSize * 2) { const mid = Math.min(start + currentSize, size); const end = Math.min(start + currentSize * 2, size); if (mid < end) { merge(array, start, mid, end, compareFn); } } mergeStep++; if (progressCallback) { const progress = 50 + Math.round((mergeStep / maxMergeSteps) * 50); // Last 50% is merging progressCallback(progress); } currentSize *= 2; // Yield to UI await new Promise(resolve => setTimeout(resolve, 0)); } return array; } /** * Merge two sorted portions of an array * @param {Array} array - The array * @param {number} start - Start index of first portion * @param {number} mid - End index of first portion (start of second) * @param {number} end - End index of second portion * @param {Function} compareFn - Comparison function */ function merge(array, start, mid, end, compareFn) { const left = array.slice(start, mid); const right = array.slice(mid, end); let i = 0, j = 0, k = start; while (i < left.length && j < right.length) { if (compareFn(left[i], right[j]) <= 0) { array[k++] = left[i++]; } else { array[k++] = right[j++]; } } while (i < left.length) { array[k++] = left[i++]; } while (j < right.length) { array[k++] = right[j++]; } } /** * Create a comparison function for table sorting * @param {number} index - Column index * @param {boolean} isAscending - Sort direction * @param {boolean} isNumeric - Whether to use numeric comparison * @param {boolean} [byLength=false] - When true, sort by visible text length instead of value * @returns {Function} Comparison function */ function createSortComparator(index, isAscending, isNumeric, byLength = false) { return (a, b) => { const valA = getCleanVisibleText(a.cells[index]).trim().toLowerCase() || ''; const valB = getCleanVisibleText(b.cells[index]).trim().toLowerCase() || ''; if (byLength) { const result = valA.length - valB.length; return isAscending ? result : -result; } if (isNumeric) { const numA = parseFloat(valA.replace(/[^0-9.-]/g, '')) || 0; const numB = parseFloat(valB.replace(/[^0-9.-]/g, '')) || 0; return isAscending ? numA - numB : numB - numA; } const result = valA.localeCompare(valB, undefined, {numeric: true, sensitivity: 'base'}); return isAscending ? result : -result; }; } /** * Creates a multi-column comparator for sorting by multiple columns in priority order. * Each entry in sortColumns carries { colIndex, direction } where direction 1 = asc, 2 = desc. * * @param {{ colIndex: number, direction: number }[]} sortColumns * @param {NodeList} headers - TH elements of the table's first header row * @returns {Function} Comparator suitable for Array.sort / sortLargeArray */ function createMultiColumnComparator(sortColumns, headers) { return (a, b) => { for (const sortCol of sortColumns) { const idx = sortCol.colIndex; const isAscending = sortCol.direction === 1; const valA = getCleanVisibleText(a.cells[idx]).trim().toLowerCase() || ''; const valB = getCleanVisibleText(b.cells[idx]).trim().toLowerCase() || ''; // Derive column name for numeric detection (strip sort/collapse icons and superscripts) const hdrName = headers[idx] ? headers[idx].textContent.replace(/[โ‡…โ–ฒโ–ผโฐยนยฒยณโดโตโถโทโธโน๐Ÿ“Šโ–ถโ—€โ–ค0-9]/g, '').trim() : ''; const isNumeric = hdrName.includes('Year') || hdrName.includes('Releases') || hdrName.includes('Track') || hdrName.includes('Length') || hdrName.includes('#'); let result; if (isNumeric) { const numA = parseFloat(valA.replace(/[^0-9.-]/g, '')) || 0; const numB = parseFloat(valB.replace(/[^0-9.-]/g, '')) || 0; result = numA - numB; } else { result = valA.localeCompare(valB, undefined, { numeric: true, sensitivity: 'base' }); } if (result !== 0) return isAscending ? result : -result; } return 0; // all compared columns are equal }; } /** * Applies sticky positioning to table headers so they remain visible while scrolling * Adds CSS styles that make both the main header row and filter row stick to the top of the viewport */ function applyStickyHeaders() { // Check if styles already added if (document.getElementById('mb-sticky-headers-style')) { Lib.debug('ui', 'Sticky headers styles already applied'); return; } const style = document.createElement('style'); style.id = 'mb-sticky-headers-style'; style.textContent = ` /* Ensure the table borders play nicely with sticky elements */ table.tbl { border-collapse: separate; border-spacing: 0; } /* Make the entire thead sticky as a single solid block */ table.tbl thead { position: sticky; top: 0; /* z-index 100 keeps it below the sidebar (105) but above table content */ z-index: 100; } /* Ensure headers have a solid background so scrolling content doesn't bleed through */ table.tbl thead th { background-color: ${Lib.settings.sa_ui_thead_th_bg || '#e8e8e8'}; color: ${Lib.settings.sa_ui_thead_th_color || '#333333'}; border-bottom: 1px solid #ddd; border-top: 1px solid #ddd; background-clip: padding-box; position: relative; } /* Filter row specific styling */ table.tbl thead tr.mb-col-filter-row th { background-color: ${Lib.settings.sa_ui_thead_filter_row_bg || '#f4f4f4'}; border-bottom: 2px solid #ccc; border-top: none; } /* Ensure resizer handles stay above the header background but below dropdowns */ .column-resizer { z-index: 101 !important; } /* Prevent visual glitches during scroll */ table.tbl thead th { will-change: transform; } /* * Measurement sandbox class used by the auto-resize measurement loops. * Applied once to the measureDiv so that all cloned descendants inherit * non-wrapping text layout without needing per-element querySelectorAll * style writes (which trigger micro-reflows for every cell measured). * !important beats MusicBrainz stylesheet rules such as .wrap-anywhere. */ .mb-measure-nowrap, .mb-measure-nowrap * { white-space: nowrap !important; overflow-wrap: normal !important; word-break: normal !important; } `; document.head.appendChild(style); Lib.debug('ui', 'Sticky headers enabled - column headers will remain visible while scrolling'); } /** * Applies sticky positioning to one column of `table`. * * The column to make sticky is determined as follows (in priority order): * 1. `activeDefinition.features.stickyColumn` โ€” a named column (e.g. 'Title'). * 2. The first

                                                          in the header row (index 0) โ€” the universal default. * * When the target column is not the first, `left` must equal the total width of * all preceding columns; this offset is re-calculated on every call and whenever * a MutationObserver detects that a colgroup
                                                          and in that column index across * all
                                                          `: * 5. Replace the text node immediately between `` and `` * with U+00A0 (NBSP), inserting one if none exists. Placing the NBSP OUTSIDE * `` is critical: `` carries `overflow-wrap:anywhere`, * which can allow a break at any code-point boundary inside the element โ€” * including a NBSP inside `` at a bidi-run boundary. A NBSP that lives * as a sibling text node of `` is governed only by the standard Unicode * Line Break Algorithm. Unicode GL class (NBSP) prohibits a break point both * before AND after it, with one exception: "no break before GL unless preceded * by SP" (U+0020). Step 6 ensures no trailing space is present, so the GL * rule is always in full effect. * 6. Trim trailing whitespace from the last text node of ``'s ``. A * trailing U+0020 SPACE would trigger the GL SP-exception, allowing a break * between the anchor text and the NBSP. Pure trimming is sufficient โ€” the * NBSP text node outside `` provides the visual gap. * * **What this intentionally does NOT do** * No `white-space: nowrap` is written to any live DOM element (`span.comment`, * ``, ``, `
                                                        • `). Previous versions set nowrap on containers to * prevent the anchorโ†’comment line break, but that forced the min-content width * of every cell in `table-layout:auto` to the full single-line text width of * the longest disambiguation comment โ€” making columns like Release and Label * unexpectedly wide even without auto-resize, and preventing manual drag-resize * from allowing text to wrap at narrower widths. * * The auto-resize measurement sandbox uses `white-space:nowrap !important` via * `.mb-measure-nowrap`, so measured column widths are unaffected by this change. * At the auto-resize minimum width the full content fits on one line and the * browser will not insert unnecessary breaks. If the user drags a column * narrower than that minimum, the browser's natural line-breaking takes over โ€” * which is the desired behaviour. * * Idempotent. Also called in `toggleAutoResizeColumns` before measurement. * * @param {HTMLTableElement} table */ function normalizeCommentSpans(table) { if (!table) return; // โ”€โ”€ Normalise span.comment interiors โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // // For each span.comment: // โ€ข Remove pure-whitespace text-node children (\n around ). // โ€ข Move a leading non-empty text node (Variant A's "(") into . // โ€ข Move a trailing non-empty text node (Variant A's ")") into , // eliminating the bidi-boundary soft-wrap point after . table.querySelectorAll('td span.comment').forEach(commentSpan => { // Snapshot children; DOM is mutated inside the loop. Array.from(commentSpan.childNodes).forEach(node => { if (node.nodeType !== Node.TEXT_NODE) return; if (node.textContent.trim() === '') { // Pure whitespace before/after โ†’ remove. node.remove(); return; } // Non-empty leading text (Variant A "(") before a : // prepend into then remove. const nextEl = node.nextSibling; if ( nextEl && nextEl.nodeType === Node.ELEMENT_NODE && nextEl.tagName === 'BDI' ) { const trimmed = node.textContent.trim(); if ( nextEl.firstChild && nextEl.firstChild.nodeType === Node.TEXT_NODE ) { nextEl.firstChild.textContent = trimmed + nextEl.firstChild.textContent; } else { nextEl.insertBefore( document.createTextNode(trimmed), nextEl.firstChild ); } node.remove(); } }); // Non-empty trailing text node after (Variant A ")"): // append into to kill the bidi-boundary break. const bdi = commentSpan.querySelector(':scope > bdi'); if (bdi) { const afterBdi = bdi.nextSibling; if ( afterBdi && afterBdi.nodeType === Node.TEXT_NODE && afterBdi.textContent.trim() !== '' ) { bdi.appendChild(afterBdi); } // Trim trailing whitespace from the last text node inside // (Variant B: MusicBrainz emits `\n ` before which // collapses to a single space and creates a soft-wrap point at the // end of the bdi content โ€” e.g. "reissue)" breaking to a new line). if (bdi.lastChild && bdi.lastChild.nodeType === Node.TEXT_NODE) { bdi.lastChild.textContent = bdi.lastChild.textContent.trimEnd(); } } }); // โ”€โ”€ Place NBSP between and โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // // MusicBrainz emits a `\n ` text node between and . // We REPLACE that text node with U+00A0 (NBSP), or INSERT one if absent. // // The NBSP is placed OUTSIDE intentionally. // carries `overflow-wrap:anywhere`, which Chrome can use to allow a break // at any code-point boundary inside the element โ€” including at a NBSP inside // `` at a bidi-run boundary. A NBSP that lives as a sibling text node // of is not inside any `overflow-wrap:anywhere` element, so only the // standard Unicode GL rule applies: no break before or after it, provided // the preceding character is not U+0020 SPACE (ensured by trimEnd below). // // Idempotent: on a second pass the NBSP text node is already present; // setting its textContent to '\u00A0' again is a no-op. table.querySelectorAll('td').forEach(td => { td.querySelectorAll('span.comment').forEach(commentSpan => { const prevEl = commentSpan.previousElementSibling; if (!prevEl || prevEl.tagName !== 'A') return; const prev = commentSpan.previousSibling; if (prev && prev.nodeType === Node.TEXT_NODE) { // Variant B: whitespace text node present โ€” replace with NBSP. prev.textContent = '\u00A0'; } else if (prev === prevEl) { // Variant A: no text node โ€” insert a NBSP text node. prevEl.after(document.createTextNode('\u00A0')); } }); }); // โ”€โ”€ Trim trailing whitespace from โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // // With NBSP now OUTSIDE , the anchor's must NOT end with U+0020 // SPACE. The Unicode GL rule has an SP-exception: "no break before GL // unless preceded by SP" โ€” a trailing space inside would allow a // line break between that space and the NBSP text node. Pure trimEnd() // removes any trailing whitespace without adding a new character; the // visual gap is provided by the NBSP outside . table.querySelectorAll('td a').forEach(anchor => { if ( !anchor.nextElementSibling || !anchor.nextElementSibling.classList.contains('comment') ) return; const bdi = anchor.querySelector(':scope > bdi'); if (!bdi) return; const last = bdi.lastChild; if (last && last.nodeType === Node.TEXT_NODE) { const trimmed = last.textContent.trimEnd(); if (trimmed !== last.textContent) { last.textContent = trimmed; } } }); } /** * Resets the MusicBrainz `even`/`odd` CSS class on every visible `
                                                        • ` with no explicit background) inherit the * row background automatically via normal table-cell inheritance. * * Rows with `display:none` are skipped so the visible sequence always starts * with the `odd` class (index 0). * * @param {HTMLTableElement} table */ function applyZebraStriping(table) { if (!table) return; let visIdx = 0; table.querySelectorAll('tbody tr').forEach(tr => { if (tr.style.display === 'none') return; // Re-assign the native MusicBrainz even/odd class based on the current // visual position, which may have changed after a sort or filter. tr.classList.remove('even', 'odd'); tr.classList.add(visIdx % 2 === 0 ? 'odd' : 'even'); visIdx++; }); } /** * RGBA components for each sort-tint class, matching the CSS `mb-mscol-*` * `background-color` definitions. Declared at module scope so both * `applyStickyColumn` (which may run after `applyTints` placed tint classes on * fresh clone rows) and `applyMultiSortColumnTints` (which runs from the sort * handler) can share the same data without duplicating it. * @type {Object.} */ const _MSCOL_TINT_RGBA = { 'mb-mscol-0a': [255, 200, 80, 0.22], 'mb-mscol-0b': [255, 200, 80, 0.44], 'mb-mscol-1a': [ 80, 180, 255, 0.22], 'mb-mscol-1b': [ 80, 180, 255, 0.44], 'mb-mscol-2a': [120, 230, 120, 0.22], 'mb-mscol-2b': [120, 230, 120, 0.44], 'mb-mscol-3a': [230, 120, 230, 0.22], 'mb-mscol-3b': [230, 120, 230, 0.44], 'mb-mscol-4a': [255, 160, 100, 0.22], 'mb-mscol-4b': [255, 160, 100, 0.44], 'mb-mscol-5a': [100, 230, 210, 0.22], 'mb-mscol-5b': [100, 230, 210, 0.44], 'mb-mscol-6a': [180, 160, 255, 0.22], 'mb-mscol-6b': [180, 160, 255, 0.44], 'mb-mscol-7a': [255, 220, 180, 0.22], 'mb-mscol-7b': [255, 220, 180, 0.44], }; /** * RGBA components for the header sort-tint classes (`mb-mscol-hdr-*`), matching * the CSS definitions (alpha = 0.60). Used by `applyStickyColumn` and * `applyMultiSortColumnTints` to compute opaque blended backgrounds for the * sticky column header cell, for the same reason as `_MSCOL_TINT_RGBA`. * @type {Object.} */ const _MSCOL_HDR_TINT_RGBA = { 'mb-mscol-hdr-0': [255, 200, 80, 0.60], 'mb-mscol-hdr-1': [ 80, 180, 255, 0.60], 'mb-mscol-hdr-2': [120, 230, 120, 0.60], 'mb-mscol-hdr-3': [230, 120, 230, 0.60], 'mb-mscol-hdr-4': [255, 160, 100, 0.60], 'mb-mscol-hdr-5': [100, 230, 210, 0.60], 'mb-mscol-hdr-6': [180, 160, 255, 0.60], 'mb-mscol-hdr-7': [255, 220, 180, 0.60], }; /** * Alpha-blends a sort-tint colour onto an opaque base background and returns * an opaque `rgb()` string suitable for use as an inline style on a sticky cell. * * The CSS `mb-mscol-*` classes use `rgba() !important` which overrides the * inline opaque background that `applyStickyColumn` sets, making the sticky * cell semi-transparent so scrolled content shows through. Calling this * function and applying the result via `setProperty('background-color', โ€ฆ, * 'important')` produces an opaque colour that wins the cascade while still * visually matching the translucent tint class. * * @param {[number,number,number,number]} tint - `[R, G, B, alpha]` of the tint. * @param {string} restBg - Opaque CSS colour string (`#rrggbb` or `rgb(r,g,b)`). * @returns {string} Opaque `rgb(r,g,b)` result. */ function _blendSortTint(tint, restBg) { let rb = 255, gb = 255, bb = 255; const hm = restBg.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); if (hm) { rb = parseInt(hm[1], 16); gb = parseInt(hm[2], 16); bb = parseInt(hm[3], 16); } else { const rm = restBg.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); if (rm) { rb = +rm[1]; gb = +rm[2]; bb = +rm[3]; } } const [r, g, b, a] = tint; return `rgb(${Math.round(r * a + rb * (1 - a))},${Math.round(g * a + gb * (1 - a))},${Math.round(b * a + bb * (1 - a))})`; } /** * Applies CSS `position:sticky; left:0` to one column of `table` so it remains * visible while the user scrolls horizontally. * * The target column is determined from `activeDefinition.features.stickyColumn` * (a column name string, e.g. `'Title'`). When no configuration is found the * first column (index 0) is used as the default. The column name is matched * against `th.dataset.colName` first, then against the stripped `th.textContent`. * * Both the `` header cell and every `` body cell in the target column * receive `position:sticky` and a `z-index` that layers correctly with the sticky * header row (header gets z-index 3, body cells get 1). The `mb-sticky-col` * class is added to every participating cell so downstream code (e.g. zebra * striping, export) can identify them. * * @param {HTMLTableElement} table - The table to apply the sticky column to. */ function applyStickyColumn(table) { if (!table) return; // โ”€โ”€ Resolve the column index to make sticky โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const headers = Array.from(table.querySelectorAll('thead tr:first-child th')); if (!headers.length) return; const stickyName = activeDefinition?.features?.stickyColumn || null; let stickyIdx = 0; // default: first column if (stickyName) { const found = headers.findIndex(th => { const txt = th.dataset.colName || th.textContent.replace(/[โ‡…โ–ฒโ–ผโฐยนยฒยณโดโตโถโทโธโน๐Ÿ“Šโ–ถโ—€โ–ค0-9]/g, '').trim().replace(/\s+/g, ' '); return txt === stickyName; }); if (found !== -1) stickyIdx = found; else Lib.debug('ui', `applyStickyColumn: column "${stickyName}" not found โ€” falling back to index 0`); } /** * Returns the `left` CSS offset for the sticky column. * * Always returns 0: the sticky column should snap to the left viewport * edge (`left: 0px`), regardless of how many columns precede it. * * Using `left: Npx` (where N = sum of preceding column widths) causes a * "ghosting" artefact: the sticky column only activates when the scroll * position reaches N pixels, leaving a transparent gap in 0..N px through * which other columns visibly scroll before the sticky column snaps. * * A non-zero `left` offset is only needed when multiple preceding columns * are ALSO sticky (so each one stacks behind the next). This script uses * a single sticky column per table, so `left: 0` is always correct. * * @returns {number} always 0 */ function _leftOffset() { return 0; } /** * Applies (or updates) the sticky CSS on every cell in column stickyIdx. */ function _apply() { const left = _leftOffset(); const leftPx = `${left}px`; const bgHeader = Lib.settings.sa_ui_thead_th_bg || '#e8e8e8'; const bgFilter = Lib.settings.sa_ui_thead_filter_row_bg || '#f4f4f4'; // Header rows table.querySelectorAll('thead tr').forEach(tr => { const cell = tr.cells[stickyIdx]; if (!cell) return; const isFilter = tr.classList.contains('mb-col-filter-row'); cell.style.position = 'sticky'; cell.style.left = leftPx; cell.style.zIndex = '101'; const headerBg = isFilter ? bgFilter : bgHeader; // Main header only: detect any active hdr-tint class, temporarily // remove it so we can set the true base background uncontested, then // restore it with an opaque blended colour (same pattern as the body // row fix โ€” the class uses rgba() !important which beats the inline // background shorthand and makes the sticky header cell see-through). const _activeHdrTint = isFilter ? null : Object.keys(_MSCOL_HDR_TINT_RGBA).find(cls => cell.classList.contains(cls)); if (_activeHdrTint) { cell.classList.remove(_activeHdrTint); cell.style.removeProperty('background-color'); } cell.style.background = headerBg; cell.dataset.mbStickyBg = headerBg; if (_activeHdrTint) { cell.classList.add(_activeHdrTint); const blended = _blendSortTint(_MSCOL_HDR_TINT_RGBA[_activeHdrTint], headerBg); cell.dataset.mbStickyBg = blended; cell.dataset.mbStickyRestBg = headerBg; cell.style.setProperty('background-color', blended, 'important'); } cell.classList.add('mb-sticky-col'); }); // Body rows โ€” the even/odd CSS classes (set by applyZebraStriping) target // elements (e.g. tr.even > td { background: #e4e4e4 }), not
                                                          elements: // โ€ข The sticky column cell itself gets an explicit inline background // (restBg below) so it paints opaquely over scrolled content. // โ€ข Injected cells (mb-re-cell, mb-rel-cell, mb-ice-cell) get an // inline backgroundColor set when they are created. // โ€ข Inline styles win the CSS cascade unconditionally, even against // !important rules in a stylesheet. // // Instead of relying on CSS :hover, we snapshot the rest-state // background of every in the row at _apply() time and swap all // of them in the shared mouseenter/mouseleave handlers. This gives // reliable full-row highlighting regardless of inline backgrounds, // compositing layer hit-testing quirks, or browser-specific :hover // propagation issues. // // The sticky cell must keep an opaque inline background so it paints // over scrolled content โ€” it gets both mbStickyBg and mbRestBg. // Non-sticky cells just get mbRestBg (stored for leave restore) and // their inline style is cleared so CSS zebra striping drives the colour. // Snapshot the sticky cell first (needs inline bg for opacity). // // Problem: renderFinalTable calls applyTints() on fresh cloned rows // before runFilter() calls applyStickyColumn. When we arrive here, // a sort-tint class (mb-mscol-*) may already be on the cell with // `background-color: rgba(โ€ฆ) !important`. Clearing the inline // background shorthand (cell.style.background = '') does NOT remove // that โ€” the class rule still wins. getComputedStyle then returns // the semi-transparent tint colour instead of the true zebra/base bg, // which we would store as mbStickyBg and later use as the blend base // in applyMultiSortColumnTints, producing a compounded wrong colour. // // Fix: find and temporarily remove any active tint class (and any // prior inline !important bg-color) before reading getComputedStyle, // so the true rest background is exposed. Restore the tint class // immediately after and apply the correct opaque blended colour. const _activeTintClass = Object.keys(_MSCOL_TINT_RGBA).find( cls => cell.classList.contains(cls) ); if (_activeTintClass) { cell.classList.remove(_activeTintClass); cell.style.removeProperty('background-color'); } cell.style.background = ''; const cellBg = getComputedStyle(cell).backgroundColor; const trueRestBg = (cellBg === 'rgba(0, 0, 0, 0)' || cellBg === 'transparent') ? '#ffffff' : cellBg; cell.dataset.mbRestBg = trueRestBg; if (_activeTintClass) { // Tint was already applied (renderFinalTable path): restore the // class and set an opaque blended colour via inline !important so // it wins the cascade over the class's rgba() !important rule. cell.classList.add(_activeTintClass); const blended = _blendSortTint(_MSCOL_TINT_RGBA[_activeTintClass], trueRestBg); cell.dataset.mbStickyBg = blended; cell.dataset.mbStickyRestBg = trueRestBg; cell.style.setProperty('background-color', blended, 'important'); } else { cell.dataset.mbStickyBg = trueRestBg; cell.style.background = trueRestBg; } cell.classList.add('mb-sticky-col'); // span.comment ) bidi-boundary wrap is handled globally by // normalizeCommentSpans(), called after every render pass. // Snapshot all non-sticky cells (clear inline bg so CSS zebra wins). Array.from(tr.cells).forEach(td => { if (td === cell) return; td.style.background = ''; const bg = getComputedStyle(td).backgroundColor; td.dataset.mbRestBg = (bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') ? '#ffffff' : bg; }); // Wire hover handlers โ€” always rewire, because after sort/filter // renderFinalTable/renderGroupedTable inserts cloneNode(true) copies of // the source rows. cloneNode copies dataset attributes (so a // dataset-flag guard like `if (!tr.dataset.mbStickyHoverWired)` would // see '1' on the clone and skip wiring), but does NOT copy JS object // properties. We therefore store the handler functions as named // properties on the tr element (_mbStickyEnter / _mbStickyLeave): // โ€ข removeEventListener with the stored reference cleanly detaches // any previously wired listener from this exact tr node // โ€ข on a freshly cloned tr these properties are undefined, so // removeEventListener is a harmless no-op and we wire fresh // This guarantees exactly one live listener pair per tr after every // _apply() call, regardless of how many times the row was cloned or // re-inserted. const _enter = () => { // Apply hover colour to every cell in the row, bypassing the // CSS :hover cascade (inline styles win over author :hover rules). // // For sticky cells that have an active sort tint (mbStickyRestBg // is set), the tint class uses `rgba() !important` which beats a // non-important inline background shorthand, making the sticky cell // semi-transparent during hover. Applying via setProperty wins. Array.from(tr.cells).forEach(td => { td.style.background = hoverBgColor; if (td.classList.contains('mb-sticky-col') && td.dataset.mbStickyRestBg) { td.style.setProperty('background-color', hoverBgColor, 'important'); } }); }; const _leave = () => { // Restore each cell to its individual rest-state background. // Sticky cell: restore opaque inline bg from mbStickyBg. // Non-sticky cells: restore from mbRestBg when present (covers // barcode-highlighted cells whose color would be lost by clearing // the background shorthand), otherwise clear inline bg so CSS // zebra striping takes over. // // When a sort tint is active on a sticky cell (mbStickyRestBg set), // mbStickyBg holds the opaque blended colour. The tint class's // `rgba() !important` would beat the non-important shorthand we set // via `background`, making the cell see-through. Use setProperty // to ensure the opaque blended colour wins the cascade. Array.from(tr.cells).forEach(td => { if (td.classList.contains('mb-sticky-col')) { const restoreBg = td.dataset.mbStickyBg || '#ffffff'; td.style.background = restoreBg; if (td.dataset.mbStickyRestBg) { td.style.setProperty('background-color', restoreBg, 'important'); } } else if (td.dataset.mbRestBg) { td.style.background = td.dataset.mbRestBg; } else { td.style.background = ''; } }); }; // Remove any previously attached listener on this exact node, then add fresh ones. if (tr._mbStickyEnter) tr.removeEventListener('mouseenter', tr._mbStickyEnter); if (tr._mbStickyLeave) tr.removeEventListener('mouseleave', tr._mbStickyLeave); tr._mbStickyEnter = _enter; tr._mbStickyLeave = _leave; tr.addEventListener('mouseenter', _enter); tr.addEventListener('mouseleave', _leave); }); Lib.debug('ui', `applyStickyColumn: idx=${stickyIdx} left=${leftPx} table=${table.id||'(no-id)'}`); } _apply(); } /** * Registry of per-sub-table column-visibility widgets (multi-table mode). * Maps `safeId` โ†’ `{ applyGlobalConfig(state) }` so the global "Visible" * button's "Choose current configuration" action can push the global column * state to every sub-table. * @type {Map} */ const subTableColVisRegistry = new Map(); /** * Toggle visibility of a column across all tables * @param {HTMLTableElement} table - Reference table (not used, kept for compatibility) * @param {number} columnIndex - Index of the column to toggle * @param {boolean} show - True to show, false to hide */ function toggleColumn(table, columnIndex, show) { const display = show ? '' : 'none'; // Toggle column in ALL tables on the page (for multi-table pages) const allTables = document.querySelectorAll('table.tbl'); allTables.forEach(currentTable => { // Toggle header cells in all header rows (main header + filter row) const headers = currentTable.querySelectorAll('thead tr'); headers.forEach(row => { if (row.cells[columnIndex]) { row.cells[columnIndex].style.display = display; } }); // Toggle all cells in the column for all body rows const rows = currentTable.querySelectorAll('tbody tr'); rows.forEach(row => { if (row.cells[columnIndex]) { row.cells[columnIndex].style.display = display; } }); // If auto-resize is active, update the colgroup and table width if (isAutoResized) { const colgroup = currentTable.querySelector('colgroup'); if (colgroup && colgroup.children[columnIndex]) { const col = colgroup.children[columnIndex]; if (show) { // Need to re-measure this column to get proper width // Create temporary measurement container const measureDiv = document.createElement('div'); measureDiv.style.cssText = ` position: absolute; visibility: hidden; white-space: nowrap; font-family: inherit; font-size: inherit; padding: 4px 8px; `; document.body.appendChild(measureDiv); let maxWidth = 0; // Measure header const th = headers[0]?.cells[columnIndex]; if (th) { const contentClone = th.cloneNode(true); const styles = window.getComputedStyle(th); measureDiv.style.fontSize = styles.fontSize; measureDiv.style.fontWeight = styles.fontWeight; measureDiv.style.padding = styles.padding; measureDiv.style.fontFamily = styles.fontFamily; measureDiv.innerHTML = ''; measureDiv.appendChild(contentClone); maxWidth = Math.max(maxWidth, measureDiv.offsetWidth); } // Measure sample data cells (up to 100 rows) const dataRows = currentTable.querySelectorAll('tbody tr'); const sampleSize = Math.min(dataRows.length, 100); const sampleStep = Math.max(1, Math.floor(dataRows.length / sampleSize)); for (let i = 0; i < dataRows.length; i += sampleStep) { const row = dataRows[i]; if (row.style.display === 'none') continue; const cell = row.cells[columnIndex]; if (cell) { const contentClone = cell.cloneNode(true); const styles = window.getComputedStyle(cell); measureDiv.style.fontSize = styles.fontSize; measureDiv.style.fontWeight = styles.fontWeight; measureDiv.style.padding = styles.padding; measureDiv.style.fontFamily = styles.fontFamily; measureDiv.innerHTML = ''; measureDiv.appendChild(contentClone); maxWidth = Math.max(maxWidth, measureDiv.offsetWidth); } } document.body.removeChild(measureDiv); // Set the measured width const finalWidth = Math.ceil(maxWidth + 20); col.style.width = `${finalWidth}px`; col.style.display = ''; Lib.debug('ui', `Column ${columnIndex} shown and re-measured: ${finalWidth}px`); } else { // Hide column in colgroup col.style.width = '0px'; col.style.display = 'none'; } } // Recalculate table width based on currently visible columns const firstRow = currentTable.querySelector('tbody tr'); if (firstRow) { const columnCount = firstRow.cells.length; const columnWidths = []; // Get current widths from colgroup if (colgroup) { for (let i = 0; i < columnCount; i++) { const col = colgroup.children[i]; const th = headers[0]?.cells[i]; const isVisible = th && th.style.display !== 'none'; if (col && isVisible) { const width = parseInt(col.style.width) || 0; columnWidths.push(width); } } } // Calculate new total width const totalWidth = columnWidths.reduce((sum, w) => sum + w, 0); if (totalWidth > 0) { currentTable.style.width = `${totalWidth}px`; currentTable.style.minWidth = `${totalWidth}px`; Lib.debug('ui', `Table width updated to ${totalWidth}px after toggling column ${columnIndex}`); } } } }); Lib.debug('ui', `Column ${columnIndex} ${show ? 'shown' : 'hidden'} in ${allTables.length} table(s)`); // Feature 4: when a collapsable-column is hidden/shown via the Visible-button, // keep collapse toggle buttons in sync with actual column visibility. syncCollapseButtonsWithColumnVisibility(show); } /** * Toggles column visibility in a SINGLE table only (per-sub-table variant of * `toggleColumn`). All logic is identical to `toggleColumn` except that only * the supplied `table` element is modified โ€” other `table.tbl` elements are * left untouched. * * Used by `createSubTableColumnVisibilityButton` so that the per-sub-table ๐Ÿ‘๏ธ * button affects only its own table. * * @param {HTMLTableElement} table The table to operate on. * @param {number} columnIndex Zero-based column index. * @param {boolean} show `true` to show, `false` to hide. */ function toggleColumnInTable(table, columnIndex, show) { const display = show ? '' : 'none'; // Toggle header cells in all header rows (main + filter row) const headers = table.querySelectorAll('thead tr'); headers.forEach(row => { if (row.cells[columnIndex]) row.cells[columnIndex].style.display = display; }); // Toggle body cells table.querySelectorAll('tbody tr').forEach(row => { if (row.cells[columnIndex]) row.cells[columnIndex].style.display = display; }); // If auto-resize is active on this table, update its colgroup width const stResized = !!subTableResizedStates.get(table); if (isAutoResized || stResized) { const colgroup = table.querySelector('colgroup'); if (colgroup && colgroup.children[columnIndex]) { const col = colgroup.children[columnIndex]; if (show) { // Re-measure this column quickly const measureDiv = document.createElement('div'); measureDiv.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap;font-family:inherit;font-size:inherit;padding:4px 8px;'; document.body.appendChild(measureDiv); let maxWidth = 0; const th = headers[0]?.cells[columnIndex]; if (th) { const styles = window.getComputedStyle(th); measureDiv.style.fontSize = styles.fontSize; measureDiv.style.fontWeight = styles.fontWeight; measureDiv.innerHTML = ''; th.childNodes.forEach(n => measureDiv.appendChild(n.cloneNode(true))); maxWidth = Math.max(maxWidth, measureDiv.offsetWidth); } const dataRows = table.querySelectorAll('tbody tr'); const step = Math.max(1, Math.floor(dataRows.length / Math.min(dataRows.length, 100))); for (let i = 0; i < dataRows.length; i += step) { const cell = dataRows[i].cells[columnIndex]; if (cell && dataRows[i].style.display !== 'none') { const styles = window.getComputedStyle(cell); measureDiv.style.fontSize = styles.fontSize; measureDiv.innerHTML = ''; cell.childNodes.forEach(n => measureDiv.appendChild(n.cloneNode(true))); maxWidth = Math.max(maxWidth, measureDiv.offsetWidth); } } document.body.removeChild(measureDiv); col.style.width = `${Math.ceil(maxWidth + 20)}px`; col.style.display = ''; } else { col.style.width = '0px'; col.style.display = 'none'; } // Recalculate table total width const firstRow = table.querySelector('tbody tr'); if (firstRow) { let totalWidth = 0; for (let i = 0; i < firstRow.cells.length; i++) { const hdrCell = headers[0]?.cells[i]; if (hdrCell && hdrCell.style.display !== 'none') { totalWidth += parseInt(colgroup.children[i]?.style.width) || 0; } } if (totalWidth > 0) { table.style.width = `${totalWidth}px`; table.style.minWidth = `${totalWidth}px`; } } } } Lib.debug('ui', `toggleColumnInTable: col ${columnIndex} ${show ? 'shown' : 'hidden'}`); } /** * Synchronises the visibility of the global and per-sub-table collapse * toggle buttons with the current column-visibility state after a call to * `toggleColumn()`. * * Rules (per the feature spec): * โ€ข If ALL collapsable columns (declared in `activeDefinition.features.collapsableColumns`) * are currently hidden (display:none on their ``), the global button AND every * `.mb-subtable-collapse-btn` are hidden immediately. * โ€ข If at least one collapsable column is visible again AND `show` is true * (a column was just un-hidden), `initCollapsableColumns` is re-run for every * `` so that the toggle infrastructure (cell toggles, header buttons, * global/sub-table buttons) is fully re-established for the newly visible column; * in multi-table mode `rewireGlobalCollapseButtonMulti()` is called afterwards. * * This is intentionally lightweight for the hide-direction (no re-render needed) and * relies on the existing `initCollapsableColumns` / `rewireGlobalCollapseButtonMulti` * machinery for the show-direction. * * @param {boolean} show - true when a column was just made visible, false when hidden */ function syncCollapseButtonsWithColumnVisibility(show) { if (!activeDefinition || !activeDefinition.features) return; const collapsableCols = activeDefinition.features.collapsableColumns; if (!Array.isArray(collapsableCols) || collapsableCols.length === 0) return; // Use the first rendered table as reference for header visibility. const firstTable = document.querySelector('table.tbl'); if (!firstTable) return; const headers = Array.from(firstTable.querySelectorAll('thead tr:first-child th')); /** * Returns true when the th for `colName` is currently visible. * @param {string} colName * @returns {boolean} */ const isColVisible = colName => { const th = headers.find(h => { const clean = h.textContent .replace(/[โ‡…โ–ฒโ–ผโฐยนยฒยณโดโตโถโทโธโน๐Ÿ“Šโ–ถโ—€โ–ค0-9]/g, '') .trim() .replace(/\s+/g, ' '); return clean === colName; }); return !!th && th.style.display !== 'none'; }; const anyCollapsableVisible = collapsableCols.some(isColVisible); if (!anyCollapsableVisible) { // All collapsable columns are invisible โ†’ hide all collapse buttons immediately. const globalBtn = document.getElementById('mb-col-collapse-all-btn'); if (globalBtn) { globalBtn.style.display = 'none'; globalBtn.style.backgroundColor = ''; globalBtn.style.color = ''; globalBtn.style.boxShadow = ''; } document.querySelectorAll('.mb-subtable-collapse-btn').forEach(btn => { btn.style.display = 'none'; btn.style.backgroundColor = ''; btn.style.color = ''; btn.style.boxShadow = ''; }); Lib.debug('ui', 'syncCollapseButtonsWithColumnVisibility: all collapsable columns hidden โ†’ collapse buttons hidden.'); return; } // At least one collapsable column is visible. if (!show) return; // Column was hidden but others remain visible โ€” no re-init needed. // A column was just made visible again โ†’ re-run initCollapsableColumns so the // toggle infrastructure is rebuilt for the newly visible column. Lib.debug('ui', 'syncCollapseButtonsWithColumnVisibility: collapsable column shown โ†’ re-running initCollapsableColumns.'); const isMultiMode = activeDefinition.tableMode === 'multi'; document.querySelectorAll('table.tbl').forEach(t => initCollapsableColumns(t)); if (isMultiMode) { // rewireGlobalCollapseButtonMulti handles the global button; each sub-table // collapse button must be shown/tinted separately via updateSubTableCollapseButton. rewireGlobalCollapseButtonMulti(); document.querySelectorAll('table.tbl').forEach(t => updateSubTableCollapseButton(t)); } } /** * Create a clear column filters button with a red โœ— symbol * This is a helper function to avoid code duplication across multiple locations * @param {HTMLTableElement} table - The table this button will clear filters for * @param {string} categoryName - The name of the table category for logging * @returns {HTMLButtonElement} The created button element */ function createClearColumnFiltersButton(table, categoryName) { const clearBtn = document.createElement('button'); clearBtn.className = 'mb-subtable-clear-btn'; clearBtn.type = 'button'; clearBtn.title = 'Clear all filters for this sub-table (the sub-table filter and all column filters)'; clearBtn.style.display = 'none'; // Initially hidden // Create red โœ— symbol const xSymbol = document.createElement('span'); xSymbol.textContent = 'โœ— '; xSymbol.style.color = 'red'; xSymbol.style.fontSize = '1.0em'; xSymbol.style.fontWeight = 'bold'; const clearBtnLabel = document.createElement('span'); clearBtnLabel.className = 'mb-subtable-clear-btn-label'; clearBtnLabel.textContent = 'Clear all filters'; // updated dynamically by updateFilterButtonsVisibility() clearBtn.appendChild(xSymbol); clearBtn.appendChild(clearBtnLabel); clearBtn.onclick = (e) => { e.preventDefault(); e.stopPropagation(); clearSubTableColumnFilters(table, categoryName); }; return clearBtn; } /** * Create a standalone "Toggle highlighting" button for a sub-table. * Used in defensive code-paths where the full createSubTableFilterContainer() * closure is not available (e.g. when .mb-subtable-controls was missing and * must be rebuilt). The button derives its id from the sanitised category * name so updateFilterButtonsVisibility() can locate it via * [id$="-toggle-filter-highlight-btn"]. * * Toggle behaviour (stateful via the button's dataset): * ON โ†’ call runFilter() to re-apply all column-filter highlights. * OFF โ†’ strip .mb-subtable-filter-highlight + .mb-column-filter-highlight * from the associated table rows. * * @param {HTMLTableElement} table * @param {string} categoryName * @returns {HTMLButtonElement} */ function createSubTableHighlightButton(table, categoryName) { const safeId = categoryName.replace(/[^a-zA-Z0-9_-]/g, '_'); const btn = document.createElement('button'); btn.id = `mb-stf-${safeId}-toggle-filter-highlight-btn`; btn.type = 'button'; btn.title = 'Toggle filter string highlighting for this sub-table filter and ALL sub-table column filters)'; btn.textContent = '๐ŸŽจ Toggle highlighting'; // Initially hidden โ€” shown by updateFilterButtonsVisibility() when active. btn.style.cssText = 'font-size:0.8em; padding:2px 6px; border-radius:4px; background:rgb(240,240,240); border:1px solid rgb(204,204,204); cursor:pointer; vertical-align:middle; transition:background-color 0.3s; display:none;'; btn.dataset.highlightEnabled = 'true'; btn.addEventListener('click', (e) => { e.stopPropagation(); const enabled = btn.dataset.highlightEnabled !== 'false'; if (enabled) { // Currently ON โ†’ switch OFF btn.dataset.highlightEnabled = 'false'; btn.style.backgroundColor = '#90ee90'; btn.style.color = '#000'; if (table) { table.querySelectorAll( '.mb-subtable-filter-highlight, .mb-column-filter-highlight' ).forEach(n => n.replaceWith(document.createTextNode(n.textContent))); } } else { // Currently OFF โ†’ switch ON btn.dataset.highlightEnabled = 'true'; btn.style.backgroundColor = ''; btn.style.color = ''; if (typeof runFilter === 'function') runFilter(); } }); return btn; } /** * Returns the innerHTML for global/sub-table collapse-toggle buttons. * * Renders as two stacked flex-column groups (โ–ถ/โ–ผ glyph + "Expand/Collapse ALL" * + "multi-row cells") laid out with the outer button carrying * display:inline-flex; align-items:center; gap:4px. * * State detection (used by all click handlers) relies on * `btn.textContent.startsWith('โ–ถ')`. * * @param {boolean} expand - true โ†’ โ–ถ Expand state; false โ†’ โ–ผ Collapse state * @returns {string} HTML string to assign to btn.innerHTML */ function makeCollapseExpandBtnHTML(expand) { // โ–ถ = currently collapsed โ†’ click will EXPAND // โ–ผ = currently expanded โ†’ click will COLLAPSE // // Two stacked flex-column label groups. The span font-size is set to // 0.72em (down from 0.9em) so that two stacked rows at line-height:1 // produce a total height of 2 ร— 0.72em โ‰ˆ 1.44em โ€” matching the height // of a single-line inline-block button whose line-height is ~1.4. // The outer button carries display:inline-flex; align-items:center; gap:4px // which is still required for the two-column layout to render correctly. const arrow = expand ? 'โ–ถ' : 'โ–ผ'; const action = expand ? 'Expand' : 'Collapse'; return `${arrow}` + `` + `${action}` + `all` + `multi-row` + `cells`; } /** * Creates the per-sub-table โ–ถ/โ—€ column-collapse toggle button. * * The button is inserted into the h3 bar immediately BEFORE * `.mb-subtable-controls` so it is visually grouped with the other * sub-table controls without being swallowed inside that flex span. * * Behaviour: * โ€ข Reads its own state from textContent (โ–ถ = all collapsed, * โ—€ = expanded) โ€” no hidden property, consistent with the global and * column-header toggle philosophy. * โ€ข On click: collects every `.mb-col-collapse-hdr-btn` inside `table`, * drives each one that is not already in the target state by calling * `.click()` (which in turn drives the delegation listener). * โ€ข Initially `display:none` โ€” shown by `updateSubTableCollapseButton()` * once `initCollapsableColumns()` confirms the table has multi-row cells. * * @param {HTMLTableElement} table - The sub-table this button controls. * @param {string} categoryName - Human-readable group name (used for * the id and accessibility attributes). * @returns {HTMLButtonElement} */ function createSubTableCollapseButton(table, categoryName) { const safeId = categoryName.replace(/[^a-zA-Z0-9_-]/g, '_'); const btn = document.createElement('button'); btn.id = `mb-stf-${safeId}-subtable-collapse-btn`; btn.type = 'button'; btn.className = 'mb-subtable-collapse-btn'; btn.innerHTML = makeCollapseExpandBtnHTML(true); btn.title = `Expand ALL collapsed multi-row cells in "${categoryName}" sub-section`; btn.setAttribute('aria-label', `Expand all collapsed multi-row cells in: ${categoryName}`); // Same base style as createSubTableHighlightButton; hidden until // updateSubTableCollapseButton() determines multi-row cells exist. // Uses inline-flex so the two-row label (โ–ถ Expand / multi-row / cells) // renders correctly; margin-left:8px shifts it right of the Ex label // to prevent overlap when the button is tinted during highlight mode. btn.style.cssText = [ 'font-size:0.8em; padding:2px 6px; border-radius:4px;', 'background:rgb(240,240,240); border:1px solid rgb(204,204,204);', 'cursor:pointer; vertical-align:middle;', 'align-items:center; gap:4px; margin-left:8px;', 'transition:background-color 0.2s, color 0.2s, box-shadow 0.2s;', 'display:none;' ].join(' '); btn.addEventListener('click', (e) => { e.stopPropagation(); const hdrBtns = Array.from(table.querySelectorAll('.mb-col-collapse-hdr-btn')); if (hdrBtns.length === 0) return; // โ–ถ = some/all collapsed โ†’ expand all; โ—€ = all expanded โ†’ collapse all. const _g = btn.querySelector('.mb-col-collapse-glyph'); const targetExpand = (_g ? _g.textContent : btn.textContent).startsWith('โ–ถ'); hdrBtns.forEach(hdrBtn => { const colIdx = parseInt(hdrBtn.dataset.colIndex, 10); if (isNaN(colIdx)) return; Array.from(table.querySelectorAll('tbody tr')).forEach(tr => { const td = tr.cells[colIdx]; if (!td) return; const toggle = td.querySelector('.mb-cell-collapse-toggle'); if (!toggle) return; _applyCollapseState(toggle, targetExpand); }); // Sync header button glyph + aria-expanded const hg = hdrBtn.querySelector('.mb-col-collapse-glyph'); if (hg) hg.textContent = targetExpand ? 'โ–ผ' : 'โ–ถ'; hdrBtn.setAttribute('aria-expanded', targetExpand ? 'true' : 'false'); }); btn.innerHTML = makeCollapseExpandBtnHTML(!targetExpand); btn.title = targetExpand ? `Collapse all expanded multi-row cells in "${categoryName}"` : `Expand all collapsed multi-row cells in "${categoryName}"`; btn.setAttribute( 'aria-label', targetExpand ? `Collapse all multi-row cells in: ${categoryName}` : `Expand all collapsed multi-row cells in: ${categoryName}` ); }); return btn; } /** * Refreshes the per-sub-table โ–ถ/โ—€ collapse button that lives in the h3 * immediately before `.mb-subtable-controls`. * * Show/hide logic (mirrors the global button): * โ€ข Hidden when the filtered-in rows of `table` contain no multi-row cells * (i.e. no `.mb-col-collapse-hdr-btn` exists inside `table`). * โ€ข Shown otherwise; text is reset to `โ–ถ Expand` so it always reflects * the post-filter collapsed state. * * Highlight tint (mirrors the global button but using subtable filter colours): * โ€ข When any `td.mb-has-collapse-toggle` inside `table` carries a filter- * highlight span (visible OR collapsed), the button's background is set * to `stfBorderActive()` with white text and a matching box-shadow so it * stands out against the h3 bar โ€” consistent with the active-filter colour * of the sub-table filter input. * โ€ข When no highlights are found the inline overrides are cleared so the * button reverts to its default (grey) appearance. * * The button is located via the h3 element found by `findH3ForTable(table)`, * so no `categoryName` is needed at call sites. * * NOTE: `table.previousElementSibling` must NOT be used here โ€” after * `_artInitBigPics` runs it returns the bigbox div, not the h3. * * Called from: * โ€ข `renderGroupedTable` โ€” after every `initCollapsableColumns(table)` call * (both initial render and filter re-run). * โ€ข `ensureCollapseDelegate` click handler โ€” after each individual cell * expand/collapse so the tint stays in sync. * * @param {HTMLTableElement} table */ /** * CSS selector that matches any filter-highlight span used by any of the three * filter layers (global, column, STF, pre-filter). * * This is the single authoritative definition used everywhere the code needs to * detect whether a filter match is present inside a collapsed multi-row cell: * - `initCollapsableColumns` โ€” initial `hasHiddenMatch` check * - `ensureCollapseDelegate` โ€” click-handler `hasHiddenMatch` check * - `updateSubTableCollapseButton` โ€” per-sub-table button tint * - `updateGlobalCollapseButtonHighlight` โ€” global button tint * - `_syncCollapseHasMatchInTable` โ€” STF post-highlight sync (see below) * * IMPORTANT: keep this list in sync whenever a new highlight class is introduced. */ const _COLLAPSE_MATCH_SEL = '.mb-global-filter-highlight, ' + '.mb-column-filter-highlight, ' + '.mb-pre-filter-highlight, ' + '.mb-subtable-filter-highlight'; /** * Re-evaluates the `mb-collapse-toggle-has-match` class on every * `.mb-cell-collapse-toggle` span inside `table`. * * The class signals the user that a collapsed (hidden) multi-row cell contains * at least one filter-highlight match โ€” expanding the cell will reveal it. * * This helper is needed because `applySubFilter()` runs AFTER * `initCollapsableColumns()` has already stamped the initial `hasHiddenMatch` * state. The initial stamp only considers global-filter and column-filter * highlights (which exist at that point). STF highlights are applied later by * `applySubFilter` โ€” so we need a second pass to reflect the STF state: * * โ€ข After step 2 (clearing STF highlights): removes `mb-collapse-toggle-has-match` * from any toggle whose hidden items no longer contain any highlight span. * โ€ข After step 4 (applying STF highlights): adds `mb-collapse-toggle-has-match` * to any toggle whose hidden items now contain a `.mb-subtable-filter-highlight`. * * Using `_COLLAPSE_MATCH_SEL` (all four highlight classes) ensures the result is * always the union of ALL active filter layers โ€” whichever combination is currently * in effect. * * @param {HTMLTableElement} table - The sub-table whose toggles are to be synced. */ function _syncCollapseHasMatchInTable(table) { if (!table) return; // โ”€โ”€ Pass 1: regular multi-row cells (.mb-cell-collapse-toggle) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ table.querySelectorAll('td.mb-has-collapse-toggle').forEach(td => { const toggle = td.querySelector(':scope > .mb-cell-collapse-toggle'); if (!toggle) return; if (toggle.getAttribute('aria-expanded') === 'true') { // Expanded: all items visible โ€” no hidden match possible. toggle.classList.remove('mb-collapse-toggle-has-match'); return; } // Collapsed: check hidden items (lis[1..n]) for any filter highlight. const ul = td.querySelector(':scope > ul'); if (!ul) return; const lis = Array.from(ul.querySelectorAll(':scope > li')); const hasHiddenMatch = lis.slice(1).some(li => li.querySelector(_COLLAPSE_MATCH_SEL) ); toggle.classList.toggle('mb-collapse-toggle-has-match', hasHiddenMatch); }); // โ”€โ”€ Pass 2: CAA/EAA art cells ([data-caa-expand-btn] inside li-0) โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Art cells do not carry mb-has-collapse-toggle; their expand button lives // inside the li-0 summary row of ul.mb-caa-art-ul. All image lis // (li.mb-caa-art-li-image) are the "hidden" items when collapsed. table.querySelectorAll('tbody td').forEach(td => { const artUl = td.querySelector(':scope > ul.mb-caa-art-ul'); if (!artUl) return; const li0 = artUl.querySelector(':scope > li.mb-caa-art-li-summary'); const expandBtn = li0 ? li0.querySelector('[data-caa-expand-btn]') : null; if (!expandBtn) return; if (expandBtn.dataset.caaExpandBtn === 'expanded') { // All image lis visible โ€” no hidden content. expandBtn.classList.remove('mb-collapse-toggle-has-match'); return; } const artLis = Array.from(artUl.querySelectorAll(':scope > li.mb-caa-art-li-image')); const hasHiddenMatch = artLis.some(li => li.querySelector(_COLLAPSE_MATCH_SEL)); expandBtn.classList.toggle('mb-collapse-toggle-has-match', hasHiddenMatch); }); } /** * Refreshes the collapse/expand state and highlight tint of the * `.mb-subtable-collapse-btn` button inside the h3 header row that * belongs to `table`. * * - Hides the button when no multi-row cells exist in the table. * - Shows the button and applies an active-color tint (matching the STF * input border) when any filter highlight span is detected inside a * collapsed multi-row cell or CAA/EAA art cell; clears the tint otherwise. * * Called after every filter/sort cycle and after cell-collapse toggling. * * @param {HTMLTableElement} table - The `` element to update. */ function updateSubTableCollapseButton(table) { const h3 = findH3ForTable(table); if (!h3 || !h3.classList.contains('mb-toggle-h3')) return; const btn = h3.querySelector('.mb-subtable-collapse-btn'); if (!btn) return; // Collect per-column header toggle buttons that currently exist in this table. const hdrBtns = Array.from(table.querySelectorAll('.mb-col-collapse-hdr-btn')); if (hdrBtns.length === 0) { // No multi-row cells visible โ†’ hide button and clear any stale tint. btn.style.display = 'none'; btn.style.backgroundColor = ''; btn.style.color = ''; btn.style.boxShadow = ''; return; } // Show the button and reset its text to the post-filter collapsed state. btn.style.display = 'inline-flex'; btn.innerHTML = makeCollapseExpandBtnHTML(true); // โ”€โ”€ Highlight tint โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Scan the whole table (visible + collapsed items) for any highlight span. // Two selectors are needed: // 1. Regular multi-row cells carry mb-has-collapse-toggle on the
                                                          . // 2. CAA/EAA art cells do NOT carry that class โ€” their image
                                                        • rows // are direct children of ul.mb-caa-art-ul inside the
                                                        • . const hasHighlight = !!table.querySelector( 'td.mb-has-collapse-toggle .mb-global-filter-highlight, ' + 'td.mb-has-collapse-toggle .mb-column-filter-highlight, ' + 'td.mb-has-collapse-toggle .mb-pre-filter-highlight, ' + 'td.mb-has-collapse-toggle .mb-subtable-filter-highlight, ' + 'td .mb-caa-art-ul .mb-global-filter-highlight, ' + 'td .mb-caa-art-ul .mb-column-filter-highlight, ' + 'td .mb-caa-art-ul .mb-pre-filter-highlight, ' + 'td .mb-caa-art-ul .mb-subtable-filter-highlight' ); if (hasHighlight) { // Use the same colour the STF input border glows with when active, // so the button visually belongs to that sub-table's filter context. const activeColor = stfBorderActive(); btn.style.backgroundColor = activeColor; btn.style.color = '#fff'; btn.style.boxShadow = `0 0 0 2px ${activeColor}`; } else { btn.style.backgroundColor = ''; btn.style.color = ''; btn.style.boxShadow = ''; } } /** * Ensure settings button is always the last button in controls container * Also adds a divider before the Resize button after data is loaded */ function ensureSettingsButtonIsLast() { const controlsContainer = document.getElementById('mb-show-all-controls-container'); if (!controlsContainer) return; const settingsBtn = document.getElementById('mb-settings-btn'); if (!settingsBtn) return; // Move settings button to the end if it's not already if (settingsBtn.nextSibling) { controlsContainer.appendChild(settingsBtn); } // Ensure โ“ app-help button is always the very last button (right of โš™๏ธ) const appHelpBtn = document.getElementById('mb-app-help-btn'); if (appHelpBtn) { controlsContainer.appendChild(appHelpBtn); } // Ensure ๐ŸŽน shortcuts button is immediately before โš™๏ธ settings button const shortcutsBtn = document.getElementById('mb-shortcuts-help-btn'); if (shortcutsBtn && shortcutsBtn.nextSibling !== settingsBtn) { controlsContainer.insertBefore(shortcutsBtn, settingsBtn); } // Keep the ' | ' divider pinned immediately before ๐ŸŽน (covers both the initial // Loadโ†’๐ŸŽน gap and the post-load Exportโ†’๐ŸŽน gap without needing separate dividers). if (shortcutsBtn) { let beforeShortcutsDivider = controlsContainer.querySelector('.mb-button-divider-before-shortcuts'); if (!beforeShortcutsDivider) { beforeShortcutsDivider = document.createElement('span'); beforeShortcutsDivider.textContent = ' | '; beforeShortcutsDivider.className = 'mb-button-divider-before-shortcuts'; beforeShortcutsDivider.style.cssText = uiButtonDividerCSS(); } // Re-insert (or insert for the first time) immediately before shortcutsBtn if (shortcutsBtn.previousSibling !== beforeShortcutsDivider) { controlsContainer.insertBefore(beforeShortcutsDivider, shortcutsBtn); } } // Add divider between Load from Disk and Resize if not already present. // Note: the initialDivider (between action buttons and Save/Load) is intentionally // kept โ€” it remains relevant both on the initial page and after load. const loadBtn = document.getElementById('mb-load-from-disk-btn'); const resizeBtn = document.getElementById('mb-resize-btn'); if (loadBtn && resizeBtn && !controlsContainer.querySelector('.mb-button-divider-after-load')) { // Add divider after Load from Disk button const divider = document.createElement('span'); divider.textContent = ' | '; divider.className = 'mb-button-divider-after-load'; divider.style.cssText = uiButtonDividerCSS(); loadBtn.after(divider); } } /** * Shared keyboard-navigation factory for column-visibility menus. * * Wires up full keyboard control inside a floating column-visibility menu: * - ArrowDown / ArrowUp : cycle through all focusable items (checkboxes โ†’ action buttons) * - Tab / Shift-Tab : same cycling, focus trapped inside the menu * - Space : toggle the focused checkbox * - Alt+S : click selectAllBtn * - Alt+D : click deselectAllBtn * - Alt+C : click chooseConfigBtn * - Enter : fire focused action button, or save+close when on a checkbox * * The handler is installed in capture phase (true) so that Tab preventDefault * fires before the browser's native focus-traversal โ€” a bubble-phase listener * would be too late to block it. * * @param {object} opts * @param {HTMLElement} opts.menu - The floating menu container element. * @param {HTMLInputElement[]} opts.checkboxes - All checkbox inputs in order. * @param {HTMLButtonElement} opts.selectAllBtn - "Select All" action button. * @param {HTMLButtonElement} opts.deselectAllBtn - "Deselect All" action button. * @param {HTMLButtonElement} opts.chooseConfigBtn- "Choose current configuration" button. * @param {Function} opts.saveState - `() => void` โ€” persists the current state. * @param {string} opts.menuBtnBase - CSS string shared by all action buttons at rest. * * @returns {{ moveFocusTo: (idx: number) => void, menuKeyHandler: (e: KeyboardEvent) => void }} */ function buildColVisMenuKeyboard(opts) { const { menu, checkboxes, selectAllBtn, deselectAllBtn, chooseConfigBtn, saveState, menuBtnBase } = opts; const menuBtnFocused = 'background:#d0e8ff; border-color:#5b9bd5;'; // All navigable items in tab/arrow order: checkboxes first, then action buttons. const menuFocusables = [...checkboxes, selectAllBtn, deselectAllBtn, chooseConfigBtn]; /** * Highlights the checkbox row at `index` with a blue tint and moves browser * focus to that checkbox. All other rows are reset. * @param {number} index */ const updateCheckboxFocus = (index) => { checkboxes.forEach((cb, i) => { const row = cb.parentElement; if (i === index) { row.style.background = '#e3f2fd'; row.style.borderRadius = '3px'; cb.focus(); } else { row.style.background = ''; } }); }; /** * Moves keyboard focus to the item at `idx` in menuFocusables and applies * the appropriate visual highlight. * - Checkboxes : blue row highlight via updateCheckboxFocus(). * - Action buttons : blue-tint background + border; all others reset to rest style. * @param {number} idx */ const moveFocusTo = (idx) => { // Reset all action-button highlights first [selectAllBtn, deselectAllBtn].forEach(b => { b.style.cssText = menuBtnBase + 'flex:1;'; }); chooseConfigBtn.style.cssText = menuBtnBase + 'width:100%; margin-top:5px;'; if (idx < checkboxes.length) { // Clear any checkbox highlight left by a previous moveFocusTo โ†’ button path updateCheckboxFocus(idx); } else { // Clear checkbox row highlights checkboxes.forEach(cb => { cb.parentElement.style.background = ''; }); const focusBtn = menuFocusables[idx]; const isChoose = focusBtn === chooseConfigBtn; focusBtn.style.cssText = menuBtnBase + menuBtnFocused + (isChoose ? 'width:100%; margin-top:5px;' : 'flex:1;'); focusBtn.focus(); } }; /** * Keyboard handler โ€” must be registered in capture phase. * Guard: exits immediately when the menu is not visible. * @param {KeyboardEvent} e */ const menuKeyHandler = (e) => { if (menu.style.display !== 'block') return; switch (e.key) { case 'ArrowDown': { e.preventDefault(); const cur = menuFocusables.indexOf(document.activeElement); moveFocusTo(cur === -1 ? 0 : (cur + 1) % menuFocusables.length); break; } case 'ArrowUp': { e.preventDefault(); const cur = menuFocusables.indexOf(document.activeElement); moveFocusTo(cur === -1 ? menuFocusables.length - 1 : (cur - 1 + menuFocusables.length) % menuFocusables.length); break; } case ' ': { // Space toggles the focused checkbox; ignored when an action button // has focus (buttons handle Space natively via their click handler). const focused = document.activeElement; if (focused && focused.type === 'checkbox') { e.preventDefault(); focused.checked = !focused.checked; focused.dispatchEvent(new Event('change')); } break; } case 'Tab': { // Trap focus inside the menu (Shift+Tab reverses direction). e.preventDefault(); let focusedIdx = menuFocusables.indexOf(document.activeElement); if (focusedIdx === -1) focusedIdx = 0; const nextIdx = e.shiftKey ? (focusedIdx - 1 + menuFocusables.length) % menuFocusables.length : (focusedIdx + 1) % menuFocusables.length; moveFocusTo(nextIdx); break; } case 's': case 'S': if (e.altKey) { e.preventDefault(); selectAllBtn.click(); } break; case 'd': case 'D': if (e.altKey) { e.preventDefault(); deselectAllBtn.click(); } break; case 'c': case 'C': if (e.altKey) { e.preventDefault(); chooseConfigBtn.click(); } break; case 'Enter': { e.preventDefault(); const focused = document.activeElement; if (focused === selectAllBtn || focused === deselectAllBtn || focused === chooseConfigBtn) { focused.click(); } else { // Enter on a checkbox row: save and close. saveState(); menu.style.display = 'none'; } break; } } }; // Register in capture phase so Tab preventDefault beats the browser's // native focus-traversal which fires at target phase. document.addEventListener('keydown', menuKeyHandler, true); return { moveFocusTo, menuKeyHandler }; } /** * Add a column visibility toggle button and menu to the controls * Allows users to show/hide columns in the table * @param {HTMLTableElement} table - The table to add controls for */ function addColumnVisibilityToggle(table) { const controlsContainer = document.getElementById('mb-show-all-controls-container'); if (!controlsContainer) { Lib.warn('ui', 'Controls container not found, cannot add column visibility toggle'); return; } // Check if button already exists const existingBtn = document.getElementById('mb-visible-btn'); if (existingBtn) { Lib.debug('ui', 'Column visibility toggle already exists, skipping'); return; } // โ”€โ”€ Persistence helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Column visibility is stored per pageType in GM storage as a plain object // mapping { "Column Name": true|false }. Column names are used as keys // (not indices) so that the state survives pages where injected columns shift // the index layout between sessions. const COLVIS_KEY_PREFIX = 'vz-mb-colvis-'; const colvisStorageKey = COLVIS_KEY_PREFIX + pageType; /** * Reads the current checkbox state as a plain `{ colName: bool }` object. * Shared by `saveColVisState()` and the global-override propagation path. */ const getCurrentColVisState = () => { const state = {}; checkboxes.forEach(cb => { const colName = cb.closest('div')?.querySelector('label')?.textContent ?? ''; if (colName) state[colName] = cb.checked; }); return state; }; /** * Persists the current checkbox state for this pageType. * Called on every path that closes the menu so the stored value always * reflects the last intentional configuration the user left behind. */ const saveColVisState = () => { const state = getCurrentColVisState(); try { GM_setValue(colvisStorageKey, JSON.stringify(state)); Lib.debug('ui', `Column visibility saved for pageType "${pageType}":`, state); } catch (err) { Lib.warn('ui', `Failed to save column visibility for "${pageType}":`, err); } }; /** * Load stored column visibility state for this pageType. * Returns a plain object { "Column Name": bool } or null when nothing is stored. */ const loadColVisState = () => { try { const raw = GM_getValue(colvisStorageKey, null); if (!raw) return null; const parsed = JSON.parse(raw); Lib.debug('ui', `Column visibility loaded for pageType "${pageType}":`, parsed); return parsed; } catch (err) { Lib.warn('ui', `Failed to load column visibility for "${pageType}":`, err); return null; } }; // Create toggle button const toggleBtn = document.createElement('button'); toggleBtn.id = 'mb-visible-btn'; toggleBtn.innerHTML = makeButtonHTML('Visible', 'V', '๐Ÿ‘๏ธ'); toggleBtn.title = `Show/hide table columns (${buildShortcutHint('sa_shortcut_open_visible_columns', 'Ctrl+V', 'V')})`; toggleBtn.style.cssText = uiActionBtnBaseCSS(); toggleBtn.type = 'button'; // Create dropdown menu container const menu = document.createElement('div'); menu.className = 'mb-column-visibility-menu'; menu.style.cssText = ` display: none; position: fixed; background: white; border: 1px solid #ccc; border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.2); z-index: 10000; min-width: 250px; max-width: 500px; width: auto; `; // Create draggable header const header = document.createElement('div'); header.style.cssText = ` background: #f5f5f5; padding: 8px 10px; margin: -10px -10px 10px -10px; border-bottom: 1px solid #ccc; cursor: move; user-select: none; font-weight: 600; border-radius: 4px 4px 0 0; `; header.textContent = 'Column Visibility'; // Add dragging functionality let isDragging = false; let currentX; let currentY; let initialX; let initialY; let xOffset = 0; let yOffset = 0; header.addEventListener('mousedown', (e) => { initialX = e.clientX - xOffset; initialY = e.clientY - yOffset; isDragging = true; header.style.cursor = 'grabbing'; }); document.addEventListener('mousemove', (e) => { if (isDragging && menu.style.display === 'block') { e.preventDefault(); currentX = e.clientX - initialX; currentY = e.clientY - initialY; xOffset = currentX; yOffset = currentY; menu.style.left = `${e.clientX - initialX}px`; menu.style.top = `${e.clientY - initialY}px`; } }); document.addEventListener('mouseup', () => { if (isDragging) { isDragging = false; header.style.cursor = 'move'; } }); // Create scrollable content wrapper const contentWrapper = document.createElement('div'); contentWrapper.style.cssText = 'padding: 0 10px 10px 10px; max-height: 60vh; overflow-y: auto;'; menu.appendChild(header); menu.appendChild(contentWrapper); // Get headers from the first row (skip filter row) const headerRow = table.querySelector('thead tr:first-child'); if (!headerRow) { Lib.warn('ui', 'No header row found for column visibility toggle'); return; } const headers = Array.from(headerRow.cells); // Store checkbox states const checkboxes = []; // Function to update button color based on column visibility const updateButtonColor = () => { const allChecked = checkboxes.every(cb => cb.checked); if (allChecked) { // All columns visible - default color toggleBtn.style.backgroundColor = ''; toggleBtn.style.color = ''; toggleBtn.style.border = ''; } else { // Some columns hidden - red color toggleBtn.style.backgroundColor = '#dc3545'; toggleBtn.style.color = 'white'; toggleBtn.style.border = '1px solid #bd2130'; } }; // Create checkbox for each column headers.forEach((th, index) => { const colName = th.textContent.replace(/[โ‡…โ–ฒโ–ผ๐Ÿ“Šโ–ถโ—€โ–ค0-9โฐยนยฒยณโดโตโถโทโธโน]/g, '').trim(); if (!colName) return; // Skip empty headers const wrapper = document.createElement('div'); wrapper.style.cssText = 'margin: 5px 0; white-space: nowrap; display: flex; align-items: center;'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = true; checkbox.id = `mb-col-vis-${index}`; checkbox.style.cssText = 'margin-right: 8px; cursor: pointer;'; checkbox.dataset.columnIndex = index; checkbox.dataset.columnName = colName; // store for persistence lookup const label = document.createElement('label'); label.htmlFor = checkbox.id; label.textContent = colName; label.style.cssText = 'cursor: pointer; user-select: none; flex: 1;'; checkbox.addEventListener('change', () => { toggleColumn(table, index, checkbox.checked); // Count visible columns const visibleCount = checkboxes.filter(cb => cb.checked).length; Lib.debug('ui', `Column "${colName}" ${checkbox.checked ? 'shown' : 'hidden'}. ${visibleCount}/${checkboxes.length} columns visible`); // Update button color updateButtonColor(); }); checkboxes.push(checkbox); wrapper.appendChild(checkbox); wrapper.appendChild(label); contentWrapper.appendChild(wrapper); }); // โ”€โ”€ Restore persisted state (applied after all checkboxes are built) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // This must happen before the separator / action buttons are appended so that // updateButtonColor() below reflects the restored state correctly. const savedState = loadColVisState(); if (savedState) { checkboxes.forEach(cb => { const colName = cb.dataset.columnName; if (colName && Object.prototype.hasOwnProperty.call(savedState, colName)) { const shouldBeVisible = !!savedState[colName]; if (cb.checked !== shouldBeVisible) { cb.checked = shouldBeVisible; // Fire the change event so toggleColumn() hides/shows the DOM cells cb.dispatchEvent(new Event('change')); } } }); Lib.debug('ui', `Restored column visibility for pageType "${pageType}" from GM storage.`); } // Reflect the (possibly-restored) state on the button tint right away updateButtonColor(); // Add separator const separator = document.createElement('div'); separator.style.cssText = 'margin: 10px 0; padding-top: 10px; border-top: 1px solid #ddd;'; contentWrapper.appendChild(separator); // Add "Select All" / "Deselect All" buttons const buttonRow = document.createElement('div'); buttonRow.style.cssText = 'display: flex; gap: 5px;'; // Base style shared by all three action buttons in the menu. // We use explicit border/background so that browser-default :focus outline // and our manual focus highlight are both clearly visible. const menuBtnBase = 'font-size:0.8em; padding:4px 8px; cursor:pointer; border-radius:3px; border:1px solid #bbb; background:#f5f5f5; transition:background 0.15s, border-color 0.15s;'; const menuBtnFocused = 'background:#d0e8ff; border-color:#5b9bd5;'; // blue tint when focused const menuBtnActive = 'background:#a8c8f0; border-color:#3a7abf;'; // darker on press /** * Applies or removes the keyboard-focus highlight on an action button. * @param {HTMLButtonElement} btn - The button to style * @param {boolean} focused - true = highlight on, false = restore default */ const setMenuBtnFocus = (btn, focused) => { btn.style.cssText = menuBtnBase + (focused ? menuBtnFocused : '') + (btn === chooseConfigBtnRef ? 'width:100%; margin-top:5px;' : 'flex:1;'); }; // Forward reference so setMenuBtnFocus can distinguish chooseConfigBtn let chooseConfigBtnRef = null; const selectAllBtn = document.createElement('button'); selectAllBtn.innerHTML = makeButtonHTML('Select All', 'S'); selectAllBtn.style.cssText = menuBtnBase + 'flex:1;'; selectAllBtn.type = 'button'; selectAllBtn.tabIndex = 0; // Visual feedback on mouse press/release selectAllBtn.addEventListener('mousedown', () => { selectAllBtn.style.cssText = menuBtnBase + menuBtnActive + 'flex:1;'; }); selectAllBtn.addEventListener('mouseup', () => { selectAllBtn.style.cssText = menuBtnBase + 'flex:1;'; }); selectAllBtn.addEventListener('mouseleave',() => { selectAllBtn.style.cssText = menuBtnBase + 'flex:1;'; }); selectAllBtn.onclick = (e) => { e.stopPropagation(); checkboxes.forEach(cb => { if (!cb.checked) { cb.checked = true; cb.dispatchEvent(new Event('change')); } }); updateButtonColor(); }; const deselectAllBtn = document.createElement('button'); deselectAllBtn.innerHTML = makeButtonHTML('Deselect All', 'D'); deselectAllBtn.style.cssText = menuBtnBase + 'flex:1;'; deselectAllBtn.type = 'button'; deselectAllBtn.tabIndex = 0; deselectAllBtn.addEventListener('mousedown', () => { deselectAllBtn.style.cssText = menuBtnBase + menuBtnActive + 'flex:1;'; }); deselectAllBtn.addEventListener('mouseup', () => { deselectAllBtn.style.cssText = menuBtnBase + 'flex:1;'; }); deselectAllBtn.addEventListener('mouseleave',() => { deselectAllBtn.style.cssText = menuBtnBase + 'flex:1;'; }); deselectAllBtn.onclick = (e) => { e.stopPropagation(); checkboxes.forEach(cb => { if (cb.checked) { cb.checked = false; cb.dispatchEvent(new Event('change')); } }); updateButtonColor(); }; buttonRow.appendChild(selectAllBtn); buttonRow.appendChild(deselectAllBtn); contentWrapper.appendChild(buttonRow); // Add "Choose current configuration" button const chooseConfigBtn = document.createElement('button'); chooseConfigBtnRef = chooseConfigBtn; // resolve forward reference chooseConfigBtn.innerHTML = makeButtonHTML('Choose current configuration', 'c'); chooseConfigBtn.style.cssText = menuBtnBase + 'width:100%; margin-top:5px;'; chooseConfigBtn.type = 'button'; chooseConfigBtn.tabIndex = 0; chooseConfigBtn.addEventListener('mousedown', () => { chooseConfigBtn.style.cssText = menuBtnBase + menuBtnActive + 'width:100%; margin-top:5px;'; }); chooseConfigBtn.addEventListener('mouseup', () => { chooseConfigBtn.style.cssText = menuBtnBase + 'width:100%; margin-top:5px;'; }); chooseConfigBtn.addEventListener('mouseleave',() => { chooseConfigBtn.style.cssText = menuBtnBase + 'width:100%; margin-top:5px;'; }); chooseConfigBtn.onclick = (e) => { e.stopPropagation(); saveColVisState(); // Global "Choose" overrides every sub-table: push this config to all // registered sub-table visibility widgets. Each widget will apply the // state to its own table DOM and persist it under its own storage key // so the "last action wins" invariant is satisfied on next page load. const globalState = getCurrentColVisState(); subTableColVisRegistry.forEach(({ applyGlobalConfig }) => { try { applyGlobalConfig(globalState); } catch (_) {} }); menu.style.display = 'none'; Lib.debug('ui', 'Chose current column configuration โ€” propagated to all sub-tables'); }; contentWrapper.appendChild(chooseConfigBtn); // Add second separator const separator2 = document.createElement('div'); separator2.style.cssText = 'margin: 10px 0; padding-top: 10px; border-top: 1px solid #ddd;'; contentWrapper.appendChild(separator2); // Add close instruction text const closeText = document.createElement('div'); closeText.textContent = 'Click outside or press Escape to close and save'; closeText.style.cssText = 'font-size: 0.9em; color: #666; text-align: center; font-style: italic;'; contentWrapper.appendChild(closeText); // Wire up full keyboard navigation via the shared factory. // moveFocusTo is used by the open handler below to focus the first item. const { moveFocusTo } = buildColVisMenuKeyboard({ menu, checkboxes, selectAllBtn, deselectAllBtn, chooseConfigBtn, saveState: saveColVisState, menuBtnBase }); // Toggle menu visibility toggleBtn.onclick = (e) => { e.stopPropagation(); const isVisible = menu.style.display === 'block'; if (isVisible) { saveColVisState(); menu.style.display = 'none'; } else { menu.style.display = 'block'; // Position menu below button (only on first open or when not manually moved) if (xOffset === 0 && yOffset === 0) { const rect = toggleBtn.getBoundingClientRect(); menu.style.top = `${rect.bottom + 5}px`; menu.style.left = `${rect.left}px`; xOffset = rect.left; yOffset = rect.bottom + 5; initialX = 0; initialY = 0; } // Reset selection and move focus to first checkbox selectedCheckboxIndex = 0; setTimeout(() => moveFocusTo(0), 10); } }; // Close menu when clicking outside โ€” also saves state const closeMenu = (e) => { if (!menu.contains(e.target) && e.target !== toggleBtn) { if (menu.style.display === 'block') { saveColVisState(); } menu.style.display = 'none'; } }; document.addEventListener('click', closeMenu); // Close menu on Escape key โ€” also saves state const closeMenuOnEscape = (e) => { if (e.key === 'Escape' && menu.style.display === 'block') { saveColVisState(); menu.style.display = 'none'; } }; document.addEventListener('keydown', closeMenuOnEscape); // Append to controls container controlsContainer.appendChild(toggleBtn); Lib.debug('ui', 'Column visibility toggle added to controls'); ensureSettingsButtonIsLast(); // Append menu to body document.body.appendChild(menu); } /** * Creates a per-sub-table column-visibility ๐Ÿ‘๏ธ button for multi-table mode. * * The button opens a floating Column Visibility dialog (identical in structure * to the global one) but affects ONLY `table` via `toggleColumnInTable`. * * Storage: * - Primary key : `vz-mb-colvis-{pageType}-sub-{safeId}` * - Fallback key: `vz-mb-colvis-{pageType}` (global config) * On load, the sub-table key is tried first. If it does not exist the * global key is used so a freshly rendered sub-table inherits the global * column configuration. * * Global override: * When the global Visible button's "Choose current configuration" is clicked * it calls `applyGlobalConfig(state)` on every widget registered here. * `applyGlobalConfig` pushes the global state to the sub-table DOM AND saves * it under the sub-table key so it persists on next page load. * * The widget registers itself in `subTableColVisRegistry` under `safeId`. * Callers should call `subTableColVisRegistry.delete(safeId)` if the sub-table * is removed from the DOM (not currently necessary โ€” SA never removes sub-tables * during a session). * * @param {HTMLTableElement} table The sub-table this button controls. * @param {string} categoryName Human-readable category name. * @returns {HTMLButtonElement} */ function createSubTableColumnVisibilityButton(table, categoryName) { const safeId = categoryName.replace(/[^a-zA-Z0-9_-]/g, '_'); const COLVIS_KEY_PREFIX = 'vz-mb-colvis-'; const subColVisKey = COLVIS_KEY_PREFIX + pageType + '-sub-' + safeId; const globalColVisKey = COLVIS_KEY_PREFIX + pageType; // โ”€โ”€ Persistence helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const saveSubColVisState = () => { const state = {}; subCheckboxes.forEach(cb => { const colName = cb.dataset.columnName || ''; if (colName) state[colName] = cb.checked; }); try { GM_setValue(subColVisKey, JSON.stringify(state)); Lib.debug('ui', `Sub-table col-vis saved for "${categoryName}":`, state); } catch (err) { Lib.warn('ui', `Failed to save sub-table col-vis for "${categoryName}":`, err); } }; const loadSubColVisState = () => { // Try sub-table key first, fall back to global const tryLoad = (key) => { try { const raw = GM_getValue(key, null); if (!raw) return null; return JSON.parse(raw); } catch (_) { return null; } }; return tryLoad(subColVisKey) || tryLoad(globalColVisKey); }; // โ”€โ”€ Button โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const btn = document.createElement('button'); btn.id = `mb-stf-${safeId}-vis-btn`; btn.type = 'button'; btn.className = 'mb-subtable-vis-btn'; btn.innerHTML = '๐Ÿ‘๏ธ'; btn.title = `Show/hide columns in sub-section "${categoryName}"`; btn.setAttribute('aria-label', `Column visibility for: ${categoryName}`); btn.style.cssText = [ 'font-size:0.85em; padding:1px 5px; border-radius:4px;', 'background:rgb(240,240,240); border:1px solid rgb(204,204,204);', 'cursor:pointer; vertical-align:middle; margin-left:6px;', 'display:inline-flex; align-items:center; box-sizing:border-box;', 'transition:background-color 0.2s, border-color 0.2s, transform 0.1s, box-shadow 0.1s;' ].join(' '); // โ”€โ”€ Menu (mirrors global menu structure) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const menu = document.createElement('div'); menu.style.cssText = [ 'display:none; position:fixed; background:white;', 'border:1px solid #ccc; border-radius:4px;', 'box-shadow:0 4px 12px rgba(0,0,0,0.2); z-index:10001;', 'min-width:220px; max-width:460px; width:auto;' ].join(' '); // Draggable header const header = document.createElement('div'); header.style.cssText = [ 'background:#f5f5f5; padding:8px 10px; margin:-10px -10px 10px -10px;', 'border-bottom:1px solid #ccc; cursor:move; user-select:none;', 'font-weight:600; border-radius:4px 4px 0 0; font-size:0.9em;' ].join(' '); header.textContent = `Column Visibility: ${categoryName}`; let isDragging = false, startX = 0, startY = 0, offX = 0, offY = 0; header.addEventListener('mousedown', (e) => { startX = e.clientX - offX; startY = e.clientY - offY; isDragging = true; header.style.cursor = 'grabbing'; }); document.addEventListener('mousemove', (e) => { if (!isDragging || menu.style.display !== 'block') return; e.preventDefault(); offX = e.clientX - startX; offY = e.clientY - startY; menu.style.left = `${offX}px`; menu.style.top = `${offY}px`; }); document.addEventListener('mouseup', () => { if (isDragging) { isDragging = false; header.style.cursor = 'move'; } }); const contentWrapper = document.createElement('div'); contentWrapper.style.cssText = 'padding:0 10px 10px 10px; max-height:55vh; overflow-y:auto;'; menu.appendChild(header); menu.appendChild(contentWrapper); // โ”€โ”€ Build checkboxes from this sub-table's header row โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const headerRow = table.querySelector('thead tr:first-child'); const subCheckboxes = []; if (headerRow) { Array.from(headerRow.cells).forEach((th, index) => { const colName = th.textContent.replace(/[โ‡…โ–ฒโ–ผ๐Ÿ“Šโ–ถโ—€โ–ค0-9โฐยนยฒยณโดโตโถโทโธโน]/g, '').trim(); if (!colName) return; const wrapper = document.createElement('div'); wrapper.style.cssText = 'margin:5px 0; white-space:nowrap; display:flex; align-items:center;'; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = true; cb.id = `mb-stf-${safeId}-col-vis-${index}`; cb.style.cssText = 'margin-right:8px; cursor:pointer;'; cb.dataset.columnIndex = index; cb.dataset.columnName = colName; const lbl = document.createElement('label'); lbl.htmlFor = cb.id; lbl.textContent = colName; lbl.style.cssText = 'cursor:pointer; user-select:none; flex:1; font-size:0.9em;'; cb.addEventListener('change', () => { toggleColumnInTable(table, index, cb.checked); updateSubVisBtnColor(); }); subCheckboxes.push(cb); wrapper.appendChild(cb); wrapper.appendChild(lbl); contentWrapper.appendChild(wrapper); }); } // โ”€โ”€ Button tint (red when columns hidden) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const updateSubVisBtnColor = () => { const allChecked = subCheckboxes.every(cb => cb.checked); if (allChecked) { btn.style.backgroundColor = ''; btn.style.color = ''; btn.style.borderColor = 'rgb(204,204,204)'; } else { btn.style.backgroundColor = '#dc3545'; btn.style.color = 'white'; btn.style.borderColor = '#bd2130'; } }; // โ”€โ”€ Restore persisted state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const savedState = loadSubColVisState(); if (savedState) { subCheckboxes.forEach(cb => { const colName = cb.dataset.columnName; if (colName && Object.prototype.hasOwnProperty.call(savedState, colName)) { const shouldBeVisible = !!savedState[colName]; if (cb.checked !== shouldBeVisible) { cb.checked = shouldBeVisible; cb.dispatchEvent(new Event('change')); } } }); } updateSubVisBtnColor(); // โ”€โ”€ Separator + action buttons โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const sep = document.createElement('div'); sep.style.cssText = 'margin:10px 0; padding-top:10px; border-top:1px solid #ddd;'; contentWrapper.appendChild(sep); // Action button styles โ€” match the global menu's constants so both menus // look identical. mousedown/mouseup/mouseleave give the same press feedback. const btnRowCSS = 'font-size:0.8em; padding:4px 8px; cursor:pointer; border-radius:3px; border:1px solid #bbb; background:#f5f5f5; transition:background 0.15s, border-color 0.15s;'; const btnRowActive = 'background:#a8c8f0; border-color:#3a7abf;'; const btnRow = document.createElement('div'); btnRow.style.cssText = 'display:flex; gap:5px;'; const selAllBtn = document.createElement('button'); selAllBtn.type = 'button'; selAllBtn.innerHTML = makeButtonHTML('Select All', 'S'); selAllBtn.style.cssText = btnRowCSS + 'flex:1;'; selAllBtn.tabIndex = 0; selAllBtn.addEventListener('mousedown', () => { selAllBtn.style.cssText = btnRowCSS + btnRowActive + 'flex:1;'; }); selAllBtn.addEventListener('mouseup', () => { selAllBtn.style.cssText = btnRowCSS + 'flex:1;'; }); selAllBtn.addEventListener('mouseleave', () => { selAllBtn.style.cssText = btnRowCSS + 'flex:1;'; }); selAllBtn.onclick = (e) => { e.stopPropagation(); subCheckboxes.forEach(cb => { if (!cb.checked) { cb.checked = true; cb.dispatchEvent(new Event('change')); } }); updateSubVisBtnColor(); }; const deselAllBtn = document.createElement('button'); deselAllBtn.type = 'button'; deselAllBtn.innerHTML = makeButtonHTML('Deselect All', 'D'); deselAllBtn.style.cssText = btnRowCSS + 'flex:1;'; deselAllBtn.tabIndex = 0; deselAllBtn.addEventListener('mousedown', () => { deselAllBtn.style.cssText = btnRowCSS + btnRowActive + 'flex:1;'; }); deselAllBtn.addEventListener('mouseup', () => { deselAllBtn.style.cssText = btnRowCSS + 'flex:1;'; }); deselAllBtn.addEventListener('mouseleave', () => { deselAllBtn.style.cssText = btnRowCSS + 'flex:1;'; }); deselAllBtn.onclick = (e) => { e.stopPropagation(); subCheckboxes.forEach(cb => { if (cb.checked) { cb.checked = false; cb.dispatchEvent(new Event('change')); } }); updateSubVisBtnColor(); }; btnRow.appendChild(selAllBtn); btnRow.appendChild(deselAllBtn); contentWrapper.appendChild(btnRow); // "Choose current configuration" โ€” saves sub-table config, independent of global const chooseBtn = document.createElement('button'); chooseBtn.type = 'button'; chooseBtn.innerHTML = makeButtonHTML('Choose current configuration', 'c'); chooseBtn.style.cssText = btnRowCSS + 'width:100%; margin-top:5px;'; chooseBtn.tabIndex = 0; chooseBtn.title = `Save this column configuration for "${categoryName}" only`; chooseBtn.addEventListener('mousedown', () => { chooseBtn.style.cssText = btnRowCSS + btnRowActive + 'width:100%; margin-top:5px;'; }); chooseBtn.addEventListener('mouseup', () => { chooseBtn.style.cssText = btnRowCSS + 'width:100%; margin-top:5px;'; }); chooseBtn.addEventListener('mouseleave', () => { chooseBtn.style.cssText = btnRowCSS + 'width:100%; margin-top:5px;'; }); chooseBtn.onclick = (e) => { e.stopPropagation(); saveSubColVisState(); menu.style.display = 'none'; Lib.debug('ui', `Chose sub-table column configuration for "${categoryName}"`); }; contentWrapper.appendChild(chooseBtn); const sep2 = document.createElement('div'); sep2.style.cssText = 'margin:8px 0 4px; padding-top:8px; border-top:1px solid #ddd;'; contentWrapper.appendChild(sep2); const hint = document.createElement('div'); hint.textContent = 'Click outside or press Escape to close and save'; hint.style.cssText = 'font-size:0.8em; color:#888; text-align:center; font-style:italic;'; contentWrapper.appendChild(hint); // โ”€โ”€ Keyboard navigation (shared factory โ€” identical to global menu) โ”€โ”€โ”€โ”€ // moveFocusTo(0) is called in the open handler below so the first checkbox // receives focus and a blue-highlight immediately when the menu opens. const { moveFocusTo: subMoveFocusTo } = buildColVisMenuKeyboard({ menu, checkboxes: subCheckboxes, selectAllBtn: selAllBtn, deselectAllBtn: deselAllBtn, chooseConfigBtn: chooseBtn, saveState: saveSubColVisState, menuBtnBase: btnRowCSS }); // โ”€โ”€ Toggle menu โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let menuPosSet = false; btn.addEventListener('click', (e) => { e.stopPropagation(); const isVisible = menu.style.display === 'block'; if (isVisible) { saveSubColVisState(); menu.style.display = 'none'; } else { menu.style.display = 'block'; if (!menuPosSet) { const rect = btn.getBoundingClientRect(); menu.style.top = `${rect.bottom + 5}px`; menu.style.left = `${rect.left}px`; offX = rect.left; offY = rect.bottom + 5; startX = 0; startY = 0; menuPosSet = true; } // Focus first checkbox with blue highlight (matches global menu open behaviour) setTimeout(() => subMoveFocusTo(0), 10); } }); // Close on outside click (save state) const closeOutside = (e) => { if (!menu.contains(e.target) && e.target !== btn) { if (menu.style.display === 'block') saveSubColVisState(); menu.style.display = 'none'; } }; document.addEventListener('click', closeOutside); // Close on Escape (the capture-phase handler from buildColVisMenuKeyboard // handles Tab/arrows/Space/Enter; this bubble-phase listener only handles // the bare Escape-to-close path so it does not interfere with the factory). document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && menu.style.display === 'block') { saveSubColVisState(); menu.style.display = 'none'; } }); document.body.appendChild(menu); // โ”€โ”€ Register in global registry for override propagation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ /** * Called by the global Visible button's "Choose current configuration" to * push the global column state to this sub-table. * Applies the state to DOM AND saves it under the sub-table storage key so * the "last write wins" invariant is maintained across page reloads. * * @param {Object.} globalState { colName: bool } */ const applyGlobalConfig = (globalState) => { subCheckboxes.forEach(cb => { const colName = cb.dataset.columnName; if (colName && Object.prototype.hasOwnProperty.call(globalState, colName)) { const shouldBeVisible = !!globalState[colName]; if (cb.checked !== shouldBeVisible) { cb.checked = shouldBeVisible; cb.dispatchEvent(new Event('change')); } } }); saveSubColVisState(); // persist so it survives the next page load updateSubVisBtnColor(); Lib.debug('ui', `Global col-vis override applied to sub-table "${categoryName}"`); }; subTableColVisRegistry.set(safeId, { applyGlobalConfig }); return btn; } /** * Export table data to CSV format * Exports only visible rows and columns * Generates filename with timestamp and page type */ /** * Extract the real display name from a column header `` element. * * Resolution order (stops at the first non-empty result): * 1. `th.dataset.colName` โ€” set by `makeTableSortableUnified` on every * header it processes (both original and synthetic columns) and by the * synthetic-column injectors in `cleanupHeaders`. This is always the * authoritative clean name when present. * 2. First text node inside `.mb-col-hdr-flex` โ€” the flex container is the * first child of the `` after `makeTableSortableUnified` runs; its * first child is a text node containing the column name followed by a * space. * 3. Fallback: clone the ``, remove all `[class^="mb-"]` children and * `.column-resizer`, return `textContent`. * * @param {HTMLElement} th Live `` element. * @returns {string} Clean column name, whitespace-normalised. */ function _cleanColHeaderText(th) { // 1. dataset.colName โ€” most reliable; set on all processed headers if (th.dataset && th.dataset.colName) { return th.dataset.colName.trim(); } // 2. First text node of .mb-col-hdr-flex (before sort/uniq spans) const _flex = th.querySelector('.mb-col-hdr-flex'); if (_flex) { for (const node of _flex.childNodes) { if (node.nodeType === Node.TEXT_NODE) { const txt = node.textContent.trim(); if (txt) return txt; } } } // 3. Clone fallback: strip known UI children, read remaining text const clone = th.cloneNode(true); clone.querySelectorAll( '.mb-col-uniq-wrap, .sort-icon-btn, .column-resizer, .mb-col-hdr-flex' ).forEach(el => el.remove()); const fallback = clone.textContent.trim().replace(/\s+/g, ' '); if (fallback) return fallback; // Last resort: raw textContent stripped of known glyph characters return th.textContent.replace(/[โ‡…โ–ฒโ–ผ๐Ÿ“Šโ–ถโ—€โ–คโฐยนยฒยณโดโตโถโทโธโน]/g, '').trim(); } /** * Extract clean plain-text from a table cell for export. * * Strips ALL invisible metadata injected by the script before reading the * cell's text content: * โ€ข Any descendant element with inline `display:none` (sort-key spans * such as `.mb-caa-sort-key`, `.mb-cancelled-sort-key`, * `.mb-video-sort-key`, and any future variants). * โ€ข `