Compare commits

...

15 Commits

Author SHA1 Message Date
f9971d7ce1 Added size getter for cache
All checks were successful
Build / Build NPM Project (push) Successful in 1m7s
Build / Publish Docs (push) Successful in 1m7s
Build / Tag Version (push) Successful in 13s
2026-07-24 21:56:08 -04:00
fbe46ddb64 Replaced regex search flag with regex operator and added unique/distinct/duplicate functions
All checks were successful
Build / Publish Docs (push) Successful in 1m8s
Build / Build NPM Project (push) Successful in 37s
Build / Tag Version (push) Successful in 9s
2026-07-22 15:48:02 -04:00
d29d0d5121 PathEvent caching fix
All checks were successful
Build / Publish Docs (push) Successful in 1m47s
Build / Build NPM Project (push) Successful in 34s
Build / Tag Version (push) Successful in 9s
2026-07-14 20:21:05 -04:00
5b3bbdf02c Added node support for uploadWithProgress
All checks were successful
Build / Publish Docs (push) Successful in 54s
Build / Build NPM Project (push) Successful in 38s
Build / Tag Version (push) Successful in 9s
2026-07-13 22:16:58 -04:00
6b379270a9 Fixed path event dir property
All checks were successful
Build / Publish Docs (push) Successful in 1m3s
Build / Build NPM Project (push) Successful in 36s
Build / Tag Version (push) Successful in 9s
2026-06-15 09:55:38 -04:00
28716c7b5a Added titleCase helper
All checks were successful
Build / Publish Docs (push) Successful in 2m20s
Build / Build NPM Project (push) Successful in 2m29s
Build / Tag Version (push) Successful in 9s
2026-04-15 21:59:42 -04:00
d530f6abdf Cross timezone day of week fix
All checks were successful
Build / Build NPM Project (push) Successful in 53s
Build / Tag Version (push) Successful in 11s
Build / Publish Docs (push) Successful in 40s
2026-04-11 23:19:10 -04:00
cbee6a4509 Timezone abbreviation support
All checks were successful
Build / Publish Docs (push) Successful in 1m48s
Build / Build NPM Project (push) Successful in 2m48s
Build / Tag Version (push) Successful in 10s
2026-04-11 16:29:53 -04:00
e8f81bb584 Proper xml to json format
All checks were successful
Build / Publish Docs (push) Successful in 49s
Build / Build NPM Project (push) Successful in 56s
Build / Tag Version (push) Successful in 10s
2026-04-04 18:32:48 -04:00
4179b4010a Revert "added fast-xml-parser for testing"
Some checks failed
Build / Tag Version (push) Has been cancelled
Build / Build NPM Project (push) Has been cancelled
Build / Publish Docs (push) Has been cancelled
This reverts commit 15ac52b6a0.
2026-04-04 18:20:44 -04:00
15ac52b6a0 added fast-xml-parser for testing
Some checks failed
Build / Publish Docs (push) Failing after 16s
Build / Build NPM Project (push) Failing after 54s
Build / Tag Version (push) Has been skipped
2026-04-04 18:18:24 -04:00
c778f3d280 Dependency updates, bump to 0.29.0
All checks were successful
Build / Publish Docs (push) Successful in 59s
Build / Build NPM Project (push) Successful in 1m6s
Build / Tag Version (push) Successful in 13s
2026-04-04 17:05:17 -04:00
d0af3a63bc Added to/from xml helpers
All checks were successful
Build / Publish Docs (push) Successful in 57s
Build / Build NPM Project (push) Successful in 1m7s
Build / Tag Version (push) Successful in 13s
2026-04-04 17:00:10 -04:00
bf73d2670b Added to/from xml helpers
Some checks failed
Build / Tag Version (push) Has been cancelled
Build / Publish Docs (push) Has been cancelled
Build / Build NPM Project (push) Has been cancelled
2026-04-04 16:58:53 -04:00
361613f507 Added decodeHTML
All checks were successful
Build / Publish Docs (push) Successful in 1m6s
Build / Build NPM Project (push) Successful in 1m10s
Build / Tag Version (push) Successful in 11s
2026-03-29 22:33:21 -04:00
13 changed files with 7820 additions and 173 deletions

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@ uploads
public/momentum*js public/momentum*js
junit.xml junit.xml
/docs/ /docs/
main.mjs
# Logs # Logs
*.log *.log

6927
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -31,6 +31,8 @@ export class Cache<K extends string | number | symbol, T> {
/** Await initial loading */ /** Await initial loading */
loading = new Promise<void>(r => this._loading = r); loading = new Promise<void>(r => this._loading = r);
get size() { return this.store.size; }
/** /**
* Create new cache * Create new cache
* @param {keyof T} key Default property to use as primary key * @param {keyof T} key Default property to use as primary key

View File

@@ -83,6 +83,7 @@ export function timestampFilename(name?: string, date: Date | number | string =
/** /**
* Upload file to URL with progress callback using PromiseProgress * Upload file to URL with progress callback using PromiseProgress
* Works in both browser (with progress) and Node.js (fallback without progress)
* *
* @param {{url: string, files: File[], headers?: {[p: string]: string}, withCredentials?: boolean}} options * @param {{url: string, files: File[], headers?: {[p: string]: string}, withCredentials?: boolean}} options
* @return {PromiseProgress<T>} Promise of request with `onProgress` callback * @return {PromiseProgress<T>} Promise of request with `onProgress` callback
@@ -93,10 +94,12 @@ export function uploadWithProgress<T>(options: {
headers?: {[key: string]: string}; headers?: {[key: string]: string};
withCredentials?: boolean; withCredentials?: boolean;
}): PromiseProgress<T> { }): PromiseProgress<T> {
// Browser environment - use XMLHttpRequest for progress
if (typeof XMLHttpRequest !== 'undefined') {
return new PromiseProgress<T>((res, rej, prog) => { return new PromiseProgress<T>((res, rej, prog) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
const formData = new FormData(); const formData = new FormData();
options.files.forEach(f => formData.append('file', f)); options.files.forEach(f => formData.append('files', f));
xhr.withCredentials = !!options.withCredentials; xhr.withCredentials = !!options.withCredentials;
xhr.upload.addEventListener('progress', (event) => event.lengthComputable ? prog(event.loaded / event.total) : null); xhr.upload.addEventListener('progress', (event) => event.lengthComputable ? prog(event.loaded / event.total) : null);
@@ -108,4 +111,23 @@ export function uploadWithProgress<T>(options: {
Object.entries(options.headers || {}).forEach(([key, value]) => xhr.setRequestHeader(key, value)); Object.entries(options.headers || {}).forEach(([key, value]) => xhr.setRequestHeader(key, value));
xhr.send(formData); xhr.send(formData);
}); });
}
// Node.js environment - fallback to fetch without progress
return new PromiseProgress<T>(async (res, rej) => {
try {
const formData = new FormData();
options.files.forEach(f => formData.append('files', f));
const response = await fetch(options.url, {method: 'POST', headers: options.headers || {}, body: formData});
if(!response.ok) {
const error = await response.text();
rej(JSONAttemptParse(error));
} else {
const result = await response.text();
res(<T>JSONAttemptParse(result));
}
} catch (error) {
rej(error);
}
});
} }

View File

@@ -1,3 +1,5 @@
export * from 'var-persist';
export * from './arg-parser'; export * from './arg-parser';
export * from './array'; export * from './array';
export * from './aset'; export * from './aset';
@@ -23,4 +25,4 @@ export * from './template';
export * from './time'; export * from './time';
export * from './tts'; export * from './tts';
export * from './types'; export * from './types';
export * from 'var-persist'; export * from './xml';

View File

@@ -76,8 +76,18 @@ export class PathEvent {
/** Whether this path contains glob patterns */ /** Whether this path contains glob patterns */
hasGlob!: boolean; hasGlob!: boolean;
/** Internal cache for PathEvent instances to avoid redundant parsing */ /** Internal cache for parsed path data (plain objects, not instances) */
private static pathEventCache: Map<string, PathEvent> = new Map(); private static pathEventCache: Map<string, {
module: string;
fullPath: string;
dir: string;
path: string;
name: string;
methods: Method[];
hasGlob: boolean;
}> = new Map();
/** Max size for path cache before LRU eviction */
private static readonly MAX_PATH_CACHE_SIZE = 1000;
/** Cache for compiled permissions (path + required permissions → result) */ /** Cache for compiled permissions (path + required permissions → result) */
private static permissionCache: Map<string, PathEvent> = new Map(); private static permissionCache: Map<string, PathEvent> = new Map();
/** Max size for permission cache before LRU eviction */ /** Max size for permission cache before LRU eviction */
@@ -111,8 +121,20 @@ export class PathEvent {
return; return;
} }
// Check cache and reconstruct from plain object
if(PathEvent.pathEventCache.has(e)) { if(PathEvent.pathEventCache.has(e)) {
Object.assign(this, PathEvent.pathEventCache.get(e)!); const cached = PathEvent.pathEventCache.get(e)!;
// Move to end (LRU - most recently used)
PathEvent.pathEventCache.delete(e);
PathEvent.pathEventCache.set(e, cached);
this.module = cached.module;
this.fullPath = cached.fullPath;
this.dir = cached.dir;
this.path = cached.path;
this.name = cached.name;
this.methods = new ASet(cached.methods);
this.hasGlob = cached.hasGlob;
return; return;
} }
@@ -128,19 +150,49 @@ export class PathEvent {
this.name = ''; this.name = '';
this.methods = new ASet<Method>(p === '*' ? ['*'] : <any>method.split('')); this.methods = new ASet<Method>(p === '*' ? ['*'] : <any>method.split(''));
this.hasGlob = true; this.hasGlob = true;
PathEvent.pathEventCache.set(e, this);
// LRU eviction
if(PathEvent.pathEventCache.size >= PathEvent.MAX_PATH_CACHE_SIZE) {
const firstKey = PathEvent.pathEventCache.keys().next().value;
if(firstKey) PathEvent.pathEventCache.delete(firstKey);
}
PathEvent.pathEventCache.set(e, {
module: this.module,
fullPath: this.fullPath,
dir: this.dir,
path: this.path,
name: this.name,
methods: [...this.methods],
hasGlob: this.hasGlob
});
return; return;
} }
let temp = p.split('/').filter(p => !!p); let temp = p.split('/').filter(p => !!p);
this.module = temp.splice(0, 1)[0] || ''; this.module = temp.splice(0, 1)[0] || '';
this.path = temp.join('/'); this.path = temp.join('/');
this.dir = temp.length > 2 ? temp.slice(0, -1).join('/') : ''; this.dir = temp.length > 1 ? temp.slice(0, -1).join('/') : '';
this.fullPath = `${this.module}${this.module && this.path ? '/' : ''}${this.path}`; this.fullPath = `${this.module}${this.module && this.path ? '/' : ''}${this.path}`;
this.name = temp.pop() || ''; this.name = temp.pop() || '';
this.hasGlob = this.fullPath.includes('*'); this.hasGlob = this.fullPath.includes('*');
this.methods = new ASet(<any>method.split('')); this.methods = new ASet(<any>method.split(''));
PathEvent.pathEventCache.set(e, this);
// LRU eviction
if(PathEvent.pathEventCache.size >= PathEvent.MAX_PATH_CACHE_SIZE) {
const firstKey = PathEvent.pathEventCache.keys().next().value;
if(firstKey) PathEvent.pathEventCache.delete(firstKey);
}
PathEvent.pathEventCache.set(e, {
module: this.module,
fullPath: this.fullPath,
dir: this.dir,
path: this.path,
name: this.name,
methods: [...this.methods],
hasGlob: this.hasGlob
});
} }
/** /**

View File

@@ -1,44 +1,122 @@
import {JSONAttemptParse, JSONSerialize} from './json.ts'; import {JSONAttemptParse, JSONSerialize} from './json.ts';
import {dotNotation} from './objects.ts'; import {dotNotation} from './objects.ts';
/** const VALID_FLAGS = new Set([...'dgimsuvy']);
* Filters an array of objects based on a search term and optional regex checking.
* function toRegex(pattern: string, defaultFlags = 'gm'): RegExp | null {
* @param {Array} rows Array of objects to filter const lit = /^\/(.+)\/([a-zA-Z]*)$/.exec(pattern);
* @param {string} search The logic string or regext to filter on try { return lit ? new RegExp(lit[1], lit[2]) : new RegExp(pattern, defaultFlags); }
* @param {boolean} [regex=false] Treat search expression as regex catch { return null; }
* @param {Function} [transform=(r) => r] - Transform rows before filtering
* @return {Array} The filtered array of objects that matched search
*/
export function search(rows: any[], search: string, regex?: boolean, transform: Function = (r: any) => r) {
if(!rows) return [];
return rows.filter(r => {
// Empty search
if(!search) return true;
const value = transform(r);
// 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 logicTest(value, search);
}
});
} }
/** /**
* Test an object against a logic condition. By default values are checked * Filters an array of objects based on a query string.
* @param {string} condition *
* @param {object} target * Supports plain text, regex, boolean/logical operators, and dataset helpers.
* @return {boolean} *
* **Examples**
* ```js
* search(rows: T[], 'alice'): T[] // Case-Insensitive
* search(rows: T[], 'Alice'): T[] // Case-Sensitive
*
* search(rows: T[], 'name = Alice'): T[] // loose equality
* search(rows: T[], 'name != Alice'): T[] // loose inequality
* search(rows: T[], 'role += admin'): T[] // Contains
* search(rows: T[], 'status -= archived'): T[] // Not Contain
* search(rows: T[], 'age > 18'): T[] // Greater Than
* search(rows: T[], 'age >= 18'): T[] // Greater Than or Equal
* search(rows: T[], 'age < 18'): T[] // Less Than
* search(rows: T[], 'age <= 18'): T[] // Less Than or Equal
*
* search(rows: T[], '/^alice/gi'): T[] // Global Regex
* search(rows: T[], 'name =~ ^Al'): T[] // Regex Match (shorthand)
* search(rows: T[], 'name =~ /^al/i'): T[] // Regex Match (with flags)
* search(rows: T[], 'email !~ /@test\.com$/i'): T[] // Regex Not Match
*
* search(rows: T[], 'unique(email)'): T[] // Unique property values
* search(rows: T[], 'duplicate(email)'): T[] // Duplicate property values
* search(rows: T[], 'distinct(type)'): T[] // Distinct property values
*
* search(rows: T[], 'active && role != admin'): T[] // ANDs
* search(rows: T[], 'email != null || distinct(email)'): T[] // ORs
* ```
*
* @param rows - Array of objects to filter
* @param query - Query string; see supported syntax above
* @param transform - Optional transform applied to each row before matching
* @returns The filtered array of rows
*/
export function search(rows: any[], query: string, transform: (r: any) => any = r => r): any[] {
if (!rows || !query?.trim()) return rows ?? [];
const q = query.trim();
// Global regex: /pattern/flags — strict, whole string, at least one valid flag
const globalRegex = /^\/(.+)\/([a-zA-Z]+)$/.exec(q);
if (globalRegex && [...globalRegex[2]].every(f => VALID_FLAGS.has(f))) {
const re = toRegex(q);
return rows.filter(r => re && Object.values(transform(r)).some((v: any) => {
try { return re.test(v?.toString() ?? ''); } catch { return false; }
}));
}
// Split top-level && into predicates and dataset helpers
const parts = q.split('&&').map(p => p.trim());
const helpers = parts.filter(p => /^(unique|duplicate|distinct)\(\w+\)$/.test(p));
const predicate = parts.filter(p => !helpers.includes(p)).join(' && ');
let filtered = predicate
? rows.filter(r => logicTest(transform(r), predicate))
: [...rows];
for (const h of helpers) {
const [, fn, field] = /^(\w+)\((\w+)\)$/.exec(h)!;
if (fn === 'distinct') continue; // run last
const freq = new Map<any, number>();
filtered.forEach(r => { const v = dotNotation(transform(r), field); freq.set(v, (freq.get(v) ?? 0) + 1); });
filtered = filtered.filter(r => fn === 'unique' ? freq.get(dotNotation(transform(r), field)) === 1 : (freq.get(dotNotation(transform(r), field)) ?? 0) > 1);
}
for (const h of helpers.filter(h => h.startsWith('distinct'))) {
const field = /\((\w+)\)/.exec(h)![1];
const seen = new Set();
filtered = filtered.filter(r => { const v = dotNotation(transform(r), field); return seen.has(v) ? false : !!seen.add(v); });
}
return filtered;
}
/**
* Tests object against a logic string.
*
* **Property operators**
* ```
* 'alice' // case-insensitive
* 'Alice' // case-sensitive
* 'name = Alice' // loose equality (==)
* 'name == Alice' // loose equality
* 'name != Alice' // loose inequality
* 'role += admin' // field contains value
* 'status -= archived' // field does not contain value
* 'age > 21' // greater than
* 'age >= 21' // greater than or equal
* 'age < 21' // less than
* 'age <= 21' // less than or equal
* 'name =~ ^Al' // regex match (shorthand)
* 'name =~ /^al/i' // regex match (with flags)
* 'email !~ /@test\.com$/i' // regex not match
* 'status = active && role != admin' // ANDs
* 'status = active || status = pending' // ORs
* ```
*
* @param target - The object to test
* @param condition - The condition string; see supported syntax above
* @returns Whether the object satisfies the condition
*/ */
export function logicTest(target: object, condition: string): boolean { export function logicTest(target: object, condition: string): boolean {
const evalBoolean = (a: any, op: string, b: any): boolean => { const evalBoolean = (a: any, op: string, b: any): boolean => {
switch(op) { switch (op) {
case '=': case '=': case '==': return a == b;
case '==': return a == b;
case '!=': return a != b; case '!=': return a != b;
case '+=': return a?.toString().includes(b); case '+=': return a?.toString().includes(b);
case '-=': return !a?.toString().includes(b); case '-=': return !a?.toString().includes(b);
@@ -46,26 +124,23 @@ export function logicTest(target: object, condition: string): boolean {
case '>=': return a >= b; case '>=': return a >= b;
case '<': return a < b; case '<': return a < b;
case '<=': return a <= b; case '<=': return a <= b;
case '~=': try { return !!toRegex(b)?.test(a?.toString() ?? ''); } catch { return false; }
case '!~': try { return !toRegex(b)?.test(a?.toString() ?? ''); } catch { return false; }
default: return false; default: return false;
} }
} };
const or = condition.split('||').map(p => p.trim()).filter(p => !!p); const resolve = (key: string) => dotNotation<any>(target, Object.keys(target).find(k => k.toLowerCase() === key.toLowerCase()) ?? key);
return -1 != or.findIndex(p => {
// Make sure all ANDs pass const evalExpr = (expr: string): boolean => {
const and = p.split('&&').map(p => p.trim()).filter(p => !!p); const e = expr.trim();
return and.filter(p => { const prop = /^(\S+)\s*(==?|!=|~=|!~|\+=|-=|>=|>|<=|<)\s*(.+)$/.exec(e);
// Boolean operator if (prop) return evalBoolean(resolve(prop[1]), prop[2], JSONAttemptParse(prop[3].trim()));
const prop = /(\S+)\s*(==?|!=|\+=|-=|>=|>|<=|<)\s*(\S+)/g.exec(p);
if(prop) {
const key = Object.keys(target).find(k => k.toLowerCase() == prop[1].toLowerCase());
return evalBoolean(dotNotation<any>(target, key || prop[1]), prop[2], JSONAttemptParse(prop[3]));
}
// Case-sensitive
const v = Object.values(target).map(JSONSerialize).join(''); const v = Object.values(target).map(JSONSerialize).join('');
if(/[A-Z]/g.test(condition)) return v.includes(p); return /[A-Z]/.test(e) ? v.includes(e) : v.toLowerCase().includes(e.toLowerCase());
// Case-insensitive };
return v.toLowerCase().includes(p);
}).length == and.length; return condition.split('||').map(p => p.trim()).filter(Boolean).some(group =>
}); group.split('&&').map(p => p.trim()).filter(Boolean).every(evalExpr)
);
} }

View File

@@ -27,6 +27,31 @@ export function camelCase(str?: string): string {
return pascal.charAt(0).toLowerCase() + pascal.slice(1); return pascal.charAt(0).toLowerCase() + pascal.slice(1);
} }
/**
* Decode HTML escaped characters
* @param html HTML to clean up
* @returns {any}
*/
export function decodeHtml(html: string) {
return html
.replace(/&nbsp;/g, '\u00A0')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&cent;/g, '¢')
.replace(/&pound;/g, '£')
.replace(/&yen;/g, '¥')
.replace(/&euro;/g, '€')
.replace(/&copy;/g, '©')
.replace(/&reg;/g, '®')
.replace(/&trade;/g, '™')
.replace(/&times;/g, '×')
.replace(/&divide;/g, '÷')
.replace(/&#(\d+);/g, (match, dec) => String.fromCharCode(dec))
.replace(/&#x([0-9a-fA-F]+);/g, (match, hex) => String.fromCharCode(parseInt(hex, 16)))
.replace(/&amp;/g, '&'); // Always last!
}
/** /**
* Convert number of bytes into a human-readable size * Convert number of bytes into a human-readable size
@@ -216,7 +241,6 @@ export function snakeCase(str?: string): string {
return wordSegments(str).map(w => w.toLowerCase()).join("_"); return wordSegments(str).map(w => w.toLowerCase()).join("_");
} }
/** /**
* Splice a string together (Similar to Array.splice) * Splice a string together (Similar to Array.splice)
* *
@@ -232,6 +256,20 @@ export function strSplice(str: string, start: number, deleteCount: number, inser
return before + insert + after; return before + insert + after;
} }
function titleCase(str: string) {
// Normalize separators: replace underscores and hyphens with spaces
let normalizedStr = str.replace(/(_|-)/g, ' ');
// Handle CamelCase/PascalCase boundaries: insert a space before capital letters
normalizedStr = normalizedStr.replace(/([a-z])([A-Z])/g, '$1 $2');
// Lowercase the whole string, split by any whitespace, and capitalize each word
let words = normalizedStr.toLowerCase().split(/\s+/).filter(Boolean);
const titledWords = words.map(word => {
if (word.length === 0) return '';
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
});
return titledWords.join(' ');
}
/** /**
* Find all substrings that match a given pattern. * Find all substrings that match a given pattern.
* *

View File

@@ -78,19 +78,62 @@ export function dayOfYear(date: Date): number {
* *
* @param {string} format How date string will be formatted, default: `YYYY-MM-DD H:mm A` * @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 {Date | number | string} date Date or timestamp, defaults to now
* @param tz Set timezone offset * @param tz Set timezone offset in: hours (-4) or minutes (430) or IANA string (America/New_York)
* @return {string} Formated date * @return {string} Formated date
*/ */
export function formatDate(format: string = 'YYYY-MM-DD H:mm', date: Date | number | string = new Date(), tz: string | number = 'local'): string { export function formatDate(format: string = 'YYYY-MM-DD H:mm', date: Date | number | string = new Date(), tz: string | number = 'local'): string {
if (typeof date === 'number' || typeof date === 'string') date = new Date(date); if (typeof date === 'number' || typeof date === 'string') date = new Date(date);
if (isNaN(date.getTime())) throw new Error('Invalid date input'); if (isNaN(date.getTime())) throw new Error('Invalid date input');
const numericTz = typeof tz === 'number';
const localTz = tz === 'local' || (!numericTz && tz.toLowerCase?.() === 'local'); const TIMEZONE_MAP = [
const tzName = localTz ? Intl.DateTimeFormat().resolvedOptions().timeZone : numericTz ? 'UTC' : tz; { name: 'IDLW', iana: 'Etc/GMT+12', offset: -720 },
{ name: 'SST', iana: 'Pacific/Pago_Pago', offset: -660 },
{ name: 'HST', iana: 'Pacific/Honolulu', offset: -600 },
{ name: 'AKST', iana: 'America/Anchorage', offset: -540 },
{ name: 'PST', iana: 'America/Los_Angeles', offset: -480 },
{ name: 'MST', iana: 'America/Denver', offset: -420 },
{ name: 'CST', iana: 'America/Chicago', offset: -360 },
{ name: 'EST', iana: 'America/New_York', offset: -300 },
{ name: 'AST', iana: 'America/Halifax', offset: -240 },
{ name: 'BRT', iana: 'America/Sao_Paulo', offset: -180 },
{ name: 'MAT', iana: 'Atlantic/South_Georgia', offset: -120 },
{ name: 'AZOT', iana: 'Atlantic/Azores', offset: -60 },
{ name: 'UTC', iana: 'UTC', offset: 0 },
{ name: 'CET', iana: 'Europe/Paris', offset: 60 },
{ name: 'EET', iana: 'Europe/Athens', offset: 120 },
{ name: 'MSK', iana: 'Europe/Moscow', offset: 180 },
{ name: 'GST', iana: 'Asia/Dubai', offset: 240 },
{ name: 'PKT', iana: 'Asia/Karachi', offset: 300 },
{ name: 'IST', iana: 'Asia/Kolkata', offset: 330 },
{ name: 'BST', iana: 'Asia/Dhaka', offset: 360 },
{ name: 'ICT', iana: 'Asia/Bangkok', offset: 420 },
{ name: 'CST', iana: 'Asia/Shanghai', offset: 480 },
{ name: 'JST', iana: 'Asia/Tokyo', offset: 540 },
{ name: 'AEST', iana: 'Australia/Sydney', offset: 600 },
{ name: 'SBT', iana: 'Pacific/Guadalcanal', offset: 660 },
{ name: 'TOT', iana: 'Pacific/Tongatapu', offset: 780 },
{ name: 'LINT', iana: 'Pacific/Kiritimati', offset: 840 },
];
let numericTz = typeof tz === 'number';
const localTz = tz === 'local' || (!numericTz && tz.toString().toLowerCase?.() === 'local');
let tzName = localTz ? Intl.DateTimeFormat().resolvedOptions().timeZone : numericTz ? 'UTC' : tz;
let offsetMinutes = 0;
if (numericTz) {
// Convert hours to minutes if offset is small (likely hours)
offsetMinutes = Math.abs(tz as number) < 24 ? (tz as number) * 60 : (tz as number);
// Find closest matching timezone
const closest = TIMEZONE_MAP.reduce((prev, curr) =>
Math.abs(curr.offset - offsetMinutes) < Math.abs(prev.offset - offsetMinutes) ? curr : prev
);
tzName = closest.iana;
}
if (!numericTz && tzName !== 'UTC') { if (!numericTz && tzName !== 'UTC') {
try { try {
new Intl.DateTimeFormat('en-US', { timeZone: tzName }).format(); new Intl.DateTimeFormat('en-US', { timeZone: <string>tzName }).format();
} catch { } catch {
throw new Error(`Invalid timezone: ${tzName}`); throw new Error(`Invalid timezone: ${tzName}`);
} }
@@ -99,9 +142,10 @@ export function formatDate(format: string = 'YYYY-MM-DD H:mm', date: Date | numb
let zonedDate = new Date(date); let zonedDate = new Date(date);
let get: (fn: 'FullYear' | 'Month' | 'Date' | 'Day' | 'Hours' | 'Minutes' | 'Seconds' | 'Milliseconds') => number; let get: (fn: 'FullYear' | 'Month' | 'Date' | 'Day' | 'Hours' | 'Minutes' | 'Seconds' | 'Milliseconds') => number;
const partsMap: Record<string, string> = {}; const partsMap: Record<string, string> = {};
if (!numericTz && tzName !== 'UTC') { if (!numericTz && tzName !== 'UTC') {
const parts = new Intl.DateTimeFormat('en-US', { const parts = new Intl.DateTimeFormat('en-US', {
timeZone: tzName, timeZone: <string>tzName,
year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'long', year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'long',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false hour12: false
@@ -111,7 +155,7 @@ export function formatDate(format: string = 'YYYY-MM-DD H:mm', date: Date | numb
}); });
const monthValue = parseInt(partsMap.month) - 1; const monthValue = parseInt(partsMap.month) - 1;
const dayOfWeekValue = new Date(`${partsMap.year}-${partsMap.month}-${partsMap.day}`).getDay(); const dayOfWeekValue = new Date(Date.UTC(parseInt(partsMap.year), parseInt(partsMap.month) - 1, parseInt(partsMap.day))).getUTCDay();
const hourValue = parseInt(partsMap.hour); const hourValue = parseInt(partsMap.hour);
get = (fn: 'FullYear' | 'Month' | 'Date' | 'Day' | 'Hours' | 'Minutes' | 'Seconds' | 'Milliseconds'): number => { get = (fn: 'FullYear' | 'Month' | 'Date' | 'Day' | 'Hours' | 'Minutes' | 'Seconds' | 'Milliseconds'): number => {
@@ -127,8 +171,7 @@ export function formatDate(format: string = 'YYYY-MM-DD H:mm', date: Date | numb
} }
}; };
} else { } else {
const offset = numericTz ? tz as number : 0; zonedDate = new Date(date.getTime() + offsetMinutes * 60 * 1000);
zonedDate = new Date(date.getTime() + offset * 60 * 60 * 1000);
get = (fn: 'FullYear' | 'Month' | 'Date' | 'Day' | 'Hours' | 'Minutes' | 'Seconds' | 'Milliseconds'): number => zonedDate[`getUTC${fn}`](); get = (fn: 'FullYear' | 'Month' | 'Date' | 'Day' | 'Hours' | 'Minutes' | 'Seconds' | 'Milliseconds'): number => zonedDate[`getUTC${fn}`]();
} }
@@ -139,14 +182,13 @@ export function formatDate(format: string = 'YYYY-MM-DD H:mm', date: Date | numb
} }
function getTZOffset(): string { function getTZOffset(): string {
if (numericTz) { if(numericTz) {
const total = (tz as number) * 60; const hours = Math.floor(Math.abs(offsetMinutes) / 60);
const hours = Math.floor(Math.abs(total) / 60); const mins = Math.abs(offsetMinutes) % 60;
const mins = Math.abs(total) % 60; return `${offsetMinutes >= 0 ? '+' : '-'}${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
return `${tz >= 0 ? '+' : '-'}${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
} }
try { try {
const offset = new Intl.DateTimeFormat('en-US', {timeZone: tzName, timeZoneName: 'longOffset', hour: '2-digit', minute: '2-digit',}) const offset = new Intl.DateTimeFormat('en-US', {timeZone: <string>tzName, timeZoneName: 'longOffset', hour: '2-digit', minute: '2-digit',})
.formatToParts(<Date>date).find(p => p.type === 'timeZoneName')?.value.match(/([+-]\d{2}:\d{2})/)?.[1]; .formatToParts(<Date>date).find(p => p.type === 'timeZoneName')?.value.match(/([+-]\d{2}:\d{2})/)?.[1];
if (offset) return offset; if (offset) return offset;
} catch {} } catch {}
@@ -154,12 +196,11 @@ export function formatDate(format: string = 'YYYY-MM-DD H:mm', date: Date | numb
} }
function getTZAbbr(): string { function getTZAbbr(): string {
if (numericTz && tz === 0) return 'UTC';
try { try {
return new Intl.DateTimeFormat('en-US', { timeZone: tzName, timeZoneName: 'short' }) return new Intl.DateTimeFormat('en-US', { timeZone: <string>tzName, timeZoneName: 'short' })
.formatToParts(<Date>date).find(p => p.type === 'timeZoneName')?.value || ''; .formatToParts(<Date>date).find(p => p.type === 'timeZoneName')?.value || '';
} catch { } catch {
return tzName; return <string>tzName;
} }
} }

199
src/xml.ts Normal file
View File

@@ -0,0 +1,199 @@
/**
* Parses an XML string into a structured JavaScript object.
* @param {string} xml - The XML string to parse
* @returns {Object} An object with `tag`, `attributes`, and `children` properties
*/
/**
* Parses an XML string into a structured JavaScript object (fast-xml-parser format).
* @param {string} xml - The XML string to parse
* @returns {Object} An object with tag names as keys and text content or nested objects as values
*/
export function fromXml(xml: string) {
xml = xml.trim();
let pos = 0;
function parseNode(): any {
skipWhitespace();
if(xml[pos] !== '<') return parseText();
pos++; // skip <
if(xml[pos] === '?') {
const declaration = parseDeclaration();
return { ['?' + declaration]: '', ...parseNode() };
}
if(xml[pos] === '!') {
parseComment();
return parseNode();
}
const tagName = parseTagName();
const attributes = parseAttributes();
skipWhitespace();
if(xml[pos] === '/' && xml[pos + 1] === '>') {
pos += 2; // skip />
return { [tagName]: '' };
}
pos++; // skip >
const children: any[] = [];
let textContent = '';
while(pos < xml.length) {
skipWhitespace();
if(xml[pos] === '<' && xml[pos + 1] === '/') {
pos += 2; // skip </
parseTagName(); // skip closing tag name
skipWhitespace();
pos++; // skip >
break;
}
const startPos = pos;
const child = parseNode();
if(typeof child === 'string') {
textContent += child;
} else if(child) {
children.push(child);
}
}
// If only text content, return simple value
if(children.length === 0 && textContent) {
const value = isNumeric(textContent) ? Number(textContent) : textContent;
return { [tagName]: value };
}
// If only text with no children
if(children.length === 0) {
return { [tagName]: '' };
}
// Merge children into object
const result: any = {};
for(const child of children) {
for(const [key, value] of Object.entries(child)) {
if(result[key]) {
// Convert to array if duplicate tags
if(!Array.isArray(result[key])) {
result[key] = [result[key]];
}
result[key].push(value);
} else {
result[key] = value;
}
}
}
return { [tagName]: result };
}
function parseTagName() {
let name = '';
while (pos < xml.length && /[a-zA-Z0-9_:-]/.test(xml[pos])) name += xml[pos++];
return name;
}
function parseAttributes() {
const attrs: any = {};
while (pos < xml.length) {
skipWhitespace();
if (xml[pos] === '>' || xml[pos] === '/') break;
const name = parseTagName();
skipWhitespace();
if (xml[pos] === '=') {
pos++;
skipWhitespace();
const quote = xml[pos++];
let value = '';
while (xml[pos] !== quote) value += xml[pos++];
pos++; // skip closing quote
attrs[name] = escapeXml(value, true);
}
}
return attrs;
}
function parseText() {
let text = '';
while (pos < xml.length && xml[pos] !== '<') text += xml[pos++];
text = text.trim();
return text ? escapeXml(text, true) : null;
}
function parseDeclaration() {
pos++; // skip ?
let name = '';
while (pos < xml.length && xml[pos] !== ' ' && xml[pos] !== '?') {
name += xml[pos++];
}
while (xml[pos] !== '>') pos++;
pos++;
return name;
}
function parseComment() {
while (!(xml[pos] === '-' && xml[pos + 1] === '-' && xml[pos + 2] === '>')) pos++;
pos += 3;
}
function skipWhitespace() {
while (pos < xml.length && /\s/.test(xml[pos])) pos++;
}
function isNumeric(str: string) {
return !isNaN(Number(str)) && !isNaN(parseFloat(str)) && str.trim() !== '';
}
return parseNode();
}
/**
* Converts a JavaScript object into an XML string.
* @param {Object} obj - Object with `tag`, `attributes`, and `children` properties, or a string
* @param {string} indent - Current indentation level (used internally for formatting)
* @returns {string} The formatted XML string
*/
export function toXml(obj: any, indent = '') {
if(typeof obj === 'string') return escapeXml(obj);
const { tag, attributes = {}, children = [] } = obj;
let xml = `${indent}<${tag}`;
for (const [key, value] of Object.entries(attributes))
xml += ` ${key}="${escapeXml(<any>value)}"`;
if (children.length === 0) {
xml += ' />';
return xml;
}
xml += '>';
const hasComplexChildren = children.some((c: any) => typeof c === 'object');
for (const child of children) {
if (hasComplexChildren) xml += '\n';
xml += toXml(child, hasComplexChildren ? indent + ' ' : '');
}
if(hasComplexChildren) xml += `\n${indent}`;
xml += `</${tag}>`;
return xml;
}
/**
* Escapes or unescapes XML special characters.
* @param {string} str - The string to process
* @param {boolean} decode - If true, decodes XML entities; if false, encodes special characters
* @returns {string} The processed string
*/
export function escapeXml(str: string, decode = false) {
if(decode) {
return str
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, '&');
}
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}

View File

@@ -17,56 +17,131 @@ describe('Search Utilities', () => {
expect(search(rows, '')).toEqual(rows); expect(search(rows, '')).toEqual(rows);
}); });
it('filters based on a simple property string', () => {
expect(search(rows, 'Alice')).toEqual([rows[0]]);
});
it('filters using regex when regex=true', () => {
expect(search(rows, '^B', true)).toEqual([rows[1]]);
});
it('applies the transform function before filtering', () => {
const transform = (r: any) => ({...r, name: r.name.toLowerCase()});
expect(search(rows, 'alice', false, transform)).toEqual([rows[0]]);
});
it('uses logicTest for non-regex search', () => {
expect(search(rows, 'age == 30')).toEqual([rows[0], rows[2]]);
expect(search(rows, 'id = 2')).toEqual([rows[1]]);
});
it('returns all if search is falsy and regex enabled', () => {
expect(search(rows, '', true)).toEqual(rows);
});
it('handles regex search with special characters', () => {
expect(search(rows, '^[AC]', true)).toEqual([rows[0], rows[2]]);
});
it('ignores case when regex is applied', () => {
expect(search(rows, 'ALICE', true)).toEqual([]);
});
it('performs partial matches for properties when regex=false', () => {
expect(search(rows, 'Da')).toEqual([rows[3]]);
});
it('handles empty array input gracefully', () => { it('handles empty array input gracefully', () => {
expect(search([], 'test')).toEqual([]); expect(search([], 'test')).toEqual([]);
}); });
it('handles numeric values with comparison logic in strings', () => { it('applies transform before filtering', () => {
expect(search(rows, 'age < 31')).toEqual([rows[0], rows[1], rows[2]]); const transform = (r: any) => ({...r, name: r.name.toLowerCase()});
expect(search(rows, 'alice', transform)).toEqual([rows[0]]);
}); });
// New test cases for `+` and `-` operators describe('plain text', () => {
it('filters rows using the + operator', () => { it('matches case-insensitively when lowercase', () => {
expect(search(rows, 'alice')).toEqual([rows[0]]);
});
it('matches case-sensitively when uppercase present', () => {
expect(search(rows, 'Alice')).toEqual([rows[0]]);
expect(search(rows, 'ALICE')).toEqual([]);
});
it('performs partial matches', () => {
expect(search(rows, 'Da')).toEqual([rows[3]]);
});
});
describe('global regex', () => {
it('matches with valid /pattern/flags syntax', () => {
expect(search(rows, '/^B/g')).toEqual([rows[1]]);
});
it('matches multiple rows', () => {
expect(search(rows, '/^[AC]/g')).toEqual([rows[0], rows[2]]);
});
it('respects flags', () => {
expect(search(rows, '/alice/i')).toEqual([rows[0]]);
expect(search(rows, '/alice/g')).toEqual([]);
});
it('does not treat /pattern/ without flags as regex', () => {
expect(search(rows, '/Alice/')).toEqual([]);
});
it('does not treat paths as regex', () => {
const pathRows = [{url: 'users/alice'}];
expect(search(pathRows, 'users/alice')).toEqual(pathRows);
});
});
describe('property operators', () => {
it('filters with equality', () => {
expect(search(rows, 'age == 30')).toEqual([rows[0], rows[2]]);
expect(search(rows, 'id = 2')).toEqual([rows[1]]);
});
it('filters with inequality', () => {
expect(search(rows, 'name != Alice')).toEqual([rows[1], rows[2], rows[3]]);
});
it('filters with contains', () => {
expect(search(rows, 'name += Al')).toEqual([rows[0]]); expect(search(rows, 'name += Al')).toEqual([rows[0]]);
}); });
it('filters rows using the - operator', () => { it('filters with not-contains', () => {
expect(search(rows, 'name -= Al')).toEqual([rows[1], rows[2], rows[3]]); expect(search(rows, 'name -= Al')).toEqual([rows[1], rows[2], rows[3]]);
}); });
it('filters with numeric comparisons', () => {
expect(search(rows, 'age < 31')).toEqual([rows[0], rows[1], rows[2]]);
expect(search(rows, 'age > 30')).toEqual([rows[3]]);
expect(search(rows, 'age >= 30')).toEqual([rows[0], rows[2], rows[3]]);
});
});
describe('property regex', () => {
it('matches with ~= shorthand', () => {
expect(search(rows, 'name ~= ^Al')).toEqual([rows[0]]);
});
it('matches with ~= /pattern/flags', () => {
expect(search(rows, 'name ~= /^al/i')).toEqual([rows[0]]);
});
it('excludes with !~', () => {
expect(search(rows, 'name !~ ^[AB]')).toEqual([rows[2], rows[3]]);
});
});
describe('logical operators', () => {
it('supports &&', () => {
expect(search(rows, 'age == 30 && name != Alice')).toEqual([rows[2]]);
});
it('supports ||', () => {
expect(search(rows, 'name = Alice || name = Bob')).toEqual([rows[0], rows[1]]);
});
});
describe('dataset helpers', () => {
const dupeRows = [
{id: 1, email: 'a@a.com', type: 'admin'},
{id: 2, email: 'b@b.com', type: 'user'},
{id: 3, email: 'a@a.com', type: 'user'},
{id: 4, email: 'c@c.com', type: 'admin'},
];
it('unique() returns rows where field appears once', () => {
expect(search(dupeRows, 'unique(email)')).toEqual([dupeRows[1], dupeRows[3]]);
});
it('duplicate() returns rows where field appears more than once', () => {
expect(search(dupeRows, 'duplicate(email)')).toEqual([dupeRows[0], dupeRows[2]]);
});
it('distinct() returns one row per field value', () => {
expect(search(dupeRows, 'distinct(type)')).toEqual([dupeRows[0], dupeRows[1]]);
});
it('composes helpers with predicates', () => {
expect(search(dupeRows, 'type = user && duplicate(email)')).toEqual([]);
});
it('applies distinct last', () => {
expect(search(dupeRows, 'duplicate(email) && distinct(type)')).toEqual([dupeRows[0], dupeRows[2]]);
});
});
}); });
describe('logicTest', () => { describe('logicTest', () => {
@@ -83,60 +158,63 @@ describe('Search Utilities', () => {
expect(logicTest(obj, 'x < 5')).toBe(false); expect(logicTest(obj, 'x < 5')).toBe(false);
}); });
it('supports case insensitive property search', () => { it('handles contains and not-contains', () => {
expect(logicTest(obj, 'alpha')).toBeTruthy(); expect(logicTest(obj, 'name += Alpha')).toBe(true);
expect(logicTest(obj, 'ALPHA')).toBeFalsy(); expect(logicTest(obj, 'name += Alp')).toBe(true);
expect(logicTest(obj, 'name += Bet')).toBe(false);
expect(logicTest(obj, 'name -= Alpha')).toBe(false);
expect(logicTest(obj, 'name -= Bet')).toBe(true);
expect(logicTest(obj, 'name += lph')).toBe(true);
expect(logicTest(obj, 'name -= lph')).toBe(false);
}); });
it('handles logical AND/OR expressions', () => { it('handles property regex ~=', () => {
expect(logicTest(obj, 'name ~= ^Alp')).toBe(true);
expect(logicTest(obj, 'name ~= /^alp/i')).toBe(true);
expect(logicTest(obj, 'name ~= ^Bet')).toBe(false);
});
it('handles property regex !~', () => {
expect(logicTest(obj, 'name !~ ^Bet')).toBe(true);
expect(logicTest(obj, 'name !~ ^Alp')).toBe(false);
});
it('handles invalid regex gracefully', () => {
expect(logicTest(obj, 'name ~= [invalid')).toBe(false);
});
it('supports plain text case-insensitive search', () => {
expect(logicTest(obj, 'alpha')).toBe(true);
});
it('supports plain text case-sensitive search', () => {
expect(logicTest(obj, 'Alpha')).toBe(true);
expect(logicTest(obj, 'ALPHA')).toBe(false);
});
it('handles logical AND/OR', () => {
expect(logicTest(obj, 'x == 10 && y == 5')).toBe(true); expect(logicTest(obj, 'x == 10 && y == 5')).toBe(true);
expect(logicTest(obj, 'x == 10 || y == 100')).toBe(true); expect(logicTest(obj, 'x == 10 || y == 100')).toBe(true);
expect(logicTest(obj, 'x == 1 && y == 5')).toBe(false); expect(logicTest(obj, 'x == 1 && y == 5')).toBe(false);
}); });
it('handles numeric ranges', () => {
expect(logicTest(obj, 'x > 5 && x < 15')).toBe(true);
expect(logicTest(obj, 'x > 15')).toBe(false);
});
it('matches keys case-insensitively', () => {
const mixedCaseObj = {TestKey: 123};
expect(logicTest(mixedCaseObj, 'TestKey == 123')).toBe(true);
expect(logicTest(mixedCaseObj, 'testkey == 123')).toBe(true);
});
it('returns false for unsupported operators', () => { it('returns false for unsupported operators', () => {
expect(logicTest(obj, 'x === 10')).toBe(false); expect(logicTest(obj, 'x === 10')).toBe(false);
}); });
it('handles invalid condition strings gracefully', () => { it('returns false for missing operators', () => {
expect(logicTest(obj, 'invalid condition')).toBe(false);
});
it('supports numeric operations with ranges', () => {
expect(logicTest(obj, 'x > 5 && x < 15')).toBe(true);
expect(logicTest(obj, 'x > 15')).toBe(false);
});
it('handles mixed case keys gracefully', () => {
const mixedCaseObj = {TestKey: 123};
expect(logicTest(mixedCaseObj, 'TestKey == 123')).toBe(true);
expect(logicTest(mixedCaseObj, 'testkey == 123')).toBe(true);
});
it('returns false if condition operators are missing', () => {
expect(logicTest(obj, 'x 10')).toBe(false); expect(logicTest(obj, 'x 10')).toBe(false);
}); });
// New test cases for `+` and `-` operators
it('handles the + operator for inclusion', () => {
expect(logicTest(obj, 'name += Alpha')).toBe(true);
expect(logicTest(obj, 'name += Alp')).toBe(true);
expect(logicTest(obj, 'name += Bet')).toBe(false);
});
it('handles the - operator for exclusion', () => {
expect(logicTest(obj, 'name -= Alpha')).toBe(false);
expect(logicTest(obj, 'name -= Alp')).toBe(false);
expect(logicTest(obj, 'name -= Bet')).toBe(true);
});
it('includes partial matches correctly with +', () => {
expect(logicTest(obj, 'name += lph')).toBe(true);
});
it('excludes partial matches correctly with -', () => {
expect(logicTest(obj, 'name -= lph')).toBe(false);
expect(logicTest(obj, 'name -= xyz')).toBe(true);
});
}); });
}); });

210
tests/xml.spec.ts Normal file
View File

@@ -0,0 +1,210 @@
import { toXml, fromXml } from '../src';
describe('XML Parser', () => {
describe('fromXml', () => {
it('should parse simple tag', () => {
const xml = '<root></root>';
const result = fromXml(xml);
expect(result).toEqual({ root: '' });
});
it('should parse self-closing tag', () => {
const xml = '<item />';
const result = fromXml(xml);
expect(result).toEqual({ item: '' });
});
it('should parse tag with attributes (ignored in fast-xml-parser format)', () => {
const xml = '<user id="1" name="someone" />';
const result = fromXml(xml);
expect(result).toEqual({ user: '' });
});
it('should parse tag with text content', () => {
const xml = '<email>someone@example.com</email>';
const result = fromXml(xml);
expect(result).toEqual({ email: 'someone@example.com' });
});
it('should parse tag with numeric content', () => {
const xml = '<ttl>240</ttl>';
const result = fromXml(xml);
expect(result).toEqual({ ttl: 240 });
});
it('should parse nested tags', () => {
const xml = '<root><child>text</child></root>';
const result = fromXml(xml);
expect(result).toEqual({
root: {
child: 'text'
}
});
});
it('should parse multiple children with same tag as array', () => {
const xml = '<root><item>a</item><item>b</item><item>c</item></root>';
const result = fromXml(xml);
expect(result).toEqual({
root: {
item: ['a', 'b', 'c']
}
});
});
it('should parse mixed children', () => {
const xml = '<root><a>1</a><b>2</b><c>3</c></root>';
const result = fromXml(xml);
expect(result.root).toEqual({ a: 1, b: 2, c: 3 });
});
it('should skip XML declaration and include as key', () => {
const xml = '<?xml version="1.0"?><root />';
const result = fromXml(xml);
expect(result).toHaveProperty('?xml');
expect(result).toHaveProperty('root');
});
it('should skip comments', () => {
const xml = '<root><!-- comment --><child>text</child></root>';
const result = fromXml(xml);
expect(result).toEqual({
root: {
child: 'text'
}
});
});
it('should handle escaped characters', () => {
const xml = '<text>&lt;hello&gt; &amp; &quot;world&quot;</text>';
const result = fromXml(xml);
expect(result.text).toBe('<hello> & "world"');
});
it('should parse complex nested structure', () => {
const xml = `
<root>
<user id="1" name="someone">
<email>someone@example.com</email>
<active />
</user>
</root>
`;
const result = fromXml(xml);
expect(result).toEqual({
root: {
user: {
email: 'someone@example.com',
active: ''
}
}
});
});
it('should parse RSS-like structure with multiple items', () => {
const xml = `
<rss>
<channel>
<title>Test Feed</title>
<item>
<title>Item 1</title>
<link>http://example.com/1</link>
</item>
<item>
<title>Item 2</title>
<link>http://example.com/2</link>
</item>
</channel>
</rss>
`;
const result = fromXml(xml);
expect(result.rss.channel.title).toBe('Test Feed');
expect(Array.isArray(result.rss.channel.item)).toBe(true);
expect(result.rss.channel.item.length).toBe(2);
expect(result.rss.channel.item[0].title).toBe('Item 1');
});
});
describe('toXml', () => {
it('should encode simple tag', () => {
const obj = { tag: 'root', attributes: {}, children: [] };
expect(toXml(obj)).toBe('<root />');
});
it('should encode tag with attributes', () => {
const obj = { tag: 'user', attributes: { id: '1', name: 'someone' }, children: [] };
const result = toXml(obj);
expect(result).toContain('id="1"');
expect(result).toContain('name="someone"');
});
it('should encode tag with text content', () => {
const obj = { tag: 'email', attributes: {}, children: ['someone@example.com'] };
expect(toXml(obj)).toBe('<email>someone@example.com</email>');
});
it('should encode nested tags with indentation', () => {
const obj = {
tag: 'root',
attributes: {},
children: [
{ tag: 'child', attributes: {}, children: ['text'] }
]
};
const result = toXml(obj);
expect(result).toContain('<root>');
expect(result).toContain(' <child>');
expect(result).toContain('</root>');
});
it('should escape special characters', () => {
const obj = { tag: 'text', attributes: {}, children: ['<hello> & "world"'] };
const result = toXml(obj);
expect(result).toContain('&lt;hello&gt; &amp; &quot;world&quot;');
});
it('should escape attributes', () => {
const obj = { tag: 'node', attributes: { attr: 'a & b' }, children: [] };
const result = toXml(obj);
expect(result).toContain('attr="a &amp; b"');
});
it('should handle multiple children', () => {
const obj = {
tag: 'root',
attributes: {},
children: [
{ tag: 'a', attributes: {}, children: [] },
{ tag: 'b', attributes: {}, children: [] }
]
};
const result = toXml(obj);
expect(result).toContain('<a />');
expect(result).toContain('<b />');
});
it('should encode string directly', () => {
expect(toXml('hello')).toBe('hello');
expect(toXml('a & b')).toBe('a &amp; b');
});
});
describe('round-trip', () => {
it('should parse toXml output back to fast-xml-parser format', () => {
const obj = {
tag: 'root',
attributes: { id: '1' },
children: [
{ tag: 'child', attributes: {}, children: ['text'] }
]
};
const xml = toXml(obj);
const parsed = fromXml(xml);
expect(parsed).toEqual({
root: {
child: 'text'
}
});
});
});
});