GGn Game Description Formatter

Buttons to format description

// ==UserScript==
// @name         GGn Game Description Formatter
// @namespace    none
// @version      1.0.1a
// @description  Buttons to format description
// @author       ingts
// @grant        GM_addStyle
// @match        https://gazellegames.net/upload.php*
// @match        https://gazellegames.net/torrents.php?action=editgroup&groupid=*
// @require      https://update.greasyfork.org/scripts/540511/1626385/GGn%20Formatters.js
// ==/UserScript==
// noinspection CssUnusedSymbol

destructiveEditsEnabled = true

//language=css
GM_addStyle(`
    #description-formatter {
        display: flex;
        gap: 3%;
        align-items: center;
        margin: 5px 0;
        padding: 0 10px;
        
        > div > button {
            height: auto;
            white-space: nowrap;
            padding: 5px;
        }

        button {
            transition-property: background-color;
            transition-duration: 1s;
        }

        button:active {
            background-color: gainsboro;
            transition-duration: 0s;
        }

        section {
            display: grid;
            grid-template-columns: 1fr 3fr;
            column-gap: 15px;
            row-gap: 8px;
        }

        .formatter-buttons-row {
            display: flex;
            flex-wrap: wrap;
            gap: 3px;
        }
    }
`)
const isEditPage = location.href.includes("action=editgroup")

/** @type {HTMLTextAreaElement} */
const descInput = isEditPage ? document.querySelector("textarea[name='body']") : document.getElementById('album_desc')

// language=HTML
descInput.insertAdjacentHTML('afterend',
    `
        <section id="description-formatter">
            <div style="display:flex;flex-direction:column;gap: 8px;width: fit-content;">
                <button type="button">Format All</button>
                <button type="button">Format About</button>
                <button type="button">Format SR</button>
            </div>
            <section>
                <div>
                    <strong>Casing</strong>
                    <div class="formatter-buttons-row"></div>
                </div>
                <div>
                    <strong>BBCode</strong>
                    <div class="formatter-buttons-row"></div>
                </div>
                <div>
                    <strong>Headers</strong>
                    <div class="formatter-buttons-row"></div>
                </div>
                <div>
                    <strong>System Requirements</strong>
                    <div class="formatter-buttons-row"></div>
                    <div class="formatter-buttons-row" style="margin-top: 3px;"></div>
                </div>
            </section>
        </section>`)

const main = document.getElementById('description-formatter')
const mainButtons = main.querySelectorAll(':scope > div button')

const title = isEditPage ? document.querySelector("#content > div > h2 > a").textContent : document.getElementById('title').value

mainButtons[0].onclick = () => mainButtonClick(desc => formatAll(desc, title))
mainButtons[1].onclick = () => mainButtonClick(desc => {
    const common = formatDescCommon(desc)
    return formatAbout(common, title)
})
mainButtons[2].onclick = () => mainButtonClick(desc => {
    const common = formatDescCommon(desc)
    return formatSysReqs(common)
})

function mainButtonClick(func) {
    const desc = descInput.value

    descInput.value = func(desc)
    createUnformattedArea(descInput, desc)
}


/**
 @typedef {{
  label: string,
  tooltip?: string,
  func?: (str: string) => string,
  text?: string,
  insertPosition?: 'start' | 'end',
  includeNewLine?: boolean,
  returnTextOnly?: boolean,
  }} ButtonProp
 */

/** @type {ButtonProp[]} */
const casingButtons = [
    {
        label: "Sentence case",
        func: toSentenceCase,
        returnTextOnly: true
    },
    {
        label: "Title Case",
        func: formatTitle,
        returnTextOnly: true
    },
]


/** @type {ButtonProp[]} */
const bbCodeButtons = [
    {
        label: "Unwrap",
        func: (str) => str,
        returnTextOnly: true,
        tooltip: "Unwraps all BBCode"
    },
    {
        label: "Header to List Item",
        func: (str) => `[*][b]${str.replaceAll(':', '')}[/b]: `,
        includeNewLine: true,
        returnTextOnly: true,
        tooltip: `Unwraps then convert to "[*][b]{text}[b]: ", removing colons and the new line`
    },
    {
        label: "To List Item",
        func: (str) => `[*]${str}`,
        tooltip: `Unwraps then convert to "[*]{text}"`
    },
    {
        label: "Bold",
        func: (str) => `[b]${str}[/b]`,
        returnTextOnly: true,
        tooltip: `Unwraps then wraps with [b]`,
    },
]


/** @type {ButtonProp[]} */
const headerButtons = [
    {
        label: "About the game",
        text: headersMap.get("aboutGame"),
        insertPosition: 'start'
    },
    {
        label: "Features",
        text: headersMap.get("features"),
    },
    {
        label: "Gameplay",
        text: "\n[align=center][b][u]Gameplay[/u][/b][/align]",
    },
    {
        label: "Story",
        text: "\n[align=center][b][u]Story[/u][/b][/align]",
    },
    {
        label: "Characters",
        text: "\n[align=center][b][u]Characters[/u][/b][/align]",
    },
]

/** @type {ButtonProp[]} */
const sysReqsButtons = [
    {
        label: "Regular",
        text: `${headersMap.get("sysReqs")}
[b]Minimum[/b]${headersMap.get("os")}${headersMap.get("processor")}${headersMap.get("memory")}${headersMap.get("graphics")}${headersMap.get("storage")}
[b]Recommended[/b]${headersMap.get("os")}${headersMap.get("processor")}${headersMap.get("memory")}${headersMap.get("graphics")}${headersMap.get("storage")}[/quote]`,
        insertPosition: 'end',
    },
    {
        label: "None",
        text: `${headersMap.get("sysReqs")}None provided[/quote]`,
        insertPosition: 'end',
    },
    {
        label: "Empty",
        text: `${headersMap.get("sysReqs")}[/quote]`,
        insertPosition: 'end',
    },
    {
        label: "Minimum",
        text: `${headersMap.get("minimumReqs")}`
    },
    {
        label: "Recommended",
        text: `${headersMap.get("recommendedReqs")}`
    },
]

/** @type {ButtonProp[]} */
const sysReqsLineButtons = [
    {
        label: "OS",
        text: `${headersMap.get("os")}`
    },
    {
        label: "Processor",
        text: `${headersMap.get("processor")}`
    },
    {
        label: "Memory",
        text: `${headersMap.get("memory")}`
    },
    {
        label: "Storage",
        text: `${headersMap.get("storage")}`
    },
    {
        label: "Graphics",
        text: `${headersMap.get("graphics")}`
    },
    {
        label: "Sound Card",
        text: `${headersMap.get("soundcard")}`
    },
    {
        label: "DirectX",
        text: `${headersMap.get("directX")}`
    },
    {
        label: "Additional Notes",
        text: `${headersMap.get("additionalnotes")}`
    },
    {
        label: "Other",
        text: `${headersMap.get("other")}`
    },
    {
        label: "Network",
        text: `${headersMap.get("network")}`
    },
    {
        label: "Drive",
        text: `${headersMap.get("drive")}`
    },
    {
        label: "Controllers",
        text: `${headersMap.get("controllers")}`
    },
]

const buttonDivs = main.querySelectorAll('.formatter-buttons-row')

addButtons({
    buttonProps: casingButtons,
    appendTo: buttonDivs[0],
    replaceTextOnly: true,
})
addButtons({
    buttonProps: bbCodeButtons,
    appendTo: buttonDivs[1],
})
addButtons({
    buttonProps: headerButtons,
    appendTo: buttonDivs[2],
    insertText: true,
    textAsTooltip: true,
})
addButtons({
    buttonProps: sysReqsButtons,
    appendTo: buttonDivs[3],
    insertText: true,
    textAsTooltip: true,
})
addButtons({
    buttonProps: sysReqsLineButtons,
    appendTo: buttonDivs[4],
    insertText: true,
    textAsTooltip: true,
})

/**
 * @param {ButtonProp[]} buttonProps
 * @param {Element} appendTo
 * @param {boolean?} replaceTextOnly
 * @param {boolean?} insertText
 * @param {boolean?} textAsTooltip
 */
function addButtons({
                        buttonProps,
                        appendTo,
                        replaceTextOnly,
                        insertText,
                        textAsTooltip
                    }) {
    for (const buttonProp of buttonProps) {
        const button = document.createElement('button')
        button.textContent = buttonProp.label
        button.type = 'button'

        button.onclick = () => {
            if (insertText) {
                const textToInsert = buttonProp.text
                const insertAtStart = buttonProp?.insertPosition === 'start'
                const insertAtEnd = buttonProp?.insertPosition === 'end'
                const currentText = descInput.value
                let newValue, newCaretPos

                if (insertAtStart || insertAtEnd) {
                    newValue = insertAtStart
                        ? textToInsert + currentText
                        : currentText + textToInsert
                    newCaretPos = insertAtStart ? textToInsert.length : newValue.length
                } else {
                    const selectionStart = descInput.selectionStart
                    const selectionEnd = descInput.selectionEnd

                    const before = currentText.substring(0, selectionStart)
                    const after = currentText.substring(selectionEnd, currentText.length)
                    newValue = before + textToInsert + after
                    newCaretPos = selectionStart + textToInsert.length
                }

                descInput.value = newValue
                descInput.selectionStart = newCaretPos
                descInput.selectionEnd = newCaretPos
            } else {
                const result = getBBCodeOrLineAtCaret(replaceTextOnly, buttonProp.includeNewLine)

                const replacement = buttonProp.func(buttonProp.returnTextOnly ? result.textOnly : result.text)
                const caretOffset = replacement.length - (result.end - result.start)
                const newCaretPos = descInput.selectionStart + caretOffset +
                    ((replaceTextOnly ? result.textOnly.length : result.text.length - result.openingTagsLength) - replacement.length)
                debugger

                descInput.value = result.replace(replacement)
                descInput.selectionStart = newCaretPos
                descInput.selectionEnd = newCaretPos
            }

            descInput.focus()
        }

        const tooltip = textAsTooltip ? buttonProp.text : buttonProp.tooltip

        if (tooltip) {
            $(button).tooltipster({
                // content: `<pre style="width: 100%;margin: 0;">${tooltip}</pre>`,
                content: tooltip.replace(/\n/g, '<br>'),
                contentAsHTML: true,
            })
        }

        appendTo.append(button)
    }
}

// button to convert align to [*][b]

/**
 * @param {boolean} textOnly
 * @param {boolean?} includeNewLine
 * @returns {{text: string, start: number, end: number, textOnly: string, openingTagsLength: number, replace: (str: string) => string}}
 */
function getBBCodeOrLineAtCaret(textOnly, includeNewLine) {
    const text = descInput.value
    const caretPos = descInput.selectionStart

    // Find the current line boundaries
    const beforeCaret = text.substring(0, caretPos)
    const afterCaret = text.substring(caretPos)

    const lastNewlineBefore = beforeCaret.lastIndexOf('\n')
    const nextNewlineAfter = afterCaret.indexOf('\n')

    const lineStart = lastNewlineBefore === -1 ? 0 : lastNewlineBefore + 1
    const lineEnd = nextNewlineAfter === -1 ? text.length : caretPos + nextNewlineAfter

    const currentLine = text.substring(lineStart, lineEnd)
    const caretPosInLine = caretPos - lineStart

    // BBCode tag pattern - matches opening and closing tags
    const tagPattern = /\[(\/?[^\]]+)]/g
    const tags = []

    // Find all tags in the current line
    for (const match of currentLine.matchAll(tagPattern)) {
        const match1 = match[1]
        if (match1 === '*' || match1 === '#') {
            continue
        }

        tags.push({
            tag: match1,
            fullTag: match[0],
            start: match.index,
            end: match.index + match[0].length,
            isClosing: match1.startsWith('/')
        })
    }

    // Build a stack to track nested tags
    const tagStack = []
    const tagPairs = []

    for (const tag of tags) {
        if (tag.isClosing) {
            // Find matching opening tag
            const tagName = tag.tag.substring(1) // Remove the '/' prefix
            for (let i = tagStack.length - 1; i >= 0; i--) {
                // Extract base tag name (before any = or space)
                const openingTagName = tagStack[i].tag.split(/[=\s]/)[0]
                if (openingTagName === tagName) {
                    tagPairs.push({
                        opening: tagStack[i],
                        closing: tag
                    })
                    tagStack.splice(i, 1)
                    break
                }
            }
        } else {
            tagStack.push(tag)
        }
    }

    // Find all tag pairs that contain the caret, sorted by nesting level
    const containingPairs = []

    for (const pair of tagPairs) {
        const contentStart = pair.opening.end
        const contentEnd = pair.closing.start

        // Check if caret is within the content of this tag pair
        if (caretPosInLine >= contentStart && caretPosInLine <= contentEnd) {
            containingPairs.push({
                ...pair,
                range: contentEnd - contentStart
            })
        }
    }

    if (containingPairs.length === 0) {
        // No BBCode found, return the whole line

        return {
            text: currentLine,
            textOnly: currentLine,
            start: lineStart,
            end: lineEnd,
            openingTagsLength: 0,
            replace: function (newText) {
                const before = text.substring(0, lineStart)
                const after = text.substring(lineEnd + (includeNewLine ? 1 : 0))
                return before + newText + after
            }
        }
    }

    // Sort by range (innermost first)
    containingPairs.sort((a, b) => a.range - b.range)

    // Find the outermost pair (contains all others)
    const outermostPair = containingPairs[containingPairs.length - 1]
    const innermostPair = containingPairs[0]

    const targetPair = textOnly ? containingPairs[0] : outermostPair

    const absoluteStart = textOnly ?
        lineStart + targetPair.opening.end :
        lineStart + outermostPair.opening.start

    const onlyHasBbCode = /^\[(?!\*]).*\[\/.*]$/.test(currentLine)
    const absoluteEnd = (textOnly ?
        lineStart + targetPair.closing.start :
        lineStart + outermostPair.closing.end) + (includeNewLine && onlyHasBbCode ? 1 : 0)

    const fullBBCode = currentLine.substring(outermostPair.opening.start, outermostPair.closing.end)

    // Check if the line contains only BBCode tags
    const textContent = currentLine.substring(innermostPair.opening.end, innermostPair.closing.start)

    return {
        text: fullBBCode,
        textOnly: textContent,
        start: absoluteStart,
        end: absoluteEnd,
        openingTagsLength: containingPairs.reduce((length, pair) => length + pair.opening.fullTag.length, 0),
        replace: function (newText) {
            const before = text.substring(0, absoluteStart)
            const after = text.substring(absoluteEnd)
            return before + newText + after
        }
    }
}