Greasy Fork

GGn Tag selector

Enhanced Tag selector for GGn

目前为 2025-04-18 提交的版本。查看 最新版本

// ==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",
        "metroidvania",
        "mini.game",
        "music",
        "open.world",
        "parody",
        "party",
        "pinball",
        "platform",
        "point.and.click",
        "puzzle",
        "quiz",
        "rhythm",
        "roguelike",
        // "roguelite",
        "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()
    }
}