AO3: [Wrangling] Mark Co- and Solo-Wrangled Fandoms

On your wrangling homepage, mark whether the fandoms are co- or solo-wrangled. Refreshes once a month.

// ==UserScript==
// @name         AO3: [Wrangling] Mark Co- and Solo-Wrangled Fandoms
// @author       escctrl
// @description  On your wrangling homepage, mark whether the fandoms are co- or solo-wrangled. Refreshes once a month.
// @namespace    https://greasyfork.org/en/users/906106-escctrl
// @version      3.2
// @license      MIT
// @match        *://*.archiveofourown.org/tag_wranglers/*
// @require      https://ajax.googleapis.com/ajax/libs/jquery/3.7.0/jquery.min.js
// @grant        none
// ==/UserScript==

/****************** CONFIGURATION ******************/

// this enables the script to change the CSS classes, which Redux and N-in-1 Filtering rely on
// sadly this works only partially for N-in-1, because it can't make filters appear which are created dynamically
// if setting this to true, make sure that this script runs AFTER the filtering script!
const FILTERING = false;

// set this to true if you don't want to see the icon indicating co/solo wrangled fandoms
// filtering would still work, even if the icon is hidden
const HIDE_MARKERS = false;

// supervisors: change these if you only want to use this script to help during the trainee checkins
const ENABLE_ON_OTHER_USERS = false; // true = enables a button to check cowrangling on other users' wrangling homepages (not stored)
const ENABLE_ON_MY_PAGE = true; // false = disables the script on your own wrangling homepage


// ****************** NOTE ON LOCAL STORAGE ******************
// For compatibility between userscript managers, this script uses local storage, which is visible from the Developer console.
// If you ever uninstall the script, unfortunately its data can't be automatically deleted.
// If you want to remove the data it sets, (1) visit your wrangling homepage, (2) go into the Developer console,
// (3) look for the Local Storage (4) and delete the entries for "aia_ref" and "aia_refdate".
// The script also removes its data if you switch DELETESTORAGE below to true, and visit your wrangling homepage.
const DELETESTORAGE = false;


(function($) {
    'use strict';

    // wait duration between checking individual bins - set this number higher if you often run into Retry Later
    //   defined in milliseconds, e.g. 3000 = 3 seconds
    const interval = 3000;

    const DEBUG = false;

    if (DELETESTORAGE || !ENABLE_ON_MY_PAGE) {
        // if you want to laugh: "aia" stands for "am i alone" wrangling this fandom?
        localStorage.removeItem("aia_refdate");
        localStorage.removeItem("aia_ref");
        if (DELETESTORAGE) return;
    }

    // Am I looking at my own page?
    var MYOWNPAGE =
        window.location.pathname.match(/\/([A-Za-z0-9_]+)\/?$/i)[1] ==
        $('#greeting>ul.user.navigation>li:first-of-type>a[href^="/users/"]')[0].href.match(/\/([A-Za-z0-9_]+)\/?$/i)[1]
        ? true : false;
    if (DEBUG) console.log(MYOWNPAGE);

    // as always, a list of available icons is here --> https://fontawesome.com/v4/icons
    const icons = { 'co': "", 'solo': "", 'load': "", 'dunno': "" };
    const title = { 'co': "co-wrangled fandom", 'solo': "solo-wrangled fandom", 'load': "fandom wranglers loading", 'dunno': "fandom wranglers not yet checked" };

    $("head").append(`<style type="text/css"> .aia-check { font-family: FontAwesome, sans-serif; min-width: 1.2em;
            display: inline-block; text-align: center; padding-right: 0.2em; } </style>`)
        .prepend(`<script src="https://use.fontawesome.com/ed555db3cc.js" />`);

    const fandomList = $('.assigned tbody tr th a'); // the full list of fandoms
    var fandomRef = new Map(); // here we'll build/load the reference list of fandoms and co-wrangling status

    if (DEBUG) console.log(fandomList);

    // if this is wrangler's own page, immediately start the process as the page loads
    if (MYOWNPAGE && ENABLE_ON_MY_PAGE) {
        const button_text = 'Co/Solo: force reload';
        $('.assigned h3').after('<p id="aia" style="font-size: 0.7em; display: inline-block;"><button id="aia-start">' + button_text + '</button></p>');
        $('#aia-start').click(forceRefresh);
        fullPageReload();
    }
    // on other people's pages, add a button to start the check
    else if (!MYOWNPAGE && ENABLE_ON_OTHER_USERS) {
        const button_text = 'Co/Solo: load';
        $('.assigned h3').after('<p id="aia" style="font-size: 0.7em; display: inline-block;"><button id="aia-start">' + button_text + '</button></p>');
        $('#aia-start').click(fullPageReload);
    }

    function forceRefresh() {
        localStorage.removeItem("aia_refdate");
        localStorage.removeItem("aia_ref");
        fullPageReload();
    }

    function fullPageReload() {
        // markers initiation
        fandomList.before(`<span class="aia-check" title="${title.load}"` + (HIDE_MARKERS ? ' style="display: none;"' : "") + `>${icons.load}</span>`);

        if (MYOWNPAGE) {
            // check if data is still recent enough
            var stored_date = new Date(localStorage.getItem("aia_refdate") || '1970'); // the date when the storage was last refreshed
            var compare_date = createDate(0, -1, 0); // a month before
            if (DEBUG) console.log(stored_date, compare_date);
            if (stored_date > compare_date) fandomRef = new Map(JSON.parse(localStorage.getItem("aia_ref")));
            if (DEBUG) console.log(fandomRef);
        }
        // if not on own page or data outdated, the fandomRef remains empty

        // resetting from any previous page loads so the fun can start
        localStorage.setItem("ao3jail", "false");

        // keep track of fandoms to be checked
        var todo = 0;

        // run through all fandoms on page
        $(fandomList).each( function(i, f) {
            // if the given fandom's part of the stored data, update the status display
            if (fandomRef.has(f.innerText)) {
                if (DEBUG) console.log(f.innerText +' was found in storage, status '+ fandomRef.get(f.innerText));
                writeMarker(f, fandomRef.get(f.innerText));
            }
            // if not in storage, create a background pageload for assigned wranglers
            else {
                todo++;
                if (DEBUG) console.log(f.innerText +' was not found in storage, page load in '+ interval*todo +'ms');
                setTimeout(function() {
                    loadFandom(f);
                }, interval*todo);
            }
        });
    }

    function loadFandom(a) {
        // if previous loops hit Ao3 Jail, don't try checking age anymore
        if ( localStorage.getItem("ao3jail") == "true") {
            console.log('previously received "Retry later" response, skipped '+ a.innerText +' cowrangler check');
            return false;
        }

        if (DEBUG) console.log('Check started for '+ a.innerText);

        // turn the url from a /wrangle into an /edit and load the page
        var url = a.href;
        if (url.endsWith("/")) url = url.slice(0, -1);
        $.get(url.slice(0, -7) + 'edit', function(response) {
        }).done(function(response) {
            // count the wranglers:
            // pick the correct field in the form containing the assigned wranglers
            // split the a comma-seperated list into an array and count its length (that's the number of wranglers)
            var assignedWranglers = $(response).find('#edit_tag fieldset:first dd:nth-of-type(4)').text().split(', ').length;
            assignedWranglers = (assignedWranglers == 1) ? "solo" : "co";

            fandomRef.set(a.innerText, assignedWranglers);

            if (DEBUG) console.log(a.innerText + ' is '+ assignedWranglers + '-wrangled');

            if (MYOWNPAGE) {
                localStorage.setItem('aia_ref', JSON.stringify(Array.from(fandomRef.entries())));
                localStorage.setItem('aia_refdate', new Date())
            }

            writeMarker(a, assignedWranglers);

        }).fail(function(data, textStatus, xhr) {
            //This shows status code eg. 429
            console.log("error", data.status);
            writeMarker(a, 'dunno'); // set ? as result
        });
    }

    // update the marker with the appropriate icon
    function writeMarker(f, status) {

        if (FILTERING) {
            // Redux uses "shared-", n-in-1 uses "co-" (but both use "solo-") as prefixes
            var cofilter = ($('p#fandom-filter').length > 0) ? "co-" : "shared-";
            var classes = $(f).parent().parent().prop("classList");

            // replace the prefixes in class names for filtering
            switch (status) {
                case 'solo':
                    classes.forEach((v, i) => {
                        if (v.startsWith(cofilter)) classes.replace(v, v.replace(cofilter, 'solo-'));
                    });
                    break;
                case 'co':
                    classes.forEach((v, i) => {
                        if (v.startsWith('solo-')) classes.replace(v, v.replace('solo-', cofilter));
                    });
                    break;
                default:
                    status = 'dunno';
                    break;
            }
        }
        $(f).prev().html(icons[status]).attr('title', title[status]);
    }


})(jQuery);

// convenience function to be able to pass minus values into a Date, so JS will automatically shift correctly over month/year boundaries
// thanks to Phil on Stackoverflow for the code snippet https://stackoverflow.com/a/37003268
function createDate(days, months, years) {
    var date = new Date();
    date.setFullYear(date.getFullYear() + years);
    date.setMonth(date.getMonth() + months);
    date.setDate(date.getDate() + days);
    return date;
}