GGn Alias Helper

Easily edit and search for aliases

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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