Compare commits
	
		
			40 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 057528b0c5 | |||
| 1595aea529 | |||
| 08503de552 | |||
| a9a9b04a5a | |||
| a7b19900da | |||
| 34227e5c4b | |||
| 7e8352ed2a | |||
| 751177c981 | |||
| 5849a97c21 | |||
| fcae9e107e | |||
| 5bb5067abc | |||
| 2064880673 | |||
| fdefdf354d | |||
| 31998c01d6 | |||
| c5270fbd7e | |||
| bfe9493d23 | |||
| 1b6fe42f78 | |||
| c3d8d75ba3 | |||
| e2d756fe28 | |||
| 52f64f9e78 | |||
| 4caf0e5048 | |||
| 55b871f4c1 | |||
| fb077775b6 | |||
| 2d2b2b8216 | |||
| b473ade178 | |||
| b3223661dd | |||
| c36af83918 | |||
| 9ec70430a5 | |||
| 397b165e55 | |||
| c1577c2106 | |||
| 2b12916246 | |||
| 5efb045f22 | |||
| 7119390681 | |||
| fd95c0c697 | |||
| 1ab8e6424b | |||
| 035a1d35cb | |||
| e78120b067 | |||
| 71552aa243 | |||
| ce3878e18b | |||
| 388a09718b | 
@@ -13,3 +13,7 @@ quote_type = single
 | 
			
		||||
[*.md]
 | 
			
		||||
max_line_length = off
 | 
			
		||||
trim_trailing_whitespace = false
 | 
			
		||||
 | 
			
		||||
[*.{yaml,yml}]
 | 
			
		||||
indent_style = space
 | 
			
		||||
indent_size = 2
 | 
			
		||||
							
								
								
									
										48
									
								
								.github/workflows/build.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										48
									
								
								.github/workflows/build.yaml
									
									
									
									
										vendored
									
									
								
							@@ -48,13 +48,43 @@ jobs:
 | 
			
		||||
        uses: ztimson/actions/tag@develop
 | 
			
		||||
        with:
 | 
			
		||||
          tag: ${{env.VERSION}}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  docs:
 | 
			
		||||
    name: Publish Documentation
 | 
			
		||||
    needs: build
 | 
			
		||||
    uses: ztimson/actions/.github/workflows/docker.yaml@develop
 | 
			
		||||
    with:
 | 
			
		||||
        name: ztimson/utils
 | 
			
		||||
        repository: ${{github.server_url}}/${{github.repository}}.git
 | 
			
		||||
        pass: ${{secrets.DEPLOY_TOKEN}}
 | 
			
		||||
    name: Publish Docs
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    container: docker:dind
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Clone Repository
 | 
			
		||||
        uses: ztimson/actions/clone@develop
 | 
			
		||||
 | 
			
		||||
      - name: Build Image
 | 
			
		||||
        run: |
 | 
			
		||||
          REGISTRY=$(echo ${{github.server_url}} | sed s%http://%% | sed s%https://%%)
 | 
			
		||||
          docker build -t "$REGISTRY/${{github.repository}}:${{github.ref_name}}" .
 | 
			
		||||
 | 
			
		||||
      - name: Build Image
 | 
			
		||||
        run: |
 | 
			
		||||
          echo "CHECKSUM: ${{env.CHECKSUM}}"
 | 
			
		||||
          REGISTRY=$(echo ${{github.server_url}} | sed s%http://%% | sed s%https://%%)
 | 
			
		||||
          docker build -t "$REGISTRY/${{github.repository}}:${{github.ref_name}}" --build-arg LICENSE_KEY="${{secrets.LICENSE_KEY}}" --build-arg UPDATE_TOKEN="${{secrets.UPDATE_TOKEN}}" .
 | 
			
		||||
 | 
			
		||||
      - name: Publish Branch Tag
 | 
			
		||||
        run: |
 | 
			
		||||
          REGISTRY=$(echo ${{github.server_url}} | sed s%http://%% | sed s%https://%%)
 | 
			
		||||
          docker login -u ${{github.repository_owner}} -p ${{secrets.DEPLOY_TOKEN}} $REGISTRY
 | 
			
		||||
          docker push "$REGISTRY/${{github.repository}}:${{github.ref_name}}"
 | 
			
		||||
 | 
			
		||||
      - name: Publish Version Tag
 | 
			
		||||
        run: |
 | 
			
		||||
          if [ "${{github.ref_name}}" = "master" ]; then
 | 
			
		||||
            REGISTRY=$(echo ${{github.server_url}} | sed s%http://%% | sed s%https://%%)
 | 
			
		||||
            docker tag "$REGISTRY/${{github.repository}}:${{github.ref_name}}" "$REGISTRY/${{github.repository}}:${{env.VERSION}}"
 | 
			
		||||
            docker push "$REGISTRY/${{github.repository}}:${{env.VERSION}}"
 | 
			
		||||
          fi
 | 
			
		||||
 | 
			
		||||
      - name: Publish Latest Tag
 | 
			
		||||
        run: |
 | 
			
		||||
          if [ "${{github.ref_name}}" = "master" ]; then
 | 
			
		||||
            REGISTRY=$(echo ${{github.server_url}} | sed s%http://%% | sed s%https://%%)
 | 
			
		||||
            docker tag "$REGISTRY/${{github.repository}}:${{github.ref_name}}" "$REGISTRY/${{github.repository}}:latest"
 | 
			
		||||
            docker push "$REGISTRY/${{github.repository}}:latest"
 | 
			
		||||
          fi
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										101
									
								
								index.html
									
									
									
									
									
								
							
							
						
						
									
										101
									
								
								index.html
									
									
									
									
									
								
							@@ -1,11 +1,102 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html>
 | 
			
		||||
	<head>
 | 
			
		||||
		<title>Console Styling Playground</title>
 | 
			
		||||
	</head>
 | 
			
		||||
	<body>
 | 
			
		||||
		<script type="module">
 | 
			
		||||
			import {PathEventEmitter} from './dist/index.mjs';
 | 
			
		||||
		<h1>Open Chrome DevTools Console to see magic! (Ctrl+Shift+J or Cmd+Option+J)</h1>
 | 
			
		||||
 | 
			
		||||
			const emitter = new PathEventEmitter('data');
 | 
			
		||||
			emitter.on('*', console.log);
 | 
			
		||||
			emitter.emit('data/asd', {});
 | 
			
		||||
		<script>
 | 
			
		||||
			// Console Styling Playground
 | 
			
		||||
			class ConsoleStyler {
 | 
			
		||||
				// Basic badge with customizable colors
 | 
			
		||||
				badge(text, bgColor = '#007bff', textColor = 'white') {
 | 
			
		||||
					console.log(
 | 
			
		||||
						`%c ${text} `,
 | 
			
		||||
						`background-color: ${bgColor};
 | 
			
		||||
                 color: ${textColor};
 | 
			
		||||
                 border-radius: 12px;
 | 
			
		||||
                 padding: 2px 8px;
 | 
			
		||||
                 font-weight: bold;`
 | 
			
		||||
					);
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				// Multi-style log with different sections
 | 
			
		||||
				richLog() {
 | 
			
		||||
					console.log(
 | 
			
		||||
						'%cSystem%c Operation %cDetails',
 | 
			
		||||
						'background-color: #f0f0f0; color: black; padding: 2px 5px; border-radius: 3px;',
 | 
			
		||||
						'color: blue; font-weight: bold;',
 | 
			
		||||
						'color: green;'
 | 
			
		||||
					);
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				// Grouped logs with nested information
 | 
			
		||||
				groupedLog(title, items) {
 | 
			
		||||
					console.group(title);
 | 
			
		||||
					items.forEach(item => {
 | 
			
		||||
						console.log(item);
 | 
			
		||||
					});
 | 
			
		||||
					console.groupEnd();
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				// Table view for structured data
 | 
			
		||||
				tableLog(data) {
 | 
			
		||||
					console.table(data);
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				// Performance tracking
 | 
			
		||||
				timeTrack(label, operation) {
 | 
			
		||||
					console.time(label);
 | 
			
		||||
					operation();
 | 
			
		||||
					console.timeEnd(label);
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				// Demonstration method
 | 
			
		||||
				demo() {
 | 
			
		||||
					// Different styled badges
 | 
			
		||||
					this.badge('NEW', '#28a745');  // Green
 | 
			
		||||
					this.badge('WARNING', '#ffc107', 'black');  // Yellow
 | 
			
		||||
					this.badge('ERROR', '#dc3545');  // Red
 | 
			
		||||
					this.badge('CUSTOM', '#6f42c1');  // Purple
 | 
			
		||||
 | 
			
		||||
					// Rich multi-style log
 | 
			
		||||
					this.richLog();
 | 
			
		||||
 | 
			
		||||
					// Grouped logs
 | 
			
		||||
					this.groupedLog('User Details', [
 | 
			
		||||
						{ name: 'John Doe', age: 30, role: 'Admin' },
 | 
			
		||||
						{ name: 'Jane Smith', age: 25, role: 'User' }
 | 
			
		||||
					]);
 | 
			
		||||
 | 
			
		||||
					// Table logging
 | 
			
		||||
					this.tableLog([
 | 
			
		||||
						{ name: 'John', age: 30, active: true },
 | 
			
		||||
						{ name: 'Jane', age: 25, active: false }
 | 
			
		||||
					]);
 | 
			
		||||
 | 
			
		||||
					// Performance tracking
 | 
			
		||||
					this.timeTrack('Complex Operation', () => {
 | 
			
		||||
						let sum = 0;
 | 
			
		||||
						for(let i = 0; i < 1000000; i++) {
 | 
			
		||||
							sum += i;
 | 
			
		||||
						}
 | 
			
		||||
						console.log('Sum:', sum);
 | 
			
		||||
					});
 | 
			
		||||
 | 
			
		||||
					// Advanced: Conditional styling
 | 
			
		||||
					const logLevel = 'warn';
 | 
			
		||||
					console.log(
 | 
			
		||||
						`%c[${logLevel.toUpperCase()}]%c Detailed message`,
 | 
			
		||||
						`color: ${logLevel === 'warn' ? 'orange' : 'red'}; font-weight: bold;`,
 | 
			
		||||
						'color: black;'
 | 
			
		||||
					);
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Create instance and run demo
 | 
			
		||||
			const styler = new ConsoleStyler();
 | 
			
		||||
			styler.demo();
 | 
			
		||||
		</script>
 | 
			
		||||
	</body>
 | 
			
		||||
</html>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
	"name": "@ztimson/utils",
 | 
			
		||||
	"version": "0.26.2",
 | 
			
		||||
	"version": "0.27.9",
 | 
			
		||||
	"description": "Utility library",
 | 
			
		||||
	"author": "Zak Timson",
 | 
			
		||||
	"license": "MIT",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										208
									
								
								src/aset.ts
									
									
									
									
									
								
							
							
						
						
									
										208
									
								
								src/aset.ts
									
									
									
									
									
								
							@@ -2,16 +2,11 @@ import {isEqual} from './objects.ts';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * An array which functions as a set. It guarantees unique elements
 | 
			
		||||
 * and provides set functions for comparisons.
 | 
			
		||||
 *
 | 
			
		||||
 * This optimized version uses a Map internally for efficient lookups
 | 
			
		||||
 * while maintaining an array for ordered iteration and array-like methods.
 | 
			
		||||
 * and provides set functions for comparisons
 | 
			
		||||
 */
 | 
			
		||||
export class ASet<T> extends Array<T> {
 | 
			
		||||
	private readonly _valueMap: Map<string, T>; // Stores string representation -> actual value for quick lookups
 | 
			
		||||
 | 
			
		||||
export class ASet<T> extends Array {
 | 
			
		||||
	/** Number of elements in set */
 | 
			
		||||
	get size(): number {
 | 
			
		||||
	get size() {
 | 
			
		||||
		return this.length;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -21,236 +16,119 @@ export class ASet<T> extends Array<T> {
 | 
			
		||||
	 */
 | 
			
		||||
	constructor(elements: T[] = []) {
 | 
			
		||||
		super();
 | 
			
		||||
		this._valueMap = new Map<string, T>(); // Initialize the map
 | 
			
		||||
 | 
			
		||||
		if (Array.isArray(elements)) {
 | 
			
		||||
		if(!!elements?.['forEach'])
 | 
			
		||||
			elements.forEach(el => this.add(el));
 | 
			
		||||
	}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * Helper to generate a unique key for the map.
 | 
			
		||||
	 * This is crucial for using a Map with custom equality.
 | 
			
		||||
	 * For primitive types, `String(el)` is often sufficient.
 | 
			
		||||
	 * For objects, you'll need a robust serialization or a unique ID.
 | 
			
		||||
	 * If isEqual handles deep equality, the key generation must reflect that.
 | 
			
		||||
	 *
 | 
			
		||||
	 * @param el Element to generate a key for
 | 
			
		||||
	 * @returns A string key
 | 
			
		||||
	 */
 | 
			
		||||
	private _getKey(el: T): string {
 | 
			
		||||
		// IMPORTANT: This is a critical point for isEqual to work correctly with Map.
 | 
			
		||||
		// If isEqual performs deep comparison, _getKey must produce the same string
 | 
			
		||||
		// for deeply equal objects. JSON.stringify can work for simple objects,
 | 
			
		||||
		// but can fail for objects with circular references, functions, or undefined values.
 | 
			
		||||
		// For more complex scenarios, consider a library like 'fast-json-stable-stringify'
 | 
			
		||||
		// or a custom hashing function that respects isEqual.
 | 
			
		||||
		try {
 | 
			
		||||
			return JSON.stringify(el);
 | 
			
		||||
		} catch (e) {
 | 
			
		||||
			// Fallback for objects that cannot be stringified (e.g., circular structures)
 | 
			
		||||
			// This might lead to less optimal performance or incorrect uniqueness for such objects.
 | 
			
		||||
			// A more robust solution for complex objects might involve unique object IDs
 | 
			
		||||
			// or a custom comparison function for Map keys if JS allowed it.
 | 
			
		||||
			return String(el);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * Add elements to set if unique.
 | 
			
		||||
	 * Optimized to use the internal Map for O(1) average time lookups.
 | 
			
		||||
	 * Add elements to set if unique
 | 
			
		||||
	 * @param items
 | 
			
		||||
	 */
 | 
			
		||||
	add(...items: T[]): this {
 | 
			
		||||
		for (const item of items) {
 | 
			
		||||
			const key = this._getKey(item);
 | 
			
		||||
			if (!this._valueMap.has(key)) {
 | 
			
		||||
				// Also ensures isEqual is respected by checking against existing values
 | 
			
		||||
				let found = false;
 | 
			
		||||
				for (const existingItem of this) {
 | 
			
		||||
					if (isEqual(existingItem, item)) {
 | 
			
		||||
						found = true;
 | 
			
		||||
						break;
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				if (!found) {
 | 
			
		||||
					super.push(item); // Add to the array
 | 
			
		||||
					this._valueMap.set(key, item); // Add to the map
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	add(...items: T[]) {
 | 
			
		||||
		items.filter(el => !this.has(el)).forEach(el => this.push(el));
 | 
			
		||||
		return this;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * Remove all elements.
 | 
			
		||||
	 * Optimized to clear both the array and the map.
 | 
			
		||||
	 * Remove all elements
 | 
			
		||||
	 */
 | 
			
		||||
	clear(): this {
 | 
			
		||||
		super.splice(0, this.length);
 | 
			
		||||
		this._valueMap.clear();
 | 
			
		||||
	clear() {
 | 
			
		||||
		this.splice(0, this.length);
 | 
			
		||||
		return this;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * Delete elements from set.
 | 
			
		||||
	 * Optimized to use the internal Map for O(1) average time lookups for key existence.
 | 
			
		||||
	 * Still requires array splice which can be O(N) in worst case for shifting.
 | 
			
		||||
	 * Delete elements from set
 | 
			
		||||
	 * @param items Elements that will be deleted
 | 
			
		||||
	 */
 | 
			
		||||
	delete(...items: T[]): this {
 | 
			
		||||
		for (const item of items) {
 | 
			
		||||
			const key = this._getKey(item);
 | 
			
		||||
			if (this._valueMap.has(key)) {
 | 
			
		||||
				// Find the actual element in the array using isEqual
 | 
			
		||||
				const index = super.findIndex((el: T) => isEqual(el, item));
 | 
			
		||||
				if (index !== -1) {
 | 
			
		||||
					super.splice(index, 1); // Remove from the array
 | 
			
		||||
					this._valueMap.delete(key); // Remove from the map
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	delete(...items: T[]) {
 | 
			
		||||
		items.forEach(el => {
 | 
			
		||||
			const index = this.indexOf(el);
 | 
			
		||||
			if(index != -1) this.splice(index, 1);
 | 
			
		||||
		})
 | 
			
		||||
		return this;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * Create list of elements this set has which the comparison set does not.
 | 
			
		||||
	 * Optimized to use `has` which is now faster.
 | 
			
		||||
	 * Create list of elements this set has which the comparison set does not
 | 
			
		||||
	 * @param {ASet<T>} set Set to compare against
 | 
			
		||||
	 * @return {ASet<T>} Different elements
 | 
			
		||||
	 */
 | 
			
		||||
	difference(set: ASet<T>): ASet<T> {
 | 
			
		||||
		const result = new ASet<T>();
 | 
			
		||||
		for (const el of this) {
 | 
			
		||||
			if (!set.has(el)) {
 | 
			
		||||
				result.add(el);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		return result;
 | 
			
		||||
	difference(set: ASet<T>) {
 | 
			
		||||
		return new ASet<T>(this.filter(el => !set.has(el)));
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * Check if set includes element.
 | 
			
		||||
	 * Optimized to use the internal Map for O(1) average time lookups.
 | 
			
		||||
	 * Check if set includes element
 | 
			
		||||
	 * @param {T} el Element to look for
 | 
			
		||||
	 * @return {boolean} True if element was found, false otherwise
 | 
			
		||||
	 */
 | 
			
		||||
	has(el: T): boolean {
 | 
			
		||||
		const key = this._getKey(el);
 | 
			
		||||
		// First check map for existence. If it exists, then verify with isEqual
 | 
			
		||||
		// as the key might be generic but isEqual is precise.
 | 
			
		||||
		if (this._valueMap.has(key)) {
 | 
			
		||||
			const storedValue = this._valueMap.get(key);
 | 
			
		||||
			// This second check with isEqual is necessary if _getKey doesn't perfectly
 | 
			
		||||
			// represent the equality criteria of isEqual, which is often the case
 | 
			
		||||
			// for complex objects where JSON.stringify might produce different strings
 | 
			
		||||
			// for isEqual objects (e.g., key order in objects).
 | 
			
		||||
			// If _getKey is guaranteed to produce identical keys for isEqual objects,
 | 
			
		||||
			// this isEqual check can be simplified or removed for performance.
 | 
			
		||||
			return isEqual(storedValue, el);
 | 
			
		||||
		}
 | 
			
		||||
		return false;
 | 
			
		||||
	has(el: T) {
 | 
			
		||||
		return this.indexOf(el) != -1;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * Find index number of element, or -1 if it doesn't exist. Matches by equality not reference.
 | 
			
		||||
	 * This method still inherently needs to iterate the array to find the *index*.
 | 
			
		||||
	 * While `has` can be O(1), `indexOf` for custom equality remains O(N).
 | 
			
		||||
	 * Find index number of element, or -1 if it doesn't exist. Matches by equality not reference
 | 
			
		||||
	 *
 | 
			
		||||
	 * @param {T} search Element to find
 | 
			
		||||
	 * @param {number} fromIndex Starting index position
 | 
			
		||||
	 * @return {number} Element index number or -1 if missing
 | 
			
		||||
	 */
 | 
			
		||||
	indexOf(search: T, fromIndex?: number): number {
 | 
			
		||||
		// Can't use the map directly for index lookup, must iterate the array
 | 
			
		||||
		return super.findIndex((el: T) => isEqual(el, search), fromIndex);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * Create list of elements this set has in common with the comparison set.
 | 
			
		||||
	 * Optimized to use `has` which is now faster.
 | 
			
		||||
	 * Create list of elements this set has in common with the comparison set
 | 
			
		||||
	 * @param {ASet<T>} set Set to compare against
 | 
			
		||||
	 * @return {ASet<T>} Set of common elements
 | 
			
		||||
	 * @return {boolean} Set of common elements
 | 
			
		||||
	 */
 | 
			
		||||
	intersection(set: ASet<T>): ASet<T> {
 | 
			
		||||
		const result = new ASet<T>();
 | 
			
		||||
		// Iterate over the smaller set for efficiency
 | 
			
		||||
		const [smallerSet, largerSet] = this.size < set.size ? [this, set] : [set, this];
 | 
			
		||||
 | 
			
		||||
		for (const el of smallerSet) {
 | 
			
		||||
			if (largerSet.has(el)) {
 | 
			
		||||
				result.add(el);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		return result;
 | 
			
		||||
	intersection(set: ASet<T>) {
 | 
			
		||||
		return new ASet<T>(this.filter(el => set.has(el)));
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * Check if this set has no elements in common with the comparison set.
 | 
			
		||||
	 * Optimized to use `intersection` and check its size.
 | 
			
		||||
	 * Check if this set has no elements in common with the comparison set
 | 
			
		||||
	 * @param {ASet<T>} set Set to compare against
 | 
			
		||||
	 * @return {boolean} True if nothing in common, false otherwise
 | 
			
		||||
	 */
 | 
			
		||||
	isDisjointFrom(set: ASet<T>): boolean {
 | 
			
		||||
		return this.intersection(set).size === 0;
 | 
			
		||||
	isDisjointFrom(set: ASet<T>) {
 | 
			
		||||
		return this.intersection(set).size == 0;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * Check if all elements in this set are included in the comparison set.
 | 
			
		||||
	 * Optimized to use `has` which is now faster.
 | 
			
		||||
	 * Check if all elements in this set are included in the comparison set
 | 
			
		||||
	 * @param {ASet<T>} set Set to compare against
 | 
			
		||||
	 * @return {boolean} True if all elements are included, false otherwise
 | 
			
		||||
	 */
 | 
			
		||||
	isSubsetOf(set: ASet<T>): boolean {
 | 
			
		||||
		if (this.size > set.size) { // A larger set cannot be a subset of a smaller one
 | 
			
		||||
			return false;
 | 
			
		||||
		}
 | 
			
		||||
		for (const el of this) {
 | 
			
		||||
			if (!set.has(el)) {
 | 
			
		||||
				return false;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		return true;
 | 
			
		||||
	isSubsetOf(set: ASet<T>) {
 | 
			
		||||
		return this.findIndex(el => !set.has(el)) == -1;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * Check if all elements from comparison set are included in this set.
 | 
			
		||||
	 * Optimized to use `has` which is now faster.
 | 
			
		||||
	 * Check if all elements from comparison set are included in this set
 | 
			
		||||
	 * @param {ASet<T>} set Set to compare against
 | 
			
		||||
	 * @return {boolean} True if all elements are included, false otherwise
 | 
			
		||||
	 */
 | 
			
		||||
	isSuperset(set: ASet<T>): boolean {
 | 
			
		||||
		if (this.size < set.size) { // A smaller set cannot be a superset of a larger one
 | 
			
		||||
			return false;
 | 
			
		||||
		}
 | 
			
		||||
		for (const el of set) {
 | 
			
		||||
			if (!this.has(el)) {
 | 
			
		||||
				return false;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		return true;
 | 
			
		||||
	isSuperset(set: ASet<T>) {
 | 
			
		||||
		return set.findIndex(el => !this.has(el)) == -1;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * Create list of elements that are only in one set but not both (XOR).
 | 
			
		||||
	 * Uses optimized `difference` method.
 | 
			
		||||
	 * Create list of elements that are only in one set but not both (XOR)
 | 
			
		||||
	 * @param {ASet<T>} set Set to compare against
 | 
			
		||||
	 * @return {ASet<T>} New set of unique elements
 | 
			
		||||
	 */
 | 
			
		||||
	symmetricDifference(set: ASet<T>): ASet<T> {
 | 
			
		||||
		return new ASet<T>([...this.difference(set), ...set.difference(this)]);
 | 
			
		||||
	symmetricDifference(set: ASet<T>) {
 | 
			
		||||
		return new ASet([...this.difference(set), ...set.difference(this)]);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * Create joined list of elements included in this & the comparison set.
 | 
			
		||||
	 * Uses optimized `add` method.
 | 
			
		||||
	 * @param {ASet<T> | Array<T>} set Set to join
 | 
			
		||||
	 * Create joined list of elements included in this & the comparison set
 | 
			
		||||
	 * @param {ASet<T>} set Set join
 | 
			
		||||
	 * @return {ASet<T>} New set of both previous sets combined
 | 
			
		||||
	 */
 | 
			
		||||
	union(set: ASet<T> | Array<T>): ASet<T> {
 | 
			
		||||
		const result = new ASet<T>(this);
 | 
			
		||||
		result.add(...Array.from(set));
 | 
			
		||||
		return result;
 | 
			
		||||
	union(set: ASet<T> | Array<T>) {
 | 
			
		||||
		return new ASet([...this, ...set]);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										144
									
								
								src/cache.ts
									
									
									
									
									
								
							
							
						
						
									
										144
									
								
								src/cache.ts
									
									
									
									
									
								
							@@ -1,5 +1,6 @@
 | 
			
		||||
import {Database, Table} from './database.ts';
 | 
			
		||||
import {deepCopy, includes, JSONSanitize} from './objects.ts';
 | 
			
		||||
import {JSONSanitize} from './json.ts';
 | 
			
		||||
import {deepCopy, includes} from './objects.ts';
 | 
			
		||||
 | 
			
		||||
export type CacheOptions = {
 | 
			
		||||
	/** Delete keys automatically after x amount of seconds */
 | 
			
		||||
@@ -8,6 +9,8 @@ export type CacheOptions = {
 | 
			
		||||
	persistentStorage?: {storage: Storage | Database, key: string} | string;
 | 
			
		||||
	/** Keep or delete cached items once expired, defaults to delete */
 | 
			
		||||
	expiryPolicy?: 'delete' | 'keep';
 | 
			
		||||
	/** Least Recently Used size limit */
 | 
			
		||||
	sizeLimit?: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type CachedValue<T> = T & {_expired?: boolean};
 | 
			
		||||
@@ -16,14 +19,17 @@ export type CachedValue<T> = T & {_expired?: boolean};
 | 
			
		||||
 * 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> {
 | 
			
		||||
	private store: Record<K, T> = <any>{};
 | 
			
		||||
	private _loading!: Function;
 | 
			
		||||
	private store: Map<K, T> = new Map();
 | 
			
		||||
	private timers: Map<K, NodeJS.Timeout> = new Map();
 | 
			
		||||
	private lruOrder: K[] = [];
 | 
			
		||||
 | 
			
		||||
	/** Support index lookups */
 | 
			
		||||
	[key: string | number | symbol]: CachedValue<T> | any;
 | 
			
		||||
	/** Whether cache is complete */
 | 
			
		||||
	complete = false;
 | 
			
		||||
	/** Await initial loading */
 | 
			
		||||
	loading!: Promise<void>;
 | 
			
		||||
	loading = new Promise<void>(r => this._loading = r);
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * Create new cache
 | 
			
		||||
@@ -31,27 +37,32 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
	 * @param options
 | 
			
		||||
	 */
 | 
			
		||||
	constructor(public readonly key?: keyof T, public readonly options: CacheOptions = {}) {
 | 
			
		||||
		let done!: Function;
 | 
			
		||||
		this.loading = new Promise(r => done = r);
 | 
			
		||||
 | 
			
		||||
		// Persistent storage
 | 
			
		||||
		if(this.options.persistentStorage != null) {
 | 
			
		||||
			if(typeof this.options.persistentStorage == 'string')
 | 
			
		||||
				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 () => {
 | 
			
		||||
					const persists: any = this.options.persistentStorage;
 | 
			
		||||
					const table: Table<any, any> = await persists.storage.createTable({name: persists.key, key: this.key});
 | 
			
		||||
					const rows = await table.getAll();
 | 
			
		||||
					Object.assign(this.store, rows.reduce((acc, row) => ({...acc, [this.getKey(row)]: row}), {}));
 | 
			
		||||
					done();
 | 
			
		||||
					for(const row of rows) this.store.set(this.getKey(row), row);
 | 
			
		||||
					this._loading();
 | 
			
		||||
				})();
 | 
			
		||||
			} else if(this.options.persistentStorage?.storage?.constructor.name == 'Storage') {
 | 
			
		||||
				const stored = (<Storage>this.options.persistentStorage.storage).getItem(this.options.persistentStorage.key);
 | 
			
		||||
				if(stored != null) try { Object.assign(this.store, JSON.parse(stored)); } catch { }
 | 
			
		||||
				done();
 | 
			
		||||
			} else if((<any>this.options.persistentStorage?.storage)?.getItem != undefined) {
 | 
			
		||||
				const {storage, key} = <{storage: Storage, key: string}>this.options.persistentStorage;
 | 
			
		||||
				const stored = storage.getItem(key);
 | 
			
		||||
				if(stored != null) {
 | 
			
		||||
					try {
 | 
			
		||||
						const obj = JSON.parse(stored);
 | 
			
		||||
						for(const k of Object.keys(obj)) this.store.set(<any>k, obj[k]);
 | 
			
		||||
					} catch { /* ignore */ }
 | 
			
		||||
				}
 | 
			
		||||
				this._loading();
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			this._loading();
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Handle index lookups
 | 
			
		||||
@@ -70,35 +81,59 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
 | 
			
		||||
	private getKey(value: T): K {
 | 
			
		||||
		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];
 | 
			
		||||
		if((value as any)[this.key] === undefined) throw new Error(`${this.key.toString()} Doesn't exist on ${JSON.stringify(value, null, 2)}`);
 | 
			
		||||
		return <K>(value as any)[this.key];
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private save(key?: K) {
 | 
			
		||||
		const persists: {storage: any, key: string} = <any>this.options.persistentStorage;
 | 
			
		||||
		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 => {
 | 
			
		||||
					if(key) {
 | 
			
		||||
						table.set(key, this.get(key));
 | 
			
		||||
					if(key !== undefined) {
 | 
			
		||||
						const value = this.get(key, true);
 | 
			
		||||
						if(value != null) table.set(value, key);
 | 
			
		||||
						else table.delete(key);
 | 
			
		||||
					} else {
 | 
			
		||||
						table.clear();
 | 
			
		||||
						this.all().forEach(row => table.add(row));
 | 
			
		||||
						this.all(true).forEach(row => table.add(row));
 | 
			
		||||
					}
 | 
			
		||||
				});
 | 
			
		||||
			} else if(persists.storage?.constructor.name == 'Storage') {
 | 
			
		||||
				persists.storage.setItem(persists.storage.key, JSONSanitize(this.all(true)));
 | 
			
		||||
			} else if(persists.storage?.setItem != undefined) {
 | 
			
		||||
				const obj: Record<any, any> = {};
 | 
			
		||||
				for(const [k, v] of this.store.entries()) obj[k as any] = v;
 | 
			
		||||
				persists.storage.setItem(persists.key, JSONSanitize(obj));
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private clearTimer(key: K) {
 | 
			
		||||
		const t = this.timers.get(key);
 | 
			
		||||
		if(t) { clearTimeout(t); this.timers.delete(key); }
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	private touchLRU(key: K) {
 | 
			
		||||
		if(!this.options.sizeLimit || this.options.sizeLimit <= 0) return;
 | 
			
		||||
		const idx = this.lruOrder.indexOf(key);
 | 
			
		||||
		if(idx >= 0) this.lruOrder.splice(idx, 1);
 | 
			
		||||
		this.lruOrder.push(key);
 | 
			
		||||
		while(this.lruOrder.length > (this.options.sizeLimit || 0)) {
 | 
			
		||||
			const lru = this.lruOrder.shift();
 | 
			
		||||
			if(lru !== undefined) this.delete(lru);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * Get all cached items
 | 
			
		||||
	 * @return {T[]} Array of items
 | 
			
		||||
	 */
 | 
			
		||||
	all(expired?: boolean): CachedValue<T>[] {
 | 
			
		||||
		return deepCopy<any>(Object.values(this.store)
 | 
			
		||||
			.filter((v: any) => expired || !v._expired));
 | 
			
		||||
		const out: CachedValue<T>[] = [];
 | 
			
		||||
		for(const v of this.store.values()) {
 | 
			
		||||
			const val: any = v;
 | 
			
		||||
			if(expired || !val?._expired) out.push(deepCopy<any>(val));
 | 
			
		||||
		}
 | 
			
		||||
		return out;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
@@ -131,7 +166,10 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
	 */
 | 
			
		||||
	clear(): this {
 | 
			
		||||
		this.complete = false;
 | 
			
		||||
		this.store = <any>{};
 | 
			
		||||
		for (const [k, t] of this.timers) clearTimeout(t);
 | 
			
		||||
		this.timers.clear();
 | 
			
		||||
		this.lruOrder = [];
 | 
			
		||||
		this.store.clear();
 | 
			
		||||
		this.save();
 | 
			
		||||
		return this;
 | 
			
		||||
	}
 | 
			
		||||
@@ -141,7 +179,10 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
	 * @param {K} key Item's primary key
 | 
			
		||||
	 */
 | 
			
		||||
	delete(key: K): this {
 | 
			
		||||
		delete this.store[key];
 | 
			
		||||
		this.clearTimer(key);
 | 
			
		||||
		const idx = this.lruOrder.indexOf(key);
 | 
			
		||||
		if(idx >= 0) this.lruOrder.splice(idx, 1);
 | 
			
		||||
		this.store.delete(key);
 | 
			
		||||
		this.save(key);
 | 
			
		||||
		return this;
 | 
			
		||||
	}
 | 
			
		||||
@@ -151,8 +192,12 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
	 * @return {[K, T][]} Key-value pairs array
 | 
			
		||||
	 */
 | 
			
		||||
	entries(expired?: boolean): [K, CachedValue<T>][] {
 | 
			
		||||
		return deepCopy<any>(Object.entries(this.store)
 | 
			
		||||
			.filter((v: any) => expired || !v?._expired));
 | 
			
		||||
		const out: [K, CachedValue<T>][] = [];
 | 
			
		||||
		for(const [k, v] of this.store.entries()) {
 | 
			
		||||
			const val: any = v;
 | 
			
		||||
			if(expired || !val?._expired) out.push([k, deepCopy<any>(val)]);
 | 
			
		||||
		}
 | 
			
		||||
		return out;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
@@ -162,8 +207,12 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
	expire(key: K): this {
 | 
			
		||||
		this.complete = false;
 | 
			
		||||
		if(this.options.expiryPolicy == 'keep') {
 | 
			
		||||
			(<any>this.store[key])._expired = true;
 | 
			
		||||
			const v: any = this.store.get(key);
 | 
			
		||||
			if(v) {
 | 
			
		||||
				v._expired = true;
 | 
			
		||||
				this.store.set(key, v);
 | 
			
		||||
				this.save(key);
 | 
			
		||||
			}
 | 
			
		||||
		} else this.delete(key);
 | 
			
		||||
		return this;
 | 
			
		||||
	}
 | 
			
		||||
@@ -175,7 +224,11 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
	 * @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));
 | 
			
		||||
		for(const v of this.store.values()) {
 | 
			
		||||
			const row: any = v;
 | 
			
		||||
			if((expired || !row._expired) && includes(row, filter)) return deepCopy<any>(row);
 | 
			
		||||
		}
 | 
			
		||||
		return undefined;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
@@ -185,7 +238,10 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
	 * @return {T} Cached item
 | 
			
		||||
	 */
 | 
			
		||||
	get(key: K, expired?: boolean): CachedValue<T> | null {
 | 
			
		||||
		const cached = deepCopy<any>(this.store[key] ?? null);
 | 
			
		||||
		const raw = this.store.get(key);
 | 
			
		||||
		if(raw == null) return null;
 | 
			
		||||
		const cached: any = deepCopy<any>(raw);
 | 
			
		||||
		this.touchLRU(key);
 | 
			
		||||
		if(expired || !cached?._expired) return cached;
 | 
			
		||||
		return null;
 | 
			
		||||
	}
 | 
			
		||||
@@ -195,8 +251,12 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
	 * @return {K[]} Array of keys
 | 
			
		||||
	 */
 | 
			
		||||
	keys(expired?: boolean): K[] {
 | 
			
		||||
		return <K[]>Object.keys(this.store)
 | 
			
		||||
			.filter(k => expired || !(<any>this.store)[k]._expired);
 | 
			
		||||
		const out: K[] = [];
 | 
			
		||||
		for(const [k, v] of this.store.entries()) {
 | 
			
		||||
			const val: any = v;
 | 
			
		||||
			if(expired || !val?._expired) out.push(k);
 | 
			
		||||
		}
 | 
			
		||||
		return out;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
@@ -204,10 +264,11 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
	 * @return {Record<K, T>}
 | 
			
		||||
	 */
 | 
			
		||||
	map(expired?: boolean): Record<K, CachedValue<T>> {
 | 
			
		||||
		const copy: any = deepCopy(this.store);
 | 
			
		||||
		if(!expired) Object.keys(copy).forEach(k => {
 | 
			
		||||
			if(copy[k]._expired) delete copy[k]
 | 
			
		||||
		});
 | 
			
		||||
		const copy: any = {};
 | 
			
		||||
		for(const [k, v] of this.store.entries()) {
 | 
			
		||||
			const val: any = v;
 | 
			
		||||
			if(expired || !val?._expired) copy[k as any] = deepCopy<any>(val);
 | 
			
		||||
		}
 | 
			
		||||
		return copy;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -220,12 +281,17 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
	 */
 | 
			
		||||
	set(key: K, value: T, ttl = this.options.ttl): this {
 | 
			
		||||
		if(this.options.expiryPolicy == 'keep') delete (<any>value)._expired;
 | 
			
		||||
		this.store[key] = value;
 | 
			
		||||
		this.clearTimer(key);
 | 
			
		||||
		this.store.set(key, value);
 | 
			
		||||
		this.touchLRU(key);
 | 
			
		||||
		this.save(key);
 | 
			
		||||
		if(ttl) setTimeout(() => {
 | 
			
		||||
		if(ttl) {
 | 
			
		||||
			const t = setTimeout(() => {
 | 
			
		||||
				this.expire(key);
 | 
			
		||||
				this.save(key);
 | 
			
		||||
			}, (ttl || 0) * 1000);
 | 
			
		||||
			this.timers.set(key, t);
 | 
			
		||||
		}
 | 
			
		||||
		return this;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -233,5 +299,5 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
	 * Get all cached items
 | 
			
		||||
	 * @return {T[]} Array of items
 | 
			
		||||
	 */
 | 
			
		||||
	values = this.all();
 | 
			
		||||
	values = this.all
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,7 @@
 | 
			
		||||
import {makeArray} from './array.ts';
 | 
			
		||||
import {ASet} from './aset.ts';
 | 
			
		||||
import {dotNotation, flattenObj, JSONSanitize} from './objects.ts';
 | 
			
		||||
import {JSONSanitize} from './json.ts';
 | 
			
		||||
import {dotNotation, flattenObj} from './objects.ts';
 | 
			
		||||
import {LETTER_LIST} from './string.ts';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 
 | 
			
		||||
@@ -14,8 +14,8 @@ class AsyncLock {
 | 
			
		||||
		const res = this.p.then(fn, fn);
 | 
			
		||||
		this.p = res.then(() => {}, () => {});
 | 
			
		||||
		return res;
 | 
			
		||||
}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class Database {
 | 
			
		||||
	private schemaLock = new AsyncLock();
 | 
			
		||||
@@ -28,7 +28,10 @@ export class Database {
 | 
			
		||||
 | 
			
		||||
	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);
 | 
			
		||||
			let req: IDBOpenDBRequest;
 | 
			
		||||
			try { req = indexedDB.open(this.database, this.version); }
 | 
			
		||||
			catch (err) { return reject(err); }
 | 
			
		||||
 | 
			
		||||
			this.tables = !tables ? [] : tables.map(t => {
 | 
			
		||||
				t = typeof t == 'object' ? t : {name: t};
 | 
			
		||||
				return {...t, name: t.name.toString()};
 | 
			
		||||
@@ -37,13 +40,22 @@ export class Database {
 | 
			
		||||
			req.onerror = () => reject(req.error);
 | 
			
		||||
 | 
			
		||||
			req.onsuccess = () => {
 | 
			
		||||
				const db = req.result;
 | 
			
		||||
				let db: IDBDatabase;
 | 
			
		||||
				try { db = req.result; }
 | 
			
		||||
				catch (err) { return reject(err); }
 | 
			
		||||
 | 
			
		||||
				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 store = tx.objectStore(t);
 | 
			
		||||
							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));
 | 
			
		||||
				if(tables && desired.symmetricDifference(new ASet(existing)).length) {
 | 
			
		||||
					db.close();
 | 
			
		||||
@@ -58,7 +70,10 @@ export class Database {
 | 
			
		||||
 | 
			
		||||
			req.onupgradeneeded = () => {
 | 
			
		||||
				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));
 | 
			
		||||
					if(tables) {
 | 
			
		||||
						const desired = new ASet((tables || []).map(t => typeof t == 'string' ? t : t.name));
 | 
			
		||||
@@ -71,6 +86,7 @@ export class Database {
 | 
			
		||||
							});
 | 
			
		||||
						});
 | 
			
		||||
					}
 | 
			
		||||
				} catch { }
 | 
			
		||||
			};
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
@@ -79,30 +95,30 @@ export class Database {
 | 
			
		||||
 | 
			
		||||
	async createTable<K extends IDBValidKey = any, T = any>(table: string | TableOptions): Promise<Table<K, T>> {
 | 
			
		||||
		return this.schemaLock.run(async () => {
 | 
			
		||||
			if (typeof table == 'string') table = { name: table };
 | 
			
		||||
			if(typeof table == 'string') table = { name: table };
 | 
			
		||||
			const conn = await this.connection;
 | 
			
		||||
			if (!this.includes(table.name)) {
 | 
			
		||||
			if(!this.includes(table.name)) {
 | 
			
		||||
				const newDb = new Database(this.database, [...this.tables, table], (this.version ?? 0) + 1);
 | 
			
		||||
				conn.close();
 | 
			
		||||
				Object.assign(this, newDb);
 | 
			
		||||
				this.connection = newDb.connection;
 | 
			
		||||
				await this.connection;
 | 
			
		||||
				Object.assign(this, newDb);
 | 
			
		||||
			}
 | 
			
		||||
			return this.table<K, T>(table.name);
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
	async deleteTable(table: string | TableOptions): Promise<void> {
 | 
			
		||||
		return this.schemaLock.run(async () => {
 | 
			
		||||
			if (typeof table == 'string') table = { name: table };
 | 
			
		||||
			if (!this.includes(table.name)) return;
 | 
			
		||||
			if(typeof table == 'string') table = { name: table };
 | 
			
		||||
			if(!this.includes(table.name)) return;
 | 
			
		||||
			const conn = await this.connection;
 | 
			
		||||
			const newDb = new Database(this.database, this.tables.filter(t => t.name != (<TableOptions>table).name), (this.version ?? 0) + 1);
 | 
			
		||||
			conn.close();
 | 
			
		||||
			Object.assign(this, newDb);
 | 
			
		||||
			this.connection = newDb.connection;
 | 
			
		||||
			await this.connection;
 | 
			
		||||
			Object.assign(this, newDb);
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	includes(name: any): boolean {
 | 
			
		||||
@@ -126,9 +142,8 @@ export class Table<K extends IDBValidKey = any, T = any> {
 | 
			
		||||
		await this.database.waitForUpgrade();
 | 
			
		||||
		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);
 | 
			
		||||
			const request = fn(tx.objectStore(table));
 | 
			
		||||
			request.onsuccess = () => resolve(request.result as R);
 | 
			
		||||
			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);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	put(key: K, value: T): Promise<void> {
 | 
			
		||||
		return this.tx(this.name, store => store.put(value, key));
 | 
			
		||||
	put(value: T, key?: string): Promise<void> {
 | 
			
		||||
		return this.tx(this.name, store => {
 | 
			
		||||
			if(store.keyPath) return store.put(value);
 | 
			
		||||
			return store.put(value, key);
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	read(): Promise<T[]>;
 | 
			
		||||
@@ -177,8 +195,9 @@ export class Table<K extends IDBValidKey = any, T = any> {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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);
 | 
			
		||||
		if(key) (<any>value)[this.key] = key;
 | 
			
		||||
		if(!(<any>value)[this.key]) return this.add(value);
 | 
			
		||||
		return this.put(value);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	update = this.set;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
import {makeArray} from './array.ts';
 | 
			
		||||
import {JSONAttemptParse} from './objects.ts';
 | 
			
		||||
import {JSONAttemptParse} from './json.ts';
 | 
			
		||||
import {PromiseProgress} from './promise-progress';
 | 
			
		||||
import {formatDate} from './time.ts';
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -78,7 +78,7 @@ export class Http {
 | 
			
		||||
		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 || '');
 | 
			
		||||
		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) {
 | 
			
		||||
			const q = Array.isArray(opts.query) ? opts.query :
 | 
			
		||||
				Object.keys(opts.query).map(k => ({key: k, value: (<any>opts.query)[k]}))
 | 
			
		||||
 
 | 
			
		||||
@@ -9,6 +9,7 @@ export * from './files';
 | 
			
		||||
export * from './emitter';
 | 
			
		||||
export * from './errors';
 | 
			
		||||
export * from './http';
 | 
			
		||||
export * from './json';
 | 
			
		||||
export * from './jwt';
 | 
			
		||||
export * from './logger';
 | 
			
		||||
export * from './math';
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										40
									
								
								src/json.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/json.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,40 @@
 | 
			
		||||
/**
 | 
			
		||||
 * Parse JSON but return the original string if it fails
 | 
			
		||||
 *
 | 
			
		||||
 * @param {any} json JSON string to parse
 | 
			
		||||
 * @param fallback Fallback value if unable to parse, defaults to original string
 | 
			
		||||
 * @return {string | T} Object if successful, original string otherwise
 | 
			
		||||
 */
 | 
			
		||||
export function JSONAttemptParse<T1, T2>(json: T2, fallback?: T1): T1 | T2 {
 | 
			
		||||
	try { return JSON.parse(<any>json); }
 | 
			
		||||
	catch { return fallback ?? json; }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Stringifies objects & skips primitives
 | 
			
		||||
 *
 | 
			
		||||
 * @param {any} obj Object to convert to serializable value
 | 
			
		||||
 * @return {string | T} Serialized value
 | 
			
		||||
 */
 | 
			
		||||
export function JSONSerialize<T1>(obj: T1): T1 | string {
 | 
			
		||||
	if(typeof obj == 'object' && obj != null) return JSONSanitize(obj);
 | 
			
		||||
	return obj;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Convert an object to a JSON string avoiding any circular references.
 | 
			
		||||
 *
 | 
			
		||||
 * @param obj Object to convert to JSON
 | 
			
		||||
 * @param {number} space Format the JSON with spaces
 | 
			
		||||
 * @return {string} JSON string
 | 
			
		||||
 */
 | 
			
		||||
export function JSONSanitize(obj: any, space?: number): string {
 | 
			
		||||
	const cache: any[] = [];
 | 
			
		||||
	return JSON.stringify(obj, (key, value) => {
 | 
			
		||||
		if(typeof value === 'object' && value !== null) {
 | 
			
		||||
			if(cache.includes(value)) return '[Circular]';
 | 
			
		||||
			cache.push(value);
 | 
			
		||||
		}
 | 
			
		||||
		return value;
 | 
			
		||||
	}, space);
 | 
			
		||||
}
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
import {JSONAttemptParse} from './objects.ts';
 | 
			
		||||
import {JSONAttemptParse} from './json.ts';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Creates a JSON Web Token (JWT) using the provided payload.
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
import {TypedEmitter, TypedEvents} from './emitter';
 | 
			
		||||
import {JSONSanitize} from './objects.ts';
 | 
			
		||||
import {JSONSanitize} from './json.ts';
 | 
			
		||||
 | 
			
		||||
export const CliEffects = {
 | 
			
		||||
	CLEAR: "\x1b[0m",
 | 
			
		||||
@@ -42,6 +42,8 @@ export const CliBackground = {
 | 
			
		||||
	GREY: "\x1b[100m",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type LogLevels = 'debug' | 'log' | 'info' | 'warn' | 'error';
 | 
			
		||||
 | 
			
		||||
export enum LOG_LEVEL {
 | 
			
		||||
	ERROR = 0,
 | 
			
		||||
	WARN = 1,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										14
									
								
								src/math.ts
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								src/math.ts
									
									
									
									
									
								
							@@ -48,3 +48,17 @@ export function fracToDec(frac: string) {
 | 
			
		||||
	split = (<string>split.pop()).split('/');
 | 
			
		||||
	return whole + (Number(split[0]) / Number(split[1]));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Add a suffix to a number:
 | 
			
		||||
 * 1 = 1st
 | 
			
		||||
 * 2 = 2nd
 | 
			
		||||
 * 3 = 3rd
 | 
			
		||||
 * N = Nth
 | 
			
		||||
 * @param {number} n
 | 
			
		||||
 * @returns {string}
 | 
			
		||||
 */
 | 
			
		||||
export function numSuffix(n: number): string {
 | 
			
		||||
	const s = ['th', 'st', 'nd', 'rd'], v = n % 100;
 | 
			
		||||
	return `${n}${s[(v - 20) % 10] || s[v] || s[0]}`;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										54
									
								
								src/misc.ts
									
									
									
									
									
								
							
							
						
						
									
										54
									
								
								src/misc.ts
									
									
									
									
									
								
							@@ -1,9 +1,50 @@
 | 
			
		||||
import {LogLevels} from './logger.ts';
 | 
			
		||||
import {PathEvent} from './path-events.ts';
 | 
			
		||||
import {md5} from './string';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Compare version numbers
 | 
			
		||||
 * @param {string} target
 | 
			
		||||
 * @param {string} vs
 | 
			
		||||
 * @return {number} -1 = target is lower, 0 = equal, 1 = higher
 | 
			
		||||
 */
 | 
			
		||||
export function compareVersions(target: string, vs: string): -1 | 0 | 1 {
 | 
			
		||||
	const [tMajor, tMinor, tPatch] = target.split('.').map(v => +v.replace(/[^0-9]/g, ''));
 | 
			
		||||
	const [vMajor, vMinor, vPatch] = vs.split('.').map(v => +v.replace(/[^0-9]/g, ''));
 | 
			
		||||
	return (tMajor > vMajor || tMinor > vMinor || tPatch > vPatch) ? 1 :
 | 
			
		||||
		(tMajor < vMajor || tMinor < vMinor || tPatch < vPatch) ? -1 : 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Create a console object to intercept logs with optional passthrough
 | 
			
		||||
 * @param {null | {debug: Function, log: Function, info: Function, warn: Function, error: Function}} out Passthrough logs, null to silence
 | 
			
		||||
 * @param {{[K in LogLevels]?: LogLevels | "none"}} map Map log levels: {log: 'debug', warn: 'error'} = Suppress debug logs, elevate warnings
 | 
			
		||||
 * @returns {{debug: Function, log: Function, info: Function, warn: Function, error: Function, stderr: string[], stdout: string[]}}
 | 
			
		||||
 */
 | 
			
		||||
export function consoleInterceptor(
 | 
			
		||||
	out: null | {debug: Function, log: Function, info: Function, warn: Function, error: Function} = console,
 | 
			
		||||
	map?: {[K in LogLevels]?: LogLevels | 'none'}
 | 
			
		||||
): {debug: Function, log: Function, info: Function, warn: Function, error: Function, output: {debug: any[], log: any[], info: any[], warn: any[], error: any[], stderr: any[], stdout: any[]}} {
 | 
			
		||||
	const logs: any = {debug: [], log: [], info: [], warn: [], error: [], stderr: [], stdout: [],}
 | 
			
		||||
	const cWrapper = (type: 'debug' | 'log' | 'info' | 'warn' | 'error') => ((...args: any[]) => {
 | 
			
		||||
		if(out) out[type](...args);
 | 
			
		||||
		logs[type].push(...args);
 | 
			
		||||
		if(type == 'error') logs.stderr.push(...args);
 | 
			
		||||
		else logs.stdout.push(...args);
 | 
			
		||||
	});
 | 
			
		||||
	return {
 | 
			
		||||
		debug: map?.debug != 'none' ? cWrapper(map?.debug || 'debug') : () => {},
 | 
			
		||||
		log: map?.log != 'none' ? cWrapper(map?.log || 'log') : () => {},
 | 
			
		||||
		info: map?.info != 'none' ? cWrapper(map?.info || 'info') : () => {},
 | 
			
		||||
		warn: map?.warn != 'none' ? cWrapper(map?.warn || 'warn') : () => {},
 | 
			
		||||
		error: map?.error != 'none' ? cWrapper(map?.error || 'error') : () => {},
 | 
			
		||||
		output: logs
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Escape any regex special characters to avoid misinterpretation during search
 | 
			
		||||
 *
 | 
			
		||||
 * @param {string} value String which should be escaped
 | 
			
		||||
 * @return {string} New escaped sequence
 | 
			
		||||
 */
 | 
			
		||||
@@ -47,6 +88,17 @@ export function ipV6ToV4(ip: string) {
 | 
			
		||||
	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.
 | 
			
		||||
 *
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,54 @@
 | 
			
		||||
import {JSONSanitize} from './json.ts';
 | 
			
		||||
 | 
			
		||||
export type Delta = { [key: string]: any | Delta | null };
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Applies deltas to `target`.
 | 
			
		||||
 * @param base starting point
 | 
			
		||||
 * @param deltas List of deltas to apply
 | 
			
		||||
 * @returns Mutated target
 | 
			
		||||
 */
 | 
			
		||||
export function applyDeltas(base: any, ...deltas: any[]): any {
 | 
			
		||||
	function applyDelta(base: any, delta: any): any {
 | 
			
		||||
		if(delta === null) return null;
 | 
			
		||||
		if(typeof base !== 'object' || base === null) return delta === undefined ? base : delta;
 | 
			
		||||
		const result = Array.isArray(base) ? [...base] : { ...base };
 | 
			
		||||
		for(const key in delta) {
 | 
			
		||||
			const val = delta[key];
 | 
			
		||||
			if(val === undefined) delete result[key];
 | 
			
		||||
			else if(typeof val === 'object' && val !== null && !Array.isArray(val)) result[key] = applyDelta(result[key], val);
 | 
			
		||||
			else result[key] = val;
 | 
			
		||||
		}
 | 
			
		||||
		return result;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for(const d of deltas.flat()) base = applyDelta(base, d?.delta ?? d);
 | 
			
		||||
	return base;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Creates a nested delta that reverts `target` back to `old`.
 | 
			
		||||
 * @param old - Original object
 | 
			
		||||
 * @param updated - Modified object
 | 
			
		||||
 * @returns New changes
 | 
			
		||||
 */
 | 
			
		||||
export function calcDelta(old: any, updated: any): any {
 | 
			
		||||
	if(updated == null) return null; // full delete
 | 
			
		||||
	const delta: any = {};
 | 
			
		||||
	const isObj = (v: any) => v && typeof v === 'object' && !Array.isArray(v);
 | 
			
		||||
	for (const key of new Set([...(old ? Object.keys(old) : []), ...(updated ? Object.keys(updated) : [])])) {
 | 
			
		||||
		const oldVal = old?.[key];
 | 
			
		||||
		const newVal = updated?.[key];
 | 
			
		||||
		if(isObj(oldVal) && isObj(newVal)) {
 | 
			
		||||
			const nested = calcDelta(oldVal, newVal);
 | 
			
		||||
			if(nested !== null && Object.keys(nested).length > 0) delta[key] = nested;
 | 
			
		||||
		} else if(JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
 | 
			
		||||
			delta[key] = newVal;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return Object.keys(delta).length === 0 ? {} : delta;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Removes any null values from an object in-place
 | 
			
		||||
 *
 | 
			
		||||
@@ -244,41 +295,12 @@ export function mixin(target: any, constructors: any[]) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Parse JSON but return the original string if it fails
 | 
			
		||||
 *
 | 
			
		||||
 * @param {any} json JSON string to parse
 | 
			
		||||
 * @return {string | T} Object if successful, original string otherwise
 | 
			
		||||
 * Run a map function on each property
 | 
			
		||||
 * @param obj Object that will be iterated
 | 
			
		||||
 * @param {(key: string, value: any) => any} fn Transformer function - receives key & value
 | 
			
		||||
 * @returns {{}}
 | 
			
		||||
 */
 | 
			
		||||
export function JSONAttemptParse<T1, T2>(json: T2): T1 | T2 {
 | 
			
		||||
	try { return JSON.parse(<any>json); }
 | 
			
		||||
	catch { return json; }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Stringifies objects & skips primitives
 | 
			
		||||
 *
 | 
			
		||||
 * @param {any} obj Object to convert to serializable value
 | 
			
		||||
 * @return {string | T} Serialized value
 | 
			
		||||
 */
 | 
			
		||||
export function JSONSerialize<T1>(obj: T1): T1 | string {
 | 
			
		||||
	if(typeof obj == 'object' && obj != null) return JSONSanitize(obj);
 | 
			
		||||
	return obj;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Convert an object to a JSON string avoiding any circular references.
 | 
			
		||||
 *
 | 
			
		||||
 * @param obj Object to convert to JSON
 | 
			
		||||
 * @param {number} space Format the JSON with spaces
 | 
			
		||||
 * @return {string} JSON string
 | 
			
		||||
 */
 | 
			
		||||
export function JSONSanitize(obj: any, space?: number): string {
 | 
			
		||||
	const cache: any[] = [];
 | 
			
		||||
	return JSON.stringify(obj, (key, value) => {
 | 
			
		||||
		if (typeof value === 'object' && value !== null) {
 | 
			
		||||
			if (cache.includes(value)) return '[Circular]';
 | 
			
		||||
			cache.push(value);
 | 
			
		||||
		}
 | 
			
		||||
		return value;
 | 
			
		||||
	}, space);
 | 
			
		||||
export function objectMap<T>(obj: any, fn: (key: string, value: any) => any): T {
 | 
			
		||||
	return <any>Object.entries(obj).map(([key, value]: [string, any]) => [key, fn(key, value)])
 | 
			
		||||
		.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -325,10 +325,13 @@ export class PathEventEmitter implements IPathEventEmitter{
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	on(event: Event | Event[], listener: PathListener): PathUnsubscribe {
 | 
			
		||||
		makeArray(event).forEach(e => this.listeners.push([
 | 
			
		||||
		makeArray(event).forEach(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
 | 
			
		||||
		]));
 | 
			
		||||
			])
 | 
			
		||||
		});
 | 
			
		||||
		return () => this.off(listener);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
import {dotNotation, JSONAttemptParse, JSONSerialize} from './objects.ts';
 | 
			
		||||
import {JSONAttemptParse, JSONSerialize} from './json.ts';
 | 
			
		||||
import {dotNotation} from './objects.ts';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Filters an array of objects based on a search term and optional regex checking.
 | 
			
		||||
@@ -13,8 +14,8 @@ export function search(rows: any[], search: string, regex?: boolean, transform:
 | 
			
		||||
	if(!rows) return [];
 | 
			
		||||
	return rows.filter(r => {
 | 
			
		||||
		// Empty search
 | 
			
		||||
		const value = transform(r);
 | 
			
		||||
		if(!search) return true;
 | 
			
		||||
		const value = transform(r);
 | 
			
		||||
		// Regex search
 | 
			
		||||
		if(regex) {
 | 
			
		||||
			return !!Object.values(value).filter((v: any) => {
 | 
			
		||||
@@ -22,7 +23,7 @@ export function search(rows: any[], search: string, regex?: boolean, transform:
 | 
			
		||||
				catch { return false; }
 | 
			
		||||
			}).length
 | 
			
		||||
		} else {
 | 
			
		||||
			return logicTest(r, search);
 | 
			
		||||
			return logicTest(value, search);
 | 
			
		||||
		}
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
@@ -39,6 +40,8 @@ export function logicTest(target: object, condition: string): boolean {
 | 
			
		||||
			case '=':
 | 
			
		||||
			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;
 | 
			
		||||
@@ -53,7 +56,7 @@ export function logicTest(target: object, condition: string): boolean {
 | 
			
		||||
		const and = p.split('&&').map(p => p.trim()).filter(p => !!p);
 | 
			
		||||
		return and.filter(p => {
 | 
			
		||||
			// Boolean operator
 | 
			
		||||
			const prop = /(\S+)\s*(==?|!=|>=|>|<=|<)\s*(\S+)/g.exec(p);
 | 
			
		||||
			const prop = /(\S+)\s*(==?|!=|\+=|-=|>=|>|<=|<)\s*(\S+)/g.exec(p);
 | 
			
		||||
			if(prop) {
 | 
			
		||||
				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]));
 | 
			
		||||
 
 | 
			
		||||
@@ -43,6 +43,24 @@ export function formatBytes(bytes: number, decimals = 2) {
 | 
			
		||||
	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
 | 
			
		||||
 *
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										261
									
								
								src/time.ts
									
									
									
									
									
								
							
							
						
						
									
										261
									
								
								src/time.ts
									
									
									
									
									
								
							@@ -20,119 +20,178 @@ export function adjustedInterval(cb: Function, ms: number) {
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function dayOfWeek(d: number): string {
 | 
			
		||||
	return ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][d];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function dayOfYear(date: Date): number {
 | 
			
		||||
	const start = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
 | 
			
		||||
	return Math.ceil((date.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Format date
 | 
			
		||||
 *
 | 
			
		||||
 * Year:
 | 
			
		||||
 * - YYYY = 2025 (4-digit year)
 | 
			
		||||
 * - YY = 25 (2-digit year)
 | 
			
		||||
 *
 | 
			
		||||
 * Month:
 | 
			
		||||
 * - MMMM = January (full month name)
 | 
			
		||||
 * - MMM = Jan (abbreviated month name)
 | 
			
		||||
 * - MM = 01 (zero-padded month number)
 | 
			
		||||
 * - M = 1 (month number)
 | 
			
		||||
 *
 | 
			
		||||
 * Day:
 | 
			
		||||
 * - DDD = 123 (day of year)
 | 
			
		||||
 * - DD = 01 (zero-padded day)
 | 
			
		||||
 * - Do = 1st (day with ordinal suffix)
 | 
			
		||||
 * - D = 1 (day number)
 | 
			
		||||
 * - dddd = Monday (full day name)
 | 
			
		||||
 * - ddd = Mon (abbreviated day name)
 | 
			
		||||
 *
 | 
			
		||||
 * Hour:
 | 
			
		||||
 * - HH = 13 (24-hour format, zero-padded)
 | 
			
		||||
 * - H = 13 (24-hour format)
 | 
			
		||||
 * - hh = 01 (12-hour format, zero-padded)
 | 
			
		||||
 * - h = 1 (12-hour format)
 | 
			
		||||
 *
 | 
			
		||||
 * Minute:
 | 
			
		||||
 * - mm = 05 (zero-padded minutes)
 | 
			
		||||
 * - m = 5 (minutes)
 | 
			
		||||
 *
 | 
			
		||||
 * Second:
 | 
			
		||||
 * - ss = 09 (zero-padded seconds)
 | 
			
		||||
 * - s = 9 (seconds)
 | 
			
		||||
 *
 | 
			
		||||
 * Millisecond:
 | 
			
		||||
 * - SSS = 123 (3-digit milliseconds)
 | 
			
		||||
 *
 | 
			
		||||
 * AM/PM:
 | 
			
		||||
 * - A = AM/PM (uppercase)
 | 
			
		||||
 * - a = am/pm (lowercase)
 | 
			
		||||
 *
 | 
			
		||||
 * Timezone:
 | 
			
		||||
 * - ZZ = +0500 (timezone offset without colon)
 | 
			
		||||
 * - Z = +05:00 (timezone offset with colon)
 | 
			
		||||
 * - z = EST (timezone abbreviation)
 | 
			
		||||
 *
 | 
			
		||||
 * @param {string} format How date string will be formatted, default: `YYYY-MM-DD H:mm A`
 | 
			
		||||
 * @param {Date | number | string} date Date or timestamp, defaults to now
 | 
			
		||||
 * @param tz Set timezone offset
 | 
			
		||||
 * @return {string} Formated date
 | 
			
		||||
 */
 | 
			
		||||
export function formatDate(format = 'YYYY-MM-DD H:mm', date: Date | number | string = new Date(), tz?: string | number): string {
 | 
			
		||||
	const timezones = [
 | 
			
		||||
		['IDLW', -12],
 | 
			
		||||
		['SST', -11],
 | 
			
		||||
		['HST', -10],
 | 
			
		||||
		['AKST', -9],
 | 
			
		||||
		['PST', -8],
 | 
			
		||||
		['MST', -7],
 | 
			
		||||
		['CST', -6],
 | 
			
		||||
		['EST', -5],
 | 
			
		||||
		['AST', -4],
 | 
			
		||||
		['BRT', -3],
 | 
			
		||||
		['MAT', -2],
 | 
			
		||||
		['AZOT', -1],
 | 
			
		||||
		['UTC', 0],
 | 
			
		||||
		['CET', 1],
 | 
			
		||||
		['EET', 2],
 | 
			
		||||
		['MSK', 3],
 | 
			
		||||
		['AST', 4],
 | 
			
		||||
		['PKT', 5],
 | 
			
		||||
		['IST', 5.5],
 | 
			
		||||
		['BST', 6],
 | 
			
		||||
		['ICT', 7],
 | 
			
		||||
		['CST', 8],
 | 
			
		||||
		['JST', 9],
 | 
			
		||||
		['AEST', 10],
 | 
			
		||||
		['SBT', 11],
 | 
			
		||||
		['FJT', 12],
 | 
			
		||||
		['TOT', 13],
 | 
			
		||||
		['LINT', 14]
 | 
			
		||||
	];
 | 
			
		||||
export function formatDate(format: string = 'YYYY-MM-DD H:mm', date: Date | number | string = new Date(), tz: string | number = 'local'): string {
 | 
			
		||||
	if (typeof date === 'number' || typeof date === 'string') date = new Date(date);
 | 
			
		||||
	if (isNaN(date.getTime())) throw new Error('Invalid date input');
 | 
			
		||||
	const numericTz = typeof tz === 'number';
 | 
			
		||||
	const localTz = tz === 'local' || (!numericTz && tz.toLowerCase?.() === 'local');
 | 
			
		||||
	const tzName = localTz ? Intl.DateTimeFormat().resolvedOptions().timeZone : numericTz ? 'UTC' : tz;
 | 
			
		||||
 | 
			
		||||
	function adjustTz(date: Date, gmt: number) {
 | 
			
		||||
		const currentOffset = date.getTimezoneOffset();
 | 
			
		||||
		const adjustedOffset = gmt * 60;
 | 
			
		||||
		return new Date(date.getTime() + (adjustedOffset + currentOffset) * 60000);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	function day(num: number): string {
 | 
			
		||||
		return ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][num] || 'Unknown';
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	function doy(date: Date) {
 | 
			
		||||
		const start = new Date(`${date.getFullYear()}-01-01 0:00:00`);
 | 
			
		||||
		return Math.ceil((date.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	function month(num: number): string {
 | 
			
		||||
		return ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][num] || 'Unknown';
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	function suffix(num: number) {
 | 
			
		||||
		if (num % 100 >= 11 && num % 100 <= 13) return `${num}th`;
 | 
			
		||||
		switch (num % 10) {
 | 
			
		||||
			case 1: return `${num}st`;
 | 
			
		||||
			case 2: return `${num}nd`;
 | 
			
		||||
			case 3: return `${num}rd`;
 | 
			
		||||
			default: return `${num}th`;
 | 
			
		||||
	if (!numericTz && tzName !== 'UTC') {
 | 
			
		||||
		try {
 | 
			
		||||
			new Intl.DateTimeFormat('en-US', { timeZone: tzName }).format();
 | 
			
		||||
		} catch {
 | 
			
		||||
			throw new Error(`Invalid timezone: ${tzName}`);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	function tzOffset(offset: number) {
 | 
			
		||||
		const hours = ~~(offset / 60);
 | 
			
		||||
		const minutes = offset % 60;
 | 
			
		||||
		return (offset > 0 ? '-' : '') + `${hours}:${minutes.toString().padStart(2, '0')}`;
 | 
			
		||||
	let zonedDate = new Date(date);
 | 
			
		||||
	let get: (fn: 'FullYear' | 'Month' | 'Date' | 'Day' | 'Hours' | 'Minutes' | 'Seconds' | 'Milliseconds') => number;
 | 
			
		||||
	const partsMap: Record<string, string> = {};
 | 
			
		||||
	if (!numericTz && tzName !== 'UTC') {
 | 
			
		||||
		const parts = new Intl.DateTimeFormat('en-US', {
 | 
			
		||||
			timeZone: tzName,
 | 
			
		||||
			year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'long',
 | 
			
		||||
			hour: '2-digit', minute: '2-digit', second: '2-digit',
 | 
			
		||||
			hour12: false
 | 
			
		||||
		}).formatToParts(date);
 | 
			
		||||
		parts.forEach(p => {
 | 
			
		||||
			partsMap[p.type] = p.value;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		const monthValue = parseInt(partsMap.month) - 1;
 | 
			
		||||
		const dayOfWeekValue = new Date(`${partsMap.year}-${partsMap.month}-${partsMap.day}`).getDay();
 | 
			
		||||
		const hourValue = parseInt(partsMap.hour);
 | 
			
		||||
 | 
			
		||||
		get = (fn: 'FullYear' | 'Month' | 'Date' | 'Day' | 'Hours' | 'Minutes' | 'Seconds' | 'Milliseconds'): number => {
 | 
			
		||||
			switch (fn) {
 | 
			
		||||
				case 'FullYear': return parseInt(partsMap.year);
 | 
			
		||||
				case 'Month': return monthValue;
 | 
			
		||||
				case 'Date': return parseInt(partsMap.day);
 | 
			
		||||
				case 'Day': return dayOfWeekValue;
 | 
			
		||||
				case 'Hours': return hourValue;
 | 
			
		||||
				case 'Minutes': return parseInt(partsMap.minute);
 | 
			
		||||
				case 'Seconds': return parseInt(partsMap.second);
 | 
			
		||||
				default: return 0;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
	if(typeof date == 'number' || typeof date == 'string' || date == null) date = new Date(date);
 | 
			
		||||
 | 
			
		||||
	// Handle timezones
 | 
			
		||||
	let t!: [string, number];
 | 
			
		||||
	if(tz == null) tz = -(date.getTimezoneOffset() / 60);
 | 
			
		||||
	t = <any>timezones.find(t => isNaN(<any>tz) ? t[0] == tz : t[1] == tz);
 | 
			
		||||
	if(!t) throw new Error(`Unknown timezone: ${tz}`);
 | 
			
		||||
	date = adjustTz(date, t[1]);
 | 
			
		||||
 | 
			
		||||
	// Token mapping
 | 
			
		||||
	const tokens: Record<string, string> = {
 | 
			
		||||
		'YYYY': date.getFullYear().toString(),
 | 
			
		||||
		'YY': date.getFullYear().toString().slice(2),
 | 
			
		||||
		'MMMM': month(date.getMonth()),
 | 
			
		||||
		'MMM': month(date.getMonth()).slice(0, 3),
 | 
			
		||||
		'MM': (date.getMonth() + 1).toString().padStart(2, '0'),
 | 
			
		||||
		'M': (date.getMonth() + 1).toString(),
 | 
			
		||||
		'DDD': doy(date).toString(),
 | 
			
		||||
		'DD': date.getDate().toString().padStart(2, '0'),
 | 
			
		||||
		'Do': suffix(date.getDate()),
 | 
			
		||||
		'D': date.getDate().toString(),
 | 
			
		||||
		'dddd': day(date.getDay()),
 | 
			
		||||
		'ddd': day(date.getDay()).slice(0, 3),
 | 
			
		||||
		'HH': date.getHours().toString().padStart(2, '0'),
 | 
			
		||||
		'H': date.getHours().toString(),
 | 
			
		||||
		'hh': (date.getHours() % 12 || 12).toString().padStart(2, '0'),
 | 
			
		||||
		'h': (date.getHours() % 12 || 12).toString(),
 | 
			
		||||
		'mm': date.getMinutes().toString().padStart(2, '0'),
 | 
			
		||||
		'm': date.getMinutes().toString(),
 | 
			
		||||
		'ss': date.getSeconds().toString().padStart(2, '0'),
 | 
			
		||||
		's': date.getSeconds().toString(),
 | 
			
		||||
		'SSS': date.getMilliseconds().toString().padStart(3, '0'),
 | 
			
		||||
		'A': date.getHours() >= 12 ? 'PM' : 'AM',
 | 
			
		||||
		'a': date.getHours() >= 12 ? 'pm' : 'am',
 | 
			
		||||
		'ZZ': tzOffset(t[1] * 60).replace(':', ''),
 | 
			
		||||
		'Z': tzOffset(t[1] * 60),
 | 
			
		||||
		'z': typeof tz == 'string' ? tz : (<any>t)[0]
 | 
			
		||||
		};
 | 
			
		||||
	} else {
 | 
			
		||||
		const offset = numericTz ? tz as number : 0;
 | 
			
		||||
		zonedDate = new Date(date.getTime() + offset * 60 * 60 * 1000);
 | 
			
		||||
		get = (fn: 'FullYear' | 'Month' | 'Date' | 'Day' | 'Hours' | 'Minutes' | 'Seconds' | 'Milliseconds'): number => zonedDate[`getUTC${fn}`]();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	function numSuffix(n: number): string {
 | 
			
		||||
		const s = ["th", "st", "nd", "rd"];
 | 
			
		||||
		const v = n % 100;
 | 
			
		||||
		return n + (s[(v - 20) % 10] || s[v] || s[0]);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	function getTZOffset(): string {
 | 
			
		||||
		if (numericTz) {
 | 
			
		||||
			const total = (tz as number) * 60;
 | 
			
		||||
			const hours = Math.floor(Math.abs(total) / 60);
 | 
			
		||||
			const mins = Math.abs(total) % 60;
 | 
			
		||||
			return `${tz >= 0 ? '+' : '-'}${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
 | 
			
		||||
		}
 | 
			
		||||
		try {
 | 
			
		||||
			const offset = new Intl.DateTimeFormat('en-US', {timeZone: tzName, timeZoneName: 'longOffset', hour: '2-digit', minute: '2-digit',})
 | 
			
		||||
				.formatToParts(<Date>date).find(p => p.type === 'timeZoneName')?.value.match(/([+-]\d{2}:\d{2})/)?.[1];
 | 
			
		||||
			if (offset) return offset;
 | 
			
		||||
		} catch {}
 | 
			
		||||
		return '+00:00';
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	function getTZAbbr(): string {
 | 
			
		||||
		if (numericTz && tz === 0) return 'UTC';
 | 
			
		||||
		try {
 | 
			
		||||
			return new Intl.DateTimeFormat('en-US', { timeZone: tzName, timeZoneName: 'short' })
 | 
			
		||||
				.formatToParts(<Date>date).find(p => p.type === 'timeZoneName')?.value || '';
 | 
			
		||||
		} catch {
 | 
			
		||||
			return tzName;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const tokens: Record<string, string> = {
 | 
			
		||||
		YYYY: get('FullYear').toString(),
 | 
			
		||||
		YY: get('FullYear').toString().slice(2),
 | 
			
		||||
		MMMM: month(get('Month')),
 | 
			
		||||
		MMM: month(get('Month')).slice(0, 3),
 | 
			
		||||
		MM: (get('Month') + 1).toString().padStart(2, '0'),
 | 
			
		||||
		M: (get('Month') + 1).toString(),
 | 
			
		||||
		DDD: dayOfYear(zonedDate).toString(),
 | 
			
		||||
		DD: get('Date').toString().padStart(2, '0'),
 | 
			
		||||
		Do: numSuffix(get('Date')),
 | 
			
		||||
		D: get('Date').toString(),
 | 
			
		||||
		dddd: dayOfWeek(get('Day')),
 | 
			
		||||
		ddd: dayOfWeek(get('Day')).slice(0, 3),
 | 
			
		||||
		HH: get('Hours').toString().padStart(2, '0'),
 | 
			
		||||
		H: get('Hours').toString(),
 | 
			
		||||
		hh: (get('Hours') % 12 || 12).toString().padStart(2, '0'),
 | 
			
		||||
		h: (get('Hours') % 12 || 12).toString(),
 | 
			
		||||
		mm: get('Minutes').toString().padStart(2, '0'),
 | 
			
		||||
		m: get('Minutes').toString(),
 | 
			
		||||
		ss: get('Seconds').toString().padStart(2, '0'),
 | 
			
		||||
		s: get('Seconds').toString(),
 | 
			
		||||
		SSS: (zonedDate[`getUTC${'Milliseconds'}`]()).toString().padStart(3, '0'),
 | 
			
		||||
		A: get('Hours') >= 12 ? 'PM' : 'AM',
 | 
			
		||||
		a: get('Hours') >= 12 ? 'pm' : 'am',
 | 
			
		||||
		ZZ: getTZOffset().replace(':', ''),
 | 
			
		||||
		Z: getTZOffset(),
 | 
			
		||||
		z: getTZAbbr(),
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	return format.replace(/YYYY|YY|MMMM|MMM|MM|M|DDD|DD|Do|D|dddd|ddd|HH|H|hh|h|mm|m|ss|s|SSS|A|a|ZZ|Z|z/g, token => tokens[token]);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -148,6 +207,10 @@ export function instantInterval(fn: () => any, interval: number) {
 | 
			
		||||
	return setInterval(fn, interval);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function month(m: number): string {
 | 
			
		||||
	return ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][m];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Use in conjunction with `await` to pause an async script
 | 
			
		||||
 *
 | 
			
		||||
 
 | 
			
		||||
@@ -3,14 +3,16 @@ import { Cache } from '../src';
 | 
			
		||||
describe('Cache', () => {
 | 
			
		||||
	type TestItem = { id: string; value: string };
 | 
			
		||||
 | 
			
		||||
	let cache: Cache<string, TestItem>;
 | 
			
		||||
	let cache: Cache<string | symbol, TestItem>;
 | 
			
		||||
	let storageMock: Storage;
 | 
			
		||||
	let storageGetItemSpy: jest.SpyInstance;
 | 
			
		||||
	let storageSetItemSpy: jest.SpyInstance;
 | 
			
		||||
 | 
			
		||||
	beforeEach(() => {
 | 
			
		||||
		jest.useFakeTimers();
 | 
			
		||||
 | 
			
		||||
		storageMock = {
 | 
			
		||||
			constructor: {name: 'Storage' as any},
 | 
			
		||||
			constructor: { name: 'Storage' as any },
 | 
			
		||||
			getItem: jest.fn(),
 | 
			
		||||
			setItem: jest.fn(),
 | 
			
		||||
			removeItem: jest.fn(),
 | 
			
		||||
@@ -23,11 +25,15 @@ describe('Cache', () => {
 | 
			
		||||
		storageGetItemSpy = jest.spyOn(storageMock, 'getItem');
 | 
			
		||||
		storageSetItemSpy = jest.spyOn(storageMock, 'setItem');
 | 
			
		||||
 | 
			
		||||
		cache = new Cache<string, TestItem>('id', {
 | 
			
		||||
		cache = new Cache<string | symbol, TestItem>('id', {
 | 
			
		||||
			persistentStorage: { storage: storageMock, key: 'cache' },
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		jest.clearAllMocks();
 | 
			
		||||
		jest.useFakeTimers();
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	afterEach(() => {
 | 
			
		||||
		jest.useRealTimers();
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	it('adds and gets an item', () => {
 | 
			
		||||
@@ -58,10 +64,12 @@ describe('Cache', () => {
 | 
			
		||||
		expect(storageSetItemSpy).toHaveBeenCalled();
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	it('clears the cache', () => {
 | 
			
		||||
		cache.add({ id: '1', value: 'test' });
 | 
			
		||||
	it('clears the cache & cancels TTL timers', () => {
 | 
			
		||||
		cache.set('1', { id: '1', value: 'x' }, 1);
 | 
			
		||||
		cache.clear();
 | 
			
		||||
		expect(cache.get('1')).toBeNull();
 | 
			
		||||
		// Even after timers advance, nothing should appear or throw
 | 
			
		||||
		jest.advanceTimersByTime(1500);
 | 
			
		||||
		expect(cache.get('1', true)).toBeNull();
 | 
			
		||||
		expect(cache.complete).toBe(false);
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
@@ -75,7 +83,7 @@ describe('Cache', () => {
 | 
			
		||||
		expect(cache.complete).toBe(true);
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	it('returns correct keys, entries, and map', () => {
 | 
			
		||||
	it('returns correct keys, entries, map, and values()', () => {
 | 
			
		||||
		cache.add({ id: 'x', value: 'foo' });
 | 
			
		||||
		cache.add({ id: 'y', value: 'bar' });
 | 
			
		||||
		expect(cache.keys().sort()).toEqual(['x', 'y']);
 | 
			
		||||
@@ -83,6 +91,8 @@ describe('Cache', () => {
 | 
			
		||||
		const m = cache.map();
 | 
			
		||||
		expect(Object.keys(m)).toEqual(expect.arrayContaining(['x', 'y']));
 | 
			
		||||
		expect(m['x'].value).toBe('foo');
 | 
			
		||||
		// CHANGE: values() was a snapshot field before; now it’s a method
 | 
			
		||||
		expect(cache.values().map(v => v.id).sort()).toEqual(['x', 'y']);
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	it('persists and restores from storage', () => {
 | 
			
		||||
@@ -93,6 +103,8 @@ describe('Cache', () => {
 | 
			
		||||
			persistentStorage: { storage: storageMock, key: 'cache' },
 | 
			
		||||
		});
 | 
			
		||||
		expect(c.get('z')).toEqual({ id: 'z', value: 'from-storage' });
 | 
			
		||||
		// ensure it used the right storage key
 | 
			
		||||
		expect(storageGetItemSpy).toHaveBeenCalledWith('cache');
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	it('expiryPolicy "delete" removes expired items completely', () => {
 | 
			
		||||
@@ -112,20 +124,71 @@ describe('Cache', () => {
 | 
			
		||||
		expect(val && val._expired).toBe(true);
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Uncomment and adapt this test if TTL/expiry timers are supported by your implementation
 | 
			
		||||
	// it('expires and deletes items after TTL', () => {
 | 
			
		||||
	//   jest.useFakeTimers();
 | 
			
		||||
	//   cache = new Cache<string, TestItem>('id', { ttl: 0.01 });
 | 
			
		||||
	//   cache.add({ id: 'ttl1', value: 'temp' });
 | 
			
		||||
	//   jest.advanceTimersByTime(100);
 | 
			
		||||
	//   expect(cache.get('ttl1')).toBeNull();
 | 
			
		||||
	// });
 | 
			
		||||
	it('TTL expiration deletes by default', () => {
 | 
			
		||||
		cache.add({ id: 'ttl1', value: 'tick' }, 1);
 | 
			
		||||
		jest.advanceTimersByTime(999);
 | 
			
		||||
		expect(cache.get('ttl1')).toEqual({ id: 'ttl1', value: 'tick' });
 | 
			
		||||
		jest.advanceTimersByTime(2);
 | 
			
		||||
		expect(cache.get('ttl1')).toBeNull();
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	// Edge: add error handling test
 | 
			
		||||
	it('throws if instantiating with invalid key property', () => {
 | 
			
		||||
		expect(() => {
 | 
			
		||||
			const invalid = new Cache<'string', TestItem>('id');
 | 
			
		||||
			// try invalid.add({id: 'z', value: 'fail'}) if needed
 | 
			
		||||
		}).not.toThrow();
 | 
			
		||||
	it('TTL overwrite cancels previous timer', () => {
 | 
			
		||||
		cache.add({ id: 'ow', value: 'v1' }, 1);
 | 
			
		||||
		jest.advanceTimersByTime(500);
 | 
			
		||||
		cache.add({ id: 'ow', value: 'v2' }, 1);
 | 
			
		||||
		jest.advanceTimersByTime(600);
 | 
			
		||||
		expect(cache.get('ow')).toEqual({ id: 'ow', value: 'v2' });
 | 
			
		||||
		jest.advanceTimersByTime(500);
 | 
			
		||||
		expect(cache.get('ow')).toBeNull();
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	it('find() returns first match respecting expired flag', () => {
 | 
			
		||||
		cache.options.expiryPolicy = 'keep';
 | 
			
		||||
		cache.add({ id: 'f1', value: 'hello' });
 | 
			
		||||
		cache.add({ id: 'f2', value: 'world' });
 | 
			
		||||
		cache.expire('f1');
 | 
			
		||||
		expect(cache.find({ value: 'hello' })).toBeUndefined();
 | 
			
		||||
		expect(cache.find({ value: 'hello' }, true)).toEqual({ id: 'f1', value: 'hello', _expired: true });
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	it('symbol keys are supported when using set/get directly', () => {
 | 
			
		||||
		const s = Symbol('k');
 | 
			
		||||
		// NOTE: can’t use add() without a primary key; set works fine
 | 
			
		||||
		cache.set(s, { id: 'sym', value: 'ok' });
 | 
			
		||||
		expect(cache.get(s as any)).toEqual({ id: 'sym', value: 'ok' });
 | 
			
		||||
		// ensure keys() includes the symbol
 | 
			
		||||
		const keys = cache.keys(true);
 | 
			
		||||
		expect(keys.includes(s as any)).toBe(true);
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	it('LRU: evicts least-recently-used when over capacity', () => {
 | 
			
		||||
		const lruCache = new Cache<string, TestItem>('id', {
 | 
			
		||||
			sizeLimit: 2,
 | 
			
		||||
			persistentStorage: { storage: storageMock, key: 'cache' },
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		lruCache.add({ id: 'A', value: '1' });
 | 
			
		||||
		lruCache.add({ id: 'B', value: '2' });
 | 
			
		||||
		// touch A to make it MRU
 | 
			
		||||
		expect(lruCache.get('A')).toEqual({ id: 'A', value: '1' });
 | 
			
		||||
		// add C → should evict least-recently-used (B)
 | 
			
		||||
		lruCache.add({ id: 'C', value: '3' });
 | 
			
		||||
 | 
			
		||||
		expect(lruCache.get('B')).toBeNull();
 | 
			
		||||
		expect(lruCache.get('A')).toEqual({ id: 'A', value: '1' });
 | 
			
		||||
		expect(lruCache.get('C')).toEqual({ id: 'C', value: '3' });
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	it('delete() removes from LRU and cancels timer', () => {
 | 
			
		||||
		const lruCache = new Cache<string, TestItem>('id', { sizeLimit: 2 });
 | 
			
		||||
		lruCache.set('A', { id: 'A', value: '1' }, 1);
 | 
			
		||||
		lruCache.delete('A');
 | 
			
		||||
		jest.advanceTimersByTime(1200);
 | 
			
		||||
		expect(lruCache.get('A', true)).toBeNull();
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	it('throws when adding item missing the primary key', () => {
 | 
			
		||||
		const c = new Cache<'id', any>('id');
 | 
			
		||||
		expect(() => c.add({ nope: 'missing' } as any)).toThrow(/Doesn't exist/);
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										33
									
								
								tests/json.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								tests/json.spec.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,33 @@
 | 
			
		||||
import {JSONAttemptParse, JSONSanitize, JSONSerialize} from '../src';
 | 
			
		||||
 | 
			
		||||
describe('JSON Utilities', () => {
 | 
			
		||||
	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();
 | 
			
		||||
		});
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
@@ -1,6 +1,5 @@
 | 
			
		||||
import {
 | 
			
		||||
	clean, deepCopy, deepMerge, dotNotation, encodeQuery, flattenObj, formData, includes, isEqual, mixin,
 | 
			
		||||
	JSONAttemptParse, JSONSerialize, JSONSanitize
 | 
			
		||||
} from '../src';
 | 
			
		||||
 | 
			
		||||
describe('Object utilities', () => {
 | 
			
		||||
@@ -131,34 +130,4 @@ describe('Object utilities', () => {
 | 
			
		||||
			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();
 | 
			
		||||
		});
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,7 @@ const rows = [
 | 
			
		||||
	{id: 1, name: 'Alice', age: 30},
 | 
			
		||||
	{id: 2, name: 'Bob', age: 24},
 | 
			
		||||
	{id: 3, name: 'Carol', age: 30},
 | 
			
		||||
	{id: 4, name: 'David', age: 35},
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
describe('Search Utilities', () => {
 | 
			
		||||
@@ -37,6 +38,35 @@ describe('Search Utilities', () => {
 | 
			
		||||
		it('returns all if search is falsy and regex enabled', () => {
 | 
			
		||||
			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', () => {
 | 
			
		||||
@@ -67,5 +97,46 @@ describe('Search Utilities', () => {
 | 
			
		||||
		it('returns false for unsupported operators', () => {
 | 
			
		||||
			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);
 | 
			
		||||
		});
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -31,11 +31,11 @@ describe('Time Utilities', () => {
 | 
			
		||||
		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);
 | 
			
		||||
			expect(formatted).toMatch(/^2023 02 01 18 05 05 123 PM \+00:00/i);
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		it('throws for unknown timezone', () => {
 | 
			
		||||
			expect(() => formatDate('YYYY', new Date(), '???')).toThrowError(/Unknown timezone/);
 | 
			
		||||
			expect(() => formatDate('YYYY', new Date(), '???')).toThrowError(/Invalid timezone/);
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		it('handles timezone by offset number', () => {
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user