Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01e799274a | ||
|
|
4c60f52b5e | ||
|
|
35c14d01c2 | ||
|
|
92f36fdb5b | ||
|
|
721fe2dcf8 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ztimson/utils",
|
||||
"version": "0.30.6",
|
||||
"version": "0.30.9",
|
||||
"description": "Utility library",
|
||||
"author": "Zak Timson",
|
||||
"license": "MIT",
|
||||
|
||||
+39
-3
@@ -82,11 +82,11 @@ export function timestampFilename(name?: string, date: Date | number | string =
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload file to URL with progress callback using PromiseProgress
|
||||
* Upload file to URL with progress (size/total/speed/eta/...) via 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
|
||||
* @return {PromiseProgress<T>} Promise of request with `onProgress` callback
|
||||
* @return {PromiseProgress<T>} Promise of request with `onProgress` callback & meta getters
|
||||
*/
|
||||
export function uploadWithProgress<T>(options: {
|
||||
url: string;
|
||||
@@ -102,7 +102,7 @@ export function uploadWithProgress<T>(options: {
|
||||
options.files.forEach(f => formData.append('files', f));
|
||||
|
||||
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);
|
||||
xhr.addEventListener('loadend', () => res(<T>JSONAttemptParse(xhr.responseText)));
|
||||
xhr.addEventListener('error', () => rej(JSONAttemptParse(xhr.responseText)));
|
||||
xhr.addEventListener('timeout', () => rej({error: 'Request timed out'}));
|
||||
@@ -131,3 +131,39 @@ export function uploadWithProgress<T>(options: {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Download file from URL with progress (size/total/speed/eta/...) via PromiseProgress
|
||||
* Streams the response body for progress when supported, falls back to a plain blob otherwise
|
||||
*
|
||||
* @param {{url: string, name?: string, headers?: {[p: string]: string}}} options name - if set, triggers a file save via `downloadFile`
|
||||
* @return {PromiseProgress<Blob>} Promise of downloaded blob with `onProgress` callback & meta getters
|
||||
*/
|
||||
export function downloadWithProgress(options: {url: string; name?: string; headers?: {[key: string]: string}}): PromiseProgress<Blob> {
|
||||
return new PromiseProgress<Blob>(async (res, rej, prog) => {
|
||||
try {
|
||||
const response = await fetch(options.url, {headers: options.headers || {}});
|
||||
if(!response.ok) return rej(JSONAttemptParse(await response.text()));
|
||||
const total = Number(response.headers.get('content-length')) || 0;
|
||||
if(!response.body?.getReader) {
|
||||
const blob = await response.blob();
|
||||
options.name && downloadFile(blob, options.name);
|
||||
return res(blob);
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
while(true) {
|
||||
const {done, value} = await reader.read();
|
||||
if(done) break;
|
||||
chunks.push(value);
|
||||
prog(size += value.length, total);
|
||||
}
|
||||
const blob = new Blob(chunks as BlobPart[]);
|
||||
options.name && downloadFile(blob, options.name);
|
||||
res(blob);
|
||||
} catch (error) {
|
||||
rej(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -97,8 +97,10 @@ export function deepCopy<T>(value: T): T {
|
||||
* @return {any} The des
|
||||
*/
|
||||
export function deepMerge<T>(target: any, ...sources: any[]): T {
|
||||
const BLOCKED = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
sources.forEach(s => {
|
||||
for(const key in s) {
|
||||
if(BLOCKED.has(key) || !Object.prototype.hasOwnProperty.call(s, key)) continue;
|
||||
if(s[key] && typeof s[key] == 'object' && !Array.isArray(s[key])) {
|
||||
if(!target[key]) target[key] = {};
|
||||
deepMerge(target[key], s[key]);
|
||||
|
||||
+43
-25
@@ -1,65 +1,83 @@
|
||||
export type ProgressCallback = (progress: number) => any;
|
||||
export interface ProgressMeta {
|
||||
start: number;
|
||||
end: number | null;
|
||||
elapsed: number; // seconds
|
||||
speed: number; // bytes/sec
|
||||
progress: number; // 0-1
|
||||
size: number; // bytes transferred
|
||||
total: number; // total bytes
|
||||
eta: number | null; // seconds remaining
|
||||
}
|
||||
|
||||
export type ProgressCallback = (meta: ProgressMeta) => any;
|
||||
|
||||
/**
|
||||
* A promise that fires the `onProgress` callback on incremental progress
|
||||
* A promise that tracks transfer progress (size/total/speed/eta/elapsed) via `onProgress` callback & direct getters
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* const promise = new Promise((resolve, reject, progress) => {
|
||||
* const max = 10;
|
||||
* for(let i = 0; i < max; i++) progress(i / max);
|
||||
* const promise = new PromiseProgress((resolve, reject, progress) => {
|
||||
* const total = 100;
|
||||
* for(let i = 0; i <= total; i++) progress(i, total);
|
||||
* resolve(1);
|
||||
* });
|
||||
*
|
||||
* console.log(promise.progress);
|
||||
* console.log(promise.progress, promise.speed, promise.eta);
|
||||
*
|
||||
* promise.onProgress(console.log)
|
||||
* promise.onProgress(m => console.log(m.progress, m.eta))
|
||||
* .then(console.log)
|
||||
* .catch(console.error)
|
||||
* .finally(...);
|
||||
*
|
||||
* ```
|
||||
*/
|
||||
export class PromiseProgress<T> extends Promise<T> {
|
||||
private listeners: ProgressCallback[] = [];
|
||||
private _meta: ProgressMeta = {start: Date.now(), end: null, elapsed: 0, speed: 0, progress: 0, size: 0, total: 0, eta: null};
|
||||
|
||||
private _progress = 0;
|
||||
get progress() { return this._progress; }
|
||||
set progress(p: number) {
|
||||
if(p == this._progress) return;
|
||||
this._progress = p;
|
||||
this.listeners.forEach(l => l(p));
|
||||
get meta() { return this._meta; }
|
||||
get start() { return this._meta.start; }
|
||||
get end() { return this._meta.end; }
|
||||
get elapsed() { return this._meta.elapsed; }
|
||||
get speed() { return this._meta.speed; }
|
||||
get progress() { return this._meta.progress; }
|
||||
get size() { return this._meta.size; }
|
||||
get total() { return this._meta.total; }
|
||||
get eta() { return this._meta.eta; }
|
||||
|
||||
private update(size: number, total: number) {
|
||||
const elapsed = (Date.now() - this._meta.start) / 1000;
|
||||
const speed = elapsed ? size / elapsed : 0;
|
||||
const progress = total ? size / total : this._meta.progress;
|
||||
this._meta = {...this._meta, size, total, progress, elapsed, speed, eta: speed && total ? (total - size) / speed : null};
|
||||
(this.listeners ||= []).forEach(l => l(this._meta));
|
||||
}
|
||||
|
||||
constructor(executor: (resolve: (value: T) => any, reject: (reason: any) => void, progress: (progress: number) => any) => void) {
|
||||
constructor(executor: (resolve: (value: T) => any, reject: (reason: any) => void, progress: (size: number, total?: number) => any) => void) {
|
||||
super((resolve, reject) => executor(
|
||||
(value: T) => resolve(value),
|
||||
(reason: any) => reject(reason),
|
||||
(progress: number) => this.progress = progress,
|
||||
(value: T) => { this._meta = {...this._meta, end: Date.now()}; resolve(value); },
|
||||
(reason: any) => { this._meta = {...this._meta, end: Date.now()}; reject(reason); },
|
||||
(size: number, total?: number) => this.update(size, total ?? this._meta.total),
|
||||
));
|
||||
}
|
||||
|
||||
static from<T>(promise: Promise<T>): PromiseProgress<T> {
|
||||
if(promise instanceof PromiseProgress) return promise;
|
||||
return new PromiseProgress<T>((res, rej) => promise
|
||||
.then((...args) => res(...args))
|
||||
.catch((...args) => rej(...args)));
|
||||
return new PromiseProgress<T>((res, rej) => promise.then(res).catch(rej));
|
||||
}
|
||||
|
||||
private from(promise: Promise<T>): PromiseProgress<T> {
|
||||
const newPromise = PromiseProgress.from(promise);
|
||||
this.onProgress(p => newPromise.progress = p);
|
||||
this.onProgress(m => newPromise.update(m.size, m.total));
|
||||
return newPromise;
|
||||
}
|
||||
|
||||
onProgress(callback: ProgressCallback) {
|
||||
this.listeners.push(callback);
|
||||
(this.listeners ||= []).push(callback);
|
||||
return this;
|
||||
}
|
||||
|
||||
then(res?: (v: T) => any, rej?: (err: any) => any): PromiseProgress<any> {
|
||||
const resp = super.then(res, rej);
|
||||
return this.from(resp);
|
||||
return this.from(super.then(res, rej));
|
||||
}
|
||||
|
||||
catch(rej?: (err: any) => any): PromiseProgress<any> {
|
||||
|
||||
+13
-13
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* Parses an XML string into a structured JavaScript object.
|
||||
* Parses an XML string into a plain JavaScript object.
|
||||
* Each tag becomes a key. Attributes and child tags are merged as
|
||||
* sibling properties on that tag's object. Duplicate tag/attribute
|
||||
* names are collapsed into arrays. Text-only tags resolve to their
|
||||
* (optionally numeric) value; if a tag has both text and attributes/
|
||||
* children, the text is kept under a `_text` key.
|
||||
* @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
|
||||
* @returns {Object} The parsed object tree
|
||||
*/
|
||||
export function fromXml(xml: string) {
|
||||
xml = xml.trim();
|
||||
@@ -33,11 +33,11 @@ export function fromXml(xml: string) {
|
||||
|
||||
if(xml[pos] === '/' && xml[pos + 1] === '>') {
|
||||
pos += 2; // skip />
|
||||
return { [tagName]: '' };
|
||||
return { [tagName]: Object.keys(attributes).length ? attributes : '' };
|
||||
}
|
||||
|
||||
pos++; // skip >
|
||||
const children: any[] = [];
|
||||
const children: any[] = Object.entries(attributes).map(([k, v]) => ({ [k]: v }));
|
||||
let textContent = '';
|
||||
|
||||
while(pos < xml.length) {
|
||||
@@ -49,7 +49,6 @@ export function fromXml(xml: string) {
|
||||
pos++; // skip >
|
||||
break;
|
||||
}
|
||||
const startPos = pos;
|
||||
const child = parseNode();
|
||||
if(typeof child === 'string') {
|
||||
textContent += child;
|
||||
@@ -64,17 +63,18 @@ export function fromXml(xml: string) {
|
||||
return { [tagName]: value };
|
||||
}
|
||||
|
||||
// If only text with no children
|
||||
// If nothing at all
|
||||
if(children.length === 0) {
|
||||
return { [tagName]: '' };
|
||||
}
|
||||
|
||||
// Merge children into object
|
||||
// Merge attributes/children into object
|
||||
const result: any = {};
|
||||
if(textContent) result._text = isNumeric(textContent) ? Number(textContent) : textContent;
|
||||
for(const child of children) {
|
||||
for(const [key, value] of Object.entries(child)) {
|
||||
if(result[key]) {
|
||||
// Convert to array if duplicate tags
|
||||
// Convert to array if duplicate tags/attrs
|
||||
if(!Array.isArray(result[key])) {
|
||||
result[key] = [result[key]];
|
||||
}
|
||||
|
||||
+6
-3
@@ -14,10 +14,10 @@ describe('XML Parser', () => {
|
||||
expect(result).toEqual({ item: '' });
|
||||
});
|
||||
|
||||
it('should parse tag with attributes (ignored in fast-xml-parser format)', () => {
|
||||
it('should parse tag with attributes as merged properties', () => {
|
||||
const xml = '<user id="1" name="someone" />';
|
||||
const result = fromXml(xml);
|
||||
expect(result).toEqual({ user: '' });
|
||||
expect(result).toEqual({ user: { id: '1', name: 'someone' } });
|
||||
});
|
||||
|
||||
it('should parse tag with text content', () => {
|
||||
@@ -94,6 +94,8 @@ describe('XML Parser', () => {
|
||||
expect(result).toEqual({
|
||||
root: {
|
||||
user: {
|
||||
id: '1',
|
||||
name: 'someone',
|
||||
email: 'someone@example.com',
|
||||
active: ''
|
||||
}
|
||||
@@ -190,7 +192,7 @@ describe('XML Parser', () => {
|
||||
});
|
||||
|
||||
describe('round-trip', () => {
|
||||
it('should parse toXml output back to fast-xml-parser format', () => {
|
||||
it('should parse toXml output back with attributes merged as properties', () => {
|
||||
const obj = {
|
||||
tag: 'root',
|
||||
attributes: { id: '1' },
|
||||
@@ -202,6 +204,7 @@ describe('XML Parser', () => {
|
||||
const parsed = fromXml(xml);
|
||||
expect(parsed).toEqual({
|
||||
root: {
|
||||
id: '1',
|
||||
child: 'text'
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user