GGn Alias Helper

Easily edit and search for aliases

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==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"],
])