// ==UserScript==
// @name Backloggery interop
// @namespace http://tampermonkey.net/
// @version 0.11.3
// @description Backloggery integration with game library websites
// @author LeXofLeviafan
// @icon https://backloggery.com/favicon.ico
// @include *://backloggery.com/*
// @include *://www.backloggery.com/*
// @include *://steamcommunity.com/id/*/games*
// @include *://steamcommunity.com/id/*/stats/*
// @include *://steamcommunity.com/id/*/gamecards/*
// @include *://steamcommunity.com/id/*/badges*
// @include *://steamcommunity.com/stats/*/achievements
// @include *://steamcommunity.com/stats/*/achievements/*
// @include *://store.steampowered.com/app/*
// @include *://steamdb.info/app/*
// @include *://steamdb.info/calculator/*
// @include *://astats.astats.nl/astats/User_Games.php?*
// @include *://gog.com/account
// @include *://gog.com/*/account
// @include *://www.gog.com/account
// @include *://www.gog.com/*/account
// @include *://www.humblebundle.com/home/*
// @include *://itch.io/my-collections
// @include *://*.itch.io/*
// @include *://www.gamersgate.com/account/*
// @include *://store.epicgames.com/*
// @include *://www.dekudeals.com/collection*
// @include *://www.dekudeals.com/items/*
// @include *://psnprofiles.com/<username>
// @include *://psnprofiles.com/*?*
// @include *://psnprofiles.com/trophies/*
// @exclude *://psnprofiles.com/<username>
// @require https://cdnjs.cloudflare.com/ajax/libs/mithril/2.0.4/mithril.min.js
// @require https://cdn.jsdelivr.net/npm/[email protected]/lib/coffeescript-browser-compiler-legacy/coffeescript.js
// @grant GM_info
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_addStyle
// ==/UserScript==
var inline_src = String.raw`
ROMANS = {Ⅰ: 'I', Ⅱ: 'II', Ⅲ: 'III', Ⅳ: 'IV', Ⅴ: 'V', Ⅵ: 'VI', Ⅶ: 'VII', Ⅷ: 'VIII', Ⅸ: 'IX', Ⅹ: 'X', Ⅺ: 'XI', Ⅻ: 'XII', Ⅼ: 'L', Ⅽ: 'C', Ⅾ: 'D', Ⅿ: 'M'}
roman = RegExp("[#{Object.keys(ROMANS).join('')}]", 'g')
identity = (x) -> x
merge = (os...) -> Object.assign {}, os...
fromPairs = (pairs) -> merge ([k]: v for [k, v] in pairs.filter identity)...
sortBy = (xs, weight) -> xs.slice().sort (a, b) -> weight(a) - weight(b)
groupBy = (xs, f) -> xs.reduce ((o, x) -> k = f(x); (o[k] = o[k] or []).push x; o), {}
keymap = (ks, f) -> fromPairs ([k, f k] for k in ks)
objmap = (o, f) -> fromPairs ([k, f(v, k, o)] for k, v of o)
objfilter = (o, f) -> fromPairs ([k, v] for k, v of o when f(v, k, o))
pick = (o, keys...) -> fromPairs ([k, o[k]] for k in keys when k of o)
method = (o, k, def=->) -> o?[k]?.bind?(o) or def
setFn = (xs) -> method (new Set xs), 'has'
last = (l) -> l[l.length - 1]
when_ = (x, f) -> x and f(x)
replace = (s, re, pattern) -> s.match(re) and s.replace(re, pattern)
qstr = (s) -> if not s.includes('?') then "" else s[1 + s.indexOf '?'..]
query = (s) -> fromPairs (l[1..] for l in qstr(s).split('&').map((s) -> s.match /([^=]+)=(.*)/) when l)
slugify = (s) -> s.replace(roman, (c) -> ROMANS[c]).toLowerCase().replace(/[.]/g, '').replace(/[^a-z0-9+]+/g, '-').replace(/(^-*|-*$)/g, '')
capitalize = (s) -> do (z = "#{s}") -> z[...1].toUpperCase() + z[1..]
pascal = (s) -> slugify(s or "").split('-').map(capitalize).join("")
strs = (ss...) -> ss.map (s) -> s or ""
statStr = (o, ks...) -> ("#{capitalize k}: #{o[k]}" for k in ks when o[k] or o[k] is 0).join '\n'
forever = (f) -> setInterval f, 100
delay = (f) -> new Promise (resolve) -> setTimeout -> resolve f()
debounce = (delay, action) -> do (last = null) -> ->
clearTimeout last
last = setTimeout action, delay
PAGE = location.href
PARAMS = query location.search
RE =
backloggeryUpdate: "backloggery\\.com/update\\.php"
backloggeryCreate: "backloggery\\.com/newgame\\.php"
backloggeryLibrary: "backloggery\\.com/games\\.php"
steamLibrary: "steamcommunity\\.com/id/([^/]+)/games($|\\?)"
steamAchievements: "steamcommunity\\.com/id/([^/]+)/stats/[^/]+"
steamAchievements2: "steamcommunity\\.com/stats/[^/]+/achievements"
steamDetails: "store\\.steampowered\\.com/app/([^/]+)"
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($|\\?)"
dekuDetails: "dekudeals\\.com/items/"
psnLibrary: "psnprofiles\\.com/([^/?]+)/?($|\\?)"
psnDetails: "psnprofiles\\.com/trophies/([^/?]+)/([^/?]+)$"
PSN_ID = (GM_info?.script?.options?.override?.use_includes or []).reduce ((x, s) -> x or s.match(RE.psnLibrary)?[1]), null
_PSN_HW = {PS3: '3', PS4: '4', PS5: '5', VITA: 'V', VR: 'v'}
_psnData = (images) -> (id) -> objmap(objfilter(GM_getValue('psn', {}), (x) -> _PSN_HW[id] in x.platforms),
(x, k) -> merge x, psn: yes, image: images[k], url: "https://psnprofiles.com/trophies/#{k}/#{PSN_ID}")
_dekuData = (info) -> (key) -> objmap(objfilter(GM_getValue('deku', {}), (x) -> x.platform is key), (x, k) -> do (x = merge x, info[x.url.replace(/^\//, "")]) ->
merge x, url: "https://www.dekudeals.com/items#{x.url}", ...(x[s] and [s]: "https://cdn.dekudeals.com/images#{x[s]}" for s in ['image', 'icon']))
[EPIC_CDN, EPIC_STORE] = ["https://cdn1.epicgames.com", "https://www.epicgames.com/store/product/"]
_epicUrls = (x) -> url: x.slug and EPIC_STORE+x.slug, icon: EPIC_CDN+x.icon, image: EPIC_CDN+x.image
DATA = do (STATS = GM_getValue('steam-stats', {}), PLATFORMS = GM_getValue('steam-platforms', {}),
psnData = _psnData(GM_getValue 'psn-img', {}), dekuData = _dekuData(GM_getValue 'deku-info', {}),
ITCH = objmap(GM_getValue('itch-info', {}), ({worksOn, rating, at, ...meta}) -> ({worksOn, rating, sync: at, meta}))) ->
steam: objmap GM_getValue('steam', {}), (x, k) -> merge(x, url: "https://steamcommunity.com/app/#{k}", achievements: STATS[k] or '?', worksOn: PLATFORMS[k] or 's')
gog: objmap GM_getValue('gog', {}), (x) -> merge(x, url: "https://gog.com#{x.url}", completed: if x.completed then 'yes' else 'no')
humble: GM_getValue('humble', {})
epic: objmap GM_getValue('epic', {}), (x) -> merge(x, _epicUrls(x), features: ['online', 'cloud'].filter((k) -> x[k]).join ", ")
itchio: objmap GM_getValue('itch', {}), (x, k) -> merge(x, ITCH[k], meta: {Acquired: x.date, ...(ITCH[k]?.meta or {})})
ggate: objmap GM_getValue('ggate', {}), (x, id) -> merge(x, url: "https://gamersgate.com/account/orders/#{id.replace(':', '#')}")
psvita: psnData('VITA'), psvr: psnData('VR'), ps3: psnData('PS3'), ps4: merge(psnData('PS4'), dekuData('ps4')),
ps5: merge(psnData('PS5'), dekuData('ps5')), switch: dekuData('switch'), xbo: dekuData('xso'), xboxsx: dekuData('xboxsx')
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']
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"}
slugs = (o) -> fromPairs sortBy ([slugify(v.name), k] for k, v of o), ([slug, id]) -> (o[id].url or "").length
USER_OS = do (platform = (navigator.platform or navigator.userAgentData?.platform or "").toLowerCase()) -> switch
when platform.startsWith "win" then 'windows'; when platform.startsWith "mac" then 'mac'; else 'linux'
$clear = (e) -> e.removeChild e.firstChild while e.firstChild; e
$append = (parent, children...) -> parent.appendChild e for e in children; parent
$before = (neighbour, children...) -> neighbour.parentElement.insertBefore(e, neighbour) for e in children; neighbour
$after = (neighbour, children...) -> $before(neighbour.nextSibling, children...); neighbour
$e = (tag, options, children...) -> $append Object.assign(document.createElement(tag), options), children...
$get = (xpath, e=document) -> document.evaluate(xpath, e, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
$find = (selector, e=document) -> e.querySelector selector
$find_ = (selector, e=document) -> Array.from e.querySelectorAll selector
$hasClass = (e, clss) -> do (l = e.classList) -> l and clss.split(' ').every((s) -> not s or l.contains s)
$visibility = (e, x) -> e.style.visibility = if x then 'visible' else 'hidden'
$markUpdate = (k) -> GM_setValue 'updated', merge(GM_getValue('updated'), [k]: +new Date)
$stop = (f=->) -> (e) -> f e; e.stopPropagation(); no
$keepScroll = (e, f) -> do (x = e.scrollTop) -> f(); m.redraw(); e.scrollTop = x
$query = (url) -> new Promise (resolve, reject) -> do (xhr = new XMLHttpRequest) ->
xhr.open 'GET', url
[xhr.onerror, xhr.onload] = [reject, -> resolve JSON.parse xhr.response]
xhr.send()
$watcher = (f) -> new MutationObserver (xs) -> xs.forEach (x) -> x.addedNodes.forEach f
NOISE = fromPairs "a an and as at by from for in into is of on or so the to collection edition remastered ii iii iv v vi vii viii ix x".split(" ").map (s) -> [s, on]
words = (s) -> slugify(s).split('-').sort().reverse()
matching = (ss, zs) -> do (res = 0, i = 0, j = 0) ->
while i < ss.length and j < zs.length
[s, z] = [ss[i], zs[j]]
if s is z
i++; j++; res += if s of NOISE or Number(s) then 1.1 else 2
else if z.startsWith s
i++; j++; res += 1
else
if s < z then j++ else i++
res
order = (sets, exclude, text, k) -> do (d = DATA[k], l = words(text), f = (s) -> not exclude["#{k}##{s}"]) ->
o = objmap(sets[k] or {}, (ss) -> matching l, ss)
Object.keys(sets[k] or {}).sort (a, b) -> f(b)-f(a) or o[b]-o[a] or d[a].name.localeCompare d[b].name
$addChanges = (newChanges) -> do (changes = GM_getValue 'changes', []) ->
oldChanges = new Set changes
GM_setValue 'changes', [changes..., (id for id in newChanges when not oldChanges.has id)...]
WATCH_FIELDS = "name worksOn completed achievements platforms status trophies".split ' '
WATCH_META = {'steam-stats': 'steam', 'steam-platforms': 'steam'}
WATCH_LIBRARY = {}
$update = (library, games1) -> do (library_ = WATCH_LIBRARY[library] or library, games0 = GM_getValue library, {}) ->
[ids1, ids0] = [games1, games0].map Object.keys
removed = (id for id in ids0 when id not of games1)
added = (id for id in ids1 when id not of games0)
updated = (id for id in ids0 when id of games1 and WATCH_FIELDS.some (k) -> games0[id][k] isnt games1[id][k])
$markUpdate library
$addChanges ("#{library_}##{id}" for id in [removed..., updated...])
GM_setValue library, games1
setTimeout -> alert "Backloggery interop: added #{added.length} games, removed #{removed.length} games\n(#{updated.length} of #{ids1.length} games changed)"
$mergeData = (k, o) -> do (library = WATCH_META[k], old = GM_getValue k, {}) ->
library and $addChanges ("#{library}##{id}" for id in Object.keys o when id of old and old[id] isnt o[id])
GM_setValue k, merge(old, o)
$logo = (k, id) -> do (o = if k is 'custom' then id else DATA[k][id]) -> switch k
when 'steam' then ["https://cdn.akamai.steamstatic.com/steam/apps/#{id}/capsule_184x69.jpg", "https://steamcdn-a.akamaihd.net/steam/apps/#{id}/header.jpg"]
when 'gog' then [196, 392].map((x) -> "https:#{o.image}_#{x}.jpg")
else o and [o.icon or o.image, o.image or o.icon]
if ["backloggery.com", "gog.com", "humblebundle.com", "itch.io", "epicgames.com", "psnprofiles.com"].some((s) -> ("."+location.host).endsWith("."+s))
$append document.head, $e('link', rel: 'stylesheet', href: "https://use.fontawesome.com/releases/v5.7.0/css/all.css")
GM_addStyle "#loader {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}"
GM_addStyle "@-webkit-keyframes rotation {from {-webkit-transform:rotate(0deg)}
to {-webkit-transform:rotate(360deg)}}
@keyframes rotation {from {transform:rotate(0deg) translate(-50%, -50%); -webkit-transform:rotate(0deg)}
to {transform:rotate(360deg) translate(-50%, -50%); -webkit-transform:rotate(360deg)}}"
GM_addStyle ".rotating {animation: rotation 2s linear infinite}"
LOGO = ".logo {height: 0; width: 0; display: flex; flex-direction: row-reverse}
.logo img {border: 1px solid darkorchid; background: #1b222f}"
if location.host.match "^(www\\.)?backloggery\\.com$"
for e in $find_("a[href$='console=PC'], #intro > .npgame > :nth-child(3)")
e.innerText = "Windows" if e.innerHTML is "PC"
BL_ID = location.host is "backloggery.com" and $get("//*[@id='menu']//a[text()='My Backlog']")?.href.match("/([^/]+)$")?[1]?.toLowerCase()
STEAM_ID = location.host is "steamcommunity.com" and $find("#global_header a.username")?.href.match("/id/([^/]+)/home/$")?[1]
if PAGE.match(RE.backloggeryUpdate) and PARAMS.user?.toLowerCase() is BL_ID
SETS = objmap DATA, (o) -> objmap(o, (x) -> words x.name)
legend = $get '//*[@id="content-wide"]/section/form/fieldset[1]/legend'
systemDropdown = $get '//*[@id="content-wide"]/section/form/fieldset[1]/div[2]'
delBtns = $get '//*[@id="content-wide"]/section/form/div[2]/div'
status = $get '//*[@id="content-wide"]/section/form/fieldset[2]/div[1]'
rating = $get '//*[@id="content-wide"]/section/form/fieldset[3]/div[1]/b[text()="Rating"]'
_system = when_ systemDropdown, (e) -> $find('select', e)
swap = -> do ([a, b] = [_system, $find '#detail2 select']) -> [a.value, b.value] = [b.value, a.value]
do (_bl = GM_getValue('backlog', {})[PARAMS.gameid]) ->
k = Object.keys(DATA).find (k) -> _bl?[k+'Id']
changeId = k and "#{k}##{_bl[k+'Id']}"
GM_setValue 'changes', (id for id in GM_getValue('changes', []) when id isnt changeId)
unless legend and systemDropdown and delBtns # deleted/doesn't exist
backlog = GM_getValue('backlog', {})
if PARAMS.gameid in backlog
delete backlog[PARAMS.gameid]
GM_setValue('backlog', backlog)
else
for es in $find("[name=#{s}console]").children for s in ['', 'orig_']
e.innerText = "Windows" for e in es when e.value is "PC"
$append systemDropdown, $e('tt', innerText: "⇄", onclick: swap, style: "cursor: pointer; padding-left: 8px; font-size: large")
$before delBtns, $e('input', type: 'submit', name: 'submit2', className: 'greengray', value: "Stealth Save ⇄", onclick: swap)
$after($find('.info.help', detail5), $e('a', target: '_blank', id: 'achievements', className: 'hint', style: "padding: 3.5ex"))
$after($find('.info.help', status), $e('span', id: 'completed', className: 'hint', style: "padding: 1ex"))
$after($find('input[name=note]'), $e('div', id: 'notes', className: 'hint'))
$after(rating, $e('span', id: 'rated', className: 'hint', style: "padding: 0 1ex"))
GM_addStyle ".overlay {position: relative; max-height: 0; top: -40px; z-index: 2; margin-right: 10px; display: flex; flex-direction: row-reverse}
.overlay input {height: 20px; background: #4b4b4b; color: white; width: 500px; border: 1px solid black; padding-left: 1ex; margin-bottom: 0}
.overlay .options {display: flex; flex-direction: column; max-height: 500px; overflow-y: auto; background: grey}
.overlay button {height: 28px; background: #4b4b4b; color: white; border-radius: 10px 8px 8px 10px; padding: 5px}
.overlay .option {white-space: nowrap; display: flex; margin: .5px} .overlay .trash {cursor: pointer}
.overlay * {flex-shrink: 0} .overlay button b {flex: 1; padding-left: 1ex; text-align: left; overflow: hidden; text-overflow: ellipsis}
.oslist {display: flex; position: absolute; width: 505px; padding-top: 7.5px; pointer-events: none} .oslist.shift {padding-right: 20px}
.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: 10px; right: 7.5px}
.iconlist {display: flex; position: absolute; width: 505px; padding-top: 7.5px; pointer-events: none; color: white !important}
.hint {font-weight: bold; font-style: italic; white-space: pre-wrap} .hint[href] {color: royalblue}
fieldset, .anchor {position: relative} .tooltip {background: rgba(0, 0, 0, 0.8)}
.icons {padding-top: 1ex; text-align: center} .icons .btn {margin: .25em} .btn.selected {border-color: white}
.btn {background: #4b4b4b; color: white; border: 1px solid black; cursor: pointer; font-size: 20px; padding: 2px; border-radius: 5px}
.btn.fa-eye {margin-left: 1.25px} .done, .preview {display: block; margin: 1ex auto} .done {width: 90%; cursor: pointer}
#{LOGO} .logo img {height: 100px} .preview {border: 1px solid darkorchid; max-width: calc(100% - 2ex)}"
gameName = $find '[name=name]'
_bl = GM_getValue('backlog')?[PARAMS.gameid] or {}
excluded = GM_getValue('exclude', {})
data = (k=state.list) -> DATA[k]
id = (s=state.list) -> "#{s}Id"
id$ = (s=state.list) -> _bl[ id(s) ]
eId$ = (k=id$(s), s=state.list) -> "#{s}##{k}"
data$ = (s=state.list) -> data(s)?[ id$(s) ]
title = (k=state.list) -> data$(k)?.name or gameName.value
_icons = -> (_bl.custom?.icons or "").split ' '
_order = (s=state.title, k=state.list) -> order(SETS, excluded, s, k)
state = do (list = (_system.value or '').toLowerCase()) ->
list: list
title: title list
active: no
order: _order(title(list), list)
when_ data$(), (o) -> do (_rating = o.rating and "[#{o.rating}/5]") ->
[achievements.innerText, notes.innerText, rated.innerText, completed.innerText] = strs(o.achievements, o.notes, _rating, statStr(o, 'completed') or o.status)
achievements.href = "https://steamcommunity.com/stats/#{id$()}/achievements" if _system.value is 'Steam'
_setBl = (o) -> $mergeData 'backlog', [PARAMS.gameid]: Object.assign(_bl, o)
_delBl = (...ks) -> delete _bl[k] for k in ks; _setBl()
_setExcl = (k, x) ->
excluded[ eId$(k) ] = x
$mergeData('exclude', [eId$(k)]: x)
state.order = _order()
if id$() and not data$() then _delBl id(), 'ignore'
section2 = $find_('fieldset')[1]
$append section2, $e('div', id: 'ignore', style: "position: absolute; top: -25px; left: 110px")
m.mount ignore, view: -> id$() and [
do (x = !_bl.ignore) -> m('i.btn', class: "far fa-eye#{if x then '' else '-slash'}", title: (if x then "Watch" else "Ignore"), onclick: -> _setBl(ignore: x))
]
section1 = $find_('fieldset')[0]
$before section1, $e('div', id: 'logo', className: 'logo')
m.mount logo, view: -> switch
when _bl.custom then m('a', {target: '_blank', href: _bl.custom.url}, m('img', src: $logo('custom', _bl.custom)[1]))
when id$() then m('a', {target: '_blank', href: data$().url}, m('img', src: $logo(state.list, id$())[1]))
$append section1, $e('div', id: 'custom', style: "position: absolute; top: -25px; left: 170px")
toggleCustom = (x) -> ->
state.active = no
if _bl.custom then _delBl('custom') else _delBl(id(), 'ignore'); _setBl custom: {}
m.mount custom, view: -> do (x = _bl.custom) -> m('i.btn', class: "fa fa-#{if x then 'edit' else 'list'}", title: (if x then "Custom" else "Listed"), onclick: toggleCustom x)
preview = null
document.addEventListener 'keydown', (e) -> if e.key is 'Escape'
_delBl id(), 'ignore'
Object.assign(state, active: no, title: title(), order: _order title())
preview = achievements.innerText = notes.innerText = rated.innerText = completed.innerText = ""
achievements.removeAttribute('href')
m.redraw()
$reset = -> do (list = (_system.value or '').toLowerCase()) -> if list isnt state.list
Object.assign(state, {list}, active: no, title: title(list), order: _order(title(list), list))
m.redraw()
$$ = (k) -> ->
Object.assign(state, active: no, title: data()[k].name)
state.order = _order()
_setBl([id()]: k)
when_ data$(), (o) -> do (_rating = o.rating and "[#{o.rating}/5]") ->
[achievements.innerText, notes.innerText, rated.innerText, completed.innerText] = strs(o.achievements, o.notes, _rating, statStr(o, 'completed') or o.status)
achievements.href = "https://steamcommunity.com/stats/#{id$()}/achievements" if _system.value is 'Steam'
no
$custom = -> _setBl custom: Object.assign _bl.custom, updated: +new Date
toggleIcon = (s) -> do (icons = _icons()) ->
_bl.custom.icons = (if s not in icons then [icons..., s] else icons.filter (x) -> x isnt s).join(' ').trim()
$custom()
gameName.onchange = _system.onchange = $reset
overlay = $e('div', style: "display: flex; flex-direction: column; width: calc(505px + 1ex); position: relative")
$after(legend, $e('div', {className: 'overlay'}, overlay))
worksOn = (o) -> (do ([s, cls] = OS[c]) -> m("i.fab.#{cls}.os", title: s)) for c in (o.worksOn or '').split ''
m.mount overlay, view: -> do (x = _bl.custom, o = data()) -> switch
when x then [
m('input', type: 'url', value: x.url or "", title: "URL", onclick: (-> state.active = yes), oninput: (-> x.url = @value), onchange: $custom)
m '.oslist', {class: x.url and 'shift'}, m('div', style: "flex: 1"),
_icons().map((s) -> m "i.os", class: CUSTOM_ICONS[s], title: s)
x.url and m('a.action', title: "Test", target: '_blank', href: x.url, m 'i.fas.fa-external-link-alt')
state.active and m '.tooltip',
m '.anchor', m('input', type: 'url', value: x.icon or "", title: "Icon URL", oninput: (-> x.icon = @value), onchange: $custom),
x.icon and m 'i.action.fas.fa-eye', title: "Preview", onmouseenter: (-> preview = x.icon), onmouseleave: (-> preview = null)
m '.anchor', m('input', type: 'url', value: x.image or "", title: "Poster URL", oninput: (-> x.image = @value), onchange: $custom),
x.image and m 'i.action.fas.fa-eye', title: "Preview", onmouseenter: (-> preview = x.image), onmouseleave: (-> preview = null)
m '.anchor.icons', Object.keys(CUSTOM_ICONS).map (s) ->
m 'i.btn', class: CUSTOM_ICONS[s] + (if s in _icons() then " selected" else ""), title: s, onclick: (-> toggleIcon s)
m '.anchor', m 'button.done', onclick: (-> [preview, state.active] = [null, no]; $custom(); no), "Done"
preview and m '.anchor', m 'img.preview', src: preview
]
when o then [
m('input', value: state.title, title: id$() or "", onclick: (-> state.active = yes), \
oninput: -> _delBl id(), 'ignore'; state.title = @value; state.order = _order())
id$() and m('.oslist', m('div', style: "flex: 1"), worksOn o[ id$() ])
state.active and m('.options', state.order.map (k) -> do (x = not excluded[ eId$(k) ]) -> [
m('button.option', {disabled: not x, onclick: $$(k), title: o[k].name}
m('i.trash', class: "fas fa-trash#{if x then '' else '-restore'}-alt", title: (if x then "Exclude" else "Restore"), \
onclick: $stop(-> _setExcl k, x))
m('b', o[k].name), worksOn o[k])
])
]
else if PAGE.match(RE.backloggeryCreate) and PARAMS.user?.toLowerCase() is BL_ID
BACKLOG = GM_getValue 'backlog', {}
MATCHED = objmap DATA, (_, s) -> setFn (x["#{s}Id"] for k, x of BACKLOG when x["#{s}Id"])
UNMATCHED = objmap DATA, (o, s) -> pick(o, (k for k of o when not MATCHED[s](k))...)
SETS = objmap UNMATCHED, (o) -> objmap(o, (x) -> words x.name)
excluded = GM_getValue 'exclude', {}
GM_addStyle ".os {padding-left: 1ex; line-height: 0; font-size: 16px} .hint {font-weight: bold; font-style: italic; white-space: pre-wrap}
#names {position: absolute; max-height: 500px; width: 730px; top: 50px; left: 9px; z-index: 2;
display: flex; flex-direction: column; overflow-y: auto; background: grey}
#names > button {flex-shrink: 0; height: 24px; border-radius: 10px; display: flex; flex-direction: row;
margin-top: 1px; text-align: left; padding-left: 1ex;}
#names > button > .name {flex-grow: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis}
#names > button > i {padding-right: .5em; color: black; cursor: pointer}
#names > button > * {line-height: 1.9}
#names > button:hover {opacity: .8} #names > button:has(> i:hover) {background: lightpink}
#{LOGO} .logo img {height: 100px} .hint[href], #oslist[href] {color: royalblue}"
for es in $find("[name=#{s}console]").children for s in ['', 'orig_']
e.innerText = "Windows" for e in es when e.value is "PC"
status = $get '//*[@id="content-wide"]/section/form/fieldset[2]/div[1]'
rating = $get '//*[@id="content-wide"]/section/form/fieldset[3]/div[1]/b[text()="Rating"]'
[name, system] = ['name', 'console'].map (s) -> $find "[name=#{s}]"
name.autocomplete = 'off'
eId = (k, s=system.value) -> "#{s.toLowerCase()}##{k}"
for e in system.children
when_(UNMATCHED[ e.value.toLowerCase() ], (o) -> e.innerText += " (+#{(k for k of o when not excluded[ eId(k, e.value) ]).length})")
$after($find('.info.help', detail2), $e('a', target: '_blank', id: 'oslist', style: "padding-left: 1ex"))
$after($find('.info.help', detail5), $e('a', target: '_blank', id: 'achievements', className: 'hint', style: "padding: 3.5ex"))
$after($find('.info.help', status), $e('span', id: 'completed', className: 'hint', style: "padding: 1ex"))
$after($find('input[name=note]'), $e('div', id: 'notes', className: 'hint'))
$after(rating, $e('span', id: 'rated', className: 'hint', style: "padding: 0 1ex"))
$find_('fieldset')[0].style.position = 'relative'
$after($find('[name=name]'), $e('div', id: 'names'))
data = (k=system.value) -> UNMATCHED[ k.toLowerCase() ] or {};
_order = (text=name.value, k=system.value) -> order(SETS, excluded, text, k.toLowerCase())
state = id: null, active: no, order: _order()
_setExcl = (k, x) ->
excluded[ eId(k) ] = x
$mergeData('exclude', [eId(k)]: x)
state.order = _order()
_redraw = (id=state.id, o=data()[id]) -> do (_rating = o?.rating and "[#{o.rating}/5]") ->
$clear oslist
o and $append oslist, ((do ([s, cls] = OS[c]) -> $e('i', className: "fab #{cls} os", title: s)) for c in (o.worksOn or '').split(''))...
[achievements.innerText, notes.innerText, rated.innerText, completed.innerText] = strs(o?.achievements, o?.notes, _rating, statStr(o or {}, 'completed') or o?.status)
if not o or system.value isnt 'Steam' then x.removeAttribute('href') for x in [achievements, oslist]
else [achievements.href, oslist.href] = ["https://steamcommunity.com/stats/#{id}/achievements", "https://store.steampowered.com/app/#{id}"]
$before($find_('fieldset')[0], $e('div', id: 'logo', className: 'logo'))
m.mount logo, view: -> do (k = system.value.toLowerCase(), o = data()[state.id]) ->
o and m('a', {target: '_blank', href: o.url}, m('img', src: $logo(k, state.id)[1]))
$upd = (id) ->
_redraw id
when_ data()[id], (o) -> name.value = o.name
Object.assign state, {id}, active: not id, order: _order()
no
name.oninput = name.onclick = -> $upd(); m.redraw()
system.onchange = ->
Object.assign state, id: null, order: _order()
_redraw(); m.redraw()
document.addEventListener 'keydown', (e) -> if e.key is 'Escape'
Object.assign state, id: null, active: no
_redraw(); m.redraw()
m.mount names, view: -> state.active and
state.order.map (k) -> do (x = not excluded[eId(k)]) ->
m 'button', {disabled: not x, onclick: -> $upd(k)}, m('span.name', {title: data()[k].name}, data()[k].name),
m 'i.fas', class: "fa-trash#{if x then '' else '-restore'}-alt", title: (if x then "Exclude" else "Restore"), \
onclick: $stop(-> $keepScroll names, -> _setExcl(k, x))
else if PAGE.match(RE.backloggeryLibrary) and PARAMS.user?.toLowerCase() is BL_ID
INCOMPLETE = ["(-)", "(u)", "(U)"]
SLUGS = objmap DATA, slugs
Object.entries({steam: "Steam", gog: "GOG"}).forEach ([k, name]) ->
[n, m] = (Object.keys(o[k]).length for o in [DATA, SLUGS])
unless n is m
groups = groupBy Object.keys(DATA[k]), (id) -> slugify DATA[k][id].name
data = (slug, ids, main=SLUGS[k][slug]) -> [main, ...ids.filter (id) -> id isnt main].map (id) -> {id, ...DATA[k][id]}
duplicates = Object.entries(groups).filter(([slug, ids]) -> ids.length > 1).map ([slug, ids]) -> [slug, data(slug, ids)]
console.warn "#{name} names have #{n-m} collisions", fromPairs duplicates
UPD = GM_getValue 'updated', {}
LIBRARIES = Object.keys DATA
STORED_CHANGES = GM_getValue('changes', [])
CHANGES = do (backlog = GM_getValue('backlog', {}), changes = new Set STORED_CHANGES) ->
objfilter backlog, (x, k) -> LIBRARIES.some (s) -> changes.has "#{s}##{x[s+'Id']}"
CHANGED = Object.keys(CHANGES).sort (a, b) -> CHANGES[a].name.localeCompare CHANGES[b].name
$clearUpdates = -> if confirm("Clear stored changelog?") then GM_deleteValue('changes'); STORED_CHANGES = []
$s = (e) -> e.innerText.trim()
info = (e) -> $find '.gamerow', e
name = (e) -> $find 'b', e
id = (e) -> query($find('a', e).href).gameid
$achievements = (e) -> $find '.info span', info e
$completion = (e) -> $find 'img', $find_('h2 a', e)[1]
$type = (s, e) -> $s(info e).match RegExp("\\b#{s}\\b", 'i')
$slug = (k, e) -> SLUGS[k][ slugify($s name e) ]
overlay = $e('div', style: "z-index:2; pointer-events:none; position:fixed; top:0; left:0; width:100%; height:100%; display:flex")
$append document.body, overlay
GM_addStyle "#{LOGO} .logo img {max-height: 62px} .logo.steam img {max-height: 67px} .logo.gog img {max-height: 64px}
.os {padding-left: .75ex; line-height: 0 !important; font-size: 20px; position: relative; top: 2.5px}
.os {font-weight: 400} .os.fa-gamepad, .os.fa-dice, .os.fa-dice-d20, .os.fa-trophy {font-weight: 900}
section.gamebox.processed .logo img {max-height: 64px}
.tooltip {margin: auto; align-items: center; display: flex; flex-direction: column; max-height: 90%;
background: rgba(0, 0, 0, 0.8); padding: 2em; transform: translateZ(0) translateX(-99px)}
.tooltip > * {max-width: 548px} .tooltip > div {padding-top: 1em; font-weight: bold}
.tooltip pre {white-space: pre-wrap; text-indent: -1em; padding-left: 1em}
.changelist {position: absolute; top: 0; right: 0; pointer-events: all; background: rgba(0, 0, 0, 0.8);
max-width: 33%; max-height: 50%; display: flex; flex-direction: column}
.changelist.collapsed {opacity: .5} .changelist:hover {opacity: 1}
.changelist .items {overflow-y: auto} .changelist .items > .item {margin: 1em}
.changelist > h1 {cursor: pointer; position: relative; padding: 1em; padding-right: 3em}
.changelist > h1 > .right {position: absolute; right: 0; margin-right: 1em}
#side-loader {position: fixed; top: 1ex; left: 1ex; z-index: 10000; font-size: 100px}"
changeListCollapsed = no
overlayData = null
$$ = (x) -> -> overlayData = x; m.redraw()
m.mount overlay, view: -> switch
when overlayData then m '.tooltip',
m('img', src: overlayData.image, style: "overflow: hidden") # this forces the image to scale down to fit the box… CSS, amirite?
m 'div', overlayData.stats?.map (s) -> m('pre', s)
when STORED_CHANGES.length > 0 then m '.changelist', {class: if changeListCollapsed then 'collapsed' else ""},
m 'h1', {onclick: -> changeListCollapsed = not changeListCollapsed}, "Unseen changes (#{CHANGED.length}) ",
m 'span.right', "[#{if changeListCollapsed then '+' else '–'}]"
unless changeListCollapsed then m '.items',
CHANGED.map (k) -> m '.item', m 'a', {href: "https://www.backloggery.com/update.php?user=#{PARAMS.user}&gameid=#{k}"},
CHANGES[k].name, (" [#{s}]" for s in LIBRARIES when CHANGES[k]["#{s}Id"])
if CHANGED.length is 0 then m 'input[type=submit].item', {onclick: $clearUpdates, value: "Clear changelog from storage"}
$tweak = (e, [k, k_=k], id, [ignore, markParam, markCond], [append, appendFmt=identity], [stats, statsUpdated]) ->
[[icon, image], x] = [$logo(k, id), if k is 'custom' then id else DATA[k][id]]
_data = {image, stats: ((stats and "#{stats}\n" or "") + "Synced: #{new Date(statsUpdated or UPD[k_])}").split '\n'}
e.style.background = if ignore then 'darkgrey' else unless markCond(markParam e) then '' else 'lightcoral'
name(e).title = "#{x.name}\nSynced: #{new Date if k is 'custom' then x.updated else UPD[k_]}"
name(e).innerHTML += unless append then '' else " [#{appendFmt append}]"
(do ([s, cls] = OS[c]) -> $append name(e), $e('i', className: "fab #{cls} os", title: s)) for c in (x.worksOn||'').split('')
$append name(e), $e('i', className: "#{CUSTOM_ICONS[s]} os", title: s) for s in (x.icons or "").split(' ') when s if k is 'custom'
$before e, $e('div', {className: "logo #{k}", onmouseleave: $$(), onmouseenter: $$ _data},
$e('a', merge(target: '_blank', x.url and href: x.url), $e('img', src: icon)))
_renameWindows = (s) -> replace(s, /^PC( \(.*\))?$/, "Windows$1") or replace(s, /^(.*)\(PC\)$/, "$1(Windows)") or s
e.innerText = _renameWindows e.innerText for e in $find_ "aside .sysbox"
_platformTitleUpdater = $watcher (e) -> e.innerText = _renameWindows e.innerText if $hasClass(e, "system title")
_platformTitleUpdater.observe output1, childList: yes
$append document.body, _loader = $e('span', {id: 'side-loader', style: "display: none"}, $e('i', className: "fas fa-cog rotating"))
_delay = (f) -> _loader.style = ""; delay -> f(); _loader.style = "display: none"
_queue = Promise.resolve()
_gameboxEnhancer = $watcher (game) -> if $hasClass(game, 'gamebox') and info game then _queue = _queue.then -> _delay ->
backlog = GM_getValue 'backlog', {}
_id = id game
_bl = backlog[_id] = Object.assign(backlog[_id] or {}, name: $s name game)
$syncId = (k) -> unless _bl.custom then _bl["#{k}Id"] = _bl["#{k}Id"] or $slug(k, game); DATA[k][ _bl["#{k}Id"] ]
_type = $find 'b', info(game)
_type.innerText = _renameWindows _type.innerText
_deku = ['ps4', 'ps5', 'switch', 'xso', 'xboxsx'].find((s) -> $type(s, game) and (s not in ['ps4', 'ps5'] or not $syncId(s)?.psn))
_psn = ['ps3', 'ps4', 'ps5', 'psvita', 'psvr'].find((s) -> $type(s, game) and (s not in ['ps4', 'ps5'] or $syncId(s)?.psn))
if _bl.custom
$tweak game, ['custom'], merge(_bl.custom, name: ""), [yes, (->''), (->'')], ["custom"], ["Custom", _bl.custom.updated]
else if $type 'steam', game
data = $syncId 'steam'
stats = data?.achievements?.replace(" (?)", "") # some games are marked as 0 / 0 in library but actually have achievements
_markCond = (e) -> stats is '?' or (not e and stats isnt "0 / 0") or (e and not e.innerText.startsWith "Achievements: #{stats} (")
data and $tweak game, ['steam'], _bl.steamId, [_bl.ignore, $achievements, _markCond],
[data.hours, (s) -> "#{s}h"], [statStr(data, 'achievements'), UPD['steam-stats']]
else if $type 'gog', game
data = $syncId 'gog'
completed = data?.completed is 'yes'
data and $tweak game, ['gog'], _bl.gogId, [_bl.ignore, $completion, (e) -> completed is INCOMPLETE.includes e.alt],
[data.rating, (n) -> "#{n}/5"], [statStr(data, 'completed', 'category')]
else if $type 'humble', game
data = $syncId 'humble'
data and $tweak game, ['humble'], _bl.humbleId, [_bl.ignore, (->''), (->'')], [], [statStr(data, 'developer', 'publisher')]
else if $type 'epic', game
data = $syncId 'epic'
data and $tweak game, ['epic'], _bl.epicId, [_bl.ignore, (->''), (->'')], [], [statStr(data, 'developer', 'features')]
else if $type 'itchio', game
data = $syncId 'itchio'
meta = data and objmap(data.meta, (x, k) -> unless k in ["Acquired", "Updated", "Published", "Release date"] then x else new Date x)
data and $tweak game, ['itchio', 'itch'], _bl.itchioId, [_bl.ignore, (->''), (->'')],
[data.rating, (n) -> "#{n}/5"], [statStr(meta, ...Object.keys meta), data.sync]
else if $type 'ggate', game
data = $syncId 'ggate'
data and $tweak game, ['ggate'], _bl.ggateId, [_bl.ignore, (->''), (->'')], [], []
else if _deku
data = $syncId _deku
status = {"Completed": ["(B)", "(C)", "(M)"], "Currently playing": ["(U)", "(B)"],\
"Abandoned": ["(U)", "(B)"], "Want to play": ["(u)"]}[data?.status]
append = [data?.physical and "Physical", data?.dlc and "DLC", data?.rating and "#{data.rating}/5"]
[hltb, notes] = [data?.time, data?.notes].map (s) -> (s and "\n#{s}") or ""
data and $tweak game, [_deku, 'deku'], _bl["#{_deku}Id"], [_bl.ignore, $completion, (e) -> status and not status.includes e.alt],
[data.rating or data.physical, -> append.filter(identity).join "] ["],
[statStr(data, 'status', 'size', 'genre', 'released', 'metacritic', 'openCritic') + hltb + notes]
else if _psn
data = $syncId _psn
stats = data?.achievements
_markCond = (e) -> (not e and stats isnt "0 / 0") or (e and not e.innerText.startsWith "Achievements: #{stats} (")
data and $tweak game, [_psn, 'psn'], _bl["#{_psn}Id"], [_bl.ignore, $achievements, _markCond],
[data.rank, (s) -> "#{s} rank"], [statStr(data, 'achievements', 'status', 'trophies', 'progress')]
GM_setValue 'backlog', backlog
_gameboxEnhancer.observe output1, childList: yes, subtree: yes
else if when_(PAGE.match(RE.steamLibrary), (m) -> m[1] is STEAM_ID)
{rgGames, achievement_progress} = JSON.parse gameslist_config.getAttribute 'data-profile-gameslist'
$update 'steam', fromPairs ([o.appid, {name: o.name, hours: o.hours_forever}] for o in rgGames)
stats = GM_getValue 'steam-stats', {}
$markUpdate 'steam-stats'
$mergeData 'steam-stats', fromPairs(for o in achievement_progress
old = (stats[o.appid] or "0 / 0").replace(" (?)", "") # Steam lists status for some games as 0/0 incorrectly
[o.appid, (if o.total is 0 and old isnt "0 / 0" then "#{old} (?)" else "#{o.unlocked} / #{o.total}")])
else if when_(PAGE.match(RE.steamAchievements), (m) -> m[1] is STEAM_ID) # personal
when_ $find('#topSummaryAchievements'), (e) -> do (id = $find('.gameLogo a').href.match(/\d+$/)[0]) ->
$mergeData('steam-stats', [id]: e.innerText.match(/(\d+) of (\d+)/)[1..].join(" / "))
else if PAGE.match RE.steamAchievements2 # global
when_ $find('#compareAvatar') and $find('#headerContentLeft'), (e) -> do (id = $find('.gameLogo a').href.match(/\d+$/)[0]) ->
$mergeData('steam-stats', [id]: e.innerText.match(/\d+ \/ \d+/)[0])
else if PAGE.match RE.steamDetails
ID = PAGE.match(RE.steamDetails)[1]
if $find '.game_area_already_owned'
platforms = $find_('.platform_img', $find '.game_area_purchase_game')
worksOn = (s[0] for s in ['win', 'linux', 'mac'] when platforms.some (e) -> $hasClass(e, s)).join('')
worksOn and $mergeData('steam-platforms', [ID]: worksOn)
else if PAGE.match RE.steamBadges # highlighting progress
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) ->
foil = $find(".badge_title", panel)?.innerText.trim().endsWith " Foil Badge"
exp = $find(".badge_info_title, .badge_empty_name", panel)?.nextElementSibling
level = Number exp.innerText.match("Level ([0-9]+)")?[1] or 0
levels = fromPairs (if foil then [0, 1] else [0, 1, 2, 3, 4, 5]).map (n, i) -> ["(#{n - level})", "level#{i}"]
foil and panel.classList.add('foil')
exp.classList.add('badge-exp', "level#{level}")
$find_(".badge_card_set_title", panel).forEach (cardTitle) ->
amount = $find(".badge_card_set_text_qty", cardTitle)?.innerText or "(0)"
cardTitle.classList.add('card-name', levels[amount] or 'excess')
cardTitle.parentNode.title = cardTitle.innerText.split('\n').reverse().join('\n')
else if PAGE.match RE.steamStats
_achievements = (ss) -> (s.match(/\d+/)?[0] or s for s in ss).join(" / ")
stats = if PARAMS.DisplayType isnt '2' then do (_table = $find '.tablesorter') -> # list
_header = $find_ 'th', $find('thead tr', _table)
_body = ($find_('td', e) for e in $find_ 'tr', $find('tbody', _table))
[_name$, _total$, _my$] = (_header.findIndex((e) -> e.innerText is s) for s in ["Name", "Total\nAch.", "Gained\nAch."])
_body.map (l) -> [query($find("a[href^='Steam_Game_Info.php']", l[_name$]).href).AppID,
_achievements(e.innerText for e in [l[_my$], l[_total$]])]
else do (_body = $get '/html/body/center/center/center/center') -> # table
_table = Array.from(_body.children).find((x) -> x.tagName is 'TABLE' and not x.classList.contains 'Pager')
_ids = (query(e.href).AppID for e in $find_('a', _table))
[_ids[i], _achievements( last($find_ 'p', e).innerText.match(/Achievements: (.*) of (.*)/)[1..] )] for e, i in $find_('table', _table)
throw "Invalid update" if stats.length > 0 and not stats[0][0]? # ensuring that next layout change won't break updater
$markUpdate 'steam-stats'
$mergeData 'steam-stats', fromPairs stats
alert "Game library interop: updated #{stats.length} games"
else if PAGE.match RE.gogLibrary
queryPage = (page=0) -> $query "/account/getFilteredProducts?mediaType=1&page=#{page+1}"
worksOn = (o) -> o and worksOn: (k[0].toLowerCase() for k, v of o when v).join('')
scrape = -> queryPage().then (o) -> do (completed = o.tags.find((x) -> x.name.toLowerCase() is 'completed').id) ->
Promise.all([Promise.resolve(o), [1...o.totalPages].map(queryPage)...]).then (data) ->
games = [].concat(data.map((x) => x.products)...)
.map (o) => [o.id, merge(pick(o, 'image', 'url'), worksOn(o.worksOn), rating: o.rating and o.rating/10,
name: o.title, category: o.category or undefined, completed: o.tags.includes completed)]
$update 'gog', fromPairs games
$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
PLATFORMS = windows: 'w', linux: 'l', osx: 'm', android: 'a'
url = -> ($find('.details-heading a') or {}).href
platformSelector = -> $find '.js-platform-select-holder'
worksOn = -> do (e = platformSelector()) ->
(PLATFORMS[k] for k of PLATFORMS when e.querySelector '.hb-'+k).join('')
scrape = -> for e in $find_ '.subproduct-selector'
e.click()
name: $find('h2', e).innerText
publisher: $find('p', e).innerText
icon: $find('.icon', e).style.backgroundImage.match(/^url\("(.*)"\)$/)?[1]
url: url()
worksOn: worksOn()
GM_addStyle "#syncBackloggery {position: absolute; top: 28px; left: 400px; cursor: pointer}"
$append document.body, $e('span', {id: 'loader'}, $e('i', className: "fas fa-cog rotating"))
$visibility loader, off
main = $find '.base-main-wrapper'
main.style.position = 'relative'
$append main, $e('i', id: 'syncBackloggery', className: "fas fa-sync-alt", title: "Sync Backloggery", onclick: ->
$visibility loader, on
setTimeout ->
$update 'humble', fromPairs scrape().map (x) -> x.worksOn and [slugify(x.name), x]
$visibility loader, off)
$visibility syncBackloggery, off
forever -> when_ $find('#switch-platform'), (e) ->
$visibility(syncBackloggery, (e.value is 'all') and not search.value and location.pathname is "/home/library")
else if PAGE.match RE.itchLibrary
GM_addStyle ".my_collections_page .game_collection h2 {display: flex} .fa-sync-alt {padding-left: 1ex; cursor: pointer}"
_div = document.createElement 'div'
_date = undefined
queryPage = (page=0) -> $query("/my-purchases?format=json&page=#{page+1}").then (o) -> if o.num_items > 0
_div.innerHTML = o.content
Array.from(_div.childNodes).map (e) -> do (title = $find(".game_title a", e)) ->
id: e.getAttribute 'data-game_id'
name: title.innerText
url: title.href.replace(/\/download\/[^/]+$/, "")
image: $find("img", e)?.getAttribute('data-lazy_src')
author: $find(".game_author", e)?.innerText
date: (_date = $find(".date_header > span", e)?.title or _date)
collect = (page=0) -> queryPage(page).then (xs) -> unless xs then [] else collect(page+1).then (ys) -> [...xs, ...ys]
scrape = -> collect().then (xs) -> $update 'itch', fromPairs 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
PLATFORMS = windows: 'w', linux: 'l', osx: 'm', android: 'a', ios: 'm', web: 'b'
_platforms = (check) -> Object.keys(PLATFORMS).filter(check).map((k) -> PLATFORMS[k]).join ""
_parseDate = (e) -> new Date($find("abbr", e).title).getTime()
if id = Object.entries(GM_getValue 'itch', {}).find(([k, o]) -> o.url is location.href)?[0]
_info = fromPairs $find_(".game_info_panel_widget tr").map (e) -> [e.firstChild.innerText, e.lastChild]
{Author, Platforms, Rating, ...info} = objmap _info, (x, k) -> switch slugify k
when 'platforms' then _platforms (k) -> $find "a[href$=platform-#{k}]", x
when 'published' then _parseDate x
when 'updated' then _parseDate x
when 'release-date' then _parseDate x
when 'rating' then Number $find(".aggregate_rating", x).title
else x.innerText
$mergeData('itch-info', [id]: {at: Date.now(), worksOn: Platforms, rating: Rating, ...info})
else if PAGE.match RE.ggateLibrary
games = $find_(".my-games-catalog .catalog-item").map (e) -> do (link = $find('a', e), icon = $find('img', e).src) ->
id = link.href.match("/account/orders/(.*)")[1].replace("/#", ":")
[id, {name: link.title, icon, image: icon.replace(/\?.*/, "")}]
$update 'ggate', fromPairs games
else if PAGE.match RE.epicStore
CONFIG = if USER_OS is 'windows' then "C:/Users/%USER%/AppData" else if USER_OS is 'linux' then "~/.config" else "~/Library/Application Support"
TOOLTIP = "Import catalog to Backloggery from launcher cache:\n* EpicGamesLauncher/Data/Catalog/catcache.bin\n* #{CONFIG}/heroic/lib-cache/library.json"
if user.classList.contains 'signed-in'
GM_addStyle ".bl-import {display: flex; align-items: center} .bl-import > * {cursor: pointer; padding: 1em}"
convertHeroic = (data) -> data.map (x) ->
id: x.app_name
name: x.title
slug: x.store_url?.replace EPIC_STORE, ""
icon: (x.art_logo or x.art_cover or x.art_square)?.replace(EPIC_CDN+"/", "/")
image: (x.art_square or x.art_cover or x.art_logo)?.replace(EPIC_CDN+"/", "/")
worksOn: ['w', x.is_linux_native and 'l', x.is_mac_native and 'm'].filter(identity).join ""
developer: x.developer
online: not x.canRunOffline or undefined
cloud: x.cloud_save_enabled or undefined
_epicImg = (x) -> fromPairs x.keyImages.map (y) -> [y.type.replace(/^DieselGameBox/, "") or 'Cover', y.url]
convertEpic = (data) -> data.filter((x) -> x.categories.some (y) -> y.path is 'games').map (x) -> do (meta = x.releaseInfo[0], img = _epicImg x) ->
id: meta.appId
name: x.title
#slug: not available
icon: (img.Logo or img.Cover or img.Tall)?.replace(EPIC_CDN+"/", "/")
image: (img.Tall or img.Cover or img.Logo)?.replace(EPIC_CDN+"/", "/")
worksOn: ['Windows' in meta.platform and 'w', 'Linux' in meta.platform and 'l', 'Mac' in meta.platform and 'm'].filter(identity).join ""
developer: x.developer
online: x.customAttributes.CanRunOffline?.value is 'false' or undefined
cloud: 'CloudSaveFolder' of x.customAttributes or undefined
parseFile = (file) -> (readFile file
.then (s) -> if s[0] is "{" then convertHeroic JSON.parse(s).library else convertEpic JSON.parse atob s
.then (games) -> $update 'epic', fromPairs games.map ({id, ...x}) -> [id, x]
.catch (e) -> console.error e; alert "Invalid catalog cache file")
readFile = (file) -> new Promise (resolve) -> do (reader = new FileReader) ->
reader.onload = -> resolve @result.trim()
reader.readAsText file
importFile = $e('input', type: 'file', accept: ".bin,.json", onchange: -> @files[0] and parseFile @files[0])
btn = $e('div', className: "bl-import", $e('i', className: "fas fa-file-import", title: TOOLTIP, onclick: -> importFile.click()))
addBtn = debounce 1000, -> user.insertAdjacentElement 'afterend', btn; _watcher.disconnect()
_watcher = $watcher addBtn
_watcher.observe rightNav, childList: yes, subtree: yes, attributes: yes
else if PAGE.match(RE.dekuLibrary) and $find "a[href='/logout']"
[counter, platform] = [{}, (s) -> {'xbox-x-s': 'xboxsx', 'xbox-one': 'xbo'}[slugify s] or slugify s]
entries = for e in $find_ ".watch-details.owned" then do (cell = e.parentNode) ->
cell = cell.parentNode until not cell or cell.classList.contains('cell') or cell.classList.contains('items-list-row')
o = fromPairs $find_(".detail", e).map (x) -> do (label = ($find(".text-muted", x) or x).innerText.trim()) ->
[pascal(label), x.innerText.replace(label, '').trim() or yes]
if cell and o.Platform
[name, link, img, form, rating] = [".name", "a.main-link", ".main-link img, .image img", "form", "input[checked][name=rating]"].map (s) -> $find(s, cell)
name = name.innerText
counter[name] = (counter[name] or 0) + 1
[new URL(form.action).pathname.replace(/^\/owned_items\//, ''),
name: name + (if counter[name] is 1 then "" else " ##{counter[name]}")
url: new URL(link.href).pathname.replace(/^\/items\//, '/')
image: new URL(img.src).pathname.replace(/^\/images\//, '/')
platform: platform(o.Platform), physical: (o.Format is 'Physical') or undefined
status: o.Status, notes: o.Notes or o.NotesPrivate, rating: Number(rating?.value) or undefined]
$update 'deku', fromPairs entries.filter(identity)
else if PAGE.match(RE.dekuDetails) and $find ".watch-details.owned"
physical = $find_(".watch-details.owned .detail").some (e) -> e.innerText is "Format\nPhysical"
opencritic = $find(".opencritic", e)?.title.split(": ")[1]
o = fromPairs $find_("ul.details > li").map (e) -> do (label = e.firstChild.innerText) ->
[pascal(label), e.innerText.replace(label, "").trim() + (if label isnt "OpenCritic:" then "" else " (#{opencritic})")]
$mergeData 'deku-info', [location.pathname.replace(/^\/items\//, '')]:
icon: (physical and new URL($find("main img").src).pathname.replace(/^\/images\//, '/')) or undefined
size: o.DownloadSize, genre: o.Genre, time: o.HowLongToBeat, released: o.Released?.split("\n").join("; "), openCritic: o.Opencritic,
metacritic: o.Metacritic?.replace(/\btbd\b/, "?").replace(" ", " | "), dlc: $find("h4")?.innerText.startsWith("DLC ") or undefined
else if PSN_ID and PAGE.match(RE.psnLibrary)?[1] is PSN_ID
PANEL = $get "../../..", $find ".dropdown-toggle.completion"
GAMES = $find '#gamesTable'
if ['search', 'completion', 'pf'].every (s) -> s not of PARAMS
$append document.body, $e('span', {id: 'loader'}, $e('i', className: "fas fa-cog rotating"))
$visibility loader, off
_loading = -> $find('#table-loading', GAMES)
load = -> new Promise (resolve) ->
unless $find '#load-more', GAMES
resolve()
else
loadMoreGames()
waiting = forever -> unless $find '#table-loading', GAMES
clearInterval waiting
resolve load()
TROPHIES = ['gold', 'silver', 'bronze']
_achievements = (s) => (replace(s, /All (\d+)/, "$1 of $1") or s).match(/(\d+) of (\d+)/)[1..].join " / "
convert = (x) -> [$find('a', x).href.match(RE.psnDetails)[1],
name: $find('.title', x).innerText, icon: $find("picture source", x).srcset.match("^.*, (.*) 1.1x$")[1],
rank: $find('.game-rank', x).innerText, progress: $find('.progress-bar', x).innerText,
achievements: _achievements($find('.small-info', x).innerText),
platforms: $find_('.platform', x).map((y) -> _PSN_HW[y.innerText]).join(''),
status: ['completion', 'platinum'].filter((s) -> $find ".#{s}.earned", x).join(", ") or undefined,
trophies: $find('.trophy-count div', x).innerText.split('\n').map((s, i) -> "#{s} #{TROPHIES[i]}").join(", ")]
$append PANEL.firstElementChild,
$e('i', id: 'syncBackloggery', className: "fas fa-sync-alt", style: "cursor: pointer; color: white", title: "Sync Backloggery", onclick: ->
$visibility loader, on
load().then ->
$visibility loader, off
$update 'psn', fromPairs $find_('tr', GAMES).map convert)
forever -> $visibility syncBackloggery, GAMES.style.display isnt 'none'
else if PSN_ID and PAGE.match(RE.psnDetails)?[2] is PSN_ID
GAME_ID = PAGE.match(RE.psnDetails)[1]
$mergeData 'psn-img', [GAME_ID]: $find('.game-image-holder a').href
`;
if (location.host != 'steamdb.info') { // can't run compiled CoffeeScript on SteamDB site
eval( CoffeeScript.compile(inline_src) );
} else {
let $worksOn = e => ['windows', 'linux', 'macos'].filter(s => e.querySelector(`.octicon-${s}`)).map(s => s[0]).join('');
let $addData = (data, {id, old = GM_getValue('steam-platforms', {}), changes = GM_getValue('changes', [])}) => {
(id in old) && (old[id] != data) && !changes.includes(id) && GM_setValue('changes', [...changes, id]);
GM_setValue('steam-platforms', {...old, [id]: data});
};
if (location.pathname.startsWith("/app/") && document.querySelector("#js-app-install.owned")) {
var info = document.querySelector('.span8');
$worksOn(info) && $addData($worksOn(info), {id: document.querySelectorAll('td', info)[1].innerText});
} else if (location.pathname.startsWith("/calculator/") && location.href.startsWith(document.querySelector(".account-menu a")?.href)) {
new MutationObserver(xs => xs.forEach(x => x.addedNodes.forEach(e => {
e.firstElementChild?.classList?.contains('hover_buttons') &&
$worksOn(e) && $addData($worksOn(e), {id: new URL(e.querySelector('a.hover_title').href).pathname.match("^/app/([0-9]+)")[1]});
}))).observe(document, {childList: true, subtree: true});
}
}