GGn Formatters

Formatters

此腳本不應該直接安裝,它是一個供其他腳本使用的函式庫。欲使用本函式庫,請在腳本 metadata 寫上: // @require https://update.cn-greasyfork.org/scripts/540511/1620007/GGn%20Formatters.js

// ==UserScript==
// @name         GGn Formatters
// @version      7
// @description  Formatters
// @author       ingts (some by ZeDoCaixao and letsclay)
// @match        https://gazellegames.net/
// ==/UserScript==
function formatTitle(str, alias) {
    const japaneseLowercase = new Map([
        ["ga", ["が", "ガ"]],
        ["no", ["の", "ノ"]],
        ["wa", ["わ", "ワ"]],
        ["mo", ["も", "モ"]],
        ["kara", ["から", "カラ"]],
        ["made", ["まで", "マデ"]],
        ["to", ["と", "ト"]],
        ["ya", ["や", "ヤ"]],
        ["de", ["で", "デ"]],
        ["ni", ["に", "ニ"]],
        ["so", ["そ", "ソ"]],
        ["na", ["な", "ナ"]],
        ["i", ["い", "イ"]],
        ["u", ["う", "ウ"]],
        ["e", ["え", "エ"]],
        ["o", ["お", "オ"]],
        ["san", ["さん"]],
        ["sama", ["さま"]],
        ["kun", ["くん"]],
        ["chan", ["ちゃん"]]
    ])

    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 allUppercase = new Set(['rpg', 'fps', 'tps', 'rts', 'tbs', 'mmo', 'mmorpg', 'arpg', 'jrpg', 'pvp', 'pve', 'ntr', 'td', 'vr', 'npc', 'ost'])
    return str
        .replace(/\s/g, ' ').replace('—', ' - ')
        .replace(/~$/, '').replace(/ ~$/, '').replace(/-$/, '').replace(/^-/, '').replace(/ ~ /, ': ').replace(/ ~/, ': ').replace(/ - /, ': ').replace(/ -/, ': ')
        .replace('™', '').replace('®', '').replace(' : ', ': ')
        .toLowerCase().trim()
        .split(wordSeparators)
        .map((current, index, array) => {
            const isFirstOrLastWord = index !== 0 && index !== array.length - 1

            if (allUppercase.has(current.trim()) || /\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 (alias && !isFirstOrLastWord) {
                const jpWords = japaneseLowercase.get(current)
                if (jpWords?.some(w => alias.includes(w))) return current
            }

            if (
                /* Check for small words */
                current.search(smallWords) > -1 &&
                /* Skip first and last word */
                isFirstOrLastWord &&
                /* 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, function (match) {
                return match.toUpperCase()
            })
        })
        .join('')
}

function formatAbout(str, gameTitle) {
    if (!str) return ""
    str = str.replace(/defence/g, 'defense')
    str = str.replace(/ *\/ */g, '/')

    // bold game title. replace [u] or [i] with bold
    if (gameTitle) {
        // noinspection RegExpSuspiciousBackref
        const boldTitleRegex = new RegExp(`(?:\\[([ui])\])?${gameTitle}(?:\\[\\/\\1])?`, 'i') // maybe use RegExp.escape later
        str = str.replace(boldTitleRegex, `[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]
    str = str.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)

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

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

    // Remove colons in text inside [align=center][b][u]
    str = str.replace(/\[align=center]\[b]\[u](.*?)\[\/u]\[\/b]\[\/align]/g, (match, _) => match.replace(/:/g, ''))

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

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

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

    // If a line starts with [u], [i], or [b] and has nothing else after the closing tag, sentence case the text inside tags
    str = str.replace(/(^|\n)(\[([uib])](.*?)\[\/([uib])]\s*$)/gm, (match, p1, p2, p3, p4) => `${p1}[${p3}]${toSentenceCase(p4)}[/${p3}]`)

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

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

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

    // Move : and . outside of closing tags
    str = str.replace(/(\[([bui])])(.*?)([:.])\[\/([bui])]/g, '$1\$3[/b]\$4')

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

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

    return str

    function fixMultiLinesInLists() {
        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');
    }

    function toSentenceCase(str) {
        let lowerStr = str.toLowerCase();

        // Capitalize the first letter
        let newStr = lowerStr.charAt(0).toUpperCase() + lowerStr.slice(1);

        // Capitalise subsequent sentences
        newStr = newStr.replace(/([.?!]\s+)([a-z])/g, (match, p1, p2) => p1 + p2.toUpperCase());

        return newStr;
    }
}

function formatSysReqs(str) {
    if (!str) return ""

    str = str.replace(/:\[\/b] /g, "[/b]: ")
    str = str.replace(/:\n/g, "\n")
    str = str.replace(/:\[\/b]\n/g, "[/b]\n")
    // str = str.replace(/\n\n\[b]/g, "\n[b]")
    str = str.replace(/\[\*]Requires a 64-bit processor and operating system\n(.*)/g, (match, osLine) =>
        osLine.includes('64') ? osLine : `${osLine} (64-bit)`)
    str = str.replace(/OS \*/g, 'OS')
    str = str.replace(/(\d+)\s?(\w)b/gi, (match, p1, p2) => `${p1} ${p2.toUpperCase()}B`)
    str = str.replace(/([a-zA-Z]{2,})(\d)/g, '$1 $2')
    str = str.replace(/ *\(?or\)? *(?:better|greater|higher|over|similar|more|later|equivalent)\)?/gi, '')

    // convert to next unit if divisible by 1024
    str = str.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
    })

    formatSection('OS', match => {
        match = match.replace(/(?:Microsoft\s?)?Win(?:dows)?/gi, 'Windows')
        match = match.replace(/^\(?64-bit\)?\s?(.*)/g, "$1 (64-bit)")
        match = match.replace(/macos/gi, "macOS")
        match = match.replace(/, /g, '/')

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

        return match
    })

    formatSection('Processor', match => {
        match = match.replace(/i(\d+)/gi, 'i$1') // small i
        match = match.replace(/ or /gi, ' /')
        match = match.replace(/ryzen/gi, 'Ryzen')
        match = match.replace(/core/gi, 'Core')
        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`)
        match = match.replace(/(\d{3,}) (.*Hz)/g, '$1, $2')
        match = match.replace(/(i\d) - (\d)/g, '$1-$2')
        return match
    })

    formatSection('Graphics', match => {
        match = match.replace(/(?:nvidia )?(?:geforce )?([rg]tx) ?(\d+)/gi,
            (_, tx, num) => `Nvidia GeForce ${tx.toUpperCase()} ${num}`)
        match = match.replace(/(?:of )?(?:dedicated )?(?:graphics |video )?/gi, '')
        match = match.replace(/(?<!V)RAM|memory/, 'VRAM')
        match = match.replace(/graphics card with (.*?)\s?(?:of)? V?RAM/gi, '$1 VRAM')
        match = match.replace(/nvidia/gi, 'Nvidia')
        match = match.replace(/gpu/gi, '')
        match = match.replace(/(\d)Ti/gi, '$1 Ti')
        match = match.replace(/(?:series|video)?\s?card?/gi, '')
        match = match.replace(/(\w)\/(\w)/g, '$1 / $2')
        return match
    })

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

    str = str.replace(/(\d)\/([a-zA-Z])/g, '$1 / $2')
    str = str.replace(/(\S)\(/g, '$1 (')
    str = str.replace(/(\S)\+/g, '$1')
    str = str.replace(/intel/gi, 'Intel')
    str = str.replace(/amd/gi, 'AMD')
    str = str.replace(/\(?64.?bit\)?/g, "(64-bit)")

    return str

    /**
     * @param {string} sectionName
     * @param {(match: string) => string} func
     */
    function formatSection(sectionName, func) {
        const regExp = new RegExp(`^\\[\\*]\\[b]${sectionName}\\[\\/b]: (.*)`, 'gm')
        for (const match of str.matchAll(regExp)) {
            if (match?.[1]) {
                str = str.replace(match[1], func(match[1]))
            }
        }
    }
}

function formatDescCommon(str) {
    if (!str) return ""
    str = str.replace(/[™®©]/g, '')
    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(/(\w)\.(\w)/g, (match, p1, p2) => {
        if (/\d/.test(p1) && /\d/.test(p2)) return match // dont add space for decimals
        return '$1. $2'
    })
    return str
}