Highlight a bin on the Wrangling Home if the oldest tag in it is overdue
// ==UserScript==
// @name AO3: [Wrangling] Highlight Bins with Overdue Tags
// @namespace https://greasyfork.org/en/users/906106-escctrl
// @description Highlight a bin on the Wrangling Home if the oldest tag in it is overdue
// @author escctrl
// @version 3.0
// @match *://*.archiveofourown.org/tag_wranglers/*
// @license GNU GPL-3.0-only
// @require https://update.greasyfork.org/scripts/541008/1615720/AO3%3A%20Initialize%20webix%20GUI.js
// ==/UserScript==
/* global q, qa, webix, $$, createMenu, initGUI */
(function() {
'use strict';
const ins = (n, l, html) => n.insertAdjacentHTML(l, html);
// ******* GUI CONFIGURATION *******
const cfg = 'overdueBins';
const heading = 'Highlight Overdue Bins';
createMenu(cfg, heading);
const defaultConfig = { overdue: '#ffb16d', soondue: '#ffdf35' };
const storedConfig = JSON.parse(localStorage.getItem(cfg)) ?? defaultConfig;
document.querySelector("#opencfg_"+cfg).addEventListener("click", async function(e) {
let innerLayout = await initGUI(e, cfg, heading, 400, [cfg, "overduePicker", "soonduePicker"]);
if (innerLayout !== false) createDialog(innerLayout);
}, { once: true });
function createDialog(container) {
$$(container).addView(
{ view:"form", id:cfg+"_form", elements:[ // alias for rows
{ // colorpicker
view:"colorpicker", value:storedConfig.overdue, name:"overdue", id:"overdue", clear: true,
label:"Bins with tags > 1 month:", labelWidth: "auto",
suggest: { type:"colorselect", body: { button:true }, id:"overduePicker", css: "darkmode" }
},
{ // colorpicker
view:"colorpicker", value:storedConfig.soondue, name:"soondue", id:"soondue", clear: true,
label:"Bins with tags > 2 weeks:", labelWidth: "auto",
suggest: { type:"colorselect", body: { button:true }, id:"soonduePicker", css: "darkmode" }
},
{ cols:[ // buttonbar
{
view:"button", value:"Reset",
click: function() {
$$(cfg+"_form").setValues(defaultConfig);
localStorage.removeItem(cfg);
$$(cfg).hide(); // close the dialog
}
},
{
view:"button", value:"Cancel",
click: function() { $$(cfg).hide(); } // close the dialog
},
{
view:"button", value:"Save", css:"webix_primary",
click: function() {
let selected = $$(cfg+"_form").getValues();
localStorage.setItem(cfg, JSON.stringify(selected));
$$(cfg).hide(); // close the dialog
}
}
]}
]}, 0);
$$(cfg).show();
}
// ********* INITIALIZATION *********
// some CSS to make this look palatable
ins(q('head'), 'beforeend', `<style type="text/css">#age-check, #age-cancel { font-size: 80%; padding: 0.2em; } #age-check[disabled] { opacity: 80%; }
#age-check svg { width: 1em; height: 1em; display: inline-block; vertical-align: -0.125em; padding-right: 0.3em; }
td a.has_agedout { font-weight: bold; position: relative; z-index: 1 }
td a.has_agedout::before { background-color: ${storedConfig.overdue}; content: ""; position: absolute; width: calc(100% + 4px); height: 60%; right: -2px; bottom: 3px; z-index: -1; transform: rotate(-8deg); }
td a.has_agedout.almost::before { background-color: ${storedConfig.soondue === "" ? "transparent" : storedConfig.soondue}; } </style>`);
// add a button to start checking
ins(q('.assigned table thead tr:nth-child(1) th:nth-child(3)'), 'beforeend',
` <button id='age-check' type='button'><span id='age-status'>Check Tag Age</span><span id='age-progress'></span></button>
<button id='age-cancel' type='button' class="hidden">Cancel</button>`);
const ageCheck = q('#age-check');
const ageStatus = q('#age-status');
const ageProg = q('#age-progress');
const ageCancel = q('#age-cancel');
ageCheck.addEventListener('click', startCheck);
const maxage_overdue = createDate(0, -1, 0); // one month ago
const maxage_soondue = createDate(0, 0, -14); // two weeks ago
// load sessionStorage (remember what we've checked while this tab is open)
let stored_overdue = JSON.parse(sessionStorage.getItem('bin_overdue')) || [];
let stored_soondue = JSON.parse(sessionStorage.getItem('bin_soondue')) || [];
// load snoozed tags in case the script is installed
let snoozed = new Map(JSON.parse(localStorage.getItem('tags_saved_date_map') || "[]"));
qa('.assigned tbody td[title~="unwrangled"] a').forEach((a) => { // show any known to be outdated already on pageload
// build the same "FANDOM/TAGTYPE" text that's stored for easy comparison
let bin = a.href.match(/tags\/(.*?)\/wrangle.*show=(characters|relationships|freeforms)/i);
bin = bin[1] + '/' + bin[2];
if (stored_overdue.includes(bin)) a.classList.add('has_agedout');
if (stored_soondue.includes(bin)) {
a.classList.add('has_agedout');
a.classList.add('almost');
}
});
let status_icons = {
// SVGs from Lucide https://lucide.dev (Copyright (c) Cole Bemis 2013-2022 as part of Feather (MIT) and Lucide Contributors 2022 https://lucide.dev/license)
// changelog: removed xmlns, height/width, classes, added titles for accessibility
succ: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><title>check completed successfully</title><path d="M20 6 9 17l-5-5"/></svg>`,
err: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><title>check failed</title><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>`,
abort: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><title>check cancelled</title><rect width="18" height="18" x="3" y="3" rx="2"/><line x1="10" x2="10" y1="15" y2="9"/><line x1="14" x2="14" y1="15" y2="9"/></svg>`,
wait: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 22h14"/><title>check paused</title><path d="M5 2h14"/><path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22"/><path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2"/></svg>`,
// SVG from https://github.com/n3r4zzurr0/svg-spinners (MIT license Copyright (c) Utkarsh Verma https://github.com/n3r4zzurr0/svg-spinners/blob/main/LICENSE)
// changelog: animation slowed x1.5 (via iconify.design), removed xmlns, width/height, added style attribute, linebreaks
pending: `<svg viewBox="0 0 24 24" style="top: 0.2em; position: relative;"><title>checking bins, please wait</title>
<circle cx="4" cy="12" r="3" fill="currentColor"><animate id="SVGKiXXedfO" attributeName="cy" begin="0;SVGgLulOGrw.end+0.375s" calcMode="spline" dur="0.9s" keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12"/></circle>
<circle cx="12" cy="12" r="3" fill="currentColor"><animate attributeName="cy" begin="SVGKiXXedfO.begin+0.15s" calcMode="spline" dur="0.9s" keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12"/></circle>
<circle cx="20" cy="12" r="3" fill="currentColor"><animate id="SVGgLulOGrw" attributeName="cy" begin="SVGKiXXedfO.begin+0.3s" calcMode="spline" dur="0.9s" keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12"/></circle>
</svg>`
};
// ********* CHECK FUNCTIONS *********
function startCheck() {
// select all bins with unwrangled tags, that are visible (not hidden by snooze or filtering)
let bins = [...qa('.assigned tbody tr td[title~="unwrangled"] a')].filter((el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length));
// set a loading indicator to user
ageCheck.disabled = true;
ageStatus.innerHTML = status_icons.pending;
ageProg.innerText = bins.length+' bins';
performCheck(bins);
}
async function performCheck(bins) {
// bins is an array of <a> Nodes
ageProg.innerText = `${bins.length} bins`;
ageCancel.addEventListener('click', () => controller.abort()); // when user clicks button, throwIfAborted() defaults to "AbortError"
ageCancel.classList.remove('hidden');
const controller = new AbortController();
try {
for (let [ix, bin] of bins.entries()) {
controller.signal.throwIfAborted(); // check in each iteration if the user has already cancelled
ageProg.innerText = `${bins.length-ix} bins`; // progress indicator to user
// build the URL to check (oldest tag on first page at the top)
let link = new URL(bin.href);
link = `${link.protocol}//${link.hostname + link.pathname}?show=${link.searchParams.get('show')}&status=unwrangled&sort_column=created_at&sort_direction=ASC`;
let response;
do { // fetch at least once, automatically try again on retry later
response = await fetch(link, { signal: controller.signal });
if (!response.ok) {
if (response.status === 429) {
let later = parseInt(response.headers.get('Retry-After'))+5; // don't hit it again immediately, wait an additional 5 secs
ageStatus.innerHTML = status_icons.wait;
ageProg.innerText = "until " + new Date(new Date().setSeconds(new Date().getSeconds() + later)).toLocaleTimeString();
await waitforXMilliseconds(later*1000, controller.signal);
ageStatus.innerHTML = status_icons.pending;
ageProg.innerText = bins.length+' bins';
}
else throw new Error(`Couldn't check wranglers on fandom, HTTP error: ${response.status}`);
}
} while (response.status === 429); // repeat while we get retry later
let page = await response.text();
page = new DOMParser().parseFromString(page, "text/html"); // Parse the text into HTML
// reset CSS classes on <a>, then set the class we actually want
bin.classList.remove('has_agedout');
bin.classList.remove('not_agedout');
bin.classList.remove('almost');
// find the first tag that isn't snoozed and check its age
let rows = qa('#wrangulator tbody tr', page);
for (let row of rows) {
if (snoozed.has(q('th label', row).innerText)) continue; // skip this tag if it's been snoozed and check the next-oldest instead
else {
let tagCreated = new Date(q('td[title="created"]', row).innerText);
if (tagCreated < maxage_overdue) bin.classList.add('has_agedout');
else if (tagCreated < maxage_soondue) {
bin.classList.add('has_agedout');
bin.classList.add('almost');
}
else bin.classList.add('not_agedout');
break; // this was the oldest un-snoozed tag, don't need to look any further
}
}
if (ix+1 === bins.length) { // if this is the last entry
ageStatus.innerHTML = status_icons.succ;
ageProg.innerText = "Recheck Tag Age";
finishCheck();
}
else await waitforXMilliseconds(3000, controller.signal);
}
}
catch(error) {
if (error.name === "AbortError") {
ageStatus.innerHTML = status_icons.abort;
ageProg.innerText = "Aborted";
}
else {
ageStatus.innerHTML = status_icons.err;
ageProg.innerText = "Error :(";
}
console.log(`Bins AgeCheck: error`, error);
finishCheck();
}
}
function finishCheck() {
// update the buttons appropriately
ageCheck.disabled = false;
ageCancel.classList.add('hidden');
// save the latest checked list for the moment (while the tab remains open)
let list_overdue = [], list_soondue = [];
qa('table a.has_agedout').forEach((e) => {
let link = e.href.match(/tags\/(.*?)\/wrangle.*show=(characters|relationships|freeforms)/i);
link = link[1] + '/' + link[2];
if (e.classList.contains('almost')) list_soondue.push(link);
else list_overdue.push(link);
});
// stores an array of "FANDOM/TAGTYPE" strings
sessionStorage.setItem('bin_overdue', JSON.stringify(list_overdue));
sessionStorage.setItem('bin_soondue', JSON.stringify(list_soondue));
}
// migration: removing old Storage that won't be used anymore
localStorage.removeItem('ao3jail');
localStorage.removeItem('agecheck_old');
localStorage.removeItem('agecheck_new');
function createDate(years, months, days) {
let date = new Date();
date.setFullYear(date.getFullYear() + years);
date.setMonth(date.getMonth() + months);
date.setDate(date.getDate() + days);
return date;
}
function waitforXMilliseconds(x, signal) {
return new Promise((resolve, reject) => {
const t = setTimeout(resolve, x);
signal.addEventListener('abort', () => { // stops waiting when the user cancels
clearTimeout(t);
reject(new DOMException("", "AbortError")); // manual AbortError to match fetch and throwIfAborted()
});
});
}
})();