UNPKG

@etsoo/shared

Version:

TypeScript shared utilities and functions

1,042 lines (929 loc) 27.2 kB
import { DataTypes, IdType } from "./DataTypes"; import isEqual from "lodash.isequal"; import { DateUtils } from "./DateUtils"; import { ParsedPath } from "./types/ParsedPath"; declare global { interface String { /** * Add parameter to URL * @param this URL to add parameter * @param name Parameter name * @param value Parameter value * @param arrayFormat Array format to array style or not to multiple fields * @returns New URL */ addUrlParam( this: string, name: string, value: DataTypes.Simple, arrayFormat?: boolean | string ): string; /** * Add parameters to URL * @param this URL to add parameters * @param data Parameters * @param arrayFormat Array format to array style or not to multiple fields * @returns New URL */ addUrlParams( this: string, data: DataTypes.SimpleObject, arrayFormat?: boolean | string ): string; /** * Add parameters to URL * @param this URL to add parameters * @param params Parameters string * @returns New URL */ addUrlParams(this: string, params: string): string; /** * Check the input string contains Chinese character or not * @param this Input * @param test Test string */ containChinese(this: string): boolean; /** * Check the input string contains Korean character or not * @param this Input * @param test Test string */ containKorean(this: string): boolean; /** * Check the input string contains Japanese character or not * @param this Input * @param test Test string */ containJapanese(this: string): boolean; /** * Format string with parameters * @param this Input template * @param parameters Parameters to fill the template */ format(this: string, ...parameters: string[]): string; /** * Format inital character to lower case or upper case * @param this Input string * @param upperCase To upper case or lower case */ formatInitial(this: string, upperCase: boolean): string; /** * Hide data * @param this Input string * @param endChar End char */ hideData(this: string, endChar?: string): string; /** * Hide email data * @param this Input email */ hideEmail(this: string): string; /** * Is digits string * @param this Input string * @param minLength Minimum length */ isDigits(this: string, minLength?: number): boolean; /** * Is email string * @param this Input string */ isEmail(this: string): boolean; /** * Remove non letters (0-9, a-z, A-Z) * @param this Input string */ removeNonLetters(this: string): string; } } String.prototype.addUrlParam = function ( this: string, name: string, value: DataTypes.Simple, arrayFormat?: boolean | string ) { return this.addUrlParams({ [name]: value }, arrayFormat); }; String.prototype.addUrlParams = function ( this: string, data: DataTypes.SimpleObject | string, arrayFormat?: boolean | string ) { if (typeof data === "string") { let url = this; if (url.includes("?")) { url += "&"; } else { if (!url.endsWith("/")) url = url + "/"; url += "?"; } return url + data; } // Simple check if (typeof URL === "undefined" || !this.includes("://")) { const params = Object.entries(data) .map(([key, value]) => { let v: string; if (Array.isArray(value)) { if (arrayFormat == null || arrayFormat === false) { return value .map((item) => `${key}=${encodeURIComponent(`${item}`)}`) .join("&"); } else { v = value.join(arrayFormat ? "," : arrayFormat); } } else if (value instanceof Date) { v = value.toJSON(); } else { v = value == null ? "" : `${value}`; } return `${key}=${encodeURIComponent(v)}`; }) .join("&"); return this.addUrlParams(params); } else { const urlObj = new URL(this); Object.entries(data).forEach(([key, value]) => { if (Array.isArray(value)) { if (arrayFormat == null || arrayFormat === false) { value.forEach((item) => { urlObj.searchParams.append(key, `${item}`); }); } else { urlObj.searchParams.append( key, value.join(arrayFormat ? "," : arrayFormat) ); } } else if (value instanceof Date) { urlObj.searchParams.append(key, value.toJSON()); } else { urlObj.searchParams.append(key, `${value == null ? "" : value}`); } }); return urlObj.toString(); } }; String.prototype.containChinese = function (this: string): boolean { const regExp = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff66-\uff9f]/g; return regExp.test(this); }; String.prototype.containKorean = function (this: string): boolean { const regExp = /[\uac00-\ud7af\u1100-\u11ff\u3130-\u318f\ua960-\ua97f\ud7b0-\ud7ff\u3400-\u4dbf]/g; return regExp.test(this); }; String.prototype.containJapanese = function (this: string): boolean { const regExp = /[\u3040-\u309f\u30a0-\u30ff\uff00-\uff9f\u4e00-\u9faf]/g; return regExp.test(this); }; String.prototype.format = function ( this: string, ...parameters: string[] ): string { let template = this; parameters.forEach((value, index) => { template = template.replace(new RegExp(`\\{${index}\\}`, "g"), value); }); return template; }; String.prototype.formatInitial = function ( this: string, upperCase: boolean = false ) { const initial = this.charAt(0); return ( (upperCase ? initial.toUpperCase() : initial.toLowerCase()) + this.slice(1) ); }; String.prototype.hideData = function (this: string, endChar?: string) { if (this.length === 0) return this; if (endChar != null) { const index = this.indexOf(endChar); if (index === -1) return this.hideData(); return this.substring(0, index).hideData() + this.substring(index); } var len = this.length; if (len < 4) return this.substring(0, 1) + "***"; if (len < 6) return this.substring(0, 2) + "***"; if (len < 8) return this.substring(0, 2) + "***" + this.slice(-2); if (len < 12) return this.substring(0, 3) + "***" + this.slice(-3); return this.substring(0, 4) + "***" + this.slice(-4); }; String.prototype.hideEmail = function (this: string) { return this.hideData("@"); }; String.prototype.isDigits = function (this: string, minLength?: number) { return this.length >= (minLength ?? 0) && /^\d+$/.test(this); }; String.prototype.isEmail = function (this: string) { const re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; return re.test(this.toLowerCase()); }; String.prototype.removeNonLetters = function (this: string) { return this.replace(/[^a-zA-Z0-9]/g, ""); }; /** * Utilities */ export namespace Utils { const IgnoredProperties = ["changedFields", "id"] as const; /** * Add blank item to collection * @param options Options * @param idField Id field, default is id * @param labelField Label field, default is label * @param blankLabel Blank label, default is --- */ export function addBlankItem<T extends object>( options: T[], idField?: string | keyof T, labelField?: unknown, blankLabel?: string ) { // Avoid duplicate blank items idField ??= "id"; if (options.length === 0 || Reflect.get(options[0], idField) !== "") { const blankItem: any = { [idField]: "", [typeof labelField === "string" ? labelField : "label"]: blankLabel ?? "---" }; options.unshift(blankItem); } return options; } /** * Base64 chars to number * @param base64Chars Base64 chars * @returns Number */ export function charsToNumber(base64Chars: string) { const chars = typeof Buffer === "undefined" ? [...atob(base64Chars)].map((char) => char.charCodeAt(0)) : [...Buffer.from(base64Chars, "base64")]; return chars.reduce((previousValue, currentValue, currentIndex) => { return previousValue + currentValue * Math.pow(128, currentIndex); }, 0); } /** * Correct object's property value type * @param input Input object * @param fields Fields to correct */ export function correctTypes< T extends object, F extends { [P in keyof T]?: DataTypes.BasicNames } >(input: T, fields: F) { for (const field in fields) { const type = fields[field]; if (type == null) continue; const value = Reflect.get(input, field); const newValue = DataTypes.convertByType(value, type); if (newValue !== value) { Reflect.set(input, field, newValue); } } } /** * Two values equal * @param v1 Value 1 * @param v2 Value 2 * @param strict Strict level, 0 with ==, 1 === but null equal undefined, 2 === */ export function equals(v1: unknown, v2: unknown, strict = 1) { // Null and undefined case if (v1 == null || v2 == null) { if (strict <= 1 && v1 == v2) return true; return v1 === v2; } // For date, array and object if (typeof v1 === "object") return isEqual(v1, v2); // 1 and '1' case if (strict === 0) return v1 == v2; // Strict equal return v1 === v2; } /** * Exclude specific items * @param items Items * @param field Filter field * @param excludedValues Excluded values * @returns Result */ export function exclude< T extends { [P in D]: IdType }, D extends string = "id" >(items: T[], field: D, ...excludedValues: T[D][]) { return items.filter((item) => !excludedValues.includes(item[field])); } /** * Async exclude specific items * @param items Items * @param field Filter field * @param excludedValues Excluded values * @returns Result */ export async function excludeAsync< T extends { [P in D]: IdType }, D extends string = "id" >(items: Promise<T[] | undefined>, field: D, ...excludedValues: T[D][]) { const result = await items; if (result == null) return result; return exclude(result, field, ...excludedValues); } /** * Format inital character to lower case or upper case * @param input Input string * @param upperCase To upper case or lower case */ export function formatInitial(input: string, upperCase: boolean = false) { return input.formatInitial(upperCase); } /** * Format string with parameters * @param template Template with {0}, {1}, ... * @param parameters Parameters to fill the template * @returns Result */ export function formatString(template: string, ...parameters: string[]) { return template.format(...parameters); } /** * Get data changed fields (ignored changedFields) with input data updated * @param input Input data * @param initData Initial data * @param ignoreFields Ignore fields, default is ['changedFields', 'id'] * @returns */ export function getDataChanges< T extends object, const I extends (keyof T & string)[] | undefined = undefined >( input: T, initData: object, ignoreFields?: I ): Exclude< keyof T & string, I extends undefined ? (typeof IgnoredProperties)[number] : Exclude<I, undefined>[number] >[] { // Default ignore fields const fields = ignoreFields ?? IgnoredProperties; // Changed fields const changes: Exclude< keyof T & string, I extends undefined ? (typeof IgnoredProperties)[number] : Exclude<I, undefined>[number] >[] = []; Object.entries(input).forEach(([key, value]) => { // Ignore fields, no process if (fields.includes(key as any)) return; // Compare with init value const initValue = Reflect.get(initData, key); if (value == null && initValue == null) { // Both are null, it's equal Reflect.deleteProperty(input, key); return; } if (initValue != null) { // Date when meets string if (value instanceof Date) { if (value.valueOf() === DateUtils.parse(initValue)?.valueOf()) { Reflect.deleteProperty(input, key); return; } changes.push(key as any); return; } const newValue = DataTypes.convert(value, initValue); if (Utils.equals(newValue, initValue)) { Reflect.deleteProperty(input, key); return; } // Update Reflect.set(input, key, newValue); } // Remove empty property if (value == null || value === "") { Reflect.deleteProperty(input, key); } // Hold the key changes.push(key as any); }); return changes; } /** * Get nested value from object * @param data Data * @param name Field name, support property chain like 'jsonData.logSize' * @returns Result */ export function getNestedValue(data: object, name: string) { const properties = name.split("."); const len = properties.length; if (len === 1) { return Reflect.get(data, name); } else { let curr = data; for (let i = 0; i < len; i++) { const property = properties[i]; if (i + 1 === len) { return Reflect.get(curr, property); } else { let p = Reflect.get(curr, property); if (p == null) { return undefined; } curr = p; } } } } /** * Get input function or value result * @param input Input function or value * @param args Arguments * @returns Result */ export const getResult = <R, T = DataTypes.Func<R> | R>( input: T, ...args: T extends DataTypes.Func<R> ? Parameters<typeof input> : never | [] ): R => { return typeof input === "function" ? input(...args) : (input as unknown as R); }; /** * Get time zone * @param tz Default timezone, default is UTC * @returns Timezone */ export const getTimeZone = (tz?: string) => { // If Intl supported if (typeof Intl === "object" && typeof Intl.DateTimeFormat === "function") return Intl.DateTimeFormat().resolvedOptions().timeZone; // Default timezone return tz ?? "UTC"; }; /** * Check the input string contains HTML entity or not * @param input Input string * @returns Result */ export function hasHtmlEntity(input: string) { return /&(lt|gt|nbsp|60|62|160|#x3C|#x3E|#xA0);/i.test(input); } /** * Check the input string contains HTML tag or not * @param input Input string * @returns Result */ export function hasHtmlTag(input: string) { return /<\/?[a-z]+[^<>]*>/i.test(input); } /** * Is digits string * @param input Input string * @param minLength Minimum length * @returns Result */ export const isDigits = (input?: string, minLength?: number) => { if (input == null) return false; return input.isDigits(minLength); }; /** * Is email string * @param input Input string * @returns Result */ export const isEmail = (input?: string) => { if (input == null) return false; return input.isEmail(); }; /** * Join items as a string * @param items Items * @param joinPart Join string */ export const joinItems = ( items: (string | undefined)[], joinPart: string = ", " ) => items .reduce((items, item) => { if (item) { const newItem = item.trim(); if (newItem) items.push(newItem); } return items; }, [] as string[]) .join(joinPart); /** * Merge class names * @param classNames Class names */ export const mergeClasses = (...classNames: (string | undefined)[]) => joinItems(classNames, " "); /** * Create a GUID */ export function newGUID() { return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0, v = c === "x" ? r : (r & 0x3) | 0x8; return v.toString(16); }); } /** * Number to base64 chars * @param num Input number * @returns Result */ export function numberToChars(num: number) { const codes = []; while (num > 0) { const code = num % 128; codes.push(code); num = (num - code) / 128; } if (typeof Buffer === "undefined") { return btoa(String.fromCharCode(...codes)); } else { const buffer = Buffer.from(codes); return buffer.toString("base64"); } } /** * Test two objects are equal or not * @param obj1 Object 1 * @param obj2 Object 2 * @param ignoreFields Ignored fields * @param strict Strict level, 0 with ==, 1 === but null equal undefined, 2 === * @returns Result */ export function objectEqual( obj1: object, obj2: object, ignoreFields: string[] = [], strict = 1 ) { // Unique keys const keys = Utils.objectKeys(obj1, obj2, ignoreFields); for (const key of keys) { // Values const v1 = Reflect.get(obj1, key); const v2 = Reflect.get(obj2, key); if (!Utils.equals(v1, v2, strict)) return false; } return true; } /** * Get two object's unqiue properties * @param obj1 Object 1 * @param obj2 Object 2 * @param ignoreFields Ignored fields * @returns Unique properties */ export function objectKeys( obj1: object, obj2: object, ignoreFields: string[] = [] ) { // All keys const allKeys = [...Object.keys(obj1), ...Object.keys(obj2)].filter( (item) => !ignoreFields.includes(item) ); // Unique keys return new Set(allKeys); } /** * Get the new object's updated fields contrast to the previous object * @param objNew New object * @param objPre Previous object * @param ignoreFields Ignored fields * @param strict Strict level, 0 with ==, 1 === but null equal undefined, 2 === * @returns Updated fields */ export function objectUpdated( objNew: object, objPrev: object, ignoreFields: string[] = [], strict = 1 ) { // Fields const fields: string[] = []; // Unique keys const keys = Utils.objectKeys(objNew, objPrev, ignoreFields); for (const key of keys) { // Values const vNew = Reflect.get(objNew, key); const vPrev = Reflect.get(objPrev, key); if (!Utils.equals(vNew, vPrev, strict)) { fields.push(key); } } return fields; } /** * Try to parse JSON input to array * @param input JSON input * @param checkValue Type check value * @returns Result */ export function parseJsonArray<T>( input: string, checkValue?: T ): T[] | undefined { try { if (!input.startsWith("[")) input = `[${input}]`; const array = JSON.parse(input); const type = typeof checkValue; if ( Array.isArray(array) && (checkValue == null || !array.some((item) => typeof item !== type)) ) { return array; } } catch (e) { console.error(`Utils.parseJsonArray ${input} with error`, e); } return; } /** * Parse string (JSON) to specific type, no type conversion * For type conversion, please use DataTypes.convert * @param input Input string * @returns Parsed value */ export function parseString<T>( input: string | undefined | null ): T | undefined; /** * Parse string (JSON) to specific type, no type conversion * For type conversion, please use DataTypes.convert * @param input Input string * @param defaultValue Default value * @returns Parsed value */ export function parseString<T>( input: string | undefined | null, defaultValue: T ): T; /** * Parse string (JSON) to specific type, no type conversion * When return type depends on parameter value, uses function overloading, otherwise uses conditional type * For type conversion, please use DataTypes.convert * @param input Input string * @param defaultValue Default value * @returns Parsed value */ export function parseString<T>( input: string | undefined | null, defaultValue?: T ): T | undefined { // Undefined and empty case, return default value if (input == null || input === "") return <T>defaultValue; // String if (typeof defaultValue === "string") return <any>input; try { // Date if (defaultValue instanceof Date) { const date = new Date(input); if (date == null) return <any>defaultValue; return <any>date; } // JSON const json = JSON.parse(input); // Return return <T>json; } catch { if (defaultValue == null) return <any>input; return <T>defaultValue; } } /** * Remove empty values (null, undefined, '') from the input object * @param input Input object */ export function removeEmptyValues(input: object) { Object.keys(input).forEach((key) => { const value = Reflect.get(input, key); if (value == null || value === "") { Reflect.deleteProperty(input, key); } }); } /** * Remove non letters * @param input Input string * @returns Result */ export const removeNonLetters = (input?: string) => { return input?.removeNonLetters(); }; /** * Replace null or empty with default value * @param input Input string * @param defaultValue Default value * @returns Result */ export const replaceNullOrEmpty = ( input: string | null | undefined, defaultValue: string ) => { if (input == null || input.trim() === "") return defaultValue; return input; }; /** * Set source with new labels * @param source Source * @param labels Labels * @param reference Key reference dictionary */ export const setLabels = ( source: DataTypes.StringRecord, labels: DataTypes.StringRecord, reference?: Readonly<DataTypes.StringDictionary> ) => { Object.keys(source).forEach((key) => { // Reference key const labelKey = reference == null ? key : reference[key] ?? key; // Label const label = labels[labelKey]; if (label != null) { // If found, update Reflect.set(source, key, label); } }); }; /** * Snake name to works, 'snake_name' to 'Snake Name' * @param name Name text * @param firstOnly Only convert the first word to upper case */ export const snakeNameToWord = (name: string, firstOnly: boolean = false) => { const items = name.split("_"); if (firstOnly) { items[0] = items[0].formatInitial(true); return items.join(" "); } return items.map((part) => part.formatInitial(true)).join(" "); }; function getSortValue(n1: number, n2: number) { if (n1 === n2) return 0; if (n1 === -1) return 1; if (n2 === -1) return -1; return n1 - n2; } /** * Set nested value to object * @param data Data * @param name Field name, support property chain like 'jsonData.logSize' * @param value Value * @param keepNull Keep null value or not */ export function setNestedValue( data: object, name: string, value: unknown, keepNull?: boolean ) { const properties = name.split("."); const len = properties.length; if (len === 1) { if (value == null && keepNull !== true) { Reflect.deleteProperty(data, name); } else { Reflect.set(data, name, value); } } else { let curr = data; for (let i = 0; i < len; i++) { const property = properties[i]; if (i + 1 === len) { setNestedValue(curr, property, value, keepNull); // Reflect.set(curr, property, value); } else { let p = Reflect.get(curr, property); if (p == null) { p = {}; Reflect.set(curr, property, p); } curr = p; } } } } /** * Parse path similar with node.js path.parse * @param path Input path */ export const parsePath = (path: string): ParsedPath => { // Two formats or mixed // /home/user/dir/file.txt // C:\\path\\dir\\file.txt const lastIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); let root = "", dir = "", base: string, ext: string, name: string; if (lastIndex === -1) { base = path; } else { base = path.substring(lastIndex + 1); const index1 = path.indexOf("/"); const index2 = path.indexOf("\\"); const index = index1 === -1 ? index2 : index2 === -1 ? index1 : Math.min(index1, index2); root = path.substring(0, index + 1); dir = path.substring(0, lastIndex); if (dir === "") dir = root; } const extIndex = base.lastIndexOf("."); if (extIndex === -1) { name = base; ext = ""; } else { name = base.substring(0, extIndex); ext = base.substring(extIndex); } return { root, dir, base, ext, name }; }; /** * Sort array by favored values * @param items Items * @param favored Favored values * @returns Sorted array */ export const sortByFavor = <T>(items: T[], favored: T[]) => { return items.sort((r1, r2) => { const n1 = favored.indexOf(r1); const n2 = favored.indexOf(r2); return getSortValue(n1, n2); }); }; /** * Sort array by favored field values * @param items Items * @param field Field to sort * @param favored Favored field values * @returns Sorted array */ export const sortByFieldFavor = <T, F extends keyof T>( items: T[], field: F, favored: T[F][] ) => { return items.sort((r1, r2) => { const n1 = favored.indexOf(r1[field]); const n2 = favored.indexOf(r2[field]); return getSortValue(n1, n2); }); }; /** * Trim chars * @param input Input string * @param chars Trim chars * @returns Result */ export const trim = (input: string, ...chars: string[]) => { return trimEnd(trimStart(input, ...chars), ...chars); }; /** * Trim end chars * @param input Input string * @param chars Trim chars * @returns Result */ export const trimEnd = (input: string, ...chars: string[]) => { let char: string | undefined; while ((char = chars.find((char) => input.endsWith(char))) != null) { input = input.substring(0, input.length - char.length); } return input; }; /** * Trim start chars * @param input Input string * @param chars Trim chars * @returns Result */ export const trimStart = (input: string, ...chars: string[]) => { let char: string | undefined; while ((char = chars.find((char) => input.startsWith(char))) != null) { input = input.substring(char.length); } return input; }; }