// ==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
}
}
}