// ==UserScript==
// @name AO3: [Wrangling] Rel Helper
// @namespace https://greasyfork.org/en/users/906106-escctrl
// @description on unwrangled rels, build the proper canonical tag name with minimal typing
// @author escctrl
// @version 1.0
// @match *://*.archiveofourown.org/tags/*/edit
// @grant none
// @license MIT
// @require https://cdn.jsdelivr.net/npm/[email protected]/Sortable.min.js
// @require https://update.greasyfork.org/scripts/491896/1516188/Copy%20Text%20and%20HTML%20to%20Clipboard.js
// ==/UserScript==
/* global Sortable, copy2Clipboard */
'use strict';
// utility to reduce verboseness
const q = (selector, node=document) => node.querySelector(selector);
const qa = (selector, node=document) => node.querySelectorAll(selector);
const ins = (n, l, html) => n.insertAdjacentHTML(l, html);
/*************** CONFIGURATION ***************/
// set this to either "syn" (to get a button that sets the rel as the Synonym Of and create canonicals that way)
// or "copy" (to get buttons for copying the rel and chars manually into a new tag)
let cfg = 'wrangleRelHelp';
let WORKFLOW = localStorage.getItem(cfg) || "syn";
// if no other script has created it yet, write out a "Userscripts" option to the main navigation
if (qa('#scriptconfig').length === 0) {
qa('#header nav[aria-label="Site"] li.search')[0] // insert as last li before search
.insertAdjacentHTML('beforebegin', `<li class="dropdown" id="scriptconfig">
<a class="dropdown-toggle" href="/" data-toggle="dropdown" data-target="#">Userscripts</a>
<ul class="menu dropdown-menu"></ul></li>`);
}
// then add this script's config option to navigation dropdown
ins(q('#scriptconfig .dropdown-menu'), 'beforeend', `<li><a href="#" id="relhelp_workflow">Rel Helper Workflow:
<span class="switch"><input type="checkbox" ${WORKFLOW === "syn" ? 'checked="checked"' : ""}><span class="slider round"></span></span>
<span id="relhelp_workflow_status">${WORKFLOW === "syn" ? "SynOf" : "Copy"}</span></a></li>`);
ins(q("head"), 'beforeend', `<style tyle="text/css"> #relhelp_workflow {
/* The switch - the box around the slider */
.switch { position: relative; display: inline-block; width: 2em; height: 1em; vertical-align: -0.2em; }
/* Hide default HTML checkbox */
.switch input { opacity: 0; width: 0; height: 0; }
/* The slider */
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; -webkit-transition: .4s; transition: .4s; }
.slider:before { position: absolute; content: ""; height: 0.8em; width: 0.8em; left: 0.2em; bottom: 0.1em; background-color: white; -webkit-transition: .4s; transition: .4s; }
input:checked + .slider { background-color: currentColor; }
input:focus + .slider { box-shadow: 0 0 1px currentColor; }
input:checked + .slider:before { -webkit-transform: translateX(0.8em); -ms-transform: translateX(0.8em); transform: translateX(0.8em); }
/* Rounded sliders */
.slider.round { border-radius: 1em; }
.slider.round:before { border-radius: 50%; }
}
.sortable .tag a { cursor: default; }
#relhelp_preview { margin: 0.643em auto; }</style>`);
// on either click on link or change of checkbox (depending where exactly user clicked), toggle between SynOf and Copy
q('#relhelp_workflow').addEventListener('click', (e)=>{
e.preventDefault();
if (e.currentTarget.tagName==="A") {
WORKFLOW = WORKFLOW === "syn" ? "copy" : "syn"; // toggle
localStorage.setItem(cfg, WORKFLOW);
q('input', e.currentTarget).checked = WORKFLOW === "syn" ? true : false;
q('#relhelp_workflow_status', e.currentTarget).innerText = WORKFLOW === "syn" ? "SynOf" : "Copy";
location.reload();
}
});
/************* CONFIGURATION END *************/
// function to get current list of added tags based on autocomplete input fields e.g. get_added(inputs.char)
const get_added = (me) => qa('.added.tag', me.parentElement.parentElement);
const inputs = {
tag: q('#tag_name'),
syn: q('#tag_syn_string_autocomplete'),
char: q('#tag_character_string_autocomplete')
};
if (q('#tag_character_string') === null) return; // works only on relationships
if (q('#tag_canonical').checked) return; // works only if not canonical
if (get_added(inputs.syn).length > 0) return; // works only if not a synonym already
const chars_ul = inputs.char.parentElement.parentElement;
const individuals = inputs.tag.value.split(/\/|&|\band\b/g);
let fillCount = 0;
let newRel = "";
// make the characters <ul> sortable
let sortable = new Sortable(chars_ul, {
draggable: ".tag", // instead of filtering .input, we make only .tag draggable to avoid problems (autocomplete selection by mouse, dragging without clicking outside the <input>)
onEnd: updatePreview // reorder in preview immediately
});
chars_ul.classList.add('sortable');
// add some buttons (automatically select the / or & based on tagname)
ins(chars_ul, 'afterend', `<div id="relhelp_btns">
<button type="button" id="relhelp_addchars" title="search for the next character from the rel in the autocomplete">Add Char</button>
<button type="button" id="relhelp_settype" title="switch between / and & rels" data-relhelptype="${inputs.tag.value.indexOf('/') !== -1 ? "/" : " & "}">${inputs.tag.value.indexOf('/') !== -1 ? "&" : "/"}</button>
<button type="button" id="relhelp_remdisambig" title="switch between keeping and removing fandom disambigs" data-relhelpdisambig="keep">Remove Disambigs</button> ·
${ WORKFLOW === "syn" ? `<button type="button" id="relhelp_setsyn" title="set the proposed tag in the Synonym Of field">Set Syn Of</button>` :
`<button type="button" id="relhelp_copy" title="copy the proposed tag to clipboard">Copy Rel</button>
<button type="button" id="relhelp_copychars" title="copy the selected chars to clipboard">Copy Chars</button>` }<br />
<input type="text" id="relhelp_preview" title="preview of the proposed tag you're building" style="max-width: 30em;" />
<span id="relhelp_error"></span>
</div>`);
chars_ul.parentElement.addEventListener('click', (e) => {
if (e.target.id === "relhelp_addchars") { // with each click fill autocomplete search with the next char
inputs.char.value = individuals[fillCount].trim();
inputs.char.focus();
fillCount = (fillCount+1 === individuals.length) ? 0 : fillCount+1;
}
else if (e.target.id === "relhelp_settype") { // with each click toggle between / and & rels
e.target.dataset.relhelptype = e.target.dataset.relhelptype === "/" ? " & " : "/";
e.target.innerText = (e.target.dataset.relhelptype === "/" ? "&" : "/");
updatePreview();
}
else if (e.target.id === "relhelp_remdisambig") { // with each click toggle between keeping and removing the disambigs
e.target.dataset.relhelpdisambig = e.target.dataset.relhelpdisambig === "keep" ? "rem" : "keep";
e.target.innerText = (e.target.dataset.relhelpdisambig === "keep" ? "Remove" : "Keep") + " Disambigs";
updatePreview();
}
else if (e.target.id === "relhelp_copy") { // copy final rel to clipboard
copy2Clipboard(e, "txt", newRel);
}
else if (e.target.id === "relhelp_copychars") { // copy CSV of chars to clipboard, and remove the chars in one go
let allchars = Array.from(get_added(inputs.char)); // grab all current chars
allchars = allchars.map((el) => el.innerText.slice(0,-1).trim()); // remove the x delete button
copy2Clipboard(e, "txt", allchars.join(','));
for (let char of get_added(inputs.char)) {
q('.delete a', char).click();
}
}
else if (e.target.id === "relhelp_setsyn") { // populate the Synonym Of field
inputs.syn.focus();
inputs.syn.value = newRel;
inputs.syn.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 13, key: "Enter" }));
}
});
q('#relhelp_preview').addEventListener('input', (e) => { // when user makes manual changes
newRel = e.target.value;
checkTagValidity();
});
// update preview when a char gets added/deleted
const observer = new MutationObserver(function(mutList, obs) {
for (const mut of mutList) {
// check if the added/removed node is an .added.tag LI
for (const node of mut.addedNodes) {
if (node.classList.contains('added')) {
// make content of the LI into a link (don't need to stop observer bc it's not listening to subtree)
let oldNode = node.childNodes[0];
let newNode = document.createElement("a");
newNode.innerText = oldNode.textContent;
newNode.href = encodeURI("https://archiveofourown.org/tags/" +
oldNode.textContent.trim().replace('/', '*s*').replace('&', '*a*').replace('#', '*h*').replace('.', '*d*').replace('?', '*q*') +
"/wrangle?show=relationships&status=canonical");
newNode.target = "_blank";
node.replaceChild(newNode, oldNode);
updatePreview();
}
}
for (const node of mut.removedNodes) { if (node.classList.contains('added')) updatePreview(); }
}
});
// listening to as few changes as possible: only direct children of UL
observer.observe(chars_ul, { attributes: false, childList: true, subtree: false });
function updatePreview() {
let allchars = Array.from(get_added(inputs.char)); // grab all current chars
allchars = allchars.map((el) => {
el = el.innerText.slice(0,-1).trim(); // remove the x delete button
// remove fandom disambigs aka the last parenthesis if asked - doesn't remove (s) in Character(s) or (ren) in Child(ren)
if (q('#relhelp_remdisambig').dataset.relhelpdisambig === "rem" && el.indexOf("(") !== -1 && el.slice(-3) !== "(s)" && el.slice(-5) !== "(ren)") {
el = el.slice(0, el.lastIndexOf("(")).trim();
}
return el;
});
newRel = allchars.join(q('#relhelp_settype').dataset.relhelptype); // join with the rel type we're supposed to use
q('#relhelp_preview').value = newRel; // write out into preview field
checkTagValidity();
}
function checkTagValidity() { // warn if new tag name too long or same as this tag
let preview = q('#relhelp_preview');
let error = "";
if (newRel.length > 150) error = "proposed canonical is too long";
if (newRel === inputs.tag.value) error = "proposed canonical is same as this tag";
if (error === "") {
preview.classList.remove('error');
preview.title = "preview of the proposed tag you're building";
q('#relhelp_error').innerText = "";
qa('#relhelp_copy, #relhelp_copychars, #relhelp_setsyn').forEach((b) => { b.style.display = "inline-block"; }); // show the buttons
}
else {
preview.classList.add('error');
preview.title = error;
q('#relhelp_error').innerText = error;
qa('#relhelp_copy, #relhelp_copychars, #relhelp_setsyn').forEach((b) => { b.style.display = "none"; }); // hide the buttons
}
}