Humble librarian

Quick management of owned HumbleBundle stuff

当前为 2020-06-06 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Humble librarian
// @namespace    http://tampermonkey.net/
// @version      0.3.5
// @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';  e.stopPropagation();  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
      _convert = R.replace /\s+/g, " "
      keys = new Set mapcat(R.values( $libraryData() ), (x) -> (x.aliases or []).concat (x.keys or []).map R.prop 'title').map _convert
      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})"].map(_convert).some (s) -> s of $libraryData() or keys.has s
          e.classList.add 'hb-lib-owned'
          e.title = "Already in collection"

    when PAGE.match "^/subscription/"

      $find_(".content-choice").forEach (e) -> $steamLookup $find(".hb-steam", e), $find(".content-choice-title", e)
      $find_(".js-choice-details").forEach (e) -> $steamLookup $find(".hb-steam", e), $find(".title span", e)
      _watcher = $watcher (e) -> $steamLookup $find(".hb-steam", e), $find(".title span", e)
      _watcher.observe $find("#site-modal"), childList: yes, subtree: yes

    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] or {id: PARAMS.key, title: $find('#hibtext').childNodes[2].data?.trim()}]
      _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
  purchaseTitle = (item) -> item.title + (if item.date then " (#{item.date})" else "")
  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', {title: s, 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}, purchaseTitle x
      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: #{purchaseTitle purchase} [#{items.length}]"
      m '.hb-lib-overlay-toggle', {onclick: -> $setState purchase: null}, '⇚'
    m 'ul.hb-lib-group.hb-lib-scrollbox', style: "padding-bottom: 20ex", R.sort(title_, 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", R.sort(title_, 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) );