Compare commits
	
		
			11 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 057528b0c5 | |||
| 1595aea529 | |||
| 08503de552 | |||
| a9a9b04a5a | |||
| a7b19900da | |||
| 34227e5c4b | |||
| 7e8352ed2a | |||
| 751177c981 | |||
| 5849a97c21 | |||
| fcae9e107e | |||
| 5bb5067abc | 
@@ -13,3 +13,7 @@ quote_type = single
 | 
			
		||||
[*.md]
 | 
			
		||||
max_line_length = off
 | 
			
		||||
trim_trailing_whitespace = false
 | 
			
		||||
 | 
			
		||||
[*.{yaml,yml}]
 | 
			
		||||
indent_style = space
 | 
			
		||||
indent_size = 2
 | 
			
		||||
							
								
								
									
										96
									
								
								.github/workflows/build.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										96
									
								
								.github/workflows/build.yaml
									
									
									
									
										vendored
									
									
								
							@@ -11,26 +11,26 @@ jobs:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    container: node:20-alpine
 | 
			
		||||
    steps:
 | 
			
		||||
      -   name: Clone Repository
 | 
			
		||||
          uses: ztimson/actions/clone@develop
 | 
			
		||||
      - name: Clone Repository
 | 
			
		||||
        uses: ztimson/actions/clone@develop
 | 
			
		||||
 | 
			
		||||
      -   name: Install & Build
 | 
			
		||||
          run: |
 | 
			
		||||
            npm i
 | 
			
		||||
            npm run build
 | 
			
		||||
      - name: Install & Build
 | 
			
		||||
        run: |
 | 
			
		||||
          npm i
 | 
			
		||||
          npm run build
 | 
			
		||||
 | 
			
		||||
      -   name: Test
 | 
			
		||||
          run: npm run test:coverage
 | 
			
		||||
      - name: Test
 | 
			
		||||
        run: npm run test:coverage
 | 
			
		||||
 | 
			
		||||
      -   name: Upload to Registry
 | 
			
		||||
          uses: ztimson/actions/npm/publish@develop
 | 
			
		||||
      - name: Upload to Registry
 | 
			
		||||
        uses: ztimson/actions/npm/publish@develop
 | 
			
		||||
 | 
			
		||||
      -   name: Upload to NPM
 | 
			
		||||
          uses: ztimson/actions/npm/publish@develop
 | 
			
		||||
          with:
 | 
			
		||||
              owner: ztimson
 | 
			
		||||
              registry: https://registry.npmjs.org/
 | 
			
		||||
              token: ${{secrets.NPM_TOKEN}}
 | 
			
		||||
      - name: Upload to NPM
 | 
			
		||||
        uses: ztimson/actions/npm/publish@develop
 | 
			
		||||
        with:
 | 
			
		||||
          owner: ztimson
 | 
			
		||||
          registry: https://registry.npmjs.org/
 | 
			
		||||
          token: ${{secrets.NPM_TOKEN}}
 | 
			
		||||
  tag:
 | 
			
		||||
    name: Tag Version
 | 
			
		||||
    needs: build
 | 
			
		||||
@@ -38,23 +38,53 @@ jobs:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    container: node
 | 
			
		||||
    steps:
 | 
			
		||||
      -   name: Clone Repository
 | 
			
		||||
          uses: ztimson/actions/clone@develop
 | 
			
		||||
 | 
			
		||||
      -   name: Get Version Number
 | 
			
		||||
          run: echo "VERSION=$(cat package.json | grep version | grep -Eo ':.+' | grep -Eo '[[:alnum:]\.\/\-]+')" >> $GITHUB_ENV
 | 
			
		||||
 | 
			
		||||
      -   name: Tag Version
 | 
			
		||||
          uses: ztimson/actions/tag@develop
 | 
			
		||||
          with:
 | 
			
		||||
            tag: ${{env.VERSION}}
 | 
			
		||||
      - name: Clone Repository
 | 
			
		||||
        uses: ztimson/actions/clone@develop
 | 
			
		||||
 | 
			
		||||
      - name: Get Version Number
 | 
			
		||||
        run: echo "VERSION=$(cat package.json | grep version | grep -Eo ':.+' | grep -Eo '[[:alnum:]\.\/\-]+')" >> $GITHUB_ENV
 | 
			
		||||
 | 
			
		||||
      - name: Tag Version
 | 
			
		||||
        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 {formatDate} from './dist/index.mjs';
 | 
			
		||||
		<h1>Open Chrome DevTools Console to see magic! (Ctrl+Shift+J or Cmd+Option+J)</h1>
 | 
			
		||||
 | 
			
		||||
			const dt = new Date('2021-03-03T09:00:00Z');
 | 
			
		||||
			const result = formatDate('Do MMMM dddd', dt, 0);
 | 
			
		||||
			console.log(result);
 | 
			
		||||
		<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.27.1",
 | 
			
		||||
	"version": "0.27.9",
 | 
			
		||||
	"description": "Utility library",
 | 
			
		||||
	"author": "Zak Timson",
 | 
			
		||||
	"license": "MIT",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										133
									
								
								src/cache.ts
									
									
									
									
									
								
							
							
						
						
									
										133
									
								
								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,15 @@ 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;
 | 
			
		||||
 | 
			
		||||
	private _loading!: Function;
 | 
			
		||||
	/** Await initial loading */
 | 
			
		||||
	loading = new Promise<void>(r => this._loading = r);
 | 
			
		||||
 | 
			
		||||
@@ -43,12 +47,18 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
					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}), {}));
 | 
			
		||||
					for(const row of rows) this.store.set(this.getKey(row), row);
 | 
			
		||||
					this._loading();
 | 
			
		||||
				})();
 | 
			
		||||
			} else if((<any>this.options.persistentStorage?.storage)?.getItem != undefined) {
 | 
			
		||||
				const stored = (<Storage>this.options.persistentStorage.storage).getItem(this.options.persistentStorage.key);
 | 
			
		||||
				if(stored != null) try { Object.assign(this.store, JSON.parse(stored)); } catch { }
 | 
			
		||||
				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 {
 | 
			
		||||
@@ -71,8 +81,8 @@ 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) {
 | 
			
		||||
@@ -80,28 +90,50 @@ export class Cache<K extends string | number | symbol, T> {
 | 
			
		||||
		if(!!persists?.storage) {
 | 
			
		||||
			if(persists.storage?.database != undefined) {
 | 
			
		||||
				(<Database>persists.storage).createTable({name: persists.key, key: <string>this.key}).then(table => {
 | 
			
		||||
					if(key) {
 | 
			
		||||
						const value = 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?.setItem != undefined) {
 | 
			
		||||
				persists.storage.setItem(persists.storage.key, JSONSanitize(this.all(true)));
 | 
			
		||||
				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;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
@@ -134,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;
 | 
			
		||||
	}
 | 
			
		||||
@@ -144,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;
 | 
			
		||||
	}
 | 
			
		||||
@@ -154,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;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
@@ -165,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;
 | 
			
		||||
			this.save(key);
 | 
			
		||||
			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;
 | 
			
		||||
	}
 | 
			
		||||
@@ -178,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;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
@@ -188,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;
 | 
			
		||||
	}
 | 
			
		||||
@@ -198,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;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
@@ -207,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;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -223,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(() => {
 | 
			
		||||
			this.expire(key);
 | 
			
		||||
			this.save(key);
 | 
			
		||||
		}, (ttl || 0) * 1000);
 | 
			
		||||
		if(ttl) {
 | 
			
		||||
			const t = setTimeout(() => {
 | 
			
		||||
				this.expire(key);
 | 
			
		||||
				this.save(key);
 | 
			
		||||
			}, (ttl || 0) * 1000);
 | 
			
		||||
			this.timers.set(key, t);
 | 
			
		||||
		}
 | 
			
		||||
		return this;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -236,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';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 
 | 
			
		||||
@@ -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';
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										29
									
								
								src/misc.ts
									
									
									
									
									
								
							
							
						
						
									
										29
									
								
								src/misc.ts
									
									
									
									
									
								
							@@ -1,3 +1,4 @@
 | 
			
		||||
import {LogLevels} from './logger.ts';
 | 
			
		||||
import {PathEvent} from './path-events.ts';
 | 
			
		||||
import {md5} from './string';
 | 
			
		||||
 | 
			
		||||
@@ -14,6 +15,34 @@ export function compareVersions(target: string, vs: string): -1 | 0 | 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
 | 
			
		||||
 
 | 
			
		||||
@@ -1,22 +1,29 @@
 | 
			
		||||
import {JSONSanitize} from './json.ts';
 | 
			
		||||
 | 
			
		||||
export type Delta = { [key: string]: any | Delta | null };
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Applies deltas in order to modify `target`.
 | 
			
		||||
 * @param base - Original
 | 
			
		||||
 * @param deltas - List of deltas to apply
 | 
			
		||||
 * Applies deltas to `target`.
 | 
			
		||||
 * @param base starting point
 | 
			
		||||
 * @param deltas List of deltas to apply
 | 
			
		||||
 * @returns Mutated target
 | 
			
		||||
 */
 | 
			
		||||
export function applyDelta(base: any, deltas: any): any {
 | 
			
		||||
	if(deltas === null) return null;
 | 
			
		||||
	if(typeof base !== 'object' || base === null) return deltas === undefined ? base : deltas;
 | 
			
		||||
	const result = Array.isArray(base) ? [...base] : { ...base };
 | 
			
		||||
	for(const key in deltas) {
 | 
			
		||||
		const val = deltas[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;
 | 
			
		||||
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;
 | 
			
		||||
	}
 | 
			
		||||
	return result;
 | 
			
		||||
 | 
			
		||||
	for(const d of deltas.flat()) base = applyDelta(base, d?.delta ?? d);
 | 
			
		||||
	return base;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
@@ -34,7 +41,7 @@ export function calcDelta(old: any, updated: any): any {
 | 
			
		||||
		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;
 | 
			
		||||
			if(nested !== null && Object.keys(nested).length > 0) delta[key] = nested;
 | 
			
		||||
		} else if(JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
 | 
			
		||||
			delta[key] = newVal;
 | 
			
		||||
		}
 | 
			
		||||
@@ -288,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 }), {});
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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.
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										85
									
								
								src/time.ts
									
									
									
									
									
								
							
							
						
						
									
										85
									
								
								src/time.ts
									
									
									
									
									
								
							@@ -1,5 +1,3 @@
 | 
			
		||||
import {numSuffix} from './math.ts';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Like setInterval but will adjust the timeout value to account for runtime
 | 
			
		||||
 * @param {Function} cb Callback function that will be ran
 | 
			
		||||
@@ -34,6 +32,50 @@ export function dayOfYear(date: Date): number {
 | 
			
		||||
/**
 | 
			
		||||
 * 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
 | 
			
		||||
@@ -55,23 +97,46 @@ export function formatDate(format: string = 'YYYY-MM-DD H:mm', date: Date | numb
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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',
 | 
			
		||||
			year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'long',
 | 
			
		||||
			hour: '2-digit', minute: '2-digit', second: '2-digit',
 | 
			
		||||
			hour12: false
 | 
			
		||||
		}).formatToParts(date);
 | 
			
		||||
		const get = (type: string) => parts.find(p => p.type === type)?.value;
 | 
			
		||||
		const build = `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}:${get('second')}Z`;
 | 
			
		||||
		zonedDate = new Date(build);
 | 
			
		||||
	} else if (numericTz || tzName === 'UTC') {
 | 
			
		||||
		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;
 | 
			
		||||
			}
 | 
			
		||||
		};
 | 
			
		||||
	} 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}`]();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const get = (fn: 'FullYear' | 'Month' | 'Date' | 'Day' | 'Hours' | 'Minutes' | 'Seconds' | 'Milliseconds') =>
 | 
			
		||||
		(numericTz || tzName === 'UTC') ? zonedDate[`getUTC${fn}`]() : zonedDate[`get${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) {
 | 
			
		||||
@@ -119,7 +184,7 @@ export function formatDate(format: string = 'YYYY-MM-DD H:mm', date: Date | numb
 | 
			
		||||
		m: get('Minutes').toString(),
 | 
			
		||||
		ss: get('Seconds').toString().padStart(2, '0'),
 | 
			
		||||
		s: get('Seconds').toString(),
 | 
			
		||||
		SSS: get('Milliseconds').toString().padStart(3, '0'),
 | 
			
		||||
		SSS: (zonedDate[`getUTC${'Milliseconds'}`]()).toString().padStart(3, '0'),
 | 
			
		||||
		A: get('Hours') >= 12 ? 'PM' : 'AM',
 | 
			
		||||
		a: get('Hours') >= 12 ? 'pm' : 'am',
 | 
			
		||||
		ZZ: getTZOffset().replace(':', ''),
 | 
			
		||||
 
 | 
			
		||||
@@ -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();
 | 
			
		||||
		});
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user