Compare commits

..

33 Commits

Author SHA1 Message Date
adafd61c83 Added IPv6 to v4 converter
All checks were successful
Build / Build NPM Project (push) Successful in 1m17s
Build / Tag Version (push) Successful in 14s
Build / Publish Documentation (push) Successful in 52s
2025-06-25 11:48:49 -04:00
7747dc5558 more path event fixes.... i hope...
All checks were successful
Build / Build NPM Project (push) Successful in 43s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 36s
2025-06-24 22:21:34 -04:00
947bdbc6d4 more path event fixes.... i hope...
Some checks failed
Build / Tag Version (push) Has been cancelled
Build / Publish Documentation (push) Has been cancelled
Build / Build NPM Project (push) Has been cancelled
2025-06-24 22:21:30 -04:00
e2b8c35535 more path event fixes.... i hope...
All checks were successful
Build / Build NPM Project (push) Successful in 44s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 37s
2025-06-24 22:05:39 -04:00
3b7d28de4b Fixed test
Some checks failed
Build / Build NPM Project (push) Failing after 39s
Build / Publish Documentation (push) Has been skipped
Build / Tag Version (push) Has been skipped
2025-06-24 20:55:12 -04:00
8f7e816571 path event fixes
Some checks failed
Build / Build NPM Project (push) Failing after 45s
Build / Publish Documentation (push) Has been skipped
Build / Tag Version (push) Has been skipped
2025-06-24 20:52:16 -04:00
8c1fb2d8cb Fixed filename timestamp
All checks were successful
Build / Build NPM Project (push) Successful in 1m20s
Build / Tag Version (push) Successful in 15s
Build / Publish Documentation (push) Successful in 56s
2025-06-23 18:08:25 -04:00
91dc17667e Better cache & database integration
All checks were successful
Build / Build NPM Project (push) Successful in 44s
Build / Tag Version (push) Successful in 13s
Build / Publish Documentation (push) Successful in 51s
2025-06-20 19:41:01 -04:00
11cfc67650 Added cache find function
All checks were successful
Build / Build NPM Project (push) Successful in 41s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 35s
2025-06-19 19:46:59 -04:00
4ed23e1502 Fixed path events casing issue
All checks were successful
Build / Build NPM Project (push) Successful in 46s
Build / Tag Version (push) Successful in 13s
Build / Publish Documentation (push) Successful in 52s
2025-06-19 18:44:20 -04:00
e3bbd13ed8 Removed log statement that snuck in
All checks were successful
Build / Build NPM Project (push) Successful in 37s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 37s
2025-06-16 23:37:02 -04:00
1877bac7ce Allow dynamically creating tables
All checks were successful
Build / Build NPM Project (push) Successful in 48s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 42s
2025-06-16 20:56:28 -04:00
c06538a725 Fixed word formatters
All checks were successful
Build / Build NPM Project (push) Successful in 45s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 35s
2025-06-11 18:41:46 -04:00
a08b0c4eea Fixed database init loop
All checks were successful
Build / Build NPM Project (push) Successful in 40s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 34s
2025-05-27 15:42:00 -04:00
1b03ae875b Fixed database includes
All checks were successful
Build / Build NPM Project (push) Successful in 41s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 35s
2025-05-27 15:13:53 -04:00
af1da75a82 Removed console.log statments
All checks were successful
Build / Build NPM Project (push) Successful in 41s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 34s
2025-05-27 14:56:24 -04:00
e19768688d Fixed using numbers as database table name
All checks were successful
Build / Build NPM Project (push) Successful in 1m16s
Build / Tag Version (push) Successful in 15s
Build / Publish Documentation (push) Successful in 53s
2025-05-27 14:51:04 -04:00
6d706d4c15 Added database wrapper
All checks were successful
Build / Build NPM Project (push) Successful in 1m7s
Build / Tag Version (push) Successful in 14s
Build / Publish Documentation (push) Successful in 53s
2025-05-25 23:02:56 -04:00
97f2bcce2e removed debugging line
All checks were successful
Build / Build NPM Project (push) Successful in 43s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 36s
2025-05-15 16:47:35 -04:00
2a7fa18c0e Added indexdb wrapper & cache support for it
All checks were successful
Build / Build NPM Project (push) Successful in 1m24s
Build / Tag Version (push) Successful in 53s
Build / Publish Documentation (push) Successful in 1m28s
2025-05-15 16:06:32 -04:00
fec373ca4c 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
2025-05-14 16:30:42 -04:00
cf122ef9e8 * Fixed cache expiry
All checks were successful
Build / Build NPM Project (push) Successful in 46s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 34s
2025-05-13 11:13:48 -04:00
76b570b3fe * Fixed cache expire checks on uncached
All checks were successful
Build / Build NPM Project (push) Successful in 39s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 35s
2025-05-13 11:10:23 -04:00
4fecf10d11 * Fixed cache value type
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 54s
2025-05-13 10:53:17 -04:00
028b9c0f4c + Caching manually expire
All checks were successful
Build / Build NPM Project (push) Successful in 41s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 36s
2025-05-12 20:29:29 -04:00
d938996a66 + Caching expiry strategies
Some checks failed
Build / Build NPM Project (push) Failing after 34s
Build / Publish Documentation (push) Has been skipped
Build / Tag Version (push) Has been skipped
+ Prefix PathEvents
2025-05-12 19:46:23 -04:00
cdcaeda67c + Added writable
All checks were successful
Build / Build NPM Project (push) Successful in 40s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 35s
2025-05-12 18:00:12 -04:00
482c90b53b + Added writable
All checks were successful
Build / Build NPM Project (push) Successful in 1m13s
Build / Tag Version (push) Successful in 15s
Build / Publish Documentation (push) Successful in 49s
2025-05-12 16:28:10 -04:00
7500ba502f + Added clear function to ASet
All checks were successful
Build / Build NPM Project (push) Successful in 44s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 37s
* fixed pathedEvent fullPath casing
2025-05-11 14:13:01 -04:00
edc059d17d Added var-persist
All checks were successful
Build / Build NPM Project (push) Successful in 37s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 35s
2025-05-11 11:55:40 -04:00
48cfbee46e Removed test
All checks were successful
Build / Build NPM Project (push) Successful in 1m8s
Build / Tag Version (push) Successful in 14s
Build / Publish Documentation (push) Successful in 53s
2025-05-11 11:50:08 -04:00
26cc18ffb3 Fixed path event, renamed testCondition to logicTest & fixed some tests
Some checks failed
Build / Build NPM Project (push) Failing after 44s
Build / Publish Documentation (push) Has been skipped
Build / Tag Version (push) Has been skipped
2025-05-11 11:46:03 -04:00
3fd5c5ed57 Added sync function runner
All checks were successful
Build / Build NPM Project (push) Successful in 42s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 41s
2025-05-06 19:52:32 -04:00
38 changed files with 2126 additions and 453 deletions

View File

@ -1,104 +1,9 @@
<!DOCTYPE html> <html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>About Us | OurTrainingRoom</title>
<style>
body {
margin: 0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #fdfdfd;
color: #333;
line-height: 1.6;
}
header {
background: #004080;
color: #fff;
padding: 2rem 1rem;
text-align: center;
}
header h1 {
margin: 0;
font-size: 2.5rem;
}
main {
max-width: 800px;
margin: 2rem auto;
padding: 0 1rem;
}
section {
margin-bottom: 2rem;
}
h2 {
color: #004080;
margin-bottom: 0.5rem;
}
ul {
padding-left: 1.25rem;
}
footer {
text-align: center;
font-size: 0.9rem;
color: #777;
padding: 2rem 1rem;
background: #f1f1f1;
margin-top: 4rem;
}
</style>
</head>
<body> <body>
<header> <script type="module">
<h1>About Us</h1> import {PES} from './dist/index.mjs';
<p>Empowering Learning Through Innovation</p>
</header>
<main> console.log(PES`storage${'Test/Test'}:d`);
<section> </script>
<p>
E-learning has evolved significantly since its inception. Today, there's a shift towards
blended learning services, integrating online activities with practical, real-world applications.
</p>
</section>
<section>
<h2>What We Do</h2>
<p>At <strong>OurTrainingRoom.com</strong>, we specialize in content management and professional development training tailored for:</p>
<ul>
<li>School Boards</li>
<li>Municipalities</li>
<li>Hospitals</li>
<li>Large Corporations</li>
</ul>
</section>
<section>
<h2>Our Roots</h2>
<p>
Our parent company, <strong>The Auxilium Group</strong>, is a leader in online data management.
The formation of OurTrainingRoom.com was a natural progression to deliver state-of-the-art front-end e-learning programs.
</p>
</section>
<section>
<h2>Our Approach</h2>
<p>
Built on principles of quality and continuous improvement, our diverse delivery range continues to grow.
We set new trends by enhancing our existing products and attentively listening to our clients and their employees.
This unique approach has solidified our position in the industry, making a substantial impact for our clients.
</p>
</section>
<section>
<h2>Have a Question?</h2>
<p>
We value your inquiries and are here to assist you. Please reach out with any questions or feedback.
</p>
</section>
</main>
<footer>
&copy; 2025 OurTrainingRoom.com. All rights reserved.
</footer>
</body> </body>
</html> </html>

View File

@ -1,6 +1,6 @@
{ {
"name": "@ztimson/utils", "name": "@ztimson/utils",
"version": "0.24.1", "version": "0.25.18",
"description": "Utility library", "description": "Utility library",
"author": "Zak Timson", "author": "Zak Timson",
"license": "MIT", "license": "MIT",
@ -26,8 +26,12 @@
"test:coverage": "npx jest --coverage", "test:coverage": "npx jest --coverage",
"watch": "npx vite build --watch" "watch": "npx vite build --watch"
}, },
"dependencies": {
"var-persist": "^1.0.1"
},
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"fake-indexeddb": "^6.0.1",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-junit": "^16.0.0", "jest-junit": "^16.0.0",
"ts-jest": "^29.1.2", "ts-jest": "^29.1.2",

View File

@ -72,7 +72,8 @@ export class ArgParser {
extras.push(arg); extras.push(arg);
continue; continue;
} }
const value = argDef.default === false ? true : const value = combined[1] != null ? combined [1] :
argDef.default === false ? true :
argDef.default === true ? false : argDef.default === true ? false :
queue.splice(queue.findIndex(q => q[0] != '-'), 1)[0] || queue.splice(queue.findIndex(q => q[0] != '-'), 1)[0] ||
argDef.default; argDef.default;

View File

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

View File

@ -29,6 +29,14 @@ export class ASet<T> extends Array {
return this; return this;
} }
/**
* Remove all elements
*/
clear() {
this.splice(0, this.length);
return this;
}
/** /**
* Delete elements from set * Delete elements from set
* @param items Elements that will be deleted * @param items Elements that will be deleted

View File

@ -1,22 +1,27 @@
import {deepCopy} from './objects.ts'; import {Table} from './database.ts';
import {deepCopy, includes, JSONSanitize} from './objects.ts';
export type CacheOptions = { export type CacheOptions = {
/** Delete keys automatically after x amount of seconds */ /** Delete keys automatically after x amount of seconds */
ttl?: number; ttl?: number;
/** Storage to persist cache */ /** Storage to persist cache */
storage?: Storage; storage?: Storage | Table<any, any>;
/** Key cache will be stored under */ /** Key cache will be stored under */
storageKey?: string; storageKey?: string;
/** Keep or delete cached items once expired, defaults to delete */
expiryPolicy?: 'delete' | 'keep';
} }
export type CachedValue<T> = T & {_expired?: boolean};
/** /**
* Map of data which tracks whether it is a complete collection & offers optional expiry of cached values * Map of data which tracks whether it is a complete collection & offers optional expiry of cached values
*/ */
export class Cache<K extends string | number | symbol, T> { export class Cache<K extends string | number | symbol, T> {
private store = <Record<K, T>>{}; private store: Record<K, T> = <any>{};
/** Support index lookups */ /** Support index lookups */
[key: string | number | symbol]: T | any; [key: string | number | symbol]: CachedValue<T> | any;
/** Whether cache is complete */ /** Whether cache is complete */
complete = false; complete = false;
@ -26,19 +31,24 @@ export class Cache<K extends string | number | symbol, T> {
* @param options * @param options
*/ */
constructor(public readonly key?: keyof T, public readonly options: CacheOptions = {}) { constructor(public readonly key?: keyof T, public readonly options: CacheOptions = {}) {
if(options.storageKey && !options.storage && typeof(Storage) !== 'undefined') if(options.storageKey && !options.storage && typeof(Storage) !== 'undefined') options.storage = localStorage;
options.storage = localStorage; if(options.storage) {
if(options.storageKey && options.storage) { if(options.storage instanceof Table) {
const stored = options.storage.getItem(options.storageKey); (async () => (await options.storage?.getAll()).forEach((v: any) => {
if(stored) { if(v) {
try { Object.assign(this.store, JSON.parse(stored)); } try { this.add(v) }
catch { } catch { }
} }
}))()
} else if(options.storageKey) {
const stored = options.storage?.getItem(options.storageKey);
if(stored != null) try { Object.assign(this.store, JSON.parse(stored)); } catch { }
}
} }
return new Proxy(this, { return new Proxy(this, {
get: (target: this, prop: string | symbol) => { get: (target: this, prop: string | symbol) => {
if(prop in target) return (target as any)[prop]; if(prop in target) return (target as any)[prop];
return deepCopy(target.store[prop as K]); return this.get(prop as K, true);
}, },
set: (target: this, prop: string | symbol, value: any) => { set: (target: this, prop: string | symbol, value: any) => {
if(prop in target) (target as any)[prop] = value; if(prop in target) (target as any)[prop] = value;
@ -50,15 +60,27 @@ export class Cache<K extends string | number | symbol, T> {
private getKey(value: T): K { private getKey(value: T): K {
if(!this.key) throw new Error('No key defined'); if(!this.key) throw new Error('No key defined');
if(value[this.key] === undefined) throw new Error(`${this.key.toString()} Doesn't exist on ${JSON.stringify(value, null, 2)}`);
return <K>value[this.key]; return <K>value[this.key];
} }
private save(key: K) {
if(this.options.storage) {
if(this.options.storage instanceof Table) {
this.options.storage.put(key, this.store[key]);
} else if(this.options.storageKey) {
this.options.storage.setItem(this.options.storageKey, JSONSanitize(this.store));
}
}
}
/** /**
* Get all cached items * Get all cached items
* @return {T[]} Array of items * @return {T[]} Array of items
*/ */
all(): T[] { all(expired?: boolean): CachedValue<T>[] {
return deepCopy(Object.values(this.store)); return deepCopy<any>(Object.values(this.store)
.filter((v: any) => expired || !v._expired));
} }
/** /**
@ -89,51 +111,85 @@ export class Cache<K extends string | number | symbol, T> {
/** /**
* Remove all keys from cache * Remove all keys from cache
*/ */
clear() { clear(): this {
this.store = <Record<K, T>>{}; this.complete = false;
this.store = <any>{};
return this;
} }
/** /**
* Delete an item from the cache * Delete an item from the cache
* @param {K} key Item's primary key * @param {K} key Item's primary key
*/ */
delete(key: K) { delete(key: K): this {
delete this.store[key]; delete this.store[key];
if(this.options.storageKey && this.options.storage) this.save(key);
this.options.storage.setItem(this.options.storageKey, JSON.stringify(this.store)); return this;
} }
/** /**
* Return cache as an array of key-value pairs * Return cache as an array of key-value pairs
* @return {[K, T][]} Key-value pairs array * @return {[K, T][]} Key-value pairs array
*/ */
entries(): [K, T][] { entries(expired?: boolean): [K, CachedValue<T>][] {
return <[K, T][]>Object.entries(this.store); return deepCopy<any>(Object.entries(this.store)
.filter((v: any) => expired || !v?._expired));
}
/**
* Manually expire a cached item
* @param {K} key Key to expire
*/
expire(key: K): this {
this.complete = false;
if(this.options.expiryPolicy == 'keep') {
(<any>this.store[key])._expired = true;
this.save(key);
} else this.delete(key);
return this;
}
/**
* Find the first cached item to match a filter
* @param {Partial<T>} filter Partial item to match
* @param {Boolean} expired Include expired items, defaults to false
* @returns {T | undefined} Cached item or undefined if nothing matched
*/
find(filter: Partial<T>, expired?: boolean): T | undefined {
return <T>Object.values(this.store).find((row: any) => (expired || !row._expired) && includes(row, filter));
} }
/** /**
* Get item from the cache * Get item from the cache
* @param {K} key Key to lookup * @param {K} key Key to lookup
* @param expired Include expired items
* @return {T} Cached item * @return {T} Cached item
*/ */
get(key: K): T { get(key: K, expired?: boolean): CachedValue<T> | null {
return deepCopy(this.store[key]); const cached = deepCopy<any>(this.store[key] ?? null);
if(expired || !cached?._expired) return cached;
return null;
} }
/** /**
* Get a list of cached keys * Get a list of cached keys
* @return {K[]} Array of keys * @return {K[]} Array of keys
*/ */
keys(): K[] { keys(expired?: boolean): K[] {
return <K[]>Object.keys(this.store); return <K[]>Object.keys(this.store)
.filter(k => expired || !(<any>this.store)[k]._expired);
} }
/** /**
* Get map of cached items * Get map of cached items
* @return {Record<K, T>} * @return {Record<K, T>}
*/ */
map(): Record<K, T> { map(expired?: boolean): Record<K, CachedValue<T>> {
return deepCopy(this.store); const copy: any = deepCopy(this.store);
if(!expired) Object.keys(copy).forEach(k => {
if(copy[k]._expired) delete copy[k]
});
return copy;
} }
/** /**
@ -144,13 +200,13 @@ export class Cache<K extends string | number | symbol, T> {
* @return {this} * @return {this}
*/ */
set(key: K, value: T, ttl = this.options.ttl): this { set(key: K, value: T, ttl = this.options.ttl): this {
if(this.options.expiryPolicy == 'keep') delete (<any>value)._expired;
this.store[key] = value; this.store[key] = value;
if(this.options.storageKey && this.options.storage) this.save(key);
this.options.storage.setItem(this.options.storageKey, JSON.stringify(this.store));
if(ttl) setTimeout(() => { if(ttl) setTimeout(() => {
this.complete = false; this.expire(key);
this.delete(key); this.save(key);
}, ttl * 1000); }, (ttl || 0) * 1000);
return this; return this;
} }

View File

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

157
src/database.ts Normal file
View File

@ -0,0 +1,157 @@
import {findByProp} from './array.ts';
import {ASet} from './aset.ts';
export type TableOptions = {
name: string;
key?: string;
autoIncrement?: boolean;
};
export class Database {
connection!: Promise<IDBDatabase>;
ready = false;
tables!: TableOptions[];
constructor(public readonly database: string, tables?: (string | TableOptions)[], public version?: number) {
this.connection = new Promise((resolve, reject) => {
const req = indexedDB.open(this.database, this.version);
this.tables = !tables ? [] : tables.map(t => {
t = typeof t == 'object' ? t : {name: t};
return {...t, name: t.name.toString()};
});
req.onerror = () => reject(req.error);
req.onsuccess = () => {
const db = req.result;
const existing = Array.from(db.objectStoreNames);
if(!tables) this.tables = existing.map(t => {
const tx = db.transaction(t, 'readonly', )
const store = tx.objectStore(t);
return {name: t, key: <string>store.keyPath};
});
const desired = new ASet((tables || []).map(t => typeof t == 'string' ? t : t.name));
if(tables && desired.symmetricDifference(new ASet(existing)).length) {
db.close();
Object.assign(this, new Database(this.database, this.tables, db.version + 1));
this.connection.then(resolve);
} else {
this.version = db.version;
resolve(db);
}
this.ready = true;
};
req.onupgradeneeded = () => {
const db = req.result;
const existingTables = new ASet(Array.from(db.objectStoreNames));
if(tables) {
const desired = new ASet((tables || []).map(t => typeof t == 'string' ? t : t.name));
existingTables.difference(desired).forEach(name => db.deleteObjectStore(name));
desired.difference(existingTables).forEach(name => {
const t = this.tables.find(findByProp('name', name));
db.createObjectStore(name, {
keyPath: t?.key,
autoIncrement: t?.autoIncrement || !t?.key
});
});
}
};
});
}
async createTable<K extends IDBValidKey = any, T = any>(table: string | TableOptions): Promise<Table<K, T>> {
if(typeof table == 'string') table = {name: table};
const conn = await this.connection;
if(!this.includes(table.name)) {
conn.close();
Object.assign(this, new Database(this.database, [...this.tables, table], (this.version ?? 0) + 1));
}
return this.table<K, T>(table.name);
}
async deleteTable(table: string | TableOptions): Promise<void> {
if(typeof table == 'string') table = {name: table};
if(!this.includes(table.name)) return;
const conn = await this.connection;
conn.close();
Object.assign(this, new Database(this.database, this.tables.filter(t => t.name != table.name), (this.version ?? 0) + 1));
}
includes(name: any): boolean {
return !!this.tables.find(t => t.name == (typeof name == 'object' ? name.name : name.toString()));
}
table<K extends IDBValidKey = any, T = any>(name: any): Table<K, T> {
return new Table<K, T>(this, name.toString());
}
}
export class Table<K extends IDBValidKey = any, T = any> {
constructor(private readonly database: Database, public readonly name: string, public readonly key: keyof T | string = 'id') {
this.database.connection.then(() => {
const exists = !!this.database.tables.find(findByProp('name', this.name));
if(!exists) this.database.createTable(this.name);
});
}
async tx<R>(table: string, fn: (store: IDBObjectStore) => IDBRequest, readonly = false): Promise<R> {
const db = await this.database.connection;
const tx = db.transaction(table, readonly ? 'readonly' : 'readwrite');
const store = tx.objectStore(table);
return new Promise<R>((resolve, reject) => {
const request = fn(store);
request.onsuccess = () => resolve(request.result as R); // ✅ explicit cast
request.onerror = () => reject(request.error);
});
}
add(value: T, key?: K): Promise<void> {
return this.tx(this.name, store => store.add(value, key));
}
all = this.getAll;
clear(): Promise<void> {
return this.tx(this.name, store => store.clear());
}
count(): Promise<number> {
return this.tx(this.name, store => store.count(), true);
}
create = this.add;
delete(key: K): Promise<void> {
return this.tx(this.name, store => store.delete(key));
}
get(key: K): Promise<T> {
return this.tx(this.name, store => store.get(key), true);
}
getAll(): Promise<T[]> {
return this.tx(this.name, store => store.getAll(), true);
}
getAllKeys(): Promise<K[]> {
return this.tx(this.name, store => store.getAllKeys(), true);
}
put(key: K, value: T): Promise<void> {
return this.tx(this.name, store => store.put(value, key));
}
read(): Promise<T[]>;
read(key: K): Promise<T>;
read(key?: K): Promise<T | T[]> {
return key ? this.get(key) : this.getAll();
}
set(value: T, key?: K): Promise<void> {
if(!key && !(<any>value)[this.key]) return this.add(value);
return this.put(key || (<any>value)[this.key], value);
}
update = this.set;
}

View File

@ -17,7 +17,7 @@ export class TypedEmitter<T extends TypedEvents = TypedEvents> {
static off(event: any, listener: TypedListener) { static off(event: any, listener: TypedListener) {
const e = event.toString(); 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) { 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]) { 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]) { on<K extends keyof T = string>(event: K, listener: T[K]) {

View File

@ -77,7 +77,7 @@ export function fileText(file: any): Promise<string | null> {
*/ */
export function timestampFilename(name?: string, date: Date | number | string = new Date()) { export function timestampFilename(name?: string, date: Date | number | string = new Date()) {
if(typeof date == 'number' || typeof date == 'string') date = new Date(date); if(typeof date == 'number' || typeof date == 'string') date = new Date(date);
const timestamp = formatDate('YYYY-MM-DD_HH:mm:ss', date); const timestamp = formatDate('YYYY-MM-DD_HH-mm', date);
return name ? name.replace('{{TIMESTAMP}}', timestamp) : timestamp; return name ? name.replace('{{TIMESTAMP}}', timestamp) : timestamp;
} }

View File

@ -4,6 +4,7 @@ export * from './aset';
export * from './cache'; export * from './cache';
export * from './color'; export * from './color';
export * from './csv'; export * from './csv';
export * from './database';
export * from './files'; export * from './files';
export * from './emitter'; export * from './emitter';
export * from './errors'; export * from './errors';
@ -19,3 +20,4 @@ export * from './search';
export * from './string'; export * from './string';
export * from './time'; export * from './time';
export * from './types'; export * from './types';
export * from 'var-persist';

View File

@ -1,7 +1,23 @@
import {JSONAttemptParse} from './objects.ts'; 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 * @param {string} token JWT to decode
* @return {unknown} JWT payload * @return {unknown} JWT payload

View File

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

View File

@ -1,15 +1,26 @@
import {PathEvent} from './path-events.ts'; import {PathEvent} from './path-events.ts';
import {md5} from './string'; import {md5} from './string';
/**
* Escape any regex special characters to avoid misinterpretation during search
*
* @param {string} value String which should be escaped
* @return {string} New escaped sequence
*/
export function escapeRegex(value: string) {
return value.replace(/[.*+?^${}()|\[\]\\]/g, '\\$&');
}
/** /**
* Run a stringified function with arguments asynchronously * Run a stringified function with arguments asynchronously
* @param {object} args Map of key/value arguments * @param {object} args Map of key/value arguments
* @param {string} fn Function as string * @param {string} fn Function as string
* @return {Promise<T>} Function string response * @param {boolean} async Run with async (returns a promise)
* @return {T | Promise<T>} Function return result
*/ */
export function asyncFunction<T>(args: object, fn: string): Promise<T> { export function fn<T>(args: object, fn: string, async: boolean = false): T {
const keys = Object.keys(args); const keys = Object.keys(args);
return new Function(...keys, `return (async (${keys.join(',')}) => { ${fn} })(${keys.join(',')})`)(...keys.map(k => (<any>args)[k])); return new Function(...keys, `return (${async ? 'async ' : ''}(${keys.join(',')}) => { ${fn} })(${keys.join(',')})`)(...keys.map(k => (<any>args)[k]));
} }
/** /**
@ -25,14 +36,25 @@ export function gravatar(email: string, def='mp') {
} }
/** /**
* Escape any regex special characters to avoid misinterpretation during search * Convert IPv6 to v4 because who uses that, NAT4Life
* * @param {string} ip IPv6 address, e.g. 2001:0db8:85a3:0000:0000:8a2e:0370:7334
* @param {string} value String which should be escaped * @returns {string | null} IPv4 address, e.g. 172.16.58.3
* @return {string} New escaped sequence
*/ */
export function escapeRegex(value: string) { export function ipV6ToV4(ip: string) {
return value.replace(/[.*+?^${}()|\[\]\\]/g, '\\$&'); if(!ip) return null;
const ipv4 = ip.split(':').splice(-1)[0];
if(ipv4 == '1') return '127.0.0.1';
return ipv4;
} }
/**
* 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; 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; export type Unsubscribe = () => void;

View File

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

View File

@ -31,7 +31,7 @@ export function PE(str: TemplateStringsArray, ...args: any[]) {
if(str[i]) combined.push(str[i]); if(str[i]) combined.push(str[i]);
if(args[i]) combined.push(args[i]); if(args[i]) combined.push(args[i]);
} }
return new PathEvent(combined.join('')); return new PathEvent(combined.join('/'));
} }
/** /**
@ -48,7 +48,7 @@ export function PES(str: TemplateStringsArray, ...args: any[]) {
if(str[i]) combined.push(str[i]); if(str[i]) combined.push(str[i]);
if(args[i]) combined.push(args[i]); if(args[i]) combined.push(args[i]);
} }
const [paths, methods] = combined.join('').split(':'); const [paths, methods] = combined.join('/').split(':');
return PathEvent.toString(paths, <any>methods?.split('')); return PathEvent.toString(paths, <any>methods?.split(''));
} }
@ -79,29 +79,29 @@ export class PathEvent {
set none(v: boolean) { v ? this.methods = new ASet<Method>(['n']) : this.methods.delete('n'); } set none(v: boolean) { v ? this.methods = new ASet<Method>(['n']) : this.methods.delete('n'); }
/** Create method specified */ /** Create method specified */
get create(): boolean { return !this.methods.has('n') && (this.methods.has('*') || this.methods.has('c')) } get create(): boolean { return !this.methods.has('n') && (this.methods.has('*') || this.methods.has('c')) }
set create(v: boolean) { v ? this.methods.delete('n').add('c') : this.methods.delete('c'); } set create(v: boolean) { v ? this.methods.delete('n').delete('*').add('c') : this.methods.delete('c'); }
/** Read method specified */ /** Read method specified */
get read(): boolean { return !this.methods.has('n') && (this.methods.has('*') || this.methods.has('r')) } get read(): boolean { return !this.methods.has('n') && (this.methods.has('*') || this.methods.has('r')) }
set read(v: boolean) { v ? this.methods.delete('n').add('r') : this.methods.delete('r'); } set read(v: boolean) { v ? this.methods.delete('n').delete('*').add('r') : this.methods.delete('r'); }
/** Update method specified */ /** Update method specified */
get update(): boolean { return !this.methods.has('n') && (this.methods.has('*') || this.methods.has('u')) } get update(): boolean { return !this.methods.has('n') && (this.methods.has('*') || this.methods.has('u')) }
set update(v: boolean) { v ? this.methods.delete('n').add('u') : this.methods.delete('u'); } set update(v: boolean) { v ? this.methods.delete('n').delete('*').add('u') : this.methods.delete('u'); }
/** Delete method specified */ /** Delete method specified */
get delete(): boolean { return !this.methods.has('n') && (this.methods.has('*') || this.methods.has('d')) } get delete(): boolean { return !this.methods.has('n') && (this.methods.has('*') || this.methods.has('d')) }
set delete(v: boolean) { v ? this.methods.delete('n').add('d') : this.methods.delete('d'); } set delete(v: boolean) { v ? this.methods.delete('n').delete('*').add('d') : this.methods.delete('d'); }
constructor(Event: string | PathEvent) { constructor(e: string | PathEvent) {
if(typeof Event == 'object') return Object.assign(this, Event); if(typeof e == 'object') return Object.assign(this, e);
let [p, scope, method] = Event.replaceAll(/\/{2,}/g, '/').split(':'); let [p, scope, method] = e.replaceAll(/\/{2,}/g, '/').split(':');
if(!method) method = scope || '*'; if(!method) method = scope || '*';
if(p == '*' || !p && method == '*') { if(p == '*' || !p && method == '*') {
p = ''; p = '';
method = '*'; method = '*';
} }
let temp = p.split('/').filter(p => !!p); let temp = p.split('/').filter(p => !!p);
this.module = temp.splice(0, 1)[0]?.toLowerCase() || ''; this.module = temp.splice(0, 1)[0] || '';
this.fullPath = p;
this.path = temp.join('/'); this.path = temp.join('/');
this.fullPath = `${this.module}${this.module && this.path ? '/' : ''}${this.path}`;
this.name = temp.pop() || ''; this.name = temp.pop() || '';
this.methods = new ASet(<any>method.split('')); this.methods = new ASet(<any>method.split(''));
} }
@ -120,6 +120,7 @@ export class PathEvent {
const l1 = p1.fullPath.length, l2 = p2.fullPath.length; const l1 = p1.fullPath.length, l2 = p2.fullPath.length;
return l1 < l2 ? 1 : (l1 > l2 ? -1 : 0); return l1 < l2 ? 1 : (l1 > l2 ? -1 : 0);
}).reduce((acc, p) => { }).reduce((acc, p) => {
if(acc && !acc.fullPath.startsWith(p.fullPath)) return acc;
if(p.none) hitNone = true; if(p.none) hitNone = true;
if(!acc) return p; if(!acc) return p;
if(hitNone) return acc; if(hitNone) return acc;
@ -139,13 +140,10 @@ export class PathEvent {
*/ */
static filter(target: string | PathEvent | (string | PathEvent)[], ...filter: (string | PathEvent)[]): PathEvent[] { static filter(target: string | PathEvent | (string | PathEvent)[], ...filter: (string | PathEvent)[]): PathEvent[] {
const parsedTarget = makeArray(target).map(pe => new PathEvent(pe)); const parsedTarget = makeArray(target).map(pe => new PathEvent(pe));
const parsedFind = makeArray(filter).map(pe => new PathEvent(pe)); const parsedFilter = makeArray(filter).map(pe => new PathEvent(pe));
return parsedTarget.filter(t => { return parsedTarget.filter(t => !!parsedFilter.find(f =>
if(!t.fullPath && t.all) return true; (t.fullPath == '*' || f.fullPath == '*' || t.fullPath.startsWith(f.fullPath) || f.fullPath.startsWith(t.fullPath)) &&
return !!parsedFind.find(f => (f.all || t.all || t.methods.intersection(f.methods).length)));
(t.fullPath.startsWith(f.fullPath) || f.fullPath.startsWith(t.fullPath)) &&
(f.all || t.all || t.methods.intersection(f.methods).length));
});
} }
/** /**
@ -156,15 +154,12 @@ export class PathEvent {
* @return {boolean} Whether there is any overlap * @return {boolean} Whether there is any overlap
*/ */
static has(target: string | PathEvent | (string | PathEvent)[], ...has: (string | PathEvent)[]): boolean { static has(target: string | PathEvent | (string | PathEvent)[], ...has: (string | PathEvent)[]): boolean {
const parsedRequired = makeArray(has).map(pe => new PathEvent(pe));
const parsedTarget = makeArray(target).map(pe => new PathEvent(pe)); const parsedTarget = makeArray(target).map(pe => new PathEvent(pe));
return !!parsedRequired.find(r => { const parsedRequired = makeArray(has).map(pe => new PathEvent(pe));
if(!r.fullPath && r.all) return true; return !!parsedRequired.find(r => !!parsedTarget.find(t =>
const filtered = parsedTarget.filter(p => r.fullPath.startsWith(p.fullPath)); (r.fullPath == '*' || t.fullPath == '*' || r.fullPath.startsWith(t.fullPath)) &&
if(!filtered.length) return false; (r.all || t.all || r.methods.intersection(t.methods).length)
const combined = PathEvent.combine(...filtered); ));
return (!combined.none && (combined.all || r.all)) || combined.methods.intersection(r.methods).length;
});
} }
/** /**
@ -206,7 +201,7 @@ export class PathEvent {
* @return {string} String representation of Event * @return {string} String representation of Event
*/ */
static toString(path: string | string[], methods: Method | Method[]): string { static toString(path: string | string[], methods: Method | Method[]): string {
let p = makeArray(path).filter(p => p != null).join('/'); let p = makeArray(path).filter(p => !!p).join('/');
p = p?.trim().replaceAll(/\/{2,}/g, '/').replaceAll(/(^\/|\/$)/g, ''); p = p?.trim().replaceAll(/\/{2,}/g, '/').replaceAll(/(^\/|\/$)/g, '');
if(methods?.length) p += `:${makeArray(methods).map(m => m.toLowerCase()).join('')}`; if(methods?.length) p += `:${makeArray(methods).map(m => m.toLowerCase()).join('')}`;
return p; return p;
@ -235,11 +230,12 @@ export class PathEvent {
export type PathListener = (event: PathEvent, ...args: any[]) => any; export type PathListener = (event: PathEvent, ...args: any[]) => any;
export type PathUnsubscribe = () => void; export type PathUnsubscribe = () => void;
export type Event = string | PathEvent;
export interface IPathEventEmitter { export interface IPathEventEmitter {
emit(event: string, ...args: any[]): void; emit(event: Event, ...args: any[]): void;
off(listener: PathListener): void; off(listener: PathListener): void;
on(event: string, listener: PathListener): PathUnsubscribe; on(event: Event | Event[], listener: PathListener): PathUnsubscribe;
once(event: string, listener?: PathListener): Promise<any>; once(event: Event | Event[], listener?: PathListener): Promise<any>;
relayEvents(emitter: PathEventEmitter): void; relayEvents(emitter: PathEventEmitter): void;
} }
@ -249,9 +245,11 @@ export interface IPathEventEmitter {
export class PathEventEmitter implements IPathEventEmitter{ export class PathEventEmitter implements IPathEventEmitter{
private listeners: [PathEvent, PathListener][] = []; private listeners: [PathEvent, PathListener][] = [];
emit(event: string | PathEvent, ...args: any[]) { constructor(public readonly prefix: string = '') { }
const parsed = new PathEvent(event);
this.listeners.filter(l => PathEvent.has(l[0], event)) emit(event: Event, ...args: any[]) {
const parsed = PE`${this.prefix}/${event}`;
this.listeners.filter(l => PathEvent.has(l[0], parsed))
.forEach(async l => l[1](parsed, ...args)); .forEach(async l => l[1](parsed, ...args));
}; };
@ -259,12 +257,15 @@ export class PathEventEmitter implements IPathEventEmitter{
this.listeners = this.listeners.filter(l => l[1] != listener); this.listeners = this.listeners.filter(l => l[1] != listener);
} }
on(event: string | string[], listener: PathListener): PathUnsubscribe { on(event: Event | Event[], listener: PathListener): PathUnsubscribe {
makeArray(event).forEach(e => this.listeners.push([new PathEvent(e), listener])); makeArray(event).forEach(e => this.listeners.push([
new PathEvent(`${this.prefix}/${e}`),
listener
]));
return () => this.off(listener); return () => this.off(listener);
} }
once(event: string | string[], listener?: PathListener): Promise<any> { once(event: Event | Event[], listener?: PathListener): Promise<any> {
return new Promise(res => { return new Promise(res => {
const unsubscribe = this.on(event, (event: PathEvent, ...args: any[]) => { const unsubscribe = this.on(event, (event: PathEvent, ...args: any[]) => {
res(args.length < 2 ? args[0] : args); res(args.length < 2 ? args[0] : args);

View File

@ -1,5 +1,14 @@
import {dotNotation, JSONAttemptParse} from './objects.ts'; 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) { export function search(rows: any[], search: string, regex?: boolean, transform: Function = (r: any) => r) {
if(!rows) return []; if(!rows) return [];
return rows.filter(r => { return rows.filter(r => {
@ -13,12 +22,18 @@ export function search(rows: any[], search: string, regex?: boolean, transform:
catch { return false; } catch { return false; }
}).length }).length
} else { } else {
return testCondition(search, r); return logicTest(r, search);
} }
}); });
} }
export function testCondition(condition: string, row: any) { /**
* Test an object against a logic condition. By default values are checked
* @param {string} condition
* @param {object} target
* @return {boolean}
*/
export function logicTest(target: object, condition: string): boolean {
const evalBoolean = (a: any, op: string, b: any): boolean => { const evalBoolean = (a: any, op: string, b: any): boolean => {
switch(op) { switch(op) {
case '=': case '=':
@ -40,11 +55,11 @@ export function testCondition(condition: string, row: any) {
// Boolean operator // Boolean operator
const prop = /(\S+)\s*(==?|!=|>=|>|<=|<)\s*(\S+)/g.exec(p); const prop = /(\S+)\s*(==?|!=|>=|>|<=|<)\s*(\S+)/g.exec(p);
if(prop) { if(prop) {
const key = Object.keys(row).find(k => k.toLowerCase() == prop[1].toLowerCase()); const key = Object.keys(target).find(k => k.toLowerCase() == prop[1].toLowerCase());
return evalBoolean(dotNotation<any>(row, key || prop[1]), prop[2], JSONAttemptParse(prop[3])); return evalBoolean(dotNotation<any>(target, key || prop[1]), prop[2], JSONAttemptParse(prop[3]));
} }
// Case-sensitive // Case-sensitive
const v = Object.values(row).map(v => typeof v == 'object' && v != null ? JSON.stringify(v) : v).join(''); const v = Object.values(target).map(JSONSerialize).join('');
if(/[A-Z]/g.test(condition)) return v.includes(p); if(/[A-Z]/g.test(condition)) return v.includes(p);
// Case-insensitive // Case-insensitive
return v.toLowerCase().includes(p); return v.toLowerCase().includes(p);

View File

@ -21,11 +21,13 @@ export const CHAR_LIST = LETTER_LIST + LETTER_LIST.toLowerCase() + NUMBER_LIST +
/** /**
* Converts text to camelCase * Converts text to camelCase
*/ */
export function camelCase(str?: string) { export function camelCase(str?: string): string {
const text = pascalCase(str); if(!str) return '';
return text[0].toLowerCase() + text.slice(1); const pascal = pascalCase(str);
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
} }
/** /**
* Convert number of bytes into a human-readable size * Convert number of bytes into a human-readable size
* *
@ -75,14 +77,12 @@ export function insertAt(target: string, str: string, index: number): String {
/** /**
* Converts text to kebab-case * Converts text to kebab-case
*/ */
export function kebabCase(str: string) { export function kebabCase(str?: string): string {
if(!str) return ''; if(!str) return '';
return str.replaceAll(/(^[^a-zA-Z]+|[^a-zA-Z0-9-_])/g, '') return wordSegments(str).map(w => w.toLowerCase()).join("-");
.replaceAll(/([A-Z]|[0-9]+)/g, (...args) => `-${args[0].toLowerCase()}`)
.replaceAll(/([0-9])([a-z])/g, (...args) => `${args[1]}-${args[2]}`)
.replaceAll(/[^a-z0-9]+(\w?)/g, (...args) => `-${args[1] ?? ''}`).toLowerCase();
} }
/** /**
* Add padding to string * Add padding to string
* *
@ -110,13 +110,15 @@ export function pad(text: any, length: number, char: string = ' ', start = true)
* @param {string} str * @param {string} str
* @return {string} * @return {string}
*/ */
export function pascalCase(str?: string) { export function pascalCase(str?: string): string {
if(!str) return ''; if(!str) return '';
const text = str.replaceAll(/(^[^a-zA-Z]+|[^a-zA-Z0-9-_])/g, '') return wordSegments(str)
.replaceAll(/[^a-zA-Z0-9]+(\w?)/g, (...args) => args[1]?.toUpperCase() || ''); .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
return text[0].toUpperCase() + text.slice(1); .join('');
} }
/** /**
* Generate a random hexadecimal value * Generate a random hexadecimal value
* *
@ -183,14 +185,12 @@ export function randomStringBuilder(length: number, letters = false, numbers = f
/** /**
* Converts text to snake_case * Converts text to snake_case
*/ */
export function snakeCase(str?: string) { export function snakeCase(str?: string): string {
if(!str) return ''; if(!str) return '';
return str.replaceAll(/(^[^a-zA-Z]+|[^a-zA-Z0-9-_])/g, '') return wordSegments(str).map(w => w.toLowerCase()).join("_");
.replaceAll(/([A-Z]|[0-9]+)/g, (...args) => `_${args[0].toLowerCase()}`)
.replaceAll(/([0-9])([a-z])/g, (...args) => `${args[1]}_${args[2]}`)
.replaceAll(/[^a-z0-9]+(\w?)/g, (...args) => `_${args[1] ?? ''}`).toLowerCase();
} }
/** /**
* Splice a string together (Similar to Array.splice) * Splice a string together (Similar to Array.splice)
* *
@ -297,6 +297,23 @@ export function md5(d: string) {
function bit_rol(d:any,_:any){return d<<_|d>>>32-_ function bit_rol(d:any,_:any){return d<<_|d>>>32-_
} }
/**
* Splits a string into logical word segments
*/
export function wordSegments(str?: string): string[] {
if (!str) return [];
return str
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
.replace(/([0-9]+)([a-zA-Z])/g, "$1 $2")
.replace(/([a-zA-Z])([0-9]+)/g, "$1 $2")
.replace(/[_\-\s]+/g, " ")
.trim()
.split(/\s+/)
.filter(Boolean);
}
/** /**
* Check if email is valid * Check if email is valid
* *

View File

@ -190,3 +190,35 @@ export async function sleepWhile(fn : () => boolean | Promise<boolean>, checkInt
export function timeUntil(date: Date | number): number { export function timeUntil(date: Date | number): number {
return (date instanceof Date ? date.getTime() : date) - (new Date()).getTime(); return (date instanceof Date ? date.getTime() : date) - (new Date()).getTime();
} }
/**
* Convert a timezone string (e.g., "America/Toronto") to its current UTC offset in minutes.
* @param {string} tz - Timezone string, e.g. "America/Toronto"
* @param {Date} [date=new Date()] - The date for which you want the offset (default is now)
* @returns {number} - Offset in minutes (e.g., -240)
*/
export function timezoneOffset(tz: string, date: Date = new Date()): number {
const dtf = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const parts = dtf.formatToParts(date);
const get = (type: string) => Number(parts.find(v => v.type === type)?.value);
const y = get('year');
const mo = get('month');
const d = get('day');
const h = get('hour');
const m = get('minute');
const s = get('second');
const asUTC = Date.UTC(y, mo - 1, d, h, m, s);
const asLocal = date.getTime();
return Math.round((asLocal - asUTC) / 60000);
}

View File

@ -1,20 +1,4 @@
/** /** Mark all properties as writable */
* Return keys on a type as an array of strings export type Writable<T> = {
* -readonly [P in keyof T]: T[P]
* @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>;
}

121
tests/arg-parser.spec.ts Normal file
View File

@ -0,0 +1,121 @@
import { Arg, ArgParser } from '../src';
describe('ArgParser', () => {
const basicArgs: Arg[] = [
{ name: 'input', desc: 'Input file' },
{ name: 'output', desc: 'Output file', default: 'out.txt' },
{ name: 'verbose', desc: 'Enable verbose mode', flags: ['-v', '--verbose'], default: false }
];
const commandArg = new ArgParser(
'subcmd',
'A sub command',
[{ name: 'foo', desc: 'Foo argument', optional: false }]
);
describe('constructor', () => {
it('should add commands and update examples', () => {
const parser = new ArgParser('main', 'desc', [commandArg, ...basicArgs], ['custom-example']);
expect(parser.commands[0].name).toBe('subcmd');
expect(parser.examples.some(e => typeof e === 'string' && e.includes('[OPTIONS] COMMAND'))).toBe(true);
expect(parser.examples).toEqual(expect.arrayContaining([
'custom-example',
expect.stringContaining('[OPTIONS]')
]));
});
});
describe('parse', () => {
it('should parse args and flags', () => {
const parser = new ArgParser('mycmd', 'desc', basicArgs);
const result = parser.parse(['file1.txt', '-v']);
expect(result.input).toBe('file1.txt');
expect(result.output).toBe('out.txt');
expect(result.verbose).toBe(true);
expect(result._extra).toEqual([]);
});
it('should handle missing required args and collect errors', () => {
const parser = new ArgParser('mycmd', 'desc', basicArgs);
const result = parser.parse([]);
expect(result._error).toContain('Argument missing: INPUT');
});
it('should handle default values correctly', () => {
const parser = new ArgParser('mycmd', 'desc', basicArgs);
const result = parser.parse(['file1.txt']);
expect(result.output).toBe('out.txt');
expect(result.verbose).toBe(false);
});
it('should parse flags with value assignment', () => {
const args: Arg[] = [
{ name: 'mode', desc: 'Mode', flags: ['-m', '--mode'], default: 'defaultMode' }
];
const parser = new ArgParser('mycmd', 'desc', args);
const result = parser.parse(['--mode=prod']);
expect(result.mode).toBe('prod');
});
it('should support extras collection', () => {
const args: Arg[] = [
{ name: 'main', desc: 'main', extras: true }
];
const parser = new ArgParser('cmd', 'desc', args);
const result = parser.parse(['a', 'b', 'c']);
expect(result.main).toEqual(['a', 'b', 'c']);
});
it('should handle unknown flags and put them to extras', () => {
const parser = new ArgParser('mycmd', 'desc', basicArgs);
const result = parser.parse(['file.txt', 'test', '--unknown']);
expect(result._extra).toContain('--unknown');
});
it('should handle subcommands and delegate parsing', () => {
const mainParser = new ArgParser('main', 'desc', [commandArg]);
const result = mainParser.parse(['subcmd', 'fooVal']);
expect(result._command).toBe('subcmd');
expect(result.foo).toBe('fooVal');
});
it('should parse combined short flags', () => {
const args: Arg[] = [
{ name: 'a', desc: 'Flag A', flags: ['-a'], default: false },
{ name: 'b', desc: 'Flag B', flags: ['-b'], default: false }
];
const parser = new ArgParser('mycmd', 'desc', args);
const result = parser.parse(['-ab']);
expect(result.a).toBe(true);
expect(result.b).toBe(true);
});
});
describe('help', () => {
it('should generate help with options and args', () => {
const parser = new ArgParser('mycmd', 'desc', basicArgs);
const helpMsg = parser.help();
expect(helpMsg).toContain('Input file');
expect(helpMsg).toContain('Enable verbose mode');
expect(helpMsg).toContain('-h, --help');
});
it('should generate help for a subcommand', () => {
const mainParser = new ArgParser('main', 'desc', [commandArg]);
const helpMsg = mainParser.help({ command: 'subcmd' });
expect(helpMsg).toContain('Foo argument');
});
it('should throw error for non-existent command', () => {
const mainParser = new ArgParser('main', 'desc', [commandArg]);
expect(() => mainParser.help({ command: 'notreal' })).toThrow();
});
it('should allow custom message override', () => {
const parser = new ArgParser('mycmd', 'desc', basicArgs);
const helpMsg = parser.help({ message: 'Custom!' });
expect(helpMsg).toContain('Custom!');
expect(helpMsg).not.toContain('desc');
});
});
});

View File

@ -1,82 +1,85 @@
import {addUnique, caseInsensitiveSort, flattenArr, sortByProp} from '../src'; import {addUnique, arrayDiff, caseInsensitiveSort, findByProp, flattenArr, makeArray, makeUnique, sortByProp,} from '../src';
describe('Array Utilities', () => { describe('Array Utilities', () => {
describe('addUnique', () => { describe('addUnique', () => {
const arr = [1, 2]; it('does not add duplicate value', () => {
const arr = [1, 2, 3];
test('non-unique', () => { addUnique(arr, 2);
addUnique(arr, 1); expect(arr).toEqual([1, 2, 3]);
expect(arr).toStrictEqual([1, 2]);
}); });
test('unique', () => { it('adds unique value', () => {
const arr = [1, 2];
addUnique(arr, 3); addUnique(arr, 3);
expect(arr).toStrictEqual([1, 2, 3]); expect(arr).toEqual([1, 2, 3]);
});
});
describe('arrayDiff', () => {
it('returns unique elements present only in one array', () => {
expect(arrayDiff([1, 2, 3], [3, 4, 5]).toSorted()).toEqual([1, 2, 4, 5]);
});
it('returns empty array if arrays have the same elements', () => {
expect(arrayDiff([1, 2], [1, 2])).toEqual([]);
});
});
describe('caseInseFsitiveSort', () => {
it('sorts objects by string property case-insensitively', () => {
const arr = [{n: 'b'}, {n: 'A'}, {n: 'c'}];
arr.sort(caseInsensitiveSort('n'));
expect(arr.map(i => i.n)).toEqual(['A', 'b', 'c']);
});
});
describe('findByProp', () => {
it('filters objects by property value', () => {
const arr = [{name: 'foo'}, {name: 'bar'}];
const found = arr.filter(findByProp('name', 'foo'));
expect(found).toEqual([{name: 'foo'}]);
}); });
}); });
describe('flattenArr', () => { describe('flattenArr', () => {
test('flat array', () => expect(flattenArr([1, 2])).toStrictEqual([1, 2])); it('flattens deeply nested arrays', () => {
test('2D array', () => expect(flattenArr([[1, 2], [3, 4]])).toStrictEqual([1, 2, 3, 4])); const arr = [1, [2, [3, [4]], 5], 6];
test('3D array', () => expect(flattenArr([[[1, 2], [3, 4]], [[5, 6], [7, 8]]])).toStrictEqual([1, 2, 3, 4, 5, 6, 7, 8])); expect(flattenArr(arr)).toEqual([1, 2, 3, 4, 5, 6]);
test('mixed array', () => expect(flattenArr([1, 2, [3, 4], [[5, 6], [7, 8]]])).toStrictEqual([1, 2, 3, 4, 5, 6, 7, 8])); });
it('flattens flat array as-is', () => {
expect(flattenArr([1, 2, 3])).toEqual([1, 2, 3]);
});
}); });
describe('sortByProp', () => { describe('sortByProp', () => {
test('random letters', () => { it('sorts by numeric property', () => {
let unsorted: any = Array(100).fill(null) const arr = [{a: 3}, {a: 1}, {a: 2}];
.map(() => String.fromCharCode(Math.round(Math.random() * 25) + 97)); arr.sort(sortByProp('a'));
const sorted = unsorted.sort((a: any, b: any) => { expect(arr.map(i => i.a)).toEqual([1, 2, 3]);
if(a > b) return 1;
if(a < b) return -1;
return 0;
}).map((l: any) => ({a: l}));
unsorted = unsorted.map((l: any) => ({a: l}));
expect(unsorted.sort(sortByProp('a'))).toStrictEqual(sorted);
}); });
test('random letters reversed', () => { it('sorts by string property reversed', () => {
let unsorted: any = Array(100).fill(null) const arr = [{a: 'apple'}, {a: 'banana'}, {a: 'pear'}];
.map(() => String.fromCharCode(Math.round(Math.random() * 25) + 97)); arr.sort(sortByProp('a', true));
const sorted = unsorted.sort((a: any, b: any) => { expect(arr.map(i => i.a)).toEqual(['pear', 'banana', 'apple']);
if(a > b) return -1;
if(a < b) return 1;
return 0;
}).map((n: any) => ({a: n}));
unsorted = unsorted.map((n: any) => ({a: n}));
expect(unsorted.sort(sortByProp('a', true))).toStrictEqual(sorted);
});
test('random numbers', () => {
let unsorted: any = Array(100).fill(null).map(() => Math.round(Math.random() * 100));
const sorted = unsorted.sort((a: any, b: any) => a - b).map((n: any) => ({a: n}));
unsorted = unsorted.map((n: any) => ({a: n}));
expect(unsorted.sort(sortByProp('a'))).toStrictEqual(sorted);
});
test('random numbers reversed', () => {
let unsorted: any = Array(100).fill(null).map(() => Math.round(Math.random() * 100));
const sorted = unsorted.sort((a: any, b: any) => b - a).map((n: any) => ({a: n}));
unsorted = unsorted.map((n: any) => ({a: n}));
expect(unsorted.sort(sortByProp('a', true))).toStrictEqual(sorted);
}); });
}); });
describe('caseInsensitiveSort', () => { describe('makeUnique', () => {
test('non-string property', () => { it('removes duplicate primitives', () => {
const unsorted: any = [{a: 'Apple', b: 123}, {a: 'Carrot', b: 789}, {a: 'banana', b: 456}]; const arr = [1, 2, 2, 3, 1];
const sorted: any = unsorted.map((u: any) => ({...u})); expect(makeUnique(arr)).toEqual([1, 2, 3]);
expect(unsorted.sort(caseInsensitiveSort('b'))).toStrictEqual(sorted);
}); });
test('simple strings', () => { it('removes duplicate objects', () => {
const unsorted: any = [{a: 'Apple'}, {a: 'Carrot'}, {a: 'banana'}]; const obj = {a: 1};
const sorted: any = unsorted.sort((first: any, second: any) => { const arr = [obj, obj, {a: 1}];
return first.a.toLowerCase().localeCompare(second.a.toLowerCase()); expect(makeUnique(arr)).toHaveLength(1);
}).map((u: any) => ({...u}));
expect(unsorted.sort(caseInsensitiveSort('a'))).toStrictEqual(sorted);
}); });
test('alphanumeric strings', () => { });
const unsorted: any = [{a: '4pple'}, {a: 'Carrot'}, {a: 'b4n4n4'}];
const sorted: any = unsorted.sort((first: any, second: any) => { describe('makeArray', () => {
return first.a.toLowerCase().localeCompare(second.a.toLowerCase()); it('wraps non-arrays in array', () => {
}).map((u: any) => ({...u})); expect(makeArray(1)).toEqual([1]);
expect(unsorted.sort(caseInsensitiveSort('a'))).toStrictEqual(sorted); });
it('returns array as-is', () => {
expect(makeArray([1, 2])).toEqual([1, 2]);
}); });
}); });
}); });

148
tests/aset.spec.ts Normal file
View File

@ -0,0 +1,148 @@
import {ASet} from '../src';
describe('ASet', () => {
describe('constructor', () => {
it('should create a set with unique elements', () => {
const set = new ASet([1, 2, 2, 3]);
expect(set.size).toBe(3);
expect(set.sort()).toEqual([1, 2, 3]);
});
it('should create an empty set by default', () => {
const set = new ASet();
expect(set.size).toBe(0);
});
});
describe('add', () => {
it('should add unique elements', () => {
const set = new ASet([1]);
set.add(2, 3, 1);
expect(set.sort()).toEqual([1, 2, 3]);
});
it('should return this', () => {
const set = new ASet();
expect(set.add(1)).toBe(set);
});
});
describe('clear', () => {
it('should remove all elements', () => {
const set = new ASet([1, 2]);
set.clear();
expect(set.size).toBe(0);
});
it('should return this', () => {
const set = new ASet([1]);
expect(set.clear()).toBe(set);
});
});
describe('delete', () => {
it('should remove specified elements', () => {
const set = new ASet([1, 2, 3]);
set.delete(2, 4);
expect(set.sort()).toEqual([1, 3]);
});
it('should return this', () => {
const set = new ASet([1]);
expect(set.delete(1)).toBe(set);
});
});
describe('difference', () => {
it('should return elements unique to this set', () => {
const setA = new ASet([1, 2, 3]);
const setB = new ASet([2, 4]);
expect(setA.difference(setB).sort()).toEqual([1, 3]);
expect(setB.difference(setA).sort()).toEqual([4]);
});
});
describe('has', () => {
it('should check if element exists in set', () => {
const set = new ASet([1, 2]);
expect(set.has(1)).toBe(true);
expect(set.has(99)).toBe(false);
});
});
describe('indexOf', () => {
it('should return correct index for primitive and object', () => {
const set = new ASet([{a: 1}, {b: 2}]);
expect(set.indexOf({a: 1})).toBe(0);
expect(set.indexOf(<any>{missing: 1})).toBe(-1);
const numbers = new ASet([1, 2, 3]);
expect(numbers.indexOf(2)).toBe(1);
expect(numbers.indexOf(10)).toBe(-1);
});
});
describe('intersection', () => {
it('should return elements common to both sets', () => {
const setA = new ASet([1, 2, 3]);
const setB = new ASet([2, 3, 4]);
expect(setA.intersection(setB).sort()).toEqual([2, 3]);
});
});
describe('isDisjointFrom', () => {
it('should check for no common elements', () => {
const setA = new ASet([1, 2]);
const setB = new ASet([3, 4]);
const setC = new ASet([2, 3]);
expect(setA.isDisjointFrom(setB)).toBe(true);
expect(setA.isDisjointFrom(setC)).toBe(false);
});
});
describe('isSubsetOf', () => {
it('should check if set is subset', () => {
const a = new ASet([1, 2]);
const b = new ASet([1, 2, 3]);
expect(a.isSubsetOf(b)).toBe(true);
expect(b.isSubsetOf(a)).toBe(false);
});
});
describe('isSuperset', () => {
it('should check if set is superset', () => {
const a = new ASet([1, 2, 3]);
const b = new ASet([1, 2]);
expect(a.isSuperset(b)).toBe(true);
expect(b.isSuperset(a)).toBe(false);
});
});
describe('symmetricDifference', () => {
it('should return elements only in one set (XOR)', () => {
const a = new ASet([1, 2, 3]);
const b = new ASet([3, 4]);
expect(a.symmetricDifference(b).sort()).toEqual([1, 2, 4]);
});
});
describe('union', () => {
it('should return union of two sets', () => {
const a = new ASet([1, 2]);
const b = new ASet([2, 3]);
expect(a.union(b).sort()).toEqual([1, 2, 3]);
});
it('should work with arrays', () => {
const a = new ASet([1]);
expect(a.union([2, 1, 3]).sort()).toEqual([1, 2, 3]);
});
});
describe('size', () => {
it('should return number of unique elements', () => {
const set = new ASet([1, 1, 2, 3, 3]);
expect(set.size).toBe(3);
});
});
});

109
tests/cache.spec.ts Normal file
View File

@ -0,0 +1,109 @@
import {Cache} from '../src';
describe('Cache', () => {
type TestItem = { id: string; value: string; };
let cache: Cache<string, TestItem>;
let storageMock: Storage;
let storageGetItemSpy: jest.SpyInstance;
let storageSetItemSpy: jest.SpyInstance;
beforeEach(() => {
storageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
key: jest.fn(),
length: 0,
};
storageGetItemSpy = jest.spyOn(storageMock, 'getItem');
storageSetItemSpy = jest.spyOn(storageMock, 'setItem');
cache = new Cache<string, TestItem>('id', {storage: storageMock, storageKey: 'cache'});
jest.clearAllMocks();
jest.useFakeTimers();
});
test('should add and get an item', () => {
const item = {id: '1', value: 'a'};
cache.add(item);
expect(cache.get('1')).toEqual(item);
});
test('should not get an expired item when expired option not set', () => {
const item = {id: '1', value: 'a'};
cache.set('1', item);
cache.options.expiryPolicy = 'keep';
cache.expire('1');
expect(cache.get('1')).toBeNull();
expect(cache.get('1', true)).toEqual({...item, _expired: true});
});
test('should set and get via property access (proxy)', () => {
(cache as any)['2'] = {id: '2', value: 'b'};
expect((cache as any)['2']).toEqual({id: '2', value: 'b'});
});
test('should remove an item', () => {
cache.set('1', {id: '1', value: 'a'});
cache.delete('1');
expect(cache.get('1')).toBeNull();
expect(storageSetItemSpy).toHaveBeenCalled();
});
test('should clear the cache', () => {
cache.add({id: '1', value: 'a'});
cache.clear();
expect(cache.get('1')).toBeNull();
expect(cache.complete).toBe(false);
});
test('should add multiple items and mark complete', () => {
const rows = [
{id: 'a', value: '1'},
{id: 'b', value: '2'},
];
cache.addAll(rows);
expect(cache.all().length).toBe(2);
expect(cache.complete).toBe(true);
});
test('should return all, keys, entries, and map', () => {
cache.add({id: '1', value: 'a'});
cache.add({id: '2', value: 'b'});
expect(cache.all().length).toBe(2);
expect(cache.keys().sort()).toEqual(['1', '2']);
expect(cache.entries().length).toBe(2);
expect(Object.keys(cache.map())).toContain('1');
expect(Object.keys(cache.map())).toContain('2');
});
// test('should expire/delete items after TTL', () => {
// jest.useFakeTimers();
// cache = new Cache<string, TestItem>('id', {ttl: 0.1});
// cache.add({id: '3', value: 'x'});
// jest.advanceTimersByTime(250);
// expect(cache.get('3')).toBeNull();
// });
test('should persist and restore from storage', () => {
(storageMock.getItem as jest.Mock).mockReturnValueOnce(JSON.stringify({a: {id: 'a', value: 'from-storage'}}));
const c = new Cache<string, TestItem>('id', {storage: storageMock, storageKey: 'cache'});
expect(c.get('a')).toEqual({id: 'a', value: 'from-storage'});
});
test('should handle expiryPolicy "delete"', () => {
cache.options.expiryPolicy = 'delete';
cache.add({id: 'k1', value: 'KeepMe'});
cache.expire('k1');
expect(cache.get('k1', true)).toBeNull();
});
test('should handle expiryPolicy "keep"', () => {
cache.options.expiryPolicy = 'keep';
cache.add({id: 'k1', value: 'KeepMe'});
cache.expire('k1');
expect(cache.get('k1')).toBeNull();
expect(cache.get('k1', true)?._expired).toBe(true);
});
});

40
tests/color.spec.ts Normal file
View File

@ -0,0 +1,40 @@
import {contrast} from '../src';
describe('contrast', () => {
it('should return "black" for white background', () => {
expect(contrast('ffffff')).toBe('black');
expect(contrast('#ffffff'.replace('#', ''))).toBe('black'); // simulate trimmed hash
});
it('should return "white" for black background', () => {
expect(contrast('000000')).toBe('white');
});
it('should return "white" for a dark color', () => {
expect(contrast('123456')).toBe('white');
expect(contrast('222222')).toBe('white');
});
it('should return "black" for a light color', () => {
expect(contrast('ffff99')).toBe('black');
expect(contrast('cccccc')).toBe('black');
});
it('should handle short hex color codes (3 chars)', () => {
expect(contrast('fff')).toBe('black');
expect(contrast('000')).toBe('white');
});
it('should return "black" for invalid input', () => {
expect(contrast('')).toBe('black');
expect(contrast('zzzzzz')).toBe('black');
expect(contrast('not-a-color')).toBe('black');
expect(contrast(undefined as unknown as string)).toBe('black');
expect(contrast(null as unknown as string)).toBe('black');
});
it('should handle hex codes with hash prefix if removed', () => {
expect(contrast('ededed')).toBe('black');
expect(contrast('343434')).toBe('white');
});
});

90
tests/csv.spec.ts Normal file
View File

@ -0,0 +1,90 @@
import {fromCsv, toCsv} from '../src';
describe('CSV Utilities', () => {
describe('fromCsv', () => {
it('parses CSV with headers', () => {
const input = `name,age,city
John,30,New York
Jane,25,Los Angeles`;
const expected = [
{name: 'John', age: '30', city: 'New York'},
{name: 'Jane', age: '25', city: 'Los Angeles'},
];
expect(fromCsv(input)).toEqual(expected);
});
it('parses CSV without headers', () => {
const input = `apple,red,1
banana,yellow,2`;
const expected = [
{A: 'apple', B: 'red', C: '1'},
{A: 'banana', B: 'yellow', C: '2'},
];
expect(fromCsv(input, false)).toEqual(expected);
});
it('handles quoted fields and commas', () => {
const input = `name,description
"Widget, Large","A large, useful widget"
Gadget,"A ""versatile"" gadget"`;
const expected = [
{name: 'Widget, Large', description: 'A large, useful widget'},
{name: 'Gadget', description: 'A "versatile" gadget'},
];
expect(fromCsv(input)).toEqual(expected);
});
it('handles empty fields', () => {
const input = `id,name,score
1,Tom,97
2,,89
3,Alice,`;
const expected = [
{id: '1', name: 'Tom', score: '97'},
{id: '2', name: '', score: '89'},
{id: '3', name: 'Alice', score: ''},
];
expect(fromCsv(input)).toEqual(expected);
});
});
describe('toCsv', () => {
it('converts array of objects to CSV', () => {
const arr = [
{name: 'John', age: 30, city: 'New York'},
{name: 'Jane', age: 25, city: 'Los Angeles'},
];
const csv = toCsv(arr);
expect(csv).toContain('name,age,city');
expect(csv).toContain('John,30,New York');
expect(csv).toContain('Jane,25,Los Angeles');
});
it('quotes fields with commas and quotes', () => {
const arr = [
{val: 'Comma, included', remark: 'needs, quotes'},
{val: 'Quote "double"', remark: 'embedded "quotes"'},
];
const csv = toCsv(arr);
expect(csv).toContain('"Comma, included","needs, quotes"');
expect(csv).toContain('"Quote ""double""","embedded ""quotes"""');
});
it('handles nested objects when flatten = true', () => {
const arr = [
{id: 1, info: {name: 'Alice', age: 20}},
{id: 2, info: {name: 'Bob', age: 22}}
];
const csv = toCsv(arr, true);
expect(csv).toMatch(/id,info\.name,info\.age/);
expect(csv).toMatch(/1,Alice,20/);
expect(csv).toMatch(/2,Bob,22/);
});
it('handles objects with array fields', () => {
const arr = [{name: 'Joe', tags: ['a', 'b']}];
const csv = toCsv(arr);
expect(csv).toContain('Joe,"[""a"",""b""]"');
});
});
});

118
tests/emitter.spec.ts Normal file
View File

@ -0,0 +1,118 @@
import {TypedEmitter} from '../src';
describe('TypedEmitter', () => {
describe('Instance', () => {
type Events = {
foo: (data: string) => void;
bar: (x: number, y: number) => void;
'*': (event: string, ...args: any[]) => void;
};
let emitter: TypedEmitter<Events>;
beforeEach(() => {
emitter = new TypedEmitter<Events>();
});
it('calls the correct listener on emit', () => {
const fooHandler = jest.fn();
emitter.on('foo', fooHandler);
emitter.emit('foo', 'hello');
expect(fooHandler).toHaveBeenCalledWith('hello');
});
it('does NOT call listener after off', () => {
const fooHandler = jest.fn();
emitter.on('foo', fooHandler);
emitter.off('foo', fooHandler);
emitter.emit('foo', 'test');
expect(fooHandler).not.toHaveBeenCalled();
});
it('returns unsubscribe function that removes handler', () => {
const handler = jest.fn();
const unsubscribe = emitter.on('foo', handler);
unsubscribe();
emitter.emit('foo', 'x');
expect(handler).not.toHaveBeenCalled();
});
it('calls wildcard listener for all events', () => {
const wildcard = jest.fn();
emitter.on('*', wildcard);
emitter.emit('foo', 'data');
emitter.emit('bar', 1, 2);
expect(wildcard).toHaveBeenCalledWith('foo', 'data');
expect(wildcard).toHaveBeenCalledWith('bar', 1, 2);
});
it('once() resolves with argument and auto-unsubscribes', async () => {
const p = emitter.once('foo');
emitter.emit('foo', 'only-once');
expect(await p).toBe('only-once');
// no more handlers
const cb = jest.fn();
emitter.on('foo', cb);
emitter.emit('foo', 'again');
expect(cb).toHaveBeenCalledWith('again');
});
it('once() calls optional listener and Promise resolves', async () => {
const listener = jest.fn();
const oncePromise = emitter.once('bar', listener);
emitter.emit('bar', 1, 2);
expect(listener).toHaveBeenCalledWith(1, 2);
expect(await oncePromise).toEqual([1, 2]);
});
});
describe('Static', () => {
beforeEach(() => {
// Clear static listeners between tests
(TypedEmitter as any).listeners = {};
});
it('calls static listeners with emit', () => {
const spy = jest.fn();
TypedEmitter.on('event', spy);
TypedEmitter.emit('event', 1, 'a');
expect(spy).toHaveBeenCalledWith(1, 'a');
});
it('wildcard static listeners receive all event types', () => {
const spy = jest.fn();
TypedEmitter.on('*', spy);
TypedEmitter.emit('xy', 123);
expect(spy).toHaveBeenCalledWith('xy', 123);
});
it('only calls listener once with once()', async () => {
const handler = jest.fn();
const p = TypedEmitter.once('ping', handler);
TypedEmitter.emit('ping', 'pong');
expect(handler).toHaveBeenCalledWith('pong');
await expect(p).resolves.toBe('pong');
handler.mockClear();
TypedEmitter.emit('ping', 'other');
expect(handler).not.toHaveBeenCalled();
});
it('removes static listener with off', () => {
const h = jest.fn();
TypedEmitter.on('offevent', h);
TypedEmitter.off('offevent', h);
TypedEmitter.emit('offevent', 42);
expect(h).not.toHaveBeenCalled();
});
});
});

112
tests/errors.spec.ts Normal file
View File

@ -0,0 +1,112 @@
import {
CustomError,
BadRequestError,
UnauthorizedError,
PaymentRequiredError,
ForbiddenError,
NotFoundError,
MethodNotAllowedError,
NotAcceptableError,
InternalServerError,
NotImplementedError,
BadGatewayError,
ServiceUnavailableError,
GatewayTimeoutError,
errorFromCode
} from '../src';
describe('CustomError Hierarchy', () => {
it('CustomError basic properties and code getter/setter', () => {
const err = new CustomError('Test', 501);
expect(err.message).toBe('Test');
expect(err.code).toBe(501);
err.code = 404;
expect(err.code).toBe(404);
// default code if not provided
const noCodeError = new CustomError('No code');
expect(noCodeError.code).toBe(500);
});
it('CustomError static from method copies properties and stack', () => {
const orig: any = new Error('oops');
orig.code = 402;
orig.stack = 'FAKE_STACK';
const custom = CustomError.from(orig);
expect(custom).toBeInstanceOf(CustomError);
expect(custom.message).toBe('oops');
expect(custom.code).toBe(500);
expect(custom.stack).toBe('FAKE_STACK');
});
it('CustomError instanceof works', () => {
expect(CustomError.instanceof(new CustomError())).toBe(true);
expect(CustomError.instanceof(new Error())).toBe(false);
});
it('CustomError toString returns message', () => {
const err = new CustomError('foo');
expect(err.toString()).toBe('foo');
});
const cases = [
[BadRequestError, 400, 'Bad Request'],
[UnauthorizedError, 401, 'Unauthorized'],
[PaymentRequiredError, 402, 'Payment Required'],
[ForbiddenError, 403, 'Forbidden'],
[NotFoundError, 404, 'Not Found'],
[MethodNotAllowedError, 405, 'Method Not Allowed'],
[NotAcceptableError, 406, 'Not Acceptable'],
[InternalServerError, 500, 'Internal Server Error'],
[NotImplementedError, 501, 'Not Implemented'],
[BadGatewayError, 502, 'Bad Gateway'],
[ServiceUnavailableError, 503, 'Service Unavailable'],
[GatewayTimeoutError, 504, 'Gateway Timeout'],
] as const;
describe.each(cases)(
'%p (code=%i, defaultMessage="%s")',
(ErrClass, code, defMsg) => {
it('has static code, default message, and instanceof', () => {
const e = new ErrClass();
expect(e).toBeInstanceOf(ErrClass);
expect(e.code).toBe(code);
expect(e.message).toBe(defMsg);
expect(ErrClass.instanceof(e)).toBe(true);
});
it('supports custom messages', () => {
const msg = 'Custom msg';
const e = new ErrClass(msg);
expect(e.message).toBe(msg);
});
}
);
describe('errorFromCode', () => {
it.each(cases)(
'returns %p for code %i',
(ErrClass, code, defMsg) => {
const err = errorFromCode(code);
expect(err).toBeInstanceOf(ErrClass);
expect(err.code).toBe(code);
expect(err.message).toBe(defMsg);
}
);
it('overrides message if provided', () => {
const err = errorFromCode(404, 'Nope');
expect(err).toBeInstanceOf(NotFoundError);
expect(err.message).toBe('Nope');
});
it('fallbacks to CustomError for unknown codes', () => {
const err = errorFromCode(999, 'xyz');
expect(err).toBeInstanceOf(CustomError);
expect(err.code).toBe(999);
expect(err.message).toBe('xyz');
});
it('handles missing message gracefully', () => {
const err = errorFromCode(555);
expect(err).toBeInstanceOf(CustomError);
expect(err.code).toBe(555);
});
});
});

61
tests/jwt.spec.ts Normal file
View File

@ -0,0 +1,61 @@
import { createJwt, decodeJwt } from '../src';
describe('JWT Utilities', () => {
describe('createJwt', () => {
it('should create a valid JWT string with default signature', () => {
const payload = { foo: 'bar', num: 123 };
const jwt = createJwt(payload);
const parts = jwt.split('.');
expect(parts).toHaveLength(3);
// Header should decode to HS256 + JWT
const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString());
expect(header).toEqual({ alg: "HS256", typ: "JWT" });
// Body should match the payload
const body = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
expect(body).toEqual(payload);
// Signature should be 'unsigned'
expect(parts[2]).toBe('unsigned');
});
it('should allow custom signature', () => {
const jwt = createJwt({ test: 1 }, 'mysignature');
expect(jwt.split('.')[2]).toBe('mysignature');
});
});
describe('decodeJwt', () => {
it('should decode a JWT payload', () => {
const payload = { user: 'alice', age: 30 };
const jwt = createJwt(payload);
const decoded = decodeJwt<typeof payload>(jwt);
expect(decoded).toEqual(payload);
});
it('should decode payload with different types', () => {
const payload = { arr: [1,2,3], flag: true, val: null };
const jwt = createJwt(payload);
const decoded = decodeJwt<typeof payload>(jwt);
expect(decoded).toEqual(payload);
});
it('should throw or return null for malformed tokens', () => {
// Not enough parts
expect(() => decodeJwt('foo.bar')).toThrow();
// Bad base64
expect(() => decodeJwt('a.b@d.c')).toThrow();
});
it('should decode JWT even if signature is missing', () => {
// Two-part JWT (not standard, but let's see what happens)
const payload = { ok: true };
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
const jwt = `${header}.${body}`;
const decoded = decodeJwt<typeof payload>(jwt + '.');
expect(decoded).toEqual(payload);
});
});
});

51
tests/math.spec.ts Normal file
View File

@ -0,0 +1,51 @@
import { dec2Frac, fracToDec } from '../src';
describe('Math Utilities', () => {
describe('dec2Frac', () => {
it('should convert decimal to fraction with whole and remainder', () => {
expect(dec2Frac(1.25)).toBe('1 1/4');
expect(dec2Frac(2.5)).toBe('2 1/2');
expect(dec2Frac(3.75)).toBe('3 3/4');
});
it('should convert integer to fraction with denominator', () => {
expect(dec2Frac(4)).toBe('4');
expect(dec2Frac(0)).toBe('0');
});
it('should convert proper fraction (less than 1)', () => {
expect(dec2Frac(0.75)).toBe('3/4');
expect(dec2Frac(0.5)).toBe('1/2');
expect(dec2Frac(0.1)).toBe('1/10');
});
it('should handle repeating decimals gracefully', () => {
expect(dec2Frac(0.333333)).toBe('1/3');
expect(dec2Frac(0.666666)).toBe('2/3');
});
});
describe('fracToDec', () => {
it('should convert mixed fraction to decimal', () => {
expect(fracToDec('1 1/4')).toBeCloseTo(1.25);
expect(fracToDec('2 1/2')).toBeCloseTo(2.5);
expect(fracToDec('3 3/4')).toBeCloseTo(3.75);
});
it('should convert fraction without whole part to decimal', () => {
expect(fracToDec('3/4')).toBeCloseTo(0.75);
expect(fracToDec('1/2')).toBeCloseTo(0.5);
expect(fracToDec('1/10')).toBeCloseTo(0.1);
});
it('should convert whole number fraction', () => {
expect(fracToDec('4 0/1')).toBeCloseTo(4);
expect(fracToDec('0/1')).toBeCloseTo(0);
});
it('should handle zero correctly', () => {
expect(fracToDec('0/1')).toBeCloseTo(0);
expect(fracToDec('0 0/1')).toBeCloseTo(0);
});
});
});

View File

@ -1,36 +1,44 @@
import {sleep, parseUrl} from '../src'; import {fn, gravatar, escapeRegex, md5} from '../src';
describe('Miscellanies Utilities', () => { describe('Misc Utilities', () => {
describe('sleep', () => { describe('fn', () => {
test('wait until', async () => { it('should execute a stringified function with arguments', () => {
const wait = ~~(Math.random() * 500); const result = fn({ x: 2, y: 3 }, 'return x + y;');
const time = new Date().getTime(); expect(result).toBe(5);
await sleep(wait); });
expect(new Date().getTime()).toBeGreaterThanOrEqual(time + wait);
it('should execute an async function if async=true', async () => {
const asyncFn = 'return await Promise.resolve(x * y);';
const result = await fn({ x: 3, y: 4 }, asyncFn, true);
expect(result).toBe(12);
});
it('should work with no arguments', () => {
const result = fn({}, 'return 42;');
expect(result).toBe(42);
}); });
}); });
describe('urlParser', () => { describe('gravatar', () => {
test('localhost w/ port', () => { it('should return empty string if email is falsy', () => {
const parsed = parseUrl('http://localhost:4200/some/path?q1=test1&q2=test2#frag'); expect(gravatar('')).toBe('');
expect(parsed.protocol).toStrictEqual('http'); });
expect(parsed.host).toStrictEqual('localhost:4200'); it('should build correct gravatar url', () => {
expect(parsed.domain).toStrictEqual('localhost'); const email = 'test@example.com';
expect(parsed.port).toStrictEqual(4200); expect(gravatar(email)).toContain(`https://www.gravatar.com/avatar/${md5(email)}`);
expect(parsed.path).toStrictEqual('/some/path'); });
expect(parsed.query).toStrictEqual({q1: 'test1', q2: 'test2'});
expect(parsed.fragment).toStrictEqual('frag');
}); });
test('advanced URL', () => { describe('escapeRegex', () => {
const parsed = parseUrl('https://sub.domain.example.com/some/path?q1=test1&q2=test2#frag'); it('should escape all special regex characters', () => {
expect(parsed.protocol).toStrictEqual('https'); const special = '.*+?^${}()|[]\\';
expect(parsed.host).toStrictEqual('sub.domain.example.com'); const escaped = escapeRegex(special);
expect(parsed.domain).toStrictEqual('example.com'); expect(escaped).toBe('\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\');
expect(parsed.subdomain).toStrictEqual('sub.domain'); });
expect(parsed.path).toStrictEqual('/some/path'); it('should return original string if nothing to escape', () => {
expect(parsed.query).toStrictEqual({q1: 'test1', q2: 'test2'}); const normal = 'abc123';
expect(parsed.fragment).toStrictEqual('frag'); const escaped = escapeRegex(normal);
expect(escaped).toBe('abc123');
}); });
}); });
}); });

View File

@ -1,89 +1,164 @@
import {clean, deepCopy, dotNotation, flattenObj, includes, isEqual} from "../src"; import {
clean, deepCopy, deepMerge, dotNotation, encodeQuery, flattenObj, formData, includes, isEqual, mixin,
describe('Object Utilities', () => { JSONAttemptParse, JSONSerialize, JSONSanitize
const TEST_OBJECT = { } from '../src';
a: 1,
b: [
[2, 3],
[4, 5]
],
c: {
d: [
[{e: 6, f: 7}]
],
},
g: {h: 8},
i: () => 9
};
describe('Object utilities', () => {
describe('clean', () => { describe('clean', () => {
test('remove null properties', () => { it('removes null values', () => {
const a = {a: 1, b: null, c: undefined}; const obj = { a: 1, b: null, c: undefined, d: 0 };
const final = {a: 1}; expect(clean({ ...obj })).toEqual({ a: 1, c: undefined, d: 0 });
expect(clean(a)).toEqual(final);
}); });
test('remove undefined properties', () => { it('throws on null input', () => {
const a = {a: 1, b: null, c: undefined}; expect(() => clean(null as any)).toThrow();
const final = {a: 1, b: null}; });
expect(clean(a, true)).toEqual(final); it('removes undefined only when specified', () => {
const obj = { a: 1, b: undefined, c: null };
expect(clean({ ...obj }, true)).toEqual({ a: 1, c: null });
});
it('works for arrays', () => {
expect(clean([1, null, 2, undefined, 3] as any)).toEqual([1, 2, 3]);
});
});
describe('deepCopy', () => {
it('creates a deep copy', () => {
const obj = { a: { b: 2 } };
const copy = deepCopy(obj);
expect(copy).toEqual(obj);
expect(copy).not.toBe(obj);
expect(copy.a).not.toBe(obj.a);
});
});
describe('deepMerge', () => {
it('merges deeply nested objects', () => {
const tgt = { a: { b: 1 }, d: 7 };
const src = { a: { c: 2 }, d: 8 };
expect(deepMerge({ ...tgt }, src)).toEqual({ a: { b: 1, c: 2 }, d: 8 });
});
it('merges multiple sources', () => {
const t = { a: 1 };
const s1 = { b: 2 };
const s2 = { c: 3 };
expect(deepMerge({ ...t }, s1, s2)).toEqual({ a: 1, b: 2, c: 3 });
}); });
}); });
describe('dotNotation', () => { describe('dotNotation', () => {
test('no object or properties', () => { it('gets nested value', () => {
expect(dotNotation(undefined, 'z')).toStrictEqual(undefined); const obj = { a: { b: { c: 3 } } };
expect(dotNotation(TEST_OBJECT, '')).toStrictEqual(undefined); expect(dotNotation(obj, 'a.b.c')).toBe(3);
}); });
test('invalid property', () => expect(dotNotation(TEST_OBJECT, 'z')).toBeUndefined()); it('sets nested value', () => {
test('by property', () => expect(dotNotation(TEST_OBJECT, 'a')).toBe(TEST_OBJECT.a)); const obj = { a: { b: { c: 3 } } };
test('by key', () => expect(dotNotation(TEST_OBJECT, '["a"]')).toBe(TEST_OBJECT['a'])); dotNotation(obj, 'a.b.c', 10);
test('by key (single quote)', () => expect(dotNotation(TEST_OBJECT, '[\'a\']')).toBe(TEST_OBJECT['a'])); expect(obj.a.b.c).toBe(10);
test('by key (double quote)', () => expect(dotNotation(TEST_OBJECT, '["a"]')).toBe(TEST_OBJECT['a']));
test('by index', () => expect(dotNotation(TEST_OBJECT, 'b[0]')).toBe(TEST_OBJECT.b[0]));
test('by index (2d)', () => expect(dotNotation(TEST_OBJECT, 'b[1][1]')).toBe(TEST_OBJECT.b[1][1]));
test('everything combined', () => expect(dotNotation(TEST_OBJECT, 'c["d"][0][0].e'))
.toBe(TEST_OBJECT.c['d'][0][0].e));
test('set value', () => {
const COPY = JSON.parse(JSON.stringify(TEST_OBJECT));
dotNotation(COPY, 'c["d"][0][0].e', 'test');
expect(COPY['c']['d'][0][0]['e']).toBe('test');
}); });
test('set new value', () => { it('returns undefined for non-existent path', () => {
const COPY = JSON.parse(JSON.stringify(TEST_OBJECT)); expect(dotNotation({ a: 1 }, 'a.b.c')).toBeUndefined();
dotNotation(COPY, 'c.x.y.z', 'test'); });
expect(COPY['c']['x']['y']['z']).toBe('test'); it('creates nested object when setting', () => {
const obj: any = {};
dotNotation(obj, 'd.e.f', 5);
expect(obj.d.e.f).toBe(5);
});
});
describe('encodeQuery', () => {
it('encodes simple objects', () => {
expect(encodeQuery({ a: 1, b: 'test' })).toBe('a=1&b=test');
});
it('handles special characters', () => {
expect(encodeQuery({ a: 'hello world' })).toBe('a=hello%20world');
});
});
describe('flattenObj', () => {
it('flattens nested objects', () => {
const obj = { a: { b: 2 }, c: 3 };
expect(flattenObj(obj)).toEqual({ 'a.b': 2, c: 3 });
});
it('handles multiple nesting', () => {
const obj = { a: { b: { c: 4 } } };
expect(flattenObj(obj)).toEqual({ 'a.b.c': 4 });
});
});
describe('formData', () => {
it('converts object to FormData', () => {
const obj = { a: '1', b: 'foo' };
const fd = formData(obj);
expect(fd.get('a')).toBe('1');
expect(fd.get('b')).toBe('foo');
}); });
}); });
describe('includes', () => { describe('includes', () => {
test('simple', () => expect(includes(TEST_OBJECT, {a: 1})).toBeTruthy()); it('checks if all values included', () => {
test('nested', () => expect(includes(TEST_OBJECT, {g: {h: 8}})).toBeTruthy()); expect(includes({ a: 2, b: 3 }, { a: 2 })).toBeTruthy();
test('array', () => expect(includes(TEST_OBJECT, {b: [[2]]})).toBeTruthy()); expect(includes({ a: 2, b: 3 }, { c: 1 })).toBeFalsy();
test('nested array', () => expect(includes(TEST_OBJECT, {a: 1, c: {d: [[{e: 6}]]}})).toBeTruthy()); });
test('wong nested array', () => expect(includes(TEST_OBJECT, {a: 1, c: {d: [{e: 7}]}})).toBeFalsy()); it('handles arrays of values', () => {
test('wrong value', () => expect(includes(TEST_OBJECT, {a: 1, b: 2})).toBeFalsy()); expect(includes([{ a: 1 }], [{ a: 1 }])).toBeTruthy();
test('missing value', () => expect(includes(TEST_OBJECT, {a: 1, i: 10})).toBeFalsy()); expect(includes([{ a: 1 }], [{ a: 2 }])).toBeFalsy();
});
it('allows missing when specified', () => {
expect(includes(undefined, { a: 2 }, true)).toBeTruthy();
});
}); });
describe('isEqual', () => { describe('isEqual', () => {
test('boolean equal', () => expect(isEqual(true, true)).toBeTruthy()); it('returns true for deeply equal objects', () => {
test('boolean not-equal', () => expect(isEqual(true, false)).toBeFalsy()); expect(isEqual({ a: 1, b: { c: 3 } }, { a: 1, b: { c: 3 } })).toBe(true);
test('number equal', () => expect(isEqual(1, 1)).toBeTruthy()); });
test('number not-equal', () => expect(isEqual(1, 0)).toBeFalsy()); it('returns false for non-equal objects', () => {
test('string equal', () => expect(isEqual('abc', 'abc')).toBeTruthy()); expect(isEqual({ a: 1 }, { a: 2 })).toBe(false);
test('string not-equal', () => expect(isEqual('abc', '')).toBeFalsy()); });
test('array equal', () => expect(isEqual([true, 1, 'a'], [true, 1, 'a'])).toBeTruthy()); it('compares functions by string', () => {
test('array not-equal', () => expect(isEqual([true, 1, 'a'], [1])).toBeFalsy()); expect(isEqual(() => 1, () => 1)).toBe(true);
test('object equal', () => expect(isEqual({a: 1, b: 2}, {a: 1, b: 2})).toBeTruthy()); });
test('object not-equal', () => expect(isEqual({a: 1, b: 2}, {a: 1})).toBeFalsy());
test('complex', () => expect(isEqual(TEST_OBJECT, TEST_OBJECT)).toBeTruthy());
}); });
describe('flattenObj', () => { describe('mixin', () => {
test('simple nested object', () => expect(flattenObj({a: {b: {c: 1}}})).toEqual({"a.b.c": 1})); it('merges prototypes', () => {
test('already flat object', () => expect(flattenObj(TEST_OBJECT['g'])).toEqual(TEST_OBJECT['g'])); class A { foo() { return 1; } }
test('non-object input', () => expect(flattenObj(TEST_OBJECT['b'])).toBeUndefined()); class B { bar() { return 2; } }
test('complex nested object', () => expect(flattenObj({a: 1, b: {c: 2}, d: {e: {f: {g: 3}}}})) class C {}
.toEqual({"a": 1, "b.c": 2, "d.e.f.g": 3})); mixin(C, [A, B]);
const c = new (C as any)();
expect(c.foo()).toBe(1);
expect(c.bar()).toBe(2);
});
});
describe('JSONAttemptParse', () => {
it('parses valid JSON', () => {
expect(JSONAttemptParse('{"a":1}')).toEqual({ a: 1 });
});
it('returns original string on error', () => {
expect(JSONAttemptParse('not json')).toBe('not json');
});
});
describe('JSONSerialize', () => {
it('serializes objects', () => {
expect(JSONSerialize({ a: 1 })).toBe(JSON.stringify({ a: 1 }));
});
it('leaves primitives as is', () => {
expect(JSONSerialize('test')).toBe('test');
expect(JSONSerialize(123)).toBe(123);
});
});
describe('JSONSanitize', () => {
it('stringifies objects', () => {
expect(JSONSanitize({ a: 1 })).toBe(JSON.stringify({ a: 1 }));
});
it('does not throw on circular refs', () => {
const obj: any = {};
obj.self = obj;
expect(() => JSONSanitize(obj)).not.toThrow();
});
}); });
}); });

198
tests/path-events.spec.ts Normal file
View File

@ -0,0 +1,198 @@
import {PathError, PathEvent, PathEventEmitter, PE, PES} from '../src';
describe('Path Events', () => {
describe('PE', () => {
it('creates PathEvent from template string', () => {
const e = PE`users/system:cr`;
expect(e).toBeInstanceOf(PathEvent);
expect(e.fullPath).toBe('users/system');
expect(e.create).toBe(true);
expect(e.read).toBe(true);
});
it('handles interpolation', () => {
const path = 'users/system';
const meth = 'r';
const e = PE`${path}:${meth}`;
expect(e.fullPath).toBe('users/system');
expect(e.read).toBe(true);
});
});
describe('PES', () => {
it('creates string for event', () => {
expect(PES`users/system:cr`).toBe('users/system:cr');
});
});
describe('PathEvent', () => {
it('parses event string', () => {
const pe = new PathEvent('users/system:cr');
expect(pe.module).toBe('users');
expect(pe.fullPath).toBe('users/system');
expect(pe.name).toBe('system');
expect(pe.create).toBe(true);
expect(pe.read).toBe(true);
});
it('parses wildcard', () => {
const pe = new PathEvent('*');
expect(pe.all).toBe(true);
expect(pe.fullPath).toBe('');
expect(pe.methods.has('*')).toBe(true);
});
it('parses none method', () => {
const pe = new PathEvent('users/system:n');
expect(pe.none).toBe(true);
pe.none = false;
expect(pe.none).toBe(false);
});
it('setters for methods', () => {
const pe = new PathEvent('users/system:r');
pe.create = true;
expect(pe.methods.has('c')).toBe(true);
pe.update = true;
expect(pe.methods.has('u')).toBe(true);
pe.delete = true;
expect(pe.methods.has('d')).toBe(true);
pe.read = false;
expect(pe.methods.has('r')).toBe(false);
});
it('combine merges longest path and methods', () => {
const a = new PathEvent('users/sys:cr');
const b = new PathEvent('users/sys:u');
const c = PathEvent.combine(a, b);
expect(c.fullPath).toBe('users/sys');
expect(c.methods.has('c')).toBe(true);
expect(c.methods.has('r')).toBe(true);
expect(c.methods.has('u')).toBe(true);
});
it('combine stops at none', () => {
const a = new PathEvent('data/collection/doc:c');
const b = new PathEvent('data/collection:r');
const c = new PathEvent('data:n');
const d = PathEvent.combine(a, b, c);
expect(d.fullPath).toBe(a.fullPath);
expect(d.create).toBe(true);
expect(d.read).toBe(true);
expect(d.update).toBe(false);
expect(d.none).toBe(false);
});
it('filter finds overlap by path and methods', () => {
const events = [
new PathEvent('users/sys:cr'),
new PathEvent('users/sys:r'),
new PathEvent('files/sys:r')
];
const filtered = PathEvent.filter(events, 'users/sys:r');
expect(filtered.length).toBe(2);
});
it('filter handles wildcard', () => {
const events = [
new PathEvent('*'),
new PathEvent('users/sys:r')
];
const filtered = PathEvent.filter(events, 'users/sys:r');
expect(filtered.length).toBe(2);
});
it('has returns true for overlapping', () => {
const events = [new PathEvent('users/sys:cr')];
expect(PathEvent.has(events, 'users/sys:r')).toBeTruthy();
expect(PathEvent.has(events, 'users/nope:r')).toBeFalsy();
});
it('hasAll returns true only if all overlap', () => {
const events = [
new PathEvent('users/sys:cr'),
new PathEvent('users/sys:u'),
];
expect(PathEvent.hasAll(events, 'users/sys:c', 'users/sys:u')).toBe(true);
expect(PathEvent.hasAll(events, 'users/sys:c', 'users/sys:no')).toBe(false);
});
it('hasFatal throws if not found', () => {
expect(() => PathEvent.hasFatal('users/sys:r', 'users/other:r')).toThrow(PathError);
expect(() => PathEvent.hasFatal('users/sys:r', 'users/sys:r')).not.toThrow();
});
it('hasAllFatal throws if missing', () => {
expect(() => PathEvent.hasAllFatal(['users/sys:r'], 'users/sys:r', 'users/sys:c')).toThrow(PathError);
});
it('toString creates correct event string', () => {
const s = PathEvent.toString('users/sys', ['c', 'r']);
expect(s).toBe('users/sys:cr');
const pe = new PathEvent('users/sys:cr');
expect(pe.toString()).toBe('users/sys:cr');
});
it('filter instance filters as expected', () => {
const pe = new PathEvent('users/sys:r');
const arr = ['users/sys:r', 'users/other:r'];
const filtered = pe.filter(arr);
expect(filtered[0].fullPath).toBe('users/sys');
});
});
describe('PathEventEmitter', () => {
it('wildcard', done => {
const emitter = new PathEventEmitter();
emitter.on('*', (event) => {
expect(event.fullPath).toBe('system');
done();
});
emitter.emit('system:c');
});
it('scoped', done => {
const emitter = new PathEventEmitter('users');
emitter.on(':cud', (event) => {
expect(event.fullPath).toBe('users/system');
done();
});
emitter.emit('system:u');
});
it('calls listener on matching emit', done => {
const emitter = new PathEventEmitter();
const fn = jest.fn((event) => {
expect(event.fullPath).toBe('users/sys');
done();
});
emitter.on('users/sys:r', fn);
emitter.emit('users/sys:r');
});
it('off removes listener', () => {
const emitter = new PathEventEmitter();
const fn = jest.fn();
emitter.on('users/sys:r', fn);
emitter.off(fn);
emitter.emit('users/sys:r');
expect(fn).not.toHaveBeenCalled();
});
it('on returns unsubscribe function', () => {
const emitter = new PathEventEmitter();
const fn = jest.fn();
const unsub = emitter.on('users/sys:r', fn);
unsub();
emitter.emit('users/sys:r');
expect(fn).not.toHaveBeenCalled();
});
it('emit supports prefix', () => {
const emitter = new PathEventEmitter('foo');
emitter.once('*', (event) =>
expect(event.fullPath).toBe('foo/bar'));
emitter.emit('bar:r');
});
});
});

71
tests/search.spec.ts Normal file
View File

@ -0,0 +1,71 @@
import {logicTest, search} from '../src';
const rows = [
{id: 1, name: 'Alice', age: 30},
{id: 2, name: 'Bob', age: 24},
{id: 3, name: 'Carol', age: 30},
];
describe('Search Utilities', () => {
describe('search', () => {
it('returns empty array for null rows', () => {
expect(search(null as any, 'test')).toEqual([]);
});
it('returns all rows if search is empty', () => {
expect(search(rows, '')).toEqual(rows);
});
it('filters based on a simple property string', () => {
expect(search(rows, 'Alice')).toEqual([rows[0]]);
});
it('filters using regex when regex=true', () => {
expect(search(rows, '^B', true)).toEqual([rows[1]]);
});
it('applies the transform function before filtering', () => {
const transform = (r: any) => ({...r, name: r.name.toLowerCase()});
expect(search(rows, 'alice', false, transform)).toEqual([rows[0]]);
});
it('uses logicTest for non-regex search', () => {
expect(search(rows, 'age == 30')).toEqual([rows[0], rows[2]]);
expect(search(rows, 'id = 2')).toEqual([rows[1]]);
});
it('returns all if search is falsy and regex enabled', () => {
expect(search(rows, '', true)).toEqual(rows);
});
});
describe('logicTest', () => {
const obj = {x: 10, y: 5, name: 'Alpha'};
it('handles equality and inequality', () => {
expect(logicTest(obj, 'x == 10')).toBe(true);
expect(logicTest(obj, 'y != 5')).toBe(false);
});
it('handles comparison operators', () => {
expect(logicTest(obj, 'x > 5')).toBe(true);
expect(logicTest(obj, 'y <= 10')).toBe(true);
expect(logicTest(obj, 'x < 5')).toBe(false);
});
it('supports case insensitive property search', () => {
expect(logicTest(obj, 'alpha')).toBeTruthy();
expect(logicTest(obj, 'ALPHA')).toBeFalsy();
});
it('handles logical AND/OR expressions', () => {
expect(logicTest(obj, 'x == 10 && y == 5')).toBe(true);
expect(logicTest(obj, 'x == 10 || y == 100')).toBe(true);
expect(logicTest(obj, 'x == 1 && y == 5')).toBe(false);
});
it('returns false for unsupported operators', () => {
expect(logicTest(obj, 'x === 10')).toBe(false);
});
});
});

View File

@ -1,50 +1,168 @@
import {matchAll, randomString, randomStringBuilder} from "../src"; import {
camelCase,
CHAR_LIST,
formatBytes,
formatPhoneNumber,
insertAt, kebabCase,
LETTER_LIST, matchAll, md5,
NUMBER_LIST, pad, parseUrl, pascalCase, randomHex, randomString, randomStringBuilder, snakeCase, strSplice,
SYMBOL_LIST
} from '../src';
describe('String Utilities', () => { describe('String Utilities', () => {
describe('randomString', () => { test('LETTER_LIST, NUMBER_LIST, SYMBOL_LIST, CHAR_LIST', () => {
test('length', () => expect(randomString(32).length).toStrictEqual(32)); expect(LETTER_LIST).toBe('ABCDEFGHIJKLMNOPQRSTUVWXYZ');
test('distribution', () => { expect(NUMBER_LIST).toBe('0123456789');
const charList = '123'; expect(SYMBOL_LIST).toContain('@');
const random = randomString(32, charList); expect(CHAR_LIST).toContain('A');
expect(random.split('').filter(c => c == '1').length).toBeGreaterThan(0); expect(CHAR_LIST).toContain('a');
expect(random.split('').filter(c => c == '2').length).toBeGreaterThan(0); expect(CHAR_LIST).toContain('5');
expect(random.split('').filter(c => c == '3').length).toBeGreaterThan(0); expect(CHAR_LIST).toContain('!');
}); });
test('binary', () => {
const randomByte = randomString(8, '01'); describe('camelCase', () => {
expect(randomByte.split('').filter(c => c == '0').length).toBeGreaterThan(0); it('converts to camelCase', () => {
expect(randomByte.split('').filter(c => c == '1').length).toBeGreaterThan(0); expect(camelCase('hello_world')).toBe('helloWorld');
expect(randomByte.length).toStrictEqual(8); expect(camelCase('Hello world test')).toBe('helloWorldTest');
});
it('returns empty string if value is falsy', () => {
expect(camelCase()).toBe('');
expect(camelCase('')).toBe('');
});
});
describe('formatBytes', () => {
it('correctly formats bytes', () => {
expect(formatBytes(0)).toBe('0 Bytes');
expect(formatBytes(1024)).toBe('1 KB');
expect(formatBytes(1024 * 1024)).toBe('1 MB');
expect(formatBytes(1234, 1)).toBe('1.2 KB');
});
});
describe('formatPhoneNumber', () => {
it('formats plain phone numbers', () => {
expect(formatPhoneNumber('1234567890')).toBe('(123) 456-7890');
expect(formatPhoneNumber('+11234567890')).toBe('+1 (123) 456-7890');
expect(formatPhoneNumber('1 123 456 7890')).toBe('+1 (123) 456-7890');
});
it('throws for invalid phone strings', () => {
expect(() => formatPhoneNumber('abc')).toThrow();
});
});
describe('insertAt', () => {
it('inserts a string at a given index', () => {
expect(insertAt('Hello!', 'X', 5)).toBe('HelloX');
});
});
describe('kebabCase', () => {
it('converts to kebab-case', () => {
expect(kebabCase('hello world')).toBe('hello-world');
expect(kebabCase('HelloWorld')).toContain('hello-world');
expect(kebabCase('')).toBe('');
});
});
describe('pad', () => {
it('pads start by default', () => {
expect(pad('1', 2, '0')).toBe('01');
});
it('pads end if start is false', () => {
expect(pad('1', 3, '0', false)).toBe('100');
});
});
describe('pascalCase', () => {
it('converts to PascalCase', () => {
expect(pascalCase('hello_world')).toBe('HelloWorld');
expect(pascalCase('')).toBe('');
});
});
describe('randomHex', () => {
it('creates a random hex string of correct length', () => {
expect(randomHex(8)).toHaveLength(8);
expect(/^[a-f0-9]{8}$/i.test(randomHex(8))).toBe(true);
});
});
describe('randomString', () => {
it('creates a random string from CHAR_LIST of correct length', () => {
const s = randomString(10);
expect(s).toHaveLength(10);
// letters, numbers, symbols all included in CHAR_LIST
});
it('uses provided pool', () => {
expect(['0','1']).toContain(randomString(1, '01'));
}); });
}); });
describe('randomStringBuilder', () => { describe('randomStringBuilder', () => {
test('length', () => { it('creates with just letters', () => {
const len = ~~(Math.random() * 32); expect(/^[A-Z]+$/.test(randomStringBuilder(5, true, false, false))).toBe(true);
expect(randomStringBuilder(len, true).length).toStrictEqual(len);
}); });
test('no length', () => { it('creates with just numbers', () => {
expect(randomStringBuilder(0, true)).toStrictEqual(''); expect(/^[0-9]+$/.test(randomStringBuilder(5, false, true, false))).toBe(true);
}); });
test('letters only', () => it('creates with just symbols', () => {
expect(/^[a-zA-Z]{10}$/g.test(randomStringBuilder(10, true))).toBeTruthy()); expect(SYMBOL_LIST).toContain(randomStringBuilder(1, false, false, true));
test('numbers only', () => });
expect(/^[0-9]{10}$/g.test(<any>randomStringBuilder(10, false, true))).toBeTruthy()); it('throws if all false', () => {
test('symbols only', () => expect(() => randomStringBuilder(5, false, false, false)).toThrow();
expect(/^[^a-zA-Z0-9]{10}$/g.test(randomStringBuilder(10, false, false, true))).toBeTruthy()); });
test('everything', () => { });
const randomString = randomStringBuilder(30, true, true, true);
expect(/[a-zA-Z]/g.test(randomString)).toBeTruthy(); describe('snakeCase', () => {
expect(/[0-9]/g.test(randomString)).toBeTruthy(); it('converts to snake_case', () => {
expect(/[^a-zA-Z0-9]/g.test(randomString)).toBeTruthy(); expect(snakeCase('helloWorld')).toContain('hello_world');
expect(snakeCase('')).toBe('');
});
});
describe('strSplice', () => {
it('splices string as expected', () => {
expect(strSplice('abcdef', 2, 2, 'ZZ')).toBe('abZZef');
expect(strSplice('abcdef', 1, 0, 'Z')).toBe('aZbcdef');
}); });
test('no pool', () =>
expect(() => randomStringBuilder(10, false, false, false)).toThrow());
}); });
describe('matchAll', () => { describe('matchAll', () => {
test('using string', () => expect(matchAll('fooBar fooBar FooBar', 'fooBar').length).toBe(2)); it('returns expected matches', () => {
test('using regex', () => expect(matchAll('fooBar fooBar FooBar', /fooBar/g).length).toBe(2)); const matches = matchAll('a1 b2 c3', /\d/g);
test('using malformed regex', () => expect(() => matchAll('fooBar fooBar FooBar', /fooBar/)).toThrow()); expect(matches.length).toBe(3);
expect(matches[0][0]).toBe('1');
});
it('throws for non-global regex', () => {
expect(() => matchAll('abc', /a/)).toThrow();
});
it('accepts regex string', () => {
const matches = matchAll('a1a2', '\\d');
expect(matches.length).toBe(2);
});
});
describe('parseUrl', () => {
it('parses a full url', () => {
const url = parseUrl('https://sub.example.com:8000/path?a=1&b=2#frag');
expect(url.protocol).toBe('https');
expect(url.subdomain).toBe('sub');
expect(url.domain).toBe('example.com');
expect(url.port).toBe(8000);
expect(url.path).toBe('/path');
expect(url.query).toEqual({ a: '1', b: '2' });
expect(url.fragment).toBe('frag');
});
it('parses domain without subdomain', () => {
const url = parseUrl('https://example.com');
expect(url.domain).toBe('example.com');
});
});
describe('md5', () => {
it('hashes string to hex', () => {
expect(md5('test')).toMatch(/^[a-f0-9]+$/i);
});
}); });
}); });

108
tests/time.spec.ts Normal file
View File

@ -0,0 +1,108 @@
import {adjustedInterval, formatDate, instantInterval, sleep, sleepWhile, timeUntil} from '../src';
jest.useFakeTimers();
describe('Time Utilities', () => {
describe('adjustedInterval', () => {
it('calls callback at roughly correct intervals, considering execution time', async () => {
const cb = jest.fn(() => new Promise(res => setTimeout(res, 5)));
const stop = adjustedInterval(cb, 50);
expect(cb).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(50);
expect(cb).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(100);
expect(cb).toHaveBeenCalledTimes(4);
stop();
await jest.advanceTimersByTimeAsync(100);
expect(cb).toHaveBeenCalledTimes(4);
});
});
describe('formatDate', () => {
it('formats current date correctly with default format', () => {
const result = formatDate('YYYY-MM-DD', new Date('2023-01-15T10:30:30.000Z'), 0);
expect(result).toBe('2023-01-15');
});
it('handles formatting for given timestamp', () => {
const timestamp = Date.UTC(2023, 1, 1, 18, 5, 5, 123); // Feb 1, 2023 18:05:05.123 UTC
const formatted = formatDate('YYYY MM DD HH mm ss SSS A Z', timestamp, 'UTC');
expect(formatted).toMatch(/^2023 02 01 18 05 05 123 PM \+?0:00$/i);
});
it('throws for unknown timezone', () => {
expect(() => formatDate('YYYY', new Date(), '???')).toThrowError(/Unknown timezone/);
});
it('handles timezone by offset number', () => {
const dt = new Date('2020-01-01T00:00:00.000Z');
const str = formatDate('HH:mm z', dt, 1);
expect(str).toMatch(/01:00/);
});
it('handles Do, MMMM, dddd tokens', () => {
const dt = new Date('2021-03-03T09:00:00Z');
const result = formatDate('Do MMMM dddd', dt, 0);
expect(result).toMatch(/^3rd March Wednesday$/);
});
});
describe('instantInterval', () => {
it('calls function immediately then at intervals', () => {
const cb = jest.fn();
const id = instantInterval(cb, 1000);
expect(cb).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(1000);
expect(cb).toHaveBeenCalledTimes(2);
clearInterval(id);
});
});
describe('sleep', () => {
it('waits the given ms', async () => {
const time = Date.now(), wait = 100;
const promise = sleep(wait);
jest.advanceTimersByTime(wait);
await promise;
expect(Date.now()).toBeGreaterThanOrEqual(time + wait);
});
});
describe('sleepWhile', () => {
it('resolves once condition is false', async () => {
const time = Date.now(), wait = 300;
let flag = true;
const promise = sleepWhile(() => flag, 100);
setTimeout(() => { flag = false; }, wait);
await jest.advanceTimersByTimeAsync(wait);
await promise;
expect(Date.now()).toBeGreaterThanOrEqual(time + wait);
});
});
describe('timeUntil', () => {
it('returns milliseconds until given date', () => {
const now = Date.now();
const future = now + 1000;
jest.spyOn(Date, 'now').mockReturnValue(now);
const result = timeUntil(future);
expect(result).toBe(1000);
});
it('accepts Date object', () => {
const now = new Date();
const t = new Date(now.getTime() + 450);
jest.spyOn(global, 'Date').mockImplementation(() => now as any);
const result = timeUntil(t);
expect(result).toBe(450);
});
});
});

18
tests/type.spec.ts Normal file
View File

@ -0,0 +1,18 @@
import {Writable} from '../src';
describe('Type Utilities', () => {
describe('Writable', () => {
it('should create a writable version of a readonly type', () => {
type ReadonlyPerson = {
readonly name: string;
readonly age: number;
};
type WritablePerson = Writable<ReadonlyPerson>;
// Typescript: WritablePerson's properties should not be readonly
const person: WritablePerson = { name: 'Alice', age: 40 };
person.name = 'Bob'; // Should not error in TypeScript
person.age = 41; // Should not error in TypeScript
expect(person).toEqual({ name: 'Bob', age: 41 });
});
});
});

View File

@ -1,7 +1,7 @@
{ {
"include": ["src"], "include": ["src"],
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ESNext",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"module": "ESNext", "module": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"], "lib": ["ESNext", "DOM", "DOM.Iterable"],