Easily edit and search for aliases
// ==UserScript==
// @name GGn Alias Helper
// @namespace gazellegames.net
// @version 1.2.5
// @description Easily edit and search for aliases
// @author Wealth, ingts
// @match https://gazellegames.net/torrents.php?action=editgroup&groupid=*
// @grant GM.xmlHttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_addValueChangeListener
// @grant GM_removeValueChangeListener
// @grant GM_openInTab
// @grant GM_addStyle
// @connect *
// @connect api.vndb.org
// @connect mobygames.com
// @connect id.twitch.tv
// @connect api.igdb.com
// ==/UserScript==
/** @type {HTMLInputElement} */
const aliasInput = document.querySelector('input[name=aliases]')
// language=CSS
GM_addStyle(`
#alias-helper {
padding: 5px 0;
display: flex;
gap: 5px;
flex-direction: column;
.chips {
display: flex;
flex-wrap: wrap;
width: 70% !important;
.chip {
background: #444;
display: flex;
align-items: baseline;
gap: 5px;
padding: 3px 6px;
margin: 2px;
border-radius: 4px;
cursor: text;
justify-content: space-between;
width: fit-content !important;
height: fit-content;
}
}
.bottom {
display: flex;
justify-content: space-between;
gap: 5px;
align-items: center;
.statuses {
display: flex;
gap: 8px;
width: fit-content !important;
}
a[id^=ah-status] {
max-width: 20em;
}
}
.search-results {
width: 80%;
display: grid;
grid-template-columns: repeat(5, 1fr);
row-gap: 2px;
column-gap: 2px;
margin-bottom: 10px;
label.result {
display: flex;
gap: 5px;
align-items: center;
}
span.source {
color: #c9aadf;
font-size: 0.8em;
}
}
button {
min-width: fit-content;
height: 30px;
}
label {
display: flex;
align-items: center;
gap: 2px;
}
}
`)
if (location.hostname === "steamdb.info"
&& GM_getValue('checking_steamdb', false)
&& document.getElementById('info')
) {
const aliases = []
const localisedNames = document.evaluate("//td[contains(text(),'name_localized')]/following-sibling::td[1]/table/tbody/tr/td[2]", document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
for (let i = 0; i < localisedNames.snapshotLength; i++) {
aliases.push(localisedNames.snapshotItem(i).textContent)
}
const aliasesTd = document.evaluate("//td[contains(text(),'Aliases')]/following-sibling::td[1]", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
if (aliasesTd) {
aliases.push(...aliasesTd.textContent.split(','))
}
GM_setValue('steamdb_aliases', aliases)
}
const settings = loadSettings({
igdb_client_id: '',
igdb_client_secret: '',
steam_source: 'steamdb'
})
document.getElementById('alias_hint').style.display = 'none' // hide "You can separate multiple aliases using a comma between each name."
const isGameGroup = document.querySelector('input[name=categoryid]').value === '1'
aliasInput.parentElement.insertAdjacentHTML('afterend',
// language=HTML
`
<section id="alias-helper">
<div style="display:flex;gap: 10px;align-items: start;">
<div class="chips"></div>
<textarea placeholder="Press Enter to add aliases, Ctrl+Enter for new line"
style="width: 30%;margin: 0;"
rows="1"></textarea>
</div>
${isGameGroup ? '<div class="search-results"></div>' : ''}
<div class="bottom">
<div style="display: flex;gap: 10px;align-items: center;">
${isGameGroup ? '<button type="button">Search aliases</button>' : ''}
<label>
<input type="checkbox">
Swap
</label>
</div>
<div class="statuses"></div>
</div>
</section>`)
const main = document.getElementById('alias-helper')
const wikiForm = main.closest('form')
const chips = main.querySelector('.chips')
const renameInput = document.querySelector('input[name=name]')
const textarea = main.querySelector('textarea')
const bottom = main.querySelector('.bottom')
const statuses = bottom.querySelector('.statuses')
const searchResults = main.querySelector('.search-results')
const controls = bottom.querySelector('div')
const searchButton = controls.querySelector('button')
/** @type {HTMLInputElement} */
const swapCheckbox = isGameGroup ? searchButton.nextElementSibling.firstElementChild : controls.firstElementChild
textarea.addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault()
if (e.ctrlKey) {
textarea.value += '\n'
textarea.rows = textarea.value.split('\n').length
return
}
for (const line of textarea.value.split('\n')) {
if (!line.trim()) continue
currentAliases.add(line)
}
updateInputAndChips()
textarea.value = ''
}
})
textarea.addEventListener('input', () => {
textarea.rows = textarea.value.split('\n').length
})
swapCheckbox.addEventListener('change', () => {
for (const child of chips.children) {
/** @type {HTMLSpanElement} */
const icon = child.children?.[1]
if (!icon) continue // skip Add chip
if (swapCheckbox.checked) {
icon.textContent = '🗘'
icon.style.fontSize = '1.25em'
icon.style.color = '#5fdbb4'
} else {
icon.textContent = '❌'
icon.style.removeProperty('font-size')
icon.style.removeProperty('color')
}
}
})
$(swapCheckbox.parentElement).tooltipster({
content: "Swaps the alias with the current group name",
})
aliasInput.addEventListener('blur', () => {
currentAliases = splitAliases()
updateInputAndChips()
})
const originalValue = aliasInput.value
let currentAliases = splitAliases()
updateInputAndChips()
function updateInputAndChips() {
chips.innerHTML = ''
const aliasesArray = [...currentAliases]
aliasInput.value = aliasesArray.length > 1
? aliasesArray.join(aliasesArray.some(a => a.includes(',')) ? '||' : ', ')
: (aliasesArray[0] || '')
if (aliasInput.value === originalValue) {
aliasInput.style.removeProperty('outline')
} else {
aliasInput.style.outline = '#96ffb3 1px solid'
}
currentAliases = splitAliases()
for (const alias of currentAliases) {
const chip = createChip()
let nameSpan = createSpan(alias)
chip.append(nameSpan)
const removeBtn = document.createElement('span')
chip.append(removeBtn)
removeBtn.style.cursor = 'pointer'
removeBtn.textContent = '❌'
chip.onclick = e => {
if (e.target === removeBtn) {
if (swapCheckbox.checked) {
aliasesArray[aliasesArray.indexOf(alias)] = renameInput.value
renameInput.value = alias
currentAliases = new Set(aliasesArray)
updateInputAndChips()
swapCheckbox.checked = false
const submit = wikiForm.querySelector('input[type=submit]')
submit.onclick = e => {
e.preventDefault()
if (!wikiForm.reportValidity()) return
submit.disabled = true
submit.value = 'Submitting'
const formDatas = [new FormData(wikiForm), new FormData(renameInput.closest('form'))]
const fetches = formDatas.map(fd => {
fetch('torrents.php', {
method: 'POST',
body: fd,
}).then(r => {
if (!(r.ok && r.redirected)) {
console.error(r)
submit.disabled = false
submit.value = 'Submit'
throw Error()
}
})
})
Promise.all(fetches).then(() => {
setTimeout(() => {
location.href = `https://gazellegames.net/torrents.php?id=${/\d+/.exec(location.href)[0]}`
}, 1000)
})
}
} else {
currentAliases.delete(alias)
updateInputAndChips()
}
return
}
const input = spanToInput(nameSpan)
nameSpan = input
input.value = alias
let cancel = false
input.onkeydown = e => {
switch (e.key) {
case 'Enter':
e.preventDefault()
edit()
break
case 'Escape':
e.preventDefault()
nameSpan = inputToSpan(nameSpan, alias, input)
cancel = true
break
}
}
input.onblur = () => requestAnimationFrame(() => {
if (cancel) {
cancel = false
return
}
edit()
})
function edit() {
if (alias === input.value) {
nameSpan = inputToSpan(nameSpan, alias, input)
return
}
if (!input.value.trim()) {
aliasesArray.splice(aliasesArray.indexOf(alias), 1)
currentAliases = new Set(aliasesArray)
updateInputAndChips()
return
}
aliasesArray[aliasesArray.indexOf(alias)] = input.value
currentAliases = new Set(aliasesArray)
updateInputAndChips()
}
}
}
const addAliasChip = createChip()
addAliasChip.style.cursor = 'pointer'
addAliasChip.style.backgroundColor = 'rgb(56 120 93)'
let addSpan = createSpan('➕')
addSpan.style.cursor = 'pointer'
addSpan.style.filter = 'grayscale(1)'
addAliasChip.append(addSpan)
let cancel = false
addAliasChip.onclick = () => {
const input = spanToInput(addSpan)
addSpan = input
input.onkeydown = e => {
switch (e.key) {
case 'Enter':
e.preventDefault()
currentAliases.add(input.value)
updateInputAndChips()
break
case 'Escape':
e.preventDefault()
addSpan = inputToSpan(addSpan, '➕', input)
cancel = true
break
}
}
input.onblur = () => requestAnimationFrame(() => {
if (cancel) {
cancel = false
return
}
if (!input.value.trim()) {
addSpan = inputToSpan(addSpan, '➕', input)
return
}
currentAliases.add(input.value)
updateInputAndChips()
})
}
function createSpan(text) {
const span = document.createElement('span')
span.textContent = text
return span
}
function createChip() {
const div = document.createElement('div')
chips.append(div)
div.className = 'chip'
return div
}
function spanToInput(span) {
const input = document.createElement('input')
span.replaceWith(input)
input.focus()
return input
}
function inputToSpan(span, text, input) {
span = createSpan(text)
input.replaceWith(span)
return span
}
}
if (searchButton) {
searchButton.onclick = async () => {
const platform = document.getElementById('user_script_data').dataset?.platform
const loading = document.createElement('h3')
loading.textContent = 'Loading...'
searchResults.append(loading)
const dlsiteCodeRegex = /[RBV][JGE]\d{4,8}/
const groupName = renameInput.value
const sitesMap = new Map([
['VNDB', document.getElementById('vndburi')?.value],
['Steam', settings.steam_source !== null && document.getElementById('steamuri')?.value],
['MobyGames', document.getElementById('mobygamesuri')?.value],
])
const promises = []
/** @type {{name: string, sources: Set<string>, url: string=}[]} */
const found = []
let none = true
for (const [label, url] of sitesMap.entries()) {
if (url) {
statuses.insertAdjacentHTML('beforeend', `<a id="ah-status-${label}" target="_blank" href=${url}>${label}</a>`)
none = false
}
}
const dlsiteCode = dlsiteCodeRegex
.exec(document.getElementById('gameswebsiteuri')?.value ?? '')?.[0]
if (dlsiteCode) addToFound(dlsiteCode, 'Web Link')
if (none && !settings.igdb_client_secret && !dlsiteCode) {
loading.textContent = 'No supported sites found'
loading.style.color = 'red'
return
}
if (sitesMap.get('VNDB')) {
const vnId = /v\d+/.exec(sitesMap.get('VNDB'))[0]
processUrl('VNDB', res => {
const {title, alttitle, titles, aliases} = res.response.results[0]
addToFound([
title,
alttitle,
...titles.flatMap(t => [t.latin, t.title]),
...aliases
], 'VNDB')
}, 'https://api.vndb.org/kana/vn', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
data: JSON.stringify({
"filters": ["id", "=", vnId],
"fields": "title, alttitle, titles{title, latin}, aliases",
"results": 1
}),
responseType: "json"
})
processUrl('VNDB', res => {
for (const {extlinks, title} of res.response.results) {
for (const extlink of extlinks) {
// don't use .test in case the Dlsite link is in the website field instead of External links
const dlsiteExec = dlsiteCodeRegex.exec(extlink.id)
if (dlsiteExec)
addToFound(dlsiteExec[0], 'VNDB', extlink.url)
if (extlink.name === 'steam')
addToFound(title, 'VNDB')
}
}
}, 'https://api.vndb.org/kana/release', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
data: JSON.stringify({
"filters": ["and", ["vn", "=", ["id", "=", vnId]], ["official", "=", 1], ["platform", "=", ggnToVndbPlatform.get(platform)]],
"fields": "extlinks{id, name, url}, title",
"results": 100
}),
responseType: "json"
})
}
if (sitesMap.get('Steam') && settings.steam_source !== null) {
const appid = /\d+/.exec(sitesMap.get('Steam'))[0]
const statusEl = document.getElementById(`ah-status-Steam`)
if (settings.steam_source === 'steamdb') {
const url = `https://steamdb.info/app/${appid}/info`
statusEl.href = `https://steamdb.info/app/${url}`
promises.push(new Promise(resolve => {
GM_deleteValue('steamdb_aliases')
GM_setValue('checking_steamdb', 1)
const tab = GM_openInTab(url)
const listener = GM_addValueChangeListener('steamdb_aliases', (key, oldValue, newValue) => {
GM_removeValueChangeListener(listener)
tab.close()
GM_deleteValue('checking_steamdb')
resolve(1)
addToFound(newValue, 'Steam')
})
}))
} else {
const url = settings.steam_source + appid
statusEl.href = url
processUrl('Steam', res => addToFound(res.response.aliases, 'Steam'), url, {
responseType: "json"
})
}
}
if (sitesMap.get('MobyGames')) {
processUrl('MobyGames', res => {
const doc = res.responseXML
addToFound([
...Array.from(doc.querySelectorAll('#main div.mb div span u')).map(el => el.textContent), // AKAs
...Array.from(doc.querySelectorAll('section > ul.text-sm > li')).map(el => el.textContent.replace(/(.*) - .*/, '$1')) // spellings
], 'MobyGames')
}, sitesMap.get('MobyGames'))
}
if (settings.igdb_client_secret) {
let token = GM_getValue('igdb_token')
if (!token || Date.now() >= token.expiry)
token = await fetchIgdbToken()
function standardise(str) {
return str.replace(/[ :?!.-]/g, '').toLowerCase()
}
igdbRequest('games', `search "${groupName}"; fields name,alternative_names,game_localizations,url,created_at; limit 30;`, res => {
let games = res.response
statuses.insertAdjacentHTML('beforeend', `<a id="ah-status-IGDB" target="_blank">IGDB</a>`)
const status = document.getElementById('ah-status-IGDB')
if (games.length === 0) {
status.style.color = 'gray'
return
}
const stdGroupName = standardise(groupName)
const {
name,
alternative_names,
game_localizations,
url,
created_at
} = games.find(g => standardise(g.name) === stdGroupName) ?? games[0]
status.textContent += ` (${name}, ${new Date(created_at * 1000).getFullYear()})`
status.href = url
if (alternative_names) {
igdbRequest('alternative_names', `where id = (${alternative_names.join(",")}); fields name;`,
res => addToFound(res.response.map(a => a.name).filter(a => !a.endsWith('.exe')), 'IGDB'))
}
if (game_localizations) {
igdbRequest('game_localizations', `where id = (${game_localizations.join(",")}); fields name;`,
res => addToFound(res.response.map(a => a.name), 'IGDB'))
}
})
function igdbRequest(endpoint, query, func) {
processUrl("IGDB", res => func(res), "https://api.igdb.com/v4/" + endpoint, {
method: "POST",
data: query,
headers: {
"Client-ID": settings.igdb_client_id,
"Authorization": `Bearer ${token.access_token}`,
"Accept": "application/json"
},
responseType: "json",
})
}
}
await Promise.allSettled(promises)
await Promise.allSettled(promises)
if (found.length === 0) {
loading.textContent = 'No results found'
searchButton.disabled = true
return
}
loading.remove()
for (const obj of found) {
const exists = currentAliases.has(obj.name)
const isGroupName = obj.name.toLowerCase() === groupName.toLowerCase()
const aliasColor = 'color: ' + (exists ? '#999c9f' : isGroupName ? '#E68A57' : '')
searchResults.insertAdjacentHTML('beforeend', `
<div>
<label class="result">
<input type="checkbox" ${exists ? 'style="visibility: hidden" disabled' : isGroupName ? '' : 'checked'}>
<div style="display: flex;flex-direction: column;">
${obj.url ? `<a href=${obj.url} target="_blank" style="${aliasColor}">${obj.name}</a>` : `<span style="${aliasColor}">${obj.name}</span>`}
<span>${[...obj.sources].map(src => `<span class="source">${src}</span>`).join('<span style="color: #56c7ae">/</span>')}</span>
</div>
</label>
</div>`)
}
swapCheckbox.parentElement.insertAdjacentHTML('beforebegin',
`<button type="button">Uncheck all</button>
<label><input type="checkbox"> Remove the group name from aliases when adding</label>`)
const uncheckAllBtn = searchButton.nextElementSibling
uncheckAllBtn.onclick = () => searchResults.querySelectorAll('input').forEach(el => el.checked = false)
/** @type {HTMLInputElement} */
const removeGroupName = uncheckAllBtn.nextElementSibling.firstElementChild
const nameRegexp = new RegExp(`${RegExp.escape(groupName)}`, 'i')
searchButton.textContent = 'Add selected'
searchButton.onclick = () => {
for (const label of searchResults.querySelectorAll('label')) {
const [checkbox, div] = label.children
if (!checkbox.checked) continue
const span = div.firstElementChild
currentAliases.add(removeGroupName.checked
? span.textContent.replace(nameRegexp, '') // same as in Steam Uploady
.trim()
.replace(/[()]|^[-~\/]\s*|\s*[-~\/]$/g, '')
: span.textContent)
}
updateInputAndChips()
}
function addToFound(obj, source, url) {
if (Array.isArray(obj)) {
for (let alias of obj) {
if (!alias) continue
alias = alias.trim()
const existing = found.find(a => a.name === alias)
if (existing) {
existing.sources.add(source)
continue
}
found.push({name: alias, sources: new Set([source]), url: url})
}
return
}
obj = obj.trim()
const existing = found.find(a => a.name === obj)
if (existing) existing.sources.add(source)
else found.push({name: obj, sources: new Set([source]), url: url})
}
function fetchIgdbToken() {
console.log("Fetching IGDB token")
return processUrl("IGDB", res => {
const token = res.response
GM_setValue('igdb_token', {
access_token: token.access_token,
expiry: Date.now() + token.expires_in * 1000
})
return token
}, "https://id.twitch.tv/oauth2/token", {
method: "POST",
data: `client_id=${settings.igdb_client_id}&client_secret=${settings.igdb_client_secret}&grant_type=client_credentials`,
headers: {"Content-Type": "application/x-www-form-urlencoded"},
responseType: "json"
})
}
function processUrl(sitename, func, url, options = {}) {
const promise = GM.xmlHttpRequest({
url: url,
timeout: 8000,
...options
})
.then(res => {
if (res.status !== 200) {
console.error(res)
throw Error()
}
return func(res, sitename)
})
.catch(e => {
setErrorStatus(sitename, e?.statusText)
})
promises.push(promise)
return promise
}
}
}
function setErrorStatus(sitename, msg) {
document.getElementById(`ah-status-${sitename}`).style.color = 'red'
if (msg) console.error(`${sitename} error: ${msg}`)
}
function splitAliases() {
const value = aliasInput.value
if (!value) return new Set()
const replaced = value.replace(value.includes('||')
? /\s*\|\|\s*/g
: /\s*,\s*/g, '‡')
return new Set(replaced.split('‡'))
}
function loadSettings(defaults) {
for (const [key, value] of Object.entries(defaults)) {
let gmValue = GM_getValue(key)
if (typeof gmValue === 'undefined') {
GM_setValue(key, value)
continue
}
defaults[key] = gmValue
}
return defaults
}
// noinspection DuplicatedCode
const ggnToVndbPlatform = new Map([
["Windows", "win"],
["Linux", "lin"],
["Mac", "mac"],
["3DO", "tdo"],
["iOS", "ios"],
["Android", "and"],
["DOS", "dos"],
["Dreamcast", "drc"],
["NES", "nes"],
["SNES", "sfc"],
["Game Boy Advance", "gba"],
["Game Boy Color", "gbc"],
["MSX", "msx"],
["Nintendo DS", "nds"],
["Switch", "swi"],
["Wii", "wii"],
["Wii U", "wiu"],
["Nintendo 3DS", "n3d"],
["NEC PC-98", "p98"],
["NEC TurboGrafx-16", "pce"],
["NEC PC-FX", "pcf"],
["PlayStation Portable", "psp"],
["PlayStation 1", "ps1"],
["PlayStation 2", "ps2"],
["PlayStation 3", "ps3"],
["PlayStation 4", "ps4"],
["PlayStation 5", "ps5"],
["PlayStation Vita", "psv"],
["Mega Drive", "smd"],
["Saturn", "sat"],
["Sharp X1", "x1s"],
["Sharp X68000", "x68"],
["Xbox", "xb1"],
["Xbox 360", "xb3"],
])