// ==UserScript==
// @name Backloggery interop
// @namespace http://tampermonkey.net/
// @version 0.7.2
// @description Backloggery integration with game library websites
// @author LeXofLeviafan
// @include *://www.backloggery.com/games.php?*
// @include *://www.backloggery.com/update.php?*
// @include *://www.backloggery.com/newgame.php?*
// @include *://steamcommunity.com/id/<username>/games/*
// @exclude *://steamcommunity.com/id/<username>/games/*
// @include *://steamcommunity.com/id/<username>/stats/*
// @exclude *://steamcommunity.com/id/<username>/stats/*
// @include *://steamcommunity.com/stats/*/achievements
// @include *://store.steampowered.com/app/*
// @include *://steamdb.info/app/*
// @include *://steamdb.info/calculator/<userid>/*
// @exclude *://steamdb.info/calculator/<userid>/*
// @include *://astats.astats.nl/astats/User_Games.php?*
// @include *://www.gog.com/account
// @include *://www.humblebundle.com/home/library
// @include *://www.humblebundle.com/monthly/trove
// @include *://*.gamersgate.com/account/*
// @include *://psnprofiles.com/<username>
// @include *://psnprofiles.com/*?*
// @include *://psnprofiles.com/trophies/*
// @exclude *://psnprofiles.com/<username>
// @require https://cdnjs.cloudflare.com/ajax/libs/mithril/1.1.6/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_addStyle
// ==/UserScript==
var inline_src = String.raw`
identity = (x) -> x
merge = (os...) -> Object.assign {}, os...
fromPairs = (pairs) -> merge ([k]: v for [k, v] in pairs.filter identity)...
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.toLowerCase().replace(/[.]/g, '').replace(/[^a-z0-9+]+/g, '-').replace(/(^-*|-*$)/g, '')
capitalize = (s) -> do (z = "#{s}") -> z[...1].toUpperCase() + z[1..]
statStr = (o, ks...) -> ("#{capitalize k}: #{o[k]}" for k in ks when k of o).join '\n'
forever = (f) -> setInterval f, 100
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/\\?tab=all"
steamRecent: "steamcommunity\\.com/id/[^/]+/games($|/$|/\\?tab=recent)"
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"
gogLibrary: "gog\\.com/account"
humbleLibrary: "humblebundle\\.com/home/library"
humbleTrove: "humblebundle\\.com/monthly/trove"
ggateLibrary: "gamersgate\\.com/account/(games|wishlist|achievements)" # they share a page and can switch without reload
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', VITA: 'V'}
_psnData = (images) -> (id) -> objmap(objfilter(GM_getValue('psn', {}), (x) -> _PSN_HW[id] in x.platforms),
(x, k) -> merge x, image: images[k], url: "https://psnprofiles.com/trophies/#{k}/#{PSN_ID}")
DATA = do (TROVE = objmap(GM_getValue('humble-trove', {}), (o) -> merge o, url: "https://www.humblebundle.com/monthly/trove")
STATS = GM_getValue('steam-stats', {}), PLATFORMS = GM_getValue('steam-platforms', {}),
psnData = _psnData(GM_getValue 'psn-img', {})) ->
steam: objmap GM_getValue('steam', {}), (x, k) -> merge(x, url: x.link, 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: objmap merge(TROVE, GM_getValue 'humble'), (x, id) -> merge(TROVE[id], x)
ggate: objmap GM_getValue('ggate', {}), (x, id) -> merge(x, url: "https://gamersgate.com/#{id}")
ps3: psnData('PS3'), ps4: psnData('PS4'), psvita: psnData('VITA')
OS = w: ["Windows", 'fa-windows'], l: ["Linux", 'fa-linux'], m: ["MacOS", 'fa-apple'], a: ["Android", 'fa-android'], s: ["Steam", 'fa-steam']
slugs = (o) -> fromPairs ([slugify(v.name), k] for k, v of o)
$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)
$assertEq = (a, b, err) -> a is b or alert(if typeof err isnt 'function' then err else err a, b)
$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()
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 += 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 = {'humble-trove': 'humble'}
$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"
$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 = DATA[k][id]) -> switch k
when 'steam' then [o.logo, "https://steamcdn-a.akamaihd.net/steam/apps/#{id}/header.jpg"]
when 'gog' then [196, 392].map((x) -> "https:#{o.image}_#{x}.jpg")
else [o.icon or o.image, o.image or o.icon]
$append document.body, $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 PAGE.match RE.backloggeryUpdate
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]'
_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('b', id: 'achievements', style: "padding: 3.5ex"))
$after($find('.info.help', status), $e('b', id: 'completed', style: "padding: 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; margin: .5px; padding: 5px; display: flex}
.overlay .trash {cursor: pointer}
.overlay * {flex-shrink: 0} .overlay button b {flex: 1; padding-left: 1ex; text-align: left}
.os {padding-left: .75ex; color: white; font-size: 20px}
.oslist {display: flex; position: absolute; width: 505px; padding-top: 7.5px; pointer-events: none}
#ignore > i {background: #4b4b4b; color: white; border: 1px solid black; cursor: pointer; font-size: 20px; padding: 2px; border-radius: 5px}
#ignore > i.fa-eye {margin-left: 1.25px}
#{LOGO} .logo img {height: 100px}"
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
_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) -> [achievements.innerText, completed.innerText] = [o.achievements or "", statStr(o, 'completed') or o.status or '']
_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]
section2.style.position = 'relative'
$append section2, $e('div', id: 'ignore', style: "position: absolute; top: -1px; left: 110px")
m.mount ignore, view: -> id$() and [
do (x = !_bl.ignore) -> m('i.far', class: "fa-eye#{if x then '' else '-slash'}", title: (if x then "Watch" else "Ignore"), onclick: -> _setBl(ignore: x))
]
$before($find_('fieldset')[0], $e('div', id: 'logo', className: 'logo'))
m.mount(logo, view: -> id$() and m('a', {target: '_blank', href: data$().url}, m('img', src: $logo(state.list, id$())[1])))
document.addEventListener 'keydown', (e) -> if e.key is 'Escape'
_delBl id(), 'ignore'
Object.assign(state, active: no, title: title(), order: _order title())
achievements.innerText = completed.innerText = ""
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) -> [achievements.innerText, completed.innerText] = [o.achievements or "", statStr(o, 'completed') or o.status or '']
no
gameName.onchange = _system.onchange = $reset
overlay = $e('div', style: "display: flex; flex-direction: column")
$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 (o = data()) -> o and [
m('input', value: state.title, title: id$() or "", onclick: (-> state.active = yes), \
oninput: (e) -> _delBl id(), 'ignore'; state.title = e.target.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', {key: k, disabled: not x, onclick: $$(k)}
m('i.trash.fas', class: "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
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}
#names {position: absolute; max-height: 500px; width: 730px; top: 75px; 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 > span {flex-grow: 1} #names > button > i {padding-right: .5em; color: black; cursor: pointer}
#{LOGO} .logo img {height: 100px}"
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]'
[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('span', id: 'oslist', style: "padding-left: 1ex"))
$after($find('.info.help', detail5), $e('b', id: 'achievements', style: "padding: 3.5ex"))
$after($find('.info.help', status), $e('b', id: 'completed', style: "padding: 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]) ->
$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, completed.innerText] = [o?.achievements or "", statStr(o or {}, 'completed') or o?.status or '']
$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()
m.redraw()
name.oninput = name.onclick = -> $upd()
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', {key: k, disabled: not x, onclick: -> $upd(k)}, m('span', 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
INCOMPLETE = ["(-)", "(u)", "(U)"]
SLUGS = objmap DATA, slugs
$assertEq(Object.keys(DATA.steam).length, Object.keys(SLUGS.steam).length, (n, m) -> "Steam names have #{n-m} collisions!")
$assertEq(Object.keys(DATA.gog).length, Object.keys(SLUGS.gog).length, (n, m) -> "GOG names have #{n-m} collisions!")
UPD = GM_getValue 'updated', {}
LIBRARIES = Object.keys DATA
CHANGES = do (backlog = GM_getValue('backlog', {}), changes = new Set GM_getValue('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
$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 {font-weight: 100; padding-left: .75ex; line-height: 0 !important; font-size: 20px; position: relative; top: 2.5px}
section.gamebox.processed .logo img {max-height: 64px}
.tooltip {margin: auto; align-items: center; display: flex; flex-direction: column;
background: rgba(0, 0, 0, 0.8); padding: 2em; transform: translateZ(0) translateX(-99px)}
.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}"
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: "max-width: 548px")
m('pre', {style: "padding-top:1em; font-weight:bold"}, overlayData.stats)
when CHANGED.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"])
$tweak = (e, [k, k_=k], id, [ignore, markParam, markCond], [append, appendFmt=identity], [stats, statsUpdated]) ->
[[icon, image], x] = [$logo(k, id), DATA[k][id]]
_data = {image, stats: (stats and "#{stats}\nUpdated: #{new Date(statsUpdated or UPD[k_])}")}
e.style.background = if ignore then 'darkgrey' else unless markCond(markParam e) then '' else 'lightcoral'
name(e).title = "#{x.name}\nUpdated: #{new Date 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('')
$before e, $e('div', {className: "logo #{k}", onmouseleave: $$(), onmouseenter: $$ _data},
$e('a', {target: '_blank', 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"
content.addEventListener 'DOMNodeInserted', ({target}) -> if $hasClass(target, "system title")
target.innerText = _renameWindows target.innerText
content.addEventListener 'DOMNodeInserted', ({target}) -> if $hasClass(target, 'gamebox') and info target
backlog = GM_getValue 'backlog', {}
_id = id target
_bl = backlog[_id] = Object.assign(backlog[_id] or {}, name: $s name target)
$syncId = (k) -> _bl["#{k}Id"] = _bl["#{k}Id"] or $slug(k, target); DATA[k][ _bl["#{k}Id"] ]
_type = $find 'b', info(target)
_type.innerText = _renameWindows _type.innerText
_psn = ['ps3', 'ps4', 'psvita'].find((s) -> $type s, target)
if $type 'steam', target
data = $syncId 'steam'
stats = data?.achievements
_markCond = (e) -> stats is '?' or (not e and stats isnt "0 / 0") or (e and not e.innerText.startsWith "Achievements: #{stats} (")
data && $tweak target, ['steam'], _bl.steamId, [_bl.ignore, $achievements, _markCond],
[data.hours_forever, (s) -> "#{s}h"], [statStr(data, 'achievements'), UPD['steam-stats']]
else if $type 'gog', target
data = $syncId 'gog'
completed = data?.completed is 'yes'
data and $tweak target, ['gog'], _bl.gogId, [_bl.ignore, $completion, (e) -> completed is INCOMPLETE.includes e.alt],
[data.rating, (n) -> "#{n/10}/5"], [statStr(data, 'completed', 'category')]
else if $type 'humble', target
data = $syncId 'humble'
data and $tweak target, ['humble'], _bl.humbleId, [_bl.ignore, (->''), (->'')],
[not data.icon and "Humble Trove"], [statStr(data, 'developer', 'publisher')]
else if $type 'ggate', target
data = $syncId 'ggate'
data and $tweak target, ['ggate'], _bl.ggateId, [_bl.ignore, (->''), (->'')], [], [statStr(data, 'developer', 'publisher')]
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 target, [_psn, 'psn'], _bl["#{_psn}Id"], [_bl.ignore, $achievements, _markCond],
[data.rank, (s) -> "#{s} rank"], [statStr(data, 'achievements', 'status', 'trophies', 'progress')]
GM_setValue 'backlog', backlog
else if PAGE.match RE.steamLibrary
$update 'steam', fromPairs ([o.appid, pick(o, 'link', 'logo', 'name', 'hours_forever')] for o in rgGames)
else if PAGE.match RE.steamRecent
stats = ([x.appid, "#{x.ach_completed} / #{x.ach_total}"] for x in rgGames when x.ach_completion)
$markUpdate 'steam-stats'
$mergeData 'steam-stats', fromPairs stats
alert "Game library interop: updated #{stats.length} games"
else if PAGE.match RE.steamAchievements # 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 && $mergeData('steam-platforms', [ID]: worksOn)
else if PAGE.match RE.steamDbDetails
unless $find('.panel-ownership').hidden
info = $find('.span8')
id = $find_('td', info)[1].innerText
worksOn = (s[0] for s in ['windows', 'linux', 'macos'] when $find(".icon-#{s}", info)).join('')
worksOn and $mergeData('steam-platforms', [id]: worksOn)
else if PAGE.match RE.steamDbLibrary
document.addEventListener 'DOMNodeInserted', ({target}) ->
when_ target.id?.match?(/^js-hover-app-([0-9]+)$/), ([_, id]) ->
worksOn = (s[0] for s in ['windows', 'linux', 'macos'] when $find(".icon-#{s}", target)).join('');
worksOn && $mergeData('steam-platforms', [id]: worksOn)
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) -> [$find('.content', l[_name$]).href.match(/^steam:\/\/run\/([0-9]+)$/)[1],
_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)
$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', 'rating', 'url'), worksOn(o.worksOn),
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)
else if PAGE.match RE.humbleTrove
name = -> $find('.product-human-name').innerText
credits = (t) -> $find(".#{t}")?.innerText.trim() # t in {'dev', 'pub'}
worksOn = -> (e.getAttribute('data-platform')[0] for e in $find('.platforms').children).join('')
scrape = -> $find_('#trove-main .trove-grid-item').reduce ((p, e) -> p.then (l) ->
e.click()
l.push(name: name(), developer: credits('dev'), publisher: credits('pub'), image: $find('img', e).src, worksOn: worksOn())
$find('.dismiss-action').click()
return new Promise (resolve) -> setTimeout (-> resolve l), 200
), Promise.resolve []
$append document.body, $e('span', {id: 'loader'}, $e('i', className: "fas fa-cog rotating", style: "color: white"))
$visibility loader, off
setTimeout (-> $before $find('.trove-sorter').firstElementChild,
$e('i', className: "fas fa-sync-alt", style: "cursor: pointer", title: "Sync Backloggery", onclick: ->
$visibility loader, on
scrape().then (xs) ->
$update 'humble-trove', fromPairs([slugify(x.name), x] for x in xs)
$visibility loader, off)),
1000
else if PAGE.match RE.ggateLibrary
PLATFORMS = pc: 'w', linux: 'l', mac: 'm', android: 'a'
worksOn = (e) -> x.src.match(/inline_(pc|linux|mac|android)\.png$/)?[1] for x in $find_ 'img', e
loadImage = (id) -> new Promise (resolve) ->
wait = ({target}) -> when_ target.firstChild and $find('.boximg', target), (img) ->
[dev, pub] = ["Developer", "Publisher"].map (s) -> $get("""//li[span = "#{s}: "]//a""", target)?.innerText
lib_rightcol_info.removeEventListener 'DOMNodeInserted', wait
setTimeout -> resolve [img.src, dev, pub]
lib_rightcol_info.addEventListener 'DOMNodeInserted', wait
Library.loadinfo 'game', "sku=#{id}&tab=details"
scrape = ->
$find_('.mygame_item').map((e) -> $find_('a.ttl', e))
.reduce ((p, [icon, name]) -> p.then (o) -> do (id = query(icon.href).sku) =>
loadImage(id).then ([image, developer, publisher]) ->
Object.assign(o, [id]: {
image, developer, publisher,
name: name.title,
icon: $find('img', icon).src,
worksOn: worksOn(name).map((s) -> PLATFORMS[s]).join('')
})
), Promise.resolve {}
GM_addStyle "#syncBackloggery {cursor: pointer; padding: 1ex}"
$append document.body, $e('span', {id: 'loader'}, $e('i', className: "fas fa-cog rotating"))
$visibility loader, off
when_ $find('h1.icon'), (e) ->
$append e, $e('i', id: 'syncBackloggery', className: "fas fa-sync-alt", title: "Sync Backloggery", onclick: ->
$visibility loader, on
scrape().then (o) ->
$update 'ggate', o
$visibility loader, off)
forever -> $visibility syncBackloggery, window.location.pathname is '/account/games' and
$find('[name=platform][value=""]')?.checked and not $find('[name=filter]').value
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
`;
eval( CoffeeScript.compile(inline_src) );