Compare commits

..

69 Commits

Author SHA1 Message Date
95f8d5762c Fixed import error
All checks were successful
Build / Build NPM Project (push) Successful in 57s
Build / Tag Version (push) Successful in 13s
Build / Publish Documentation (push) Successful in 48s
2025-05-06 16:01:57 -04:00
3bc82fab45 - Fixed cache.addAll()
Some checks failed
Build / Build NPM Project (push) Failing after 34s
Build / Publish Documentation (push) Has been skipped
Build / Tag Version (push) Has been skipped
- Renamed jwtDecode to decodeJWT to match conventions
- Added testCondition to search
2025-05-06 15:59:08 -04:00
6b15848896 Fixed defaulting date in date formatter
All checks were successful
Build / Build NPM Project (push) Successful in 1m15s
Build / Tag Version (push) Successful in 16s
Build / Publish Documentation (push) Successful in 49s
2025-04-30 11:58:03 -04:00
9a0f32323e Fix HTTP empty responses
All checks were successful
Build / Build NPM Project (push) Successful in 37s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 34s
2025-04-13 10:16:50 -04:00
f952abc95a More zelous path fixing in the http client
All checks were successful
Build / Build NPM Project (push) Successful in 1m13s
Build / Tag Version (push) Successful in 14s
Build / Publish Documentation (push) Successful in 51s
2025-04-13 09:50:19 -04:00
21fc1378b8 Added more naming convention utilities
All checks were successful
Build / Build NPM Project (push) Successful in 42s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 38s
2025-04-08 19:02:42 -04:00
a03567eba3 toCSV comma fix
All checks were successful
Build / Build NPM Project (push) Successful in 1m15s
Build / Tag Version (push) Successful in 13s
Build / Publish Documentation (push) Successful in 50s
2025-04-06 21:37:01 -04:00
f9fc4fb7ff Better CSV handling
All checks were successful
Build / Build NPM Project (push) Successful in 38s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 35s
2025-03-24 23:01:57 -04:00
ff16f3bf9b Better CSV handling
All checks were successful
Build / Build NPM Project (push) Successful in 38s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 36s
2025-03-24 21:40:03 -04:00
e0085ecb6f Better CSV handling
All checks were successful
Build / Build NPM Project (push) Successful in 1m15s
Build / Tag Version (push) Successful in 16s
Build / Publish Documentation (push) Successful in 53s
2025-03-24 21:28:51 -04:00
1c2c18b65d Added camelCase function
All checks were successful
Build / Build NPM Project (push) Successful in 1m12s
Build / Tag Version (push) Successful in 12s
Build / Publish Documentation (push) Successful in 50s
2025-03-10 09:50:16 -04:00
cd5741d6ab Simplified timestampedFilename to use formatDate
All checks were successful
Build / Build NPM Project (push) Successful in 43s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 45s
2025-03-02 21:37:48 -05:00
5b9e0714ce Cache returns deep copies to prevent deletion mid-use
All checks were successful
Build / Build NPM Project (push) Successful in 1m13s
Build / Tag Version (push) Successful in 17s
Build / Publish Documentation (push) Successful in 54s
2025-02-27 08:36:30 -05:00
a3b34ef03f Fixed http no decode
All checks were successful
Build / Build NPM Project (push) Successful in 43s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 40s
2025-02-18 16:04:18 -05:00
f755d8f5b8 Fixed PathEvents filter
All checks were successful
Build / Build NPM Project (push) Successful in 38s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 35s
2025-02-04 22:17:31 -05:00
35c471eef4 Fixed PathEvents filter
All checks were successful
Build / Build NPM Project (push) Successful in 1m12s
Build / Tag Version (push) Successful in 12s
Build / Publish Documentation (push) Successful in 47s
2025-02-04 21:35:31 -05:00
fe9fdb9384 Fixed blackorwhite invalid argument crash
All checks were successful
Build / Build NPM Project (push) Successful in 38s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 40s
2025-02-03 14:20:18 -05:00
a5d7a35fdc Fixed formatDate
All checks were successful
Build / Build NPM Project (push) Successful in 42s
Build / Tag Version (push) Successful in 9s
Build / Publish Documentation (push) Successful in 38s
2025-01-08 14:21:14 -05:00
cb117273a7 Fixed path-event filter
All checks were successful
Build / Build NPM Project (push) Successful in 39s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 36s
2024-12-27 22:20:27 -05:00
3bda688b1e added blackOrWhite color contrast function
All checks were successful
Build / Build NPM Project (push) Successful in 41s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 36s
2024-12-26 10:40:39 -05:00
446b1aa9db Updated arg-parser help output
All checks were successful
Build / Build NPM Project (push) Successful in 1m2s
Build / Tag Version (push) Successful in 16s
Build / Publish Documentation (push) Successful in 57s
2024-11-29 18:26:09 -05:00
30be1c5a91 bump 0.23.4
All checks were successful
Build / Build NPM Project (push) Successful in 1m2s
Build / Tag Version (push) Successful in 14s
Build / Publish Documentation (push) Successful in 56s
2024-11-29 17:41:20 -05:00
6b328e64ba Added default help argument to arg-parser
Some checks failed
Build / Tag Version (push) Blocked by required conditions
Build / Publish Documentation (push) Blocked by required conditions
Build / Build NPM Project (push) Has been cancelled
2024-11-29 17:41:00 -05:00
435be31347 Added arg-parser
All checks were successful
Build / Build NPM Project (push) Successful in 1m46s
Build / Tag Version (push) Successful in 19s
Build / Publish Documentation (push) Successful in 1m10s
2024-11-29 17:26:07 -05:00
2522635815 Added JSONSerialize
All checks were successful
Build / Build NPM Project (push) Successful in 1m12s
Build / Tag Version (push) Successful in 12s
Build / Publish Documentation (push) Successful in 48s
2024-11-24 11:08:17 -05:00
e8fdca8236 bump 0.23.1
All checks were successful
Build / Build NPM Project (push) Successful in 38s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 1m40s
2024-11-20 11:03:15 -05:00
67ae86d69b Added India Standard Time to formatDate
All checks were successful
Build / Build NPM Project (push) Successful in 33s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 1m42s
2024-11-20 10:58:31 -05:00
87db4d4755 Combined adjustTz into formatDate
Some checks failed
Build / Tag Version (push) Has been cancelled
Build / Publish Documentation (push) Has been cancelled
Build / Build NPM Project (push) Has been cancelled
2024-11-20 10:56:51 -05:00
0fb1e13ba0 Fixed timezone adjust
All checks were successful
Build / Build NPM Project (push) Successful in 1m12s
Build / Tag Version (push) Successful in 11s
Build / Publish Documentation (push) Successful in 2m4s
2024-11-20 10:04:17 -05:00
ff4401934c Fixed formatDate month shortform
All checks were successful
Build / Build NPM Project (push) Successful in 1m21s
Build / Tag Version (push) Successful in 13s
Build / Publish Documentation (push) Successful in 1m57s
2024-11-16 11:02:32 -05:00
ce3c8d012a Fixed import
All checks were successful
Build / Build NPM Project (push) Successful in 1m5s
Build / Tag Version (push) Successful in 14s
Build / Publish Documentation (push) Successful in 2m2s
2024-11-15 16:22:01 -05:00
a86e8350ee bump 0.22.9
Some checks failed
Build / Build NPM Project (push) Failing after 24s
Build / Tag Version (push) Has been skipped
Build / Publish Documentation (push) Has been skipped
2024-11-15 16:18:45 -05:00
7b4e17e4c4 Added advanced search function
Some checks failed
Build / Build NPM Project (push) Failing after 20s
Build / Tag Version (push) Has been skipped
Build / Publish Documentation (push) Has been skipped
2024-11-15 16:17:52 -05:00
fa66820c76 more time utils
All checks were successful
Build / Build NPM Project (push) Successful in 38s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 2m27s
2024-11-14 23:29:58 -05:00
49a2df8cd4 Fixed timezone format
All checks were successful
Build / Build NPM Project (push) Successful in 1m15s
Build / Tag Version (push) Successful in 13s
Build / Publish Documentation (push) Successful in 1m57s
2024-11-14 22:58:24 -05:00
ccf3fcb043 fixed fromCsv escaping quotes & letter headers
All checks were successful
Build / Build NPM Project (push) Successful in 37s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 1m46s
2024-11-09 17:27:05 -05:00
341ef37205 Added fromCsv
All checks were successful
Build / Build NPM Project (push) Successful in 37s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 1m43s
2024-11-09 16:30:05 -05:00
0eab2630ad added fileText method
All checks were successful
Build / Build NPM Project (push) Successful in 37s
Build / Tag Version (push) Successful in 6s
Build / Publish Documentation (push) Successful in 2m21s
2024-11-09 15:35:05 -05:00
2f59a9d02e improved toCSV
All checks were successful
Build / Build NPM Project (push) Successful in 41s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 1m41s
2024-11-09 13:49:04 -05:00
ce2f32dd45 Fixed PathEvents double slash
All checks were successful
Build / Build NPM Project (push) Successful in 1m10s
Build / Tag Version (push) Successful in 13s
Build / Publish Documentation (push) Successful in 1m54s
2024-11-08 18:49:11 -05:00
ce40b5b1e7 Deprecated old methods
All checks were successful
Build / Build NPM Project (push) Successful in 39s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 1m41s
2024-11-07 10:46:56 -05:00
2500bcdcf1 Added string splice and updated dateFormat to accept custom format stings
All checks were successful
Build / Build NPM Project (push) Successful in 1m6s
Build / Tag Version (push) Successful in 13s
Build / Publish Documentation (push) Successful in 1m50s
2024-11-07 10:30:43 -05:00
db18c010aa Added adjusted interval & set errorFromCode to always return an error even on 200's
All checks were successful
Build / Build NPM Project (push) Successful in 1m16s
Build / Tag Version (push) Successful in 14s
Build / Publish Documentation (push) Successful in 2m1s
2024-10-29 10:41:25 -04:00
b292d5ed17 PathEvents has none fix
All checks were successful
Build / Build NPM Project (push) Successful in 36s
Build / Tag Version (push) Successful in 10s
Build / Publish Documentation (push) Successful in 1m47s
2024-10-23 21:39:42 -04:00
a1ea8cdf67 Added filter function to path events
All checks were successful
Build / Build NPM Project (push) Successful in 37s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 1m46s
2024-10-23 14:13:47 -04:00
fbbe3c99ef Updated ASet & Path Events
All checks were successful
Build / Build NPM Project (push) Successful in 37s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 1m43s
2024-10-17 10:24:18 -04:00
1c1a3f6a6e Updated ASet & Path Events
All checks were successful
Build / Build NPM Project (push) Successful in 36s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 1m40s
2024-10-17 00:36:53 -04:00
2dce1ad9ac Object logging with formatting
All checks were successful
Build / Build NPM Project (push) Successful in 39s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 35s
2024-10-16 20:32:41 -04:00
cebfd2c508 Object logging with formatting
All checks were successful
Build / Build NPM Project (push) Successful in 40s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 35s
2024-10-16 20:28:56 -04:00
7c5cf3535d Handle logging objects
All checks were successful
Build / Build NPM Project (push) Successful in 39s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 35s
2024-10-16 20:21:35 -04:00
847b493772 Handle logging objects
All checks were successful
Build / Build NPM Project (push) Successful in 38s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 34s
2024-10-16 19:55:35 -04:00
b1005227ab Added PathError for easy detection
All checks were successful
Build / Build NPM Project (push) Successful in 43s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 38s
2024-10-16 19:52:47 -04:00
3a389de72e Cache localstorage fix
All checks were successful
Build / Build NPM Project (push) Successful in 38s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 35s
2024-10-15 16:44:23 -04:00
151465aa65 Cache localstorage fix
All checks were successful
Build / Build NPM Project (push) Successful in 39s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 36s
2024-10-15 16:39:29 -04:00
b103b6f786 Cache localstorage fix
All checks were successful
Build / Build NPM Project (push) Successful in 38s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 34s
2024-10-15 16:35:11 -04:00
3b486310de Deleting lock file, seems to mess up build
All checks were successful
Build / Build NPM Project (push) Successful in 1m4s
Build / Tag Version (push) Successful in 11s
Build / Publish Documentation (push) Successful in 46s
2024-10-15 13:51:00 -04:00
8699fb49ff New lock file
Some checks failed
Build / Build NPM Project (push) Failing after 18s
Build / Tag Version (push) Has been skipped
Build / Publish Documentation (push) Has been skipped
2024-10-15 13:49:52 -04:00
fdb29e7984 New lock file
Some checks failed
Build / Tag Version (push) Blocked by required conditions
Build / Publish Documentation (push) Blocked by required conditions
Build / Build NPM Project (push) Has been cancelled
2024-10-15 13:49:17 -04:00
274c22bb83 Fixed localStorage access on node environments
Some checks failed
Build / Build NPM Project (push) Failing after 15s
Build / Tag Version (push) Has been skipped
Build / Publish Documentation (push) Has been skipped
2024-10-15 13:46:11 -04:00
b21f462d35 Added clear function to cache
All checks were successful
Build / Build NPM Project (push) Successful in 37s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 35s
2024-10-14 21:07:59 -04:00
0f10aebfd2 bubble up all fetch errors in the http helper
All checks were successful
Build / Build NPM Project (push) Successful in 40s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 36s
2024-10-14 20:46:32 -04:00
1af23ac544 Fixed cache localstorage
All checks were successful
Build / Build NPM Project (push) Successful in 37s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 34s
2024-10-14 20:04:29 -04:00
494cfaaccd Added localStorage support to cache
All checks were successful
Build / Build NPM Project (push) Successful in 41s
Build / Tag Version (push) Successful in 8s
Build / Publish Documentation (push) Successful in 41s
2024-10-14 18:51:24 -04:00
23df6ad265 Renamed PathEventEmitter to follow convention
All checks were successful
Build / Build NPM Project (push) Successful in 27s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 27s
2024-10-14 14:09:51 -04:00
2fda11f3b7 Renamed PathEventEmitter to follow convention
All checks were successful
Build / Build NPM Project (push) Successful in 27s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 26s
2024-10-14 14:06:06 -04:00
379de273c4 Added path-events
All checks were successful
Build / Build NPM Project (push) Successful in 31s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 28s
2024-10-14 13:52:03 -04:00
c51239d12b Bump 0.18.2
All checks were successful
Build / Build NPM Project (push) Successful in 25s
Build / Tag Version (push) Successful in 7s
Build / Publish Documentation (push) Successful in 26s
2024-10-12 12:30:23 -04:00
e4229296c1 Added escapeRegex
All checks were successful
Build / Build NPM Project (push) Successful in 59s
Build / Tag Version (push) Successful in 14s
Build / Publish Documentation (push) Successful in 42s
2024-10-12 12:27:45 -04:00
b93ed45521 Renamed sleepUntil to sleepWhile, it was misleading
All checks were successful
Build / Build NPM Project (push) Successful in 1m5s
Build / Tag Version (push) Successful in 14s
Build / Publish Documentation (push) Successful in 39s
2024-10-07 15:40:14 -04:00
24 changed files with 1093 additions and 5896 deletions

12
.npmignore Normal file
View File

@ -0,0 +1,12 @@
src
tests
.editorconfig
.gitignore
.gitmodules
.npmignore
CODEOWNERS
Dockerfile
index.html
jest.config.js
tsconfig.json
vite.config.js

104
index.html Normal file
View File

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

5753
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "@ztimson/utils",
"version": "0.17.2",
"version": "0.24.0",
"description": "Utility library",
"author": "Zak Timson",
"license": "MIT",
@ -14,9 +14,9 @@
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
"require": "./dist/index.cjs"
}
},
"scripts": {
@ -34,12 +34,9 @@
"typedoc": "^0.26.7",
"typescript": "^5.3.3",
"vite": "^5.0.12",
"vite-plugin-dts": "^3.7.2"
"vite-plugin-dts": "^4.5.3"
},
"files": [
"dist"
],
"dependencies": {
"var-persist": "^1.0.1"
}
]
}

141
src/arg-parser.ts Normal file
View File

@ -0,0 +1,141 @@
export type Arg<T = any> = {
/** Argument/property name */
name: string;
/** Argument description for help page */
desc: string;
/** Available shorthands */
flags?: string[];
/** Argument is not required */
optional?: boolean;
/** Default value if argument is not used */
default?: T;
/** Collects any unmatched arguments */
extras?: boolean;
}
export class ArgParser {
commands: ArgParser[] = [];
args: Arg[] = [];
flags: Arg[] = [];
defaults!: {[key: string]: any};
/**
* Create a unix-like argument parser to extract flags from the argument list. Can also create help messages.
*
* @param {string} name Script name
* @param {string} desc Help description
* @param {(ArgParser | Arg[]} argList Array of CLI arguments
* @param {string[]} examples Additional examples to display
*/
constructor(public readonly name: string,
public readonly desc: string,
public readonly argList: (ArgParser | Arg)[] = [],
public readonly examples: string[] = []
) {
// Arguments
this.commands = argList.filter(arg => arg instanceof ArgParser);
this.args = <Arg[]>argList.filter(arg => !(arg instanceof ArgParser) && !arg.flags?.length);
this.flags = <Arg[]>[...argList.filter(arg => !(arg instanceof ArgParser) && arg.flags && arg.flags.length),
{name: 'help', desc: 'Display command\'s help message', flags: ['-h', '--help'], default: false}];
this.defaults = argList.reduce((acc, arg: any) => ({...acc, [arg.name]: arg['extras'] ? [] : (arg.default ?? null)}), {});
// Examples
this.examples = <string[]>[
...examples,
`[OPTIONS] ${this.args.map(arg => (arg.optional ? `[${arg.name.toUpperCase()}]` : arg.name.toUpperCase()) + (arg.extras ? '...' : '')).join(' ')}`,
this.commands.length ? `[OPTIONS] COMMAND` : null,
`--help ${this.commands.length ? '[COMMAND]' : ''}`
].filter(e => !!e);
}
/**
* Parse an array into an arguments dictionary using the configuration.
*
* @param {string[]} args Array of arguments to be parsed
* @returns {object} Dictionary of arguments with defaults applied
*/
parse(args: string[]) {
// Parse arguments
let extras = [], parsed: any = {...this.defaults, '_error': []}, queue = [...args];
while(queue.length) {
let arg = queue.splice(0, 1)[0];
if(arg[0] == '-') { // Flags
// Check for combined shorthand
if(arg[1] != '-' && arg.length > 2) {
queue = [...arg.substring(2).split('').map(a => `-${a}`), ...queue];
arg = `-${arg[1]}`;
}
// Find & add flag
const combined = arg.split('=');
const argDef = this.flags.find(flag => flag.flags?.includes(combined[0] || arg));
if(argDef == null) { // Not found, add to extras
extras.push(arg);
continue;
}
const value = argDef.default === false ? true :
argDef.default === true ? false :
queue.splice(queue.findIndex(q => q[0] != '-'), 1)[0] ||
argDef.default;
if(value == null) parsed['_error'].push(`Option missing value: ${argDef.name || combined[0]}`);
parsed[argDef.name] = value;
} else { // Command
const c = this.commands.find(command => command.name == arg);
if(!!c) {
const parsedCommand = c.parse(queue.splice(0, queue.length));
Object.keys(parsedCommand).forEach(key => {
if(parsed[key] != parsedCommand[key] && parsedCommand[key] == c.defaults[key])
delete parsedCommand[key];
});
parsed = {
...parsed,
...parsedCommand,
_command: c.name
};
} else extras.push(arg); // Not found, add to extras
}
}
// Arguments
this.args.filter(arg => !arg.extras).forEach(arg => {
if(!arg.optional && !extras.length) parsed['_error'].push(`Argument missing: ${arg.name.toUpperCase()}`);
if(extras.length) parsed[arg.name] = extras.splice(0, 1)[0];
});
// Extras
const extraKey = this.args.find(arg => arg.extras)?.name || '_extra';
parsed[extraKey] = extras;
return parsed;
}
/**
* Create help message from the provided description & argument list.
*
* @returns {string} Help message
* @param opts Help options: command - display a commands help, message - override help description
*/
help(opts: {command?: string, message?: string} = {}): string {
const spacer = (text: string) => Array(24 - text.length || 1).fill(' ').join('');
// Help with specific command
if(opts.command) {
const argParser = this.commands.find(parser => parser.name == opts.command);
if(!argParser) throw new Error(`${opts.command.toUpperCase()} is not a command`)
return argParser.help({...opts, command: undefined});
}
// Description
let msg = `\n\n${opts.message || this.desc}`;
// Examples
msg += '\n\nUsage:\t' + this.examples.map(ex => `${this.name} ${ex}`).join('\n\t');
// Arguments
if(this.args.length) msg += '\n\n\t' + this.args.map(arg =>
`${arg.name.toUpperCase()}${spacer(arg.name)}${arg.desc}`).join('\n\t');
// Flags
msg += '\n\nOptions:\n\t' + this.flags.map(flag => {
const flags = flag.flags?.join(', ') || '';
return `${flags}${spacer(flags)}${flag.desc}`;
}).join('\n\t');
// Commands
if(this.commands.length) msg += '\n\nCommands:\n\t' + this.commands.map(command =>
`${command.name}${spacer(command.name)}${command.desc}`).join('\n\t');
return `${msg}\n\n`;
}
}

View File

@ -1,3 +1,5 @@
import {isEqual} from './objects.ts';
/**
* An array which functions as a set. It guarantees unique elements
* and provides set functions for comparisons
@ -24,6 +26,7 @@ export class ASet<T> extends Array {
*/
add(...items: T[]) {
items.filter(el => !this.has(el)).forEach(el => this.push(el));
return this;
}
/**
@ -33,8 +36,9 @@ export class ASet<T> extends Array {
delete(...items: T[]) {
items.forEach(el => {
const index = this.indexOf(el);
if(index != -1) this.slice(index, 1);
if(index != -1) this.splice(index, 1);
})
return this;
}
/**
@ -55,6 +59,17 @@ export class ASet<T> extends Array {
return this.indexOf(el) != -1;
}
/**
* 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 {
return super.findIndex((el: T) => isEqual(el, search), fromIndex);
}
/**
* Create list of elements this set has in common with the comparison set
* @param {ASet<T>} set Set to compare against

View File

@ -1,3 +1,14 @@
import {deepCopy} from './objects.ts';
export type CacheOptions = {
/** Delete keys automatically after x amount of seconds */
ttl?: number;
/** Storage to persist cache */
storage?: Storage;
/** Key cache will be stored under */
storageKey?: string;
}
/**
* Map of data which tracks whether it is a complete collection & offers optional expiry of cached values
*/
@ -11,19 +22,27 @@ export class Cache<K extends string | number | symbol, T> {
/**
* Create new cache
*
* @param {keyof T} key Default property to use as primary key
* @param {number} ttl Default expiry in milliseconds
* @param options
*/
constructor(public readonly key?: keyof T, public ttl?: number) {
constructor(public readonly key?: keyof T, public readonly options: CacheOptions = {}) {
if(options.storageKey && !options.storage && typeof(Storage) !== 'undefined')
options.storage = localStorage;
if(options.storageKey && options.storage) {
const stored = options.storage.getItem(options.storageKey);
if(stored) {
try { Object.assign(this.store, JSON.parse(stored)); }
catch { }
}
}
return new Proxy(this, {
get: (target: this, prop: string | symbol) => {
if (prop in target) return (target as any)[prop];
return target.store[prop as K];
if(prop in target) return (target as any)[prop];
return deepCopy(target.store[prop as K]);
},
set: (target: this, prop: string | symbol, value: any) => {
if (prop in target) (target as any)[prop] = value;
else target.store[prop as K] = value;
if(prop in target) (target as any)[prop] = value;
else this.set(prop as K, value);
return true;
}
});
@ -36,16 +55,14 @@ export class Cache<K extends string | number | symbol, T> {
/**
* Get all cached items
*
* @return {T[]} Array of items
*/
all(): T[] {
return Object.values(this.store);
return deepCopy(Object.values(this.store));
}
/**
* Add a new item to the cache. Like set, but finds key automatically
*
* @param {T} value Item to add to cache
* @param {number | undefined} ttl Override default expiry
* @return {this}
@ -58,24 +75,32 @@ export class Cache<K extends string | number | symbol, T> {
/**
* Add several rows to the cache
*
* @param {T[]} rows Several items that will be cached using the default key
* @param complete Mark cache as complete & reliable, defaults to true
* @return {this}
*/
addAll(rows: T[], complete = true): this {
this.clear();
rows.forEach(r => this.add(r));
this.complete = complete;
return this;
}
/**
* Remove all keys from cache
*/
clear() {
this.store = <Record<K, T>>{};
}
/**
* Delete an item from the cache
*
* @param {K} key Item's primary key
*/
delete(key: K) {
delete this.store[key];
if(this.options.storageKey && this.options.storage)
this.options.storage.setItem(this.options.storageKey, JSON.stringify(this.store));
}
/**
@ -92,12 +117,11 @@ export class Cache<K extends string | number | symbol, T> {
* @return {T} Cached item
*/
get(key: K): T {
return this.store[key];
return deepCopy(this.store[key]);
}
/**
* Get a list of cached keys
*
* @return {K[]} Array of keys
*/
keys(): K[] {
@ -106,33 +130,32 @@ export class Cache<K extends string | number | symbol, T> {
/**
* Get map of cached items
*
* @return {Record<K, T>}
*/
map(): Record<K, T> {
return structuredClone(this.store);
return deepCopy(this.store);
}
/**
* Add an item to the cache manually specifying the key
*
* @param {K} key Key item will be cached under
* @param {T} value Item to cache
* @param {number | undefined} ttl Override default expiry
* @param {number | undefined} ttl Override default expiry in seconds
* @return {this}
*/
set(key: K, value: T, ttl = this.ttl): this {
set(key: K, value: T, ttl = this.options.ttl): this {
this.store[key] = value;
if(this.options.storageKey && this.options.storage)
this.options.storage.setItem(this.options.storageKey, JSON.stringify(this.store));
if(ttl) setTimeout(() => {
this.complete = false;
this.delete(key);
}, ttl);
}, ttl * 1000);
return this;
}
/**
* Get all cached items
*
* @return {T[]} Array of items
*/
values = this.all();

12
src/color.ts Normal file
View File

@ -0,0 +1,12 @@
/**
* Determine if either black or white provides more contrast to the provided color
* @param {string} background Color to compare against
* @return {"white" | "black"} Color with the most contrast
*/
export function blackOrWhite(background: string): 'white' | 'black' {
const exploded = background?.match(background.length >= 6 ? /\w\w/g : /\w/g);
if(!exploded) return 'black';
const [r, g, b] = exploded.map(hex => parseInt(hex, 16));
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? 'black' : 'white';
}

View File

@ -1,25 +1,87 @@
import {dotNotation, flattenObj} from './objects.ts';
import {makeArray} from './array.ts';
import {ASet} from './aset.ts';
import {dotNotation, flattenObj, JSONSanitize} from './objects.ts';
import {LETTER_LIST} from './string.ts';
/**
* Convert an object to a CSV string
* Parse a CSV string into an array of objects
*
* @param csv String with CSV
* @param hasHeaders First line of CSV contains headers
* @return {T[]} Array of parsed objects
*/
export function fromCsv<T = any>(csv: string, hasHeaders = true): T[] {
function parseLine(line: string): (string | null)[] {
const columns: string[] = [];
let current = '', inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
const nextChar = line[i + 1];
if (char === '"') {
if (inQuotes && nextChar === '"') {
current += '"'; // Handle escaped quotes
i++;
} else inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
columns.push(current.trim()); // Trim column values
current = '';
} else current += char;
}
columns.push(current.trim()); // Trim last column value
return columns.map(col => col.replace(/^"|"$/g, '').replace(/""/g, '"'));
}
// Normalize line endings and split rows
const rows = [];
let currentRow = '', inQuotes = false;
for (const char of csv.replace(/\r\n/g, '\n')) { // Normalize \r\n to \n
if (char === '"') inQuotes = !inQuotes;
if (char === '\n' && !inQuotes) {
rows.push(currentRow.trim()); // Trim row
currentRow = '';
} else currentRow += char;
}
if (currentRow) rows.push(currentRow.trim()); // Trim last row
// Extract headers
let headers: any = hasHeaders ? rows.splice(0, 1)[0] : null;
if (headers) headers = headers.match(/(?:[^,"']+|"(?:[^"]|"")*"|'(?:[^']|'')*')+/g)?.map((h: any) => h.trim());
// Parse rows
return <T[]>rows.map(r => {
const props = parseLine(r);
const h = headers || (Array(props.length).fill(null).map((_, i) => {
let letter = '';
const first = i / 26;
if (first > 1) letter += LETTER_LIST[Math.floor(first - 1)];
letter += LETTER_LIST[i % 26];
return letter;
}));
return h.reduce((acc: any, h: any, i: number) => {
dotNotation(acc, h, props[i]);
return acc;
}, {});
});
}
/**
* Convert an array of objects to a CSV string
*
* @param {any[]} target Array of objects to create CSV from
* @param {boolean} flatten Should nested object be flattened or treated as values
* @return {string} CSV string
*/
export function csv(target: any[], flatten=true) {
const headers = target.reduce((acc, row) => {
Object.keys(flatten ? flattenObj(row) : row)
.forEach(key => { if(!acc.includes(key)) acc.push(key); });
return acc;
}, []);
export function toCsv(target: any, flatten=true) {
const t = makeArray(target);
const headers = new ASet(t.reduce((acc, row) => [...acc, ...Object.keys(flatten ? flattenObj(row) : row)], []));
return [
headers.join(','),
...target.map(row => headers.map((h: string) => {
...t.map(row => headers.map((h: string) => {
const value = dotNotation<any>(row, h);
const type = typeof value;
if(type == 'string' && value.includes(',')) return `"${value}"`;
if(type == 'object') return `"${JSON.stringify(value)}"`;
if(value == null) return '';
if(typeof value == 'object') return `"${JSONSanitize(value).replaceAll('"', '""')}"`;
if(typeof value == 'string' && /[\n",]/g.test(value)) return `"${value.replaceAll('"', '""')}"`;
return value;
}).join(','))
].join('\n');

View File

@ -1,33 +1,33 @@
export type Listener = (...args: any[]) => any;
export type TypedEvents = {[k in string | symbol]: Listener} & {'*': (event: string, ...args: any[]) => any};
export type TypedListener = (...args: any[]) => any;
export type TypedEvents = {[k in string | symbol]: TypedListener} & {'*': (event: string, ...args: any[]) => any};
export type NamespaceEvents<Namespace extends string, Events extends TypedEvents> = {
[K in keyof Events as `${Namespace}:${Extract<K, string>}`]: Events[K];
};
export class TypedEmitter<T extends TypedEvents = TypedEvents> {
private static listeners: {[key: string]: Listener[]} = {};
private static listeners: {[key: string]: TypedListener[]} = {};
private listeners: { [key in keyof T]?: Listener[] } = {};
private listeners: { [key in keyof T]?: TypedListener[] } = {};
static emit(event: any, ...args: any[]) {
(this.listeners['*'] || []).forEach(l => l(event, ...args));
(this.listeners[event.toString()] || []).forEach(l => l(...args));
};
static off(event: any, listener: Listener) {
static off(event: any, listener: TypedListener) {
const e = event.toString();
this.listeners[e] = (this.listeners[e] || []).filter(l => l === listener);
}
static on(event: any, listener: Listener) {
static on(event: any, listener: TypedListener) {
const e = event.toString();
if(!this.listeners[e]) this.listeners[e] = [];
this.listeners[e]?.push(listener);
return () => this.off(event, listener);
}
static once(event: any, listener?: Listener): Promise<any> {
static once(event: any, listener?: TypedListener): Promise<any> {
return new Promise(res => {
const unsubscribe = this.on(event, <any>((...args: any) => {
res(args.length == 1 ? args[0] : args);

View File

@ -180,7 +180,6 @@ export class GatewayTimeoutError extends CustomError {
* @return {CustomError} The proper error type
*/
export function errorFromCode(code: number, message?: string) {
if(code >= 200 && code < 300) return null;
switch(code) {
case 400:
return new BadRequestError(message);

View File

@ -1,6 +1,7 @@
import {makeArray} from './array.ts';
import {JSONAttemptParse} from './objects.ts';
import {PromiseProgress} from './promise-progress';
import {formatDate} from './time.ts';
/**
* Download blob as a file
@ -52,6 +53,21 @@ export function fileBrowser(options: {accept?: string, multiple?: boolean} = {})
});
}
/**
* Extract text from a file
*
* @param file File to extract text from
* @return {Promise<string | null>} File contents
*/
export function fileText(file: any): Promise<string | null> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(<string>reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsText(file);
});
}
/**
* Create timestamp intended for filenames from a date
*
@ -61,7 +77,7 @@ export function fileBrowser(options: {accept?: string, multiple?: boolean} = {})
*/
export function timestampFilename(name?: string, date: Date | number | string = new Date()) {
if(typeof date == 'number' || typeof date == 'string') date = new Date(date);
const timestamp = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}_${date.getHours().toString().padStart(2, '0')}-${date.getMinutes().toString().padStart(2, '0')}-${date.getSeconds().toString().padStart(2, '0')}`;
const timestamp = formatDate('YYYY-MM-DD_HH:mm:ss', date);
return name ? name.replace('{{TIMESTAMP}}', timestamp) : timestamp;
}

View File

@ -22,6 +22,28 @@ export type HttpDefaults = {
url?: string;
}
class HttpResponse<T = any> extends Response {
data?: T
ok!: boolean;
redirected!: boolean;
type!: ResponseType;
url!: string;
constructor(resp: Response, stream: ReadableStream) {
const body = [204, 205, 304].includes(resp.status) ? null : stream;
super(body, {
headers: resp.headers,
status: resp.status,
statusText: resp.statusText,
});
this.ok = resp.ok;
this.redirected = resp.redirected;
this.type = resp.type;
this.url = resp.url;
}
}
export class Http {
private static interceptors: {[key: string]: HttpInterceptor} = {};
@ -54,8 +76,9 @@ export class Http {
request<T>(opts: HttpRequestOptions = {}): PromiseProgress<DecodedResponse<T>> {
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 || '')).replace(/([^:]\/)\/+/g, '$1');
if(opts.fragment) url.includes('#') ? url.replace(/#.*(\?|\n)/g, (match, arg1) => `#${opts.fragment}${arg1}`) : url += '#' + opts.fragment;
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.query) {
const q = Array.isArray(opts.query) ? opts.query :
Object.keys(opts.query).map(k => ({key: k, value: (<any>opts.query)[k]}))
@ -75,6 +98,7 @@ export class Http {
// Send request
return new PromiseProgress((res, rej, prog) => {
try {
fetch(url, {
headers,
method: opts.method || (opts.body ? 'POST' : 'GET'),
@ -104,18 +128,21 @@ export class Http {
}
});
resp.data = new Response(stream);
if(opts.decode == null || opts.decode) {
resp = new HttpResponse<T>(resp, stream);
if(opts.decode !== false) {
const content = resp.headers.get('Content-Type')?.toLowerCase();
if(content?.includes('form')) resp.data = <T>await resp.data.formData();
else if(content?.includes('json')) resp.data = <T>await resp.data.json();
else if(content?.includes('text')) resp.data = <T>await resp.data.text();
else if(content?.includes('application')) resp.data = <T>await resp.data.blob();
if(content?.includes('form')) resp.data = <T>await resp.formData();
else if(content?.includes('json')) resp.data = <T>await resp.json();
else if(content?.includes('text')) resp.data = <T>await resp.text();
else if(content?.includes('application')) resp.data = <T>await resp.blob();
}
if(resp.ok) res(resp);
else rej(resp);
})
}).catch(err => rej(err));
} catch(err) {
rej(err);
}
});
}
}

View File

@ -1,6 +1,8 @@
export * from './arg-parser';
export * from './array';
export * from './aset';
export * from './cache';
export * from './color';
export * from './csv';
export * from './files';
export * from './emitter';
@ -11,8 +13,9 @@ export * from './logger';
export * from './math';
export * from './misc';
export * from './objects';
export * from './path-events';
export * from './promise-progress';
export * from './search';
export * from './string';
export * from './time';
export * from './types';
export * from 'var-persist';

View File

@ -6,7 +6,7 @@ import {JSONAttemptParse} from './objects.ts';
* @param {string} token JWT to decode
* @return {unknown} JWT payload
*/
export function jwtDecode<T>(token: string): T {
export function decodeJwt<T>(token: string): T {
const base64 = token.split('.')[1]
.replace(/-/g, '+').replace(/_/g, '/');
return <T>JSONAttemptParse(decodeURIComponent(atob(base64).split('').map(function(c) {

View File

@ -1,4 +1,5 @@
import {TypedEmitter, TypedEvents} from './emitter';
import {JSONSanitize} from './objects.ts';
export const CliEffects = {
CLEAR: "\x1b[0m",
@ -64,49 +65,41 @@ export class Logger extends TypedEmitter<LoggerEvents> {
super();
}
private pad(text: any, length: number, char: string, end = false) {
const t = text.toString();
const l = length - t.length;
if(l <= 0) return t;
const padding = Array(~~(l / char.length)).fill(char).join('');
return !end ? padding + t : t + padding;
}
private format(...text: string[]): string {
protected format(...text: any[]): string {
const now = new Date();
const timestamp = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()} ${this.pad(now.getHours().toString(), 2, '0')}:${this.pad(now.getMinutes().toString(), 2, '0')}:${this.pad(now.getSeconds().toString(), 2, '0')}.${this.pad(now.getMilliseconds().toString(), 3, '0', true)}`;
return `${timestamp}${this.namespace ? ` [${this.namespace}]` : ''} ${text.join(' ')}`;
const timestamp = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()} ${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}.${now.getMilliseconds().toString().padEnd(3, '0')}`;
return `${timestamp}${this.namespace ? ` [${this.namespace}]` : ''} ${text.map(t => typeof t == 'string' ? t : JSONSanitize(t, 2)).join(' ')}`;
}
debug(...args: string[]) {
debug(...args: any[]) {
if(Logger.LOG_LEVEL < LOG_LEVEL.DEBUG) return;
const str = this.format(...args);
Logger.emit(LOG_LEVEL.DEBUG, str);
console.debug(CliForeground.LIGHT_GREY + str + CliEffects.CLEAR);
}
log(...args: string[]) {
log(...args: any[]) {
if(Logger.LOG_LEVEL < LOG_LEVEL.LOG) return;
const str = this.format(...args);
Logger.emit(LOG_LEVEL.LOG, str);
console.log(CliEffects.CLEAR + str);
}
info(...args: string[]) {
info(...args: any[]) {
if(Logger.LOG_LEVEL < LOG_LEVEL.INFO) return;
const str = this.format(...args);
Logger.emit(LOG_LEVEL.INFO, str);
console.info(CliForeground.BLUE + str + CliEffects.CLEAR);
}
warn(...args: string[]) {
warn(...args: any[]) {
if(Logger.LOG_LEVEL < LOG_LEVEL.WARN) return;
const str = this.format(...args);
Logger.emit(LOG_LEVEL.WARN, str);
console.warn(CliForeground.YELLOW + str + CliEffects.CLEAR);
}
error(...args: string[]) {
error(...args: any[]) {
if(Logger.LOG_LEVEL < LOG_LEVEL.ERROR) return;
const str = this.format(...args);
Logger.emit(LOG_LEVEL.ERROR, str);

View File

@ -1,3 +1,4 @@
import {PathEvent} from './path-events.ts';
import {md5} from './string';
/**
@ -11,3 +12,16 @@ export function gravatar(email: string, def='mp') {
if(!email) return '';
return `https://www.gravatar.com/avatar/${md5(email)}?d=${def}`;
}
/**
* Escape any regex special characters to avoid misinterpretation during search
*
* @param {string} value String which should be escaped
* @return {string} New escaped sequence
*/
export function escapeRegex(value: string) {
return value.replace(/[.*+?^${}()|\[\]\\]/g, '\\$&');
}
export type Listener = (event: PathEvent, ...args: any[]) => any;
export type Unsubscribe = () => void;

View File

@ -29,10 +29,10 @@ export function clean<T>(obj: T, undefinedOnly = false): Partial<T> {
* Should be replaced by `structuredClone` once released.
* @param {T} value Object to copy
* @returns {T} Type
* @deprecated Please use `structuredClone`
*/
export function deepCopy<T>(value: T): T {
return structuredClone(value);
try {return structuredClone(value); }
catch { return JSON.parse(JSONSanitize(value)); }
}
/**
@ -91,7 +91,6 @@ export function dotNotation<T>(obj: any, prop: string, set?: T): T | undefined {
}, obj);
}
/**
* Convert object into URL encoded query string
*
@ -110,7 +109,6 @@ export function encodeQuery(data: any): string {
).join('&');
}
/**
* Recursively flatten a nested object, while maintaining key structure
*
@ -128,8 +126,9 @@ export function encodeQuery(data: any): string {
export function flattenObj(obj: any, parent?: any, result: any = {}) {
if(typeof obj === "object" && !Array.isArray(obj)) {
for(const key of Object.keys(obj)) {
const propName = parent ? parent + '.' + key : key;
if(typeof obj[key] === 'object') {
const propName = parent ? `${parent}.${key}` : key;
if(typeof obj[key] === 'object' && obj[key] != null && !Array.isArray(obj[key])) {
console.log(propName, );
flattenObj(obj[key], propName, result);
} else {
result[propName] = obj[key];
@ -216,14 +215,25 @@ export function mixin(target: any, constructors: any[]) {
/**
* Parse JSON but return the original string if it fails
*
* @param {string} json JSON string to parse
* @param {any} json JSON string to parse
* @return {string | T} Object if successful, original string otherwise
*/
export function JSONAttemptParse<T>(json: string): T | string {
try { return JSON.parse(json); }
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.
*
@ -234,10 +244,8 @@ export function JSONAttemptParse<T>(json: string): T | string {
export function JSONSanitize(obj: any, space?: number): string {
let cache: any[] = [];
return JSON.stringify(obj, (key, value) => {
if (typeof value === 'object' && value !== null) {
if (cache.includes(value)) return;
cache.push(value);
}
if(typeof value === 'object' && value !== null)
if(!cache.includes(value)) cache.push(value);
return value;
}, space);
}

280
src/path-events.ts Normal file
View File

@ -0,0 +1,280 @@
import {makeArray} from './array.ts';
import {ASet} from './aset.ts';
/**
* Available methods:
* * - All/Wildcard
* n - None
* c - Create
* r - Read
* u - Update
* d - Delete
* x - Execute
*/
export type Method = '*' | 'n' | 'c' | 'r' | 'u' | 'd' | 'x';
/**
* Shorthand for creating Event from a string
*
* @example
* ```ts
* const event: Event = PE`users/system:*`;
* ```
*
* @param {TemplateStringsArray} str String that will be parsed into Event
* @param {string} args
* @return {PathEvent} Event object
*/
export function PE(str: TemplateStringsArray, ...args: any[]) {
const combined = [];
for(let i = 0; i < str.length || i < args.length; i++) {
if(str[i]) combined.push(str[i]);
if(args[i]) combined.push(args[i]);
}
return new PathEvent(combined.join(''));
}
/**
* Shorthand for creating Event strings, ensures paths are correct
*
* @param {TemplateStringsArray} str
* @param {string} args
* @return {string}
* @constructor
*/
export function PES(str: TemplateStringsArray, ...args: any[]) {
let combined = [];
for(let i = 0; i < str.length || i < args.length; i++) {
if(str[i]) combined.push(str[i]);
if(args[i]) combined.push(args[i]);
}
const [paths, methods] = combined.join('').split(':');
return PathEvent.toString(paths, <any>methods?.split(''));
}
export class PathError extends Error { }
/**
* A event broken down into its core components for easy processing
* Event Structure: `module/path/name:property:method`
* Example: `users/system:crud` or `storage/some/path/file.txt:r`
*/
export class PathEvent {
/** First directory in path */
module!: string;
/** Entire path, including the module & name */
fullPath!: string;
/** Path including the name, excluding the module */
path!: string;
/** Last sagment of path */
name!: string;
/** List of methods */
methods!: ASet<Method>;
/** All/Wildcard specified */
get all(): boolean { return this.methods.has('*') }
set all(v: boolean) { v ? new ASet<Method>(['*']) : this.methods.delete('*'); }
/** None specified */
get none(): boolean { return this.methods.has('n') }
set none(v: boolean) { v ? this.methods = new ASet<Method>(['n']) : this.methods.delete('n'); }
/** Create method specified */
get create(): boolean { return !this.methods.has('n') && (this.methods.has('*') || this.methods.has('c')) }
set create(v: boolean) { v ? this.methods.delete('n').add('c') : this.methods.delete('c'); }
/** Read method specified */
get read(): boolean { return !this.methods.has('n') && (this.methods.has('*') || this.methods.has('r')) }
set read(v: boolean) { v ? this.methods.delete('n').add('r') : this.methods.delete('r'); }
/** Update method specified */
get update(): boolean { return !this.methods.has('n') && (this.methods.has('*') || this.methods.has('u')) }
set update(v: boolean) { v ? this.methods.delete('n').add('u') : this.methods.delete('u'); }
/** Delete method specified */
get delete(): boolean { return !this.methods.has('n') && (this.methods.has('*') || this.methods.has('d')) }
set delete(v: boolean) { v ? this.methods.delete('n').add('d') : this.methods.delete('d'); }
constructor(Event: string | PathEvent) {
if(typeof Event == 'object') return Object.assign(this, Event);
let [p, scope, method] = Event.replaceAll(/\/{2,}/g, '/').split(':');
if(!method) method = scope || '*';
if(p == '*' || !p && method == '*') {
p = '';
method = '*';
}
let temp = p.split('/').filter(p => !!p);
this.module = temp.splice(0, 1)[0]?.toLowerCase() || '';
this.fullPath = p;
this.path = temp.join('/');
this.name = temp.pop() || '';
this.methods = new ASet(<any>method.split(''));
}
/**
* Combine multiple events into one parsed object. Longest path takes precedent, but all subsequent methods are
* combined until a "none" is reached
*
* @param {string | PathEvent} paths Events as strings or pre-parsed
* @return {PathEvent} Final combined permission
*/
static combine(...paths: (string | PathEvent)[]): PathEvent {
let hitNone = false;
const combined = paths.map(p => new PathEvent(p))
.toSorted((p1, p2) => {
const l1 = p1.fullPath.length, l2 = p2.fullPath.length;
return l1 < l2 ? 1 : (l1 > l2 ? -1 : 0);
}).reduce((acc, p) => {
if(p.none) hitNone = true;
if(!acc) return p;
if(hitNone) return acc;
acc.methods = [...acc.methods, ...p.methods];
return acc;
}, <any>null);
combined.methods = new ASet<Method>(combined.methods);
return combined;
}
/**
* Filter a set of paths based on the target
*
* @param {string | PathEvent | (string | PathEvent)[]} target Array of events that will filtered
* @param filter {...PathEvent} Must container one of
* @return {boolean} Whether there is any overlap
*/
static filter(target: string | PathEvent | (string | PathEvent)[], ...filter: (string | PathEvent)[]): PathEvent[] {
const parsedTarget = makeArray(target).map(pe => new PathEvent(pe));
const parsedFind = makeArray(filter).map(pe => new PathEvent(pe));
return parsedTarget.filter(t => {
if(!t.fullPath && t.all) return true;
return !!parsedFind.find(f =>
(t.fullPath.startsWith(f.fullPath) || f.fullPath.startsWith(t.fullPath)) &&
(f.all || t.all || t.methods.intersection(f.methods).length));
});
}
/**
* Squash 2 sets of paths & return true if any overlap is found
*
* @param {string | PathEvent | (string | PathEvent)[]} target Array of Events as strings or pre-parsed
* @param has Target must have at least one of these path
* @return {boolean} Whether there is any overlap
*/
static has(target: string | PathEvent | (string | PathEvent)[], ...has: (string | PathEvent)[]): boolean {
const parsedRequired = makeArray(has).map(pe => new PathEvent(pe));
const parsedTarget = makeArray(target).map(pe => new PathEvent(pe));
return !!parsedRequired.find(r => {
if(!r.fullPath && r.all) return true;
const filtered = parsedTarget.filter(p => r.fullPath.startsWith(p.fullPath));
if(!filtered.length) return false;
const combined = PathEvent.combine(...filtered);
return (!combined.none && (combined.all || r.all)) || combined.methods.intersection(r.methods).length;
});
}
/**
* Squash 2 sets of paths & return true if the target has all paths
*
* @param {string | PathEvent | (string | PathEvent)[]} target Array of Events as strings or pre-parsed
* @param has Target must have all these paths
* @return {boolean} Whether there is any overlap
*/
static hasAll(target: string | PathEvent | (string | PathEvent)[], ...has: (string | PathEvent)[]): boolean {
return has.filter(h => PathEvent.has(target, h)).length == has.length;
}
/**
* Same as `has` but raises an error if there is no overlap
*
* @param {string | string[]} target Array of Events as strings or pre-parsed
* @param has Target must have at least one of these path
*/
static hasFatal(target: string | PathEvent | (string | PathEvent)[], ...has: (string | PathEvent)[]): void {
if(!PathEvent.has(target, ...has)) throw new PathError(`Requires one of: ${makeArray(has).join(', ')}`);
}
/**
* Same as `hasAll` but raises an error if the target is missing any paths
*
* @param {string | string[]} target Array of Events as strings or pre-parsed
* @param has Target must have all these paths
*/
static hasAllFatal(target: string | PathEvent | (string | PathEvent)[], ...has: (string | PathEvent)[]): void {
if(!PathEvent.hasAll(target, ...has)) throw new PathError(`Requires all: ${makeArray(has).join(', ')}`);
}
/**
* Create event string from its components
*
* @param {string | string[]} path Event path
* @param {Method} methods Event method
* @return {string} String representation of Event
*/
static toString(path: string | string[], methods: Method | Method[]): string {
let p = makeArray(path).filter(p => p != null).join('/');
p = p?.trim().replaceAll(/\/{2,}/g, '/').replaceAll(/(^\/|\/$)/g, '');
if(methods?.length) p += `:${makeArray(methods).map(m => m.toLowerCase()).join('')}`;
return p;
}
/**
* Filter a set of paths based on this event
*
* @param {string | PathEvent | (string | PathEvent)[]} target Array of events that will filtered
* @return {boolean} Whether there is any overlap
*/
filter(target: string | PathEvent | (string | PathEvent)[]): PathEvent[] {
return PathEvent.filter(target, this);
}
/**
* Create event string from its components
*
* @return {string} String representation of Event
*/
toString() {
return PathEvent.toString(this.fullPath, this.methods);
}
}
export type PathListener = (event: PathEvent, ...args: any[]) => any;
export type PathUnsubscribe = () => void;
export interface IPathEventEmitter {
emit(event: string, ...args: any[]): void;
off(listener: PathListener): void;
on(event: string, listener: PathListener): PathUnsubscribe;
once(event: string, listener?: PathListener): Promise<any>;
relayEvents(emitter: PathEventEmitter): void;
}
/**
* Event emitter that uses paths allowing listeners to listen to different combinations of modules, paths & methods
*/
export class PathEventEmitter implements IPathEventEmitter{
private listeners: [PathEvent, PathListener][] = [];
emit(event: string | PathEvent, ...args: any[]) {
const parsed = new PathEvent(event);
this.listeners.filter(l => PathEvent.has(l[0], event))
.forEach(async l => l[1](parsed, ...args));
};
off(listener: PathListener) {
this.listeners = this.listeners.filter(l => l[1] != listener);
}
on(event: string | string[], listener: PathListener): PathUnsubscribe {
makeArray(event).forEach(e => this.listeners.push([new PathEvent(e), listener]));
return () => this.off(listener);
}
once(event: string | string[], listener?: PathListener): Promise<any> {
return new Promise(res => {
const unsubscribe = this.on(event, (event: PathEvent, ...args: any[]) => {
res(args.length < 2 ? args[0] : args);
if(listener) listener(event, ...args);
unsubscribe();
});
});
}
relayEvents(emitter: IPathEventEmitter) {
emitter.on('*', (event, ...args) => this.emit(event, ...args));
}
}

53
src/search.ts Normal file
View File

@ -0,0 +1,53 @@
import {dotNotation, JSONAttemptParse} from './objects.ts';
export function search(rows: any[], search: string, regex?: boolean, transform: Function = (r: any) => r) {
if(!rows) return [];
return rows.filter(r => {
// Empty search
const value = transform(r);
if(!search) return true;
// Regex search
if(regex) {
return !!Object.values(value).filter((v: any) => {
try { return RegExp(search, 'gm').test(v.toString()); }
catch { return false; }
}).length
} else {
return testCondition(search, r);
}
});
}
export function testCondition(condition: string, row: any) {
const evalBoolean = (a: any, op: string, b: any): boolean => {
switch(op) {
case '=':
case '==': return a == b;
case '!=': return a != b;
case '>': return a > b;
case '>=': return a >= b;
case '<': return a < b;
case '<=': return a <= b;
default: return false;
}
}
const or = condition.split('||').map(p => p.trim()).filter(p => !!p);
return -1 != or.findIndex(p => {
// Make sure all ANDs pass
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);
if(prop) {
const key = Object.keys(row).find(k => k.toLowerCase() == prop[1].toLowerCase());
return evalBoolean(dotNotation<any>(row, key || prop[1]), prop[2], JSONAttemptParse(prop[3]));
}
// Case-sensitive
const v = Object.values(row).map(v => typeof v == 'object' && v != null ? JSON.stringify(v) : v).join('');
if(/[A-Z]/g.test(condition)) return v.includes(p);
// Case-insensitive
return v.toLowerCase().includes(p);
}).length == and.length;
});
}

View File

@ -1,24 +1,30 @@
import {dotNotation, flattenObj} from './objects.ts';
/**
* String of all letters
*/
const LETTER_LIST = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
export const LETTER_LIST = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
/**
* String of all numbers
*/
const NUMBER_LIST = '0123456789';
export const NUMBER_LIST = '0123456789';
/**
* String of all symbols
*/
const SYMBOL_LIST = '~`!@#$%^&*()_-+={[}]|\\:;"\'<,>.?/';
export const SYMBOL_LIST = '~`!@#$%^&*()_-+={[}]|\\:;"\'<,>.?/';
/**
* String of all letters, numbers & symbols
*/
const CHAR_LIST = LETTER_LIST + NUMBER_LIST + SYMBOL_LIST;
export const CHAR_LIST = LETTER_LIST + LETTER_LIST.toLowerCase() + NUMBER_LIST + SYMBOL_LIST;
/**
* Converts text to camelCase
*/
export function camelCase(str?: string) {
const text = pascalCase(str);
return text[0].toLowerCase() + text.slice(1);
}
/**
* Convert number of bytes into a human-readable size
@ -44,7 +50,7 @@ export function formatBytes(bytes: number, decimals = 2) {
export function formatPhoneNumber(number: string) {
const parts = /(\+?1)?.*?(\d{3}).*?(\d{3}).*?(\d{4})/g.exec(number);
if(!parts) throw new Error(`Number cannot be parsed: ${number}`);
return `${parts[1] ?? ''} (${parts[2]}) ${parts[3]}-${parts[4]}`.trim();
return `${parts[1] ? '+1' : ''} (${parts[2]}) ${parts[3]}-${parts[4]}`.trim();
}
/**
@ -60,11 +66,23 @@ export function formatPhoneNumber(number: string) {
* @param {string} str - Value that will be injected to parent
* @param {number} index - Position to inject string at
* @returns {string} - New string
* @deprecated use `strSplice()`
*/
export function insertAt(target: string, str: string, index: number): String {
return `${target.slice(0, index)}${str}${target.slice(index + 1)}`;
}
/**
* Converts text to kebab-case
*/
export function kebabCase(str: string) {
if(!str) return '';
return str.replaceAll(/(^[^a-zA-Z]+|[^a-zA-Z0-9-_])/g, '')
.replaceAll(/([A-Z]|[0-9]+)/g, (...args) => `-${args[0].toLowerCase()}`)
.replaceAll(/([0-9])([a-z])/g, (...args) => `${args[1]}-${args[2]}`)
.replaceAll(/[^a-z0-9]+(\w?)/g, (...args) => `-${args[1] ?? ''}`).toLowerCase();
}
/**
* Add padding to string
*
@ -87,6 +105,18 @@ export function pad(text: any, length: number, char: string = ' ', start = true)
return text.toString().padEnd(length, char);
}
/**
* Convert text to PascalCase
* @param {string} str
* @return {string}
*/
export function pascalCase(str?: string) {
if(!str) return '';
const text = str.replaceAll(/(^[^a-zA-Z]+|[^a-zA-Z0-9-_])/g, '')
.replaceAll(/[^a-zA-Z0-9]+(\w?)/g, (...args) => args[1]?.toUpperCase() || '');
return text[0].toUpperCase() + text.slice(1);
}
/**
* Generate a random hexadecimal value
*
@ -150,6 +180,32 @@ export function randomStringBuilder(length: number, letters = false, numbers = f
}).join('');
}
/**
* Converts text to snake_case
*/
export function snakeCase(str?: string) {
if(!str) return '';
return str.replaceAll(/(^[^a-zA-Z]+|[^a-zA-Z0-9-_])/g, '')
.replaceAll(/([A-Z]|[0-9]+)/g, (...args) => `_${args[0].toLowerCase()}`)
.replaceAll(/([0-9])([a-z])/g, (...args) => `${args[1]}_${args[2]}`)
.replaceAll(/[^a-z0-9]+(\w?)/g, (...args) => `_${args[1] ?? ''}`).toLowerCase();
}
/**
* Splice a string together (Similar to Array.splice)
*
* @param {string} str String that will be modified
* @param {number} start Start index of splice
* @param {number} deleteCount Number of characters to remove in splice
* @param {string} insert Insert new string into splice
* @return {string} Returned spliced string
*/
export function strSplice(str: string, start: number, deleteCount: number, insert = '') {
const before = str.slice(0, start);
const after = str.slice(start + deleteCount);
return before + insert + after;
}
/**
* Find all substrings that match a given pattern.
*
@ -195,6 +251,7 @@ export type ParsedUrl = {
*
* @param {string} url URL string that will be parsed
* @returns {RegExpExecArray} Parts of URL
* @deprecated Use built-in URL object: `new URL('...')`;
*/
export function parseUrl(url: string): ParsedUrl {
const processed = new RegExp(

View File

@ -1,17 +1,151 @@
/**
* Return date formated highest to lowest: YYYY-MM-DD H:mm AM
* Like setInterval but will adjust the timeout value to account for runtime
* @param {Function} cb Callback function that will be ran
* @param {number} ms Run function ever x seconds
* @return {() => void}
*/
export function adjustedInterval(cb: Function, ms: number) {
let cancel = false, timeout: any = null;
const p = async () => {
if (cancel) return;
const start = new Date().getTime();
await cb();
const end = new Date().getTime();
timeout = setTimeout(() => p(), ms - (end - start) || 1);
};
p();
return () => {
cancel = true;
if(timeout) clearTimeout(timeout);
}
}
/**
* Format date
*
* @param {Date | number | string} date Date or timestamp to convert to string
* @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(date: Date | number | string): string {
if(typeof date == 'number' || typeof date == 'string') date = new Date(date);
let hours = date.getHours(), postfix = 'AM';
if(hours >= 12) {
if(hours > 12) hours -= 12;
postfix = 'PM';
} else if(hours == 0) hours = 12;
return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}, ${hours}:${date.getMinutes().toString().padStart(2, '0')} ${postfix}`;
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]
];
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`;
}
}
function tzOffset(offset: number) {
const hours = ~~(offset / 60);
const minutes = offset % 60;
return (offset > 0 ? '-' : '') + `${hours}:${minutes.toString().padStart(2, '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]
};
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]);
}
/**
* Run a function immediately & repeat every x ms
*
* @param {() => any} fn Callback function
* @param {number} interval Repeat in ms
* @return {number} Clear Interval ID
*/
export function instantInterval(fn: () => any, interval: number) {
fn();
return setInterval(fn, interval);
}
/**
@ -36,14 +170,14 @@ export function sleep(ms: number): Promise<void> {
* ```js
* let loading = true;
* setTimeout(() => wait = false, 1000);
* await sleepUntil(() => loading); // Won't continue until loading flag is false
* await sleepWhile(() => loading); // Won't continue until loading flag is false
* ```
*
* @param {() => boolean | Promise<boolean>} fn Return true to continue
* @param {number} checkInterval Run function ever x milliseconds
* @return {Promise<void>} Callback when sleep is over
*/
export async function sleepUntil(fn : () => boolean | Promise<boolean>, checkInterval = 100): Promise<void> {
export async function sleepWhile(fn : () => boolean | Promise<boolean>, checkInterval = 100): Promise<void> {
while(await fn()) await sleep(checkInterval);
}

View File

@ -15,6 +15,6 @@
*
* @return {Array<keyof T>} Available keys
*/
export function tyoeKeys<T extends object>() {
export function typeKeys<T extends object>() {
return Object.keys(<T>{}) as Array<keyof T>;
}

View File

@ -13,7 +13,7 @@ export default defineConfig({
}
},
emptyOutDir: true,
minify: true,
minify: false,
sourcemap: true
},
plugins: [dts()],