Greasy Fork 还支持 简体中文。

AO3: [Wrangling] Highlight Bins with Overdue Tags

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

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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

})();