Greasy Fork is available in English.

GGn Tag selector

Enhanced Tag selector for GGn

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
  1. // ==UserScript==
  2. // @name GGn Tag selector
  3. // @namespace ggntagselector
  4. // @version 1.2.1
  5. // @match *://gazellegames.net/upload.php*
  6. // @match *://gazellegames.net/torrents.php?*action=advanced*
  7. // @match *://gazellegames.net/torrents.php*id=*
  8. // @match *://gazellegames.net/requests.php*
  9. // @match *://gazellegames.net/user.php*action=edit*
  10. // @grant GM.setValue
  11. // @grant GM.getValue
  12. // @grant GM_setValue
  13. // @grant GM_getValue
  14. // @grant GM_addStyle
  15. // @license MIT
  16. // @author tweembp, ingts
  17. // @description Enhanced Tag selector for GGn
  18. // ==/UserScript==
  19. // noinspection CssUnresolvedCustomProperty,CssUnusedSymbol
  20.  
  21. const locationhref = location.href
  22. const isUploadPage = locationhref.includes('upload.php'),
  23. isGroupPage = locationhref.includes('torrents.php?id='),
  24. isSearchPage = locationhref.includes('action=advanced'),
  25. isRequestPage = locationhref.includes('requests.php') && !locationhref.includes('action=new'),
  26. isCreateRequestPage = locationhref.includes('action=new'),
  27. isUserPage = locationhref.includes('user')
  28.  
  29. const SEPERATOR = '|'
  30. const TAGSEPERATOR = ', '
  31. const defaultHotkeys = {
  32. 'favorite': [
  33. 'shift + digit1',
  34. 'shift + digit2',
  35. 'shift + digit3',
  36. 'shift + digit4',
  37. 'shift + digit5',
  38. 'shift + digit6',
  39. 'shift + digit7',
  40. 'shift + digit8',
  41. 'shift + digit9',
  42. ],
  43. 'preset': [
  44. 'alt + digit1',
  45. 'alt + digit2',
  46. 'alt + digit3',
  47. 'alt + digit4',
  48. 'alt + digit5',
  49. 'alt + digit6',
  50. 'alt + digit7',
  51. 'alt + digit8',
  52. 'alt + digit9',
  53. ],
  54. }
  55. const defaulthotkeyPrefixes = {
  56. 'show_indices': 'shift'
  57. }
  58. const modifiers = ["shift", "alt", "ctrl", "cmd"]
  59. const categoryDict = {
  60. "genre": [
  61. "4x",
  62. "action",
  63. "adventure",
  64. "aerial.combat",
  65. "agriculture",
  66. "arcade",
  67. "auto.battler",
  68. "beat.em.up",
  69. "board.game",
  70. "building",
  71. "bullet.hell",
  72. "card.game",
  73. "casual",
  74. "childrens",
  75. "city.building",
  76. "clicker",
  77. "d10.system",
  78. "d20.system",
  79. "driving",
  80. "dungeon.crawler",
  81. "educational",
  82. "exploration",
  83. "fighting",
  84. "fitness",
  85. "game.show",
  86. "grand.strategy",
  87. "hack.and.slash",
  88. "hidden.object",
  89. "horror",
  90. "hunting",
  91. "interactive.fiction",
  92. "jigsaw",
  93. "karaoke",
  94. "management",
  95. "match.3",
  96. "mini.game",
  97. "music",
  98. "open.world",
  99. "parody",
  100. "party",
  101. "pinball",
  102. "platform",
  103. "point.and.click",
  104. "puzzle",
  105. "quiz",
  106. "rhythm",
  107. "roguelike",
  108. "role.playing.game",
  109. "runner",
  110. "sandbox",
  111. "shoot.em.up",
  112. "shooter",
  113. "first.person.shooter",
  114. "third.person.shooter",
  115. "simulation",
  116. "solitaire",
  117. "space",
  118. "stealth",
  119. "strategy",
  120. "real.time.strategy",
  121. "turn.based.strategy",
  122. "stunts",
  123. "survival",
  124. "tabletop",
  125. "tactics",
  126. "text.adventure",
  127. "time.management",
  128. "tower.defense",
  129. "trivia",
  130. "typing",
  131. "vehicular.combat",
  132. "visual.novel",
  133. "wargame",
  134. "word.game",
  135. "word.construction",
  136. ],
  137. "theme": [
  138. "adult",
  139. "romance",
  140. "comedy",
  141. "crime",
  142. "drama",
  143. "fantasy",
  144. "historical",
  145. "mystery",
  146. "thriller",
  147. "science.fiction",
  148. ],
  149. "sports": [
  150. "american.football",
  151. "baseball",
  152. "basketball",
  153. "billiards",
  154. "blackjack",
  155. "bowling",
  156. "boxing",
  157. "chess",
  158. "cricket",
  159. "cycling",
  160. "extreme.sports",
  161. "fishing",
  162. "go",
  163. "golf",
  164. "hockey",
  165. "mahjong",
  166. "pachinko",
  167. "pinball",
  168. "poker",
  169. "racing",
  170. "rugby",
  171. "skateboarding",
  172. "slots",
  173. "snowboarding",
  174. "soccer",
  175. "sports",
  176. "tennis",
  177. "wrestling",
  178. ],
  179. "simulation": [
  180. "business.simulation",
  181. "construction.simulation",
  182. "dating.simulation",
  183. "flight.simulation",
  184. "life.simulation",
  185. "space.simulation",
  186. "vehicle.simulation",
  187. "walking.simulation",
  188. ],
  189. "ost": [
  190. "acappella",
  191. "acid.house",
  192. "acid.jazz",
  193. "acoustic",
  194. "afrobeat",
  195. "alternative",
  196. "ambient",
  197. "arrangement",
  198. "ballad",
  199. "black.metal",
  200. "breakbeat",
  201. "breakcore",
  202. "chill.out",
  203. "chillwave",
  204. "chipbreak",
  205. "chiptune",
  206. "choral",
  207. "citypop",
  208. "classical",
  209. "country",
  210. "dance",
  211. "dark.ambient",
  212. "dark.electro",
  213. "dark.synth",
  214. "dark.wave",
  215. "downtempo",
  216. "dream.pop",
  217. "drum.and.bass",
  218. "dubstep",
  219. "electro",
  220. "electronic",
  221. "electronic.rock",
  222. "epic.metal",
  223. "euro.house",
  224. "experimental",
  225. "folk",
  226. "funk",
  227. "happy.hardcore",
  228. "hardcore",
  229. "heavy.metal",
  230. "hip.hop",
  231. "horrorcore",
  232. "house",
  233. "hymn",
  234. "indie.pop",
  235. "indie.rock",
  236. "industrial",
  237. "instrumental",
  238. "jazz",
  239. "lo.fi",
  240. "modern.classical",
  241. "new.age",
  242. "opera",
  243. "orchestral",
  244. "phonk",
  245. "piano",
  246. "pop",
  247. "rhythm.and.blues",
  248. "rock",
  249. "smooth.jazz",
  250. "sound.effects",
  251. "symphonic",
  252. "synth",
  253. "synth.pop",
  254. "synthwave",
  255. "traditional",
  256. "techno",
  257. "trance",
  258. "vaporwave",
  259. "violin",
  260. "vocal",
  261. ],
  262. "books": [
  263. "art.book",
  264. "collection",
  265. "comic.book",
  266. "fiction",
  267. "game.design",
  268. "game.programming",
  269. "psychology",
  270. "social.science",
  271. "gamebook",
  272. "graphic.novel",
  273. "guide",
  274. "magazine",
  275. "non.fiction",
  276. "novelization",
  277. "programming",
  278. "business",
  279. "reference",
  280. "study"
  281. ],
  282. "applications": [
  283. "apps.windows",
  284. "apps.linux",
  285. "apps.mac",
  286. "apps.android",
  287. "utility",
  288. "development",
  289. ],
  290. }
  291. // relevant keys for each upload category
  292. const categoryKeys = {
  293. 'Games': ["genre", "theme", "sports", "simulation"],
  294. 'E-Books': ['books'],
  295. 'Applications': ['applications'],
  296. 'OST': ['ost']
  297. }
  298. const specialTags = ['pack', 'collection']
  299.  
  300. // common functions
  301. function titlecase(s) {
  302. let out = s.split('.').map((e) => {
  303. if (!["and", "em"].includes(e)) {
  304. return e[0].toUpperCase() + e.slice(1)
  305. } else {
  306. return e
  307. }
  308. }).join(' ')
  309. return out[0].toUpperCase() + out.slice(1)
  310. }
  311.  
  312. function normalise_combo_string(s) {
  313. return s.trim().split('+').map((c) => c.trim().toLowerCase()).join(' + ')
  314. }
  315.  
  316. function observe_element(element, property, callback, delay = 0) {
  317. let elementPrototype = Object.getPrototypeOf(element)
  318. if (elementPrototype.hasOwnProperty(property)) {
  319. let descriptor = Object.getOwnPropertyDescriptor(elementPrototype, property)
  320. Object.defineProperty(element, property, {
  321. get: function () {
  322. return descriptor.get.apply(this, arguments)
  323. },
  324. set: function () {
  325. let oldValue = this[property]
  326. descriptor.set.apply(this, arguments)
  327. let newValue = this[property]
  328. if (typeof callback == "function") {
  329. setTimeout(callback.bind(this, oldValue, newValue), delay)
  330. }
  331. return newValue
  332. },
  333. configurable: true
  334. })
  335. }
  336. }
  337.  
  338.  
  339. function addTagFromSearch(event) {
  340. let tag = event.target.value.trim()
  341. tag = tag.replaceAll(' ', '.')
  342. if (tag.length > 0) {
  343. add_tag(tag)
  344. }
  345. }
  346.  
  347. if (!isUserPage) {
  348. if (isSearchPage) {
  349. const taglist = document.getElementById('taglist')
  350. taglist.style.display = 'none'
  351. taglist.nextElementSibling.style.display = 'none'
  352. }
  353. if (isGroupPage) {
  354. document.getElementById('tags_add_note').remove() // "To add multiple tags separate by comma" text
  355. }
  356. // load settings
  357. let currentFavoritesDict = (GM_getValue('gts_favorites')) || {}
  358. let currentPresetsDict = (GM_getValue('gts_presets')) || {}
  359. let hotkeys = (GM_getValue('gts_hotkeys')) || defaultHotkeys
  360. let hotkeyPrefixes = (GM_getValue('gts_hotkey_prefixes')) || defaulthotkeyPrefixes
  361.  
  362. let searchStringDict = {}
  363. for (const tags of Object.values(categoryDict)) {
  364. // map from tag => search title, string
  365. for (const tag of tags) {
  366. const title = titlecase(tag)
  367. searchStringDict[tag] = `${title.toLowerCase()}${SEPERATOR}${tag}`
  368. }
  369. }
  370.  
  371. let foundTags = -1
  372. let windowEvents = []
  373.  
  374. // language=CSS
  375. GM_addStyle(`
  376. .gts-selector *::-webkit-scrollbar {
  377. width: 3px;
  378. }
  379.  
  380. .gts-selector *::-webkit-scrollbar-track {
  381. background: transparent;
  382. }
  383.  
  384. .gts-selector *::-webkit-scrollbar-thumb {
  385. background-color: rgba(155, 155, 155, 0.5);
  386. border-radius: 20px;
  387. border: transparent;
  388. }
  389.  
  390. .gts-unlisted-tag {
  391. color: coral !important;
  392. }
  393.  
  394. .gts-remove-unlisted {
  395. margin-top: 15px;
  396. }
  397.  
  398. #genre_tags {
  399. display: none !important
  400. }
  401.  
  402. .gts-add-preset {
  403. display: none;
  404. }
  405. .gts-selector {
  406. display: none;
  407. position: absolute;
  408. background-color: rgb(27, 48, 63);
  409. box-sizing: border-box;
  410. padding: .5em 1em 1em 1em;
  411. border: 3px solid var(--rowb);
  412. box-shadow: -3px 3px 5px var(--black);
  413. z-index: 99999;
  414. grid-template-columns: auto fit-content(180px) fit-content(180px);
  415. column-gap: 1em;
  416. min-width: min-content !important;
  417. max-width: 1000px !important;
  418. font-size: 13px;
  419. }
  420.  
  421. .gts-selector h1 {
  422. margin: 0;
  423. font-weight: normal;
  424. padding-bottom: 0;
  425. }
  426.  
  427. .gts-tag {
  428. height: fit-content;
  429. font-family: inherit;
  430. font-size: inherit;
  431. opacity: 1 !important;
  432. background: none!important;
  433. border: none;
  434. padding: 0!important;
  435. color: var(--lightBlue);
  436. text-decoration: none;
  437. cursor: pointer;
  438. text-align: start;
  439. }
  440.  
  441. .gts-sidearea {
  442. min-width: 150px;
  443. box-sizing: border-box;
  444. border-left: 2px solid var(--grey);
  445. padding-left: 1em;
  446. }
  447.  
  448. .gts-selector .gts-sidearea h1 {
  449. font-size: 1.2em;
  450. margin-top: 1em;
  451. margin-bottom: 0.25em;
  452. }
  453.  
  454. .gts-sidearea h1:nth-child(2) {
  455. margin-top: 0;
  456. }
  457.  
  458. .gts-current-tags-inner {
  459. font-size: 0.9em;
  460. margin-top: 1em;
  461. overflow-y: auto;
  462. max-height: 320px;
  463. }
  464.  
  465. .gts-searchbar {
  466. display: grid;
  467. align-items: center;
  468. grid-template-columns: 3fr auto 1fr;
  469. column-gap: 1em;
  470. margin-bottom: 1em;
  471. }
  472.  
  473. .gts-categoryarea {
  474. display: grid;
  475. grid-template-columns: 1fr 1fr;
  476. column-gap: 10px;
  477.  
  478. .gts-right {
  479. display: grid;
  480. grid-template-columns: 1fr 1fr;
  481. height: 100%;
  482. column-gap: 1em;
  483. width: max-content;
  484. }
  485.  
  486. .gts-left {
  487. height: 100%;
  488. }
  489.  
  490. .gts-category-inner {
  491. display: grid;
  492. grid-template-columns: 1fr;
  493. column-gap: 1em;
  494. overflow-y: auto;
  495. max-height: 145px;
  496. }
  497. }
  498.  
  499. .gts-category .gts-category-inner, #gts-favoritearea, #gts-presetarea {
  500. font-size: .9em;
  501. margin-top: 0.5em;
  502. width: max-content !important;
  503. }
  504.  
  505. #gts-presetarea {
  506. max-height: 140px;
  507. overflow-y: auto;
  508. width: unset !important;
  509. }
  510.  
  511. #gts-favoritearea {
  512. max-height: 140px;
  513. overflow-y: auto;
  514. grid-template-columns: 1fr 1fr;
  515. display: grid;
  516. column-gap: .5em;
  517. }
  518.  
  519. .gts-category h1 {
  520. font-size: 1.1em;
  521. }
  522.  
  523. .gts-category-genre .gts-category-inner {
  524. grid-template-columns: auto auto;
  525. max-height: 320px;
  526. }
  527.  
  528. .gts-tag-idx {
  529. color: yellow;
  530. font-weight: bold;
  531. margin-left: 0.25em;
  532. }
  533.  
  534. .hide-idx .gts-tag-idx {
  535. visibility: hidden;;
  536. }
  537.  
  538. #gts-selector a {
  539. font-size: inherit !important;
  540. }
  541.  
  542. .gts-tag-link-wrapper {
  543. width: fit-content !important;
  544. max-width: 100px;
  545. scroll-snap-align: start;
  546. }
  547.  
  548. .gts-tag-wrapper {
  549. width: fit-content !important;
  550. max-width: 100px;
  551. }
  552.  
  553. .gts-category .gts-tag-wrapper {
  554. width: fit-content(120px);
  555. }
  556.  
  557. .gts-category-genre .gts-tag-wrapper {
  558. max-width: 120px;
  559. width: max-content !important;
  560. }
  561.  
  562. .gts-category .gts-tag-link-wrapper {
  563. width: fit-content(120px);
  564. }
  565.  
  566. .gts-category-genre .gts-tag-link-wrapper {
  567. max-width: 120px;
  568. width: max-content !important;
  569. }
  570.  
  571. .gts-category-simulation {
  572. grid-column: span 2;
  573.  
  574. .gts-tag-wrapper {
  575. max-width: unset;
  576. }
  577. .gts-category-inner {
  578. width: 100% !important;
  579. }
  580. }
  581.  
  582. /*region non-Games*/
  583. .gts-categoryarea-E-Books,
  584. .gts-categoryarea-E-Books .gts-right,
  585. .gts-categoryarea-Applications,
  586. .gts-categoryarea-Applications .gts-right,
  587. .gts-categoryarea-OST,
  588. .gts-categoryarea-OST .gts-right {
  589. grid-template-columns: 1fr;
  590. }
  591.  
  592. .gts-categoryarea-E-Books .gts-category .gts-category-inner,
  593. .gts-categoryarea-OST .gts-category .gts-category-inner,
  594. .gts-categoryarea-Applications .gts-category .gts-category-inner {
  595. max-height: 300px;
  596. grid-template-columns: repeat(6, fit-content(180px));
  597. row-gap: 0.3em;
  598. }
  599.  
  600. /*endregion*/
  601. `)
  602. // renderer functions
  603. let tagBox, searchBox, modal, presetButton, currentUploadCategory, showIndicess, removeUnlistedButton
  604. let allCurrentCategoryTags = []
  605.  
  606. function render_tag_links(tags, idx) {
  607. let html = ''
  608. for (const tag of tags) {
  609. html += `<div class="gts-tag-wrapper"><button type="button" class="gts-tag" data-tag-idx="${idx}" data-tag="${tag}">${titlecase(tag)}</button>`
  610. if (idx < 9) {
  611. html += `<span data-tag-idx="${idx}" class="gts-tag-idx">${idx + 1}</span>`
  612. }
  613. html += `</div>`
  614. idx += 1
  615. }
  616. return [html, idx]
  617. }
  618.  
  619. function filter_category_dict(query, categoryDict, currentUploadCategory = 'Games') {
  620. let filteredDict = {}
  621. foundTags = []
  622. for (const [category, tags] of Object.entries(categoryDict)) {
  623. if (!categoryKeys[currentUploadCategory].includes(category)) {
  624. continue
  625. }
  626. filteredDict[category] = []
  627. for (const tag of tags) {
  628. if (searchStringDict[tag].includes(query)) {
  629. filteredDict[category].push(tag)
  630. foundTags.push(tag)
  631. }
  632. }
  633. }
  634. return filteredDict
  635. }
  636.  
  637. function draw_currenttagsarea() {
  638. removeUnlistedButton.style.display = 'none'
  639. let html = `<h1>Current Tags</h1> (<small>Click to remove</small>)
  640. <div class="gts-current-tags-inner">`
  641. const tags = parse_text_to_tag_list(tagBox.value.trim())
  642.  
  643. const unlistedTags = tags.filter(tag => !allCurrentCategoryTags.includes(tag))
  644. for (const [idx, tag] of tags.entries()) {
  645. html += `<div>${idx + 1}. <button type="button" class="gts-tag ${unlistedTags.includes(tag) ? 'gts-unlisted-tag' : ''}" data-tag="${tag}">${titlecase(tag)}</button></div>`
  646. }
  647. html += `</div>`
  648. const tagArea = document.querySelector('#gts-currenttagsarea')
  649. tagArea.innerHTML = html
  650. for (const tagLink of tagArea.querySelectorAll('.gts-tag')) {
  651. tagLink.onclick = event => {
  652. event.preventDefault()
  653. const currentTags = parse_text_to_tag_list(tagBox.value.trim())
  654. const clickedTag = event.target.getAttribute('data-tag')
  655. tagBox.value = currentTags.filter(t => t !== clickedTag).join(TAGSEPERATOR)
  656. // draw_currenttagsarea()
  657. }
  658. }
  659. if (unlistedTags.length > 0) {
  660. removeUnlistedButton.style.display = 'block'
  661. removeUnlistedButton.onclick = () => {
  662. for (const unlistedTag of tagArea.querySelectorAll('.gts-unlisted-tag')) {
  663. unlistedTag.click()
  664. }
  665. }
  666. }
  667. }
  668.  
  669. function draw_categoryarea(query = SEPERATOR) {
  670. let categoryAreaHTML = ''
  671. let idx = 0
  672. let tagLinks
  673. const filteredDict = filter_category_dict(query, categoryDict, currentUploadCategory)
  674. if (currentUploadCategory === 'Games') {
  675. if (filteredDict['genre'].length > 0) {
  676. [tagLinks, idx] = render_tag_links(filteredDict['genre'], idx)
  677. categoryAreaHTML += `
  678. <div class="gts-left">
  679. <div class="gts-category gts-category-genre" tabindex="-1">
  680. <h1 class="gts-h1">Genre</h1>
  681. <div class="gts-category-inner" tabindex="-1">
  682. ${tagLinks}
  683. </div>
  684. </div>
  685. </div>`
  686. }
  687.  
  688. }
  689. categoryAreaHTML += `<div class="gts-right" tabindex="-1">`
  690. for (const [category, tags] of Object.entries(filteredDict)) {
  691. if ((currentUploadCategory === 'Games' && category === 'genre') || tags.length === 0) {
  692. continue
  693. }
  694. [tagLinks, idx] = render_tag_links(tags, idx)
  695. categoryAreaHTML += `<div class="gts-category gts-category-${category}" tabindex="-1">`
  696. if (categoryKeys[currentUploadCategory].length > 1) {
  697. categoryAreaHTML += `<h1>${titlecase(category)}</h1>`
  698. }
  699. categoryAreaHTML += `
  700. <div class="gts-category-inner" tabindex="-1">
  701. ${tagLinks}
  702. </div>
  703. </div>`
  704. }
  705. document.querySelector('#gts-categoryarea').innerHTML = categoryAreaHTML
  706. document.querySelectorAll('#gts-categoryarea .gts-tag').forEach((el) => {
  707. el.addEventListener('click', (event) => {
  708. event.preventDefault()
  709. const tag = event.target.getAttribute('data-tag').trim()
  710. const favoriteChecked = check_favorite()
  711. if (favoriteChecked) {
  712. add_favorite(tag).then(() => {
  713. draw_favoritearea()
  714. register_hotkeys('favorite')
  715. })
  716. } else {
  717. add_tag(tag)
  718. }
  719. })
  720. })
  721. }
  722.  
  723. function draw_presetarea() {
  724. let html = ''
  725. const currentPresets = currentPresetsDict[currentUploadCategory] || []
  726. for (const [idx, preset] of currentPresets.entries()) {
  727. html += `<div class="gts-preset">${idx + 1}.
  728. <button type="button" class="gts-preset-link gts-tag" data-preset="${preset}">
  729. ${preset.split(TAGSEPERATOR).map((tag) => titlecase(tag)).join(TAGSEPERATOR)}</button>
  730. </div>
  731. </div>`
  732. }
  733. document.querySelector('#gts-presetarea').innerHTML = html
  734. document.querySelectorAll('#gts-presetarea .gts-preset-link').forEach((el) => {
  735. el.addEventListener('click', (event) => {
  736. event.preventDefault()
  737. const preset = event.target.getAttribute('data-preset').trim()
  738. if (check_remove()) {
  739. remove_preset(preset).then(() => {
  740. draw_presetarea()
  741. })
  742. } else {
  743. tagBox.value = preset
  744. tagBox.focus()
  745. searchBox.value = ''
  746. searchBox.focus()
  747. }
  748. })
  749. })
  750. }
  751.  
  752. function draw_favoritearea() {
  753. let html = ''
  754. const currentFavorites = currentFavoritesDict[currentUploadCategory] || []
  755. for (const [idx, tag] of currentFavorites.entries()) {
  756. html += `<div class="gts-favorite">${idx + 1}. <button type="button" class="gts-tag" data-tag="${tag}">${titlecase(tag)}</button></div></div>`
  757. }
  758. document.querySelector('#gts-favoritearea').innerHTML = html
  759. document.querySelectorAll('#gts-favoritearea .gts-tag').forEach((el) => {
  760. el.addEventListener('click', (event) => {
  761. event.preventDefault()
  762. const tag = event.target.getAttribute('data-tag').trim()
  763. if (check_remove()) {
  764. remove_favorite(tag).then(() => {
  765. draw_favoritearea()
  766. register_hotkeys('favorite')
  767. })
  768. } else {
  769. add_tag(tag)
  770. }
  771. })
  772. })
  773. }
  774.  
  775. function insert_modal(addTagsToggle) {
  776. modal = document.createElement('div')
  777. const tagBoxStyle = tagBox.currentStyle || window.getComputedStyle(tagBox)
  778. const tdStyle = tagBox.parentElement.currentStyle || window.getComputedStyle(tagBox.parentElement)
  779. modal.style.top = (parseInt(tagBoxStyle.marginTop.replace('px', ''), 10) +
  780. parseInt(tagBoxStyle.marginBottom.replace('px', ''), 10) +
  781. tagBoxStyle.offsetHeight) + 'px'
  782. modal.style.left = (parseInt(tagBoxStyle.marginLeft.replace('px', ''), 10) + parseInt(tdStyle.paddingLeft.replace('px', ''), 10)) + 'px'
  783. modal.id = 'gts-selector'
  784. modal.classList.add('gts-selector')
  785. modal.setAttribute('tabindex', '-1')
  786. modal.innerHTML = `
  787. <div class="gts-selectarea">
  788. <div class="gts-searchbar">
  789. <input id="gts-search" type="text" placeholder="Search (Enter to add as-is${addTagsToggle ? ', ctrl+enter to submit' : ''})">
  790. <div class="gts-settings-wrapper" tabindex="-1">
  791. <a href="/user.php?action=edit#ggn-tag-selector" target="_blank" tabindex="-1">[Settings]</a>
  792. </div>
  793. <div class="gts-checkbox-wrapper" style="text-align: right; min-width: 80px;">
  794. <input id="gts-favorite-checkbox" type="checkbox" tabindex="-1"><label class="gts-label" for="gts-favorite-checkbox">Favorite</label>
  795. </div>
  796. </div>
  797. <div id="gts-categoryarea" class="hide-idx gts-categoryarea gts-categoryarea-${currentUploadCategory}">
  798. </div>
  799. </div>
  800. <div class="gts-sidearea">
  801. <div class="gts-sidetopbar" tabindex="-1" style="text-align: right !important;">
  802. <div class="gts-checkbox-wrapper" style="text-align: right !important;">
  803. <input id="gts-remove-checkbox" type="checkbox" tabindex="-1"><label class="gts-label" for="gts-remove-checkbox">Remove</label>
  804. </div>
  805. </div>
  806. <h1>Presets</h1>
  807. <div id="gts-presetarea" tabindex="-1">
  808. </div>
  809. <h1>Favorites</h1>
  810. <div id="gts-favoritearea" tabindex="-1">
  811. </div>
  812. </div>
  813. <div class="gts-sidearea" style="display:flex;flex-direction:column;justify-content: start">
  814. <div id="gts-currenttagsarea"></div>
  815. <button type="button" style="display:none;font-size: smaller;" class="gts-remove-unlisted">Remove Unlisted Tags</button>
  816. </div>
  817. `
  818.  
  819. tagBox.parentElement.style.position = 'relative'
  820. tagBox.parentElement.appendChild(modal)
  821. draw_categoryarea()
  822.  
  823. removeUnlistedButton = modal.querySelector('.gts-remove-unlisted')
  824. searchBox = document.querySelector('#gts-search')
  825. searchBox.addEventListener('keydown', (event) => {
  826. if (event.key === 'Enter' || (event.key === 'Tab' && foundTags.length === 1)) {
  827. event.preventDefault()
  828. event.stopPropagation()
  829. }
  830. })
  831. searchBox.addEventListener('keyup', (event) => {
  832. if (event.key === 'Tab' && foundTags.length === 1) {
  833. add_tag(foundTags[0])
  834. } else if (event.key === 'Enter') {
  835. addTagFromSearch(event)
  836. }
  837. let query = event.target.value.trim()
  838. if (query === '') {
  839. query = SEPERATOR
  840. }
  841. query = query.toLowerCase()
  842. draw_categoryarea(query)
  843.  
  844. if (event.code === 'Escape') {
  845. hide_gts()
  846. }
  847. })
  848.  
  849. draw_presetarea()
  850. draw_favoritearea()
  851. draw_currenttagsarea()
  852. }
  853.  
  854. function insert_preset_button() {
  855. presetButton = document.createElement('button')
  856. presetButton.id = 'gts-add-preset'
  857. presetButton.classList.add('gts-add-preset')
  858. presetButton.type = 'button'
  859. presetButton.setAttribute('tabindex', '-1')
  860. presetButton.textContent = 'Add Preset'
  861.  
  862. if (!isGroupPage) {
  863. tagBox.after(presetButton)
  864. if (!isUploadPage) presetButton.style.marginLeft = '5px'
  865. } else {
  866. const div = document.createElement('div')
  867. const submitButton = tagBox.nextElementSibling
  868. div.style.cssText = `
  869. display: flex;
  870. justify-content: end;
  871. align-items: center;
  872. `
  873. tagBox.after(div)
  874. div.append(presetButton, submitButton)
  875. }
  876. presetButton.addEventListener('click', () => {
  877. const preset = tagBox.value.trim()
  878. add_preset(preset).then(() => {
  879. draw_presetarea()
  880. })
  881. })
  882. }
  883.  
  884. // actions
  885. function add_tag(tag) {
  886. const currentValue = tagBox.value.trim()
  887. tag = tag.trim().toLowerCase()
  888. if (currentValue === "") {
  889. tagBox.value = tag
  890. } else {
  891. let tags = currentValue.split(TAGSEPERATOR)
  892. if (!tags.includes(tag)) {
  893. tags.push(tag)
  894. }
  895. tagBox.value = tags.join(TAGSEPERATOR)
  896. }
  897. tagBox.focus()
  898. tagBox.setSelectionRange(-1, -1)
  899. searchBox.focus()
  900. searchBox.value = ''
  901. draw_categoryarea()
  902. }
  903.  
  904. async function add_favorite(tag) {
  905. const currentFavorites = currentFavoritesDict[currentUploadCategory] || []
  906. if (currentFavorites.length < 9 && !currentFavorites.includes(tag)) {
  907. currentFavoritesDict[currentUploadCategory] = currentFavorites.concat(tag)
  908. return GM.setValue('gts_favorites', currentFavoritesDict)
  909. }
  910. }
  911.  
  912. async function remove_favorite(tag) {
  913. const currentFavorites = currentFavoritesDict[currentUploadCategory] || []
  914. let _temp = []
  915. for (const fav of currentFavorites) {
  916. if (fav !== tag) {
  917. _temp.push(fav)
  918. }
  919. }
  920. currentFavoritesDict[currentUploadCategory] = _temp
  921. return GM.setValue('gts_favorites', currentFavoritesDict)
  922. }
  923.  
  924. function parse_text_to_tag_list(text) {
  925. let tagList = []
  926. for (let tag of text.split(TAGSEPERATOR.trim())) {
  927. tag = tag.trim()
  928. if (tag !== '') {
  929. tagList.push(tag)
  930. }
  931. }
  932. return tagList
  933. }
  934.  
  935. async function add_preset(rawPreset) {
  936. let preset = parse_text_to_tag_list(rawPreset)
  937. const currentPresets = currentPresetsDict[currentUploadCategory] || []
  938. preset = preset.join(TAGSEPERATOR)
  939. if (!currentPresets.includes(preset)) {
  940. currentPresetsDict[currentUploadCategory] = currentPresets.concat(preset)
  941. return GM.setValue('gts_presets', currentPresetsDict)
  942. }
  943. }
  944.  
  945. async function remove_preset(preset) {
  946. let _temp = []
  947. const currentPresets = currentPresetsDict[currentUploadCategory] || []
  948. for (const pres of currentPresets) {
  949. if (pres !== preset) {
  950. _temp.push(pres)
  951. }
  952. }
  953. currentPresetsDict[currentUploadCategory] = _temp
  954. return GM.setValue('gts_presets', currentPresetsDict)
  955. }
  956.  
  957. function check_favorite() {
  958. return document.querySelector('#gts-favorite-checkbox').checked
  959. }
  960.  
  961. function check_remove() {
  962. return document.querySelector('#gts-remove-checkbox').checked
  963. }
  964.  
  965. function check_gts_element(element) {
  966. if (typeof element === 'undefined' || !(element instanceof HTMLElement)) {
  967. return false
  968. }
  969. const _id = element.id || ''
  970. const _class = element.getAttribute('class') || ''
  971. return (_id === 'tags' ||
  972. _id.includes('gts-') ||
  973. _class.includes('gts-'))
  974. }
  975.  
  976. function hide_gts() {
  977. modal.style.display = 'none'
  978. presetButton.style.display = 'none'
  979. }
  980.  
  981. function show_gts() {
  982. if (!check_gts_active()) {
  983. modal.style.display = 'grid'
  984. presetButton.style.display = 'inline'
  985. searchBox.focus()
  986. draw_currenttagsarea()
  987. }
  988. }
  989.  
  990. function hide_indices() {
  991. document.querySelector('#gts-categoryarea').classList.add('hide-idx')
  992. showIndicess = false
  993. }
  994.  
  995. function show_indices() {
  996. document.querySelector('#gts-categoryarea').classList.remove('hide-idx')
  997. showIndicess = true
  998. }
  999.  
  1000. function check_gts_active() {
  1001. return modal.style.display === 'grid'
  1002. }
  1003.  
  1004. function check_query_exists() {
  1005. // returns true if there is query
  1006. return searchBox.value.trim() !== ''
  1007. }
  1008.  
  1009. function get_index_from_code(code) {
  1010. if (code.indexOf('Digit') === 0) {
  1011. return parseInt(code.replaceAll('Digit', ''), 10) - 1
  1012. }
  1013. return null
  1014. }
  1015.  
  1016. function get_current_upload_category(defaultCategory = 'Games') {
  1017. if (isSearchPage || isRequestPage) {
  1018. const list = document.querySelectorAll('input[type=checkbox][name^=filter_cat]:checked')
  1019. if (list.length < 1) return defaultCategory
  1020. const lastChecked = list[list.length - 1]
  1021.  
  1022. return {
  1023. 1: "Games",
  1024. 2: "Applications",
  1025. 3: "E-Books",
  1026. 4: "OST",
  1027. }[/\d/.exec(lastChecked.id)[0]]
  1028. }
  1029. let categoryElement = document.querySelector('#categories')
  1030. if (categoryElement) {
  1031. return categoryElement.value
  1032. }
  1033. categoryElement = document.querySelector('#group_nofo_bigdiv .head:first-child')
  1034. const s = categoryElement.innerText.trim()
  1035. if (s.indexOf('Application') !== -1) {
  1036. return 'Applications'
  1037. } else if (s.indexOf('OST') !== -1) {
  1038. return 'OST'
  1039. } else if (s.indexOf('Book') !== -1) {
  1040. return 'E-Books'
  1041. } else if (s.indexOf('Game') !== -1) {
  1042. return 'Games'
  1043. }
  1044. return defaultCategory
  1045. }
  1046.  
  1047. function check_hotkey_prefix(event, type) {
  1048. let eventModifiers = [event.shiftKey, event.altKey, event.ctrlKey, event.metaKey]
  1049. const targetKeys = hotkeyPrefixes[type].split(' + ').map((key) => key.trim().toLowerCase())
  1050. for (let i = 0; i < modifiers.length; i++) {
  1051. if (targetKeys.includes(modifiers[i]) !== eventModifiers[i]) {
  1052. return false
  1053. }
  1054. }
  1055. return true
  1056. }
  1057.  
  1058. function get_hotkey_target(event, type) {
  1059. for (const [idx, hotkey] of Object.entries(hotkeys[type])) {
  1060. let normalKeys = []
  1061. const targetKeys = hotkey.split('+').map((s) => {
  1062. const key = s.toLowerCase().trim()
  1063. if (!modifiers.includes(key)) {
  1064. normalKeys.push(key)
  1065. }
  1066. return key
  1067. })
  1068. let modifierMismatch = false
  1069. let eventModifiers = [event.shiftKey, event.altKey, event.ctrlKey, event.metaKey]
  1070. for (let i = 0; i < modifiers.length; i++) {
  1071. if (targetKeys.includes(modifiers[i]) !== eventModifiers[i]) {
  1072. modifierMismatch = true
  1073. break
  1074. }
  1075. }
  1076. if (modifierMismatch) {
  1077. continue
  1078. }
  1079. if (normalKeys.length > 0 && (
  1080. !(normalKeys.includes(event.key.toLowerCase()) || normalKeys.includes(event.code.toLowerCase()))
  1081. )) {
  1082. continue
  1083. }
  1084. return idx
  1085. }
  1086. return null
  1087. }
  1088.  
  1089. function register_hotkeys(type) {
  1090. if (['favorite', 'preset'].includes(type) && !windowEvents.includes(`hotkey-${type}`)) {
  1091. window.addEventListener('keydown', (event) => {
  1092. if (!check_gts_active()) {
  1093. return
  1094. }
  1095. const target = get_hotkey_target(event, type)
  1096. let currentList
  1097. if (type === 'favorite') {
  1098. if (check_query_exists()) {
  1099. return // return early if query is active
  1100. }
  1101. currentList = currentFavoritesDict[currentUploadCategory] || []
  1102. } else if (type === 'preset') {
  1103. // if we're working with presets,
  1104. // we proceed anyway
  1105. currentList = currentPresetsDict[currentUploadCategory] || []
  1106. }
  1107. if (target !== null) {
  1108. if (target < currentList.length) {
  1109. event.preventDefault()
  1110. if (type === 'favorite') {
  1111. add_tag(currentList[target])
  1112. } else if (type === 'preset') {
  1113. tagBox.value = currentList[target]
  1114. tagBox.focus()
  1115. }
  1116. searchBox.focus()
  1117. }
  1118. }
  1119. }, true)
  1120. } else if (type === 'show_indices' && !windowEvents.includes(!`hotkey-${type}`)) {
  1121. window.addEventListener('keydown', (event) => {
  1122. if (!check_gts_active() || !check_query_exists()) {
  1123. return
  1124. }
  1125. if (check_hotkey_prefix(event, type)) {
  1126. show_indices()
  1127. const idx = get_index_from_code(event.code)
  1128. if (idx !== null) {
  1129. document.querySelector(`button.gts-tag[data-tag-idx="${idx}"]`).click()
  1130. event.preventDefault()
  1131. }
  1132. }
  1133. }, true)
  1134. window.addEventListener('keyup', () => {
  1135. if (showIndicess) {
  1136. hide_indices()
  1137. }
  1138. }, true)
  1139. }
  1140. windowEvents.push(`hotkey-${type}`)
  1141. }
  1142.  
  1143. // initialiser
  1144. function init() {
  1145. const modal = document.querySelector('#gts-selector')
  1146. if (modal) {
  1147. modal.remove()
  1148. }
  1149. currentUploadCategory = get_current_upload_category()
  1150. allCurrentCategoryTags = categoryKeys[currentUploadCategory].flatMap(c => categoryDict[c])
  1151.  
  1152. tagBox = document.getElementById('tags') || document.querySelector('input[name=tags]')
  1153. tagBox.setAttribute('onfocus', 'this.value = this.value')
  1154. const addTagsToggle = document.getElementById('tags_add_toggle')
  1155. insert_modal(addTagsToggle)
  1156. insert_preset_button()
  1157.  
  1158. if (addTagsToggle) { // on group page and has tagging priviledge (maybe)
  1159. const groupTagEls = Array.from(document.querySelectorAll("a[href^='torrents.php?taglist=']"))
  1160. const unlistedTagEls = groupTagEls.filter(tag => !allCurrentCategoryTags.includes(tag.textContent) && !specialTags.includes(tag.textContent))
  1161.  
  1162. for (const groupTagEl of groupTagEls) {
  1163. if (unlistedTagEls.includes(groupTagEl)) {
  1164. groupTagEl.classList.add('gts-unlisted-tag')
  1165. }
  1166. }
  1167. addTagsToggle.style.display = 'none'
  1168. const addTagForm = document.getElementById('tag_add_form')
  1169. addTagForm.style.display = 'block'
  1170. // add/remove tags replaces the whole list
  1171. new MutationObserver(() => {
  1172. for (const el of document.querySelectorAll("a[href^='torrents.php?taglist=']")) {
  1173. if (unlistedTagEls.some(t => t.href === el.href)) {
  1174. el.classList.add('gts-unlisted-tag')
  1175. }
  1176. }
  1177. }).observe(document.getElementById('tagslist'), {childList: true})
  1178.  
  1179. tagBox.placeholder = 'Add tags'
  1180. GM_addStyle(`#gts-search::placeholder {font-size: 0.9em;}`)
  1181.  
  1182. searchBox.addEventListener('keydown', e => {
  1183. if (e.ctrlKey && e.key === 'Enter') {
  1184. addTagFromSearch(e)
  1185. addTagForm.querySelector('input[type=submit]').click()
  1186. hide_gts()
  1187. }
  1188. })
  1189. }
  1190.  
  1191. if (!windowEvents.includes('click')) {
  1192. window.addEventListener('click', (event) => {
  1193. if (!check_gts_element(event.target)) {
  1194. setTimeout(() => {
  1195. if (!check_gts_element(document.activeElement)) {
  1196. hide_gts()
  1197. }
  1198. }, 50)
  1199. }
  1200. }, true)
  1201. windowEvents.push('click')
  1202. }
  1203. if (!windowEvents.includes('esc')) {
  1204. window.addEventListener('keyup', (event) => {
  1205. if (event.code === 'Escape') {
  1206. if (check_gts_active()) {
  1207. hide_gts()
  1208. }
  1209. }
  1210. }, true)
  1211. windowEvents.push('esc')
  1212. }
  1213. tagBox.addEventListener('focus', show_gts)
  1214. tagBox.addEventListener('click', show_gts)
  1215. tagBox.addEventListener('keyup', (event) => {
  1216. if (event.code !== 'Escape') {
  1217. draw_currenttagsarea()
  1218. }
  1219. })
  1220. register_hotkeys('favorite')
  1221. register_hotkeys('preset')
  1222. register_hotkeys('show_indices')
  1223. draw_currenttagsarea()
  1224. // watch for value change in the tagBox
  1225. observe_element(tagBox, 'value', (_) => {
  1226. draw_currenttagsarea()
  1227. })
  1228. }
  1229.  
  1230. if (isUploadPage) {
  1231. const observerTarget = document.querySelector('#dynamic_form')
  1232. let observer = new MutationObserver(init)
  1233. const observerConfig = {childList: true, attributes: false, subtree: false}
  1234. if (document.readyState === "loading") {
  1235. document.addEventListener("DOMContentLoaded", init)
  1236. observer.observe(observerTarget, observerConfig)
  1237. } else {
  1238. init()
  1239. observer.observe(observerTarget, observerConfig)
  1240. }
  1241. } else {
  1242. init()
  1243. if (isSearchPage || isRequestPage) {
  1244. document.querySelector('.cat_list').addEventListener('change', e => {
  1245. if (e.target.checked) {
  1246. init()
  1247. }
  1248. })
  1249. }
  1250. else if (isCreateRequestPage) { // it doesn't use dynamic form
  1251. init()
  1252. document.getElementById('categories').addEventListener('change', () => {
  1253. init()
  1254. })
  1255. }
  1256. }
  1257. } else {
  1258. let hotkeys = (GM_getValue('gts_hotkeys')) || defaultHotkeys
  1259. let hotkeyPrefixes = (GM_getValue('gts_hotkey_prefixes')) || defaulthotkeyPrefixes
  1260.  
  1261. GM_addStyle(`
  1262. #gts-save-settings {
  1263. min-width: 200px;
  1264. }
  1265. .gts-hotkey-grid {
  1266. display: grid;
  1267. column-gap: 1em;
  1268. grid-template-columns: repeat(2, fit-content(400px)) 1fr;
  1269. }
  1270. .gts-hotkey-grid h1 {
  1271. font-size: 1.1em;
  1272. }
  1273. .gts-hotkey-col div {
  1274. margin-bottom: 0.25em;
  1275. }
  1276. `)
  1277.  
  1278. async function init() {
  1279. let colhead = document.createElement('tr')
  1280. colhead.classList.add('colhead_dark')
  1281. colhead.innerHTML = '<td colspan="2" id="ggn-tag-selector"><strong>GGn Tag Selector</strong></span>'
  1282. const lastTr = document.querySelector('#userform > table > tbody > tr:last-child')
  1283. lastTr.before(colhead)
  1284. let hotkeyTr = document.createElement('tr')
  1285. let html = `
  1286. <td class="label"><strong>Hotkeys</strong></td>
  1287. <td class="gts-hotkey-grid">
  1288. `
  1289. for (const [type, cHotkeys] of Object.entries(hotkeys)) {
  1290. html += `<div class="gts-hotkey-col"><h1>${titlecase(type)}</h1>`
  1291. for (const [idx, hotkey] of cHotkeys.entries()) {
  1292. html += `<div>${idx + 1}. <input class="gts-settings" data-gts-settings="gts_hotkeys:${type}-${idx}" value="${hotkey}"></div>`
  1293. }
  1294. html += `</div>`
  1295. }
  1296. html += `<div class="gts-hotkey-col">
  1297. <h1>Index peeker</h1>
  1298. 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).
  1299. Use the key along with a digit (1-9) to add the tag according to the index.
  1300. Note that peeking/adding by index will not work if the filter query is empty.
  1301. <h1>How to set combos/keys</h1>
  1302. 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>
  1303. <ul>
  1304. <li>Modifier keys: shift, alt, ctrl, cmd</li>
  1305. <li>Numbers: digit1, digit2, digit3, digit4, digit5, digit6, digit7, digit8, digit9</li>
  1306. <li>Alphabet: a, b, c, d, (etc.)</li>
  1307. </ul>
  1308. <div style="margin-top: 1em;">
  1309. 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>.
  1310. </div>
  1311. </div>`
  1312. html += `</td>`
  1313. html += `<div style="margin-left: 1em;"><input type="button" id="gts-save-settings" value="Save GGn Tag Selector settings">
  1314. <input type="button" id="gts-restore-settings" value="Restore Defaults"></div>`
  1315. hotkeyTr.innerHTML = html
  1316. colhead.after(hotkeyTr)
  1317. document.querySelector('#gts-save-settings').addEventListener('click', (event) => {
  1318. const originalText = event.target.value
  1319. let newData = {
  1320. 'gts_hotkeys': hotkeys,
  1321. 'gts_hotkey_prefixes': hotkeyPrefixes
  1322. }
  1323. event.target.value = 'Saving ...'
  1324. document.querySelectorAll('.gts-settings').forEach((el) => {
  1325. const meta = el.getAttribute('data-gts-settings')
  1326. const rawValue = el.value
  1327. const [settingKey, settingSubKey] = meta.split(':')
  1328. if (settingKey === 'gts_hotkey_prefixes') {
  1329. newData[settingKey][settingSubKey] = normalise_combo_string(rawValue)
  1330. } else if (settingKey === 'gts_hotkeys') {
  1331. const [type, idx] = settingSubKey.split('-')
  1332. // normalise the value
  1333. newData[settingKey][type][idx] = normalise_combo_string(rawValue)
  1334. }
  1335. })
  1336. let promises = []
  1337. for (const [key, value] of Object.entries(newData)) {
  1338. promises.push(GM.setValue(key, value))
  1339. }
  1340. Promise.all(promises).then(() => {
  1341. event.target.value = 'Saved!'
  1342. setTimeout(() => {
  1343. event.target.value = originalText
  1344. }, 500)
  1345. })
  1346. })
  1347. document.querySelector('#gts-restore-settings').addEventListener('click', () => {
  1348. let defaults = {
  1349. 'gts_hotkeys': defaultHotkeys,
  1350. 'gts_hotkey_prefixes': hotkeyPrefixes
  1351. }
  1352. document.querySelectorAll('.gts-settings').forEach((el) => {
  1353. const meta = el.getAttribute('data-gts-settings')
  1354. const [settingKey, settingSubKey] = meta.split(':')
  1355. if (settingKey === 'gts_hotkey_prefixes') {
  1356. el.value = defaults[settingKey][settingSubKey]
  1357. } else if (settingKey === 'gts_hotkeys') {
  1358. const [type, idx] = settingSubKey.split('-')
  1359. el.value = defaults[settingKey][type][idx]
  1360. }
  1361. })
  1362. })
  1363.  
  1364. if (window.location.hash.substring(1) === 'ggn-tag-selector') {
  1365. document.querySelector('#ggn-tag-selector').scrollIntoView()
  1366. }
  1367. }
  1368.  
  1369. if (document.readyState === "loading") {
  1370. document.addEventListener("DOMContentLoaded", init)
  1371. } else {
  1372. init()
  1373. }
  1374. }