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