Backloggery interop

Backloggery integration with game library websites

Pada tanggal 22 Juni 2019. Lihat %(latest_version_link).

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Backloggery interop
// @namespace    http://tampermonkey.net/
// @version      0.5
// @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/*/?tab=achievements
// @exclude      *://steamcommunity.com/id/<username>/stats/*/?tab=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/*
// @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_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// ==/UserScript==

var inline_src = (<><![CDATA[

  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)
  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'

  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/[^/]+"
    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

  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', {})) ->
    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}")
  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
  $update = (k, games1) -> do (games0 = GM_getValue k, {}) ->
    [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)
    $markUpdate k
    GM_setValue k, games1
    setTimeout -> alert "Backloggery interop: added #{added.length} games, removed #{removed.length} games"
  $mergeData = (k, o) -> GM_setValue k, merge(GM_getValue(k), 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")
    when 'humble' then [o.icon or o.image, o.image or o.icon]
    when 'ggate'  then [o.icon, o.image]
  $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]

    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')]
      _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()
      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')]
        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')]
    $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', {}
    $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)}"
    overlayData = null
    $$ = (x) -> -> overlayData = x;  m.redraw()
    m.mount overlay, view: -> overlayData and m '.tooltip',
      m('img', src: overlayData.image, style: "max-width: 548px")
      m('pre', {style: "padding-top:1em; font-weight:bold"}, overlayData.stats)
    $tweak = (e, 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
    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
      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')]
      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

    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.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
    setInterval (-> when_ $find('#switch-platform'), (e) -> $visibility(syncBackloggery, (e.value is 'all') and not search.value)), 100

  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
    $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)

  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)
      setInterval (-> $visibility syncBackloggery, window.location.pathname is '/account/games' and $find('[name=platform][value=""]')?.checked),
                  100

]]></>).toString();
eval( CoffeeScript.compile(inline_src) );