Greasy Fork

Greasy Fork is available in English.

Backloggery interop

Backloggery integration with game library websites

当前为 2022-11-02 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Backloggery interop
// @namespace    http://tampermonkey.net/
// @version      0.10.1
// @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/<username>/games/*
// @exclude      *://steamcommunity.com/id/<username>/games/*
// @include      *://steamcommunity.com/id/<username>/stats/*
// @exclude      *://steamcommunity.com/id/<username>/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      *://psnprofiles.com/<username>
// @include      *://psnprofiles.com/*?*
// @include      *://psnprofiles.com/trophies/*
// @exclude      *://psnprofiles.com/<username>
// @require      https://cdnjs.cloudflare.com/ajax/libs/mithril/2.0.4/mithril.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/lib/coffeescript-browser-compiler-legacy/coffeescript.js
// @grant        GM_info
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// ==/UserScript==

var inline_src = String.raw`

  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)...
  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..]
  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/\\?tab=all"
    steamRecent:        "steamcommunity\\.com/id/[^/]+/games($|/$|/\\?tab=(recent|perfect))"
    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"
    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"
    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}")
  [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', {}),
             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(':', '#')}")
    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'], 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 ([slugify(v.name), k] for k, v of o)
  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)
  $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()
  $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]
  $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"

  if PAGE.match RE.backloggeryUpdate

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

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

  else if PAGE.match RE.backloggeryCreate

    BACKLOG = GM_getValue 'backlog', {}
    MATCHED = objmap DATA, (_, s) -> setFn (x["#{s}Id"] for k, x of BACKLOG when x["#{s}Id"])
    UNMATCHED = objmap DATA, (o, s) -> pick(o, (k for k of o when not MATCHED[s](k))...)
    SETS = objmap UNMATCHED, (o) -> objmap(o, (x) -> words x.name)
    excluded = GM_getValue 'exclude', {}
    GM_addStyle ".os {padding-left: 1ex;  line-height: 0;  font-size: 16px}
                 #names {position: absolute;  max-height: 500px;  width: 730px;  top: 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}"
    for es in $find("[name=#{s}console]").children for s in ['', 'orig_']
      e.innerText = "Windows" for e in es when e.value is "PC"
    status         = $get '//*[@id="content-wide"]/section/form/fieldset[2]/div[1]'
    [name, system] = ['name', 'console'].map (s) -> $find "[name=#{s}]"
    name.autocomplete = 'off'
    eId = (k, s=system.value) -> "#{s.toLowerCase()}##{k}"
    for e in system.children
      when_(UNMATCHED[ e.value.toLowerCase() ], (o) -> e.innerText += " (+#{(k for k of o when not excluded[ eId(k, e.value) ]).length})")
    $after($find('.info.help', detail2), $e('span', id: 'oslist', style: "padding-left: 1ex"))
    $after($find('.info.help', detail5), $e('b', id: 'achievements', style: "padding: 3.5ex"))
    $after($find('.info.help', status), $e('b', id: 'completed', style: "padding: 1ex"))
    $find_('fieldset')[0].style.position = 'relative'
    $after($find('[name=name]'), $e('div', id: 'names'))
    data = (k=system.value) -> UNMATCHED[ k.toLowerCase() ] or {};
    _order = (text=name.value, k=system.value) -> order(SETS, excluded, text, k.toLowerCase())
    state = id: null,  active: no,  order: _order()
    _setExcl = (k, x) ->
      excluded[ eId(k) ] = x
      $mergeData('exclude', [eId(k)]: x)
      state.order = _order()
    _redraw = (id=state.id, o=data()[id]) ->
      $clear oslist
      o and $append oslist, ((do ([s, cls] = OS[c]) -> $e('i', className: "fab #{cls} os", title: s)) for c in (o.worksOn or '').split(''))...
      [achievements.innerText, completed.innerText] = [o?.achievements or "", statStr(o or {}, 'completed') or o?.status or '']
    $before($find_('fieldset')[0], $e('div', id: 'logo', className: 'logo'))
    m.mount logo, view: -> do (k = system.value.toLowerCase(),  o = data()[state.id]) ->
      o and m('a', {target: '_blank', href: o.url}, m('img', src: $logo(k, state.id)[1]))
    $upd = (id) ->
      _redraw id
      when_ data()[id], (o) -> name.value = o.name
      Object.assign state, {id}, active: not id, order: _order()
      no
    name.oninput = name.onclick = -> $upd();  m.redraw()
    system.onchange = ->
      Object.assign state, id: null, order: _order()
      _redraw();  m.redraw()
    document.addEventListener 'keydown', (e) -> if e.key is 'Escape'
      Object.assign state, id: null, active: no
      _redraw();  m.redraw()
    m.mount names, view: -> state.active and
      state.order.map (k) -> do (x = not excluded[eId(k)]) ->
        m 'button', {disabled: not x, onclick: -> $upd(k)}, m('span.name', {title: data()[k].name}, data()[k].name),
          m 'i.fas', class: "fa-trash#{if x then '' else '-restore'}-alt", title: (if x then "Exclude" else "Restore"), \
                     onclick: $stop(-> $keepScroll names, -> _setExcl(k, x))

  else if PAGE.match RE.backloggeryLibrary

    INCOMPLETE = ["(-)", "(u)", "(U)"]
    SLUGS = objmap DATA, slugs
    $assertEq(Object.keys(DATA.steam).length, Object.keys(SLUGS.steam).length, (n, m) -> "Steam names have #{n-m} collisions!")
    $assertEq(Object.keys(DATA.gog).length,   Object.keys(SLUGS.gog).length,   (n, m) -> "GOG names have #{n-m} collisions!")
    UPD = GM_getValue 'updated', {}
    LIBRARIES = Object.keys DATA
    CHANGES = do (backlog = GM_getValue('backlog', {}),  changes = new Set GM_getValue('changes', [])) ->
      objfilter backlog, (x, k) -> LIBRARIES.some (s) -> changes.has "#{s}##{x[s+'Id']}"
    CHANGED = Object.keys(CHANGES).sort (a, b) -> CHANGES[a].name.localeCompare CHANGES[b].name
    $s = (e) -> e.innerText.trim()
    info = (e) -> $find '.gamerow', e
    name = (e) -> $find 'b', e
    id = (e) -> query($find('a', e).href).gameid
    $achievements = (e) -> $find '.info span', info e
    $completion = (e) -> $find 'img', $find_('h2 a', e)[1]
    $type = (s, e) -> $s(info e).match RegExp("\\b#{s}\\b", 'i')
    $slug = (k, e) -> SLUGS[k][ slugify($s name e) ]
    overlay = $e('div', style: "z-index:2; pointer-events:none; position:fixed; top:0; left:0; width:100%; height:100%; display:flex")
    $append document.body, overlay
    GM_addStyle "#{LOGO}  .logo img {max-height: 62px}  .logo.steam img {max-height: 67px}  .logo.gog img {max-height: 64px}
                 .os {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;
                           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)
        m 'div', overlayData.stats?.map (s) -> m('pre', s)
      when CHANGED.length > 0 then m '.changelist', {class: if changeListCollapsed then 'collapsed' else ""},
        m 'h1', {onclick: -> changeListCollapsed = not changeListCollapsed}, "Unseen changes (#{CHANGED.length}) ",
          m 'span.right', "[#{if changeListCollapsed then '+' else '–'}]"
        unless changeListCollapsed then m '.items',
          CHANGED.map (k) -> m '.item', m 'a', {href: "https://www.backloggery.com/update.php?user=#{PARAMS.user}&gameid=#{k}"},
                                          CHANGES[k].name, (" [#{s}]" for s in LIBRARIES when CHANGES[k]["#{s}Id"])
    $tweak = (e, [k, k_=k], id, [ignore, markParam, markCond], [append, appendFmt=identity], [stats, statsUpdated]) ->
      [[icon, image], x] = [$logo(k, id), if k is 'custom' then id else DATA[k][id]]
      _data = {image,  stats: (stats and "#{stats}\nSynced: #{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) -> _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
      _psn = ['ps3', 'ps4', 'psvita'].find((s) -> $type s, game)
      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
        _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/10}/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 _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 PAGE.match RE.steamLibrary

    $update 'steam', fromPairs ([o.appid, {name: o.name, hours: o.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 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')

  else if PAGE.match RE.steamDbDetails

    if $find '#js-app-install.owned'
      info = $find('.span8')
      id = $find_('td', info)[1].innerText
      worksOn = (s[0] for s in ['windows', 'linux', 'macos'] when $find(".octicon-#{s}", info)).join('')
      worksOn and $mergeData('steam-platforms', [id]: worksOn)

  else if PAGE.match(RE.steamDbLibrary) and PAGE.startsWith $get("//div[@class='header-menu']//a[text()='Your page']")?.href

    _hoverPlatformSnooper = $watcher (e) -> if e.firstElementChild?.classList?.contains 'hover_buttons'
      [id] = $find('a.hover_title', e).href.replace(/\/?\?.*$/, "").match /[0-9]+$/
      worksOn = (s[0] for s in ['windows', 'linux', 'macos'] when $find(".octicon-#{s}", e)).join('');
      worksOn and $mergeData('steam-platforms', [id]: worksOn)
    _hoverPlatformSnooper.observe document, childList: yes, subtree: yes

  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', 'rating', 'url'), worksOn(o.worksOn),
                                           name: o.title, category: o.category or undefined, completed: o.tags.includes completed)]
        $update 'gog', fromPairs games
    $append $find('.collection-header'),
            $e('i', className: "fas fa-sync-alt _clickable account__filters-option", title: "Sync Backloggery", onclick: scrape)

  else if PAGE.match RE.humbleLibrary

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

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

  else if PAGE.match RE.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.filter((x) -> x.is_game).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 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) );