// ==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()
}
}