From 982b6cc6cc67fcb7688238114396bfeec1b18ea9 Mon Sep 17 00:00:00 2001 From: Daniel Power Date: Mon, 30 Sep 2024 16:34:12 -0230 Subject: [PATCH 1/3] Add folder detection for osu! linux (osu-winello) --- src/main/router/dir-router.ts | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/main/router/dir-router.ts b/src/main/router/dir-router.ts index 5d160961..abebabed 100644 --- a/src/main/router/dir-router.ts +++ b/src/main/router/dir-router.ts @@ -1,10 +1,8 @@ -import { Router } from '../lib/route-pass/Router'; -import { none, some } from '../lib/rust-like-utils-backend/Optional'; -import { dialog } from 'electron'; +import { Router } from "../lib/route-pass/Router"; +import { none, some } from "../lib/rust-like-utils-backend/Optional"; +import { dialog } from "electron"; import path from "path"; - - let waitList: ((dir: string) => void)[] = []; Router.respond("dir::select", () => { @@ -21,11 +19,18 @@ Router.respond("dir::select", () => { }); Router.respond("dir::autoGetOsuSongsDir", () => { - if (process.env.LOCALAPPDATA === undefined) { - return none(); + if (process.platform === "win32") { + if (process.env.LOCALAPPDATA === undefined) { + return none(); + } + return some(path.join(process.env.LOCALAPPDATA, "osu!", "Songs")); + } else if (process.platform === "linux") { + if (process.env.XDG_DATA_HOME === undefined) { + return none(); + } + return some(path.join(process.env.XDG_DATA_HOME, "osu-wine", "osu!", "Songs")); } - - return some(path.join(process.env.LOCALAPPDATA, "osu!", "Songs")); + return none(); }); Router.respond("dir::submit", (_evt, dir) => { @@ -36,10 +41,8 @@ Router.respond("dir::submit", (_evt, dir) => { waitList = []; }); - - export function dirSubmit(): Promise { - return new Promise(resolve => { + return new Promise((resolve) => { waitList.push(resolve); }); -} \ No newline at end of file +} From 2019e2f6a45ebdc2e05986361ad2891e64092974 Mon Sep 17 00:00:00 2001 From: hrfarmer Date: Tue, 1 Oct 2024 10:44:06 -0500 Subject: [PATCH 2/3] codeowners --- CODEOWNERS | 1 + 1 file changed, 1 insertion(+) create mode 100644 CODEOWNERS diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000..100a25df --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @CaptSiro From 8ce2c60ae161295f16cce4ceecbf4159ff5451fe Mon Sep 17 00:00:00 2001 From: CaptSiro Date: Tue, 1 Oct 2024 21:01:35 +0200 Subject: [PATCH 3/3] Added documentation comments to backend code --- package-lock.json | 13 +- src/@types.d.ts | 43 ++- src/RequestAPI.d.ts | 2 + src/main/index.ts | 3 +- src/main/lib/Signal.ts | 23 -- src/main/lib/delay-backend.ts | 6 + src/main/lib/fs-promises.ts | 6 +- src/main/lib/route-pass/Router.ts | 22 ++ src/main/lib/search-parser/@search-types.d.ts | 5 +- src/main/lib/search-parser/SearchParser.ts | 254 ++++++++-------- .../lib/search-parser/levenshteinDistance.ts | 8 +- src/main/lib/search-parser/validators.ts | 277 ++++++++++-------- src/main/lib/song/average-bpm.ts | 1 + src/main/lib/song/filter.ts | 18 ++ src/main/lib/song/index.ts | 14 + src/main/lib/song/order.ts | 1 + src/main/lib/storage/Storage.ts | 47 ++- src/main/lib/storage/Table.ts | 27 ++ .../template-parser/parser/TemplateParser.ts | 6 + .../tokenizer/TemplateTokenizer.ts | 5 + src/main/lib/throttle.ts | 14 +- src/main/lib/tungsten/assertNever.ts | 4 + src/main/lib/tungsten/collections.ts | 4 + src/main/lib/tungsten/errorIgnored.ts | 3 + src/main/lib/tungsten/math.ts | 6 + src/main/lib/tungsten/token.ts | 15 + src/main/lib/utils.ts | 20 -- src/main/lib/window/resizer.ts | 9 +- src/main/main.ts | 31 +- src/main/router/dev-router.ts | 9 + src/main/router/dir-router.ts | 24 +- src/main/router/error-router.ts | 20 +- src/main/router/import.ts | 38 +-- src/main/router/parser-router.ts | 16 + src/main/router/queue-router.ts | 31 +- src/main/router/resource-router.ts | 1 + src/main/router/songs-pool-router.ts | 2 + 37 files changed, 651 insertions(+), 377 deletions(-) delete mode 100644 src/main/lib/Signal.ts delete mode 100644 src/main/lib/utils.ts create mode 100644 src/main/router/dev-router.ts diff --git a/package-lock.json b/package-lock.json index 4ac49a68..e1b552f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -671,19 +671,13 @@ "node_modules/@electron-toolkit/preload": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@electron-toolkit/preload/-/preload-2.0.0.tgz", - "integrity": "sha512-zpZDzbqJTZQC5d4LRs2EKruKWnqah+T75s+niBYFemYLtiW5TTZcWi3Q8UxHqnwTudDMuWJb233aaS2yjx3Xiw==", - "peerDependencies": { - "electron": ">=13.0.0" - } + "integrity": "sha512-zpZDzbqJTZQC5d4LRs2EKruKWnqah+T75s+niBYFemYLtiW5TTZcWi3Q8UxHqnwTudDMuWJb233aaS2yjx3Xiw==" }, "node_modules/@electron-toolkit/tsconfig": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@electron-toolkit/tsconfig/-/tsconfig-1.0.1.tgz", "integrity": "sha512-M0Mol3odspvtCuheyujLNAW7bXq7KFNYVMRtpjFa4ZfES4MuklXBC7Nli/omvc+PRKlrklgAGx3l4VakjNo8jg==", - "dev": true, - "peerDependencies": { - "@types/node": "*" - } + "dev": true }, "node_modules/@electron-toolkit/utils": { "version": "1.0.2", @@ -1642,7 +1636,8 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", - "dependencies": { + "dev": true, + "requires": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", diff --git a/src/@types.d.ts b/src/@types.d.ts index 21bd0359..22f8a799 100644 --- a/src/@types.d.ts +++ b/src/@types.d.ts @@ -47,14 +47,10 @@ export type Resource = { export type AudioSource = { songID: ResourceID; volume?: number; - - //todo audio file waveform } & Resource export type ImageSource = { songID: ResourceID; - - //todo prominent colors of image } & Resource export type Song = { @@ -66,6 +62,7 @@ export type Song = { title: string, artist: string, creator: string, + // For the life of me I can't remember why is it 2D array bpm: number[][], duration: number, beatmapSetID?: number, @@ -78,25 +75,34 @@ export type Song = { diffs: string[], } & Resource +// Serialization is in JSON that's why properties are only single letter export type SongIndex = { id: string, - t: string, // title - a: string, // artist - c: string, // creator - m?: number, // mode - d: number, // duration + // title + t: string, + // artist + a: string, + // artist + c: string, + // mode + m?: number, + // duration + d: number, tags?: string[], + // beatmap difficulty names diffs: string[], bpm: number } - +// System table definition export type System = { + "songDir.mtime": string, indexes: SongIndex[], allTags: { [key: string]: string[] }, } +// Settings table definition export type Settings = { volume: number, osuSongsDir: string, @@ -107,7 +113,7 @@ export type Settings = { } - +// Key is a name of a table. This name is passed to Storage.getTable(name) function to retrieve whole table as an object export type TableMap = { 'songs': { [key: ResourceID]: Song }, 'audio': { [key: ResourceID]: AudioSource }, @@ -117,6 +123,14 @@ export type TableMap = { 'system': System, } +// I guess this is definition of all binary blob files that can be access from the database code? +export type BlobMap = { + 'times' +} + + + +// Tables that work on ID -> Record relation export type ResourceTables = "songs" | "audio" | "images"; @@ -125,12 +139,14 @@ type OmitPropsWithReturnType any }, [K in keyof O as ReturnType extends V ? never : K]: O[K] } +// Types as functions for type type OmitPropsWithoutReturnType any }, V> = { [K in keyof O as ReturnType extends V ? K : never]: O[K] } -export type APIFunction any> = (evt: Electron.IpcMainInvokeEvent, ...args: Parameters) => ReturnType | Promise> +export type APIFunction any> = (evt: Electron.IpcMainInvokeEvent, ...args: Parameters) + => ReturnType | Promise> export type PacketType = 'DATA' | 'ERROR' @@ -148,6 +164,7 @@ export type APIListener any> = (...args: Parameters< export type Tag = { name: string, + // Is excluded. Name should be changed to isExcluded in future version isSpecial?: boolean } @@ -158,12 +175,14 @@ export type SongsQueryPayload = { order: string, } +// Context for backend to use proper database (all songs, current queue, playlist(s)) export type QueueView = SongViewProps & { playlists?: string[] }; export type QueueCreatePayload = { view: QueueView, searchQuery?: SearchQuerySuccess, tags: Tag[], + // The format is: OsuSearchAbleProperties:(asc|desc) -> bpm:asc order: string, startSong: ResourceID, } diff --git a/src/RequestAPI.d.ts b/src/RequestAPI.d.ts index d67acaa4..f7b2f304 100644 --- a/src/RequestAPI.d.ts +++ b/src/RequestAPI.d.ts @@ -53,4 +53,6 @@ export type RequestAPI = { "query::queue": (request: InfiniteScrollerRequest) => InfiniteScrollerResponse, "save::localVolume": (volume: number, song: ResourceID) => void, + + "dev::storeLocation": () => string, } \ No newline at end of file diff --git a/src/main/index.ts b/src/main/index.ts index 293d56ab..d3a1806d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -35,13 +35,14 @@ async function createWindow() { }); // HMR for renderer base on electron-vite cli. - // Load the remote URL for development or the local html file for production. + // Load the remote URL for hot reloading or the local html file for production. if (is.dev && process.env['ELECTRON_RENDERER_URL']) { await window.loadURL(process.env['ELECTRON_RENDERER_URL']); } else { await window.loadFile(join(__dirname, '../renderer/index.html')); } + // Launch main app logic await main(window) .catch(error => { if (error === null || error === undefined) { diff --git a/src/main/lib/Signal.ts b/src/main/lib/Signal.ts deleted file mode 100644 index f12fe30b..00000000 --- a/src/main/lib/Signal.ts +++ /dev/null @@ -1,23 +0,0 @@ -type SignalListener = (value: T) => any; - -export class Signal { - #value: T; - #listeners: SignalListener[] = []; - - - - constructor(startValue?: T) { - this.#value = startValue; - } - - listen(listener: SignalListener): void { - this.#listeners.push(listener); - } - - set value(value: T) { - this.#value = value; - for (let i = 0; i < this.#listeners.length; i++) { - this.#listeners[i](value); - } - } -} diff --git a/src/main/lib/delay-backend.ts b/src/main/lib/delay-backend.ts index 440b2a83..20656a0f 100644 --- a/src/main/lib/delay-backend.ts +++ b/src/main/lib/delay-backend.ts @@ -4,6 +4,12 @@ export type DelayCancel = () => void; +/** + * Provided `fn` is wrapped and calling returned wrapped function will cause that all calls to that function will be + * delayed. If function is called while it is being delayed the delay timer will reset and start over + * @param fn + * @param ms + */ export function delay any>(fn: F, ms: number): [DelayedFunction, DelayCancel] { let timeout: NodeJS.Timeout | undefined = undefined; diff --git a/src/main/lib/fs-promises.ts b/src/main/lib/fs-promises.ts index 75ba7080..11ef5bd0 100644 --- a/src/main/lib/fs-promises.ts +++ b/src/main/lib/fs-promises.ts @@ -12,7 +12,7 @@ export function access(path, mode: number | undefined = undefined): Promise { +export function getSubDirs(dirPath: string): Promise { return new Promise((resolve, reject) => { fs.opendir(dirPath, { encoding: "utf8" }, async (err, dir) => { if (err !== null) { @@ -35,7 +35,7 @@ export function getSubDirs(dirPath): Promise { -export function getFiles(dirPath, ext?): Promise { +export function getFiles(dirPath: string, ext?: string): Promise { return new Promise((resolve, reject) => { fs.opendir(dirPath, { encoding: "utf8" }, async (err, dir) => { if (err !== null) { @@ -63,7 +63,7 @@ export function getFiles(dirPath, ext?): Promise { -export function stat(path): Promise { +export function stat(path: string): Promise { return new Promise((resolve, reject) => { fs.stat(path, (err, stats) => { if (err !== null) { diff --git a/src/main/lib/route-pass/Router.ts b/src/main/lib/route-pass/Router.ts index 776da4de..260e8dd2 100644 --- a/src/main/lib/route-pass/Router.ts +++ b/src/main/lib/route-pass/Router.ts @@ -39,10 +39,32 @@ ipcMain.on("communication/main", (_evt, packet: Packet) => { export class Router { + /** + * Client - Server - Client + * + * Respond to `event` that was dispatched from client and send data back to client. The data that are send is the + * value returned from closure `fn`. Client may send data to server. The data is passed as arguments to closure `fn` + * + * Wrapper for `ipcMain.handle(...)` + * + * @param event + * @param fn + */ static respond(event: E, fn: APIFunction): void { ipcMain.handle(event, fn as any); } + /** + * Server - Client + * + * Send data to client. Provide client (`window`), `event`, and `data` that shall be delivered to client. + * + * @param window + * @param channel + * @param data + * @returns {Promise} The data the Promise is resolved with should be return value of client-side listener + * function + */ static dispatch(window: BrowserWindow, channel: E, ...data: Parameters): Promise { const packet = cratePacket(channel, tokens.create(), data); diff --git a/src/main/lib/search-parser/@search-types.d.ts b/src/main/lib/search-parser/@search-types.d.ts index e65c214b..082b6caf 100644 --- a/src/main/lib/search-parser/@search-types.d.ts +++ b/src/main/lib/search-parser/@search-types.d.ts @@ -33,6 +33,7 @@ export type SearchQuery = SearchQueryError | SearchQuerySuccess export type SearchConfig = { tokenDelimiter: string, + // Relation symbol provides meaning between two tokens. Example: bpm=200 - relation symbol is `=` relationSymbols: string[], propertyMap: SearchPropertyMap } @@ -52,11 +53,13 @@ export type SearchPropertyValidation = { }, } | { isValid: true, - parsed: any + parsed: any // Represents value that is serialized in string. Example: value = String("727") -> parsed = Number(727) } +// Function that shall validate user input export type SearchPropertyValidator = (value: string, symbol: string) => SearchPropertyValidation +// All available tokens for searching. Example: "bpm": num() export type SearchPropertyMap = { [key: string]: SearchPropertyValidator } \ No newline at end of file diff --git a/src/main/lib/search-parser/SearchParser.ts b/src/main/lib/search-parser/SearchParser.ts index 79713588..ec741ea9 100644 --- a/src/main/lib/search-parser/SearchParser.ts +++ b/src/main/lib/search-parser/SearchParser.ts @@ -1,23 +1,23 @@ import { - SearchPropertyValidation, - SearchConfig, - SearchProperty, - SearchQuery, - ValidationSuggestion, - SearchQuerySuggestion -} from "./@search-types"; -import { closestLevenDist } from "./levenshteinDistance"; + SearchPropertyValidation, + SearchConfig, + SearchProperty, + SearchQuery, + ValidationSuggestion, + SearchQuerySuggestion +} from './@search-types'; +import { closestLevenDist } from './levenshteinDistance'; type ComparisonExtractionTrue = { - isPresent: true, - start: number, - symbol: string + isPresent: true, + start: number, + symbol: string }; type ComparisonExtractionFalse = { - isPresent: false, + isPresent: false, }; type ComparisonExtraction = ComparisonExtractionFalse | ComparisonExtractionTrue @@ -25,133 +25,141 @@ type ComparisonExtraction = ComparisonExtractionFalse | ComparisonExtractionTrue export class SearchParser { - private config: SearchConfig; + private config: SearchConfig; - constructor(config: SearchConfig) { - this.config = config; - this.config.relationSymbols = [...this.config.relationSymbols]; - this.config.relationSymbols.sort((a, b) => b.length - a.length); - } - parse(query: string): SearchQuery { - const tokens = query.trim().split(this.config.tokenDelimiter); - const unnamed: string[] = []; - const properties: Record = {}; - - for (let i = 0; i < tokens.length; i++) { - const token = tokens[i].trim(); - - if (token === "") { - continue; - } - - const extracted = this.extractComparison(token); - - if (!extracted.isPresent) { - unnamed.push(token); - continue; - } - - const [prop, value] = this.split(token, extracted); - const validation = this.validateProperty(prop, value, extracted.symbol); - - if (!validation.isValid) { - const slice = tokens.slice(0, i); - const index = slice.join(this.config.tokenDelimiter); - const start = slice.length !== 0 - ? index.length + this.config.tokenDelimiter.length - : index.length - - return { - query, - type: "error", - error: { - message: validation.error.message, - start, - end: start + token.length, - suggestion: this.createSuggestion(validation.error.suggestion, prop, extracted.symbol, value), - } - } - } - - if (properties[prop] === undefined) { - properties[prop] = [{ - symbol: extracted.symbol, - value: validation.parsed - }]; - continue; - } - - properties[prop].push({ - symbol: extracted.symbol, - value: validation.parsed - }); - } - return { - query, - type: "success", - unnamed, - properties, - delimiter: this.config.tokenDelimiter - }; - } + constructor(config: SearchConfig) { + this.config = config; + this.config.relationSymbols = [...this.config.relationSymbols]; + this.config.relationSymbols.sort((a, b) => b.length - a.length); + } - private extractComparison(token: string): ComparisonExtraction { - for (let i = 0; i < this.config.relationSymbols.length; i++) { - const index = token.indexOf(this.config.relationSymbols[i]); - if (index === -1) { - continue; - } - return { - isPresent: true, - start: index, - symbol: this.config.relationSymbols[i] - } - } + parse(query: string): SearchQuery { + const tokens = query.trim().split(this.config.tokenDelimiter); + // unnamed list is for text index searching. That may include title, artist, difficulty, and more + const unnamed: string[] = []; + const properties: Record = {}; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i].trim(); + + if (token === '') { + continue; + } + + // Check if token is simple (DragonForce) or with relation symbol (artist=xi) + const extracted = this.extractComparison(token); + + if (!extracted.isPresent) { + // Simple tokens + unnamed.push(token); + continue; + } + + const [prop, value] = this.split(token, extracted); + const validation = this.validateProperty(prop, value, extracted.symbol); + + if (!validation.isValid) { + // Create suggestion which is able to replace incorrect part of search query + const slice = tokens.slice(0, i); + const index = slice.join(this.config.tokenDelimiter); + const start = slice.length !== 0 + ? index.length + this.config.tokenDelimiter.length + : index.length; return { - isPresent: false + query, + type: 'error', + error: { + message: validation.error.message, + start, + end: start + token.length, + suggestion: this.createSuggestion(validation.error.suggestion, prop, extracted.symbol, value) + } }; + } + + if (properties[prop] === undefined) { + properties[prop] = [{ + symbol: extracted.symbol, + value: validation.parsed + }]; + continue; + } + + properties[prop].push({ + symbol: extracted.symbol, + value: validation.parsed + }); } - private split(token: string, extraction: ComparisonExtractionTrue): [string, string] { - return [ - token.substring(0, extraction.start), - token.substring(extraction.start + extraction.symbol.length) - ]; + return { + query, + type: 'success', + unnamed, + properties, + delimiter: this.config.tokenDelimiter + }; + } + + private extractComparison(token: string): ComparisonExtraction { + for (let i = 0; i < this.config.relationSymbols.length; i++) { + const index = token.indexOf(this.config.relationSymbols[i]); + + if (index === -1) { + continue; + } + + return { + isPresent: true, + start: index, + symbol: this.config.relationSymbols[i] + }; } - private validateProperty(prop: string, value: string, comparison: string): SearchPropertyValidation { - if (this.config.propertyMap[prop] === undefined) { - const props = Object.keys(this.config.propertyMap); - const closest = props[closestLevenDist(prop, props)]; - - return { - isValid: false, - error: { - message: `'${prop}' is not supported`, - suggestion: { - prop: closest, - description: `Did you mean ${closest}?` - } - } - }; + return { + isPresent: false + }; + } + + private split(token: string, extraction: ComparisonExtractionTrue): [string, string] { + return [ + token.substring(0, extraction.start), + token.substring(extraction.start + extraction.symbol.length) + ]; + } + + private validateProperty(prop: string, value: string, comparison: string): SearchPropertyValidation { + if (this.config.propertyMap[prop] === undefined) { + const props = Object.keys(this.config.propertyMap); + const closest = props[closestLevenDist(prop, props)]; + + return { + isValid: false, + error: { + message: `'${prop}' is not supported`, + suggestion: { + prop: closest, + description: `Did you mean ${closest}?` + } } - - return this.config.propertyMap[prop](value, comparison); + }; } - private createSuggestion(validationSuggestion: ValidationSuggestion | undefined, prop: string, symbol: string, value: string): SearchQuerySuggestion | undefined { - if (validationSuggestion === undefined) { - return undefined; - } + return this.config.propertyMap[prop](value, comparison); + } - return { - description: validationSuggestion.description, - fullReplacement: (validationSuggestion.prop ?? prop) + (validationSuggestion.symbol ?? symbol) + (validationSuggestion.value ?? value) - }; + private createSuggestion(validationSuggestion: ValidationSuggestion | undefined, prop: string, symbol: string, value: string): SearchQuerySuggestion | undefined { + if (validationSuggestion === undefined) { + return undefined; } + + return { + description: validationSuggestion.description, + fullReplacement: (validationSuggestion.prop ?? prop) + (validationSuggestion.symbol ?? symbol) + (validationSuggestion.value ?? value) + }; + } } \ No newline at end of file diff --git a/src/main/lib/search-parser/levenshteinDistance.ts b/src/main/lib/search-parser/levenshteinDistance.ts index 24ea31f5..b3465c5b 100644 --- a/src/main/lib/search-parser/levenshteinDistance.ts +++ b/src/main/lib/search-parser/levenshteinDistance.ts @@ -1,6 +1,12 @@ -import { distance } from "fastest-levenshtein"; +import { distance } from 'fastest-levenshtein'; + +/** + * Compute Levenshtein distance for all values using `string` and return index to value that is the closest match + * @param string + * @param values + */ export function closestLevenDist(string: string, values: string[]): -1 | number { if (values.length === 0) { return -1; diff --git a/src/main/lib/search-parser/validators.ts b/src/main/lib/search-parser/validators.ts index 2fa6c823..4df03921 100644 --- a/src/main/lib/search-parser/validators.ts +++ b/src/main/lib/search-parser/validators.ts @@ -1,187 +1,209 @@ -import { SearchPropertyValidation, SearchPropertyValidator } from "./@search-types"; -import { closestLevenDist } from "./levenshteinDistance"; +import { SearchPropertyValidation, SearchPropertyValidator } from './@search-types'; +import { closestLevenDist } from './levenshteinDistance'; -export const equalsSymbols = ["=", "==", "!="]; -export const greaterThanSymbols = [">", ">="]; -export const lessThanSymbols = ["<", "<="]; +export const equalsSymbols = ['=', '==', '!=']; +export const greaterThanSymbols = ['>', '>=']; +export const lessThanSymbols = ['<', '<=']; export const defaultRelationSymbols = [...equalsSymbols, ...greaterThanSymbols, ...lessThanSymbols]; export function text(): SearchPropertyValidator { - return (value: string, symbol: string): SearchPropertyValidation => { - if (!(symbol === "=" || symbol === "!=" || symbol === "==")) { - return { - isValid: false, - error: { - message: "Text can only use =, ==, != comparison symbols.", - suggestion: { - symbol: "=", - description: "Use equals instead." - } - } - }; + return (value: string, symbol: string): SearchPropertyValidation => { + if (!(symbol === '=' || symbol === '!=' || symbol === '==')) { + return { + isValid: false, + error: { + message: 'Text can only use =, ==, != comparison symbols.', + suggestion: { + symbol: '=', + description: 'Use equals instead.' + } } - - return { - isValid: true, - parsed: value - }; + }; } + + return { + isValid: true, + parsed: value + }; + }; } export function num(includeFloats = true): SearchPropertyValidator { - const integerRegex = /^[0-9]+$/; - - return (value, symbol): SearchPropertyValidation => { - if (!defaultRelationSymbols.includes(symbol)) { - return { - isValid: false, - error: { - message: `Numbers can only use ${defaultRelationSymbols.join(", ")} comparison symbols.` - } - }; - } + const integerRegex = /^[0-9]+$/; - if (includeFloats) { - const parsed = Number(value); - if (isNaN(parsed)) { - return { - isValid: false, - error: { - message: `'${value}' is not valid number value.` - } - }; - } - - return { - isValid: true, - parsed - } - } - - if (!integerRegex.test(value)) { - return { - isValid: false, - error: { - message: `'${value}' is not valid integer value.` - } - }; + return (value, symbol): SearchPropertyValidation => { + if (!defaultRelationSymbols.includes(symbol)) { + return { + isValid: false, + error: { + message: `Numbers can only use ${defaultRelationSymbols.join(', ')} comparison symbols.` } + }; + } + if (includeFloats) { + const parsed = Number(value); + if (isNaN(parsed)) { return { - isValid: true, - parsed: Number(value) + isValid: false, + error: { + message: `'${value}' is not valid number value.` + } + }; + } + + return { + isValid: true, + parsed + }; + } + + if (!integerRegex.test(value)) { + return { + isValid: false, + error: { + message: `'${value}' is not valid integer value.` } + }; } + + return { + isValid: true, + parsed: Number(value) + }; + }; } export function set(set: string[], caseSensitive = true): SearchPropertyValidator { - if (set.length === 0) { - throw new Error("Set must have at least one value."); + if (set.length === 0) { + throw new Error('Set must have at least one value.'); + } + + // Apply normalization to set values + for (let i = 0; i < set.length; i++) { + const v = caseSensitive ? set[i] : set[i].toLowerCase(); + const index = set.indexOf(v); + + // Index of normalized value is same as current index (apples = apples.lower()) + if (i === index) { + continue; } - for (let i = 0; i < set.length; i++) { - const v = caseSensitive ? set[i] : set[i].toLowerCase(); + // Normalized value is not present in set (Bananas != Bananas.lower()) thus replace with normalized value + if (index === -1) { + set[i] = v; + continue; + } - const index = set.indexOf(v); + // Remove duplicated value + set.splice(i, 1); + } - if (i === index || index === -1) { - set[i] = v; - continue; + return (value, symbol): SearchPropertyValidation => { + if (!(symbol === '=' || symbol === '!=' || symbol === '==')) { + return { + isValid: false, + error: { + message: 'Set can only use =, ==, != comparison symbols.', + suggestion: { + symbol: '=', + description: 'Use equals instead.' + } } - - set.splice(i, 1); + }; } - return (value, symbol): SearchPropertyValidation => { - if (!(symbol === "=" || symbol === "!=" || symbol === "==")) { - return { - isValid: false, - error: { - message: "Set can only use =, ==, != comparison symbols.", - suggestion: { - symbol: "=", - description: "Use equals instead." - } - } - }; - } + const values = value.split(','); + const setValues = [...set]; - const values = value.split(","); - const setValues = [...set]; - - for (let i = 0; i < values.length; i++) { - const j = setValues.indexOf(values[i]); - - if (j !== -1) { - setValues.splice(j, 1); - continue; - } - - const incorrectValue = values[i]; - const index = closestLevenDist(incorrectValue, setValues); - let description = ""; - - if (index === -1) { - values.splice(i, 1); - description = `Remove '${incorrectValue}'`; - } else { - values[i] = set[index]; - description = `Did you mean '${set[index]}'?`; - } - - return { - isValid: false, - error: { - message: `'${incorrectValue}' is not one of these values: ${set.join(", ")}`, - suggestion: { - value: values.join(","), - description - }, - } - } - } + for (let i = 0; i < values.length; i++) { + const j = setValues.indexOf(values[i]); - return { - isValid: true, - parsed: values + if (j !== -1) { + setValues.splice(j, 1); + continue; + } + + // Value was not found in set. Suggest the closest value from set + const incorrectValue = values[i]; + const index = closestLevenDist(incorrectValue, setValues); + let description = ''; + + if (index === -1) { + // Submitted value is completely wrong. Suggest user to remove it + values.splice(i, 1); + description = `Remove '${incorrectValue}'`; + } else { + // User made a typo suggest correct value (tile -> title) + values[i] = set[index]; + description = `Did you mean '${set[index]}'?`; + } + + return { + isValid: false, + error: { + message: `'${incorrectValue}' is not one of these values: ${set.join(', ')}`, + suggestion: { + value: values.join(','), + description + } } + }; } + + return { + isValid: true, + parsed: values + }; + }; } +/** + * Supports multiple values for: + * - `true`: true, yes, 1 + * - `false`: false, no, 0 + */ export function bool(): SearchPropertyValidator { - return set(["true", "false", "yes", "no", "1", "0"], false); + return set(['true', 'false', 'yes', 'no', '1', '0'], false); } -const timeExtractors: [RegExp, (matches: RegExpMatchArray)=>any][] = [ +const timeExtractors: [RegExp, (matches: RegExpMatchArray) => any][] = [ [/^([0-9]+)s?$/, (matches: RegExpMatchArray) => Number(matches[1])], [/^([0-9]+)m$/, (matches: RegExpMatchArray) => Number(matches[1]) * 60], [/^([0-9]+):([0-9]+)$/, (matches: RegExpMatchArray) => Number(matches[1]) * 60 + Number(matches[2])] ]; +/** + * Supports multiple time formats + * - `{seconds}` or `{seconds}s` + * - `{minutes}m` + * - `{minutes}:{seconds}` + */ export function time(): SearchPropertyValidator { return (value: string, symbol: string): SearchPropertyValidation => { if (!defaultRelationSymbols.includes(symbol)) { return { isValid: false, error: { - message: `Time can only use ${defaultRelationSymbols.join(", ")} comparison symbols.` + message: `Time can only use ${defaultRelationSymbols.join(', ')} comparison symbols.` } }; } + // Find used format and return parsed value in seconds for (let i = 0; i < timeExtractors.length; i++) { const [regex, constructor] = timeExtractors[i]; const matches = regex.exec(value); @@ -193,18 +215,19 @@ export function time(): SearchPropertyValidator { return { isValid: true, parsed: constructor(matches) - } + }; } + // No smart suggestion because the used format is unknown. Suggest default 0:0 with `{minutes}:{seconds}` format return { isValid: false, error: { - message: "Not recognised time signature.", + message: 'Not recognised time format.', suggestion: { - value: "0:0", - description: "Try using min:sec signature." + value: '0:00', + description: 'Try using minutes:seconds format.' } } }; - } + }; } \ No newline at end of file diff --git a/src/main/lib/song/average-bpm.ts b/src/main/lib/song/average-bpm.ts index f3fd721e..68f97684 100644 --- a/src/main/lib/song/average-bpm.ts +++ b/src/main/lib/song/average-bpm.ts @@ -10,6 +10,7 @@ export function averageBPM(bpm: number[][], durationMS: number): number { return bpm[0][1]; } + // Calculate bpm that is used for the longest duration and return it const lookup = new Map(); let highestEntry: [number, number] = [-Infinity, NaN]; diff --git a/src/main/lib/song/filter.ts b/src/main/lib/song/filter.ts index 009f9c5d..b7653115 100644 --- a/src/main/lib/song/filter.ts +++ b/src/main/lib/song/filter.ts @@ -8,10 +8,13 @@ export function filter(indexes: SongIndex[], query: SongsQueryPayload): SongInde return indexes; } + // Unnamed may be valid words (DragonForce, Freedom, Dive) or parts of difficulty name ([Endless, [YEP]) + // All words are treated as parts of title const [title, diffs] = parseUnnamed(query.searchQuery.unnamed); return indexes.filter(s => { if (query.searchQuery === undefined) { + // Default pass return true; } @@ -58,6 +61,8 @@ export function filter(indexes: SongIndex[], query: SongsQueryPayload): SongInde }); } + + function parseUnnamed(unnamed: string[]): [string, string[]] { let titleBuffer = ""; const diffsBuffer: string[] = []; @@ -80,6 +85,18 @@ function parseUnnamed(unnamed: string[]): [string, string[]] { return [titleBuffer, diffsBuffer]; } + + +/** + * Pattern may be spelled incorrectly but still satisfy the filtering. The letters must be in correct order to pass + * + * Example: + * + * `pattern = sin` and `str = string` is valid because `SIN` is in `StrINg` + * + * @param pattern + * @param str + */ function compare(pattern: string, str: string) { pattern = pattern.toLowerCase().replaceAll("_", ""); // underscores are ignored @@ -195,6 +212,7 @@ function tagsFilter(indexTags: string[], tags: Tag[]): boolean { for (let i = 0; i < tags.length; i++) { const includesTag = indexTags.includes(tags[i].name); + // isSpecial is wrong name for the variable and should be renamed... It means isExcluded if (tags[i].isSpecial === true) { if (includesTag) { return false; diff --git a/src/main/lib/song/index.ts b/src/main/lib/song/index.ts index 36639d58..733b7c68 100644 --- a/src/main/lib/song/index.ts +++ b/src/main/lib/song/index.ts @@ -17,7 +17,19 @@ function createSongIndex(id: string, song: Song): SongIndex { }; } + + export type IndexCallback = (i: number, song: string) => void; + + + +/** + * For each song create song search index and creates relation between tag and song IDs. Returns array of song search + * indexes and map where key is tag and value is array of song IDs + * @param songs + * @param fn + * @see {SongIndex} + */ export function collectTagsAndIndexSongs(songs: { [id: string]: Song }, fn?: IndexCallback): [SongIndex[], Map] { const indexes: SongIndex[] = []; const tags = new Map(); @@ -28,6 +40,7 @@ export function collectTagsAndIndexSongs(songs: { [id: string]: Song }, fn?: Ind const song = songs[id]; if (fn !== undefined) { + // Update client progress bar fn(i, song.artist + " - " + song.title); } @@ -37,6 +50,7 @@ export function collectTagsAndIndexSongs(songs: { [id: string]: Song }, fn?: Ind continue; } + // Assign to each tag current song ID for (let i = 0; i < song.tags.length; i++) { const key = song.tags[i].toLowerCase(); const entry = tags.get(key); diff --git a/src/main/lib/song/order.ts b/src/main/lib/song/order.ts index cd3e383d..1790488b 100644 --- a/src/main/lib/song/order.ts +++ b/src/main/lib/song/order.ts @@ -10,6 +10,7 @@ export default function order(ordering: string): Result<(a: Song, b: Song) => nu const sortDirection = mode === "asc" ? 1 : -1; if (prop === undefined || mode === undefined) { + // idk why this is here tbh... return fail(`Bruh, this ordering '${ordering}' won't work... And you should know...`); } diff --git a/src/main/lib/storage/Storage.ts b/src/main/lib/storage/Storage.ts index 2e4ad428..2266b8b9 100644 --- a/src/main/lib/storage/Storage.ts +++ b/src/main/lib/storage/Storage.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import path from 'path'; import { app } from 'electron'; -import { TableMap } from '../../../@types'; +import { BlobMap, TableMap } from '../../../@types'; import { Table } from './Table'; @@ -9,6 +9,11 @@ import { Table } from './Table'; export class Storage { private static cache: Map> = new Map(); + /** + * Get file located in default-appdata-directory/storage/[table-name].json and parse it then save to cache and return. + * If file does not exist it is created with empty table + * @param name + */ static getTable(name: T): Table { const hit = this.cache.get(name); @@ -29,6 +34,11 @@ export class Storage { return table; } + /** + * Write whole table to file + * @param name + * @param contents + */ static setTable(name: T, contents: TableMap[T]): Table { const tablePath = path.join(app.getPath('userData'), `/storage/${name}.json`); if (!fs.existsSync(tablePath)) { @@ -42,8 +52,43 @@ export class Storage { return t; } + /** + * Delete whole table + * @param name + */ static removeTable(name: T): void { this.cache.delete(name); fs.unlinkSync(path.join(app.getPath('userData'), `/storage/${name}.json`)); } + + /** + * Get file with binary data. If the file does not exist an empty file is created and read. Resulting in empty buffer + * being returned + * @param name + */ + static getBlob(name: T): Buffer { + const blobPath = path.join(app.getPath('userData'), '/storage/' + name); + + if (!fs.existsSync(blobPath)) { + fs.mkdirSync(path.join(app.getPath('userData'), '/storage'), { recursive: true }); + fs.writeFileSync(blobPath, ''); + } + + return fs.readFileSync(blobPath); + } + + /** + * Write Buffer to file + * @param name + * @param blob + */ + static setBlob(name: T, blob: Buffer): void { + const blobPath = path.join(app.getPath('userData'), '/storage/' + name); + + if (!fs.existsSync(blobPath)) { + fs.mkdirSync(path.join(app.getPath('userData'), '/storage'), { recursive: true }); + } + + fs.writeFileSync(blobPath, blob); + } } diff --git a/src/main/lib/storage/Table.ts b/src/main/lib/storage/Table.ts index 2a638670..169a0b5c 100644 --- a/src/main/lib/storage/Table.ts +++ b/src/main/lib/storage/Table.ts @@ -9,21 +9,34 @@ export class Table { private readonly struct: S; private ramOnly = false; + + constructor(path: string, struct: S) { this.path = path; this.struct = struct; } + + get(key: K): Optional { return this.struct[key] === undefined ? none() : some(this.struct[key]); } + /** + * Returns underlying structure of this table + */ getStruct(): S { return this.struct; } + /** + * Writes content to given key (row). The contents are automatically saved to table file, unless {@link Table.hold} + * had been called + * @param key + * @param content + */ write(key: K, content: S[K]): void { this.struct[key] = content; @@ -34,6 +47,11 @@ export class Table { fs.writeFileSync(this.path, JSON.stringify(this.struct), { encoding: 'utf8' }); } + /** + * Remove given row. The contents are automatically saved to table file, unless {@link Table.hold} + * had been called + * @param key + */ delete(key: K): void { delete this.struct[key]; @@ -44,14 +62,23 @@ export class Table { fs.writeFileSync(this.path, JSON.stringify(this.struct), { encoding: 'utf8' }); } + /** + * Return path to table file + */ filePath(): string { return this.path; } + /** + * All changes are not going to be automatically saved to file until {@link Table.writeBack} is called + */ hold() { this.ramOnly = true; } + /** + * Saves all changes to table file + */ writeBack() { this.ramOnly = false; fs.writeFileSync(this.path, JSON.stringify(this.struct), { encoding: 'utf8' }); diff --git a/src/main/lib/template-parser/parser/TemplateParser.ts b/src/main/lib/template-parser/parser/TemplateParser.ts index a1966a1a..b3411fa2 100644 --- a/src/main/lib/template-parser/parser/TemplateParser.ts +++ b/src/main/lib/template-parser/parser/TemplateParser.ts @@ -1,3 +1,8 @@ +// Template follows tokenizer-parser architecture +// String is converted to tokens and parser validates grammar of token sequence. From this sequence a +// "list of instructions" is created. Using the "list of instructions" together with data source will produce a dynamic +// string templating. Similar architecture is used to interpret search queries + import { TemplateTokenizer, Tokens } from "../tokenizer/TemplateTokenizer"; import { closest } from "fastest-levenshtein"; @@ -43,6 +48,7 @@ export class TemplateParser { for (let i = 0; i < tokens.length; i++) { switch (tokens[i].type) { case "{": { + // Must follow pattern: {TEXT} const isIdentifier = tokens[i + 1]?.type === Tokens.Text && tokens[i + 2]?.type === Tokens.RightSquirly; if (!isIdentifier) { diff --git a/src/main/lib/template-parser/tokenizer/TemplateTokenizer.ts b/src/main/lib/template-parser/tokenizer/TemplateTokenizer.ts index adb5f1bd..7bcc423d 100644 --- a/src/main/lib/template-parser/tokenizer/TemplateTokenizer.ts +++ b/src/main/lib/template-parser/tokenizer/TemplateTokenizer.ts @@ -1,3 +1,8 @@ +// Template follows tokenizer-parser architecture +// String is converted to tokens and parser validates grammar of token sequence. From this sequence a +// "list of instructions" is created. Using the "list of instructions" together with data source will produce a dynamic +// string templating. Similar architecture is used to interpret search queries + export const Tokens = { Illegal: "ILLEGAL", EOF: "EOF", diff --git a/src/main/lib/throttle.ts b/src/main/lib/throttle.ts index fc5a4ae5..51012f73 100644 --- a/src/main/lib/throttle.ts +++ b/src/main/lib/throttle.ts @@ -1,17 +1,29 @@ export type ThrottledFunction any> = (...args: Parameters) => NodeJS.Timeout; - export type ThrottleCancel = () => void; + + +/** + * Wraps `fn` in throttled function. Calling this function will ignore and skip all calls until the initial call is + * resolved. Afterward the function will not go back to skipped calls and "waits" for next call + * + * @param fn + * @param ms + */ export function throttle any>(fn: F, ms: number): [ThrottledFunction, ThrottleCancel] { let timeout: NodeJS.Timeout | undefined = undefined; return [(...args) => { if (timeout !== undefined) { + // Ignore all calls return timeout; } + // Save timeout ID timeout = setTimeout(() => { const got = fn(...args); + + // Remove timeout ID so that next call will get throttled if (got instanceof Promise) { got.then(() => timeout = undefined); return; diff --git a/src/main/lib/tungsten/assertNever.ts b/src/main/lib/tungsten/assertNever.ts index 24bb5c3e..083575b4 100644 --- a/src/main/lib/tungsten/assertNever.ts +++ b/src/main/lib/tungsten/assertNever.ts @@ -1 +1,5 @@ +/** + * Code shall never reach this path + * @param _value + */ export function assertNever(_value: never) {} diff --git a/src/main/lib/tungsten/collections.ts b/src/main/lib/tungsten/collections.ts index 1eca75d2..120ca140 100644 --- a/src/main/lib/tungsten/collections.ts +++ b/src/main/lib/tungsten/collections.ts @@ -2,6 +2,10 @@ import { flatRNG } from './math'; +/** + * Randomly shuffle array of elements + * @param array + */ export function shuffle(array: T[]): void { for (let i = 0; i < array.length; i++) { const j = flatRNG(0, array.length); diff --git a/src/main/lib/tungsten/errorIgnored.ts b/src/main/lib/tungsten/errorIgnored.ts index da4d61f8..ef569653 100644 --- a/src/main/lib/tungsten/errorIgnored.ts +++ b/src/main/lib/tungsten/errorIgnored.ts @@ -1 +1,4 @@ +/** + * Used to explicitly state that the error is in fact ignored + */ export default function errorIgnored() {} \ No newline at end of file diff --git a/src/main/lib/tungsten/math.ts b/src/main/lib/tungsten/math.ts index 02285548..ba87f508 100644 --- a/src/main/lib/tungsten/math.ts +++ b/src/main/lib/tungsten/math.ts @@ -26,6 +26,12 @@ export function flatRNG(from: number, to: number): number { const CHARSET = "0123456789qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM-_"; + +/** + * Hash string and convert it to custom Base64 encoding + * @param str + * @param bits + */ export function hash(str: string, bits = 64): string { let n = 0n; const mask = BigInt("0b" + ("1".repeat(bits))); diff --git a/src/main/lib/tungsten/token.ts b/src/main/lib/tungsten/token.ts index f856f33c..d78db1e2 100644 --- a/src/main/lib/tungsten/token.ts +++ b/src/main/lib/tungsten/token.ts @@ -1,3 +1,5 @@ +// Library to create random unique tokens in given namespace + import { flatRNG } from './math'; @@ -10,6 +12,12 @@ export type Token = string; const globalTokens: Set = new Set(); +/** + * Generates unique token for given namespace. If namespace is undefined the global namespace is used + * @param forceFirstLetter + * @param length + * @param set + */ export function generateToken(forceFirstLetter = false, length = 8, set: Set = undefined as any): Token { let id = ""; const MAX_RETRIES = 10_000; @@ -36,6 +44,13 @@ export function generateToken(forceFirstLetter = false, length = 8, set: Set = undefined as any): void { (set ?? globalTokens).delete(token); } diff --git a/src/main/lib/utils.ts b/src/main/lib/utils.ts deleted file mode 100644 index 93f519a5..00000000 --- a/src/main/lib/utils.ts +++ /dev/null @@ -1,20 +0,0 @@ -// import { Song } from '../../@types'; -// import path from 'path'; -// import fs from 'fs'; - - - -// export function checkConfigChanges(songs: { [id: string]: Song }): void { -// let count = 0; -// const total = Object.values(songs).length; -// -// for (const id in songs) { -// const s = songs[id]; -// -// const configSource = path.join(s.dir, '/' + s.config.fileName); -// if (!(fs.existsSync(configSource) && fs.lstatSync(configSource).ctime.toISOString() === s.config.ctime)) { -// count++; -// process.stdout.write('\r\x1b[KNeed to update: ' + count + '/' + total); -// } -// } -// } diff --git a/src/main/lib/window/resizer.ts b/src/main/lib/window/resizer.ts index f3a11ed2..32cc964e 100644 --- a/src/main/lib/window/resizer.ts +++ b/src/main/lib/window/resizer.ts @@ -4,6 +4,10 @@ import { orDefault } from '../rust-like-utils-backend/Optional'; +/** + * Save window dimensions so that it can be opened the same size it was closed + * @param window + */ export default function trackBounds(window: BrowserWindow): void { const settings = Storage.getTable("settings"); @@ -33,6 +37,9 @@ export function getBounds(): [number, number] { ]; } + + export function wasMaximized(): boolean { - return orDefault(Storage.getTable("settings").get("window.isMaximized"), false); + return orDefault(Storage.getTable("settings") + .get("window.isMaximized"), false); } \ No newline at end of file diff --git a/src/main/main.ts b/src/main/main.ts index 6bfd522b..00b9efbb 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -3,7 +3,9 @@ import { Storage } from './lib/storage/Storage'; import { Router } from './lib/route-pass/Router'; import { showError } from './router/error-router'; import { dirSubmit } from './router/dir-router'; + import "./router/import"; + import { DirParseResult, OsuParser } from './lib/osu-file-parser/OsuParser'; import { orDefault } from './lib/rust-like-utils-backend/Optional'; import { throttle } from './lib/throttle'; @@ -19,9 +21,12 @@ export async function main(window: BrowserWindow) { mainWindow = window; const settings = Storage.getTable("settings"); + + // Deleting osuSongsDir will force initial beatmap import // settings.delete("osuSongsDir"); + const osuSongsDir = settings.get("osuSongsDir"); - if (settings.get("osuSongsDir").isNone) { + if (osuSongsDir.isNone) { await configureOsuDir(window); } else { //todo check for updates in song files @@ -42,6 +47,9 @@ const SONGS = 0; const AUDIO = 1; const IMAGES = 2; +// Update client progress bar 50 times a second +const UPDATE_DELAY_MS = 1_000 / 50; + async function configureOsuDir(mainWindow: BrowserWindow) { let tables: Awaited; const settings = Storage.getTable("settings"); @@ -53,62 +61,77 @@ async function configureOsuDir(mainWindow: BrowserWindow) { await Router.dispatch(mainWindow, "changeScene", "loading"); await Router.dispatch(mainWindow, "loadingScene::setTitle", "Importing songs from osu! Songs directory"); + // Wrap client update function to update only every UPDATE_DELAY_MS const [update, cancelUpdate] = throttle(async (i: number, total: number, file: string) => { await Router.dispatch(mainWindow, "loadingScene::update", { current: i, max: total, hint: file, }); - }, 25); + }, UPDATE_DELAY_MS); tables = await OsuParser.parseDir(dir, update); + // Cancel ongoing throttled update, so it does not look bad when it finishes and afterward the update overwrites + // finished state cancelUpdate(); if (tables.isError) { await showError(mainWindow, tables.error); + // Try again continue; } if (tables.value[SONGS].size === 0) { - await showError(mainWindow, `No songs found in folder: ${orDefault(settings.get("osuSongsDir"), "[No folder]")}. Please make sure this is the directory where you have all your songs saved.`); + await showError(mainWindow, `No songs found in folder: ${dir}. Please make sure this is the directory where you have all your songs saved.`); + // Try again continue; } + // All went smoothly. Save osu directory and continue with import procedure settings.write("osuSongsDir", dir); break; } while(true); + // Show finished state await Router.dispatch(mainWindow, "loadingScene::update", { max: tables.value[SONGS].size, current: tables.value[SONGS].size, hint: `Imported total of ${tables.value[SONGS].size} songs` }); + // Save created tables const songs = Object.fromEntries(tables.value[SONGS]); Storage.setTable("songs", songs); Storage.setTable("audio", Object.fromEntries(tables.value[AUDIO])); Storage.setTable("images", Object.fromEntries(tables.value[IMAGES])); + // Start indexing songs const total = Object.values(songs).length; await Router.dispatch(mainWindow, "loadingScene::setTitle", "Indexing songs"); + // Wrap client update function to update only every UPDATE_DELAY_MS const [update, cancelUpdate] = throttle(async (i: number, song: string) => { await Router.dispatch(mainWindow, "loadingScene::update", { current: i, hint: song, max: total }); - }, 25); + }, UPDATE_DELAY_MS); const [indexes, tags] = collectTagsAndIndexSongs(songs, update); + // Cancel ongoing throttled update, so it does not look bad when it finishes and afterward the update overwrites + // finished state cancelUpdate(); const system = Storage.getTable("system"); + + // Write batch of updates to system table system.hold(); system.write("indexes", indexes); system.write("allTags", Object.fromEntries(tags)); system.writeBack(); + // Display finished state await Router.dispatch(mainWindow, "loadingScene::update", { current: total, hint: "Indexed " + total + " songs", diff --git a/src/main/router/dev-router.ts b/src/main/router/dev-router.ts new file mode 100644 index 00000000..a0877d74 --- /dev/null +++ b/src/main/router/dev-router.ts @@ -0,0 +1,9 @@ +import { Router } from '../lib/route-pass/Router'; +import { app } from 'electron'; +import path from 'path'; + + + +Router.respond("dev::storeLocation", () => { + return path.join(app.getPath('userData'), "/storage"); +}); \ No newline at end of file diff --git a/src/main/router/dir-router.ts b/src/main/router/dir-router.ts index abebabed..f5e60b00 100644 --- a/src/main/router/dir-router.ts +++ b/src/main/router/dir-router.ts @@ -3,7 +3,7 @@ import { none, some } from "../lib/rust-like-utils-backend/Optional"; import { dialog } from "electron"; import path from "path"; -let waitList: ((dir: string) => void)[] = []; + Router.respond("dir::select", () => { const path = dialog.showOpenDialogSync({ @@ -18,6 +18,8 @@ Router.respond("dir::select", () => { return some(path[0]); }); + + Router.respond("dir::autoGetOsuSongsDir", () => { if (process.platform === "win32") { if (process.env.LOCALAPPDATA === undefined) { @@ -33,16 +35,28 @@ Router.respond("dir::autoGetOsuSongsDir", () => { return none(); }); + + +let pendingDirRequests: ((dir: string) => void)[] = []; + Router.respond("dir::submit", (_evt, dir) => { - for (let i = 0; i < waitList.length; i++) { - waitList[i](dir); + // Resolve all pending promises with value from client + for (let i = 0; i < pendingDirRequests.length; i++) { + pendingDirRequests[i](dir); } - waitList = []; + pendingDirRequests = []; }); + + +/** + * Await submitted directory from client. This function works on suspending promise's resolve function in array of + * pending requests. When user clicks Submit button the directory is passed to all pending resolve functions and the + * promises are resolved + */ export function dirSubmit(): Promise { return new Promise((resolve) => { - waitList.push(resolve); + pendingDirRequests.push(resolve); }); } diff --git a/src/main/router/error-router.ts b/src/main/router/error-router.ts index f34f4c7f..1266f004 100644 --- a/src/main/router/error-router.ts +++ b/src/main/router/error-router.ts @@ -3,21 +3,31 @@ import { BrowserWindow } from 'electron'; -let waitList: (()=>void)[] = []; +let pending: (()=>void)[] = []; Router.respond("error::dismissed", () => { - for (let i = 0; i < waitList.length; i++) { - waitList[i](); + // Error has been dismissed. Resolve all pending errors + for (let i = 0; i < pending.length; i++) { + pending[i](); } - waitList = []; + pending = []; }); + + +/** + * Requests change of scenes to error scene and displays error message. Returned promise is resolved when user dismisses + * the error. + * + * @param window + * @param msg + */ export async function showError(window: BrowserWindow, msg: string): Promise { await Router.dispatch(window, "changeScene", "error"); await Router.dispatch(window, "error::setMessage", msg); return new Promise(resolve => { - waitList.push(resolve); + pending.push(resolve); }); } \ No newline at end of file diff --git a/src/main/router/import.ts b/src/main/router/import.ts index 03819ac6..e8a2b748 100644 --- a/src/main/router/import.ts +++ b/src/main/router/import.ts @@ -1,3 +1,6 @@ +// This file serves as router initializer only. Reason being that main.ts had massive wall of import statements. Now +// it is just single file + import "./dir-router"; import "./error-router"; import "./queue-router"; @@ -6,37 +9,4 @@ import "./songs-pool-router"; import "./parser-router"; import "./local-volume-router"; import "./settings-router"; - - - -// Router.respond("bidirectionalInit", () => { -// return { -// initialIndex: 1 -// } -// }); -// -// const BUFFER_SIZE = 10; -// -// Router.respond("bidirectional", (_evt, request) => { -// if (request.index < 0 || request.index === 20) { -// return none(); -// } -// -// if (request.direction === "up") { -// return some({ -// index: request.index - 1, -// total: 200, -// items: new Array(BUFFER_SIZE) -// .fill(request.index * BUFFER_SIZE) -// .map((n, i) => n + i) -// }); -// } -// -// return some({ -// index: request.index + 1, -// total: 200, -// items: new Array(BUFFER_SIZE) -// .fill(request.index * BUFFER_SIZE) -// .map((n, i) => n + i) -// }); -// }); \ No newline at end of file +import "./dev-router"; \ No newline at end of file diff --git a/src/main/router/parser-router.ts b/src/main/router/parser-router.ts index e1a704b5..4c3e6fce 100644 --- a/src/main/router/parser-router.ts +++ b/src/main/router/parser-router.ts @@ -12,6 +12,12 @@ import templateIdentifiers from '../lib/template-parser/template-identifiers'; +/** + * Examples of valid search queries: + * - `freedom dive` + * - `bpm>220 mode=o length<300 artist=xi` + * - `[endless dimensions]` + */ const searchParser = new SearchParser({ tokenDelimiter: " ", relationSymbols: defaultRelationSymbols, @@ -26,13 +32,23 @@ const searchParser = new SearchParser({ }); Router.respond("parse::search", (_evt, query) => { + // Validates query and creates request for filtering. If the search query is incorrect suggestion to make it correct + // is returned return searchParser.parse(query); }); +/** + * Valid templates: + * - `Static template` -> Static template + * - `{ARTIST} - {TITLE}` -> xi - Freedom Dive + * - `[{BPM} BPM] {ARTIST} - {TITLE}` -> [222 BPM] xi - Freedom Dive + */ const templateParser = new TemplateParser(templateIdentifiers); Router.respond("parse::template", (_evt, template) => { + // Validates template. If the template is incorrect suggestion to make it correct is incorrect suggestion to make it + // correct is returned return templateParser.parse(template); }); \ No newline at end of file diff --git a/src/main/router/queue-router.ts b/src/main/router/queue-router.ts index 3151222f..f1d636e9 100644 --- a/src/main/router/queue-router.ts +++ b/src/main/router/queue-router.ts @@ -29,6 +29,7 @@ let lastPayload: QueueCreatePayload | undefined; Router.respond("queue::create", async (_evt, payload) => { if (comparePayload(payload, lastPayload)) { + // Payload is practically same. Find start song and play queue from there const newIndex = queue.findIndex(s => s.path === payload.startSong); if (newIndex === -1 || newIndex === index) { @@ -43,14 +44,22 @@ Router.respond("queue::create", async (_evt, payload) => { } lastPayload = payload; + /** + * Create list of {@link SongIndex} from current {@link QueueView}. This list is filtered via payload + * specifications. Afterward it is mapped back to {@link Song} object + */ queue = Array.from(indexMapper(filter(getIndexes(payload.view), payload))); + /** + * Create ordering function from order literal {@link QueueCreatePayload} + */ const ordering = order(payload.order); if (!ordering.isError) { queue.sort(ordering.value); } + // Set playing index const songIndex = queue.findIndex(s => s.path === payload.startSong); if (songIndex !== -1) { @@ -158,6 +167,7 @@ function duration(startIndex = 0): Result { Router.respond("queue::shuffle", async () => { + // Shuffle whole queue except currently playing song. Its position will be first in shuffled queue if (queue === undefined) { return; } @@ -191,6 +201,7 @@ Router.respond("queue::shuffle", async () => { Router.respond("queue::place", (_evt, what, after) => { + // Find index of subject const whatIndex = queue.findIndex(s => s.path === what); if (whatIndex === -1) { @@ -201,14 +212,17 @@ Router.respond("queue::place", (_evt, what, after) => { queue.splice(whatIndex, 1); if (after === undefined) { + // After is referring to the head of the queue. Place subject at the very start queue.unshift(s); if (whatIndex === index) { + // Update currently playing index index = 0; return; } if (whatIndex > index) { + // Subject was moved before currently playing thus currently playing must be increased index++; } @@ -218,6 +232,7 @@ Router.respond("queue::place", (_evt, what, after) => { const afterIndex = queue.findIndex(s => s.path === after); if (afterIndex === -1) { + // After index was not found... put subject back queue.splice(whatIndex, 0, s); return; } @@ -225,17 +240,18 @@ Router.respond("queue::place", (_evt, what, after) => { queue.splice(afterIndex + 1, 0, s); if (whatIndex === index) { + // Subject was currently playing before move operation. Update currently playing index index = afterIndex + 1; return; } if (whatIndex > index && afterIndex < index) { - // moved song that was after currently playing song before currently playing + // Moved subject that was after currently playing before currently playing -> increment index++; } if (whatIndex < index && afterIndex + 1 >= index) { - // moved song that was before currently playing song after currently playing + // Moved subject that was before currently playing after currently playing -> decrement index--; } }); @@ -243,6 +259,7 @@ Router.respond("queue::place", (_evt, what, after) => { Router.respond("queue::play", async (_evt, song) => { + // Point currently playing index to given song const newIndex = queue.findIndex(s => s.path === song); if (newIndex === -1 || newIndex === index) { @@ -276,6 +293,7 @@ Router.respond("queue::playNext", async (_evt, song) => { queue.splice(index + 1, 0, s.value); } else { + // Song is in queue. Move it after currently playing const s = queue[songIndex]; queue.splice(songIndex, 1); queue.splice(index + 1, 0, s); @@ -296,8 +314,6 @@ Router.respond("queue::removeSong", async (_evt, what) => { return; } - console.log(index); - const whatIndex = queue.findIndex(s => s.path === what); if (whatIndex === -1) { @@ -308,8 +324,6 @@ Router.respond("queue::removeSong", async (_evt, what) => { index--; } - console.log(what, whatIndex, index); - queue.splice(whatIndex, 1); await Router.dispatch(mainWindow, 'queue::created') @@ -377,6 +391,11 @@ Router.respond("query::queue::init", () => { }); Router.respond('query::queue', (_evt, request) => { + // Queue view may be rendered only around currently playing. When user scrolls up and there is content that could be + // loaded and prepended the request.direction is "up". If user scrolls down the request.direction is "down". For given + // request create new page of size BUFFER_SIZE and send it to client to. This way user will load the whole list + // incrementally, and it will reduce initial load lag + if (queue === undefined || request.index < 0 || request.index > Math.floor(queue.length / BUFFER_SIZE)) { return none(); } diff --git a/src/main/router/resource-router.ts b/src/main/router/resource-router.ts index 1030cc5f..af1aa7d6 100644 --- a/src/main/router/resource-router.ts +++ b/src/main/router/resource-router.ts @@ -15,6 +15,7 @@ Router.respond("resource::getPath", (_evt, id) => { return fail("Could not provide absolute path because osu! Songs folder is undefined."); } + // todo User may have spaces in osuDir if they are not using default path. Ensure that the whole path is valid URL return ok(osuDir.value + "/" + encodeFile(id)); }); diff --git a/src/main/router/songs-pool-router.ts b/src/main/router/songs-pool-router.ts index ce16246d..f7e7fa9a 100644 --- a/src/main/router/songs-pool-router.ts +++ b/src/main/router/songs-pool-router.ts @@ -27,6 +27,8 @@ Router.respond("query::songsPool::init", (_evt, payload) => { const BUFFER_SIZE = 50; Router.respond("query::songsPool", (_evt, request, payload) => { + // Similar as for queue list pagination. Song pool is simpler that the direction may only be down + if (request.direction === "up") { return none(); }