Files
Bridge-Multi/src-shared/UtilFunctions.ts
2024-07-12 15:42:41 -05:00

405 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { interpolate as culoriInterpolate, oklch, wcagContrast } from 'culori'
import _ from 'lodash'
import sanitize from 'sanitize-filename'
import { Difficulty, Instrument } from 'scan-chart'
import { ChartData } from './interfaces/search.interface'
import { ThemeColors } from './interfaces/theme.interface'
// WARNING: do not import anything related to Electron; the code will not compile correctly.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AnyFunction = (...args: any) => any
/** Overwrites the type of a nested property in `T` with `U`. */
export type Overwrite<T, U> = U extends object ? (
T extends object ? {
[K in keyof T]: K extends keyof U ? Overwrite<T[K], U[K]> : T[K];
} : U
) : U
export type RequireMatchingProps<T, K extends keyof T> = T & { [P in K]-?: NonNullable<T[P]> }
/**
* @returns `https://drive.google.com/open?id=${fileID}`
*/
export function driveLink(fileId: string) {
return `https://drive.google.com/open?id=${fileId}`
}
/**
* @returns `text` converted to lower case.
*/
export function lower(text: string) {
return text.toLowerCase()
}
/**
* Converts `val` from the range (`fromStart`, `fromEnd`) to the range (`toStart`, `toEnd`).
*/
export function interpolate(val: number, fromStart: number, fromEnd: number, toStart: number, toEnd: number) {
return ((val - fromStart) / (fromEnd - fromStart)) * (toEnd - toStart) + toStart
}
/**
* @returns `objectList` split into multiple groups, where each group contains objects where every one of its values in `keys` matches.
*/
export function groupBy<T>(objectList: T[], ...keys: (keyof T)[]) {
const results: T[][] = []
for (const object of objectList) {
const matchingGroup = results.find(result => keys.every(key => result[0][key] === object[key]))
if (matchingGroup !== undefined) {
matchingGroup.push(object)
} else {
results.push([object])
}
}
return results
}
export const instruments = [
'guitar', 'guitarcoop', 'rhythm', 'bass', 'drums', 'keys', 'guitarghl', 'guitarcoopghl', 'rhythmghl', 'bassghl',
] as const satisfies Readonly<Instrument[]>
export const difficulties = ['expert', 'hard', 'medium', 'easy'] as const satisfies Readonly<Difficulty[]>
export function instrumentDisplay(instrument: Instrument | null) {
switch (instrument) {
case 'guitar': return 'Lead Guitar'
case 'guitarcoop': return 'Co-op Guitar'
case 'rhythm': return 'Rhythm Guitar'
case 'bass': return 'Bass Guitar'
case 'drums': return 'Drums'
case 'keys': return 'Keys'
case 'guitarghl': return 'GHL (6-fret) Lead Guitar'
case 'guitarcoopghl': return 'GHL (6-fret) Co-op Guitar'
case 'rhythmghl': return 'GHL (6-fret) Rhythm Guitar'
case 'bassghl': return 'GHL (6-fret) Bass Guitar'
case null: return 'Any Instrument'
}
}
export function shortInstrumentDisplay(instrument: Instrument | null) {
switch (instrument) {
case 'guitar': return 'Guitar'
case 'guitarcoop': return 'Co-op'
case 'rhythm': return 'Rhythm'
case 'bass': return 'Bass'
case 'drums': return 'Drums'
case 'keys': return 'Keys'
case 'guitarghl': return 'GHL Guitar'
case 'guitarcoopghl': return 'GHL Co-op'
case 'rhythmghl': return 'GHL Rhythm'
case 'bassghl': return 'GHL Bass'
case null: return 'Any Instrument'
}
}
export function difficultyDisplay(difficulty: Difficulty | null) {
switch (difficulty) {
case 'expert': return 'Expert'
case 'hard': return 'Hard'
case 'medium': return 'Medium'
case 'easy': return 'Easy'
case null: return 'Any Difficulty'
}
}
export function instrumentToDiff(instrument: Instrument | 'vocals') {
switch (instrument) {
case 'guitar': return 'diff_guitar'
case 'guitarcoop': return 'diff_guitar_coop'
case 'rhythm': return 'diff_rhythm'
case 'bass': return 'diff_bass'
case 'drums': return 'diff_drums'
case 'keys': return 'diff_keys'
case 'guitarghl': return 'diff_guitarghl'
case 'guitarcoopghl': return 'diff_guitar_coop_ghl'
case 'rhythmghl': return 'diff_rhythm_ghl'
case 'bassghl': return 'diff_bassghl'
case 'vocals': return 'diff_vocals'
}
}
/**
* @returns a string representation of `ms` that looks like HH:MM:SS
*/
export function msToRoughTime(ms: number) {
const seconds = _.floor((ms / 1000) % 60)
const minutes = _.floor((ms / 1000 / 60) % 60)
const hours = _.floor((ms / 1000 / 60 / 60) % 24)
return `${hours ? `${hours}:` : ''}${minutes}:${_.padStart(String(seconds), 2, '0')}`
}
const allowedTags = [
'align', 'allcaps', 'alpha', 'b', 'br', 'color', 'cspace', 'font', 'font-weight',
'gradient', 'i', 'indent', 'line-height', 'line-indent', 'link', 'lowercase',
'margin', 'mark', 'mspace', 'nobr', 'noparse', 'page', 'pos', 'rotate', 's',
'size', 'smallcaps', 'space', 'sprite', 'strikethrough', 'style', 'sub', 'sup',
'u', 'uppercase', 'voffset', 'width', '#',
]
const tagPattern = allowedTags.map(tag => `\\b${tag}\\b`).join('|')
/**
* @returns `text` with all style tags removed. (e.g. "<color=#AEFFFF>Aren Eternal</color> & Geo" -> "Aren Eternal & Geo")
*/
export function removeStyleTags(text: string) {
let oldText = text
let newText = text
do {
oldText = newText
newText = newText.replace(new RegExp(`<\\s*\\/?\\s*(?:${tagPattern})[^>]*>`, 'gi'), '').trim()
} while (newText !== oldText)
return newText
}
export function hasIssues(chart: Pick<ChartData, 'metadataIssues' | 'folderIssues' | 'notesData'>) {
if (chart.metadataIssues.length > 0) { return true }
for (const folderIssue of chart.folderIssues) {
if (!['albumArtSize', 'invalidIni', 'multipleVideo', 'badIniLine'].includes(folderIssue.folderIssue)) { return true }
}
for (const chartIssue of chart.notesData?.chartIssues ?? []) {
if (chartIssue !== 'isDefaultBPM') { return true }
}
for (const trackIssue of chart.notesData?.trackIssues ?? []) {
for (const ti of trackIssue.trackIssues) {
if (ti !== 'noNotesOnNonemptyTrack') { return true }
}
}
for (const noteIssue of chart.notesData?.noteIssues ?? []) {
for (const ni of noteIssue.noteIssues) {
if (ni.issueType !== 'babySustain') { return true }
}
}
return false
}
export function resolveChartFolderName(
chartFolderName: string,
chart: { name: string; artist: string; album: string; genre: string; year: string; charter: string },
) {
if (_.sumBy(chartFolderName.split('/'), n => n.length) === 0) {
chartFolderName = '{artist} - {name} ({charter})'
}
const pathParts = chartFolderName.split('/')
const resolvedPathParts: string[] = []
for (const pathPart of pathParts) {
const resolvedPath = sanitizeNonemptyFilename(pathPart
.replace(/\{name\}/g, chart.name)
.replace(/\{artist\}/g, chart.artist)
.replace(/\{album\}/g, chart.album)
.replace(/\{genre\}/g, chart.genre)
.replace(/\{year\}/g, chart.year)
.replace(/\{charter\}/g, chart.charter))
if (resolvedPath.length > 0) {
resolvedPathParts.push(resolvedPath)
}
}
return resolvedPathParts.join('/')
}
/**
* @returns `filename` with all invalid filename characters replaced. Assumes `filename` has at least one valid filename character already.
*/
export function sanitizeNonemptyFilename(filename: string) {
return sanitize(filename, {
replacement: (invalidChar: string) => {
switch (invalidChar) {
case '<':
return ''
case '>':
return ''
case ':':
return ''
case '"':
return "'"
case '/':
return ''
case '\\':
return ''
case '|':
return '⏐'
case '?':
return ''
case '*':
return ''
default:
return '_'
}
},
})
}
/* eslint-disable @typescript-eslint/naming-convention */
export const colorNames = {
"primary": "--p",
"primary-content": "--pc",
"secondary": "--s",
"secondary-content": "--sc",
"accent": "--a",
"accent-content": "--ac",
"neutral": "--n",
"neutral-content": "--nc",
"base-100": "--b1",
"base-200": "--b2",
"base-300": "--b3",
"base-content": "--bc",
"info": "--in",
"info-content": "--inc",
"success": "--su",
"success-content": "--suc",
"warning": "--wa",
"warning-content": "--wac",
"error": "--er",
"error-content": "--erc",
} as { [colorName: string]: string }
const defaultVariables = {
"--rounded-box": "1rem",
"--rounded-btn": "0.5rem",
"--rounded-badge": "1.9rem",
"--animation-btn": "0.25s",
"--animation-input": ".2s",
"--btn-focus-scale": "0.95",
"--border-btn": "1px",
"--tab-border": "1px",
"--tab-radius": "0.5rem",
}
/* eslint-enable @typescript-eslint/naming-convention */
export function convertColorFormat(input: ThemeColors) {
if (typeof input !== "object" || input === null) {
return input
}
const resultObj: { [cssKey: string]: string } = {}
for (const [rule, value] of Object.entries(input)) {
if (Object.hasOwn(colorNames, rule)) {
const colorObj = oklch(value)
resultObj[colorNames[rule]] = colorObjToString(colorObj!)
} else {
resultObj[rule] = value
}
// auto generate base colors
if (!Object.hasOwn(input, "base-100")) {
resultObj["--b1"] = "100% 0 0"
}
if (!Object.hasOwn(input, "base-200")) {
resultObj["--b2"] = generateDarkenColorFrom(input["base-100"], 0.07)
}
if (!Object.hasOwn(input, "base-300")) {
if (Object.hasOwn(input, "base-200")) {
resultObj["--b3"] = generateDarkenColorFrom(input["base-200"], 0.07)
} else {
resultObj["--b3"] = generateDarkenColorFrom(input["base-100"], 0.14)
}
}
// auto generate state colors
if (!Object.hasOwn(input, "info")) {
resultObj["--in"] = "72.06% 0.191 231.6"
}
if (!Object.hasOwn(input, "success")) {
resultObj["--su"] = "64.8% 0.150 160"
}
if (!Object.hasOwn(input, "warning")) {
resultObj["--wa"] = "84.71% 0.199 83.87"
}
if (!Object.hasOwn(input, "error")) {
resultObj["--er"] = "71.76% 0.221 22.18"
}
// auto generate content colors
if (!Object.hasOwn(input, "base-content")) {
resultObj["--bc"] = generateForegroundColorFrom(input["base-100"], 0.8)
}
if (!Object.hasOwn(input, "primary-content")) {
resultObj["--pc"] = generateForegroundColorFrom(input.primary, 0.8)
}
if (!Object.hasOwn(input, "secondary-content")) {
resultObj["--sc"] = generateForegroundColorFrom(input.secondary, 0.8)
}
if (!Object.hasOwn(input, "accent-content")) {
resultObj["--ac"] = generateForegroundColorFrom(input.accent, 0.8)
}
if (!Object.hasOwn(input, "neutral-content")) {
resultObj["--nc"] = generateForegroundColorFrom(input.neutral, 0.8)
}
if (!Object.hasOwn(input, "info-content")) {
if (Object.hasOwn(input, "info")) {
resultObj["--inc"] = generateForegroundColorFrom(input.info, 0.8)
} else {
resultObj["--inc"] = "0% 0 0"
}
}
if (!Object.hasOwn(input, "success-content")) {
if (Object.hasOwn(input, "success")) {
resultObj["--suc"] = generateForegroundColorFrom(input.success, 0.8)
} else {
resultObj["--suc"] = "0% 0 0"
}
}
if (!Object.hasOwn(input, "warning-content")) {
if (Object.hasOwn(input, "warning")) {
resultObj["--wac"] = generateForegroundColorFrom(input.warning, 0.8)
} else {
resultObj["--wac"] = "0% 0 0"
}
}
if (!Object.hasOwn(input, "error-content")) {
if (Object.hasOwn(input, "error")) {
resultObj["--erc"] = generateForegroundColorFrom(input.error, 0.8)
} else {
resultObj["--erc"] = "0% 0 0"
}
}
// add css variables if not exist
for (const item of Object.entries(defaultVariables)) {
const [variable, value] = item
if (!Object.hasOwn(input, variable)) {
resultObj[variable] = value
}
}
// add other custom styles
if (!Object.hasOwn(colorNames, rule)) {
resultObj[rule] = value
}
}
return resultObj
}
function generateForegroundColorFrom(input: string, percentage = 0.8) {
const result = culoriInterpolate([input, isDark(input) ? "white" : "black"], "oklch")(percentage)
return colorObjToString(result)
}
function generateDarkenColorFrom(input: string, percentage = 0.07) {
const result = culoriInterpolate([input, "black"], "oklch")(percentage)
return colorObjToString(result)
}
function colorObjToString(input: { l: number; c: number; h?: number }) {
const { l, c, h } = input
return `${Number.parseFloat((cutNumber(l) * 100).toFixed(6))}% ${cutNumber(c)} ${cutNumber(h ?? 0)}`
}
function cutNumber(number: number) {
if (number) {
return +number.toFixed(6)
}
return 0
}
function isDark(color: string) {
return wcagContrast(color, "black") < wcagContrast(color, "white")
}