Backloggery interop

Backloggery integration with game library websites

As of 2019-10-20. See the latest version.

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.8.3
// @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/*
// @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/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_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|purchases|keys|coupons)"
    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']
  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-html5", \
                  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 ([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 = if k is 'custom' then id else 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.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 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;  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}
                   fieldset, .anchor {position: relative}   .tooltip {background: rgba(0, 0, 0, 0.8)}
                   .icons {padding: 1ex;  padding-bottom: unset}   .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) -> [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]
      $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.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: -1px;  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 = 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
      $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', {key: k, 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

    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 > .name {flex-grow: 1;  white-space: nowrap;  overflow: hidden;  text-overflow: ellipsis}
                 #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()
      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', {key: k, 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

    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), if k is 'custom' then id else 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 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"
    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 _bl.custom
        $tweak target, ['custom'], merge(_bl.custom, name: ""), [yes, (->''), (->'')], ["custom"], ["Custom", _bl.custom.updated]
      else 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 and $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 and location.pathname is "/home/library")

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