├── .editorconfig ├── .eslintrc.json ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── LICENSE ├── README.md ├── angular.json ├── angular.webpack.js ├── app ├── favicon.ico ├── main.ts ├── package-lock.json ├── package.json ├── router │ ├── beatmaps.ts │ ├── collections.ts │ ├── filters.ts │ └── load.ts └── util │ ├── beatmaps.ts │ ├── bpm.ts │ ├── collections.ts │ ├── database │ ├── collection.ts │ ├── database.ts │ ├── filterScripts.ts │ ├── filters.ts │ └── settings.ts │ ├── evaluation.ts │ ├── hitobjects.ts │ ├── load.ts │ ├── mods.ts │ ├── parsing │ ├── cache.ts │ ├── collections.ts │ ├── hitobjects.ts │ └── utf8.ts │ └── practice.ts ├── electron-builder.json ├── models ├── beatmaps.ts ├── cache.ts ├── collection.ts ├── filters.ts └── settings.ts ├── package-lock.json ├── package.json ├── resources └── icon.ico ├── src ├── app │ ├── app-routing.module.ts │ ├── app.component.html │ ├── app.component.ts │ ├── app.module.ts │ ├── components │ │ ├── add-modal │ │ │ ├── add-modal.component.html │ │ │ └── add-modal.component.ts │ │ ├── collection-dropdown │ │ │ ├── collection-dropdown.component.html │ │ │ └── collection-dropdown.component.ts │ │ ├── confirm-modal │ │ │ ├── confirm-modal.component.html │ │ │ └── confirm-modal.component.ts │ │ ├── filter-select │ │ │ ├── filter-select.component.html │ │ │ └── filter-select.component.ts │ │ ├── header │ │ │ ├── header.component.html │ │ │ └── header.component.ts │ │ ├── help │ │ │ ├── help.component.html │ │ │ └── help.component.ts │ │ ├── language-dropdown │ │ │ ├── language-dropdown.component.html │ │ │ └── language-dropdown.component.ts │ │ ├── loading-bar │ │ │ ├── loading-bar.component.html │ │ │ └── loading-bar.component.ts │ │ ├── pagination │ │ │ ├── pagination.component.html │ │ │ └── pagination.component.ts │ │ ├── rename-modal │ │ │ ├── rename-modal.component.html │ │ │ └── rename-modal.component.ts │ │ ├── settings-modal │ │ │ ├── settings-modal.component.html │ │ │ └── settings-modal.component.ts │ │ ├── sidebar-button │ │ │ ├── sidebar-button.component.html │ │ │ └── sidebar-button.component.ts │ │ ├── sidebar │ │ │ ├── sidebar.component.html │ │ │ └── sidebar.component.ts │ │ ├── update │ │ │ ├── update.component.html │ │ │ └── update.component.ts │ │ └── value-override-slider │ │ │ ├── value-override-slider.component.html │ │ │ └── value-override-slider.component.ts │ ├── core │ │ ├── core.module.ts │ │ └── services │ │ │ ├── electron │ │ │ ├── electron.service.spec.ts │ │ │ └── electron.service.ts │ │ │ └── index.ts │ ├── pages │ │ ├── bpm-changer │ │ │ ├── bpm-changer.component.html │ │ │ └── bpm-changer.component.ts │ │ ├── customfilter │ │ │ ├── customfilter.component.html │ │ │ └── customfilter.component.ts │ │ ├── edit │ │ │ ├── edit.component.css │ │ │ ├── edit.component.html │ │ │ └── edit.component.ts │ │ ├── filters │ │ │ ├── filters.component.html │ │ │ └── filters.component.ts │ │ ├── home │ │ │ ├── home.component.html │ │ │ └── home.component.ts │ │ ├── importexport │ │ │ ├── importexport.component.html │ │ │ └── importexport.component.ts │ │ ├── loading │ │ │ ├── loading.component.html │ │ │ └── loading.component.ts │ │ ├── practice-diffs │ │ │ ├── practice-diffs.component.html │ │ │ └── practice-diffs.component.ts │ │ └── settings │ │ │ ├── settings.component.html │ │ │ └── settings.component.ts │ ├── services │ │ ├── beatmap.service.ts │ │ ├── collections.service.ts │ │ ├── component.service.ts │ │ ├── filter.service.ts │ │ ├── ipc.service.ts │ │ ├── loading.service.ts │ │ ├── selected.service.ts │ │ ├── title.service.ts │ │ └── util.service.ts │ ├── shared │ │ ├── directives │ │ │ ├── index.ts │ │ │ └── webview │ │ │ │ ├── webview.directive.spec.ts │ │ │ │ └── webview.directive.ts │ │ └── shared.module.ts │ └── util │ │ ├── baseConfig.ts │ │ └── processing.ts ├── assets │ ├── .gitkeep │ ├── i18n │ │ ├── en.json │ │ └── ru.json │ └── icons │ │ ├── favicon.ico │ │ └── favicon.png ├── environments │ ├── environment.dev.ts │ ├── environment.prod.ts │ ├── environment.ts │ └── environment.web.ts ├── favicon.ico ├── index.html ├── karma.conf.js ├── main.ts ├── polyfills-test.ts ├── polyfills.ts ├── styles.scss ├── test.ts ├── tsconfig.app.json ├── tsconfig.spec.json └── typings.d.ts ├── tailwind.config.js ├── tsconfig.json └── tsconfig.serve.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": [ 4 | "app/**/*", // ignore nodeJs files 5 | "dist/**/*", 6 | "release/**/*" 7 | ], 8 | "overrides": [ 9 | { 10 | "files": [ 11 | "*.ts" 12 | ], 13 | "parserOptions": { 14 | "project": [ 15 | "./tsconfig.serve.json", 16 | "./src/tsconfig.app.json", 17 | "./src/tsconfig.spec.json", 18 | "./e2e/tsconfig.e2e.json" 19 | ], 20 | "createDefaultProgram": true 21 | }, 22 | "extends": [ 23 | "plugin:@angular-eslint/ng-cli-compat", 24 | "plugin:@angular-eslint/ng-cli-compat--formatting-add-on", 25 | "plugin:@angular-eslint/template/process-inline-templates" 26 | ], 27 | "rules": { 28 | "prefer-arrow/prefer-arrow-functions": 0, 29 | "@angular-eslint/directive-selector": 0, 30 | "@angular-eslint/component-selector": [ 31 | "error", 32 | { 33 | "type": "element", 34 | "prefix": "app", 35 | "style": "kebab-case" 36 | } 37 | ] 38 | } 39 | }, 40 | { 41 | "files": [ 42 | "*.html" 43 | ], 44 | "extends": [ 45 | "plugin:@angular-eslint/template/recommended" 46 | ], 47 | "rules": { 48 | "avoidEscape": true, 49 | "allowTemplateLiterals": true, 50 | } 51 | } 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /app-builds 8 | /release 9 | main.js 10 | src/**/*.js 11 | !src/karma.conf.js 12 | *.js.map 13 | 14 | # dependencies 15 | node_modules 16 | 17 | # IDEs and editors 18 | .idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # IDE - VSCode 27 | .vscode 28 | .vscode/* 29 | .vscode/settings.json 30 | !.vscode/tasks.json 31 | !.vscode/launch.json 32 | !.vscode/extensions.json 33 | 34 | # misc 35 | /.sass-cache 36 | /connect.lock 37 | /coverage 38 | /libpeerconnection.log 39 | npm-debug.log 40 | testem.log 41 | /typings 42 | 43 | # e2e 44 | /e2e/*.js 45 | !/e2e/protractor.conf.js 46 | /e2e/*.map 47 | 48 | # System Files 49 | .DS_Store 50 | Thumbs.db 51 | 52 | *.js 53 | !angular.webpack.js 54 | *.db 55 | 56 | e2e 57 | .eslintrc.json 58 | .editorconfig 59 | electron-builder.yml 60 | 61 | *.json 62 | !src/assets/i18n/*.json 63 | snipetool.ts 64 | 65 | !tailwind.config.js 66 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 James 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 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "cli": { 4 | "analytics": "d910a8fa-5b83-4585-96d5-1ab79df14828", 5 | "defaultCollection": "@angular-eslint/schematics" 6 | }, 7 | "version": 1, 8 | "newProjectRoot": "projects", 9 | "projects": { 10 | "angular-electron": { 11 | "root": "", 12 | "sourceRoot": "src", 13 | "projectType": "application", 14 | "schematics": { 15 | "@schematics/angular:application": { 16 | "strict": true 17 | } 18 | }, 19 | "architect": { 20 | "build": { 21 | "builder": "@angular-builders/custom-webpack:browser", 22 | "options": { 23 | "outputPath": "dist", 24 | "index": "src/index.html", 25 | "main": "src/main.ts", 26 | "tsConfig": "src/tsconfig.app.json", 27 | "polyfills": "src/polyfills.ts", 28 | "assets": [ 29 | { "glob": "**/*", "input": "node_modules/monaco-editor", "output": "assets/monaco-editor" }, 30 | { "glob": "**/*", "input": "node_modules/ngx-monaco-editor/assets/monaco", "output": "assets/monaco" }, 31 | "src/favicon.ico", 32 | "src/assets" 33 | ], 34 | "styles": [ 35 | "src/styles.scss", 36 | "node_modules/ngx-easy-table/style.css", 37 | "node_modules/@angular/material/prebuilt-themes/deeppurple-amber.css", 38 | "node_modules/ngx-ui-switch/ui-switch.component.css" 39 | ], 40 | "scripts": [], 41 | "customWebpackConfig": { 42 | "path": "./angular.webpack.js" 43 | } 44 | }, 45 | "configurations": { 46 | "dev": { 47 | "optimization": false, 48 | "outputHashing": "none", 49 | "sourceMap": true, 50 | "namedChunks": false, 51 | "aot": false, 52 | "extractLicenses": true, 53 | "vendorChunk": false, 54 | "buildOptimizer": false, 55 | "fileReplacements": [ 56 | { 57 | "replace": "src/environments/environment.ts", 58 | "with": "src/environments/environment.dev.ts" 59 | } 60 | ] 61 | }, 62 | "web": { 63 | "optimization": false, 64 | "outputHashing": "none", 65 | "sourceMap": true, 66 | "namedChunks": false, 67 | "aot": false, 68 | "extractLicenses": true, 69 | "vendorChunk": false, 70 | "buildOptimizer": false, 71 | "fileReplacements": [ 72 | { 73 | "replace": "src/environments/environment.ts", 74 | "with": "src/environments/environment.web.ts" 75 | } 76 | ] 77 | }, 78 | "production": { 79 | "optimization": true, 80 | "outputHashing": "all", 81 | "sourceMap": false, 82 | "namedChunks": false, 83 | "aot": true, 84 | "extractLicenses": true, 85 | "vendorChunk": false, 86 | "buildOptimizer": true, 87 | "fileReplacements": [ 88 | { 89 | "replace": "src/environments/environment.ts", 90 | "with": "src/environments/environment.prod.ts" 91 | } 92 | ] 93 | } 94 | } 95 | }, 96 | "serve": { 97 | "builder": "@angular-builders/custom-webpack:dev-server", 98 | "options": { 99 | "browserTarget": "angular-electron:build" 100 | }, 101 | "configurations": { 102 | "dev": { 103 | "browserTarget": "angular-electron:build:dev" 104 | }, 105 | "web": { 106 | "browserTarget": "angular-electron:build:web" 107 | }, 108 | "production": { 109 | "browserTarget": "angular-electron:build:production" 110 | } 111 | } 112 | }, 113 | "extract-i18n": { 114 | "builder": "@angular-devkit/build-angular:extract-i18n", 115 | "options": { 116 | "browserTarget": "angular-electron:build" 117 | } 118 | }, 119 | "test": { 120 | "builder": "@angular-builders/custom-webpack:karma", 121 | "options": { 122 | "main": "src/test.ts", 123 | "polyfills": "src/polyfills-test.ts", 124 | "tsConfig": "src/tsconfig.spec.json", 125 | "karmaConfig": "src/karma.conf.js", 126 | "scripts": [], 127 | "styles": [ 128 | "src/styles.scss" 129 | ], 130 | "assets": [ 131 | "src/assets" 132 | ], 133 | "customWebpackConfig": { 134 | "path": "./angular.webpack.js" 135 | } 136 | } 137 | }, 138 | "lint": { 139 | "builder": "@angular-eslint/builder:lint", 140 | "options": { 141 | "lintFilePatterns": [ 142 | "src/**/*.ts", 143 | "src/**/*.html" 144 | ] 145 | } 146 | } 147 | } 148 | }, 149 | "angular-electron-e2e": { 150 | "root": "e2e", 151 | "projectType": "application", 152 | "architect": { 153 | "lint": { 154 | "builder": "@angular-eslint/builder:lint", 155 | "options": { 156 | "lintFilePatterns": [ 157 | "e2e/**/*.ts" 158 | ] 159 | } 160 | } 161 | } 162 | } 163 | }, 164 | "defaultProject": "angular-electron", 165 | "schematics": { 166 | "@schematics/angular:component": { 167 | "prefix": "app", 168 | "style": "scss" 169 | }, 170 | "@schematics/angular:directive": { 171 | "prefix": "app" 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /angular.webpack.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom angular webpack configuration 3 | */ 4 | 5 | module.exports = (config, options) => { 6 | config.target = "electron-renderer"; 7 | 8 | if (options.fileReplacements) { 9 | for (let fileReplacement of options.fileReplacements) { 10 | if (fileReplacement.replace !== "src/environments/environment.ts") { 11 | continue; 12 | } 13 | 14 | let fileReplacementParts = fileReplacement["with"].split("."); 15 | if ( 16 | fileReplacementParts.length > 1 && 17 | ["web"].indexOf(fileReplacementParts[1]) >= 0 18 | ) { 19 | config.target = "web"; 20 | } 21 | break; 22 | } 23 | } 24 | 25 | return config; 26 | }; 27 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nzbasic/Collection-Helper/3f90ff23fd3727b139646205a515d5726d6a212a/app/favicon.ico -------------------------------------------------------------------------------- /app/main.ts: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, ipcMain } from "electron"; 2 | import * as path from "path"; 3 | import * as fs from "fs"; 4 | import * as url from "url"; 5 | import * as express from "express"; 6 | import * as cors from "cors"; 7 | import collectionRouter from './router/collections' 8 | import loadRouter from './router/load' 9 | import beatmapRouter from './router/beatmaps' 10 | import filterRouter from './router/filters' 11 | import { AddressInfo } from "net"; 12 | import { autoUpdater } from 'electron-updater' 13 | import * as log from 'electron-log' 14 | import * as installed from 'fetch-installed-software' 15 | 16 | const rest = express(); 17 | 18 | rest.use(express.json({limit: '50mb'})); 19 | rest.use(express.urlencoded({ extended: false, limit: '50mb' })); 20 | rest.use(cors({ credentials: true, origin: true })); 21 | rest.use(function (req, res, next) { 22 | res.header("Access-Control-Allow-Origin", "*"); 23 | res.header( 24 | "Access-Control-Allow-Headers", 25 | "Origin, X-Requested-With, Content-Type, Accept" 26 | ); 27 | next(); 28 | }); 29 | 30 | rest.use("/collections", collectionRouter) 31 | rest.use("/", loadRouter); 32 | rest.use("/beatmaps", beatmapRouter) 33 | rest.use("/filters", filterRouter) 34 | 35 | // Initialize remote module 36 | //require("@electron/remote/main").initialize(); 37 | 38 | export let win: BrowserWindow = null 39 | const args = process.argv.slice(1) 40 | export const serve = args.some((val) => val === "--serve"); 41 | 42 | const port = serve ? 7373 : 0 43 | const server = rest.listen(port, '127.0.0.1'); 44 | 45 | const mainOpts: Electron.BrowserWindowConstructorOptions = { 46 | x: 0, 47 | y: 0, 48 | show: false, 49 | width: 1400, 50 | height: 900, 51 | minWidth: 1400, 52 | backgroundColor: '#fff', 53 | minHeight: 900, 54 | webPreferences: { 55 | nodeIntegration: true, 56 | allowRunningInsecureContent: serve ? true : false, 57 | contextIsolation: false, // false if you want to run 2e2 test with Spectron 58 | enableRemoteModule: false, // true if you want to run 2e2 test with Spectron or use remote module in renderer context (ie. Angular) 59 | }, 60 | title: "Collection Helper" 61 | }; 62 | 63 | function createWindow(): BrowserWindow { 64 | // Create the browser window. 65 | win = new BrowserWindow(mainOpts); 66 | win.setMenu(null); 67 | 68 | if (serve) { 69 | win.webContents.openDevTools(); 70 | require("electron-reload")(__dirname, { 71 | electron: require(path.join(__dirname, "/../node_modules/electron")), 72 | }); 73 | win.loadURL("http://localhost:4200"); 74 | } else { 75 | 76 | // Path when running electron executable 77 | let pathIndex = "./index.html"; 78 | 79 | if (fs.existsSync(path.join(__dirname, "../app/index.html"))) { 80 | // Path when running electron in local folder 81 | pathIndex = "../app/index.html"; 82 | } 83 | 84 | win.loadURL( 85 | url.format({ 86 | pathname: path.join(__dirname, pathIndex), 87 | protocol: "file:", 88 | slashes: true, 89 | }) 90 | ); 91 | 92 | win.webContents.once('did-finish-load', () => { 93 | win.show(); 94 | }) 95 | 96 | autoUpdater.on('update-available', () => { 97 | log.info("Update available."); 98 | win.webContents.send('update_available') 99 | }) 100 | 101 | autoUpdater.on('update-downloaded', () => { 102 | log.info("Update downloaded"); 103 | win.webContents.send('update_downloaded') 104 | }) 105 | 106 | autoUpdater.on("checking-for-update", function (_arg1) { 107 | log.info("Checking for update..."); 108 | }); 109 | 110 | autoUpdater.on("update-not-available", function (_arg3) { 111 | log.info("Update not available."); 112 | }); 113 | 114 | autoUpdater.on("error", function (err) { 115 | log.info("Error in auto-updater. " + err); 116 | }); 117 | 118 | autoUpdater.on("download-progress", function (progressObj) { 119 | log.info("downloading update"); 120 | }); 121 | 122 | ipcMain.on('restart_app', () => { 123 | autoUpdater.quitAndInstall() 124 | }) 125 | 126 | //win.webContents.openDevTools(); 127 | } 128 | 129 | // Emitted when the window is closed. 130 | win.on("closed", () => { 131 | // Dereference the window object, usually you would store window 132 | // in an array if your app supports multi windows, this is the time 133 | // when you should delete the corresponding element. 134 | win = null; 135 | }); 136 | 137 | return win; 138 | } 139 | 140 | try { 141 | // This method will be called when Electron has finished 142 | // initialization and is ready to create browser windows. 143 | // Some APIs can only be used after this event occurs. 144 | // Added 400 ms to fix the black background issue while using transparent window. More detais at https://github.com/electron/electron/issues/15947 145 | if (!serve) { 146 | app.on("ready", () => setTimeout(createWindow, 400)); 147 | } 148 | 149 | // Quit when all windows are closed. 150 | app.on("window-all-closed", () => { 151 | // On OS X it is common for applications and their menu bar 152 | // to stay active until the user quits explicitly with Cmd + Q 153 | if (process.platform !== "darwin") { 154 | app.quit(); 155 | } 156 | }); 157 | 158 | app.on("activate", () => { 159 | // On OS X it's common to re-create a window in the app when the 160 | // dock icon is clicked and there are no other windows open. 161 | if (win === null) { 162 | createWindow(); 163 | } 164 | }); 165 | 166 | ipcMain.handle('app_details', async (event) => { 167 | autoUpdater.checkForUpdatesAndNotify() 168 | 169 | // find osu installation 170 | let osuPath = "" 171 | const softwareAll = await installed.getAllInstalledSoftware(); 172 | softwareAll.forEach((software: any) => { 173 | if (software.DisplayName == "osu!") { 174 | // remove osu!.exe from path 175 | osuPath = software.DisplayIcon.slice(0, -8); 176 | } 177 | }) 178 | 179 | return { version: app.getVersion(), port: (server.address() as AddressInfo).port, path: osuPath } 180 | }) 181 | 182 | } catch (e) { 183 | // Catch Error 184 | // throw e; 185 | } 186 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "collection-helper", 3 | "version": "1.5.4", 4 | "description": "Standalone, beautiful, feature rich collection manager for osu!", 5 | "homepage": "https://github.com/nzbasic/Collection-Helper-Electron", 6 | "author": { 7 | "name": "James Coppard", 8 | "email": "jamescoppard024@gmail.com" 9 | }, 10 | "keywords": [ 11 | "osu", 12 | "osu!", 13 | "collection", 14 | "angular", 15 | "electron", 16 | "typescript", 17 | "windows", 18 | "mac", 19 | "linux" 20 | ], 21 | "main": "main.js", 22 | "private": true, 23 | "dependencies": { 24 | "@electron/remote": "1.0.4", 25 | "@supercharge/promise-pool": "^1.7.0", 26 | "@types/adm-zip": "^0.4.34", 27 | "@types/cors": "^2.8.10", 28 | "@types/express": "^4.17.12", 29 | "@types/ffmpeg-static": "^3.0.0", 30 | "@types/is-reachable": "^3.1.0", 31 | "adm-zip": "^0.5.5", 32 | "axios": "^0.21.1", 33 | "byte-size": "^8.0.0", 34 | "cors": "^2.8.5", 35 | "electron-log": "^4.3.5", 36 | "electron-updater": "^4.3.9", 37 | "express": "^4.17.1", 38 | "fetch-installed-software": "0.0.6", 39 | "ffmpeg-static": "^4.4.0", 40 | "fluent-ffmpeg": "^2.1.2", 41 | "fs.promises.exists": "^1.0.0", 42 | "is-reachable": "^5.0.0", 43 | "osu-buffer": "^2.0.2", 44 | "random-hash": "^4.0.1", 45 | "sqlite": "^4.0.23", 46 | "sqlite3": "^5.0.2", 47 | "threads": "^1.6.5", 48 | "ts-node": "^10.2.1", 49 | "username": "^5.1.0", 50 | "utf8-string-bytes": "^1.0.3" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/router/beatmaps.ts: -------------------------------------------------------------------------------- 1 | import * as express from "express"; 2 | import { GetBeatmapsReq, GetSelectedReq } from "../../models/beatmaps"; 3 | import { getBeatmaps, getSelected } from "../util/beatmaps"; 4 | import { getTestObjects } from "../util/hitobjects"; 5 | import { beatmapMap } from "../util/parsing/cache"; 6 | import * as log from 'electron-log' 7 | 8 | const router = express.Router(); 9 | 10 | router.route("/").post(async (req, res) => { 11 | //log.info("[API] /beatmaps/ called " + JSON.stringify(req.body)) 12 | let body: GetBeatmapsReq = req.body 13 | let page = await getBeatmaps(body) 14 | res.json(page) 15 | }) 16 | 17 | router.route("/selectedList").post(async (req, res) => { 18 | //log.info("[API] /beatmaps/selectedList called " + JSON.stringify(req.body)) 19 | let body: GetSelectedReq = req.body 20 | let selected = await getSelected(body) 21 | res.json(selected) 22 | }) 23 | 24 | export default router 25 | -------------------------------------------------------------------------------- /app/router/collections.ts: -------------------------------------------------------------------------------- 1 | import { dialog } from "electron"; 2 | import * as express from "express"; 3 | import { addCollection, addMaps, exportCollectionDetails, mergeCollections, removeCollections, removeMaps, renameCollection } from "../util/collections"; 4 | import { collections } from "../util/parsing/collections"; 5 | import { win } from '../main' 6 | import { exportCollection, exportPercentage, importCollection, importPercentage } from "../util/database/collection"; 7 | import { beatmapMap } from "../util/parsing/cache"; 8 | import * as log from 'electron-log' 9 | import { BpmChangerOptions, Collection } from "../../models/collection"; 10 | import { generatePracticeDiffs, generationPercentage } from "../util/practice"; 11 | import { generateBPMChanges, generationBpmProgress } from "../util/bpm"; 12 | 13 | const router = express.Router(); 14 | 15 | router.route("/").get((req, res) => { 16 | //log.info("[API] /collections/ called") 17 | res.json(collections); 18 | }); 19 | 20 | router.route("/add").post(async (req, res) => { 21 | //log.info("[API] /collections/add called") 22 | await addCollection(req.body.name, req.body.hashes) 23 | res.json(collections) 24 | }) 25 | 26 | router.route("/rename").post(async (req, res) => { 27 | //log.info("[API] /collections/rename called " + JSON.stringify(req.body)) 28 | await renameCollection(req.body.oldName, req.body.newName) 29 | res.json(collections) 30 | }) 31 | 32 | router.route("/merge").post(async (req, res) => { 33 | //log.info("[API] /collections/merge called " + JSON.stringify(req.body)) 34 | await mergeCollections(req.body.newName, req.body.names) 35 | res.json(collections) 36 | }) 37 | 38 | router.route("/remove").post(async (req, res) => { 39 | //log.info("[API] /collections/remove called " + JSON.stringify(req.body)) 40 | await removeCollections(req.body) 41 | res.json(collections) 42 | }) 43 | 44 | router.route("/addMaps").post(async (req, res) => { 45 | //log.info("[API] /collections/addMaps called") 46 | const body: { name: string, hashes: string[] } = req.body 47 | await addMaps(body.name, body.hashes) 48 | res.json(collections) 49 | }) 50 | 51 | router.route("/removeMaps").post(async (req, res) => { 52 | //log.info("[API] /collections/removeMaps called " + JSON.stringify(req.body)) 53 | const body: { name: string, hashes: string[] } = req.body 54 | await removeMaps(body.name, body.hashes) 55 | res.json(collections) 56 | }) 57 | 58 | router.route("/exportDetails").post(async (req, res) => { 59 | 60 | const fileName = "collection-details" 61 | const dialogRes = await dialog.showSaveDialog(win, {defaultPath: fileName, filters: [{ name: 'Collection Details', extensions: ['txt']}]}) 62 | 63 | if (!dialogRes.canceled) { 64 | await exportCollectionDetails(req.body.collections, dialogRes.filePath) 65 | } 66 | 67 | res.json(dialogRes) 68 | }) 69 | 70 | let multipleFolderPath = ""; 71 | router.route("/export").post(async (req, res) => { 72 | //log.info("[API] /collections/export called " + JSON.stringify(req.body)) 73 | let fileName: string = req.body.name 74 | let multiple: boolean = req.body.multiple 75 | let last = true 76 | fileName = fileName.replace(/[<>:"/\\|?*]/g, '_') 77 | 78 | let dialogRes: any 79 | if (multiple) { 80 | last = req.body.last 81 | if (multipleFolderPath == "") { 82 | dialogRes = await dialog.showOpenDialog(win, { properties: ['openDirectory'] }) 83 | } else { 84 | dialogRes = { canceled: false } 85 | } 86 | } else { 87 | dialogRes = await dialog.showSaveDialog(win, {defaultPath: fileName, filters: [{ name: 'Collection Database File', extensions: ['db']}]}) 88 | } 89 | 90 | let path: string 91 | if (!dialogRes.canceled) { 92 | if (multiple) { 93 | if (multipleFolderPath == "") { 94 | multipleFolderPath = dialogRes.filePaths[0] 95 | } 96 | 97 | path = multipleFolderPath 98 | if (last) { 99 | multipleFolderPath = "" 100 | } 101 | } else { 102 | path = dialogRes.filePath 103 | } 104 | await exportCollection(req.body.name, req.body.exportBeatmaps, multiple, path, last) 105 | } 106 | 107 | res.json(dialogRes) 108 | }) 109 | 110 | router.route("/import").post(async (req, res) => { 111 | //log.info("[API] /collections/import called " + JSON.stringify(req.body)) 112 | const multiple = req.body.multiple 113 | const options = {properties: [], filters: [{ name: 'Collection Database File', extensions: ['db']}]} 114 | if (multiple) { 115 | options.properties.push('multiSelections') 116 | } 117 | 118 | const dialogRes = await dialog.showOpenDialog(win, options) 119 | let error: void | string 120 | const errors: string[] = [] 121 | if (!dialogRes.canceled) { 122 | for (let i = 0; i < dialogRes.filePaths.length; i++) { 123 | error = await importCollection(dialogRes.filePaths[i], req.body.name, multiple, i==(dialogRes.filePaths.length-1)) 124 | if (typeof error === "string") { 125 | errors.push(error) 126 | } 127 | } 128 | } 129 | 130 | res.json({ collections: collections, errors: errors }) 131 | }) 132 | 133 | router.route("/setCount").post((req, res) => { 134 | //log.info("[API] /collections/setCount called") 135 | const body: { hashes: string[] } = req.body 136 | const setSet = new Set() 137 | let numberInvalid = 0 138 | 139 | let lastFolder = "" 140 | body.hashes.forEach(hash => { 141 | const beatmap = beatmapMap.get(hash) 142 | if (beatmap) { 143 | if (beatmap.setId == -1) { 144 | if (lastFolder == beatmap.folderName) { 145 | return; 146 | } else { 147 | lastFolder = beatmap.folderName 148 | numberInvalid++ 149 | } 150 | } 151 | setSet.add(beatmap.setId) 152 | } 153 | }) 154 | 155 | res.json(setSet.size + numberInvalid) 156 | }) 157 | 158 | router.route("/exportProgress").get((req, res) => { 159 | res.json(exportPercentage) 160 | }) 161 | 162 | router.route("/importProgress").get((req, res) => { 163 | res.json(importPercentage) 164 | }) 165 | 166 | router.route("/generationProgress").get((req, res) => { 167 | res.json(generationPercentage) 168 | }) 169 | 170 | router.route("/bpmGenerationProgress").get((req, res) => { 171 | res.json(generationBpmProgress) 172 | }) 173 | 174 | router.route("/generatePracticeDiffs").post(async (req, res) => { 175 | const body: { collection: Collection, prefLength: number } = req.body 176 | await generatePracticeDiffs(body.collection, body.prefLength) 177 | res.json() 178 | }) 179 | 180 | router.route("/generateBPMChanges").post(async (req, res) => { 181 | const body: { collection: Collection, options: BpmChangerOptions } = req.body 182 | await generateBPMChanges(body.collection, body.options) 183 | res.json() 184 | }) 185 | 186 | export default router 187 | -------------------------------------------------------------------------------- /app/router/filters.ts: -------------------------------------------------------------------------------- 1 | import { readFilters, removeFilters, setCache, updateFilter, writeFilter } from './../util/database/filters'; 2 | import * as express from "express"; 3 | import { CustomFilter } from '../../models/filters'; 4 | import { generateCache, progress, testFilter } from '../util/evaluation'; 5 | import * as log from 'electron-log' 6 | 7 | const router = express.Router(); 8 | 9 | router.route("/add").post(async (req, res) => { 10 | //log.info("[API] /filters/add called " + JSON.stringify(req.body)) 11 | let body: CustomFilter = req.body 12 | await writeFilter(body) 13 | let filters = await readFilters() 14 | res.json(filters) 15 | }) 16 | 17 | router.route("/").get(async (req, res) => { 18 | //log.info("[API] /filters called " + JSON.stringify(req.body)) 19 | let filters = await readFilters() 20 | let clones: CustomFilter[] = [] 21 | 22 | filters.forEach(filter => { 23 | let copy = JSON.parse(JSON.stringify(filter)) 24 | delete(copy.cache) 25 | clones.push(copy) 26 | }) 27 | 28 | res.json(clones) 29 | }) 30 | 31 | router.route("/setCache").post(async (req, res) => { 32 | //log.info("[API] /filters/setCache called " + JSON.stringify(req.body.name)) 33 | let body: {name: string, cache: string[]} = req.body 34 | await setCache(body.name, body.cache) 35 | res.json(true) 36 | }) 37 | 38 | router.route("/testFilter").post(async (req, res) => { 39 | //log.info("[API] /filters/testFilter called " + JSON.stringify(req.body)) 40 | let body: { filter: string, getHitObjects: boolean, name: string } = req.body 41 | res.json(await testFilter(body.filter, body.getHitObjects, body.name)) 42 | }) 43 | 44 | router.route("/generateCache").post(async (req, res) => { 45 | //log.info("[API] /filters/generateCache called " + JSON.stringify(req.body)) 46 | await generateCache(req.body) 47 | let filters = await readFilters() 48 | res.json(filters) 49 | }) 50 | 51 | router.route("/progress").get((req, res) => { 52 | res.json(progress) 53 | }) 54 | 55 | router.route("/remove").post(async (req, res) => { 56 | //log.info("[API] /filters/remove called " + JSON.stringify(req.body)) 57 | await removeFilters(req.body) 58 | let filters = await readFilters() 59 | res.json(filters) 60 | }) 61 | 62 | router.route("/save").post(async (req, res) => { 63 | //log.info("[API] /filters/save called") 64 | let body: { oldName: string, filter: CustomFilter, sameAsOld: boolean } = req.body 65 | await updateFilter(body.oldName, body.filter, body.sameAsOld) 66 | let filters = await readFilters() 67 | res.json(filters) 68 | }) 69 | 70 | export default router 71 | -------------------------------------------------------------------------------- /app/router/load.ts: -------------------------------------------------------------------------------- 1 | import * as express from "express"; 2 | import { loadFiles } from "../util/load"; 3 | import { dialog, shell } from 'electron' 4 | import { getDarkMode, getLanguage, getOsuPath, setDarkMode, setLanguage, setOsuPath, verifyOsuPath } from "../util/database/settings"; 5 | import { win } from '../main' 6 | import * as log from 'electron-log' 7 | import { writeCollections } from "../util/parsing/collections"; 8 | 9 | const router = express.Router(); 10 | router.route("/loadFiles").post(async (req, res) => { 11 | //log.info("[API] /loadFiles called " + JSON.stringify(req.body)) 12 | const result = await loadFiles() 13 | res.json(result); 14 | }); 15 | 16 | router.route("/loadSettings").get(async (req, res) => { 17 | //log.info("[API] /loadSettings called") 18 | const path = await getOsuPath() 19 | const darkMode = await getDarkMode() 20 | const language = await getLanguage() 21 | res.json({ path: path, darkMode: darkMode, code: language }) 22 | }) 23 | 24 | router.route("/setPath").post(async (req, res) => { 25 | //log.info("[API] /setPath called " + JSON.stringify(req.body)) 26 | await setOsuPath(req.body) 27 | res.json(true) 28 | }) 29 | 30 | router.route("/verifyPath").post(async (req, res) => { 31 | //log.info("[API] /verifyPath called " + JSON.stringify(req.body)) 32 | let body: { path: string, mode: string } = req.body 33 | res.json(await verifyOsuPath(body.path, body.mode)) 34 | }) 35 | 36 | router.route("/openUrl").post((req, res) => { 37 | //log.info("[API] /openUrl called " + JSON.stringify(req.body)) 38 | shell.openExternal(req.body.url) 39 | }) 40 | 41 | router.route("/openBrowseDialog").get(async (req, res) => { 42 | //log.info("[API] /openBrowseDialog called " + JSON.stringify(req.body)) 43 | let dialogResult = await dialog.showOpenDialog(win, { properties: ['openDirectory'] }) 44 | res.json(dialogResult) 45 | }) 46 | 47 | router.route("/darkMode").post(async (req, res) => { 48 | //log.info("[API] POST /darkMode called " + JSON.stringify(req.body)) 49 | const body: { mode: boolean } = req.body 50 | await setDarkMode(body.mode) 51 | }) 52 | 53 | router.route("/darkMode").get(async (req, res) => { 54 | //log.info("[API] GET /darkMode called") 55 | const darkMode = await getDarkMode() 56 | res.json({ darkMode: darkMode }) 57 | }) 58 | 59 | router.route("/createBackup").post(async (req, res) => { 60 | //log.info("[API] POST /createBackup called") 61 | const dateTime = await writeCollections(false, true) 62 | res.json(dateTime) 63 | }) 64 | 65 | router.route("/setLanguage").post(async (req, res) => { 66 | //log.info("[API] POST /setLanguage called " + JSON.stringify(req.body)) 67 | const body: { code: string } = req.body 68 | res.json(await setLanguage(body.code)) 69 | }) 70 | 71 | export default router 72 | -------------------------------------------------------------------------------- /app/util/collections.ts: -------------------------------------------------------------------------------- 1 | import { Beatmap } from "../../models/cache"; 2 | import { Collection } from "../../models/collection"; 3 | import { beatmapMap } from "./parsing/cache"; 4 | import { collections, writeCollections } from "./parsing/collections"; 5 | import * as fs from 'fs' 6 | 7 | export const renameCollection = async (oldName: string, newName: string) => { 8 | 9 | for (const collection of collections.collections) { 10 | if (collection.name == oldName) { 11 | collection.name = newName 12 | } 13 | } 14 | await writeCollections() 15 | } 16 | 17 | export const removeCollections = async (names: string[]) => { 18 | 19 | let newCollections: Collection[] = []; 20 | 21 | for (const collection of collections.collections) { 22 | if (!names.includes(collection.name)) { 23 | newCollections.push(collection) 24 | } 25 | } 26 | 27 | collections.collections = newCollections 28 | collections.numberCollections = collections.collections.length 29 | await writeCollections() 30 | } 31 | 32 | export const mergeCollections = async (newName: string, names: string[]) => { 33 | 34 | const newHashes: Set = new Set() 35 | for (const collection of collections.collections) { 36 | if (names.includes(collection.name)) { 37 | for (const hash of collection.hashes) { 38 | newHashes.add(hash) 39 | } 40 | } 41 | } 42 | 43 | await addCollection(newName, Array.from(newHashes)) 44 | } 45 | 46 | export const addCollection = async (name: string, hashes: string[]) => { 47 | const newCollection: Collection = {name: name, numberMaps: hashes.length, hashes: hashes} 48 | collections.collections.push(newCollection) 49 | collections.numberCollections++ 50 | await writeCollections() 51 | } 52 | 53 | export const addMaps = async (name: string, hashes: string[]) => { 54 | const match = collections.collections.filter(collection => collection.name == name) 55 | 56 | if (match.length) { 57 | const collection = match[0] 58 | let combined = collection.hashes.concat(hashes) 59 | 60 | //remove duplicates, there shouldn't be any but just in case 61 | combined = Array.from(new Set(combined)) 62 | 63 | collection.hashes = combined 64 | collection.numberMaps = combined.length 65 | await writeCollections() 66 | } 67 | } 68 | 69 | export const removeMaps = async (name: string, hashes: string[]) => { 70 | const match = collections.collections.filter(collection => collection.name == name) 71 | 72 | if (match.length) { 73 | const collection = match[0] 74 | const newHashes = collection.hashes.filter(hash => !hashes.includes(hash)) 75 | collection.hashes = newHashes 76 | collection.numberMaps = newHashes.length 77 | await writeCollections() 78 | } 79 | } 80 | 81 | export const exportCollectionDetails = async (exporting: Collection[], path: string) => { 82 | 83 | let output = "Collection Details as of " + new Date().toISOString().replace(/:/g, '-') + "\n\n" 84 | 85 | const toExport = exporting.length == 0 ? collections.collections : exporting 86 | 87 | for (const collection of toExport) { 88 | output += collection.name + "\n" 89 | const formatted = collection.hashes.map(hash => formatBeatmap(hash)).sort((a,b) => a.localeCompare(b)) 90 | formatted.forEach(item => output += "- " + item + "\n") 91 | output += "\n" 92 | } 93 | 94 | fs.writeFile(path, output, () => {}) 95 | } 96 | 97 | const formatBeatmap = (hash: string): string => { 98 | const beatmap = beatmapMap.get(hash) 99 | 100 | if (!beatmap) { 101 | return "Unknown hash: " + hash 102 | } else { 103 | return beatmap.artist + " - " + beatmap.song + " [" + beatmap.difficulty + "]" 104 | } 105 | } 106 | 107 | 108 | -------------------------------------------------------------------------------- /app/util/database/filterScripts.ts: -------------------------------------------------------------------------------- 1 | export const stream = ` 2 | const burstCount = 5 3 | const minimumPercentage = 20 4 | const minimumBpm = 140 5 | const maximumTimeDifference = Math.ceil((1/(minimumBpm*4/60))*1000) 6 | 7 | const filtered = beatmaps.filter(beatmap => { 8 | const bpms = convertTimingBpm(beatmap.timingPoints) 9 | 10 | if (!bpms.length) { 11 | return false 12 | } 13 | 14 | if (beatmap.bpm <= minimumBpm) { 15 | return false 16 | } 17 | 18 | let currentBurstCount = 0 19 | let maxBurstCount = 1 20 | let totalBurstNotes = 0 21 | let lastNoteTime 22 | beatmap.hitObjects.forEach(hits => { 23 | 24 | // if object is a circle 25 | if (hits.type & 1 == 1) { 26 | 27 | // if a burst hasnt started, do no checks and begin the current burst on this object 28 | if (currentBurstCount == 0) { 29 | 30 | // for the first circle, we need to set this variable 31 | lastNoteTime = hits.time 32 | currentBurstCount = 1 33 | 34 | } else { 35 | // this is the second circle in a row, so we need to check if is <= 1/4 timing away from the last note 36 | 37 | // bpm logic: we need to keep track of the current bpm at any time for 1/4 comparisons. 38 | // T = timing point, C = circle 39 | // Two cases: 40 | // ___T1__C1___T2___C2____T3_____ 41 | // ___T1_________C1______________ 42 | // To avoid constant checking for each timing point, if the current circle is past the 2ND last timing point, remove it from the bpm array. 43 | // This way we get O(1) bpm checking, the current bpm will always be the first bpm in the array. 44 | if (bpms.length >= 2) { 45 | if (hits.time > bpms[0].time && hits.time > bpms[1].time) { 46 | bpms.shift() 47 | } 48 | } 49 | const bpm = bpms[0].bpm 50 | 51 | // 1/4 time calculation in ms 52 | // bpm * 4 = notes per minute.. / 60 = notes per second.. 1/ans = seconds per note.. * 1000 = ms per note 53 | const calculatedTimeDifference = Math.ceil((1/(bpm*4/60))*1000)*1.1 54 | 55 | // if last note is within this time period, count it as a burst 56 | const timeDifference = hits.time - lastNoteTime 57 | if (timeDifference <= calculatedTimeDifference && timeDifference < maximumTimeDifference) { 58 | currentBurstCount++ 59 | 60 | // set as max burst length if greater than current 61 | if (currentBurstCount > maxBurstCount) { 62 | maxBurstCount = currentBurstCount 63 | maxBurstEndsAt = hits.time 64 | } 65 | 66 | // keep track of total notes in bursts 67 | if (currentBurstCount == burstCount) { 68 | totalBurstNotes += burstCount 69 | } else if (currentBurstCount > burstCount) { 70 | totalBurstNotes++ 71 | } 72 | } else { 73 | currentBurstCount = 0 74 | } 75 | // finally, keep track of last note time 76 | lastNoteTime = hits.time 77 | } 78 | } else { 79 | currentBurstCount = 0 80 | lastNoteTime = hits.time 81 | } 82 | }) 83 | return (totalBurstNotes/beatmap.hitObjects.length)*100 >= minimumPercentage 84 | }) 85 | 86 | function convertTimingBpm(timingPoints) { 87 | 88 | const bpmList = [] 89 | 90 | timingPoints.forEach(point => { 91 | if (point.inherited) { 92 | bpmList.push({bpm: Math.round(60000 / point.bpm), time: point.offset}) 93 | } 94 | }) 95 | 96 | return bpmList 97 | } 98 | 99 | resolve(filtered) 100 | ` 101 | 102 | export const farm = ` 103 | const filtered = beatmaps.filter(beatmap => farmSets.includes(beatmap.setId.toString())) 104 | 105 | resolve(filtered) 106 | ` 107 | -------------------------------------------------------------------------------- /app/util/database/filters.ts: -------------------------------------------------------------------------------- 1 | import { CustomFilter } from "../../../models/filters"; 2 | import { getDb } from "./database"; 3 | 4 | export const writeFilter = async (filter: CustomFilter) => { 5 | 6 | const database = await getDb(); 7 | let buf = Buffer.from(JSON.stringify(filter.cache)) 8 | 9 | await database.run("INSERT INTO filters (name, filter, description, gethitobjects, iscached, cache) VALUES (:name, :filter, :description, :gethitobjects, :iscached, :cache)", { 10 | ":name": filter.name, 11 | ":filter": filter.filter, 12 | ":description": filter.description, 13 | ":gethitobjects": filter.getHitObjects, 14 | ":iscached": filter.isCached, 15 | ":cache": buf 16 | }); 17 | } 18 | 19 | export const updateFilter = async (oldName: string, filter: CustomFilter, sameAsOld: boolean) => { 20 | const database = await getDb(); 21 | 22 | await database.run("UPDATE filters SET name=$1, description=$2, filter=$3, gethitobjects=$4, iscached=$5, cache=$6 WHERE name=$7", { 23 | $1: filter.name, 24 | $2: filter.description, 25 | $3: filter.filter, 26 | $4: filter.getHitObjects, 27 | $5: sameAsOld, 28 | $6: sameAsOld ? Buffer.from(JSON.stringify(filter.cache)??[]) : Buffer.from(JSON.stringify([])), 29 | $7: oldName 30 | }) 31 | 32 | } 33 | 34 | export const setCache = async (name: string, cache: string[]) => { 35 | 36 | const database = await getDb(); 37 | const row = await database.get("SELECT * FROM filters WHERE name=$1", { $1: name }); 38 | 39 | let buf = Buffer.from(JSON.stringify(cache)) 40 | 41 | if (row) { 42 | await database.run("UPDATE filters SET iscached=$1, cache=$2 WHERE name=$3", { $1: true, $2: buf, $3: name }) 43 | } 44 | 45 | } 46 | 47 | export const readFilters = async (): Promise => { 48 | 49 | const database = await getDb(); 50 | const rows = await database.all("SELECT * FROM filters"); 51 | 52 | const output: CustomFilter[] = [] 53 | rows.forEach(row => { 54 | const text = JSON.parse(row.cache.length > 0 ? row.cache.toString() : "[]") 55 | const number = text.length 56 | const getHitObjects = row.gethitobjects == 1 57 | const isCached = row.iscached == 1 58 | const filter: CustomFilter = {name: row.name, filter: row.filter, description: row.description, getHitObjects: getHitObjects, isCached: isCached, numberCached: number, cache: text} 59 | output.push(filter) 60 | }) 61 | 62 | return output 63 | } 64 | 65 | export const removeFilters = async (names: string[]) => { 66 | const database = await getDb(); 67 | for (const name of names) { 68 | await database.run("DELETE FROM filters WHERE name=$1", { $1: name }) 69 | } 70 | } 71 | 72 | export const updateFarmFallback = async (farm: string[]) => { 73 | const database = await getDb(); 74 | await database.run("DELETE FROM farmfallback") 75 | await database.run("BEGIN TRANSACTION") 76 | for (const setId of farm) { 77 | await database.run("INSERT INTO farmfallback (setId) VALUES (?)", [setId]) 78 | } 79 | await database.run("COMMIT") 80 | } 81 | 82 | export const getFarmFallback = async (): Promise => { 83 | const database = await getDb(); 84 | const rows = await database.all("SELECT * FROM farmfallback"); 85 | const output: string[] = [] 86 | rows.forEach(row => { 87 | output.push(row.setId) 88 | }) 89 | return output 90 | } 91 | 92 | -------------------------------------------------------------------------------- /app/util/database/settings.ts: -------------------------------------------------------------------------------- 1 | import { getDb } from "../database/database"; 2 | import * as fs from 'fs' 3 | 4 | export const getOsuPath = async (): Promise => { 5 | const database = await getDb(); 6 | const row = await database.get("SELECT * FROM osupath"); 7 | return row?.path??"" 8 | }; 9 | 10 | export const addOrUpdateOsuPath = async (path: string): Promise => { 11 | const database = await getDb(); 12 | const row = await database.get("SELECT * FROM osupath"); 13 | if (row) { 14 | await changeOsuPath(path); 15 | } else { 16 | await setOsuPath(path) 17 | } 18 | } 19 | 20 | export const setOsuPath = async (path: string): Promise => { 21 | const database = await getDb(); 22 | await database.run("INSERT INTO osupath (path) VALUES (:path)", { 23 | ":path": path, 24 | }); 25 | }; 26 | 27 | const changeOsuPath = async (path: string): Promise => { 28 | const database = await getDb(); 29 | await database.run("UPDATE osupath SET path = :path", { 30 | ":path": path, 31 | }); 32 | } 33 | 34 | export const verifyOsuPath = async (path: string, mode: string): Promise => { 35 | if (fs.existsSync(path + "/osu!.db")) { 36 | await addOrUpdateOsuPath(path) 37 | return true 38 | } else { 39 | return false 40 | } 41 | } 42 | 43 | export const setDarkMode = async (mode: boolean) => { 44 | const database = await getDb(); 45 | 46 | const rows = await database.get("SELECT * FROM darkmode") 47 | if (!rows) { 48 | await database.run("INSERT INTO darkmode (mode) VALUES (?)", [mode]) 49 | } else { 50 | await database.run("UPDATE darkmode SET mode = ?", [mode]) 51 | } 52 | } 53 | 54 | export const getDarkMode = async () => { 55 | const database = await getDb(); 56 | const row = await database.get("SELECT * FROM darkmode"); 57 | return row?.mode??false 58 | } 59 | 60 | export const getLanguage = async () => { 61 | const database = await getDb(); 62 | const row = await database.get("SELECT * FROM language"); 63 | return row?.code??'en' 64 | } 65 | 66 | export const setLanguage = async (code: string) => { 67 | const database = await getDb(); 68 | const rows = await database.get("SELECT * FROM language") 69 | if (!rows) { 70 | await database.run("INSERT INTO language (code) VALUES (?)", [code]) 71 | } else { 72 | await database.run("UPDATE language SET code = ?", [code]) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /app/util/evaluation.ts: -------------------------------------------------------------------------------- 1 | import { CustomFilter } from "../../models/filters" 2 | import { Beatmap } from "../../models/cache" 3 | import { getTestObjects } from "./hitobjects" 4 | import { beatmapMap } from './parsing/cache' 5 | import axios from 'axios' 6 | import { readBeatmap } from "./parsing/hitobjects" 7 | import { getFarmFallback, readFilters, setCache, updateFarmFallback } from "./database/filters" 8 | import { getOsuPath } from "./database/settings" 9 | import { collections } from "./parsing/collections" 10 | import { Collection } from "../../models/collection" 11 | import * as dns from 'dns' 12 | import * as isReachable from 'is-reachable' 13 | 14 | let lastName: string 15 | let randomBeatmaps: Beatmap[] 16 | let storedBeatmaps: Beatmap[] 17 | let hitObjectsLoaded = false 18 | const args = ['resolve', 'beatmaps', 'axios', 'farmSets'] 19 | 20 | export let progress = 0 21 | 22 | export const testFilter = async (filter: string, getHitObjects: boolean, name: string) => { 23 | 24 | let userFilter: Function 25 | let beatmaps: Beatmap[] 26 | let collection: Collection 27 | 28 | if (lastName != name) { 29 | hitObjectsLoaded = false 30 | } 31 | 32 | try { 33 | userFilter = new Function(...args, filter); 34 | } catch(error) { 35 | return { filteredText: error.message, numberTested: 0 } 36 | } 37 | 38 | if (name) { 39 | collection = collections.collections.find(item => item.name == name) 40 | randomBeatmaps = collection.hashes.map(item => beatmapMap.get(item)).sort(() => 0.5-Math.random()).slice(0,1000) 41 | } 42 | 43 | if (!randomBeatmaps || (name == "" && lastName != "")) { 44 | randomBeatmaps = Array.from(beatmapMap.values()).sort(() => 0.5-Math.random()).slice(0,1000) 45 | } 46 | 47 | if (!getHitObjects && !hitObjectsLoaded) { 48 | storedBeatmaps = randomBeatmaps 49 | } 50 | 51 | if (lastName == name) { 52 | if (getHitObjects && !hitObjectsLoaded) { 53 | storedBeatmaps = await getTestObjects(randomBeatmaps) 54 | hitObjectsLoaded = true 55 | } 56 | beatmaps = storedBeatmaps 57 | } else { 58 | lastName = name 59 | if (getHitObjects) { 60 | storedBeatmaps = await getTestObjects(randomBeatmaps) 61 | hitObjectsLoaded = true 62 | } 63 | beatmaps = storedBeatmaps 64 | } 65 | 66 | const farm = await getFarmSets() 67 | let filteredText: string 68 | const filtered = await waitEval(userFilter, beatmaps, farm); 69 | 70 | if (typeof(filtered) == "string") { 71 | filteredText = filtered 72 | } else { 73 | try { 74 | filteredText = JSON.stringify(filtered.filter(item => item).map(({artist, song, creator, difficulty, bpm}) => ({artist, song, creator, difficulty, bpm})), null, 2) 75 | } catch(error) { 76 | filteredText = error.message 77 | } 78 | } 79 | 80 | return { filteredText: filteredText, numberTested: randomBeatmaps.length } 81 | } 82 | 83 | export const generateCache = async (names: string[]) => { 84 | 85 | const path = await getOsuPath() 86 | const userFilters = new Map() 87 | const cache = new Map() 88 | const filters = await readFilters() 89 | let getHitObjects = false 90 | 91 | names.forEach(filterName => { 92 | const filter = filters.find(filter => filter.name == filterName) 93 | if (filter) { 94 | userFilters.set(filterName, (new Function(...args, filter.filter))) 95 | if (filter.getHitObjects) { 96 | // if one of them needs objects, get them for all 97 | getHitObjects = true 98 | } 99 | } 100 | }) 101 | 102 | const farm = await getFarmSets() 103 | 104 | const beatmaps = Array.from(beatmapMap.values()) 105 | const pages = beatmaps.length / 1000 106 | for (let i = 0; i < pages; i++) { 107 | progress = ((i)/pages) * 100 108 | 109 | const lowerBound = i*1000 110 | const upperBound = (i+1)*1000 111 | let slice = beatmaps.slice(lowerBound, upperBound) 112 | 113 | if (getHitObjects) { 114 | //await new Promise((res, rej) => setTimeout(() => res(), 10000)) 115 | slice = await Promise.all(slice.map((beatmap) => readBeatmap(beatmap, path))) 116 | } 117 | 118 | // generate hashes for each filter given 119 | for (const [name, filter] of userFilters) { 120 | let beatmapObjects = await waitEval(filter, slice, farm) 121 | if (typeof(beatmapObjects) != "string") { 122 | cache.set(name, (cache.get(name)??[]).concat(beatmapObjects.map(beatmap => beatmap.md5))) 123 | } 124 | } 125 | } 126 | 127 | for (const [name, filter] of userFilters) { 128 | await setCache(name, cache.get(name)??[]) 129 | } 130 | 131 | progress = 0 132 | 133 | } 134 | 135 | /** 136 | * Uses promises to wait for user code to finish evaluating. Returns either result beatmaps or error string. 137 | */ 138 | const waitEval = (func, beatmaps, farm): Promise => { 139 | return new Promise(async (res, rej) => { 140 | try { 141 | // resolve inside eval 142 | func(res, beatmaps, axios, farm) 143 | } catch(error) { 144 | res(error.message) 145 | } 146 | }) 147 | } 148 | 149 | const getFarmSets = async (): Promise => { 150 | const isConnected = await isReachable("osutracker.com", { timeout: 1000 }) 151 | let farm: string[] = [] 152 | 153 | if (isConnected) { 154 | farm = (await axios.get("https://osutracker.com/api/stats/farmSets")).data 155 | updateFarmFallback(farm) 156 | } else { 157 | farm = await getFarmFallback() 158 | } 159 | 160 | return farm 161 | } 162 | -------------------------------------------------------------------------------- /app/util/hitobjects.ts: -------------------------------------------------------------------------------- 1 | import { Beatmap } from "../../models/cache"; 2 | import { getOsuPath } from "./database/settings"; 3 | import { beatmapMap } from "./parsing/cache"; 4 | import { readBeatmap } from "./parsing/hitobjects"; 5 | 6 | export const getTestObjects = async (toGet: Beatmap[]): Promise => { 7 | const path = await getOsuPath() 8 | const output = await Promise.all(toGet.map((beatmap) => readBeatmap(beatmap, path))); 9 | return output 10 | } 11 | -------------------------------------------------------------------------------- /app/util/load.ts: -------------------------------------------------------------------------------- 1 | import { readCollections, writeCollections } from "./parsing/collections"; 2 | import { getOsuPath } from "./database/settings"; 3 | import { readCache } from "./parsing/cache"; 4 | import * as username from 'username' 5 | import * as fs from 'fs' 6 | import * as log from 'electron-log' 7 | import { exportIds } from './snipetool' 8 | 9 | export let externalStorage: string 10 | 11 | export const loadFiles = async () => { 12 | const path = await getOsuPath(); 13 | 14 | if (!fs.existsSync(path + "/collection.db")) { 15 | return -1 16 | } 17 | 18 | externalStorage = await findExternalBeatmapStorage(path) 19 | await readCollections(path); 20 | await writeCollections(true); 21 | await readCache(path); 22 | 23 | return 0 24 | }; 25 | 26 | const findExternalBeatmapStorage = async (path: string): Promise => { 27 | const user = await username() 28 | 29 | if (user) { 30 | try { 31 | const file = await fs.promises.readFile(path + "/osu!." + user + ".cfg") 32 | const lines = file.toString("utf8").split(/\r?\n/); 33 | 34 | for (const line of lines) { 35 | if (line.startsWith("BeatmapDirectory")) { 36 | const index = line.indexOf("="); 37 | const value = line.substring(index + 1).trim() 38 | 39 | if (value.toLowerCase() != "songs") { 40 | return value; 41 | } 42 | } 43 | } 44 | } catch(err) { 45 | log.error(err) 46 | } 47 | } 48 | 49 | return undefined 50 | } 51 | -------------------------------------------------------------------------------- /app/util/mods.ts: -------------------------------------------------------------------------------- 1 | const modsDict = new Map(); 2 | 3 | modsDict.set("ez", 1) 4 | modsDict.set("td", 2) 5 | modsDict.set("hd", 3) 6 | modsDict.set("hr", 4) 7 | modsDict.set("dt", 6) 8 | modsDict.set("ht", 8) 9 | modsDict.set("nc", 9) 10 | modsDict.set("fl", 10) 11 | modsDict.set("4k", 15) 12 | modsDict.set("5k", 16) 13 | modsDict.set("6k", 17) 14 | modsDict.set("7k", 18) 15 | modsDict.set("8k", 19) 16 | modsDict.set("fi", 20) 17 | modsDict.set("rd", 21) 18 | modsDict.set("cn", 22) 19 | modsDict.set("tg", 23) 20 | modsDict.set("9k", 24) 21 | modsDict.set("ck", 25) 22 | modsDict.set("1k", 26) 23 | modsDict.set("3k", 27) 24 | modsDict.set("2k", 28) 25 | modsDict.set("v2", 29) 26 | modsDict.set("mr", 30) 27 | 28 | export function convertMods(mods: string[]) { 29 | let result = 0; 30 | mods.forEach(mod => { 31 | // best nullish coalesce ever done 32 | result += Math.pow(2, modsDict.get(mod)??-Infinity); 33 | }); 34 | return result; 35 | } 36 | 37 | -------------------------------------------------------------------------------- /app/util/parsing/collections.ts: -------------------------------------------------------------------------------- 1 | import { Collections, Collection } from "../../../models/collection"; 2 | import { OsuReader, OsuWriter } from "osu-buffer"; 3 | import * as fs from "fs"; 4 | import { getOsuPath } from "../database/settings"; 5 | import { readNameUtf8, writeNameUtf8 } from "./utf8"; 6 | import { stringToUtf8ByteArray } from 'utf8-string-bytes' 7 | import * as log from 'electron-log' 8 | 9 | export let collections: Collections; 10 | 11 | /** 12 | * Reads a binary collections file to memory 13 | * @param path Path to collection.db file 14 | */ 15 | export const readCollections = async (path: string) => { 16 | const buffer = await fs.promises.readFile(path + "\\collection.db"); 17 | const reader = new OsuReader(buffer.buffer); 18 | 19 | collections = { 20 | version: reader.readInt32(), 21 | numberCollections: reader.readInt32(), 22 | collections: [], 23 | }; 24 | 25 | for (let colIdx = 0; colIdx < collections.numberCollections; colIdx++) { 26 | const collection: Collection = { 27 | name: readNameUtf8(reader), 28 | numberMaps: reader.readInt32(), 29 | hashes: [], 30 | } 31 | 32 | for (let mapIdx = 0; mapIdx < collection.numberMaps; mapIdx++) { 33 | collection.hashes.push(reader.readString()); 34 | } 35 | 36 | collections.collections.push(collection); 37 | } 38 | }; 39 | 40 | /** 41 | * Writes the current collections to disk 42 | */ 43 | export const writeCollections = async (initialBackup?: boolean, newBackup?: boolean) => { 44 | 45 | const osuPath = await getOsuPath() 46 | let writer: OsuWriter; 47 | 48 | try { 49 | const length = calculateLength(); 50 | const arrayBuffer = new ArrayBuffer(length); 51 | writer = new OsuWriter(arrayBuffer); 52 | 53 | writer.writeInt32(collections.version); 54 | writer.writeInt32(collections.numberCollections); 55 | 56 | collections.collections.forEach((collection) => { 57 | writeNameUtf8(writer, collection.name); 58 | writer.writeInt32(collection.numberMaps); 59 | 60 | collection.hashes.forEach((hash) => { 61 | writer.writeString(hash); 62 | }); 63 | }); 64 | 65 | } catch(err) { 66 | log.error(err) 67 | } 68 | 69 | const buffer = Buffer.from(writer.buff); 70 | 71 | let path = "" 72 | if (initialBackup) { 73 | path = osuPath + "/collectionBackup.db" 74 | try { 75 | await fs.promises.stat(path) 76 | } catch { 77 | await fs.promises.writeFile(path, buffer); 78 | } 79 | } else if (newBackup) { 80 | const dateTime = new Date().toISOString().replace(/:/g, '-') 81 | await fs.promises.writeFile(osuPath + "/collection" + dateTime + ".db", buffer) 82 | return dateTime 83 | } else { 84 | path = osuPath + "/collection.db" 85 | await fs.promises.writeFile(path, buffer); 86 | } 87 | }; 88 | 89 | /** 90 | * Calculates the length of the given collections in bytes for use in a buffer 91 | * @param collections Collections to calculate 92 | * @returns Byte length 93 | */ 94 | const calculateLength = (): number => { 95 | // starts at 8 for version + numberCollections 96 | let count = 8; 97 | 98 | collections.collections.forEach((collection) => { 99 | // 1 byte for empty name, length+2 for anything else 100 | if (collection.name == "") { 101 | count += 1; 102 | } else { 103 | count += stringToUtf8ByteArray(collection.name).length + 2; 104 | } 105 | 106 | // 4 bytes for numberMaps 107 | count += 4; 108 | 109 | // 34 bytes for each hash 110 | count += 34 * collection.numberMaps; 111 | }); 112 | 113 | return count; 114 | }; 115 | 116 | -------------------------------------------------------------------------------- /app/util/parsing/hitobjects.ts: -------------------------------------------------------------------------------- 1 | import { Beatmap, HitObject } from "../../../models/cache"; 2 | import * as fs from "graceful-fs"; 3 | import { promisify } from "util"; 4 | import * as log from 'electron-log' 5 | import { externalStorage } from '../load' 6 | 7 | const readFile = promisify(fs.readFile); 8 | 9 | export const readBeatmap = async (beatmap: Beatmap, osuPath: string): Promise => { 10 | 11 | const songsPath = externalStorage ? (externalStorage + "/") : (osuPath + "/Songs/") 12 | const path = songsPath + beatmap.folderName + "/" + beatmap.fileName 13 | 14 | try { 15 | await fs.promises.stat(path) 16 | const buffer = await readFile(path); 17 | const lines = buffer.toString("utf8").split(/\r?\n/); 18 | 19 | const objects: HitObject[] = []; 20 | let flag = false; 21 | 22 | lines.forEach((line) => { 23 | if (flag && line != "") { 24 | const contents = line.split(","); 25 | if (contents.length < 4) { 26 | return; 27 | } 28 | 29 | const x = parseInt(contents[0]); 30 | const y = parseInt(contents[1]); 31 | const time = parseInt(contents[2]); 32 | const type = parseInt(contents[3]); 33 | objects.push({ x: x, y: y, time: time, type: type }); 34 | } 35 | 36 | if (line == "[HitObjects]") { 37 | flag = true; 38 | } 39 | }); 40 | 41 | // dont want to write hitobjects to the cache 42 | const copy = JSON.parse(JSON.stringify(beatmap)) 43 | copy.hitObjects = objects 44 | 45 | return copy; 46 | } catch (err) { 47 | log.warn("Could not load hitobjects of " + beatmap.setId + " " + beatmap.artist + " - " + beatmap.song + " [" + beatmap.difficulty + "]"); 48 | beatmap.hitObjects = [] 49 | return beatmap; 50 | } 51 | }; 52 | 53 | 54 | -------------------------------------------------------------------------------- /app/util/parsing/utf8.ts: -------------------------------------------------------------------------------- 1 | import { OsuReader, OsuWriter } from 'osu-buffer'; 2 | import { utf8ByteArrayToString, stringToUtf8ByteArray } from 'utf8-string-bytes' 3 | 4 | export const readNameUtf8 = (reader: OsuReader): string => { 5 | const byte = reader.readBytes(1); 6 | if (byte[0] !== 0) { 7 | const length = reader.read7bitInt(); 8 | const bytes = reader.readBytes(length); 9 | return utf8ByteArrayToString(bytes) 10 | } else { 11 | return ""; 12 | } 13 | } 14 | 15 | export const writeNameUtf8 = (writer: OsuWriter, name: string): void => { 16 | if (name == "") { 17 | writer.writeUint8(0); 18 | } else { 19 | writer.writeUint8(11); 20 | const bytes = stringToUtf8ByteArray(name) 21 | writer.write7bitInt(bytes.length); 22 | writer.writeBytes(bytes); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /electron-builder.json: -------------------------------------------------------------------------------- 1 | { 2 | "asar": false, 3 | "directories": { 4 | "output": "release/" 5 | }, 6 | "files": [ 7 | "**/*", 8 | "!**/*.ts", 9 | "!*.map", 10 | "!package.json", 11 | "!package-lock.json" 12 | ], 13 | "extraResources": [ 14 | { 15 | "from": "dist", 16 | "to": "app", 17 | "filter": [ 18 | "**/*" 19 | ] 20 | } 21 | ], 22 | "win": { 23 | "icon": "dist/assets/icons", 24 | "target": [ 25 | "portable", 26 | "nsis" 27 | ] 28 | }, 29 | "portable": { 30 | "splashImage": "dist/assets/icons/splash.png" 31 | }, 32 | "mac": { 33 | "icon": "dist/assets/icons", 34 | "target": [ 35 | "dmg" 36 | ] 37 | }, 38 | "linux": { 39 | "icon": "dist/assets/icons", 40 | "target": [ 41 | "AppImage" 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /models/beatmaps.ts: -------------------------------------------------------------------------------- 1 | import { Beatmap } from "./cache"; 2 | import { CustomFilter } from "./filters"; 3 | 4 | export interface FilterDetail { 5 | type?: string 6 | filtering?: string 7 | valueString?: string 8 | valueNumber?: number 9 | operator?: string 10 | } 11 | 12 | export interface Filter { 13 | text?: string 14 | filters?: FilterDetail[] 15 | mods?: string[] 16 | } 17 | 18 | export interface Sorting { 19 | header?: string 20 | order?: string 21 | } 22 | 23 | export interface PageResponse { 24 | beatmaps: Beatmap[] 25 | numberResults: number 26 | } 27 | 28 | export interface SetCount { 29 | setId: string 30 | count: number 31 | } 32 | 33 | export interface GetBeatmapsReq { 34 | page: number 35 | filter: Filter 36 | name: string 37 | force: boolean 38 | order: Sorting 39 | getCollection: boolean 40 | customFilters?: string[] 41 | infiniteScroll?: boolean 42 | } 43 | 44 | export interface GetSelectedReq { 45 | name: string 46 | filter: Filter 47 | customFilters?: string[] 48 | force: boolean 49 | } 50 | -------------------------------------------------------------------------------- /models/cache.ts: -------------------------------------------------------------------------------- 1 | export interface Beatmap { 2 | artist?: string; 3 | artistUnicode?: string; 4 | song?: string; 5 | songUnicode?: string; 6 | creator?: string; 7 | difficulty?: string; 8 | audioFile?: string; 9 | md5: string; 10 | fileName?: string; 11 | status?: number; 12 | circleNumber?: number; 13 | sliderNumber?: number; 14 | spinnerNumber?: number; 15 | modified?: bigint; 16 | ar?: number; 17 | ogAr?: number; 18 | cs?: number; 19 | ogCs?: number; 20 | hp?: number; 21 | ogHp?: number; 22 | od?: number; 23 | ogOd?: number; 24 | sr?: number; 25 | bpm?: number; 26 | ogBpm?: number; 27 | sliderVelocity?: number; 28 | standardDiffs?: IntDoublePair[]; 29 | taikoDiffs?: IntDoublePair[]; 30 | catchDiffs?: IntDoublePair[]; 31 | maniaDiffs?: IntDoublePair[]; 32 | drain?: number; 33 | ogDrain?: number; 34 | time?: number; 35 | previewTime?: number; 36 | timingPoints?: TimingPoint[]; 37 | id?: number; 38 | setId: number; 39 | threadId?: number; 40 | standardRank?: number; 41 | taikoRank?: number; 42 | catchRank?: number; 43 | maniaRank?: number; 44 | localOffset?: number; 45 | stackLeniency?: number; 46 | mode?: number; 47 | songSource?: string; 48 | songTags?: string; 49 | onlineOffset?: number; 50 | font?: string; 51 | unplayed?: boolean; 52 | timeLastPlayed?: bigint; 53 | osz2?: boolean; 54 | folderName?: string; 55 | timeChecked?: bigint; 56 | ignoreSound?: boolean; 57 | ignoreSkin?: boolean; 58 | disableStory?: boolean; 59 | disableVideo?: boolean; 60 | visualOverride?: boolean; 61 | lastModified?: number; 62 | scrollSpeed?: number; 63 | hitObjects?: HitObject[]; 64 | missing?: boolean; 65 | } 66 | 67 | export interface IntDoublePair { 68 | mods: number; 69 | stars: number; 70 | } 71 | 72 | export interface TimingPoint { 73 | bpm: number; 74 | offset: number; 75 | inherited: boolean; 76 | } 77 | 78 | export interface HitObject { 79 | x: number; 80 | y: number; 81 | time: number; 82 | type: number; 83 | } 84 | -------------------------------------------------------------------------------- /models/collection.ts: -------------------------------------------------------------------------------- 1 | export interface Collections { 2 | version: number; 3 | numberCollections: number; 4 | collections: Collection[]; 5 | } 6 | 7 | export interface Collection { 8 | name: string; 9 | numberMaps: number; 10 | hashes: string[]; 11 | } 12 | 13 | export interface MissingMap { 14 | setId: number; 15 | md5: string 16 | } 17 | 18 | export interface Override { 19 | value: number; 20 | enabled: boolean 21 | } 22 | export interface BpmChangerOptions { 23 | bpm: Override 24 | ar: Override 25 | hp: Override 26 | cs: Override 27 | od: Override 28 | } 29 | -------------------------------------------------------------------------------- /models/filters.ts: -------------------------------------------------------------------------------- 1 | export interface CustomFilter { 2 | name: string, 3 | filter: string, 4 | description: string, 5 | getHitObjects: boolean, 6 | isCached: boolean, 7 | numberCached: number, 8 | cache?: string[] 9 | } 10 | -------------------------------------------------------------------------------- /models/settings.ts: -------------------------------------------------------------------------------- 1 | export interface Settings { 2 | osuPath: string; 3 | apiKey?: string; 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "collection-helper", 3 | "version": "1.5.4", 4 | "description": "Standalone, beautiful, feature rich collection manager for osu!", 5 | "homepage": "https://github.com/nzbasic/Collection-Helper-Electron", 6 | "author": { 7 | "name": "James Coppard", 8 | "email": "jamescoppard024@gmail.com" 9 | }, 10 | "keywords": [ 11 | "osu", 12 | "osu!", 13 | "collection", 14 | "angular", 15 | "electron", 16 | "typescript" 17 | ], 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/nzbasic/Collection-Helper.git" 21 | }, 22 | "build": { 23 | "directories": { 24 | "buildResources": "resources", 25 | "output": "release/" 26 | }, 27 | "extraResources": [ 28 | { 29 | "from": "dist", 30 | "to": "app", 31 | "filter": [ 32 | "**/*" 33 | ] 34 | } 35 | ], 36 | "productName": "Collection Helper", 37 | "buildVersion": "1.0.1", 38 | "nsis": { 39 | "perMachine": true, 40 | "allowToChangeInstallationDirectory": true, 41 | "oneClick": false 42 | }, 43 | "win": { 44 | "publish": [ 45 | "github" 46 | ] 47 | }, 48 | "files": [ 49 | "**/*", 50 | "!**/*.ts", 51 | "!*.map", 52 | "!package.json", 53 | "!package-lock.json" 54 | ] 55 | }, 56 | "main": "app/main.js", 57 | "private": true, 58 | "scripts": { 59 | "postinstall": "electron-builder install-app-deps", 60 | "ng": "ng", 61 | "start": "npm-run-all -p electron:serve ng:serve", 62 | "build": "npm run electron:serve-tsc && ng build --base-href ./", 63 | "build:dev": "npm run build -- -c dev", 64 | "build:prod": "npm run build -- -c production", 65 | "ng:serve": "ng serve -c web -o", 66 | "electron:serve-tsc": "tsc -p tsconfig.serve.json", 67 | "electron:serve": "wait-on tcp:4200 && npm run electron:serve-tsc && npx electron . --serve", 68 | "electron:local": "npm run build:prod && npx electron .", 69 | "electron:build": "npm run build:prod && electron-builder build --publish=never", 70 | "electron:deploy": "npm run build:prod && electron-builder build --win --publish=always", 71 | "test": "ng test --watch=false", 72 | "test:watch": "ng test", 73 | "e2e": "npm run build:prod && cross-env TS_NODE_PROJECT='e2e/tsconfig.e2e.json' mocha --timeout 300000 --require ts-node/register e2e/**/*.e2e.ts", 74 | "version": "conventional-changelog -i CHANGELOG.md -s -r 0 && git add CHANGELOG.md", 75 | "lint": "ng lint" 76 | }, 77 | "dependencies": { 78 | "@angular-slider/ngx-slider": "^2.0.3", 79 | "@angular/animations": "12.1.1", 80 | "@angular/cdk": "12.1.1", 81 | "@angular/common": "12.0.5", 82 | "@angular/compiler": "12.0.5", 83 | "@angular/core": "12.0.5", 84 | "@angular/forms": "12.0.5", 85 | "@angular/language-service": "12.0.5", 86 | "@angular/material": "12.1.1", 87 | "@angular/platform-browser": "12.0.5", 88 | "@angular/platform-browser-dynamic": "12.0.5", 89 | "@angular/router": "12.0.5", 90 | "@electron/remote": "1.0.4", 91 | "@fontsource/inter": "4.5.0", 92 | "@fontsource/open-sans": "4.5.0", 93 | "@materia-ui/ngx-monaco-editor": "5.1.0", 94 | "@supercharge/promise-pool": "1.7.0", 95 | "@trodi/electron-splashscreen": "1.0.0", 96 | "@types/adm-zip": "^0.4.34", 97 | "@types/bytes": "^3.1.1", 98 | "@types/fluent-ffmpeg": "^2.1.18", 99 | "@types/graceful-fs": "4.1.5", 100 | "@types/is-reachable": "^3.1.0", 101 | "@types/sqlite3": "3.1.7", 102 | "adm-zip": "^0.5.5", 103 | "autoprefixer": "10.2.6", 104 | "axios": "0.21.1", 105 | "byte-size": "^8.0.0", 106 | "bytes": "^3.1.0", 107 | "electron-log": "^4.3.5", 108 | "express": "4.17.1", 109 | "fetch-installed-software": "0.0.6", 110 | "fluent-ffmpeg": "^2.1.2", 111 | "fs.promises.exists": "^1.0.0", 112 | "graceful-fs": "4.2.6", 113 | "is-reachable": "^5.0.0", 114 | "lodash": "^4.17.21", 115 | "monaco-editor": "0.25.2", 116 | "ngx-dropdown-list": "1.1.2", 117 | "ngx-easy-table": "15.0.0", 118 | "ngx-monaco-editor": "12.0.0", 119 | "ngx-select-dropdown": "2.1.0", 120 | "ngx-toastr": "14.0.0", 121 | "ngx-ui-switch": "^12.0.1", 122 | "postcss": "8.3.5", 123 | "random-hash": "^4.0.1", 124 | "reverse-line-reader": "0.2.6", 125 | "rxjs": "6.6.6", 126 | "sqlite": "4.0.23", 127 | "sqlite3": "5.0.2", 128 | "tailwindcss": "2.2.4", 129 | "ts-debounce": "3.0.0", 130 | "tslib": "2.1.0", 131 | "username": "^5.1.0", 132 | "utf8-string-bytes": "^1.0.3", 133 | "zone.js": "~0.11.4" 134 | }, 135 | "devDependencies": { 136 | "@angular-builders/custom-webpack": "12.0.0", 137 | "@angular-devkit/build-angular": "12.0.5", 138 | "@angular-eslint/builder": "12.1.0", 139 | "@angular-eslint/eslint-plugin": "12.1.0", 140 | "@angular-eslint/eslint-plugin-template": "12.1.0", 141 | "@angular-eslint/schematics": "12.1.0", 142 | "@angular-eslint/template-parser": "12.1.0", 143 | "@angular/cli": "12.0.5", 144 | "@angular/compiler-cli": "12.0.5", 145 | "@ngx-translate/core": "^13.0.0", 146 | "@ngx-translate/http-loader": "^6.0.0", 147 | "@types/jasmine": "3.7.6", 148 | "@types/jasminewd2": "2.0.9", 149 | "@types/mocha": "8.2.2", 150 | "@types/node": "15.6.1", 151 | "@typescript-eslint/eslint-plugin": "4.25.0", 152 | "@typescript-eslint/parser": "4.25.0", 153 | "chai": "4.3.4", 154 | "conventional-changelog-cli": "2.1.1", 155 | "cross-env": "7.0.3", 156 | "electron": "13.0.1", 157 | "electron-builder": "22.10.5", 158 | "electron-packager": "15.2.0", 159 | "electron-reload": "1.5.0", 160 | "eslint": "7.27.0", 161 | "eslint-plugin-import": "2.23.4", 162 | "eslint-plugin-jsdoc": "35.0.0", 163 | "eslint-plugin-prefer-arrow": "1.2.3", 164 | "jasmine-core": "3.7.1", 165 | "jasmine-spec-reporter": "7.0.0", 166 | "karma": "6.3.2", 167 | "karma-coverage-istanbul-reporter": "3.0.3", 168 | "karma-electron": "7.0.0", 169 | "karma-jasmine": "4.0.1", 170 | "karma-jasmine-html-reporter": "1.6.0", 171 | "mocha": "8.4.0", 172 | "nan": "2.14.2", 173 | "npm-run-all": "4.1.5", 174 | "spectron": "15.0.0", 175 | "ts-node": "9.1.1", 176 | "typescript": "4.2.4", 177 | "wait-on": "5.0.1", 178 | "webdriver-manager": "12.1.8" 179 | }, 180 | "engines": { 181 | "node": ">=12.0.0" 182 | }, 183 | "browserslist": [ 184 | "chrome 83" 185 | ] 186 | } 187 | -------------------------------------------------------------------------------- /resources/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nzbasic/Collection-Helper/3f90ff23fd3727b139646205a515d5726d6a212a/resources/icon.ico -------------------------------------------------------------------------------- /src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from "@angular/core"; 2 | import { Routes, RouterModule } from "@angular/router"; 3 | import { AppComponent } from "./app.component"; 4 | 5 | const routes: Routes = [{ path: "", component: AppComponent }]; 6 | @NgModule({ 7 | imports: [RouterModule.forRoot(routes)], 8 | exports: [RouterModule], 9 | }) 10 | export class AppRoutingModule {} 11 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 |
6 |
7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 |
20 |
21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectorRef, Component, OnInit } from "@angular/core"; 2 | import { LangChangeEvent, TranslateService } from "@ngx-translate/core"; 3 | import { ComponentService, Display } from "./services/component.service"; 4 | import { LoadingService } from "./services/loading.service"; 5 | import { IpcService } from "./services/ipc.service"; 6 | 7 | export let fullIp: string 8 | @Component({ 9 | selector: "app-root", 10 | templateUrl: "./app.component.html", 11 | }) 12 | export class AppComponent implements OnInit { 13 | public display: Display; 14 | public allTypes = Display; 15 | public version = "1.0.0" 16 | public update = false 17 | public downloaded = false 18 | public launch = false 19 | public installationPath = "" 20 | public lines = [] 21 | 22 | constructor( 23 | private translate: TranslateService, 24 | private componentService: ComponentService, 25 | private loadingService: LoadingService, 26 | private readonly ipcService: IpcService, 27 | private changeDetector: ChangeDetectorRef 28 | ) { 29 | translate.setDefaultLang("en"); 30 | 31 | translate.onLangChange.subscribe((event: LangChangeEvent) => { 32 | this.updateTranslation() 33 | }) 34 | this.updateTranslation() 35 | } 36 | 37 | updateTranslation() { 38 | this.translate.get('PAGES.HOME.MODALS.LAUNCH').subscribe(res => { 39 | this.lines = Object.values(res) 40 | }) 41 | } 42 | 43 | async ngOnInit() { 44 | const res = await this.ipcService.invoke('app_details') 45 | const baseIp = "http://127.0.0.1:" 46 | 47 | if (res) { 48 | fullIp = baseIp + res.port 49 | this.version = res.version 50 | } else { 51 | fullIp = baseIp + 7373 52 | } 53 | 54 | this.installationPath = res.path??"" 55 | 56 | this.ipcService.on('update_available', () => { 57 | this.update = true 58 | this.changeDetector.detectChanges() 59 | }) 60 | 61 | this.ipcService.on('update_downloaded', () => { 62 | this.downloaded = true 63 | this.changeDetector.detectChanges() 64 | }); 65 | 66 | this.componentService.componentSelected.subscribe((display: Display) => { 67 | this.display = display; 68 | }); 69 | 70 | await this.loadingService.loadSettings() 71 | this.launch = true 72 | } 73 | 74 | hideUpdate(status: boolean) { 75 | if (!status) { 76 | this.update = false 77 | } 78 | } 79 | 80 | hideLaunch() { 81 | this.launch = false 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from "@angular/platform-browser"; 2 | import { NgModule } from "@angular/core"; 3 | import { FormsModule } from "@angular/forms"; 4 | import { HttpClientModule, HttpClient } from "@angular/common/http"; 5 | import { CoreModule } from "./core/core.module"; 6 | import { SharedModule } from "./shared/shared.module"; 7 | 8 | import { AppRoutingModule } from "./app-routing.module"; 9 | import { MatProgressBarModule } from "@angular/material/progress-bar"; 10 | import { TableModule } from 'ngx-easy-table' 11 | import { MatTooltipModule } from '@angular/material/tooltip'; 12 | import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'; 13 | import { ToastrModule } from 'ngx-toastr'; 14 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 15 | import { DropdownListModule } from 'ngx-dropdown-list'; 16 | import { UiSwitchModule } from 'ngx-ui-switch' 17 | import { NgxSliderModule } from '@angular-slider/ngx-slider'; 18 | 19 | // NG Translate 20 | import { TranslateModule, TranslateLoader, TranslateService } from "@ngx-translate/core"; 21 | import { TranslateHttpLoader } from "@ngx-translate/http-loader"; 22 | 23 | import { AppComponent } from "./app.component"; 24 | import { HomeComponent } from "./pages/home/home.component"; 25 | import { CustomfilterComponent } from "./pages/customfilter/customfilter.component"; 26 | import { EditComponent } from "./pages/edit/edit.component"; 27 | import { FiltersComponent } from "./pages/filters/filters.component"; 28 | import { ImportexportComponent } from "./pages/importexport/importexport.component"; 29 | import { SettingsComponent } from "./pages/settings/settings.component"; 30 | import { ConfirmModalComponent } from "./components/confirm-modal/confirm-modal.component"; 31 | import { HeaderComponent } from "./components/header/header.component"; 32 | import { LoadingBarComponent } from "./components/loading-bar/loading-bar.component"; 33 | import { RenameModalComponent } from "./components/rename-modal/rename-modal.component"; 34 | import { SettingsModalComponent } from "./components/settings-modal/settings-modal.component"; 35 | import { SidebarComponent } from "./components/sidebar/sidebar.component"; 36 | import { SidebarButtonComponent } from "./components/sidebar-button/sidebar-button.component"; 37 | import { LoadingComponent } from "./pages/loading/loading.component"; 38 | import { PaginationComponent } from './components/pagination/pagination.component'; 39 | import { UpdateComponent } from './components/update/update.component'; 40 | import { HelpComponent } from './components/help/help.component'; 41 | import { AddModalComponent } from './components/add-modal/add-modal.component'; 42 | import { FilterSelectComponent } from './components/filter-select/filter-select.component'; 43 | import { CollectionDropdownComponent } from './components/collection-dropdown/collection-dropdown.component'; 44 | import { PracticeDiffsComponent } from './pages/practice-diffs/practice-diffs.component'; 45 | import { BpmChangerComponent } from './pages/bpm-changer/bpm-changer.component'; 46 | import { ValueOverrideSliderComponent } from './components/value-override-slider/value-override-slider.component'; 47 | import { LanguageDropdownComponent } from './components/language-dropdown/language-dropdown.component'; 48 | 49 | // AoT requires an exported function for factories 50 | const httpLoaderFactory = (http: HttpClient): TranslateHttpLoader => 51 | new TranslateHttpLoader(http, "./assets/i18n/", ".json"); 52 | 53 | @NgModule({ 54 | declarations: [ 55 | AppComponent, 56 | HomeComponent, 57 | CustomfilterComponent, 58 | EditComponent, 59 | FiltersComponent, 60 | ImportexportComponent, 61 | SettingsComponent, 62 | ConfirmModalComponent, 63 | HeaderComponent, 64 | LoadingComponent, 65 | LoadingBarComponent, 66 | RenameModalComponent, 67 | SettingsModalComponent, 68 | SidebarComponent, 69 | SidebarButtonComponent, 70 | PaginationComponent, 71 | UpdateComponent, 72 | HelpComponent, 73 | AddModalComponent, 74 | FilterSelectComponent, 75 | CollectionDropdownComponent, 76 | PracticeDiffsComponent, 77 | BpmChangerComponent, 78 | ValueOverrideSliderComponent, 79 | LanguageDropdownComponent, 80 | ], 81 | imports: [ 82 | BrowserModule, 83 | FormsModule, 84 | HttpClientModule, 85 | CoreModule, 86 | SharedModule, 87 | AppRoutingModule, 88 | TranslateModule.forRoot({ 89 | defaultLanguage: 'en', 90 | loader: { 91 | provide: TranslateLoader, 92 | useFactory: httpLoaderFactory, 93 | deps: [HttpClient], 94 | }, 95 | }), 96 | MatProgressBarModule, 97 | TableModule, 98 | MatTooltipModule, 99 | MonacoEditorModule, 100 | DropdownListModule, 101 | ToastrModule.forRoot(), 102 | BrowserAnimationsModule, 103 | UiSwitchModule, 104 | NgxSliderModule, 105 | ], 106 | providers: [TranslateService], 107 | bootstrap: [AppComponent], 108 | }) 109 | export class AppModule {} 110 | -------------------------------------------------------------------------------- /src/app/components/add-modal/add-modal.component.html: -------------------------------------------------------------------------------- 1 | 37 | -------------------------------------------------------------------------------- /src/app/components/add-modal/add-modal.component.ts: -------------------------------------------------------------------------------- 1 | import { Subscription } from 'rxjs'; 2 | import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; 3 | import { debounce } from 'ts-debounce'; 4 | import { BeatmapService } from '../../services/beatmap.service'; 5 | import { FilterService } from '../../services/filter.service'; 6 | 7 | export interface AddResponse { 8 | name: string; 9 | hashes: string[]; 10 | } 11 | 12 | @Component({ 13 | selector: 'app-add-modal', 14 | templateUrl: './add-modal.component.html' 15 | }) 16 | export class AddModalComponent implements OnInit, OnDestroy { 17 | 18 | @Input() list!: Set 19 | @Output() response = new EventEmitter(); 20 | 21 | public res: AddResponse = { name: "", hashes: []} 22 | public searchValue = "" 23 | public result = "0" 24 | public selectedFilters: string[] = [] 25 | public filterNumber = 0 26 | private numberSubscription: Subscription 27 | 28 | constructor(private beatmapService: BeatmapService, private filterService: FilterService) { } 29 | 30 | ngOnInit() { 31 | this.numberSubscription = this.filterService.filterNumber.subscribe((filterNumber: number) => { 32 | this.filterNumber = filterNumber 33 | }) 34 | } 35 | 36 | ngOnDestroy() { 37 | this.numberSubscription.unsubscribe() 38 | } 39 | 40 | confirm(): void { 41 | this.response.emit(this.res) 42 | } 43 | 44 | cancel(): void { 45 | this.res.name = ""; 46 | this.response.emit(this.res) 47 | } 48 | 49 | textChange(event: KeyboardEvent): void { 50 | this.res.name = (event.target as HTMLTextAreaElement).value.trim() 51 | } 52 | 53 | public debouncedSearch = debounce(this.search, 300) 54 | 55 | searchChange(event: KeyboardEvent): void { 56 | this.debouncedSearch() 57 | this.searchValue = (event.target as HTMLTextAreaElement).value 58 | } 59 | 60 | async search() { 61 | this.getSelectedList() 62 | } 63 | 64 | async getSelectedList() { 65 | this.result = "..." 66 | const trimmed = this.searchValue.trim() 67 | if (this.selectedFilters.length || (trimmed && !trimmed.match(/^([+]\w+)$/g))) { 68 | let res = await this.beatmapService.getSelectedList(this.searchValue, "", true, this.selectedFilters) 69 | this.res.hashes = res 70 | this.result = res.length.toString() 71 | } else { 72 | this.res.hashes = [] 73 | this.result = "0" 74 | } 75 | } 76 | 77 | changeSelected(selected: string[]) { 78 | this.selectedFilters = selected 79 | this.getSelectedList() 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/app/components/collection-dropdown/collection-dropdown.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/app/components/collection-dropdown/collection-dropdown.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; 2 | import { Collection } from '../../../../models/collection'; 3 | import { CollectionsService } from '../../services/collections.service'; 4 | import { limitTextLength } from "../../util/processing"; 5 | 6 | export interface SelectCollection { 7 | text: string; 8 | value: Collection; 9 | selected: boolean; 10 | } 11 | 12 | @Component({ 13 | selector: 'app-collection-dropdown', 14 | templateUrl: './collection-dropdown.component.html' 15 | }) 16 | export class CollectionDropdownComponent implements OnInit { 17 | 18 | @Output() emitter = new EventEmitter() 19 | @Input() placeHolder!: string 20 | @Input() multi!: boolean; 21 | 22 | private collection: Collection 23 | public collections: Collection[] 24 | public collectionItems: SelectCollection[] 25 | public selected: string 26 | public limitTextLength = limitTextLength 27 | 28 | constructor(private collectionService: CollectionsService) { } 29 | 30 | ngOnInit(): void { 31 | this.collections = this.collectionService.getCollections() 32 | this.collectionItems = this.collections.map(item => { 33 | return { 34 | text: limitTextLength(item.name, 30), 35 | value: item, 36 | selected: false, 37 | } 38 | }) 39 | } 40 | 41 | onChange() { 42 | const collections = this.collectionItems.filter(item => item.selected).map(filter => filter.value) 43 | this.emitter.emit(collections) 44 | this.placeHolder = limitTextLength(collections.map(item => item.name).join(', '), 30) 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/app/components/confirm-modal/confirm-modal.component.html: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /src/app/components/confirm-modal/confirm-modal.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; 2 | 3 | @Component({ 4 | selector: "app-confirm-modal", 5 | templateUrl: "./confirm-modal.component.html", 6 | }) 7 | export class ConfirmModalComponent { 8 | @Input() title!: string 9 | @Input() text!: string 10 | @Input() confirmText!: string 11 | 12 | @Output() response = new EventEmitter() 13 | 14 | confirmAction() { 15 | this.response.emit(true) 16 | } 17 | 18 | hide() { 19 | this.response.emit(false) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/app/components/filter-select/filter-select.component.html: -------------------------------------------------------------------------------- 1 | 2 | No filters cached! 3 | -------------------------------------------------------------------------------- /src/app/components/filter-select/filter-select.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; 2 | import { FilterService } from '../../services/filter.service'; 3 | import { limitTextLength } from '../../util/processing'; 4 | 5 | export interface SelectFilter { 6 | text: string; 7 | value: string; 8 | selected: boolean; 9 | } 10 | 11 | @Component({ 12 | selector: 'app-filter-select', 13 | templateUrl: './filter-select.component.html' 14 | }) 15 | export class FilterSelectComponent implements OnInit { 16 | 17 | @Output() emitter = new EventEmitter() 18 | public filters: SelectFilter[] = [] 19 | public placeHolder = "" 20 | 21 | constructor(private filterService: FilterService) { } 22 | 23 | ngOnInit(): void { 24 | this.filters = this.filterService.getFilters().filter(filter => filter.isCached).map((filter): SelectFilter => { 25 | return {text: limitTextLength(filter.name, 15), value: filter.name, selected: false} 26 | }) 27 | } 28 | 29 | onChange() { 30 | let selected = this.filters.filter(item => item.selected).map(filter => filter.value) 31 | this.placeHolder = limitTextLength(selected.toString(), 15) 32 | this.emitter.emit(selected) 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/app/components/header/header.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{title}} 4 | {{subtitle}} 5 |
6 |
7 | 8 | 9 |
10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/app/components/header/header.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from "@angular/core"; 2 | import { LangChangeEvent, TranslateService } from "@ngx-translate/core"; 3 | import { ComponentService, Display } from "../../services/component.service"; 4 | import { LoadingService } from "../../services/loading.service"; 5 | import { Title, TitleService } from "../../services/title.service"; 6 | 7 | interface Help { 8 | title: string, 9 | lines: string[] 10 | } 11 | 12 | @Component({ 13 | selector: "app-header", 14 | templateUrl: "./header.component.html", 15 | }) 16 | export class HeaderComponent implements OnInit { 17 | public title!: string; 18 | public subtitle!: string; 19 | public helpShown = false 20 | public dict = new Map() 21 | public helpCurrent: Help 22 | 23 | constructor(private titleService: TitleService, 24 | private componentService: ComponentService, 25 | private loadingService: LoadingService, 26 | translateService: TranslateService) { 27 | 28 | translateService.onLangChange.subscribe((event: LangChangeEvent) => { 29 | this.updateTranslation(event.translations.HELP) 30 | this.ngOnInit() 31 | }) 32 | 33 | translateService.get('HELP').subscribe(res => { 34 | this.updateTranslation(res) 35 | }) 36 | } 37 | 38 | updateTranslation(res: any) { 39 | this.dict.set(Display.COLLECTIONS, { title: res.HOME.NAME, lines: Object.values(res.HOME.LINES)}) 40 | this.dict.set(Display.FILTERS, { title: res.FILTERS.NAME, lines: Object.values(res.FILTERS.LINES)}) 41 | this.dict.set(Display.CUSTOM_FILTERS, { title: res.CUSTOM_FILTERS.NAME, lines: Object.values(res.CUSTOM_FILTERS.LINES)}) 42 | this.dict.set(Display.EDIT, { title: res.EDIT.NAME, lines: Object.values(res.EDIT.LINES)}) 43 | this.dict.set(Display.IMPORT_EXPORT, { title: res.IMPORT_EXPORT.NAME, lines: Object.values(res.IMPORT_EXPORT.LINES)}) 44 | this.dict.set(Display.SETTINGS, { title: res.SETTINGS.NAME, lines: Object.values(res.SETTINGS.LINES)}) 45 | this.dict.set(Display.PRACTICE_DIFF_GENERATOR, { title: res.PRACTICE.NAME, lines: Object.values(res.PRACTICE.LINES)}) 46 | this.dict.set(Display.BPM_CHANGER, { title: res.DIFF_CHANGER.NAME, lines: Object.values(res.DIFF_CHANGER.LINES)}) 47 | } 48 | 49 | ngOnInit(): void { 50 | this.titleService.currentTitle.subscribe((title: Title) => { 51 | this.title = title.title; 52 | this.subtitle = title.subtitle; 53 | }); 54 | 55 | this.componentService.componentSelected.subscribe(component => { 56 | this.helpCurrent = this.dict.get(component)??{title: "", lines: []} 57 | }) 58 | } 59 | 60 | help(): void { 61 | this.helpShown = true 62 | } 63 | 64 | hideHelp(): void { 65 | this.helpShown = false 66 | } 67 | 68 | refresh(): void { 69 | this.loadingService.loadData() 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /src/app/components/help/help.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | {{title}} 5 |
6 | {{line}} 7 |
8 |
9 | 10 |
11 |
12 | 13 |
14 | -------------------------------------------------------------------------------- /src/app/components/help/help.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-help', 5 | templateUrl: './help.component.html' 6 | }) 7 | export class HelpComponent { 8 | 9 | @Input() innerHelp: boolean = false 10 | @Input() title!: string 11 | @Input() lines!: string[] 12 | @Output() emitter = new EventEmitter(); 13 | 14 | hide(): void { 15 | this.emitter.emit() 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/app/components/language-dropdown/language-dropdown.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/app/components/language-dropdown/language-dropdown.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { TranslateService } from '@ngx-translate/core'; 3 | import { UtilService } from '../../services/util.service'; 4 | 5 | export interface Language { 6 | name: string; 7 | author: string; 8 | code: string 9 | } 10 | 11 | interface LanguageSelect { 12 | text: string, 13 | value: Language, 14 | selected: boolean 15 | } 16 | 17 | @Component({ 18 | selector: 'app-language-dropdown', 19 | templateUrl: './language-dropdown.component.html', 20 | }) 21 | export class LanguageDropdownComponent implements OnInit { 22 | 23 | public languages: Language[] = [ 24 | { name: "English", author: "nzbasic", code: "en"}, 25 | { name: "Russian", author: "Maks220v", code: "ru"}, 26 | ] 27 | public languageItems: LanguageSelect[] = [] 28 | public current = "English (by nzbasic)" 29 | 30 | constructor(private translateService: TranslateService, private utilService: UtilService) { } 31 | 32 | ngOnInit(): void { 33 | this.languageItems = this.languages.map(item => { 34 | return { 35 | text: item.name + " (by " + item.author + ")", 36 | value: item, 37 | selected: false, 38 | } 39 | }) 40 | 41 | const currentLang = this.translateService.currentLang 42 | this.current = this.languageItems.find(item => item.value.code == currentLang).text 43 | } 44 | 45 | onChange() { 46 | let language = this.languageItems.find(item => item.selected) 47 | if (!language) { 48 | language = this.languageItems[0] 49 | } 50 | 51 | this.translateService.use(language.value.code) 52 | this.current = language.text 53 | this.utilService.setLanguage(language.value.code) 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/app/components/loading-bar/loading-bar.component.html: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /src/app/components/loading-bar/loading-bar.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnDestroy, OnInit } from "@angular/core"; 2 | import { ProgressBarMode } from "@angular/material/progress-bar"; 3 | import { Subscription } from "rxjs"; 4 | import { FilterService } from "../../services/filter.service"; 5 | import { LoadingService } from "../../services/loading.service"; 6 | 7 | @Component({ 8 | selector: "app-loading-bar", 9 | templateUrl: "./loading-bar.component.html", 10 | }) 11 | export class LoadingBarComponent { 12 | 13 | @Input() text!: string 14 | @Input() mode!: ProgressBarMode 15 | @Input() percentage = 0 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/app/components/pagination/pagination.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | {{(pageNumber*rowsPerPage)-(rowsPerPage-1) + "-" + pageNumber*rowsPerPage}} 5 | 6 | MISC.PAGE: 7 | 8 | / {{ceil(numberResults / rowsPerPage)}} 9 |
10 | {{'PAGINATION.TEXT' | translate:({value: numberResults})}} 11 |
12 | -------------------------------------------------------------------------------- /src/app/components/pagination/pagination.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; 2 | import { debounce } from 'ts-debounce'; 3 | 4 | @Component({ 5 | selector: 'app-pagination', 6 | templateUrl: './pagination.component.html' 7 | }) 8 | export class PaginationComponent { 9 | 10 | @Input() pageNumber!: number 11 | @Input() rowsPerPage!: number 12 | @Input() showNumberResults!: boolean 13 | @Input() numberResults!: number 14 | @Input() infiniteScroll?: boolean 15 | 16 | ceil(number: number) { 17 | return Math.ceil(number) 18 | } 19 | 20 | inputValidator(event) { 21 | const separator = '^([0-9])'; 22 | const maskSeparator = new RegExp(separator , 'g'); 23 | const result = maskSeparator.test(event.key); 24 | return result; 25 | } 26 | 27 | public debouncedValidate = debounce(this.pageUpdateValidate, 500) 28 | 29 | pageUpdateValidate() { 30 | if (this.pageNumber > Math.ceil(this.numberResults / this.rowsPerPage)) { 31 | this.pageNumber = Math.ceil(this.numberResults / this.rowsPerPage) 32 | } 33 | this.pageUpdate() 34 | } 35 | 36 | @Output() emitter = new EventEmitter() 37 | 38 | pageUpdate(change?: number) { 39 | if (change) { 40 | this.pageNumber += change 41 | } 42 | this.emitter.emit(this.pageNumber); 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /src/app/components/rename-modal/rename-modal.component.html: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /src/app/components/rename-modal/rename-modal.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; 2 | 3 | @Component({ 4 | selector: "app-rename-modal", 5 | templateUrl: "./rename-modal.component.html", 6 | }) 7 | export class RenameModalComponent { 8 | @Input() list!: Set 9 | @Input() value!: string 10 | @Input() title!: string 11 | @Input() action!: string 12 | 13 | @Output() response = new EventEmitter(); 14 | 15 | public inputValue = "" 16 | 17 | ngOnInit() { 18 | this.list.delete(this.value) 19 | } 20 | 21 | confirm(): void { 22 | this.response.emit(this.inputValue) 23 | } 24 | 25 | cancel(): void { 26 | this.response.emit("") 27 | } 28 | 29 | textChange(event: KeyboardEvent): void { 30 | this.inputValue = (event.target as HTMLTextAreaElement).value 31 | } 32 | 33 | disabled(): boolean { 34 | return this.list.has(this.inputValue.toLowerCase()) || this.inputValue == '' || this.inputValue == this.value 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/app/components/settings-modal/settings-modal.component.html: -------------------------------------------------------------------------------- 1 | 23 | -------------------------------------------------------------------------------- /src/app/components/settings-modal/settings-modal.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; 2 | import axios from "axios"; 3 | import { LoadingService } from "../../services/loading.service"; 4 | import { fullIp } from '../../app.component' 5 | import { SelectedService } from "../../services/selected.service"; 6 | 7 | @Component({ 8 | selector: "app-settings-modal", 9 | templateUrl: "./settings-modal.component.html", 10 | }) 11 | export class SettingsModalComponent { 12 | 13 | @Input() path!: string 14 | @Input() mode!: string 15 | @Output() emitter = new EventEmitter() 16 | public invalid = false 17 | 18 | constructor(private loadingService: LoadingService, private selectedService: SelectedService) {} 19 | 20 | async confirm() { 21 | 22 | let verify: boolean = (await axios.post(fullIp + "/verifyPath", { path: this.path, mode: this.mode })).data 23 | 24 | if (verify) { 25 | this.invalid = false 26 | this.loadingService.settingsSource.next(this.path) 27 | this.selectedService.clearSelected() 28 | this.loadingService.loadData() 29 | } else { 30 | this.invalid = true 31 | } 32 | } 33 | 34 | textChange(event: KeyboardEvent): void { 35 | this.path = (event.target as HTMLTextAreaElement).value 36 | if (this.invalid) { 37 | this.invalid = false 38 | } 39 | } 40 | 41 | async openBrowseDialog() { 42 | let res = await axios.get(fullIp + "/openBrowseDialog") 43 | if (!res.data.canceled) { 44 | this.path = res.data.filePaths[0] 45 | } 46 | } 47 | 48 | cancel(): void { 49 | this.emitter.emit(false) 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/app/components/sidebar-button/sidebar-button.component.html: -------------------------------------------------------------------------------- 1 |
2 |

{{text}}

3 |
4 | -------------------------------------------------------------------------------- /src/app/components/sidebar-button/sidebar-button.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnDestroy, OnInit } from "@angular/core"; 2 | import { Subscription } from "rxjs"; 3 | import { ComponentService, Display } from "../../services/component.service"; 4 | 5 | @Component({ 6 | selector: "app-sidebar-button", 7 | templateUrl: "./sidebar-button.component.html", 8 | }) 9 | export class SidebarButtonComponent implements OnInit, OnDestroy { 10 | @Input() text!: string; 11 | @Input() type!: Display; 12 | public selected: Display; 13 | private componentSubscription: Subscription; 14 | 15 | constructor(private componentService: ComponentService) {} 16 | 17 | changeComponent(): void { 18 | this.componentService.changeComponent(this.type); 19 | } 20 | 21 | ngOnInit(): void { 22 | this.componentSubscription = 23 | this.componentService.componentSelected.subscribe((selected: Display) => { 24 | this.selected = selected; 25 | }); 26 | } 27 | 28 | ngOnDestroy(): void { 29 | this.componentSubscription.unsubscribe(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/components/sidebar/sidebar.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 |
6 | Collection Helper 7 | v{{version}} 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 |
27 | {{ 'SIDEBAR.USAGE_VIDEO_LINK' | translate }} 28 | {{ 'SIDEBAR.MY_SOCIALS' | translate }} 29 | osu!: YEP 30 | Github: nzbasic 31 | Twitter: @nzbasic 32 | Discord: basic#7373 33 |
34 | 35 |
36 | -------------------------------------------------------------------------------- /src/app/components/sidebar/sidebar.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnInit } from "@angular/core"; 2 | import { Collection } from "../../../../models/collection"; 3 | import { ComponentService, Display } from "../../services/component.service"; 4 | import { SelectedService } from "../../services/selected.service"; 5 | import { UtilService } from "../../services/util.service"; 6 | import { limitTextLength } from "../../util/processing"; 7 | 8 | @Component({ 9 | selector: "app-sidebar", 10 | templateUrl: "./sidebar.component.html", 11 | }) 12 | export class SidebarComponent implements OnInit { 13 | @Input() version!: string 14 | 15 | public limitTextLength = limitTextLength; 16 | public buttonSelected: Display = Display.COLLECTIONS; 17 | public Display = Display; 18 | public collectionSelected: string = ""; 19 | 20 | constructor( 21 | private selectedService: SelectedService, 22 | private componentService: ComponentService, 23 | private utilService: UtilService 24 | ) {} 25 | 26 | ngOnInit(): void { 27 | this.selectedService.currentSelected.subscribe((selected: Collection) => { 28 | this.collectionSelected = selected.name; 29 | }); 30 | this.componentService.componentSelected.subscribe((selected: Display) => { 31 | this.buttonSelected = selected; 32 | }); 33 | } 34 | 35 | openOsu() { 36 | this.utilService.openUrl("https://osu.ppy.sh/users/9008211") 37 | } 38 | 39 | openGithub() { 40 | this.utilService.openUrl("https://github.com/nzbasic/") 41 | } 42 | 43 | openTwitter() { 44 | this.utilService.openUrl("https://twitter.com/nzbasic") 45 | } 46 | 47 | openVideo() { 48 | this.utilService.openUrl("https://www.youtube.com/watch?v=gafuDhsAIC0") 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/app/components/update/update.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
MISC.UPDATE_MODAL.AVAILABLE
5 |
{{downloaded ? ('MISC.UPDATE_MODAL.AVAILABLE' | translate) : ('MISC.UPDATE_MODAL.DOWNLOADING' | translate)}}
6 |
7 | 8 | 9 |
10 |
11 | 12 |
13 | -------------------------------------------------------------------------------- /src/app/components/update/update.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; 2 | import { IpcService } from '../../services/ipc.service'; 3 | 4 | @Component({ 5 | selector: 'app-update', 6 | templateUrl: './update.component.html' 7 | }) 8 | export class UpdateComponent implements OnInit { 9 | 10 | @Input() downloaded!: boolean 11 | @Output() emitter = new EventEmitter() 12 | 13 | constructor(private ipcService: IpcService) { } 14 | 15 | ngOnInit(): void { 16 | 17 | } 18 | 19 | yes(): void { 20 | this.ipcService.send('restart_app') 21 | } 22 | 23 | no(): void { 24 | this.emitter.emit(false) 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/app/components/value-override-slider/value-override-slider.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 |
6 | {{value}} 7 |
8 | 9 | -------------------------------------------------------------------------------- /src/app/components/value-override-slider/value-override-slider.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; 2 | import { Options } from '@angular-slider/ngx-slider'; 3 | 4 | @Component({ 5 | selector: 'app-value-override-slider', 6 | templateUrl: './value-override-slider.component.html' 7 | }) 8 | export class ValueOverrideSliderComponent { 9 | 10 | @Input() value!: number; 11 | @Output() valueChange = new EventEmitter() 12 | 13 | @Input() enabled!: boolean 14 | @Output() enabledChange = new EventEmitter() 15 | 16 | public on = false 17 | public options: Options = { 18 | floor: 0, 19 | ceil: 10, 20 | step: 0.1, 21 | hideLimitLabels: true, 22 | hidePointerLabels: true, 23 | disabled: true 24 | } 25 | 26 | change(value: number) { 27 | this.valueChange.emit(value) 28 | } 29 | 30 | /* Due to the way Angular 2+ handles change detection, we have to create a new options object. */ 31 | onChangeDisabled(): void { 32 | this.options = Object.assign({}, this.options, {disabled: !this.on}); 33 | this.enabledChange.emit(this.on) 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/app/core/core.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | 4 | @NgModule({ 5 | declarations: [], 6 | imports: [ 7 | CommonModule 8 | ] 9 | }) 10 | export class CoreModule { } 11 | -------------------------------------------------------------------------------- /src/app/core/services/electron/electron.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { ElectronService } from './electron.service'; 4 | 5 | describe('ElectronService', () => { 6 | beforeEach(() => TestBed.configureTestingModule({})); 7 | 8 | it('should be created', () => { 9 | const service: ElectronService = TestBed.get(ElectronService); 10 | expect(service).toBeTruthy(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/app/core/services/electron/electron.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | // If you import a module but never use any of the imported values other than as TypeScript types, 4 | // the resulting javascript file will look as if you never imported the module at all. 5 | import { ipcRenderer, webFrame } from 'electron'; 6 | import * as remote from '@electron/remote'; 7 | import * as childProcess from 'child_process'; 8 | import * as fs from 'fs'; 9 | 10 | @Injectable({ 11 | providedIn: 'root' 12 | }) 13 | export class ElectronService { 14 | ipcRenderer: typeof ipcRenderer; 15 | webFrame: typeof webFrame; 16 | remote: typeof remote; 17 | childProcess: typeof childProcess; 18 | fs: typeof fs; 19 | 20 | get isElectron(): boolean { 21 | return !!(window && window.process && window.process.type); 22 | } 23 | 24 | constructor() { 25 | // Conditional imports 26 | if (this.isElectron) { 27 | this.ipcRenderer = window.require('electron').ipcRenderer; 28 | this.webFrame = window.require('electron').webFrame; 29 | 30 | this.childProcess = window.require('child_process'); 31 | this.fs = window.require('fs'); 32 | 33 | // If you want to use a NodeJS 3rd party deps in Renderer process (like @electron/remote), 34 | // it must be declared in dependencies of both package.json (in root and app folders) 35 | // If you want to use remote object in renderer process, please set enableRemoteModule to true in main.ts 36 | //this.remote = window.require('@electron/remote'); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/app/core/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './electron/electron.service'; 2 | -------------------------------------------------------------------------------- /src/app/pages/bpm-changer/bpm-changer.component.html: -------------------------------------------------------------------------------- 1 |
2 | PAGES.DIFF_CHANGER.TIPS.ONE 3 | PAGES.DIFF_CHANGER.TIPS.TWO 4 | PAGES.DIFF_CHANGER.TIPS.THREE 5 | PAGES.DIFF_CHANGER.TIPS.FOUR 6 | PAGES.DIFF_CHANGER.TIPS.FIVE 7 |
8 | MISC.COLLECTION 9 | 10 | 11 |
12 |
13 | {{'MISC.SET' | translate}} BPM 14 | 15 | 16 |
17 |
18 | {{'MISC.SET' | translate}} AR 19 | 20 |
21 |
22 | {{'MISC.SET' | translate}} OD 23 | 24 |
25 |
26 | {{'MISC.SET' | translate}} HP 27 | 28 |
29 |
30 | {{'MISC.SET' | translate}} CS 31 | 32 |
33 | 34 |
35 | 36 | {{'PAGES.DIFF_CHANGER.ESTIMATE' | translate:({estimateSize: estimateSize})}} 37 |
38 |
39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/app/pages/bpm-changer/bpm-changer.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ToastrService } from 'ngx-toastr'; 3 | import { BpmChangerOptions, Collection, Override } from '../../../../models/collection'; 4 | import { CollectionsService } from '../../services/collections.service'; 5 | import bytes from 'bytes'; 6 | import { Subscription } from 'rxjs'; 7 | import { TitleService } from '../../services/title.service'; 8 | import { TranslateService } from '@ngx-translate/core'; 9 | 10 | @Component({ 11 | selector: 'app-bpm-changer', 12 | templateUrl: './bpm-changer.component.html' 13 | }) 14 | export class BpmChangerComponent implements OnInit { 15 | 16 | public selected: Collection 17 | private progressSubscription: Subscription; 18 | public inputValueBpm = "240"; 19 | public percentage = 0 20 | public generatingModal = false 21 | public warning = false 22 | public estimateSize: string 23 | public options: BpmChangerOptions = { 24 | bpm: { value: 240, enabled: true }, 25 | ar: { value: 9.5, enabled: false }, 26 | od: { value: 9, enabled: false }, 27 | cs: { value: 4, enabled: false }, 28 | hp: { value: 8, enabled: false } 29 | } 30 | 31 | public lines = [ 32 | "A new collection has been created, you will need to launch/relaunch osu! (possibly multiple times) for it to load properly.", 33 | ]; 34 | 35 | constructor(private collectionsService: CollectionsService, 36 | private toastr: ToastrService, 37 | private titleService: TitleService, 38 | private translateService: TranslateService) { 39 | this.titleService.changeTitle('PAGES.DIFF_CHANGER'); 40 | } 41 | 42 | ngOnInit(): void { 43 | this.progressSubscription = this.collectionsService.progressCurrent.subscribe(progress => { 44 | this.percentage = progress 45 | }) 46 | } 47 | 48 | ngOnDestroy(): void { 49 | this.progressSubscription.unsubscribe() 50 | } 51 | 52 | inputBpmUpdate(): void { 53 | this.options.bpm.value = parseInt(this.inputValueBpm, 10) 54 | 55 | if (!this.options.bpm.value) { 56 | this.options.bpm.enabled = false 57 | this.options.bpm.value = 0 58 | } 59 | this.updateEstimate() 60 | } 61 | 62 | async updateEstimate() { 63 | if (this.selected) { 64 | const setCount = await this.collectionsService.getSetCount(this.selected.hashes) 65 | if (this.options.bpm.enabled && this.options.bpm.value) { 66 | this.estimateSize = bytes(setCount * 3000000) 67 | } else { 68 | this.estimateSize = bytes(this.selected.hashes?.length??0 * 25000) 69 | } 70 | } else { 71 | this.estimateSize = null; 72 | } 73 | } 74 | 75 | bpmChange(event) { 76 | const separator = '^([0-9])'; 77 | const maskSeparator = new RegExp(separator , 'g'); 78 | const result = maskSeparator.test(event.key); 79 | return result; 80 | } 81 | 82 | async onChange(selected: Collection[]) { 83 | if (selected.length) { 84 | this.selected = selected[0]; 85 | } else { 86 | this.selected = null 87 | } 88 | 89 | this.updateEstimate() 90 | } 91 | 92 | async generate() { 93 | this.generatingModal = true 94 | await this.collectionsService.generateBPM(this.selected, this.options); 95 | this.generatingModal = false 96 | this.warning = true 97 | this.toastr.success("Practice difficulties created!", "Success") 98 | } 99 | 100 | hideWarning(): void { 101 | this.warning = false 102 | } 103 | 104 | generationDisabled(): boolean { 105 | if (!this.selected) { 106 | return true 107 | } 108 | 109 | const overrides: Override[] = Object.values(this.options) 110 | for (const override of overrides) { 111 | if (override.enabled) { 112 | return false 113 | } 114 | } 115 | 116 | return true 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/app/pages/customfilter/customfilter.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | MISC.NAME 6 | 7 | MISC.DESCRIPTION 8 | 9 |
10 |
11 | PAGES.CUSTOM_FILTER.GET_OBJECTS 12 | 13 |
14 | 15 |
16 | PAGES.CUSTOM_FILTER.TIPS.ONE 17 | PAGES.CUSTOM_FILTER.TIPS.TWO 18 | PAGES.CUSTOM_FILTER.TIPS.THREE 19 | PAGES.CUSTOM_FILTER.TIPS.FOUR 20 |
21 | PAGES.CUSTOM_FILTER.TIPS.FIVE 22 |
23 | 24 |
25 | 26 |
27 | 28 |
29 | 30 |
31 | 32 | 33 | 34 | PAGES.CUSTOM_FILTER.NAME_EXISTS 35 | PAGES.CUSTOM_FILTER.NOT_TESTED 36 | PAGES.CUSTOM_FILTER.NAME_REQUIRED 37 |
38 | 39 |
40 | {{'PAGES.CUSTOM_FILTER.RESULT' | translate:(rawError.length ? {value: "Failed"} : {value: numberResult + "/" + totalTested})}} 41 |
42 | 43 |
44 |
45 |
46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/app/pages/customfilter/customfilter.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnDestroy, OnInit } from "@angular/core"; 2 | import { TranslateService } from "@ngx-translate/core"; 3 | import axios from "axios"; 4 | import { ToastrService } from "ngx-toastr"; 5 | import { Subscription } from "rxjs"; 6 | import { Beatmap } from "../../../../models/cache"; 7 | import { Collection } from "../../../../models/collection"; 8 | import { CustomFilter } from "../../../../models/filters"; 9 | import { fullIp } from "../../app.component"; 10 | import { ComponentService, Display } from "../../services/component.service"; 11 | import { FilterService } from "../../services/filter.service"; 12 | import { TitleService } from "../../services/title.service"; 13 | import { UtilService } from "../../services/util.service"; 14 | 15 | interface Tested { 16 | status: boolean; 17 | text: string; 18 | } 19 | 20 | @Component({ 21 | selector: "app-customfilter", 22 | templateUrl: "./customfilter.component.html", 23 | }) 24 | export class CustomfilterComponent implements OnInit, OnDestroy { 25 | 26 | public tested: Tested = { status: false, text: "" } 27 | public selected: Collection 28 | private errorSubscription: Subscription 29 | private editSubscription: Subscription 30 | public editFilter: CustomFilter 31 | private oldContent = "" 32 | private oldName = "" 33 | private names: string[] = [] 34 | public content = "resolve(beatmaps)" 35 | public numberResult = 0 36 | public totalTested = 0 37 | public filteredText = "" 38 | public errorText = "" 39 | public rawError = "" 40 | public gettingData = false 41 | public inputValue = "" 42 | public description = "" 43 | public alreadyExists = false 44 | public getHitObjects = false 45 | 46 | public editorOptions = {theme: document.querySelector('html').classList.contains('dark') ? 'vs-dark' : 'vs', language: 'javascript'}; 47 | public resultOptions = {theme: document.querySelector('html').classList.contains('dark') ? 'vs-dark' : 'vs', language: 'json', readOnly: true, } 48 | 49 | constructor(private toastr: ToastrService, 50 | private titleService: TitleService, 51 | private filterService: FilterService, 52 | private componentService: ComponentService, 53 | private utilService: UtilService, 54 | private translateService: TranslateService) { 55 | this.titleService.changeTitle('PAGES.CUSTOM_FILTER'); 56 | } 57 | 58 | ngOnInit(): void { 59 | this.names = this.filterService.getFilters().map(filter => filter.name.toLowerCase()) 60 | 61 | this.errorSubscription = this.filterService.evaluationError.subscribe(error => { 62 | this.rawError = error 63 | this.errorText = JSON.stringify([{error: error}]) 64 | if (error != "") { 65 | this.gettingData = false 66 | } 67 | }) 68 | 69 | this.editSubscription = this.filterService.editCurrent.subscribe(edit => { 70 | if (edit.name) { 71 | this.oldContent = edit.filter 72 | this.oldName = edit.name 73 | this.editFilter = edit 74 | this.inputValue = edit.name 75 | this.description = edit.description 76 | this.content = edit.filter 77 | this.getHitObjects = edit.getHitObjects 78 | this.tested = { text: edit.filter, status: true } 79 | this.names = this.names.filter(name => name !== edit.name.toLowerCase()) 80 | } 81 | }) 82 | } 83 | 84 | ngOnDestroy(): void { 85 | this.errorSubscription.unsubscribe() 86 | this.editSubscription.unsubscribe() 87 | 88 | if (this.editFilter) { 89 | this.filterService.editSource.next({ name: "", filter: "", description: "", isCached: false, getHitObjects: false, numberCached: 0 }) 90 | } 91 | } 92 | 93 | validFilter(): boolean { 94 | return this.alreadyExists || !this.inputValue || this.content != this.tested.text || !this.tested.status 95 | } 96 | 97 | onNameChange(event: string): void { 98 | this.alreadyExists = this.names.includes(this.inputValue.toLowerCase()) 99 | } 100 | 101 | openDoc(): void { 102 | this.utilService.openUrl("https://github.com/nzbasic/Collection-Helper#custom-filters") 103 | } 104 | 105 | openVid(): void { 106 | this.utilService.openUrl("https://youtu.be/ukOA1JCHLLo") 107 | } 108 | 109 | async test() { 110 | 111 | this.filteredText = "" 112 | this.filterService.evaluationErrorSource.next("") 113 | this.gettingData = true 114 | 115 | // check if resolve exists 116 | if (!this.content.match(/resolve\(*.+\)/g)) { 117 | this.filterService.evaluationErrorSource.next("No resolve found") 118 | this.toastr.error('Test failed, resolve was not found in your script', 'Error') 119 | } 120 | 121 | if (this.content.trim() == "") { 122 | this.filterService.evaluationErrorSource.next("Empty!") 123 | this.toastr.error('Test failed, the content field is empty', 'Error') 124 | return 125 | } 126 | 127 | const res = await this.filterService.testFilter(this.content, this.getHitObjects, this.selected?.name??"") 128 | 129 | try { 130 | this.numberResult = JSON.parse(res.filteredText).length 131 | this.totalTested = res.numberTested 132 | this.filteredText = res.filteredText 133 | this.toastr.success('Test was successful, check output for beatmaps matching the filter', 'Success') 134 | this.tested = { text: this.content, status: true } 135 | } catch { 136 | this.filterService.evaluationErrorSource.next(res.filteredText) 137 | this.toastr.error('Test failed, check output for error logs', 'Error') 138 | } 139 | this.gettingData = false 140 | 141 | } 142 | 143 | async save() { 144 | 145 | let filter: CustomFilter = {name: this.inputValue, description: this.description, getHitObjects: this.getHitObjects, filter: this.content, cache: [], numberCached: 0, isCached: false} 146 | 147 | if (this.editFilter) { 148 | 149 | const sameAsOld = this.content == this.oldContent 150 | this.editFilter.name = this.inputValue 151 | this.editFilter.description = this.description 152 | this.editFilter.filter = this.content 153 | 154 | await this.filterService.saveFilter(this.oldName, this.editFilter, sameAsOld) 155 | 156 | if (sameAsOld) { 157 | this.toastr.success('Filter saved', 'Success') 158 | } else { 159 | this.toastr.success('Filter saved, you must generate its cache before using it in map selection', 'Success') 160 | } 161 | 162 | // reset edit observable 163 | this.filterService.editSource.next({ name: "", filter: "", description: "", isCached: false, getHitObjects: false, numberCached: 0 }) 164 | } else { 165 | await this.filterService.addFilter(filter) 166 | this.toastr.success('New filter created, you must generate its cache before using it in map selection.', 'Success') 167 | } 168 | 169 | this.componentService.changeComponent(Display.FILTERS) 170 | } 171 | 172 | onCollectionChange(selected: Collection[]) { 173 | if (selected.length) { 174 | this.selected = selected[0] 175 | } else { 176 | this.selected = null 177 | } 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/app/pages/edit/edit.component.css: -------------------------------------------------------------------------------- 1 | #beatmap-checkbox { 2 | width: 3.75%; 3 | } 4 | 5 | #beatmap-song { 6 | width: 18.9%; 7 | } 8 | 9 | #beatmap-artist, #beatmap-creator { 10 | width: 11.5%; 11 | } 12 | 13 | #beatmap-difficulty { 14 | width: 11.25% 15 | } 16 | 17 | #beatmap-sr, #beatmap-bpm, #beatmap-hp, #beatmap-od, #beatmap-ar, #beatmap-cs { 18 | width: 6.3% 19 | } 20 | 21 | cdk-virtual-scroll-viewport { 22 | height: 35rem !important; 23 | } 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/app/pages/edit/edit.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | {{isCollectionShown ? ('PAGES.EDIT.TABLE1' | translate) : ('PAGES.EDIT.TABLE2' | translate)}} 5 |
6 | 7 |
8 |
9 | 10 | 11 | 12 | 13 | {{'PAGES.EDIT.SELECTED' | translate:({value: selected.size})}} 14 | 15 |
16 |
17 | 18 |
19 | 20 |
21 | 22 | 32 | 33 | 34 |
35 | 39 |
40 | 41 | 42 | {{'PAGES.EDIT.MAP_MISSING' | translate:({value: row.setId})}} 43 | {{limitTextLength(row.song, 20)}} 44 | 45 | {{limitTextLength(row?.artist??"", 10)}} 46 | {{limitTextLength(row?.creator??"", 10)}} 47 | {{limitTextLength(row?.difficulty??"", 10)}} 48 | {{(row?.sr??0).toFixed(3)}} 49 | {{row?.bpm??0}} 50 | {{(row?.hp??0).toFixed(1)}} 51 | {{(row?.od??0).toFixed(1)}} 52 | {{(row?.ar??0).toFixed(1)}} 53 | {{(row?.cs??0).toFixed(1)}} 54 | {{row?.drain??0}} 55 |
56 | 57 |
58 | 59 |
60 | 61 | 64 |
65 | 66 |
67 | 68 | 69 | 70 |
{{isCollectionShown ? ('PAGES.EDIT.LOADING_COLLECTION' | translate) : ('PAGES.EDIT.LOADING_CACHE' | translate)}}
71 | 72 |
73 | 74 | 75 | 76 | {{(isCollectionShown && !selectedCollection.numberMaps) ? ('PAGES.EDIT.NO_MAPS_COLLECTION' | translate) : ('MISC.NO_RESULTS' | translate)}} 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /src/app/pages/filters/filters.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | PAGES.FILTERS.TIPS.ONE 5 | PAGES.FILTERS.TIPS.TWO 6 | PAGES.FILTERS.TIPS.THREE 7 | PAGES.FILTERS.TIPS.FOUR 8 |
9 | 10 |
11 |
12 | 13 | 14 | 15 | {{ 'MISC.SELECTED' | translate:({value: selected.size}) }} 16 |
17 |
18 | 19 |
20 | 21 |
22 | 23 | 30 | 31 | 32 |
33 | 37 |
38 | 39 | 40 | {{limitTextLength(row.name, 30)}} 41 | 42 | 43 | {{row.isCached ? ('BUTTONS.YES' | translate) : ('BUTTONS.NO' | translate)}} 44 | 45 | 46 | {{row.description}} 47 | 48 | 49 |
50 | 51 | 52 | 53 |
54 | 55 |
56 |
57 | 58 | 59 | 60 |
61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /src/app/pages/filters/filters.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnDestroy, OnInit, TemplateRef, ViewChild } from "@angular/core"; 2 | import { TranslateService } from "@ngx-translate/core"; 3 | import { Columns, Config } from "ngx-easy-table"; 4 | import { ToastrService } from "ngx-toastr"; 5 | import { Subscription } from "rxjs"; 6 | import { CustomFilter } from "../../../../models/filters"; 7 | import { ComponentService, Display } from "../../services/component.service"; 8 | import { FilterService } from "../../services/filter.service"; 9 | import { TitleService } from "../../services/title.service"; 10 | import baseConfig from "../../util/baseConfig"; 11 | import { limitTextLength } from "../../util/processing" 12 | 13 | @Component({ 14 | selector: "app-filters", 15 | templateUrl: "./filters.component.html", 16 | }) 17 | export class FiltersComponent implements OnInit, OnDestroy { 18 | 19 | @ViewChild('actionTpl', { static: true }) actionTpl!: TemplateRef; 20 | public filters: CustomFilter[] 21 | public shownFilters: CustomFilter[] 22 | public configuration: Config = JSON.parse(JSON.stringify(baseConfig)); 23 | public columns: Columns[] 24 | public selected: Set = new Set() 25 | private progressSubscription: Subscription 26 | 27 | public isLoading = true 28 | public removeModal = false 29 | public pageNumber = 1 30 | public inputValue = "" 31 | public generatingModal = false 32 | private selectAll = false; 33 | private noResetSelected = false; 34 | public percentage = 0 35 | private toRemove: string[] 36 | public limitTextLength = limitTextLength 37 | 38 | constructor(private toastr: ToastrService, 39 | private titleService: TitleService, 40 | private filterService: FilterService, 41 | private componentService: ComponentService, 42 | private translateService: TranslateService) { 43 | this.titleService.changeTitle('PAGES.FILTERS'); 44 | } 45 | 46 | ngOnInit(): void { 47 | this.progressSubscription = this.filterService.progressCurrent.subscribe(percentage => { 48 | this.percentage = percentage 49 | }) 50 | 51 | this.columns = [ 52 | { key: 'name', title: 'Name', width: '10%' }, 53 | { key: 'isCached', title: 'Cached', width: '5%'}, 54 | { key: 'description', title: 'Description', width: '55%' }, 55 | { key: 'action', title: 'Action', cellTemplate: this.actionTpl, width: '30%'} 56 | ] 57 | 58 | const translateCols = ['name', 'isCached', 'description', 'action'] 59 | this.translateService.get('TABLES').subscribe(res => { 60 | for (const col of translateCols) { 61 | this.columns.find(c => c.key === col).title = res[col.toUpperCase()] 62 | } 63 | }) 64 | 65 | this.configuration.orderEnabled = false 66 | this.filters = this.filterService.getFilters("", 1) 67 | this.configuration.isLoading = false; 68 | } 69 | 70 | ngOnDestroy() { 71 | this.progressSubscription.unsubscribe() 72 | } 73 | 74 | eventEmitted($event: { event: string; value: any }): void { 75 | if ($event.event == "onSelectAll") { 76 | if ($event.value) { 77 | let toAdd = this.filterService.getFilters(this.inputValue); 78 | toAdd.forEach((details, _) => { 79 | if (!this.selected.has(details.name)) { 80 | this.selected.add(details.name); 81 | } 82 | }); 83 | this.selectAll = true; 84 | } else { 85 | if (!this.noResetSelected) { 86 | this.selected = new Set(); 87 | } 88 | this.selectAll = false; 89 | } 90 | } 91 | } 92 | 93 | isChecked(row: CustomFilter): boolean { 94 | return this.selected.has(row.name); 95 | } 96 | 97 | checkBoxSelect(row: CustomFilter): void { 98 | this.selected.has(row.name) ? this.selected.delete(row.name) : this.selected.add(row.name); 99 | } 100 | 101 | pageUpdate(change: number): void { 102 | this.pageNumber = change; 103 | this.filters = this.filterService.getFilters(this.inputValue, this.pageNumber) 104 | } 105 | 106 | numberResults(): number { 107 | return this.filterService.getFilters(this.inputValue).length 108 | } 109 | 110 | onChange(event: Event): void { 111 | this.inputValue = (event.target as HTMLInputElement).value 112 | this.pageNumber = 1 113 | this.filters = this.filterService.getFilters(this.inputValue, 1) 114 | 115 | if (this.selectAll) { 116 | this.noResetSelected = true; 117 | document.getElementById("selectAllCheckboxes").click(); 118 | this.noResetSelected = false; 119 | } 120 | } 121 | 122 | addFilter(): void { 123 | this.componentService.changeComponent(Display.CUSTOM_FILTERS) 124 | } 125 | 126 | showRemoveModal(row: CustomFilter): void { 127 | this.removeModal = true 128 | this.toRemove = this.selectedConversion(row) 129 | } 130 | 131 | async generateCache(row: CustomFilter) { 132 | this.generatingModal = true 133 | await this.filterService.generateCache(this.selectedConversion(row)) 134 | this.filters = this.filterService.getFilters(this.inputValue, this.pageNumber) 135 | this.generatingModal = false 136 | this.toastr.success('Filter cache has been generated', 'Success') 137 | } 138 | 139 | selectedConversion(row: CustomFilter): string[] { 140 | if (row) { 141 | return [row.name] 142 | } else { 143 | return Array.from(this.selected) 144 | } 145 | } 146 | 147 | async removeResponse(status: boolean) { 148 | if (status) { 149 | await this.filterService.removeFilters(this.toRemove) 150 | this.toastr.success('Filter(s) removed', 'Success') 151 | this.filters = this.filterService.getFilters(this.inputValue, this.pageNumber) 152 | this.toRemove.forEach(name => { 153 | if (this.selected.has(name)) { 154 | this.selected.delete(name) 155 | } 156 | }) 157 | } 158 | this.removeModal = false 159 | } 160 | 161 | edit(row: CustomFilter): void { 162 | this.filterService.edit(row) 163 | } 164 | 165 | 166 | } 167 | -------------------------------------------------------------------------------- /src/app/pages/home/home.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | MISC.STREAM_FILTER_UPDATE 5 | MISC.HERE. 6 |
7 | 8 |
9 |
10 | 11 | 12 | 13 | {{ 'MISC.SELECTED' | translate:({value: selected.size}) }} 14 |
15 |
16 | 17 |
18 | 19 |
20 | 21 | 26 | 27 | 28 | 29 |
30 | 34 |
35 | 36 | 37 | 38 | {{row.name}} 39 | 40 | 41 | 42 | {{row.numberMaps}} 43 | 44 | 45 |
46 | 47 | 48 | 49 |
50 | 51 |
52 |
53 | 54 | 55 | 56 |
57 | 58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /src/app/pages/home/home.component.ts: -------------------------------------------------------------------------------- 1 | import { AddResponse } from './../../components/add-modal/add-modal.component'; 2 | import { Component, ComponentFactoryResolver, OnInit, TemplateRef, ViewChild } from "@angular/core"; 3 | import { Collection } from "../../../../models/collection"; 4 | import { TitleService } from "../../services/title.service"; 5 | import { Columns, Config } from "ngx-easy-table"; 6 | import { CollectionsService } from "../../services/collections.service"; 7 | import baseConfig from "../../util/baseConfig"; 8 | import { SelectedService } from "../../services/selected.service"; 9 | import { ComponentService, Display } from "../../services/component.service"; 10 | import { ToastrService } from 'ngx-toastr'; 11 | import { TranslateService } from '@ngx-translate/core'; 12 | 13 | @Component({ 14 | selector: "app-home", 15 | templateUrl: "./home.component.html", 16 | }) 17 | export class HomeComponent implements OnInit { 18 | @ViewChild("actionTpl", { static: true }) actionTpl!: TemplateRef; 19 | @ViewChild("table") table; 20 | public configuration: Config = JSON.parse(JSON.stringify(baseConfig)); 21 | public columns: Columns[] = []; 22 | public selected = new Set(); 23 | public collections: Collection[] = []; 24 | public names = new Set(); 25 | public singleSelected!: Collection; 26 | 27 | public addModal = false; 28 | public renameModal = false; 29 | public removeModal = false; 30 | public mergeModal = false; 31 | 32 | public loading = false 33 | public pageNumber = 1; 34 | private removeType = ""; 35 | public inputValue = ""; 36 | private selectAll = false; 37 | private noResetSelected = false; 38 | 39 | constructor( 40 | private titleService: TitleService, 41 | private collectionsService: CollectionsService, 42 | private selectedService: SelectedService, 43 | private componentService: ComponentService, 44 | private toastr: ToastrService, 45 | private translateService: TranslateService, 46 | ) { 47 | this.titleService.changeTitle('PAGES.HOME'); 48 | } 49 | 50 | ngOnInit() { 51 | this.columns = [ 52 | { key: "Name", title: "Name" }, 53 | { key: "NumberMaps", title: "Number of Maps", searchEnabled: false }, 54 | { key: "action", title: "Action", cellTemplate: this.actionTpl, searchEnabled: false, } 55 | ]; 56 | 57 | const translateCols = ['Name', 'NumberMaps', 'action'] 58 | this.translateService.get('TABLES').subscribe(res => { 59 | for (const col of translateCols) { 60 | this.columns.find(c => c.key === col).title = res[col.toUpperCase()] 61 | } 62 | }) 63 | 64 | this.configuration.orderEnabled = false 65 | this.setCollections() 66 | this.configuration.isLoading = false; 67 | } 68 | 69 | onChange(event: Event): void { 70 | this.inputValue = (event.target as HTMLInputElement).value; 71 | this.pageNumber = 1 72 | this.collections = this.collectionsService.getCollections(this.inputValue, this.pageNumber); 73 | 74 | if (this.selectAll) { 75 | this.noResetSelected = true; 76 | document.getElementById("selectAllCheckboxes").click(); 77 | this.noResetSelected = false; 78 | } 79 | } 80 | 81 | pageUpdate(change: number): void { 82 | this.pageNumber = change; 83 | this.collections = this.collectionsService.getCollections(this.inputValue, this.pageNumber) 84 | } 85 | 86 | numberResults(): number { 87 | return this.collectionsService.getCollections(this.inputValue).length 88 | } 89 | 90 | eventEmitted($event: { event: string; value: any }): void { 91 | if ($event.event == "onSelectAll") { 92 | if ($event.value) { 93 | let toAdd = this.collectionsService.getCollections(this.inputValue); 94 | toAdd.forEach((details, _) => { 95 | if (!this.selected.has(details.name)) { 96 | this.selected.add(details.name); 97 | } 98 | }); 99 | this.selectAll = true; 100 | } else { 101 | if (!this.noResetSelected) { 102 | this.selected = new Set(); 103 | } 104 | this.selectAll = false; 105 | } 106 | } else if ($event.event == "onClick") { 107 | this.singleSelected = $event.value.row; 108 | } 109 | } 110 | 111 | checkBoxSelect(row: Collection): void { 112 | this.selected.has(row.name) ? this.selected.delete(row.name) : this.selected.add(row.name); 113 | } 114 | 115 | select(row: Collection): void { 116 | this.selectedService.changeSelected(row); 117 | this.componentService.changeComponent(Display.EDIT); 118 | } 119 | 120 | isChecked(row: Collection): boolean { 121 | return this.selected.has(row.name); 122 | } 123 | 124 | showAddModal() { this.addModal = true } 125 | showMergeModal() { this.mergeModal = true } 126 | showRenameModal() { this.renameModal = true } 127 | showRemoveModal(type: string): void { 128 | this.removeModal = true; 129 | this.removeType = type; 130 | } 131 | 132 | setCollections(noResetPage?: boolean) { 133 | if (!noResetPage) { 134 | this.pageNumber = 1 135 | } 136 | this.collections = this.collectionsService.getCollections(this.inputValue, this.pageNumber), 137 | this.names = new Set(this.collections.map((collection) => collection.name.toLowerCase())); 138 | } 139 | 140 | async addResponse(res: AddResponse) { 141 | this.addModal = false 142 | if (res.name) { 143 | this.loading = true 144 | await this.collectionsService.addCollection(res.name, res.hashes) 145 | this.toastr.success('The new collection has been written', 'Success') 146 | this.setCollections() 147 | this.loading = false 148 | } 149 | } 150 | 151 | async mergeResponse(res: string) { 152 | this.mergeModal = false 153 | if (res) { 154 | this.loading = true 155 | await this.collectionsService.mergeCollections(res, Array.from(this.selected)) 156 | this.toastr.success('The selected collections have been merged into a new collection', 'Success') 157 | this.setCollections() 158 | this.loading = false 159 | } 160 | this.selected = new Set() 161 | } 162 | 163 | async renameResponse(name: string) { 164 | this.renameModal = false 165 | if (name) { 166 | this.loading = true 167 | await this.collectionsService.renameCollection(this.singleSelected.name, name) 168 | this.toastr.success('Your collection has been renamed', 'Success') 169 | this.setCollections(true) 170 | this.loading = false 171 | } 172 | } 173 | 174 | async removeResponse(status: boolean) { 175 | this.removeModal = false; 176 | if (status) { 177 | this.loading = true 178 | let toRemove = this.removeType == "Mass" ? Array.from(this.selected) : Array.from([this.singleSelected.name]); 179 | if (this.removeType == "Mass") { this.selected = new Set() } 180 | await this.collectionsService.removeCollections(toRemove); 181 | this.toastr.success('Removed collection(s)', 'Success') 182 | this.setCollections() 183 | this.loading = false 184 | } 185 | this.removeType = ""; 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/app/pages/importexport/importexport.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | PAGES.IMPORT.TITLE 6 |
7 |
8 | PAGES.IMPORT.TIPS.ONE 9 | 10 | PAGES.IMPORT.TIPS.TWO 11 | MISC.VIDEO_GUIDE 12 | 13 | 14 |
15 | PAGES.IMPORT.MULTIPLE 16 | 17 |
18 | 19 |
20 | 21 |
22 | 23 |
24 |
25 | PAGES.IMPORT.NAME 26 | 27 | MISC.ALREADY_EXISTS 28 |
29 |
30 | PAGES.IMPORT.OPEN 31 | 32 |
33 |
34 |
35 |
36 | 37 | 38 |
39 | 40 |
41 |
42 | PAGES.EXPORT.TITLE 43 |
44 |
45 | PAGES.EXPORT.TIPS.ONE 46 | PAGES.EXPORT.TIPS.TWO 47 | PAGES.EXPORT.TIPS.THREE 48 | PAGES.EXPORT.TIPS.FOUR 49 |
50 | PAGES.EXPORT.SELECT 51 | 52 |
53 |
54 | PAGES.EXPORT.EXPORT_WITH 55 | 56 |
57 |
58 | 59 | {{'MISC.ESTIMATED_OUTPUT' | translate:({value: estimatedSize})}} 60 | 61 |
62 |
63 |
64 |
65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /src/app/pages/importexport/importexport.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnDestroy, OnInit } from "@angular/core"; 2 | import { ToastrService } from "ngx-toastr"; 3 | import { Subscription } from "rxjs"; 4 | import { runInThisContext } from "vm"; 5 | import { Collection } from "../../../../models/collection"; 6 | import { SelectCollection } from "../../components/collection-dropdown/collection-dropdown.component"; 7 | import { CollectionsService } from "../../services/collections.service"; 8 | import { TitleService } from "../../services/title.service"; 9 | import { UtilService } from "../../services/util.service"; 10 | import bytes from 'bytes'; 11 | import { TranslateService } from "@ngx-translate/core"; 12 | 13 | @Component({ 14 | selector: "app-importexport", 15 | templateUrl: "./importexport.component.html", 16 | }) 17 | export class ImportexportComponent implements OnInit, OnDestroy { 18 | 19 | public selected: Collection[] = [] 20 | private collections: Collection[] 21 | private percentageSubscription: Subscription 22 | private multipleImportExportSubscription: Subscription 23 | public multipleNumberProgress = 1; 24 | public exportBeatmaps = true 25 | public newName = "" 26 | public estimatedSize = "" 27 | public exporting = false 28 | public percentage = 0 29 | public warning = false 30 | public importing = false 31 | public importMultiple = false; 32 | 33 | public lines = [ 34 | "You will need to launch/relaunch osu! AND refresh cache in this client for new maps or new collections to load.", 35 | ]; 36 | 37 | constructor(private titleService: TitleService, 38 | private collectionService: CollectionsService, 39 | private toastr: ToastrService, 40 | private utilService: UtilService, 41 | private translateService: TranslateService) { 42 | this.titleService.changeTitle('PAGES.IMPORT_EXPORT'); 43 | this.collections = this.collectionService.getCollections() 44 | } 45 | 46 | ngOnInit(): void { 47 | this.percentageSubscription = this.collectionService.progressCurrent.subscribe(progress => { 48 | this.percentage = progress 49 | }) 50 | 51 | this.multipleImportExportSubscription = this.collectionService.multipleImportExport.subscribe(number => { 52 | this.multipleNumberProgress = number 53 | }) 54 | } 55 | 56 | ngOnDestroy(): void { 57 | this.percentageSubscription.unsubscribe() 58 | this.multipleImportExportSubscription.unsubscribe() 59 | } 60 | 61 | exists(): boolean { 62 | return this.collections.find(item => item.name == this.newName) != undefined 63 | } 64 | 65 | export(): void { 66 | this.exporting = true 67 | this.collectionService.exportCollection(this.selected, this.exportBeatmaps).then((res) => { 68 | if (res) { 69 | this.toastr.success("Collection exported", "Success") 70 | } 71 | this.exporting = false 72 | }) 73 | } 74 | 75 | exportCollectionDetails(): void { 76 | this.collectionService.exportCollectionDetails(this.selected).then((res) => { 77 | if (res) { 78 | this.toastr.success("Collection exported", "Success") 79 | } 80 | }) 81 | } 82 | 83 | import(): void { 84 | this.exporting = false 85 | this.importing = true 86 | this.collectionService.importCollection(this.newName, this.importMultiple).then((res) => { 87 | if (typeof res === "string") { 88 | this.toastr.error(res, "Error") 89 | } else { 90 | if (res) { 91 | this.toastr.success("Collection imported", "Success") 92 | this.newName = "" 93 | this.warning = true 94 | } 95 | this.importing = false 96 | } 97 | }) 98 | } 99 | 100 | async onChange(selected: Collection[]) { 101 | this.selected = selected 102 | this.calculateSize() 103 | } 104 | 105 | async selectExportBeatmaps() { 106 | this.calculateSize() 107 | } 108 | 109 | async calculateSize() { 110 | if (this.selected.length) { 111 | this.estimatedSize = "" 112 | let size = 0; 113 | for (const collection of this.selected) { 114 | size += await this.collectionService.getEstimatedSize(collection, this.exportBeatmaps) 115 | } 116 | this.estimatedSize = bytes(size) 117 | } else { 118 | this.estimatedSize = "" 119 | } 120 | } 121 | 122 | hideWarning(): void { 123 | this.warning = false 124 | } 125 | 126 | openCollectionsDrive() { 127 | this.utilService.openUrl("https://drive.google.com/drive/folders/1PBtYMH5EmrAezc8wQdLd8cMiozddYhRZ?usp=sharing") 128 | } 129 | 130 | openImportVideo() { 131 | this.utilService.openUrl("https://www.youtube.com/watch?v=Su9Av0jSX_U") 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/app/pages/loading/loading.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/app/pages/loading/loading.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from "@angular/core"; 2 | import { TranslateService } from "@ngx-translate/core"; 3 | import { TitleService } from "../../services/title.service"; 4 | 5 | @Component({ 6 | selector: "app-loading", 7 | templateUrl: "./loading.component.html", 8 | }) 9 | export class LoadingComponent implements OnInit { 10 | constructor(private titleService: TitleService) { 11 | this.titleService.changeTitle('PAGES.LOADING'); 12 | } 13 | 14 | ngOnInit(): void {} 15 | } 16 | -------------------------------------------------------------------------------- /src/app/pages/practice-diffs/practice-diffs.component.html: -------------------------------------------------------------------------------- 1 |
2 | PAGES.PRACTICE.TIPS.ONE 3 | PAGES.PRACTICE.TIPS.TWO 4 | PAGES.PRACTICE.TIPS.THREE 5 | PAGES.PRACTICE.TIPS.FOUR 6 | PAGES.PRACTICE.TIPS.FIVE 7 | PAGES.PRACTICE.TIPS.SIX 8 |
9 | PAGES.PRACTICE.COLLECTION 10 | 11 |
12 |
13 | PAGES.PRACTICE.PREFERRED 14 | 15 |
16 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/app/pages/practice-diffs/practice-diffs.component.ts: -------------------------------------------------------------------------------- 1 | import { TitleService } from './../../services/title.service'; 2 | import { Component, OnDestroy, OnInit } from '@angular/core'; 3 | import { ToastrService } from 'ngx-toastr'; 4 | import { Subscription } from 'rxjs'; 5 | import { Collection } from '../../../../models/collection'; 6 | import { CollectionsService } from '../../services/collections.service'; 7 | import { TranslateService } from '@ngx-translate/core'; 8 | 9 | @Component({ 10 | selector: 'app-practice-diffs', 11 | templateUrl: './practice-diffs.component.html' 12 | }) 13 | export class PracticeDiffsComponent implements OnInit, OnDestroy { 14 | 15 | public selected: Collection; 16 | public length = "30"; 17 | public percentage = 0 18 | public generatingModal = false 19 | public warning = false 20 | private progressSubscription: Subscription 21 | 22 | public lines = [ 23 | "A new collection has been created, you will need to launch/relaunch osu! (possibly multiple times) for it to load properly.", 24 | ]; 25 | 26 | constructor(private collectionsService: CollectionsService, 27 | private toastr: ToastrService, 28 | private titleService: TitleService, 29 | private translateService: TranslateService) { 30 | this.titleService.changeTitle('PAGES.PRACTICE'); 31 | } 32 | 33 | ngOnInit(): void { 34 | this.progressSubscription = this.collectionsService.progressCurrent.subscribe(percentage => { 35 | this.percentage = percentage 36 | }) 37 | } 38 | 39 | ngOnDestroy(): void { 40 | this.progressSubscription.unsubscribe() 41 | } 42 | 43 | lengthChange(event) { 44 | const separator = '^([0-9])'; 45 | const maskSeparator = new RegExp(separator , 'g'); 46 | const result = maskSeparator.test(event.key); 47 | return result; 48 | } 49 | 50 | onChange(selected: Collection[]) { 51 | if (selected.length) { 52 | this.selected = selected[0]; 53 | } else { 54 | this.selected = null; 55 | } 56 | } 57 | 58 | async generate() { 59 | this.generatingModal = true 60 | await this.collectionsService.generatePracticeDiffs(this.selected, parseInt(this.length)); 61 | this.generatingModal = false 62 | this.warning = true 63 | this.toastr.success("Practice difficulties created!", "Success") 64 | } 65 | 66 | hideWarning(): void { 67 | this.warning = false 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /src/app/pages/settings/settings.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | PAGES.SETTINGS.TITLE 4 |
5 |
6 | {{'MISC.PATH' | translate}} {{osuPath}} 7 | 8 |
9 |
10 | PAGES.SETTINGS.DARK 11 | 12 |
13 |
14 | PAGES.SETTINGS.BACKUP 15 | 16 |
17 |
18 | PAGES.SETTINGS.LANGUAGE 19 |
20 | 21 |
22 | 23 |
24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /src/app/pages/settings/settings.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnDestroy, OnInit } from "@angular/core"; 2 | import { TranslateService } from "@ngx-translate/core"; 3 | import axios from "axios"; 4 | import { ToastrService } from "ngx-toastr"; 5 | import { Subscription } from "rxjs"; 6 | import { fullIp } from "../../app.component"; 7 | import { ComponentService } from "../../services/component.service"; 8 | import { LoadingService } from "../../services/loading.service"; 9 | import { TitleService } from "../../services/title.service"; 10 | 11 | @Component({ 12 | selector: "app-settings", 13 | templateUrl: "./settings.component.html", 14 | }) 15 | export class SettingsComponent implements OnInit, OnDestroy { 16 | 17 | public osuPath = "" 18 | public isChange = false 19 | private pathSubscription: Subscription 20 | public darkModeSubscription: Subscription; 21 | public darkMode = false 22 | 23 | constructor(private titleService: TitleService, 24 | private loadingService: LoadingService, 25 | private toastr: ToastrService, 26 | private translateService: TranslateService) { 27 | this.titleService.changeTitle('PAGES.SETTINGS'); 28 | } 29 | 30 | ngOnInit(): void { 31 | this.pathSubscription = this.loadingService.settingsCurrent.subscribe(path => { 32 | this.osuPath = path; 33 | }) 34 | 35 | this.darkModeSubscription = this.loadingService.darkModeCurrent.subscribe(mode => { 36 | this.darkMode = mode 37 | }) 38 | } 39 | 40 | ngOnDestroy(): void { 41 | this.pathSubscription.unsubscribe(); 42 | this.darkModeSubscription.unsubscribe(); 43 | } 44 | 45 | changePath() { 46 | this.isChange = true 47 | } 48 | 49 | closePath() { 50 | this.isChange = false 51 | } 52 | 53 | changeStyle(mode: boolean) { 54 | this.loadingService.setDarkMode(mode) 55 | } 56 | 57 | backup() { 58 | this.loadingService.createBackup() 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/app/services/beatmap.service.ts: -------------------------------------------------------------------------------- 1 | import { FilterDetail, GetBeatmapsReq, GetSelectedReq, PageResponse, Sorting } from './../../../models/beatmaps'; 2 | import { Injectable } from '@angular/core'; 3 | import { Filter } from '../../../models/beatmaps'; 4 | import axios from 'axios'; 5 | import { fullIp } from '../app.component'; 6 | 7 | @Injectable({ 8 | providedIn: 'root' 9 | }) 10 | export class BeatmapService { 11 | 12 | async getBeatmaps(page: number, search: string, name: string, force: boolean, order: Sorting, getCollection: boolean, customFilters?: string[], infiniteScroll?: boolean,): Promise { 13 | let filter = this.parseFilter(search) 14 | let req: GetBeatmapsReq = { page: page, filter: filter, name: name, force: force, order: order, getCollection: getCollection, infiniteScroll: !!infiniteScroll } 15 | 16 | if (customFilters.length) { 17 | req.customFilters = customFilters 18 | } 19 | 20 | let res = await axios.post(fullIp + "/beatmaps/", req) 21 | return res.data 22 | } 23 | 24 | async getSelectedList(search: string, name: string, force: boolean, customFilters?: string[]): Promise { 25 | let filter = this.parseFilter(search) 26 | let req: GetSelectedReq = { filter: filter, name: name, force: force } 27 | 28 | if (customFilters.length) { 29 | req.customFilters = customFilters 30 | } 31 | 32 | let res = await axios.post(fullIp + "/beatmaps/selectedList", req) 33 | return res.data 34 | } 35 | 36 | private parseFilter(search: string): Filter { 37 | let split = search.split(" ") 38 | let filter: Filter = {text: "", filters: [], mods: []} 39 | 40 | // warning regex ahead 41 | split.forEach(word => { 42 | word = word.toLowerCase() 43 | if (word.match(/^\w*(<|<=|>|>=|==|=|!=)\d+\.?\d*$/g)) { // this line matches word operator number e.g. length<120 44 | 45 | let type = word.match(/^\w+/g)[0] // gets the word before operator 46 | let symbol = word.match(/(<|<=|>|>=|==|=|!=)/g)[0] // gets operator 47 | let number = parseFloat(word.match(/\d+\.?\d*$/g)[0]) // gets number after operator 48 | 49 | if (type == "length") { type = "drain" } 50 | if (type == "stars") { type = "sr" } 51 | 52 | let filterDetail: FilterDetail = {type: "Numeric", valueNumber: number, operator: symbol, filtering: type} 53 | 54 | if (type == "year" ) { 55 | filterDetail = { type: "Year", filtering: "setId", valueNumber: number, operator: symbol } 56 | } 57 | 58 | if (type == "keys") { 59 | filterDetail.filtering = "cs"; 60 | filter.filters.push({ type: "Numeric", valueNumber: 3, operator: "==", filtering: "mode" }) 61 | } 62 | 63 | filter.filters.push(filterDetail) 64 | 65 | } else if (word.match(/^\w*(==|=|!=)\w+$/g)) { // this line matches word operator word e.g. artist=xanthochroid 66 | 67 | let type = word.match(/^\w+/g)[0] // gets the word before operator 68 | let operator = word.match(/(==|=|!=)/g)[0] // gets operator 69 | let term = word.match(/\w+$/g)[0] // gets word after operator 70 | 71 | if (type == "version") { type = "difficulty" } 72 | 73 | let filterDetail: FilterDetail = {type: "Text", filtering: type, valueString: term, operator: operator} 74 | 75 | if (type == "mode" ) { 76 | filterDetail = { type: "Mode", filtering: "mode", valueString: term, operator: operator } 77 | } 78 | 79 | if (type == "status") { 80 | filterDetail.type = "Status" 81 | } 82 | 83 | filter.filters.push(filterDetail) 84 | 85 | } else if (word == "unplayed" || word == "!unplayed") { // special filter for single word boolean 86 | 87 | let operator = "==" 88 | if (word.startsWith("!")) { 89 | operator = "!" 90 | } 91 | filter.filters.push({type: "Unplayed", operator: operator}) 92 | 93 | } else if (word.match(/[+]\w+/g)) { // filter for applying mods to SR 94 | let mods = word.match(/[+]\w+/g)[0] 95 | for (let i=1; i(0) 14 | progressCurrent = this.progressSource.asObservable() 15 | 16 | private multipleImportExportSource = new BehaviorSubject(1) 17 | multipleImportExport = this.multipleImportExportSource.asObservable() 18 | 19 | setCollections(collections: Collections): void { 20 | this.collections = collections 21 | } 22 | 23 | getCollections(filter?: string, pageNumber?: number): Collection[] { 24 | this.collections.collections = this.collections.collections.sort((a,b) => a.name.localeCompare(b.name)) 25 | let output = this.collections.collections 26 | if (filter) { 27 | output = output.filter((collection) => 28 | collection.name.toLowerCase().includes(filter.toLowerCase()) 29 | ); 30 | } 31 | 32 | if (pageNumber) { 33 | output = output.slice((pageNumber - 1) * 10, pageNumber * 10); 34 | } 35 | 36 | return output 37 | } 38 | 39 | async removeCollections(names: string[]): Promise { 40 | this.collections = (await axios.post(fullIp + "/collections/remove", names)).data 41 | } 42 | 43 | async addCollection(name: string, hashes: string[]): Promise { 44 | this.collections = (await axios.post(fullIp + "/collections/add", {name: name, hashes: hashes})).data 45 | } 46 | 47 | async mergeCollections(newName: string, names: string[]): Promise { 48 | this.collections = (await axios.post(fullIp + "/collections/merge", { newName: newName, names: names })).data 49 | } 50 | 51 | async renameCollection(oldName: string, newName: string): Promise { 52 | this.collections = (await axios.post(fullIp + "/collections/rename", { oldName: oldName, newName: newName })).data 53 | } 54 | 55 | async addMaps(hashes: string[], name: string): Promise { 56 | this.collections = (await axios.post(fullIp + "/collections/addMaps", { name: name, hashes: hashes })).data 57 | return this.collections.collections.find(collection => collection.name == name) 58 | } 59 | 60 | async removeMaps(hashes: string[], name: string): Promise { 61 | this.collections = (await axios.post(fullIp + "/collections/removeMaps", { name: name, hashes: hashes })).data 62 | return this.collections.collections.find(collection => collection.name == name) 63 | } 64 | 65 | async exportCollection(collections: Collection[], exportBeatmaps: boolean) { 66 | for (let i = 0; i < collections.length; i++) { 67 | this.multipleImportExportSource.next(i+1) 68 | const collection = collections[i] 69 | let progressInterval = setInterval(async () => { 70 | let progress = await axios.get(fullIp + "/collections/exportProgress") 71 | this.progressSource.next(progress.data) 72 | }, 200) 73 | 74 | let dialogRes = (await axios.post(fullIp + "/collections/export", { name: collection.name, exportBeatmaps: exportBeatmaps, multiple: collections.length > 1, last: i == collections.length-1 })).data 75 | clearInterval(progressInterval) 76 | this.progressSource.next(0) 77 | if (dialogRes.canceled) { 78 | return false 79 | } 80 | } 81 | return 82 | } 83 | 84 | async exportCollectionDetails(collections: Collection[]) { 85 | const dialogRes = (await axios.post(fullIp + "/collections/exportDetails", { collections: collections })).data 86 | return !dialogRes.canceled 87 | } 88 | 89 | async importCollection(name: string, multiple: boolean): Promise { 90 | let progressInterval = setInterval(async () => { 91 | let progress = await axios.get(fullIp + "/collections/importProgress") 92 | this.progressSource.next(progress.data) 93 | }, 200) 94 | 95 | let res = (await axios.post(fullIp + "/collections/import", { name: name, multiple: multiple })).data 96 | clearInterval(progressInterval) 97 | this.progressSource.next(0) 98 | 99 | if (res.error) { 100 | return res.error 101 | } 102 | 103 | if (res.collections.collections.length == this.collections.collections.length) { 104 | return false 105 | } 106 | 107 | this.setCollections(res.collections) 108 | return true 109 | } 110 | 111 | async getSetCount(hashes: string[]): Promise { 112 | return (await axios.post(fullIp + "/collections/setCount", { hashes: hashes })).data 113 | } 114 | 115 | async getEstimatedSize(collection: Collection, exportBeatmaps: boolean): Promise { 116 | let size = 28672 117 | const setCount = await this.getSetCount(collection.hashes) 118 | 119 | if (exportBeatmaps) { 120 | // 21500 map sets = 207,660,670,976 bytes 121 | // average map set = 9658635 bytes 122 | size = size + (setCount * 9658635) 123 | } else { 124 | // 21500 map sets = 13,111,296 bytes 125 | // average map set = 609 126 | size = size + (setCount * 609) 127 | } 128 | 129 | return size 130 | } 131 | 132 | async generatePracticeDiffs(collection: Collection, prefLength: number) { 133 | let progressInterval = setInterval(async () => { 134 | let progress = await axios.get(fullIp + "/collections/generationProgress") 135 | this.progressSource.next(progress.data) 136 | }, 200) 137 | await axios.post(fullIp + "/collections/generatePracticeDiffs", { collection: collection, prefLength: prefLength }) 138 | clearInterval(progressInterval) 139 | this.progressSource.next(0) 140 | } 141 | 142 | async generateBPM(collection: Collection, options: BpmChangerOptions) { 143 | let progressInterval = setInterval(async () => { 144 | let progress = await axios.get(fullIp + "/collections/bpmGenerationProgress") 145 | this.progressSource.next(progress.data) 146 | }, 200) 147 | await axios.post(fullIp + "/collections/generateBPMChanges", { collection: collection, options: options }) 148 | clearInterval(progressInterval) 149 | this.progressSource.next(0) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/app/services/component.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@angular/core"; 2 | import { BehaviorSubject } from "rxjs"; 3 | 4 | export enum Display { 5 | COLLECTIONS, 6 | EDIT, 7 | LOADING, 8 | SETUP, 9 | FILTERS, 10 | CUSTOM_FILTERS, 11 | IMPORT_EXPORT, 12 | SETTINGS, 13 | PRACTICE_DIFF_GENERATOR, 14 | BPM_CHANGER 15 | } 16 | 17 | @Injectable({ 18 | providedIn: "root", 19 | }) 20 | export class ComponentService { 21 | private componentSource = new BehaviorSubject(Display.LOADING); 22 | componentSelected = this.componentSource.asObservable(); 23 | 24 | changeComponent(display: Display) { 25 | this.componentSource.next(display); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app/services/filter.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import axios from 'axios'; 3 | import { BehaviorSubject } from 'rxjs'; 4 | import { Beatmap } from '../../../models/cache'; 5 | import { CustomFilter } from '../../../models/filters'; 6 | import { fullIp } from '../app.component'; 7 | import { ComponentService, Display } from './component.service'; 8 | 9 | @Injectable({ 10 | providedIn: 'root' 11 | }) 12 | export class FilterService { 13 | 14 | private filters: CustomFilter[] 15 | 16 | private filterNumberSource = new BehaviorSubject(0) 17 | filterNumber = this.filterNumberSource.asObservable() 18 | 19 | private progressSource = new BehaviorSubject(0) 20 | progressCurrent = this.progressSource.asObservable() 21 | 22 | public evaluationErrorSource = new BehaviorSubject("") 23 | evaluationError = this.evaluationErrorSource.asObservable() 24 | 25 | public editSource = new BehaviorSubject({ name: "", filter: "", description: "", isCached: false, getHitObjects: false, numberCached: 0 }) 26 | editCurrent = this.editSource.asObservable() 27 | 28 | constructor(private componentService: ComponentService) {} 29 | 30 | setFilters(filters: CustomFilter[]) { 31 | this.filters = filters 32 | this.filterNumberSource.next(filters.filter(filter => filter.isCached).length) 33 | } 34 | 35 | getFilters(filterString?: string, pageNumber?: number): CustomFilter[] { 36 | let output = this.filters 37 | 38 | if (filterString) { 39 | output = output.filter((customFilter) => 40 | customFilter.name.toLowerCase().includes(filterString.toLowerCase()) 41 | ); 42 | } 43 | 44 | if (pageNumber) { 45 | output = output.slice((pageNumber - 1) * 10, pageNumber * 10); 46 | } 47 | 48 | return output 49 | } 50 | 51 | async addFilter(filter: CustomFilter) { 52 | let res = await axios.post(fullIp + "/filters/add", filter) 53 | this.setFilters(res.data) 54 | } 55 | 56 | async removeFilters(names: string[]) { 57 | let res = await axios.post(fullIp + "/filters/remove", names) 58 | this.setFilters(res.data) 59 | } 60 | 61 | async testFilter(filter: string, getHitObjects: boolean, name: string) { 62 | let res = await axios.post(fullIp + "/filters/testFilter", { filter: filter, getHitObjects: getHitObjects, name: name }) 63 | let data: { filteredText: string, numberTested: number } = res.data 64 | return data 65 | } 66 | 67 | async generateCache(names: string[]) { 68 | 69 | let progressInterval = setInterval(async () => { 70 | let progress = await axios.get(fullIp + "/filters/progress") 71 | this.progressSource.next(progress.data) 72 | }, 200) 73 | 74 | let res = await axios.post(fullIp + "/filters/generateCache", names) 75 | clearInterval(progressInterval) 76 | this.setFilters(res.data) 77 | return 78 | } 79 | 80 | edit(row: CustomFilter) { 81 | this.editSource.next(row) 82 | this.componentService.changeComponent(Display.CUSTOM_FILTERS) 83 | } 84 | 85 | async saveFilter(oldName: string, filter: CustomFilter, sameAsOld?: boolean) { 86 | let res = await axios.post(fullIp + "/filters/save", { oldName: oldName, filter: filter, sameAsOld: sameAsOld??false }) 87 | this.setFilters(res.data) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/app/services/ipc.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { IpcRenderer } from 'electron'; 3 | 4 | @Injectable({ 5 | providedIn: 'root' 6 | }) 7 | export class IpcService { 8 | 9 | private ipc: IpcRenderer | undefined = void 0; 10 | 11 | constructor() { 12 | if ((window as any).require) { 13 | try { 14 | this.ipc = (window as any).require('electron').ipcRenderer; 15 | } catch (e) { 16 | throw e; 17 | } 18 | } else { 19 | console.warn('Electron IPC was not loaded'); 20 | } 21 | } 22 | 23 | public on(channel: string, listener: any): boolean { 24 | if (!this.ipc) { 25 | return false 26 | } 27 | 28 | this.ipc.on(channel, listener); 29 | return true 30 | } 31 | 32 | public send(channel: string, ...args: any[]): boolean { 33 | if (!this.ipc) { 34 | return false 35 | } 36 | 37 | this.ipc.send(channel, ...args); 38 | return true 39 | } 40 | 41 | public invoke(channel: string, ...args: any[]): Promise { 42 | if (!this.ipc) { 43 | return new Promise((res) => res(false)) 44 | } 45 | return this.ipc.invoke(channel, ...args) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/app/services/loading.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@angular/core"; 2 | import { TranslateService } from "@ngx-translate/core"; 3 | import axios from "axios"; 4 | import { ToastrService } from "ngx-toastr"; 5 | import { BehaviorSubject } from "rxjs"; 6 | import { Collections } from "../../../models/collection"; 7 | import { CustomFilter } from "../../../models/filters"; 8 | import { fullIp } from "../app.component"; 9 | import { CollectionsService } from "./collections.service"; 10 | import { ComponentService, Display } from "./component.service"; 11 | import { FilterService } from "./filter.service"; 12 | 13 | @Injectable({ 14 | providedIn: "root", 15 | }) 16 | export class LoadingService { 17 | constructor( 18 | private filterService: FilterService, 19 | private componentService: ComponentService, 20 | private collectionsService: CollectionsService, 21 | private translationService: TranslateService, 22 | private toastr: ToastrService 23 | ) {} 24 | 25 | public darkModeSource = new BehaviorSubject(false); 26 | darkModeCurrent = this.darkModeSource.asObservable(); 27 | 28 | public loadingSource = new BehaviorSubject(0); 29 | loadingCurrent = this.loadingSource.asObservable(); 30 | 31 | public settingsSource = new BehaviorSubject("") 32 | settingsCurrent = this.settingsSource.asObservable(); 33 | 34 | async loadData() { 35 | this.componentService.changeComponent(Display.LOADING); 36 | const res = await axios.post(fullIp + "/loadFiles") 37 | 38 | if (res.data === -1) { 39 | return false 40 | } 41 | 42 | this.collectionsService.setCollections((await axios.get(fullIp + "/collections")).data) 43 | this.filterService.setFilters((await axios.get(fullIp + "/filters")).data) 44 | this.componentService.changeComponent(Display.COLLECTIONS); 45 | 46 | return true 47 | } 48 | 49 | async loadSettings() { 50 | 51 | this.componentService.changeComponent(Display.LOADING); 52 | const settings = (await axios.get(fullIp + "/loadSettings")).data 53 | 54 | this.darkModeSource.next(settings.darkMode) 55 | 56 | if (settings.darkMode) { 57 | document.querySelector('html').classList.add('dark') 58 | } 59 | 60 | if (settings.path) { 61 | this.settingsSource.next(settings.path); 62 | const validPath = await this.loadData(); 63 | if (!validPath) { 64 | this.componentService.changeComponent(Display.SETUP); 65 | } 66 | } else { 67 | this.componentService.changeComponent(Display.SETUP); 68 | } 69 | 70 | if (settings.code) { 71 | this.translationService.use(settings.code) 72 | } 73 | } 74 | 75 | async setDarkMode(mode: boolean) { 76 | if (mode) { 77 | document.querySelector('html').classList.add('dark') 78 | } else { 79 | document.querySelector('html').classList.remove('dark') 80 | } 81 | 82 | this.darkModeSource.next(mode) 83 | axios.post(fullIp + "/darkMode", { mode: document.querySelector('html').classList.contains('dark') }) 84 | } 85 | 86 | async createBackup() { 87 | const timeRes = await axios.post(fullIp + "/createBackup") 88 | this.toastr.success("collection" + timeRes.data + ".db created in your osu! path.", "Success") 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /src/app/services/selected.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@angular/core"; 2 | import { BehaviorSubject } from "rxjs"; 3 | import { Collection } from "../../../models/collection"; 4 | 5 | @Injectable({ 6 | providedIn: "root", 7 | }) 8 | export class SelectedService { 9 | public selectedSource = new BehaviorSubject({ 10 | name: "", 11 | numberMaps: 0, 12 | hashes: [], 13 | }); 14 | currentSelected = this.selectedSource.asObservable(); 15 | 16 | changeSelected(selected: Collection) { 17 | this.selectedSource.next(selected); 18 | } 19 | 20 | clearSelected() { 21 | this.selectedSource.next({ 22 | name: "", 23 | numberMaps: 0, 24 | hashes: [], 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app/services/title.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@angular/core"; 2 | import { LangChangeEvent, TranslateService } from "@ngx-translate/core"; 3 | import { BehaviorSubject } from "rxjs"; 4 | 5 | export interface Title { 6 | title: string; 7 | subtitle: string; 8 | } 9 | 10 | @Injectable({ 11 | providedIn: "root", 12 | }) 13 | export class TitleService { 14 | constructor(private translateService: TranslateService) {} 15 | 16 | defaultTitle: Title = { title: "Loading", subtitle: "Loading your data" }; 17 | 18 | private titleSource = new BehaviorSubject(this.defaultTitle); 19 | currentTitle = this.titleSource.asObservable(); 20 | 21 | changeTitle(page: string) { 22 | this.updateTranslation(page) 23 | this.translateService.onLangChange.subscribe((event: LangChangeEvent) => { 24 | this.updateTranslation(page); 25 | }); 26 | } 27 | 28 | updateTranslation(page: string) { 29 | this.translateService.get([page + '.TITLE', page + ".SUBTITLE"]).subscribe((res: string[]) => { 30 | const title: Title = { 31 | title: res[page + '.TITLE'], 32 | subtitle: res[page + ".SUBTITLE"] 33 | } 34 | 35 | this.titleSource.next(title); 36 | }) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/app/services/util.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import axios from 'axios'; 3 | import { fullIp } from '../app.component'; 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class UtilService { 9 | openUrl(url: string) { 10 | axios.post(fullIp + "/openUrl", { url: url }) 11 | } 12 | 13 | setLanguage(code: string) { 14 | axios.post(fullIp + "/setLanguage", { code }) 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/app/shared/directives/index.ts: -------------------------------------------------------------------------------- 1 | export * from './webview/webview.directive'; 2 | -------------------------------------------------------------------------------- /src/app/shared/directives/webview/webview.directive.spec.ts: -------------------------------------------------------------------------------- 1 | import { WebviewDirective } from './webview.directive'; 2 | 3 | describe('WebviewDirective', () => { 4 | it('should create an instance', () => { 5 | const directive = new WebviewDirective(); 6 | expect(directive).toBeTruthy(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/app/shared/directives/webview/webview.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive } from '@angular/core'; 2 | 3 | @Directive({ 4 | selector: 'webview' 5 | }) 6 | export class WebviewDirective { 7 | constructor() { } 8 | } 9 | -------------------------------------------------------------------------------- /src/app/shared/shared.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from "@angular/core"; 2 | import { CommonModule } from "@angular/common"; 3 | 4 | import { TranslateModule } from "@ngx-translate/core"; 5 | 6 | import { WebviewDirective } from "./directives/"; 7 | import { FormsModule } from "@angular/forms"; 8 | 9 | @NgModule({ 10 | declarations: [WebviewDirective], 11 | imports: [CommonModule, TranslateModule, FormsModule], 12 | exports: [TranslateModule, WebviewDirective, FormsModule], 13 | }) 14 | export class SharedModule {} 15 | -------------------------------------------------------------------------------- /src/app/util/baseConfig.ts: -------------------------------------------------------------------------------- 1 | import { DefaultConfig } from "ngx-easy-table"; 2 | 3 | let configuration = { ...DefaultConfig }; 4 | configuration.isLoading = true; 5 | configuration.checkboxes = true; 6 | configuration.selectRow = true; 7 | configuration.clickEvent = true; 8 | configuration.paginationEnabled = false; 9 | configuration.paginationRangeEnabled = false; 10 | 11 | export default configuration; 12 | -------------------------------------------------------------------------------- /src/app/util/processing.ts: -------------------------------------------------------------------------------- 1 | export const limitTextLength = (string: string, number: number): string => { 2 | return string.length > number ? string.slice(0, number) + "..." : string; 3 | }; 4 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nzbasic/Collection-Helper/3f90ff23fd3727b139646205a515d5726d6a212a/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/assets/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nzbasic/Collection-Helper/3f90ff23fd3727b139646205a515d5726d6a212a/src/assets/icons/favicon.ico -------------------------------------------------------------------------------- /src/assets/icons/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nzbasic/Collection-Helper/3f90ff23fd3727b139646205a515d5726d6a212a/src/assets/icons/favicon.png -------------------------------------------------------------------------------- /src/environments/environment.dev.ts: -------------------------------------------------------------------------------- 1 | export const APP_CONFIG = { 2 | production: false, 3 | environment: 'DEV' 4 | }; 5 | -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const APP_CONFIG = { 2 | production: true, 3 | environment: 'PROD' 4 | }; 5 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const APP_CONFIG = { 2 | production: false, 3 | environment: 'LOCAL' 4 | }; 5 | -------------------------------------------------------------------------------- /src/environments/environment.web.ts: -------------------------------------------------------------------------------- 1 | export const APP_CONFIG = { 2 | production: false, 3 | environment: 'WEB' 4 | }; 5 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nzbasic/Collection-Helper/3f90ff23fd3727b139646205a515d5726d6a212a/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | <!doctype html> 2 | <html> 3 | <head> 4 | <meta charset="utf-8"> 5 | <title>Collection Helper 6 | 7 | 8 | 9 | 10 | 11 | 12 | Loading... 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/0.13/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-electron'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client:{ 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, '../coverage'), 20 | reports: [ 'html', 'lcovonly' ], 21 | fixWebpackSourcePaths: true 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | browsers: ['AngularElectron'], 28 | customLaunchers: { 29 | AngularElectron: { 30 | base: 'Electron', 31 | flags: [ 32 | '--remote-debugging-port=9222' 33 | ], 34 | browserWindowOptions: { 35 | webPreferences: { 36 | nodeIntegration: true, 37 | nodeIntegrationInSubFrames: true, 38 | allowRunningInsecureContent: true, 39 | enableRemoteModule: true, 40 | contextIsolation: false 41 | } 42 | } 43 | } 44 | } 45 | }); 46 | }; 47 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { APP_CONFIG } from './environments/environment'; 6 | 7 | if (APP_CONFIG.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic() 12 | .bootstrapModule(AppModule, { 13 | preserveWhitespaces: false 14 | }) 15 | .catch(err => console.error(err)); 16 | -------------------------------------------------------------------------------- /src/polyfills-test.ts: -------------------------------------------------------------------------------- 1 | import 'core-js/es/reflect'; 2 | import 'zone.js'; 3 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 22 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 23 | 24 | /** 25 | * Web Animations `@angular/platform-browser/animations` 26 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 27 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 28 | */ 29 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 30 | 31 | /** 32 | * By default, zone.js will patch all possible macroTask and DomEvents 33 | * user can disable parts of macroTask/DomEvents patch by setting following flags 34 | * because those flags need to be set before `zone.js` being loaded, and webpack 35 | * will put import in the top of bundle, so user need to create a separate file 36 | * in this directory (for example: zone-flags.ts), and put the following flags 37 | * into that file, and then add the following code before importing zone.js. 38 | * import './zone-flags.ts'; 39 | * 40 | * The flags allowed in zone-flags.ts are listed here. 41 | * 42 | * The following flags will work for all browsers. 43 | * 44 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 45 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 46 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 47 | * 48 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 49 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 50 | * 51 | * (window as any).__Zone_enable_cross_context_check = true; 52 | * 53 | */ 54 | 55 | /*************************************************************************************************** 56 | * Zone JS is required by default for Angular itself. 57 | */ 58 | import 'zone.js'; // Included with Angular CLI. 59 | 60 | 61 | /*************************************************************************************************** 62 | * APPLICATION IMPORTS 63 | */ 64 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | 3 | @import "tailwindcss/base"; 4 | @import "tailwindcss/components"; 5 | @import "tailwindcss/utilities"; 6 | @import "~@fontsource/open-sans"; 7 | @import "~ngx-toastr/toastr"; 8 | 9 | .switch small { 10 | @apply border-2 dark:bg-black dark:border-white #{!important}; 11 | } 12 | 13 | .ngx-slider-bar { 14 | @apply dark:bg-black #{!important}; 15 | } 16 | 17 | .ngx-slider[disabled] .ngx-slider-pointer { 18 | @apply dark:bg-black #{!important}; 19 | } 20 | 21 | html, 22 | body { 23 | margin: 0; 24 | padding: 0; 25 | font-size: small; 26 | height: 100%; 27 | font-family: "Open Sans" 28 | } 29 | 30 | input[type=checkbox] { 31 | appearance: none; 32 | @apply border dark:border-black border-gray-300 rounded cursor-pointer w-5 h-5 dark:bg-dark2 flex flex-col items-center justify-center; 33 | } 34 | 35 | input[type=checkbox]:checked:after { 36 | content: "✓"; 37 | @apply text-black dark:text-white; 38 | } 39 | 40 | ngx-slider { 41 | margin: 0 !important; 42 | } 43 | 44 | .ngx-form-checkbox { 45 | padding: .2rem .4rem .4rem 0rem !important; 46 | } 47 | 48 | #selectAllCheckbox { 49 | @apply dark:bg-dark2 dark:border-black; 50 | height: 1.25rem !important; 51 | width: 1.25rem !important; 52 | } 53 | 54 | .w-add { 55 | width: 42rem; 56 | } 57 | 58 | .modal { 59 | margin-left: -16rem; 60 | animation: fadeIn .5s; 61 | @apply dark:bg-gray-700 dark:bg-opacity-70 top-0 z-20 w-screen h-screen bg-gray-400 bg-opacity-60 inline-flex flex-row items-center justify-center; 62 | } 63 | 64 | .modal-help { 65 | margin-left: -15rem; 66 | animation: fadeIn .5s; 67 | @apply dark:bg-gray-700 dark:bg-opacity-70 top-0 z-20 w-screen h-screen bg-gray-400 bg-opacity-60 inline-flex flex-row items-center justify-center; 68 | } 69 | 70 | @keyframes fadeIn { 71 | from { 72 | opacity: 0; 73 | } to { 74 | opacity: 1; 75 | } 76 | } 77 | 78 | th { 79 | text-align: left; 80 | } 81 | 82 | .search { 83 | border-top: 1px solid lightgray; 84 | border-bottom: 1px solid lightgray; 85 | border-right: none; 86 | border-left: none; 87 | @apply dark:bg-dark1 dark:border-black dark:text-white; 88 | } 89 | 90 | input { 91 | @apply dark:bg-dark2 #{!important}; 92 | } 93 | 94 | .search-add { 95 | border-right: 1px solid lightgray; 96 | border-left: 1px solid lightgray; 97 | @apply dark:text-white; 98 | } 99 | 100 | #selectAllCheckbox { 101 | margin-left: 0.75rem; 102 | } 103 | 104 | .top-bar { 105 | border-top: 1px solid lightgrey; 106 | @apply dark:border-black; 107 | } 108 | 109 | .button { 110 | font-size: 1rem; 111 | line-height: 1.5rem; 112 | border-width: 1px; 113 | border-color: transparent; 114 | color: white; 115 | border-radius: 0.125rem; 116 | padding-bottom: 0.25rem; 117 | padding-top: 0.25rem; 118 | padding-left: 0.5rem; 119 | padding-right: 0.5rem; 120 | box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px; 121 | @apply transition duration-150 ease-in; 122 | } 123 | 124 | .button-large { 125 | @apply text-lg px-4 py-2 rounded-md #{!important}; 126 | } 127 | 128 | .button-blue-enabled { 129 | cursor: pointer; 130 | background-color: rgb(59, 130, 246); 131 | @apply dark:bg-blue-700 132 | } 133 | 134 | .button-blue-enabled:hover { 135 | background-color: rgb(29, 78, 216); 136 | @apply dark:hover:bg-blue-800 137 | } 138 | 139 | .button-blue-disabled { 140 | cursor: default; 141 | background-color: rgb(96, 165, 250); 142 | @apply dark:bg-blue-400 143 | } 144 | 145 | .button-red-enabled { 146 | cursor: pointer; 147 | background-color: rgb(239, 68, 68); 148 | @apply dark:bg-red-700 149 | } 150 | 151 | .button-red-enabled:hover { 152 | background-color: rgb(185, 28, 28); 153 | @apply dark:hover:bg-red-800 154 | } 155 | 156 | .button-red-disabled { 157 | cursor: default; 158 | background-color: rgb(248, 113, 113); 159 | @apply dark:bg-red-400 160 | } 161 | 162 | .button-green-enabled { 163 | cursor: pointer; 164 | color: white; 165 | background-color: rgb(16, 185, 129); 166 | @apply dark:bg-green-600 167 | } 168 | 169 | .button-green-enabled:hover { 170 | color: white; 171 | background-color: rgb(5, 150, 105); 172 | @apply dark:bg-green-800 173 | } 174 | 175 | .ngx-select { 176 | @apply dark:bg-dark2 dark:border-black dark:text-white #{!important}; 177 | } 178 | 179 | .options { 180 | @apply dark:bg-dark2 dark:text-white dark:border-black #{!important}; 181 | } 182 | 183 | .selection-anchor-open, .selection-anchor-close { 184 | @apply dark:border-black #{!important}; 185 | } 186 | 187 | .place-holder { 188 | @apply dark:text-white #{!important}; 189 | } 190 | 191 | .switch { 192 | @apply dark:border-black #{!important}; 193 | } 194 | 195 | .dropdown { 196 | @apply dark:border-black #{!important}; 197 | } 198 | 199 | .container-checkbox > input { 200 | display: none !important; 201 | } 202 | 203 | app-pagination { 204 | @apply dark:border-black border-t; 205 | } 206 | 207 | cdk-virtual-scroll-viewport { 208 | &::-webkit-scrollbar { 209 | width: 7px; 210 | } 211 | 212 | /* Handle */ 213 | &::-webkit-scrollbar-thumb { 214 | background: black; 215 | border-radius: 5px; 216 | } 217 | } 218 | 219 | .ngx { 220 | // &-container--dark { 221 | // background-color: $dark; 222 | // } 223 | 224 | &-infinite-scroll-viewport { 225 | height: calc(100vh - 24rem) !important; 226 | } 227 | 228 | &-table { 229 | @apply dark:bg-dark1 dark:text-white; 230 | 231 | td { 232 | @apply dark:border-black border-t-2; 233 | } 234 | 235 | th { 236 | @apply border-t-0; 237 | } 238 | 239 | &__table--normal > thead > tr > th { 240 | @apply border-b border-black #{!important}; 241 | } 242 | 243 | &__table-row--selected, 244 | &__table-col--selected, 245 | &__table-cell--selected { 246 | @apply dark:bg-dark2 #{!important}; 247 | } 248 | 249 | &__table--hoverable > tbody tr:hover { 250 | @apply dark:bg-dark2; 251 | } 252 | 253 | &__table--striped > tbody tr:nth-of-type(odd) { 254 | @apply dark:bg-dark2; 255 | } 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, 7 | platformBrowserDynamicTesting 8 | } from '@angular/platform-browser-dynamic/testing'; 9 | 10 | declare const require: any; 11 | 12 | // First, initialize the Angular testing environment. 13 | getTestBed().initTestEnvironment( 14 | BrowserDynamicTestingModule, 15 | platformBrowserDynamicTesting() 16 | ); 17 | // Then we find all the tests. 18 | const context = require.context('./', true, /\.spec\.ts$/); 19 | // And load the modules. 20 | context.keys().map(context); 21 | -------------------------------------------------------------------------------- /src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "module": "es2015", 6 | "baseUrl": "", 7 | "types": [] 8 | }, 9 | "files": [ 10 | "main.ts", 11 | "polyfills.ts" 12 | ], 13 | "include": [ 14 | "**/*.d.ts" 15 | ], 16 | "exclude": [ 17 | "**/*.spec.ts" 18 | ], 19 | "angularCompilerOptions": { 20 | "strictTemplates": true, 21 | "fullTemplateTypeCheck": true, 22 | "annotateForClosureCompiler": true, 23 | "strictInjectionParameters": true, 24 | "skipTemplateCodegen": false, 25 | "preserveWhitespaces": true, 26 | "skipMetadataEmit": false, 27 | "disableTypeScriptVersionCheck": true 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "module": "commonjs", 6 | "types": [ 7 | "jasmine", 8 | "node" 9 | ] 10 | }, 11 | "files": [ 12 | "test.ts", 13 | "polyfills-test.ts" 14 | ], 15 | "include": [ 16 | "**/*.spec.ts", 17 | "**/*.d.ts" 18 | ], 19 | "exclude": [ 20 | "dist", 21 | "release", 22 | "node_modules" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | /* SystemJS module definition */ 2 | declare const nodeModule: NodeModule; 3 | interface NodeModule { 4 | id: string; 5 | } 6 | interface Window { 7 | process: any; 8 | require: any; 9 | } 10 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: [], 3 | darkMode: "class", // or 'media' or 'class' 4 | theme: { 5 | extend: { 6 | colors: { 7 | one: "#194350", 8 | two: "#d5dde0", 9 | three: "#0a2b36", 10 | default: "#E5E7EB", 11 | dark0: "#0a0a0a", 12 | dark1: "#121212", 13 | dark2: "#1D1D1D", 14 | dark3: "#212121", 15 | }, 16 | }, 17 | }, 18 | variants: { 19 | extend: {}, 20 | }, 21 | plugins: [], 22 | }; 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "outDir": "./dist/out-tsc", 5 | "module": "es2020", 6 | "sourceMap": true, 7 | "esModuleInterop": true, 8 | "declaration": false, 9 | "moduleResolution": "node", 10 | "emitDecoratorMetadata": true, 11 | "experimentalDecorators": true, 12 | "downlevelIteration": true, 13 | "allowJs": true, 14 | "target": "es5", 15 | "typeRoots": [ 16 | "node_modules/@types" 17 | ], 18 | "lib": [ 19 | "es2017", 20 | "es2016", 21 | "es2015", 22 | "dom" 23 | ] 24 | }, 25 | "include": [ 26 | "src/**/*.d.ts" 27 | ], 28 | "exclude": [ 29 | "node_modules" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.serve.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "declaration": false, 5 | "moduleResolution": "node", 6 | "emitDecoratorMetadata": true, 7 | "downlevelIteration": true, 8 | "experimentalDecorators": true, 9 | "target": "es5", 10 | "types": [ 11 | "node" 12 | ], 13 | "lib": [ 14 | "es2017", 15 | "es2016", 16 | "es2015", 17 | "dom" 18 | ] 19 | }, 20 | "files": [ 21 | "app/main.ts" 22 | ], 23 | "exclude": [ 24 | "node_modules", 25 | "**/*.spec.ts" 26 | ] 27 | } 28 | --------------------------------------------------------------------------------