Backloggery interop

Backloggery integration with game library websites

当前为 2019-10-05 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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