├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── beatsaver.svg ├── gulpfile.mjs ├── package.json ├── scoresaber.user.js ├── src ├── api │ ├── beastsaber.ts │ ├── beatsaver.ts │ └── scoresaber.ts ├── compare.ts ├── components │ ├── QuickButton.svelte │ ├── SettingsDialogue.svelte │ ├── events.ts │ ├── modal.ts │ └── toggle_button.ts ├── declarations │ ├── Types.ts │ ├── chart.d.ts │ ├── moment.d.ts │ └── userscript.d.ts ├── env.ts ├── global.ts ├── header.ts ├── header.user.js ├── main.ts ├── pages │ ├── song.ts │ ├── songlist.ts │ └── user.ts ├── ppgraph.ts ├── settings.ts ├── style.css ├── themes.ts ├── updater.ts ├── usercache.ts └── util │ ├── dom.ts │ ├── err.ts │ ├── format.ts │ ├── lazy.ts │ ├── limiter.ts │ ├── log.ts │ ├── net.ts │ ├── sessioncache.ts │ ├── song.ts │ └── userscript.ts ├── svelte.config.mjs ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | out 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "es2021": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "ecmaVersion": 12 14 | }, 15 | "plugins": [ 16 | "@typescript-eslint" 17 | ], 18 | "rules": { 19 | "@typescript-eslint/no-inferrable-types": 0, 20 | "@typescript-eslint/no-non-null-assertion": 0, 21 | "@typescript-eslint/no-explicit-any": 0, 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Build data 2 | out/ 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Microbundle cache 60 | .rpt2_cache/ 61 | .rts2_cache_cjs/ 62 | .rts2_cache_es/ 63 | .rts2_cache_umd/ 64 | 65 | # Optional REPL history 66 | .node_repl_history 67 | 68 | # Output of 'npm pack' 69 | *.tgz 70 | 71 | # Yarn Integrity file 72 | .yarn-integrity 73 | 74 | # dotenv environment variables file 75 | .env 76 | .env.test 77 | 78 | # parcel-bundler cache (https://parceljs.org/) 79 | .cache 80 | 81 | # next.js build output 82 | .next 83 | 84 | # nuxt.js build output 85 | .nuxt 86 | 87 | # gatsby files 88 | .cache/ 89 | public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": ".\\node_modules\\typescript\\lib" 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Splamy, TheAsuro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ScoreSaberEnhanced 2 | Provides additional features to the ScoreSaber website 3 | 4 | # Install 5 | Get Tampermonkey or Greasemonkey for [Chrome](https://chrome.google.com/webstore/detail/tampermonkey/dhdgffkkebhmkfjojejmpbldmpobfkfo) or [Firefox](https://addons.mozilla.org/firefox/addon/tampermonkey/). Then install the script from [here](https://github.com/Splamy/ScoreSaberEnhanced/raw/master/scoresaber.user.js). 6 | 7 | # Compare Scores 8 | - Compare your scores with your friends 9 | - Extend the song table to the full box width 10 | - Direct links to BeatSaver and OneClick download 11 | 12 | ![Compare](https://i.imgur.com/3xy8FQo.png) 13 | 14 | - Compare scores on a song page with friends 15 | 16 | ![Compare2](https://i.imgur.com/ZtCGEbx.png) 17 | 18 | - Show pp distribution Graphs 19 | 20 | ![Compare3](https://i.imgur.com/KQNqWFg.png) 21 | 22 | # Pin 23 | - Pin your own profile to the nav bar 24 | - Jump directly to other saved users 25 | 26 | ![Pin](https://i.imgur.com/2B0GLwi.png) 27 | 28 | # Themes 29 | - Many themes, including various dark themes. 30 | 31 | ![Themes](https://i.imgur.com/3Nso0TP.png) 32 | 33 | # Other 34 | - Rank number link on a user page jumps to the page where the user is 35 | - Rank number now links to the song leaderboard page where the user is 36 | ![LinkFix2](https://i.imgur.com/U1quEKP.png) 37 | 38 | 39 | 40 | # Development 41 | 42 | Setup everything with `yarn` / `npm install` 43 | 44 | Run `yarn build` to build the project. 45 | 46 | Run `yarn dev` to run the watcher which will continuously build the project. 47 | 48 | The generated output file is always `scoresaber.user.js`. 49 | 50 | # Changelog 51 | 52 | 1.12.0 | 2021-11-06 53 | - Added smol notice when a map has a newer version on BeatSaver 54 | 55 | 1.11.1 | 2021-11-06 56 | - Fixed calculated percentages on Expert+ ([#26](https://github.com/Splamy/ScoreSaberEnhanced/pull/25) Thanks [@ChrisJAllan](https://github.com/ChrisJAllan) for this PR) 57 | 58 | 1.11.0 | 2021-08-05 59 | - Updated to new beatsaber api ([#25](https://github.com/Splamy/ScoreSaberEnhanced/pull/25) Thanks [@ChrisJAllan](https://github.com/ChrisJAllan) for this PR) 60 | 61 | 1.10.1 | 2021-08-04 62 | - Added option to disable user song compare 63 | 64 | 1.10.0 | 2021-08-04 65 | - Added Download links to the main song list & filter for hiding duplicate songs ([#24](https://github.com/Splamy/ScoreSaberEnhanced/pull/24) Thanks [@urholaukkarinen](https://github.com/urholaukkarinen) for this PR) 66 | - Added ButtonMatrix in the options to customize where to show which quickaction buttons 67 | - Minor Bugfixes 68 | 69 | 1.9.2 | 2021-05-10 70 | - Fixed [#21](https://github.com/Splamy/ScoreSaberEnhanced/issues/21) sometimes failing to update users. ([#23](https://github.com/Splamy/ScoreSaberEnhanced/pull/23) Thanks [@Lemmmy](https://github.com/Lemmmy) for this PR) 71 | 72 | 1.9.1 | 2021-02-25 73 | - Also change country rank link to jump to correct page. 74 | 75 | 1.9.0 | 2020-08-13 76 | - Added percentage info on Leaderboard and Profile pages ([#20](https://github.com/Splamy/ScoreSaberEnhanced/pull/20) Thanks [@karghoff](https://github.com/karghoff) for this PR) 77 | 78 | 1.8.7 | 2020-08-05 79 | - Fixed graphjs throwing when loading in background tab. 80 | 81 | 1.8.6 | 2020-08-04 82 | - Fixed [#19](https://github.com/Splamy/ScoreSaberEnhanced/issues/19) incorrectly loading css. 83 | 84 | 1.8.5 | 2020-07-28 85 | - Using sessionStore to cache some data. 86 | 87 | 1.8.4 | 2020-07-27 88 | - Explicitely import graphjs to make ppgraph loading more consistent ([#18](https://github.com/Splamy/ScoreSaberEnhanced/pull/18) Thanks [@trgwii](https://github.com/trgwii) for this PR) 89 | 90 | 1.8.3 | 2020-07-13 91 | - Added ratelimit detection for the scoresaber api 92 | 93 | 1.8.2 | 2020-06-20 94 | - Fixed new.scoresaber api data contract ([#14](https://github.com/Splamy/ScoreSaberEnhanced/pull/14) Thanks [@ErisApps](https://github.com/ErisApps) for this PR) 95 | 96 | 1.8.1 | 2020-05-05 97 | - Added !bsr button 98 | 99 | 1.8.0 | 2020-05-05 100 | - Added BeastSaber bookmarks loading ([#12](https://github.com/Splamy/ScoreSaberEnhanced/pull/12) Thanks [@sre](https://github.com/sre) for this PR) 101 | - Improved options modal 102 | 103 | 1.7.1 104 | - Actually fixed colors for default dark theme (hopefully) 105 | 106 | 1.7.0 107 | - Added 'Update All' and 'Force Update All' for easier updating ([#11](https://github.com/Splamy/ScoreSaberEnhanced/issues/11)) 108 | (Especially since the recent pp rework this might be useful) 109 | 110 | 1.6.6 111 | - Fixed compatibility for GreaseMonkey ([#10](https://github.com/Splamy/ScoreSaberEnhanced/issues/10)) 112 | 113 | 1.6.5 114 | - Fixed typo ([#9](https://github.com/Splamy/ScoreSaberEnhanced/issues/9)) 115 | 116 | 1.6.4 117 | - Fixed wrong accuracy calculation with new ss api 118 | 119 | 1.6.3 120 | - Added preview button to song page 121 | - Fixed issues with the new ss backend api 122 | 123 | 1.6.2 124 | - Added new ScoreSaber api as loader backend. 125 | Retrieving data should now be super fast again. 126 | If you have Problems you can disable it in the settings. 127 | 128 | 1.6.1 129 | - Improved layout of bs/bs stats and added song length 130 | 131 | 1.6.0 132 | - Added song stats from BeatSaver and BeastSaber to song page 133 | 134 | 1.5.0 135 | - Added pp distribution graph 136 | - Adjust colors to dark mode of ScoreSaber 137 | 138 | 1.4.1 139 | - Fixed force update for song pages 140 | 141 | 1.4.0 142 | - Added visual feedback for download start/fail 143 | 144 | 1.3.1 145 | - Fixed 'friends' tab being broken 146 | - Fixed some songs breaking the song comparison 147 | - Disable BS/OC buttons on songs with invalid ids 148 | -------------------------------------------------------------------------------- /beatsaver.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /gulpfile.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "fs"; 2 | import pkg from "gulp"; 3 | import concat from "gulp-concat"; 4 | import replace from "gulp-replace"; 5 | import { rollup } from "rollup"; 6 | import typescript from "@rollup/plugin-typescript"; 7 | import resolve from "@rollup/plugin-node-resolve"; 8 | import eslint from "@rbnlffl/rollup-plugin-eslint"; 9 | import svelte from "rollup-plugin-svelte"; 10 | import sveltePreprocess from "svelte-preprocess"; 11 | import commonjs from "@rollup/plugin-commonjs"; 12 | const { series, src, dest, task, watch } = pkg; 13 | 14 | task("build", async () => { 15 | const bundle = await rollup({ 16 | input: "./src/main.ts", 17 | plugins: [ 18 | svelte({ 19 | include: '**/*.svelte', 20 | preprocess: sveltePreprocess(), 21 | emitCss: false, 22 | onwarn: (warning, handler) => { 23 | // e.g. don't warn on elements, cos they're cool 24 | if (warning.code === 'a11y-label-has-associated-control') return; 25 | handler(warning); 26 | }, 27 | }), 28 | eslint({ 29 | filterInclude: "src/**/*", 30 | filterExclude: [ 31 | "**/*.svelte", 32 | "node_modules/**" 33 | ] 34 | }), 35 | resolve({ browser: true }), 36 | typescript({ tsconfig: './tsconfig.json' }), 37 | commonjs({ extensions: ['.js', '.ts'] }), 38 | ] 39 | }); 40 | return bundle.write({ 41 | file: "./out/rollup.js", 42 | format: "iife", 43 | name: "library", 44 | sourcemap: true 45 | }); 46 | }); 47 | 48 | task("userscript", async () => { 49 | let meta = JSON.parse(readFileSync("./package.json")); 50 | return src(["./src/header.user.js", "./out/rollup.js"]) 51 | .pipe(replace(/include\$GULP_CSS/, readFileSync("./src/style.css", "utf8"))) 52 | .pipe(replace(/include\$GULP_METADATA/, [ 53 | `// @version ${meta.version}`, 54 | `// @description ${meta.description}`, 55 | `// @author ${meta.author}`, 56 | ].join('\n'))) 57 | .pipe(concat("./scoresaber.user.js")) 58 | .pipe(dest("./")); 59 | }); 60 | 61 | const _default = series("build", "userscript"); 62 | export { _default as default }; 63 | const _watch = function () { 64 | watch([ 65 | "src/**/*.ts", 66 | "src/**/*.css", 67 | "src/**/*.svelte", 68 | "src/**/*.user.js", 69 | "./package.json", 70 | ], series("build", "userscript")); 71 | }; 72 | export { _watch as watch }; 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scoresaberenhanced", 3 | "version": "1.12.0", 4 | "description": "Adds links to beatsaver, player comparison and various other improvements", 5 | "main": "src/main.ts", 6 | "scripts": { 7 | "build": "gulp", 8 | "dev": "gulp watch" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/Splamy/ScoreSaberEnhanced.git" 13 | }, 14 | "keywords": [ 15 | "BeatSaber", 16 | "ScoreSaber", 17 | "Beat", 18 | "Saber", 19 | "Score", 20 | "Enhanced", 21 | "UserScript" 22 | ], 23 | "author": "Splamy, TheAsuro", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/Splamy/ScoreSaberEnhanced/issues" 27 | }, 28 | "homepage": "https://github.com/Splamy/ScoreSaberEnhanced#readme", 29 | "devDependencies": { 30 | "@rbnlffl/rollup-plugin-eslint": "2.0.0", 31 | "@rollup/plugin-commonjs": "^21.0.1", 32 | "@rollup/plugin-node-resolve": "^13.0.4", 33 | "@rollup/plugin-typescript": "8.3.0", 34 | "@typescript-eslint/eslint-plugin": "5.3.0", 35 | "@typescript-eslint/parser": "5.3.0", 36 | "eslint": "8.2.0", 37 | "gulp": "4.0.2", 38 | "gulp-concat": "2.6.1", 39 | "gulp-replace": "1.1.3", 40 | "rollup": "2.59.0", 41 | "rollup-plugin-svelte": "^7.1.0", 42 | "svelte": "^3.41.0", 43 | "svelte-preprocess": "^4.7.4", 44 | "tslib": "2.3.1", 45 | "typescript": "4.4.4" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/api/beastsaber.ts: -------------------------------------------------------------------------------- 1 | import { fetch2 } from "../util/net"; 2 | import { SessionCache } from "../util/sessioncache"; 3 | 4 | const api_cache = new SessionCache("beast"); 5 | 6 | export async function get_data(song_key: string): Promise { 7 | const cached_data = api_cache.get(song_key); 8 | if (cached_data !== undefined) 9 | return cached_data; 10 | try { 11 | const data_str = await fetch2(`https://bsaber.com/wp-json/bsaber-api/songs/${song_key}/ratings`); 12 | const data = JSON.parse(data_str); 13 | api_cache.set(song_key, data); 14 | return data; 15 | } catch (e) { return undefined; } 16 | } 17 | 18 | export async function get_bookmarks(username: string, page: number, count: number): Promise { 19 | try { 20 | const data_str = await fetch2(`https://bsaber.com/wp-json/bsaber-api/songs/?bookmarked_by=${username}&page=${page}&count=${count}`); 21 | const data = JSON.parse(data_str); 22 | return data; 23 | } catch (e) { return undefined; } 24 | } 25 | 26 | export interface IBeastSaberData { 27 | overall_rating: number; 28 | 29 | average_ratings: { 30 | fun_factor: number; 31 | rhythm: number; 32 | flow: number; 33 | pattern_quality: number; 34 | readability: number; 35 | level_quality: number; 36 | }; 37 | } 38 | 39 | export interface IBeastSaberSongInfo { 40 | title: string; 41 | song_key: string; 42 | hash: string; 43 | level_author: string; 44 | } 45 | 46 | export interface IBeastSaberBookmarks { 47 | songs: IBeastSaberSongInfo[]; 48 | next_page: number; 49 | } 50 | -------------------------------------------------------------------------------- /src/api/beatsaver.ts: -------------------------------------------------------------------------------- 1 | import { logc } from "../util/log"; 2 | import { fetch2 } from "../util/net"; 3 | import { SessionCache } from "../util/sessioncache"; 4 | import { diff_name_to_value } from "../util/song"; 5 | 6 | const api_cache = new SessionCache("saver"); 7 | 8 | export async function get_data_by_hash(song_hash: string): Promise { 9 | const cached_data = api_cache.get(song_hash); 10 | if (cached_data !== undefined) 11 | return cached_data; 12 | try { 13 | const data_str = await fetch2(`https://api.beatsaver.com/maps/hash/${song_hash}`); 14 | const data = JSON.parse(data_str); 15 | api_cache.set(song_hash, data); 16 | return data; 17 | } catch (err) { 18 | logc("Failed to download song data", err) 19 | return undefined; 20 | } 21 | } 22 | 23 | export async function get_scoresaber_data_by_hash(song_hash: string, diff_name?: string): Promise { 24 | try { 25 | const diff_value = diff_name === undefined ? 0 : diff_name_to_value(diff_name); 26 | const data_str = await fetch2(`https://beatsaver.com/api/scores/${song_hash}/0?difficulty=${diff_value}&gameMode=0`); 27 | const data = JSON.parse(data_str); 28 | return data; 29 | } catch (err) { 30 | logc("Failed to download song data", err) 31 | return undefined; 32 | } 33 | } 34 | 35 | 36 | export interface IBeatSaverData { 37 | id: string; 38 | name: string; 39 | stats: { 40 | plays: number; 41 | downloads: number; 42 | upvotes: number; 43 | downvotes: number; 44 | score: number; 45 | }; 46 | versions: IBeatSaverSongVersion[]; 47 | metadata: { 48 | duration: number; 49 | }; 50 | } 51 | 52 | export interface IBeatSaverSongVersion { 53 | hash: string; 54 | diffs: { 55 | characteristic: string; 56 | difficulty: string; 57 | notes: number; 58 | }[]; 59 | downloadURL: string; 60 | } 61 | 62 | export interface IBeatSaverScoreSaber { 63 | ranked: boolean; 64 | uid: string; 65 | scores: { 66 | playerId: string; 67 | name: string; 68 | rank: number; 69 | score: number; 70 | pp: number; 71 | mods: string[]; 72 | }[]; 73 | mods: boolean; 74 | valid: boolean; 75 | } 76 | -------------------------------------------------------------------------------- /src/api/scoresaber.ts: -------------------------------------------------------------------------------- 1 | import { ISong } from "../declarations/Types"; 2 | import { get_document_user, get_use_new_ss_api } from "../env"; 3 | import g from "../global"; 4 | import { check } from "../util/err"; 5 | import { read_inline_date, round2 } from "../util/format"; 6 | import { Limiter, sleep } from "../util/limiter"; 7 | import { logc } from "../util/log"; 8 | import { parse_mods, parse_score_bottom } from "../util/song"; 9 | 10 | const SCORESABER_LINK = "https://new.scoresaber.com/api"; 11 | const API_LIMITER = new Limiter(); 12 | 13 | export async function get_user_recent_songs_dynamic(user_id: string, page: number): Promise { 14 | logc(`Fetching user ${user_id} page ${page}`); 15 | if (get_use_new_ss_api()) { 16 | return get_user_recent_songs_new_api_wrap(user_id, page); 17 | } else { 18 | return get_user_recent_songs_old_api_wrap(user_id, page); 19 | } 20 | } 21 | 22 | // NEW API ==================================================================== 23 | 24 | async function get_user_recent_songs_new_api_wrap(user_id: string, page: number): Promise { 25 | const recent_songs = await get_user_recent_songs(user_id, page); 26 | if (!recent_songs) { 27 | // We reached the end of pagination unexpectedly (multiple of 8 scores) 28 | return { 29 | meta: { was_last_page: true }, 30 | songs: [] 31 | }; 32 | } 33 | 34 | return { 35 | meta: { 36 | was_last_page: recent_songs.scores.length < 8 37 | }, 38 | songs: recent_songs.scores.map(s => [String(s.leaderboardId), { 39 | time: s.timeSet, 40 | pp: s.pp, 41 | accuracy: s.maxScore !== 0 ? round2((s.unmodififiedScore / s.maxScore) * 100) : undefined, 42 | score: s.score, 43 | mods: parse_mods(s.mods) 44 | }]) 45 | }; 46 | } 47 | 48 | export async function get_user_recent_songs(user_id: string, page: number): Promise { 49 | const req = await auto_fetch_retry(`${SCORESABER_LINK}/player/${user_id}/scores/recent/${page}`); 50 | 51 | // If the user has a multiple of 8 scores, there's no way to know we've 52 | // reached the end of the results until hitting a 404. 53 | if (req.status === 404) { 54 | return null; // End of pagination. 55 | } 56 | 57 | const data = await req.json() as IScoresaberSongList; 58 | return sanitize_song_ids(data); 59 | } 60 | 61 | export async function get_user_top_songs(user_id: string, page: number): Promise { 62 | const req = await auto_fetch_retry(`${SCORESABER_LINK}/player/${user_id}/scores/top/${page}`); 63 | const data = await req.json() as IScoresaberSongList; 64 | return sanitize_song_ids(data); 65 | } 66 | 67 | export async function get_user_info_basic(user_id: string): Promise { 68 | const req = await auto_fetch_retry(`${SCORESABER_LINK}/player/${user_id}/basic`); 69 | const data = await req.json() as IScoresaberUserBasic; 70 | return sanitize_player_ids(data); 71 | } 72 | 73 | export async function get_user_info_full(user_id: string): Promise { 74 | const req = await auto_fetch_retry(`${SCORESABER_LINK}/player/${user_id}/full`); 75 | const data = await req.json() as IScoresaberUserFull; 76 | return sanitize_player_ids(data); 77 | } 78 | 79 | async function auto_fetch_retry(url: string) { 80 | // 'MAX_RETRIES * SLEEP_WAIT' should be grater than the max time we are blocked 81 | // when hitting the rate limit. Rate limit timeout is currently 60sec 82 | const MAX_RETRIES = 20; 83 | const SLEEP_WAIT = 5000; 84 | 85 | for (let retries = MAX_RETRIES; retries >= 0; retries--) { 86 | await API_LIMITER.wait(); 87 | const response = await fetch(url); 88 | const remaining = Number(response.headers.get("x-ratelimit-remaining")); 89 | const reset = Number(response.headers.get("x-ratelimit-reset")); 90 | const limit = Number(response.headers.get("x-ratelimit-limit")); 91 | API_LIMITER.setLimitData(remaining, reset, limit); 92 | if (response.status === 429) { // Too Many Requests 93 | await sleep(SLEEP_WAIT); 94 | } else { 95 | return response; 96 | } 97 | } 98 | throw new Error("Can't fetch data from new.scoresaber."); 99 | } 100 | 101 | function sanitize_player_ids(data: T): T { 102 | data.playerInfo.playerId = String(data.playerInfo.playerId); 103 | return data; 104 | } 105 | 106 | function sanitize_song_ids(data: T): T { 107 | for (const s of data.scores) { 108 | s.scoreId = String(s.scoreId); 109 | s.leaderboardId = String(s.leaderboardId); 110 | s.playerId = String(s.playerId); 111 | } 112 | return data; 113 | } 114 | 115 | // OLD API ==================================================================== 116 | 117 | async function get_user_recent_songs_old_api_wrap(user_id: string, page: number): Promise { 118 | let doc: Document | undefined; 119 | let tries = 5; 120 | while ((!doc || doc.body.textContent === '"Rate Limit Exceeded"') && tries > 0) { 121 | await sleep(500); 122 | doc = await fetch_user_page(user_id, page); 123 | tries--; 124 | } 125 | 126 | if (doc === undefined) { 127 | throw Error("Error fetching user page"); 128 | } 129 | 130 | // Get meta stuff 131 | const last_page_elem = doc.querySelector("nav ul.pagination-list li:last-child a")!; 132 | const max_pages = Number(last_page_elem.innerText) + 1; 133 | const data: IUserPageData = { 134 | meta: { 135 | max_pages, 136 | user_name: get_document_user(doc).name, 137 | was_last_page: page === max_pages, 138 | }, 139 | songs: [], 140 | }; 141 | 142 | // Extract data into format 143 | const table_row = doc.querySelectorAll("table.ranking.songs tbody tr"); 144 | for (const row of table_row) { 145 | const song_data = get_row_data(row); 146 | data.songs.push(song_data); 147 | } 148 | 149 | return data; 150 | } 151 | 152 | async function fetch_user_page(user_id: string, page: number): Promise { 153 | const link = g.scoresaber_link + `/u/${user_id}&page=${page}&sort=2`; 154 | if (window.location.href.toLowerCase() === link) { 155 | logc("Efficient get :P"); 156 | return document; 157 | } 158 | 159 | const init_fetch = await (await fetch(link)).text(); 160 | const parser = new DOMParser(); 161 | return parser.parseFromString(init_fetch, "text/html"); 162 | } 163 | 164 | export function get_row_data(row: Element): ISongTuple { 165 | const rowc = row as Element & { cache?: ISongTuple; }; 166 | if (rowc.cache) { 167 | return rowc.cache; 168 | } 169 | 170 | const leaderboard_elem = check(row.querySelector("th.song a")); 171 | const pp_elem = check(row.querySelector("th.score .ppValue")); 172 | const score_elem = check(row.querySelector("th.score .scoreBottom")); 173 | const time_elem = check(row.querySelector("th.song .time")); 174 | 175 | const song_id = g.leaderboard_reg.exec(leaderboard_elem.href)![1]; 176 | const pp = Number(pp_elem.innerText); 177 | const time = read_inline_date(time_elem.title).toISOString(); 178 | const { score, accuracy, mods } = parse_score_bottom(score_elem.innerText); 179 | 180 | const song = { 181 | pp, 182 | time, 183 | score, 184 | accuracy, 185 | mods, 186 | }; 187 | const data: [string, ISong] = [song_id, song]; 188 | rowc.cache = data; 189 | return data; 190 | } 191 | 192 | export type ISongTuple = [string, ISong]; 193 | 194 | // ============================================================================ 195 | 196 | interface IUserPageData { 197 | meta: { 198 | max_pages?: number; 199 | user_name?: string; 200 | was_last_page: boolean; 201 | }; 202 | songs: ISongTuple[]; 203 | } 204 | 205 | interface IScoresaberSong { 206 | scoreId: string; // SANITIZED 207 | leaderboardId: string; // SANITIZED 208 | /** Final score (After applying all song modifier factors) */ 209 | score: number; 210 | /** 211 | * unmodified score (Raw score before applying song modifier factors) 212 | * TODO: Will probably break again due to typo? 213 | */ 214 | unmodififiedScore: number; 215 | mods: string; // comma separated number list 216 | playerId: string; // SANITIZED 217 | /** Time of score set. Format in ISO-8601 */ 218 | timeSet: string; 219 | pp: number; 220 | /** Score weighting factor (Depends on the position in the top songs list of the user) */ 221 | weight: number; // factor 222 | /** Song hash */ 223 | songHash: string; 224 | songName: string; 225 | songSubName: string; 226 | songAuthorName: string; 227 | levelAuthorName: string; 228 | difficulty: number; 229 | difficultyRaw: string; 230 | /** Max possible score (Without modifiers) */ 231 | maxScore: number; 232 | rank: number; 233 | } 234 | 235 | export interface IScoresaberSongList { 236 | scores: IScoresaberSong[]; 237 | } 238 | 239 | export interface IScoresaberUserBasic { 240 | playerInfo: IScoresaberPlayerInfoBasic; 241 | } 242 | 243 | export interface IScoresaberUserFull { 244 | playerInfo: Modify; 245 | scoreStats: IScoresaberScoreStats; 246 | } 247 | 248 | interface IScoresaberPlayerInfoBasic { 249 | playerId: string; // SANITIZE 250 | pp: number; 251 | banned: number; // boolean? 252 | inactive: number; // boolean? 253 | playerName: string; 254 | country: string; 255 | role: string; 256 | badges: string; // comma separated number list 257 | history: string; // comma separated number list 258 | rank: number; 259 | countryRank: number; 260 | } 261 | 262 | interface IScoresaberPlayerInfoFull { 263 | badges: { 264 | image: string; 265 | description: string; 266 | }[]; 267 | } 268 | 269 | interface IScoresaberScoreStats { 270 | totalScore: number; 271 | totalRankedScore: number; 272 | averageRankedAccuracy: number; 273 | totalPlayCount: number; 274 | rankedPlayCount: number; 275 | } 276 | -------------------------------------------------------------------------------- /src/compare.ts: -------------------------------------------------------------------------------- 1 | import * as scoresaber from "./api/scoresaber"; 2 | import SseEvent from "./components/events"; 3 | import { IDbUser } from "./declarations/Types"; 4 | import { get_compare_user, get_current_user, get_use_new_ss_api, get_user_header, insert_compare_feature, is_user_page, set_compare_user, get_home_user } from "./env"; 5 | import g from "./global"; 6 | import * as usercache from "./usercache"; 7 | import { create, into, IntoElem, intor } from "./util/dom"; 8 | import { check } from "./util/err"; 9 | import { format_en, round2 } from "./util/format"; 10 | import { logc } from "./util/log"; 11 | import { get_song_compare_value, song_equals } from "./util/song"; 12 | 13 | export function setup_user_compare(): void { 14 | if (!is_user_page()) { return; } 15 | 16 | // find the element we want to modify 17 | 18 | const header = get_user_header(); 19 | header.style.display = "flex"; 20 | header.style.alignItems = "center"; 21 | 22 | const user = get_current_user(); 23 | into(header, 24 | create("div", { 25 | class: "button icon is-medium", 26 | style: { cursor: "pointer" }, 27 | data: { tooltip: g.user_list[user.id] ? "Update score cache" : "Add user to your score cache" }, 28 | async onclick() { 29 | await fetch_user(get_current_user().id); 30 | }, 31 | }, 32 | create("i", { class: ["fas", g.user_list[user.id] ? "fa-sync" : "fa-bookmark"] }), 33 | ) 34 | ); 35 | 36 | const status_elem = create("div"); 37 | into(header, status_elem); 38 | SseEvent.StatusInfo.register((status) => intor(status_elem, status.text)); 39 | 40 | g.users_elem = create("div"); 41 | insert_compare_feature(g.users_elem); 42 | 43 | update_user_compare_dropdown(); 44 | 45 | SseEvent.UserCacheChanged.register(update_user_compare_dropdown); 46 | SseEvent.UserCacheChanged.register(update_user_compare_songtable); 47 | SseEvent.CompareUserChanged.register(update_user_compare_songtable); 48 | 49 | SseEvent.CompareUserChanged.invoke(); 50 | } 51 | 52 | export function update_user_compare_dropdown(): void { 53 | if (!is_user_page()) { return; } 54 | 55 | const compare = get_compare_user(); 56 | intor(g.users_elem, 57 | create("div", { class: "select" }, 58 | create("select", { 59 | id: "user_compare", 60 | onchange() { 61 | const user = (this as HTMLSelectElement).value; // TOOO convert to IUser 62 | set_compare_user(user); 63 | SseEvent.CompareUserChanged.invoke(); 64 | } 65 | }, 66 | create("option", { value: undefined, selected: compare === undefined }, "(None)"), 67 | ...Object.entries(g.user_list).map(([id, user]) => { 68 | return create("option", { value: id, selected: compare === id }, user.name); 69 | }) 70 | ) 71 | ) 72 | ); 73 | } 74 | 75 | export function update_user_compare_songtable(other_user?: string): void { 76 | if (!is_user_page()) { return; } 77 | 78 | const table = check(document.querySelector("table.ranking.songs")); 79 | const table_row = table.querySelectorAll("tbody tr"); 80 | const scoreHeader = check(table.querySelector("tr th.score")); 81 | 82 | // Reset table data 83 | scoreHeader.textContent = "Score"; 84 | table.querySelectorAll(".comparisonScore").forEach(el => el.remove()); 85 | table_row.forEach(row => row.style.backgroundImage = "unset"); 86 | 87 | if (other_user === undefined) { 88 | other_user = get_compare_user(); 89 | if (other_user === undefined) { 90 | return; 91 | } 92 | } 93 | 94 | const other_data = g.user_list[other_user]; 95 | if (!other_data) { 96 | logc("Other user not found: ", other_user); 97 | return; 98 | } 99 | 100 | const ranking_table_header = check(table.querySelector("thead > tr")); 101 | const scoreCompareHeader = create("th", { class: "comparisonScore" }, other_data.name); 102 | check(ranking_table_header.querySelector(".score")).insertAdjacentElement("afterend", scoreCompareHeader); 103 | 104 | const isSameCompare = other_user === get_current_user().id; 105 | const isSelfCompare = isSameCompare && other_user === get_home_user()?.id; 106 | if (isSelfCompare) { 107 | scoreHeader.textContent = "You (now)"; 108 | scoreCompareHeader.textContent = "You (last cache)"; 109 | } else if (isSameCompare) { 110 | scoreHeader.textContent = `${other_data.name} (now)`; 111 | scoreCompareHeader.textContent = "(last cache)"; 112 | } else { 113 | scoreHeader.textContent = get_current_user().name; 114 | } 115 | 116 | // Update table 117 | for (const row of table_row) { 118 | const [song_id, song] = scoresaber.get_row_data(row); 119 | const other_song = other_data.songs[song_id]; 120 | 121 | // add score column 122 | let other_score_content: IntoElem[]; 123 | if (other_song) { 124 | other_score_content = [ 125 | create("span", { class: "scoreTop ppValue" }, format_en(other_song.pp)), 126 | create("span", { class: "scoreTop ppLabel" }, "pp"), 127 | create("br"), 128 | (() => { 129 | let str; 130 | if (other_song.accuracy !== undefined) { 131 | str = `accuracy: ${format_en(other_song.accuracy)}%`; 132 | } else if (other_song.score !== undefined) { 133 | str = `score: ${format_en(other_song.score)}`; 134 | } else { 135 | return ""; 136 | } 137 | if (other_song.mods !== undefined) { 138 | str += ` (${other_song.mods.join(",")})`; 139 | } 140 | return create("span", { class: "scoreBottom" }, str); 141 | })() 142 | // create("span", { class: "songBottom time" }, other_song.time) // TODO: Date formatting 143 | ]; 144 | } else { 145 | other_score_content = [create("hr", {})]; 146 | } 147 | check(row.querySelector(".score")).insertAdjacentElement("afterend", create("th", { class: "comparisonScore" }, ...other_score_content)); 148 | 149 | if (!other_song) { 150 | logc("No match"); 151 | continue; 152 | } 153 | 154 | const [value1, value2] = get_song_compare_value(song, other_song); 155 | if (value1 === -1 && value2 === -1) { 156 | logc("No score"); 157 | continue; 158 | } 159 | 160 | let value = (Math.min(value1, value2) / Math.max(value1, value2)) * 100; 161 | const better = value1 > value2; 162 | if (better) { 163 | value = 100 - value; 164 | } 165 | value = round2(value); // Issue #17: post '##.##???' decimal digits can affect display 166 | 167 | if (better) { 168 | row.style.backgroundImage = `linear-gradient(75deg, var(--color-ahead) ${value}%, rgba(0,0,0,0) ${value}%)`; 169 | } else { 170 | row.style.backgroundImage = `linear-gradient(105deg, rgba(0,0,0,0) ${value}%, var(--color-behind) ${value}%)`; 171 | } 172 | } 173 | } 174 | 175 | async function fetch_user(user_id: string, force: boolean = false): Promise { 176 | let user = g.user_list[user_id]; 177 | if (!user) { 178 | user = { 179 | name: "User" + user_id, 180 | songs: {} 181 | }; 182 | g.user_list[user_id] = user; 183 | } 184 | 185 | let page_max = undefined; 186 | let user_name = user.name; 187 | let updated = false; 188 | 189 | SseEvent.StatusInfo.invoke({ text: `Fetching user ${user_name}` }); 190 | 191 | if (get_use_new_ss_api()) { 192 | const user_data = await scoresaber.get_user_info_basic(user_id); 193 | user_name = user_data.playerInfo.playerName; 194 | } 195 | 196 | for (let page = 1; ; page++) { 197 | SseEvent.StatusInfo.invoke({ text: `Updating user ${user_name} page ${page}/${(page_max ?? "?")}` }); 198 | 199 | const recent_songs = await scoresaber.get_user_recent_songs_dynamic(user_id, page); 200 | 201 | const { has_old_entry, has_updated } = process_user_page(recent_songs.songs, user); 202 | updated = updated || has_updated; 203 | page_max = recent_songs.meta.max_pages ?? page_max; 204 | user_name = recent_songs.meta.user_name ?? user_name; 205 | if ((!force && has_old_entry) || recent_songs.meta.was_last_page) { 206 | break; 207 | } 208 | } 209 | 210 | // TODO ADD FEATURE BACK 211 | // process current page to allow force-updating the current site 212 | // const [, has_updated] = process_user_page(document, user); 213 | // updated = updated || has_updated; 214 | 215 | user.name = user_name ?? user.name; 216 | 217 | if (updated) { 218 | usercache.save(); 219 | } 220 | 221 | SseEvent.StatusInfo.invoke({ text: `User ${user_name} updated` }); 222 | SseEvent.UserCacheChanged.invoke(); 223 | } 224 | 225 | export async function fetch_all(force: boolean = false): Promise { 226 | const users = Object.keys(g.user_list); 227 | for (const user of users) { 228 | await fetch_user(user, force); 229 | } 230 | SseEvent.StatusInfo.invoke({ text: `All users updated` }); 231 | } 232 | 233 | interface IProcessResult { 234 | has_old_entry: boolean; 235 | has_updated: boolean; 236 | } 237 | 238 | function process_user_page(songs: scoresaber.ISongTuple[], user: IDbUser): IProcessResult { 239 | let has_old_entry = false; 240 | let has_updated = false; 241 | 242 | for (const [song_id, song] of songs) { 243 | const song_old = user.songs[song_id]; 244 | if (!song_old || !song_equals(song_old, song)) { 245 | logc("Updated: ", song_old, song); 246 | has_updated = true; 247 | } else { 248 | logc("Old found: ", song); 249 | has_old_entry = true; 250 | } 251 | user.songs[song_id] = song; 252 | } 253 | 254 | return { has_old_entry, has_updated }; 255 | } 256 | -------------------------------------------------------------------------------- /src/components/QuickButton.svelte: -------------------------------------------------------------------------------- 1 | 105 | 106 |
115 | {#if type === "BS"} 116 |
117 | {:else if type === "OC"} 118 | 119 | {:else if type === "Beast"} 120 |
121 | {:else if type === "BeastBook"} 122 | 123 | {:else if type === "Preview"} 124 | 125 | {:else if type === "BSR"} 126 | 127 | 128 | {/if} 129 |
130 | 131 | 156 | -------------------------------------------------------------------------------- /src/components/SettingsDialogue.svelte: -------------------------------------------------------------------------------- 1 | 117 | 118 |
119 |
120 | 121 |
122 |
123 | 130 |
131 |
132 |
133 | 134 |
135 | 136 |
137 |
138 | 145 | 148 |
149 | 150 |
151 | 152 |
153 | 154 | 155 | 165 | {/each} 166 | 167 | {#each env.BMPage as page} 168 | 169 | 170 | {#each env.BMButton as button} 171 | 182 | {/each} 183 | 184 | {/each} 185 |
156 | {#each env.BMButton as button} 157 | 158 | 164 |
{page} 172 | 180 |
186 | 187 |
188 | 189 |
190 |
191 | 198 | 201 |
202 | 203 |
204 | 205 |
206 |
207 | 210 | 213 |
214 | 215 |
216 | 217 |
218 |
219 |
220 | 228 | 229 | 230 | 231 |
232 |
233 | 240 |
241 |
242 |
243 |
244 | -------------------------------------------------------------------------------- /src/components/events.ts: -------------------------------------------------------------------------------- 1 | import { BulmaColor } from "../declarations/Types"; 2 | import { logc } from "../util/log"; 3 | 4 | type FuncTyp = T extends void ? () => void : (param: T) => void; 5 | 6 | class SseEventHandler { 7 | private eventName: string; 8 | private callList: (FuncTyp)[]; 9 | 10 | constructor(eventName: string) { 11 | this.eventName = eventName; 12 | this.callList = []; 13 | } 14 | 15 | public invoke(param: T): void { 16 | logc("Event", this.eventName); 17 | for (const func of this.callList) { 18 | func(param); 19 | } 20 | } 21 | 22 | public register(func: FuncTyp): void { 23 | this.callList.push(func); 24 | } 25 | } 26 | 27 | export default class SseEvent { 28 | public static readonly UserCacheChanged = new SseEventHandler("UserCacheChanged"); 29 | public static readonly CompareUserChanged = new SseEventHandler("CompareUserChanged"); 30 | public static readonly PinnedUserChanged = new SseEventHandler("PinnedUserChanged"); 31 | public static readonly UserNotification = new SseEventHandler("UserNotification"); 32 | public static readonly StatusInfo = new SseEventHandler<{ text: string }>("StatusInfo"); 33 | 34 | public static addNotification(notify: IUserNotification): void { 35 | this.notificationList.push(notify); 36 | SseEvent.UserNotification.invoke(); 37 | } 38 | public static getNotifications(): IUserNotification[] { 39 | return this.notificationList; 40 | } 41 | private static readonly notificationList: IUserNotification[] = []; 42 | } 43 | 44 | export interface IUserNotification { 45 | msg: string; 46 | type: BulmaColor; 47 | } 48 | -------------------------------------------------------------------------------- /src/components/modal.ts: -------------------------------------------------------------------------------- 1 | import { create, into, IntoElem } from "../util/dom"; 2 | 3 | interface IModalOptions { 4 | title?: IntoElem; 5 | text: IntoElem; 6 | footer?: IntoElem; 7 | type?: "content" | "card"; 8 | buttons?: IModalButtonGroup; 9 | default?: boolean; 10 | } 11 | 12 | type IModalButtonGroup = { [K in T]: IModalButton; }; 13 | type Answer = T | "x"; 14 | 15 | interface IModalButton { 16 | text: string; 17 | class?: string; 18 | } 19 | 20 | export class Modal { 21 | public after_close?: (answer: Answer) => void; 22 | private elem: HTMLElement; 23 | constructor(elem: HTMLElement) { 24 | this.elem = elem; 25 | } 26 | public show(): void { 27 | this.elem.classList.add("is-active"); 28 | document.documentElement.classList.add("is-clipped"); 29 | } 30 | public close(answer?: Answer): void { 31 | this.elem.classList.remove("is-active"); 32 | if (!document.querySelector(".modal.is-active")) 33 | document.documentElement.classList.remove("is-clipped"); 34 | if (this.after_close) 35 | this.after_close(answer ?? "x"); 36 | } 37 | public dispose(): void { 38 | document.body.removeChild(this.elem); 39 | } 40 | } 41 | 42 | export function create_modal(opt: IModalOptions): Modal { 43 | const base_div = create("div", { class: "modal" }); 44 | const modal = new Modal(base_div); 45 | 46 | const button_bar = create("div", { class: "buttons" }); 47 | 48 | let inner; 49 | switch (opt.type ?? "content") { 50 | case "content": 51 | inner = create("div", { class: "modal-content" }, 52 | create("div", { class: "box" }, 53 | opt.text, 54 | create("br"), 55 | button_bar, 56 | ), 57 | ); 58 | break; 59 | case "card": 60 | inner = create("div", { class: "modal-card" }, 61 | create("header", { class: "modal-card-head" }, opt.title ?? ""), 62 | create("section", { class: "modal-card-body" }, opt.text), 63 | create("footer", { class: "modal-card-foot" }, opt.footer ?? button_bar), 64 | ); 65 | break; 66 | default: 67 | throw new Error("invalid type"); 68 | } 69 | 70 | into(base_div, 71 | create("div", { 72 | class: "modal-background", 73 | onclick() { 74 | modal.close("x"); 75 | } 76 | }), 77 | inner, 78 | create("button", { 79 | class: "modal-close is-large", 80 | /*aria-label="close"*/ 81 | onclick() { 82 | modal.close("x"); 83 | } 84 | }) 85 | ); 86 | 87 | if (opt.buttons) { 88 | for (const btn_name of Object.keys(opt.buttons) as T[]) { 89 | const btn_data = opt.buttons[btn_name]; 90 | into(button_bar, create("button", { 91 | class: ["button", btn_data.class ?? ""], 92 | onclick() { 93 | modal.close(btn_name); 94 | } 95 | }, btn_data.text)); 96 | } 97 | } 98 | 99 | document.body.appendChild(base_div); 100 | if (opt.default) modal.show(); 101 | 102 | return modal; 103 | } 104 | 105 | export function show_modal(opt: IModalOptions): Promise> { 106 | return new Promise((resolve) => { 107 | opt.default = true; 108 | const modal = create_modal(opt); 109 | modal.after_close = (answer) => { 110 | modal.dispose(); 111 | resolve(answer); 112 | }; 113 | }); 114 | } 115 | 116 | export const buttons = { 117 | OkOnly: { x: { text: "Ok", class: "is-primary" } }, 118 | }; 119 | -------------------------------------------------------------------------------- /src/components/toggle_button.ts: -------------------------------------------------------------------------------- 1 | import { create } from "../util/dom"; 2 | 3 | export type IButtonElement = HTMLElement & { 4 | on: () => void, 5 | off: () => void, 6 | toggle: () => void, 7 | view_class: string, 8 | }; 9 | 10 | type ButtonType = "primary" | "link" | "info" | "success" | "danger"; 11 | 12 | interface IButtonOptions { 13 | text: string; 14 | type?: ButtonType; 15 | onclick?: (this: IButtonElement, state: boolean) => void; 16 | default?: boolean; 17 | } 18 | 19 | function get_state(elem: IButtonElement): boolean { 20 | return !elem.classList.contains(elem.view_class); 21 | } 22 | 23 | function set_state(elem: IButtonElement, state: boolean): void { 24 | if (state) { 25 | elem.classList.remove(elem.view_class); 26 | } else { 27 | elem.classList.add(elem.view_class); 28 | } 29 | } 30 | 31 | export function button(opt: IButtonOptions): IButtonElement { 32 | const btn = create("div", { 33 | class: "button" 34 | }, opt.text) as any as IButtonElement; 35 | btn.view_class = `is-${opt.type ?? "primary"}`; 36 | 37 | btn.on = () => { 38 | set_state(btn, true); 39 | opt.onclick?.call(btn, true); 40 | }; 41 | 42 | btn.off = () => { 43 | set_state(btn, false); 44 | opt.onclick?.call(btn, false); 45 | }; 46 | 47 | btn.toggle = () => { 48 | const state = !get_state(btn); 49 | set_state(btn, state); 50 | opt.onclick?.call(btn, state); 51 | }; 52 | 53 | btn.onclick = () => { 54 | if (btn.getAttribute("disabled") == null) { 55 | btn.toggle(); 56 | } 57 | }; 58 | 59 | set_state(btn, opt.default ?? false); 60 | 61 | return btn; 62 | } 63 | -------------------------------------------------------------------------------- /src/declarations/Types.ts: -------------------------------------------------------------------------------- 1 | export interface ISong { 2 | time: string; 3 | pp: number; 4 | accuracy?: number; 5 | score?: number; 6 | mods?: string[]; 7 | } 8 | 9 | export interface IUser { 10 | id: string; 11 | name: string; 12 | } 13 | 14 | export interface IDbUser { 15 | name: string; 16 | songs: { [song_id: string]: ISong }; 17 | } 18 | 19 | export type BulmaSize = "small" | "medium" | "large"; 20 | 21 | export type BulmaColor = "primary" | "link" | "info" | "success" | "warning" | "danger"; 22 | 23 | export type BulmaColorClass = "is-primary" | "is-link" | "is-info" | "is-success" | "is-warning" | "is-danger"; 24 | -------------------------------------------------------------------------------- /src/declarations/chart.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for Chart.js 2.8 2 | // Project: https://github.com/nnnick/Chart.js, https://www.chartjs.org 3 | // Definitions by: Alberto Nuti 4 | // Fabien Lavocat 5 | // KentarouTakeda 6 | // Larry Bahr 7 | // Daniel Luz 8 | // Joseph Page 9 | // Dan Manastireanu 10 | // Guillaume Rodriguez 11 | // Simon Archer 12 | // Ken Elkabany 13 | // Francesco Benedetto 14 | // Alexandros Dorodoulis 15 | // Manuel Heidrich 16 | // Conrad Holtzhausen 17 | // Adrián Caballero 18 | // wertzui 19 | // Martin Trobäck 20 | // Elian Cordoba 21 | // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped 22 | // TypeScript Version: 2.3 23 | 24 | declare class Chart { 25 | static readonly Chart: typeof Chart; 26 | constructor( 27 | context: string | CanvasRenderingContext2D | HTMLCanvasElement | ArrayLike, 28 | options: Chart.ChartConfiguration 29 | ); 30 | config: Chart.ChartConfiguration; 31 | data: Chart.ChartData; 32 | destroy: () => {}; 33 | update: ({duration, lazy, easing}?: Chart.ChartUpdateProps) => {}; 34 | render: ({duration, lazy, easing}?: Chart.ChartRenderProps) => {}; 35 | stop: () => {}; 36 | resize: () => {}; 37 | clear: () => {}; 38 | toBase64Image: () => string; 39 | generateLegend: () => {}; 40 | getElementAtEvent: (e: any) => [{}]; 41 | getElementsAtEvent: (e: any) => Array<{}>; 42 | getDatasetAtEvent: (e: any) => Array<{}>; 43 | getDatasetMeta: (index: number) => Meta; 44 | ctx: CanvasRenderingContext2D | null; 45 | canvas: HTMLCanvasElement | null; 46 | width: number | null; 47 | height: number | null; 48 | aspectRatio: number | null; 49 | options: Chart.ChartOptions; 50 | chartArea: Chart.ChartArea; 51 | static pluginService: PluginServiceStatic; 52 | static plugins: PluginServiceStatic; 53 | 54 | static defaults: { 55 | global: Chart.ChartOptions & Chart.ChartFontOptions; 56 | [key: string]: any; 57 | }; 58 | 59 | static controllers: { 60 | [key: string]: any; 61 | }; 62 | 63 | static helpers: { 64 | [key: string]: any; 65 | }; 66 | 67 | // Tooltip Static Options 68 | static Tooltip: Chart.ChartTooltipsStaticConfiguration; 69 | } 70 | declare class PluginServiceStatic { 71 | register(plugin: Chart.PluginServiceGlobalRegistration & Chart.PluginServiceRegistrationOptions): void; 72 | unregister(plugin: Chart.PluginServiceGlobalRegistration & Chart.PluginServiceRegistrationOptions): void; 73 | } 74 | 75 | interface Meta { 76 | type: Chart.ChartType; 77 | data: MetaData[]; 78 | dataset?: Chart.ChartDataSets; 79 | controller: { [key: string]: any; }; 80 | hidden?: boolean; 81 | total?: string; 82 | xAxisID?: string; 83 | yAxisID?: string; 84 | "$filler"?: { [key: string]: any; }; 85 | } 86 | 87 | interface MetaData { 88 | _chart: Chart; 89 | _datasetIndex: number; 90 | _index: number; 91 | _model: Model; 92 | _start?: any; 93 | _view: Model; 94 | _xScale: Chart.ChartScales; 95 | _yScale: Chart.ChartScales; 96 | hidden?: boolean; 97 | } 98 | 99 | interface Model { 100 | backgroundColor: string; 101 | borderColor: string; 102 | borderWidth?: number; 103 | controlPointNextX: number; 104 | controlPointNextY: number; 105 | controlPointPreviousX: number; 106 | controlPointPreviousY: number; 107 | hitRadius: number; 108 | pointStyle: string; 109 | radius: string; 110 | skip?: boolean; 111 | steppedLine?: undefined; 112 | tension: number; 113 | x: number; 114 | y: number; 115 | base: number; 116 | head: number; 117 | } 118 | 119 | declare namespace Chart { 120 | type ChartType = 'line' | 'bar' | 'horizontalBar' | 'radar' | 'doughnut' | 'polarArea' | 'bubble' | 'pie' | 'scatter'; 121 | 122 | type TimeUnit = 'millisecond' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'; 123 | 124 | type ScaleType = 'category' | 'linear' | 'logarithmic' | 'time' | 'radialLinear'; 125 | 126 | type PointStyle = 'circle' | 'cross' | 'crossRot' | 'dash' | 'line' | 'rect' | 'rectRounded' | 'rectRot' | 'star' | 'triangle'; 127 | 128 | type PositionType = 'left' | 'right' | 'top' | 'bottom'; 129 | 130 | type InteractionMode = 'point' | 'nearest' | 'single' | 'label' | 'index' | 'x-axis' | 'dataset' | 'x' | 'y'; 131 | 132 | type Easing = 'linear' | 'easeInQuad' | 'easeOutQuad' | 'easeInOutQuad' | 'easeInCubic' | 'easeOutCubic' | 'easeInOutCubic' | 133 | 'easeInQuart' | 'easeOutQuart' | 'easeInOutQuart' | 'easeInQuint' | 'easeOutQuint' | 'easeInOutQuint' | 'easeInSine' | 'easeOutSine' | 134 | 'easeInOutSine' | 'easeInExpo' | 'easeOutExpo' | 'easeInOutExpo' | 'easeInCirc' | 'easeOutCirc' | 'easeInOutCirc' | 'easeInElastic' | 135 | 'easeOutElastic' | 'easeInOutElastic' | 'easeInBack' | 'easeOutBack' | 'easeInOutBack' | 'easeInBounce' | 'easeOutBounce' | 'easeInOutBounce'; 136 | 137 | type TextAlignment = 'left' | 'center' | 'right'; 138 | 139 | type BorderAlignment = 'center' | 'inner'; 140 | 141 | type BorderWidth = number | { [key in PositionType]?: number }; 142 | 143 | interface ChartArea { 144 | top: number; 145 | right: number; 146 | bottom: number; 147 | left: number; 148 | } 149 | 150 | interface ChartLegendItem { 151 | text?: string; 152 | fillStyle?: string; 153 | hidden?: boolean; 154 | lineCap?: 'butt' | 'round' | 'square'; 155 | lineDash?: number[]; 156 | lineDashOffset?: number; 157 | lineJoin?: 'bevel' | 'round' | 'miter'; 158 | lineWidth?: number; 159 | strokeStyle?: string; 160 | pointStyle?: PointStyle; 161 | } 162 | 163 | interface ChartLegendLabelItem extends ChartLegendItem { 164 | datasetIndex: number; 165 | } 166 | 167 | interface ChartTooltipItem { 168 | label?: string; 169 | value?: string; 170 | xLabel?: string | number; 171 | yLabel?: string | number; 172 | datasetIndex?: number; 173 | index?: number; 174 | x?: number; 175 | y?: number; 176 | } 177 | 178 | interface ChartTooltipLabelColor { 179 | borderColor: ChartColor; 180 | backgroundColor: ChartColor; 181 | } 182 | 183 | interface ChartTooltipCallback { 184 | beforeTitle?(item: ChartTooltipItem[], data: ChartData): string | string[]; 185 | title?(item: ChartTooltipItem[], data: ChartData): string | string[]; 186 | afterTitle?(item: ChartTooltipItem[], data: ChartData): string | string[]; 187 | beforeBody?(item: ChartTooltipItem[], data: ChartData): string | string[]; 188 | beforeLabel?(tooltipItem: ChartTooltipItem, data: ChartData): string | string[]; 189 | label?(tooltipItem: ChartTooltipItem, data: ChartData): string | string[]; 190 | labelColor?(tooltipItem: ChartTooltipItem, chart: Chart): ChartTooltipLabelColor; 191 | labelTextColor?(tooltipItem: ChartTooltipItem, chart: Chart): string; 192 | afterLabel?(tooltipItem: ChartTooltipItem, data: ChartData): string | string[]; 193 | afterBody?(item: ChartTooltipItem[], data: ChartData): string | string[]; 194 | beforeFooter?(item: ChartTooltipItem[], data: ChartData): string | string[]; 195 | footer?(item: ChartTooltipItem[], data: ChartData): string | string[]; 196 | afterFooter?(item: ChartTooltipItem[], data: ChartData): string | string[]; 197 | } 198 | 199 | interface ChartAnimationParameter { 200 | chartInstance?: any; 201 | animationObject?: any; 202 | } 203 | 204 | interface ChartPoint { 205 | x?: number | string | Date; 206 | y?: number | string | Date; 207 | r?: number; 208 | t?: number | string | Date; 209 | } 210 | 211 | interface ChartConfiguration { 212 | type?: ChartType | string; 213 | data?: ChartData; 214 | options?: ChartOptions; 215 | plugins?: PluginServiceRegistrationOptions[]; 216 | } 217 | 218 | interface ChartData { 219 | labels?: Array; 220 | datasets?: ChartDataSets[]; 221 | } 222 | 223 | interface RadialChartOptions extends ChartOptions { 224 | scale?: RadialLinearScale; 225 | } 226 | 227 | interface ChartSize { 228 | height: number; 229 | width: number; 230 | } 231 | 232 | interface ChartOptions { 233 | responsive?: boolean; 234 | responsiveAnimationDuration?: number; 235 | aspectRatio?: number; 236 | maintainAspectRatio?: boolean; 237 | events?: string[]; 238 | legendCallback?(chart: Chart): string; 239 | onHover?(this: Chart, event: MouseEvent, activeElements: Array<{}>): any; 240 | onClick?(event?: MouseEvent, activeElements?: Array<{}>): any; 241 | onResize?(this: Chart, newSize: ChartSize): void; 242 | title?: ChartTitleOptions; 243 | legend?: ChartLegendOptions; 244 | tooltips?: ChartTooltipOptions; 245 | hover?: ChartHoverOptions; 246 | animation?: ChartAnimationOptions; 247 | elements?: ChartElementsOptions; 248 | layout?: ChartLayoutOptions; 249 | scale?: { display?: boolean }; 250 | scales?: ChartScales; 251 | showLines?: boolean; 252 | spanGaps?: boolean; 253 | cutoutPercentage?: number; 254 | circumference?: number; 255 | rotation?: number; 256 | devicePixelRatio?: number; 257 | plugins?: ChartPluginsOptions; 258 | } 259 | 260 | interface ChartFontOptions { 261 | defaultFontColor?: ChartColor; 262 | defaultFontFamily?: string; 263 | defaultFontSize?: number; 264 | defaultFontStyle?: string; 265 | } 266 | 267 | interface ChartTitleOptions { 268 | display?: boolean; 269 | position?: PositionType; 270 | fullWidth?: boolean; 271 | fontSize?: number; 272 | fontFamily?: string; 273 | fontColor?: ChartColor; 274 | fontStyle?: string; 275 | padding?: number; 276 | text?: string | string[]; 277 | } 278 | 279 | interface ChartLegendOptions { 280 | display?: boolean; 281 | position?: PositionType; 282 | fullWidth?: boolean; 283 | onClick?(event: MouseEvent, legendItem: ChartLegendLabelItem): void; 284 | onHover?(event: MouseEvent, legendItem: ChartLegendLabelItem): void; 285 | labels?: ChartLegendLabelOptions; 286 | reverse?: boolean; 287 | } 288 | 289 | interface ChartLegendLabelOptions { 290 | boxWidth?: number; 291 | fontSize?: number; 292 | fontStyle?: string; 293 | fontColor?: ChartColor; 294 | fontFamily?: string; 295 | padding?: number; 296 | generateLabels?(chart: Chart): ChartLegendLabelItem[]; 297 | filter?(legendItem: ChartLegendLabelItem, data: ChartData): any; 298 | usePointStyle?: boolean; 299 | } 300 | 301 | interface ChartTooltipOptions { 302 | enabled?: boolean; 303 | custom?(a: any): void; 304 | mode?: InteractionMode; 305 | intersect?: boolean; 306 | backgroundColor?: ChartColor; 307 | titleAlign?: TextAlignment; 308 | titleFontFamily?: string; 309 | titleFontSize?: number; 310 | titleFontStyle?: string; 311 | titleFontColor?: ChartColor; 312 | titleSpacing?: number; 313 | titleMarginBottom?: number; 314 | bodyAlign?: TextAlignment; 315 | bodyFontFamily?: string; 316 | bodyFontSize?: number; 317 | bodyFontStyle?: string; 318 | bodyFontColor?: ChartColor; 319 | bodySpacing?: number; 320 | footerAlign?: TextAlignment; 321 | footerFontFamily?: string; 322 | footerFontSize?: number; 323 | footerFontStyle?: string; 324 | footerFontColor?: ChartColor; 325 | footerSpacing?: number; 326 | footerMarginTop?: number; 327 | xPadding?: number; 328 | yPadding?: number; 329 | caretSize?: number; 330 | cornerRadius?: number; 331 | multiKeyBackground?: string; 332 | callbacks?: ChartTooltipCallback; 333 | filter?(item: ChartTooltipItem, data: ChartData): boolean; 334 | itemSort?(itemA: ChartTooltipItem, itemB: ChartTooltipItem, data?: ChartData): number; 335 | position?: string; 336 | caretPadding?: number; 337 | displayColors?: boolean; 338 | borderColor?: ChartColor; 339 | borderWidth?: number; 340 | } 341 | 342 | // NOTE: declare plugin options as interface instead of inline '{ [plugin: string]: any }' 343 | // to allow module augmentation in case some plugins want to strictly type their options. 344 | interface ChartPluginsOptions { 345 | [pluginId: string]: any; 346 | } 347 | 348 | interface ChartTooltipsStaticConfiguration { 349 | positioners: { [mode: string]: ChartTooltipPositioner }; 350 | } 351 | 352 | type ChartTooltipPositioner = (elements: any[], eventPosition: Point) => Point; 353 | 354 | interface ChartHoverOptions { 355 | mode?: InteractionMode; 356 | animationDuration?: number; 357 | intersect?: boolean; 358 | onHover?(this: Chart, event: MouseEvent, activeElements: Array<{}>): any; 359 | } 360 | 361 | interface ChartAnimationObject { 362 | currentStep?: number; 363 | numSteps?: number; 364 | easing?: Easing; 365 | render?(arg: any): void; 366 | onAnimationProgress?(arg: any): void; 367 | onAnimationComplete?(arg: any): void; 368 | } 369 | 370 | interface ChartAnimationOptions { 371 | duration?: number; 372 | easing?: Easing; 373 | onProgress?(chart: any): void; 374 | onComplete?(chart: any): void; 375 | animateRotate?: boolean; 376 | animateScale?: boolean; 377 | } 378 | 379 | interface ChartElementsOptions { 380 | point?: ChartPointOptions; 381 | line?: ChartLineOptions; 382 | arc?: ChartArcOptions; 383 | rectangle?: ChartRectangleOptions; 384 | } 385 | 386 | interface ChartArcOptions { 387 | backgroundColor?: ChartColor; 388 | borderColor?: ChartColor; 389 | borderWidth?: number; 390 | } 391 | 392 | interface ChartLineOptions { 393 | cubicInterpolationMode?: 'default' | 'monotone'; 394 | tension?: number; 395 | backgroundColor?: ChartColor; 396 | borderWidth?: number; 397 | borderColor?: ChartColor; 398 | borderCapStyle?: string; 399 | borderDash?: any[]; 400 | borderDashOffset?: number; 401 | borderJoinStyle?: string; 402 | capBezierPoints?: boolean; 403 | fill?: 'zero' | 'top' | 'bottom' | boolean; 404 | stepped?: boolean; 405 | } 406 | 407 | interface ChartPointOptions { 408 | radius?: number; 409 | pointStyle?: PointStyle; 410 | backgroundColor?: ChartColor; 411 | borderWidth?: number; 412 | borderColor?: ChartColor; 413 | hitRadius?: number; 414 | hoverRadius?: number; 415 | hoverBorderWidth?: number; 416 | } 417 | 418 | interface ChartRectangleOptions { 419 | backgroundColor?: ChartColor; 420 | borderWidth?: number; 421 | borderColor?: ChartColor; 422 | borderSkipped?: string; 423 | } 424 | 425 | interface ChartLayoutOptions { 426 | padding?: ChartLayoutPaddingObject | number; 427 | } 428 | 429 | interface ChartLayoutPaddingObject { 430 | top?: number; 431 | right?: number; 432 | bottom?: number; 433 | left?: number; 434 | } 435 | 436 | interface GridLineOptions { 437 | display?: boolean; 438 | color?: ChartColor; 439 | borderDash?: number[]; 440 | borderDashOffset?: number; 441 | lineWidth?: number | number[]; 442 | drawBorder?: boolean; 443 | drawOnChartArea?: boolean; 444 | drawTicks?: boolean; 445 | tickMarkLength?: number; 446 | zeroLineWidth?: number; 447 | zeroLineColor?: ChartColor; 448 | zeroLineBorderDash?: number[]; 449 | zeroLineBorderDashOffset?: number; 450 | offsetGridLines?: boolean; 451 | } 452 | 453 | interface ScaleTitleOptions { 454 | display?: boolean; 455 | labelString?: string; 456 | lineHeight?: number | string; 457 | fontColor?: ChartColor; 458 | fontFamily?: string; 459 | fontSize?: number; 460 | fontStyle?: string; 461 | padding?: ChartLayoutPaddingObject | number; 462 | } 463 | 464 | interface TickOptions extends NestedTickOptions { 465 | minor?: NestedTickOptions | false; 466 | major?: MajorTickOptions | false; 467 | } 468 | 469 | interface NestedTickOptions { 470 | autoSkip?: boolean; 471 | autoSkipPadding?: number; 472 | backdropColor?: ChartColor; 473 | backdropPaddingX?: number; 474 | backdropPaddingY?: number; 475 | beginAtZero?: boolean; 476 | callback?(value: any, index: any, values: any): string | number; 477 | display?: boolean; 478 | fontColor?: ChartColor; 479 | fontFamily?: string; 480 | fontSize?: number; 481 | fontStyle?: string; 482 | labelOffset?: number; 483 | lineHeight?: number; 484 | max?: any; 485 | maxRotation?: number; 486 | maxTicksLimit?: number; 487 | min?: any; 488 | minRotation?: number; 489 | mirror?: boolean; 490 | padding?: number; 491 | reverse?: boolean; 492 | showLabelBackdrop?: boolean; 493 | source?: 'auto' | 'data' | 'labels'; 494 | stepSize?: number; 495 | suggestedMax?: number; 496 | suggestedMin?: number; 497 | } 498 | 499 | interface MajorTickOptions extends NestedTickOptions { 500 | enabled?: boolean; 501 | } 502 | 503 | interface AngleLineOptions { 504 | display?: boolean; 505 | color?: ChartColor; 506 | lineWidth?: number; 507 | } 508 | 509 | interface PointLabelOptions { 510 | callback?(arg: any): any; 511 | fontColor?: ChartColor; 512 | fontFamily?: string; 513 | fontSize?: number; 514 | fontStyle?: string; 515 | } 516 | 517 | interface LinearTickOptions extends TickOptions { 518 | maxTicksLimit?: number; 519 | stepSize?: number; 520 | precision?: number; 521 | suggestedMin?: number; 522 | suggestedMax?: number; 523 | } 524 | 525 | interface LogarithmicTickOptions extends TickOptions { 526 | } 527 | 528 | type ChartColor = string | CanvasGradient | CanvasPattern | string[]; 529 | 530 | type Scriptable = (ctx: { 531 | chart?: Chart; 532 | dataIndex?: number; 533 | dataset?: ChartDataSets 534 | datasetIndex?: number; 535 | }) => T; 536 | 537 | interface ChartDataSets { 538 | cubicInterpolationMode?: 'default' | 'monotone'; 539 | backgroundColor?: ChartColor | ChartColor[] | Scriptable; 540 | borderAlign?: BorderAlignment | BorderAlignment[] | Scriptable; 541 | borderWidth?: BorderWidth | BorderWidth[] | Scriptable; 542 | borderColor?: ChartColor | ChartColor[] | Scriptable; 543 | borderCapStyle?: 'butt' | 'round' | 'square'; 544 | borderDash?: number[]; 545 | borderDashOffset?: number; 546 | borderJoinStyle?: 'bevel' | 'round' | 'miter'; 547 | borderSkipped?: PositionType | PositionType[] | Scriptable; 548 | data?: Array | ChartPoint[]; 549 | fill?: boolean | number | string; 550 | hitRadius?: number | number[] | Scriptable; 551 | hoverBackgroundColor?: ChartColor | ChartColor[] | Scriptable; 552 | hoverBorderColor?: ChartColor | ChartColor[] | Scriptable; 553 | hoverBorderWidth?: number | number[] | Scriptable; 554 | label?: string; 555 | lineTension?: number; 556 | steppedLine?: 'before' | 'after' | 'middle' | boolean; 557 | pointBorderColor?: ChartColor | ChartColor[] | Scriptable; 558 | pointBackgroundColor?: ChartColor | ChartColor[] | Scriptable; 559 | pointBorderWidth?: number | number[] | Scriptable; 560 | pointRadius?: number | number[] | Scriptable; 561 | pointRotation?: number | number[] | Scriptable; 562 | pointHoverRadius?: number | number[] | Scriptable; 563 | pointHitRadius?: number | number[] | Scriptable; 564 | pointHoverBackgroundColor?: ChartColor | ChartColor[] | Scriptable; 565 | pointHoverBorderColor?: ChartColor | ChartColor[] | Scriptable; 566 | pointHoverBorderWidth?: number | number[] | Scriptable; 567 | pointStyle?: PointStyle | HTMLImageElement | HTMLCanvasElement | Array | Scriptable; 568 | radius?: number | number[] | Scriptable; 569 | rotation?: number | number[] | Scriptable; 570 | xAxisID?: string; 571 | yAxisID?: string; 572 | type?: ChartType | string; 573 | hidden?: boolean; 574 | hideInLegendAndTooltip?: boolean; 575 | showLine?: boolean; 576 | stack?: string; 577 | spanGaps?: boolean; 578 | } 579 | 580 | interface ChartScales { 581 | type?: ScaleType | string; 582 | display?: boolean; 583 | position?: PositionType | string; 584 | gridLines?: GridLineOptions; 585 | scaleLabel?: ScaleTitleOptions; 586 | ticks?: TickOptions; 587 | xAxes?: ChartXAxe[]; 588 | yAxes?: ChartYAxe[]; 589 | } 590 | 591 | interface CommonAxe { 592 | bounds?: string; 593 | type?: ScaleType | string; 594 | display?: boolean; 595 | id?: string; 596 | stacked?: boolean; 597 | position?: string; 598 | ticks?: TickOptions; 599 | gridLines?: GridLineOptions; 600 | barThickness?: number | "flex"; 601 | maxBarThickness?: number; 602 | minBarLength?: number; 603 | scaleLabel?: ScaleTitleOptions; 604 | time?: TimeScale; 605 | offset?: boolean; 606 | beforeUpdate?(scale?: any): void; 607 | beforeSetDimension?(scale?: any): void; 608 | beforeDataLimits?(scale?: any): void; 609 | beforeBuildTicks?(scale?: any): void; 610 | beforeTickToLabelConversion?(scale?: any): void; 611 | beforeCalculateTickRotation?(scale?: any): void; 612 | beforeFit?(scale?: any): void; 613 | afterUpdate?(scale?: any): void; 614 | afterSetDimension?(scale?: any): void; 615 | afterDataLimits?(scale?: any): void; 616 | afterBuildTicks?(scale?: any): void; 617 | afterTickToLabelConversion?(scale?: any): void; 618 | afterCalculateTickRotation?(scale?: any): void; 619 | afterFit?(scale?: any): void; 620 | } 621 | 622 | interface ChartXAxe extends CommonAxe { 623 | categoryPercentage?: number; 624 | barPercentage?: number; 625 | distribution?: 'linear' | 'series'; 626 | } 627 | 628 | interface ChartYAxe extends CommonAxe { 629 | } 630 | 631 | interface LinearScale extends ChartScales { 632 | ticks?: LinearTickOptions; 633 | } 634 | 635 | interface LogarithmicScale extends ChartScales { 636 | ticks?: LogarithmicTickOptions; 637 | } 638 | 639 | interface TimeDisplayFormat { 640 | millisecond?: string; 641 | second?: string; 642 | minute?: string; 643 | hour?: string; 644 | day?: string; 645 | week?: string; 646 | month?: string; 647 | quarter?: string; 648 | year?: string; 649 | } 650 | 651 | interface TimeScale extends ChartScales { 652 | displayFormats?: TimeDisplayFormat; 653 | isoWeekday?: boolean; 654 | max?: string; 655 | min?: string; 656 | parser?: string | ((arg: any) => any); 657 | round?: TimeUnit; 658 | tooltipFormat?: string; 659 | unit?: TimeUnit; 660 | unitStepSize?: number; 661 | stepSize?: number; 662 | minUnit?: TimeUnit; 663 | } 664 | 665 | interface RadialLinearScale extends LinearScale { 666 | lineArc?: boolean; 667 | angleLines?: AngleLineOptions; 668 | pointLabels?: PointLabelOptions; 669 | ticks?: TickOptions; 670 | } 671 | 672 | interface Point { 673 | x: number; 674 | y: number; 675 | } 676 | 677 | interface PluginServiceGlobalRegistration { 678 | id?: string; 679 | } 680 | 681 | interface PluginServiceRegistrationOptions { 682 | beforeInit?(chartInstance: Chart, options?: any): void; 683 | afterInit?(chartInstance: Chart, options?: any): void; 684 | 685 | beforeUpdate?(chartInstance: Chart, options?: any): void; 686 | afterUpdate?(chartInstance: Chart, options?: any): void; 687 | 688 | beforeLayout?(chartInstance: Chart, options?: any): void; 689 | afterLayout?(chartInstance: Chart, options?: any): void; 690 | 691 | beforeDatasetsUpdate?(chartInstance: Chart, options?: any): void; 692 | afterDatasetsUpdate?(chartInstance: Chart, options?: any): void; 693 | 694 | beforeDatasetUpdate?(chartInstance: Chart, options?: any): void; 695 | afterDatasetUpdate?(chartInstance: Chart, options?: any): void; 696 | 697 | // This is called at the start of a render. It is only called once, even if the animation will run for a number of frames. Use beforeDraw or afterDraw 698 | // to do something on each animation frame 699 | beforeRender?(chartInstance: Chart, options?: any): void; 700 | afterRender?(chartInstance: Chart, options?: any): void; 701 | 702 | // Easing is for animation 703 | beforeDraw?(chartInstance: Chart, easing: Easing, options?: any): void; 704 | afterDraw?(chartInstance: Chart, easing: Easing, options?: any): void; 705 | 706 | // Before the datasets are drawn but after scales are drawn 707 | beforeDatasetsDraw?(chartInstance: Chart, easing: Easing, options?: any): void; 708 | afterDatasetsDraw?(chartInstance: Chart, easing: Easing, options?: any): void; 709 | 710 | beforeDatasetDraw?(chartInstance: Chart, easing: Easing, options?: any): void; 711 | afterDatasetDraw?(chartInstance: Chart, easing: Easing, options?: any): void; 712 | 713 | // Called before drawing the `tooltip`. If any plugin returns `false`, 714 | // the tooltip drawing is cancelled until another `render` is triggered. 715 | beforeTooltipDraw?(chartInstance: Chart, tooltipData?: any, options?: any): void; 716 | // Called after drawing the `tooltip`. Note that this hook will not, 717 | // be called if the tooltip drawing has been previously cancelled. 718 | afterTooltipDraw?(chartInstance: Chart, tooltipData?: any, options?: any): void; 719 | 720 | // Called when an event occurs on the chart 721 | beforeEvent?(chartInstance: Chart, event: Event, options?: any): void; 722 | afterEvent?(chartInstance: Chart, event: Event, options?: any): void; 723 | 724 | resize?(chartInstance: Chart, newChartSize: ChartSize, options?: any): void; 725 | destroy?(chartInstance: Chart): void; 726 | 727 | /** @deprecated since version 2.5.0. Use `afterLayout` instead. */ 728 | afterScaleUpdate?(chartInstance: Chart, options?: any): void; 729 | } 730 | 731 | interface ChartUpdateProps { 732 | duration?: number; 733 | lazy?: boolean; 734 | easing?: Easing; 735 | } 736 | 737 | interface ChartRenderProps { 738 | duration?: number; 739 | lazy?: boolean; 740 | easing?: Easing; 741 | } 742 | } 743 | 744 | //export = Chart; 745 | //export as namespace Chart; 746 | -------------------------------------------------------------------------------- /src/declarations/moment.d.ts: -------------------------------------------------------------------------------- 1 | declare function moment(inp?: moment.MomentInput, format?: moment.MomentFormatSpecification, strict?: boolean): moment.Moment; 2 | declare function moment(inp?: moment.MomentInput, format?: moment.MomentFormatSpecification, language?: string, strict?: boolean): moment.Moment; 3 | 4 | declare namespace moment { 5 | type RelativeTimeKey = 's' | 'ss' | 'm' | 'mm' | 'h' | 'hh' | 'd' | 'dd' | 'M' | 'MM' | 'y' | 'yy'; 6 | type CalendarKey = 'sameDay' | 'nextDay' | 'lastDay' | 'nextWeek' | 'lastWeek' | 'sameElse' | string; 7 | type LongDateFormatKey = 'LTS' | 'LT' | 'L' | 'LL' | 'LLL' | 'LLLL' | 'lts' | 'lt' | 'l' | 'll' | 'lll' | 'llll'; 8 | 9 | interface Locale { 10 | calendar(key?: CalendarKey, m?: Moment, now?: Moment): string; 11 | 12 | longDateFormat(key: LongDateFormatKey): string; 13 | invalidDate(): string; 14 | ordinal(n: number): string; 15 | 16 | preparse(inp: string): string; 17 | postformat(inp: string): string; 18 | relativeTime(n: number, withoutSuffix: boolean, 19 | key: RelativeTimeKey, isFuture: boolean): string; 20 | pastFuture(diff: number, absRelTime: string): string; 21 | set(config: Object): void; 22 | 23 | months(): string[]; 24 | months(m: Moment, format?: string): string; 25 | monthsShort(): string[]; 26 | monthsShort(m: Moment, format?: string): string; 27 | monthsParse(monthName: string, format: string, strict: boolean): number; 28 | monthsRegex(strict: boolean): RegExp; 29 | monthsShortRegex(strict: boolean): RegExp; 30 | 31 | week(m: Moment): number; 32 | firstDayOfYear(): number; 33 | firstDayOfWeek(): number; 34 | 35 | weekdays(): string[]; 36 | weekdays(m: Moment, format?: string): string; 37 | weekdaysMin(): string[]; 38 | weekdaysMin(m: Moment): string; 39 | weekdaysShort(): string[]; 40 | weekdaysShort(m: Moment): string; 41 | weekdaysParse(weekdayName: string, format: string, strict: boolean): number; 42 | weekdaysRegex(strict: boolean): RegExp; 43 | weekdaysShortRegex(strict: boolean): RegExp; 44 | weekdaysMinRegex(strict: boolean): RegExp; 45 | 46 | isPM(input: string): boolean; 47 | meridiem(hour: number, minute: number, isLower: boolean): string; 48 | } 49 | 50 | interface StandaloneFormatSpec { 51 | format: string[]; 52 | standalone: string[]; 53 | isFormat?: RegExp; 54 | } 55 | 56 | interface WeekSpec { 57 | dow: number; 58 | doy?: number; 59 | } 60 | 61 | type CalendarSpecVal = string | ((m?: MomentInput, now?: Moment) => string); 62 | interface CalendarSpec { 63 | sameDay?: CalendarSpecVal; 64 | nextDay?: CalendarSpecVal; 65 | lastDay?: CalendarSpecVal; 66 | nextWeek?: CalendarSpecVal; 67 | lastWeek?: CalendarSpecVal; 68 | sameElse?: CalendarSpecVal; 69 | 70 | // any additional properties might be used with moment.calendarFormat 71 | [x: string]: CalendarSpecVal | void; // undefined 72 | } 73 | 74 | type RelativeTimeSpecVal = ( 75 | string | 76 | ((n: number, withoutSuffix: boolean, 77 | key: RelativeTimeKey, isFuture: boolean) => string) 78 | ); 79 | type RelativeTimeFuturePastVal = string | ((relTime: string) => string); 80 | 81 | interface RelativeTimeSpec { 82 | future?: RelativeTimeFuturePastVal; 83 | past?: RelativeTimeFuturePastVal; 84 | s?: RelativeTimeSpecVal; 85 | ss?: RelativeTimeSpecVal; 86 | m?: RelativeTimeSpecVal; 87 | mm?: RelativeTimeSpecVal; 88 | h?: RelativeTimeSpecVal; 89 | hh?: RelativeTimeSpecVal; 90 | d?: RelativeTimeSpecVal; 91 | dd?: RelativeTimeSpecVal; 92 | M?: RelativeTimeSpecVal; 93 | MM?: RelativeTimeSpecVal; 94 | y?: RelativeTimeSpecVal; 95 | yy?: RelativeTimeSpecVal; 96 | } 97 | 98 | interface LongDateFormatSpec { 99 | LTS: string; 100 | LT: string; 101 | L: string; 102 | LL: string; 103 | LLL: string; 104 | LLLL: string; 105 | 106 | // lets forget for a sec that any upper/lower permutation will also work 107 | lts?: string; 108 | lt?: string; 109 | l?: string; 110 | ll?: string; 111 | lll?: string; 112 | llll?: string; 113 | } 114 | 115 | type MonthWeekdayFn = (momentToFormat: Moment, format?: string) => string; 116 | type WeekdaySimpleFn = (momentToFormat: Moment) => string; 117 | 118 | interface LocaleSpecification { 119 | months?: string[] | StandaloneFormatSpec | MonthWeekdayFn; 120 | monthsShort?: string[] | StandaloneFormatSpec | MonthWeekdayFn; 121 | 122 | weekdays?: string[] | StandaloneFormatSpec | MonthWeekdayFn; 123 | weekdaysShort?: string[] | StandaloneFormatSpec | WeekdaySimpleFn; 124 | weekdaysMin?: string[] | StandaloneFormatSpec | WeekdaySimpleFn; 125 | 126 | meridiemParse?: RegExp; 127 | meridiem?: (hour: number, minute:number, isLower: boolean) => string; 128 | 129 | isPM?: (input: string) => boolean; 130 | 131 | longDateFormat?: LongDateFormatSpec; 132 | calendar?: CalendarSpec; 133 | relativeTime?: RelativeTimeSpec; 134 | invalidDate?: string; 135 | ordinal?: (n: number) => string; 136 | ordinalParse?: RegExp; 137 | 138 | week?: WeekSpec; 139 | 140 | // Allow anything: in general any property that is passed as locale spec is 141 | // put in the locale object so it can be used by locale functions 142 | [x: string]: any; 143 | } 144 | 145 | interface MomentObjectOutput { 146 | years: number; 147 | /* One digit */ 148 | months: number; 149 | /* Day of the month */ 150 | date: number; 151 | hours: number; 152 | minutes: number; 153 | seconds: number; 154 | milliseconds: number; 155 | } 156 | 157 | interface Duration { 158 | clone(): Duration; 159 | 160 | humanize(withSuffix?: boolean): string; 161 | 162 | abs(): Duration; 163 | 164 | as(units: unitOfTime.Base): number; 165 | get(units: unitOfTime.Base): number; 166 | 167 | milliseconds(): number; 168 | asMilliseconds(): number; 169 | 170 | seconds(): number; 171 | asSeconds(): number; 172 | 173 | minutes(): number; 174 | asMinutes(): number; 175 | 176 | hours(): number; 177 | asHours(): number; 178 | 179 | days(): number; 180 | asDays(): number; 181 | 182 | weeks(): number; 183 | asWeeks(): number; 184 | 185 | months(): number; 186 | asMonths(): number; 187 | 188 | years(): number; 189 | asYears(): number; 190 | 191 | add(inp?: DurationInputArg1, unit?: DurationInputArg2): Duration; 192 | subtract(inp?: DurationInputArg1, unit?: DurationInputArg2): Duration; 193 | 194 | locale(): string; 195 | locale(locale: LocaleSpecifier): Duration; 196 | localeData(): Locale; 197 | 198 | toISOString(): string; 199 | toJSON(): string; 200 | 201 | isValid(): boolean; 202 | 203 | /** 204 | * @deprecated since version 2.8.0 205 | */ 206 | lang(locale: LocaleSpecifier): Moment; 207 | /** 208 | * @deprecated since version 2.8.0 209 | */ 210 | lang(): Locale; 211 | /** 212 | * @deprecated 213 | */ 214 | toIsoString(): string; 215 | } 216 | 217 | interface MomentRelativeTime { 218 | future: any; 219 | past: any; 220 | s: any; 221 | ss: any; 222 | m: any; 223 | mm: any; 224 | h: any; 225 | hh: any; 226 | d: any; 227 | dd: any; 228 | M: any; 229 | MM: any; 230 | y: any; 231 | yy: any; 232 | } 233 | 234 | interface MomentLongDateFormat { 235 | L: string; 236 | LL: string; 237 | LLL: string; 238 | LLLL: string; 239 | LT: string; 240 | LTS: string; 241 | 242 | l?: string; 243 | ll?: string; 244 | lll?: string; 245 | llll?: string; 246 | lt?: string; 247 | lts?: string; 248 | } 249 | 250 | interface MomentParsingFlags { 251 | empty: boolean; 252 | unusedTokens: string[]; 253 | unusedInput: string[]; 254 | overflow: number; 255 | charsLeftOver: number; 256 | nullInput: boolean; 257 | invalidMonth: string | void; // null 258 | invalidFormat: boolean; 259 | userInvalidated: boolean; 260 | iso: boolean; 261 | parsedDateParts: any[]; 262 | meridiem: string | void; // null 263 | } 264 | 265 | interface MomentParsingFlagsOpt { 266 | empty?: boolean; 267 | unusedTokens?: string[]; 268 | unusedInput?: string[]; 269 | overflow?: number; 270 | charsLeftOver?: number; 271 | nullInput?: boolean; 272 | invalidMonth?: string; 273 | invalidFormat?: boolean; 274 | userInvalidated?: boolean; 275 | iso?: boolean; 276 | parsedDateParts?: any[]; 277 | meridiem?: string; 278 | } 279 | 280 | interface MomentBuiltinFormat { 281 | __momentBuiltinFormatBrand: any; 282 | } 283 | 284 | type MomentFormatSpecification = string | MomentBuiltinFormat | (string | MomentBuiltinFormat)[]; 285 | 286 | namespace unitOfTime { 287 | type Base = ( 288 | "year" | "years" | "y" | 289 | "month" | "months" | "M" | 290 | "week" | "weeks" | "w" | 291 | "day" | "days" | "d" | 292 | "hour" | "hours" | "h" | 293 | "minute" | "minutes" | "m" | 294 | "second" | "seconds" | "s" | 295 | "millisecond" | "milliseconds" | "ms" 296 | ); 297 | 298 | type _quarter = "quarter" | "quarters" | "Q"; 299 | type _isoWeek = "isoWeek" | "isoWeeks" | "W"; 300 | type _date = "date" | "dates" | "D"; 301 | type DurationConstructor = Base | _quarter; 302 | 303 | type DurationAs = Base; 304 | 305 | type StartOf = Base | _quarter | _isoWeek | _date | void; // null 306 | 307 | type Diff = Base | _quarter; 308 | 309 | type MomentConstructor = Base | _date; 310 | 311 | type All = Base | _quarter | _isoWeek | _date | 312 | "weekYear" | "weekYears" | "gg" | 313 | "isoWeekYear" | "isoWeekYears" | "GG" | 314 | "dayOfYear" | "dayOfYears" | "DDD" | 315 | "weekday" | "weekdays" | "e" | 316 | "isoWeekday" | "isoWeekdays" | "E"; 317 | } 318 | 319 | interface MomentInputObject { 320 | years?: number; 321 | year?: number; 322 | y?: number; 323 | 324 | months?: number; 325 | month?: number; 326 | M?: number; 327 | 328 | days?: number; 329 | day?: number; 330 | d?: number; 331 | 332 | dates?: number; 333 | date?: number; 334 | D?: number; 335 | 336 | hours?: number; 337 | hour?: number; 338 | h?: number; 339 | 340 | minutes?: number; 341 | minute?: number; 342 | m?: number; 343 | 344 | seconds?: number; 345 | second?: number; 346 | s?: number; 347 | 348 | milliseconds?: number; 349 | millisecond?: number; 350 | ms?: number; 351 | } 352 | 353 | interface DurationInputObject extends MomentInputObject { 354 | quarters?: number; 355 | quarter?: number; 356 | Q?: number; 357 | 358 | weeks?: number; 359 | week?: number; 360 | w?: number; 361 | } 362 | 363 | interface MomentSetObject extends MomentInputObject { 364 | weekYears?: number; 365 | weekYear?: number; 366 | gg?: number; 367 | 368 | isoWeekYears?: number; 369 | isoWeekYear?: number; 370 | GG?: number; 371 | 372 | quarters?: number; 373 | quarter?: number; 374 | Q?: number; 375 | 376 | weeks?: number; 377 | week?: number; 378 | w?: number; 379 | 380 | isoWeeks?: number; 381 | isoWeek?: number; 382 | W?: number; 383 | 384 | dayOfYears?: number; 385 | dayOfYear?: number; 386 | DDD?: number; 387 | 388 | weekdays?: number; 389 | weekday?: number; 390 | e?: number; 391 | 392 | isoWeekdays?: number; 393 | isoWeekday?: number; 394 | E?: number; 395 | } 396 | 397 | interface FromTo { 398 | from: MomentInput; 399 | to: MomentInput; 400 | } 401 | 402 | type MomentInput = Moment | Date | string | number | (number | string)[] | MomentInputObject | void; // null | undefined 403 | type DurationInputArg1 = Duration | number | string | FromTo | DurationInputObject | void; // null | undefined 404 | type DurationInputArg2 = unitOfTime.DurationConstructor; 405 | type LocaleSpecifier = string | Moment | Duration | string[] | boolean; 406 | 407 | interface MomentCreationData { 408 | input: MomentInput; 409 | format?: MomentFormatSpecification; 410 | locale: Locale; 411 | isUTC: boolean; 412 | strict?: boolean; 413 | } 414 | 415 | interface Moment extends Object { 416 | format(format?: string): string; 417 | 418 | startOf(unitOfTime: unitOfTime.StartOf): Moment; 419 | endOf(unitOfTime: unitOfTime.StartOf): Moment; 420 | 421 | add(amount?: DurationInputArg1, unit?: DurationInputArg2): Moment; 422 | /** 423 | * @deprecated reverse syntax 424 | */ 425 | add(unit: unitOfTime.DurationConstructor, amount: number|string): Moment; 426 | 427 | subtract(amount?: DurationInputArg1, unit?: DurationInputArg2): Moment; 428 | /** 429 | * @deprecated reverse syntax 430 | */ 431 | subtract(unit: unitOfTime.DurationConstructor, amount: number|string): Moment; 432 | 433 | calendar(time?: MomentInput, formats?: CalendarSpec): string; 434 | 435 | clone(): Moment; 436 | 437 | /** 438 | * @return Unix timestamp in milliseconds 439 | */ 440 | valueOf(): number; 441 | 442 | // current date/time in local mode 443 | local(keepLocalTime?: boolean): Moment; 444 | isLocal(): boolean; 445 | 446 | // current date/time in UTC mode 447 | utc(keepLocalTime?: boolean): Moment; 448 | isUTC(): boolean; 449 | /** 450 | * @deprecated use isUTC 451 | */ 452 | isUtc(): boolean; 453 | 454 | parseZone(): Moment; 455 | isValid(): boolean; 456 | invalidAt(): number; 457 | 458 | hasAlignedHourOffset(other?: MomentInput): boolean; 459 | 460 | creationData(): MomentCreationData; 461 | parsingFlags(): MomentParsingFlags; 462 | 463 | year(y: number): Moment; 464 | year(): number; 465 | /** 466 | * @deprecated use year(y) 467 | */ 468 | years(y: number): Moment; 469 | /** 470 | * @deprecated use year() 471 | */ 472 | years(): number; 473 | quarter(): number; 474 | quarter(q: number): Moment; 475 | quarters(): number; 476 | quarters(q: number): Moment; 477 | month(M: number|string): Moment; 478 | month(): number; 479 | /** 480 | * @deprecated use month(M) 481 | */ 482 | months(M: number|string): Moment; 483 | /** 484 | * @deprecated use month() 485 | */ 486 | months(): number; 487 | day(d: number|string): Moment; 488 | day(): number; 489 | days(d: number|string): Moment; 490 | days(): number; 491 | date(d: number): Moment; 492 | date(): number; 493 | /** 494 | * @deprecated use date(d) 495 | */ 496 | dates(d: number): Moment; 497 | /** 498 | * @deprecated use date() 499 | */ 500 | dates(): number; 501 | hour(h: number): Moment; 502 | hour(): number; 503 | hours(h: number): Moment; 504 | hours(): number; 505 | minute(m: number): Moment; 506 | minute(): number; 507 | minutes(m: number): Moment; 508 | minutes(): number; 509 | second(s: number): Moment; 510 | second(): number; 511 | seconds(s: number): Moment; 512 | seconds(): number; 513 | millisecond(ms: number): Moment; 514 | millisecond(): number; 515 | milliseconds(ms: number): Moment; 516 | milliseconds(): number; 517 | weekday(): number; 518 | weekday(d: number): Moment; 519 | isoWeekday(): number; 520 | isoWeekday(d: number|string): Moment; 521 | weekYear(): number; 522 | weekYear(d: number): Moment; 523 | isoWeekYear(): number; 524 | isoWeekYear(d: number): Moment; 525 | week(): number; 526 | week(d: number): Moment; 527 | weeks(): number; 528 | weeks(d: number): Moment; 529 | isoWeek(): number; 530 | isoWeek(d: number): Moment; 531 | isoWeeks(): number; 532 | isoWeeks(d: number): Moment; 533 | weeksInYear(): number; 534 | isoWeeksInYear(): number; 535 | dayOfYear(): number; 536 | dayOfYear(d: number): Moment; 537 | 538 | from(inp: MomentInput, suffix?: boolean): string; 539 | to(inp: MomentInput, suffix?: boolean): string; 540 | fromNow(withoutSuffix?: boolean): string; 541 | toNow(withoutPrefix?: boolean): string; 542 | 543 | diff(b: MomentInput, unitOfTime?: unitOfTime.Diff, precise?: boolean): number; 544 | 545 | toArray(): number[]; 546 | toDate(): Date; 547 | toISOString(keepOffset?: boolean): string; 548 | inspect(): string; 549 | toJSON(): string; 550 | unix(): number; 551 | 552 | isLeapYear(): boolean; 553 | /** 554 | * @deprecated in favor of utcOffset 555 | */ 556 | zone(): number; 557 | zone(b: number|string): Moment; 558 | utcOffset(): number; 559 | utcOffset(b: number|string, keepLocalTime?: boolean): Moment; 560 | isUtcOffset(): boolean; 561 | daysInMonth(): number; 562 | isDST(): boolean; 563 | 564 | zoneAbbr(): string; 565 | zoneName(): string; 566 | 567 | isBefore(inp?: MomentInput, granularity?: unitOfTime.StartOf): boolean; 568 | isAfter(inp?: MomentInput, granularity?: unitOfTime.StartOf): boolean; 569 | isSame(inp?: MomentInput, granularity?: unitOfTime.StartOf): boolean; 570 | isSameOrAfter(inp?: MomentInput, granularity?: unitOfTime.StartOf): boolean; 571 | isSameOrBefore(inp?: MomentInput, granularity?: unitOfTime.StartOf): boolean; 572 | isBetween(a: MomentInput, b: MomentInput, granularity?: unitOfTime.StartOf, inclusivity?: "()" | "[)" | "(]" | "[]"): boolean; 573 | 574 | /** 575 | * @deprecated as of 2.8.0, use locale 576 | */ 577 | lang(language: LocaleSpecifier): Moment; 578 | /** 579 | * @deprecated as of 2.8.0, use locale 580 | */ 581 | lang(): Locale; 582 | 583 | locale(): string; 584 | locale(locale: LocaleSpecifier): Moment; 585 | 586 | localeData(): Locale; 587 | 588 | /** 589 | * @deprecated no reliable implementation 590 | */ 591 | isDSTShifted(): boolean; 592 | 593 | // NOTE(constructor): Same as moment constructor 594 | /** 595 | * @deprecated as of 2.7.0, use moment.min/max 596 | */ 597 | max(inp?: MomentInput, format?: MomentFormatSpecification, strict?: boolean): Moment; 598 | /** 599 | * @deprecated as of 2.7.0, use moment.min/max 600 | */ 601 | max(inp?: MomentInput, format?: MomentFormatSpecification, language?: string, strict?: boolean): Moment; 602 | 603 | // NOTE(constructor): Same as moment constructor 604 | /** 605 | * @deprecated as of 2.7.0, use moment.min/max 606 | */ 607 | min(inp?: MomentInput, format?: MomentFormatSpecification, strict?: boolean): Moment; 608 | /** 609 | * @deprecated as of 2.7.0, use moment.min/max 610 | */ 611 | min(inp?: MomentInput, format?: MomentFormatSpecification, language?: string, strict?: boolean): Moment; 612 | 613 | get(unit: unitOfTime.All): number; 614 | set(unit: unitOfTime.All, value: number): Moment; 615 | set(objectLiteral: MomentSetObject): Moment; 616 | 617 | toObject(): MomentObjectOutput; 618 | } 619 | 620 | export var version: string; 621 | export var fn: Moment; 622 | 623 | // NOTE(constructor): Same as moment constructor 624 | export function utc(inp?: MomentInput, format?: MomentFormatSpecification, strict?: boolean): Moment; 625 | export function utc(inp?: MomentInput, format?: MomentFormatSpecification, language?: string, strict?: boolean): Moment; 626 | 627 | export function unix(timestamp: number): Moment; 628 | 629 | export function invalid(flags?: MomentParsingFlagsOpt): Moment; 630 | export function isMoment(m: any): m is Moment; 631 | export function isDate(m: any): m is Date; 632 | export function isDuration(d: any): d is Duration; 633 | 634 | /** 635 | * @deprecated in 2.8.0 636 | */ 637 | export function lang(language?: string): string; 638 | /** 639 | * @deprecated in 2.8.0 640 | */ 641 | export function lang(language?: string, definition?: Locale): string; 642 | 643 | export function locale(language?: string): string; 644 | export function locale(language?: string[]): string; 645 | export function locale(language?: string, definition?: LocaleSpecification | void): string; // null | undefined 646 | 647 | export function localeData(key?: string | string[]): Locale; 648 | 649 | export function duration(inp?: DurationInputArg1, unit?: DurationInputArg2): Duration; 650 | 651 | // NOTE(constructor): Same as moment constructor 652 | export function parseZone(inp?: MomentInput, format?: MomentFormatSpecification, strict?: boolean): Moment; 653 | export function parseZone(inp?: MomentInput, format?: MomentFormatSpecification, language?: string, strict?: boolean): Moment; 654 | 655 | export function months(): string[]; 656 | export function months(index: number): string; 657 | export function months(format: string): string[]; 658 | export function months(format: string, index: number): string; 659 | export function monthsShort(): string[]; 660 | export function monthsShort(index: number): string; 661 | export function monthsShort(format: string): string[]; 662 | export function monthsShort(format: string, index: number): string; 663 | 664 | export function weekdays(): string[]; 665 | export function weekdays(index: number): string; 666 | export function weekdays(format: string): string[]; 667 | export function weekdays(format: string, index: number): string; 668 | export function weekdays(localeSorted: boolean): string[]; 669 | export function weekdays(localeSorted: boolean, index: number): string; 670 | export function weekdays(localeSorted: boolean, format: string): string[]; 671 | export function weekdays(localeSorted: boolean, format: string, index: number): string; 672 | export function weekdaysShort(): string[]; 673 | export function weekdaysShort(index: number): string; 674 | export function weekdaysShort(format: string): string[]; 675 | export function weekdaysShort(format: string, index: number): string; 676 | export function weekdaysShort(localeSorted: boolean): string[]; 677 | export function weekdaysShort(localeSorted: boolean, index: number): string; 678 | export function weekdaysShort(localeSorted: boolean, format: string): string[]; 679 | export function weekdaysShort(localeSorted: boolean, format: string, index: number): string; 680 | export function weekdaysMin(): string[]; 681 | export function weekdaysMin(index: number): string; 682 | export function weekdaysMin(format: string): string[]; 683 | export function weekdaysMin(format: string, index: number): string; 684 | export function weekdaysMin(localeSorted: boolean): string[]; 685 | export function weekdaysMin(localeSorted: boolean, index: number): string; 686 | export function weekdaysMin(localeSorted: boolean, format: string): string[]; 687 | export function weekdaysMin(localeSorted: boolean, format: string, index: number): string; 688 | 689 | export function min(moments: Moment[]): Moment; 690 | export function min(...moments: Moment[]): Moment; 691 | export function max(moments: Moment[]): Moment; 692 | export function max(...moments: Moment[]): Moment; 693 | 694 | /** 695 | * Returns unix time in milliseconds. Overwrite for profit. 696 | */ 697 | export function now(): number; 698 | 699 | export function defineLocale(language: string, localeSpec: LocaleSpecification | void): Locale; // null 700 | export function updateLocale(language: string, localeSpec: LocaleSpecification | void): Locale; // null 701 | 702 | export function locales(): string[]; 703 | 704 | export function normalizeUnits(unit: unitOfTime.All): string; 705 | export function relativeTimeThreshold(threshold: string): number | boolean; 706 | export function relativeTimeThreshold(threshold: string, limit: number): boolean; 707 | export function relativeTimeRounding(fn: (num: number) => number): boolean; 708 | export function relativeTimeRounding(): (num: number) => number; 709 | export function calendarFormat(m: Moment, now: Moment): string; 710 | 711 | export function parseTwoDigitYear(input: string): number; 712 | 713 | /** 714 | * Constant used to enable explicit ISO_8601 format parsing. 715 | */ 716 | export var ISO_8601: MomentBuiltinFormat; 717 | export var RFC_2822: MomentBuiltinFormat; 718 | 719 | export var defaultFormat: string; 720 | export var defaultFormatUtc: string; 721 | 722 | export var HTML5_FMT: { 723 | DATETIME_LOCAL: string, 724 | DATETIME_LOCAL_SECONDS: string, 725 | DATETIME_LOCAL_MS: string, 726 | DATE: string, 727 | TIME: string, 728 | TIME_SECONDS: string, 729 | TIME_MS: string, 730 | WEEK: string, 731 | MONTH: string 732 | }; 733 | 734 | } 735 | -------------------------------------------------------------------------------- /src/declarations/userscript.d.ts: -------------------------------------------------------------------------------- 1 | interface ParentNode { 2 | querySelector(selectors: string): E | null; 3 | querySelectorAll(selectors: string): NodeListOf; 4 | } 5 | 6 | type Modify = Omit & R; 7 | 8 | declare module '*.svelte' { 9 | export { SvelteComponentDev as default } from 'svelte/internal'; 10 | } 11 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | import { IUser } from "./declarations/Types"; 2 | import g from "./global"; 3 | import { create, into } from "./util/dom"; 4 | import { check } from "./util/err"; 5 | 6 | export function get_user_header(): HTMLHeadingElement { 7 | return check(document.querySelector(".content div.columns h5")); 8 | } 9 | 10 | export function get_navbar(): HTMLDivElement { 11 | return check(document.querySelector("#navMenu div.navbar-start")); 12 | } 13 | 14 | export function is_user_page(): boolean { 15 | return window.location.href.toLowerCase().startsWith(g.scoresaber_link + "/u/"); 16 | } 17 | 18 | export function is_song_leaderboard_page(): boolean { 19 | return window.location.href.toLowerCase().startsWith(g.scoresaber_link + "/leaderboard/"); 20 | } 21 | 22 | export function get_current_user(): IUser { 23 | if (g._current_user) { return g._current_user; } 24 | if (!is_user_page()) { throw new Error("Not on a user page"); } 25 | 26 | g._current_user = get_document_user(document); 27 | return g._current_user; 28 | } 29 | 30 | export function get_document_user(doc: Document): IUser { 31 | const username_elem = check(doc.querySelector(".content .title a")); 32 | const user_name = username_elem.innerText.trim(); 33 | // TODO will be wrong when calling from a different page 34 | const user_id = g.user_reg.exec(window.location.href)![1]; 35 | 36 | return { id: user_id, name: user_name }; 37 | } 38 | 39 | export function get_home_user(): IUser | undefined { 40 | if (g._home_user) { return g._home_user; } 41 | 42 | const json = localStorage.getItem("home_user"); 43 | if (!json) { 44 | return undefined; 45 | } 46 | g._home_user = JSON.parse(json); 47 | return g._home_user; 48 | } 49 | 50 | export function get_compare_user(): string | undefined { 51 | if (g.last_selected) { 52 | return g.last_selected; 53 | } 54 | 55 | const stored_last = localStorage.getItem("last_selected"); 56 | if (stored_last) { 57 | g.last_selected = stored_last; 58 | return g.last_selected; 59 | } 60 | 61 | const compare = document.getElementById("user_compare") as HTMLSelectElement | null; 62 | if (compare?.value) { 63 | g.last_selected = compare.value; 64 | return g.last_selected; 65 | } 66 | return undefined; 67 | } 68 | 69 | /** 70 | * Adds an element into the toolbar which is right above the song scores of a user. 71 | */ 72 | export function insert_compare_feature(elem: HTMLElement): void { 73 | if (!is_user_page()) { throw Error("Invalid call to 'insert_compare_feature'"); } 74 | setup_compare_feature_list(); 75 | elem.style.marginLeft = "1em"; 76 | into(check(g.feature_list), elem); 77 | } 78 | 79 | /** 80 | * Adds an element between the toolbar and the song scores of a user. 81 | */ 82 | export function insert_compare_display(elem: HTMLElement): void { 83 | if (!is_user_page()) { throw Error("Invalid call to 'insert_compare_display'"); } 84 | setup_compare_feature_list(); 85 | into(check(g.feature_display_list), elem); 86 | } 87 | 88 | function setup_compare_feature_list(): void { 89 | if (g.feature_list === undefined) { 90 | // find the old dropdown elem to replace it with out container 91 | const select_score_order_elem = check(document.querySelector(".content div.select")); 92 | const parent_box_elem = check(select_score_order_elem.parentElement); 93 | g.feature_list = create("div", { class: "level-item" }); 94 | const level_box_elem = create("div", { class: "level" }, g.feature_list); 95 | 96 | parent_box_elem.replaceChild(level_box_elem, select_score_order_elem); 97 | 98 | // reinsert the dropdown in our own cotainer now 99 | insert_compare_feature(select_score_order_elem); 100 | 101 | // Setup the box for feature display elements 102 | g.feature_display_list = create("div", { class: "level-item" }); 103 | level_box_elem.insertAdjacentElement("afterend", g.feature_display_list); 104 | } 105 | } 106 | 107 | export function set_compare_user(user: string): void { 108 | g.last_selected = user; 109 | localStorage.setItem("last_selected", user); 110 | } 111 | 112 | export function set_home_user(user: IUser): void { 113 | g._home_user = user; 114 | localStorage.setItem("home_user", JSON.stringify(user)); 115 | } 116 | 117 | export function set_wide_table(value: boolean): void { 118 | localStorage.setItem("wide_song_table", value ? "true" : "false"); 119 | } 120 | export function get_wide_table(): boolean { 121 | return localStorage.getItem("wide_song_table") === "true"; 122 | } 123 | 124 | export const BMPage: Pages[] = ["song", "songlist", "user"] 125 | export const BMButton: Buttons[] = ["BS", "OC", "Beast", "BeastBook", "Preview", "BSR"]; 126 | export const BMPageButtons: PageButtons[] = BMPage 127 | .map(p => BMButton.map(b => `${p}-${b}`)) 128 | .reduce((agg, lis) => [...agg, ...lis], []) as PageButtons[]; 129 | export type Pages = "song" | "songlist" | "user"; 130 | export type Buttons = "BS" | "OC" | "Beast" | "BeastBook" | "Preview" | "BSR"; 131 | export type PageButtons = `${Pages}-${Buttons}`; 132 | export type ButtonMatrix = Partial>; 133 | export const BMButtonHelp: Record = { 138 | BS: { short: "BS", long: "BeatSaver", tip: "View on BeatSaver" }, 139 | OC: { short: "OC", long: "OneClick™", tip: "Download with OneClick™" }, 140 | Beast: { short: "BST", long: "BeastSaber", tip: "View/Add rating on BeastSaber" }, 141 | BeastBook: { short: "BB", long: "BeastSaber Bookmark", tip: "Bookmark on BeastSaber" }, 142 | Preview: { short: "👓", long: "Preview", tip: "Preview map" }, 143 | BSR: { short: "❗", long: "BeatSaver Request", tip: "Copy !bsr" }, 144 | }; 145 | 146 | export function bmvar(page: Pages, button: Buttons, def: string): Partial { 147 | return { 148 | display: `var(--sse-show-${page}-${button}, ${def})`, 149 | }; 150 | } 151 | 152 | export function get_button_matrix(): ButtonMatrix { 153 | const json = localStorage.getItem("sse_button_matrix"); 154 | if (!json) 155 | return default_button_matrix(); 156 | return JSON.parse(json); 157 | } 158 | 159 | function default_button_matrix(): ButtonMatrix { 160 | return { 161 | "song-BS": true, 162 | "song-BSR": true, 163 | "song-Beast": true, 164 | "song-BeastBook": true, 165 | "song-OC": true, 166 | "song-Preview": true, 167 | "songlist-BS": true, 168 | "songlist-OC": true, 169 | "user-BS": true, 170 | "user-OC": true, 171 | }; 172 | } 173 | 174 | export function set_button_matrix(bm: ButtonMatrix): void { 175 | localStorage.setItem("sse_button_matrix", JSON.stringify(bm)); 176 | } 177 | 178 | export function set_use_new_ss_api(value: boolean): void { 179 | localStorage.setItem("use_new_api", value ? "true" : "false"); 180 | } 181 | export function get_use_new_ss_api(): boolean { 182 | return (localStorage.getItem("use_new_api") || "true") === "true"; 183 | } 184 | 185 | export function set_bsaber_username(value: string): void { 186 | localStorage.setItem("bsaber_username", value); 187 | } 188 | export function get_bsaber_username(): string | undefined { 189 | return (localStorage.getItem("bsaber_username") || undefined); 190 | } 191 | 192 | function get_bsaber_bookmarks(): string[] { 193 | const data = localStorage.getItem("bsaber_bookmarks"); 194 | if (!data) return []; 195 | return JSON.parse(data); 196 | } 197 | 198 | export function add_bsaber_bookmark(song_hash: string): void { 199 | const bookmarks = get_bsaber_bookmarks(); 200 | bookmarks.push(song_hash); 201 | localStorage.setItem("bsaber_bookmarks", JSON.stringify(bookmarks)); 202 | } 203 | export function check_bsaber_bookmark(song_hash: string): boolean { 204 | const bookmarks = get_bsaber_bookmarks(); 205 | return bookmarks.includes(song_hash.toLowerCase()); 206 | } 207 | 208 | export function get_show_bb_link(): boolean { 209 | return (get_bsaber_bookmarks() !== [] && !!get_bsaber_username()); 210 | } 211 | -------------------------------------------------------------------------------- /src/global.ts: -------------------------------------------------------------------------------- 1 | import { IDbUser, IUser } from "./declarations/Types"; 2 | 3 | export default class Global { 4 | public static user_list: { [user_id: string]: IDbUser }; 5 | public static users_elem: HTMLElement; 6 | public static feature_list: HTMLElement | undefined; 7 | public static feature_display_list: HTMLElement | undefined; 8 | public static last_selected: string; 9 | public static debug = false; 10 | public static _current_user: IUser | undefined; 11 | public static _home_user: IUser | undefined; 12 | public static style_themed_elem: HTMLStyleElement; 13 | public static song_table_backup: HTMLElement | undefined; 14 | 15 | public static readonly scoresaber_link = "https://scoresaber.com"; 16 | public static readonly beatsaver_link = "https://beatsaver.com/maps/"; 17 | public static readonly bsaber_songs_link = "https://bsaber.com/songs/"; 18 | public static readonly song_hash_reg = /\/([\da-zA-Z]{40})\.png/; 19 | public static readonly score_reg = /(score|accuracy):\s*([\d.,]+)%?\s*(\(([\w,]*)\))?/; 20 | public static readonly leaderboard_reg = /leaderboard\/(\d+)/; 21 | public static readonly leaderboard_rank_reg = /#([\d,]+)/; 22 | public static readonly leaderboard_country_reg = /(\?|&)country=(\w+)$/; 23 | public static readonly user_reg = /u\/(\d+)/; 24 | public static readonly script_version_reg = /\/\/\s*@version\s+([\d.]+)/; 25 | public static readonly user_per_page_global_leaderboard = 50; 26 | public static readonly user_per_page_song_leaderboard = 12; 27 | /** 28 | * This is the exponential factor ScoreSaber is using to weight their scores. 29 | */ 30 | public static readonly pp_weighting_factor = 0.965; 31 | } 32 | -------------------------------------------------------------------------------- /src/header.ts: -------------------------------------------------------------------------------- 1 | import SseEvent from "./components/events"; 2 | import * as modal from "./components/modal"; 3 | import { get_current_user, get_home_user, get_navbar, get_user_header, is_user_page, set_home_user } from "./env"; 4 | import g from "./global"; 5 | import * as usercache from "./usercache"; 6 | import { create, into, intor } from "./util/dom"; 7 | import { check } from "./util/err"; 8 | import { logc } from "./util/log"; 9 | 10 | export function setup_self_pin_button(): void { 11 | if (!is_user_page()) { return; } 12 | 13 | const header = get_user_header(); 14 | into(header, create("div", { 15 | class: "button icon is-medium", 16 | style: { cursor: "pointer" }, 17 | data: { tooltip: "Pin this user to your navigation bar" }, 18 | onclick() { 19 | set_home_user(get_current_user()); 20 | SseEvent.PinnedUserChanged.invoke(); 21 | } 22 | }, 23 | create("i", { class: "fas fa-thumbtack" }), 24 | )); 25 | } 26 | 27 | export function setup_self_button(): void { 28 | const home_user = get_home_user() ?? { name: "", id: "0" }; 29 | 30 | into(get_navbar(), 31 | create("div", { class: "navbar-item has-dropdown is-hoverable" }, 32 | create("a", { 33 | id: "home_user", 34 | class: "navbar-item", 35 | href: g.scoresaber_link + "/u/" + home_user.id 36 | }, home_user.name), 37 | create("div", { 38 | id: "home_user_list", 39 | class: "navbar-dropdown" 40 | }) 41 | ) 42 | ); 43 | 44 | update_self_user_list(); 45 | 46 | SseEvent.UserCacheChanged.register(update_self_user_list); 47 | SseEvent.PinnedUserChanged.register(update_self_button); 48 | } 49 | 50 | function update_self_button(): void { 51 | const home_user = get_home_user() ?? { name: "", id: "0" }; 52 | 53 | const home_elem = document.getElementById("home_user") as HTMLAnchorElement | null; 54 | if (home_elem) { 55 | home_elem.href = g.scoresaber_link + "/u/" + home_user.id; 56 | home_elem.innerText = home_user.name; 57 | } 58 | } 59 | 60 | function update_self_user_list(): void { 61 | const home_user_list_elem = check(document.getElementById("home_user_list")); 62 | intor(home_user_list_elem, 63 | ...Object.entries(g.user_list).map(([id, user]) => { 64 | return create("a", { 65 | class: "navbar-item", 66 | style: { 67 | paddingRight: "1em", 68 | flexWrap: "nowrap", 69 | display: "flex", 70 | }, 71 | href: g.scoresaber_link + "/u/" + id, 72 | }, 73 | create("div", { style: { flex: "1" } }, user.name), 74 | create("div", { 75 | class: "button icon is-medium is-danger is-outlined", 76 | style: { marginLeft: "3em" }, 77 | async onclick(ev) { 78 | ev.preventDefault(); 79 | ev.stopPropagation(); 80 | const response = await modal.show_modal({ 81 | text: `Delete User "${user.name}" from cache?`, 82 | buttons: { 83 | delete: { text: "Delete", class: "is-danger" }, 84 | x: { text: "Abort", class: "is-info" } 85 | }, 86 | }); 87 | 88 | if (response === "delete") { 89 | logc("Delete user", id, user.name); 90 | delete_user(id); 91 | } 92 | } 93 | }, 94 | create("i", { class: "fas fa-trash-alt" }) 95 | ), 96 | ); 97 | }) 98 | ); 99 | } 100 | 101 | function delete_user(user_id: string): void { 102 | if (g.user_list[user_id]) { 103 | delete g.user_list[user_id]; 104 | usercache.save(); 105 | SseEvent.UserCacheChanged.invoke(); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/header.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name ScoreSaberEnhanced 3 | include$GULP_METADATA 4 | // @namespace https://scoresaber.com 5 | // @match http://scoresaber.com/* 6 | // @match https://scoresaber.com/* 7 | // @icon https://scoresaber.com/imports/images/logo.ico 8 | // @updateURL https://github.com/Splamy/ScoreSaberEnhanced/raw/master/scoresaber.user.js 9 | // @downloadURL https://github.com/Splamy/ScoreSaberEnhanced/raw/master/scoresaber.user.js 10 | // @require https://cdn.jsdelivr.net/npm/moment@2.24.0/moment.js 11 | // @run-at document-start 12 | // for Tampermonkey 13 | // @grant GM_xmlhttpRequest 14 | // @grant GM_addStyle 15 | // @grant GM_info 16 | // for Greasemonkey 17 | // @grant GM.xmlHttpRequest 18 | // @connect unpkg.com 19 | // @connect beatsaver.com 20 | // @connect githubusercontent.com 21 | // @connect bsaber.com 22 | // ==/UserScript== 23 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as compare from "./compare"; 2 | import * as header from "./header"; 3 | import * as page_songlist from "./pages/songlist"; 4 | import * as page_song from "./pages/song"; 5 | import * as page_user from "./pages/user"; 6 | import * as ppgraph from "./ppgraph"; 7 | import * as settings from "./settings"; 8 | import * as themes from "./themes"; 9 | import * as updater from "./updater"; 10 | import * as usercache from "./usercache"; 11 | import * as log from "./util/log"; 12 | import * as userscript from "./util/userscript"; 13 | 14 | log.setup(); 15 | userscript.setup(); 16 | usercache.load(); 17 | 18 | let has_loaded_head = false; 19 | function on_load_head() { 20 | if (!document.head) { log.logc("Head not ready"); return; } 21 | if (has_loaded_head) { log.logc("Already loaded head"); return; } 22 | has_loaded_head = true; 23 | log.logc("Loading head"); 24 | 25 | themes.setup(); 26 | settings.load_last_theme(); 27 | } 28 | 29 | let has_loaded_body = false; 30 | function on_load_body(): void { 31 | if (document.readyState !== "complete" && document.readyState !== "interactive") { 32 | log.logc("Body not ready"); 33 | return; 34 | } 35 | if (has_loaded_body) { log.logc("Already loaded body"); return; } 36 | has_loaded_body = true; 37 | log.logc("Loading body"); 38 | 39 | page_user.setup_dl_link_user_site(); 40 | page_user.add_percentage(); 41 | page_user.setup_user_rank_link_swap(); 42 | page_user.setup_song_rank_link_swap(); 43 | page_user.update_wide_table_css(); 44 | page_song.setup_dl_link_leaderboard(); 45 | page_song.setup_song_filter_tabs(); 46 | page_song.highlight_user(); 47 | page_song.add_percentage(); 48 | page_songlist.setup_links_songlist(); 49 | page_songlist.setup_extra_filter_checkboxes(); 50 | page_songlist.apply_extra_filters(); 51 | header.setup_self_pin_button(); 52 | header.setup_self_button(); 53 | compare.setup_user_compare(); 54 | settings.setup(); 55 | settings.update_button_visibility(); 56 | ppgraph.setup_pp_graph(); 57 | updater.check_for_updates(); 58 | } 59 | 60 | function onload() { 61 | on_load_head(); 62 | on_load_body(); 63 | } 64 | 65 | onload(); 66 | window.addEventListener("DOMContentLoaded", onload); 67 | window.addEventListener("load", onload); 68 | -------------------------------------------------------------------------------- /src/pages/song.ts: -------------------------------------------------------------------------------- 1 | import * as beastsaber from "../api/beastsaber"; 2 | import * as beatsaver from "../api/beatsaver"; 3 | import { IDbUser, ISong } from "../declarations/Types"; 4 | import { BMButton, get_home_user, is_song_leaderboard_page, Pages } from "../env"; 5 | import g from "../global"; 6 | import { create, intor } from "../util/dom"; 7 | import { check } from "../util/err"; 8 | import { format_en, number_invariant, number_to_timespan, toggled_class } from "../util/format"; 9 | import { Lazy } from "../util/lazy"; 10 | import { calculate_max_score, get_notes_count, get_song_compare_value, get_song_hash_from_text } from "../util/song"; 11 | import QuickButton from "../components/QuickButton.svelte"; 12 | import { new_page } from "../util/net"; 13 | import { get_scoresaber_data_by_hash } from "../api/beatsaver"; 14 | 15 | const PAGE: Pages = "song"; 16 | 17 | const shared = new Lazy(() => { 18 | // find the element we want to modify 19 | let details_box = check(document.querySelector(".content .title.is-5")); 20 | details_box = check(details_box.parentElement); 21 | 22 | const song_hash = get_song_hash_from_text(details_box.innerHTML); 23 | 24 | const diff_name = document.querySelector(`div.tabs li.is-active span`)?.innerText; 25 | return { song_hash, details_box, diff_name }; 26 | }); 27 | 28 | export function setup_song_filter_tabs(): void { 29 | if (!is_song_leaderboard_page()) { return; } 30 | 31 | const tab_list_content = check(document.querySelector(".tabs > ul")); 32 | 33 | function load_friends() { 34 | let score_table = check(document.querySelector(".ranking .global > tbody")); 35 | g.song_table_backup = score_table; 36 | const table = check(score_table.parentNode); 37 | table.removeChild(score_table); 38 | score_table = table.appendChild(create("tbody")); 39 | const song_id = g.leaderboard_reg.exec(window.location.pathname)![1]; 40 | 41 | const elements: [ISong, HTMLElement][] = []; 42 | for (const [user_id, user] of Object.entries(g.user_list)) { 43 | const song = user.songs[song_id]; 44 | // Check if the user has a score on this song 45 | if (!song) 46 | continue; 47 | elements.push([song, generate_song_table_row(user_id, user, song)]); 48 | } 49 | elements.sort((a, b) => { const [sa, sb] = get_song_compare_value(a[0], b[0]); return sb - sa; }); 50 | elements.forEach(x => score_table.appendChild(x[1])); 51 | add_percentage(); 52 | } 53 | 54 | function load_all() { 55 | if (!g.song_table_backup) { 56 | return; 57 | } 58 | 59 | let score_table = check(document.querySelector(".ranking .global > tbody")); 60 | const table = check(score_table.parentNode); 61 | table.removeChild(score_table); 62 | score_table = table.appendChild(g.song_table_backup); 63 | g.song_table_backup = undefined; 64 | add_percentage(); 65 | } 66 | 67 | tab_list_content.appendChild(generate_tab("All Scores", "all_scores_tab", load_all, true, true)); 68 | tab_list_content.appendChild(generate_tab("Friends", "friends_tab", load_friends, false, false)); 69 | // tab_list_content.appendChild(generate_tab("Around Me", "around_me_tab", () => {}, false, false)); 70 | } 71 | 72 | export function setup_dl_link_leaderboard(): void { 73 | if (!is_song_leaderboard_page()) { return; } 74 | 75 | const { song_hash, details_box } = shared.get(); 76 | 77 | const tool_strip = create("div", { 78 | id: "leaderboard_tool_strip", 79 | style: { 80 | marginTop: "1em" 81 | } 82 | }); 83 | for (const btn of BMButton) { 84 | new QuickButton({ 85 | target: tool_strip, 86 | props: { song_hash, size: "large", type: btn, page: PAGE } 87 | }); 88 | } 89 | details_box.appendChild(tool_strip); 90 | 91 | const song_warning = create("div"); 92 | details_box.appendChild(song_warning); 93 | 94 | const box_style = { class: "box", style: { display: "flex", flexDirection: "column", alignItems: "end", padding: "0.5em 1em" } }; 95 | const beatsaver_box = create("div", box_style, 96 | create("b", {}, "BeatSaver"), 97 | create("span", { class: "icon" }, create("i", { class: "fas fa-spinner fa-pulse" })) 98 | ); 99 | const beastsaber_box = create("div", box_style, 100 | create("b", {}, "BeastSaber"), 101 | create("span", { class: "icon" }, create("i", { class: "fas fa-spinner fa-pulse" })) 102 | ); 103 | 104 | const column_style = { class: "column", style: { padding: "0 0.75em" } }; 105 | details_box.appendChild( 106 | create("div", { 107 | class: "columns", 108 | style: { 109 | marginTop: "1em" 110 | } 111 | }, 112 | create("div", column_style, beatsaver_box), 113 | create("div", column_style, beastsaber_box), 114 | )); 115 | 116 | if (!song_hash) 117 | return; 118 | 119 | (async () => { 120 | const data = await beatsaver.get_data_by_hash(song_hash); 121 | if (!data) 122 | return; 123 | show_song_warning(song_warning, song_hash, data); 124 | show_beatsaver_song_data(beatsaver_box, data); 125 | const data2 = await beastsaber.get_data(data.id); 126 | if (!data2) 127 | return; 128 | show_beastsaber_song_data(beastsaber_box, data2); 129 | })(); 130 | } 131 | 132 | function show_song_warning(elem: HTMLElement, song_hash: string, data: beatsaver.IBeatSaverData) { 133 | const contains_version = data.versions.some(x => x.hash === song_hash); 134 | if (!contains_version) { 135 | const new_song_hash = data.versions[data.versions.length - 1].hash; 136 | const { diff_name } = shared.get(); 137 | 138 | intor(elem, 139 | create("div", { 140 | style: { marginTop: "1em", cursor: "pointer" }, 141 | class: "notification is-warning", 142 | onclick: async () => { 143 | const bs2ss = await get_scoresaber_data_by_hash(new_song_hash, diff_name); 144 | if (bs2ss === undefined) 145 | return; 146 | new_page(`https://scoresaber.com/leaderboard/${bs2ss.uid}`); 147 | }, 148 | }, 149 | create("i", { class: "fas fa-exclamation-triangle" }), 150 | create("span", { style: { marginLeft: "0.25em" } }, "A newer version of this song exists on BeatSaver") 151 | ) 152 | ); 153 | } 154 | } 155 | 156 | function show_beatsaver_song_data(elem: HTMLElement, data: beatsaver.IBeatSaverData) { 157 | intor(elem, 158 | create("div", { title: "Downloads" }, `${data.stats.downloads} 💾`), 159 | create("div", { title: "Upvotes" }, `${data.stats.upvotes} 👍`), 160 | create("div", { title: "Downvotes" }, `${data.stats.downvotes} 👎`), 161 | create("div", { title: "Beatmap Rating" }, `${(data.stats.score * 100).toFixed(2)}% 💯`), 162 | create("div", { title: "Beatmap Duration" }, `${number_to_timespan(data.metadata.duration)} ⏱`), 163 | ); 164 | } 165 | 166 | function show_beastsaber_song_data(elem: HTMLElement, data: beastsaber.IBeastSaberData) { 167 | intor(elem, 168 | create("div", { title: "Fun Factor" }, `${data.average_ratings.fun_factor} 😃`), 169 | create("div", { title: "Rhythm" }, `${data.average_ratings.rhythm} 🎶`), 170 | create("div", { title: "Flow" }, `${data.average_ratings.flow} 🌊`), 171 | create("div", { title: "Pattern Quality" }, `${data.average_ratings.pattern_quality} 💠`), 172 | create("div", { title: "Readability" }, `${data.average_ratings.readability} 👓`), 173 | create("div", { title: "Level Quality" }, `${data.average_ratings.level_quality} ✔️`), 174 | ); 175 | } 176 | 177 | function generate_song_table_row(user_id: string, user: IDbUser, song: ISong): HTMLElement { 178 | return create("tr", {}, // style: { backgroundColor: ":var(--color-highlight);" } 179 | create("td", { class: "picture" }), 180 | create("td", { class: "rank" }, "-"), 181 | create("td", { class: "player" }, generate_song_table_player(user_id, user)), 182 | create("td", { class: "score" }, song.score !== undefined ? format_en(song.score, 0) : "-"), 183 | create("td", { class: "timeset" }, moment(song.time).fromNow()), 184 | create("td", { class: "mods" }, song.mods !== undefined ? song.mods.toString() : "-"), 185 | create("td", { class: "percentage" }, song.accuracy ? (song.accuracy.toString() + "%") : "-"), 186 | create("td", { class: "pp" }, 187 | create("span", { class: "scoreTop ppValue" }, format_en(song.pp)), 188 | create("span", { class: "scoreTop ppLabel" }, "pp") 189 | ) 190 | ); 191 | } 192 | 193 | function generate_song_table_player(user_id: string, user: IDbUser): HTMLElement { 194 | return create("a", { href: `${g.scoresaber_link}/u/${user_id}` }, user.name); 195 | } 196 | 197 | function generate_tab( 198 | title: string | HTMLElement, 199 | css_id: string, 200 | action: (() => void) | undefined, 201 | is_active: boolean, 202 | has_offset: boolean 203 | ): HTMLElement { 204 | const tabClass = `filter_tab ${toggled_class(is_active, "is-active")} ${toggled_class(has_offset, "offset_tab")}`; 205 | return create("li", { 206 | id: css_id, 207 | class: tabClass, 208 | }, 209 | create("a", { 210 | class: "has-text-info", 211 | onclick: () => { 212 | document.querySelectorAll(".tabs > ul .filter_tab").forEach(x => x.classList.remove("is-active")); 213 | check(document.getElementById(css_id)).classList.add("is-active"); 214 | if (action) action(); 215 | } 216 | }, title) 217 | ); 218 | } 219 | 220 | // TODO not quite correct here, should be somewhere for general leaderboards 221 | 222 | export function highlight_user(): void { 223 | // (No page check, this should work on global and song boards) 224 | const home_user = get_home_user(); 225 | if (!home_user) { return; } 226 | 227 | const element = document.querySelector(`table.ranking.global a[href='/u/${home_user.id}']`); 228 | 229 | if (element != null) { 230 | element.parentElement!.parentElement!.style.backgroundColor = "var(--color-highlight)"; 231 | } 232 | } 233 | 234 | export function add_percentage(): void { 235 | if (!is_song_leaderboard_page()) { 236 | return; 237 | } 238 | 239 | const { song_hash, diff_name } = shared.get(); 240 | 241 | if (!song_hash) { 242 | return; 243 | } 244 | 245 | (async () => { 246 | const data = await beatsaver.get_data_by_hash(song_hash); 247 | if (!data) 248 | return; 249 | // Scoresaber fails to display the difficlulty tab for some categories (e.g. Lawless), ex: 250 | // - none at all: https://scoresaber.com/leaderboard/307121 251 | // - only expert+ shown, but actual diff is missing: https://scoresaber.com/leaderboard/314128 252 | if (!diff_name) 253 | return; 254 | const version = data.versions.find((v) => v.hash === song_hash.toLowerCase()); 255 | if (!diff_name || !version) 256 | return; 257 | const notes = get_notes_count(diff_name, "Standard", version); 258 | if (notes < 0) 259 | return; 260 | const max_score = calculate_max_score(notes); 261 | const user_scores = document.querySelectorAll("table.ranking.global tbody > tr"); 262 | for (const score_row of user_scores) { 263 | const percentage_column = check(score_row.querySelector("td.percentage")); 264 | const percentage_value = percentage_column.innerText; 265 | if (percentage_value === "-") { 266 | const score = check(score_row.querySelector("td.score")).innerText; 267 | const score_num = number_invariant(score); 268 | const calculated_percentage = (100 * score_num / max_score).toFixed(2); 269 | percentage_column.innerText = calculated_percentage + "%"; 270 | } 271 | } 272 | })(); 273 | } 274 | -------------------------------------------------------------------------------- /src/pages/songlist.ts: -------------------------------------------------------------------------------- 1 | import { BMButton, BMButtonHelp, bmvar, Pages } from "../env"; 2 | import { as_fragment, create, into } from "../util/dom"; 3 | import { check } from "../util/err"; 4 | import { get_song_hash_from_text } from "../util/song"; 5 | import QuickButton from "../components/QuickButton.svelte"; 6 | 7 | const PAGE: Pages = "songlist"; 8 | 9 | export function setup_links_songlist(): void { 10 | if (!is_songlist_page()) { 11 | return; 12 | } 13 | 14 | const song_table = check(document.querySelector("table.ranking.songs")); 15 | const song_table_header = check(song_table.querySelector("thead tr")); 16 | 17 | for (const btn of BMButton) { 18 | into(song_table_header, 19 | create("th", { 20 | class: "compact", 21 | style: bmvar(PAGE, btn, "table-cell"), 22 | // TODO: Tooltip is currently cut off at the to due to div nesting 23 | //data: { tooltip: BMButtonHelp[btn].long }, 24 | }, BMButtonHelp[btn].short) 25 | ); 26 | } 27 | 28 | // add a link for each song 29 | const song_rows = song_table.querySelectorAll("tbody tr"); 30 | for (const row of song_rows) { 31 | const song_hash = get_song_hash_from_row(row); 32 | 33 | for (const btn of BMButton) { 34 | into(row, 35 | create("th", { class: "compact", style: bmvar(PAGE, btn, "table-cell") }, 36 | as_fragment(target => new QuickButton({ 37 | target, 38 | props: { song_hash, size: "medium", type: btn } 39 | })) 40 | ) 41 | ); 42 | } 43 | } 44 | } 45 | 46 | function get_song_hash_from_row(row: HTMLElement): string | undefined { 47 | const image_link = 48 | check(row.querySelector("td.song img")).src; 49 | return get_song_hash_from_text(image_link); 50 | } 51 | 52 | 53 | export function setup_extra_filter_checkboxes(): void { 54 | if (!is_songlist_page()) { 55 | return; 56 | } 57 | 58 | setup_duplicates_filter_checkbox(); 59 | } 60 | 61 | function setup_duplicates_filter_checkbox(): void { 62 | const checked = should_hide_duplicate_songs(); 63 | const duplicates_filter = 64 | create("label", { class: "checkbox" }, create("input", { 65 | id: "duplicates", 66 | type: "checkbox", 67 | checked: checked, 68 | onclick() { 69 | set_hide_duplicate_songs_filter(!checked); 70 | window.location.reload(); 71 | } 72 | })); 73 | 74 | duplicates_filter.appendChild( 75 | document.createTextNode(" Hide duplicate songs ")); 76 | 77 | const ranked_filter = 78 | check(document.querySelector("input#ranked")?.parentElement); 79 | ranked_filter.parentNode?.insertBefore( 80 | duplicates_filter, ranked_filter.nextSibling); 81 | } 82 | 83 | export function apply_extra_filters(): void { 84 | if (!is_songlist_page()) { 85 | return; 86 | } 87 | 88 | if (should_hide_duplicate_songs()) { 89 | hide_duplicate_songs(); 90 | } 91 | } 92 | 93 | function hide_duplicate_songs(): void { 94 | const song_table = check(document.querySelector("table.ranking.songs tbody")); 95 | const song_rows = check(song_table.querySelectorAll("tr")); 96 | 97 | const hashes = new Set(); 98 | 99 | for (const row of song_rows) { 100 | const song_hash = check(get_song_hash_from_row(row)); 101 | 102 | if (hashes.has(song_hash)) { 103 | song_table.removeChild(row); 104 | } else { 105 | hashes.add(song_hash); 106 | } 107 | } 108 | } 109 | 110 | function should_hide_duplicate_songs(): boolean { 111 | return localStorage.getItem("hide_songlist_duplicates") == "true"; 112 | } 113 | 114 | function set_hide_duplicate_songs_filter(filter: boolean): void { 115 | localStorage.setItem("hide_songlist_duplicates", JSON.stringify(filter)); 116 | } 117 | 118 | export function is_songlist_page(): boolean { 119 | return location.pathname == "/"; 120 | } 121 | -------------------------------------------------------------------------------- /src/pages/user.ts: -------------------------------------------------------------------------------- 1 | import * as beatsaver from "../api/beatsaver"; 2 | import { BMButton, BMButtonHelp, bmvar, get_wide_table, is_user_page, Pages } from "../env"; 3 | import g from "../global"; 4 | import { as_fragment, create, into } from "../util/dom"; 5 | import { check } from "../util/err"; 6 | import { number_invariant } from "../util/format"; 7 | import { calculate_max_score, get_notes_count, get_song_hash_from_text, parse_score_bottom } from "../util/song"; 8 | import QuickButton from "../components/QuickButton.svelte"; 9 | 10 | const PAGE: Pages = "user"; 11 | 12 | export function setup_dl_link_user_site(): void { 13 | if (!is_user_page()) { return; } 14 | 15 | // find the table we want to modify 16 | const table = check(document.querySelector("table.ranking.songs")); 17 | 18 | // add a new column for our links 19 | const table_tr = check(table.querySelector("thead tr")); 20 | for (const btn of BMButton) { 21 | into(table_tr, 22 | create("th", { 23 | class: "compact", 24 | style: bmvar(PAGE, btn, "table-cell"), 25 | // TODO: Tooltip is currently cut off at the to due to div nesting 26 | //data: { tooltip: BMButtonHelp[btn].long }, 27 | }, BMButtonHelp[btn].short) 28 | ); 29 | } 30 | 31 | // add a link for each song 32 | const table_row = table.querySelectorAll("tbody tr"); 33 | for (const row of table_row) { 34 | const image_link = check(row.querySelector("th.song img")).src; 35 | const song_hash = get_song_hash_from_text(image_link); 36 | 37 | for (const btn of BMButton) { 38 | into(row, 39 | create("th", { class: "compact", style: bmvar(PAGE, btn, "table-cell") }, 40 | as_fragment(target => new QuickButton({ 41 | target, 42 | props: { song_hash, size: "medium", type: btn } 43 | })) 44 | ) 45 | ); 46 | } 47 | } 48 | } 49 | 50 | // ** Wide table *** 51 | 52 | export function update_wide_table_css(): void { 53 | if (!is_user_page()) { return; } 54 | 55 | const table = check(document.querySelector("table.ranking.songs")); 56 | table.classList.toggle("wide_song_table", get_wide_table()); 57 | } 58 | 59 | // ** Link util ** 60 | 61 | export function setup_user_rank_link_swap(): void { 62 | if (!is_user_page()) { return; } 63 | 64 | const elem_ranking_links = document.querySelectorAll(".content div.columns ul > li > a"); 65 | console.assert(elem_ranking_links.length >= 2, elem_ranking_links); 66 | // Global rank 67 | const elem_global = elem_ranking_links[0]; 68 | const res_global = check(g.leaderboard_rank_reg.exec(elem_global.innerText)); 69 | const rank_global = number_invariant(res_global[1]); 70 | elem_global.href = g.scoresaber_link + "/global/" + rank_to_page(rank_global, g.user_per_page_global_leaderboard); 71 | // Country rank 72 | const elem_country = elem_ranking_links[1]; 73 | const res_country = check(g.leaderboard_rank_reg.exec(elem_country.innerText)); 74 | const country_str = check(g.leaderboard_country_reg.exec(elem_country.href)); 75 | const number_country = number_invariant(res_country[1]); 76 | elem_country.href = g.scoresaber_link + 77 | "/global/" + rank_to_page(number_country, g.user_per_page_global_leaderboard) + 78 | "?country=" + country_str[2]; 79 | } 80 | 81 | export function setup_song_rank_link_swap(): void { 82 | if (!is_user_page()) { return; } 83 | 84 | const song_elems = document.querySelectorAll("table.ranking.songs tbody tr"); 85 | for (const row of song_elems) { 86 | const rank_elem = check(row.querySelector(".rank")); 87 | // there's only one link, so 'a' will find it. 88 | const leaderboard_link = check(row.querySelector("th.song a")).href; 89 | const rank = number_invariant(rank_elem.innerText.slice(1)); 90 | const rank_str = rank_elem.innerText; 91 | rank_elem.innerHTML = ""; 92 | into(rank_elem, 93 | create("a", { 94 | href: `${leaderboard_link}?page=${rank_to_page(rank, g.user_per_page_song_leaderboard)}` 95 | }, rank_str) 96 | ); 97 | } 98 | } 99 | 100 | function rank_to_page(rank: number, ranks_per_page: number): number { 101 | return Math.max(Math.floor((rank + ranks_per_page - 1) / ranks_per_page), 1); 102 | } 103 | 104 | export function add_percentage(): void { 105 | if (!is_user_page()) { return; } 106 | 107 | // find the table we want to modify 108 | const table = check(document.querySelector("table.ranking.songs")); 109 | const table_row = table.querySelectorAll("tbody tr"); 110 | for (const row of table_row) { 111 | const image_link = check(row.querySelector("th.song img")).src; 112 | const song_hash = get_song_hash_from_text(image_link); 113 | 114 | if (!song_hash) { 115 | return; 116 | } 117 | 118 | const score_column = check(row.querySelector(`th.score`)); 119 | // skip rows with percentage from ScoreSaber 120 | if (!score_column.innerText || score_column.innerText.includes("%")) { continue; } 121 | 122 | (async () => { 123 | const data = await beatsaver.get_data_by_hash(song_hash); 124 | if (!data) 125 | return; 126 | const song_column = check(row.querySelector(`th.song`)); 127 | const diff_name = check(song_column.querySelector(`span > span`)).innerText; 128 | const version = data.versions.find((v) => v.hash === song_hash.toLowerCase()); 129 | if (!diff_name || !version) 130 | return; 131 | const notes = get_notes_count(diff_name, "Standard", version); 132 | if (notes < 0) 133 | return; 134 | const max_score = calculate_max_score(notes); 135 | const user_score = check(score_column.querySelector(".scoreBottom")).innerText; 136 | const { score } = parse_score_bottom(user_score); 137 | if (score !== undefined) { 138 | const calculated_percentage = (100 * score / max_score).toFixed(2); 139 | check(score_column.querySelector(".ppWeightedValue")).innerHTML = `(${calculated_percentage}%)`; 140 | } 141 | })(); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/ppgraph.ts: -------------------------------------------------------------------------------- 1 | import SseEvent from "./components/events"; 2 | import { button, IButtonElement } from "./components/toggle_button"; 3 | import { get_compare_user, get_current_user, insert_compare_display, insert_compare_feature, is_user_page } from "./env"; 4 | import g from "./global"; 5 | import { create } from "./util/dom"; 6 | import { check } from "./util/err"; 7 | import { load_chart_lib } from "./util/userscript"; 8 | 9 | let chart: Chart | undefined; 10 | let chart_elem: HTMLCanvasElement | undefined; 11 | let chart_button: IButtonElement | undefined; 12 | 13 | export function setup_pp_graph(): void { 14 | if (!is_user_page()) { return; } 15 | 16 | chart_elem = create("canvas"); 17 | const chart_container = create("div", { 18 | style: { 19 | width: "100%", 20 | height: "20em", 21 | display: "none", // for the toggle button 22 | } 23 | }, chart_elem); 24 | insert_compare_display(chart_container); 25 | 26 | chart_button = button({ 27 | default: false, 28 | text: "Show pp Graph", 29 | onclick(active) { 30 | if (!chart_elem) return; 31 | this.innerText = (active ? "Hide" : "Show") + " pp Graph"; 32 | set_pp_graph_visibility(chart_container, active); 33 | } 34 | }); 35 | insert_compare_feature(chart_button); 36 | 37 | update_pp_graph_buttons(); 38 | 39 | SseEvent.UserCacheChanged.register(update_pp_graph_buttons); 40 | SseEvent.CompareUserChanged.register(update_pp_graph); 41 | } 42 | 43 | async function chartUserData(canvasContext: CanvasRenderingContext2D, datasets: Chart.ChartDataSets[], labels: (string | string[])[]): Promise { 44 | if (chart !== undefined) { 45 | chart.data = { 46 | labels, 47 | datasets 48 | }; 49 | chart.update(); 50 | return; 51 | } 52 | 53 | if (!await load_chart_lib()) 54 | return; 55 | 56 | chart = new Chart(canvasContext, { 57 | type: "line", 58 | data: { 59 | labels, 60 | datasets, 61 | }, 62 | options: { 63 | responsive: true, 64 | maintainAspectRatio: false, 65 | elements: { 66 | point: { 67 | radius: 2, 68 | } 69 | }, 70 | tooltips: { 71 | callbacks: { 72 | label: tooltipItem => String(tooltipItem.yLabel), 73 | title: () => "", 74 | } 75 | }, 76 | scales: { 77 | xAxes: [{ 78 | display: false, 79 | }] 80 | } 81 | }, 82 | }); 83 | } 84 | 85 | function get_graph_data(user_id: string) { 86 | const user = g.user_list[user_id]; 87 | if (user === undefined) 88 | return []; 89 | 90 | const data: number[] = []; 91 | const data_scaled: number[] = []; 92 | Object.values(user.songs) 93 | .filter((song) => song.pp > 0) 94 | .sort((a, b) => b.pp - a.pp) 95 | .forEach((song, index) => { 96 | // labels.push("lul"); 97 | const pp = song.pp; 98 | data.push(pp); 99 | data_scaled.push(+(pp * Math.pow(g.pp_weighting_factor, index)).toFixed(2)); 100 | }); 101 | const color = (Number(user_id) % 3600) / 10; 102 | 103 | return [{ 104 | label: `${user.name} (song pp)`, 105 | backgroundColor: `hsl(${color}, 100%, 50%)`, 106 | borderColor: `hsl(${color}, 100%, 50%)`, 107 | fill: false, 108 | data, 109 | }, { 110 | label: `${user.name} (weighted pp)`, 111 | backgroundColor: `hsl(${color}, 60%, 25%)`, 112 | borderColor: `hsl(${color}, 60%, 25%)`, 113 | fill: false, 114 | data: data_scaled, 115 | }]; 116 | } 117 | 118 | function update_pp_graph(): void { 119 | if (chart_elem === undefined) 120 | return; 121 | let dataSets = get_graph_data(get_current_user().id); 122 | const compare_user = get_compare_user(); 123 | if (get_current_user().id !== compare_user && compare_user !== undefined) 124 | dataSets = [...dataSets, ...get_graph_data(compare_user)]; 125 | 126 | let max = 0; 127 | for (const set of dataSets) { 128 | max = Math.max(max, set.data.length); 129 | } 130 | for (const set of dataSets) { 131 | if (set.data.length < max) { 132 | set.data.length = max; 133 | set.data.fill(0, set.data.length, max); 134 | } 135 | } 136 | const labels = Array(max); 137 | labels.fill("Song", 0, max); 138 | 139 | chartUserData(check(chart_elem.getContext("2d")), dataSets, labels); 140 | } 141 | 142 | export function update_pp_graph_buttons(): void { 143 | if (!chart_button) { return; } 144 | 145 | // Check if the current user is in the database 146 | const user = get_current_user(); 147 | if (g.user_list[user.id] === undefined) { 148 | // When not, we disable the feature 149 | chart_button.setAttribute("disabled", ""); 150 | chart_button.setAttribute("data-tooltip", "Add the user to your score cache for this feature"); 151 | chart_button.off(); 152 | } else { 153 | chart_button.removeAttribute("disabled"); 154 | chart_button.removeAttribute("data-tooltip"); 155 | } 156 | } 157 | 158 | function set_pp_graph_visibility(elem: HTMLElement, active: boolean) { 159 | if (active) { 160 | if (!chart) { 161 | update_pp_graph(); 162 | } 163 | elem.style.display = ""; 164 | } else { 165 | elem.style.display = "none"; 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import SseEvent from "./components/events"; 2 | import * as modal from "./components/modal"; 3 | import * as env from "./env"; 4 | import g from "./global"; 5 | import * as themes from "./themes"; 6 | import { clear_children, create, into, intor } from "./util/dom"; 7 | import { fetch2 } from "./util/net"; 8 | import { SSE_addStyle } from "./util/userscript"; 9 | import SettingsDialogue from "./components/SettingsDialogue.svelte"; 10 | 11 | let notify_box: HTMLElement | undefined; 12 | let settings_modal: modal.Modal | undefined; 13 | 14 | export function setup(): void { 15 | notify_box = create("div", { class: "field" }); 16 | const cog = create("i", { class: "fas fa-cog" }); 17 | into(env.get_navbar(), 18 | create("a", { 19 | id: "settings_menu", 20 | class: "navbar-item", 21 | style: { 22 | cursor: "pointer", 23 | }, 24 | onclick: () => show_settings_lazy(), 25 | }, cog) 26 | ); 27 | 28 | SseEvent.UserNotification.register(() => { 29 | const ntfys = SseEvent.getNotifications(); 30 | if (ntfys.length > 0) { 31 | cog.classList.remove("fa-cog"); 32 | cog.classList.add("fa-bell"); 33 | cog.style.color = "yellow"; 34 | } else { 35 | cog.classList.remove("fa-bell"); 36 | cog.classList.add("fa-cog"); 37 | cog.style.color = ""; 38 | } 39 | 40 | if (!notify_box) return; 41 | clear_children(notify_box); 42 | for (const ntfy of ntfys) { 43 | into(notify_box, 44 | create("div", { class: `notification is-${ntfy.type}` }, ntfy.msg) 45 | ); 46 | } 47 | }); 48 | } 49 | 50 | function show_settings_lazy() { 51 | if (settings_modal) { 52 | settings_modal.show(); 53 | return; 54 | } 55 | 56 | const status_box = create("div", {}); 57 | SseEvent.StatusInfo.register((status) => intor(status_box, status.text)); 58 | 59 | const set_div = create("div"); 60 | new SettingsDialogue({ target: set_div }); 61 | 62 | settings_modal = modal.create_modal({ 63 | title: "Options", 64 | text: set_div, 65 | footer: status_box, 66 | type: "card", 67 | default: true, 68 | }); 69 | } 70 | 71 | // *** Theming *** 72 | 73 | export async function settings_set_theme(name: string): Promise { 74 | let css = ""; 75 | if (name !== "Default") { 76 | css = await fetch2( 77 | `https://unpkg.com/bulmaswatch/${name.toLowerCase()}/bulmaswatch.min.css` 78 | ); 79 | } 80 | localStorage.setItem("theme_name", name); 81 | localStorage.setItem("theme_css", css); 82 | load_theme(name, css); 83 | } 84 | 85 | export function load_last_theme(): void { 86 | let theme_name = localStorage.getItem("theme_name"); 87 | let theme_css = localStorage.getItem("theme_css"); 88 | if (!theme_name || !theme_css) { 89 | theme_name = "Default"; 90 | theme_css = ""; 91 | } 92 | load_theme(theme_name, theme_css); 93 | } 94 | 95 | function load_theme(name: string, css: string): void { 96 | let css_fin: string; 97 | 98 | if (get_scoresaber_darkmode() || themes.dark_themes.includes(name)) { 99 | css_fin = css + " " + themes.theme_dark; 100 | } else { 101 | css_fin = css + " " + themes.theme_light; 102 | } 103 | if (!g.style_themed_elem) { 104 | g.style_themed_elem = SSE_addStyle(css_fin); 105 | } else { 106 | g.style_themed_elem.innerHTML = css_fin; 107 | } 108 | } 109 | 110 | function get_scoresaber_darkmode(): boolean { 111 | return document.cookie.includes("dark=1"); 112 | } 113 | 114 | export function update_button_visibility(): void { 115 | const bm = env.get_button_matrix(); 116 | for (const pb of env.BMPageButtons) { 117 | const showButton = bm[pb] || false; 118 | document.documentElement.style.setProperty( 119 | `--sse-show-${pb}`, 120 | showButton ? "unset" : "none"); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | .compact { 2 | padding-right: 0 !important; 3 | padding-left: 0 !important; 4 | margin-left: 0px !important; 5 | margin-right: 0px !important; 6 | text-align: center !important; 7 | } 8 | 9 | h5 > * { 10 | margin-right: 0.3em; 11 | } 12 | 13 | .wide_song_table { 14 | max-width: unset !important; 15 | } 16 | 17 | #leaderboard_tool_strip > * { 18 | margin-right: 0.5em; 19 | } 20 | 21 | .offset_tab { 22 | margin-left: auto; 23 | } 24 | 25 | .beatsaver_bg { 26 | background: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200' viewBox='0 0 200 200' version='1.1'%3E%3Cg fill='none' stroke='%23000000' stroke-width='10'%3E %3Cpath d='M 100,7 189,47 100,87 12,47 Z' stroke-linejoin='round'/%3E %3Cpath d='M 189,47 189,155 100,196 12,155 12,47' stroke-linejoin='round'/%3E %3Cpath d='M 100,87 100,196' stroke-linejoin='round'/%3E %3Cpath d='M 26,77 85,106 53,130 Z' stroke-linejoin='round'/%3E %3C/g%3E %3C/svg%3E") no-repeat center/85%; 27 | width: 100%; 28 | height: 100%; 29 | } 30 | 31 | .fas_big { 32 | line-height: 150%; 33 | padding-right: 0.5em; 34 | padding-left: 0.5em; 35 | min-width: 2.25em; 36 | /* Fix for some themes overriding font */ 37 | font-weight: 900; 38 | font-family: "Font Awesome 5 Free"; 39 | } 40 | 41 | .fas_big::before { 42 | font-size: 120%; 43 | } 44 | 45 | [data-tooltip]::before { 46 | visibility: hidden; 47 | background-color: #555; 48 | color: #fff; 49 | border-radius: 6px; 50 | position: absolute; 51 | z-index: 1; 52 | margin-top: -5px; 53 | opacity: 0; 54 | transition: opacity 0.3s; 55 | padding: 0.2em 1em; 56 | content: attr(data-tooltip); 57 | /* Default */ 58 | top: 0; 59 | left: 50%; 60 | right: auto; 61 | bottom: auto; 62 | transform: translate(-50%, -100%); 63 | } 64 | [data-tooltip].has-tooltip-left::before { 65 | top: auto; 66 | right: auto; 67 | bottom: 50%; 68 | left: -11px; 69 | transform: translate(-100%, 50%); 70 | } 71 | [data-tooltip]:hover::before { 72 | visibility: visible; 73 | opacity: 1; 74 | } 75 | 76 | @keyframes fill_anim { 77 | 0%{background-position:top;} 78 | 20%{background-position:bottom;} 79 | 80%{background-position:bottom;} 80 | 100%{background-position:top;} 81 | } 82 | .button_error { 83 | background: linear-gradient(to top, red 50%, transparent 50%); 84 | background-size: 100% 200%; 85 | background-position:top; 86 | animation: fill_anim 3s cubic-bezier(.23,1,.32,1) forwards; 87 | } 88 | .button_success { 89 | background: linear-gradient(to top, green 50%, transparent 50%); 90 | background-size: 100% 200%; 91 | background-position:top; 92 | animation: fill_anim 3s cubic-bezier(.23,1,.32,1) forwards; 93 | } 94 | 95 | /* Fix weird tab list offset */ 96 | 97 | .content li { 98 | margin-top: 0; 99 | } 100 | 101 | /* Fix bulma+scoresable dark color */ 102 | /* Theme CSS will be appended and can therefore 103 | * conveniently overwrite those rules. 104 | * This makes them effectively useful for the default 105 | * Light/Dark Themes of ScoreSaber */ 106 | 107 | .navbar-dropdown, .modal-card-head, .modal-card-foot { 108 | color: var(--textColor, black); 109 | background-color: var(--background, white); 110 | border-color: var(--foreground, #dbdbdb); 111 | } 112 | 113 | .box, .modal-card-body { 114 | color: var(--textColor, black); 115 | background-color: var(--background, white); 116 | } 117 | -------------------------------------------------------------------------------- /src/themes.ts: -------------------------------------------------------------------------------- 1 | import { create, into } from "./util/dom"; 2 | import { SSE_addStyle } from "./util/userscript"; 3 | 4 | export const themes = ["Default", "Cerulean", "Cosmo", "Cyborg", "Darkly", "Flatly", 5 | "Journal", "Litera", "Lumen", "Lux", "Materia", "Minty", "Nuclear", "Pulse", 6 | "Sandstone", "Simplex", "Slate", "Solar", "Spacelab", "Superhero", "United", 7 | "Yeti"]; 8 | export const dark_themes = ["Cyborg", "Darkly", "Nuclear", "Slate", "Solar", "Superhero"]; 9 | 10 | export const theme_light = `:root { 11 | --color-ahead: rgb(128, 255, 128); 12 | --color-behind: rgb(255, 128, 128); 13 | --color-highlight: lightgreen; 14 | }`; 15 | export const theme_dark = `:root { 16 | --color-ahead: rgb(0, 128, 0); 17 | --color-behind: rgb(128, 0, 0); 18 | --color-highlight: darkgreen; 19 | } 20 | .BS_bg_btn { 21 | background-color: white; 22 | } 23 | /* Reset colors for generic themes */ 24 | span.songBottom.time, span.scoreBottom, span.scoreTop.ppWeightedValue { 25 | color:unset; 26 | } 27 | span.songTop.pp, span.scoreTop.ppValue, span.scoreTop.ppLabel, span.songTop.mapper { 28 | text-shadow: 1px 1px 2px #000; 29 | }`; 30 | 31 | export function setup(): void { 32 | const style_data = `include$GULP_CSS`; 33 | SSE_addStyle(style_data); 34 | into(document.head, create("link", { rel: "stylesheet", href: "https://cdn.jsdelivr.net/npm/bulma-checkradio/dist/css/bulma-checkradio.min.css" })); 35 | } 36 | -------------------------------------------------------------------------------- /src/updater.ts: -------------------------------------------------------------------------------- 1 | import SseEvent from "./components/events"; 2 | import g from "./global"; 3 | import { fetch2 } from "./util/net"; 4 | import { SSE_info } from "./util/userscript"; 5 | 6 | export async function check_for_updates(): Promise { 7 | const current_version = SSE_info.script.version; 8 | const update_check = localStorage.getItem("update_check"); 9 | 10 | if (update_check && Number(update_check) >= new Date().getTime()) { 11 | return; 12 | } 13 | 14 | const latest_script = await fetch2(`https://raw.githubusercontent.com/Splamy/ScoreSaberEnhanced/master/scoresaber.user.js`); 15 | const latest_version = g.script_version_reg.exec(latest_script)![1]; 16 | if (current_version !== latest_version) { 17 | SseEvent.addNotification({ msg: "An update is available", type: "warning" }); 18 | } else { 19 | const now = new Date(); 20 | now.setDate(now.getDate() + 1); 21 | localStorage.setItem("update_check", now.getTime().toString()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/usercache.ts: -------------------------------------------------------------------------------- 1 | import g from "./global"; 2 | import { read_inline_date } from "./util/format"; 3 | import { logc } from "./util/log"; 4 | 5 | const CURRENT_DATA_VER: number = 1; 6 | 7 | export function load(): void { 8 | const json = localStorage.getItem("users"); 9 | if (!json) { 10 | reset_data(); 11 | return; 12 | } 13 | try { 14 | g.user_list = JSON.parse(json); 15 | } catch (ex) { 16 | console.error("Failed to read user cache, resetting!"); 17 | reset_data(); 18 | return; 19 | } 20 | 21 | let users_data_ver = get_data_ver(); 22 | if (users_data_ver !== CURRENT_DATA_VER) { 23 | logc("Updating usercache format"); 24 | 25 | if (users_data_ver <= 0) { 26 | for (const user of Object.values(g.user_list)) { 27 | for (const song of Object.values(user.songs)) { 28 | const time = read_inline_date(song.time); 29 | song.time = time.toISOString(); 30 | } 31 | } 32 | users_data_ver = 1; 33 | } 34 | 35 | update_data_ver(); 36 | save(); 37 | logc("Update successful"); 38 | } 39 | logc("Loaded usercache", g.user_list); 40 | } 41 | 42 | function reset_data(): void { 43 | g.user_list = {}; 44 | localStorage.setItem("users", "{}"); 45 | update_data_ver(); 46 | } 47 | 48 | function get_data_ver(): number { 49 | return Number(localStorage.getItem("users_data_ver") ?? "0"); 50 | } 51 | 52 | function update_data_ver(): void { 53 | localStorage.setItem("users_data_ver", String(CURRENT_DATA_VER)); 54 | } 55 | 56 | export function save(): void { 57 | localStorage.setItem("users", JSON.stringify(g.user_list)); 58 | } 59 | -------------------------------------------------------------------------------- /src/util/dom.ts: -------------------------------------------------------------------------------- 1 | type ElementBuilder = Omit; 2 | type AutoBuild = Partial & { 3 | style: Partial, 4 | id: string, 5 | class: string | string[], 6 | for: string, 7 | disabled: boolean, 8 | data: { [att: string]: string } 9 | }>; 10 | export type IntoElem = Node | string | Promise; 11 | 12 | export function create( 13 | tag: K, 14 | attrs?: AutoBuild, 15 | ...children: IntoElem[]): HTMLElementTagNameMap[K] { 16 | if (tag === undefined) throw new Error("'tag' not defined"); 17 | 18 | const ele = document.createElement(tag); 19 | if (attrs) { 20 | for (const [attrName, attrValue] of Object.entries(attrs)) { 21 | if (attrName === "style") { 22 | for (const [styleName, styleValue] of Object.entries(attrs.style!)) { ele.style[styleName as any] = styleValue; } 23 | } else if (attrName === "class") { 24 | if (typeof attrs.class === "string") { 25 | const classes = attrs.class.split(/ /g).filter(c => c.trim().length > 0); 26 | ele.classList.add(...classes); 27 | } else { 28 | ele.classList.add(...attrs.class!); 29 | } 30 | } else if (attrName === "for") { 31 | (ele as HTMLLabelElement).htmlFor = attrValue; 32 | } else if (attrName === "selected") { 33 | (ele as HTMLOptionElement).selected = (attrValue ? "selected" : undefined) as any; 34 | } else if (attrName === "disabled") { 35 | if (attrValue) ele.setAttribute("disabled", undefined!); 36 | } else if (attrName === "data") { 37 | const data_dict: { [att: string]: string } = attrs[attrName] as any; 38 | for (const [data_key, data_value] of Object.entries(data_dict)) { 39 | ele.dataset[data_key] = data_value; 40 | } 41 | } else { 42 | (ele as any)[attrName] = (attrs as any)[attrName]; 43 | } 44 | } 45 | } 46 | 47 | into(ele, ...children); 48 | return ele; 49 | } 50 | 51 | /** 52 | * Removes all child elements 53 | */ 54 | export function clear_children(elem: HTMLElement): void { 55 | while (elem.lastChild) { 56 | elem.removeChild(elem.lastChild); 57 | } 58 | } 59 | 60 | /** 61 | * Into, but replaces the content 62 | */ 63 | export function intor(parent: HTMLElement, ...children: IntoElem[]): HTMLElement { 64 | clear_children(parent); 65 | return into(parent, ...children); 66 | } 67 | 68 | /** 69 | * Appends the children to the parent 70 | * @returns The parent itself. (Useful for fluent declaration.) 71 | */ 72 | export function into(parent: HTMLElement, ...children: IntoElem[]): HTMLElement { 73 | for (const child of children) { 74 | if (typeof child === "string") { 75 | if (children.length > 1) { 76 | parent.appendChild(to_node(child)); 77 | } else { 78 | parent.textContent = child; 79 | } 80 | } else if ("then" in child) { 81 | const dummy = document.createElement("DIV"); 82 | parent.appendChild(dummy); 83 | (async () => { 84 | const node = await child; 85 | parent.replaceChild(to_node(node), dummy); 86 | })(); 87 | } else { 88 | parent.appendChild(child); 89 | } 90 | } 91 | return parent; 92 | } 93 | 94 | function to_node(elem: Node | string): Node { 95 | if (typeof elem === "string") { 96 | const text_div = document.createElement("DIV"); 97 | text_div.textContent = elem; 98 | return text_div; 99 | } 100 | return elem; 101 | } 102 | 103 | export function as_fragment(builder: (target: Element) => void): Node { 104 | const frag = document.createDocumentFragment(); 105 | builder(frag as any as Element); 106 | return frag; 107 | } 108 | -------------------------------------------------------------------------------- /src/util/err.ts: -------------------------------------------------------------------------------- 1 | export function check(elem: T | undefined | null): T { 2 | if (elem === undefined || elem === null) { 3 | throw new Error("Expected value to not be null"); 4 | } 5 | return elem; 6 | } 7 | -------------------------------------------------------------------------------- /src/util/format.ts: -------------------------------------------------------------------------------- 1 | export function format_en(num: number, digits?: number): string { 2 | if (digits === undefined) digits = 2; 3 | return num.toLocaleString("en", { minimumFractionDigits: digits, maximumFractionDigits: digits }); 4 | } 5 | 6 | export function toggled_class(bool: boolean, css_class: string): string { 7 | return bool ? css_class : ""; 8 | } 9 | 10 | export function number_invariant(num: string): number { 11 | return Number(num.replace(/,/g, "")); 12 | } 13 | 14 | export function number_to_timespan(num: number): string { 15 | const SECONDS_IN_MINUTE = 60; 16 | const MINUTES_IN_HOUR = 60; 17 | let str = ""; 18 | 19 | let mod = (num % SECONDS_IN_MINUTE); 20 | str = mod.toFixed(0).padStart(2, "0") + str; 21 | num = (num - mod) / SECONDS_IN_MINUTE; 22 | 23 | mod = (num % MINUTES_IN_HOUR); 24 | str = mod.toFixed(0).padStart(2, "0") + ":" + str; 25 | num = (num - mod) / MINUTES_IN_HOUR; 26 | 27 | // optional hours 28 | return str; 29 | } 30 | 31 | export function round2(num: number): number { 32 | return Math.round(num * 100) / 100; 33 | } 34 | 35 | export function read_inline_date(date: string): moment.Moment { 36 | return moment.utc(date, "YYYY-MM-DD HH:mm:ss UTC"); 37 | } 38 | -------------------------------------------------------------------------------- /src/util/lazy.ts: -------------------------------------------------------------------------------- 1 | export class Lazy { 2 | private value: T | undefined; 3 | 4 | constructor( 5 | private generator: () => T 6 | ) { } 7 | 8 | public get(): T { 9 | if (this.generator !== undefined) { 10 | this.value = this.generator(); 11 | this.generator = undefined!; 12 | } 13 | return this.value!; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/util/limiter.ts: -------------------------------------------------------------------------------- 1 | export class Limiter { 2 | public ratelimit_reset: number | undefined; 3 | public ratelimit_remaining: number | undefined; 4 | public ratelimit_limit: number | undefined; 5 | 6 | constructor() { 7 | this.ratelimit_reset = undefined; 8 | this.ratelimit_remaining = undefined; 9 | } 10 | 11 | public async wait(): Promise { 12 | const now = unix_timestamp(); 13 | if (this.ratelimit_reset === undefined || now > this.ratelimit_reset) { 14 | this.ratelimit_reset = undefined; 15 | this.ratelimit_remaining = undefined; 16 | return; 17 | } 18 | 19 | if (this.ratelimit_remaining === 0) { 20 | const sleepTime = (this.ratelimit_reset - now); 21 | console.log(`Waiting for cloudflare rate limiter... ${sleepTime}sec`); 22 | await sleep(sleepTime * 1000); 23 | this.ratelimit_remaining = this.ratelimit_limit; 24 | this.ratelimit_reset = undefined; 25 | } 26 | } 27 | 28 | public setLimitData(remaining: number, reset: number, limit: number): void { 29 | this.ratelimit_remaining = remaining; 30 | this.ratelimit_reset = reset; 31 | this.ratelimit_limit = limit; 32 | } 33 | } 34 | 35 | export async function sleep(timeout: number): Promise { 36 | return new Promise(resolve => setTimeout(resolve, timeout)); 37 | } 38 | 39 | export function unix_timestamp(): number { 40 | return Math.round((new Date()).getTime() / 1000); 41 | } 42 | 43 | // Some benchmarks for scoresaber.com 44 | 45 | // Requesting user [2538637699496776]; has 243 pages 46 | // (Requests, Time, RPM, OK) 47 | // - ( 80, 27, 180, ❌) 48 | // - ( 80, 40, 120, ❌) 49 | // - ( 134, 90, 90, ❌) 50 | // - ( 138, 98, 85, ❌) 51 | // - ( ALL, 183, 80, ✅) 52 | // - ( ALL, 217, 70, ✅) 53 | // - ( ALL, 244, 60, ✅) 54 | 55 | // The cloudflare rate limit data matches this observation 56 | // x-ratelimit-limit: 80 57 | // x-ratelimit-remaining: 79 58 | // x-ratelimit-reset: 1594669651 59 | -------------------------------------------------------------------------------- /src/util/log.ts: -------------------------------------------------------------------------------- 1 | import g from "../global"; 2 | 3 | export function setup(): void { 4 | g.debug = localStorage.getItem("debug") === "true"; 5 | } 6 | 7 | /* eslint-disable @typescript-eslint/no-explicit-any,@typescript-eslint/explicit-module-boundary-types */ 8 | export function logc(message: any, ...optionalParams: any[]): void { 9 | if (g.debug) { 10 | console.log("DBG", message, ...optionalParams); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/util/net.ts: -------------------------------------------------------------------------------- 1 | import { SSE_xmlhttpRequest } from "./userscript"; 2 | 3 | export function fetch2(url: string): Promise { 4 | return new Promise((resolve, reject) => { 5 | const host = get_hostname(url); 6 | const request_param = { 7 | method: "GET", 8 | url: url, 9 | headers: { Origin: host }, 10 | onload: (req: XMLHttpRequest) => { 11 | if (req.status >= 200 && req.status < 300) { 12 | resolve(req.responseText); 13 | } else { 14 | reject(`request errored: ${url} (${req.status})`); 15 | } 16 | }, 17 | onerror: () => { 18 | reject(`request errored: ${url}`); 19 | } 20 | }; 21 | SSE_xmlhttpRequest(request_param); 22 | }); 23 | } 24 | 25 | function get_hostname(url: string): string | undefined { 26 | const match = url.match(/:\/\/([^/:]+)/i); 27 | if (match !== null) { 28 | return match[1]; 29 | } else { 30 | return undefined; 31 | } 32 | } 33 | 34 | export function new_page(link: string): void { 35 | window.open(link, "_blank"); 36 | } 37 | -------------------------------------------------------------------------------- /src/util/sessioncache.ts: -------------------------------------------------------------------------------- 1 | export class SessionCache { 2 | constructor( 3 | private prefix: string 4 | ) { 5 | if (prefix === undefined) 6 | throw Error("Prefix must be set. If you don't want a prefix, explicitely pass ''."); 7 | } 8 | 9 | public get(key: string): T | undefined { 10 | const item = sessionStorage.getItem(this.prefix + key); 11 | if (item === null) 12 | return undefined; 13 | return JSON.parse(item); 14 | } 15 | 16 | public set(key: string, value: T): void { 17 | sessionStorage.setItem(this.prefix + key, JSON.stringify(value)); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/util/song.ts: -------------------------------------------------------------------------------- 1 | import * as beatsaver from "../api/beatsaver"; 2 | import * as modal from "../components/modal"; 3 | import { ISong } from "../declarations/Types"; 4 | import g from "../global"; 5 | import { check } from "./err"; 6 | import { number_invariant } from "./format"; 7 | 8 | export function get_song_compare_value(song_a: ISong, song_b: ISong): [number, number] { 9 | if (song_a.pp > 0 || song_b.pp > 0) { 10 | return [song_a.pp, song_b.pp]; 11 | } else if (song_a.score !== undefined && song_b.score !== undefined) { 12 | return [song_a.score, song_b.score]; 13 | } else if (song_a.accuracy !== undefined && song_b.accuracy !== undefined) { 14 | return [song_a.accuracy * get_song_mod_multiplier(song_a), song_b.accuracy * get_song_mod_multiplier(song_b)]; 15 | } else { 16 | return [-1, -1]; 17 | } 18 | } 19 | 20 | function get_song_mod_multiplier(song: ISong): number { 21 | if (!song.mods) 22 | return 1.0; 23 | 24 | // Note: ranked maps may use different values for modifiers like GN, DA, FS 25 | // this function returns a modifier which should only be used for accuracy. 26 | let multiplier = 1.0; 27 | for (const mod of song.mods) { 28 | switch (mod) { 29 | case "NF": multiplier -= 0.50; break; 30 | case "NO": multiplier -= 0.05; break; 31 | case "NB": multiplier -= 0.10; break; 32 | case "SS": multiplier -= 0.30; break; 33 | case "NA": multiplier -= 0.30; break; 34 | 35 | case "DA": multiplier += 0.07; break; 36 | case "GN": multiplier += 0.11; break; 37 | case "FS": multiplier += 0.08; break; 38 | } 39 | } 40 | return Math.max(0, multiplier); 41 | } 42 | 43 | export function get_song_hash_from_text(text: string): string | undefined { 44 | const res = g.song_hash_reg.exec(text); 45 | return res ? res[1]?.toLowerCase() : undefined; 46 | } 47 | 48 | export async function fetch_hash(link: string): Promise { 49 | // we can't get the beatsaver song link directly so we fetch 50 | // the song hash from the leaderboard site with an async fetch request. 51 | const leaderboard_text = await (await fetch(link)).text(); 52 | return get_song_hash_from_text(leaderboard_text); 53 | } 54 | 55 | export async function oneclick_install_byhash(song_hash: string): Promise { 56 | const song_info = await beatsaver.get_data_by_hash(song_hash); 57 | if (!song_info) return false; 58 | await oneclick_install(song_info.id); 59 | return true; 60 | } 61 | 62 | export async function oneclick_install(song_key: string): Promise { 63 | const lastCheck = localStorage.getItem("oneclick-prompt"); 64 | const prompt = !lastCheck || 65 | new Date(lastCheck).getTime() + (1000 * 60 * 60 * 24 * 31) < new Date().getTime(); 66 | 67 | if (prompt) { 68 | localStorage.setItem("oneclick-prompt", new Date().getTime().toString()); 69 | 70 | const resp = await modal.show_modal({ 71 | buttons: { 72 | install: { text: "Get ModAssistant Installer", class: "is-info" }, 73 | done: { text: "OK, now leave me alone", class: "is-success" }, 74 | }, 75 | text: "OneClick™ requires any current ModInstaller tool with the OneClick™ feature enabled.\nMake sure you have one installed before proceeding.", 76 | }); 77 | 78 | if (resp === "install") { 79 | window.open("https://github.com/Assistant/ModAssistant/releases"); 80 | return; 81 | } 82 | } 83 | 84 | console.log("Downloading: ", song_key); 85 | window.location.assign(`beatsaver://${song_key}`); 86 | } 87 | 88 | export function song_equals(a?: ISong, b?: ISong): boolean { 89 | if (a === b) // Catches 'reference equal', and 'a = b = undefined' 90 | return true; 91 | if (a === undefined || b === undefined) 92 | return false; 93 | return ( 94 | a.accuracy === b.accuracy && 95 | a.pp === b.pp && 96 | a.score === b.score && 97 | a.time === b.time && 98 | array_equals(a.mods, b.mods)); 99 | } 100 | 101 | function array_equals(a: T[] | undefined, b: T[] | undefined): boolean { 102 | if (a === b) // Catches 'reference equal', and 'a = b = undefined' 103 | return true; 104 | if (a === undefined || b === undefined) 105 | return false; 106 | if (a.length !== b.length) 107 | return false; 108 | for (let i = 0; i < a.length; i++) { 109 | if (a[i] !== b[i]) return false; 110 | } 111 | return true; 112 | } 113 | 114 | export function parse_mods(mods: string): string[] | undefined { 115 | if (!mods) return undefined; 116 | const modarr = mods.split(/,/g); 117 | if (modarr.length === 0) return undefined; 118 | return modarr; 119 | } 120 | 121 | export function parse_score_bottom(text: string): { score?: number, accuracy?: number, mods?: string[] } { 122 | let score = undefined; 123 | let accuracy = undefined; 124 | let mods = undefined; 125 | const score_res = check(g.score_reg.exec(text)); 126 | if (score_res[1] === "score") { 127 | score = number_invariant(score_res[2]); 128 | } else if (score_res[1] === "accuracy") { 129 | accuracy = Number(score_res[2]); 130 | } 131 | if (score_res[4]) { 132 | mods = parse_mods(score_res[4]); 133 | } 134 | return { score, accuracy, mods }; 135 | } 136 | 137 | export function get_notes_count(diff_name: string, characteristic: string, version: beatsaver.IBeatSaverSongVersion): number { 138 | if (diff_name === "Expert+") 139 | diff_name = "ExpertPlus"; 140 | const diff = version.diffs.find((d) => (d.characteristic === characteristic && d.difficulty === diff_name)); 141 | return diff?.notes ?? -1; 142 | } 143 | 144 | export function calculate_max_score(notes: number): number { 145 | const note_score = 115; 146 | 147 | if (notes <= 1) // x1 (+1 note) 148 | return note_score * (0 + (notes - 0) * 1); 149 | if (notes <= 5) // x2 (+4 notes) 150 | return note_score * (1 + (notes - 1) * 2); 151 | if (notes <= 13) // x4 (+8 notes) 152 | return note_score * (9 + (notes - 5) * 4); 153 | // x8 154 | return note_score * (41 + (notes - 13) * 8); 155 | } 156 | 157 | export function diff_value_to_name(value: number): string { 158 | switch (value) { 159 | case 1: return "Easy"; 160 | case 3: return "Medium"; 161 | case 5: return "Hard"; 162 | case 7: return "Expert"; 163 | case 9: return "ExpertPlus"; 164 | default: return "Unknown"; 165 | } 166 | } 167 | 168 | export function diff_name_to_value(name: string): number { 169 | switch (name) { 170 | case "Easy": return 1; 171 | case "Medium": return 3; 172 | case "Hard": return 5; 173 | case "Expert": return 7; 174 | case "ExpertPlus": return 9; 175 | default: return -1; 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/util/userscript.ts: -------------------------------------------------------------------------------- 1 | import { create, into } from "./dom"; 2 | import { logc } from "./log"; 3 | 4 | declare const GM: any; 5 | declare function GM_addStyle(css: string): HTMLStyleElement; 6 | type GM_XHR_Options = Partial & { onload?: (req: XMLHttpRequest) => void, onerror?: () => void } | { headers: any }; 7 | declare function GM_xmlhttpRequest(info: GM_XHR_Options): void; 8 | interface IInfo { 9 | /** An object containing data about the currently running script. */ 10 | script: { 11 | /** Version. Possibly empty string. */ 12 | version?: string, 13 | // ... 14 | }; 15 | /** A string, the entire literal Metadata Block (without the delimiters) for the currently running script. */ 16 | scriptMetaStr: string; 17 | /** The name of the user script engine handling this script's execution. E.g. `Greasemonkey`. */ 18 | scriptHandler: string; 19 | /** The version of Greasemonkey, a string e.g. `4.0`. */ 20 | version: string; 21 | } 22 | declare const GM_info: IInfo; 23 | 24 | export let SSE_addStyle: (css: string) => HTMLStyleElement; 25 | export let SSE_xmlhttpRequest: (info: GM_XHR_Options) => void; 26 | export let SSE_info: IInfo; 27 | 28 | export function setup(): void { 29 | if (typeof (GM) !== "undefined") { 30 | logc("Using GM.* extenstions", GM); 31 | SSE_addStyle = GM_addStyle_custom; 32 | SSE_xmlhttpRequest = GM.xmlHttpRequest; 33 | SSE_info = GM.info; 34 | } else { 35 | logc("Using GM_ extenstions"); 36 | SSE_addStyle = GM_addStyle; 37 | SSE_xmlhttpRequest = GM_xmlhttpRequest; 38 | SSE_info = GM_info; 39 | } 40 | } 41 | 42 | function GM_addStyle_custom(css: string): HTMLStyleElement { 43 | const style = create("style"); 44 | style.innerHTML = css; 45 | into(document.head, style); 46 | return style; 47 | } 48 | 49 | export async function load_chart_lib(): Promise { 50 | if (typeof Chart !== "function") { 51 | try { 52 | const resp = await fetch("https://scoresaber.com/imports/js/chart.js"); 53 | const js = await resp.text(); 54 | new Function(js)(); 55 | } catch (err) { 56 | console.warn("Failed to fetch chartjs. Charts might not work", err); 57 | return false; 58 | } 59 | } 60 | return true; 61 | } 62 | -------------------------------------------------------------------------------- /svelte.config.mjs: -------------------------------------------------------------------------------- 1 | import preprocess from 'svelte-preprocess'; 2 | 3 | const config = { 4 | preprocess: preprocess({}) 5 | } 6 | 7 | export default config; 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "module": "ES2020", 5 | "moduleResolution": "node", 6 | "sourceMap": true, 7 | "watch": false, 8 | // scoresaber.user.js 9 | "removeComments": true, 10 | "noImplicitAny": true, 11 | "strictNullChecks": true, 12 | "alwaysStrict": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": true, 16 | "noUnusedLocals": true, 17 | //,"noUnusedParameters": true 18 | "strictPropertyInitialization": true, 19 | "strictFunctionTypes": true, 20 | "strict": true, 21 | } 22 | } 23 | --------------------------------------------------------------------------------