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