Greasy Fork

Greasy Fork is available in English.

GGn Tag selector

Enhanced Tag selector for GGn

当前为 2025-04-19 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name			 GGn Tag selector
// @namespace	     ggntagselector
// @version			 1.1.1
// @match			 *://gazellegames.net/upload.php*
// @match			 *://gazellegames.net/torrents.php?*action=advanced*
// @match			 *://gazellegames.net/torrents.php*id=*
// @match			 *://gazellegames.net/requests.php*
// @match			 *://gazellegames.net/user.php*action=edit*
// @grant			 GM.setValue
// @grant			 GM.getValue
// @grant			 GM_setValue
// @grant			 GM_getValue
// @grant			 GM_addStyle
// @license			 MIT
// @author			 tweembp, ingts
// @description		 Enhanced Tag selector for GGn
// ==/UserScript==
// noinspection CssUnresolvedCustomProperty,CssUnusedSymbol

const locationhref = location.href
const isUploadPage = locationhref.includes('upload.php'),
    isGroupPage = locationhref.includes('torrents.php?id='),
    isSearchPage = locationhref.includes('action=advanced'),
    isRequestPage = locationhref.includes('requests.php') && !locationhref.includes('action=new'),
    isCreateRequestPage = locationhref.includes('action=new'),
    isUserPage = locationhref.includes('user')

const SEPERATOR = '|'
const TAGSEPERATOR = ', '
const defaultHotkeys = {
    'favorite': [
        'shift + digit1',
        'shift + digit2',
        'shift + digit3',
        'shift + digit4',
        'shift + digit5',
        'shift + digit6',
        'shift + digit7',
        'shift + digit8',
        'shift + digit9',
    ],
    'preset': [
        'alt + digit1',
        'alt + digit2',
        'alt + digit3',
        'alt + digit4',
        'alt + digit5',
        'alt + digit6',
        'alt + digit7',
        'alt + digit8',
        'alt + digit9',
    ],
}
const defaulthotkeyPrefixes = {
    'show_indices': 'shift'
}
const modifiers = ["shift", "alt", "ctrl", "cmd"]
const categoryDict = {
    "genre": [
        "4x",
        "action",
        "adventure",
        "aerial.combat",
        "agriculture",
        "arcade",
        "auto.battler",
        "beat.em.up",
        "board.game",
        "building",
        "bullet.hell",
        "card.game",
        "casual",
        "childrens",
        "city.building",
        "clicker",
        "d10.system",
        "d20.system",
        "driving",
        "dungeon.crawler",
        "educational",
        "exploration",
        "fighting",
        "fitness",
        "game.show",
        "grand.strategy",
        "hack.and.slash",
        "hidden.object",
        "horror",
        "hunting",
        "interactive.fiction",
        "jigsaw",
        "karaoke",
        "management",
        "match.3",
        "mini.game",
        "music",
        "open.world",
        "parody",
        "party",
        "pinball",
        "platform",
        "point.and.click",
        "puzzle",
        "quiz",
        "rhythm",
        "roguelike",
        "role.playing.game",
        "runner",
        "sandbox",
        "shoot.em.up",
        "shooter",
        "first.person.shooter",
        "third.person.shooter",
        "simulation",
        "solitaire",
        "space",
        "stealth",
        "strategy",
        "real.time.strategy",
        "turn.based.strategy",
        "stunts",
        "survival",
        "tabletop",
        "tactics",
        "text.adventure",
        "time.management",
        "tower.defense",
        "trivia",
        "typing",
        "vehicular.combat",
        "visual.novel",
        "wargame",
        "word.game",
        "word.construction",
    ],
    "theme": [
        "adult",
        "romance",
        "comedy",
        "crime",
        "drama",
        "fantasy",
        "historical",
        "mystery",
        "thriller",
        "science.fiction",
    ],
    "sports": [
        "american.football",
        "baseball",
        "basketball",
        "billiards",
        "blackjack",
        "bowling",
        "boxing",
        "chess",
        "cricket",
        "cycling",
        "extreme.sports",
        "fishing",
        "go",
        "golf",
        "hockey",
        "mahjong",
        "pachinko",
        "pinball",
        "poker",
        "racing",
        "rugby",
        "skateboarding",
        "slots",
        "snowboarding",
        "soccer",
        "sports",
        "tennis",
        "wrestling",
    ],
    "simulation": [
        "business.simulation",
        "construction.simulation",
        "dating.simulation",
        "flight.simulation",
        "life.simulation",
        "space.simulation",
        "vehicle.simulation",
        "walking.simulation",
    ],
    "ost": [
        "acappella",
        "acid.house",
        "acid.jazz",
        "acid.techno",
        "acoustic",
        "afrobeat",
        "alternative",
        "ambient",
        "arrangement",
        "ballad",
        "black.metal",
        "breakbeat",
        "breakcore",
        "chill.out",
        "chillwave",
        "chipbreak",
        "chiptune",
        "choral",
        "citypop",
        "classical",
        "country",
        "dance",
        "dark.ambient",
        "dark.electro",
        "dark.synth",
        "dark.wave",
        "downtempo",
        "dream.pop",
        "drum.and.bass",
        "dubstep",
        "electro",
        "electronic",
        "electronic.rock",
        "epic.metal",
        "euro.house",
        "experimental",
        "folk",
        "funk",
        "happy.hardcore",
        "hardcore",
        "heavy.metal",
        "hip.hop",
        "horrorcore",
        "house",
        "hymn",
        "indie.pop",
        "indie.rock",
        "industrial",
        "instrumental",
        "jazz",
        "lo.fi",
        "modern.classical",
        "new.age",
        "opera",
        "orchestral",
        "phonk",
        "piano",
        "pop",
        "rhythm.and.blues",
        "rock",
        "smooth.jazz",
        "sound.effects",
        "symphonic",
        "synth",
        "synth.pop",
        "synthwave",
        "traditional",
        "techno",
        "trance",
        "vaporwave",
        "violin",
        "vocal",
    ],
    "books": [
        "art.book",
        "collection",
        "comic.book",
        "fiction",
        "game.design",
        "game.programming",
        "psychology",
        "social.science",
        "gamebook",
        "graphic.novel",
        "guide",
        "magazine",
        "non.fiction",
        "novelization",
        "programming",
        "business",
        "reference",
        "study"
    ],
    "applications": [
        "apps.windows",
        "apps.linux",
        "apps.mac",
        "apps.android",
        "utility",
        "development",
    ],
}
// relevant keys for each upload category
const categoryKeys = {
    'Games': ["genre", "theme", "sports", "simulation"],
    'E-Books': ['books'],
    'Applications': ['applications'],
    'OST': ['ost']
}
const specialTags = ['pack', 'collection']

// common functions
function titlecase(s) {
    let out = s.split('.').map((e) => {
        if (!["and", "em"].includes(e)) {
            return e[0].toUpperCase() + e.slice(1)
        } else {
            return e
        }
    }).join(' ')
    return out[0].toUpperCase() + out.slice(1)
}

function normalise_combo_string(s) {
    return s.trim().split('+').map((c) => c.trim().toLowerCase()).join(' + ')
}

function observe_element(element, property, callback, delay = 0) {
    let elementPrototype = Object.getPrototypeOf(element)
    if (elementPrototype.hasOwnProperty(property)) {
        let descriptor = Object.getOwnPropertyDescriptor(elementPrototype, property)
        Object.defineProperty(element, property, {
            get: function () {
                return descriptor.get.apply(this, arguments)
            },
            set: function () {
                let oldValue = this[property]
                descriptor.set.apply(this, arguments)
                let newValue = this[property]
                if (typeof callback == "function") {
                    setTimeout(callback.bind(this, oldValue, newValue), delay)
                }
                return newValue
            },
            configurable: true
        })
    }
}


if (!isUserPage) {
    if (isSearchPage) {
        const taglist = document.getElementById('taglist')
        taglist.style.display = 'none'
        taglist.nextElementSibling.style.display = 'none'
    }
    if (isGroupPage) {
        document.getElementById('tags_add_note').remove() // "To add multiple tags separate by comma" text
    }
    // load settings
    let currentFavoritesDict = (GM_getValue('gts_favorites')) || {}
    let currentPresetsDict = (GM_getValue('gts_presets')) || {}
    let hotkeys = (GM_getValue('gts_hotkeys')) || defaultHotkeys
    let hotkeyPrefixes = (GM_getValue('gts_hotkey_prefixes')) || defaulthotkeyPrefixes

    let searchStringDict = {}
    for (const tags of Object.values(categoryDict)) {
        // map from tag => search title, string
        for (const tag of tags) {
            const title = titlecase(tag)
            searchStringDict[tag] = `${title.toLowerCase()}${SEPERATOR}${tag}`
        }
    }

    let foundTags = -1
    let windowEvents = []

    // language=CSS
    GM_addStyle(`
        .gts-selector *::-webkit-scrollbar {
            width: 3px;
        }

        .gts-selector *::-webkit-scrollbar-track {
            background: transparent;
        }

        .gts-selector *::-webkit-scrollbar-thumb {
            background-color: rgba(155, 155, 155, 0.5);
            border-radius: 20px;
            border: transparent;
        }

        .gts-unlisted-tag {
            color: coral !important;
        }

        .gts-remove-unlisted {
            margin-top: 15px;
        }

        #genre_tags {
            display: none !important
        }

        .gts-add-preset {
            display: none;
        }

        /*#torrents .gts-add-preset {*/
        /*    float: right;*/
        /*}*/

        .gts-selector {
            display: none;
            position: absolute;
            background-color: rgb(27, 48, 63);
            box-sizing: border-box;
            padding: .5em 1em 1em 1em;
            border: 3px solid var(--rowb);
            box-shadow: -3px 3px 5px var(--black);
            z-index: 99999;
            grid-template-columns: auto fit-content(180px) fit-content(180px);
            column-gap: 1em;
            min-width: min-content !important;
            max-width: 1000px !important;
            font-size: 13px;
        }

        .gts-selector h1 {
            margin: 0;
            font-weight: normal;
            padding-bottom: 0;
        }

        .gts-tag {
            height: fit-content;
            font-family: inherit;
            font-size: inherit;
            opacity: 1 !important;
            background: none!important;
            border: none;
            padding: 0!important;
            color: var(--lightBlue);
            text-decoration: none;
            cursor: pointer;
            text-align: start;
        }

        .gts-sidearea {
            min-width: 150px;
            box-sizing: border-box;
            border-left: 2px solid var(--grey);
            padding-left: 1em;
        }

        .gts-selector .gts-sidearea h1 {
            font-size: 1.2em;
            margin-top: 1em;
            margin-bottom: 0.25em;
        }

        .gts-sidearea h1:nth-child(2) {
            margin-top: 0;
        }

        .gts-current-tags-inner {
            font-size: 0.9em;
            margin-top: 1em;
            overflow-y: auto;
            max-height: 320px;
        }

        .gts-searchbar {
            display: grid;
            align-items: center;
            grid-template-columns: 3fr auto 1fr;
            column-gap: 1em;
            margin-bottom: 1em;
        }

        .gts-categoryarea {
            display: grid;
            grid-template-columns: 1fr 1fr;
            column-gap: 10px;

            .gts-right {
                display: grid;
                grid-template-columns: 1fr 1fr;
                height: 100%;
                column-gap: 1em;
                width: max-content;
            }

            .gts-left {
                height: 100%;
            }

            .gts-category-inner {
                display: grid;
                grid-template-columns: 1fr;
                column-gap: 1em;
                overflow-y: auto;
                max-height: 145px;
            }
        }

        .gts-category .gts-category-inner, #gts-favoritearea, #gts-presetarea {
            font-size: .9em;
            margin-top: 0.5em;
            width: max-content !important;
        }

        #gts-presetarea {
            max-height: 140px;
            overflow-y: auto;
            width: unset !important;
        }

        #gts-favoritearea {
            max-height: 140px;
            overflow-y: auto;
            grid-template-columns: 1fr 1fr;
            display: grid;
            column-gap: .5em;
        }

        .gts-category h1 {
            font-size: 1.1em;
        }

        .gts-category-genre .gts-category-inner {
            grid-template-columns: auto auto;
            max-height: 320px;
        }

        .gts-tag-idx {
            color: yellow;
            font-weight: bold;
            margin-left: 0.25em;
        }

        .hide-idx .gts-tag-idx {
            display: none;
        }

        #gts-selector a {
            font-size: inherit !important;
        }

        .gts-tag-link-wrapper {
            width: fit-content !important;
            max-width: 100px;
            scroll-snap-align: start;
        }

        .gts-category .gts-tag-link-wrapper {
            width: fit-content(120px);
        }

        .gts-category-genre .gts-tag-link-wrapper {
            max-width: 120px;
            width: max-content !important;
        }

        .gts-category-simulation {
            .gts-category-inner {
                width: 100% !important;
            }
        }

        /*region non-Games*/
        .gts-categoryarea-E-Books,
        .gts-categoryarea-E-Books .gts-right,
        .gts-categoryarea-Applications,
        .gts-categoryarea-Applications .gts-right,
        .gts-categoryarea-OST,
        .gts-categoryarea-OST .gts-right {
            grid-template-columns: 1fr;
        }

        .gts-categoryarea-E-Books .gts-category .gts-category-inner,
        .gts-categoryarea-OST .gts-category .gts-category-inner,
        .gts-categoryarea-Applications .gts-category .gts-category-inner {
            max-height: 300px;
            grid-template-columns: repeat(6, fit-content(180px));
            row-gap: 0.3em;
        }

        /*endregion*/wrapper {
            width: fit-content !important;
            max-width: 100px;
            scroll-snap-align: start;
        }

        .gts-category .gts-tag-wrapper {
            width: fit-content(120px);
        }

        .gts-category-genre .gts-tag-wrapper {
            max-width: 120px;
            width: max-content !important;
        }

        /* simulation category */
        .gts-category:nth-of-type(3) {
            grid-column: span 2;

            .gts-tag-wrapper {
                max-width: unset;
            }
        }

        /*region non-Games*/
        .gts-categoryarea-E-Books,
        .gts-categoryarea-E-Books .gts-right,
        .gts-categoryarea-Applications,
        .gts-categoryarea-Applications .gts-right,
        .gts-categoryarea-OST,
        .gts-categoryarea-OST .gts-right {
            grid-template-columns: 1fr;
        }

        .gts-categoryarea-E-Books .gts-category .gts-category-inner,
        .gts-categoryarea-OST .gts-category .gts-category-inner,
        .gts-categoryarea-Applications .gts-category .gts-category-inner {
            max-height: 300px;
            grid-template-columns: repeat(6, fit-content(180px));
            row-gap: 0.3em;
        }

        /*endregion*/`)
    // renderer functions
    let tagBox, searchBox, modal, presetButton, currentUploadCategory, showIndicess, removeUnlistedButton
    let allCurrentCategoryTags = []

    function render_tag_links(tags, idx) {
        let html = ''
        for (const tag of tags) {
            html += `<div class="gts-tag-wrapper"><button type="button" class="gts-tag" data-tag-idx="${idx}" data-tag="${tag}">${titlecase(tag)}</button>`
            if (idx < 9) {
                html += `<span data-tag-idx="${idx}" class="gts-tag-idx">${idx + 1}</span>`
            }
            html += `</div>`
            idx += 1
        }
        return [html, idx]
    }

    function filter_category_dict(query, categoryDict, currentUploadCategory = 'Games') {
        let filteredDict = {}
        foundTags = []
        for (const [category, tags] of Object.entries(categoryDict)) {
            if (!categoryKeys[currentUploadCategory].includes(category)) {
                continue
            }
            filteredDict[category] = []
            for (const tag of tags) {
                if (searchStringDict[tag].includes(query)) {
                    filteredDict[category].push(tag)
                    foundTags.push(tag)
                }
            }
        }
        return filteredDict
    }

    function draw_currenttagsarea() {
        removeUnlistedButton.style.display = 'none'
        let html = `<h1>Current Tags</h1> (<small>Click to remove</small>)
		<div class="gts-current-tags-inner">`
        const tags = parse_text_to_tag_list(tagBox.value.trim())

        const unlistedTags = tags.filter(tag => !allCurrentCategoryTags.includes(tag))
        for (const [idx, tag] of tags.entries()) {
            html += `<div>${idx + 1}. <button type="button" class="gts-tag ${unlistedTags.includes(tag) ? 'gts-unlisted-tag' : ''}" data-tag="${tag}">${titlecase(tag)}</button></div>`
        }
        html += `</div>`
        const tagArea = document.querySelector('#gts-currenttagsarea')
        tagArea.innerHTML = html
        for (const tagLink of tagArea.querySelectorAll('.gts-tag')) {
            tagLink.onclick = event => {
                event.preventDefault()
                const currentTags = parse_text_to_tag_list(tagBox.value.trim())
                const clickedTag = event.target.getAttribute('data-tag')
                tagBox.value = currentTags.filter(t => t !== clickedTag).join(TAGSEPERATOR)
                // draw_currenttagsarea()
            }
        }
        if (unlistedTags.length > 0) {
            removeUnlistedButton.style.display = 'block'
            removeUnlistedButton.onclick = () => {
                for (const unlistedTag of tagArea.querySelectorAll('.gts-unlisted-tag')) {
                    unlistedTag.click()
                }
            }
        }
    }

    function draw_categoryarea(query = SEPERATOR) {
        let categoryAreaHTML = ''
        let idx = 0
        let tagLinks
        const filteredDict = filter_category_dict(query, categoryDict, currentUploadCategory)
        if (currentUploadCategory === 'Games') {
            if (filteredDict['genre'].length > 0) {
                [tagLinks, idx] = render_tag_links(filteredDict['genre'], idx)
                categoryAreaHTML += `
							<div class="gts-left">
								<div class="gts-category gts-category-genre" tabindex="-1">
									<h1 class="gts-h1">Genre</h1>
									<div class="gts-category-inner" tabindex="-1">
										${tagLinks}
									</div>
								</div>
							</div>`
            }

        }
        categoryAreaHTML += `<div class="gts-right" tabindex="-1">`
        for (const [category, tags] of Object.entries(filteredDict)) {
            if ((currentUploadCategory === 'Games' && category === 'genre') || tags.length === 0) {
                continue
            }
            [tagLinks, idx] = render_tag_links(tags, idx)
            categoryAreaHTML += `<div class="gts-category gts-category-${category}" tabindex="-1">`
            if (categoryKeys[currentUploadCategory].length > 1) {
                categoryAreaHTML += `<h1>${titlecase(category)}</h1>`
            }
            categoryAreaHTML += `
					<div class="gts-category-inner" tabindex="-1">
						${tagLinks}
					</div>
				</div>`
        }
        document.querySelector('#gts-categoryarea').innerHTML = categoryAreaHTML
        document.querySelectorAll('#gts-categoryarea .gts-tag').forEach((el) => {
            el.addEventListener('click', (event) => {
                event.preventDefault()
                const tag = event.target.getAttribute('data-tag').trim()
                const favoriteChecked = check_favorite()
                if (favoriteChecked) {
                    add_favorite(tag).then(() => {
                        draw_favoritearea()
                        register_hotkeys('favorite')
                    })
                } else {
                    add_tag(tag)
                }
            })
        })
    }

    function draw_presetarea() {
        let html = ''
        const currentPresets = currentPresetsDict[currentUploadCategory] || []
        for (const [idx, preset] of currentPresets.entries()) {
            html += `<div class="gts-preset">${idx + 1}. 
				<button type="button" class="gts-preset-link gts-tag" data-preset="${preset}">
					${preset.split(TAGSEPERATOR).map((tag) => titlecase(tag)).join(TAGSEPERATOR)}</a>
				</div>
			</div>`
        }
        document.querySelector('#gts-presetarea').innerHTML = html
        document.querySelectorAll('#gts-presetarea .gts-preset-link').forEach((el) => {
            el.addEventListener('click', (event) => {
                event.preventDefault()
                const preset = event.target.getAttribute('data-preset').trim()
                if (check_remove()) {
                    remove_preset(preset).then(() => {
                        draw_presetarea()
                    })
                } else {
                    tagBox.value = preset
                    tagBox.focus()
                    searchBox.value = ''
                    searchBox.focus()
                }
            })
        })
    }

    function draw_favoritearea() {
        let html = ''
        const currentFavorites = currentFavoritesDict[currentUploadCategory] || []
        for (const [idx, tag] of currentFavorites.entries()) {
            html += `<div class="gts-favorite">${idx + 1}. <button type="button" class="gts-tag" data-tag="${tag}">${titlecase(tag)}</a></div></div>`
        }
        document.querySelector('#gts-favoritearea').innerHTML = html
        document.querySelectorAll('#gts-favoritearea .gts-tag').forEach((el) => {
            el.addEventListener('click', (event) => {
                event.preventDefault()
                const tag = event.target.getAttribute('data-tag').trim()
                if (check_remove()) {
                    remove_favorite(tag).then(() => {
                        draw_favoritearea()
                        register_hotkeys('favorite')
                    })
                } else {
                    add_tag(tag)
                }
            })
        })
    }

    function insert_modal() {
        modal = document.createElement('div')
        const tagBoxStyle = tagBox.currentStyle || window.getComputedStyle(tagBox)
        const tdStyle = tagBox.parentElement.currentStyle || window.getComputedStyle(tagBox.parentElement)
        modal.style.top = (parseInt(tagBoxStyle.marginTop.replace('px', ''), 10) +
            parseInt(tagBoxStyle.marginBottom.replace('px', ''), 10) +
            tagBoxStyle.offsetHeight) + 'px'
        modal.style.left = (parseInt(tagBoxStyle.marginLeft.replace('px', ''), 10) + parseInt(tdStyle.paddingLeft.replace('px', ''), 10)) + 'px'
        modal.id = 'gts-selector'
        modal.classList.add('gts-selector')
        modal.setAttribute('tabindex', '-1')
        modal.innerHTML = `
			<div class="gts-selectarea">
				<div class="gts-searchbar">
					<input id="gts-search" type="text" placeholder="Search (Enter to add as-is)">
					<div class="gts-settings-wrapper" tabindex="-1">
						<a href="/user.php?action=edit#ggn-tag-selector" target="_blank"  tabindex="-1">[Settings]</a>
					</div>
					<div class="gts-checkbox-wrapper" style="text-align: right; min-width: 80px;">
						<input id="gts-favorite-checkbox" type="checkbox" tabindex="-1"><label class="gts-label" for="gts-favorite-checkbox">Favorite</label>
					</div>
				</div>
				<div id="gts-categoryarea" class="hide-idx gts-categoryarea gts-categoryarea-${currentUploadCategory}">
				</div>
			</div>
			<div class="gts-sidearea">
				<div class="gts-sidetopbar" tabindex="-1" style="text-align: right !important;">
					<div class="gts-checkbox-wrapper" style="text-align: right !important;">
						<input id="gts-remove-checkbox" type="checkbox" tabindex="-1"><label class="gts-label" for="gts-remove-checkbox">Remove</label>
					</div>
				</div>
				<h1>Presets</h1>
				<div id="gts-presetarea" tabindex="-1">
				</div>
				<h1>Favorites</h1>
				<div id="gts-favoritearea" tabindex="-1">
				</div>
			</div>
			<div class="gts-sidearea" style="display:flex;flex-direction:column;justify-content: start">
				<div id="gts-currenttagsarea"></div>
				<button type="button" style="display:none;font-size: smaller;" class="gts-remove-unlisted">Remove Unlisted Tags</button>
			</div>
			`

        tagBox.parentElement.style.position = 'relative'
        tagBox.parentElement.appendChild(modal)
        draw_categoryarea()

        removeUnlistedButton = modal.querySelector('.gts-remove-unlisted')
        searchBox = document.querySelector('#gts-search')
        searchBox.addEventListener('keydown', (event) => {
            if (event.key === 'Enter' || (event.key === 'Tab' && foundTags.length === 1)) {
                event.preventDefault()
                event.stopPropagation()
            }
        })
        searchBox.addEventListener('keyup', (event) => {
            if (event.key === 'Tab' && foundTags.length === 1) {
                add_tag(foundTags[0])
            } else if (event.key === 'Enter') {
                let tag = event.target.value.trim()
                tag = tag.replaceAll(' ', '.')
                if (tag.length > 0) {
                    add_tag(tag)
                }
            }
            let query = event.target.value.trim()
            if (query === '') {
                query = SEPERATOR
            }
            query = query.toLowerCase()
            draw_categoryarea(query)

            if (event.code === 'Escape') {
                hide_gts()
            }
        })
        draw_presetarea()
        draw_favoritearea()
        draw_currenttagsarea()
    }

    function insert_preset_button() {
        presetButton = document.createElement('button')
        presetButton.id = 'gts-add-preset'
        presetButton.classList.add('gts-add-preset')
        presetButton.type = 'button'
        presetButton.setAttribute('tabindex', '-1')
        presetButton.textContent = 'Add Preset'

        if (!isGroupPage) {
            tagBox.after(presetButton)
            if (!isUploadPage) presetButton.style.marginLeft = '5px'
        } else {
            const div = document.createElement('div')
            const submitButton = tagBox.nextElementSibling
            div.style.cssText = `
            display: flex;
            justify-content: end;
            align-items: center;
            `
            tagBox.after(div)
            div.append(presetButton, submitButton)
        }
        presetButton.addEventListener('click', () => {
            const preset = tagBox.value.trim()
            add_preset(preset).then(() => {
                draw_presetarea()
            })
        })
    }

    // actions
    function add_tag(tag) {
        const currentValue = tagBox.value.trim()
        tag = tag.trim().toLowerCase()
        if (currentValue === "") {
            tagBox.value = tag
        } else {
            let tags = currentValue.split(TAGSEPERATOR)
            if (!tags.includes(tag)) {
                tags.push(tag)
            }
            tagBox.value = tags.join(TAGSEPERATOR)
        }
        tagBox.focus()
        tagBox.setSelectionRange(-1, -1)
        searchBox.focus()
        searchBox.value = ''
        draw_categoryarea()
    }

    async function add_favorite(tag) {
        const currentFavorites = currentFavoritesDict[currentUploadCategory] || []
        if (currentFavorites.length < 9 && !currentFavorites.includes(tag)) {
            currentFavoritesDict[currentUploadCategory] = currentFavorites.concat(tag)
            return GM.setValue('gts_favorites', currentFavoritesDict)
        }
    }

    async function remove_favorite(tag) {
        const currentFavorites = currentFavoritesDict[currentUploadCategory] || []
        let _temp = []
        for (const fav of currentFavorites) {
            if (fav !== tag) {
                _temp.push(fav)
            }
        }
        currentFavoritesDict[currentUploadCategory] = _temp
        return GM.setValue('gts_favorites', currentFavoritesDict)
    }

    function parse_text_to_tag_list(text) {
        let tagList = []
        for (let tag of text.split(TAGSEPERATOR.trim())) {
            tag = tag.trim()
            if (tag !== '') {
                tagList.push(tag)
            }
        }
        return tagList
    }

    async function add_preset(rawPreset) {
        let preset = parse_text_to_tag_list(rawPreset)
        const currentPresets = currentPresetsDict[currentUploadCategory] || []
        preset = preset.join(TAGSEPERATOR)
        if (!currentPresets.includes(preset)) {
            currentPresetsDict[currentUploadCategory] = currentPresets.concat(preset)
            return GM.setValue('gts_presets', currentPresetsDict)
        }
    }

    async function remove_preset(preset) {
        let _temp = []
        const currentPresets = currentPresetsDict[currentUploadCategory] || []
        for (const pres of currentPresets) {
            if (pres !== preset) {
                _temp.push(pres)
            }
        }
        currentPresetsDict[currentUploadCategory] = _temp
        return GM.setValue('gts_presets', currentPresetsDict)
    }

    function check_favorite() {
        return document.querySelector('#gts-favorite-checkbox').checked
    }

    function check_remove() {
        return document.querySelector('#gts-remove-checkbox').checked
    }

    function check_gts_element(element) {
        if (typeof element === 'undefined' || !(element instanceof HTMLElement)) {
            return false
        }
        const _id = element.id || ''
        const _class = element.getAttribute('class') || ''
        return (_id === 'tags' ||
            _id.includes('gts-') ||
            _class.includes('gts-'))
    }

    function hide_gts() {
        modal.style.display = 'none'
        presetButton.style.display = 'none'
    }

    function show_gts() {
        if (!check_gts_active()) {
            modal.style.display = 'grid'
            presetButton.style.display = 'inline'
            searchBox.focus()
            draw_currenttagsarea()
        }
    }

    function hide_indices() {
        document.querySelector('#gts-categoryarea').classList.add('hide-idx')
        showIndicess = false
    }

    function show_indices() {
        document.querySelector('#gts-categoryarea').classList.remove('hide-idx')
        showIndicess = true
    }

    function check_gts_active() {
        return (modal.style.display === 'grid') && (presetButton.style.display === 'block')
    }

    function check_query_exists() {
        // returns true if there is query
        return searchBox.value.trim() !== ''
    }

    function get_index_from_code(code) {
        if (code.indexOf('Digit') === 0) {
            return parseInt(code.replaceAll('Digit', ''), 10) - 1
        }
        return null
    }

    function get_current_upload_category(defaultCategory = 'Games') {
        if (isSearchPage || isRequestPage) {
            const list = document.querySelectorAll('input[type=checkbox][name^=filter_cat]:checked')
            if (list.length < 1) return defaultCategory
            const lastChecked = list[list.length - 1]

            return {
                1: "Games",
                2: "Applications",
                3: "E-Books",
                4: "OST",
            }[/\d/.exec(lastChecked.id)[0]]
        }
        let categoryElement = document.querySelector('#categories')
        if (categoryElement) {
            return categoryElement.value
        }
        categoryElement = document.querySelector('#group_nofo_bigdiv .head:first-child')
        const s = categoryElement.innerText.trim()
        if (s.indexOf('Application') !== -1) {
            return 'Applications'
        } else if (s.indexOf('OST') !== -1) {
            return 'OST'
        } else if (s.indexOf('Book') !== -1) {
            return 'E-Books'
        } else if (s.indexOf('Game') !== -1) {
            return 'Games'
        }
        return defaultCategory
    }

    function check_hotkey_prefix(event, type) {
        let eventModifiers = [event.shiftKey, event.altKey, event.ctrlKey, event.metaKey]
        const targetKeys = hotkeyPrefixes[type].split(' + ').map((key) => key.trim().toLowerCase())
        for (let i = 0; i < modifiers.length; i++) {
            if (targetKeys.includes(modifiers[i]) !== eventModifiers[i]) {
                return false
            }
        }
        return true
    }

    function get_hotkey_target(event, type) {
        for (const [idx, hotkey] of Object.entries(hotkeys[type])) {
            let normalKeys = []
            const targetKeys = hotkey.split('+').map((s) => {
                key = s.toLowerCase().trim()
                if (!modifiers.includes(key)) {
                    normalKeys.push(key)
                }
                return key
            })
            let modifierMismatch = false
            let eventModifiers = [event.shiftKey, event.altKey, event.ctrlKey, event.metaKey]
            for (let i = 0; i < modifiers.length; i++) {
                if (targetKeys.includes(modifiers[i]) !== eventModifiers[i]) {
                    modifierMismatch = true
                    break
                }
            }
            if (modifierMismatch) {
                continue
            }
            if (normalKeys.length > 0 && (
                !(normalKeys.includes(event.key.toLowerCase()) || normalKeys.includes(event.code.toLowerCase()))
            )) {
                continue
            }
            return idx
        }
        return null
    }

    function register_hotkeys(type) {
        if (['favorite', 'preset'].includes(type) && !windowEvents.includes(`hotkey-${type}`)) {
            window.addEventListener('keydown', (event) => {
                if (!check_gts_active()) {
                    return
                }
                const target = get_hotkey_target(event, type)
                let currentList
                if (type === 'favorite') {
                    if (check_query_exists()) {
                        return // return early if query is active
                    }
                    currentList = currentFavoritesDict[currentUploadCategory] || []
                } else if (type === 'preset') {
                    // if we're working with presets,
                    // we proceed anyway
                    currentList = currentPresetsDict[currentUploadCategory] || []
                }
                if (target !== null) {
                    if (target < currentList.length) {
                        event.preventDefault()
                        if (type === 'favorite') {
                            add_tag(currentList[target])
                        } else if (type === 'preset') {
                            tagBox.value = currentList[target]
                            tagBox.focus()
                        }
                        searchBox.focus()
                    }
                }
            }, true)
        } else if (type === 'show_indices' && !windowEvents.includes(!`hotkey-${type}`)) {
            window.addEventListener('keydown', (event) => {
                if (!check_gts_active() || !check_query_exists()) {
                    return
                }
                if (check_hotkey_prefix(event, type)) {
                    show_indices()
                    const idx = get_index_from_code(event.code)
                    if (idx !== null) {
                        document.querySelector(`a.gts-tag[data-tag-idx="${idx}"]`).click()
                        event.preventDefault()
                    }
                }
            }, true)
            window.addEventListener('keyup', () => {
                if (showIndicess) {
                    hide_indices()
                }
            }, true)
        }
        windowEvents.push(`hotkey-${type}`)
    }

    // initialiser
    function init() {
        const modal = document.querySelector('#gts-selector')
        if (modal) {
            modal.remove()
        }
        currentUploadCategory = get_current_upload_category()
        allCurrentCategoryTags = categoryKeys[currentUploadCategory].flatMap(c => categoryDict[c])

        if (isGroupPage) {
            const groupTagEls = Array.from(document.querySelectorAll("a[href^='torrents.php?taglist=']"))
            const unlistedTagEls = groupTagEls
                .filter(tag => !allCurrentCategoryTags.includes(tag.textContent) && !specialTags.some(t  => t === tag.textContent))
            for (const groupTagEl of groupTagEls) {
                if (unlistedTagEls.includes(groupTagEl)) {
                    groupTagEl.classList.add('gts-unlisted-tag')
                }
            }
        }
        tagBox = document.getElementById('tags') || document.querySelector('input[name=tags]')
        tagBox.setAttribute('onfocus', 'this.value = this.value')
        insert_modal()
        insert_preset_button()
        if (!windowEvents.includes('click')) {
            window.addEventListener('click', (event) => {
                if (!check_gts_element(event.target)) {
                    setTimeout(() => {
                        if (!check_gts_element(document.activeElement)) {
                            hide_gts()
                        }
                    }, 50)
                }
            }, true)
            windowEvents.push('click')
        }
        if (!windowEvents.includes('esc')) {
            window.addEventListener('keyup', (event) => {
                if (event.code === 'Escape') {
                    if (check_gts_active()) {
                        hide_gts()
                    }
                }
            }, true)
            windowEvents.push('esc')
        }
        tagBox.addEventListener('focus', show_gts)
        tagBox.addEventListener('click', show_gts)
        tagBox.addEventListener('keyup', (event) => {
            if (event.code !== 'Escape') {
                draw_currenttagsarea()
            }
        })
        register_hotkeys('favorite')
        register_hotkeys('preset')
        register_hotkeys('show_indices')
        draw_currenttagsarea()
        // watch for value change in the tagBox
        observe_element(tagBox, 'value', (_) => {
            draw_currenttagsarea()
        })
    }

    if (isUploadPage) {
        const observerTarget = document.querySelector('#dynamic_form')
        let observer = new MutationObserver(init)
        const observerConfig = {childList: true, attributes: false, subtree: false}
        if (document.readyState === "loading") {
            document.addEventListener("DOMContentLoaded", init)
            observer.observe(observerTarget, observerConfig)
        } else {
            init()
            observer.observe(observerTarget, observerConfig)
        }
    } else {
        init()
        if (isSearchPage || isRequestPage) {
            document.querySelector('.cat_list').addEventListener('change', e => {
                if (e.target.checked) {
                    init()
                }
            })
        }
        else if (isCreateRequestPage) { // it doesn't use dynamic form
            init()
            document.getElementById('categories').addEventListener('change', () => {
                init()
            })
        }
    }
} else {
    let hotkeys = (GM_getValue('gts_hotkeys')) || defaultHotkeys
    let hotkeyPrefixes = (GM_getValue('gts_hotkey_prefixes')) || defaulthotkeyPrefixes

    GM_addStyle(`
                #gts-save-settings {
                    min-width: 200px;
                }
                .gts-hotkey-grid {
                    display: grid;
                    column-gap: 1em;
                    grid-template-columns: repeat(2, fit-content(400px)) 1fr;
                }
                .gts-hotkey-grid h1 {
                    font-size: 1.1em;
                }
                .gts-hotkey-col div {
                    margin-bottom: 0.25em;
                }
            `)

    async function init() {
        let colhead = document.createElement('tr')
        colhead.classList.add('colhead_dark')
        colhead.innerHTML = '<td colspan="2" id="ggn-tag-selector"><strong>GGn Tag Selector</strong></span>'
        const lastTr = document.querySelector('#userform > table > tbody > tr:last-child')
        lastTr.before(colhead)
        let hotkeyTr = document.createElement('tr')
        let html = `
                    <td class="label"><strong>Hotkeys</strong></td>
                    <td class="gts-hotkey-grid">
                `
        for (const [type, cHotkeys] of Object.entries(hotkeys)) {
            html += `<div class="gts-hotkey-col"><h1>${titlecase(type)}</h1>`
            for (const [idx, hotkey] of cHotkeys.entries()) {
                html += `<div>${idx + 1}. <input class="gts-settings" data-gts-settings="gts_hotkeys:${type}-${idx}" value="${hotkey}"></div>`
            }
            html += `</div>`
        }
        html += `<div class="gts-hotkey-col">
                    <h1>Index peeker</h1>
                    Hold <input type="text" style="width: 5em" class="gts-settings" data-gts-settings="gts_hotkey_prefixes:show_indices" value="${hotkeyPrefixes['show_indices']}"> to display indices of the filtered results (modifier keys/their combinations only). 
                    Use the key along with a digit (1-9) to add the tag according to the index.
                    Note that peeking/adding by index will not work if the filter query is empty.
                    <h1>How to set combos/keys</h1>
                    To set a combo, use the keys joined by the plus sign. For example, Ctrl + Shift + 1 is <span style="font-family: monospace">ctrl + shift + digit1</span>
                    <ul>
                        <li>Modifier keys: shift, alt, ctrl, cmd</li>
                        <li>Numbers: digit1, digit2, digit3, digit4, digit5, digit6, digit7, digit8, digit9</li>
                        <li>Alphabet: a, b, c, d, (etc.)</li>
                    </ul>
                    <div style="margin-top: 1em;">
                        Other keys should also work. If not, use the <span style="font-family: monospace">event.code</span> value from <a target="_blank" href="https://www.toptal.com/developers/keycode">the keycode tool</a>.
                    </div>
                </div>`
        html += `</td>`
        html += `<div style="margin-left: 1em;"><input type="button" id="gts-save-settings" value="Save GGn Tag Selector settings">
                <input type="button" id="gts-restore-settings" value="Restore Defaults"></div>`
        hotkeyTr.innerHTML = html
        colhead.after(hotkeyTr)
        document.querySelector('#gts-save-settings').addEventListener('click', (event) => {
            const originalText = event.target.value
            let newData = {
                'gts_hotkeys': hotkeys,
                'gts_hotkey_prefixes': hotkeyPrefixes
            }
            event.target.value = 'Saving ...'
            document.querySelectorAll('.gts-settings').forEach((el) => {
                const meta = el.getAttribute('data-gts-settings')
                const rawValue = el.value
                const [settingKey, settingSubKey] = meta.split(':')
                if (settingKey === 'gts_hotkey_prefixes') {
                    newData[settingKey][settingSubKey] = normalise_combo_string(rawValue)
                } else if (settingKey === 'gts_hotkeys') {
                    const [type, idx] = settingSubKey.split('-')
                    // normalise the value
                    newData[settingKey][type][idx] = normalise_combo_string(rawValue)
                }
            })
            let promises = []
            for (const [key, value] of Object.entries(newData)) {
                promises.push(GM.setValue(key, value))
            }
            Promise.all(promises).then(() => {
                event.target.value = 'Saved!'
                setTimeout(() => {
                    event.target.value = originalText
                }, 500)
            })
        })
        document.querySelector('#gts-restore-settings').addEventListener('click', () => {
            let defaults = {
                'gts_hotkeys': defaultHotkeys,
                'gts_hotkey_prefixes': hotkeyPrefixes
            }
            document.querySelectorAll('.gts-settings').forEach((el) => {
                const meta = el.getAttribute('data-gts-settings')
                const [settingKey, settingSubKey] = meta.split(':')
                if (settingKey === 'gts_hotkey_prefixes') {
                    el.value = defaults[settingKey][settingSubKey]
                } else if (settingKey === 'gts_hotkeys') {
                    const [type, idx] = settingSubKey.split('-')
                    el.value = defaults[settingKey][type][idx]
                }
            })
        })

        if (window.location.hash.substring(1) === 'ggn-tag-selector') {
            document.querySelector('#ggn-tag-selector').scrollIntoView()
        }
    }

    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", init)
    } else {
        init()
    }
}