Humble librarian

Quick management of owned HumbleBundle stuff

目前为 2019-10-27 提交的版本。查看 最新版本

// ==UserScript==
// @name         Humble librarian
// @namespace    http://tampermonkey.net/
// @version      0.3
// @description  Quick management of owned HumbleBundle stuff
// @author       LeXofLeviafan
// @match        *://www.humblebundle.com/*
// @require      https://cdnjs.cloudflare.com/ajax/libs/mithril/2.0.4/mithril.min.js
// @require      https://cdn.jsdelivr.net/npm/ramda@latest/dist/ramda.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/lib/coffeescript-browser-compiler-legacy/coffeescript.js
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// ==/UserScript==

var inline_src = String.raw`

  MSEC   = 1
  SECOND = 1000*MSEC
  MINUTE = 60*SECOND

  compact    = R.filter R.identity
  each       = R.flip R.forEach
  mapcat     = R.flip R.chain
  prefixOf   = R.flip R.startsWith
  sort       = R.sortBy R.identity
  descending = R.descend R.identity
  $merge     = Object.assign
  merge      = R.mergeAll
  fromPairs  = R.compose R.fromPairs, compact
  notEquals  = R.compose R.complement, R.equals
  notEmpty   = (x) -> not R.isEmpty (x or [])
  nilEmpty   = (x) -> if notEmpty x then x
  ojuxt      = R.curry (o, x) -> R.mapObjIndexed ((f) -> f x), o
  qstr  = (s) -> if not s.includes('?') then "" else s[1 + s.indexOf '?'..]
  query = (s) -> fromPairs (l[1..] for l in qstr(s).split('&').map(R.match /([^=]+)=(.*)/) when l[0])

  $e       = (tag, options...) -> $merge document.createElement(tag), options...
  $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
  $text    = (e) -> e and (e.innerText or e.data or "").trim() or ""
  $content = (e) -> e and e.innerHTML or ""
  $timer   = (time, action, args...) -> setTimeout (-> action args...), time
  $inst    = -> "#{new Date} #{Math.random()}".replace /0\./, '#'
  $forever = (f) -> setInterval f, SECOND//2
  $watcher = (f) -> new MutationObserver (xs) -> each xs, (x) -> each x.addedNodes, f
  $notNils = R.pickBy R.complement R.isNil
  auxclick = (f) -> {onauxclick: f, oncontextmenu: -> no}
  $redraw  = -> $timer SECOND//16, m.redraw

  throttle = (delay, action, args...) -> do (last = 0) -> ->
    now = +new Date
    if now > last+delay
      last = now
      action args...
  debounce = (delay, action, args...) -> do (last = null) -> ->
    last = cur = $inst()
    $timer delay, -> last is cur and action last, args...
  cache = (expire, f) -> do (value = null) ->
    calc = throttle expire, -> value = f()
    -> calc();  value

  $storageCache = (key, default_) -> cache SECOND//4, -> GM_getValue key, default_
  $update = (key, data) -> GM_setValue key, merge [(GM_getValue key), data]
  $storageUpdater = (key) -> do (changes = {}) ->
    push = debounce SECOND//4, -> console.warn {changes};  $update(key, changes);  changes = {};  $redraw()
    (k, v) -> changes[k] = $notNils merge [changes[k], v];  push()
  $updateData = (get, $update, key, value) ->
    data = get()[key] or {}
    R.whereEq($notNils(value), data) or $update key, merge [data, value]
  $storage = (key, default_) -> [$storageCache(key, default_), $storageUpdater(key)]
  [$libraryData,   $libraryPush]   = $storage 'library', {}
  [$purchasesData, $purchasesPush] = $storage 'purchases', {}
  $addGroup = (s) -> GM_setValue 'groups', sort R.union GM_getValue('groups', []), [s]
  $delGroup = (s) -> GM_setValue 'groups', R.without [s], GM_getValue('groups', [])
  groupExists = (s) -> s in GM_getValue 'groups', []

  EXECUTABLE = "Android Windows Linux Mac".split ' '
  TYPE       = "game software resource music tabletop puzzlebook fiction nonfiction tutorial other".split ' '
  EXEC_TYPES = ['game', 'software', undefined, null]  # type defaults to 'game'

  URL    = location.href
  HOST   = location.host
  PAGE   = location.pathname
  PARAMS = query location.search

  title_ = (x) -> x.title_ or x.title
  steamSearchUrl = (term) -> "https://store.steampowered.com/search/?term=#{encodeURIComponent term}"
  $steamLookup = (e, header) -> e and do (url = steamSearchUrl if R.is String, header then header else header.innerHTML.trim()) ->   # innerText may be capsed
    $merge e, title: "Look up on Steam", style: "cursor: pointer;  pointer-events: all", onclick: (e) -> open url, '_blank';  no
  groupList = (groups = GM_getValue 'groups', []) -> sort R.uniq [groups..., compact(R.map R.prop('group'), R.values $libraryData())...]
  grouping = (groups, library) -> R.groupBy ((x) -> x.group or groups.find(prefixOf title_ x) or ""), R.sortBy R.compose(R.toLower, title_), R.values library
  matcher = (subs) -> do (z = R.toLower "#{subs}") -> (s) -> R.includes z, R.toLower "#{s}"
  parseDate = (s) -> new Date("#{s} UTC").toJSON().replace(/T.*/, '')

  if HOST is "www.humblebundle.com" then switch      # checking in case user wants to have overlay on other sites
    when PAGE.match "^/home/(library|purchases|keys|coupons)$"

      _iconUrl         = (s) -> s and s.match(/^url\("(.*)"\)$/)[1]
      _keyExpired      = (e) -> R.contains "expired", $text $find(".keyfield.redeemed .keyfield-value", e)
      _downloadVisible = (e) -> e.parentNode.style.display isnt 'none'
      _download        = (e) ->
        [caption, meta] = e.children
        [title, link] = R.map $text, caption.childNodes
        [info, md5]   = meta.childNodes
        {title, link, info: $text(info).replace(/ \|$/, ""), md5: md5.getAttribute 'data-md5'}
      _downloads = (platform, downloads, audioDownloads) ->
        disabled = platform?.classList.contains 'disabled'
        category = platform and $text $find(".selected", platform)
        others   = unless platform then [] else $find_(".choice", platform).map $text
        noExec   = R.none ((s) -> s in EXECUTABLE), [category, others...]
        _files   = if category and (disabled or noExec)
                     R.mapObjIndexed R.map(_download), R.groupBy ((e) -> if _downloadVisible(e) then category else others[0]), downloads
        files    = _files and if noExec and others.length is 1 then _files else R.pick [category], _files
        audio    = notEmpty(audioDownloads) and compact audioDownloads.map _download
        (audio or category) and {categories: compact([audio and 'Audio', category, others...]).sort().join(' '), \
                                 files:      merge [audio and {Audio: audio}, files]}
      _keys = (platformSections) -> mapcat platformSections, (section) ->
        type     = R.find(notEquals('platform'), section.classList)
        platform = unless type is 'generic' then $text $find("h3", section)
        $find_(".key-redeemer", section).map (e) -> $notNils
          platform:     platform
          title:        $text $find(".heading-text h4", e)
          instructions: $content( $find(".custom-instruction", e) ) or null
          expiration:   $content( $find(".expiration-messaging", e) ) or null
          status:       switch
            when _keyExpired e                       then 'expired'
            when $find(".keyfield.redeemed",      e) then 'redeemed'
            when $find(".keyfield.redeemed-gift", e) then 'gifted'
            when $find(".keyfield.enabled",       e) then 'unused'
            else "???"			# this will appear if I missed something
      _links = (customLinks) -> customLinks.map (e) -> $text $find(".js-raw-html", e)
      _selector = ojuxt
        icon:      (e) -> _iconUrl($find(".icon", e).style.backgroundImage) or null
        title:     (e) -> $text $find(".text-holder h2", e)
        publisher: (e) -> $text( $find(".text-holder p", e) ) or null
      _details  = ojuxt
        title:     (e) -> $text $find(".details-heading .text-holder h2", e)
        url:       (e) -> $find(".details-heading a", e)?.href
        downloads: (e) -> _downloads $find(".select-holder .custom-select", e), ($find_(".#{s}-section .download-button", e) for s in ['download', 'audio'])...
        keys:      (e) -> nilEmpty _keys $find_(".key-redeemers .platform", e)
        links:     (e) -> nilEmpty _links $find_(".custom-html .show-whitebox", e)
      _purchase = ojuxt
        id:    (e) -> e.getAttribute 'data-hb-gamekey'
        title: (e) -> $text $find(".product-name", e)
        date:  (e) -> parseDate $text $find(".order-placed", e)
        total: (e) -> do (s = $text $find(".total", e)) -> if s isnt "--" then s

      GM_addStyle ".hb-lib-downloads-steamicon {padding-right: 5px}"
      _steamLookups = debounce SECOND//4, ->
        each $find_(".platform.steam .key-redeemer .heading-text h4"), (e) -> do (icon = $e 'i', className: "hb hb-steam hb-lib-downloads-steamicon") ->
          $steamLookup icon, e
          e.prepend icon
      _watcher = (getData) -> $watcher (e) ->
        _steamLookups()
        data = getData e
        $updateData($libraryData, $libraryPush, data.title, data)
      _initLib = R.once ->
        _watcher(_selector).observe $find(".subproducts-holder"), childList: yes
        _watcher(_details).observe  $find(".details-holder"),     childList: yes
      _initKeys = R.once -> do (steamLookups = (e) -> do (steam = $find(".platform .hb-steam", e)) -> steam and $steamLookup steam, $find(".game-name h4")) ->
        R.map steamLookups, $find_ ".unredeemed-keys-table tr"
        $watcher(steamLookups).observe $find(".unredeemed-keys-table"), childList: yes, subtree: yes
      _regPurchase = (e) -> e.nodeName is 'DIV' and do (data = _purchase e) -> $updateData($purchasesData, $purchasesPush, data.id, data)
      _initPurchases = R.once ->
        R.map _regPurchase, $find_ ".js-purchase-holder .results .body .row"
        $watcher(_regPurchase).observe $find(".js-purchase-holder .results .body"), childList: yes
      do (isLibrary   = -> location.pathname is "/home/library")   -> if isLibrary()   then _initLib()       else $forever -> isLibrary()   and _initLib()
      do (isKeys      = -> location.pathname is "/home/keys")      -> if isKeys()      then _initKeys()      else $forever -> isKeys()      and _initKeys()
      do (isPurchases = -> location.pathname is "/home/purchases") -> if isPurchases() then _initPurchases() else $forever -> isPurchases() and _initPurchases()

    when PAGE.match "^/(software|games|books)/"

      GM_addStyle ".hb-lib-owned {color: limegreen}"
      _watcher = $watcher (e) -> if e.className is 'desktop-slideout-content'
        $steamLookup $find(".slideout-availability-icons .hb-steam", e), $find(".desktop-slideout-content-heading", e)
      _watcher.observe document.body, childList: yes, subtree: yes
      keys = new Set mapcat R.values( $libraryData() ), (x) -> (x.aliases or []).concat (x.keys or []).map R.prop 'title'
      each $find_(".front-page-art-image-text"), (e) -> do (title = $text(e),  sub = $find(".subtitle", e.parentNode.parentNode)) ->
        subtitle = $text((sub?.children.length is 0) and sub)
        if [title, "#{title} (#{subtitle})"].some (s) -> s of $libraryData() or keys.has s
          e.classList.add 'hb-lib-owned'
          e.title = "Already in collection"

    when PAGE.match "^/store"

      each ($find(".#{s} .hb-steam") for s in ['platform-delivery', 'availability-section']), (e) ->
        $steamLookup e, $find(".js-human-name .human_name-view")
      each $find_(".entity"), (e) -> $steamLookup $find(".hb-steam", e), do (x = $find(".entity-title", e)) -> x?.title or x
      _watcher = $watcher (e) -> if e.classList?.contains('entity') or e.classList?.contains('entity-block-container')
        $steamLookup $find(".hb-steam", e), $find(".entity-title", e)
      _watcher.observe ($find(".recommendations-view") or $find(".entity-lists") or $find(".search-results-holder")), childList: yes, subtree: yes

    when PAGE is "/downloads"

      GM_addStyle ".hb-lib-downloads-steamicon {padding-right: 5px}"
      [_keys, _downloads, _data] = [{}, {}, -> $purchasesData()[PARAMS.key]]
      _keysWatcher = $watcher (section) -> do (data = _data(), keys = $find_(".key-redeemer h4", section), title = $text $find('h2', section)) ->
        $merge _keys, fromPairs keys.map (e) -> [$text(e), if title is "Key" then "" else title]
        $updateData $purchasesData, $purchasesPush, data.id, merge [data, keys: merge [data.keys, _keys]]
        if title is "Steam" then each keys, (e) -> do (icon = $e 'i', className: "hb hb-steam hb-lib-downloads-steamicon") ->
          $steamLookup icon, e;   e.prepend icon
      _downloadsWatcher = $watcher (root) -> each $find_(".whitebox-redux", root), (section) ->
        do (data = _data(), platforms = $find_(".js-platform-button", section).map $text) -> if platforms.length is 1
          $merge _downloads, fromPairs $find_(".download-rows .row .title", section).map (e) -> [$text(e), platforms[0]]
          $updateData $purchasesData, $purchasesPush, data.id, merge [data, downloads: merge [data.downloads, _downloads]]
      do (e = $find ".key-container")           -> e and _keysWatcher.observe      e, childList: yes
      do (e = $find ".js-all-downloads-holder") -> e and _downloadsWatcher.observe e, childList: yes


  GM_addStyle ".hb-lib-overlay {position: fixed;  top: 0;  right: 0;  z-index: 1000;  background: rgba(0, 0, 0, 0.8);  color: grey;
                                font-size: medium;  max-height: 80%;  max-width: 80%;  display: flex;  flex-direction: column;  margin-left: 75px}
               .hb-lib-overlay-toggle {display: inline-block;  padding: 1ex;  font-family: monospace;  cursor: pointer}
               .hb-lib-overlay-header {margin: 0 1ex}   .hb-lib-overlay-body {margin: 1ex;  overflow: hidden;  display: flex;  flex-direction: column}
               .hb-lib-grow {flex-grow: 1}   .hb-lib-overlay-header {display: flex;  font-size: large;  font-weight: bold}
               .hb-lib-scrollbox {overflow: auto;  margin-top: 1ex}   .hb-lib-group li {padding-right: 1em}   .hb-lib-icon {padding-right: 1ex}
               .hb-lib-selectable {cursor: pointer}   .hb-lib-selectable:hover {opacity: .5}   .hb-lib-purchase {padding-left: 1ex}
               .hb-lib-preview {height: 0;  position: relative;  left: -10ex;  top: 1ex}
               .hb-lib-custom-title, .hb-lib-custom-group, .hb-lib-row {display: flex}   .hb-lib-publisher {font-size: x-small;  cursor: pointer}
               .hb-lib-select-types, .hb-lib-custom-type select, .hb-lib-custom-type option {text-transform: capitalize}
               .hb-lib-select-types label {padding: 1ex;  cursor: pointer}   .hb-lib-category {padding-top: 1em}   .hb-lib-category ul {margin-top: 0}"
  overlay = $e 'div', className: 'hb-lib-overlay'
  document.body.appendChild overlay
  defState = -> open: no, filter: "", view: null, group: null, publisher: null, purchase: null, expand: {}, unused: no, types: fromPairs ([s, yes] for s in TYPE)
  state = defState()
  $setState = (x) -> $merge state, x;  $redraw()
  document.addEventListener 'keydown', (e) -> if e.key is 'Escape' then $setState defState()
  $editItem = (changes, item=state.view.item) -> $merge item, changes;   $libraryPush item.title, item;   setTimeout $redraw, SECOND//2
  $addAlias = (item=state.view.item) -> $editItem aliases: [(item.aliases or [])..., ""]
  $delAlias = (i, item=state.view.item) -> $editItem aliases: nilEmpty R.remove i, 1, item.aliases
  $setAlias = (i, s, item=state.view.item) -> $editItem aliases: R.update i, s, item.aliases
  $setInState = (value, path...) -> $setState R.assocPath path, value, state
  $setTypes = (items, value) -> each items, (x) -> $libraryPush x.title, merge [x, type: value]
  $mkGroup = -> $addGroup state.filter;   $setState filter: ""
  $renameGroup = (group, items) ->
    $delGroup state.group;   $addGroup group;   $setState {group}
    each items, ((x) -> x.group and $libraryPush x.title, merge [x, {group}]);
  $itemView = (item, extras...) -> do (groups = groupList()) ->
    $setState merge [extras..., view: {item, groups, group: item.group or R.findLast(((s) -> item.title.startsWith s), groups) or ""}]
  titleFilter = -> matcher state.filter or ""
  typeFilter = (item) -> state.types[item.type or 'game'] and (not state.unused or (item.keys or []).some R.propEq 'status', 'unused')
  itemFilter = (p) -> (item) -> p(title_ item) and typeFilter item
  purchaseFilter = R.curry ({keys={}, downloads={}}, item) ->
    (item.keys or []).some((x) -> x.title of keys) or R.unnest(R.values item.downloads?.files or {}).some((x) -> x.title of downloads)

  ViewItem = view: ({attrs: {item, group, groups}}) -> [
    m '.hb-lib-overlay-header',
      item.icon and m('img.hb-lib-icon', src: item.icon)
      m '.hb-lib-entry-title.hb-lib-grow',
        m('a', {href: item.url, target: '_blank'}, item.title), m('.hb-lib-publisher', {onclick: -> $setState publisher: item.publisher}, item.publisher)
      m('.hb-lib-overlay-toggle', {onclick: -> $setState view: null}, '⇚')
    m '.hb-lib-scrollbox',
      m '.hb-lib-custom-title', title: "Custom title",
        m 'label', m 'input', type: 'checkbox', checked: not R.isNil(item.title_), onchange: -> $editItem title_: if @checked then "" else null
        m 'input.hb-lib-grow', disabled: R.isNil(item.title_), value: title_(item), oninput: -> $editItem title_: @value
      m '.hb-lib-custom-group', title: "Custom group",
        m 'label', m 'input', type: 'checkbox', checked: not R.isNil(item.group), onchange: -> $editItem group: if @checked then group else null
        m 'input.hb-lib-grow', {disabled: R.isNil(item.group), placeholder: "Custom group…", onfocus: (=> @groups = yes),\
                                onblur: (=> $timer SECOND//16, => @groups = no), value: item.group or group, oninput: -> $editItem group: @value}
      @groups and groups.filter(matcher item.group).map (s) -> m '.hb-lib-selectable', {key: s, onclick: -> $editItem group: s}, s
      m '.hb-lib-custom-type', title: "Type",
        m 'label', "Type: ", m 'select', {onchange: -> $editItem type: @value or null},
          TYPE.map (s) -> m 'option', {selected: item.type is s, value: if s is 'game' then "" else s}, s
      m '.hb-lib-aliases', title: "Aliases can be used for additional key matching (in case of typos)",
        m '.hb-lib-row', m('.hb-lib-grow', "Aliases"), m('button', {onclick: $addAlias}, "+")
        (item.aliases or []).map (s, i) -> m '.hb-lib-row',
          m('input.hb-lib-grow', {value: s, oninput: -> $setAlias i, @value}), m('button', {onclick: -> $delAlias i}, '-')
      R.sortBy(R.prop('date'), R.values $purchasesData()).filter((x) -> purchaseFilter(x, item)).map (x) ->
        m '.hb-lib-row', "Found in:", m '.hb-lib-purchase.hb-lib-selectable', {onclick: -> $setState purchase: x.id}, "#{x.title} (#{x.date})"
      item.downloads and do (categories = item.downloads.categories.split ' ') -> m '.hb-lib-downloads',
        m '.hb-lib-category', "Downloads: ", categories.join ", "
        compact categories.map (s) -> do (files = (item.type not in EXEC_TYPES or s not in EXECUTABLE) and item.downloads.files[s]) -> if files
          m '.hb-lib-category', {key: s}, "#{s} downloads:", m 'ul', files.map (x) -> m 'li', "#{x.title} (#{x.link})", m('br'), "#{x.info} [#{x.md5}]"
      item.keys and do (platforms = R.groupBy R.propOr("Generic", 'platform'), item.keys) => R.keys(platforms).sort().map (s) =>
          m '.hb-lib-category', {key: s}, "#{s} keys:", m 'ul', platforms[s].map (x) =>
            m 'li', {onmouseenter: => @hover = x.title}, "#{x.title} [#{x.status}]",
              if @hover is x.title then [m.trust(x.instructions), m.trust(x.expiration)] else (x.instructions or x.expiration) and " (…)"
      item.links and m '.hb-lib-category', "Additional content:", m 'ul', item.links.map (s, i) =>
        m 'li', {onmouseenter: => @hover = "link##{i}"}, unless @hover is "link##{i}" then "(…)" else m.trust s
  ]

  EditGroup = view: ({attrs: {title, items}}) -> do (prefixGroup = groupExists title) => [
    m '.hb-lib-overlay-header', title: "Title", m('input.hb-lib-grow', value: title, onchange: -> $renameGroup @value, items),
      m('.hb-lib-overlay-toggle', {onclick: -> $setState group: null}, '⇚')
    groupExists(title) and m 'button', {onclick: -> $setState group: null;  $delGroup title}, "Remove prefix group"
    m '.hb-lib-custom-type', title: "Type",
      m 'label', "Set type for all: ", m 'select', {onchange: -> $setTypes items, @value or null},
        m 'option', selected: yes, disabled: yes
        TYPE.map (s) -> m 'option', {value: if s is 'game' then "" else s}, s
    m 'ul.hb-lib-scrollbox.hb-lib-group', items.map (x) -> m 'li', {key: x.title, title: x.title}, title_(x), x.type and " [#{x.type}]"
  ]

  ViewGroup =
    oninit: -> Object.defineProperty @, 'open', get: (=> state.expand[@title]), set: (x) => $setInState x, 'expand', @title
    view: ({attrs: {@title="", items, filter}}) -> do (filtering = not filter @title) => m '.hb-lib-group',
      m '.hb-lib-overlay-header.hb-lib-selectable', not filtering and {onclick: => @open = not @open},
        m '.hb-lib-grow', @title and {title: "Right/middle click to edit", auxclick(=> $setState group: @title)...},
          @title or "—", " [#{items.length}]"
        not filtering and m('.hb-lib-overlay-toggle', if @open then '-' else '+')
      (@open or filtering) and m 'ul', items.map (x) => m.fragment {key: x.title}, [
        m 'li.hb-lib-selectable', {title: x.title, onmouseenter: (=> @preview = x.title), onmouseleave: (=> @preview = null),\
                                   onclick: (=> $setState view: {item: x, group: @title, groups: groupList()})},
          title_(x), x.type and " [#{x.type}]"
        @preview is x.title and x.icon and m '.hb-lib-preview', m 'a',  m 'img', src: x.icon
      ]

  BrowsePurchase = view: ({attrs: {id, items}}) -> do (purchase = $purchasesData()[id]) -> [
    m '.hb-lib-overlay-header.hb-lib-selectable',
      m '.hb-lib-grow', "Purchase: #{purchase.title} (#{purchase.date}) [#{items.length}]"
      m '.hb-lib-overlay-toggle', {onclick: -> $setState purchase: null}, '⇚'
    m 'ul.hb-lib-group.hb-lib-scrollbox', style: "padding-bottom: 20ex", items.map (x) => m.fragment {key: x.title}, [
      m 'li.hb-lib-selectable', {title: x.title, onmouseenter: (=> @preview = x.title), onmouseleave: (=> @preview = null), onclick: => $itemView x, purchase: null},
        title_(x), x.type and " [#{x.type}]"
      @preview is x.title and x.icon and m '.hb-lib-preview', m 'a',  m 'img', src: x.icon
    ]
  ]

  BrowsePublisher = view: ({attrs: {name, items}}) -> [
    m '.hb-lib-overlay-header.hb-lib-selectable',
      m '.hb-lib-grow', "Publisher: #{name} [#{items.length}]"
      m '.hb-lib-overlay-toggle', {onclick: -> $setState publisher: null}, '⇚'
    m 'ul.hb-lib-group.hb-lib-scrollbox', style: "padding-bottom: 20ex", items.map (x) => m.fragment {key: x.title}, [
      m 'li.hb-lib-selectable', {title: x.title, onmouseenter: (=> @preview = x.title), onmouseleave: (=> @preview = null), onclick: => $itemView x, publisher: null},
        title_(x), x.type and " [#{x.type}]"
      @preview is x.title and x.icon and m '.hb-lib-preview', m 'a',  m 'img', src: x.icon
    ]
  ]

  Browse =
    oninit: -> Object.defineProperty @, 'filter', get: titleFilter
    filtered: (items, title="", p=@filter) -> items.filter if p title then typeFilter else itemFilter p
    view: ({attrs: {grouped}}) -> do (groups=R.omit([""], grouped),  noGroup=grouped[""], p=@filter) => [
      m '.hb-lib-row',
        m 'input.hb-lib-grow', {title: "Filter", placeholder: "Search…", value: state.filter, oninput: -> $setState filter: @value}
        m 'button', {onclick: $mkGroup, disabled: not state.filter}, "Add prefix group"
      m '.hb-lib-select-types', title: "Types",
        TYPE.map (s) -> m 'label', {key: s}, m('input', type: 'checkbox', checked: state.types[s], onchange: -> $setInState @checked, 'types', s), s
        m 'button', {title: "Show unused keys", onclick: -> $setState unused: not state.unused}, "#{if state.unused then '☑' else '☐'} Unused"
      m '.hb-lib-scrollbox', style: "padding-bottom: 15ex",
        compact R.toPairs(groups).sort().map ([title, xs]) => do (items = @filtered xs, title) => notEmpty(items) and m ViewGroup, {title, items, key: title, @filter}
        noGroup and m ViewGroup, {@filter, items: @filtered noGroup}
    ]

  m.mount overlay, view: -> do (library = $libraryData(), groups = R.sort descending, GM_getValue 'groups', []) -> notEmpty(library) and [
    m 'h4.hb-lib-overlay-header',
      state.open and m('span.hb-lib-grow', "Humble library")
      m('span.hb-lib-overlay-toggle', {onclick: -> $setState open: not state.open}, if state.open then '×' else '+')
    state.open and m '.hb-lib-overlay-body', switch
      when state.purchase  then m BrowsePurchase, id: state.purchase, items: R.values(library).filter purchaseFilter $purchasesData()[state.purchase]
      when state.publisher then m BrowsePublisher, name: state.publisher, items: R.values(library).filter (x) -> x.publisher is state.publisher
      when state.view      then m ViewItem, state.view
      when state.group     then m EditGroup, title: state.group, items: grouping(groups, library)[state.group] or []
      else m Browse, {grouped: grouping(groups, library)}
  ]

  setInterval $redraw, MINUTE//4

`;
eval( CoffeeScript.compile(inline_src) );