User:Catttte/ArtistBackgroundHelper.js

/// 

// https://en.wikipedia.org/w/index.php?title=Special:WhatLinksHere/Template:Infobox_musical_artist&hidetrans=1&hidelinks=1 const INFOBOX_ALIASES = [ "Infobox musical artist", "Infobox Musical Artist", "Infobox singer", "Infobox Musician", "Infobox musician", "Infobox Musical artist", "Infobox Band", "Infobox band", "Musician", "Infobox Musique (artiste)", "Infobox musicial artists", "Infobox Instrumentalist", "Музыкант" ]

// https://en.wikipedia.org/wiki/Template:Infobox_musical_artist#background const ARTIST_COLORS = { "#f0e68c": "solo_singer", "#f4bf92": "non_vocal_instrumentalist", "#bfe0bf": "non_performing_personnel", "#b0c4de": "group_or_band", // can also be an invalid value "#b0e0e6": "classical_ensemble", "#d3d3d3": "temporary" }

// https://en.wikipedia.org/wiki/Template:Infobox_musical_artist#Parameters // these are in reverse order (just before background = first) const PARAMETERS_BEFORE_BACKGROUND = [ "caption", "alt", "landscape", "image_upright", "image_size", "image", "honorific_suffix", "name", "honorific_prefix" ]

/** * @param {mw.Api} api MediaWiki API instance * @returns {Promise } */ async function fetchPageSource(api) { const response = await api.get({       action: "query",        prop: "revisions",        titles: mw.config.get("wgPageName"),        rvprop: "content",        rvslots: "main",        formatversion: 2    })

return response.query.pages[0].revisions[0].slots.main.content }

/** * @param {string} type An infobox musical artist background type, e.g. "solo_singer" * @returns {string} */ function humanizeType(type) { const first = type[0].toUpperCase const rest = type.slice(1).replace(/_/g, " ") return (first + rest).replace("Non ", "Non-") }

/** @returns {string} */ // this is pretty basic but it can be useful (but it can also be wrong!) function findRecommendedType { /** @type {HTMLParagraphElement} */ const firstPara = document.querySelector(".mw-parser-output > p:not(.mw-empty-elt)") const lead = firstPara && firstPara.innerText.replace(/\[\d+\]/g, "")

// no more than 5 words before "band", because it could say "is a singer ... part of band" if (lead.match(/(is|was|are|were) an?(.*\s+){0,5} (duo|band)/)) return "group_or_band" if (lead.match(/(is|was) an?.*?(singer|vocalist)/)) return "solo_singer" // prettier-ignore if (lead.match(/((pian|guitar|bass|harp|violin|saxophon|fla?ut|cell|clarinet|trombon)ist|(drumm|trumpet)er)/)) return "non_vocal_instrumentalist" if (lead.match(/(is|was) an?.*?classical (\w* )?(trio|quartet|ensemble|orchestra)/)) return "classical_ensemble" // prettier-ignore if (lead.match(/an?.*?(disc jockey|DJ|audio engineer|composer|songwriter|record producer)/)) return "non_performing_personnel" return null }

/** @returns {RegExp} */ function buildInfoboxRegex { const aliasExpressions = INFOBOX_ALIASES.map(alias => {       const upper = alias[0]        const lower = upper.toLowerCase        const rest = alias.slice(1)        return `[${lower}${upper}]${rest}`.replace(/\((.*)\)/, "\\($1\\)")    }) const aliases = "(" + aliasExpressions.join("|") + ")" const comment = `(\\s*)*` return new RegExp(`\\{\\{\\s*${aliases}${comment}\\s*(\\|.*?\\}\\}|\\}\\})`, "s") }

/** @returns { { infobox: HTMLTableElement, type: string } } */ function findResemblingInfobox { const infobox = document.querySelector("table.infobox") if (!infobox) return { infobox: null, type: null } // no infobox at all const header = infobox.querySelector("tbody > tr > th") const style = header.getAttribute("style") || "" const color = (style.match(/background-color: ?(#[a-z0-9]{6})/i) || [])[1] if (!color) return { infobox: null, type: null } // no color found const type = ARTIST_COLORS[color.toLowerCase] if (!type) return { infobox: null, type: null } // not a musical artist infobox return { infobox, type } }

/** * @param {mw.Api} api MediaWiki API instance * @param {HTMLTableElement} infobox The infobox table element, obtained with findResemblingInfobox * @param {string} type The name of the type to insert * @param {string} value The background value, if type is invalid * @returns {void} */ function insertType(api, infobox, type, value) { const row = document.createElement("tr") const rowHeader = document.createElement("th") const rowCell = document.createElement("td") rowHeader.setAttribute("scope", "row") rowHeader.innerText = "Type" rowCell.innerText = humanizeType(type) // "Invalid " if (type === "invalid" && value !== "") { rowCell.innerText += " " const code = document.createElement("code") code.innerText = value rowCell.appendChild(code) }

if (mw.config.get("wgIsProbablyEditable")) { // modifying innerHTML instead of innerText so that the isn't flattened rowCell.innerHTML += " ("       const editButton = document.createElement("a")        editButton.innerText = "edit"        editButton.setAttribute("href", "javascript:0")        rowCell.appendChild(editButton)        rowCell.innerHTML += ")"

// for some reason adding a listener to `editButton` doesn't work, // so add the listener after the element has been inserted const appendedEditButton = rowCell.querySelector("a") appendedEditButton.addEventListener("click", =>            insertTypeSelection(api, infobox, type, value)        ) }

const body = infobox.getElementsByTagName("tbody")[0] const rows = Array.from(body.getElementsByTagName("tr")) const isHeader = row => row.querySelector("th[colspan='2']") && row.innerText const headers = rows.filter(isHeader) const header = headers.length > 1 ? headers[1] : headers[0]

row.appendChild(rowHeader) row.appendChild(rowCell) body.insertBefore(row, header.nextSibling) // insert after header }

/** * @param {mw.Api} api MediaWiki API instance * @param {HTMLTableElement} infobox The infobox table element, obtained with findResemblingInfobox * @param {string} currentType The current type * @param {string} currentValue The current value * @returns {void} */ function insertTypeSelection(api, infobox, currentType, currentValue) { let types = Object.values(ARTIST_COLORS) const recommended = findRecommendedType

const body = infobox.getElementsByTagName("tbody")[0] const rows = Array.from(body.getElementsByTagName("tr")) const typeRow = rows.find(row => (row.querySelector("th") || {}).innerText === "Type") // put current and recommended types at the top of the list types = types.filter(type => type !== currentType) types.unshift(currentType) if (recommended) { types = types.filter(type => type !== recommended) types.unshift(recommended) }

const selection = document.createElement("select") for (const type of types) { const option = document.createElement("option") option.setAttribute("value", type) option.innerText = humanizeType(type) if (type === recommended && type !== currentType) { const group = document.createElement("optgroup") group.setAttribute("label", "Recommended") group.appendChild(option) selection.appendChild(group) } else { if (type === currentType) { option.setAttribute("selected", true) option.setAttribute("disabled", true) }           selection.appendChild(option) }   }

selection.addEventListener("change", event => {       if (!event.isTrusted) return        const selected = event.target.selectedOptions[0].value        updateInfobox(api, infobox, currentType, currentValue, selected)    })

const typeCell = typeRow.getElementsByTagName("td")[0] typeCell.innerHTML = "" typeCell.appendChild(selection) }

/** * @param {mw.Api} api MediaWiki API instance * @param {HTMLTableElement} infobox The infobox table element * @param {string} currentType The current type * @param {string} currentValue The current value * @param {string} newType The type to update to * @returns {Promise } */ async function updateInfobox(api, infobox, currentType, currentValue, newType) { const key = (obj, value) => Object.entries(obj).find(e => e[1] === value)[0] if (currentType === "invalid") currentType = "group_or_band"

const currentColor = key(ARTIST_COLORS, currentType) const newColor = key(ARTIST_COLORS, newType) const colorRegex = new RegExp(currentColor, "gi") infobox.innerHTML = infobox.innerHTML.replace(colorRegex, newColor)

const body = infobox.getElementsByTagName("tbody")[0] const rows = Array.from(body.getElementsByTagName("tr")) const typeRow = rows.find(row => (row.querySelector("th") || {}).innerText === "Type") typeRow.remove insertType(api, infobox, newType, null)

const source = await fetchPageSource(api) const infoboxRegex = buildInfoboxRegex const infoboxSource = (source.match(infoboxRegex) || [])[0] if (!infoboxSource) return location.reload // no infobox in source let newInfoboxSource = infoboxSource

const bgMatch = infoboxSource.match(/\|\s*[bB]ackground\s*=\s*(.*?)\s*(\||\})/s) if (!bgMatch) { // there is no background parameter to replace, so we have to create it       // now we are going to do some black magic so that the new background parameter // is stylistically consistent with the rest of the parameters

/**        * @param {RegExp} regex The regex to find all occurrences of this spacing * @returns {string} */       function findCommonSpacing(regex) { // first, gather all spacings // key is the kind (e.g. " | "), value is the times it is used /** @type { { [spacing: string]: number } } */ let spacingKinds = {}

/** @type {RegExpExecArray} */ let groups = null while ((groups = regex.exec(infoboxSource))) { const kind = groups[1] if (spacingKinds[kind]) spacingKinds[kind]++ else spacingKinds[kind] = 1 }

// then, find the most used spacing // x[0] is the key (kind), x[1] is the value (count) // find is used with reduce and it finds the greatest value (count) const find = (a, b) => (b[1] > a[1] ? b : a)           const entries = Object.entries(spacingKinds) const commonSpacing = (entries.reduce(find, ["", 0]) || [])[0] return commonSpacing }

// default to "|" (edge case in which infobox has no parameters beforehand) // second non-capturing group is to make sure pipe belongs to a parameter // not to a wikilink, file, etc.       const commonPipe = findCommonSpacing(/(\s*\|\s*)(?:[\w\s]+=)/g) || "|" // now we have to find what spacing to use for the equals sign! // for multiline templates, sometimes parameters are aligned like this: //    param     = val //    parameter = value // this doesn't apply for singleline templates, so we won't check it if so       let consistentWidth = null if (infoboxSource.includes("\n")) { const lines = infoboxSource.split("\n") const parameterLines = lines.filter(line => line.match(/^\s*\|.*=.*$/)) for (const line of parameterLines) { // space from pipe to equals const width = (line.match(/\|(\s*.+\s*)=/) || [])[1].length if (!consistentWidth) { consistentWidth = width } else if (consistentWidth !== width) { consistentWidth = null break }           }        }

const commonEquals = findCommonSpacing(/(\s*\=\s*)/g) || " = " let equals = null if (consistentWidth) { // spacing left of common pipe const pipeLeft = commonPipe.split("|")[1] const usableWidth = consistentWidth - pipeLeft.length // if "background" is wider than the usable width, we'll just leave it. // maybe make it mutate all existing parameters to conform to the new width // in a later update const rightPaddingWidth = Math.min(usableWidth - "background".length, 0) const beforeEquals = " ".repeat(rightPaddingWidth) // use second half of common equals, since that doesn't depend on consistent width equals = `${beforeEquals}=${commonEquals.split("=")[1]}` } else { equals = commonEquals }

const parameterEntry = `${commonPipe}background${equals}${newType}` let placed = false // now, we have to figure out where to insert this new parameter for (const parameterBefore of PARAMETERS_BEFORE_BACKGROUND) { const parameterPattern = `(\\|\\s*${parameterBefore}\\s*=.*)(\\s*\\||\\s*\\}\\})` const parameterRegex = new RegExp(parameterPattern, "i") if (newInfoboxSource.match(parameterRegex)) { // $1 is parameter before, $2 is whitespace + next pipe or closing braces const rep = `$1${parameterEntry}$2` newInfoboxSource = newInfoboxSource.replace(parameterRegex, rep) placed = true break }       }        // no parameters before background, so we'll just place it first if (!placed) { // $1 is opening braces + template name, $2 is closing braces const rep = `$1${parameterEntry}$2` newInfoboxSource = newInfoboxSource.replace(/([^\s])\s*(\}\})/, rep) }

// that's it! } else { // $1 is "| background = ", $2 is old value, $3 is " |" or " }}" newInfoboxSource = infoboxSource.replace(           /(\|\s*[bB]ackground\s*=[^\S\r\n]*)(.*?)(\s*(?:\||\}))/,            `$1${newType}$3`        ) }

const action = currentValue ? "Change" : "Set" const invalid = currentType === "invalid" && action === "Change" ? " invalid " : " " const newSource = source.replace(infoboxSource, newInfoboxSource) await api.postWithEditToken({       action: "edit",        title: mw.config.get("wgPageName"),        text: newSource,        summary:            `${action}${invalid}"background" parameter in {` +            `{Infobox musical artist}} to "${newType}" ` +            `using ArtistBackgroundHelper`    })

location.reload }

/** @returns {Promise } */ async function main { // find if an infobox exists first, so we don't request for *every* article you open let { infobox, type } = findResemblingInfobox if (!infobox || !type) return // no musical artist infobox let value = type // only used if type is invalid

const agent = "en:User:Catttte/ArtistBackgroundHelper.js" const api = new mw.Api({ ajax: { headers: { "Api-User-Agent": agent } } })

// since the group_or_band color (#b0c4de) could also stand for invalid, // make sure which one it is by seeing source // otherwise, we can just find the type from the infobox color // (which findResemblingInfobox just did) if (type === "group_or_band") { const source = await fetchPageSource(api) const regex = buildInfoboxRegex const infoboxSource = (source.match(regex) || [])[0] if (!infoboxSource) return // weird, no infobox in source const background = (infoboxSource.match( /\|\s*[bB]ackground\s*=\s*(.*?)\s*(\||\})/s ) || [])[1]

if (background === "group_or_band") { type = "group_or_band" } else { type = "invalid" value = background || "" }   }

insertType(api, infobox, type, value) }

$( => main.catch(e => alert(e.stack)))