Backloggery interop

Backloggery integration with game library websites

// ==UserScript==
// @name         Backloggery interop
// @namespace    http://tampermonkey.net/
// @version      1.2.11
// @description  Backloggery integration with game library websites
// @author       LeXofLeviafan
// @icon         https://backloggery.com/favicon.ico
// @match        *://backloggery.com/*
// @match        *://www.backloggery.com/*
// @match        *://steamcommunity.com/id/*/games*
// @match        *://steamcommunity.com/id/*/stats/*
// @match        *://steamcommunity.com/id/*/gamecards/*
// @match        *://steamcommunity.com/id/*/badges*
// @match        *://steamcommunity.com/stats/*/achievements
// @match        *://steamcommunity.com/stats/*/achievements/*
// @match        *://store.steampowered.com/app/*
// @match        *://steamdb.info/app/*
// @match        *://steamdb.info/calculator/*
// @match        *://astats.astats.nl/astats/User_Games.php?*
// @match        *://gog.com/account
// @match        *://gog.com/*/account
// @match        *://www.gog.com/account
// @match        *://www.gog.com/*/account
// @match        *://www.humblebundle.com/home/*
// @match        *://itch.io/my-collections
// @match        *://*.itch.io/*
// @match        *://www.gamersgate.com/account/*
// @match        *://store.epicgames.com/*
// @match        *://www.dekudeals.com/collection*
// @match        *://www.dekudeals.com/items/*
// @match        *://psnprofiles.com/*
// @match        *://psnprofiles.com/trophies/*
// @match        *://retroachievements.org/user/*
// @match        *://retroachievements.org/game/*
// @require      https://unpkg.com/[email protected]/dist/mreframe.js#sha256=58e147d4a7a1d068a4c6b8e256d4349050fc9cfec3d28e60d66e6e11b660f514
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_addValueChangeListener
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// ==/UserScript==

/* eslint no-multi-spaces: "off", no-sequences: "off", no-return-assign: "off", curly: "off" */
(function() {
  'use strict';

  console.debug("[BL] loaded");
  GM_getValue('settings') || GM_setValue('settings', {});
  const NIL = undefined;
  const ROMAN = {Ⅰ: 'I', Ⅱ: 'II', Ⅲ: 'III', Ⅳ: 'IV', Ⅴ: 'V', Ⅵ: 'VI', Ⅶ: 'VII', Ⅷ: 'VIII', Ⅸ: 'IX', Ⅹ: 'X', Ⅺ: 'XI', Ⅻ: 'XII', Ⅼ: 'L', Ⅽ: 'C', Ⅾ: 'D', Ⅿ: 'M'};
  const LIG = {æ: 'ae', ł: 'l'};
  const [RE_ROMAN, RE_LIG] = [ROMAN, LIG].map(o => RegExp(`[${Object.keys(o).join('')}]`, 'g'));

  /* global require */
  let {identity, keys, vals, entries, dict: _dict, getIn, merge, assoc, assocIn, dissoc, update, chunks, eq, repr, chain, multi} = require('mreframe/util');

  let compact = xs => (xs||[]).filter(Boolean);
  let dict = pairs => _dict( compact(pairs) );
  let nameDict = (custom, ...names) => merge(custom, ...names.map(s => ({[s.toLowerCase()]: s})));
  let sortBy = (xs, weight) => xs.slice().sort((a, b) => weight(a) - weight(b));
  let groupBy = (xs, f) => xs.reduce((o, x, k) => (k = f(x),  (o[k] = o[k] || []).push(x),  o), {});
  let keymap = (ks, f) => dict( ks.map((k, i) => [k, f(k, i)]) );
  let mapEntries = (o, f) => dict( entries(o||{}).map(([k, v]) => f(k, v, o)) );
  let mapKeys = (o, f) => mapEntries(o, (k, v) => [f(k, v, o), v]);
  let mapVals = (o, f) => mapEntries(o, (k, v) => [k, f(v, k, o)]);
  let filterKeys = (o, f) => dict( entries(o||{}).filter(([k, v]) => f(k, v, o)) );
  let filterVals = (o, f) => filterKeys(o, (k, v) => f(v, k, o));
  let pick = (o, ...ks) => dict( ks.map(k => ((o||{})[k] != null) && [k, o[k]]) );
  let last = xs => xs[xs.length - 1];
  let range = (n, m) => (m == null ? range(0, n) : Array.from({length: m-n}, (_, i) => i+n));
  let in_ = (x, xs) => (xs||[]).includes(x);
  let when = (x, f) => (x ? f(x) : NIL);
  let replace = (s, re, pattern) => s.match(re) && s.replace(re, pattern);
  let str = (x, y=`${x}`, n="") => (x ? y : n);
  let join = (...ss) => compact(ss).join('\n');
  let qstr = s => str(in_('?', s), s.slice(1 + s.indexOf('?')));
  let query = s => dict( qstr(s).split('&').map(s => s.match(/([^=]+)=(.*)/)?.slice(1)) );
  let isHost = (...ss) => ss.some(s => `.${location.host}`.endsWith(`.${s}`));
  let slugify = s => s.replace(RE_ROMAN, c => ROMAN[c]).toLowerCase()               // replacing Roman numbers with equivalent Latin letters
    .normalize('NFD').replace(/[\u0300-\u036f]/g, "").replace(RE_LIG, c => LIG[c])  // scrapping diacritics and ligatures
    .replace(/(?<=\b[a-z])[.](?=[a-z]\b)/g, "").replace(/['’]/g, "")                // removing apostrophes, and dots in abbreviations
    .replace(/[^a-z0-9]+/g, '-').replace(/(^-*|-*$)/g, "");                         // replacing non-alphanumeric chars with '-', then trimming
  let capitalize = s => (s = `${s}`,  s.slice(0, 1).toUpperCase() + s.slice(1));
  let pascal = s => slugify(str(s)).split('-').map(capitalize).join("");
  let diff = (oldData, newData, normalize=identity) => when([oldData, newData].map(keys), ([oldKeys, newKeys]) =>
    ({removed: oldKeys.filter(k => !(k in newData)),
      added:   newKeys.filter(k => !(k in oldData)),
      updated: oldKeys.filter(k => (k in newData) && !eq(normalize(oldData[k]), normalize(newData[k])))}));
  let forever = f => setInterval(f, 100);
  let delay = msec => new Promise(resolve => setTimeout(resolve, msec));
  let debounce = (delay, action) => {
    let last = null;
    return function (...args) {clearTimeout(last);   last = setTimeout(() => action.apply(this, args), delay)};
  };

  const USER_OS = when([(navigator.platform||navigator.userAgentData?.platform||"").toLowerCase()], ([platform]) =>
    (platform.startsWith("win") ? 'windows' : platform.startsWith("mac") ? 'mac' : 'linux'));
  const PAGE = location.href;
  const PARAMS = query(location.search);
  const RE = {
    backloggery:        "backloggery\\.com/([^!/]+)($|[/?])",
    backloggeryAdd:     "backloggery\\.com/!/add$",
    backloggeryLists:   "backloggery\\.com/([^!/]+)/lists(?:/([0-9]+))?$",
    backloggeryTypes:   "backloggery\\.com/!/settings/platforms$",
    steamLibrary:       "steamcommunity\\.com/id/([^/]+)/games/?($|\\?)",
    steamAchievements:  "steamcommunity\\.com/id/([^/]+)/stats/[^/]+",
    steamAchievements2: "steamcommunity\\.com/stats/[^/]+/achievements",
    steamDetails:       "store\\.steampowered\\.com/app/([^/]+)",
    steamDbDetails:     "steamdb\\.info/app/[^/]+",
    steamDbLibrary:     "steamdb\\.info/calculator/([^/]+)/",
    steamStats:         "astats\\.astats\\.nl/astats/User_Games\\.php",
    steamBadges:        "steamcommunity\\.com/id/([^/]+)/(gamecards|badges)",
    gogLibrary:         "gog\\.com/([^/]+/)?account",
    humbleLibrary:      "humblebundle\\.com/home/(library|purchases|keys|coupons)",
    itchLibrary:        "itch\\.io/my-collections",
    itchDetails:        "[^/.]\\.itch\\.io/[^/]+$",
    ggateLibrary:       "gamersgate\\.com/account/games",
    epicStore:          "epicgames\\.com",
    dekuLibrary:        "dekudeals\\.com/collection($|\\?(?!(.*&)?filter\\[))",
    dekuDetails:        "dekudeals\\.com/items/",
    psnLibrary:         "psnprofiles\\.com/([^/?]+)/?($|\\?)",
    psnDetails:         "psnprofiles\\.com/trophies/([^/?]+)/([^/?]+)$",
    retroProgress:      "retroachievements\\.org/user/([^/?]+)(/progress)?/?($|\\?)",  // progress + recents (on your profile page)
    retroGame:          "retroachievements\\.org/game/([0-9]+)/?($|\\?)",
  };
  const SETTINGS = GM_getValue('settings');

  const PSN_HW = {PS3: '3', PS4: '4', PS5: '5', VITA: 'V', VR: 'v'};
  const [ITCH_CDN, GGATE_CDN, PSN_CDN] = ["https://img.itch.zone/", "https://sttc.gamersgate.com/images/product/", "https://i.psnprofiles.com/games/"];
  const [EPIC_CDN, EPIC_STORE, RETRO_CDN] = ["https://cdn1.epicgames.com", "https://www.epicgames.com/store/product/", "https://media.retroachievements.org/Images/"];

  let $clear  = e => {while (e.firstChild) e.removeChild(e.firstChild);
                      return e};
  let $append = (parent, ...children) => (children.forEach(e => e && parent.appendChild(e)),  parent);
  let $before = (neighbour, ...children) => (children.forEach(e => e && neighbour.parentNode.insertBefore(e, neighbour)),  neighbour);
  let $after  = (neighbour, ...children) => when(neighbour.parentNode, parent => {
    (neighbour == parent.lastChild ? $append(parent, ...children) : $before(neighbour.nextSibling, ...children));
    return neighbour;
  });
  let $e = (tag, options, ...children) => $append(Object.assign(document.createElement(tag), options), ...children);
  let $get   = (xpath, e=document) => e && document.evaluate(xpath, e, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
  let $find  = (selector, e=document) => e && e.querySelector(selector);
  let $find_ = (selector, e=document) => Array.from(typeof e?.querySelectorAll !== 'function' ? [] : e.querySelectorAll(selector));
  let $loadIcons = (e=document.head) => e?.append($e('link', {rel: 'stylesheet', href: "https://use.fontawesome.com/releases/v5.7.0/css/all.css"}));
  let $simulateInput = (input, value) => Object.assign(input, {value}).dispatchEvent(new Event('input', {bubbles: true}));
  let $addEventListener = (e, key, fn, _key=`_${key}BL`) => e?.addEventListener(key, e[_key] || (e[_key] = fn));
  let $setEventListener = (e, key, fn, _key=`_${key}BL`) => (e?.removeEventListener(key, e[_key]),   e?.addEventListener(key, (e[_key] = fn)));
  let $visibility = (e, x) => {e.style.visibility = (x ? 'visible' : 'hidden')};
  let $withLoading = (cursor, thunk) => new Promise(resolve => {
    cursor && document.body.classList.add(`${cursor}BL`);
    setTimeout(resolve, 100);
  }).then(thunk).finally(() => cursor && document.body.classList.remove(`${cursor}BL`));
  let $markUpdate = k => GM_setValue('updated', merge(GM_getValue('updated'), {[k]: new Date().getTime()}));
  let $stop = (f=(()=>{})) => e => (f(e),  e.stopPropagation(),  false);
  let $fetchJson = url => fetch(url).then(response => response.json());
  let $watcher = f => new MutationObserver((xs, watcher) => xs.forEach(x => x.addedNodes.forEach(e => e.tagName && f(e, watcher))));
  let $addStyle = (prefix, styles) => GM_addStyle( styles.map(s => s.replace(/^(?!\$)\s*/g, prefix+' ').replace(/\$/g, prefix)).join("\n") );
  let $addChanges = (newChanges) => when(GM_getValue('changes', []), (changes, oldChanges=new Set(changes)) =>
    GM_setValue('changes', [...changes, ...compact( newChanges.filter(id => !oldChanges.has(id)) )]));
  const WATCH_FIELDS = "name slug worksOn completed tags achievements platforms platform status trophies".split(' ');
  let $update = (library, newData) => when(GM_getValue(library, {}), oldData => {
    let {removed, added, updated} = diff(oldData, newData, o => chain(o, [pick, ...WATCH_FIELDS], [filterVals, Boolean]));
    $markUpdate(library);
    $addChanges( [...removed, ...updated].map(id => `${library}#${id}`) );
    console.debug("[BL] update stats", {library, old: oldData, new: newData, removed, added, updated});
    (removed.length > 0) && console.warn("[BL] removed:", library, removed.length, pick(oldData, ...removed));
    (updated.length > 0) && console.warn("[BL] updated:", library, updated.length, pick(oldData, ...updated), pick(newData, ...updated));
    (added.length   > 0) && console.warn("[BL] added:",   library, added.length,   pick(newData, ...added));
    GM_setValue(library, newData);
    setTimeout(() => alert( join(`Backloggery interop: added ${added.length} games, removed ${removed.length} games`,
                                 `(${updated.length} of ${keys(oldData).length} games changed)`) ));
  });
  const WATCH_META = {'steam-stats': 'steam', 'steam-platforms': 'steam', 'retro': 'retro'};
  let $mergeData = (key, newData, {showAlert}={}) => when([WATCH_META[key], GM_getValue(key, {})], ([library, oldData]) => {
    let {added, updated} = diff(oldData, newData, repr);
    library && $addChanges( updated.map(id => `${library}#${id}`) );
    console.debug("[BL] merging update stats", {library: library||key.replace(/-.*/, ""), size: keys(newData).length,
                                                old: pick(oldData, ...keys(newData)), oldAll: oldData, new: newData, added, updated});
    (updated.length > 0) && console.warn("[BL] updated:", key, updated.length, pick(oldData, ...updated), pick(newData, ...updated));
    (added.length   > 0) && console.warn("[BL] added:",   key, added.length,   pick(newData, ...added));
    GM_setValue(key, merge(oldData, newData));
    showAlert && (updated.length + added.length > 0) && setTimeout(() =>
      alert( join(`Backloggery interop: added ${added.length} games`, `(${updated.length} of ${keys(oldData).length} games changed)`) ));
  });

  if (isHost('backloggery.com', 'gog.com', 'humblebundle.com', 'itch.io', 'epicgames.com', 'psnprofiles.com')) {
    $loadIcons();
    GM_addStyle(`#loaderBL {position: fixed;  top: 50%;  left: 50%;  z-index: 10000;  transform: translate(-50%, -50%);
                            font-size: 300px;  text-shadow: -1px 0 grey, 0 1px grey, 1px 0 grey, 0 -1px grey}
                 @-webkit-keyframes rotationBL {from {-webkit-transform:rotate(0deg)}
                                                to {-webkit-transform:rotate(360deg)}}
                 @keyframes rotationBL {from {transform:rotate(0deg) translate(-50%, -50%);  -webkit-transform:rotate(0deg)}
                                        to {transform:rotate(360deg) translate(-50%, -50%);  -webkit-transform:rotate(360deg)}}
                 .rotatingBL {animation: rotationBL 2s linear infinite}
                 .progressBL * {cursor: progress !important}   .waitBL * {cursor: wait !important}
                 #loaderBL {display: none}   :is(.progressBL, .waitBL) #loaderBL {display: unset}`);
  }

  const USER_ID = (isHost('steamcommunity.com', 'store.steampowered.com') ? $find("#global_actions a.user_avatar")?.href.match("/id/([^/]+)/$")?.[1] :
                   isHost('steamdb.info') ? $get("//a[text()='Your calculator']", $find('.account-menu')||null)?.href.match(RE.steamDbLibrary)?.[1] :
                   isHost('astats.nl') ? when($find(".navbar-right .dropdown-menu a[href^='/astats/User_Info.php?']")?.href, s =>
                                           query(new URL(s).search).SteamID64) :
                   isHost('retroachievements.org') ? $find(`nav .dropdown-menu-right a.dropdown-item[href*="/user/"]`)?.href.match(RE.retroProgress)?.[1] :
                   isHost('psnprofiles.com') ? SETTINGS.psnId : NIL);
  USER_ID && console.debug("[BL] info", {USER_ID});

  // eslint-disable-next-line no-undef
  if (isHost('backloggery.com')) new Promise(resolve => app.__vue__.$store.watch(x => x.page_platforms, resolve, {once: true})).then(BL_PLATFORMS => {

    let {reFrame: rf, reagent: r, atom: {deref, reset, swap}} = require('mreframe');

    let _type = s => s?.toLowerCase().replace(/\s+/g, '').replace(/^pc$/, 'windows').replace(/^ns(?=2?$)/, 'switch');
    const TYPES = dict(BL_PLATFORMS.map(x => [_type(x.abbr), x.platform_id]));
    let _loadTypeNames = () => Promise.resolve(GM_getValue('platforms')||fetch(`/api/fetch_platforms.php`).then(x => x.json())
      .then(o => dict( o.payload.map(x => [_type(x.abbr), x.title.replace(/^PC$/, "Windows (PC)")]).sort() )).then(o => (GM_setValue('platforms', o), o)));
    const RETRO_TYPES = nameDict({
      '64dd': 'N64', ams: 'CPC', appii: 'A2', arc2k1: 'A2001', ardu: 'ARD', cboy: 'DUCK', erdr: 'GBA', fc: 'NES', fds: 'NES', gcn: 'GC', gen: 'MD',
      intvis: 'INTV', jagcd: 'JCD', jaguar: 'JAG', nds: 'DS', ody2: 'MO2', pc80: '80/88', pc88: '80/88', pcecd: 'PCCD', pcfx: 'PC-FX', pkmini: 'MINI',
      ps: 'PS1', saturn: 'SAT', sg1000: 'SG1K', sfc: 'SNES', sgfx: 'PCE', smd: 'MD', svis: 'WSV', tg16: 'PCE', tgcd: 'PCCD', tvgc: 'ELEK', uzebox: 'UZE',
    }, '2600', '32X', '3DO', '7800', 'ARC', 'CHF', 'CV', 'DC', 'DSi', 'GB', 'GBA', 'GBC', 'GG', 'Lynx', 'MSX',
       'N64', 'NES', 'NGCD', 'NGP', 'PCE', 'PS2', 'PSP', 'SCD', 'SMS', 'SNES', 'VB', 'VECT', 'WASM4', 'WS');
    const RETRO_MISC = ['EXE', 'VC4000'];  // unmatched: standalone, Inverton VC 4000
    console.debug("[BL] types", TYPES, mapEntries(TYPES, k => when(RETRO_TYPES[k], x => [k, x])));

    const LIBS = ['steam', 'gog', 'humble', 'epic', 'itch', 'ggate', 'psn', 'deku', 'retro'];
    const EXTRAS = ['updated', 'steam-stats', 'steam-platforms', 'steam-rating', 'steam-my-tags', 'itch-info', 'psn-img', 'deku-info', 'retro-info'];
    const OS = {w: ["Windows", 'fa-windows'], l: ["Linux", 'fa-linux'], m: ["MacOS", 'fa-apple'],
                a: ["Android", 'fa-android'], s: ["Steam", 'fa-steam'], b: ["Web", 'fa-chrome']};
    const CUSTOM_ICONS = {steam: "fab fa-steam", windows: "fab fa-windows", linux: "fab fa-linux", mac: "fab fa-apple", android: "fab fa-android",
                          console: "fas fa-gamepad", xbox: "fab fa-xbox", playstation: "fab fa-playstation", web: "fab fa-chrome",
                          nodejs: "fab fa-node-js", flash: "fab fa-adobe", dice: "fas fa-dice", d20: "fas fa-dice-d20", trophy: "fas fa-trophy"};
    const INCOMPLETE = new Set(['none', 'unfinished', 'unplayed']);
    const RATING_ICON = [[94, '😎'], [90, '😍'], [80, '😋'], [70, '😏'], [60, '😌'], [50, '😐'], [40, '😕'], [30, '😢'], [20, '😨'], [0, '😱']];

    let statStr = (o={}, ...ks) => join(...ks.map(k => (o[k] || (o[k] === 0)) && `${capitalize(k)}: ${o[k]}`));
    const NOISE = new Set(`a an and as at by from for in into is of on or so the to game
                           collection edition remastered anniversary i ii iii iv v vi vii viii ix x`.split(/\s+/));
    let words = s => slugify(s).split('-').sort().reverse();
    let matching = (ss, zs) => {
      let weight = 0, i = 0, j = 0;
      while ((i < ss.length) && (j < zs.length)) {
        let s = ss[i],  z = zs[j];
        if (s === z) {
          i++,  j++,  weight += (NOISE.has(s) || !isNaN(s) ? 1.1 : 2);
        } else if (z.startsWith(s)) {
          i++,  j++,  weight += (z === s+'s' ? 1.5 : 1);
        } else {
          if (s < z) j++;  else i++;
        }
      }
      return weight;
    };
    let convertExcludeKey = (k, deku, psn) => k.replace(/^itchio#/, "itch#")
      .replace(/^(?:psvr|psvita|ps[345]|switch|xbo|xboxs[xs])#(.*)$/, (_, id) => (id in deku ? `deku#${id}` : id in psn ? `psn#${id}` : ""));
    let convertExclude = (old, [deku, psn] = ['deku', 'psn'].map(k => GM_getValue(k, {}))) =>
      chain(groupBy(keys(old), k => k.replace(/#.*$/, "")),
            [mapVals, ks => compact( ks.map(k => old[k] && convertExcludeKey(k, deku, psn)) ).sort()],
            [filterVals, ks => ks.length > 0]);

    const INITIAL_STATE = {
      location:   new URL(location),
      cache:      keymap([...LIBS, ...EXTRAS, 'changes'], k => GM_getValue(k, (k === 'changes' ? [] : {}))),
      backlog:    GM_getValue('backlog2', {}),
      oldBacklog: GM_getValue('backlog'),
      exclude:    when(GM_getValue('exclude', {}), exclude => (keys(exclude).every(k => k in TYPES) ? exclude :
                                                               when(convertExclude(exclude), o => (GM_setValue('exclude', o),  o)))),
      lists:      GM_getValue('lists', {}),
      collapsed:  false,
    };

    rf.regSub('userId', getIn);
    rf.regSub('location', getIn);
    rf.regSub('cache', getIn);
    rf.regSub('hovered', getIn);
    rf.regSub('overlay', getIn);
    rf.regSub('overlayTypes', getIn);
    rf.regSub('collapsed', getIn);
    rf.regSub('upd', getIn);
    rf.regSub('backlog', getIn);
    rf.regSub('oldBacklog', getIn);
    rf.regSub('backlogTypeNames', getIn);
    rf.regSub('exclude', getIn);
    rf.regSub('lists', getIn);

    let _types = [], _regDataSub = (k, ...args) => when(TYPES[k], () => {_types.push(k);  rf.regSub(`data:${k}`, ...args)});
    let _rating = n => RATING_ICON.find(([m]) => n >= m)?.[1],  _rating5 = n => `${n}/5 ${_rating(25*(n-1))}`; // 1..5
    _regDataSub('steam', '<-', ['cache', 'steam'], '<-', ['cache', 'steam-stats'], '<-', ['cache', 'steam-platforms'],
                         '<-', ['cache', 'steam-rating'], '<-', ['cache', 'steam-my-tags'],
      ([data, stats={}, platforms={}, rating={}, tags={}]) => mapEntries(data, (id, o) =>
        [`steam#${id}`, merge(o, {url: `https://steamcommunity.com/app/${id}`, tags: tags[id], achievements: stats[id]||'?',
                                  rating: when(rating[id], n => n+"% "+_rating(n)), worksOn: platforms[id]||'s'})]));
    _regDataSub('gog', '<-', ['cache', 'gog'], data => mapEntries(data, (id, o) =>
      [`gog#${id}`, merge(o, {url: str(o.url, `https://gog.com${o.url}`, NIL), completed: (o.completed ? 'yes' : 'no')})]));
    _regDataSub('humble', '<-', ['cache', 'humble'], data => mapKeys(data, k => `humble#${k}`));
    _regDataSub('epic', '<-', ['cache', 'epic'], data => mapEntries(data, (id, o) =>
      [`epic#${id}`, merge(o, {url: o.slug && EPIC_STORE+o.slug,  icon: EPIC_CDN+o.icon,  image: EPIC_CDN+o.image,
                               features: ['online', 'cloud'].filter(k => o[k]).join(", ")})]));
    _regDataSub('itchio', '<-', ['cache', 'itch'], '<-', ['cache', 'itch-info'], ([data, info={}]) => {
      let _info = mapVals(info, ({worksOn, rating, at, ...meta}) => ({worksOn, rating, sync: at, meta}));
      return mapEntries(data, (id, o) => [`itch#${id}`, merge(o, _info[id], o.image && {image: ITCH_CDN+o.image},
                                                              {meta: {Acquired: o.date, ...(_info[id]?.meta||{})}})]);
    });
    _regDataSub('ggate', '<-', ['cache', 'ggate'], data =>
      mapEntries(data, (id, o) => [`ggate#${id}`, merge(o, {url: `https://gamersgate.com/account/orders/${id.replace(':', '#')}`,
                                                            image: o.image && (GGATE_CDN+o.image)})]));
    let _dekuData = platform => ([data, info={}]) => mapEntries(filterVals(data, o => o.platform === platform), (id, o) =>
      when(merge(o, info[ o.url.replace(/^\//, "") ]), o =>
        [`deku#${id}`, merge(o, {url: `https://www.dekudeals.com/items${o.url}`},
                             ...['image', 'icon'].map(s => o[s] && {[s]: `https://cdn.dekudeals.com/images${o[s]}`}))]));
    let _psnData = platform => ([data, images={}]) => mapEntries(filterVals(data, o => in_(PSN_HW[platform], o.platforms)),
      (id, o) => [`psn#${id}`, merge(o, {url: `https://psnprofiles.com/trophies/${id}/${SETTINGS.psnId||''}`},
                                     mapVals({icon: o.icon, image: images[id]}, s => s && PSN_CDN+s))]);
    let _retroData = platform => ([data, info={}]) => mapEntries(filterVals(data, o => o.platform === platform), (id, o) =>
      [`retro#${id}`, merge(info[id], o, {url: `https://retroachievements.org/game/${id}`},
                            mapVals({icon: o.icon, image: info[id]?.image}, s => s && (RETRO_CDN+s)))]);
    entries( nameDict({xboxss: 'xboxsx'}, 'switch', 'xbo', 'xboxsx') ).forEach(([type, platform]) =>
      _regDataSub(type, '<-', ['cache', 'deku'], '<-', ['cache', 'deku-info'], _dekuData(platform)));
    entries( nameDict({psvita: 'VITA', psvr: 'VR'}, 'PS3') ).forEach(([type, platform]) =>
      _regDataSub(type, '<-', ['cache', 'psn'], '<-', ['cache', 'psn-img'], _psnData(platform)));
    entries( nameDict({}, 'PS4', 'PS5') ).forEach(([type, platform]) =>
      _regDataSub(type, '<-', ['cache', 'psn'], '<-', ['cache', 'psn-img'], '<-', ['cache', 'deku'], '<-', ['cache', 'deku-info'],
        ([psn, psnImages, deku, dekuInfo]) => merge(_psnData(platform)([psn, psnImages]), _dekuData(type)([deku, dekuInfo]))));
    entries(RETRO_TYPES).forEach(([type, platform]) => _regDataSub(type, '<-', ['cache', 'retro'], '<-', ['cache', 'retro-info'], _retroData(platform)));
    _regDataSub('misc', '<-', ['cache', 'retro'], '<-', ['cache', 'retro-info'], args => merge( ...RETRO_MISC.map(k => _retroData(k)(args)) ));
    rf.regSub('data', () => keymap(_types, type => rf.subscribe([`data:${type}`])), identity);
    rf.regSub('data*', ([_, type]) => (!in_(type, _types) ? r.atom() : rf.subscribe([`data:${type}`])), (o, [_, type, ...path]) => getIn(o, path));
    rf.regSub('cached', '<-', ['cache'], o => LIBS.filter(k => o[k]).flatMap( multi().default(k => [[keys(o[k]).length, {itch: 'itchio'}[k]||k]])
      .when('deku', k => entries( groupBy(vals(o[k]), x => x.platform) ).map(([type, xs]) => [xs.length, type, ...({xboxsx: ['xboxss']}[type] || [])]))
      .when('psn', k => entries( groupBy(vals(o[k]).flatMap(x => x.platforms.split('').map(c => [c, x])), ([c]) => c) ).map(([c, xs]) =>
        [xs.length, 'ps' + ({[PSN_HW.VITA]: 'vita', [PSN_HW.VR]: 'vr'}[c]||c)]))
      .when('retro', k => entries( groupBy(vals(o[k]), x => x.platform) ).map(([type, xs]) =>
        [xs.length, ...(in_(type, RETRO_MISC) ? ['misc'] : keys(RETRO_TYPES).filter(k => RETRO_TYPES[k] == type))])) ));
    rf.regSub('counts', '<-', ['cached'], '<-', ['backlogTypeNames'], ([cached, names={}]) =>
      chain(groupBy(cached, ([n, ...ks]) => compact( ks.map(k => names[k]) ).sort().join(" | ") || '?'),
            [mapVals, xs => xs.map(([n]) => n).reduce((a, b) => a+b)], (o => entries(o).sort()), [sortBy, ([s, n]) => -(s != '?' ? n : Infinity)]));
    rf.regSub('#data:all', ([_, type]) => rf.subscribe(['data*', type]), o => keys(o).length);
    rf.regSub('bound-ids', '<-', ['backlog'], (o, [_, type]) => compact( vals(o).map(x => x[type]) ).sort());
    rf.regSub('bound-ids*', ([_, type]) => rf.subscribe(['bound-ids', type]), ids => new Set(ids));
    rf.regSub('#bound-ids', ([_, type]) => rf.subscribe(['bound-ids', type]), ids =>
      mapVals(groupBy(ids, identity), xs => xs.length));
    rf.regSub('exclude*', ([_, type]) => rf.subscribe(['exclude', type]), ks => new Set(ks||[]));
    rf.regSub('data+', ([_, type]) => ['data*', '#bound-ids', 'exclude*'].map(k => rf.subscribe([k, type])), ([data, bound, excluded]) =>
      keys(data).map(id => ({id, bound: bound[id]||0, exclude: excluded.has(id), ...data[id]}))
                .sort((a, b) => (a.exclude-b.exclude) || (a.bound-b.bound) || a.name.localeCompare(b.name)));
    rf.regSub('word-sets', ([_, type]) => rf.subscribe(['data*', type]), data => mapVals(data, o => words(o.name)));
    rf.regSub('sort:all', ([_, type]) => ['word-sets', 'data+'].map(k => rf.subscribe([k, type])),
      ([sets, data], [_, type, id, text, weight=when(words(text), zs => mapVals(sets, ss => matching(zs, ss)))]) =>
        data.slice().sort((a, b) => ((b.id == id)-(a.id == id)) || (a.exclude-b.exclude) || (weight[b.id]-weight[a.id])));
    rf.regSub('sort:unbound', ([_, ...args]) => rf.subscribe(['sort:all', ...args]), xs => xs.filter(x => x.bound == 0));
    rf.regSub('#data:unbound', ([_, type]) => rf.subscribe(['data+', type]), (xs, [_, type, {excluded=true}={}]) =>
      xs.filter(x => (x.bound == 0) && (excluded || !x.exclude)).length);
    let _convertList = ([list, {'0': name, ...games}]) => when(name.match(/^(.*?)(?:\n([^]*))?$/), ([_, _name, desc]) =>
      entries(games).map(([id, s]) => [id, _name, desc, list, ...(s?.match(/(.*?):([^]*)/)||[]).slice(1)]));
    rf.regSub('lists*', '<-', ['lists'], o => mapVals(groupBy(entries(o).flatMap(_convertList), ([id]) => id), xs => xs.map(([_, ...x]) => x).sort()));
    rf.regSub('slugs', ([_, type]) => ['data*', 'bound-ids*', 'exclude*'].map(k => rf.subscribe([k, type])),
      ([data, bound, excluded], [_, type, {withExcluded=false}={}]) =>
         dict( sortBy(keys(data), k => !bound.has(k)).map(k => (withExcluded || !excluded.has(k)) && [slugify(data[k].name), k]) ));
    rf.regSub('slug', (([_, type]) => rf.subscribe(['slugs', type])), (o, [_, type, ...path]) => getIn(o, path));
    rf.regSub('#slugs', (([_, type]) => rf.subscribe(['slugs', type])), o => keys(o).length);

    rf.regSub('detected-id', ([_, {type, name}]) => rf.subscribe(['slug', type, slugify(name)]), identity);
    rf.regSub('library-id', ([_, o]) => rf.subscribe(['backlog', o.id]), (bl, [_, {type, libId}]) => libId || (bl?.custom ? NIL : bl?.[type]));
    rf.regSub('library-id*', ([_, o]) => [['backlog', o.id, 'custom'], ['library-id', o], ['detected-id', o]].map(rf.subscribe),
      ([custom, libId, detected], [_, {type}]) => (custom ? NIL : libId || detected));
    rf.regSub('backlog-entry', ([_, o]) => [['backlog', o.id], ['library-id', o]].map(rf.subscribe),
      ([bl, libId], [_, {name, type}]) => merge(bl, name && {name}, libId && {[type]: libId}));
    rf.regSub('library-entry', ([_, o]) => [['backlog-entry', o], ['library-id', o], ['data*', o.type]].map(rf.subscribe),
      ([bl, libId, data]) => bl?.custom||data?.[libId]);
    rf.regSub('library-source', ([_, o]) => [['backlog-entry', o], ['library-id', o]].map(rf.subscribe), ([bl, libId], [_, {type}]) =>
      (bl.custom ? 'custom' :  libId ? libId.replace(/#.*/, "") :
       in_(type, ['switch', 'xbo', 'xboxsx', 'xboxss', 'ps4', 'ps5']) ? 'deku' :
       in_(type, ['psvita', 'psvr', 'ps3', 'ps4', 'ps5']) ? 'psn' :
       type == 'itchio' ? 'itch' : in_(type, _types) ? type : NIL));
    rf.regSub('library-stats-updated', ([_, o]) => [['cache', 'updated'], ['library-source', o]].map(rf.subscribe),
              ([o, source]) => o[{steam: 'steam-stats'}[source]||source]);

    const _DATA_SUBS = {bl: 'backlog-entry', libId: 'library-id', data: 'library-entry', source: 'library-source'};
    let _dataSubs = (...extras) => ([_, o]) => mapVals(merge(_DATA_SUBS, ...extras), k => rf.subscribe([k, o]));
    rf.regSub('game-icon', _dataSubs(), multi(o => o.source).default(({data}) => data?.icon||data?.image)
      .when('steam', ({libId}) => `https://cdn.akamai.steamstatic.com/steam/apps/${libId.replace("steam#", "")}/capsule_184x69.jpg`)
      .when('gog',   ({data}) => `https:${data?.image}_196.jpg`));
    rf.regSub('game-image', _dataSubs(), multi(o => o.source).default(({data}) => data?.image||data?.icon)
      .when('steam', ({libId}) => `https://steamcdn-a.akamaihd.net/steam/apps/${libId.replace("steam#", "")}/header.jpg`)
      .when('gog',   ({data}) => `https:${data?.image}_392.jpg`));
    let _retroStatus = s => capitalize((s||'unfinished').split('-').map((z, i) => (i < 1 ? z : `(${z})`)).join(' '));
    const _ITCH_DATE = new Set(["Acquired", "Updated", "Published", "Release date"]);
    rf.regSub('game-stats', _dataSubs(), multi(o => o.source).default(() => "")
      .when('steam',  ({data}) => statStr(data, 'rating', 'tags', 'achievements'))
      .when('gog',    ({data}) => statStr(data, 'category', 'tags'))
      .when('humble', ({data}) => statStr(data, 'developer', 'publisher'))
      .when('epic',   ({data}) => statStr(data, 'developer', 'features'))
      .when('itch',   ({data}) => when(mapVals(data?.meta, (x, k) => str(!_ITCH_DATE.has(k), x, new Date(x))), meta =>
                                    statStr(meta, ...keys(meta))))
      .when('deku',   ({data}) =>
        join(statStr(data, 'status', 'size', 'genre', 'released', 'metacritic', 'openCritic'), data?.time, data?.notes))
      .when('psn',    ({data}) => statStr(data, 'achievements', 'status', 'trophies', 'progress'))
      .when('retro',  ({data}) => join(when(data.status, s => "Status: "+_retroStatus(s)),
        statStr(data, 'achievements', 'hardcore', 'softcore', 'genre', 'released', 'developer', 'publisher'))));
    rf.regSub('game-name-stats', _dataSubs({stats: 'game-stats'}), (o, [_, {name}]) => join(`[${o.source}] ${o.data?.name||name||""}`, o.stats));
    rf.regSub('game-stats-updated', _dataSubs({upd: 'library-stats-updated'}), multi(o => o.source).default(({upd}) => upd)
      .when('custom', ({data}) => data?.updated)
      .when('itch',   ({data}) => data?.sync)
      .when('retro',  ({data}) => data?.sync));
    rf.regSub('game-synced', ([_, o]) => rf.subscribe(['game-stats-updated', o]), x => str(x, `Synced: ${new Date(x)}`));
    rf.regSub('game-stats*', ([_, o]) => [['game-name-stats', o], ['game-synced', o]].map(rf.subscribe), ss => join(...ss).split('\n'));
    rf.regSub('game-overlay', ([_, o]) => mapVals({image: ['game-image', o], stats: ['game-stats*', o]}, rf.subscribe), identity);
    let _cheevosMatch = ({achievements="0 / 0"}, cheevos) => (achievements || "0 / 0") == cheevos;
    rf.regSub('game-mark', _dataSubs(), multi(o => o.source).default(() => false)
      .when('steam', ({data}, [_, {cheevos}]) => !_cheevosMatch(data, cheevos))
      .when('psn',   ({data}, [_, {cheevos}]) => !_cheevosMatch(data, cheevos))
      .when('gog',   ({data}, [_, {status}]) => (data.completed == 'yes') == INCOMPLETE.has(status))
      .when('retro', ({data}, [_, {status, cheevos}]) => !_cheevosMatch(data, cheevos) || (data.achievements &&
        !in_(data.status, {completed: ['completed', 'mastered'], beaten: ['beaten-softcore', 'beaten-hardcore']}[status] || [NIL])))
      .when('deku',  ({data}, [_, {status, physical, priority}]) => {
        let valid = {"Completed": in_(status, ['beaten', 'completed']), "Currently playing": in_(priority, ["Ongoing", "Now Playing"]),
                     "Abandoned": priority === "Shelved",               "Want to play": in_(priority, ["Paused", "High"])}[data?.status];
        return ((data?.physical||false) != physical) || (data?.status && !valid)
      }));
    rf.regSub('game-highlight', ([_, o]) => [['backlog-entry', o], ['game-mark', o]].map(rf.subscribe), ([bl, mark]) =>
      (bl.custom || bl.ignore ? "linear-gradient(45deg, grey, dimgrey, transparent)" :
       mark                   ? "linear-gradient(45deg, darkred, indianred)" :
       "linear-gradient(45deg, #000A, transparent)"));
    rf.regSub('game-append', _dataSubs(), multi(o => o.source).default(() => "")
      .when('custom', () => " [custom]")
      .when('steam',  ({data}) => [str(data?.rating, ` [${data?.rating}]`), str(data?.hours, ` [${data?.hours}h]`)].join(""))
      .when('gog',    ({data}) => str(data?.rating, ` [${_rating5(data?.rating)}]`))
      .when('itch',   ({data}) => str(data?.rating, ` [${_rating5(data?.rating)}]`))
      .when('deku',   ({data}) => when({physical: "Physical", dlc: "DLC", rating: _rating5(data?.rating)}, o =>
        entries(o).map(([k, v]) => str(data?.[k], ` [${v}]`)).join("")))
      .when('psn',    ({data}) => str(data?.rank, ` [${data?.rank} rank]`)));
    rf.regSub('game-append*', ([_, o]) => [['backlog-entry', o], ['game-append', o]].map(rf.subscribe),
              ([bl, append]) => (bl?.ignore ? " [STATUS IGNORED]" : append||""));
    rf.regSub('game-platforms', _dataSubs(), ({data, source}) =>
      (source == 'custom' ? str(data?.icons).split(" ").map(s => [capitalize(s), CUSTOM_ICONS[s]]) :
       str(data?.worksOn).split("").map(c => OS[c]).map(([title, cls]) => [title, `fab ${cls}`])));
    rf.regSub('game-platforms*', ([_, o]) => rf.subscribe(['game-platforms', o]), xs => xs.map(([title]) => title).join(", "));
    rf.regSub('game-tooltip', ([_, o]) => ['append*', 'platforms*', 'synced'].map(k => rf.subscribe([`game-${k}`, o])),
      ([append, os, synced], [_, {name}]) => join(name + append + str(os, ` {${os}}`), synced));
    rf.regSub('game-link', ([_, o]) => rf.subscribe(['library-entry', o]), x => x?.url && {href: x.url});
    rf.regSub('game-status', ([_, type, id]) => rf.subscribe(['data*', type, id]),
      multi((o, [_, type, id]) => id.replace(/#.*/, "")).default(o => o?.status)
        .when('gog',   o => statStr(o, 'tags'))
        .when('psn',   o => statStr(o, 'rank', 'progress', 'trophies'))
        .when('deku',  o => join(o?.notes, o?.status))
        .when('retro', o => join(_retroStatus(o?.status), statStr(o, 'hardcore', 'softcore'))));
    rf.regSub('game-details', ([_, type, id]) => ['data*', 'game-status'].map(k => rf.subscribe([k, type, id])),
      ([o, status]) => ({...pick(o, 'achievements'), status}));

    rf.regSub('changes?', '<-', ['cache', 'changes'], xs => (xs||[]).length > 0);
    rf.regSub('changed', '<-', ['cache', 'changes'], xs => new Set(xs||[]));
    rf.regSub('changes', '<-', ['backlog'], '<-', ['changed'], ([backlog, changed]) =>
      filterVals(backlog, x => _types.some(k => changed.has(x[k]))));
    let _sortedBl = o => entries(o).map(([id, x]) => ({id, ...x})).sort((a, b) => (a.name||"").localeCompare(b.name||""));
    rf.regSub('changes*', '<-', ['changes'], _sortedBl);
    rf.regSub('custom', '<-', ['backlog'], o => filterVals(o, x => x.custom));
    rf.regSub('oldBacklog*', '<-', ['oldBacklog'], o => o||{});
    rf.regSub('old-custom', '<-', ['oldBacklog'], o => filterVals(o, x => x.custom));
    rf.regSub('old-custom*', '<-', ['old-custom'], _sortedBl);
    rf.regSub('#old-libs', '<-', ['oldBacklog'], o => vals(o).filter(x => !x.custom).length);
    rf.regSub('search-uri', '<-', ['userId'], (userId, [_, name, type]) => (!userId ? NIL :
      `/${userId}/library?${new URLSearchParams( dict([['search', name], ['page', 1], type && ['platform', '['+type+']']]) )}`));
    rf.regSub('uri', '<-', ['location'], o => `${o.pathname}?${new URLSearchParams(o.search)}`);

    let _checkUrl = rf.enrich(db => merge(db, {location: new URL(location), overlay: location.href.match(RE.backloggery),
                                               overlayTypes: location.href.match(RE.backloggeryTypes)}));
    let _setLists = rf.after((db, evt) => GM_setValue('lists', db.lists));

    rf.regEventDb('init', [_checkUrl], () => INITIAL_STATE);
    rf.regEventDb('set-userId', [_checkUrl, rf.unwrap, rf.path('userId')], (_, id) => id);
    rf.regEventDb('set-typeNames', [rf.unwrap, rf.path('backlogTypeNames')], (_, o) => o);
    rf.regEventDb('update-cache', [_checkUrl, rf.trimV, rf.path('cache')], (o, [k, v]) => assoc(o, k, v));
    rf.regEventDb('update-backlog', [_checkUrl, rf.unwrap, rf.path('backlog')], (_, o) => o);
    rf.regEventDb('update-exclude', [_checkUrl, rf.unwrap, rf.path('exclude')], (_, o) => o);
    rf.regEventDb('update-lists', [_checkUrl, rf.unwrap, rf.path('lists')], (_, o) => o);
    rf.regEventFx('check-url', [_checkUrl], () => {});  // invoking cofx explicitly
    rf.regEventDb('set-hovered', [rf.unwrap, rf.path('hovered')], (_, data) => data);
    rf.regEventDb('toggle-collapsed', [rf.path('collapsed')], x => !x);
    rf.regEventFx('delete-old', [rf.unwrap, rf.path('oldBacklog')], ({db}, id) =>
      ({confirm: {message: `Delete the old custom record '${db[id]?.name||'<unnamed>'}'?`, onSuccess: ['-delete-old', id]}}));
    rf.regEventFx('-delete-old', [rf.unwrap, rf.path('oldBacklog')], ({db}, id) => when(dissoc(db, id), o =>
      ({db: o, GM_setValue: {key: 'backlog', value: o}})));
    rf.regEventFx('clear-old', () => ({confirm: {message: "Delete all old entries?", onSuccess: ['-clear-old']}}));
    rf.regEventFx('-clear-old', [_checkUrl, rf.path('oldBacklog')], () => ({db: null, GM_deleteValue: 'backlog'}));
    rf.regEventFx('remove-change', [rf.unwrap, rf.path('cache', 'changes')], ({db}, id) =>
      when((db||[]).filter(x => x != id), xs => ({db: xs, GM_setValue: {key: 'changes', value: xs}})));
    rf.regEventFx('clear-changes', () => ({confirm: {message: "Clear stored changelog?", onSuccess: ['-clear-changes']}}));
    rf.regEventFx('-clear-changes', [_checkUrl, rf.path('cache', 'changes')], () => ({db: [], GM_deleteValue: 'changes'}));
    rf.regEventFx('toggle-exclude', [rf.trimV, rf.path('exclude')], ({db}, [type, id]) =>
      when(update(db, type, xs => (!in_(id, xs) ? [...(xs||[]), id].sort() : xs.filter(k => k !== id))), o =>
        ({db: o, GM_setValue: {key: 'exclude', value: filterVals(o, xs => xs.length > 0)}})));
    rf.regEventFx('init-backlog', [_checkUrl, rf.trimV, rf.path('backlog')], ({db}, [id, {type, libId}, bl]) =>
      when(merge(db, {[id]: merge(dissoc(bl, 'custom', ..._types), type && {[type]: libId})}), o =>
        ({db: o, GM_setValue: {key: 'backlog2', value: o}})));
    rf.regEventFx('assoc-backlog', [_checkUrl, rf.trimV, rf.path('backlog')], ({db}, args, path=args.slice(0, -1), x=last(args)) =>
        when(assocIn(db, path, x), o => !eq(db, o) &&
          {db: o, enhanceGameItem: path[0], GM_setValue: {key: 'backlog2', value: o}}));
    rf.regEventFx('dissoc-backlog', [_checkUrl, rf.unwrap, rf.path('backlog')], ({db}, id) =>
        when(dissoc(db, id), o => ({db: o, GM_setValue: {key: 'backlog2', value: o}})));
    rf.regEventFx('toggle-ignore', [_checkUrl, rf.unwrap, rf.path('backlog')], ({db}, id, o=db[id]) =>
      o && {dispatch: ['assoc-backlog', id, (o.ignore ? dissoc(o, 'ignore') : {...o, ignore: true})]});
    rf.regEventFx('toggle-custom', [rf.trimV], ({db}, [id, bl]) =>
      ({dispatch: ['assoc-backlog', id, (bl.custom ? dissoc(bl, 'custom') :
                                         merge(dissoc(bl, ..._types), {custom: {}}))]}));
    rf.regEventDb('purge-lists', [_setLists, rf.unwrap, rf.path('lists')], (lists, names) =>
      filterVals(lists, x => names.has(x['0'].split('\n', 1)[0])));
    rf.regEventDb('assoc-list', [_setLists, rf.trimV, rf.path('lists')], (lists, [id, name, desc, games]) =>
      assoc(lists, id, {...games, '0': name + str(desc, `\n${desc}`)}));
    rf.regEventFx('$set', [rf.trimV], (_, [state, data]) => ({$merge: {state, data}}));
    rf.regEventFx('$toggle-icon', [rf.trimV], (_, [state, id, icon, icons]) =>
      when([keys(CUSTOM_ICONS).filter(s => (s === icon) != in_(s, icons)).join(" ")||NIL], ([s]) =>
        ({$merge:   {state, data: {icons: s}},
          dispatch: ['assoc-backlog', id, 'custom', 'icons', s]})));
    rf.regEventFx('$reset', [rf.trimV], ({db}, [state, id, expand=false]) =>
      ({$merge: {state, data: merge(keymap("id icons url icon image name first".split(' '), _ => NIL), getIn(db, ['backlog', id, 'custom']))},
        dispatch: ['$expand', state, expand]}));
    rf.regEventFx('deselect', [rf.trimV, rf.path('backlog')], ({db}, [state, {id, type}]) =>
      ({fx: [['dispatch', ['$reset', state, id]], ['dispatch', ['assoc-backlog', id, dissoc(db[id], type)]]]}));
    rf.regEventFx('$expand', [rf.trimV], (_, [state, active=true]) =>
      ({$merge: {state, data: {active, preview: null}}, unfocus: !active}));
    rf.regEventFx('$unset-old', [rf.unwrap], (_, state) => ({$merge: {state, data: {id: NIL, icons: NIL, url: NIL, icon: NIL, image: NIL}}}));
    rf.regEventFx('$load-old-custom', [rf.trimV], ({db}, [state, oldId]) => when(getIn(db, ['oldBacklog', oldId, 'custom']), custom =>
      ({$merge: {state, data: {id: oldId, url: "", image: "", icon: "", icons: "", ...custom}}})));
    rf.regEventFx('move-old-custom', [rf.trimV], (_, args) =>
      ({confirm: {message: "Move this entry to new backlog?", onSuccess: ['-move-old-custom', ...args]}}));
    rf.regEventFx('-move-old-custom', [rf.trimV], ({db}, [state, oldId, newId]) =>
      when(db.backlog[newId] && getIn(db, ['oldBacklog', oldId, 'custom']), (custom, o=dissoc(db.oldBacklog, oldId)) =>
        ({db: merge(db, {oldBacklog: o}),  dispatch: ['assoc-backlog', newId, 'custom', custom],
          $merge: {state, data: {id: null, preview: null, active: false}},  GM_setValue: {key: 'backlog', value: o}})));
    rf.regEventFx('select', [rf.trimV, rf.path('backlog')], ({db}, [state, {id, type, name}, libId]) =>
      when((db[id]||{}), bl => ({fx: [['dispatch', ['assoc-backlog', id, merge(bl, {name, [type]: libId})]],
                                      ['dispatch', ['$reset', state, id]],  ['dispatch', ['remove-change', libId]]]})));
    rf.regEventFx('navigate', [_checkUrl, rf.unwrap], (_, uri) => uri && {navigate: uri});

    rf.regFx('confirm', ({message, onSuccess}) => confirm(message) && rf.disp(onSuccess));
    rf.regFx('unfocus', ok => {ok && document.activeElement?.blur()});
    rf.regFx('$merge', ({state, data}) => swap(state, merge, data));
    rf.regFx('GM_setValue', ({key, value}) => GM_setValue(key, value));
    rf.regFx('GM_deleteValue', GM_deleteValue);
    rf.regFx('enhanceGameItem', (id='_adder') => when($find(`#game${id}`)?.parentNode, enhanceGameItem));
    rf.regFx('navigate', uri => app.__vue__.$router.push(uri));  // eslint-disable-line no-undef

    rf.dispatchSync(['init']);
    _loadTypeNames().then(o => {console.debug('[bl] platforms', o);   rf.disp(['set-typeNames', o])});
    if (typeof GM_addValueChangeListener == 'function') {
      [...LIBS, ...EXTRAS, 'changes'].forEach(k =>
        GM_addValueChangeListener(k, (k, old, value, remote) => remote && rf.disp(['update-cache', k, value])));
      GM_addValueChangeListener('backlog2', (k, old, value, remote) => remote && rf.disp(['update-backlog', value]));
      GM_addValueChangeListener('exclude', (k, old, value, remote) => remote && rf.disp(['update-exclude', value]));
      GM_addValueChangeListener('lists', (k, old, value, remote) => remote && rf.disp(['update-lists', value]));
    }
    entries( rf.dsub(['data']) ).forEach(([type, data, slugs=rf.dsub(['slugs', type, {withExcluded: true}])]) => {
      let [n, m] = [data, slugs].map(o => keys(o).length);
      if (n != m) {
        let groups = groupBy(keys(data), id => slugify(data[id].name));
        let _data = (slug, ids, main=slugs[slug]) => [main, ...ids.filter(id => id != main)].map(id => ({id, ...data[id]}));
        let duplicates = entries(groups).filter(([slug, ids]) => ids.length > 1).map(([slug, ids]) => [slug, _data(slug, ids)]);
        console.warn(`[BL] ${type} names have ${n-m} collisions`, dict(duplicates));
      };
    })

    $append(document.body, $e('span', {id: 'side-loaderBL'}, $e('i', {className: "fas fa-cog rotatingBL"})));
    $append(document.body, $e('div', {id: 'overlayBL'}));
    GM_addStyle("body:not(.progressBL) #side-loaderBL {display: none}");
    $addStyle('.logoBL', ["{position: absolute;  left: -.5ex;  top: -1px;  width: 0;  display: flex;  flex-direction: row-reverse}",
                          "img {border: 1px solid darkorchid;  background: #1b222f;  max-height: 54px;  max-width: none}"]);
    $addStyle('.draggable_list', [".logoBL {left: .5ex}"]);
    $addStyle(':not(.listed_game, .draggable_list)', ["> .game-item {overflow: visible}"]);  // otherwise logo will be cut off
    $addStyle('.os', ["{padding-left: .75ex;  line-height: 0 !important;  font-size: 20px;  position: relative;  top: 2.5px;  font-weight: 400}",
                      "$:is(.fa-gamepad, .fa-dice, .fa-dice-d20, .fa-trophy) {font-weight: 900}"]);
    $addStyle('.tagsBL', [`{position: absolute;  top: 42px;  width: calc(100% - 75px);  margin: 0 53px;  padding: 0 4px;  pointer-events: none;
                            overflow-y: hidden;  overflow-x: auto;  scrollbar-width: none;  border-radius: 1em;  text-align: right;  z-index: 1}`,
                          `a {background-color: var(--active-accent);  color: var(--active-accent-text);  pointer-events: all;  border: 1px solid #0008;
                              border-radius: .25rem;  opacity: .8;  padding: 0 .25em;  font-size: smaller;  user-select: none;  white-space: nowrap}`]);
    $addStyle('#overlayBL', ["{z-index: 10000;  pointer-events: none;  position: fixed;  top: 0;  left: 0;  width: 100%;  height: 100%;  display: flex}"]);
    $addStyle('#overlayBL .tooltip', [`{margin: auto;  align-items: center;  display: flex;  flex-direction: column;
                                        max-height: calc((100% - 54px) * .95);  background: rgba(0, 0, 0, 0.8);  padding: 2em;
                                        width: calc(min(66%, 800px) - 116px - 2vw);  transform: translate(calc(min(33%, 400px) - 20px - 1vw), 27px)}`,
                                      "$ > * {max-width: 100%}   $ > div {padding-top: 1em;  font-weight: bold}",
                                      "pre {white-space: pre-wrap;  margin: 1ex 0}"]);
    $addStyle('#overlayBL .changelist', [`{position: absolute;  top: 53px;  left: 0;  pointer-events: all;  background: rgba(0, 0, 0, 0.8);
                                           max-width: 33%;  max-height: 50%;  display: flex;  flex-direction: column}`,
                                         "$.right {right: 0;  left: auto}   $.collapsed {opacity: .5}   $:hover {opacity: 1}",
                                         "$ .items {overflow-y: auto}   $ .items > .item {margin: 1em}   $ .items > .item .delete {cursor: pointer;  float: right}",
                                         "> h1 {cursor: pointer;  position: relative;  padding: 1em;  padding-right: 3em}",
                                         "> h1 > .right {position: absolute;  right: 0;  margin-right: 1em}",
                                         "button {background: #4b4b4b;  color: white;  border: 1px solid black;  cursor: pointer;  border-radius: 5px}"]);
    $addStyle('#side-loaderBL', [`{position: fixed;  bottom: 1ex;  left: 1ex;  z-index: 10000;  font-size: 100px;
                                   text-shadow: -2px -2px 0 #000, 2px -2px 0 #000, -2px 2px 0 #000, 2px 2px 0 #000}`]);
    $addStyle('#import-dialogBL', [`{background: black;  position: fixed;  top: 45%;  left: 45%;  padding: 1em;  text-align: center}`]);
    const NAME_HEIGHT = 33.5/*px*/;
    $addStyle('.edit-widget', ["$ {position: relative;  top: 50px;  z-index: 500}   $:has(+ :not(.data)) {display: none}",
                               "$ .row {width: 100%;  display: flex;  flex-direction: row;  flex-shrink: 1}   $ .row.reverse {flex-direction: row-reverse}",
                               "input {margin-bottom: 0;  margin-right: 0;  color: white;  background: linear-gradient(45deg, dimgrey, grey, dimgrey)}",
                               "input::placeholder {color: darkgrey;  font-style: italic}",
                               ".names {max-height: 500px;  overflow-y: auto;  background: grey;  position: absolute;  width: 100%}",
                               "$ .names .list {display: flex;  flex-direction: column}   $ .row > :not(.row) {flex-shrink: 0}",
                               "button {height: 28px;  background: #4b4b4b;  color: white;  border-radius: 10px 8px 8px 10px;  padding: 5px}",
                               `.name {white-space: nowrap;  display: flex;  margin: .5px;  height: ${NAME_HEIGHT-1}px;  padding-top: 8px;  flex-shrink: 0}`,
                               "$ .name .title {flex-grow: 1;  text-align: left;  overflow: hidden;  text-overflow: ellipsis;  font-weight: normal}",
                               "$ .trash {cursor: pointer;  padding-right: 5px}   $ .name.bound .trash {opacity: 0.5;  cursor: not-allowed}",
                               "$ .name {width: calc(100% - 2px)}   $ .current .title {font-weight: bold}   $ .name .os {padding: 5px}",
                               "button b {flex: 1;  padding-left: 1ex;  text-align: left;  overflow: hidden;  text-overflow: ellipsis}",
                               "$ .oslist {display: flex;  position: absolute;  padding: 15px}   $ .name.bound:not(.current) .title {text-decoration: line-through}",
                               "$ .oslist .os {padding-top: 7.5px}   $ .os {padding-left: .75ex;  font-size: 20px;  color: white}",
                               "$ .oslist .action {padding-left: 1ex;  pointer-events: all}   $ .action {color: white;  cursor: pointer}",
                               "$ .anchor .action {position: absolute;  top: 17px;  right: 7.5px}   $ .action.fa-eye {cursor: zoom-in}",
                               "$ .hint {font-weight: bold;  font-style: italic;  white-space: pre-wrap}   $ .hint[href] {color: royalblue}",
                               "$ .semi-opaque {background: rgba(0, 0, 0, 0.8)}   $ .btn.custom {align-self: center;  margin-top: .5vh;  margin-right: .5vw}",
                               "$ .anchor {position: relative}   $ .anchor input {padding-right: 30px}",
                               "$ .icons {padding-top: 1ex;  text-align: center}   $ .icons .btn {margin: .12em}   $ .btn.selected {border-color: white}",
                               ".btn {background: #4b4b4b;  color: white;  border: 1px solid black;  font-size: 20px;  padding: 5px;  border-radius: 5px}",
                               "$ .btn.disabled {color: dimgrey}   $ .btn:not(.disabled) {cursor: pointer}   $ .done {width: 90%;  cursor: pointer}",
                               "$ .btn.fa-eye {margin-left: 1.25px}   $ :is(.done, .img-preview) {display: block;  margin: 1ex auto}",
                               ".img-preview {border: 1px solid darkorchid;  max-width: calc(100% - 2ex)}"]);
    $addStyle('input + .edit-widget',       [".oslist .os {color: var(--active-secondary-text)}"]);  // on Add form
    $addStyle('input:focus + .edit-widget', [".oslist .os {color: #000000d9}"]);                     // same when Title input is focused
    $addStyle('.game-info', ["$ .achBL {float: right;  padding: 0 1ex}   $ .statusBL {padding: 0 1ex;  font-size: 12px;  font-weight: bold;  white-space: pre-wrap}",
                             ".warnBL {color: red;  text-shadow: 0px 0px 10px red}",
                             `.ignoreBL {background: #4b4b4b;  color: white;  border: 1px solid black;  font-size: 15px;  padding: 6px;  border-radius: 5px;
                                         line-height: 10px;  margin: -5px 0;  cursor: pointer;  float: right}`,
                             "> :not(.data) :is(.achBL, .statusBL, .ignoreBL) {display: none}"]);    // Vue renderer moves these between tabs :-/
    $addStyle('.add-game', [".imageBL {display: block;  margin: 1em auto 0}"]);
    let Image = attrs => attrs.src && ['img', {...attrs, key: attrs.src||attrs.key}];
    let Overlay = () => when([['overlay'], ['overlayTypes'], ['hovered'], ['changes?'], ['old-custom*'], ['collapsed']].map(rf.dsub),
                             ([overlay, overlayTypes, hovered, stored, old, collapsed]) =>
      (overlayTypes            ? when(rf.dsub(['counts']), xs =>
         [Overlay.ChangeList, `Synced Platforms (${xs.length})`,
           {class: 'right', items: !collapsed && xs.map(([s, n]) => ['.item', ['strong', s], ` (${n})`])}]) :
       !overlay                ? NIL :
       hovered                 ? when(rf.dsub(['game-overlay', hovered]), data =>
         ['.tooltip',
           [Image, {key: '-', src: data.image, style: {overflow: 'hidden'}}],  // this forces the image to scale down to fit the box… CSS, amirite?
           ['div', {key: ''}, ...data.stats.map(s => ['pre', s])]]) :
       rf.dsub(['oldBacklog']) ? when(rf.dsub(['uri']), uri =>
         [Overlay.ChangeList, `Old custom entries (${old.length}) `,
           {empty: ['button', {onclick: () => rf.disp(['clear-old'])}, "Delete old entries from storage"],
            items: !collapsed && old.map(o => when(rf.dsub(['search-uri', o.name]), (href, here=(uri == href)) =>
              ['.item', [Overlay.Link, (here ? NIL : href), o.name],
                        here && ['i.delete.fas.fa-trash-alt', {title: "Delete", onclick () {rf.disp(['delete-old', o.id])}}]]))}]) :
       stored                 && when([['changes*'], ['uri']].map(rf.dsub), ([changes, uri]) =>
         [Overlay.ChangeList, `Unseen changes (${changes.length}) `,
           {empty: ['button', {onclick: () => rf.disp(['clear-changes'])}, "Clear changelog from storage"],
            items: !collapsed && changes.map(o => when(_types.find(k => o[k]),
              (type, href=rf.dsub(['search-uri', o.name, TYPES[type]]), here=(uri == href)) =>
                ['.item', [Overlay.Link, (here ? NIL : href), o.name, ` [${type}]`],
                          here && ['i.delete.fas.fa-trash-alt', {title: "Delete", onclick () {rf.disp(['remove-change', o[type]])}}]]))}])));
    Overlay.ChangeList = (header, {empty, items, collapsed=rf.dsub(['collapsed']), class: cls}) =>
      ['.changelist', {class: r.classNames({collapsed}, cls)},
        ['h1', {onclick: () => rf.disp(['toggle-collapsed'])}, header, ['span.right', `[${collapsed ? '+' : '-'}]`]],
        !collapsed && ['.items', ...((items||[]).length > 0 ? items : [empty])]]
    Overlay.Link = (href, ...content) => ['a', {href, onclick: e => (e.preventDefault(),  rf.disp(['navigate', href]))}, ...content];
    r.render([Overlay], overlayBL);  // eslint-disable-line no-undef

    let WidthWatcher = r.createClass({
      getInitialState:   () => ({signalWidth: () => {}}),
      componentDidMount  () {this.state.signalWidth(this.dom.offsetWidth)},
      componentDidUpdate () {this.state.signalWidth(this.dom.offsetWidth)},
      reagentRender (signalWidth, body) {this.state.signalWidth = signalWidth;
                                         return body},
    });
    let EditWidget = ({id}, gameItem) => when(r.atom( rf.dsub(['backlog', id, 'custom']) ), state => (info, gameItem, form) => {
      let bl = rf.dsub(['backlog-entry', info]);
      return ['.row',
               id && ['i.btn.custom', {class: ['fa', (bl.custom ? 'fa-edit' : 'fa-list')], title: (bl.custom ? "Custom" : "Listed"),
                                       onclick: () => rf.disp(['toggle-custom', id, bl])}],
               (!bl.custom ? [EditWidget.ForType, state, info, form] :
                [EditWidget.Custom, state, bl.custom, info, gameItem, form])];
    });
    let $upd = (state, key, extra={}) => debounce(500, function () {rf.disp(['$set', state, {[key]: this.value||NIL, ...extra}])});
    EditWidget.Custom = (state, custom, info, gameItem, form) => {
      let {active, row, id, oslist, preview, icons="", url="", icon="", image=""} = deref(state)||{};
      let [oldCustom, _icons] = [rf.dsub(['old-custom*']), compact( icons.split(" ") )];
      let $preview = (x=null) => rf.disp(['$set', state, {preview: x}]);
      let $save = o => eq(custom, merge(custom, o)) || rf.disp(['assoc-backlog', info.id, 'custom', merge(custom, {updated: Date.now()}, o)]);
      let $done = () => rf.disp(!id ? ['$expand', state, false] : ['move-old-custom', state, id, info.id]);
      enhanceForm(form, info.id);
      return ['.row.reverse', {onkeydown (e) {(e.key == 'Escape') && rf.disp(['$reset', state, info.id])}},
               [WidthWatcher, x => (row == x) || rf.disp(['$set', state, {row: x}]),
                 ['input[type=url]', {value: url, disabled: id, placeholder: "Link", title: "URL", style: {paddingRight: `${(oslist||30) - 15}px`},
                            onfocus () {rf.disp(['$expand', state])},   oninput: $upd(state, 'url'),   onchange () {$save({url})}}]],
               [WidthWatcher, x => (oslist == x) || rf.disp(['$set', state, {oslist: x}]),
                 ['.oslist',
                   ...rf.dsub(['game-platforms', info]).map(([title, cls]) => ['i.os', {class: cls, title}]),
                   url && ['a.action', {title: "Test", target: '_blank', href: url}, ['i.fas.fa-external-link-alt']]]],
               active &&
                 ['.semi-opaque', {style: {position: 'absolute', top: "43px", width: `${row||0}px`}},
                   ['.anchor',
                     ['input[type=url]', {value: icon, disabled: id, placeholder: "Logo", title: "Logo URL", oninput: $upd(state, 'icon'), onchange () {$save({icon})}}],
                     icon  && ['i.action.fas.fa-eye', {title: "Preview", onmouseenter () {$preview('icon')},  onmouseleave () {$preview()}}]],
                   ['.anchor',
                     ['input[type=url]', {value: image, disabled: id, placeholder: "Poster", title: "Poster URL", oninput: $upd(state, 'image'), onchange () {$save({image})}}],
                     image && ['i.action.fas.fa-eye', {title: "Preview", onmouseenter () {$preview('image')}, onmouseleave () {$preview()}}]],
                   ['.anchor.icons', ...keys(CUSTOM_ICONS).map(k =>
                     ['i.btn', {class: [CUSTOM_ICONS[k], in_(k, _icons) && 'selected', id && 'disabled'], title: capitalize(k),
                                onclick () {id || rf.disp(['$toggle-icon', state, info.id, k, _icons])}}])],
                   (id || !(url||icon||image||icons)) && (oldCustom.length > 0) &&
                     ['select', {onchange () {rf.disp(['$load-old-custom', state, this.value])}},
                       ['option', {disabled: true, selected: !id}, "Import from old custom"],
                       ...oldCustom.map(x => ['option', {value: x.id, selected: id === x.id}, x.name])],
                   ['.anchor',
                     ['button.done', {onclick: $done}, (!id ? "Done" : "Move from the old backlog")],
                     id && ['button.done', {onclick: () => rf.disp(['$unset-old', state])}, "Reset"]],
                   [Image, {class: 'img-preview', src: {icon, image}[preview]}]]];
    };
    EditWidget.ForType = (state, info, form) => {
      let {active, row, oslist, first=0, name} = deref(state)||{};
      let [id, data] = ['id', 'entry'].map(k => rf.dsub([`library-${k}`, info]));
      enhanceForm(form, info.id, id && rf.dsub(['game-details', info.type, id]));
      return ['.row.reverse', {onkeydown (e) {(e.key == 'Escape') && rf.disp(['deselect', state, info])}},
               [WidthWatcher, x => (row == x) || rf.disp(['$set', state, {row: x}]),
                 ['input', {disabled: !in_(info.type, _types),   style: {paddingRight: `${(oslist||30) - 15}px`},
                            value: (active ? name : data?.name),   oninput: $upd(state, 'name'),
                            onfocus () {rf.disp(['$set', state, {active: true, name: this.value||data?.name||info.name}])}}]],
               [WidthWatcher, x => (oslist == x) || rf.disp(['$set', state, {oslist: x}]),
                 ['.oslist', ...rf.dsub(['game-platforms', info]).map(([title, cls]) => ['i.os', {class: cls, title}])]],
               [EditWidget.Names, 'all', state, {position: 'absolute', top: "43px", width: `${row||0}px`},
                id, info.type, name, x => rf.disp(['select', state, info, x.id])]];
    };
    const FORM_TITLE    = "//label[normalize-space()='Title *']/following::input";
    const FORM_PLATFORM = "//label[normalize-space()='Platform *']/following::select";
    const FORM_SAVES = [1, 2].map(i => `//*[@class='button-row']/button[${i}]`);
    EditWidget.Add = (panel, form, gameItem, {type}) => when(r.atom({id: gameItem._id}), state => {
      let [title, platform, ...saves] = [FORM_TITLE, FORM_PLATFORM, ...FORM_SAVES].map(s => $get(s, form));
      let _save = x => panel._saved.unshift({name: x.name||title.value, href: rf.dsub(['search-uri', title.value, platform.value])});
      let _setId = id => (gameItem._id = id,  enhanceGameItem(gameItem),  id);
      rf.dispatchSync(['$set', state, {name: title.value}]);
      $setEventListener(title, 'focus', () => rf.disp(['$set', state, {active: true, name: title.value}]));
      $setEventListener(title, 'keydown', e =>
        when(e.key == 'Escape', () => {title.blur();  _setId();  swap(state, pick, 'saved')}));
      $setEventListener(title, 'input', $upd(state, 'name'));
      saves.forEach(e => $setEventListener(e, 'click', () => {_save(deref(state));  reset(state, {});  _setId()}));
      return (panel, form, gameItem, info) => {
        let [{type}, {active, id, name, oslist, first=0}] = [info, deref(state)];
        let data = id && rf.dsub(['data*', type, id]);
        title.style.paddingRight = `${(oslist||30) - 15}px`;
        enhanceForm(form, null, id && rf.dsub(['game-details', type, id]));
        return ['<>',
                 id && [WidthWatcher, x => (oslist == x) || rf.disp(['$set', state, {oslist: x}]),
                         ['.oslist', {style: {right: 0, top: '-6ex'}},
                           ...(data.worksOn||"").split("").map(c => OS[c]).map(([title, cls]) => [`i.fab.${cls}.os`, {title}])]],
                 [EditWidget.Names, 'unbound', state, {}, id, type, name, x =>
                    {$simulateInput(title, x.name);   rf.disp(['$set', state, {active: false, id: _setId(x.id), first: 0}])}]];
      };
    });
    const LIST_OFFSET = 50, LIST_MARGIN = (LIST_OFFSET-15) / 2;  // 500px/33.5px = 15 rows visible (clipping the list for performance)
    EditWidget.Names = (suffix, state, style, id, type, name, onclick) => {
      let {active, first=0} = deref(state)||{};
      let count = rf.dsub([`#data:${suffix}`, type]);
      return active && when(rf.dsub([`sort:${suffix}`, type, id, name||""]).slice(first, first+LIST_OFFSET), results =>
        ['.names', {style, onscroll: e => when([Math.max(0, parseInt(e.target.scrollTop/NAME_HEIGHT - LIST_MARGIN))], ([x]) =>
                                               (first != x) && rf.dispatchSync(['$set', state, {first: x}]))},
          ['.list', {style: {height: `${count * NAME_HEIGHT}px`}}, ...results.map((x, i) =>
            ['button.name', {class: {current: x.id == id, bound: x.bound},  disabled: x.exclude,
                             style: {position: 'absolute', top: `${(first+i) * NAME_HEIGHT}px`},  onclick: () => onclick(x),
                             title: x.name + ([" ", " [bound]"][x.bound] || ` [bound×${x.bound}]`)},
              ['i.trash', {class: `fas fa-trash${!x.exclude ? '' : '-restore'}-alt`, title: (x.exclude ? "Restore" : "Exclude"),
                           onclick: $stop(() => (x.exclude || (x.bound == 0)) && rf.disp(['toggle-exclude', type, x.id]))}],
              ['span.title', x.name],
              ...(x.worksOn||"").split("").map(c => OS[c]).map(([title, cls]) => [`i.fab.${cls}.os`, {title}])])]]);
    };

    let _queue = (e, task) => {_queue._.unshift(e);   $withLoading('progress', () => _queue.process(task))};
    _queue._ = [];
    _queue.pop = () => (_queue._ = _queue._.filter(e => e.isConnected)).pop();
    _queue.process = task => delay().then(() => when(_queue.pop(), e => (task(e), _queue.process(task))));

    let _renameWindows = (e) => when(e, () => {
      if (e.title == "PC")     e.title     = "Windows";
      if (e.innerText == "PC") e.innerText = "Windows";
    });

    let $getText = (element, ...hide) => {
      hide.forEach(e => e && $visibility(e, false));
      let text = element.innerText;
      hide.forEach(e => e && $visibility(e, true));
      return text;
    }

    const CHEEVOS = "^([0-9]+ / [0-9]+) Achievements ";
    let _info = (game, data=getIn(game, ['parentNode', '__vue__', '_vnode', 'context', '_data', 'game'])) => {
      let [_platform, _status] = [".platform > * > *", ".status"].map(s => $find(s, game));
      let type = (_platform?.alt || _platform?.innerText || data?.abbr);
      return {id:       when(_status.id.replace(/^game/, ""), x => Number(x)||NIL),
              type:     _type(type),
              name:     data?.title || $getText($find(".title", game), $find(".append", game)),
              status:   $find("img", _status).title?.toLowerCase(),
              cheevos:  $find_(".icons img", game).map(x => x.alt?.match(CHEEVOS)?.[1]).find(s => s) || "0 / 0",
              priority: $find(".priority img", game)?.title||"Normal",
              physical: Array.from(game.parentNode.children).some(e => e.matches(".format[title='Physical']"))};
    };

    let getLogos = game => Array.from(game.parentNode.children).filter(e => e.matches('.logoBL'));
    let $tweak = (game, info, source) =>
      when(['icon', 'image', 'highlight', 'tooltip', 'append', 'platforms', 'link'].map(k => rf.dsub([`game-${k}`, info])),
           ([icon, image, highlight, tooltip, append, platforms, link]) => {
        let $$ = info => (() => rf.disp(['set-hovered', info]));
        game.style.background = highlight;
        let _name = $find(".title", Object.assign(game, {title: tooltip}));
        $find_(".append, .os", _name).forEach(e => e.remove());
        append && $append(_name, $e('span', {className: 'append', innerText: append}));
        platforms.forEach(([title, cls]) => $append(_name, $e('i', {className: `${cls} os`, title})));
        game.parentNode.parentNode.style.position = 'relative';
        let _game = (!game.parentNode.parentNode.matches(".listed_game, .draggable_list") ? game : game.parentNode);
        let onerror, onload = onerror = when(getLogos(_game), oldLogos => (() => oldLogos.forEach(e => e.remove())));
        $append(_game.parentNode, $e('div', {className: `logoBL source-${source}`, onmouseleave: $$(), onmouseenter: $$(info)},
                                     $e('a', merge({target: '_blank'}, link), $e('img', {src: icon, onload, onerror}))));
        when($find(".add-game aside"), aside =>
             when($find('.imageBL', aside) || $e('img', {className: 'imageBL'}), e =>
                  (!image ? e.remove() : aside.prepend( Object.assign(e, {src: image}) ))));
      });

    let $addTags = (game, {id}, tags=rf.dsub(['lists*'])[id], userId=rf.dsub(['userId'])) => ((tags||[]).length > 0) &&
      when([$find('.tagsBL', game.parentNode)||$e('div', {className: 'tagsBL'}), location.href.match(RE.backloggeryLists)?.[2]],
        ([e, listId]) => $after(game, $append($clear(e), ...tags.map(([tag, desc, list, rank, note=""]) => (list != listId) &&
          $e('a', {target: '_blank', href: `/${userId}/lists/${list}`,
                   title: (str(desc, `${desc}\n\n`) + note).trimEnd(), innerText: tag + str(rank, ` [#${rank}]`)})))));

    let enhanceGameItem = (game, {detect=false}={}) => {
      $find(".platform > *", game).children.forEach(_renameWindows);
      let info, {id, type, name, status, priority, physical} = info = _info(game);
      let [libId, source] = [(detect ? 'id*' : 'id'), 'source'].map(k => type && rf.dsub([`library-${k}`, info]));
      detect && libId && rf.dispatchSync(['init-backlog', id, {type, libId}, rf.dsub(['backlog-entry', info])]);
      $addTags(game, info);
      if (game._id || libId || (source == 'custom'))
        $tweak(game, merge(info, game._id && {libId: game._id}), source);
      else {
        game.style.background = "";
        $find_(".title :is(.append, .os)", game).forEach(e => e.remove());
        getLogos(game).forEach(e => e.remove());
        $find(".add-game .imageBL")?.remove();
      }
    };
    let $enhanceGameItem = game => when([$enhanceGameItem.observer], ([x]) =>
      (x ? x.observe(game) : _queue(game, (game => enhanceGameItem(game, {detect: true})))));
    $enhanceGameItem.observer = (typeof IntersectionObserver == 'function') && new IntersectionObserver((xs, self) =>
      xs.forEach(x => when(x.intersectionRatio > 0, () => {enhanceGameItem(x.target, {detect: true});  self.unobserve(x.target)})));

    let enhanceGameEdit = (e, gameItem=e.parentNode.firstElementChild) => when($find(".data", e), form => {
      when($get(FORM_PLATFORM, form), platform => {
        $find_('option', platform).forEach(_renameWindows);
        $addEventListener(platform, 'change', () => enhanceGameEdit(e));
      });
      _renameWindows( $get("//label[normalize-space()='Sub-Platform']/following::select/option[normalize-space()='PC']") );
      let info, {id, type} = info = _info(gameItem);
      when(rf.dsub(['backlog', id]), bl =>
        !bl.custom && _types.some(k => bl[k] && (k != type)) && rf.dispatchSync(['init-backlog', id, {}, bl]));
      when(rf.dsub(['library-id', info]), libId => rf.dispatchSync(['remove-change', libId]));
      $addEventListener($get("//button[normalize-space()='Delete']", e), 'click', () => {document.body._delId = id});
      enhanceGameItem(gameItem);
      if (type) {
        let editWidget = $find(".edit-widget", e);
        $before(form, editWidget || (editWidget = $e('div', {className: 'edit-widget'})));
        r.render([EditWidget, info, gameItem, e], editWidget);
      }
    });
    let ignoreButton = id => $e('i', {className: "ignoreBL far fa-eye", onclick () {rf.disp(['toggle-ignore', id])}});
    let enhanceForm = (form, id, {achievements, status}={}) => {
      $find_(":is(.achBL, .statusBL)", form).forEach(e => e.remove());
      when($find(".data .cheevos", form), (cheevos, [inputs, [label]]=['input', 'label'].map(s => $find_(s, cheevos))) => {
        label.append( $e('span', {className: 'achBL', innerText: achievements||""}) );
        let _check = () => achievements && (!id || when(rf.dsub(['backlog', id]), bl => !bl?.custom && !bl?.ignore));
        let _matching = () => achievements === inputs.map(e => e.value||0).join(' / ');
        inputs.forEach(e => {e.oninput = () => label.classList[_check() && !_matching() ? 'add' : 'remove']('warnBL')});
        inputs.forEach(e => {e.onblur = () => enhanceGameItem(form.parentNode.firstElementChild)});
        inputs[0].oninput();
      });
      status && when($find(".data .form-tip", form), e => $after(e, $e('div', {className: 'statusBL', innerText: status})));
      !id && _types.forEach(k => when($get(`${FORM_PLATFORM}//option[@value='${TYPES[k]}']`, form), e => {
        ($find('.countBL', e) || $append(e, $e('span', {className: 'countBL'})).lastChild).innerText =
          ` (${rf.dsub(['#data:unbound', k, {excluded: false}])})`;
      }));
      when(id && [$find('#status-changer', form)?.previousElementSibling, rf.dsub(['backlog', id])], ([label, bl]) =>
        (bl?.custom ? $find('.ignoreBL', label)?.remove() :
         when($find('.ignoreBL', label) || $append(label, ignoreButton(id)).lastChild, e => {
           e.classList.remove(bl?.ignore ? 'fa-eye' : 'fa-eye-slash');
           e.classList.add(!bl?.ignore ? 'fa-eye' : 'fa-eye-slash');
           e.title = (!bl?.ignore ? "Watch" : "Ignore");
         })));
    };

    const FORM_STATUS = "#status-changer, #priority-changer, #format-changer";
    let enhanceGameAdd = e => when(e && $find(".game-info", e), (form, gameItem=$find('.game-item', e).firstElementChild) => {
      when($get(FORM_PLATFORM, form), platform => {
        $find_('option', platform).forEach(_renameWindows);
        $addEventListener(platform, 'change', () => {gameItem._id = NIL;  enhanceGameItem(gameItem);  enhanceGameAdd(e)});
      });
      _renameWindows( $get("//label[normalize-space()='Sub-Platform']/following::select/option[normalize-space()='PC']") );
      when($get("//label[normalize-space()='Format']//following::select", e), input => {input.id = 'format-changer'});
      $find_(FORM_STATUS, form).forEach(input => $addEventListener(input, 'change', () => enhanceGameItem(gameItem)));
      e._saved = e._saved||[];
      when($find(".add-game aside section ul"), list =>
        list?.firstChild?.tagName || e._saved.forEach(({name, href}, i) => when(list?.children?.[i], item =>
          $clear(item).append( $e('a', {target: '_blank', innerText: name, href}) ))));
      let [info, editWidget] = [dissoc(_info(gameItem), 'id'), $find(".edit-widget", e)];
      editWidget || $after($get(FORM_TITLE, form), editWidget = $e('div', {className: 'edit-widget', style: "width:100%; top:-.5vh"}));
      r.render([EditWidget.Add, e, form, gameItem, info], editWidget);
    });

    let purgeLists = debounce(0, names => rf.disp(['purge-lists', new Set(names)]));
    let scrapeList = debounce(0, (title, items=$find_(`.viewing .listed_game`)) => when(location.href.match(RE.backloggeryLists)?.[2], id => {
      let [name, desc] = [$getText(title, $find('button', title)).trim(), $find('.list-desc')?.innerText||""];
      let games = items.map(e => when($find('.status', e).id.match(/^game(.*)$/)?.[1], id =>
        [id, [$find('.rank', e)?.innerText||"", $find(`:scope > .markdown`, e)?.innerText||""].join(':')]));
      rf.disp(['assoc-list', id, name, desc, dict(games)]);
    }));

    new Promise(resolve => {
      let _userId = (e=document) => $get("//a[text()='Home']", e)?.href.match("/([^/]+)$")?.[1];
      (_userId() ? resolve(_userId()) : $watcher((e, watcher) => when(_userId(e), _uid => {
        resolve(_uid);
        watcher.disconnect();
      })).observe(nav, {childList: true}));  // eslint-disable-line no-undef
    }).then(USER_ID => {
      console.warn("[BL] activated!");
      rf.dispatchSync(['set-userId', USER_ID]);
      $addEventListener(document.body, 'click', (evt, x=evt.target, id=document.body._delId) =>
        when(id && x.matches('button') && (x.innerText == 'OK') && x.parentNode.previousSibling?.innerText?.startsWith("Really delete "),
             () => {rf.disp(['dissoc-backlog', id]);  document.body._delId = NIL}));
      let _redraw = e => {
        rf.dispatchSync(['check-url']);
        if (location.href.match(RE.backloggery)?.[1] == USER_ID) {
          _renameWindows( $get("//*[@class='platform-card']/a[@class='title'][normalize-space()='PC']", e) );
          _renameWindows( $get("//h2[normalize-space()='PC']", e) );
          _renameWindows( $get("//*[@id='modal_filter']//label[starts-with(@for, 'ef_platform')][normalize-space()='PC']", e) );
          when(location.href.match(RE.backloggeryLists), ([_, userId, list]) =>
            (!list ? $find('.button-section') && purgeLists( $find_(`.viewing .list .title`).map(x => x.innerText) ) :
             when($find(`.viewing .title`, e), title => $get(`//button[text()="Edit"]`, title) && scrapeList(title))));
          when(location.href.match(RE.backloggeryLists)?.[2] && $find(`.viewing .title`, e), title =>
            $get(`//button[text()="Edit"]`, title) && scrapeList(title, $find_(`.viewing .listed_game`)));
          $find_(".game-item > :first-child", e).forEach($enhanceGameItem);
          if (e.matches(".game-info, .data, .cheevos") || ((e.tagName == 'OPTION') && (e.innerText == 'PC'))) {
            while (e && !e.matches(".game-info")) e = e.parentNode;
            enhanceGameEdit(e);
          };
        } else if (location.href.match(RE.backloggeryAdd)) {
          if (e.matches(".game-info, .data, .cheevos") || $find(FORM_STATUS, e) || ((e.tagName == 'OPTION') && (e.innerText == 'PC'))) {
            when($find(".add-game"), enhanceGameAdd);
          }
        } else if (location.href.match(RE.backloggeryTypes))
          when($get("//*[@class='platform_item']/*[normalize-space()='PC']", e), caption => {caption.innerText = "Windows (PC)"});
      };
      _redraw(document.body);
      $watcher(_redraw).observe(app, {childList: true, subtree: true});  // eslint-disable-line no-undef
      rf.dsub(['overlay']) && $find_(".game-item > :first-child").forEach($enhanceGameItem);

      if (rf.dsub(['oldBacklog'])) {
        GM_registerMenuCommand("Export old custom matches & delete old backlog", () => {
          if (!confirm("Are you sure? This will delete your old backlog!")) return;
          $e('a', {
            href: "data:application/json;base64," + btoa(JSON.stringify(rf.dsub(['old-custom']), null, 2) + '\n'),
            download: `Backloggery-oldcustom_${new Date().toJSON().replace(/T.*/, '')}.json`,
          }).click();
          GM_deleteValue('backlog');
          location.reload();
        });
      } else {
        GM_registerMenuCommand("Import custom matches", () => {
          let _close = (ok=true) => {
            $find('#import-dialogBL').remove();
            ok && $e('input', {type: 'file', accept: 'application/json', onchange () {
              when(this.files?.[0], file => Object.assign(new FileReader(), {
                onload () {GM_setValue('backlog', JSON.parse(this.result));   location.reload()},
              }).readAsText(file));
            }}).click();  // this only works immediately after user input within page (i.e. clicking "Yes")
          };
          document.body.append($e('div', {id: 'import-dialogBL'},
                                 $e('h1', {innerText: "Import custom matches?"}),
                                 $e('button', {innerText: "Yes", autofocus: true, onclick: _close}),
                                 $e('button', {innerText: "No", onclick: () => _close(false)})));
        });
        GM_registerMenuCommand("Export custom matches", () => $e('a', {
          href: "data:application/json;base64," + btoa(JSON.stringify(rf.dsub(['custom']), null, 2) + '\n'),
          download: `Backloggery-custom_${new Date().toJSON().replace(/T.*/, '')}.json`,
        }).click());
        GM_registerMenuCommand("Reset all non-custom matches", () => when(rf.dsub(['custom']), o => {
          if (!confirm(`Are you SURE?\nThis will reset all matches other than the ${keys(o).length} custom ones!`)) return;
          GM_setValue('backlog', o);
          GM_deleteValue('backlog2');
          location.reload();
        }));
      }
      GM_registerMenuCommand("Refresh platforms list", () => {
        GM_deleteValue('platforms');
        _loadTypeNames().then(o => {console.debug('[bl] platforms', o);   rf.disp(['set-typeNames', o])});
      });
    });

  }); else if (USER_ID && (PAGE.match(RE.steamLibrary)?.[1] == USER_ID)) {

    console.warn("[BL] activated!");
    // eslint-disable-next-line no-undef
    const {rgGames: GAMES, achievement_progress: PROGRESS} = JSON.parse( gameslist_config.getAttribute('data-profile-gameslist') );
    const STATS = GM_getValue('steam-stats', {});
    delay(1000).then(() => {
      $update('steam', dict( GAMES.map(o => [o.appid, {name: o.name, hours: parseFloat((o.playtime_forever/60)?.toFixed(1))||NIL}]) ));
      $markUpdate('steam-stats');
      $mergeData('steam-stats', dict(PROGRESS.map(o => {
        let old = (STATS[o.appid]||"0 / 0").replace(" (?)", "")            // Steam lists status for some games as 0/0 incorrectly
        return [o.appid, ((o.total == 0) && (old != "0 / 0") ? `${old} (?)` : `${o.unlocked} / ${o.total}`)];
      })));
    });

  } else if (USER_ID && (PAGE.match(RE.steamAchievements)?.[1] == USER_ID)) {  // personal

    console.warn("[BL] activated!");
    when($find('#topSummaryAchievements'), e => when($find('.gameLogo a').href.match(/\d+$/)[0], id =>
      $mergeData('steam-stats', {[id]: e.innerText.match(/(\d+) of (\d+)/).slice(1).join(" / ")})));

  } else if (USER_ID && PAGE.match(RE.steamAchievements2) && $find('#compareAvatar')) {  // global

    console.warn("[BL] activated!");
    when($find('#headerContentLeft'), e => when($find('.gameLogo a').href.match(/\d+$/)[0], id =>
      $mergeData('steam-stats', {[id]: e.innerText.match(/\d+ \/ \d+/)[0]})));

  } else if (USER_ID && PAGE.match(RE.steamDetails) && $find('.game_area_already_owned')) {

    console.warn("[BL] activated!");
    const ID = PAGE.match(RE.steamDetails)[1];
    let hasPlatform = k => $find(`.game_area_purchase_game .platform_img.${k}`);
    when(compact( ['win', 'linux', 'mac'].map(k => hasPlatform(k) && k[0]) ).join(""),
         worksOn => $mergeData('steam-platforms', {[ID]: worksOn}));
    when($find(`[itemprop=aggregateRating]`)?.getAttribute('data-tooltip-html')?.match(/^([0-9]+(.[0-9]+)?)%/)?.[1],
         rating => $mergeData('steam-rating', {[ID]: Math.round( Number(rating) )}));
    let myTags = $find_(".glance_tags_ctn .app_tag.user_defined").map(e => e.innerText.trim()).join(", ");
    (myTags || GM_getValue('steam-my-tags', {})[ID]) && $mergeData('steam-my-tags', {[ID]: myTags||NIL});

  } else if (PAGE.match(RE.steamBadges)) {  // highlighting progress

    console.warn("[BL] activated!");
    GM_addStyle(`.foil {box-shadow: white 0 0 2em}  .badge-exp, .card-name.excess {color:lime}
                 .level0 {color:violet}  .level1 {color:pink}  .card-name.level1 {color:red}  .level2 {color:orange}
                 .level3 {color:yellow}  .level4 {color:yellowgreen}  .card-name.level4 {color:olive}
                 .level5, .foil .level1 {color:limegreen}  .card-name.level5, .foil .card-name.level1 {color:green}`);
    $find_(".badge_row_inner").forEach(panel => {
      let foil = $find(".badge_title", panel)?.innerText.trim().endsWith(" Foil Badge");
      let exp = $find(".badge_info_title, .badge_empty_name", panel)?.nextElementSibling;
      let level = Number(exp.innerText.match("Level ([0-9]+)")?.[1] || 0);
      let levels = dict( (foil ? [0, 1] : [0, 1, 2, 3, 4, 5]).map((n, i) => [`(${n - level})`, `level${i}`]) );

      foil && panel.classList.add('foil');
      exp.classList.add('badge-exp', `level${level}`);
      $find_(".badge_card_set_title", panel).forEach(cardTitle => {
        let amount = $find(".badge_card_set_text_qty", cardTitle)?.innerText || "(0)";
        cardTitle.classList.add('card-name', levels[amount]||'excess');
        cardTitle.parentNode.title = cardTitle.innerText.split('\n').reverse().join('\n');  // "Name\n(X)"
      });
    });

  } else if (USER_ID && PAGE.match(RE.steamDbDetails) && $find('#js-app-install.owned')) {

    console.warn("[BL] activated!");
    const INFO = $find('.span8');
    const ID = $find_('td', INFO)[1].innerText;
    when(compact( ['windows', 'linux', 'macos'].map(s => $find(`.octicon-${s}`, INFO) && s[0]) ).join(''),
          worksOn => $mergeData('steam-platforms', {[ID]: worksOn}));
    when(($find(`[itemprop=aggregateRating]`)?.getAttribute('aria-label')?.match(/^([0-9]+(.[0-9]+)?)%/)?.[1] ||
          $find(`[itemprop=aggregateRating] [itemprop=ratingValue]`)?.getAttribute('content')),
         rating => $mergeData('steam-rating', {[ID]: Math.round( Number(rating) )}));

  } else if (USER_ID && (PAGE.match(RE.steamDbLibrary)?.[1] == USER_ID)) {

    console.warn("[BL] activated!");
    $watcher(e => when(e.firstElementChild?.matches(".hover_buttons"), () => {
      let id = new URL($find('a.hover_title', e).href).pathname.match("^/app/([0-9]+)")[1];
      when(compact( ['windows', 'linux', 'macos'].map(s => $find(`.octicon-${s}`, e) && s[0]) ).join(''),
           worksOn => $mergeData('steam-platforms', {[id]: worksOn}));
      when($find(`.hover_review_summary span:not(.muted)`, e)?.innerText.match(/([0-9]+(.[0-9]+)?)%$/)?.[1],
           rating => $mergeData('steam-rating', {[id]: Math.round( Number(rating) )}));
    })).observe(document, {childList: true, subtree: true});

  } else if (USER_ID && PAGE.match(RE.steamStats) && (PARAMS.SteamID64 == USER_ID)) {

    console.warn("[BL] activated!");
    let _achievements = ss => ss.map(s => s.match(/\d+/)?.[0]||s).join(" / ");
    const STATS = (PARAMS.DisplayType != '2' ? when($find('.tablesorter'), _table => {   // list
                     let _header = $find_('th', $find('thead tr', _table));
                     let _body = $find_('tr', $find('tbody', _table)).map(e => $find_('td', e));
                     let [_name$, _total$, _my$] = ["Name", "Total\nAch.", "Gained\nAch."].map(s => _header.findIndex(e => e.innerText == s));
                     return _body.map(l => [query($find("a[href^='Steam_Game_Info.php']", l[_name$]).href).AppID,
                                            _achievements([l[_my$], l[_total$]].map(e => e.innerText))]);
                   }) : when($get('/html/body/center/center/center/center'), _body => {  // table
                     let _table = Array.from(_body.children).find(x => (x.tagName == 'TABLE') && !x.matches('.Pager'));
                     let _ids = $find_('a', _table).map(e => query(e.href).AppID);
                     return $find_('table', _table).map((e, i) =>
                       [_ids[i], _achievements( last( $find_('p', e) ).innerText.match(/Achievements: (.*) of (.*)/).slice(1) )]);
                   }));
    if ((STATS.length > 0) && (STATS[0][0] == null)) throw "Invalid update";  // ensuring that next layout change won't break updater
    $markUpdate('steam-stats');
    $mergeData('steam-stats', dict(STATS));
    alert(`Game library interop: updated ${STATS.length} games`);

  } else if (PAGE.match(RE.gogLibrary)) {

    console.warn("[BL] activated!");
    let queryPage = (page=0) => $fetchJson(`/account/getFilteredProducts?mediaType=1&page=${page+1}`);
    let worksOn = o => o && {worksOn: compact( entries(o).map(([k, v]) => v && k[0].toLowerCase()) ).join('')};
    let scrape = () => $withLoading('progress', () =>
      queryPage().then(o => when(dict( o.tags.map(x => [x.id, x.name]) ), tags => {
        let completed = keys(tags).find(k => tags[k].toLowerCase() == 'completed');
        let convert = (o => [o.id, merge(pick(o, 'image', 'url'), worksOn(o.worksOn), {rating: o.rating/10||NIL,   name},
                                         {name: o.title,  tags: o.tags.map(id => tags[id]).join(", ")||NIL,
                                          completed: in_(completed, o.tags)||NIL,  category: o.category||NIL})]);
        return Promise.all([Promise.resolve(o), ...range(1, o.totalPages).map(queryPage)]).then(data =>
          $update('gog', dict( data.flatMap(x => x.products).map(convert) )));
      })));

    $append($find('.collection-header'),
            $e('i', {className: "fas fa-sync-alt _clickable account__filters-option", title: "Sync Backloggery", onclick: scrape}));

  } else if (PAGE.match(RE.humbleLibrary)) {

    console.warn("[BL] activated!");
    const PLATFORMS = {windows: 'w',  linux: 'l',  osx: 'm',  android: 'a'};
    let collect = e => ({name:      $find('h2', e).innerText,
                         publisher: $find('p',  e).innerText,
                         icon:      $find('.icon', e).style.backgroundImage.match(/^url\("(.*)"\)$/)?.[1],
                         url:       $find('.details-heading a')?.href,
                         worksOn:   when($find('.js-platform-select-holder'), e =>
                                      compact( entries(PLATFORMS).map(([k, c]) => $find(`.hb-${k}`, e) && c) ).join(''))});
    let scrape = () => $find_('.subproduct-selector').reduce((p, e, i, es) => p.then(xs => {
      syncBL.setAttribute('data-progress', `${i+1}/${es.length}`);
      e.click();
      return delay().then(() => (xs.push(collect(e)), xs));
    }), Promise.resolve([]));

    GM_addStyle(`#syncBL {cursor: pointer;  margin-right: 1em;  vertical-align: text-top}   #loaderBL {inset: auto -150px -250px auto}
                 .waitBL #syncBL:before {content: attr(data-progress) " \\f2f1";}`);
    $append(document.body, $e('span', {id: 'loaderBL'}, $e('i', {className: "fas fa-cog rotatingBL"})));
    let filters = $find(".js-library-holder .header .filter");
    filters.prepend($e('i', {id: 'syncBL', className: "fas fa-sync-alt", title: "Sync Backloggery", onclick: () =>
      $withLoading('wait', () => scrape().then(xs => $update('humble', dict( xs.map(x => x.worksOn && [slugify(x.name), x]) ))))}));
    $visibility(syncBL, false);  /* global syncBL */
    forever(() => when($find('#switch-platform'), e =>
      $visibility(syncBL, (e.value == 'all') && !search.value && (location.pathname == "/home/library"))));  // eslint-disable-line no-undef

  } else if (PAGE.match(RE.itchLibrary)) {

    console.warn("[BL] activated!");
    GM_addStyle(".my_collections_page .game_collection h2 {display: flex}   .fa-sync-alt {padding-left: 1ex;  cursor: pointer}");
    let _div = document.createElement('div');
    let _date = NIL;
    let queryPage = (page=0) => $fetchJson(`/my-purchases?format=json&page=${page+1}`).then(o => when(o.num_items > 0, () =>
      (Object.assign(_div, {innerHTML: o.content}),
       Array.from(_div.childNodes).map(e => when($find(".game_title a", e), title =>
         ({id:     e.getAttribute('data-game_id'),
           name:   title.innerText,
           url:    title.href.replace(/\/download\/[^/]+$/, ""),
           image:  $find("img", e)?.getAttribute('data-lazy_src')?.replace(ITCH_CDN, ""),
           author: $find(".game_author", e)?.innerText,
           date:   (_date = $find(".date_header > span", e)?.title||_date)}))))));
    let collect = (page=0) => queryPage(page).then(xs => (!xs ? [] : collect(page+1).then(ys => [...xs, ...ys])));
    let scrape = () => $withLoading('progress', () => collect().then(xs => $update('itch', dict( xs.map(({id, ...o}) => [id, o]) ))));
    $find_("a[href='/my-purchases']").forEach(e =>
      e.insertAdjacentElement('afterend', $e('i', {className: "fas fa-sync-alt", title: "Sync Backloggery", onclick: scrape})));

  } else if (PAGE.match(RE.itchDetails)) {

    const ID = when(GM_getValue('itch'), o => keys(o).find(k => o[k].url == PAGE));
    if (!ID) return;
    console.warn("[BL] activated!");
    const PLATFORMS = {windows: 'w', linux: 'l', osx: 'm', android: 'a', ios: 'm', web: 'b'};
    let _platforms = check => keys(PLATFORMS).filter(check).map(k => PLATFORMS[k]).join("");
    let _parseDate = e => new Date($find("abbr", e).title).getTime();
    let _info = dict( $find_(".game_info_panel_widget tr").map(e => [e.firstChild.innerText, e.lastChild]) );
    let {Author, Platforms, Rating, ...info} = mapVals(_info, (x, s) => when(slugify(s), k =>
      (k == 'platforms' ? _platforms(k => $find(`a[href$=platform-${k}]`, x)) :
       k == 'rating'    ? Number($find(".aggregate_rating", x).title)         :
       in_(k, ['published', 'updated', 'release-date']) ? _parseDate(x) : x.innerText)));
    $mergeData('itch-info', {[ID]: {at: Date.now(), worksOn: Platforms, rating: Rating, ...info}});

  } else if (PAGE.match(RE.ggateLibrary)) {

    console.warn("[BL] activated!");
    const GAMES = $find_(".my-games-catalog .catalog-item").map(e => when([$find('a', e), $find('img', e)], ([link, image]) =>
      [link.href.match("/account/orders/(.*)")[1].replace("/#", ":"),  {name: link.title, image: image?.src?.replace(GGATE_CDN, "")}]));
    $update('ggate', dict(GAMES));

  } else if (PAGE.match(RE.epicStore)) setTimeout(() => {

    const NAV = $find('egs-navigation')?.shadowRoot;
    if (!NAV || !$find("header [aria-controls=nav-account-menu]", NAV)) return;
    console.warn("[BL] activated!");
    /*// NodeJS script for parsing Epic launcher cache file (for reference):
      let fs = require('fs');
      var encoded = fs.readFileSync("catcache.bin");  // if running from the folder containing the file
      var json = Buffer.from(encoded, 'base64').toString('utf-8');
      fs.writeFileSync("catcache.json", JSON.stringify(JSON.parse(json), null, 2));  // reformatting for readability
    */
    const CONFDIR = {windows: "C:/Users/%USER%/AppData", linux: "~/.config", mac: "~/Library/Application Support"};
    const TOOLTIP = join("Import catalog to Backloggery from launcher cache (Heroic or Epic):",
                         `* ${CONFDIR[USER_OS]}/heroic/store_cache/legendary_library.json`,
                         `* <EPIC_INSTALL_DIR>/EpicGamesLauncher/Data/Catalog/catcache.bin`,
                         "(Heroic launcher is preferred since its file stores game URLs and doesn't mangle Unicode)");
    $loadIcons(NAV);
    NAV.append($e('style', {innerHTML: "#importBL {display: flex;  align-items: center}   #importBL > * {cursor: pointer;  padding: 1em}"}));
    let convertHeroic = data => data.map(x =>
      ({id:        x.app_name,
        name:      x.title,
        slug:      x.store_url?.replace(EPIC_STORE, ""),
        icon:      (x.art_logo||x.art_cover||x.art_square)?.replace(EPIC_CDN+"/", "/"),
        image:     (x.art_square||x.art_cover||x.art_logo)?.replace(EPIC_CDN+"/", "/"),
        worksOn:   compact(['w', x.is_linux_native && 'l', x.is_mac_native && 'm']).join(""),
        developer: x.developer,
        online:    !x.canRunOffline||NIL,
        cloud:     x.cloud_save_enabled||NIL}));
    let _epicImg = x => dict( x.keyImages.map(y => [y.type.replace(/^DieselGameBox/, "") || 'Cover', y.url]) );
    let _epicGame = x => x.categories.some(y => y.path == 'games');
    const EPIC_PLATFORM = {Windows: 'w', Linux: 'l', Mac: 'm'};
    let convertEpic = data => data.filter(_epicGame).map(x => when([x.releaseInfo[0], _epicImg(x)], ([meta, img]) =>
      ({id:        meta.appId,
        name:      x.title,
        //slug:     not available,
        icon:      (img.Logo||img.Cover||img.Tall)?.replace(EPIC_CDN+"/", "/"),
        image:     (img.Tall||img.Cover||img.Logo)?.replace(EPIC_CDN+"/", "/"),
        worksOn:   vals( filterKeys(EPIC_PLATFORM, k => in_(k, meta.platform)) ).join(""),
        developer: x.developer,
        online:    (x.customAttributes.CanRunOffline?.value == 'false')||NIL,
        cloud:     ('CloudSaveFolder' in x.customAttributes)||NIL})));
    let parseFile = file => $withLoading('progress', readFile(file)
      .then(s => (s[0] == "{" ? convertHeroic(JSON.parse(s).library) : convertEpic(JSON.parse( atob(s) ))))
      .then(games => $update('epic', dict( games.map(({id, ...x}) => [id, x]) )))
      .catch(e => (console.error('[BL]', e),  alert("Invalid catalog cache file"))));
    let readFile = file => new Promise(resolve => when(new FileReader, reader => {
      reader.onload = () => resolve( reader.result.trim() );
      reader.readAsText(file);
    }));
    const IMPORT_FILE = $e('input', {type: 'file', accept: ".bin,.json", onchange () {this.files[0] && parseFile(this.files[0])}});
    const BTN = $e('div', {id: 'importBL'}, $e('i', {className: "fas fa-file-import", title: TOOLTIP, onclick () {IMPORT_FILE.click()}}));
    $find('.toolbar', NAV).prepend(BTN);

  }); else if (PAGE.match(RE.dekuLibrary) && $find("a[href='/logout']")) {

    let _fullLink = $find(`.pagination_controls a[href$="page_size=all"]`);
    if (_fullLink) return confirm("Show all on one page?") && location.replace(_fullLink.href);
    console.warn("[BL] activated!");
    const OLD = GM_getValue('deku', {});
    const SELECTORS = ["a.main-link", ".img-frame img, .img-wrapper img", "form", "input[checked][name=rating]"];
    let platform = s => when(slugify(s), k => ({'xbox-x-s': 'xboxsx', 'xbox-one': 'xbo'})[k] || k);
    let convert = (o, {clean}, [link, image, form, rating], id=new URL(form.action).pathname.replace(/^\/owned_items\//, ''),
                   img=(image.parentNode.matches(".img-frame, .img-wrapper") ? 'image' : 'icon')) =>
      [id, {...(clean ? {} : pick(OLD[id], 'image', 'icon')),
            name:     link.innerText,
            url:      new URL(link.href).pathname.replace(/^\/items\//, '/'),
            [img]:    new URL(image.src).pathname.replace(/^\/images\//, '/'),
            platform: platform(o.platform),   physical: (o.format == 'Physical')||NIL,
            status:   o.status,   notes: o.notes,   rating: Number(rating?.value)||NIL}];

    let games = params => $find_(".browse-cards.desktop .summarized-details.owned").map(e => when(e.parentNode, cell => {
      while (cell && !cell.matches(".d-block.col")) cell = cell.parentNode;
      let [platform, format] = $find(".main", e)?.innerText.trim().split('\uFF5C')||[];
      let o = dict( $find_(".detail", e).map(x => when(x.innerText.trim(), text =>
        (text.includes('\n') || x.previousElementSibling.matches('.spacer')             ? ['notes', text]  :
         text == "Hidden publicly"                                                      ? ['hidden', true] :
         ["Want to play", "Currently playing", "Completed", "Abandoned"].includes(text) ? ['status', text] :
         when(text.match(/^Paid (.*[.0-9]+.*)$/)?.[1], paid => ['paid', paid]) || ['notes', text]))) );
      return cell && platform && convert({platform, format, ...o}, params, SELECTORS.map(s => $find(s, cell)));
    }));
    $update('deku', dict(games({clean: false})));
    GM_registerMenuCommand("Reset image data", () => confirm("Remove old image data?") && $update('deku', dict(games({clean: true}))));

  } else if (PAGE.match(RE.dekuDetails) && $find(".summarized-details.owned")) {

    console.warn("[BL] activated!");
    const PHYSICAL = $find_(".summarized-details.owned .main").some(e => e.innerText.endsWith("Physical"));
    const OPENCRITIC = $find(".opencritic")?.title.split(": ")[1];
    const $ = dict( $find_("ul.details > li").map(e => when(e.firstChild.innerText, label =>
      [pascal(label), e.innerText.replace(label, "").trim() + str(label == "OpenCritic:", ` (${OPENCRITIC})`)])) );
    const RELEASED = when($.ReleaseDate, s => chunks(s.split('\n'), 2).map(ss => compact(ss).join(": ")).join("; "));
    $mergeData('deku-info', {[location.pathname.replace(/^\/items\//, '')]: {
      [PHYSICAL ? 'icon' : 'image']: new URL($find("main img").src).pathname.replace(/^\/images\//, '/'),
      size: $.DownloadSize,  genre: $.Genre,  time: $.HowLongToBeat,  released: RELEASED,  openCritic: $.Opencritic,
      metacritic: $.Metacritic?.replace(/\btbd\b/g, "?").replace(/ /g, " | "),   dlc: $find("h4")?.innerText.startsWith("DLC ")||NIL,
    }})

  } else if (PAGE.match(RE.psnLibrary) && (PAGE.match(RE.psnLibrary)[1] != USER_ID) && !['search', 'completion', 'pf'].some(s => s in PARAMS)) {

    console.warn("[BL] can be activated");
    const PSN_ID = PAGE.match(RE.psnLibrary)[1];
    const PANEL = $get("../../../*", $find(".dropdown-toggle.completion"));
    $append(PANEL, $e('i', {
      className: "fas fa-save", style: "cursor: pointer;  color: white;  margin-left: 1ex", title: "[BL] This is my profile!",
      onclick: () => when(confirm(`[BL] Set '${PSN_ID}' as your profile?`), () => {
        GM_setValue('settings', merge(GM_getValue('settings'), {psnId: PSN_ID}));
        ['psn', 'psn-img'].forEach(GM_deleteValue);  // switching profile involves resetting your data
        location = location.href;
      })}));

  } else if (USER_ID && (PAGE.match(RE.psnLibrary)?.[1] == USER_ID) && !['search', 'completion', 'pf'].some(s => s in PARAMS)) {

    console.warn("[BL] activated!");
    const TROPHIES = ['gold', 'silver', 'bronze'];
    const PANEL = $get("../../../*", $find(".dropdown-toggle.completion"));
    const GAMES = $find('#gamesTable');

    $append(document.body, $e('span', {id: 'loaderBL'}, $e('i', {className: "fas fa-cog rotatingBL"})));
    let _loading = () => $find('#table-loading', GAMES);
    let load = () => new Promise(resolve => {
      if (!$find('#load-more', GAMES))
        resolve()
      else {
        loadMoreGames();  // eslint-disable-line no-undef
        let waiting = forever(() => when(!$find('#table-loading', GAMES), () => {
          clearInterval(waiting);
          resolve( load() );
        }));
      }
    });

    let _achievements = s => (replace(s, /All (\d+)/, "$1 of $1") || s).match(/(\d+) of (\d+)/).slice(1).join(" / ");
    let convert = e => when([$find("picture source", e).srcset.match("^.*, (.*) 1.1x$")[1]], ([icon]) =>
      [$find('a', e).href.match(RE.psnDetails)[1],
       {name: $find('.title', e).innerText,   icon: icon?.replace(PSN_CDN, ""),
        rank: $find('.game-rank', e).innerText,   progress: $find('.progress-bar', e).innerText,
        achievements: _achievements($find('.small-info', e).innerText),
        platforms: $find_('.platform', e).map(y => PSN_HW[y.innerText]).join(''),
        status: ['completion', 'platinum'].filter(s => $find(`.${s}.earned`, e)).join(", ")||NIL,
        trophies: $find('.trophy-count div', e).innerText.split('\n').map((s, i) => `${s} ${TROPHIES[i]}`).join(", ")}]);
    let scrape = () => $withLoading('progress', () => load().then(() => $update('psn', dict( $find_('tr', GAMES).map(convert) ))));

    $append(PANEL, $e('i', {className: "fas fa-sync-alt", style: "cursor: pointer;  color: white;  margin-left: 1ex",
                            id: 'syncBL', title: "Sync Backloggery", onclick: scrape}));
    forever(() => $visibility(syncBL, GAMES.style.display != 'none'));

  } else if (USER_ID && (PAGE.match(RE.psnDetails)?.[2] == USER_ID)) {

    console.warn("[BL] activated!");
    const GAME_ID = PAGE.match(RE.psnDetails)[1];
    when($find('.game-image-holder a').href?.replace(PSN_CDN, ""), img => $mergeData('psn-img', {[GAME_ID]: img}));

  } else if (USER_ID && (PAGE.match(RE.retroProgress)?.[1] == USER_ID)) {

    console.warn("[BL] activated!");
    const ACHIEVEMENTS = /^(?:All|([0-9]+) of) ([0-9]+) achievements$/;
    const DATES = mapVals(GM_getValue('retro', {}), x => x.sync);
    const GAMES = $find_(`.cprogress-pmeta__root`).map(game =>
      when([game.parentNode.parentNode, $find(`a[href*='/game/']`, game)], ([row, title], id=parseInt(title.href.match(RE.retroGame)?.[1])) =>
        [id, {name: title.innerText,
              icon: $find(`img[src^="${RETRO_CDN}"]`, row)?.src.replace(RETRO_CDN, ""),
              platform: $find(`img[src*='/assets/images/system/'] + p`, row)?.innerText,
              sync: Math.max(DATES[id]||0, new Date(game.lastElementChild.innerText.replace(/^Last played /, "")+" 12:00").getTime()||0) || NIL,
              status: $find('.cprogress-ind__root', row).getAttribute('data-award') || NIL,
              achievements: when(game.children[1]?.firstElementChild?.innerText?.match(ACHIEVEMENTS), m => [m[1]||m[2], m[2]].join(" / ")),
              ...keymap(['hardcore', 'softcore'], (k, i) =>
                when(parseInt($find(`[role=progressbar]`, row).children[i]?.style.width.replace(/%$/, "")), n => `${n}%`))}]));
    $mergeData('retro', dict(GAMES), {showAlert: true});

  } else if (PAGE.match(RE.retroGame)) {

    const ID = PAGE.match(RE.retroGame)[1];
    if (!(ID in GM_getValue('retro', {})) && $find(`aside [role=progressbar]`)?.parentNode.previousElementSibling) return;  // progress actions menu
    console.warn("[BL] activated!");
    const META = dict($find_(`img[alt="Game icon"] + * > *`).map(e => [e.firstElementChild.innerText.toLowerCase(), e.lastElementChild.innerText]));
    $mergeData('retro-info', {[ID]: {...META, image: $find(`aside img[src^="${RETRO_CDN}"]`)?.src.replace(RETRO_CDN, "")}});

  }
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址