Added test suite
All checks were successful
Build / Build NPM Project (push) Successful in 1m16s
Build / Tag Version (push) Successful in 14s
Build / Publish Documentation (push) Successful in 53s

This commit is contained in:
2025-05-14 16:30:42 -04:00
parent cf122ef9e8
commit fec373ca4c
32 changed files with 1719 additions and 310 deletions

View File

@ -72,10 +72,11 @@ export class ArgParser {
extras.push(arg);
continue;
}
const value = argDef.default === false ? true :
argDef.default === true ? false :
queue.splice(queue.findIndex(q => q[0] != '-'), 1)[0] ||
argDef.default;
const value = combined[1] != null ? combined [1] :
argDef.default === false ? true :
argDef.default === true ? false :
queue.splice(queue.findIndex(q => q[0] != '-'), 1)[0] ||
argDef.default;
if(value == null) parsed['_error'].push(`Option missing value: ${argDef.name || combined[0]}`);
parsed[argDef.name] = value;
} else { // Command

View File

@ -1,3 +1,4 @@
import {ASet} from './aset.ts';
import {dotNotation, isEqual} from './objects';
/**
@ -28,10 +29,7 @@ export function addUnique<T>(array: T[], el: T): T[] {
* @deprecated Use ASet to perform Set operations on arrays
*/
export function arrayDiff(a: any[], b: any[]): any[] {
return makeUnique([
...a.filter(v1 => !b.includes((v2: any) => isEqual(v1, v2))),
...b.filter(v1 => !a.includes((v2: any) => isEqual(v1, v2))),
]);
return new ASet(a).symmetricDifference(new ASet(b));
}
/**

View File

@ -99,18 +99,20 @@ export class Cache<K extends string | number | symbol, T> {
/**
* Remove all keys from cache
*/
clear() {
clear(): this {
this.complete = false;
this.store = <any>{};
return this;
}
/**
* Delete an item from the cache
* @param {K} key Item's primary key
*/
delete(key: K) {
delete(key: K): this {
delete this.store[key];
this.save();
return this;
}
/**
@ -126,10 +128,11 @@ export class Cache<K extends string | number | symbol, T> {
* Manually expire a cached item
* @param {K} key Key to expire
*/
expire(key: K) {
expire(key: K): this {
this.complete = false;
if(this.options.expiryPolicy == 'keep') (<any>this.store[key])._expired = true;
else this.delete(key);
return this;
}
/**
@ -137,7 +140,7 @@ export class Cache<K extends string | number | symbol, T> {
* @param {K} key Key to lookup
* @return {T} Cached item
*/
get(key: K, expired?: boolean): T | null {
get(key: K, expired?: boolean): CachedValue<T> | null {
const cached = deepCopy<any>(this.store[key] ?? null);
if(expired || !cached?._expired) return cached;
return null;
@ -178,7 +181,7 @@ export class Cache<K extends string | number | symbol, T> {
if(ttl) setTimeout(() => {
this.expire(key);
this.save();
}, ttl * 1000);
}, (ttl || 0) * 1000);
return this;
}

View File

@ -3,10 +3,10 @@
* @param {string} background Color to compare against
* @return {"white" | "black"} Color with the most contrast
*/
export function blackOrWhite(background: string): 'white' | 'black' {
const exploded = background?.match(background.length >= 6 ? /\w\w/g : /\w/g);
if(!exploded) return 'black';
const [r, g, b] = exploded.map(hex => parseInt(hex, 16));
export function contrast(background: string): 'white' | 'black' {
const exploded = background?.match(background.length >= 6 ? /[0-9a-fA-F]{2}/g : /[0-9a-fA-F]/g);
if(!exploded || exploded?.length < 3) return 'black';
const [r, g, b] = exploded.map(hex => parseInt(hex.length == 1 ? `${hex}${hex}` : hex, 16));
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? 'black' : 'white';
}

View File

@ -17,7 +17,7 @@ export class TypedEmitter<T extends TypedEvents = TypedEvents> {
static off(event: any, listener: TypedListener) {
const e = event.toString();
this.listeners[e] = (this.listeners[e] || []).filter(l => l === listener);
this.listeners[e] = (this.listeners[e] || []).filter(l => l != listener);
}
static on(event: any, listener: TypedListener) {
@ -43,7 +43,7 @@ export class TypedEmitter<T extends TypedEvents = TypedEvents> {
};
off<K extends keyof T = string>(event: K, listener: T[K]) {
this.listeners[event] = (this.listeners[event] || []).filter(l => l === listener);
this.listeners[event] = (this.listeners[event] || []).filter(l => l != listener);
}
on<K extends keyof T = string>(event: K, listener: T[K]) {

View File

@ -1,7 +1,23 @@
import {JSONAttemptParse} from './objects.ts';
/**
* Decode a JWT payload, this will not check for tampering so be careful
* Creates a JSON Web Token (JWT) using the provided payload.
*
* @param {object} payload The payload to include in the JWT.
* @param signature Add a JWT signature
* @return {string} The generated JWT string.
*/
export function createJwt(payload: object, signature = 'unsigned'): string {
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" }))
.toString('base64url');
const body = Buffer.from(JSON.stringify(payload))
.toString('base64url');
// Signature is irrelevant for decodeJwt
return `${header}.${body}.${signature}`;
}
/**
* Decode a JSON Web Token (JWT) payload, this will not check for tampering so be careful
*
* @param {string} token JWT to decode
* @return {unknown} JWT payload

View File

@ -7,25 +7,30 @@
* ```
*
* @param {number} num Number to convert
* @param maxDen
* @return {string} Fraction with remainder
*/
export function dec2Frac(num: number) {
const gcd = (a: number, b: number): number => {
if (b < 0.0000001) return a;
return gcd(b, ~~(a % b));
};
const len = num.toString().length - 2;
let denominator = Math.pow(10, len);
let numerator = num * denominator;
const divisor = gcd(numerator, denominator);
numerator = ~~(numerator / divisor);
denominator = ~~(denominator / divisor)
const remainder = ~~(numerator / denominator);
numerator -= remainder * denominator;
return `${remainder ? remainder + ' ' : ''}${~~(numerator)}/${~~(denominator)}`;
export function dec2Frac(num: number, maxDen=1000): string {
let sign = Math.sign(num);
num = Math.abs(num);
if (Number.isInteger(num)) return (sign * num) + "";
let closest = { n: 0, d: 1, diff: Math.abs(num) };
for (let d = 1; d <= maxDen; d++) {
let n = Math.round(num * d);
let diff = Math.abs(num - n / d);
if (diff < closest.diff) {
closest = { n, d, diff };
if (diff < 1e-8) break; // Close enough
}
}
let integer = Math.floor(closest.n / closest.d);
let numerator = closest.n - integer * closest.d;
return (sign < 0 ? '-' : '') +
(integer ? integer + ' ' : '') +
(numerator ? numerator + '/' + closest.d : '');
}
/**
* Convert fraction to decimal number
*

View File

@ -35,5 +35,14 @@ export function escapeRegex(value: string) {
return value.replace(/[.*+?^${}()|\[\]\\]/g, '\\$&');
}
/**
* Represents a function that listens for events and handles them accordingly.
*
* @param {PathEvent} event - The event object containing data related to the triggered event.
* @param {...any} args - Additional arguments that may be passed to the listener.
* @returns {any} The return value of the listener, which can vary based on implementation.
*/
export type Listener = (event: PathEvent, ...args: any[]) => any;
/** Represents a function that can be called to unsubscribe from an event, stream, or observer */
export type Unsubscribe = () => void;

View File

@ -14,7 +14,7 @@
export function clean<T>(obj: T, undefinedOnly = false): Partial<T> {
if(obj == null) throw new Error("Cannot clean a NULL value");
if(Array.isArray(obj)) {
obj = <any>obj.filter(o => o != null);
obj = <any>obj.filter(o => undefinedOnly ? o !== undefined : o != null);
} else {
Object.entries(obj).forEach(([key, value]) => {
if((undefinedOnly && value === undefined) || (!undefinedOnly && value == null)) delete (<any>obj)[key];
@ -128,7 +128,6 @@ export function flattenObj(obj: any, parent?: any, result: any = {}) {
for(const key of Object.keys(obj)) {
const propName = parent ? `${parent}.${key}` : key;
if(typeof obj[key] === 'object' && obj[key] != null && !Array.isArray(obj[key])) {
console.log(propName, );
flattenObj(obj[key], propName, result);
} else {
result[propName] = obj[key];
@ -242,10 +241,12 @@ export function JSONSerialize<T1>(obj: T1): T1 | string {
* @return {string} JSON string
*/
export function JSONSanitize(obj: any, space?: number): string {
let cache: any[] = [];
const cache: any[] = [];
return JSON.stringify(obj, (key, value) => {
if(typeof value === 'object' && value !== null)
if(!cache.includes(value)) cache.push(value);
if (typeof value === 'object' && value !== null) {
if (cache.includes(value)) return '[Circular]';
cache.push(value);
}
return value;
}, space);
}

View File

@ -120,6 +120,7 @@ export class PathEvent {
const l1 = p1.fullPath.length, l2 = p2.fullPath.length;
return l1 < l2 ? 1 : (l1 > l2 ? -1 : 0);
}).reduce((acc, p) => {
if(acc && !acc.fullPath.startsWith(p.fullPath)) return acc;
if(p.none) hitNone = true;
if(!acc) return p;
if(hitNone) return acc;
@ -253,8 +254,8 @@ export class PathEventEmitter implements IPathEventEmitter{
constructor(public readonly prefix: string = '') { }
emit(event: Event, ...args: any[]) {
const parsed = new PathEvent(`${this.prefix}/${typeof event == 'string' ? event : event.toString()}`);
this.listeners.filter(l => PathEvent.has(l[0], event))
const parsed = new PathEvent(`${this.prefix}/${new PathEvent(event).toString()}`);
this.listeners.filter(l => PathEvent.has(l[0], `${this.prefix}/${event}`))
.forEach(async l => l[1](parsed, ...args));
};
@ -264,7 +265,7 @@ export class PathEventEmitter implements IPathEventEmitter{
on(event: Event | Event[], listener: PathListener): PathUnsubscribe {
makeArray(event).forEach(e => this.listeners.push([
new PathEvent(`${this.prefix}/${typeof e == 'string' ? event : event.toString()}`),
new PathEvent(`${this.prefix}/${new PathEvent(e).toString()}`),
listener
]));
return () => this.off(listener);

View File

@ -1,5 +1,14 @@
import {dotNotation, JSONAttemptParse, JSONSerialize} from './objects.ts';
/**
* Filters an array of objects based on a search term and optional regex checking.
*
* @param {Array} rows Array of objects to filter
* @param {string} search The logic string or regext to filter on
* @param {boolean} [regex=false] Treat search expression as regex
* @param {Function} [transform=(r) => r] - Transform rows before filtering
* @return {Array} The filtered array of objects that matched search
*/
export function search(rows: any[], search: string, regex?: boolean, transform: Function = (r: any) => r) {
if(!rows) return [];
return rows.filter(r => {

View File

@ -23,7 +23,7 @@ export const CHAR_LIST = LETTER_LIST + LETTER_LIST.toLowerCase() + NUMBER_LIST +
*/
export function camelCase(str?: string) {
const text = pascalCase(str);
return text[0].toLowerCase() + text.slice(1);
return !text ? '' : text[0].toLowerCase() + text.slice(1);
}
/**
@ -110,13 +110,14 @@ export function pad(text: any, length: number, char: string = ' ', start = true)
* @param {string} str
* @return {string}
*/
export function pascalCase(str?: string) {
export function pascalCase(str?: string): string {
if(!str) return '';
const text = str.replaceAll(/(^[^a-zA-Z]+|[^a-zA-Z0-9-_])/g, '')
.replaceAll(/[^a-zA-Z0-9]+(\w?)/g, (...args) => args[1]?.toUpperCase() || '');
return text[0].toUpperCase() + text.slice(1);
return str.match(/[a-zA-Z0-9]+/g)
?.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join('') ?? '';
}
/**
* Generate a random hexadecimal value
*

View File

@ -1,27 +1,4 @@
/**
* Return keys on a type as an array of strings
*
* @example
* ```ts
* type Person = {
* firstName: string;
* lastName: string;
* age: number;
* }
*
* const keys = typeKeys<Person>();
* console.log(keys); // Output: ["firstName", "lastName", "age"]
* ```
*
* @return {Array<keyof T>} Available keys
*/
export function typeKeys<T extends object>() {
return Object.keys(<T>{}) as Array<keyof T>;
}
/**
* Mark all properties as writable
*/
/** Mark all properties as writable */
export type Writable<T> = {
-readonly [P in keyof T]: T[P]
};