// ==UserScript== // @name Backloggery interop // @namespace http://tampermonkey.net/ // @version 0.10.1 // @description Backloggery integration with game library websites // @author LeXofLeviafan // @icon https://backloggery.com/favicon.ico // @include *://backloggery.com/* // @include *://www.backloggery.com/* // @include *://steamcommunity.com/id//games/* // @exclude *://steamcommunity.com/id//games/* // @include *://steamcommunity.com/id//stats/* // @exclude *://steamcommunity.com/id//stats/* // @include *://steamcommunity.com/id/*/gamecards/* // @include *://steamcommunity.com/id/*/badges* // @include *://steamcommunity.com/stats/*/achievements // @include *://steamcommunity.com/stats/*/achievements/* // @include *://store.steampowered.com/app/* // @include *://steamdb.info/app/* // @include *://steamdb.info/calculator/* // @include *://astats.astats.nl/astats/User_Games.php?* // @include *://gog.com/account // @include *://gog.com/*/account // @include *://www.gog.com/account // @include *://www.gog.com/*/account // @include *://www.humblebundle.com/home/* // @include *://itch.io/my-collections // @include *://*.itch.io/* // @include *://www.gamersgate.com/account/* // @include *://store.epicgames.com/* // @include *://psnprofiles.com/ // @include *://psnprofiles.com/*?* // @include *://psnprofiles.com/trophies/* // @exclude *://psnprofiles.com/ // @require https://cdnjs.cloudflare.com/ajax/libs/mithril/2.0.4/mithril.min.js // @require https://cdn.jsdelivr.net/npm/coffeescript@2.4.1/lib/coffeescript-browser-compiler-legacy/coffeescript.js // @grant GM_info // @grant GM_getValue // @grant GM_setValue // @grant GM_addStyle // @downloadURL none // ==/UserScript== var inline_src = String.raw` ROMANS = {Ⅰ: 'I', Ⅱ: 'II', Ⅲ: 'III', Ⅳ: 'IV', Ⅴ: 'V', Ⅵ: 'VI', Ⅶ: 'VII', Ⅷ: 'VIII', Ⅸ: 'IX', Ⅹ: 'X', Ⅺ: 'XI', Ⅻ: 'XII', Ⅼ: 'L', Ⅽ: 'C', Ⅾ: 'D', Ⅿ: 'M'} roman = RegExp("[#{Object.keys(ROMANS).join('')}]", 'g') identity = (x) -> x merge = (os...) -> Object.assign {}, os... fromPairs = (pairs) -> merge ([k]: v for [k, v] in pairs.filter identity)... keymap = (ks, f) -> fromPairs ([k, f k] for k in ks) objmap = (o, f) -> fromPairs ([k, f(v, k, o)] for k, v of o) objfilter = (o, f) -> fromPairs ([k, v] for k, v of o when f(v, k, o)) pick = (o, keys...) -> fromPairs ([k, o[k]] for k in keys when k of o) method = (o, k, def=->) -> o?[k]?.bind?(o) or def setFn = (xs) -> method (new Set xs), 'has' last = (l) -> l[l.length - 1] when_ = (x, f) -> x and f(x) replace = (s, re, pattern) -> s.match(re) and s.replace(re, pattern) qstr = (s) -> if not s.includes('?') then "" else s[1 + s.indexOf '?'..] query = (s) -> fromPairs (l[1..] for l in qstr(s).split('&').map((s) -> s.match /([^=]+)=(.*)/) when l) slugify = (s) -> s.replace(roman, (c) -> ROMANS[c]).toLowerCase().replace(/[.]/g, '').replace(/[^a-z0-9+]+/g, '-').replace(/(^-*|-*$)/g, '') capitalize = (s) -> do (z = "#{s}") -> z[...1].toUpperCase() + z[1..] statStr = (o, ks...) -> ("#{capitalize k}: #{o[k]}" for k in ks when o[k] or o[k] is 0).join '\n' forever = (f) -> setInterval f, 100 delay = (f) -> new Promise (resolve) -> setTimeout -> resolve f() debounce = (delay, action) -> do (last = null) -> -> clearTimeout last last = setTimeout action, delay PAGE = location.href PARAMS = query location.search RE = backloggeryUpdate: "backloggery\\.com/update\\.php" backloggeryCreate: "backloggery\\.com/newgame\\.php" backloggeryLibrary: "backloggery\\.com/games\\.php" steamLibrary: "steamcommunity\\.com/id/[^/]+/games/\\?tab=all" steamRecent: "steamcommunity\\.com/id/[^/]+/games($|/$|/\\?tab=(recent|perfect))" steamAchievements: "steamcommunity\\.com/id/[^/]+/stats/[^/]+" steamAchievements2: "steamcommunity\\.com/stats/[^/]+/achievements" steamDetails: "store\\.steampowered\\.com/app/([^/]+)" steamDbDetails: "steamdb\\.info/app/[^/]+" steamDbLibrary: "steamdb\\.info/calculator/[^/]+/" steamStats: "astats\\.astats\\.nl/astats/User_Games\\.php" steamBadges: "steamcommunity\\.com/id/[^/]+/(gamecards|badges)" gogLibrary: "gog\\.com/([^/]+/)?account" humbleLibrary: "humblebundle\\.com/home/(library|purchases|keys|coupons)" itchLibrary: "itch\\.io/my-collections" itchDetails: "[^/.]\\.itch\\.io/[^/]+$" ggateLibrary: "gamersgate\\.com/account/games" epicStore: "epicgames\\.com" psnLibrary: "psnprofiles\\.com/([^/?]+)/?($|\\?)" psnDetails: "psnprofiles\\.com/trophies/([^/?]+)/([^/?]+)$" PSN_ID = (GM_info.script.options.override.use_includes or []).reduce ((x, s) -> x or s.match(RE.psnLibrary)?[1]), null _PSN_HW = {PS3: '3', PS4: '4', VITA: 'V'} _psnData = (images) -> (id) -> objmap(objfilter(GM_getValue('psn', {}), (x) -> _PSN_HW[id] in x.platforms), (x, k) -> merge x, image: images[k], url: "https://psnprofiles.com/trophies/#{k}/#{PSN_ID}") [EPIC_CDN, EPIC_STORE] = ["https://cdn1.epicgames.com", "https://www.epicgames.com/store/product/"] _epicUrls = (x) -> url: x.slug and EPIC_STORE+x.slug, icon: EPIC_CDN+x.icon, image: EPIC_CDN+x.image DATA = do (STATS = GM_getValue('steam-stats', {}), PLATFORMS = GM_getValue('steam-platforms', {}), psnData = _psnData(GM_getValue 'psn-img', {}), ITCH = objmap(GM_getValue('itch-info', {}), ({worksOn, rating, at, ...meta}) -> ({worksOn, rating, sync: at, meta}))) -> steam: objmap GM_getValue('steam', {}), (x, k) -> merge(x, url: "https://steamcommunity.com/app/#{k}", achievements: STATS[k] or '?', worksOn: PLATFORMS[k] or 's') gog: objmap GM_getValue('gog', {}), (x) -> merge(x, url: "https://gog.com#{x.url}", completed: if x.completed then 'yes' else 'no') humble: GM_getValue('humble', {}) epic: objmap GM_getValue('epic', {}), (x) -> merge(x, _epicUrls(x), features: ['online', 'cloud'].filter((k) -> x[k]).join ", ") itchio: objmap GM_getValue('itch', {}), (x, k) -> merge(x, ITCH[k], meta: {Acquired: x.date, ...(ITCH[k]?.meta or {})}) ggate: objmap GM_getValue('ggate', {}), (x, id) -> merge(x, url: "https://gamersgate.com/account/orders/#{id.replace(':', '#')}") ps3: psnData('PS3'), ps4: psnData('PS4'), psvita: psnData('VITA') OS = w: ["Windows", 'fa-windows'], l: ["Linux", 'fa-linux'], m: ["MacOS", 'fa-apple'], a: ["Android", 'fa-android'], s: ["Steam", 'fa-steam'], b: ["Web", 'fa-chrome'] CUSTOM_ICONS = {steam: "fab fa-steam", windows: "fab fa-windows", linux: "fab fa-linux", mac: "fab fa-apple", android: "fab fa-android", \ console: "fas fa-gamepad", xbox: "fab fa-xbox", playstation: "fab fa-playstation", web: "fab fa-chrome", \ nodejs: "fab fa-node-js", flash: "fab fa-adobe", dice: "fas fa-dice", d20: "fas fa-dice-d20", trophy: "fas fa-trophy"} slugs = (o) -> fromPairs ([slugify(v.name), k] for k, v of o) USER_OS = do (platform = (navigator.platform or navigator.userAgentData?.platform or "").toLowerCase()) -> switch when platform.startsWith "win" then 'windows'; when platform.startsWith "mac" then 'mac'; else 'linux' $clear = (e) -> e.removeChild e.firstChild while e.firstChild; e $append = (parent, children...) -> parent.appendChild e for e in children; parent $before = (neighbour, children...) -> neighbour.parentElement.insertBefore(e, neighbour) for e in children; neighbour $after = (neighbour, children...) -> $before(neighbour.nextSibling, children...); neighbour $e = (tag, options, children...) -> $append Object.assign(document.createElement(tag), options), children... $get = (xpath, e=document) -> document.evaluate(xpath, e, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue $find = (selector, e=document) -> e.querySelector selector $find_ = (selector, e=document) -> Array.from e.querySelectorAll selector $hasClass = (e, clss) -> do (l = e.classList) -> l and clss.split(' ').every((s) -> not s or l.contains s) $visibility = (e, x) -> e.style.visibility = if x then 'visible' else 'hidden' $markUpdate = (k) -> GM_setValue 'updated', merge(GM_getValue('updated'), [k]: +new Date) $assertEq = (a, b, err) -> a is b or alert(if typeof err isnt 'function' then err else err a, b) $stop = (f=->) -> (e) -> f e; e.stopPropagation(); no $keepScroll = (e, f) -> do (x = e.scrollTop) -> f(); m.redraw(); e.scrollTop = x $query = (url) -> new Promise (resolve, reject) -> do (xhr = new XMLHttpRequest) -> xhr.open 'GET', url [xhr.onerror, xhr.onload] = [reject, -> resolve JSON.parse xhr.response] xhr.send() $watcher = (f) -> new MutationObserver (xs) -> xs.forEach (x) -> x.addedNodes.forEach f NOISE = fromPairs "a an and as at by from for in into is of on or so the to collection edition remastered ii iii iv v vi vii viii ix x".split(" ").map (s) -> [s, on] words = (s) -> slugify(s).split('-').sort().reverse() matching = (ss, zs) -> do (res = 0, i = 0, j = 0) -> while i < ss.length and j < zs.length [s, z] = [ss[i], zs[j]] if s is z i++; j++; res += if s of NOISE or Number(s) then 1.1 else 2 else if z.startsWith s i++; j++; res += 1 else if s < z then j++ else i++ res order = (sets, exclude, text, k) -> do (d = DATA[k], l = words(text), f = (s) -> not exclude["#{k}##{s}"]) -> o = objmap(sets[k] or {}, (ss) -> matching l, ss) Object.keys(sets[k] or {}).sort (a, b) -> f(b)-f(a) or o[b]-o[a] or d[a].name.localeCompare d[b].name $addChanges = (newChanges) -> do (changes = GM_getValue 'changes', []) -> oldChanges = new Set changes GM_setValue 'changes', [changes..., (id for id in newChanges when not oldChanges.has id)...] WATCH_FIELDS = "name worksOn completed achievements platforms status trophies".split ' ' WATCH_META = {'steam-stats': 'steam', 'steam-platforms': 'steam'} WATCH_LIBRARY = {} $update = (library, games1) -> do (library_ = WATCH_LIBRARY[library] or library, games0 = GM_getValue library, {}) -> [ids1, ids0] = [games1, games0].map Object.keys removed = (id for id in ids0 when id not of games1) added = (id for id in ids1 when id not of games0) updated = (id for id in ids0 when id of games1 and WATCH_FIELDS.some (k) -> games0[id][k] isnt games1[id][k]) $markUpdate library $addChanges ("#{library_}##{id}" for id in [removed..., updated...]) GM_setValue library, games1 setTimeout -> alert "Backloggery interop: added #{added.length} games, removed #{removed.length} games\n(#{updated.length} of #{ids1.length} games changed)" $mergeData = (k, o) -> do (library = WATCH_META[k], old = GM_getValue k, {}) -> library and $addChanges ("#{library}##{id}" for id in Object.keys o when id of old and old[id] isnt o[id]) GM_setValue k, merge(old, o) $logo = (k, id) -> do (o = if k is 'custom' then id else DATA[k][id]) -> switch k when 'steam' then ["https://cdn.akamai.steamstatic.com/steam/apps/#{id}/capsule_184x69.jpg", "https://steamcdn-a.akamaihd.net/steam/apps/#{id}/header.jpg"] when 'gog' then [196, 392].map((x) -> "https:#{o.image}_#{x}.jpg") else o and [o.icon or o.image, o.image or o.icon] $append document.head, $e('link', rel: 'stylesheet', href: "https://use.fontawesome.com/releases/v5.7.0/css/all.css") GM_addStyle "#loader {position: fixed; top: 50%; left: 50%; z-index: 10000; transform: translate(-50%, -50%); font-size: 300px; text-shadow: -1px 0 grey, 0 1px grey, 1px 0 grey, 0 -1px grey}" GM_addStyle "@-webkit-keyframes rotation {from {-webkit-transform:rotate(0deg)} to {-webkit-transform:rotate(360deg)}} @keyframes rotation {from {transform:rotate(0deg) translate(-50%, -50%); -webkit-transform:rotate(0deg)} to {transform:rotate(360deg) translate(-50%, -50%); -webkit-transform:rotate(360deg)}}" GM_addStyle ".rotating {animation: rotation 2s linear infinite}" LOGO = ".logo {height: 0; width: 0; display: flex; flex-direction: row-reverse} .logo img {border: 1px solid darkorchid; background: #1b222f}" if location.host.match "^(www\\.)?backloggery\\.com$" for e in $find_("a[href$='console=PC'], #intro > .npgame > :nth-child(3)") e.innerText = "Windows" if e.innerHTML is "PC" if PAGE.match RE.backloggeryUpdate SETS = objmap DATA, (o) -> objmap(o, (x) -> words x.name) legend = $get '//*[@id="content-wide"]/section/form/fieldset[1]/legend' systemDropdown = $get '//*[@id="content-wide"]/section/form/fieldset[1]/div[2]' delBtns = $get '//*[@id="content-wide"]/section/form/div[2]/div' status = $get '//*[@id="content-wide"]/section/form/fieldset[2]/div[1]' _system = when_ systemDropdown, (e) -> $find('select', e) swap = -> do ([a, b] = [_system, $find '#detail2 select']) -> [a.value, b.value] = [b.value, a.value] do (_bl = GM_getValue('backlog', {})[PARAMS.gameid]) -> k = Object.keys(DATA).find (k) -> _bl?[k+'Id'] changeId = k and "#{k}##{_bl[k+'Id']}" GM_setValue 'changes', (id for id in GM_getValue('changes', []) when id isnt changeId) unless legend and systemDropdown and delBtns # deleted/doesn't exist backlog = GM_getValue('backlog', {}) if PARAMS.gameid in backlog delete backlog[PARAMS.gameid] GM_setValue('backlog', backlog) else for es in $find("[name=#{s}console]").children for s in ['', 'orig_'] e.innerText = "Windows" for e in es when e.value is "PC" $append systemDropdown, $e('tt', innerText: "⇄", onclick: swap, style: "cursor: pointer; padding-left: 8px; font-size: large") $before delBtns, $e('input', type: 'submit', name: 'submit2', className: 'greengray', value: "Stealth Save ⇄", onclick: swap) $after($find('.info.help', detail5), $e('b', id: 'achievements', style: "padding: 3.5ex")) $after($find('.info.help', status), $e('b', id: 'completed', style: "padding: 1ex")) GM_addStyle ".overlay {position: relative; max-height: 0; top: -40px; z-index: 2; margin-right: 10px; display: flex; flex-direction: row-reverse} .overlay input {height: 20px; background: #4b4b4b; color: white; width: 500px; border: 1px solid black; padding-left: 1ex; margin-bottom: 0} .overlay .options {display: flex; flex-direction: column; max-height: 500px; overflow-y: auto; background: grey} .overlay button {height: 28px; background: #4b4b4b; color: white; border-radius: 10px 8px 8px 10px; padding: 5px} .overlay .option {white-space: nowrap; display: flex; margin: .5px} .overlay .trash {cursor: pointer} .overlay * {flex-shrink: 0} .overlay button b {flex: 1; padding-left: 1ex; text-align: left; overflow: hidden; text-overflow: ellipsis} .oslist {display: flex; position: absolute; width: 505px; padding-top: 7.5px; pointer-events: none} .oslist.shift {padding-right: 20px} .os {padding-left: .75ex; font-size: 20px; color: white} .oslist .action {padding-left: 1ex; pointer-events: all} .action {color: white; cursor: pointer} .anchor .action {position: absolute; top: 10px; right: 7.5px} .iconlist {display: flex; position: absolute; width: 505px; padding-top: 7.5px; pointer-events: none; color: white !important} fieldset, .anchor {position: relative} .tooltip {background: rgba(0, 0, 0, 0.8)} .icons {padding-top: 1ex; text-align: center} .icons .btn {margin: .25em} .btn.selected {border-color: white} .btn {background: #4b4b4b; color: white; border: 1px solid black; cursor: pointer; font-size: 20px; padding: 2px; border-radius: 5px} .btn.fa-eye {margin-left: 1.25px} .done, .preview {display: block; margin: 1ex auto} .done {width: 90%; cursor: pointer} #{LOGO} .logo img {height: 100px} .preview {border: 1px solid darkorchid; max-width: calc(100% - 2ex)}" gameName = $find '[name=name]' _bl = GM_getValue('backlog')?[PARAMS.gameid] or {} excluded = GM_getValue('exclude', {}) data = (k=state.list) -> DATA[k] id = (s=state.list) -> "#{s}Id" id$ = (s=state.list) -> _bl[ id(s) ] eId$ = (k=id$(s), s=state.list) -> "#{s}##{k}" data$ = (s=state.list) -> data(s)?[ id$(s) ] title = (k=state.list) -> data$(k)?.name or gameName.value _icons = -> (_bl.custom?.icons or "").split ' ' _order = (s=state.title, k=state.list) -> order(SETS, excluded, s, k) state = do (list = (_system.value or '').toLowerCase()) -> list: list title: title list active: no order: _order(title(list), list) when_ data$(), (o) -> [achievements.innerText, completed.innerText] = [o.achievements or "", statStr(o, 'completed') or o.status or ''] _setBl = (o) -> $mergeData 'backlog', [PARAMS.gameid]: Object.assign(_bl, o) _delBl = (...ks) -> delete _bl[k] for k in ks; _setBl() _setExcl = (k, x) -> excluded[ eId$(k) ] = x $mergeData('exclude', [eId$(k)]: x) state.order = _order() if id$() and not data$() then _delBl id(), 'ignore' section2 = $find_('fieldset')[1] $append section2, $e('div', id: 'ignore', style: "position: absolute; top: -25px; left: 110px") m.mount ignore, view: -> id$() and [ do (x = !_bl.ignore) -> m('i.btn', class: "far fa-eye#{if x then '' else '-slash'}", title: (if x then "Watch" else "Ignore"), onclick: -> _setBl(ignore: x)) ] section1 = $find_('fieldset')[0] $before section1, $e('div', id: 'logo', className: 'logo') m.mount logo, view: -> switch when _bl.custom then m('a', {target: '_blank', href: _bl.custom.url}, m('img', src: $logo('custom', _bl.custom)[1])) when id$() then m('a', {target: '_blank', href: data$().url}, m('img', src: $logo(state.list, id$())[1])) $append section1, $e('div', id: 'custom', style: "position: absolute; top: -25px; left: 170px") toggleCustom = (x) -> -> state.active = no if _bl.custom then _delBl('custom') else _delBl(id(), 'ignore'); _setBl custom: {} m.mount custom, view: -> do (x = _bl.custom) -> m('i.btn', class: "fa fa-#{if x then 'edit' else 'list'}", title: (if x then "Custom" else "Listed"), onclick: toggleCustom x) preview = null document.addEventListener 'keydown', (e) -> if e.key is 'Escape' _delBl id(), 'ignore' Object.assign(state, active: no, title: title(), order: _order title()) preview = achievements.innerText = completed.innerText = "" m.redraw() $reset = -> do (list = (_system.value or '').toLowerCase()) -> if list isnt state.list Object.assign(state, {list}, active: no, title: title(list), order: _order(title(list), list)) m.redraw() $$ = (k) -> -> Object.assign(state, active: no, title: data()[k].name) state.order = _order() _setBl([id()]: k) when_ data$(), (o) -> [achievements.innerText, completed.innerText] = [o.achievements or "", statStr(o, 'completed') or o.status or ''] no $custom = -> _setBl custom: Object.assign _bl.custom, updated: +new Date toggleIcon = (s) -> do (icons = _icons()) -> _bl.custom.icons = (if s not in icons then [icons..., s] else icons.filter (x) -> x isnt s).join(' ').trim() $custom() gameName.onchange = _system.onchange = $reset overlay = $e('div', style: "display: flex; flex-direction: column; width: calc(505px + 1ex); position: relative") $after(legend, $e('div', {className: 'overlay'}, overlay)) worksOn = (o) -> (do ([s, cls] = OS[c]) -> m("i.fab.#{cls}.os", title: s)) for c in (o.worksOn or '').split '' m.mount overlay, view: -> do (x = _bl.custom, o = data()) -> switch when x then [ m('input', type: 'url', value: x.url or "", title: "URL", onclick: (-> state.active = yes), oninput: (-> x.url = @value), onchange: $custom) m '.oslist', {class: x.url and 'shift'}, m('div', style: "flex: 1"), _icons().map((s) -> m "i.os", class: CUSTOM_ICONS[s], title: s) x.url and m('a.action', title: "Test", target: '_blank', href: x.url, m 'i.fas.fa-external-link-alt') state.active and m '.tooltip', m '.anchor', m('input', type: 'url', value: x.icon or "", title: "Icon URL", oninput: (-> x.icon = @value), onchange: $custom), x.icon and m 'i.action.fas.fa-eye', title: "Preview", onmouseenter: (-> preview = x.icon), onmouseleave: (-> preview = null) m '.anchor', m('input', type: 'url', value: x.image or "", title: "Poster URL", oninput: (-> x.image = @value), onchange: $custom), x.image and m 'i.action.fas.fa-eye', title: "Preview", onmouseenter: (-> preview = x.image), onmouseleave: (-> preview = null) m '.anchor.icons', Object.keys(CUSTOM_ICONS).map (s) -> m 'i.btn', class: CUSTOM_ICONS[s] + (if s in _icons() then " selected" else ""), title: s, onclick: (-> toggleIcon s) m '.anchor', m 'button.done', onclick: (-> [preview, state.active] = [null, no]; $custom(); no), "Done" preview and m '.anchor', m 'img.preview', src: preview ] when o then [ m('input', value: state.title, title: id$() or "", onclick: (-> state.active = yes), \ oninput: -> _delBl id(), 'ignore'; state.title = @value; state.order = _order()) id$() and m('.oslist', m('div', style: "flex: 1"), worksOn o[ id$() ]) state.active and m('.options', state.order.map (k) -> do (x = not excluded[ eId$(k) ]) -> [ m('button.option', {disabled: not x, onclick: $$(k), title: o[k].name} m('i.trash', class: "fas fa-trash#{if x then '' else '-restore'}-alt", title: (if x then "Exclude" else "Restore"), \ onclick: $stop(-> _setExcl k, x)) m('b', o[k].name), worksOn o[k]) ]) ] else if PAGE.match RE.backloggeryCreate BACKLOG = GM_getValue 'backlog', {} MATCHED = objmap DATA, (_, s) -> setFn (x["#{s}Id"] for k, x of BACKLOG when x["#{s}Id"]) UNMATCHED = objmap DATA, (o, s) -> pick(o, (k for k of o when not MATCHED[s](k))...) SETS = objmap UNMATCHED, (o) -> objmap(o, (x) -> words x.name) excluded = GM_getValue 'exclude', {} GM_addStyle ".os {padding-left: 1ex; line-height: 0; font-size: 16px} #names {position: absolute; max-height: 500px; width: 730px; top: 50px; left: 9px; z-index: 2; display: flex; flex-direction: column; overflow-y: auto; background: grey} #names > button {flex-shrink: 0; height: 24px; border-radius: 10px; display: flex; flex-direction: row; margin-top: 1px; text-align: left; padding-left: 1ex;} #names > button > .name {flex-grow: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis} #names > button > i {padding-right: .5em; color: black; cursor: pointer} #names > button > * {line-height: 1.9} #names > button:hover {opacity: .8} #names > button:has(> i:hover) {background: lightpink} #{LOGO} .logo img {height: 100px}" for es in $find("[name=#{s}console]").children for s in ['', 'orig_'] e.innerText = "Windows" for e in es when e.value is "PC" status = $get '//*[@id="content-wide"]/section/form/fieldset[2]/div[1]' [name, system] = ['name', 'console'].map (s) -> $find "[name=#{s}]" name.autocomplete = 'off' eId = (k, s=system.value) -> "#{s.toLowerCase()}##{k}" for e in system.children when_(UNMATCHED[ e.value.toLowerCase() ], (o) -> e.innerText += " (+#{(k for k of o when not excluded[ eId(k, e.value) ]).length})") $after($find('.info.help', detail2), $e('span', id: 'oslist', style: "padding-left: 1ex")) $after($find('.info.help', detail5), $e('b', id: 'achievements', style: "padding: 3.5ex")) $after($find('.info.help', status), $e('b', id: 'completed', style: "padding: 1ex")) $find_('fieldset')[0].style.position = 'relative' $after($find('[name=name]'), $e('div', id: 'names')) data = (k=system.value) -> UNMATCHED[ k.toLowerCase() ] or {}; _order = (text=name.value, k=system.value) -> order(SETS, excluded, text, k.toLowerCase()) state = id: null, active: no, order: _order() _setExcl = (k, x) -> excluded[ eId(k) ] = x $mergeData('exclude', [eId(k)]: x) state.order = _order() _redraw = (id=state.id, o=data()[id]) -> $clear oslist o and $append oslist, ((do ([s, cls] = OS[c]) -> $e('i', className: "fab #{cls} os", title: s)) for c in (o.worksOn or '').split(''))... [achievements.innerText, completed.innerText] = [o?.achievements or "", statStr(o or {}, 'completed') or o?.status or ''] $before($find_('fieldset')[0], $e('div', id: 'logo', className: 'logo')) m.mount logo, view: -> do (k = system.value.toLowerCase(), o = data()[state.id]) -> o and m('a', {target: '_blank', href: o.url}, m('img', src: $logo(k, state.id)[1])) $upd = (id) -> _redraw id when_ data()[id], (o) -> name.value = o.name Object.assign state, {id}, active: not id, order: _order() no name.oninput = name.onclick = -> $upd(); m.redraw() system.onchange = -> Object.assign state, id: null, order: _order() _redraw(); m.redraw() document.addEventListener 'keydown', (e) -> if e.key is 'Escape' Object.assign state, id: null, active: no _redraw(); m.redraw() m.mount names, view: -> state.active and state.order.map (k) -> do (x = not excluded[eId(k)]) -> m 'button', {disabled: not x, onclick: -> $upd(k)}, m('span.name', {title: data()[k].name}, data()[k].name), m 'i.fas', class: "fa-trash#{if x then '' else '-restore'}-alt", title: (if x then "Exclude" else "Restore"), \ onclick: $stop(-> $keepScroll names, -> _setExcl(k, x)) else if PAGE.match RE.backloggeryLibrary INCOMPLETE = ["(-)", "(u)", "(U)"] SLUGS = objmap DATA, slugs $assertEq(Object.keys(DATA.steam).length, Object.keys(SLUGS.steam).length, (n, m) -> "Steam names have #{n-m} collisions!") $assertEq(Object.keys(DATA.gog).length, Object.keys(SLUGS.gog).length, (n, m) -> "GOG names have #{n-m} collisions!") UPD = GM_getValue 'updated', {} LIBRARIES = Object.keys DATA CHANGES = do (backlog = GM_getValue('backlog', {}), changes = new Set GM_getValue('changes', [])) -> objfilter backlog, (x, k) -> LIBRARIES.some (s) -> changes.has "#{s}##{x[s+'Id']}" CHANGED = Object.keys(CHANGES).sort (a, b) -> CHANGES[a].name.localeCompare CHANGES[b].name $s = (e) -> e.innerText.trim() info = (e) -> $find '.gamerow', e name = (e) -> $find 'b', e id = (e) -> query($find('a', e).href).gameid $achievements = (e) -> $find '.info span', info e $completion = (e) -> $find 'img', $find_('h2 a', e)[1] $type = (s, e) -> $s(info e).match RegExp("\\b#{s}\\b", 'i') $slug = (k, e) -> SLUGS[k][ slugify($s name e) ] overlay = $e('div', style: "z-index:2; pointer-events:none; position:fixed; top:0; left:0; width:100%; height:100%; display:flex") $append document.body, overlay GM_addStyle "#{LOGO} .logo img {max-height: 62px} .logo.steam img {max-height: 67px} .logo.gog img {max-height: 64px} .os {padding-left: .75ex; line-height: 0 !important; font-size: 20px; position: relative; top: 2.5px} .os {font-weight: 400} .os.fa-gamepad, .os.fa-dice, .os.fa-dice-d20, .os.fa-trophy {font-weight: 900} section.gamebox.processed .logo img {max-height: 64px} .tooltip {margin: auto; align-items: center; display: flex; flex-direction: column; background: rgba(0, 0, 0, 0.8); padding: 2em; transform: translateZ(0) translateX(-99px)} .tooltip > * {max-width: 548px} .tooltip > div {padding-top: 1em; font-weight: bold} .tooltip pre {white-space: pre-wrap; text-indent: -1em; padding-left: 1em} .changelist {position: absolute; top: 0; right: 0; pointer-events: all; background: rgba(0, 0, 0, 0.8); max-width: 33%; max-height: 50%; display: flex; flex-direction: column} .changelist.collapsed {opacity: .5} .changelist:hover {opacity: 1} .changelist .items {overflow-y: auto} .changelist .items > .item {margin: 1em} .changelist > h1 {cursor: pointer; position: relative; padding: 1em; padding-right: 3em} .changelist > h1 > .right {position: absolute; right: 0; margin-right: 1em} #side-loader {position: fixed; top: 1ex; left: 1ex; z-index: 10000; font-size: 100px}" changeListCollapsed = no overlayData = null $$ = (x) -> -> overlayData = x; m.redraw() m.mount overlay, view: -> switch when overlayData then m '.tooltip', m('img', src: overlayData.image) m 'div', overlayData.stats?.map (s) -> m('pre', s) when CHANGED.length > 0 then m '.changelist', {class: if changeListCollapsed then 'collapsed' else ""}, m 'h1', {onclick: -> changeListCollapsed = not changeListCollapsed}, "Unseen changes (#{CHANGED.length}) ", m 'span.right', "[#{if changeListCollapsed then '+' else '–'}]" unless changeListCollapsed then m '.items', CHANGED.map (k) -> m '.item', m 'a', {href: "https://www.backloggery.com/update.php?user=#{PARAMS.user}&gameid=#{k}"}, CHANGES[k].name, (" [#{s}]" for s in LIBRARIES when CHANGES[k]["#{s}Id"]) $tweak = (e, [k, k_=k], id, [ignore, markParam, markCond], [append, appendFmt=identity], [stats, statsUpdated]) -> [[icon, image], x] = [$logo(k, id), if k is 'custom' then id else DATA[k][id]] _data = {image, stats: (stats and "#{stats}\nSynced: #{new Date(statsUpdated or UPD[k_])}".split '\n')} e.style.background = if ignore then 'darkgrey' else unless markCond(markParam e) then '' else 'lightcoral' name(e).title = "#{x.name}\nSynced: #{new Date if k is 'custom' then x.updated else UPD[k_]}" name(e).innerHTML += unless append then '' else " [#{appendFmt append}]" (do ([s, cls] = OS[c]) -> $append name(e), $e('i', className: "fab #{cls} os", title: s)) for c in (x.worksOn||'').split('') $append name(e), $e('i', className: "#{CUSTOM_ICONS[s]} os", title: s) for s in (x.icons or "").split(' ') when s if k is 'custom' $before e, $e('div', {className: "logo #{k}", onmouseleave: $$(), onmouseenter: $$ _data}, $e('a', merge(target: '_blank', x.url and href: x.url), $e('img', src: icon))) _renameWindows = (s) -> replace(s, /^PC( \(.*\))?$/, "Windows$1") or replace(s, /^(.*)\(PC\)$/, "$1(Windows)") or s e.innerText = _renameWindows e.innerText for e in $find_ "aside .sysbox" _platformTitleUpdater = $watcher (e) -> e.innerText = _renameWindows e.innerText if $hasClass(e, "system title") _platformTitleUpdater.observe output1, childList: yes $append document.body, _loader = $e('span', {id: 'side-loader', style: "display: none"}, $e('i', className: "fas fa-cog rotating")) _delay = (f) -> _loader.style = ""; delay -> f(); _loader.style = "display: none" _queue = Promise.resolve() _gameboxEnhancer = $watcher (game) -> if $hasClass(game, 'gamebox') and info game then _queue = _queue.then -> _delay -> backlog = GM_getValue 'backlog', {} _id = id game _bl = backlog[_id] = Object.assign(backlog[_id] or {}, name: $s name game) $syncId = (k) -> _bl["#{k}Id"] = _bl["#{k}Id"] or $slug(k, game); DATA[k][ _bl["#{k}Id"] ] _type = $find 'b', info(game) _type.innerText = _renameWindows _type.innerText _psn = ['ps3', 'ps4', 'psvita'].find((s) -> $type s, game) if _bl.custom $tweak game, ['custom'], merge(_bl.custom, name: ""), [yes, (->''), (->'')], ["custom"], ["Custom", _bl.custom.updated] else if $type 'steam', game data = $syncId 'steam' stats = data?.achievements _markCond = (e) -> stats is '?' or (not e and stats isnt "0 / 0") or (e and not e.innerText.startsWith "Achievements: #{stats} (") data and $tweak game, ['steam'], _bl.steamId, [_bl.ignore, $achievements, _markCond], [data.hours, (s) -> "#{s}h"], [statStr(data, 'achievements'), UPD['steam-stats']] else if $type 'gog', game data = $syncId 'gog' completed = data?.completed is 'yes' data and $tweak game, ['gog'], _bl.gogId, [_bl.ignore, $completion, (e) -> completed is INCOMPLETE.includes e.alt], [data.rating, (n) -> "#{n/10}/5"], [statStr(data, 'completed', 'category')] else if $type 'humble', game data = $syncId 'humble' data and $tweak game, ['humble'], _bl.humbleId, [_bl.ignore, (->''), (->'')], [], [statStr(data, 'developer', 'publisher')] else if $type 'epic', game data = $syncId 'epic' data and $tweak game, ['epic'], _bl.epicId, [_bl.ignore, (->''), (->'')], [], [statStr(data, 'developer', 'features')] else if $type 'itchio', game data = $syncId 'itchio' meta = data and objmap(data.meta, (x, k) -> unless k in ["Acquired", "Updated", "Published", "Release date"] then x else new Date x) data and $tweak game, ['itchio', 'itch'], _bl.itchioId, [_bl.ignore, (->''), (->'')], [data.rating, (n) -> "#{n}/5"], [statStr(meta, ...Object.keys meta), data.sync] else if $type 'ggate', game data = $syncId 'ggate' data and $tweak game, ['ggate'], _bl.ggateId, [_bl.ignore, (->''), (->'')], [], [] else if _psn data = $syncId _psn stats = data?.achievements _markCond = (e) -> (not e and stats isnt "0 / 0") or (e and not e.innerText.startsWith "Achievements: #{stats} (") data and $tweak game, [_psn, 'psn'], _bl["#{_psn}Id"], [_bl.ignore, $achievements, _markCond], [data.rank, (s) -> "#{s} rank"], [statStr(data, 'achievements', 'status', 'trophies', 'progress')] GM_setValue 'backlog', backlog _gameboxEnhancer.observe output1, childList: yes, subtree: yes else if PAGE.match RE.steamLibrary $update 'steam', fromPairs ([o.appid, {name: o.name, hours: o.hours_forever}] for o in rgGames) else if PAGE.match RE.steamRecent stats = ([x.appid, "#{x.ach_completed} / #{x.ach_total}"] for x in rgGames when x.ach_completion) $markUpdate 'steam-stats' $mergeData 'steam-stats', fromPairs stats alert "Game library interop: updated #{stats.length} games" else if PAGE.match RE.steamAchievements # personal when_ $find('#topSummaryAchievements'), (e) -> do (id = $find('.gameLogo a').href.match(/\d+$/)[0]) -> $mergeData('steam-stats', [id]: e.innerText.match(/(\d+) of (\d+)/)[1..].join(" / ")) else if PAGE.match RE.steamAchievements2 # global when_ $find('#compareAvatar') and $find('#headerContentLeft'), (e) -> do (id = $find('.gameLogo a').href.match(/\d+$/)[0]) -> $mergeData('steam-stats', [id]: e.innerText.match(/\d+ \/ \d+/)[0]) else if PAGE.match RE.steamDetails ID = PAGE.match(RE.steamDetails)[1] if $find '.game_area_already_owned' platforms = $find_('.platform_img', $find '.game_area_purchase_game') worksOn = (s[0] for s in ['win', 'linux', 'mac'] when platforms.some (e) -> $hasClass(e, s)).join('') worksOn and $mergeData('steam-platforms', [ID]: worksOn) else if PAGE.match RE.steamBadges # highlighting progress GM_addStyle ".foil {box-shadow: white 0 0 2em} .badge-exp, .card-name.excess {color:lime} .level0 {color:violet} .level1 {color:pink} .card-name.level1 {color:red} .level2 {color:orange} .level3 {color:yellow} .level4 {color:yellowgreen} .card-name.level4 {color:olive} .level5, .foil .level1 {color:limegreen} .card-name.level5, .foil .card-name.level1 {color:green}" $find_(".badge_row_inner").forEach (panel) -> foil = $find(".badge_title", panel)?.innerText.trim().endsWith " Foil Badge" exp = $find(".badge_info_title, .badge_empty_name", panel)?.nextElementSibling level = Number exp.innerText.match("Level ([0-9]+)")?[1] or 0 levels = fromPairs (if foil then [0, 1] else [0, 1, 2, 3, 4, 5]).map (n, i) -> ["(#{n - level})", "level#{i}"] foil and panel.classList.add('foil') exp.classList.add('badge-exp', "level#{level}") $find_(".badge_card_set_title", panel).forEach (cardTitle) -> amount = $find(".badge_card_set_text_qty", cardTitle)?.innerText or "(0)" cardTitle.classList.add('card-name', levels[amount] or 'excess') else if PAGE.match RE.steamDbDetails if $find '#js-app-install.owned' info = $find('.span8') id = $find_('td', info)[1].innerText worksOn = (s[0] for s in ['windows', 'linux', 'macos'] when $find(".octicon-#{s}", info)).join('') worksOn and $mergeData('steam-platforms', [id]: worksOn) else if PAGE.match(RE.steamDbLibrary) and PAGE.startsWith $get("//div[@class='header-menu']//a[text()='Your page']")?.href _hoverPlatformSnooper = $watcher (e) -> if e.firstElementChild?.classList?.contains 'hover_buttons' [id] = $find('a.hover_title', e).href.replace(/\/?\?.*$/, "").match /[0-9]+$/ worksOn = (s[0] for s in ['windows', 'linux', 'macos'] when $find(".octicon-#{s}", e)).join(''); worksOn and $mergeData('steam-platforms', [id]: worksOn) _hoverPlatformSnooper.observe document, childList: yes, subtree: yes else if PAGE.match RE.steamStats _achievements = (ss) -> (s.match(/\d+/)?[0] or s for s in ss).join(" / ") stats = if PARAMS.DisplayType isnt '2' then do (_table = $find '.tablesorter') -> # list _header = $find_ 'th', $find('thead tr', _table) _body = ($find_('td', e) for e in $find_ 'tr', $find('tbody', _table)) [_name$, _total$, _my$] = (_header.findIndex((e) -> e.innerText is s) for s in ["Name", "Total\nAch.", "Gained\nAch."]) _body.map (l) -> [query($find("a[href^='Steam_Game_Info.php']", l[_name$]).href).AppID, _achievements(e.innerText for e in [l[_my$], l[_total$]])] else do (_body = $get '/html/body/center/center/center/center') -> # table _table = Array.from(_body.children).find((x) -> x.tagName is 'TABLE' and not x.classList.contains 'Pager') _ids = (query(e.href).AppID for e in $find_('a', _table)) [_ids[i], _achievements( last($find_ 'p', e).innerText.match(/Achievements: (.*) of (.*)/)[1..] )] for e, i in $find_('table', _table) throw "Invalid update" if stats.length > 0 and not stats[0][0]? # ensuring that next layout change won't break updater $markUpdate 'steam-stats' $mergeData 'steam-stats', fromPairs stats alert "Game library interop: updated #{stats.length} games" else if PAGE.match RE.gogLibrary queryPage = (page=0) -> $query "/account/getFilteredProducts?mediaType=1&page=#{page+1}" worksOn = (o) -> o and worksOn: (k[0].toLowerCase() for k, v of o when v).join('') scrape = -> queryPage().then (o) -> do (completed = o.tags.find((x) -> x.name.toLowerCase() is 'completed').id) -> Promise.all([Promise.resolve(o), [1...o.totalPages].map(queryPage)...]).then (data) -> games = [].concat(data.map((x) => x.products)...) .map (o) => [o.id, merge(pick(o, 'image', 'rating', 'url'), worksOn(o.worksOn), name: o.title, category: o.category or undefined, completed: o.tags.includes completed)] $update 'gog', fromPairs games $append $find('.collection-header'), $e('i', className: "fas fa-sync-alt _clickable account__filters-option", title: "Sync Backloggery", onclick: scrape) else if PAGE.match RE.humbleLibrary PLATFORMS = windows: 'w', linux: 'l', osx: 'm', android: 'a' url = -> ($find('.details-heading a') or {}).href platformSelector = -> $find '.js-platform-select-holder' worksOn = -> do (e = platformSelector()) -> (PLATFORMS[k] for k of PLATFORMS when e.querySelector '.hb-'+k).join('') scrape = -> for e in $find_ '.subproduct-selector' e.click() name: $find('h2', e).innerText publisher: $find('p', e).innerText icon: $find('.icon', e).style.backgroundImage.match(/^url\("(.*)"\)$/)?[1] url: url() worksOn: worksOn() GM_addStyle "#syncBackloggery {position: absolute; top: 28px; left: 400px; cursor: pointer}" $append document.body, $e('span', {id: 'loader'}, $e('i', className: "fas fa-cog rotating")) $visibility loader, off main = $find '.base-main-wrapper' main.style.position = 'relative' $append main, $e('i', id: 'syncBackloggery', className: "fas fa-sync-alt", title: "Sync Backloggery", onclick: -> $visibility loader, on setTimeout -> $update 'humble', fromPairs scrape().map (x) -> x.worksOn and [slugify(x.name), x] $visibility loader, off) $visibility syncBackloggery, off forever -> when_ $find('#switch-platform'), (e) -> $visibility(syncBackloggery, (e.value is 'all') and not search.value and location.pathname is "/home/library") else if PAGE.match RE.itchLibrary GM_addStyle ".my_collections_page .game_collection h2 {display: flex} .fa-sync-alt {padding-left: 1ex; cursor: pointer}" _div = document.createElement 'div' _date = undefined queryPage = (page=0) -> $query("/my-purchases?format=json&page=#{page+1}").then (o) -> if o.num_items > 0 _div.innerHTML = o.content Array.from(_div.childNodes).map (e) -> do (title = $find(".game_title a", e)) -> id: e.getAttribute 'data-game_id' name: title.innerText url: title.href.replace(/\/download\/[^/]+$/, "") image: $find("img", e)?.getAttribute('data-lazy_src') author: $find(".game_author", e)?.innerText date: (_date = $find(".date_header > span", e)?.title or _date) collect = (page=0) -> queryPage(page).then (xs) -> unless xs then [] else collect(page+1).then (ys) -> [...xs, ...ys] scrape = -> collect().then (xs) -> $update 'itch', fromPairs xs.map ({id, ...o}) -> [id, o] $find_("a[href='/my-purchases']").forEach (e) -> e.insertAdjacentElement 'afterend', $e('i', className: "fas fa-sync-alt", title: "Sync Backloggery", onclick: scrape) else if PAGE.match RE.itchDetails PLATFORMS = windows: 'w', linux: 'l', osx: 'm', android: 'a', ios: 'm', web: 'b' _platforms = (check) -> Object.keys(PLATFORMS).filter(check).map((k) -> PLATFORMS[k]).join "" _parseDate = (e) -> new Date($find("abbr", e).title).getTime() if id = Object.entries(GM_getValue 'itch', {}).find(([k, o]) -> o.url is location.href)?[0] _info = fromPairs $find_(".game_info_panel_widget tr").map (e) -> [e.firstChild.innerText, e.lastChild] {Author, Platforms, Rating, ...info} = objmap _info, (x, k) -> switch slugify k when 'platforms' then _platforms (k) -> $find "a[href$=platform-#{k}]", x when 'published' then _parseDate x when 'updated' then _parseDate x when 'release-date' then _parseDate x when 'rating' then Number $find(".aggregate_rating", x).title else x.innerText $mergeData('itch-info', [id]: {at: Date.now(), worksOn: Platforms, rating: Rating, ...info}) else if PAGE.match RE.ggateLibrary games = $find_(".my-games-catalog .catalog-item").map (e) -> do (link = $find('a', e), icon = $find('img', e).src) -> id = link.href.match("/account/orders/(.*)")[1].replace("/#", ":") [id, {name: link.title, icon, image: icon.replace(/\?.*/, "")}] $update 'ggate', fromPairs games else if PAGE.match RE.epicStore CONFIG = if USER_OS is 'windows' then "C:/Users/%USER%/AppData" else if USER_OS is 'linux' then "~/.config" else "~/Library/Application Support" TOOLTIP = "Import catalog to Backloggery from launcher cache:\n* EpicGamesLauncher/Data/Catalog/catcache.bin\n* #{CONFIG}/heroic/lib-cache/library.json" if user.classList.contains 'signed-in' GM_addStyle ".bl-import {display: flex; align-items: center} .bl-import > * {cursor: pointer; padding: 1em}" convertHeroic = (data) -> data.filter((x) -> x.is_game).map (x) -> id: x.app_name name: x.title slug: x.store_url?.replace EPIC_STORE, "" icon: (x.art_logo or x.art_cover or x.art_square)?.replace(EPIC_CDN+"/", "/") image: (x.art_square or x.art_cover or x.art_logo)?.replace(EPIC_CDN+"/", "/") worksOn: ['w', x.is_linux_native and 'l', x.is_mac_native and 'm'].filter(identity).join "" developer: x.developer online: not x.canRunOffline or undefined cloud: x.cloud_save_enabled or undefined _epicImg = (x) -> fromPairs x.keyImages.map (y) -> [y.type.replace(/^DieselGameBox/, "") or 'Cover', y.url] convertEpic = (data) -> data.filter((x) -> x.categories.some (y) -> y.path is 'games').map (x) -> do (meta = x.releaseInfo[0], img = _epicImg x) -> id: meta.appId name: x.title #slug: not available icon: (img.Logo or img.Cover or img.Tall)?.replace(EPIC_CDN+"/", "/") image: (img.Tall or img.Cover or img.Logo)?.replace(EPIC_CDN+"/", "/") worksOn: ['Windows' in meta.platform and 'w', 'Linux' in meta.platform and 'l', 'Mac' in meta.platform and 'm'].filter(identity).join "" developer: x.developer online: x.customAttributes.CanRunOffline?.value is 'false' or undefined cloud: 'CloudSaveFolder' of x.customAttributes or undefined parseFile = (file) -> (readFile file .then (s) -> if s[0] is "{" then convertHeroic JSON.parse(s).library else convertEpic JSON.parse atob s .then (games) -> $update 'epic', fromPairs games.map ({id, ...x}) -> [id, x] .catch (e) -> console.error e; alert "Invalid catalog cache file") readFile = (file) -> new Promise (resolve) -> do (reader = new FileReader) -> reader.onload = -> resolve @result.trim() reader.readAsText file importFile = $e('input', type: 'file', accept: ".bin,.json", onchange: -> @files[0] and parseFile @files[0]) btn = $e('div', className: "bl-import", $e('i', className: "fas fa-file-import", title: TOOLTIP, onclick: -> importFile.click())) addBtn = debounce 1000, -> user.insertAdjacentElement 'afterend', btn; _watcher.disconnect() _watcher = $watcher addBtn _watcher.observe rightNav, childList: yes, subtree: yes, attributes: yes else if PSN_ID and PAGE.match(RE.psnLibrary)?[1] is PSN_ID PANEL = $get "../../..", $find ".dropdown-toggle.completion" GAMES = $find '#gamesTable' if ['search', 'completion', 'pf'].every (s) -> s not of PARAMS $append document.body, $e('span', {id: 'loader'}, $e('i', className: "fas fa-cog rotating")) $visibility loader, off _loading = -> $find('#table-loading', GAMES) load = -> new Promise (resolve) -> unless $find '#load-more', GAMES resolve() else loadMoreGames() waiting = forever -> unless $find '#table-loading', GAMES clearInterval waiting resolve load() TROPHIES = ['gold', 'silver', 'bronze'] _achievements = (s) => (replace(s, /All (\d+)/, "$1 of $1") or s).match(/(\d+) of (\d+)/)[1..].join " / " convert = (x) -> [$find('a', x).href.match(RE.psnDetails)[1], name: $find('.title', x).innerText, icon: $find("picture source", x).srcset.match("^.*, (.*) 1.1x$")[1], rank: $find('.game-rank', x).innerText, progress: $find('.progress-bar', x).innerText, achievements: _achievements($find('.small-info', x).innerText), platforms: $find_('.platform', x).map((y) -> _PSN_HW[y.innerText]).join(''), status: ['completion', 'platinum'].filter((s) -> $find ".#{s}.earned", x).join(", ") or undefined, trophies: $find('.trophy-count div', x).innerText.split('\n').map((s, i) -> "#{s} #{TROPHIES[i]}").join(", ")] $append PANEL.firstElementChild, $e('i', id: 'syncBackloggery', className: "fas fa-sync-alt", style: "cursor: pointer; color: white", title: "Sync Backloggery", onclick: -> $visibility loader, on load().then -> $visibility loader, off $update 'psn', fromPairs $find_('tr', GAMES).map convert) forever -> $visibility syncBackloggery, GAMES.style.display isnt 'none' else if PSN_ID and PAGE.match(RE.psnDetails)?[2] is PSN_ID GAME_ID = PAGE.match(RE.psnDetails)[1] $mergeData 'psn-img', [GAME_ID]: $find('.game-image-holder a').href `; eval( CoffeeScript.compile(inline_src) );