AO3: [Wrangling] Highlight Bins with Overdue Tags

Highlight a bin on the Wrangling Home if the oldest tag in it is overdue

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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()
            });
        });
    }

})();