Greasy Fork

Greasy Fork is available in English.

GGn Formatters

Formatters

此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.greasyfork.icu/scripts/540511/1633678/GGn%20Formatters.js

// ==UserScript==
// @name         GGn Formatters
// @version      23
// @description  Formatters
// @author       ingts (some by ZeDoCaixao and letsclay)
// @match        https://gazellegames.net/
// ==/UserScript==

if (typeof RegExp.escape === 'undefined') { // temporary
    RegExp.escape = function (s) {
        return s.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&")
    }
}

let destructiveEditsEnabled = false
const isEditPage = location.href.includes("action=editgroup")

/**
 * @param {string} str
 * @param {string=} alias
 * @returns {string}
 */
function formatTitle(str, alias) {
    if (!str) return ''

    const japaneseLowercase = new Map([
        ["ga", ["が", "ガ"]],
        ["no", ["の", "ノ"]],
        ["wa", ["わ", "ワ"]],
        ["mo", ["も", "モ"]],
        ["kara", ["から", "カラ"]],
        ["made", ["まで", "マデ"]],
        ["to", ["と", "ト"]],
        ["yo", ["よ", "ヨ"]],
        ["ka", ["か", "カ"]],
        ["ya", ["や", "ヤ"]],
        ["de", ["で", "デ"]],
        ["ni", ["に", "ニ"]],
        ["so", ["そ", "ソ"]],
        ["na", ["な", "ナ"]],
        ["i", ["い", "イ"]],
        ["u", ["う", "ウ"]],
        ["e", ["え", "エ"]],
        ["o", ["お", "オ"]],
        ["wo", ["を", "ヲ"]],
        ["san", ["さん"]],
        ["sama", ["さま"]],
        ["kun", ["くん"]],
        ["chan", ["ちゃん"]],
        ["de", ["で", "デ"]],
        ["ne", ["ね", "ネ"]],
        ["sa", ["さ", "サ"]],
        ["ba", ["ば", "バ"]],
        ["demo", ["でも", "デモ"]],
    ])

    const smallWords = /^(a|an|and|as|at|but|by|en|for|if|in|nor|of|on|or|per|the|to|v.?|vs.?|via)$/i
    const alphanumericPattern = /([A-Za-z0-9\u00C0-\u00FF])/
    const wordSeparators = /([ :–—-]|[^a-zA-Z0-9'’])/
    const allUppercaseWords = ['RPG', 'FPS', 'TPS', 'RTS', 'TBS', 'MMO', 'MMORPG', 'ARPG', 'JRPG', 'PVP', 'PVE', 'NTR', 'NPC', 'OST']
    const uppercaseRegex = new RegExp(`^[A-Z]{2}$|${allUppercaseWords.join('|')}`)
    return str
        .replace(/\s/g, ' ')
        .replace(/ -(.*)- /, ': $1 ')
        .replace('—', ' - ')
        .replace(/ ?~$/, '')
        .replace(/-$/, '')
        .replace(/^-/, '')
        .replace(/ ~ ?/, ': ')
        .replace(/ - ?/, ': ')
        .replace(/[™®©]/g, '')
        .replace(' : ', ': ')
        .trim()
        .split(wordSeparators)
        .map((current, index, array) => {
            const isSmallWord = smallWords.test(current)
            if ((uppercaseRegex.test(current) && !isSmallWord) || /\b([IVX])(X{0,3}I{0,3}|X{0,2}VI{0,3}|X{0,2}I?[VX])(?![A-Za-z'])\b/i.test(current)) {
                return current.toUpperCase()
            }

            // if the current word has an uppercase letter after 2 lowercase letters, assume it should just be like that
            if (!/\w[a-z]{2}[A-Z]/.test(current)) current = current.toLowerCase()

            const firstOrLastWord = index === 0 || index === array.length - 1

            if (alias && !firstOrLastWord) {
                const jpWords = japaneseLowercase.get(current)
                if (jpWords?.some(w => alias.includes(w))) return current
            }

            if (
                /* Check for small words */
                isSmallWord &&
                /* Ignore first and last word */
                !firstOrLastWord &&
                /* Ignore title end and subtitle start */
                array[index - 3] !== ':' &&
                array[index + 1] !== ':' &&
                /* Ignore small words that start a hyphenated phrase */
                (array[index + 1] !== '-' ||
                    (array[index - 1] === '-' && array[index + 1] === '-'))
            ) {
                return current
            }
            /* Capitalize the first letter */
            return current.replace(alphanumericPattern, match => match.toUpperCase())
        })
        .join('')
}

const headersMap = new Map([
    ["aboutGame", "[align=center][b][u]About the game[/u][/b][/align]\n"],
    ["features", "\n[align=center][b][u]Features[/u][/b][/align]"],
    ["sysReqs", "\n\n[quote][align=center][b][u]System Requirements[/u][/b][/align]\n"],
    ["minimumReqs", "\n[b]Minimum[/b]"],
    ["recommendedReqs", "\n[b]Recommended[/b]"],
    ["os", "\n[*][b]OS[/b]: "],
    ["processor", "\n[*][b]Processor[/b]: "],
    ["memory", "\n[*][b]Memory[/b]: "],
    ["storage", "\n[*][b]Storage[/b]: "],
    ["graphics", "\n[*][b]Graphics[/b]: "],
    ["soundcard", "\n[*][b]Sound Card[/b]: "],
    ["directX", "\n[*][b]DirectX[/b]: "],
    ["additionalnotes", "\n[*][b]Additional Notes[/b]: "],
    ["other", "\n[*][b]Other[/b]: "],
    ["network", "\n[*][b]Network[/b]: "],
    ["drive", "\n[*][b]Drive[/b]: "],
    ["controllers", "\n[*][b]Controllers[/b]: "],
])

/**
 * @param {string} str
 * @param {string=} gameTitle
 * @returns {string}
 */
function formatAbout(str, gameTitle) {
    if (!str) return ""

    const aboutHeader = headersMap.get("aboutGame")
    const aboutGameRegex = /^(\[size=3])?\n?(\[(b|u|i|align=center)]\n?){0,4}(About\sthe\sGame|About\sThis\sGame|What\sis\sThis\sGame\?|About|Description)\s*:?(\n?\[\/(b|u|i|align|size)]){0,4}(\s*:|:|)/i
    str = str.replace(aboutGameRegex, aboutHeader)

    let [_, about, reqs] = new RegExp(`(?:${RegExp.escape(aboutHeader)})?(.*?)(\\[quote].*|$)`, 's').exec(str)

    about = about.trim()
    about = about.replace(/^\[align=.*?](.*)\[\/align]$/s, '$1')
    about = about.replace(/\bdefence\b/g, 'defense')
    about = about.replace(/ *\/ */g, '/')

    // Replace different list symbols with [*]
    about = about.replace(/^\s*[-•◦■・★*]\s*/gm, '[*]')

    // If a line starts with [*] followed by a [u] or [i], replace them with [b]
    about = about.replace(/^\[\*]\[[ui]](.*?)\[\/[ui]]/gm, '[*][b]$1[/b]')

    if (destructiveEditsEnabled) {
        // If a line is all uppercase, title case it
        about = about.split('\n').map(line => line.toUpperCase() === line ? formatTitle(line) : line).join('\n')

        // Bold text before colon in list item, change -/— to :
        about = about.replace(/\[\*](?:\[b])?(.*?)(?:\[\/b])?(?:: *| +- *| *— *)(\w)/g,
            (_, p1, p2) => `[*][b]${p1}[/b]: ${p2.toUpperCase()}`)
    }

    // bold game title. replace [u] or [i] with bold
    if (gameTitle) {
        const boldTitleRegex = new RegExp(`((?:\\[[uib]])+)?(?:${RegExp.escape(gameTitle)}|${RegExp.escape(formatTitle(gameTitle))})(?:(?:\\[\\/[uib]])+)?`, 'i')
        about = about.replace(boldTitleRegex, (match, p1) => {
            if (p1 === '[b]') return match
            return `[b]${gameTitle}[/b]`
        })
    }

    /*
        // If a line starts with [u], [i], or [b] (:), there is no other text on that line, and it contains 'features', replace tags with [align=center][b][u]
        about = about.replace(/^(\[b]|\[u]|\[i])+(.*?)(\[\/b]|\[\/u]|\[\/i])+:$/gm,
            (match, p1, p2, p3) => (p1 && p3 && /features/i.test(p2)) ? `[align=center][b][u]${p2}[/u][/b][/align]` : match)
    */

    // If a line starts with [b]text[/b]: and it is not the only text on that line, add [*] at the start and replace tags with [b]
    about = about.replace(/^\[b](.*?]:)(.*)/gm, (match, p1, p2) => {
        if (p2.trim() === '') {
            return match
        }
        return p1 ? `[*][b]${removeBbcode(p1)}${p2}` : match
    })

    // If a line starts with [*], have only one new line until the next [*] and after the last one, have a double newline
    about = fixMultiLinesInLists(about)

    /*
        // Remove double newlines between [*] lines
        about = about.replace(/(\[\*][^\n]*)(\n{2,})(?=\[\*])/g, '$1\n')

        // Add a newline when next line doesn't start with [*]
        about = about.replace(/(\[\*][^\n]*\n)([^\[*\]\n])/g, '$1\n$2')
    */

    // If the line starts with [*] and the whole line until terminal punctuation is wrapped in [u], [i], or [b], remove the wrapping tags
    about = about.replace(/^\[\*]\[([bui])](.*?)\[\/([bui])]([.?!])$/gm, "[*]$2$4")

    // If a line ends with [/align] replace double newlines with one newline
    about = about.replace(/(\[\/align])\n\n/g, '$1\n')

    // Remove colons in [align=center]
    about = about.replace(/\[align=center].*?(?:\[\/\w]:|:)\[\/align]/g,
        match => match.replace(/:/g, ''))

    if (destructiveEditsEnabled) {
        // If a line starts with [u], [i], or [b] and has only a new line after the closing tag, make it a list item
        about = about.replace(/\[[uib]](.*?)\[\/[uib]]:?\n(.*)/g,
            (_, p1, p2) => `[*][b]${p1}[/b]: ${p2}`)

        // Title case text inside [align=center][b][u] and remove extra tags
        about = about.replace(/\[align=center]\[([bu])]\[([bu])]([\s\S]*?)\[\/\2]\[\/\1]\[\/align]/g,
            (match, p1, p2, p3) => `[align=center][b][u]${formatTitle(removeBbcode(p3))}[/u][/b][/align]`)
    }

    about = about.replace(/\n*\s*\[\*]\s*/g, "\n[*]")

    const regFeatures = /\n(\[size=3])?\n?(?:\[\*])?(\[(b|u|i|align=center|size=2)]\n?){0,4}(the features|Key\sFeatures|Main\sFeatures|Game\sFeatures|Other\sFeatures|Features\sof\sthe\sGame|Features|Featuring|Feautures)\s*:?\s*(\n?\[\/(b|u|i|align|size)]){0,4}\s*:/i
    const featuresHeader = headersMap.get("features")
    about = about.replace(regFeatures, featuresHeader)

    // Add features header
    about = about.replace(/(.*?)\n((?:\[\*].*\n?){3,})/g, (match, lineBefore, list) => {
        if (!lineBefore.includes('[/align]')) {
            return (/^\s+$/.test(lineBefore) ? '' : lineBefore + '\n')
                + (about.includes(featuresHeader) ? '' : featuresHeader) + '\n' + list
        }
        return match
    })

    // Add a newline before lines with [align=center] if there isn't already a double newline before it. Here after adding features header
    about = about.replace(/(?<!\n\n)(\[align=center])/g, '\n$1')

    about = about.replace(/\[\/align]\n*/gi, "[/align]\n")

    about = about.replace(/\n{2,10}/g, "\n\n")

    return aboutHeader + about + (reqs ? `\n\n${reqs}` : '')

}

function fixMultiLinesInLists(str) {
    const lines = str.split('\n')
    const result = []
    let i = 0

    while (i < lines.length) {
        const line = lines[i]

        // If line starts with [*], we're in a list section
        if (line.startsWith('[*]')) {
            const listItems = []

            // Collect all consecutive [*] items (skipping empty lines between them)
            while (i < lines.length && (lines[i].startsWith('[*]') || lines[i].trim() === '')) {
                if (lines[i].startsWith('[*]')) {
                    listItems.push(lines[i])
                }
                i++
            }

            // Add list items with single newlines between them
            result.push(...listItems)

            // Add single empty line after the list section if there's more content
            if (i < lines.length) {
                result.push('') // This creates one empty line, which with the next line creates a double newline
            }
        } else {
            // Regular line, add it
            result.push(line)
            i++
        }
    }

    return result.join('\n')
}


/**
 * @param {string} str
 * @returns {string}
 */
function formatSysReqs(str) {
    if (!str) return ""
    const sysReqsHeader = headersMap.get("sysReqs")

    let reqs = new RegExp(`${RegExp.escape(sysReqsHeader)}(.*)\\[\\/quote]`, 's').exec(str)?.[1]
    if (!reqs) return str
    const original = reqs

    const osHeader = headersMap.get("os")
    reqs = reqs.replace(/:\n/g, "\n")
    reqs = reqs.replace(/:\[\/b]\n/g, "[/b]\n")

    if (destructiveEditsEnabled) {
        // remove + after a number
        reqs = reqs.replace(/(\d)\+/g, '$1')
        reqs = reqs.replace(/\.x/g, '')
    }

    const platform = (isEditPage && (
                document.getElementById('nexusmodsuri') ? 'Windows'
                    : (document.getElementById('itunesuri') && document.getElementById('epicgamesuri')) ? 'Mac'
                        : document.getElementById('googleplayuri')) ? 'Android'
                : (document.getElementById('itunesuri') && !document.getElementById('epicgamesuri') ? 'iOS'
                    : '')
        )
        || document.getElementById('platform')?.value


    const minimumHeader = headersMap.get("minimumReqs")
    const recommendedHeader = headersMap.get("recommendedReqs")

    //region Section labels formatting (mostly from Description Broom)

    // Minimum
    reqs = reqs.replace(/\n*(\[\*]\[([bi])]|((\s*|)\[\*])|\[([bi])]|\*|)(\s*|)(Minimum\sSpecifications|Minimum\sSystem\sRequirements|Minimum\sRequirements|Minimum)(\s|)(:\s\[\/([bi])]|:\[\/([bi])]|\[\/([bi])]:|\[\/([bi])]|:)(?:\n*(?!\S))?/gi, minimumHeader)

    // Recommended
    reqs = reqs.replace(/\n*(\[\*]\[([bi])]|((\s*|)\[\*])|\[([bi])]|\*|)(\s*|)(Recommended\sSpecifications|Recommended\sSystem\sRequirements|Re(c|cc)o(mm|m)ended)(\s|)(:\s\[\/([bi])]|:\[\/([bi])]|\[\/([bi])]:|\[\/([bi])]|:)(?:\n*(?!\S))?/gi, recommendedHeader)

    formatSectionLabel("Supported\\sOS|OS|Operating\\sSystems|Operating\\sSystem|Mac\\sOS|System|Mac", osHeader)
    formatSectionLabel("CPU\\sType|CPU\\sProcessor|CPU|Processor", headersMap.get("processor"))
    formatSectionLabel("System\\sRAM|RAM|System\\sMemory|Memory", headersMap.get("memory"))
    formatSectionLabel("Free\\sHard\\sDisk\\sSpace|Hard\\sDrive\\sSpace|Hard\\sDisk\\sSpace|Hard\\sDisk|Free\\sSpace|Hard\\sDrive|HDD\\sSpace|HDD|Storage|Disk\\sSpace|Free\\sDisk\\sSpace|Drive\\sSpace|Available\\sHard\\sDisk\\sSpace", headersMap.get("storage"))
    formatSectionLabel("VGA|Graphics|Graphic\\sCard|GPU|Video\\sCard|Video|GFX", headersMap.get("graphics"))
    formatSectionLabel("audio\\scard|Sound\\sCard|Sound", headersMap.get("soundcard"))
    formatSectionLabel("DirectX\\sVersion|DirectX|Direct\\sX|DX", headersMap.get("directX"))
    formatSectionLabel("Additional\\sNotes|Additional|Notice|Please\\snote|Notes", headersMap.get("additionalnotes"))
    formatSectionLabel("Other\\sRequirements|Other|Peripherals", headersMap.get("other"))
    formatSectionLabel("Network|Internet", headersMap.get("network"))
    formatSectionLabel("(CD\\sDrive\\sSpeed|Disc\\sDrive|CD-ROM|DVD\\sDrive)", headersMap.get("drive"))
    formatSectionLabel("Controllers|Supported\\sJoysticks|Input", headersMap.get("controllers"))

    //endregion
    // remove duplicate Additional Notes
    let notes
    reqs = reqs.replace(new RegExp(RegExp.escape(headersMap.get("additionalnotes")) + '.*?$', 'gms'), match => {
        if (!notes) {
            notes = match
            return match
        }
        return notes.toLowerCase() === match.toLowerCase() ? '' : match
    })

    reqs = reqs.replace(/\n(.*)\n?\[\*]Requires a 64-bit.*\n?(.*)/g, (_, header, nextLine) => {
        /*
        Remove the whole section when it's like
        [b]Recommended[/b]
        [*]Requires a 64-bit processor and operating system[/quote] */
        if (!nextLine) return ''
        return `\n${header ? `${header}\n` : ''}${nextLine}` + (nextLine.includes('OS') && !nextLine.includes('64') ? ' (64-bit)' : '')
    })

    reqs = reqs.replace(/intel/gi, 'Intel')
    reqs = reqs.replace(/amd/gi, 'AMD')
    reqs = reqs.replace(/\(?64.?bit\)?/gi, "(64-bit)")

    // has minimum but no recommended, replace the minimum with new line
    if (reqs.includes(minimumHeader) && !reqs.includes(recommendedHeader)) {
        reqs = reqs.replace(minimumHeader + '\n', "")
    }

    reqs = reqs.replace(/OS \*/g, 'OS')

    // uppercase unit
    reqs = reqs.replace(/(\d+)\s?(\w)b/gi, (match, p1, p2) => `${p1} ${p2.toUpperCase()}B`)

    // add space between at least 2 letters then a number
    reqs = reqs.replace(/([a-zA-Z]{2,})(\d)/g, '$1 $2')
    reqs = reqs.replace(/,? *(?:or|and) +(?:up|better|greater|higher|over|more|later|newer|faster|similar|equal|equivalent)/gi, '')
    reqs = reqs.replace(/ *\(\)/g, '') // leftover empty brackets from the above because it needs to handle (or better) or (3 GHz or greater / 6 cores or greater)
    reqs = reqs.replace(/ *以上/g, '')

    // convert to next unit if divisible by 1024
    reqs = reqs.replace(/(\d+)\s*([KM]B)/gi, (match, num, unit) => {
        const intNum = parseInt(num)

        if (intNum % 1024 === 0) {
            return unit === 'KB' ? `${intNum / 1024} MB` : unit === 'MB' ? `${intNum / 1024} GB` : match
        }
        return match
    })

    if (platform === 'Android' || platform === 'iOS') {
        // add the OS label if missing
        reqs = reqs.replace(/^(?:android|ios)? *(\d.*)/i,
            osHeader.replace('\n', '') + platform + ' ' + '$1')
    }

    formatSection('OS', match => {
        match = match.replace(/ *home/gi, '')
        match = match.replace(/^(\d)/, `${platform ? platform + ' ' : ''}$1`)
        match = match.replace(/,? *\(?:?32.*64.?bit\)? ?/gi, '') // both bits written, remove all
        match = match.replace(/^ *\(?64-bit\)?\s?(.*)/g, "$1 (64-bit)")
        match = match.replace(/(?:Microsoft\s?)?Win(?:dows)?/gi, 'Windows')
        match = match.replace(/macos/gi, "macOS")
        match = replaceOrAndCommaWithSlash(match)
        match = match.replace(/ *\/ */g, '/')

        // Remove repeated OS names
        let name = ''
        match = match.replace(/[a-zA-Z]+ /g, match => {
            if (!name) {
                name = match
                return match
            }
            return match.toLowerCase() === name.toLowerCase() ? '' : match
        })

        return match
    })

    formatSection('Processor', match => {
        match = match.replace(/ryzen/gi, 'Ryzen')
        match = match.replace(/core/gi, 'Core')
        match = match.replace(/(?<!intel )pentium/gi, 'Intel Pentium')
        match = match.replace(/core with (.*)Hz/gi, 'Core $1Hz')
        match = match.replace(/(.*?)-core/gi, '$1 Core')

        match = match.replace(/(\d\.?\d?)\s?(\w)hz/gi, (match, p1, p2) => `${p1} ${p2.toUpperCase()}Hz`)

        if (destructiveEditsEnabled) {
            match = match.replace(/ *processor */gi, '')
            match = match.replace(/(?:intel )?core 2 (Solo|Duo|Quad|Extreme)/gi, (match, p1) =>
                `Intel Core 2 ${toSentenceCase(p1)}`)

            match = match.replace(/(\w+-? *)Core ?(?:intel )?i(\d)/gi, (m, p1, p2) => {
                if (p1.toLowerCase().trim() !== 'intel') return `${p1} Core Intel Core i${p2}` // keep the cores quantifier e.g. Quad Core/4-Core
                return m
            })

            match = match.replace(/^(\d\.?\d? .Hz),? (.+ core)$/gi, (match, hz, cores) => cores + ' ' + hz)
            match = replaceOrAndCommaWithSlash(match)

            // if all Hz are the same, put it after a comma at the end
            const hzRegex = / \d\.?\d? \wHz/g
            const hzMatches = [...match.matchAll(hzRegex)]
            if (hzMatches.length > 1 && hzMatches.every(arr => arr[0] === hzMatches[0][0])) {
                for (const hzMatch of hzMatches) {
                    match = match.replace(hzMatch[0], '')
                }
                match = match + `, ${hzMatches[0][0]}`
            }
        }

        match = match.replace(/(\d)\/([a-zA-Z])/g, '$1 / $2')
        match = match.replace(/(?:intel )?(?:core *)?i ?(\d)(?: *- *(\d+)(\w)?)?/gi,
            (_, gen, model, suffix) => `Intel Core i${gen}${model ? `-${model}` : ''}${suffix ? suffix.toUpperCase() : ''}`)

        return match
    })

    formatSection('Memory', match => {
        return match.replace(/([kmg])B(?: ?ram)?/i, '$1B RAM')
    })
    formatSection('Storage', match => {
        return match.replace(/hd(?!d)/i, 'HDD')
    })

    formatSection('Graphics', match => {
        match = match.replace(/(?<!V)RAM|memory/gi, 'VRAM')
        if (destructiveEditsEnabled) {
            match = match.replace(/(?:nvidia )?(?:geforce )?([rg]tx) ?(\d+)/gi,
                (_, tx, num) => `Nvidia GeForce ${tx.toUpperCase()} ${num}`)

            match = match.replace(/graphics card with (.*?)\s?(?:of)? V?RAM/gi, '$1 VRAM')
            match = match.replace(/(?:of )?(?:dedicated )?(?:(?<!intel hd )graphics |video )?/gi, '')
            match = match.replace(/(at least )?(\d [MG]B)(?! ?vram)/gi, (match, p1, p2) => {
                if (!p1 && p2.includes('GB')) return match
                return `${p2} VRAM`
            })
            match = replaceOrAndCommaWithSlash(match)
            match = match.replace(/(?:series|video)?\s?cards? ?/gi, '')
        }

        match = match.replace(/d(?:irect)?x (\d)(?:\.0(?!\w))?/gi, 'DirectX $1')
        match = match.replace(/nvidia/gi, 'Nvidia')
        match = match.replace(/open ?gl/gi, 'OpenGL')
        match = match.replace(/(?:amd )?radeon/gi, 'AMD Radeon')
        match = match.replace(/gpu/gi, 'GPU')
        match = match.replace(/(\d) ?Ti/gi, '$1 Ti')
        match = match.replace(/(\w)\/(\w)/g, '$1 / $2')
        match = match.replace(
            /(OpenGL .*) hardware driver support required for WebGL acceleration.*\((AMD Catalyst .*? \/ Nvidia \d.*)\)/i, '$2; $1')
        return match
    })

    formatSection('DirectX', match => {
        match = match.replace(/v(?:ersion)?\s?/i, '')
        match = match.replace(/\.0(?!\w)/, '')
        return match
    })

    reqs = reqs.replace(/(\S)\(/g, '$1 (')

    return str.replace(original, reqs)

    function formatSectionLabel(partialPattern, replacement) {
        reqs = reqs.replace(
            // colon only, colon preceded/followed by [/b], colon with space before [/b]
            new RegExp(`\\n(?:\\[\\*]\\[b]|\\s*\\[\\*]|\\[b]|\\*)\\s*(?:${partialPattern})\\s*(?::\\s\\[\\/b]|:\\[\\/b]|\\[\\/b]:|:)\\s`, 'gi'),
            replacement)
    }

    /**
     * @param {string} sectionName
     * @param {(match: string) => string} func
     */
    function formatSection(sectionName, func) {
        const regExp = new RegExp(`(^\\[\\*]\\[b]${sectionName}\\[\\/b]: )(.*)`, 'gm')
        reqs = reqs.replace(regExp, (_, p1, p2) => `${p1}${func(p2)}`)
    }

    function replaceOrAndCommaWithSlash(str) {
        return str.replace(/,? or |, /gi, ' / ')
    }
}

/**
 * @param {string} str
 * @returns {string}
 */
function formatDescCommon(str) {
    if (!str) return ""

    if (destructiveEditsEnabled) {
        // Convert full-width ASCII characters (!~) to half-width
        str = str.replace(/[\uFF01-\uFF5E]/g,
            match => String.fromCharCode(match.charCodeAt(0) - 0xFEE0)).replace(/\u3000/g, ' ');
    }

    str = str.replace(/\s*([.,?!:])[^\S\r\n]+/g, '$1 ')
    str = str.replace(/[^\S\r\n]*\n/g, '\n')
    str = str.replace(/[™®©]/g, '')
    str = str.replace(/。/g, '.')
    str = str.replace(/?/g, '?')
    str = str.replace(/!/g, '!')
    str = str.replace(/ ?\((?:[RC]|TM)\)/gi, '')
    str = str.replace(/([?!#$%;])(\w)/g, '$1 $2')
    str = str.replace(/([a-zA-Z]):(\w)/g, '$1: $2') // [a-zA-Z] to avoid aspect ratio
    str = str.replace(/([a-z0-9])([.,])(?![A-Z]\.)(\w)/g, (match, p1, p2, p3) => { // try to avoid initialisms
        if (/\d/.test(p1) && /\d/.test(p3)) return match // dont add space for numbers, decimals
        return `${p1}${p2} ${p3}`
    })

    // Move punctuation outside closing tags
    str = str.replace(/((?:\[[bui]])+)(.*?)([,.?!:])\s*((?:\[\/[bui]])+)\s*/g, '$1$2$4$3 ')

    str = str.replace(/\[u]\[b](.*?)\[\/b]\[\/u]/g, '[b][u]$1[/u][/b]')

    // region Description Broom stuff
    if (str.includes("[quote]") && !str.includes("[/quote]")) {
        str = str.replace("\n[/align]", "[/quote]")
        if (!str.includes("[/quote]")) {
            str = str + "[/quote]"
        }
    }

    str = str.replace(/\n{2,10}\[align=center]/g, "\n\n[align=center]")

    str = str.replace(/\n*\[quote]/g, "\n\n[quote]")

    str = str.replace(/\n*\[\/quote].*/gi, "[/quote]")

    // here so that formatAbout can find it
    const sysReqsHeader = headersMap.get("sysReqs")
    if (!str.includes(sysReqsHeader)) {
        str = str.replace(/\n?(\[size=3])?\n(\[(b|u|i|quote|align=center|align=left)]\n?){0,5}\s*(System\sRequirements|Game\sSystem\sRequirements|Requirements|GOG\sSystem\sRequirements|Minimum\sSystem\sRequirements|System\sRequierments)\s*([:.])?\s*(\n?\[\/(b|u|i|align|size)]){0,5}(\s*:|:|)\n*(\n\[align=(left|center)])?/i,
            sysReqsHeader)
    }

    //endregion

    return str
}

/**
 * @param {string} str
 * @param {string=} gameTitle
 * @returns {string}
 */
function formatAll(str, gameTitle) {
    str = formatDescCommon(str)
    str = formatAbout(str, gameTitle)
    str = formatSysReqs(str)
    return str
}

function removeBbcode(str) {
    return str.replace(/\[(\/?[^\]]+)]/g, '')
}

function toSentenceCase(str) {
    const pos = /]?\w/.exec(str)?.index // mainly to skip [*]
    if (pos === undefined) return

    let lowerStr = str.toLowerCase()
    const newStr = lowerStr.charAt(pos).toUpperCase() + lowerStr.slice(pos + 1)
    // Capitalise subsequent sentences
    return str.substring(0, pos) + newStr.replace(/([.?!\]]\s+)([a-z])/g, (match, p1, p2) => p1 + p2.toUpperCase())
}

/**
 * @param {HTMLTextAreaElement} textarea
 * @param {string} unformattedDesc
 */
function createUnformattedArea(textarea, unformattedDesc) {
    let div = document.getElementById('unformatted-desc')
    if (div) return

    textarea.insertAdjacentHTML('beforebegin',
        `<div id="unformatted-desc">
    <button type="button" style="margin-bottom: 5px;"> Show unformatted description</button>
    <textarea cols="30" rows="15" style="filter: brightness(0.8); display:none; margin-bottom: 5px;margin-left: 0;width: 100%;" readonly>${unformattedDesc}</textarea>
</div>`)

    div = document.getElementById('unformatted-desc')
    const ta = div.querySelector('textarea')
    const btn = div.querySelector('button')
    btn.onclick = () => {
        if (ta.style.display === 'none') {
            ta.style.display = 'block'
            btn.textContent = 'Hide unformatted description'
        } else if (ta.style.display === 'block') {
            ta.style.display = 'none'
            btn.textContent = 'Show unformatted description'
        }
    }
}