[GMT] Tags Helper

Improvements for working with groups of tags + increased efficiency of new requests creation

当前为 2021-09-26 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name [GMT] Tags Helper
  3. // @version 1.01.6
  4. // @author Anakunda
  5. // @copyright 2021, Anakunda (https://greasyfork.org/users/321857-anakunda)
  6. // @license GPL-3.0-or-later
  7. // @namespace https://greasyfork.org/users/321857-anakunda
  8. // @run-at document-end
  9. // @iconURL https://i.ibb.co/ws8w9Jc/Tag-3-icon.png
  10. // @match https://*/artist.php?id=*
  11. // @match https://*/artist.php?*&id=*
  12. // @match https://*/requests.php
  13. // @match https://*/requests.php?submit=true&*
  14. // @match https://*/requests.php?type=*
  15. // @match https://*/requests.php?page=*
  16. // @match https://*/requests.php?action=new*
  17. // @match https://*/requests.php?action=view&id=*
  18. // @match https://*/requests.php?action=view&*&id=*
  19. // @match https://*/requests.php?action=edit&id=*
  20. // @match https://*/torrents.php?id=*
  21. // @match https://*/torrents.php
  22. // @match https://*/torrents.php?action=advanced
  23. // @match https://*/torrents.php?action=advanced&*
  24. // @match https://*/torrents.php?*&action=advanced
  25. // @match https://*/torrents.php?*&action=advanced&*
  26. // @match https://*/torrents.php?action=basic
  27. // @match https://*/torrents.php?action=basic&*
  28. // @match https://*/torrents.php?*&action=basic
  29. // @match https://*/torrents.php?*&action=basic&*
  30. // @match https://*/torrents.php?page=*
  31. // @match https://*/torrents.php?action=notify
  32. // @match https://*/torrents.php?action=notify&*
  33. // @match https://*/torrents.php?type=*
  34. // @match https://*/collages.php?id=*
  35. // @match https://*/collages.php?page=*&id=*
  36. // @match https://*/collages.php?action=new
  37. // @match https://*/collages.php?action=edit&collageid=*
  38. // @match https://*/bookmarks.php?type=*
  39. // @match https://*/bookmarks.php?page=*
  40. // @match https://*/upload.php
  41. // @match https://*/upload.php?url=*
  42. // @match https://*/upload.php?tags=*
  43. // @match https://*/bookmarks.php?type=torrents
  44. // @match https://*/bookmarks.php?page=*&type=torrents
  45. // @match https://*/top10.php
  46. // @match https://*/top10.php?*
  47. // @grant GM_getValue
  48. // @grant GM_setClipboard
  49. // @grant GM_registerMenuCommand
  50. // @grant GM_getValue
  51. // @grant GM_setValue
  52. // @grant GM_deleteValue
  53. // @require https://openuserjs.org/src/libs/Anakunda/xhrLib.min.js
  54. // @require https://openuserjs.org/src/libs/Anakunda/libLocks.min.js
  55. // @require https://openuserjs.org/src/libs/Anakunda/gazelleApiLib.min.js
  56. // @require https://openuserjs.org/src/libs/Anakunda/QobuzLib.min.js
  57. // @require https://openuserjs.org/src/libs/Anakunda/GazelleTagManager.min.js
  58. // @description Improvements for working with groups of tags + increased efficiency of new requests creation
  59. // ==/UserScript==
  60.  
  61. (function() {
  62. 'use strict';
  63.  
  64. const hasStyleSheet = styleSheet => document.styleSheetSets && document.styleSheetSets.contains(styleSheet);
  65. const isLightTheme = ['postmod', 'shiro', 'layer_cake', 'proton', 'red_light'].some(hasStyleSheet);
  66. const isDarkTheme = ['kuro', 'minimal', 'red_dark'].some(hasStyleSheet);
  67. const uriTest = /^(https?:\/\/.+)$/i;
  68. const isFirefox = /\b(?:Firefox)\b/.test(navigator.userAgent) || Boolean(window.InstallTrigger);
  69. const fieldNames = ['tags', 'tagname', 'taglist'];
  70. const exclusions = GM_getValue('exclusions', [
  71. '/^(?:\\d{4}s)$/i',
  72. '/^(?:delete\.this\.tag|staff\.recs|freely\.available)$/i',
  73. ]).map(function(expr) {
  74. const m = /^\/(.+)\/([dgimsuy]*)$/i.exec(expr);
  75. if (m != null) try { return new RegExp(m[1], m[2]) } catch(e) { console.warn(e) }
  76. }).filter(it => it instanceof RegExp);
  77.  
  78. const getTagsFromIterable = iterable => Array.from(iterable)
  79. .filter(elem => elem.offsetWidth > 0 && elem.offsetHeight > 0 && elem.pathname && elem.search
  80. && fieldNames.some(URLSearchParams.prototype.has.bind(new URLSearchParams(elem.search))))
  81. .map(elem => elem.textContent.trim().toLowerCase())
  82. .filter(tag => /^([a-z\d\.]+)$/.test(tag) && !exclusions.some(rx => rx.test(tag)));
  83.  
  84. const contextId = 'cae67c72-9aa7-4b96-855e-73cb23f5c7f8';
  85. let menuHooks = 0, menuInvoker;
  86.  
  87. function createMenu() {
  88. const menu = document.createElement('menu');
  89. menu.type = 'context';
  90. menu.id = contextId;
  91. menu.className = 'tags-helper';
  92.  
  93. function addMenuItem(label, callback) {
  94. if (label) {
  95. let menuItem = document.createElement('MENUITEM');
  96. menuItem.label = label;
  97. if (typeof callback == 'function') menuItem.onclick = callback;
  98. menu.append(menuItem);
  99. }
  100. return menu.children.length;
  101. }
  102.  
  103. addMenuItem('Copy tags to clipboard', function(evt) {
  104. console.assert(menuInvoker instanceof HTMLElement, 'menuInvoker instanceof HTMLElement')
  105. if (!(menuInvoker instanceof HTMLElement)) throw 'Invalid invoker';
  106. const tags = getTagsFromIterable(menuInvoker.getElementsByTagName('A'));
  107. if (tags.length > 0) GM_setClipboard(tags.join(', '), 'text');
  108. });
  109. addMenuItem('Make new request using these tags', function(evt) {
  110. console.assert(menuInvoker instanceof HTMLElement, 'menuInvoker instanceof HTMLElement')
  111. if (!(menuInvoker instanceof HTMLElement)) throw 'Invalid invoker';
  112. const tags = getTagsFromIterable(menuInvoker.getElementsByTagName('A'));
  113. if (tags.length > 0) document.location.assign('/requests.php?' + new URLSearchParams({
  114. action: 'new',
  115. tags: JSON.stringify(tags),
  116. }).toString());
  117. });
  118. addMenuItem('Make new upload using these tags', function(evt) {
  119. console.assert(menuInvoker instanceof HTMLElement, 'menuInvoker instanceof HTMLElement')
  120. if (!(menuInvoker instanceof HTMLElement)) throw 'Invalid invoker';
  121. const tags = getTagsFromIterable(menuInvoker.getElementsByTagName('A'));
  122. if (tags.length > 0) document.location.assign('/upload.php?' + new URLSearchParams({
  123. tags: JSON.stringify(tags),
  124. }).toString());
  125. });
  126. document.body.append(menu);
  127. }
  128.  
  129. function setElemHandlers(elem, textCallback) {
  130. console.assert(elem instanceof HTMLElement);
  131. elem.addEventListener('click', function(evt) {
  132. if (evt.altKey) evt.preventDefault(); else return;
  133. const tags = getTagsFromIterable(evt.currentTarget.getElementsByTagName('A'));
  134. if (tags.length > 0) if (evt.ctrlKey) document.location.assign('/requests.php?' + new URLSearchParams({
  135. action: 'new',
  136. tags: JSON.stringify(tags)
  137. }).toString()); else if (evt.shiftKey) document.location.assign('/upload.php?' + new URLSearchParams({
  138. tags: JSON.stringify(tags)
  139. }).toString()); else {
  140. GM_setClipboard(tags.join(', '), 'text');
  141. evt.currentTarget.style.backgroundColor = isDarkTheme ? 'darkgreen' : 'lightgreen';
  142. setTimeout(elem => { elem.style.backgroundColor = null }, 1000, evt.currentTarget);
  143. }
  144. return false;
  145. });
  146. elem.ondragover = evt => false;
  147. elem.ondragenter = evt => { evt.currentTarget.style.backgroundColor = 'lawngreen' };
  148. elem[isFirefox ? 'ondragexit' : 'ondragleave'] = evt => { evt.currentTarget.style.backgroundColor = null };
  149. elem.draggable = true;
  150. elem.ondragstart = function(evt) {
  151. //evt.dataTransfer.clearData('text/uri-list');
  152. //evt.dataTransfer.clearData('text/x-moz-url');
  153. evt.dataTransfer.setData('text/plain',
  154. getTagsFromIterable(evt.currentTarget.getElementsByTagName('A')).join(', '));
  155. console.debug(evt.currentTarget, evt.currentTarget.getElementsByTagName('A'),
  156. getTagsFromIterable(evt.currentTarget.getElementsByTagName('A')));
  157. };
  158. elem.ondrop = function(evt) {
  159. evt.stopPropagation();
  160. let links = evt.dataTransfer.getData('text/uri-list');
  161. if (links) links = links.split(/\r?\n/); else {
  162. links = evt.dataTransfer.getData('text/x-moz-url');
  163. if (links) links = links.split(/\r?\n/).filter((item, ndx) => ndx % 2 == 0);
  164. else if (links = evt.dataTransfer.getData('text/plain'))
  165. links = links.split(/\r?\n/).filter(RegExp.prototype.test.bind(uriTest));
  166. }
  167. if (Array.isArray(links) && links.length > 0) {
  168. const tags = getTagsFromIterable(evt.currentTarget.getElementsByTagName('A'));
  169. if (tags.length > 0) if (evt.shiftKey) document.location.assign('/upload.php?' + new URLSearchParams({
  170. url: links[0],
  171. tags: JSON.stringify(tags),
  172. }).toString()); else document.location.assign('/requests.php?' + new URLSearchParams({
  173. action: 'new',
  174. url: links[0],
  175. tags: JSON.stringify(tags),
  176. }).toString());
  177. } else if (typeof textCallback == 'function' && (links = evt.dataTransfer.getData('text/plain'))
  178. //&& (links = links.split(/[\r\n\,\;\|\>]+/).map(expr => expr.trim()).filter(Boolean)).length > 0
  179. && (links = new TagManager(links)).length > 0) textCallback(evt, links);
  180. evt.currentTarget.style.backgroundColor = null;
  181. return false;
  182. };
  183. elem.setAttribute('contextmenu', contextId);
  184. elem.oncontextmenu = evt => { menuInvoker = evt.currentTarget };
  185. elem.style.cursor = 'context-menu';
  186. ++menuHooks;
  187. elem.title = `Alt + click => copy tags to clipboard
  188. Ctrl + Alt + click => make new request using these tags
  189. Shift + Alt + click => make new upload using these tags
  190. ---
  191. Drag & drop active link here => make new request using these tags
  192. Shift + Drag & drop active link here => make new upload using these tags
  193. Drag this tags area and drop to any text input to get inserted all tags as comma-separated list
  194. --or-- use context menu (older browsers only)`;
  195. }
  196.  
  197. switch (document.location.pathname) {
  198. case '/torrents.php': {
  199. const urlParams = new URLSearchParams(document.location.search);
  200. if (urlParams.has('id')) try {
  201. let tags = urlParams.get('tags');
  202. if (tags && (tags = JSON.parse(tags)).length > 0) {
  203. const input = document.getElementById('tagname');
  204. if (input == null) throw 'Tags input not found';
  205. tags = new TagManager(...tags);
  206. input.value = tags.toString();
  207. input.scrollIntoView({ behavior: 'smooth', block: 'start' });
  208. //if (input.nextElementSibling != null) input.nextElementSibling.click();
  209. }
  210. } catch(e) { console.warn(e) }
  211. break;
  212. }
  213. case '/requests.php':
  214. case '/upload.php': {
  215. const urlParams = new URLSearchParams(document.location.search);
  216. try {
  217. let tags = urlParams.get('tags');
  218. if (tags && (tags = JSON.parse(tags)).length > 0) {
  219. const input = document.getElementById('tags');
  220. if (input == null) throw 'Tags input not found';
  221. tags = new TagManager(...tags);
  222. input.value = tags.toString();
  223. }
  224. } catch(e) { }
  225. const url = urlParams.get('url');
  226. if (uriTest.test(url)) {
  227. let ua = document.getElementById('ua-data');
  228. function feedData() {
  229. ua.value = url;
  230. if ((ua = document.getElementById('autofill-form-2')) == null) return; // assertion failed
  231. if (typeof ua.onclick == 'function') ua.onclick(); else ua.click();
  232. }
  233. if (ua != null) feedData(); else {
  234. const container = document.querySelector('form#request_form > table > tbody');
  235. if (container != null) {
  236. let counters = [0, 0], timeStamp = Date.now();
  237. const mo = new MutationObserver(function(mutationsList) {
  238. ++counters[0];
  239. for (let mutation of mutationsList) for (let node of mutation.addedNodes) {
  240. ++counters[1];
  241. if (node.nodeName != 'TR' || (ua = node.querySelector('textarea#ua-data')) == null) continue;
  242. console.log('Found UA data by trigger:', counters, (Date.now() - timeStamp) / 1000);
  243. clearTimeout(timer);
  244. return feedData();
  245. }
  246. }), timer = setTimeout(mo => { mo.disconnect() }, 10000, mo);
  247. mo.observe(container, { childList: true });
  248. }
  249. }
  250. }
  251. break;
  252. }
  253. }
  254.  
  255. document.body.querySelectorAll('div.tags').forEach(div => { setElemHandlers(div, function(evt, tags) {
  256. const a = evt.currentTarget.parentNode.querySelector('a[href*="torrents.php?id="]');
  257. if (a == null) return false;
  258. if (evt.ctrlKey && ajaxApiKey) {
  259. const tagsElement = evt.currentTarget, groupId = parseInt(new URLSearchParams(a.search).get('id')) || undefined;
  260. if (groupId) queryAjaxAPI('addtag', { groupid: groupId }, { tagname: tags.toString() }).then(function(response) {
  261. console.log(response);
  262. if (!['added', 'voted'].some(key => response[key].length > 0)) return;
  263. queryAjaxAPI('torrentgroup', { id: groupId }).then(function(response) {
  264. if (!response.group.tags) {
  265. document.location.reload();
  266. return;
  267. }
  268. const urlParams = new URLSearchParams(tagsElement.childElementCount > 0 ? tagsElement.children[0].search : {
  269. action: 'advanced',
  270. searchsubmit: 1,
  271. });
  272. while (tagsElement.childNodes.length > 0) tagsElement.removeChild(tagsElement.childNodes[0]);
  273. for (let tag of response.group.tags) {
  274. if (tagsElement.childElementCount > 0) tagsElement.append(', ');
  275. const a = document.createElement('A');
  276. for (let param of fieldNames) if (urlParams.has(param)) urlParams.set(param, a.textContent = tag);
  277. a.setAttribute('href', 'torrents.php?' + urlParams.toString());
  278. tagsElement.append(a);
  279. }
  280. });
  281. });
  282. } else {
  283. const url = new URL(a);
  284. url.searchParams.set('tags', JSON.stringify(tags));
  285. document.location.assign(url);
  286. }
  287. }) });
  288.  
  289. (function() {
  290. const tagsBox = document.body.querySelector('div.box_tags');
  291. const groupId = document.location.pathname == '/torrents.php'
  292. && parseInt(new URLSearchParams(document.location.search).get('id')) || undefined;
  293. if (tagsBox != null) setElemHandlers(tagsBox, function(evt, tags) {
  294. function fallBack() {
  295. const input = document.getElementById('tagname');
  296. if (input == null) throw 'Tags input not found';
  297. input.value = tags.toString();
  298. input.scrollIntoView({ behavior: 'smooth', block: 'start' });
  299. //if (input.nextElementSibling != null) input.nextElementSibling.click();
  300. }
  301.  
  302. if (!groupId) return fallBack();
  303. if (ajaxApiKey) {
  304. const tagsElement = evt.currentTarget.querySelector('ul') || evt.currentTarget.querySelector('ol');
  305. if (tagsElement != null) queryAjaxAPI('addtag', { groupid: groupId }, { tagname: tags.toString() }).then(function(response) {
  306. console.log(response);
  307. if (['added', 'voted'].some(key => response[key].length > 0)) document.location.reload();
  308. // queryAjaxAPI('torrentgroup', { id: groupId }).then(function(response) {
  309. // if (!response.group.tags) {
  310. // document.location.reload();
  311. // return;
  312. // }
  313. // let a = tagsElement.querySelector('li > a');
  314. // const urlParams = new URLSearchParams(a != null ? a.search : undefined);
  315. // while (tagsElement.childNodes.length > 0) tagsElement.removeChild(tagsElement.childNodes[0]);
  316. // for (let tag of response.group.tags) {
  317. // urlParams.set('taglist', (a = document.createElement('A')).textContent = tag);
  318. // a.setAttribute('href', 'torrents.php?' + encodeURIComponent(urlParams.toString()));
  319. // const li = document.createElement('LI');
  320. // li.append(a);
  321. // tagsElement.append(li);
  322. // }
  323. // });
  324. }); else fallBack();
  325. } else fallBack();
  326. }); else return;
  327. for (let a of tagsBox.querySelectorAll('* > li > div > span > a')) {
  328. if (new URLSearchParams(a.search).get('action') != 'delete_tag') continue;
  329. a.onclick = function(evt) {
  330. const currentTarget = evt.currentTarget;
  331. localXHR(evt.currentTarget.href, { responseType: null }).then(function() {
  332. currentTarget.parentNode.parentNode.parentNode.remove();
  333. }, alert);
  334. return false;
  335. };
  336. }
  337. const head = tagsBox.querySelector('div.head');
  338. if (head == null) return;
  339. let span = document.createElement('SPAN'), a = document.createElement('A');
  340. span.style = 'float: right; margin-left: 6pt;';
  341. span.title = 'Edit the tags in batch';
  342. a.className = 'brackets';
  343. a.textContent = 'Edit';
  344. a.href = '#';
  345. a.onclick = function(evt) {
  346. tagsBox.draggable = false;
  347. tagsBox.ondrop = tagsBox.onpaste = null;
  348. const tags = Array.from(tagsBox.querySelectorAll(':scope > ul > li')).map(function(li) {
  349. const tagInfo = {
  350. name: li.querySelector(':scope > a'),
  351. id: li.querySelector(':scope > div > span > a'),
  352. }
  353. if (tagInfo.name == null || tagInfo.id == null) throw 'Asserion failed';
  354. tagInfo.id = new URLSearchParams(tagInfo.id.search);
  355. if (tagInfo.id.get('action') != 'delete_tag'
  356. || !(tagInfo.id = parseInt(tagInfo.id.get('tagid')))) throw 'Asserion failed';
  357. tagInfo.name = tagInfo.name.textContent.trim();
  358. return tagInfo;
  359. });
  360. evt.currentTarget.parentNode.hidden = true;
  361. evt.currentTarget.parentNode.nextElementSibling.hidden = true;
  362. tagsBox.removeAttribute('title');
  363. let elem = tagsBox.querySelector(':scope > ul');
  364. if (elem != null) elem.hidden = true;
  365. const form = document.createElement('FORM');
  366. elem = document.createElement('TEXTAREA');
  367. elem.id = 'tags-edit';
  368. elem.style = 'width: 90%; height: 6em; margin: 6pt; resize: vertical;';
  369. elem.value = tags.map(tag => tag.name).join('\n');
  370. elem.ondrop = function(evt) {
  371. evt.stopPropagation();
  372. let data = evt.dataTransfer.getData('text/plain').split(/(?:\r?\n)+/);
  373. if (!data) return;
  374. const tags = new TagManager(...evt.currentTarget.value.split(/(?:\r?\n)+/));
  375. tags.add(...data);
  376. evt.currentTarget.value = tags.join('\n');
  377. tagsBox.style.backgroundColor = null;
  378. return false;
  379. };
  380. elem.onpaste = function(evt) {
  381. evt.stopPropagation();
  382. let data = evt.clipboardData.getData('text/plain').split(/(?:\r?\n)+/);
  383. if (!data) return;
  384. let tags = new TagManager(...data);
  385. tags = tags.join('\n');
  386. const cursor = evt.currentTarget.selectionStart + tags.length;
  387. evt.currentTarget.value = evt.currentTarget.value.slice(0, evt.currentTarget.selectionStart) +
  388. tags + evt.currentTarget.value.slice(evt.currentTarget.selectionEnd);
  389. evt.currentTarget.selectionEnd = evt.currentTarget.selectionStart = cursor;
  390. return false;
  391. };
  392. form.append(elem);
  393. elem = document.createElement('INPUT');
  394. elem.type = 'submit';
  395. elem.style = 'margin: 0 6pt 6pt 6pt;';
  396. elem.value = 'Update';
  397. form.append(elem);
  398. form.onsubmit = function(evt) {
  399. let userAuth = document.body.querySelector('input[name="auth"]');
  400. if (userAuth != null) userAuth = userAuth.value;
  401. else throw 'Assertion failed: User auth could not be located';
  402. let newTags = document.getElementById('tags-edit');
  403. if (newTags != null) newTags = new TagManager(...newTags.value.split(/(?:\r?\n)+/));
  404. const workers = [ ], deleteTags = tags.filter(tag => !newTags.includes(tag.name)),
  405. addTags = Array.from(newTags).filter(tag => !tags.some(_tag => _tag.name == tag));
  406. if (addTags.length > 0) workers.push(localXHR('/torrents.php', { responseType: null }, new URLSearchParams({
  407. action: 'add_tag',
  408. groupid: groupId,
  409. tagname: addTags.join(', '),
  410. auth: userAuth,
  411. })));
  412. if (deleteTags.length > 0) Array.prototype.push.apply(workers, deleteTags.map(tag => localXHR('/torrents.php?' + new URLSearchParams({
  413. action: 'delete_tag',
  414. groupid: groupId,
  415. tagid: tag.id,
  416. auth: userAuth,
  417. }), { responseType: null })));
  418. if (workers.length > 0) Promise.all(workers).then(() => { document.location.reload() });
  419. return false;
  420. };
  421. tagsBox.append(form);
  422. return false;
  423. };
  424. span.append(a);
  425. head.append(span);
  426. span = document.createElement('SPAN'), a = document.createElement('A');
  427. span.style = 'float: right;';
  428. a.className = 'brackets';
  429. a.textContent = 'Copy';
  430. a.href = '#';
  431. a.onclick = function(evt) {
  432. let tags = getTagsFromIterable(tagsBox.querySelectorAll('* > li > a'));
  433. if (tags.length <= 0) return false;
  434. GM_setClipboard(tags.join(', '), 'text');
  435. evt.currentTarget.style.color = isDarkTheme ? 'darkgreen' : 'lightgreen';
  436. setTimeout(elem => {elem.style.color = null }, 1000, evt.currentTarget);
  437. return false;
  438. };
  439. span.append(a);
  440. head.append(span);
  441. })();
  442.  
  443. if (menuHooks > 0) createMenu();
  444.  
  445. function inputDataHandler(evt) {
  446. switch (evt.type) {
  447. case 'paste': var tags = evt.clipboardData; break;
  448. case 'drop': tags = evt.dataTransfer; break;
  449. }
  450. if (tags) tags = tags.getData('text/plain'); else return;
  451. //if (tags) tags = tags.split(/[\r\n\;\|\>]+|,(?:\s*&)?/).map(expr => expr.trim()).filter(Boolean); else return;
  452. if (tags.length > 0) switch (evt.type) {
  453. case 'paste': tags = new TagManager(tags); break;
  454. case 'drop': tags = new TagManager(evt.currentTarget.value, tags); break;
  455. } else return;
  456. if (tags.length > 0) tags = tags.toString(); else return;
  457. evt.stopPropagation();
  458. switch (evt.type) {
  459. case 'paste': {
  460. const cursor = evt.currentTarget.selectionStart + tags.toString().length;
  461. evt.currentTarget.value = evt.currentTarget.value.slice(0, evt.currentTarget.selectionStart) +
  462. tags.toString() + evt.currentTarget.value.slice(evt.currentTarget.selectionEnd);
  463. evt.currentTarget.selectionEnd = evt.currentTarget.selectionStart = cursor;
  464. break;
  465. }
  466. case 'drop': evt.currentTarget.value = tags; break;
  467. }
  468. return false;
  469. }
  470.  
  471. for (let input of document.body.querySelectorAll(fieldNames.map(name => `input[name="${name}"]`).join(', ')))
  472. input.onpaste = input.ondrop = inputDataHandler;
  473. })();