MFC Tagger+

A set of tools to help MyFigureCollection contributors with tagging items

// ==UserScript==
// @name        MFC Tagger+
// @description A set of tools to help MyFigureCollection contributors with tagging items
// @namespace   https://takkkane.tumblr.com/scripts/mfcTagCounter
// @version     0.2.1
// @author      Nefere
// @homepage    https://github.com/Nefere256/mfc-tagger-plus/tree/main
// @supportURL  https://github.com/Nefere256/mfc-tagger-plus/tree/main
// @match       https://myfigurecollection.net/entry/*
// @match       https://myfigurecollection.net/browse.v4.php*
// @match       https://myfigurecollection.net/browse/calendar/*
// @match       https://myfigurecollection.net/*
// @match       https://myfigurecollection.net/item/browse/figure/
// @match       https://myfigurecollection.net/item/browse/goods/
// @match       https://myfigurecollection.net/item/browse/media/
// @match       https://myfigurecollection.net/item/browse/calendar/*
// @icon        https://www.google.com/s2/favicons?sz=64&domain=myfigurecollection.net
// @license     MIT
// @connect     github.com
// @run-at      document-idle
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_addStyle
// @grant       GM_getResourceText
// @grant       GM_xmlhttpRequest
// ==/UserScript==
GM_addStyle(GM_getResourceText('css'));
var app = (function () {
    'use strict';
 
    function noop() { }
    function run(fn) {
        return fn();
    }
    function blank_object() {
        return Object.create(null);
    }
    function run_all(fns) {
        fns.forEach(run);
    }
    function is_function(thing) {
        return typeof thing === 'function';
    }
    function safe_not_equal(a, b) {
        return a != a ? b == b : a !== b || ((a && typeof a === 'object') || typeof a === 'function');
    }
    function is_empty(obj) {
        return Object.keys(obj).length === 0;
    }
 
    const globals = (typeof window !== 'undefined'
        ? window
        : typeof globalThis !== 'undefined'
            ? globalThis
            : global);
    function detach(node) {
        if (node.parentNode) {
            node.parentNode.removeChild(node);
        }
    }
    function children(element) {
        return Array.from(element.childNodes);
    }
    function custom_event(type, detail, { bubbles = false, cancelable = false } = {}) {
        const e = document.createEvent('CustomEvent');
        e.initCustomEvent(type, bubbles, cancelable, detail);
        return e;
    }
 
    let current_component;
    function set_current_component(component) {
        current_component = component;
    }
 
    const dirty_components = [];
    const binding_callbacks = [];
    let render_callbacks = [];
    const flush_callbacks = [];
    const resolved_promise = /* @__PURE__ */ Promise.resolve();
    let update_scheduled = false;
    function schedule_update() {
        if (!update_scheduled) {
            update_scheduled = true;
            resolved_promise.then(flush);
        }
    }
    function add_render_callback(fn) {
        render_callbacks.push(fn);
    }
    // flush() calls callbacks in this order:
    // 1. All beforeUpdate callbacks, in order: parents before children
    // 2. All bind:this callbacks, in reverse order: children before parents.
    // 3. All afterUpdate callbacks, in order: parents before children. EXCEPT
    //    for afterUpdates called during the initial onMount, which are called in
    //    reverse order: children before parents.
    // Since callbacks might update component values, which could trigger another
    // call to flush(), the following steps guard against this:
    // 1. During beforeUpdate, any updated components will be added to the
    //    dirty_components array and will cause a reentrant call to flush(). Because
    //    the flush index is kept outside the function, the reentrant call will pick
    //    up where the earlier call left off and go through all dirty components. The
    //    current_component value is saved and restored so that the reentrant call will
    //    not interfere with the "parent" flush() call.
    // 2. bind:this callbacks cannot trigger new flush() calls.
    // 3. During afterUpdate, any updated components will NOT have their afterUpdate
    //    callback called a second time; the seen_callbacks set, outside the flush()
    //    function, guarantees this behavior.
    const seen_callbacks = new Set();
    let flushidx = 0; // Do *not* move this inside the flush() function
    function flush() {
        // Do not reenter flush while dirty components are updated, as this can
        // result in an infinite loop. Instead, let the inner flush handle it.
        // Reentrancy is ok afterwards for bindings etc.
        if (flushidx !== 0) {
            return;
        }
        const saved_component = current_component;
        do {
            // first, call beforeUpdate functions
            // and update components
            try {
                while (flushidx < dirty_components.length) {
                    const component = dirty_components[flushidx];
                    flushidx++;
                    set_current_component(component);
                    update(component.$$);
                }
            }
            catch (e) {
                // reset dirty state to not end up in a deadlocked state and then rethrow
                dirty_components.length = 0;
                flushidx = 0;
                throw e;
            }
            set_current_component(null);
            dirty_components.length = 0;
            flushidx = 0;
            while (binding_callbacks.length)
                binding_callbacks.pop()();
            // then, once components are updated, call
            // afterUpdate functions. This may cause
            // subsequent updates...
            for (let i = 0; i < render_callbacks.length; i += 1) {
                const callback = render_callbacks[i];
                if (!seen_callbacks.has(callback)) {
                    // ...so guard against infinite loops
                    seen_callbacks.add(callback);
                    callback();
                }
            }
            render_callbacks.length = 0;
        } while (dirty_components.length);
        while (flush_callbacks.length) {
            flush_callbacks.pop()();
        }
        update_scheduled = false;
        seen_callbacks.clear();
        set_current_component(saved_component);
    }
    function update($$) {
        if ($$.fragment !== null) {
            $$.update();
            run_all($$.before_update);
            const dirty = $$.dirty;
            $$.dirty = [-1];
            $$.fragment && $$.fragment.p($$.ctx, dirty);
            $$.after_update.forEach(add_render_callback);
        }
    }
    /**
     * Useful for example to execute remaining `afterUpdate` callbacks before executing `destroy`.
     */
    function flush_render_callbacks(fns) {
        const filtered = [];
        const targets = [];
        render_callbacks.forEach((c) => fns.indexOf(c) === -1 ? filtered.push(c) : targets.push(c));
        targets.forEach((c) => c());
        render_callbacks = filtered;
    }
    const outroing = new Set();
    function transition_in(block, local) {
        if (block && block.i) {
            outroing.delete(block);
            block.i(local);
        }
    }
    function mount_component(component, target, anchor, customElement) {
        const { fragment, after_update } = component.$$;
        fragment && fragment.m(target, anchor);
        if (!customElement) {
            // onMount happens before the initial afterUpdate
            add_render_callback(() => {
                const new_on_destroy = component.$$.on_mount.map(run).filter(is_function);
                // if the component was destroyed immediately
                // it will update the `$$.on_destroy` reference to `null`.
                // the destructured on_destroy may still reference to the old array
                if (component.$$.on_destroy) {
                    component.$$.on_destroy.push(...new_on_destroy);
                }
                else {
                    // Edge case - component was destroyed immediately,
                    // most likely as a result of a binding initialising
                    run_all(new_on_destroy);
                }
                component.$$.on_mount = [];
            });
        }
        after_update.forEach(add_render_callback);
    }
    function destroy_component(component, detaching) {
        const $$ = component.$$;
        if ($$.fragment !== null) {
            flush_render_callbacks($$.after_update);
            run_all($$.on_destroy);
            $$.fragment && $$.fragment.d(detaching);
            // TODO null out other refs, including component.$$ (but need to
            // preserve final state?)
            $$.on_destroy = $$.fragment = null;
            $$.ctx = [];
        }
    }
    function make_dirty(component, i) {
        if (component.$$.dirty[0] === -1) {
            dirty_components.push(component);
            schedule_update();
            component.$$.dirty.fill(0);
        }
        component.$$.dirty[(i / 31) | 0] |= (1 << (i % 31));
    }
    function init(component, options, instance, create_fragment, not_equal, props, append_styles, dirty = [-1]) {
        const parent_component = current_component;
        set_current_component(component);
        const $$ = component.$$ = {
            fragment: null,
            ctx: [],
            // state
            props,
            update: noop,
            not_equal,
            bound: blank_object(),
            // lifecycle
            on_mount: [],
            on_destroy: [],
            on_disconnect: [],
            before_update: [],
            after_update: [],
            context: new Map(options.context || (parent_component ? parent_component.$$.context : [])),
            // everything else
            callbacks: blank_object(),
            dirty,
            skip_bound: false,
            root: options.target || parent_component.$$.root
        };
        append_styles && append_styles($$.root);
        let ready = false;
        $$.ctx = instance
            ? instance(component, options.props || {}, (i, ret, ...rest) => {
                const value = rest.length ? rest[0] : ret;
                if ($$.ctx && not_equal($$.ctx[i], $$.ctx[i] = value)) {
                    if (!$$.skip_bound && $$.bound[i])
                        $$.bound[i](value);
                    if (ready)
                        make_dirty(component, i);
                }
                return ret;
            })
            : [];
        $$.update();
        ready = true;
        run_all($$.before_update);
        // `false` as a special case of no DOM component
        $$.fragment = create_fragment ? create_fragment($$.ctx) : false;
        if (options.target) {
            if (options.hydrate) {
                const nodes = children(options.target);
                // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                $$.fragment && $$.fragment.l(nodes);
                nodes.forEach(detach);
            }
            else {
                // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                $$.fragment && $$.fragment.c();
            }
            if (options.intro)
                transition_in(component.$$.fragment);
            mount_component(component, options.target, options.anchor, options.customElement);
            flush();
        }
        set_current_component(parent_component);
    }
    /**
     * Base class for Svelte components. Used when dev=false.
     */
    class SvelteComponent {
        $destroy() {
            destroy_component(this, 1);
            this.$destroy = noop;
        }
        $on(type, callback) {
            if (!is_function(callback)) {
                return noop;
            }
            const callbacks = (this.$$.callbacks[type] || (this.$$.callbacks[type] = []));
            callbacks.push(callback);
            return () => {
                const index = callbacks.indexOf(callback);
                if (index !== -1)
                    callbacks.splice(index, 1);
            };
        }
        $set($$props) {
            if (this.$$set && !is_empty($$props)) {
                this.$$.skip_bound = true;
                this.$$set($$props);
                this.$$.skip_bound = false;
            }
        }
    }
 
    function dispatch_dev(type, detail) {
        document.dispatchEvent(custom_event(type, Object.assign({ version: '3.59.2' }, detail), { bubbles: true }));
    }
    function validate_slots(name, slot, keys) {
        for (const slot_key of Object.keys(slot)) {
            if (!~keys.indexOf(slot_key)) {
                console.warn(`<${name}> received an unexpected slot "${slot_key}".`);
            }
        }
    }
    /**
     * Base class for Svelte components with some minor dev-enhancements. Used when dev=true.
     */
    class SvelteComponentDev extends SvelteComponent {
        constructor(options) {
            if (!options || (!options.target && !options.$$inline)) {
                throw new Error("'target' is a required option");
            }
            super();
        }
        $destroy() {
            super.$destroy();
            this.$destroy = () => {
                console.warn('Component was already destroyed'); // eslint-disable-line no-console
            };
        }
        $capture_state() { }
        $inject_state() { }
    }
 
    /* src\App.svelte generated by Svelte v3.59.2 */
 
    const { Object: Object_1, console: console_1 } = globals;
 
    function create_fragment(ctx) {
    	const block = {
    		c: noop,
    		l: function claim(nodes) {
    			throw new Error("options.hydrate only works if the component was compiled with the `hydratable: true` option");
    		},
    		m: noop,
    		p: noop,
    		i: noop,
    		o: noop,
    		d: noop
    	};
 
    	dispatch_dev("SvelteRegisterBlock", {
    		block,
    		id: create_fragment.name,
    		type: "component",
    		source: "",
    		ctx
    	});
 
    	return block;
    }
 
    function instance($$self, $$props) {
    	let { $$slots: slots = {}, $$scope } = $$props;
    	validate_slots('App', slots, []);
 
    	(async function () {
 
    		/**
     * Name of the class used for a tag counter container.
     * It should be not used on the page it's inserted to.
     **/
    		var TAG_CLASSNAME = "us-tag";
 
    		/**
     * Name of the class absent on the page.
     * Used to return empty collection of nodes from a function.
     **/
    		var FAKE_CLASS_PLACEHOLDER = "fake-class-placeholder";
 
    		/**
     * A time in miliseconds to wait between requests to MFC.
     * Too short time may results in "429 - Too many requests" error responses.
     * Can be increased with REQUEST_DELAY_MULTIPLIER.
     **/
    		var REQUEST_DELAY = 1000;
 
    		/**
     * A multipler that is used on REQUEST_DELAY when 429 response error is obtained.
     * Should be over 1 to work properly.
     **/
    		var REQUEST_DELAY_MULTIPLIER = 1.1;
 
    		/**
     * A time in seconds for how long the entry data saved in a cache is considered "fresh" and up to date.
     * After the entry data is "stale", it is removed from cache and may be replaced with new data.
     **/
    		var CACHE_FRESH_SECONDS = 10 * 60;
 
    		/**
     * Map entries for tagCounterCache that are yet to be persisted in the extension storage.
    	 * The contents: see tagCounterCache
     **/
    		var CACHE_SAVE_ENTRIES = [];
 
    		/**
     * How many entries have to be added to the cache so the cache can be persisted in the extension storage.
    	 * That way if the user gets into another page, some of the data gathered will not be lost.
     * It requires using GM.getValue() and GM.setValue()
     **/
    		var CACHE_SAVE_AFTER_SETTING_VALUES_ORDER = 5;
 
    		/**
     * A cache for tag count indicated in the entry page.
     * It's a Map() consisted of:
     * * keys: pathname of an entry page ("/entry/39")
     * * values: object with fields:
     * ** number: integer with number of tags on the entry page (39)
     * ** updatedTime: timestamp of when the map was updated.
     * Map entries may be deleted after time indicated in CACHE_FRESH_SECONDS.
     **/
    		var tagCounterCache;
 
    		/**
     * Util method. It let the thread sleep for ms (miliseconds) between calls to MFC website.
     **/
    		function sleep(ms) {
    			return new Promise(resolve => setTimeout(resolve, ms));
    		}
 
    		/**
     * Get tagCounterCache from a persistent storage.
     **/
    		async function getTagCounterCache() {
    			return new Map(Object.entries(JSON.parse(await GM.getValue('tagCounterCache', '{}'))));
    		}
 
    		/**
     * Save tagCounterCache with new CACHE_SAVE_ENTRIES to a persistent storage.
     * CACHE_SAVE_ENTRIES will be cleared after succesful save.
     **/
    		async function saveTagCounterCache() {
    			var newTagCounterCache = await getTagCounterCache();
 
    			for (var entry of CACHE_SAVE_ENTRIES) {
    				newTagCounterCache.set(entry.key, entry.value);
    			}
 
    			GM.setValue('tagCounterCache', JSON.stringify(Object.fromEntries(newTagCounterCache)));
    			tagCounterCache = newTagCounterCache;
    			newTagCounterCache.length = 0; /* clear new data as they are persisted */
    		}
 
    		/**
     * Save an url and count of tags to both tagCounterCache and CACHE_SAVE_ENTRIES.
     * If CACHE_SAVE_ENTRIES will have CACHE_SAVE_AFTER_SETTING_VALUES_ORDER entries, 
     * the persistent storage will be updated.
     **/
    		async function pushToTagCounterCache(url, tagCounter) {
    			if (tagCounter) {
    				var time = Date.now();
 
    				var entry = {
    					key: url,
    					value: {
    						'number': tagCounter,
    						'updatedTime': time
    					}
    				};
 
    				tagCounterCache.set(entry.key, entry.value);
    				CACHE_SAVE_ENTRIES.push(entry);
 
    				if (CACHE_SAVE_ENTRIES.length % CACHE_SAVE_AFTER_SETTING_VALUES_ORDER == 0) {
    					saveTagCounterCache();
    				}
    			}
    		}
 
    		/**
     * Get a number of tags for a specified url from cache.
     * if the info is stale after CACHE_FRESH_SECONDS since last update of entry,
     * the info would be deleted, and the functon will return 0.
     * Otherwise, return number of tags.
     **/
    		function getTagCounterFromTagCounterCache(url) {
    			var tagCounterPair = tagCounterCache.get(url);
 
    			if (tagCounterPair == null) {
    				return 0;
    			}
 
    			var stalePairDate = new Date(tagCounterPair.updatedTime);
    			stalePairDate.setSeconds(stalePairDate.getSeconds() + CACHE_FRESH_SECONDS);
 
    			if (stalePairDate < Date.now()) {
    				tagCounterCache.delete(url);
    				return 0;
    			}
 
    			return tagCounterPair.number;
    		}
 
    		/**
    * Add a style for tag counter container (with a TAG_CLASSNAME class).
    * It's done only once the page is loaded.
    **/
    		function addStyles() {
    			let style = document.createElement('style');
    			style.type = 'text/css';
 
    			style.innerHTML = "\
            .item-icon ." + TAG_CLASSNAME + " {\
            position: absolute;\
            display: block;\
            right: -4px;\
            top: -4px;\
            padding: 4px;\
            border-radius: 3px;\
            text-align: center;\
            vertical-align: middle;\
            min-width: 12px;\
            font-weight: 700;\
            font-size: 11px;\
            color: gold;\
            background-color: darkgreen\
            }";
 
    			document.getElementsByTagName('head')[0].appendChild(style);
    		}
 
    		function getEntryContainers() {
    			var pathname = window.location.pathname;
    			var search = window.location.search;
    			var searchParams = new URLSearchParams(search);
    			var tbParam = searchParams.get("_tb");
 
    			if (pathname.includes("/entry/") || pathname.includes("/browse.v4.php") || pathname.includes("/browse/calendar/") || pathname.includes("/item/browse/calendar/") || pathname.includes("/item/browse/figure/") || pathname.includes("/item/browse/goods/") || pathname.includes("/item/browse/media/") || tbParam !== null) {
    				var result = document.querySelectorAll("#wide .result:not(.hidden)"); /* encyclopedia entry */ /* search results with filters */ /* calendar page */ /* new calendar page */ /* new figures page */ /* new goods page */ /* new media page */
    				return result;
    			}
 
    			console.log("unsupported getEntryContainers");
    			return document.querySelectorAll(FAKE_CLASS_PLACEHOLDER);
    		}
 
    		/**
    * Check if the current page (intended to be one with search results)
    * is detailed list.
    * The info is taken from GET/query params instead from the page contents.
    **/
    		function isDetailedList() {
    			var search = window.location.search;
    			var searchParams = new URLSearchParams(search);
    			var outputParam = searchParams.get("output"); /* 0 - detailedList, 1,2 - grid, 3 - diaporama */
    			return outputParam == 0;
    		}
 
    		function getItemsFromContainer(entryContainer) {
    			var icons = entryContainer.querySelectorAll(".item-icons .item-icon");
 
    			if (icons.length > 0) {
    				return icons;
    			}
 
    			var pathname = window.location.pathname;
 
    			if (pathname.includes("/browse.v4.php") && isDetailedList()) {
    				return document.querySelectorAll(FAKE_CLASS_PLACEHOLDER); /* search page, detailed list view */
    			}
 
    			console.log("unsupported getItemsFromContainer");
    			return document.querySelectorAll(FAKE_CLASS_PLACEHOLDER);
    		}
 
    		function getTagCounterFromHtml(html) {
    			var parser = new DOMParser();
    			var doc = parser.parseFromString(html, 'text/html');
    			var tagCounterNode = doc.querySelector("div.tbx-target-TAGS .actions > .meta");
    			if (tagCounterNode == null) console.log("No tag counter element on downloaded html.");
    			return tagCounterNode.textContent;
    		}
 
    		function addTagCounterToSearchResult(itemLinkElement, countOfTags) {
    			var tagElement = document.createElement("span");
    			tagElement.setAttribute("class", TAG_CLASSNAME);
    			tagElement.textContent = countOfTags;
    			itemLinkElement.appendChild(tagElement);
    		}
 
    		async function fetchAndHandle(queue) {
    			var resultQueue = [];
 
    			for (var itemElement of queue) {
    				var itemLinkElement = itemElement.firstChild;
    				var entryLink = itemLinkElement.getAttribute("href");
 
    				fetch(entryLink, {
    					headers: {
    						"User-Agent": GM.info.script.name + " " + GM.info.script.version
    					}
    				}).then(function (response) {
    					if (response.ok) {
    						response.text().then(function (html) {
    							var countOfTags = getTagCounterFromHtml(html);
    							addTagCounterToSearchResult(itemLinkElement, countOfTags);
    							pushToTagCounterCache(entryLink, countOfTags);
    						});
    					}
 
    					return Promise.reject(response);
    				}).catch(function (err) {
    					if (err.status == 429) {
    						console.warn('Too many requests. Added the request to fetch later', err.url);
    						resultQueue.push(itemElement);
    						REQUEST_DELAY = REQUEST_DELAY * REQUEST_DELAY_MULTIPLIER;
    						console.info('Increased delay to ' + REQUEST_DELAY);
    					}
    				});
 
    				await sleep(REQUEST_DELAY);
    			}
 
    			return resultQueue;
    		}
 
    		async function main() {
    			var cacheQueue = [];
    			var entryContainers = getEntryContainers();
 
    			entryContainers.forEach(entryContainer => {
    				var itemsElements = getItemsFromContainer(entryContainer);
 
    				itemsElements.forEach(itemElement => {
    					cacheQueue.push(itemElement);
    				});
    			});
 
    			var queue = [];
    			tagCounterCache = await getTagCounterCache();
 
    			for (var itemElement of cacheQueue) {
    				var itemLinkElement = itemElement.firstChild;
    				var entryLink = itemLinkElement.getAttribute("href");
    				var cache = getTagCounterFromTagCounterCache(entryLink);
 
    				if (cache > 0) {
    					addTagCounterToSearchResult(itemLinkElement, cache);
    				} else {
    					queue.push(itemElement);
    				}
    			}
 
    			while (queue.length) {
    				queue = await fetchAndHandle(queue);
    			}
 
    			saveTagCounterCache();
    		}
 
    		/**
    * All variables and methods are set.
    * Enjoy the show.
    **/
    		addStyles();
 
    		main();
    	})();
 
    	const writable_props = [];
 
    	Object_1.keys($$props).forEach(key => {
    		if (!~writable_props.indexOf(key) && key.slice(0, 2) !== '$$' && key !== 'slot') console_1.warn(`<App> was created with unknown prop '${key}'`);
    	});
 
    	return [];
    }
 
    class App extends SvelteComponentDev {
    	constructor(options) {
    		super(options);
    		init(this, options, instance, create_fragment, safe_not_equal, {});
 
    		dispatch_dev("SvelteRegisterComponent", {
    			component: this,
    			tagName: "App",
    			options,
    			id: create_fragment.name
    		});
    	}
    }
 
    const app = new App({
        target: document.body,
        props: {
            name: "World"
        }
    });
 
    return app;
 
})();