Greasy Fork

Backloggery interop

Backloggery integration with game library websites

目前为 2024-03-20 提交的版本。查看 最新版本

// ==UserScript==
// @name         Backloggery interop
// @namespace    http://tampermonkey.net/
// @version      0.11.3
// @description  Backloggery integration with game library websites
// @author       LeXofLeviafan
// @icon         https://backloggery.com/favicon.ico
// @include      *://backloggery.com/*
// @include      *://www.backloggery.com/*
// @include      *://steamcommunity.com/id/*/games*
// @include      *://steamcommunity.com/id/*/stats/*
// @include      *://steamcommunity.com/id/*/gamecards/*
// @include      *://steamcommunity.com/id/*/badges*
// @include      *://steamcommunity.com/stats/*/achievements
// @include      *://steamcommunity.com/stats/*/achievements/*
// @include      *://store.steampowered.com/app/*
// @include      *://steamdb.info/app/*
// @include      *://steamdb.info/calculator/*
// @include      *://astats.astats.nl/astats/User_Games.php?*
// @include      *://gog.com/account
// @include      *://gog.com/*/account
// @include      *://www.gog.com/account
// @include      *://www.gog.com/*/account
// @include      *://www.humblebundle.com/home/*
// @include      *://itch.io/my-collections
// @include      *://*.itch.io/*
// @include      *://www.gamersgate.com/account/*
// @include      *://store.epicgames.com/*
// @include      *://www.dekudeals.com/collection*
// @include      *://www.dekudeals.com/items/*
// @include      *://psnprofiles.com/<username>
// @include      *://psnprofiles.com/*?*
// @include      *://psnprofiles.com/trophies/*
// @exclude      *://psnprofiles.com/<username>
// @require      https://cdnjs.cloudflare.com/ajax/libs/mithril/2.0.4/mithril.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/lib/coffeescript-browser-compiler-legacy/coffeescript.js
// @grant        GM_info
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_addStyle
// ==/UserScript==

var inline_src = String.raw`

  ROMANS = {Ⅰ: 'I', Ⅱ: 'II', Ⅲ: 'III', Ⅳ: 'IV', Ⅴ: 'V', Ⅵ: 'VI', Ⅶ: 'VII', Ⅷ: 'VIII', Ⅸ: 'IX', Ⅹ: 'X', Ⅺ: 'XI', Ⅻ: 'XII', Ⅼ: 'L', Ⅽ: 'C', Ⅾ: 'D', Ⅿ: 'M'}
  roman = RegExp("[#{Object.keys(ROMANS).join('')}]", 'g')

  identity = (x) -> x
  merge = (os...) -> Object.assign {}, os...
  fromPairs = (pairs) -> merge ([k]: v for [k, v] in pairs.filter identity)...
  sortBy = (xs, weight) -> xs.slice().sort (a, b) -> weight(a) - weight(b)
  groupBy = (xs, f) -> xs.reduce ((o, x) -> k = f(x);  (o[k] = o[k] or []).push x;  o), {}
  keymap = (ks, f) -> fromPairs ([k, f k] for k in ks)
  objmap = (o, f) -> fromPairs ([k, f(v, k, o)] for k, v of o)
  objfilter = (o, f) -> fromPairs ([k, v] for k, v of o when f(v, k, o))
  pick = (o, keys...) -> fromPairs ([k, o[k]] for k in keys when k of o)
  method = (o, k, def=->) -> o?[k]?.bind?(o) or def
  setFn = (xs) -> method (new Set xs), 'has'
  last = (l) -> l[l.length - 1]
  when_ = (x, f) -> x and f(x)
  replace = (s, re, pattern) -> s.match(re) and s.replace(re, pattern)
  qstr = (s) -> if not s.includes('?') then "" else s[1 + s.indexOf '?'..]
  query = (s) -> fromPairs (l[1..] for l in qstr(s).split('&').map((s) -> s.match /([^=]+)=(.*)/) when l)
  slugify = (s) -> s.replace(roman, (c) -> ROMANS[c]).toLowerCase().replace(/[.]/g, '').replace(/[^a-z0-9+]+/g, '-').replace(/(^-*|-*$)/g, '')
  capitalize = (s) -> do (z = "#{s}") -> z[...1].toUpperCase() + z[1..]
  pascal = (s) -> slugify(s or "").split('-').map(capitalize).join("")
  strs = (ss...) -> ss.map (s) -> s or ""
  statStr = (o, ks...) -> ("#{capitalize k}: #{o[k]}" for k in ks when o[k] or o[k] is 0).join '\n'
  forever = (f) -> setInterval f, 100
  delay = (f) -> new Promise (resolve) -> setTimeout -> resolve f()
  debounce = (delay, action) -> do (last = null) -> ->
    clearTimeout last
    last = setTimeout action, delay

  PAGE = location.href
  PARAMS = query location.search
  RE =
    backloggeryUpdate:  "backloggery\\.com/update\\.php"
    backloggeryCreate:  "backloggery\\.com/newgame\\.php"
    backloggeryLibrary: "backloggery\\.com/games\\.php"
    steamLibrary:       "steamcommunity\\.com/id/([^/]+)/games($|\\?)"
    steamAchievements:  "steamcommunity\\.com/id/([^/]+)/stats/[^/]+"
    steamAchievements2: "steamcommunity\\.com/stats/[^/]+/achievements"
    steamDetails:       "store\\.steampowered\\.com/app/([^/]+)"
    steamStats:         "astats\\.astats\\.nl/astats/User_Games\\.php"
    steamBadges:        "steamcommunity\\.com/id/([^/]+)/(gamecards|badges)"
    gogLibrary:         "gog\\.com/([^/]+/)?account"
    humbleLibrary:      "humblebundle\\.com/home/(library|purchases|keys|coupons)"
    itchLibrary:        "itch\\.io/my-collections"
    itchDetails:        "[^/.]\\.itch\\.io/[^/]+$"
    ggateLibrary:       "gamersgate\\.com/account/games"
    epicStore:          "epicgames\\.com"
    dekuLibrary:        "dekudeals\\.com/collection($|\\?)"
    dekuDetails:        "dekudeals\\.com/items/"
    psnLibrary:         "psnprofiles\\.com/([^/?]+)/?($|\\?)"
    psnDetails:         "psnprofiles\\.com/trophies/([^/?]+)/([^/?]+)$"
  PSN_ID = (GM_info?.script?.options?.override?.use_includes or []).reduce ((x, s) -> x or s.match(RE.psnLibrary)?[1]), null

  _PSN_HW = {PS3: '3', PS4: '4', PS5: '5', VITA: 'V', VR: 'v'}
  _psnData = (images) -> (id) -> objmap(objfilter(GM_getValue('psn', {}), (x) -> _PSN_HW[id] in x.platforms),
                                        (x, k) -> merge x, psn: yes, image: images[k], url: "https://psnprofiles.com/trophies/#{k}/#{PSN_ID}")
  _dekuData = (info) -> (key) -> objmap(objfilter(GM_getValue('deku', {}), (x) -> x.platform is key), (x, k) -> do (x = merge x, info[x.url.replace(/^\//, "")]) ->
    merge x, url: "https://www.dekudeals.com/items#{x.url}", ...(x[s] and [s]: "https://cdn.dekudeals.com/images#{x[s]}" for s in ['image', 'icon']))
  [EPIC_CDN, EPIC_STORE] = ["https://cdn1.epicgames.com", "https://www.epicgames.com/store/product/"]
  _epicUrls = (x) -> url: x.slug and EPIC_STORE+x.slug,  icon: EPIC_CDN+x.icon,  image: EPIC_CDN+x.image
  DATA = do (STATS = GM_getValue('steam-stats', {}),          PLATFORMS = GM_getValue('steam-platforms', {}),
             psnData = _psnData(GM_getValue 'psn-img', {}),   dekuData = _dekuData(GM_getValue 'deku-info', {}),
             ITCH = objmap(GM_getValue('itch-info', {}), ({worksOn, rating, at, ...meta}) -> ({worksOn, rating, sync: at, meta}))) ->
    steam:  objmap GM_getValue('steam', {}),  (x, k) -> merge(x, url: "https://steamcommunity.com/app/#{k}", achievements: STATS[k] or '?', worksOn: PLATFORMS[k] or 's')
    gog:    objmap GM_getValue('gog',   {}),  (x) -> merge(x, url: "https://gog.com#{x.url}", completed: if x.completed then 'yes' else 'no')
    humble: GM_getValue('humble', {})
    epic:   objmap GM_getValue('epic', {}),   (x) -> merge(x, _epicUrls(x), features: ['online', 'cloud'].filter((k) -> x[k]).join ", ")
    itchio: objmap GM_getValue('itch', {}),   (x, k) -> merge(x, ITCH[k], meta: {Acquired: x.date, ...(ITCH[k]?.meta or {})})
    ggate:  objmap GM_getValue('ggate', {}),  (x, id) -> merge(x, url: "https://gamersgate.com/account/orders/#{id.replace(':', '#')}")
    psvita: psnData('VITA'),   psvr: psnData('VR'),   ps3: psnData('PS3'),   ps4: merge(psnData('PS4'), dekuData('ps4')),
    ps5:    merge(psnData('PS5'), dekuData('ps5')),   switch: dekuData('switch'),   xbo: dekuData('xso'),   xboxsx: dekuData('xboxsx')
  OS = w: ["Windows", 'fa-windows'], l: ["Linux", 'fa-linux'], m: ["MacOS", 'fa-apple'], a: ["Android", 'fa-android'], s: ["Steam", 'fa-steam'], b: ["Web", 'fa-chrome']
  CUSTOM_ICONS = {steam: "fab fa-steam", windows: "fab fa-windows", linux: "fab fa-linux", mac: "fab fa-apple", android: "fab fa-android", \
                  console: "fas fa-gamepad", xbox: "fab fa-xbox", playstation: "fab fa-playstation", web: "fab fa-chrome", \
                  nodejs: "fab fa-node-js", flash: "fab fa-adobe", dice: "fas fa-dice", d20: "fas fa-dice-d20", trophy: "fas fa-trophy"}
  slugs = (o) -> fromPairs sortBy ([slugify(v.name), k] for k, v of o), ([slug, id]) -> (o[id].url or "").length
  USER_OS = do (platform = (navigator.platform or navigator.userAgentData?.platform or "").toLowerCase()) -> switch
    when platform.startsWith "win" then 'windows';  when platform.startsWith "mac" then 'mac';  else 'linux'

  $clear  = (e) -> e.removeChild e.firstChild while e.firstChild;  e
  $append = (parent, children...) -> parent.appendChild e for e in children;  parent
  $before = (neighbour, children...) -> neighbour.parentElement.insertBefore(e, neighbour) for e in children;  neighbour
  $after  = (neighbour, children...) -> $before(neighbour.nextSibling, children...);  neighbour
  $e      = (tag, options, children...) -> $append Object.assign(document.createElement(tag), options), children...
  $get   = (xpath, e=document) -> document.evaluate(xpath, e, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
  $find  = (selector, e=document) -> e.querySelector selector
  $find_ = (selector, e=document) -> Array.from e.querySelectorAll selector
  $hasClass = (e, clss) -> do (l = e.classList) -> l and clss.split(' ').every((s) -> not s or l.contains s)
  $visibility = (e, x) -> e.style.visibility = if x then 'visible' else 'hidden'
  $markUpdate = (k) -> GM_setValue 'updated', merge(GM_getValue('updated'), [k]: +new Date)
  $stop = (f=->) -> (e) -> f e;  e.stopPropagation();  no
  $keepScroll = (e, f) -> do (x = e.scrollTop) -> f();  m.redraw();  e.scrollTop = x
  $query = (url) -> new Promise (resolve, reject) -> do (xhr = new XMLHttpRequest) ->
    xhr.open 'GET', url
    [xhr.onerror, xhr.onload] = [reject, -> resolve JSON.parse xhr.response]
    xhr.send()
  $watcher = (f) -> new MutationObserver (xs) -> xs.forEach (x) -> x.addedNodes.forEach f
  NOISE = fromPairs "a an and as at by from for in into is of on or so the to collection edition remastered ii iii iv v vi vii viii ix x".split(" ").map (s) -> [s, on]
  words = (s) -> slugify(s).split('-').sort().reverse()
  matching = (ss, zs) -> do (res = 0, i = 0, j = 0) ->
    while i < ss.length and j < zs.length
      [s, z] = [ss[i], zs[j]]
      if s is z
        i++;  j++;  res += if s of NOISE or Number(s) then 1.1 else 2
      else if z.startsWith s
        i++;  j++;  res += 1
      else
        if s < z then j++ else i++
    res
  order = (sets, exclude, text, k) -> do (d = DATA[k],  l = words(text),  f = (s) -> not exclude["#{k}##{s}"]) ->
    o = objmap(sets[k] or {}, (ss) -> matching l, ss)
    Object.keys(sets[k] or {}).sort (a, b) -> f(b)-f(a) or o[b]-o[a] or d[a].name.localeCompare d[b].name
  $addChanges = (newChanges) -> do (changes = GM_getValue 'changes', []) ->
    oldChanges = new Set changes
    GM_setValue 'changes', [changes..., (id for id in newChanges when not oldChanges.has id)...]
  WATCH_FIELDS = "name worksOn completed achievements platforms status trophies".split ' '
  WATCH_META = {'steam-stats': 'steam', 'steam-platforms': 'steam'}
  WATCH_LIBRARY = {}
  $update = (library, games1) -> do (library_ = WATCH_LIBRARY[library] or library,  games0 = GM_getValue library, {}) ->
    [ids1, ids0] = [games1, games0].map Object.keys
    removed = (id for id in ids0 when id not of games1)
    added   = (id for id in ids1 when id not of games0)
    updated = (id for id in ids0 when id of games1 and WATCH_FIELDS.some (k) -> games0[id][k] isnt games1[id][k])
    $markUpdate library
    $addChanges ("#{library_}##{id}" for id in [removed..., updated...])
    GM_setValue library, games1
    setTimeout -> alert "Backloggery interop: added #{added.length} games, removed #{removed.length} games\n(#{updated.length} of #{ids1.length} games changed)"
  $mergeData = (k, o) -> do (library = WATCH_META[k],  old = GM_getValue k, {}) ->
    library and $addChanges ("#{library}##{id}" for id in Object.keys o when id of old and old[id] isnt o[id])
    GM_setValue k, merge(old, o)
  $logo = (k, id) -> do (o = if k is 'custom' then id else DATA[k][id]) -> switch k
    when 'steam'  then ["https://cdn.akamai.steamstatic.com/steam/apps/#{id}/capsule_184x69.jpg", "https://steamcdn-a.akamaihd.net/steam/apps/#{id}/header.jpg"]
    when 'gog'    then [196, 392].map((x) -> "https:#{o.image}_#{x}.jpg")
    else o and [o.icon or o.image, o.image or o.icon]
  if ["backloggery.com", "gog.com", "humblebundle.com", "itch.io", "epicgames.com", "psnprofiles.com"].some((s) -> ("."+location.host).endsWith("."+s))
    $append document.head, $e('link', rel: 'stylesheet', href: "https://use.fontawesome.com/releases/v5.7.0/css/all.css")
  GM_addStyle "#loader {position: fixed;  top: 50%;  left: 50%;  z-index: 10000;  transform: translate(-50%, -50%);
                        font-size: 300px;  text-shadow: -1px 0 grey, 0 1px grey, 1px 0 grey, 0 -1px grey}"
  GM_addStyle "@-webkit-keyframes rotation {from {-webkit-transform:rotate(0deg)}
                                            to {-webkit-transform:rotate(360deg)}}
               @keyframes rotation {from {transform:rotate(0deg) translate(-50%, -50%);  -webkit-transform:rotate(0deg)}
                                    to {transform:rotate(360deg) translate(-50%, -50%);  -webkit-transform:rotate(360deg)}}"
  GM_addStyle ".rotating {animation: rotation 2s linear infinite}"
  LOGO = ".logo {height: 0;  width: 0;  display: flex;  flex-direction: row-reverse}
          .logo img {border: 1px solid darkorchid;  background: #1b222f}"


  if location.host.match "^(www\\.)?backloggery\\.com$"
    for e in $find_("a[href$='console=PC'], #intro > .npgame > :nth-child(3)")
      e.innerText = "Windows" if e.innerHTML is "PC"

  BL_ID = location.host is "backloggery.com" and $get("//*[@id='menu']//a[text()='My Backlog']")?.href.match("/([^/]+)$")?[1]?.toLowerCase()
  STEAM_ID = location.host is "steamcommunity.com" and $find("#global_header a.username")?.href.match("/id/([^/]+)/home/$")?[1]

  if PAGE.match(RE.backloggeryUpdate) and PARAMS.user?.toLowerCase() is BL_ID

    SETS = objmap DATA, (o) -> objmap(o, (x) -> words x.name)
    legend         = $get '//*[@id="content-wide"]/section/form/fieldset[1]/legend'
    systemDropdown = $get '//*[@id="content-wide"]/section/form/fieldset[1]/div[2]'
    delBtns        = $get '//*[@id="content-wide"]/section/form/div[2]/div'
    status         = $get '//*[@id="content-wide"]/section/form/fieldset[2]/div[1]'
    rating         = $get '//*[@id="content-wide"]/section/form/fieldset[3]/div[1]/b[text()="Rating"]'
    _system        = when_ systemDropdown, (e) -> $find('select', e)
    swap = -> do ([a, b] = [_system, $find '#detail2 select']) -> [a.value, b.value] = [b.value, a.value]
    do (_bl = GM_getValue('backlog', {})[PARAMS.gameid]) ->
      k = Object.keys(DATA).find (k) -> _bl?[k+'Id']
      changeId = k and "#{k}##{_bl[k+'Id']}"
      GM_setValue 'changes', (id for id in GM_getValue('changes', []) when id isnt changeId)

    unless legend and systemDropdown and delBtns # deleted/doesn't exist
      backlog = GM_getValue('backlog', {})
      if PARAMS.gameid in backlog
        delete backlog[PARAMS.gameid]
        GM_setValue('backlog', backlog)
    else
      for es in $find("[name=#{s}console]").children for s in ['', 'orig_']
        e.innerText = "Windows" for e in es when e.value is "PC"
      $append systemDropdown, $e('tt', innerText: "⇄", onclick: swap, style: "cursor: pointer;  padding-left: 8px;  font-size: large")
      $before delBtns, $e('input', type: 'submit', name: 'submit2', className: 'greengray', value: "Stealth Save ⇄", onclick: swap)
      $after($find('.info.help', detail5), $e('a', target: '_blank', id: 'achievements', className: 'hint', style: "padding: 3.5ex"))
      $after($find('.info.help', status), $e('span', id: 'completed', className: 'hint', style: "padding: 1ex"))
      $after($find('input[name=note]'), $e('div', id: 'notes', className: 'hint'))
      $after(rating, $e('span', id: 'rated', className: 'hint', style: "padding: 0 1ex"))
      GM_addStyle ".overlay {position: relative;  max-height: 0;  top: -40px;  z-index: 2;  margin-right: 10px;  display: flex;  flex-direction: row-reverse}
                   .overlay input {height: 20px;  background: #4b4b4b;  color: white;  width: 500px;  border: 1px solid black;  padding-left: 1ex;  margin-bottom: 0}
                   .overlay .options {display: flex;  flex-direction: column;  max-height: 500px;  overflow-y: auto;  background: grey}
                   .overlay button {height: 28px;  background: #4b4b4b;  color: white;  border-radius: 10px 8px 8px 10px;  padding: 5px}
                   .overlay .option {white-space: nowrap;  display: flex;  margin: .5px}   .overlay .trash {cursor: pointer}
                   .overlay * {flex-shrink: 0}  .overlay button b {flex: 1;  padding-left: 1ex;  text-align: left;  overflow: hidden;  text-overflow: ellipsis}
                   .oslist {display: flex;  position: absolute;  width: 505px;  padding-top: 7.5px;  pointer-events: none}   .oslist.shift {padding-right: 20px}
                   .os {padding-left: .75ex;  font-size: 20px;  color: white}   .oslist .action {padding-left: 1ex;  pointer-events: all}
                   .action {color: white;  cursor: pointer}   .anchor .action {position: absolute;  top: 10px;  right: 7.5px}
                   .iconlist {display: flex;  position: absolute;  width: 505px;  padding-top: 7.5px;  pointer-events: none;  color: white !important}
                   .hint {font-weight: bold;  font-style: italic;  white-space: pre-wrap}   .hint[href] {color: royalblue}
                   fieldset, .anchor {position: relative}   .tooltip {background: rgba(0, 0, 0, 0.8)}
                   .icons {padding-top: 1ex;  text-align: center}   .icons .btn {margin: .25em}  .btn.selected {border-color: white}
                   .btn {background: #4b4b4b;  color: white;  border: 1px solid black;  cursor: pointer;  font-size: 20px;  padding: 2px;  border-radius: 5px}
                   .btn.fa-eye {margin-left: 1.25px}   .done, .preview {display: block;  margin: 1ex auto}   .done {width: 90%;  cursor: pointer}
                   #{LOGO} .logo img {height: 100px}   .preview {border: 1px solid darkorchid;  max-width: calc(100% - 2ex)}"
      gameName = $find '[name=name]'
      _bl = GM_getValue('backlog')?[PARAMS.gameid] or {}
      excluded = GM_getValue('exclude', {})
      data = (k=state.list) -> DATA[k]
      id = (s=state.list) -> "#{s}Id"
      id$ = (s=state.list) -> _bl[ id(s) ]
      eId$ = (k=id$(s), s=state.list) -> "#{s}##{k}"
      data$ = (s=state.list) -> data(s)?[ id$(s) ]
      title = (k=state.list) -> data$(k)?.name or gameName.value
      _icons = -> (_bl.custom?.icons or "").split ' '
      _order = (s=state.title, k=state.list) -> order(SETS, excluded, s, k)
      state = do (list = (_system.value or '').toLowerCase()) ->
        list:   list
        title:  title list
        active: no
        order:  _order(title(list), list)
      when_ data$(), (o) -> do (_rating = o.rating and "[#{o.rating}/5]") ->
        [achievements.innerText, notes.innerText, rated.innerText, completed.innerText] = strs(o.achievements, o.notes, _rating, statStr(o, 'completed') or o.status)
        achievements.href = "https://steamcommunity.com/stats/#{id$()}/achievements" if _system.value is 'Steam'
      _setBl = (o) -> $mergeData 'backlog', [PARAMS.gameid]: Object.assign(_bl, o)
      _delBl = (...ks) -> delete _bl[k] for k in ks;  _setBl()
      _setExcl = (k, x) ->
        excluded[ eId$(k) ] = x
        $mergeData('exclude', [eId$(k)]: x)
        state.order = _order()
      if id$() and not data$() then _delBl id(), 'ignore'
      section2 = $find_('fieldset')[1]
      $append section2, $e('div', id: 'ignore', style: "position: absolute;  top: -25px;  left: 110px")
      m.mount ignore, view: -> id$() and [
        do (x = !_bl.ignore) -> m('i.btn', class: "far fa-eye#{if x then '' else '-slash'}", title: (if x then "Watch" else "Ignore"), onclick: -> _setBl(ignore: x))
      ]
      section1 = $find_('fieldset')[0]
      $before section1, $e('div', id: 'logo', className: 'logo')
      m.mount logo, view: -> switch
        when _bl.custom then m('a', {target: '_blank', href: _bl.custom.url}, m('img', src: $logo('custom', _bl.custom)[1]))
        when id$()      then m('a', {target: '_blank', href: data$().url}, m('img', src: $logo(state.list, id$())[1]))
      $append section1, $e('div', id: 'custom', style: "position: absolute;  top: -25px;  left: 170px")
      toggleCustom = (x) -> ->
        state.active = no
        if _bl.custom then _delBl('custom') else _delBl(id(), 'ignore');  _setBl custom: {}
      m.mount custom, view: -> do (x = _bl.custom) -> m('i.btn', class: "fa fa-#{if x then 'edit' else 'list'}", title: (if x then "Custom" else "Listed"), onclick: toggleCustom x)
      preview = null
      document.addEventListener 'keydown', (e) -> if e.key is 'Escape'
        _delBl id(), 'ignore'
        Object.assign(state, active: no, title: title(), order: _order title())
        preview = achievements.innerText = notes.innerText = rated.innerText = completed.innerText = ""
        achievements.removeAttribute('href')
        m.redraw()
      $reset = -> do (list = (_system.value or '').toLowerCase()) -> if list isnt state.list
        Object.assign(state, {list}, active: no, title: title(list), order: _order(title(list), list))
        m.redraw()
      $$ = (k) -> ->
        Object.assign(state, active: no, title: data()[k].name)
        state.order = _order()
        _setBl([id()]: k)
        when_ data$(), (o) -> do (_rating = o.rating and "[#{o.rating}/5]") ->
          [achievements.innerText, notes.innerText, rated.innerText, completed.innerText] = strs(o.achievements, o.notes, _rating, statStr(o, 'completed') or o.status)
          achievements.href = "https://steamcommunity.com/stats/#{id$()}/achievements" if _system.value is 'Steam'
        no
      $custom = -> _setBl custom: Object.assign _bl.custom, updated: +new Date
      toggleIcon = (s) -> do (icons = _icons()) ->
        _bl.custom.icons = (if s not in icons then [icons..., s] else icons.filter (x) -> x isnt s).join(' ').trim()
        $custom()
      gameName.onchange = _system.onchange = $reset
      overlay = $e('div', style: "display: flex;  flex-direction: column;  width: calc(505px + 1ex);  position: relative")
      $after(legend, $e('div', {className: 'overlay'}, overlay))
      worksOn = (o) -> (do ([s, cls] = OS[c]) -> m("i.fab.#{cls}.os", title: s)) for c in (o.worksOn or '').split ''
      m.mount overlay, view: -> do (x = _bl.custom,  o = data()) -> switch
        when x then [
          m('input', type: 'url', value: x.url or "", title: "URL", onclick: (-> state.active = yes), oninput: (-> x.url = @value), onchange: $custom)
           m '.oslist', {class: x.url and 'shift'}, m('div', style: "flex: 1"),
             _icons().map((s) -> m "i.os", class: CUSTOM_ICONS[s], title: s)
             x.url and m('a.action', title: "Test", target: '_blank', href: x.url, m 'i.fas.fa-external-link-alt')
          state.active and m '.tooltip',
            m '.anchor', m('input', type: 'url', value: x.icon or "", title: "Icon URL", oninput: (-> x.icon = @value), onchange: $custom),
              x.icon  and m 'i.action.fas.fa-eye', title: "Preview", onmouseenter: (-> preview = x.icon),  onmouseleave: (-> preview = null)
            m '.anchor', m('input', type: 'url', value: x.image or "", title: "Poster URL", oninput: (-> x.image = @value), onchange: $custom),
              x.image and m 'i.action.fas.fa-eye', title: "Preview", onmouseenter: (-> preview = x.image), onmouseleave: (-> preview = null)
            m '.anchor.icons', Object.keys(CUSTOM_ICONS).map (s) ->
              m 'i.btn', class: CUSTOM_ICONS[s] + (if s in _icons() then " selected" else ""), title: s, onclick: (-> toggleIcon s)
            m '.anchor', m 'button.done', onclick: (-> [preview, state.active] = [null, no];  $custom();  no), "Done"
            preview and m '.anchor', m 'img.preview', src: preview
        ]
        when o then [
          m('input', value: state.title, title: id$() or "", onclick: (-> state.active = yes), \
                     oninput: -> _delBl id(), 'ignore';  state.title = @value;  state.order = _order())
          id$() and m('.oslist', m('div', style: "flex: 1"), worksOn o[ id$() ])
          state.active and m('.options', state.order.map (k) -> do (x = not excluded[ eId$(k) ]) -> [
            m('button.option', {disabled: not x, onclick: $$(k), title: o[k].name}
              m('i.trash', class: "fas fa-trash#{if x then '' else '-restore'}-alt", title: (if x then "Exclude" else "Restore"), \
                               onclick: $stop(-> _setExcl k, x))
              m('b', o[k].name), worksOn o[k])
          ])
        ]

  else if PAGE.match(RE.backloggeryCreate) and PARAMS.user?.toLowerCase() is BL_ID

    BACKLOG = GM_getValue 'backlog', {}
    MATCHED = objmap DATA, (_, s) -> setFn (x["#{s}Id"] for k, x of BACKLOG when x["#{s}Id"])
    UNMATCHED = objmap DATA, (o, s) -> pick(o, (k for k of o when not MATCHED[s](k))...)
    SETS = objmap UNMATCHED, (o) -> objmap(o, (x) -> words x.name)
    excluded = GM_getValue 'exclude', {}
    GM_addStyle ".os {padding-left: 1ex;  line-height: 0;  font-size: 16px}   .hint {font-weight: bold;  font-style: italic;  white-space: pre-wrap}
                 #names {position: absolute;  max-height: 500px;  width: 730px;  top: 50px;  left: 9px;  z-index: 2;
                         display: flex;  flex-direction: column;  overflow-y: auto;  background: grey}
                 #names > button {flex-shrink: 0;  height: 24px;  border-radius: 10px;  display: flex;  flex-direction: row;
                                  margin-top: 1px;  text-align: left;  padding-left: 1ex;}
                 #names > button > .name {flex-grow: 1;  white-space: nowrap;  overflow: hidden;  text-overflow: ellipsis}
                 #names > button > i {padding-right: .5em;  color: black;  cursor: pointer}
                 #names > button > * {line-height: 1.9}
                 #names > button:hover {opacity: .8}   #names > button:has(> i:hover) {background: lightpink}
                 #{LOGO} .logo img {height: 100px}   .hint[href], #oslist[href] {color: royalblue}"
    for es in $find("[name=#{s}console]").children for s in ['', 'orig_']
      e.innerText = "Windows" for e in es when e.value is "PC"
    status         = $get '//*[@id="content-wide"]/section/form/fieldset[2]/div[1]'
    rating         = $get '//*[@id="content-wide"]/section/form/fieldset[3]/div[1]/b[text()="Rating"]'
    [name, system] = ['name', 'console'].map (s) -> $find "[name=#{s}]"
    name.autocomplete = 'off'
    eId = (k, s=system.value) -> "#{s.toLowerCase()}##{k}"
    for e in system.children
      when_(UNMATCHED[ e.value.toLowerCase() ], (o) -> e.innerText += " (+#{(k for k of o when not excluded[ eId(k, e.value) ]).length})")
    $after($find('.info.help', detail2), $e('a', target: '_blank', id: 'oslist', style: "padding-left: 1ex"))
    $after($find('.info.help', detail5), $e('a', target: '_blank', id: 'achievements', className: 'hint', style: "padding: 3.5ex"))
    $after($find('.info.help', status), $e('span', id: 'completed', className: 'hint', style: "padding: 1ex"))
    $after($find('input[name=note]'), $e('div', id: 'notes', className: 'hint'))
    $after(rating, $e('span', id: 'rated', className: 'hint', style: "padding: 0 1ex"))
    $find_('fieldset')[0].style.position = 'relative'
    $after($find('[name=name]'), $e('div', id: 'names'))
    data = (k=system.value) -> UNMATCHED[ k.toLowerCase() ] or {};
    _order = (text=name.value, k=system.value) -> order(SETS, excluded, text, k.toLowerCase())
    state = id: null,  active: no,  order: _order()
    _setExcl = (k, x) ->
      excluded[ eId(k) ] = x
      $mergeData('exclude', [eId(k)]: x)
      state.order = _order()
    _redraw = (id=state.id, o=data()[id]) -> do (_rating = o?.rating and "[#{o.rating}/5]") ->
      $clear oslist
      o and $append oslist, ((do ([s, cls] = OS[c]) -> $e('i', className: "fab #{cls} os", title: s)) for c in (o.worksOn or '').split(''))...
      [achievements.innerText, notes.innerText, rated.innerText, completed.innerText] = strs(o?.achievements, o?.notes, _rating, statStr(o or {}, 'completed') or o?.status)
      if not o or system.value isnt 'Steam' then x.removeAttribute('href') for x in [achievements, oslist]
      else [achievements.href, oslist.href] = ["https://steamcommunity.com/stats/#{id}/achievements", "https://store.steampowered.com/app/#{id}"]
    $before($find_('fieldset')[0], $e('div', id: 'logo', className: 'logo'))
    m.mount logo, view: -> do (k = system.value.toLowerCase(),  o = data()[state.id]) ->
      o and m('a', {target: '_blank', href: o.url}, m('img', src: $logo(k, state.id)[1]))
    $upd = (id) ->
      _redraw id
      when_ data()[id], (o) -> name.value = o.name
      Object.assign state, {id}, active: not id, order: _order()
      no
    name.oninput = name.onclick = -> $upd();  m.redraw()
    system.onchange = ->
      Object.assign state, id: null, order: _order()
      _redraw();  m.redraw()
    document.addEventListener 'keydown', (e) -> if e.key is 'Escape'
      Object.assign state, id: null, active: no
      _redraw();  m.redraw()
    m.mount names, view: -> state.active and
      state.order.map (k) -> do (x = not excluded[eId(k)]) ->
        m 'button', {disabled: not x, onclick: -> $upd(k)}, m('span.name', {title: data()[k].name}, data()[k].name),
          m 'i.fas', class: "fa-trash#{if x then '' else '-restore'}-alt", title: (if x then "Exclude" else "Restore"), \
                     onclick: $stop(-> $keepScroll names, -> _setExcl(k, x))

  else if PAGE.match(RE.backloggeryLibrary) and PARAMS.user?.toLowerCase() is BL_ID

    INCOMPLETE = ["(-)", "(u)", "(U)"]
    SLUGS = objmap DATA, slugs
    Object.entries({steam: "Steam", gog: "GOG"}).forEach ([k, name]) ->
      [n, m] = (Object.keys(o[k]).length for o in [DATA, SLUGS])
      unless n is m
        groups = groupBy Object.keys(DATA[k]), (id) -> slugify DATA[k][id].name
        data = (slug, ids, main=SLUGS[k][slug]) -> [main, ...ids.filter (id) -> id isnt main].map (id) -> {id, ...DATA[k][id]}
        duplicates = Object.entries(groups).filter(([slug, ids]) -> ids.length > 1).map ([slug, ids]) -> [slug, data(slug, ids)]
        console.warn "#{name} names have #{n-m} collisions", fromPairs duplicates
    UPD = GM_getValue 'updated', {}
    LIBRARIES = Object.keys DATA
    STORED_CHANGES = GM_getValue('changes', [])
    CHANGES = do (backlog = GM_getValue('backlog', {}),  changes = new Set STORED_CHANGES) ->
      objfilter backlog, (x, k) -> LIBRARIES.some (s) -> changes.has "#{s}##{x[s+'Id']}"
    CHANGED = Object.keys(CHANGES).sort (a, b) -> CHANGES[a].name.localeCompare CHANGES[b].name
    $clearUpdates = -> if confirm("Clear stored changelog?") then GM_deleteValue('changes'); STORED_CHANGES = []
    $s = (e) -> e.innerText.trim()
    info = (e) -> $find '.gamerow', e
    name = (e) -> $find 'b', e
    id = (e) -> query($find('a', e).href).gameid
    $achievements = (e) -> $find '.info span', info e
    $completion = (e) -> $find 'img', $find_('h2 a', e)[1]
    $type = (s, e) -> $s(info e).match RegExp("\\b#{s}\\b", 'i')
    $slug = (k, e) -> SLUGS[k][ slugify($s name e) ]
    overlay = $e('div', style: "z-index:2; pointer-events:none; position:fixed; top:0; left:0; width:100%; height:100%; display:flex")
    $append document.body, overlay
    GM_addStyle "#{LOGO}  .logo img {max-height: 62px}  .logo.steam img {max-height: 67px}  .logo.gog img {max-height: 64px}
                 .os {padding-left: .75ex;  line-height: 0 !important;  font-size: 20px;  position: relative;  top: 2.5px}
                 .os {font-weight: 400}  .os.fa-gamepad, .os.fa-dice, .os.fa-dice-d20, .os.fa-trophy {font-weight: 900}
                 section.gamebox.processed .logo img {max-height: 64px}
                 .tooltip {margin: auto;  align-items: center;  display: flex;  flex-direction: column;  max-height: 90%;
                           background: rgba(0, 0, 0, 0.8);  padding: 2em;  transform: translateZ(0) translateX(-99px)}
                 .tooltip > * {max-width: 548px}  .tooltip > div {padding-top: 1em;  font-weight: bold}
                 .tooltip pre {white-space: pre-wrap;  text-indent: -1em;  padding-left: 1em}
                 .changelist {position: absolute;  top: 0;  right: 0;  pointer-events: all;  background: rgba(0, 0, 0, 0.8);
                              max-width: 33%;  max-height: 50%;  display: flex;  flex-direction: column}
                 .changelist.collapsed {opacity: .5}   .changelist:hover {opacity: 1}
                 .changelist .items {overflow-y: auto}   .changelist .items > .item {margin: 1em}
                 .changelist > h1 {cursor: pointer;  position: relative;  padding: 1em;  padding-right: 3em}
                 .changelist > h1 > .right {position: absolute;  right: 0;  margin-right: 1em}
                 #side-loader {position: fixed;  top: 1ex;  left: 1ex;  z-index: 10000;  font-size: 100px}"
    changeListCollapsed = no
    overlayData = null
    $$ = (x) -> -> overlayData = x;  m.redraw()
    m.mount overlay, view: -> switch
      when overlayData        then m '.tooltip',
        m('img', src: overlayData.image, style: "overflow: hidden")  # this forces the image to scale down to fit the box… CSS, amirite?
        m 'div', overlayData.stats?.map (s) -> m('pre', s)
      when STORED_CHANGES.length > 0 then m '.changelist', {class: if changeListCollapsed then 'collapsed' else ""},
        m 'h1', {onclick: -> changeListCollapsed = not changeListCollapsed}, "Unseen changes (#{CHANGED.length}) ",
          m 'span.right', "[#{if changeListCollapsed then '+' else '–'}]"
        unless changeListCollapsed then m '.items',
          CHANGED.map (k) -> m '.item', m 'a', {href: "https://www.backloggery.com/update.php?user=#{PARAMS.user}&gameid=#{k}"},
                                          CHANGES[k].name, (" [#{s}]" for s in LIBRARIES when CHANGES[k]["#{s}Id"])
          if CHANGED.length is 0 then m 'input[type=submit].item', {onclick: $clearUpdates, value: "Clear changelog from storage"}
    $tweak = (e, [k, k_=k], id, [ignore, markParam, markCond], [append, appendFmt=identity], [stats, statsUpdated]) ->
      [[icon, image], x] = [$logo(k, id), if k is 'custom' then id else DATA[k][id]]
      _data = {image,  stats: ((stats and "#{stats}\n" or "") + "Synced: #{new Date(statsUpdated or UPD[k_])}").split '\n'}
      e.style.background = if ignore then 'darkgrey' else unless markCond(markParam e) then '' else 'lightcoral'
      name(e).title = "#{x.name}\nSynced: #{new Date if k is 'custom' then x.updated else UPD[k_]}"
      name(e).innerHTML += unless append then '' else " [#{appendFmt append}]"
      (do ([s, cls] = OS[c]) -> $append name(e), $e('i', className: "fab #{cls} os", title: s)) for c in (x.worksOn||'').split('')
      $append name(e), $e('i', className: "#{CUSTOM_ICONS[s]} os", title: s) for s in (x.icons or "").split(' ') when s if k is 'custom'
      $before e, $e('div', {className: "logo #{k}", onmouseleave: $$(), onmouseenter: $$ _data},
                    $e('a', merge(target: '_blank', x.url and href: x.url), $e('img', src: icon)))
    _renameWindows = (s) -> replace(s, /^PC( \(.*\))?$/, "Windows$1") or replace(s, /^(.*)\(PC\)$/, "$1(Windows)") or s
    e.innerText = _renameWindows e.innerText for e in $find_ "aside .sysbox"
    _platformTitleUpdater = $watcher (e) -> e.innerText = _renameWindows e.innerText if $hasClass(e, "system title")
    _platformTitleUpdater.observe output1, childList: yes
    $append document.body, _loader = $e('span', {id: 'side-loader', style: "display: none"}, $e('i', className: "fas fa-cog rotating"))
    _delay = (f) -> _loader.style = "";  delay -> f();  _loader.style = "display: none"
    _queue = Promise.resolve()
    _gameboxEnhancer = $watcher (game) -> if $hasClass(game, 'gamebox') and info game then _queue = _queue.then -> _delay ->
      backlog = GM_getValue 'backlog', {}
      _id = id game
      _bl = backlog[_id] = Object.assign(backlog[_id] or {}, name: $s name game)
      $syncId = (k) -> unless _bl.custom then _bl["#{k}Id"] = _bl["#{k}Id"] or $slug(k, game);  DATA[k][ _bl["#{k}Id"] ]
      _type = $find 'b', info(game)
      _type.innerText = _renameWindows _type.innerText
      _deku = ['ps4', 'ps5', 'switch', 'xso', 'xboxsx'].find((s) -> $type(s, game) and (s not in ['ps4', 'ps5'] or not $syncId(s)?.psn))
      _psn = ['ps3', 'ps4', 'ps5', 'psvita', 'psvr'].find((s) -> $type(s, game) and (s not in ['ps4', 'ps5'] or $syncId(s)?.psn))
      if _bl.custom
        $tweak game, ['custom'], merge(_bl.custom, name: ""), [yes, (->''), (->'')], ["custom"], ["Custom", _bl.custom.updated]
      else if $type 'steam', game
        data = $syncId 'steam'
        stats = data?.achievements?.replace(" (?)", "")  # some games are marked as 0 / 0 in library but actually have achievements
        _markCond = (e) -> stats is '?' or (not e and stats isnt "0 / 0") or (e and not e.innerText.startsWith "Achievements: #{stats} (")
        data and $tweak game, ['steam'], _bl.steamId, [_bl.ignore, $achievements, _markCond],
                        [data.hours, (s) -> "#{s}h"], [statStr(data, 'achievements'), UPD['steam-stats']]
      else if $type 'gog', game
        data = $syncId 'gog'
        completed = data?.completed is 'yes'
        data and $tweak game, ['gog'], _bl.gogId, [_bl.ignore, $completion, (e) -> completed is INCOMPLETE.includes e.alt],
                        [data.rating, (n) -> "#{n}/5"], [statStr(data, 'completed', 'category')]
      else if $type 'humble', game
        data = $syncId 'humble'
        data and $tweak game, ['humble'], _bl.humbleId, [_bl.ignore, (->''), (->'')], [], [statStr(data, 'developer', 'publisher')]
      else if $type 'epic', game
        data = $syncId 'epic'
        data and $tweak game, ['epic'], _bl.epicId, [_bl.ignore, (->''), (->'')], [], [statStr(data, 'developer', 'features')]
      else if $type 'itchio', game
        data = $syncId 'itchio'
        meta = data and objmap(data.meta, (x, k) -> unless k in ["Acquired", "Updated", "Published", "Release date"] then x else new Date x)
        data and $tweak game, ['itchio', 'itch'], _bl.itchioId, [_bl.ignore, (->''), (->'')],
                        [data.rating, (n) -> "#{n}/5"], [statStr(meta, ...Object.keys meta), data.sync]
      else if $type 'ggate', game
        data = $syncId 'ggate'
        data and $tweak game, ['ggate'], _bl.ggateId, [_bl.ignore, (->''), (->'')], [], []
      else if _deku
        data = $syncId _deku
        status = {"Completed": ["(B)", "(C)", "(M)"], "Currently playing": ["(U)", "(B)"],\
                  "Abandoned": ["(U)", "(B)"],        "Want to play": ["(u)"]}[data?.status]
        append = [data?.physical and "Physical", data?.dlc and "DLC", data?.rating and "#{data.rating}/5"]
        [hltb, notes] = [data?.time, data?.notes].map (s) -> (s and "\n#{s}") or ""
        data and $tweak game, [_deku, 'deku'], _bl["#{_deku}Id"], [_bl.ignore, $completion, (e) -> status and not status.includes e.alt],
                        [data.rating or data.physical, -> append.filter(identity).join "] ["],
                        [statStr(data, 'status', 'size', 'genre', 'released', 'metacritic', 'openCritic') + hltb + notes]
      else if _psn
        data = $syncId _psn
        stats = data?.achievements
        _markCond = (e) -> (not e and stats isnt "0 / 0") or (e and not e.innerText.startsWith "Achievements: #{stats} (")
        data and $tweak game, [_psn, 'psn'], _bl["#{_psn}Id"], [_bl.ignore, $achievements, _markCond],
                        [data.rank, (s) -> "#{s} rank"], [statStr(data, 'achievements', 'status', 'trophies', 'progress')]
      GM_setValue 'backlog', backlog
    _gameboxEnhancer.observe output1, childList: yes, subtree: yes

  else if when_(PAGE.match(RE.steamLibrary), (m) -> m[1] is STEAM_ID)

    {rgGames, achievement_progress} = JSON.parse gameslist_config.getAttribute 'data-profile-gameslist'
    $update 'steam', fromPairs ([o.appid, {name: o.name, hours: o.hours_forever}] for o in rgGames)
    stats = GM_getValue 'steam-stats', {}
    $markUpdate 'steam-stats'
    $mergeData 'steam-stats', fromPairs(for o in achievement_progress
      old = (stats[o.appid] or "0 / 0").replace(" (?)", "")            # Steam lists status for some games as 0/0 incorrectly
      [o.appid, (if o.total is 0 and old isnt "0 / 0" then "#{old} (?)" else "#{o.unlocked} / #{o.total}")])

  else if when_(PAGE.match(RE.steamAchievements), (m) -> m[1] is STEAM_ID)  # personal

    when_ $find('#topSummaryAchievements'), (e) -> do (id = $find('.gameLogo a').href.match(/\d+$/)[0]) ->
      $mergeData('steam-stats', [id]: e.innerText.match(/(\d+) of (\d+)/)[1..].join(" / "))

  else if PAGE.match RE.steamAchievements2 # global

    when_ $find('#compareAvatar') and $find('#headerContentLeft'), (e) -> do (id = $find('.gameLogo a').href.match(/\d+$/)[0]) ->
      $mergeData('steam-stats', [id]: e.innerText.match(/\d+ \/ \d+/)[0])

  else if PAGE.match RE.steamDetails

    ID = PAGE.match(RE.steamDetails)[1]
    if $find '.game_area_already_owned'
      platforms = $find_('.platform_img', $find '.game_area_purchase_game')
      worksOn = (s[0] for s in ['win', 'linux', 'mac'] when platforms.some (e) -> $hasClass(e, s)).join('')
      worksOn and $mergeData('steam-platforms', [ID]: worksOn)

  else if PAGE.match RE.steamBadges # highlighting progress

    GM_addStyle ".foil {box-shadow: white 0 0 2em}  .badge-exp, .card-name.excess {color:lime}
                 .level0 {color:violet}  .level1 {color:pink}  .card-name.level1 {color:red}  .level2 {color:orange}
                 .level3 {color:yellow}  .level4 {color:yellowgreen}  .card-name.level4 {color:olive}
                 .level5, .foil .level1 {color:limegreen}  .card-name.level5, .foil .card-name.level1 {color:green}"
    $find_(".badge_row_inner").forEach (panel) ->
      foil = $find(".badge_title", panel)?.innerText.trim().endsWith " Foil Badge"
      exp = $find(".badge_info_title, .badge_empty_name", panel)?.nextElementSibling
      level = Number exp.innerText.match("Level ([0-9]+)")?[1] or 0
      levels = fromPairs (if foil then [0, 1] else [0, 1, 2, 3, 4, 5]).map (n, i) -> ["(#{n - level})", "level#{i}"]

      foil and panel.classList.add('foil')
      exp.classList.add('badge-exp', "level#{level}")
      $find_(".badge_card_set_title", panel).forEach (cardTitle) ->
        amount = $find(".badge_card_set_text_qty", cardTitle)?.innerText or "(0)"
        cardTitle.classList.add('card-name', levels[amount] or 'excess')
        cardTitle.parentNode.title = cardTitle.innerText.split('\n').reverse().join('\n')

  else if PAGE.match RE.steamStats

    _achievements = (ss) -> (s.match(/\d+/)?[0] or s for s in ss).join(" / ")
    stats = if PARAMS.DisplayType isnt '2' then do (_table = $find '.tablesorter') ->  # list
                _header = $find_ 'th', $find('thead tr', _table)
                _body = ($find_('td', e) for e in $find_ 'tr', $find('tbody', _table))
                [_name$, _total$, _my$] = (_header.findIndex((e) -> e.innerText is s) for s in ["Name", "Total\nAch.", "Gained\nAch."])
                _body.map (l) -> [query($find("a[href^='Steam_Game_Info.php']", l[_name$]).href).AppID,
                                  _achievements(e.innerText for e in [l[_my$], l[_total$]])]
              else do (_body = $get '/html/body/center/center/center/center') ->       # table
                _table = Array.from(_body.children).find((x) -> x.tagName is 'TABLE' and not x.classList.contains 'Pager')
                _ids = (query(e.href).AppID for e in $find_('a', _table))
                [_ids[i], _achievements( last($find_ 'p', e).innerText.match(/Achievements: (.*) of (.*)/)[1..] )] for e, i in $find_('table', _table)
    throw "Invalid update" if stats.length > 0 and not stats[0][0]?	# ensuring that next layout change won't break updater
    $markUpdate 'steam-stats'
    $mergeData 'steam-stats', fromPairs stats
    alert "Game library interop: updated #{stats.length} games"

  else if PAGE.match RE.gogLibrary

    queryPage = (page=0) -> $query "/account/getFilteredProducts?mediaType=1&page=#{page+1}"
    worksOn = (o) -> o and worksOn: (k[0].toLowerCase() for k, v of o when v).join('')
    scrape = -> queryPage().then (o) -> do (completed = o.tags.find((x) -> x.name.toLowerCase() is 'completed').id) ->
      Promise.all([Promise.resolve(o), [1...o.totalPages].map(queryPage)...]).then (data) ->
        games = [].concat(data.map((x) => x.products)...)
                  .map (o) => [o.id, merge(pick(o, 'image', 'url'), worksOn(o.worksOn), rating: o.rating and o.rating/10,
                                           name: o.title, category: o.category or undefined, completed: o.tags.includes completed)]
        $update 'gog', fromPairs games
    $append $find('.collection-header'),
            $e('i', className: "fas fa-sync-alt _clickable account__filters-option", title: "Sync Backloggery", onclick: scrape)

  else if PAGE.match RE.humbleLibrary

    PLATFORMS = windows: 'w',  linux: 'l',  osx: 'm',  android: 'a'
    url = -> ($find('.details-heading a') or {}).href
    platformSelector = -> $find '.js-platform-select-holder'
    worksOn = -> do (e = platformSelector()) ->
      (PLATFORMS[k] for k of PLATFORMS when e.querySelector '.hb-'+k).join('')

    scrape = -> for e in $find_ '.subproduct-selector'
      e.click()
      name:      $find('h2', e).innerText
      publisher: $find('p',  e).innerText
      icon:      $find('.icon', e).style.backgroundImage.match(/^url\("(.*)"\)$/)?[1]
      url:       url()
      worksOn:   worksOn()
    GM_addStyle "#syncBackloggery {position: absolute;  top: 28px;  left: 400px;  cursor: pointer}"
    $append document.body, $e('span', {id: 'loader'}, $e('i', className: "fas fa-cog rotating"))
    $visibility loader, off
    main = $find '.base-main-wrapper'
    main.style.position = 'relative'
    $append main, $e('i', id: 'syncBackloggery', className: "fas fa-sync-alt", title: "Sync Backloggery", onclick: ->
      $visibility loader, on
      setTimeout ->
        $update 'humble', fromPairs scrape().map (x) -> x.worksOn and [slugify(x.name), x]
        $visibility loader, off)
    $visibility syncBackloggery, off
    forever -> when_ $find('#switch-platform'), (e) ->
      $visibility(syncBackloggery, (e.value is 'all') and not search.value and location.pathname is "/home/library")

  else if PAGE.match RE.itchLibrary

    GM_addStyle ".my_collections_page .game_collection h2 {display: flex}   .fa-sync-alt {padding-left: 1ex;  cursor: pointer}"
    _div = document.createElement 'div'
    _date = undefined
    queryPage = (page=0) -> $query("/my-purchases?format=json&page=#{page+1}").then (o) -> if o.num_items > 0
      _div.innerHTML = o.content
      Array.from(_div.childNodes).map (e) -> do (title = $find(".game_title a", e)) ->
        id:     e.getAttribute 'data-game_id'
        name:   title.innerText
        url:    title.href.replace(/\/download\/[^/]+$/, "")
        image:  $find("img", e)?.getAttribute('data-lazy_src')
        author: $find(".game_author", e)?.innerText
        date:   (_date = $find(".date_header > span", e)?.title or _date)
    collect = (page=0) -> queryPage(page).then (xs) -> unless xs then [] else collect(page+1).then (ys) -> [...xs, ...ys]
    scrape = -> collect().then (xs) -> $update 'itch', fromPairs xs.map ({id, ...o}) -> [id, o]
    $find_("a[href='/my-purchases']").forEach (e) ->
      e.insertAdjacentElement 'afterend', $e('i', className: "fas fa-sync-alt", title: "Sync Backloggery", onclick: scrape)

  else if PAGE.match RE.itchDetails

    PLATFORMS = windows: 'w', linux: 'l', osx: 'm', android: 'a', ios: 'm', web: 'b'
    _platforms = (check) -> Object.keys(PLATFORMS).filter(check).map((k) -> PLATFORMS[k]).join ""
    _parseDate = (e) -> new Date($find("abbr", e).title).getTime()

    if id = Object.entries(GM_getValue 'itch', {}).find(([k, o]) -> o.url is location.href)?[0]
      _info = fromPairs $find_(".game_info_panel_widget tr").map (e) -> [e.firstChild.innerText, e.lastChild]
      {Author, Platforms, Rating, ...info} = objmap _info, (x, k) -> switch slugify k
        when 'platforms'    then _platforms (k) -> $find "a[href$=platform-#{k}]", x
        when 'published'    then _parseDate x
        when 'updated'      then _parseDate x
        when 'release-date' then _parseDate x
        when 'rating'       then Number $find(".aggregate_rating", x).title
        else x.innerText
      $mergeData('itch-info', [id]: {at: Date.now(), worksOn: Platforms, rating: Rating, ...info})

  else if PAGE.match RE.ggateLibrary

    games = $find_(".my-games-catalog .catalog-item").map (e) -> do (link = $find('a', e), icon = $find('img', e).src) ->
      id = link.href.match("/account/orders/(.*)")[1].replace("/#", ":")
      [id, {name: link.title, icon, image: icon.replace(/\?.*/, "")}]
    $update 'ggate', fromPairs games

  else if PAGE.match RE.epicStore

    CONFIG = if USER_OS is 'windows' then "C:/Users/%USER%/AppData" else if USER_OS is 'linux' then "~/.config" else "~/Library/Application Support"
    TOOLTIP = "Import catalog to Backloggery from launcher cache:\n* EpicGamesLauncher/Data/Catalog/catcache.bin\n* #{CONFIG}/heroic/lib-cache/library.json"
    if user.classList.contains 'signed-in'
      GM_addStyle ".bl-import {display: flex;  align-items: center}   .bl-import > * {cursor: pointer;  padding: 1em}"
      convertHeroic = (data) -> data.map (x) ->
        id:        x.app_name
        name:      x.title
        slug:      x.store_url?.replace EPIC_STORE, ""
        icon:      (x.art_logo or x.art_cover or x.art_square)?.replace(EPIC_CDN+"/", "/")
        image:     (x.art_square or x.art_cover or x.art_logo)?.replace(EPIC_CDN+"/", "/")
        worksOn:   ['w', x.is_linux_native and 'l', x.is_mac_native and 'm'].filter(identity).join ""
        developer: x.developer
        online:    not x.canRunOffline or undefined
        cloud:     x.cloud_save_enabled or undefined
      _epicImg = (x) -> fromPairs x.keyImages.map (y) -> [y.type.replace(/^DieselGameBox/, "") or 'Cover', y.url]
      convertEpic = (data) -> data.filter((x) -> x.categories.some (y) -> y.path is 'games').map (x) -> do (meta = x.releaseInfo[0], img = _epicImg x) ->
        id:        meta.appId
        name:      x.title
        #slug:     not available
        icon:      (img.Logo or img.Cover or img.Tall)?.replace(EPIC_CDN+"/", "/")
        image:     (img.Tall or img.Cover or img.Logo)?.replace(EPIC_CDN+"/", "/")
        worksOn:   ['Windows' in meta.platform and 'w', 'Linux' in meta.platform and 'l', 'Mac' in meta.platform and 'm'].filter(identity).join ""
        developer: x.developer
        online:    x.customAttributes.CanRunOffline?.value is 'false' or undefined
        cloud:     'CloudSaveFolder' of x.customAttributes or undefined
      parseFile = (file) -> (readFile file
        .then (s) -> if s[0] is "{" then convertHeroic JSON.parse(s).library else convertEpic JSON.parse atob s
        .then (games) -> $update 'epic', fromPairs games.map ({id, ...x}) -> [id, x]
        .catch (e) -> console.error e;  alert "Invalid catalog cache file")
      readFile = (file) -> new Promise (resolve) -> do (reader = new FileReader) ->
        reader.onload = -> resolve @result.trim()
        reader.readAsText file
      importFile = $e('input', type: 'file', accept: ".bin,.json", onchange: -> @files[0] and parseFile @files[0])
      btn = $e('div', className: "bl-import", $e('i', className: "fas fa-file-import", title: TOOLTIP, onclick: -> importFile.click()))
      addBtn = debounce 1000, -> user.insertAdjacentElement 'afterend', btn;  _watcher.disconnect()
      _watcher = $watcher addBtn
      _watcher.observe rightNav, childList: yes, subtree: yes, attributes: yes

  else if PAGE.match(RE.dekuLibrary) and $find "a[href='/logout']"

    [counter, platform] = [{}, (s) -> {'xbox-x-s': 'xboxsx', 'xbox-one': 'xbo'}[slugify s] or slugify s]
    entries = for e in $find_ ".watch-details.owned" then do (cell = e.parentNode) ->
      cell = cell.parentNode until not cell or cell.classList.contains('cell') or cell.classList.contains('items-list-row')
      o = fromPairs $find_(".detail", e).map (x) -> do (label = ($find(".text-muted", x) or x).innerText.trim()) ->
        [pascal(label), x.innerText.replace(label, '').trim() or yes]
      if cell and o.Platform
        [name, link, img, form, rating] = [".name", "a.main-link", ".main-link img, .image img", "form", "input[checked][name=rating]"].map (s) -> $find(s, cell)
        name = name.innerText
        counter[name] = (counter[name] or 0) + 1
        [new URL(form.action).pathname.replace(/^\/owned_items\//, ''),
         name:     name + (if counter[name] is 1 then "" else " ##{counter[name]}")
         url:      new URL(link.href).pathname.replace(/^\/items\//, '/')
         image:    new URL(img.src).pathname.replace(/^\/images\//, '/')
         platform: platform(o.Platform),   physical: (o.Format is 'Physical') or undefined
         status:   o.Status,   notes: o.Notes or o.NotesPrivate,   rating: Number(rating?.value) or undefined]

    $update 'deku', fromPairs entries.filter(identity)

  else if PAGE.match(RE.dekuDetails) and $find ".watch-details.owned"

    physical = $find_(".watch-details.owned .detail").some (e) -> e.innerText is "Format\nPhysical"
    opencritic = $find(".opencritic", e)?.title.split(": ")[1]
    o = fromPairs $find_("ul.details > li").map (e) -> do (label = e.firstChild.innerText) ->
      [pascal(label), e.innerText.replace(label, "").trim() + (if label isnt "OpenCritic:" then "" else " (#{opencritic})")]
    $mergeData 'deku-info', [location.pathname.replace(/^\/items\//, '')]:
      icon: (physical and new URL($find("main img").src).pathname.replace(/^\/images\//, '/')) or undefined
      size: o.DownloadSize,   genre: o.Genre,   time: o.HowLongToBeat,   released: o.Released?.split("\n").join("; "),   openCritic: o.Opencritic,
      metacritic: o.Metacritic?.replace(/\btbd\b/, "?").replace(" ", " | "),   dlc: $find("h4")?.innerText.startsWith("DLC ") or undefined

  else if PSN_ID and PAGE.match(RE.psnLibrary)?[1] is PSN_ID

    PANEL = $get "../../..", $find ".dropdown-toggle.completion"
    GAMES = $find '#gamesTable'
    if ['search', 'completion', 'pf'].every (s) -> s not of PARAMS
      $append document.body, $e('span', {id: 'loader'}, $e('i', className: "fas fa-cog rotating"))
      $visibility loader, off
      _loading = -> $find('#table-loading', GAMES)
      load = -> new Promise (resolve) ->
        unless $find '#load-more', GAMES
          resolve()
        else
          loadMoreGames()
          waiting = forever -> unless $find '#table-loading', GAMES
            clearInterval waiting
            resolve load()

      TROPHIES = ['gold', 'silver', 'bronze']
      _achievements = (s) => (replace(s, /All (\d+)/, "$1 of $1") or s).match(/(\d+) of (\d+)/)[1..].join " / "
      convert = (x) -> [$find('a', x).href.match(RE.psnDetails)[1],
                        name: $find('.title', x).innerText,   icon: $find("picture source", x).srcset.match("^.*, (.*) 1.1x$")[1],
                        rank: $find('.game-rank', x).innerText,   progress: $find('.progress-bar', x).innerText,
                        achievements: _achievements($find('.small-info', x).innerText),
                        platforms: $find_('.platform', x).map((y) -> _PSN_HW[y.innerText]).join(''),
                        status: ['completion', 'platinum'].filter((s) -> $find ".#{s}.earned", x).join(", ") or undefined,
                        trophies: $find('.trophy-count div', x).innerText.split('\n').map((s, i) -> "#{s} #{TROPHIES[i]}").join(", ")]

      $append PANEL.firstElementChild,
              $e('i', id: 'syncBackloggery', className: "fas fa-sync-alt", style: "cursor: pointer;  color: white", title: "Sync Backloggery", onclick: ->
                   $visibility loader, on
                   load().then ->
                     $visibility loader, off
                     $update 'psn', fromPairs $find_('tr', GAMES).map convert)
      forever -> $visibility syncBackloggery, GAMES.style.display isnt 'none'

  else if PSN_ID and PAGE.match(RE.psnDetails)?[2] is PSN_ID

    GAME_ID = PAGE.match(RE.psnDetails)[1]
    $mergeData 'psn-img', [GAME_ID]: $find('.game-image-holder a').href

`;

if (location.host != 'steamdb.info') {  // can't run compiled CoffeeScript on SteamDB site
  eval( CoffeeScript.compile(inline_src) );
} else {
  let $worksOn = e => ['windows', 'linux', 'macos'].filter(s => e.querySelector(`.octicon-${s}`)).map(s => s[0]).join('');
  let $addData = (data, {id, old = GM_getValue('steam-platforms', {}), changes = GM_getValue('changes', [])}) => {
    (id in old) && (old[id] != data) && !changes.includes(id) && GM_setValue('changes', [...changes, id]);
    GM_setValue('steam-platforms', {...old, [id]: data});
  };

  if (location.pathname.startsWith("/app/") && document.querySelector("#js-app-install.owned")) {
    var info = document.querySelector('.span8');
    $worksOn(info) && $addData($worksOn(info), {id: document.querySelectorAll('td', info)[1].innerText});
  } else if (location.pathname.startsWith("/calculator/") && location.href.startsWith(document.querySelector(".account-menu a")?.href)) {
    new MutationObserver(xs => xs.forEach(x => x.addedNodes.forEach(e => {
      e.firstElementChild?.classList?.contains('hover_buttons') &&
        $worksOn(e) && $addData($worksOn(e), {id: new URL(e.querySelector('a.hover_title').href).pathname.match("^/app/([0-9]+)")[1]});
    }))).observe(document, {childList: true, subtree: true});
  }
}