// ==UserScript==
// @name GGn Game Description Formatter
// @namespace none
// @version 1.5.1.003
// @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/1656390/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;
}
}
`)
/** @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)
}
/**
* @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'
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,
maxWidth: 450,
})
}
appendTo.append(button)
button.onclick = () => {
const selectionStart = descInput.selectionStart
const selectionEnd = descInput.selectionEnd
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 before = currentText.substring(0, selectionStart)
const after = currentText.substring(selectionEnd)
newValue = before + textToInsert + after
newCaretPos = selectionStart + textToInsert.length
}
descInput.value = newValue
descInput.selectionStart = newCaretPos
descInput.selectionEnd = newCaretPos
} else {
if (buttonProp.selectionFunc && selectionStart !== descInput.selectionEnd) { // use selection
const selectedText = descInput.value.substring(selectionStart, descInput.selectionEnd)
const currentText = descInput.value
const before = currentText.substring(0, selectionStart)
const after = currentText.substring(descInput.selectionEnd)
const replacement = buttonProp.selectionFunc(selectedText)
descInput.value = before + replacement + after
descInput.selectionStart = selectionStart
descInput.selectionEnd = selectionStart
} else { // use caret
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 = 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()
}
}
}
/**
@typedef {{
label: string,
tooltip?: string,
func?: (str: string) => string,
selectionFunc?: (str: string) => string,
text?: string,
insertPosition?: 'start' | 'end',
includeNewLine?: boolean,
returnTextOnly?: boolean,
}} ButtonProp
*/
/** @type {ButtonProp[]} */
const casingButtons = [
{
label: "Sentence case",
func: toSentenceCase,
selectionFunc: (str) => caseConvertSelection(str, toSentenceCase),
returnTextOnly: true,
tooltip: "Sentence cases the selection. If the selection contains [*][b]text[/b], only the bold text will be converted",
},
{
label: "Title Case",
func: formatTitle,
selectionFunc: (str) => caseConvertSelection(str, formatTitle),
returnTextOnly: true,
tooltip: "Title cases the selection. If the selection contains [*][b]text[/b], only the bold text will be converted",
},
{
label: "Lowercase",
func: (str) => str.toLowerCase(),
selectionFunc: (str) => str.toLowerCase(),
returnTextOnly: true,
},
]
function caseConvertSelection(str, convertFunc) {
const listItemsReplaced = str.replace(/\[\*]\[b](.*?)\[\/b]/g, (match, p1) => {
return `[*][b]${convertFunc(p1)}[/b]`
})
return str !== listItemsReplaced ? listItemsReplaced : convertFunc(str)
}
function headerToListItem(str) {
str = toSentenceCase(removeBbcode(str))
return str.replace(/(.+?)([:.?!])?$/, (match, p1, p2) => `[*][b]${str.replace(p2, '')}[/b]${p2 ? p2 : ':'} `)
}
function joinLinesToPreviousListItem(str) {
const lines = str.split('\n')
const result = []
for (let line of lines) {
line = line.trim()
if (!line) continue
if (line.startsWith('[*]')) {
result.push(line)
} else {
if (result.length > 0) {
result[result.length - 1] += (/[.?!]$/.test(line) ? ' ' : '. ') + line
}
}
}
return result.join('\n')
}
/** @type {ButtonProp[]} */
const bbCodeButtons = [
{
func: (str) => str,
selectionFunc: removeBbcode,
label: "Unwrap",
returnTextOnly: true,
tooltip: "Unwraps all BBCode at caret or removes tags in the selection"
},
{
func: (str) => headerToListItem(str),
selectionFunc: (str) => {
let s = str.replace(/^\[\*]\[b].*?\[\/b]/gm, '')
.replace(/\[align=center](.*?)\[\/align]\n/g, (_, p1) => headerToListItem(p1))
.replace(/\[b](.*?)\[\/b]\n/g, (_, p1) => headerToListItem(p1))
.replace(/(^.+[^.?!\s])\s*\n/g, (match, p1) => match.split(' ') > 10 ? match : headerToListItem(p1))
const fixedMulti = fixMultiLinesInLists(s)
return joinLinesToPreviousListItem(fixedMulti)
},
label: "Header to List Item",
includeNewLine: true,
returnTextOnly: true,
tooltip: `Unwraps then convert to "[*][b]{text}[b]: ". Also sentence cases, removes colons and the new line. With selection, lines like "[align=center]...[/align]" and "[b]...[/b]" will be converted first, then lines without terminal punctuation and shorter than 10 words. Also only with selection, non list item lines are joined to the previous list item. Empty lines are removed`
},
{
func: (str) => `[*]${str}`,
selectionFunc: (str) => str.split('\n').filter(line => line.trim() !== '')
.map(line => line.startsWith('[*]') ? line : '[*]' + line).join('\n'),
label: "To List Item",
tooltip: `Unwraps then convert to "[*]{text}". Every line in the selection will be converted and empty lines are removed`
},
{
func: (str) => `[b]${str}[/b]`,
label: "Bold",
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,
})
// 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)
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
}
}
}