// ==UserScript==
// @name GGn Tag Helper
// @description Add tags more easily
// @version 2.2.1
// @match *://gazellegames.net/upload.php*
// @match *://gazellegames.net/torrents.php?*action=advanced*
// @match *://gazellegames.net/torrents.php*id=*
// @exclude *://gazellegames.net/torrents.php*action=editgroup*
// @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
// @namespace ggntagselector
// ==/UserScript==
// noinspection CssUnresolvedCustomProperty,CssUnusedSymbol,DuplicatedCode
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.php')
const TAGSEPERATOR = ', '
let hotkeys = GM_getValue('hotkeys')
if (!hotkeys) {
hotkeys = {
"index": {
"modifier": "Shift",
"keys": ["1", "2", "3", "4", "5", "Q", "W", "E", "R", "T"]
},
"favorites": {
"modifier": "Shift",
"keys": ["1", "2", "3", "4", "5", "Q", "W", "E", "R", "T"]
},
"presets": {
"modifier": "Alt",
"keys": ["1", "2", "3", "4", "5", "Q", "W", "E", "R", "T"]
}
}
GM_setValue('hotkeys', hotkeys)
}
const trailingCommaRegex = /(?:, *)+$/
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)
}
if (!isUserPage) {
let modal,
tagInput,
currentUploadCategory = 'Games',
favsList,
presetsList,
currentTagsList,
removalCheckbox
// language=CSS
GM_addStyle(`
#tag-helper {
display: none;
grid-template-columns: 200px 300px 200px;
grid-template-rows: repeat(2, auto);
gap: 15px;
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;
min-width: min-content;
max-width: 800px;
font-size: 13px;
label input[type=checkbox] {
margin: 0 5px 0 0;
}
section {
div.spaced {
display: flex;
justify-content: space-between;
align-items: center;
}
div.list {
display: flex;
flex-direction: column;
max-height: 450px;
overflow: auto;
}
button {
margin-right: 10px;
word-break: break-word;
}
h1 {
font-weight: normal;
padding-bottom: 0;
font-size: 1.2em;
margin: 0.5em 0 0.5em 0;
}
div.tag-wrapper {
display: flex;
align-items: center;
gap: 0.25em;
}
}
.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;
}
}
.th-tag-idx {
color: yellow;
float: right;
display: none;
font-family: monospace;
}
`)
if (isGroupPage)
document.getElementById('add_tags_link').click()
modal = document.createElement('div')
document.body.appendChild(modal)
modal.id = 'tag-helper'
modal.innerHTML =
//language=HTML
`
<section style="grid-column: 1">
<div class="spaced">
<h1>Favorites</h1>
<button type="button" id="th-add-fav">Add</button>
</div>
<div class="list" id="th-favs"></div>
</section>
<section style="grid-column: 2;">
<div class="spaced">
<h1>Presets</h1>
<button type="button" id="th-add-preset">Add</button>
</div>
<div class="list" id="th-presets"></div>
</section>
<section style="grid-column: 3;">
<h1>Current Tags</h1>
<div class="list" id="th-currenttags"></div>
</section>
<label style="display:flex;align-items:center;font-size: 0.9em;grid-row: 2;grid-column: 1/3">
<input type="checkbox" id="th-remove">
Remove (click favorite or preset when checked)
</label>
`
favsList = document.getElementById('th-favs')
presetsList = document.getElementById('th-presets')
currentTagsList = document.getElementById('th-currenttags')
removalCheckbox = document.getElementById('th-remove')
let currentFavoritesDict = (GM_getValue('favorites')) || {}
let currentPresetsDict = (GM_getValue('presets')) || {}
function init() {
if (isUploadPage || isCreateRequestPage) {
currentUploadCategory = document.querySelector('#categories').value
} else if (isGroupPage) {
const categoryHeaderText = document.querySelector('#group_nofo_bigdiv > div.head > strong').textContent
if (categoryHeaderText.includes('Application')) {
currentUploadCategory = 'Applications'
} else if (categoryHeaderText.includes('OST')) {
currentUploadCategory = 'OST'
} else if (categoryHeaderText.includes('Book')) {
currentUploadCategory = 'E-Books'
} else if (categoryHeaderText.includes('Game')) {
currentUploadCategory = 'Games'
}
} else if (isSearchPage || isRequestPage) {
const checkedBoxes = document.querySelectorAll('input[type=checkbox][name^=filter_cat]:checked')
if (checkedBoxes.length > 0) {
const lastChecked = checkedBoxes[checkedBoxes.length - 1]
currentUploadCategory = {
1: "Games",
2: "Applications",
3: "E-Books",
4: "OST",
}[/\d/.exec(lastChecked.id)[0]]
}
}
tagInput = isCreateRequestPage ? document.getElementById(`tags_${currentUploadCategory}`).firstElementChild :
(document.getElementById('tags') || document.querySelector('[name=add_tags_input]') || document.querySelector('[name=tags]'))
const tagInputRect = tagInput.getBoundingClientRect()
modal.style.top = `${tagInputRect.top + window.scrollY + tagInputRect.height + 5}px`
modal.style.left = `${tagInputRect.left + window.scrollX + 250}px`
drawFavorites()
drawPresets()
drawCurrentTags()
tagInput.addEventListener('keyup', () => {
for (const span of suggestionIdxSpans) {
span.style.display = 'none'
}
for (const span of favIdxSpans) {
span.style.display = 'none'
}
for (const span of presetIdxSpans) {
span.style.display = 'none'
}
})
tagInput.addEventListener('blur', () => {
tagInput.value = tagInput.value.replace(trailingCommaRegex, '')
})
tagInput.addEventListener('focus', () => {
modal.style.display = 'grid'
addCommaToEndAndDrawCurrent()
})
tagInput.addEventListener('change', () => {
drawCurrentTags()
})
window.addEventListener('click', e => {
if (!(e.target === tagInput || modal.contains(e.target) || e.target.className === 'tag'))
modal.style.display = 'none'
})
window.addEventListener('keydown', ev => {
if (ev.code === 'Escape') {
modal.style.display = 'none'
}
})
}
init()
if (isUploadPage) {
tagInput.style.width = '100%'
tagInput.size = 80
document.getElementById('categories').addEventListener('change', () => {
new MutationObserver(() => init())
.observe(document.getElementById('dynamic_form'), {childList: true, subtree: true})
})
} else if (isSearchPage || isRequestPage) {
document.querySelector('.cat_list').addEventListener('change', e => {
if (!e.target.checked) return
init()
})
} else if (isCreateRequestPage) { // it doesn't use dynamic form
document.getElementById('categories').addEventListener('change', () => {
init()
})
}
/** @type {HTMLInputElement} */
const autocompleteDiv = tagInput.nextElementSibling
const addTagsButton = document.getElementById('add_tags_button')
const groupTagDivs = document.getElementsByClassName('group_tag')
const suggestionIdxSpans = autocompleteDiv.getElementsByClassName('th-tag-idx')
const favIdxSpans = favsList.getElementsByClassName('th-tag-idx')
const presetIdxSpans = presetsList.getElementsByClassName('th-tag-idx')
const autocompleteItems = autocompleteDiv.getElementsByClassName('tag_autocomplete_items')
// this is to prevent submission when accepting a suggestion using tab
let acceptedSuggestion = false
new MutationObserver(() => {
// not using addedNodes because it's empty if a suggestion remains at the same position
for (let idx = 0; idx < autocompleteItems.length; idx++) {
const autocompleteItem = autocompleteItems[idx]
autocompleteItem.addEventListener('click', () => {
acceptedSuggestion = true
addCommaToEndAndDrawCurrent()
})
if (idx > 0) {
const span = document.createElement('span')
autocompleteItem.append(span)
span.textContent = hotkeys.index.keys?.[idx - 1] ?? ''
span.className = 'th-tag-idx'
}
}
}).observe(autocompleteDiv, {childList: true})
/**
* @param {KeyboardEvent} event
* @param {string} hotkeyType
*/
function isCorrectKeyModifier(event, hotkeyType) {
const modifier = hotkeys[hotkeyType].modifier
return (event.shiftKey && !isSearchPage && modifier === 'Shift')
|| (event.altKey && modifier === 'Alt')
|| (event.ctrlKey && modifier === 'Control')
|| (event.metaKey && modifier === 'Meta')
}
/**
* @param {KeyboardEvent} ev
* @param {string} hotkeyType
* @param {HTMLCollectionOf<HTMLSpanElement>} spanList
* @param {string} code
* @param {HTMLElement} tagList
* @param {boolean} [skip1]
*/
function handleListIndexPress(ev, hotkeyType, spanList, code, tagList, skip1) {
if (isCorrectKeyModifier(ev, hotkeyType)) {
ev.preventDefault()
for (const span of spanList) {
span.style.display = 'inline'
}
const keyIndex = hotkeys.index.keys.indexOf(code)
if (keyIndex !== -1) {
const child = tagList.children[keyIndex + (skip1 ? 1 : 0)];
(child.querySelector('button') || child).click()
}
}
}
tagInput.addEventListener('keydown', /** @param {KeyboardEvent} ev */ev => {
const code = ev.code.replace('Digit', '').replace('Key', '')
if (code === 'Space') {
addCommaToEndAndDrawCurrent()
tagInput.value = tagInput.value.replace(/ $/, '')
return
}
const hasSuggestions = autocompleteDiv.children.length > 0
if (hasSuggestions) { // add suggestion by index
handleListIndexPress(ev, 'index', suggestionIdxSpans, code, autocompleteDiv, true)
} else {
handleListIndexPress(ev, 'favorites', favIdxSpans, code, favsList)
}
handleListIndexPress(ev, 'presets', presetIdxSpans, code, presetsList)
// tab submit shortcut
if (isGroupPage && tagInput.value && !hasSuggestions && !acceptedSuggestion && code === 'Tab') {
ev.preventDefault()
addTagsButton.click()
}
acceptedSuggestion = false
})
let originalTagColor
function addTag(tag) {
const groupTags = [...groupTagDivs].map(div => div.children[0])
const existingTag = groupTags.find(t => t.textContent === tag)
if (existingTag) {
originalTagColor ??= window.getComputedStyle(existingTag).getPropertyValue('color')
existingTag.style.color = '#69c364'
setTimeout(() => existingTag.style.color = originalTagColor, 1000)
return
}
if (!tagInput.value) {
tagInput.value = tag
} else {
const tags = tagInput.value.replace(trailingCommaRegex, '').split(TAGSEPERATOR)
if (!tags.includes(tag)) {
tags.push(tag)
}
tagInput.value = tags.join(TAGSEPERATOR)
}
tagInput.focus()
tagInput.setSelectionRange(-1, -1)
addCommaToEndAndDrawCurrent()
drawCurrentTags()
}
// region Favorites
//
//
//
//
function drawFavorites() {
let html = ''
for (const [idx, tag] of getFavorites().entries()) {
html += `<div class="spaced">
<div class="tag-wrapper">${idx + 1}. <button type="button" class="tag" data-tag="${tag}">${titlecase(tag)}</button></div>
<span class="th-tag-idx">${hotkeys.favorites.keys?.[idx] ?? ''}</span>
</div>`
}
favsList.innerHTML = html
favsList.querySelectorAll('.tag').forEach(el => {
el.addEventListener('click', event => {
event.preventDefault()
const tag = event.target.dataset.tag
if (removalCheckbox.checked) {
removeFavorite(tag).then(() => {
drawFavorites()
})
} else {
addTag(tag)
}
})
})
}
async function removeFavorite(tag) {
let _temp = []
for (const fav of getFavorites()) {
if (fav !== tag) {
_temp.push(fav)
}
}
currentFavoritesDict[currentUploadCategory] = _temp
return GM.setValue('favorites', currentFavoritesDict)
}
document.getElementById('th-add-fav').onclick = async () => {
const currentFavorites = getFavorites()
const tags = parse_text_to_tag_list()
.filter((value, index, array) => !array.some(value => currentFavorites.includes(value)))
currentFavoritesDict[currentUploadCategory] = currentFavorites.concat(...tags)
await GM.setValue('favorites', currentFavoritesDict)
drawFavorites()
}
function getFavorites() {
return currentFavoritesDict[currentUploadCategory] || []
}
//
//
//
//
// endregion
//region Presets
//
//
//
//
function drawPresets() {
let html = ''
for (const [idx, preset] of getPresets().entries()) {
html += `<div class="spaced">
<div class="tag-wrapper">${idx + 1}. <button type="button" class="tag" data-preset="${preset}">
${preset.split(TAGSEPERATOR).map((tag) => titlecase(tag)).join(TAGSEPERATOR)}</button></div>
<span class="th-tag-idx">${hotkeys.presets.keys?.[idx] ?? ''}</span>
</div>`
}
presetsList.innerHTML = html
presetsList.querySelectorAll('.tag').forEach((el) => {
el.addEventListener('click', event => {
event.preventDefault()
const preset = event.target.dataset.preset
if (removalCheckbox.checked) {
removePreset(preset).then(() => {
drawPresets()
})
} else {
for (const tag of parse_text_to_tag_list(preset)) {
addTag(tag)
}
}
})
})
}
function getPresets() {
return currentPresetsDict[currentUploadCategory] || []
}
async function removePreset(preset) {
let _temp = []
for (const pres of getPresets()) {
if (pres !== preset) {
_temp.push(pres)
}
}
currentPresetsDict[currentUploadCategory] = _temp
return GM.setValue('presets', currentPresetsDict)
}
document.getElementById('th-add-preset').onclick = async () => {
const str = parse_text_to_tag_list().join(TAGSEPERATOR)
const currentPresets = getPresets()
if (!currentPresets.includes(str)) {
currentPresetsDict[currentUploadCategory] = currentPresets.concat(str)
await GM.setValue('presets', currentPresetsDict)
drawPresets()
}
}
//
//
//
//
//endregion
/** @returns {string[]} */
function parse_text_to_tag_list(text = tagInput.value) {
let tagList = []
for (let tag of text.replaceAll(' ', '').split(TAGSEPERATOR.trim())) {
tag.trim() && tagList.push(tag)
}
return tagList
}
function addCommaToEndAndDrawCurrent() {
if (tagInput.value && !trailingCommaRegex.test(tagInput.value)) {
tagInput.value += TAGSEPERATOR
}
drawCurrentTags()
}
function drawCurrentTags() {
let html = ''
const tags = parse_text_to_tag_list()
for (const [idx, tag] of tags.entries()) {
html += `<div class="tag-wrapper">${idx + 1}. <button type="button" class="tag" data-tag="${tag}">${titlecase(tag)}</button></div>`
}
currentTagsList.innerHTML = html
for (const tagLink of currentTagsList.querySelectorAll('.tag')) {
tagLink.onclick = event => {
const currentTags = parse_text_to_tag_list()
const clickedTag = event.target.getAttribute('data-tag')
tagInput.value = currentTags.filter(t => t !== clickedTag).join(TAGSEPERATOR)
tagInput.focus()
}
}
}
} else {
// language=CSS
GM_addStyle(`
#tag-helper {
display: grid;
flex-direction: column;
gap: 10px;
h1 {
font-size: 1.1em;
margin: 0;
}
input[type=text] {
width: 50%;
}
}
`)
const colhead = document.createElement('tr')
colhead.classList.add('colhead_dark')
colhead.innerHTML = '<td colspan="2" ><strong>Tag Helper</strong></span>'
const lastTr = document.querySelector('#userform > table > tbody > tr:last-child')
lastTr.before(colhead)
const hotkeyTr = document.createElement('tr')
hotkeyTr.innerHTML = `<td class="label"><strong>Hotkeys</strong></td>`
const td = document.createElement('td')
td.id = 'tag-helper'
hotkeyTr.append(td)
for (const [name, obj] of Object.entries(hotkeys)) {
// language=HTML
td.innerHTML += `
<h1>${titlecase(name)}</h1>
<div>
<select name="${name}-modifier">
<option value="Shift">shift</option>
<option value="Alt">alt</option>
<option value="Control">ctrl</option>
<option value="Meta">meta</option>
</select>
<input type="text" name="${name}-keys" value="${obj.keys.join(', ')}">
</div>
`
const selects = td.querySelectorAll('select')
selects[selects.length - 1].value = obj.modifier
}
const saveButton = document.createElement('button')
td.append(saveButton)
saveButton.textContent = 'Save'
saveButton.style.width = 'max-content'
saveButton.style.fontSize = 'larger'
saveButton.type = 'button'
saveButton.onclick = () => {
for (const name of Object.keys(hotkeys)) {
const modifierInput = td.querySelector(`select[name="${name}-modifier"]`)
const keysInput = td.querySelector(`input[name="${name}-keys"]`)
hotkeys[name].modifier = modifierInput.value
hotkeys[name].keys = keysInput.value.split(', ')
}
GM_setValue('hotkeys', hotkeys)
saveButton.textContent = 'Saved'
}
colhead.after(hotkeyTr)
}