@ti-engine/core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/utils/tools.js ADDED
@@ -0,0 +1,537 @@
1
+ /*
2
+ * SPDX-FileCopyrightText: © 2021 Boris Kostadinov <kostadinov.boris@gmail.com>
3
+ * SPDX-License-Identifier: ICU
4
+ */
5
+
6
+ const _ = require( "lodash" );
7
+ const fs = require( "fs-extra" );
8
+ const crypto = require( "crypto" );
9
+
10
+ /**
11
+ * @typedef {Object} TiEnumValue
12
+ * @property {number|string} value
13
+ * @property {string} name
14
+ * @property {string} description
15
+ */
16
+
17
+ /**
18
+ * @typedef {Object} TiEnum
19
+ * @property {Object.<number|string,TiEnumValue>} properties
20
+ */
21
+
22
+ /**
23
+ * Used to generate and return new UUID.
24
+ *
25
+ * @method
26
+ * @returns {string}
27
+ * @public
28
+ */
29
+ module.exports.getUUID = () => {
30
+ return crypto.randomUUID( { disableEntropyCache: true } );
31
+ };
32
+
33
+ /**
34
+ * Used to create a custom Enum list.
35
+ *
36
+ * @method
37
+ * @param {Object} seed
38
+ * @returns {TiEnum}
39
+ * @public
40
+ */
41
+ module.exports.enum = ( seed ) => {
42
+ let properties = {};
43
+
44
+ _.forOwn( seed, ( value, key ) => {
45
+ if ( value instanceof Array ) {
46
+ seed[ key ] = value[ 0 ];
47
+ properties[ value[ 0 ] ] = {
48
+ value: value[ 0 ],
49
+ name: value[ 1 ],
50
+ description: value[ 2 ]
51
+ };
52
+ } else {
53
+ properties[ value ] = {
54
+ value: value,
55
+ name: key.toLowerCase(),
56
+ description: ""
57
+ };
58
+ }
59
+ } );
60
+ seed.properties = properties;
61
+
62
+ Object.freeze( seed );
63
+ return seed;
64
+ };
65
+
66
+ /**
67
+ * Used to get the name of an {@link TiEnum} value if such exists.
68
+ *
69
+ * @method
70
+ * @param {TiEnum} enumList
71
+ * @param {number|string} enumValue
72
+ * @param {string} [placeholder=undefined] If provided it will be returned when the enum value does not have a name defined.
73
+ * @returns {string|undefined}
74
+ * @public
75
+ */
76
+ module.exports.getEnumName = ( enumList, enumValue, placeholder = undefined ) => {
77
+ return ( enumList.properties[ enumValue ] ) ? enumList.properties[ enumValue ].name : placeholder;
78
+ };
79
+
80
+ /**
81
+ * Convert an Error to JSON object.
82
+ * <br/>
83
+ * NOTE: If the value provided is not an error, then it will just be cloned.
84
+ *
85
+ * @method
86
+ * @param {Error} value
87
+ * @returns {Object}
88
+ * @public
89
+ */
90
+ module.exports.errorToJSON = ( value ) => {
91
+ let error = {};
92
+
93
+ if ( value instanceof Error ) {
94
+ Object.getOwnPropertyNames( value ).forEach( ( key ) => {
95
+ error[ key ] = value[ key ];
96
+ } );
97
+ } else {
98
+ error = _.cloneDeep( value );
99
+ }
100
+
101
+ return error;
102
+ };
103
+
104
+ /**
105
+ * Used to parse a value and return its boolean representation (if possible).
106
+ *
107
+ * @param {*} value
108
+ * @returns {boolean}
109
+ * @public
110
+ */
111
+ module.exports.toBool = ( value ) => {
112
+ let result = true;
113
+ let regexp = /^false$|^0$|^no$/i;
114
+
115
+ if ( !value || regexp.test( value ) || value === "N" || value === "0" || ( _.isObjectLike( value ) && _.size( value ) === 0 ) ) {
116
+ result = false;
117
+ }
118
+
119
+ return result;
120
+ };
121
+
122
+ /**
123
+ * Will return UTC date string in format YYYY-MM-DD from the provided date.
124
+ *
125
+ * @method
126
+ * @param {Date} date
127
+ * @returns {string}
128
+ * @public
129
+ */
130
+ module.exports.getUTCDateString = ( date ) => {
131
+ let year = date.getUTCFullYear();
132
+ let month = ( "00" + ( date.getUTCMonth() + 1 ) ).match( /\d{2}$/ );
133
+ let day = ( "00" + date.getUTCDate() ).match( /\d{2}$/ );
134
+
135
+ return String( year + "-" + month + "-" + day );
136
+ };
137
+
138
+ /**
139
+ * Will return UTC time string in format hh:mm:ss.MMM from the provided date.
140
+ *
141
+ * @method
142
+ * @param {Date} date
143
+ * @param {boolean} [useMilliseconds=false]
144
+ * @returns {string}
145
+ * @public
146
+ */
147
+ module.exports.getUTCTimeString = ( date, useMilliseconds ) => {
148
+ let hours = ( "00" + date.getUTCHours() ).match( /\d{2}$/ );
149
+ let minutes = ( "00" + date.getUTCMinutes() ).match( /\d{2}$/ );
150
+ let seconds = ( "00" + date.getUTCSeconds() ).match( /\d{2}$/ );
151
+ let milliseconds = ( "000" + date.getUTCMilliseconds() ).match( /\d{3}$/ );
152
+
153
+ return String( hours + ":" + minutes + ":" + seconds + ( ( useMilliseconds ) ? "." + milliseconds : "" ) );
154
+ };
155
+
156
+ /**
157
+ * Used to remove any circular dependencies from JSON objects.
158
+ * <br/>
159
+ * NOTE: Original file from here - https://github.com/douglascrockford/JSON-js/blob/master/cycle.js
160
+ *
161
+ * Make a deep copy of an object or array, assuring that there is at most one instance of each object or array in the
162
+ * resulting structure. The duplicate references (which might be forming cycles) are replaced with an object of the
163
+ * form of {"$ref": PATH} where the PATH is a JSONPath string that locates the first occurrence.
164
+ *
165
+ * So,
166
+ *
167
+ * var a = [];
168
+ * a[0] = a;
169
+ * return JSON.stringify(JSON.decycle(a));
170
+ *
171
+ * produces the string '[{"$ref":"$"}]'.
172
+ *
173
+ * If a replacer function is provided, then it will be called for each value. A replacer function receives a value
174
+ * and returns a replacement value.
175
+ *
176
+ * JSONPath is used to locate the unique object. $ indicates the top level of the object or array. [NUMBER] or [STRING]
177
+ * indicates a child element or property.
178
+ *
179
+ * @method
180
+ * @param {Object} object
181
+ * @param {function} [replacer]
182
+ * @returns {Object}
183
+ * @public
184
+ */
185
+ module.exports.decycle = ( object, replacer ) => {
186
+ "use strict";
187
+
188
+ let objects = new WeakMap();
189
+
190
+ // The derez function recurse through the object, producing the deep copy.
191
+ return ( function derez( value, path ) {
192
+ let oldPath; // The path of an earlier occurrence of value
193
+ let newItem; // The new object or array
194
+
195
+ // If a replacer function was provided, then call it to get a replacement value.
196
+ if ( replacer !== undefined ) {
197
+ value = replacer( value );
198
+ }
199
+
200
+ // typeof null === "object", so go on if this value is really an object but not
201
+ // one of the weird builtin objects.
202
+ if (
203
+ typeof value === "object"
204
+ && value !== null
205
+ && !( value instanceof Boolean )
206
+ && !( value instanceof Date )
207
+ && !( value instanceof Number )
208
+ && !( value instanceof RegExp )
209
+ && !( value instanceof String )
210
+ ) {
211
+ // If the value is an object or array, look to see if we have already
212
+ // encountered it. If so, return a {"$ref":PATH} object. This uses an
213
+ // ES6 WeakMap.
214
+ oldPath = objects.get( value );
215
+ if ( oldPath !== undefined ) {
216
+ return { $ref: oldPath };
217
+ }
218
+
219
+ // Otherwise, accumulate the unique value and its path.
220
+ objects.set( value, path );
221
+
222
+ // If it is an array, replicate the array.
223
+ if ( Array.isArray( value ) ) {
224
+ newItem = [];
225
+ value.forEach( ( element, i ) => {
226
+ newItem[ i ] = derez( element, path + "[" + i + "]" );
227
+ } );
228
+ } else {
229
+ // If it is an object, replicate the object.
230
+ newItem = {};
231
+ Object.keys( value ).forEach( ( name ) => {
232
+ newItem[ name ] = derez(
233
+ value[ name ],
234
+ path + "[" + JSON.stringify( name ) + "]"
235
+ );
236
+ } );
237
+ }
238
+ return newItem;
239
+ }
240
+ return value;
241
+ }( object, "$" ) );
242
+ };
243
+
244
+ /**
245
+ * Used to restore any circular dependencies from JSON objects after using the 'decycle' method.
246
+ * <br/>
247
+ * NOTE: Original file from here - https://github.com/douglascrockford/JSON-js/blob/master/cycle.js
248
+ *
249
+ * Restore an object that was reduced by decycle. Members whose values are objects of the form of {$ref: PATH} are
250
+ * replaced with references to the value found by the PATH. This will restore cycles. The object will be mutated.
251
+ *
252
+ * The eval function is used to locate the values described by a PATH. The root object is kept in a $ variable. A
253
+ * regular expression is used to assure that the PATH is extremely well formed. The regexp contains nested quantifiers.
254
+ * That has been known to have extremely bad performance problems on some browsers for very long strings. A PATH is
255
+ * expected to be reasonably short. A PATH is allowed to belong to a very restricted subset of Goessner's JSONPath.
256
+ *
257
+ * So,
258
+ *
259
+ * var s = '[{"$ref":"$"}]';
260
+ * return JSON.retrocycle(JSON.parse(s));
261
+ *
262
+ * produces an array containing a single element which is the array itself.
263
+ *
264
+ * @method
265
+ * @param {Object} $
266
+ * @returns {Object}
267
+ * @public
268
+ */
269
+ module.exports.retrocycle = ( $ ) => {
270
+ "use strict";
271
+
272
+ let px = /^\$(?:\[(?:\d+|"(?:[^\\"\u0000-\u001f]|\\(?:[\\"\/bfnrt]|u[0-9a-zA-Z]{4}))*")])*$/;
273
+
274
+ // The rez function walks recursively through the object looking for $ref
275
+ // properties. When it finds one that has a value that is a path, then it
276
+ // replaces the $ref object with a reference to the value that is found by
277
+ // the path.
278
+ ( function rez( value ) {
279
+ if ( value && typeof value === "object" ) {
280
+ if ( Array.isArray( value ) ) {
281
+ value.forEach( ( element, i ) => {
282
+ if ( typeof element === "object" && element !== null ) {
283
+ let path = element.$ref;
284
+ if ( typeof path === "string" && px.test( path ) ) {
285
+ value[ i ] = eval( path );
286
+ } else {
287
+ rez( element );
288
+ }
289
+ }
290
+ } );
291
+ } else {
292
+ Object.keys( value ).forEach( ( name ) => {
293
+ let item = value[ name ];
294
+ if ( typeof item === "object" && item !== null ) {
295
+ let path = item.$ref;
296
+ if ( typeof path === "string" && px.test( path ) ) {
297
+ value[ name ] = eval( path );
298
+ } else {
299
+ rez( item );
300
+ }
301
+ }
302
+ } );
303
+ }
304
+ }
305
+ }( $ ) );
306
+ return $;
307
+ };
308
+
309
+ /**
310
+ * Use this to stringify any JSON object for internal system purposes as it ensures no potential circular dependencies
311
+ * will cause it to throw exception.
312
+ *
313
+ * @method
314
+ * @param {Object} value
315
+ * @return {string}
316
+ * @public
317
+ */
318
+ module.exports.stringifyJSON = ( value ) => {
319
+ let transformed = module.exports.decycle( value );
320
+ return _.isObjectLike( transformed ) ? JSON.stringify( _.toPlainObject( transformed ) ) : value;
321
+ };
322
+
323
+ /**
324
+ * Use this to verify if the provided string can be parsed as a JSON.
325
+ *
326
+ * @method
327
+ * @param {string} string
328
+ * @returns {boolean}
329
+ * @public
330
+ */
331
+ module.exports.isJsonString = ( string ) => {
332
+ try {
333
+ JSON.parse( string );
334
+ } catch ( error ) {
335
+ return false;
336
+ }
337
+ return true;
338
+ };
339
+
340
+ /**
341
+ * Use this to parse any JSON string into JSON object for internal system purposes as it ensures to restore any
342
+ * circular dependencies obscured with 'stringifyJSON'.
343
+ *
344
+ * @method
345
+ * @param {string} value
346
+ * @return {Object}
347
+ * @public
348
+ */
349
+ module.exports.parseJSON = ( value ) => {
350
+ try {
351
+ let transformed = JSON.parse( value );
352
+ return module.exports.retrocycle( transformed );
353
+ } catch ( error ) {
354
+ return value;
355
+ }
356
+ };
357
+
358
+ /**
359
+ * Use this to decompose a JSON object into a sorted string. The values will be ordered alphabetically and combined with
360
+ * their keys, where applicable, starting from the bottom and moving up. Null or undefined values will be ignored and
361
+ * their keys will not be included in the final string.
362
+ *
363
+ * @param {Object} input
364
+ * @recursion
365
+ * @return {string|null}
366
+ * @public
367
+ */
368
+ module.exports.decomposeJSON = ( input ) => {
369
+ let decomposed;
370
+
371
+ if ( !_.isNil( input ) ) {
372
+ if ( _.isArray( input ) ) {
373
+ decomposed = [];
374
+ _.forEach( input, ( value ) => {
375
+ let decomposedValue = module.exports.decomposeJSON( value );
376
+ if ( decomposedValue !== undefined ) {
377
+ decomposed.push( decomposedValue );
378
+ }
379
+ } );
380
+ decomposed = decomposed.sort();
381
+ decomposed = decomposed.join( ":" );
382
+ } else if ( _.isPlainObject( input ) ) {
383
+ decomposed = [];
384
+ _.forOwn( input, ( value, key ) => {
385
+ let decomposedValue = module.exports.decomposeJSON( value );
386
+ if ( decomposedValue !== undefined ) {
387
+ decomposed.push( _.toString( key ) + ":" + decomposedValue );
388
+ }
389
+ } );
390
+ decomposed = decomposed.sort();
391
+ decomposed = decomposed.join( ":" );
392
+ } else {
393
+ decomposed = _.toString( input );
394
+ }
395
+ }
396
+
397
+ return decomposed;
398
+ };
399
+
400
+ /**
401
+ * Used to create a CSV file from the provided data.
402
+ *
403
+ * @method
404
+ * @param {Object[]} data
405
+ * @param {string} filePath
406
+ * @param {string} fileName
407
+ * @return {Promise}
408
+ * @public
409
+ */
410
+ module.exports.createCSVFile = ( data, filePath, fileName ) => {
411
+ return new Promise( ( resolve, reject ) => {
412
+ let fileData = "";
413
+ if ( data && data.length > 0 ) {
414
+ let keys = [];
415
+ _.forOwn( data[ 0 ], ( value, key ) => {
416
+ keys.push( key );
417
+ } );
418
+ keys.sort();
419
+
420
+ _.forEach( keys, ( key, idx ) => {
421
+ fileData += key + ( ( idx < keys.length - 1 ) ? "," : "" );
422
+ } );
423
+ fileData += "\n";
424
+
425
+ _.forEach( data, ( entry ) => {
426
+ _.forEach( keys, ( key, idx ) => {
427
+ fileData += entry[ key ] + ( ( idx < keys.length - 1 ) ? "," : "" );
428
+ } );
429
+ fileData += "\n";
430
+ } );
431
+ }
432
+
433
+ fs.ensureDir( filePath ).then( () => {
434
+ const fullPath = filePath + "/" + Date.now() + "-" + fileName + ".csv";
435
+ return fs.appendFile( fullPath, fileData );
436
+ } ).then( () => {
437
+ resolve();
438
+ } ).catch( ( error ) => {
439
+ reject( error );
440
+ } );
441
+ } );
442
+ };
443
+
444
+ /**
445
+ * Used to create retry policy for the execution of an operation.
446
+ *
447
+ * @class RetryPolicy
448
+ * @public
449
+ */
450
+ class RetryPolicy {
451
+
452
+ #maxAttempts;
453
+ #onFailedAttempt;
454
+ #onRetry;
455
+
456
+ /**
457
+ * @constructor
458
+ */
459
+ constructor( maxAttempts ) {
460
+ this.#maxAttempts = maxAttempts;
461
+ }
462
+
463
+ /* Public interface */
464
+
465
+ /**
466
+ * Used to start execution of the provided operation.
467
+ *
468
+ * @method
469
+ * @param {Object} context The context in which the operation will be executed (i.e. this reference).
470
+ * @param {function} operation Operation to be executed; has to return a Promise.
471
+ * @param {Array} params The arguments to be provided to the operation upon execution.
472
+ * @returns {Promise}
473
+ * @public
474
+ */
475
+ execute( context, operation, params ) {
476
+ return this.#retry( context, operation, params, 1, undefined );
477
+ }
478
+
479
+ /**
480
+ * Used to register a method that will be automatically called on a failed execution attempt.
481
+ *
482
+ * @method
483
+ * @param {function( Error )} action The execution error will be provided as an argument.
484
+ * @public
485
+ */
486
+ onFailedAttempt( action ) {
487
+ if ( typeof ( action ) === "function" ) {
488
+ this.#onFailedAttempt = action;
489
+ }
490
+ }
491
+
492
+ /**
493
+ * Used to register a method that will be automatically called on each execution retry (after the initial one).
494
+ *
495
+ * @method
496
+ * @param {function( number )} action The current attempt number will be provided as an argument.
497
+ * @public
498
+ */
499
+ onRetry( action ) {
500
+ if ( typeof ( action ) === "function" ) {
501
+ this.#onRetry = action;
502
+ }
503
+ }
504
+
505
+ /* Private interface */
506
+
507
+ /**
508
+ * Will retry the execution of operation up to max attempts.
509
+ *
510
+ * @method
511
+ * @param {Object} context
512
+ * @param {function} operation
513
+ * @param {Array} params
514
+ * @param {number} attempt
515
+ * @param {Error} error
516
+ * @returns {Promise}
517
+ * @private
518
+ */
519
+ #retry( context, operation, params, attempt, error ) {
520
+ if ( attempt > this.#maxAttempts ) {
521
+ return Promise.reject( error );
522
+ } else {
523
+ if ( attempt > 1 && this.#onRetry ) {
524
+ this.#onRetry( attempt );
525
+ }
526
+ return operation.apply( context, params ).catch( error => {
527
+ if ( this.#onFailedAttempt ) {
528
+ this.#onFailedAttempt( error );
529
+ }
530
+ return this.#retry( context, operation, params, ( attempt - 1 ), error );
531
+ } );
532
+ }
533
+ }
534
+
535
+ }
536
+
537
+ module.exports.RetryPolicy = RetryPolicy;