您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Quick management of owned HumbleBundle stuff
当前为
// ==UserScript== // @name Humble librarian // @namespace http://tampermonkey.net/ // @version 0.4 // @description Quick management of owned HumbleBundle stuff // @author LeXofLeviafan // @icon https://humblebundle-a.akamaihd.net/static/hashed/47e474eed38083df699b7dfd8d29d575e3398f1e.ico // @match *://www.humblebundle.com/* // @require https://unpkg.com/mreframe/dist/mreframe.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/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 DELAY = SECOND//4 {reagent: r, reFrame: rf, atom: {deref, reset}, util: {keys, entries, dict, getIn, assoc, assocIn, merge, update}} = require 'mreframe' compact = R.filter R.identity each = R.flip R.forEach prefixOf = R.flip R.startsWith sort = R.sortBy R.identity descending = R.descend R.identity $merge = Object.assign notEquals = R.compose R.complement, R.equals notEmpty = (x) -> not R.isEmpty (x or []) nilEmpty = (x) -> if notEmpty x then x qstr = (s) -> if not s.includes('?') then "" else s[1 + s.indexOf '?'..] query = (s) -> dict compact (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) -> 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} throttle = (delay, action, args...) -> do (last = 0) -> -> now = +new Date if now > last+delay last = now action args... debounce = (delay, action) -> do (last = null) -> -> clearTimeout last last = $timer delay, action cache = (expire, f) -> do (value = null) -> calc = throttle expire, -> value = f() -> calc(); value $storageCache = (key, default_) -> cache DELAY, -> GM_getValue key, default_ $update = (key, data) -> GM_setValue key, merge((GM_getValue key), data) $storageUpdater = (key) -> do (changes = {}) -> push = debounce DELAY, -> console.warn {changes}; $update(key, changes); changes = {}; rf.disp ['refresh'] (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', {} [$choiceData, $choicePush] = $storage 'choice', {} $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' MONTH_FILTER = ['unclaimed', 'claimed', 'unseen'] MONTHS = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december'] CHOICE_BUNDLES = Array.from do (now = new Date) -> do (year = now.getFullYear(), month = now.getMonth()+1) -> while year > 2019 or month >= 12 yield "#{year}-#{(""+month).padStart 2, '0'}" if month > 1 then month -= 1 else [year, month] = [year-1, 12] 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())...] matcher = (subs) -> do (z = R.toLower "#{subs}") -> (s) -> R.includes z, R.toLower "#{s}" parseDate = (s) -> new Date("#{s} UTC").toJSON().replace(/T.*/, '') htmlDecode = (s) -> s and new DOMParser().parseFromString(s, "text/html").documentElement.textContent if HOST is "www.humblebundle.com" purchases = dict ([x.title.replace(/^Humble [^ ]+ Bundle: /, '').replace(/^Humble (.+) Bundle$/, '$1'), x.date] for _, x of $purchasesData()) GM_addStyle ".hb-lib-owned {color: limegreen !important}" $find_("a.bundle").forEach (e) -> do (name = $find(".name", e)) -> if htmlDecode(name?.innerHTML) of purchases name.classList.add 'hb-lib-owned' e.title = "Owned since #{purchases[name.innerHTML]}" 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.includes "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] = meta.childNodes {title, link, info: $text(info).replace(/ \|$/, "")} _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) -> platformSections.flatMap (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 = R.applySpec 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 = R.applySpec 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 = R.applySpec 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 _initLib = R.once -> do (_update = (data) -> $updateData($libraryData, $libraryPush, data.title, data)) -> _syncList = -> each $find_(".subproducts-holder > *"), (e) -> _update _selector e _syncDetails = (e) -> _steamLookups _update _details e $watcher(debounce 5*SECOND, _syncList).observe $find(".subproducts-holder"), childList: yes $watcher(_syncDetails).observe $find(".details-holder"), childList: yes _initKeys = R.once -> do (_syncKeys = (e) -> $steamLookup $find(".platform .hb-steam", e), $find(".game-name h4", e)) -> R.map _syncKeys, $find_ ".unredeemed-keys-table tr" $watcher(_syncKeys).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 $forever -> location.pathname is "/home/library" and _initLib() $forever -> location.pathname is "/home/keys" and _initKeys() $forever -> location.pathname is "/home/purchases" and _initPurchases() when PAGE.match "^/(software|games|books)/" GM_addStyle ".hb-lib-owned .item-title {color: limegreen !important} .hb-lib-owned.item-details:hover .img-container:after {box-shadow: inset 0 0 0 2px limegreen}" each $find_(".tier-item-details-view"), (e) -> $steamLookup $find(".hb-steam", e), $find(".header-area .heading-medium", e) _convert = R.replace /\s+/g, " " _keys = new Set R.values( $libraryData() ).flatMap((x) -> (x.aliases or []).concat (x.keys or []).map R.prop 'title').map _convert each $find_(".item-details"), (e) -> do (title = $text($find ".item-title", e), sub = $find(".item-flavor-text", e)) -> 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 "^/membership/" each $find_(".content-choice"), (e) -> $steamLookup $find(".hb-steam", e), $find(".content-choice-title", e) each $find_(".js-choice-details"), (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 each $find_(".js-content-choices"), (bundle) -> _month = $find(".content-choices-title span", bundle).getAttribute('data-machine-name').match(/([a-z]+)_([0-9]+)_choice/) if _month and _month[1] in MONTHS month = _month[2] + "-" + "#{MONTHS.indexOf(_month[1]) + 1}".padStart(2, "0") games = if $find(".js-initialize-multiselect", bundle) dict $find_(".content-choice:not(.claimed)", bundle).map (e) -> [$find(".choice-image-container", e).getAttribute('data-machine-name'), title: $find(".content-choice-title", e).innerHTML.trim() image: $find("img", e).src or null type: $find_(".delivery-methods i", e).map((it) -> it.getAttribute('aria-label')).join(" ") or null] $choicePush month, games when PAGE.match "^/store" each $find_(".entity"), (e) -> $steamLookup $find(".hb-steam", e), do (x = $find(".entity-title", e)) -> x?.title or x _watcher = $watcher (e) -> e.classList and each $find_(".entity, .entity-block-container", e), (x) -> $steamLookup $find(".hb-steam", x), $find(".entity-title", x) each [$find_('.entity-lists')..., $find('.search-results-holder')], (e) -> e and _watcher.observe e, childList: yes, subtree: yes _productWatcher = $watcher (e) -> if e.className is "product-details-page" each ($find(".#{s} .hb-steam", e) for s in ['platform-delivery', 'availability-section', 'user-rating-view']), (x) -> x and $steamLookup x, $find(".js-human-name .human_name-view") _watcher.observe $find(".recommendations-row", e), childList: yes, subtree: yes _productWatcher.observe $find(".js-page-content"), childList: 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, dict keys.map (e) -> [$text(e), if title is "Key" then "" else title] $updateData $purchasesData, $purchasesPush, data.id, update(data, 'keys', merge, _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, dict $find_(".download-rows .row .title", section).map (e) -> [$text(e), platforms[0]] $updateData $purchasesData, $purchasesPush, data.id, update(data, 'downloads', merge, _downloads) _couponsWatcher = $watcher (e) -> do (name = $find(".coupon-name", e)?.innerText, icon = $find(".platforms .hb-steam", e)) -> name and icon and $steamLookup icon, name.replace(/^ *[0-9]+% off +/, "") _subproductsWatcher = $watcher (e) -> e.tagName and do (data = _data(), key = $text $find("h4", e)) -> $updateData $purchasesData, $purchasesPush, data.id, assocIn(data, ['keys', key], "Unique Links") 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 do (e = $find ".js-coupon-whitebox-holder") -> e and _couponsWatcher.observe e, childList: yes do (e = $find ".js-subproduct-whitebox-holder") -> e and _subproductsWatcher.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-tab {background: darkgrey; border: none} .hb-lib-tab:disabled {background: lightgrey} .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-overlay .hidden {display: none} .hb-lib-custom-title, .hb-lib-custom-group, .hb-lib-row {display: flex} .hb-lib-publisher {font-size: x-small} .hb-lib-select-types, .hb-lib-custom-type select, .hb-lib-custom-type option, .hb-lib-month {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} .hb-lib-preview-centered {position: fixed; bottom: 0; left: 0; padding: 2em; background: rgba(0, 0, 0, 0.8); pointer-events: none} .banner .dismiss-button {top: auto; bottom: 0} .hb-lib-links a {color: cadetblue} .hb-lib-links .unseen a {color: darkmagenta}" overlay = $e 'div', className: 'hb-lib-overlay' document.body.appendChild overlay _all = (ks) -> dict ([k, yes] for k in ks) defState = -> open: no, tab: 'lib', view: {}, expand: {}, item: null, filter: "", unused: no, types: _all(TYPE), status: _all(MONTH_FILTER) _stored = (o={}, open=o.open) -> merge o, if open lib: $libraryData(), purchases: $purchasesData(), groups: groupList(), prefixGroups: GM_getValue('groups', []), unclaimed: $choiceData() typeFilter = ({types, unused}) -> (item) -> types[item.type or 'game'] and (not unused or (item.keys or []).some R.propEq 'status', 'unused') itemFilter = (predicate) -> (o) -> do (_typeFilter = typeFilter o) -> (item) -> predicate(title_ item) and _typeFilter item purchaseFilter = ({keys={}, downloads={}, id}) -> (item) -> keys[item.title] in ["", "Unique Links"] or (item.keys or []).some((x) -> x.title of keys) or R.unnest(R.values item.downloads?.files or {}).some((x) -> x.title of downloads) purchaseUrl = (id) -> "https://www.humblebundle.com/downloads?key=#{id}" purchaseTitle = ({title, date}) -> title + (if date then " (#{date})" else "") monthId = (iso) -> do ([_, year, month] = iso.match /([0-9]+)-([0-9]+)/) -> "#{MONTHS[Number(month) - 1]}-#{year}" choiceBundleUrl = (month) -> "https://www.humblebundle.com/membership/#{monthId month}" choiceUrl = ({id, month}) -> "#{choiceBundleUrl month}/#{id}" {trust} = require 'mithril/hyperscript' rf.regSub 'lib', getIn rf.regSub 'purchases', getIn rf.regSub 'groups', getIn rf.regSub 'prefixGroups', getIn rf.regSub 'unclaimed', getIn rf.regSub 'open', getIn rf.regSub 'tab', getIn rf.regSub 'expand', getIn rf.regSub 'filter', getIn rf.regSub 'unused', getIn rf.regSub 'types', getIn rf.regSub 'status', getIn rf.regSub 'item', getIn rf.regSub 'view', getIn ['title', 'group', 'publisher', 'purchase'].forEach (k) -> rf.regSub k, '<-', ['view', k], R.identity rf.regSub 'sorted', '<-', ['lib'], (o) -> R.sortBy R.compose(R.toLower, title_), R.values o rf.regSub 'grouping', '<-', ['prefixGroups'], '<-', ['sorted'], ([groups, items]) -> R.groupBy ((x) -> x.group or groups.find(prefixOf title_ x) or ""), items rf.regSub 'group-names', '<-', ['grouping'], (o) -> sort compact keys o rf.regSub 'group-names*', '<-', ['group-names'], '<-', ['item', 'group'], ([xs, s]) -> xs.filter matcher s rf.regSub 'group-items', '<-', ['grouping'], '<-', ['group'], ([groups, k]) -> groups[k] or [] rf.regSub 'unfiltered', '<-', ['grouping'], (groups, [_, title=""]) -> groups[title] or [] rf.regSub 'title-filter', '<-', ['filter'], (filter) -> matcher filter or "" rf.regSub 'filtering', '<-', ['title-filter'], (p, [_, title]) -> p title rf.regSub 'predicate', '<-', ['title-filter'], '<-', ['types'], '<-', ['unused'], ([p, types, unused], [_, title]) -> (if p title then typeFilter else itemFilter p)({types, unused}) rf.regSub 'filtered', (([_, k=""]) -> [['predicate', k], ['unfiltered', k]].map rf.subscribe), ([predicate, items]) -> items.filter predicate rf.regSub 'purchase-ids', '<-', ['purchases'], (o) -> R.sortBy ((k) -> o[k].date), keys o rf.regSub 'item-meta', (-> dict "title icon url publisher keys downloads".split(" ").map (k) -> [k, rf.subscribe ['item', k]]), R.identity rf.regSub 'item-categories', '<-', ['item', 'downloads', 'categories'], (s) -> s and s.split " " rf.regSub 'category-files', '<-', ['item', 'type'], '<-', ['item', 'downloads', 'files'], '<-', ['item-categories'], ([type, files, categories], [_, k]) -> (type not in EXEC_TYPES or k not in EXECUTABLE) and files[k] rf.regSub 'item-keys', '<-', ['item', 'keys'], (xs) -> R.groupBy R.propOr("Generic", 'platform'), (xs or []) rf.regSub 'item-keys*', '<-', ['item-keys'], (o) -> nilEmpty keys(o).sort().map (k) -> [k, o[k]] rf.regSub 'purchases-of-item', '<-', ['purchases'], '<-', ['purchase-ids'], '<-', ['item-meta'], ([purchases, ids, item]) -> ids.map((id) -> purchases[id]).filter (x) -> purchaseFilter(x) item rf.regSub 'items-in-purchase', (([_, id]) -> [['purchases', id], ['sorted']].map rf.subscribe), ([purchase, items]) -> items.filter purchaseFilter purchase rf.regSub 'items-by-publisher', '<-', ['sorted'], '<-', ['publisher'], ([xs, k]) -> xs.filter (x) -> x.publisher is k rf.regSub 'choice-bundles', '<-', ['unclaimed'], '<-', ['status'], ([o, {unseen, claimed, unclaimed}]) -> CHOICE_BUNDLES.filter (k) -> if not o[k] then unseen else if notEmpty o[k] then unclaimed else claimed rf.regSub 'unclaimed-month', '<-', ['unclaimed'], '<-', ['title-filter'], ([o, p], [_, month]) -> xs = o[month] and entries(o[month]).map(([id, x]) -> merge x, {id, month}) [xs?.length, xs?.filter (x) -> p x.title] rf.regEventDb 'set', [rf.unwrap], merge rf.regEventFx 'open', [rf.unwrap, rf.path 'open'], (_, open) -> db: open, refresh: open rf.regEventDb 'set-tab', [rf.unwrap, rf.path 'tab'], (_, tab) -> tab rf.regEventFx 'refresh', [], ({db}) -> refresh: db.open and not db.item rf.regEventDb 'expand', [rf.trimV, rf.path 'expand'], (expand, [k, v=yes]) -> assoc expand, k, v rf.regEventDb 'type-filter', [rf.trimV, rf.path 'types'], (types, [k, v]) -> assoc types, k, v rf.regEventDb 'month-filter', [rf.trimV, rf.path 'status'], (status, [k, v]) -> assoc status, k, v rf.regEventDb '-lib', [rf.unwrap, rf.path 'lib'], (lib, o) -> merge lib, o _edit = (item) -> {db: item, lib: [item]} rf.regEventFx 'edit', [rf.unwrap, rf.path 'item'], ({db: item}, changes) -> _edit merge(item, changes) _editAlias = (f) -> ({db: item}, [_, args...]) -> _edit update(item, 'aliases', f, args...) rf.regEventFx 'add-alias', [rf.path 'item'], _editAlias (aliases) -> [(aliases or [])..., ""] rf.regEventFx 'del-alias', [rf.path 'item'], _editAlias (aliases, i) -> nilEmpty R.remove(i, 1, aliases) rf.regEventFx 'set-alias', [rf.path 'item'], _editAlias (aliases, i, s) -> R.update(i, s, aliases) rf.regEventFx 'toggle', [rf.trimV], (_, [checkbox, changes={}]) -> {refocus: checkbox, dispatch: ['edit', if checkbox.checked then changes else dict(keys(changes).map (k) -> [k])]} rf.regEventFx 'set-types', [rf.trimV], (_, [items, type]) -> lib: items.map (x) -> merge x, {type} rf.regEventFx 'set-view', [rf.unwrap], ({db}, {title, group, ...view}, old=db.view.title) -> group = group or (getIn(db, ['items', title, 'group']) or R.findLast(((s) -> title.startsWith s), db.groups) or "" if title) db: merge(db, item: db.lib[title], view: merge(view, {title, group})) fx: [old and title isnt old and ['dispatch', ['set', lib: assoc(db.lib, old, db.item)]]] refresh: yes rf.regEventFx 'view-item', [rf.unwrap], (_, {title}) -> dispatch: ['set-view', {title}] rf.regEventFx 'add-prefix-group', [rf.path 'filter'], ({db: filter}) -> {db: "", addGroup: filter, refresh: yes} rf.regEventFx 'del-prefix-group', [rf.path 'view'], ({db: {group}}) -> {db: {}, delGroup: group, refresh: yes} rf.regEventFx 'rename-group', ({db}, [_, group, items]) -> do () -> db: update db, 'view', merge, {group} fx: unless db.view.group in db.prefixGroups then [] else [['delGroup', db.view.group], ['addGroup', group]] lib: items.map (x) -> x.group and merge(x, {group}) rf.regFx 'lib', (items) -> do (items = compact items) -> each items, (x) -> $libraryPush x.title, x rf.regFx 'refocus', (checkbox, buddy=checkbox.parentNode.nextSibling) -> checkbox.checked and $timer DELAY, -> buddy.focus() rf.regFx 'addGroup', $addGroup rf.regFx 'delGroup', $delGroup rf.regFx 'refresh', (open=rf.dsub ['open']) -> rf.disp ['set', _stored({}, open)] ToggleableInput = (_disabled) -> (disabled, name, value, [key, default_], extra={}) -> if _disabled isnt disabled then _disabled = disabled; disabled or setTimeout => @dom.lastChild.focus() toggle = [key]: if disabled then default_ else null [".hb-lib-custom-#{name}", title: "Custom #{name}" ['label', ['input[type=checkbox]', {checked: not disabled, onchange: -> rf.disp ['edit', toggle]}]] ['input.hb-lib-grow', {disabled, value, ...extra, oninput: -> rf.dispatchSync ['edit', [key]: @value]}]] ViewItem = -> do ([showGroups, hover] = [no, null].map r.atom) ->-> [{title, url, icon, type, publisher, links, aliases=[]}] = [item, categories, platforms, group, groups] = [['item'], ['item-categories'], ['item-keys*'], ['group'], ['group-names*']].map rf.dsub ['<>', ['.hb-lib-overlay-header' icon and ['img.hb-lib-icon', src: icon] ['.hb-lib-entry-title.hb-lib-grow' ['a[target=_blank]', {href: url}, title] ['.hb-lib-publisher.hb-lib-selectable', {onclick: -> rf.disp ['set-view', {title, publisher}]}, publisher]] ['.hb-lib-overlay-toggle', {onclick: -> rf.disp ['set-view', {}]}, "⇚"]] ['.hb-lib-scrollbox' [ToggleableInput, R.isNil(item.title_), 'title', title_(item), ['title_', ""]] [ToggleableInput, R.isNil(item.group), 'group', item.group or group, ['group', group], {placeholder: "Custom group…", onfocus: (-> reset showGroups, yes), onblur: (-> $timer DELAY, -> reset showGroups, no)}] if deref showGroups ['<>', ...groups.map (s) -> ['.hb-lib-selectable', {key: s, onclick: -> reset(showGroups, no); rf.disp ['edit', group: s]}, s]] ['.hb-lib-custom-type', title: "Type" ['label', "Type: " ['select', {onchange: -> rf.disp ['edit', type: @value or null]} ...TYPE.map (s) -> ['option', {selected: type is s, value: if s is 'game' then "" else s}, s]]]] ['.hb-lib-aliases', title: "Aliases can be used for additional key matching (in case of typos)" ['.hb-lib-row', ['.hb-lib-grow', "Aliases"], ['button', {onclick: -> rf.disp ['add-alias']}, "+"]] ...aliases.map (s, i) -> ['.hb-lib-row', ['input.hb-lib-grow', {title: s, value: s, oninput: -> rf.disp ['set-alias', i, @value]}] ['button', {onclick: -> rf.disp ['del-alias', i]}, "−"]]] ...rf.dsub(['purchases-of-item']).map (x) -> ['.hb-lib-row', "Found in:", ['.hb-lib-purchase.hb-lib-selectable', {onclick: -> rf.disp ['set-view', {title, purchase: x.id}]}, purchaseTitle x]] if categories ['.hb-lib-downloads' ['.hb-lib-category', "Downloads: ", categories.join ", "] ...compact categories.map (k) -> do (files = rf.dsub ['category-files', k]) -> if files ['.hb-lib-category', {key: k}, "#{k} downloads:" ['ul', ...files.map (x) -> ['li', "#{x.title} (#{x.link})", ['br'], "#{x.info}"]]]] if platforms ['<>', ...platforms.map ([k, xs]) -> ['.hb-lib-category', {key: k}, "#{k} keys:", ['ul', ...xs.map (x) -> ['li', {onmouseenter: -> reset hover, x.title}, "#{x.title} [#{x.status}]" if deref(hover) is x.title then ['<>', trust(x.instructions), trust(x.expiration)] else (x.instructions or x.expiration) and " (…)"]]]] if links ['.hb-lib-category', "Additional content:", ['ul', ...links.map (s, i) => ['li', {onmouseenter: => reset hover, "link##{i}"}, unless deref(hover) is "link##{i}" then "(…)" else trust s]]]]] EditGroup = -> do ([title, items, prefixGroups] = [['group'], ['group-items'], ['prefixGroups']].map rf.dsub) -> ['<>' ['.hb-lib-overlay-header', title: "Title" ['input.hb-lib-grow', {value: title, onchange: -> rf.disp ['rename-group', @value, items]}] ['.hb-lib-overlay-toggle', {onclick: -> rf.disp ['set-view', {}]}, "⇚"]] title in prefixGroups and ['button', {onclick: -> rf.disp ['del-prefix-group', title]}, "Remove prefix group"] ['.hb-lib-custom-type', title: "Type" ['label', "Set type for all: " ['select', {onchange: -> rf.disp ['set-types', items, @value or null]} ['option', {selected: yes, disabled: yes}] ...TYPE.map (s) -> ['option', {value: if s is 'game' then "" else s}, s]]]] ['ul.hb-lib-scrollbox.hb-lib-group', ...items.map (x) -> ['li', {key: x.title, title: x.title}, title_(x), x.type and " [#{x.type}]"]]] ViewGroupItem = (item, preview, setPreview) -> do ({title, type, icon} = item) -> ['<>' ['li.hb-lib-selectable', {title, onclick: (-> rf.disp ['set-view', {title}]), onmouseenter: (-> setPreview title), onmouseleave: -> setPreview()} title_(item), type and " [#{type}]"] preview and icon and ['.hb-lib-preview', ['a', ['img', src: icon]]]] ViewGroup = -> do (preview = r.atom()) -> do (_setPreview = (s) -> setTimeout -> reset preview, s) -> (title) -> [items, filtering, open] = [['filtered', title], ['filtering', title], ['expand', title]].map rf.dsub do (_preview = deref preview) -> if notEmpty items ['.hb-lib-group' ['.hb-lib-overlay-header.hb-lib-selectable', not filtering and {onclick: => rf.disp ['expand', title, not open]}, ['.hb-lib-grow', title and {title: "Right/middle click to edit", ...auxclick(-> rf.disp ['set-view', group: title])}, title or "—", " [#{items.length}]"] not filtering and ['.hb-lib-overlay-toggle', if open then '−' else '+']] (open or filtering) and ['ul', ...items.map (x) -> r.with {key: x.title}, [ViewGroupItem, x, _preview is x.title, _setPreview]]] Choice = -> do (preview = r.atom()) ->-> do ([filter, status, bundles] = [['filter'], ['status'], ['choice-bundles']].map rf.dsub) -> ['<>', ['.hb-lib-row' ['input.hb-lib-grow', {title: "Filter", placeholder: "Search…", value: filter, oninput: -> rf.dispatchSync ['set', filter: @value]}]] ['.hb-lib-select-types', title: "Status filter" ...MONTH_FILTER.map (k) -> ['label', ['input[type=checkbox]', {checked: status[k], onchange: -> rf.disp ['month-filter', k, @checked]}], k]] ['.hb-lib-scrollbox.hb-lib-links', style: "padding-bottom: 18ex", ...bundles.map (month) -> do (id = monthId(month), [total, games] = rf.dsub ['unclaimed-month', month]) -> r.with {key: month}, ['.hb-lib-group', class: {unseen: not games, claimed: total is 0, hidden: filter and R.isEmpty(games or [])} ['.hb-lib-overlay-header', title: "#{month} (#{if total then 'unclaimed' else if total? then 'claimed' else 'unseen'})" ['.hb-lib-grow.hb-lib-month', ['a[target=_blank]', {href: choiceBundleUrl month}, id.replace("-", " ")], " [#{if total? then total else '?'}]"]] ['ul', ...(games or []).map (x) -> r.with {key: x.id}, ['<>' ['li', {title: x.title, onmouseenter: (-> reset preview, x.id), onmouseleave: -> reset preview}, ...(compact (x.type or "").split " ").map (k) -> ['<>', ["i.hb.hb-#{k}"], " "] ['a[target=_blank]', {href: choiceUrl x}, x.title]] deref(preview) is x.id and x.image and ['.hb-lib-preview-centered', ['img', src: x.image]]]]]]] BrowsePurchase = -> do (preview = r.atom(), id = rf.dsub ['purchase']) ->-> [purchase, items, title] = [['purchases', id], ['items-in-purchase', id], ['title']].map rf.dsub ['<>', ['.hb-lib-overlay-header.hb-lib-selectable', ['.hb-lib-grow', "Purchase: ", ['a[target=_blank]', {href: purchaseUrl id}, purchaseTitle purchase], " [#{items.length}]"] ['.hb-lib-overlay-toggle', {onclick: -> rf.disp ['set-view', {title}]}, "⇚"]] ['ul.hb-lib-group.hb-lib-scrollbox', {style: "padding-bottom: 22ex"}, ...items.map (x) -> r.with {key: x.title}, ['<>', ['li.hb-lib-selectable' {title: x.title, onmouseenter: (-> reset preview, x.title), onmouseleave: (-> reset preview), onclick: -> rf.disp ['view-item', x]} title_(x), x.type and " [#{x.type}]"] deref(preview) is x.title and x.icon and ['.hb-lib-preview', ['a', ['img', src: x.icon]]]]]] BrowsePublisher = -> do (preview = r.atom()) ->-> [name, items, title] = [['publisher'], ['items-by-publisher'], ['title']].map rf.dsub ['<>', ['.hb-lib-overlay-header' ['.hb-lib-grow', "Publisher: #{name} [#{items.length}]"] ['.hb-lib-overlay-toggle', {onclick: -> rf.disp ['set-view', {title}]}, "⇚"]] ['ul.hb-lib-group.hb-lib-scrollbox', {style: "padding-bottom: 22ex"}, ...items.map (x) => r.with {key: x.title}, ['<>' ['li.hb-lib-selectable' {title: x.title, onmouseenter: (-> reset preview, x.title), onmouseleave: (-> reset preview), onclick: => rf.disp ['view-item', x]} title_(x), x.type and " [#{x.type}]"] deref(preview) is x.title and x.icon and ['.hb-lib-preview', ['a', ['img', src: x.icon]]]]]] Browse = -> do ([filter, unused, types, groups] = [['filter'], ['unused'], ['types'], ['group-names']].map rf.dsub) -> ['<>', ['.hb-lib-row' ['input.hb-lib-grow', {title: "Filter", placeholder: "Search…", value: filter, oninput: -> rf.dispatchSync ['set', filter: @value]}] ['button', {disabled: not filter, onclick: -> rf.disp ['add-prefix-group']}, "Add prefix group"]] ['.hb-lib-select-types', title: "Types", ...TYPE.map (k) -> ['label', ['input[type=checkbox]', {checked: types[k], onchange: -> rf.disp ['type-filter', k, @checked]}], k] ['button', {title: "Show unused keys", onclick: -> rf.disp ['set', unused: not unused]}, "#{if unused then '☑' else '☐'} Unused"]] ['.hb-lib-scrollbox', style: "padding-bottom: 18ex", ...groups.map (title) -> r.with {key: title}, [ViewGroup, title] r.with {key: ""}, [ViewGroup, ""]]] Header = -> do (tab = rf.dsub ['tab']) -> ['span.hb-lib-grow', ...entries(lib: "Humble library", choice: "Humble Choice").map ([k, s]) -> ['button.hb-lib-tab', {disabled: tab is k, onclick: -> rf.disp ['set-tab', k]}, s]] Overlay = -> do ([open, lib, tab] = [['open'], ['lib'], ['tab']].map rf.dsub) -> if notEmpty lib ['<>', ['h4.hb-lib-overlay-header' open and [Header] ['span.hb-lib-overlay-toggle', {onclick: -> rf.disp ['open', not open]}, if open then '×' else '+']] open and ['.hb-lib-overlay-body', switch when tab is 'choice' then [Choice] when rf.dsub ['purchase'] then [BrowsePurchase] when rf.dsub ['publisher'] then [BrowsePublisher] when rf.dsub ['item'] then [ViewItem] when rf.dsub ['group'] then [EditGroup] else [Browse]]] rf.dispatchSync ['set', _stored(defState(), yes)] setInterval (-> rf.disp ['refresh']), MINUTE//4 r.render [Overlay], overlay document.addEventListener 'keydown', (e) -> if e.key is 'Escape' then rf.disp ['set', _stored( defState() )] `; eval( CoffeeScript.compile(inline_src, {inlineMap: true}) );