bajo-extra 2.6.0 → 2.7.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.
@@ -5,138 +5,122 @@ import { createGzip, createGunzip } from 'zlib'
5
5
  import path from 'path'
6
6
  import { Readable } from 'stream'
7
7
  import numbro from 'numbro'
8
-
9
- async function fetching ({ url, opts, bulk, spin }) {
10
- const { setImmediate, print } = this.app.bajo
11
- const { isEmpty, isFunction, has } = this.app.lib._
12
- const { validationErrorMessage } = this.app.bajoDb
13
- const resp = await this.fetchUrl(url, opts ?? {})
14
- if (isEmpty(resp)) {
15
- spin.fatal('noServerResponse')
16
- return -1
17
- }
18
- if (bulk.abort) {
19
- const aborted = await bulk.abort.call(this, resp)
20
- if (aborted) {
21
- spin.fatal(aborted)
22
- return -1
23
- }
24
- }
25
- let count = 0
26
- const stat = { created: 0, updated: 0, skipped: 0, error: 0 }
27
- bulk.dataKey = bulk.dataKey ?? 'data'
28
- if (bulk.printCount === true) bulk.printCount = 100
29
- const data = isFunction(bulk.dataKey) ? await bulk.dataKey.call(this, resp) : resp[bulk.dataKey]
30
- if (data.length === 0) {
31
- print.warn('noRecordToProcess')
32
- return 0
33
- }
34
- spin.setText('gotRecordsProcessing%d', data.length)
35
- for (let r of data) {
36
- await setImmediate()
37
- if (bulk.converter) r = await bulk.converter.call(this, r, bulk)
38
- if (isEmpty(r)) {
39
- stat.skipped++
40
- continue
41
- }
42
- try {
43
- const result = await bulk.handler.call(this, r, bulk)
44
- if (result && has(stat, result)) stat[result]++
45
- if (bulk.printCount && bulk.printCount < count && (count % bulk.printCount === 0)) print.succeed('[%s] Processed %d/%d', spin.getElapsed(), count, data.length)
46
- else if (!spin.opts.isLog) spin.setText('rec%d%d', count, data.length)
47
- count++
48
- } catch (err) {
49
- console.log(err)
50
- spin.setText(validationErrorMessage(err) + ', continue')
51
- }
52
- }
53
- print.succeed('recProcessed%s%d%d', spin.getElapsed(), count, data.length)
54
- if (!bulk.noStat) print.succeed('createdUpdatedSkipped%s%d%d%d', spin.getElapsed(), stat.created, stat.updated, stat.skipped)
55
- return data.length
56
- }
57
-
58
- async function handler (rec, bulk) {
59
- const { isFunction, set } = this.app.lib._
60
- const { recordCreate, recordFind, recordUpdate } = this.app.bajoDb
61
- const save = bulk.save ?? {}
62
- const current = save.current ?? {}
63
- let existing
64
- let record
65
- let method
66
- save.checkUnique = save.checkUnique ?? 'id'
67
- if (['unique', 'upsert'].includes(save.mode)) {
68
- const query = isFunction(save.checkUnique) ? await save.checkUnique.call(this, rec, save) : set({}, save.checkUnique, rec[save.checkUnique])
69
- const resp = await recordFind(save.coll, { query, limit: 1 }, { noCache: true })
70
- if (resp.length > 0) existing = resp[0]
71
- }
72
- if (existing) {
73
- if (save.mode === 'upsert') {
74
- const body = save.updateConverter ? await save.updateConverter.call(this, rec, save) : rec
75
- try {
76
- record = await recordUpdate(save.coll, existing.id, body)
77
- method = 'updated'
78
- } catch (err) {
79
- console.error(err)
80
- method = 'error'
81
- }
82
- } else {
83
- method = 'skipped'
84
- }
85
- } else {
86
- try {
87
- record = await recordCreate(save.coll, rec)
88
- method = 'created'
89
- } catch (err) {
90
- console.error(err)
91
- method = 'error'
92
- }
93
- }
94
- if (record && current.coll && current.query) {
95
- const query = await current.query.call(this, { body: rec, record, opts: save })
96
- const recs = await recordFind(current.coll, { query }, { noCache: true })
97
- const rc = current.converter ? await current.converter.call(this, { body: rec, record, opts: save }) : rec
98
- if (rc) {
99
- if (recs.length > 0) {
100
- const id = recs[0].id
101
- await recordUpdate(current.coll, id, rc)
102
- } else {
103
- await recordCreate(current.coll, rc)
104
- }
105
- }
106
- }
107
- return method
108
- }
8
+ import sharp from 'sharp'
109
9
 
110
10
  /**
111
- * Plugin factory
11
+ * Plugin factory.
12
+ *
13
+ * **Never** call this function directly!!! It's only-meant to be called by the {@link https://ardhi.github.io/bajo|Bajo framework} during plugin initialization.
112
14
  *
113
15
  * @param {string} pkgName - NPM package name
114
- * @returns {class}
16
+ * @returns {class} BajoExtra
115
17
  */
116
18
  async function factory (pkgName) {
117
19
  const me = this
118
20
 
119
21
  /**
120
- * BajoExtra class
22
+ * BajoExtra class definition.
121
23
  *
122
24
  * @class
123
25
  */
124
- class BajoExtra extends this.app.pluginClass.base {
125
- static alias = 'extra'
126
-
26
+ class BajoExtra extends this.app.baseClass.Base {
27
+ /**
28
+ * Constructor
29
+ */
127
30
  constructor () {
128
31
  super(pkgName, me.app)
32
+ /**
33
+ * @property {object} config - Configuration object
34
+ * @property {string} config.secret - Secret key for encryption/decryption
35
+ * @property {object} config.fetch - Fetch configuration
36
+ * @property {object} config.thumbnail - Thumbnail configuration
37
+ */
129
38
  this.config = {
130
- secret: 'hxKY8Eh63Op9js6ovU25qmq2DmCE9dIB',
39
+ secret: 'hxKY8Eh63Op9js6ovU25qmq2DmCE9dIB', // random hard coded, should be overridden by user config
131
40
  fetch: {
132
41
  agent: {
133
42
  autoSelectFamilyAttemptTimeout: 1000,
134
43
  autoSelectFamily: true
135
44
  }
45
+ },
46
+ thumbnail: {
47
+ inputFormats: ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.tiff', '.svg'],
48
+ outputFormats: ['.png']
136
49
  }
137
50
  }
138
51
  }
139
52
 
53
+ /**
54
+ * Private method to fetch data from a URL and save it, with optional bulk processing.
55
+ *
56
+ * @private
57
+ * @async
58
+ * @method
59
+ * @param {object} options - Options object
60
+ * @param {string} options.url - URL to fetch data from
61
+ * @param {object} options.bulk - Bulk processing options
62
+ * @param {object} options.opts - Fetch options
63
+ * @param {object} options.spin - Spinner object
64
+ * @returns {Promise<number>} - Number of records processed
65
+ */
66
+ _fetching = async (options = {}) => {
67
+ const { url, bulk, opts, spin } = options
68
+ const { setImmediate, print } = this.app.bajo
69
+ const { isEmpty, isFunction, has } = this.app.lib._
70
+ const { validationErrorMessage } = this.app.bajoDb
71
+ const resp = await this.fetchUrl(url, opts ?? {})
72
+ if (isEmpty(resp)) {
73
+ spin.fatal('noServerResponse')
74
+ return -1
75
+ }
76
+ if (bulk.abort) {
77
+ const aborted = await bulk.abort.call(this, resp)
78
+ if (aborted) {
79
+ spin.fatal(aborted)
80
+ return -1
81
+ }
82
+ }
83
+ let count = 0
84
+ const stat = { created: 0, updated: 0, skipped: 0, error: 0 }
85
+ bulk.dataKey = bulk.dataKey ?? 'data'
86
+ if (bulk.printCount === true) bulk.printCount = 100
87
+ const data = isFunction(bulk.dataKey) ? await bulk.dataKey.call(this, resp) : resp[bulk.dataKey]
88
+ if (data.length === 0) {
89
+ print.warn('noRecordToProcess')
90
+ return 0
91
+ }
92
+ spin.setText('gotRecordsProcessing%d', data.length)
93
+ for (let r of data) {
94
+ await setImmediate()
95
+ if (bulk.converter) r = await bulk.converter.call(this, r, bulk)
96
+ if (isEmpty(r)) {
97
+ stat.skipped++
98
+ continue
99
+ }
100
+ try {
101
+ const result = await bulk.handler.call(this, r, bulk)
102
+ if (result && has(stat, result)) stat[result]++
103
+ if (bulk.printCount && bulk.printCount < count && (count % bulk.printCount === 0)) print.succeed('[%s] Processed %d/%d', spin.getElapsed(), count, data.length)
104
+ else if (!spin.opts.isLog) spin.setText('rec%d%d', count, data.length)
105
+ count++
106
+ } catch (err) {
107
+ console.log(err)
108
+ spin.setText(validationErrorMessage(err) + ', continue')
109
+ }
110
+ }
111
+ print.succeed('recProcessed%s%d%d', spin.getElapsed(), count, data.length)
112
+ if (!bulk.noStat) print.succeed('createdUpdatedSkipped%s%d%d%d', spin.getElapsed(), stat.created, stat.updated, stat.skipped)
113
+ return data.length
114
+ }
115
+
116
+ /**
117
+ * Format a value as bytes.
118
+ *
119
+ * @method
120
+ * @param {number} value - The value to format.
121
+ * @param {object} opts - Formatting options.
122
+ * @returns {string} - Formatted byte string.
123
+ */
140
124
  formatByte = (value, opts = {}) => {
141
125
  opts.output = 'byte'
142
126
  opts.base = 'binary'
@@ -145,18 +129,42 @@ async function factory (pkgName) {
145
129
  return numbro(value).format(opts)
146
130
  }
147
131
 
132
+ /**
133
+ * Format a value as a floating-point number.
134
+ *
135
+ * @method
136
+ * @param {number} value - The value to format.
137
+ * @param {object} opts - Formatting options.
138
+ * @returns {string} - Formatted floating-point string.
139
+ */
148
140
  formatFloat = (value, opts = {}) => {
149
141
  opts.mantissa = opts.mantissa ?? opts.scale ?? 2
150
142
  opts.thousandSeparated = opts.thousandSeparated ?? true
151
143
  return numbro(value).format(opts)
152
144
  }
153
145
 
146
+ /**
147
+ * Format a value as an integer.
148
+ *
149
+ * @method
150
+ * @param {number} value - The value to format.
151
+ * @param {object} opts - Formatting options.
152
+ * @returns {string} - Formatted integer string.
153
+ */
154
154
  formatInteger = (value, opts = {}) => {
155
155
  opts.mantissa = 0
156
156
  opts.thousandSeparated = opts.thousandSeparated ?? true
157
157
  return numbro(value).format(opts)
158
158
  }
159
159
 
160
+ /**
161
+ * Format a value as a percentage.
162
+ *
163
+ * @method
164
+ * @param {number} value - The value to format.
165
+ * @param {object} opts - Formatting options.
166
+ * @returns {string} - Formatted percentage string.
167
+ */
160
168
  formatPercentage = (value, opts = {}) => {
161
169
  opts.output = 'percent'
162
170
  opts.mantissa = opts.mantissa ?? opts.scale ?? 2
@@ -164,7 +172,15 @@ async function factory (pkgName) {
164
172
  return numbro(value).format(opts)
165
173
  }
166
174
 
167
- // taken from: https://stackoverflow.com/a/41439945
175
+ /**
176
+ * Count the number of lines in a file.
177
+ *
178
+ * @async
179
+ * @method
180
+ * @param {string} file - The path to the file.
181
+ * @returns {Promise<number>} - The number of lines in the file.
182
+ * @see {@link https://stackoverflow.com/a/41439945|StackOverflow}
183
+ */
168
184
  countFileLines = async (file) => {
169
185
  const { fs } = this.app.lib
170
186
  return new Promise((resolve, reject) => {
@@ -185,15 +201,26 @@ async function factory (pkgName) {
185
201
  })
186
202
  }
187
203
 
204
+ /**
205
+ * Download a file from a URL.
206
+ *
207
+ * @async
208
+ * @method
209
+ * @param {string} url - The URL to download the file from.
210
+ * @param {object} opts - Fetch options.
211
+ * @param {object} extra - Extra options for downloading.
212
+ * @returns {Promise<string>} - The path to the downloaded file.
213
+ */
188
214
  download = async (url, opts = {}, extra = {}) => {
189
- const { getPluginDataDir, importPkg, generateId } = this.app.bajo
215
+ const { importPkg } = this.app.bajo
216
+ const { generateId } = this.app.lib.aneka
190
217
  const { fetch } = await importPkg('bajoExtra:undici')
191
218
  const { fs } = this.app.lib
192
219
  const { isFunction, merge } = this.app.lib._
193
220
  if (typeof opts === 'string') extra = { dir: opts }
194
221
  const increment = await importPkg('bajo:add-filename-increment')
195
222
  if (!extra.dir) {
196
- extra.dir = `${getPluginDataDir('bajoExtra')}/download`
223
+ extra.dir = `${this.app.getPluginDataDir('bajoExtra')}/download`
197
224
  fs.ensureDirSync(extra.dir)
198
225
  }
199
226
  if (!fs.existsSync(extra.dir)) throw this.error('dlDirNotExists%s', extra.dir)
@@ -231,14 +258,86 @@ async function factory (pkgName) {
231
258
  })
232
259
  }
233
260
 
234
- fetchAndSave = async ({ url, bulk, save = {}, opts = {} } = {}) => {
235
- const { startPlugin } = this.bajo
236
- const { merge } = this.bajo.lib._
237
- merge(bulk, { handler, save })
238
- await startPlugin('dobo')
261
+ /**
262
+ * Fetch data from a URL and save it using the specified options.
263
+ *
264
+ * @async
265
+ * @method
266
+ * @param {object} options - The options for fetching and saving data.
267
+ * @param {string} options.url - The URL to fetch data from.
268
+ * @param {object} options.bulk - Bulk operation options.
269
+ * @param {object} options.save - Save options.
270
+ * @param {object} options.opts - Fetch options.
271
+ */
272
+ fetchAndSave = async (options) => {
273
+ const { url, bulk, save = {}, opts = {} } = options
274
+ if (!this.app.dobo) throw this.error('unknownPluginOrNotLoaded%s', 'dobo')
275
+ bulk.save = save
276
+ bulk.handler = async (rec, bulk) => {
277
+ const { isFunction, set } = this.app.lib._
278
+ const { recordCreate, recordFind, recordUpdate } = this.app.bajoDb
279
+ const save = bulk.save ?? {}
280
+ const current = save.current ?? {}
281
+ let existing
282
+ let record
283
+ let method
284
+ save.checkUnique = save.checkUnique ?? 'id'
285
+ if (['unique', 'upsert'].includes(save.mode)) {
286
+ const query = isFunction(save.checkUnique) ? await save.checkUnique.call(this, rec, save) : set({}, save.checkUnique, rec[save.checkUnique])
287
+ const resp = await recordFind(save.coll, { query, limit: 1 }, { noCache: true })
288
+ if (resp.length > 0) existing = resp[0]
289
+ }
290
+ if (existing) {
291
+ if (save.mode === 'upsert') {
292
+ const body = save.updateConverter ? await save.updateConverter.call(this, rec, save) : rec
293
+ try {
294
+ record = await recordUpdate(save.coll, existing.id, body)
295
+ method = 'updated'
296
+ } catch (err) {
297
+ console.error(err)
298
+ method = 'error'
299
+ }
300
+ } else {
301
+ method = 'skipped'
302
+ }
303
+ } else {
304
+ try {
305
+ record = await recordCreate(save.coll, rec)
306
+ method = 'created'
307
+ } catch (err) {
308
+ console.error(err)
309
+ method = 'error'
310
+ }
311
+ }
312
+ if (record && current.coll && current.query) {
313
+ const query = await current.query.call(this, { body: rec, record, opts: save })
314
+ const recs = await recordFind(current.coll, { query }, { noCache: true })
315
+ const rc = current.converter ? await current.converter.call(this, { body: rec, record, opts: save }) : rec
316
+ if (rc) {
317
+ if (recs.length > 0) {
318
+ const id = recs[0].id
319
+ await recordUpdate(current.coll, id, rc)
320
+ } else {
321
+ await recordCreate(current.coll, rc)
322
+ }
323
+ }
324
+ }
325
+ return method
326
+ }
327
+
328
+ await this.app.startPlugin('dobo')
239
329
  await this.fetchBulk(url, bulk, opts)
240
330
  }
241
331
 
332
+ /**
333
+ * Fetch data from a URL in bulk, processing it in steps and applying a handler function to each item.
334
+ *
335
+ * @async
336
+ * @method
337
+ * @param {string} url - The URL to fetch data from.
338
+ * @param {object} bulk - Bulk operation options.
339
+ * @param {object} opts - Fetch options.
340
+ */
242
341
  fetchBulk = async (url, bulk = {}, opts = {}) => {
243
342
  const { isFunction } = this.bajo.lib._
244
343
  opts.params = opts.params ?? {}
@@ -252,7 +351,7 @@ async function factory (pkgName) {
252
351
  this.print.info('batch%s%d', spin.getElapsed(), step)
253
352
  const newOpts = await bulk.paramsIncFn.call(this, { url, bulk, opts })
254
353
  if (newOpts) opts = newOpts
255
- const length = await fetching.call(this, { url, bulk, opts, spin })
354
+ const length = await this._fetching({ url, bulk, opts, spin })
256
355
  if (length === 0 || (bulk.maxStep > 0 && step >= bulk.maxStep)) {
257
356
  this.print.info('allDone')
258
357
  break
@@ -261,10 +360,33 @@ async function factory (pkgName) {
261
360
  }
262
361
  } else {
263
362
  const spin = this.print.spinner({ showCounter: true }).start('fetchingStarts')
264
- await fetching.call(this, { url, bulk, opts, spin })
363
+ await this._fetching({ url, bulk, opts, spin })
265
364
  }
266
365
  }
267
366
 
367
+ /**
368
+ * Download a file/resource from a URL.
369
+ *
370
+ * @async
371
+ * @method
372
+ * @param {string} url - The URL to download the file from.
373
+ * @param {object} [opts={}] - Fetch options. See {@link https://undici.nodejs.org//api/Client|Undici Client options} for available options.
374
+ * @param {object} [opts.method='GET'] - HTTP method to use for the request. Defaults to ```'GET'```.
375
+ * @param {object} [opts.auth] - Basic authentication credentials.
376
+ * @param {string} [opts.auth.username] - Username for basic authentication.
377
+ * @param {string} [opts.auth.password] - Password for basic authentication.
378
+ * @param {object} [opts.query] - Query parameters to append to the URL.
379
+ * @param {object} [opts.params] - Alias for ```opts.query```. Query parameters to append to the URL.
380
+ * @param {object} [opts.headers] - HTTP headers to include in the request.
381
+ * @param {object} [opts.body] - Request body to send with the request.
382
+ * @param {boolean} [extra.formData=false] - If true, sends the request body as multipart/form-data. Defaults to ```false```.
383
+ * @param {object} [extra={}] - Extra options for downloading.
384
+ * @param {boolean} [extra.cacheBuster=true] - If true, appends a cache-busting query parameter to the URL. Defaults to ```true```.
385
+ * @param {string} [extra.cacheBusterKey='_'] - The query parameter key to use for cache-busting. Defaults to ```'_'```.
386
+ * @param {object} [extra.agent] - Custom agent options for the fetch request. If provided, overrides the default agent configuration.
387
+ * @param {boolean} [extra.rawResponse=false] - If true, returns the raw response object instead of parsing it as JSON. Defaults to ```false```.
388
+ * @returns {Promise<Object>} - The response object.
389
+ */
268
390
  fetchUrl = async (url, opts = {}, extra = {}) => {
269
391
  const { importPkg } = this.app.bajo
270
392
  const { fetch, Agent } = await importPkg('bajoExtra:undici')
@@ -286,6 +408,7 @@ async function factory (pkgName) {
286
408
  for (const q in query) {
287
409
  if (!isSet(query[q])) delete query[q]
288
410
  }
411
+ opts.query = opts.query ?? {}
289
412
  if (!isEmpty(query)) opts.query = query
290
413
  delete opts.params
291
414
  if (!has(extra, 'cacheBuster')) extra.cacheBuster = true
@@ -320,10 +443,29 @@ async function factory (pkgName) {
320
443
  return await resp.json()
321
444
  }
322
445
 
446
+ /**
447
+ * Gunzip a file, optionally deleting the original file after extraction.
448
+ *
449
+ * @async
450
+ * @method
451
+ * @param {string} file - The path to the file to be gunzipped.
452
+ * @param {boolean} [deleteOld=false] - If true, deletes the original file after extraction. Defaults to false.
453
+ * @returns {Promise<void>} - Resolves when the gunzip operation is complete.
454
+ */
323
455
  gunzip = async (file, deleteOld) => {
324
456
  await this.gzip(file, deleteOld, true)
325
457
  }
326
458
 
459
+ /**
460
+ * Gzip or gunzip a file, optionally deleting the original file after the operation.
461
+ *
462
+ * @async
463
+ * @method
464
+ * @param {string} file - The path to the file to be gzipped or gunzipped.
465
+ * @param {boolean} [deleteOld=false] - If true, deletes the original file after the operation. Defaults to ```false```.
466
+ * @param {boolean} [expand=false] - If true, gunzips the file. If false, gzips the file. Defaults to ```false```.
467
+ * @returns {Promise<void>} - Resolves when the gzip or gunzip operation is complete.
468
+ */
327
469
  gzip = async (file, deleteOld, expand) => {
328
470
  const { fs } = this.app.lib
329
471
  return new Promise((resolve, reject) => {
@@ -341,30 +483,90 @@ async function factory (pkgName) {
341
483
  })
342
484
  }
343
485
 
344
- hash = async (text, type = 'md5', options = {}) => {
486
+ /**
487
+ * Hash a given text or object using the specified algorithm and options.
488
+ *
489
+ * @async
490
+ * @method
491
+ * @param {string|object} input - The text or object to be hashed.
492
+ * @param {string} [type='md5'] - The hashing algorithm to use. Defaults to 'md5'. Set to 'bcrypt' for bcrypt hashing or 'short' for a short hash.
493
+ * @param {object} [options={}] - Additional options for the hashing algorithm.
494
+ * @param {string} [options.digest='hex'] - The output encoding for the hash. Defaults to 'hex'.
495
+ * @param {number} [options.salt=10] - The salt rounds for bcrypt hashing. Defaults to 10.
496
+ * @returns {Promise<string>} - The resulting hash.
497
+ */
498
+ hash = async (input, type = 'md5', options = {}) => {
345
499
  const { importPkg } = this.app.bajo
346
500
  const bcrypt = await importPkg('bajoExtra:bcrypt')
347
501
  options.digest = options.digest ?? 'hex'
348
- options.salt = options.hash ?? 10
349
- if (typeof text !== 'string') text = JSON.stringify(text)
350
- if (type === 'bcrypt') return await bcrypt.hash(text, options.salt)
502
+ options.salt = options.salt ?? 10
503
+ if (typeof input !== 'string') input = JSON.stringify(input)
504
+ if (type === 'bcrypt') return await bcrypt.hash(input, options.salt)
351
505
  if (type === 'short') {
352
506
  type = 'shake256'
353
507
  options.outputLength = 6
354
508
  }
355
- return crypto.createHash(type, options).update(text).digest(options.digest)
509
+ return crypto.createHash(type, options).update(input).digest(options.digest)
356
510
  }
357
511
 
512
+ /**
513
+ * Check if a given text is a bcrypt hash.
514
+ *
515
+ * @method
516
+ * @param {string} text - The text to be checked.
517
+ * @returns {boolean} - True if the text is a bcrypt hash, false otherwise.
518
+ */
358
519
  isBcrypt = (text) => {
359
520
  // return /^\$2[ayb]\$.{56}$/.test(text)
360
521
  return /^\$2[aby]?\$\d{1,2}\$[./A-Za-z0-9]{53}$/.test(text)
361
522
  }
362
523
 
524
+ /**
525
+ * Check if a given text is an MD5 hash.
526
+ *
527
+ * @method
528
+ * @param {string} text - The text to be checked.
529
+ * @returns {boolean} - True if the text is an MD5 hash, false otherwise.
530
+ */
363
531
  isMd5 = (text) => {
364
532
  return /^[a-f0-9]{32}$/i.test(text)
365
533
  }
366
534
 
367
- encrypt = async (text, { type = 'short', subType = 'qr' } = {}) => {
535
+ /**
536
+ * Check if a given text is a SHA-256 hash.
537
+ *
538
+ * @method
539
+ * @param {string} text - The text to be checked.
540
+ * @returns {boolean} - True if the text is a SHA-256 hash, false otherwise.
541
+ */
542
+ isSha256 = (text) => {
543
+ return /^[a-f0-9]{64}$/i.test(text)
544
+ }
545
+
546
+ /**
547
+ * Check if a given text is an HTML link.
548
+ *
549
+ * @method
550
+ * @param {string} text - The text to be checked.
551
+ * @returns {boolean} - True if the text is an HTML link, false otherwise.
552
+ */
553
+ isHtmlLink = (text) => {
554
+ return /<a\s+[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi.test(text)
555
+ }
556
+
557
+ /**
558
+ * Encrypt a given text using the specified encryption type and options.
559
+ *
560
+ * @async
561
+ * @method
562
+ * @param {string} text - The text to be encrypted.
563
+ * @param {Object} [options={}] - The encryption options.
564
+ * @param {string} [options.type='short'] - The encryption type. Defaults to 'short'.
565
+ * @param {string} [options.subType='qr'] - The encryption sub-type. Defaults to 'qr'.
566
+ * @returns {Promise<string>} - The encrypted text.
567
+ */
568
+ encrypt = async (text, options = {}) => {
569
+ const { type = 'short', subType = 'qr' } = options
368
570
  const { importPkg } = this.app.bajo
369
571
  const { ShortCrypt } = await importPkg('bajoExtra:short-crypt')
370
572
  const short = (item) => {
@@ -378,7 +580,19 @@ async function factory (pkgName) {
378
580
  throw this.error('invalid%s%s', this.t('encryption type'), type)
379
581
  }
380
582
 
381
- decrypt = async (cipher, { type = 'short', subType = 'qr' } = {}) => {
583
+ /**
584
+ * Decrypt a given cipher using the specified decryption type and options.
585
+ *
586
+ * @async
587
+ * @method
588
+ * @param {string} cipher - The cipher to be decrypted.
589
+ * @param {Object} [options={}] - The decryption options.
590
+ * @param {string} [options.type='short'] - The decryption type. Defaults to 'short'.
591
+ * @param {string} [options.subType='qr'] - The decryption sub-type. Defaults to 'qr'.
592
+ * @returns {Promise<string>} - The decrypted text.
593
+ */
594
+ decrypt = async (cipher, options = {}) => {
595
+ const { type = 'short', subType = 'qr' } = options
382
596
  const { importPkg } = this.app.bajo
383
597
  const { ShortCrypt } = await importPkg('bajoExtra:short-crypt')
384
598
  const short = (item) => {
@@ -392,11 +606,94 @@ async function factory (pkgName) {
392
606
  throw this.error('invalid%s%s', this.t('decryption type'), type)
393
607
  }
394
608
 
609
+ /**
610
+ * Generate a random number or letter within a specified range.
611
+ *
612
+ * @method
613
+ * @param {number} min - The minimum value of the range.
614
+ * @param {number} max - The maximum value of the range.
615
+ * @param {boolean} [alpha=false] - Whether to return a letter instead of a number.
616
+ * @returns {number|string} - The generated random number or letter.
617
+ */
395
618
  randomRange = (min, max, alpha) => {
396
619
  const num = Math.floor(Math.random() * (max - min + 1) + min)
397
620
  if (!alpha) return num
398
621
  return String.fromCharCode(96 + num)
399
622
  }
623
+
624
+ /**
625
+ * Get the dimensions for a given thumbnail size name.
626
+ *
627
+ * @method
628
+ * @param {string} name - The name of the thumbnail size (e.g., 's', 'm', 'l', or '<width>x<height>').
629
+ * @returns {number[]} - The width and height of the thumbnail.
630
+ */
631
+ thumbnailSizes = name => {
632
+ switch (name) {
633
+ case 's': return [36, 36]
634
+ case 'm': return [100, 100]
635
+ case 'l': return [250, 250]
636
+ default: {
637
+ const [w, h] = name.split('x').map(s => parseInt(s))
638
+ if (!w || !h) return [0, 0]
639
+ return [w, h]
640
+ }
641
+ }
642
+ }
643
+
644
+ /**
645
+ * Create a thumbnail for a given image file.
646
+ *
647
+ * @async
648
+ * @method
649
+ * @param {string} file - The path to the image file.
650
+ * @param {Object} [options={}] - The options for creating the thumbnail.
651
+ * @param {string} [options.dir] - The directory to save the thumbnail. Defaults to the same directory as the file.
652
+ * @param {boolean} [options.silent=true] - Whether to silently ignore errors. Defaults to true.
653
+ * @param {string|string[]} [options.size] - The size(s) of the thumbnail. Defaults to the configured sizes.
654
+ * @param {string|string[]} [options.format] - The format(s) of the thumbnail. Defaults to the configured output formats.
655
+ * @param {Object} [options.opts={}] - Additional options for the sharp library.
656
+ * @returns {Promise<void>}
657
+ */
658
+ createThumbnail = async (file, options = {}) => {
659
+ const { fs } = this.app.lib
660
+ const { isString } = this.app.lib._
661
+ let {
662
+ dir = path.dirname(file),
663
+ silent = true,
664
+ size = this.config.thumbnail.sizes,
665
+ format = this.config.thumbnail.outputFormats,
666
+ opts = {}
667
+ } = options
668
+ if (isString(size)) size = [size]
669
+ if (isString(format)) format = [format]
670
+ const ext = path.extname(file)
671
+
672
+ if (!this.config.thumbnail.inputFormats.includes(ext)) {
673
+ if (silent) return
674
+ throw this.error('tnUnsupportedFormat%s%s', file, this.config.thumbnail.inputFormats)
675
+ }
676
+ const base = path.basename(file, ext)
677
+ fs.ensureDirSync(dir)
678
+ for (const s of size) {
679
+ const [w, h] = this.thumbnailSizes(s)
680
+ if (w === 0 || h === 0) {
681
+ if (silent) continue
682
+ throw this.error('tnInvalidSize%s%s', file, [...size, '<width>x<height>'])
683
+ }
684
+ for (const to of format) {
685
+ const dest = `${dir}/${base}-${s}${to}`
686
+ try {
687
+ await sharp(file)
688
+ .resize(w, h, opts)
689
+ .toFile(dest)
690
+ } catch (err) {
691
+ if (silent) continue
692
+ throw err
693
+ }
694
+ }
695
+ }
696
+ }
400
697
  }
401
698
 
402
699
  return BajoExtra