Compare commits

..

21 Commits

Author SHA1 Message Date
52f64f9e78 IndexDB fixes
Some checks failed
Build / Build NPM Project (push) Successful in 45s
Build / Publish Documentation (push) Failing after 4s
Build / Tag Version (push) Successful in 8s
2025-07-28 13:09:31 -04:00
4caf0e5048 IndexDB safari fixes
Some checks failed
Build / Build NPM Project (push) Successful in 54s
Build / Publish Documentation (push) Failing after 7s
Build / Tag Version (push) Successful in 10s
2025-07-28 12:52:22 -04:00
55b871f4c1 Fixed cache loading promise when persistence storage is off
Some checks failed
Build / Build NPM Project (push) Successful in 54s
Build / Publish Documentation (push) Failing after 4s
Build / Tag Version (push) Successful in 8s
2025-07-26 11:24:04 -04:00
fb077775b6 Added format milliseconds method
Some checks failed
Build / Build NPM Project (push) Successful in 42s
Build / Publish Documentation (push) Failing after 5s
Build / Tag Version (push) Successful in 8s
2025-07-25 19:51:06 -04:00
2d2b2b8216 Added format milliseconds method
Some checks failed
Build / Build NPM Project (push) Successful in 41s
Build / Publish Documentation (push) Failing after 4s
Build / Tag Version (push) Successful in 8s
2025-07-25 19:46:21 -04:00
b473ade178 Added format milliseconds method
Some checks failed
Build / Build NPM Project (push) Successful in 58s
Build / Publish Documentation (push) Failing after 4s
Build / Tag Version (push) Successful in 8s
2025-07-25 19:42:01 -04:00
b3223661dd More efficient regex
Some checks failed
Build / Build NPM Project (push) Successful in 44s
Build / Publish Documentation (push) Failing after 4s
Build / Tag Version (push) Successful in 8s
2025-07-22 14:22:47 -04:00
c36af83918 Added search transform
Some checks failed
Build / Build NPM Project (push) Successful in 43s
Build / Publish Documentation (push) Failing after 4s
Build / Tag Version (push) Successful in 9s
2025-07-22 14:16:18 -04:00
9ec70430a5 Updated include/exclude search operands
Some checks failed
Build / Build NPM Project (push) Successful in 56s
Build / Publish Documentation (push) Failing after 5s
Build / Tag Version (push) Successful in 8s
2025-07-22 13:53:10 -04:00
397b165e55 Better search include/excludes
Some checks failed
Build / Build NPM Project (push) Successful in 43s
Build / Publish Documentation (push) Failing after 5s
Build / Tag Version (push) Successful in 8s
2025-07-21 19:11:16 -04:00
c1577c2106 Better search include/excludes
Some checks failed
Build / Build NPM Project (push) Successful in 43s
Build / Publish Documentation (push) Failing after 4s
Build / Tag Version (push) Successful in 8s
2025-07-21 18:46:27 -04:00
2b12916246 Handle more reservedIP cases
Some checks failed
Build / Build NPM Project (push) Successful in 42s
Build / Publish Documentation (push) Failing after 4s
Build / Tag Version (push) Successful in 8s
2025-07-21 18:45:11 -04:00
5efb045f22 Search include/exclude null fix
Some checks failed
Build / Build NPM Project (push) Successful in 45s
Build / Publish Documentation (push) Failing after 5s
Build / Tag Version (push) Successful in 9s
2025-07-21 18:33:15 -04:00
7119390681 Added reserved IP utility
Some checks failed
Build / Build NPM Project (push) Successful in 1m4s
Build / Publish Documentation (push) Failing after 1m2s
Build / Tag Version (push) Successful in 1m11s
2025-07-21 18:25:14 -04:00
fd95c0c697 Added includes / excludes to search
Some checks failed
Build / Build NPM Project (push) Successful in 1m4s
Build / Publish Documentation (push) Failing after 15s
Build / Tag Version (push) Successful in 23s
2025-07-21 18:15:21 -04:00
1ab8e6424b handle cache cleanup in db
Some checks failed
Build / Build NPM Project (push) Successful in 40s
Build / Publish Documentation (push) Failing after 5s
Build / Tag Version (push) Successful in 8s
2025-07-14 01:00:34 -04:00
035a1d35cb PathEventEmitter handles wildcards while prefixed
Some checks failed
Build / Build NPM Project (push) Successful in 52s
Build / Publish Documentation (push) Failing after 5s
Build / Tag Version (push) Successful in 8s
2025-07-14 00:31:11 -04:00
e78120b067 Trying to do a better job of detecting persistent caching strategy
Some checks failed
Build / Build NPM Project (push) Successful in 51s
Build / Publish Documentation (push) Failing after 5s
Build / Tag Version (push) Successful in 8s
2025-07-08 01:46:06 -04:00
71552aa243 Fixed cache save to db
Some checks failed
Build / Build NPM Project (push) Successful in 43s
Build / Publish Documentation (push) Failing after 5s
Build / Tag Version (push) Successful in 8s
2025-07-07 15:27:07 -04:00
ce3878e18b Added db safe guards
Some checks failed
Build / Build NPM Project (push) Successful in 44s
Build / Publish Documentation (push) Failing after 5s
Build / Tag Version (push) Successful in 9s
2025-07-07 14:53:14 -04:00
388a09718b Reverted ASet
Some checks failed
Build / Build NPM Project (push) Successful in 43s
Build / Publish Documentation (push) Failing after 5s
Build / Tag Version (push) Successful in 8s
2025-07-07 14:34:29 -04:00
13 changed files with 264 additions and 78 deletions

View File

@ -1,9 +1,9 @@
<html> <html>
<body> <body>
<script type="module"> <script type="module">
import {PES} from './dist/index.mjs'; import {PE} from './dist/index.mjs';
console.log(PES`storage${'Test/Test'}:d`); console.log('data/Ts:n', PE`${'data/Ts'}:n`.methods);
</script> </script>
</body> </body>
</html> </html>

View File

@ -1,6 +1,6 @@
{ {
"name": "@ztimson/utils", "name": "@ztimson/utils",
"version": "0.26.1", "version": "0.26.22",
"description": "Utility library", "description": "Utility library",
"author": "Zak Timson", "author": "Zak Timson",
"license": "MIT", "license": "MIT",

View File

@ -22,8 +22,10 @@ export class Cache<K extends string | number | symbol, T> {
[key: string | number | symbol]: CachedValue<T> | any; [key: string | number | symbol]: CachedValue<T> | any;
/** Whether cache is complete */ /** Whether cache is complete */
complete = false; complete = false;
private _loading!: Function;
/** Await initial loading */ /** Await initial loading */
loading!: Promise<void>; loading = new Promise<void>(r => this._loading = r);
/** /**
* Create new cache * Create new cache
@ -31,27 +33,26 @@ 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 = {}) {
let done!: Function;
this.loading = new Promise(r => done = r);
// Persistent storage // Persistent storage
if(this.options.persistentStorage != null) { if(this.options.persistentStorage != null) {
if(typeof this.options.persistentStorage == 'string') if(typeof this.options.persistentStorage == 'string')
this.options.persistentStorage = {storage: localStorage, key: this.options.persistentStorage}; this.options.persistentStorage = {storage: localStorage, key: this.options.persistentStorage};
if(this.options.persistentStorage?.storage?.constructor.name == 'Database') { if(this.options.persistentStorage?.storage?.database != undefined) {
(async () => { (async () => {
const persists: any = this.options.persistentStorage; const persists: any = this.options.persistentStorage;
const table: Table<any, any> = await persists.storage.createTable({name: persists.key, key: this.key}); const table: Table<any, any> = await persists.storage.createTable({name: persists.key, key: this.key});
const rows = await table.getAll(); const rows = await table.getAll();
Object.assign(this.store, rows.reduce((acc, row) => ({...acc, [this.getKey(row)]: row}), {})); Object.assign(this.store, rows.reduce((acc, row) => ({...acc, [this.getKey(row)]: row}), {}));
done(); this._loading();
})(); })();
} else if(this.options.persistentStorage?.storage?.constructor.name == 'Storage') { } else if((<any>this.options.persistentStorage?.storage)?.getItem != undefined) {
const stored = (<Storage>this.options.persistentStorage.storage).getItem(this.options.persistentStorage.key); const stored = (<Storage>this.options.persistentStorage.storage).getItem(this.options.persistentStorage.key);
if(stored != null) try { Object.assign(this.store, JSON.parse(stored)); } catch { } if(stored != null) try { Object.assign(this.store, JSON.parse(stored)); } catch { }
done(); this._loading();
} }
} else {
this._loading();
} }
// Handle index lookups // Handle index lookups
@ -77,16 +78,18 @@ export class Cache<K extends string | number | symbol, T> {
private save(key?: K) { private save(key?: K) {
const persists: {storage: any, key: string} = <any>this.options.persistentStorage; const persists: {storage: any, key: string} = <any>this.options.persistentStorage;
if(!!persists?.storage) { if(!!persists?.storage) {
if(persists.storage?.constructor.name == 'Database') { if(persists.storage?.database != undefined) {
(<Database>persists.storage).createTable({name: persists.key, key: <string>this.key}).then(table => { (<Database>persists.storage).createTable({name: persists.key, key: <string>this.key}).then(table => {
if(key) { if(key) {
table.set(key, this.get(key)); const value = this.get(key);
if(value != null) table.set(value, key);
else table.delete(key);
} else { } else {
table.clear(); table.clear();
this.all().forEach(row => table.add(row)); this.all().forEach(row => table.add(row));
} }
}); });
} else if(persists.storage?.constructor.name == 'Storage') { } else if(persists.storage?.setItem != undefined) {
persists.storage.setItem(persists.storage.key, JSONSanitize(this.all(true))); persists.storage.setItem(persists.storage.key, JSONSanitize(this.all(true)));
} }
} }

View File

@ -64,6 +64,7 @@ export function fromCsv<T = any>(csv: string, hasHeaders = true): T[] {
}); });
} }
/** /**
* Convert an array of objects to a CSV string * Convert an array of objects to a CSV string
* *

View File

@ -28,7 +28,10 @@ export class Database {
constructor(public readonly database: string, tables?: (string | TableOptions)[], public version?: number) { constructor(public readonly database: string, tables?: (string | TableOptions)[], public version?: number) {
this.connection = new Promise((resolve, reject) => { this.connection = new Promise((resolve, reject) => {
const req = indexedDB.open(this.database, this.version); let req: IDBOpenDBRequest;
try { req = indexedDB.open(this.database, this.version); }
catch (err) { return reject(err); }
this.tables = !tables ? [] : tables.map(t => { this.tables = !tables ? [] : tables.map(t => {
t = typeof t == 'object' ? t : {name: t}; t = typeof t == 'object' ? t : {name: t};
return {...t, name: t.name.toString()}; return {...t, name: t.name.toString()};
@ -37,13 +40,22 @@ export class Database {
req.onerror = () => reject(req.error); req.onerror = () => reject(req.error);
req.onsuccess = () => { req.onsuccess = () => {
const db = req.result; let db: IDBDatabase;
try { db = req.result; }
catch (err) { return reject(err); }
const existing = Array.from(db.objectStoreNames); const existing = Array.from(db.objectStoreNames);
if(!tables) this.tables = existing.map(t => { if(!tables) {
this.tables = existing.map(t => {
try {
const tx = db.transaction(t, 'readonly'); const tx = db.transaction(t, 'readonly');
const store = tx.objectStore(t); const store = tx.objectStore(t);
return {name: t, key: <string>store.keyPath}; return {name: t, key: <string>store.keyPath};
} catch {
return {name: t}; // 🛡️ fallback
}
}); });
}
const desired = new ASet((tables || []).map(t => typeof t == 'string' ? t : t.name)); const desired = new ASet((tables || []).map(t => typeof t == 'string' ? t : t.name));
if(tables && desired.symmetricDifference(new ASet(existing)).length) { if(tables && desired.symmetricDifference(new ASet(existing)).length) {
db.close(); db.close();
@ -58,7 +70,10 @@ export class Database {
req.onupgradeneeded = () => { req.onupgradeneeded = () => {
this.upgrading = true; this.upgrading = true;
const db = req.result; let db: IDBDatabase;
try { db = req.result; }
catch { return; }
try {
const existingTables = new ASet(Array.from(db.objectStoreNames)); const existingTables = new ASet(Array.from(db.objectStoreNames));
if(tables) { if(tables) {
const desired = new ASet((tables || []).map(t => typeof t == 'string' ? t : t.name)); const desired = new ASet((tables || []).map(t => typeof t == 'string' ? t : t.name));
@ -71,6 +86,7 @@ export class Database {
}); });
}); });
} }
} catch { }
}; };
}); });
} }
@ -84,14 +100,14 @@ export class Database {
if(!this.includes(table.name)) { if(!this.includes(table.name)) {
const newDb = new Database(this.database, [...this.tables, table], (this.version ?? 0) + 1); const newDb = new Database(this.database, [...this.tables, table], (this.version ?? 0) + 1);
conn.close(); conn.close();
Object.assign(this, newDb); this.connection = newDb.connection;
await this.connection; await this.connection;
Object.assign(this, newDb);
} }
return this.table<K, T>(table.name); return this.table<K, T>(table.name);
}); });
} }
async deleteTable(table: string | TableOptions): Promise<void> { async deleteTable(table: string | TableOptions): Promise<void> {
return this.schemaLock.run(async () => { return this.schemaLock.run(async () => {
if(typeof table == 'string') table = { name: table }; if(typeof table == 'string') table = { name: table };
@ -99,10 +115,10 @@ export class Database {
const conn = await this.connection; const conn = await this.connection;
const newDb = new Database(this.database, this.tables.filter(t => t.name != (<TableOptions>table).name), (this.version ?? 0) + 1); const newDb = new Database(this.database, this.tables.filter(t => t.name != (<TableOptions>table).name), (this.version ?? 0) + 1);
conn.close(); conn.close();
Object.assign(this, newDb); this.connection = newDb.connection;
await this.connection; await this.connection;
Object.assign(this, newDb);
}); });
} }
includes(name: any): boolean { includes(name: any): boolean {
@ -126,9 +142,8 @@ export class Table<K extends IDBValidKey = any, T = any> {
await this.database.waitForUpgrade(); await this.database.waitForUpgrade();
const db = await this.database.connection; const db = await this.database.connection;
const tx = db.transaction(table, readonly ? 'readonly' : 'readwrite'); const tx = db.transaction(table, readonly ? 'readonly' : 'readwrite');
const store = tx.objectStore(table);
return new Promise<R>((resolve, reject) => { return new Promise<R>((resolve, reject) => {
const request = fn(store); const request = fn(tx.objectStore(table));
request.onsuccess = () => resolve(request.result as R); request.onsuccess = () => resolve(request.result as R);
request.onerror = () => reject(request.error); request.onerror = () => reject(request.error);
}); });
@ -166,8 +181,11 @@ export class Table<K extends IDBValidKey = any, T = any> {
return this.tx(this.name, store => store.getAllKeys(), true); return this.tx(this.name, store => store.getAllKeys(), true);
} }
put(key: K, value: T): Promise<void> { put(value: T, key?: string): Promise<void> {
return this.tx(this.name, store => store.put(value, key)); return this.tx(this.name, store => {
if(store.keyPath) return store.put(value);
return store.put(value, key);
});
} }
read(): Promise<T[]>; read(): Promise<T[]>;
@ -177,8 +195,9 @@ export class Table<K extends IDBValidKey = any, T = any> {
} }
set(value: T, key?: K): Promise<void> { set(value: T, key?: K): Promise<void> {
if(!key && !(<any>value)[this.key]) return this.add(value); if(key) (<any>value)[this.key] = key;
return this.put(key || (<any>value)[this.key], value); if(!(<any>value)[this.key]) return this.add(value);
return this.put(value);
} }
update = this.set; update = this.set;

View File

@ -78,7 +78,7 @@ export class Http {
if(!this.url && !opts.url) throw new Error('URL needs to be set'); if(!this.url && !opts.url) throw new Error('URL needs to be set');
let url = opts.url?.startsWith('http') ? opts.url : (this.url || '') + (opts.url || ''); let url = opts.url?.startsWith('http') ? opts.url : (this.url || '') + (opts.url || '');
url = url.replaceAll(/([^:]\/)\/+/g, '$1'); url = url.replaceAll(/([^:]\/)\/+/g, '$1');
if(opts.fragment) url.includes('#') ? url.replace(/#.*(\?|\n)/g, (match, arg1) => `#${opts.fragment}${arg1}`) : `${url}#${opts.fragment}`; if(opts.fragment) url.includes('#') ? url.replace(/#.*([?\n])/g, (match, arg1) => `#${opts.fragment}${arg1}`) : `${url}#${opts.fragment}`;
if(opts.query) { if(opts.query) {
const q = Array.isArray(opts.query) ? opts.query : const q = Array.isArray(opts.query) ? opts.query :
Object.keys(opts.query).map(k => ({key: k, value: (<any>opts.query)[k]})) Object.keys(opts.query).map(k => ({key: k, value: (<any>opts.query)[k]}))

View File

@ -47,6 +47,17 @@ export function ipV6ToV4(ip: string) {
return ipv4; return ipv4;
} }
/**
* Check if IP is reserved, e.g. localhost, private IPs, etc.
* @param {string} ip
* @returns {boolean}
*/
export function reservedIp(ip: string): boolean {
if(ip == 'localhost' || ip == '127.0.0.1') return true;
return /\b(10\.(?:[0-9]{1,3}\.){2}[0-9]{1,3})\b|\b(172\.(?:1[6-9]|2[0-9]|3[0-1])\.(?:[0-9]{1,3}\.)[0-9]{1,3})\b|\b(192\.168\.(?:[0-9]{1,3}\.)[0-9]{1,3})\b/.test(ip);
}
/** /**
* Represents a function that listens for events and handles them accordingly. * Represents a function that listens for events and handles them accordingly.
* *

View File

@ -177,19 +177,51 @@ export function includes(target: any, values: any, allowMissing = false): boolea
} }
/** /**
* Deep check if two objects are equal * Deep check if two items are equal.
* Handles primitives, objects, arrays, functions, Date, RegExp, and circular references.
* *
* @param {any} a - first item to compare * @param {any} a - first item to compare
* @param {any} b - second item to compare * @param {any} b - second item to compare
* @param {WeakMap<object, object>} [seen] - Internal parameter to track circular references
* @returns {boolean} True if they match * @returns {boolean} True if they match
*/ */
export function isEqual(a: any, b: any): boolean { export function isEqual(a: any, b: any, seen = new WeakMap<object, object>()): boolean {
const ta = typeof a, tb = typeof b; // Simple cases
if((ta != 'object' || a == null) || (tb != 'object' || b == null)) if(a === b) return true;
return ta == 'function' && tb == 'function' ? a.toString() == b.toString() : a === b; if(a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
const keys = Object.keys(a); if(a instanceof RegExp && b instanceof RegExp) return a.source === b.source && a.flags === b.flags;
if(keys.length != Object.keys(b).length) return false;
return Object.keys(a).every(key => isEqual(a[key], b[key])); // Null checks
if(typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
if(Number.isNaN(a) && Number.isNaN(b)) return true;
if(typeof a === 'function' && typeof b === 'function') return a.toString() === b.toString()
return false;
}
// Circular references
if(seen.has(a)) return seen.get(a) === b;
seen.set(a, b);
const isArrayA = Array.isArray(a);
const isArrayB = Array.isArray(b);
// Array checks
if(isArrayA && isArrayB) {
if(a.length !== b.length) return false;
for(let i = 0; i < a.length; i++) {
if(!isEqual(a[i], b[i], seen)) return false;
}
return true;
}
if(isArrayA !== isArrayB) return false;
// Key & value deep comparison
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if(keysA.length !== keysB.length) return false;
for(const key of keysA) {
if(!Object.prototype.hasOwnProperty.call(b, key) || !isEqual(a[key], b[key], seen)) return false;
}
return true;
} }
/** /**

View File

@ -71,9 +71,12 @@ export class PathEvent {
/** List of methods */ /** List of methods */
methods!: ASet<Method>; methods!: ASet<Method>;
/** Internal cache for PathEvent instances to avoid redundant parsing */
private static pathEventCache: Map<string, PathEvent> = new Map();
/** All/Wildcard specified */ /** All/Wildcard specified */
get all(): boolean { return this.methods.has('*') } get all(): boolean { return this.methods.has('*') }
set all(v: boolean) { v ? new ASet<Method>(['*']) : this.methods.delete('*'); } set all(v: boolean) { v ? this.methods = new ASet<Method>(['*']) : this.methods.delete('*'); }
/** None specified */ /** None specified */
get none(): boolean { return this.methods.has('n') } get none(): boolean { return this.methods.has('n') }
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'); }
@ -91,10 +94,20 @@ export class PathEvent {
set delete(v: boolean) { v ? this.methods.delete('n').delete('*').add('d') : this.methods.delete('d'); } set delete(v: boolean) { v ? this.methods.delete('n').delete('*').add('d') : this.methods.delete('d'); }
constructor(e: string | PathEvent) { constructor(e: string | PathEvent) {
if(typeof e == 'object') return Object.assign(this, e); if(typeof e == 'object') {
Object.assign(this, e);
return;
}
// Check cache first
if (PathEvent.pathEventCache.has(e)) {
Object.assign(this, PathEvent.pathEventCache.get(e)!);
return;
}
let [p, scope, method] = e.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 = '*';
} }
@ -104,6 +117,14 @@ export class PathEvent {
this.fullPath = `${this.module}${this.module && this.path ? '/' : ''}${this.path}`; 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(''));
// Store in cache
PathEvent.pathEventCache.set(e, this);
}
/** Clear the cache of all PathEvents */
static clearCache(): void {
PathEvent.pathEventCache.clear();
} }
/** /**
@ -115,7 +136,7 @@ export class PathEvent {
*/ */
static combine(...paths: (string | PathEvent)[]): PathEvent { static combine(...paths: (string | PathEvent)[]): PathEvent {
let hitNone = false; let hitNone = false;
const combined = paths.map(p => new PathEvent(p)) const combined = paths.map(p => p instanceof PathEvent ? p : new PathEvent(p))
.toSorted((p1, p2) => { .toSorted((p1, p2) => {
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);
@ -124,10 +145,9 @@ export class PathEvent {
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;
acc.methods = [...acc.methods, ...p.methods]; acc.methods = new ASet([...acc.methods, ...p.methods]);
return acc; return acc;
}, <any>null); }, <any>null);
combined.methods = new ASet<Method>(combined.methods);
return combined; return combined;
} }
@ -139,11 +159,12 @@ export class PathEvent {
* @return {boolean} Whether there is any overlap * @return {boolean} Whether there is any overlap
*/ */
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 => pe instanceof PathEvent ? pe : new PathEvent(pe));
const parsedFilter = makeArray(filter).map(pe => new PathEvent(pe)); const parsedFilter = makeArray(filter).map(pe => pe instanceof PathEvent ? pe : new PathEvent(pe));
return parsedTarget.filter(t => !!parsedFilter.find(r => { return parsedTarget.filter(t => !!parsedFilter.find(r => {
const wildcard = r.fullPath == '*' || t.fullPath == '*'; const wildcard = r.fullPath == '*' || t.fullPath == '*';
const p1 = r.fullPath.slice(0, r.fullPath.indexOf('*')), p2 = t.fullPath.slice(0, t.fullPath.indexOf('*')) const p1 = r.fullPath.includes('*') ? r.fullPath.slice(0, r.fullPath.indexOf('*')) : r.fullPath;
const p2 = t.fullPath.includes('*') ? t.fullPath.slice(0, t.fullPath.indexOf('*')) : t.fullPath;
const scope = p1.startsWith(p2) || p2.startsWith(p1); const scope = p1.startsWith(p2) || p2.startsWith(p1);
const methods = r.all || t.all || r.methods.intersection(t.methods).length; const methods = r.all || t.all || r.methods.intersection(t.methods).length;
return (wildcard || scope) && methods; return (wildcard || scope) && methods;
@ -158,12 +179,13 @@ 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 parsedTarget = makeArray(target).map(pe => new PathEvent(pe)); const parsedTarget = makeArray(target).map(pe => pe instanceof PathEvent ? pe : new PathEvent(pe));
const parsedRequired = makeArray(has).map(pe => new PathEvent(pe)); const parsedRequired = makeArray(has).map(pe => pe instanceof PathEvent ? pe : new PathEvent(pe));
return !!parsedRequired.find(r => !!parsedTarget.find(t => { return !!parsedRequired.find(r => !!parsedTarget.find(t => {
const wildcard = r.fullPath == '*' || t.fullPath == '*'; const wildcard = r.fullPath == '*' || t.fullPath == '*';
const p1 = r.fullPath.slice(0, r.fullPath.indexOf('*')), p2 = t.fullPath.slice(0, t.fullPath.indexOf('*')) const p1 = r.fullPath.includes('*') ? r.fullPath.slice(0, r.fullPath.indexOf('*')) : r.fullPath;
const scope = p1.startsWith(p2); const p2 = t.fullPath.includes('*') ? t.fullPath.slice(0, t.fullPath.indexOf('*')) : t.fullPath;
const scope = p1.startsWith(p2); // Note: Original had || p2.startsWith(p1) here, but has implies target has required.
const methods = r.all || t.all || r.methods.intersection(t.methods).length; const methods = r.all || t.all || r.methods.intersection(t.methods).length;
return (wildcard || scope) && methods; return (wildcard || scope) && methods;
})); }));
@ -293,7 +315,7 @@ export class PathEventEmitter implements IPathEventEmitter{
constructor(public readonly prefix: string = '') { } constructor(public readonly prefix: string = '') { }
emit(event: Event, ...args: any[]) { emit(event: Event, ...args: any[]) {
const parsed = PE`${this.prefix}/${event}`; const parsed = event instanceof PathEvent ? event : new PathEvent(`${this.prefix}/${event}`);
this.listeners.filter(l => PathEvent.has(l[0], parsed)) this.listeners.filter(l => PathEvent.has(l[0], parsed))
.forEach(async l => l[1](parsed, ...args)); .forEach(async l => l[1](parsed, ...args));
}; };
@ -303,10 +325,13 @@ export class PathEventEmitter implements IPathEventEmitter{
} }
on(event: Event | Event[], listener: PathListener): PathUnsubscribe { on(event: Event | Event[], listener: PathListener): PathUnsubscribe {
makeArray(event).forEach(e => this.listeners.push([ makeArray(event).forEach(e => {
new PathEvent(`${this.prefix}/${e}`), if(typeof e == 'string' && e[0] == '*' && this.prefix) e = e.slice(1);
this.listeners.push([
e instanceof PathEvent ? e : new PathEvent(`${this.prefix}/${e}`),
listener listener
])); ])
});
return () => this.off(listener); return () => this.off(listener);
} }

View File

@ -13,8 +13,8 @@ export function search(rows: any[], search: string, regex?: boolean, transform:
if(!rows) return []; if(!rows) return [];
return rows.filter(r => { return rows.filter(r => {
// Empty search // Empty search
const value = transform(r);
if(!search) return true; if(!search) return true;
const value = transform(r);
// Regex search // Regex search
if(regex) { if(regex) {
return !!Object.values(value).filter((v: any) => { return !!Object.values(value).filter((v: any) => {
@ -22,7 +22,7 @@ export function search(rows: any[], search: string, regex?: boolean, transform:
catch { return false; } catch { return false; }
}).length }).length
} else { } else {
return logicTest(r, search); return logicTest(value, search);
} }
}); });
} }
@ -39,6 +39,8 @@ export function logicTest(target: object, condition: string): boolean {
case '=': case '=':
case '==': return a == b; case '==': return a == b;
case '!=': return a != b; case '!=': return a != b;
case '+=': return a?.toString().includes(b);
case '-=': return !a?.toString().includes(b);
case '>': return a > b; case '>': return a > b;
case '>=': return a >= b; case '>=': return a >= b;
case '<': return a < b; case '<': return a < b;
@ -53,7 +55,7 @@ export function logicTest(target: object, condition: string): boolean {
const and = p.split('&&').map(p => p.trim()).filter(p => !!p); const and = p.split('&&').map(p => p.trim()).filter(p => !!p);
return and.filter(p => { return and.filter(p => {
// 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(target).find(k => k.toLowerCase() == prop[1].toLowerCase()); const key = Object.keys(target).find(k => k.toLowerCase() == prop[1].toLowerCase());
return evalBoolean(dotNotation<any>(target, key || prop[1]), prop[2], JSONAttemptParse(prop[3])); return evalBoolean(dotNotation<any>(target, key || prop[1]), prop[2], JSONAttemptParse(prop[3]));

View File

@ -43,6 +43,24 @@ export function formatBytes(bytes: number, decimals = 2) {
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
} }
/**
* Convert milliseconds to human-readable duration
* @param {string} ms milliseconds
* @param {boolean} short Use unit initial instead of word
* @return {string} formated duration
*/
export function formatMs(ms: number, short = false): string {
if (isNaN(ms) || ms < 0) return "Invalid input";
const seconds = ms / 1000;
const minutes = seconds / 60;
const hours = minutes / 60;
const days = hours / 24;
if (days >= 1) return `${days.toFixed(1)} ${short ? 'd' : 'days'}`;
else if (hours >= 1) return `${hours.toFixed(1)} ${short ? 'h' : 'hours'}`;
else if (minutes >= 1) return `${minutes.toFixed(1)} ${short ? 'm' : 'minutes'}`;
else return `${seconds.toFixed(1)} ${short ? 's' : 'seconds'}`;
}
/** /**
* Extract numbers from a string & create a formated phone number: +1 (123) 456-7890 * Extract numbers from a string & create a formated phone number: +1 (123) 456-7890
* *

View File

@ -1,6 +1,10 @@
import {PathError, PathEvent, PathEventEmitter, PE, PES} from '../src'; import {PathError, PathEvent, PathEventEmitter, PE, PES} from '../src';
describe('Path Events', () => { describe('Path Events', () => {
beforeEach(() => {
PathEvent.clearCache();
});
describe('PE', () => { describe('PE', () => {
it('creates PathEvent from template string', () => { it('creates PathEvent from template string', () => {
const e = PE`users/system:cr`; const e = PE`users/system:cr`;

View File

@ -4,6 +4,7 @@ const rows = [
{id: 1, name: 'Alice', age: 30}, {id: 1, name: 'Alice', age: 30},
{id: 2, name: 'Bob', age: 24}, {id: 2, name: 'Bob', age: 24},
{id: 3, name: 'Carol', age: 30}, {id: 3, name: 'Carol', age: 30},
{id: 4, name: 'David', age: 35},
]; ];
describe('Search Utilities', () => { describe('Search Utilities', () => {
@ -37,6 +38,35 @@ describe('Search Utilities', () => {
it('returns all if search is falsy and regex enabled', () => { it('returns all if search is falsy and regex enabled', () => {
expect(search(rows, '', true)).toEqual(rows); expect(search(rows, '', true)).toEqual(rows);
}); });
it('handles regex search with special characters', () => {
expect(search(rows, '^[AC]', true)).toEqual([rows[0], rows[2]]);
});
it('ignores case when regex is applied', () => {
expect(search(rows, 'ALICE', true)).toEqual([]);
});
it('performs partial matches for properties when regex=false', () => {
expect(search(rows, 'Da')).toEqual([rows[3]]);
});
it('handles empty array input gracefully', () => {
expect(search([], 'test')).toEqual([]);
});
it('handles numeric values with comparison logic in strings', () => {
expect(search(rows, 'age < 31')).toEqual([rows[0], rows[1], rows[2]]);
});
// New test cases for `+` and `-` operators
it('filters rows using the + operator', () => {
expect(search(rows, 'name += Al')).toEqual([rows[0]]);
});
it('filters rows using the - operator', () => {
expect(search(rows, 'name -= Al')).toEqual([rows[1], rows[2], rows[3]]);
});
}); });
describe('logicTest', () => { describe('logicTest', () => {
@ -67,5 +97,46 @@ describe('Search Utilities', () => {
it('returns false for unsupported operators', () => { it('returns false for unsupported operators', () => {
expect(logicTest(obj, 'x === 10')).toBe(false); expect(logicTest(obj, 'x === 10')).toBe(false);
}); });
it('handles invalid condition strings gracefully', () => {
expect(logicTest(obj, 'invalid condition')).toBe(false);
});
it('supports numeric operations with ranges', () => {
expect(logicTest(obj, 'x > 5 && x < 15')).toBe(true);
expect(logicTest(obj, 'x > 15')).toBe(false);
});
it('handles mixed case keys gracefully', () => {
const mixedCaseObj = {TestKey: 123};
expect(logicTest(mixedCaseObj, 'TestKey == 123')).toBe(true);
expect(logicTest(mixedCaseObj, 'testkey == 123')).toBe(true);
});
it('returns false if condition operators are missing', () => {
expect(logicTest(obj, 'x 10')).toBe(false);
});
// New test cases for `+` and `-` operators
it('handles the + operator for inclusion', () => {
expect(logicTest(obj, 'name += Alpha')).toBe(true);
expect(logicTest(obj, 'name += Alp')).toBe(true);
expect(logicTest(obj, 'name += Bet')).toBe(false);
});
it('handles the - operator for exclusion', () => {
expect(logicTest(obj, 'name -= Alpha')).toBe(false);
expect(logicTest(obj, 'name -= Alp')).toBe(false);
expect(logicTest(obj, 'name -= Bet')).toBe(true);
});
it('includes partial matches correctly with +', () => {
expect(logicTest(obj, 'name += lph')).toBe(true);
});
it('excludes partial matches correctly with -', () => {
expect(logicTest(obj, 'name -= lph')).toBe(false);
expect(logicTest(obj, 'name -= xyz')).toBe(true);
});
}); });
}); });