// ==UserScript==
// @name [GMT] Tags Helper
// @version 1.01.8
// @author Anakunda
// @copyright 2021, Anakunda (https://gf.qytechs.cn/users/321857-anakunda)
// @license GPL-3.0-or-later
// @namespace https://gf.qytechs.cn/users/321857-anakunda
// @run-at document-end
// @iconURL https://i.ibb.co/ws8w9Jc/Tag-3-icon.png
// @match https://*/artist.php?id=*
// @match https://*/artist.php?*&id=*
// @match https://*/requests.php
// @match https://*/requests.php?submit=true&*
// @match https://*/requests.php?type=*
// @match https://*/requests.php?page=*
// @match https://*/requests.php?action=new*
// @match https://*/requests.php?action=view&id=*
// @match https://*/requests.php?action=view&*&id=*
// @match https://*/requests.php?action=edit&id=*
// @match https://*/torrents.php?id=*
// @match https://*/torrents.php
// @match https://*/torrents.php?action=advanced
// @match https://*/torrents.php?action=advanced&*
// @match https://*/torrents.php?*&action=advanced
// @match https://*/torrents.php?*&action=advanced&*
// @match https://*/torrents.php?action=basic
// @match https://*/torrents.php?action=basic&*
// @match https://*/torrents.php?*&action=basic
// @match https://*/torrents.php?*&action=basic&*
// @match https://*/torrents.php?page=*
// @match https://*/torrents.php?action=notify
// @match https://*/torrents.php?action=notify&*
// @match https://*/torrents.php?type=*
// @match https://*/collages.php?id=*
// @match https://*/collages.php?page=*&id=*
// @match https://*/collages.php?action=new
// @match https://*/collages.php?action=edit&collageid=*
// @match https://*/bookmarks.php?type=*
// @match https://*/bookmarks.php?page=*
// @match https://*/upload.php
// @match https://*/upload.php?url=*
// @match https://*/upload.php?tags=*
// @match https://*/bookmarks.php?type=torrents
// @match https://*/bookmarks.php?page=*&type=torrents
// @match https://*/top10.php
// @match https://*/top10.php?*
// @grant GM_getValue
// @grant GM_setClipboard
// @grant GM_registerMenuCommand
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @require https://openuserjs.org/src/libs/Anakunda/xhrLib.min.js
// @require https://openuserjs.org/src/libs/Anakunda/libLocks.min.js
// @require https://openuserjs.org/src/libs/Anakunda/gazelleApiLib.min.js
// @require https://openuserjs.org/src/libs/Anakunda/QobuzLib.min.js
// @require https://openuserjs.org/src/libs/Anakunda/GazelleTagManager.min.js
// @description Improvements for working with groups of tags + increased efficiency of new requests creation
// ==/UserScript==
'use strict';
function hasStyleSheet(name) {
if (name) name = name.toLowerCase(); else throw 'Invalid argument';
const hrefRx = new RegExp('\\/' + name + '\\b', 'i');
if (document.styleSheets) for (let styleSheet of document.styleSheets)
if (styleSheet.title && styleSheet.title.toLowerCase() == name) return true;
else if (styleSheet.href && hrefRx.test(styleSheet.href)) return true;
return false;
}
const isLightTheme = ['postmod', 'shiro', 'layer_cake', 'proton', 'red_light'].some(hasStyleSheet);
if (isLightTheme) console.log('Light Gazelle theme detected');
const isDarkTheme = ['kuro', 'minimal', 'red_dark'].some(hasStyleSheet);
if (isDarkTheme) console.log('Dark Gazelle theme detected');
const uriTest = /^(https?:\/\/.+)$/i;
const isFirefox = /\b(?:Firefox)\b/.test(navigator.userAgent) || Boolean(window.InstallTrigger);
const fieldNames = ['tags', 'tagname', 'taglist'];
const exclusions = GM_getValue('exclusions', [
'/^(?:\\d{4}s)$/i',
'/^(?:delete\.this\.tag|staff\.recs|freely\.available)$/i',
]).map(function(expr) {
const m = /^\/(.+)\/([dgimsuy]*)$/i.exec(expr);
if (m != null) try { return new RegExp(m[1], m[2]) } catch(e) { console.warn(e) }
}).filter(it => it instanceof RegExp);
const stdPasteBehaviour = GM_getValue('std_paste_behavior', true);
const getTagsFromIterable = iterable => Array.from(iterable)
.filter(elem => elem.offsetWidth > 0 && elem.offsetHeight > 0 && elem.pathname && elem.search
&& fieldNames.some(URLSearchParams.prototype.has.bind(new URLSearchParams(elem.search))))
.map(elem => elem.textContent.trim().toLowerCase())
.filter(tag => /^([a-z\d\.]+)$/.test(tag) && !exclusions.some(rx => rx.test(tag)));
const contextId = 'cae67c72-9aa7-4b96-855e-73cb23f5c7f8';
let menuHooks = 0, menuInvoker;
function createMenu() {
const menu = document.createElement('menu');
menu.type = 'context';
menu.id = contextId;
menu.className = 'tags-helper';
function addMenuItem(label, callback) {
if (label) {
let menuItem = document.createElement('MENUITEM');
menuItem.label = label;
if (typeof callback == 'function') menuItem.onclick = callback;
menu.append(menuItem);
}
return menu.children.length;
}
addMenuItem('Copy tags to clipboard', function(evt) {
console.assert(menuInvoker instanceof HTMLElement, 'menuInvoker instanceof HTMLElement')
if (!(menuInvoker instanceof HTMLElement)) throw 'Invalid invoker';
const tags = getTagsFromIterable(menuInvoker.getElementsByTagName('A'));
if (tags.length > 0) GM_setClipboard(tags.join(', '), 'text');
});
addMenuItem('Make new request using these tags', function(evt) {
console.assert(menuInvoker instanceof HTMLElement, 'menuInvoker instanceof HTMLElement')
if (!(menuInvoker instanceof HTMLElement)) throw 'Invalid invoker';
const tags = getTagsFromIterable(menuInvoker.getElementsByTagName('A'));
if (tags.length > 0) document.location.assign('/requests.php?' + new URLSearchParams({
action: 'new',
tags: JSON.stringify(tags),
}).toString());
});
addMenuItem('Make new upload using these tags', function(evt) {
console.assert(menuInvoker instanceof HTMLElement, 'menuInvoker instanceof HTMLElement')
if (!(menuInvoker instanceof HTMLElement)) throw 'Invalid invoker';
const tags = getTagsFromIterable(menuInvoker.getElementsByTagName('A'));
if (tags.length > 0) document.location.assign('/upload.php?' + new URLSearchParams({
tags: JSON.stringify(tags),
}).toString());
});
document.body.append(menu);
}
function setElemHandlers(elem, textCallback) {
console.assert(elem instanceof HTMLElement);
elem.addEventListener('click', function(evt) {
if (evt.altKey) evt.preventDefault(); else return;
const tags = getTagsFromIterable(evt.currentTarget.getElementsByTagName('A'));
if (tags.length > 0) if (evt.ctrlKey) document.location.assign('/requests.php?' + new URLSearchParams({
action: 'new',
tags: JSON.stringify(tags)
}).toString()); else if (evt.shiftKey) document.location.assign('/upload.php?' + new URLSearchParams({
tags: JSON.stringify(tags)
}).toString()); else {
GM_setClipboard(tags.join(', '), 'text');
evt.currentTarget.style.backgroundColor = isDarkTheme ? 'darkgreen' : 'lightgreen';
setTimeout(elem => { elem.style.backgroundColor = null }, 1000, evt.currentTarget);
}
return false;
});
elem.ondragover = evt => false;
elem.ondragenter = evt => { evt.currentTarget.style.backgroundColor = 'lawngreen' };
elem[isFirefox ? 'ondragexit' : 'ondragleave'] = evt => { evt.currentTarget.style.backgroundColor = null };
elem.draggable = true;
elem.ondragstart = function(evt) {
//evt.dataTransfer.clearData('text/uri-list');
//evt.dataTransfer.clearData('text/x-moz-url');
evt.dataTransfer.setData('text/plain',
getTagsFromIterable(evt.currentTarget.getElementsByTagName('A')).join(', '));
console.debug(evt.currentTarget, evt.currentTarget.getElementsByTagName('A'),
getTagsFromIterable(evt.currentTarget.getElementsByTagName('A')));
};
elem.ondrop = function(evt) {
evt.stopPropagation();
let links = evt.dataTransfer.getData('text/uri-list');
if (links) links = links.split(/\r?\n/); else {
links = evt.dataTransfer.getData('text/x-moz-url');
if (links) links = links.split(/\r?\n/).filter((item, ndx) => ndx % 2 == 0);
else if (links = evt.dataTransfer.getData('text/plain'))
links = links.split(/\r?\n/).filter(RegExp.prototype.test.bind(uriTest));
}
if (Array.isArray(links) && links.length > 0) {
const tags = getTagsFromIterable(evt.currentTarget.getElementsByTagName('A'));
if (tags.length > 0) if (evt.shiftKey) document.location.assign('/upload.php?' + new URLSearchParams({
url: links[0],
tags: JSON.stringify(tags),
}).toString()); else document.location.assign('/requests.php?' + new URLSearchParams({
action: 'new',
url: links[0],
tags: JSON.stringify(tags),
}).toString());
} else if (typeof textCallback == 'function' && (links = evt.dataTransfer.getData('text/plain'))
//&& (links = links.split(/[\r\n\,\;\|\>]+/).map(expr => expr.trim()).filter(Boolean)).length > 0
&& (links = new TagManager(links)).length > 0) textCallback(evt, links);
evt.currentTarget.style.backgroundColor = null;
return false;
};
elem.setAttribute('contextmenu', contextId);
elem.oncontextmenu = evt => { menuInvoker = evt.currentTarget };
elem.style.cursor = 'context-menu';
++menuHooks;
elem.title = `Alt + click => copy tags to clipboard
Ctrl + Alt + click => make new request using these tags
Shift + Alt + click => make new upload using these tags
---
Drag & drop active link here => make new request using these tags
Shift + Drag & drop active link here => make new upload using these tags
Drag this tags area and drop to any text input to get inserted all tags as comma-separated list
--or-- use context menu (older browsers only)`;
}
function addFormNormalizer(form) {
function submitListener(evt) {
for (let input of evt.currentTarget.getElementsByTagName('INPUT')) {
if (!['text', 'search'].includes(input.type) || !fieldNames.includes(input.name)) continue;
const tags = new TagManager(input.value);
input.value = tags.toString();
}
}
if (form instanceof HTMLFormElement) form.addEventListener('submit', submitListener);
}
switch (document.location.pathname) {
case '/torrents.php': {
const urlParams = new URLSearchParams(document.location.search);
if (urlParams.has('id')) try {
let tags = urlParams.get('tags');
if (tags && (tags = JSON.parse(tags)).length > 0) {
const input = document.getElementById('tagname');
if (input == null) throw 'Tags input not found';
tags = new TagManager(...tags);
input.value = tags.toString();
input.scrollIntoView({ behavior: 'smooth', block: 'start' });
//if (input.nextElementSibling != null) input.nextElementSibling.click();
}
} catch(e) { console.warn(e) }
break;
}
case '/requests.php':
case '/upload.php': {
const urlParams = new URLSearchParams(document.location.search);
try {
let tags = urlParams.get('tags');
if (tags && (tags = JSON.parse(tags)).length > 0) {
const input = document.getElementById('tags');
if (input == null) throw 'Tags input not found';
tags = new TagManager(...tags);
input.value = tags.toString();
}
} catch(e) { }
const url = urlParams.get('url');
if (uriTest.test(url)) {
let ua = document.getElementById('ua-data');
function feedData() {
ua.value = url;
if ((ua = document.getElementById('autofill-form-2')) == null) return; // assertion failed
if (typeof ua.onclick == 'function') ua.onclick(); else ua.click();
}
if (ua != null) feedData(); else {
const container = document.querySelector('form#request_form > table > tbody');
if (container != null) {
let counters = [0, 0], timeStamp = Date.now();
const mo = new MutationObserver(function(mutationsList) {
++counters[0];
for (let mutation of mutationsList) for (let node of mutation.addedNodes) {
++counters[1];
if (node.nodeName != 'TR' || (ua = node.querySelector('textarea#ua-data')) == null) continue;
console.log('Found UA data by trigger:', counters, (Date.now() - timeStamp) / 1000);
clearTimeout(timer);
return feedData();
}
}), timer = setTimeout(mo => { mo.disconnect() }, 10000, mo);
mo.observe(container, { childList: true });
}
}
}
break;
}
}
document.body.querySelectorAll('div.tags').forEach(div => { setElemHandlers(div, function(evt, tags) {
const a = evt.currentTarget.parentNode.querySelector('a[href*="torrents.php?id="]');
if (a == null) return false;
if (evt.ctrlKey && ajaxApiKey) {
const tagsElement = evt.currentTarget, groupId = parseInt(new URLSearchParams(a.search).get('id')) || undefined;
if (groupId) queryAjaxAPI('addtag', { groupid: groupId }, { tagname: tags.toString() }).then(function(response) {
console.log(response);
if (!['added', 'voted'].some(key => response[key].length > 0)) return;
queryAjaxAPI('torrentgroup', { id: groupId }).then(function(response) {
if (!response.group.tags) return document.location.reload();
const urlParams = new URLSearchParams(tagsElement.childElementCount > 0 ? tagsElement.children[0].search : {
action: 'advanced',
searchsubmit: 1,
});
while (tagsElement.childNodes.length > 0) tagsElement.removeChild(tagsElement.childNodes[0]);
for (let tag of response.group.tags) {
if (tagsElement.childElementCount > 0) tagsElement.append(', ');
const a = document.createElement('A');
for (let param of fieldNames) if (urlParams.has(param)) urlParams.set(param, a.textContent = tag);
a.setAttribute('href', 'torrents.php?' + urlParams.toString());
tagsElement.append(a);
}
});
});
} else {
const url = new URL(a);
url.searchParams.set('tags', JSON.stringify(tags));
document.location.assign(url);
}
}) });
(function() {
const tagsBox = document.body.querySelector('div.box_tags');
const groupId = document.location.pathname == '/torrents.php'
&& parseInt(new URLSearchParams(document.location.search).get('id')) || undefined;
if (tagsBox != null) setElemHandlers(tagsBox, function(evt, tags) {
function fallBack() {
const input = document.getElementById('tagname');
if (input == null) throw 'Tags input not found';
input.value = tags.toString();
input.scrollIntoView({ behavior: 'smooth', block: 'start' });
//if (input.nextElementSibling != null) input.nextElementSibling.click();
}
if (!groupId) return fallBack();
if (ajaxApiKey) {
const tagsElement = evt.currentTarget.querySelector('ul') || evt.currentTarget.querySelector('ol');
if (tagsElement != null) queryAjaxAPI('addtag', { groupid: groupId }, { tagname: tags.toString() }).then(function(response) {
console.log(response);
if (['added', 'voted'].some(key => response[key].length > 0)) document.location.reload();
// queryAjaxAPI('torrentgroup', { id: groupId }).then(function(response) {
// if (!response.group.tags) {
// document.location.reload();
// return;
// }
// let a = tagsElement.querySelector('li > a');
// const urlParams = new URLSearchParams(a != null ? a.search : undefined);
// while (tagsElement.childNodes.length > 0) tagsElement.removeChild(tagsElement.childNodes[0]);
// for (let tag of response.group.tags) {
// urlParams.set('taglist', (a = document.createElement('A')).textContent = tag);
// a.setAttribute('href', 'torrents.php?' + encodeURIComponent(urlParams.toString()));
// const li = document.createElement('LI');
// li.append(a);
// tagsElement.append(li);
// }
// });
}); else fallBack();
} else fallBack();
}); else return;
for (let a of tagsBox.querySelectorAll('* > li > div > span > a')) {
if (new URLSearchParams(a.search).get('action') != 'delete_tag') continue;
a.onclick = function(evt) {
const currentTarget = evt.currentTarget;
localXHR(evt.currentTarget.href, { responseType: null }).then(function() {
currentTarget.parentNode.parentNode.parentNode.remove();
}, alert);
return false;
};
}
const head = tagsBox.querySelector('div.head');
if (head == null) return;
let span = document.createElement('SPAN'), a = document.createElement('A');
if (document.location.pathname == '/torrents.php') {
span.style = 'float: right; margin-left: 6pt;';
span.title = 'Edit all tags in one batch';
a.className = 'brackets';
a.textContent = 'Edit';
a.href = '#';
a.onclick = function(evt) {
tagsBox.draggable = false;
tagsBox.ondrop = tagsBox.onpaste = null;
const tags = Array.from(tagsBox.querySelectorAll(':scope > ul > li')).map(function(li) {
const tagInfo = {
name: li.querySelector(':scope > a'),
id: li.querySelector(':scope > div > span > a'),
}
if (tagInfo.name == null || tagInfo.id == null) return;
tagInfo.id = new URLSearchParams(tagInfo.id.search);
if (tagInfo.id.get('action') != 'delete_tag' || !(tagInfo.id = parseInt(tagInfo.id.get('tagid')))) return;
tagInfo.name = tagInfo.name.textContent.trim();
return tagInfo;
}).filter(Boolean);
evt.currentTarget.parentNode.hidden = true;
evt.currentTarget.parentNode.nextElementSibling.hidden = true;
tagsBox.removeAttribute('title');
let elem = tagsBox.querySelector(':scope > ul');
if (elem != null) elem.hidden = true;
const form = document.createElement('FORM');
elem = document.createElement('TEXTAREA');
elem.className = 'noWhutBB';
elem.id = 'tags-edit';
elem.style = 'width: 90%; height: 15em; margin: 6pt; font: 10pt monospace; resize: vertical;';
if (isLightTheme) {
elem.style.color = 'black';
elem.style.backgroundColor = 'cornsilk';
}
if (tags.length > 0) elem.value = tags.map(tag => tag.name).join('\n') + '\n';
elem.spellcheck = false;
elem.ondrop = elem.onpaste = function(evt) {
switch (evt.type) {
case 'paste': var data = evt.clipboardData; break;
case 'drop': data = evt.dataTransfer; break;
}
const multilineParser = text => text.split(/(?:\r?\n)+/)
.map(line => line.trim()).filter(Boolean);
if (!(data = multilineParser(data.getData('text/plain')))) return;
switch (evt.type) {
case 'paste':
if (stdPasteBehaviour) {
tags = new TagManager(...data);
tags = tags.join('\n');
const cursor = evt.currentTarget.selectionStart + tags.length;
evt.currentTarget.value = evt.currentTarget.value.slice(0, evt.currentTarget.selectionStart) +
tags + evt.currentTarget.value.slice(evt.currentTarget.selectionEnd);
evt.currentTarget.selectionEnd = evt.currentTarget.selectionStart = cursor;
break;
}
case 'drop':
var tags = new TagManager(...multilineParser(evt.currentTarget.value.slice(0, evt.currentTarget.selectionStart) +
evt.currentTarget.value.slice(evt.currentTarget.selectionEnd)));
tags.add(...data);
evt.currentTarget.value = tags.length > 0 ? tags.join('\n') + '\n' : '';
tagsBox.style.backgroundColor = null;
break;
}
return false;
};
elem.onkeypress = evt => !evt.ctrlKey || evt.key != 'Enter' || (evt.currentTarget.parentNode.onsubmit(), false);
elem.title = 'One tag per line or comma-separated list of tags; paste/drag and drop list of genres from anywhere; all input converted to Gazelle format';
form.append(elem);
elem = document.createElement('INPUT');
elem.type = 'submit';
elem.style = 'margin: 0 6pt 6pt 6pt;';
elem.value = 'Update';
form.append(elem);
form.onsubmit = function(evt) {
let userAuth = document.body.querySelector('input[name="auth"]');
if (userAuth != null) userAuth = userAuth.value;
else throw 'Assertion failed: User auth could not be located';
let newTags = document.getElementById('tags-edit');
if (newTags != null) newTags = new TagManager(...newTags.value.split(/(?:\r?\n)+/));
const workers = [ ], deleteTags = tags.filter(tag => !newTags.includes(tag.name)),
addTags = Array.from(newTags).filter(tag => !tags.some(_tag => _tag.name == tag));
if (addTags.length > 0) workers.push(localXHR('/torrents.php', { responseType: null }, new URLSearchParams({
action: 'add_tag',
groupid: groupId,
tagname: addTags.join(', '),
auth: userAuth,
})));
if (deleteTags.length > 0) Array.prototype.push.apply(workers, deleteTags.map(tag => localXHR('/torrents.php?' + new URLSearchParams({
action: 'delete_tag',
groupid: groupId,
tagid: tag.id,
auth: userAuth,
}), { responseType: null })));
if (workers.length > 0) Promise.all(workers).then(() => { document.location.reload() });
return false;
};
tagsBox.append(form);
return false;
};
span.append(a);
head.append(span);
span = document.createElement('SPAN'), a = document.createElement('A');
}
span.style = 'float: right;';
a.className = 'brackets';
a.textContent = 'Copy';
a.href = '#';
a.onclick = function(evt) {
let tags = getTagsFromIterable(tagsBox.querySelectorAll('* > li > a'));
if (tags.length <= 0) return false;
GM_setClipboard(tags.join(', '), 'text');
evt.currentTarget.style.color = isDarkTheme ? 'darkgreen' : 'lightgreen';
setTimeout(elem => {elem.style.color = null }, 1000, evt.currentTarget);
return false;
};
span.append(a);
head.append(span);
})();
if (menuHooks > 0) createMenu();
function inputDataHandler(evt) {
switch (evt.type) {
case 'paste': var tags = evt.clipboardData; break;
case 'drop': tags = evt.dataTransfer; break;
}
if (tags) tags = tags.getData('text/plain'); else return;
//if (tags) tags = tags.split(/[\r\n\;\|\>]+|,(?:\s*&)?/).map(expr => expr.trim()).filter(Boolean); else return;
if (tags.length > 0) switch (evt.type) {
case 'paste': tags = new TagManager(tags); break;
case 'drop': tags = new TagManager(evt.currentTarget.value, tags); break;
} else return;
if (tags.length > 0) tags = tags.toString(); else return;
evt.stopPropagation();
switch (evt.type) {
case 'paste': {
const cursor = evt.currentTarget.selectionStart + tags.toString().length;
evt.currentTarget.value = evt.currentTarget.value.slice(0, evt.currentTarget.selectionStart) +
tags.toString() + evt.currentTarget.value.slice(evt.currentTarget.selectionEnd);
evt.currentTarget.selectionEnd = evt.currentTarget.selectionStart = cursor;
break;
}
case 'drop': evt.currentTarget.value = tags; break;
}
return false;
}
for (let input of document.body.querySelectorAll(fieldNames.map(name => `input[name="${name}"]`).join(', ')))
input.onpaste = input.ondrop = inputDataHandler;
for (let selector of [
'div#content form.add_form[name="tags"]',
'div#content form.search_form',
]) addFormNormalizer(document.body.querySelector(selector));