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