@nexusbloom/cli 0.9.6 → 0.9.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nexusbloom/cli",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "description": "NexusBloom CLI — run tools and workflows from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
package/src/values.js CHANGED
@@ -13,6 +13,8 @@
13
13
  */
14
14
 
15
15
  /** Distinguishes "the field is absent" from "the field holds undefined". */
16
+ import { createHash, randomUUID } from "crypto";
17
+
16
18
  export const MISSING = Symbol("missing");
17
19
 
18
20
  /**
@@ -96,6 +98,40 @@ export function formatPath(segments) {
96
98
  return out;
97
99
  }
98
100
 
101
+ // ─── Helpers for the computed transforms ─────────────────────────────────────
102
+
103
+ /**
104
+ * Compare loosely enough that `eq(200)` matches a JSON number but `eq("200")`
105
+ * also matches the string a tool happened to return, while still refusing to
106
+ * claim `[1]` equals `[1]`.
107
+ */
108
+ function coerceLike(other, sample) {
109
+ if (typeof other === "string" && typeof sample === "number") {
110
+ const n = Number(other);
111
+ return isNaN(n) ? other : n;
112
+ }
113
+ return other;
114
+ }
115
+
116
+ /**
117
+ * Equality that forgives a number arriving as text, without extending that
118
+ * forgiveness to containers. Comparing by string would make two separate
119
+ * `[1]`s equal and two `{a:1}`s equal, which is never what "are these the same"
120
+ * should answer.
121
+ */
122
+ function looseEqual(a, b) {
123
+ if (a === b) return true;
124
+ const bothPrimitives =
125
+ (a === null || typeof a !== "object") && (b === null || typeof b !== "object");
126
+ if (!bothPrimitives) return false;
127
+ return String(a) === String(b);
128
+ }
129
+
130
+ /** Stable hash of a stringified value, for change detection and cache keys. */
131
+ function digest(value, algorithm) {
132
+ return createHash(algorithm).update(toStr(value), "utf8").digest("hex");
133
+ }
134
+
99
135
  // ─── Coercion helpers ────────────────────────────────────────────────────────
100
136
 
101
137
  const toStr = (v) => {
@@ -204,6 +240,226 @@ export const TRANSFORMS = {
204
240
  return r;
205
241
  },
206
242
  default: (v, fallback) => (v === MISSING || v === undefined || v === null || v === "" ? fallback : v),
243
+
244
+ // — arithmetic —
245
+ //
246
+ // The tools in a catalogue rarely line up numerically: one returns a count, the
247
+ // next wants a word count. These fill the gap without a dedicated tool, which is
248
+ // the difference between "add a tool" and "compose what exists".
249
+ //
250
+ // A wrong input type returns the value untouched rather than throwing or
251
+ // coercing, so a transform never silently invents a number.
252
+ add: (v, n) => (typeof v === "number" && isFinite(v) ? v + Number(n) : v),
253
+ sub: (v, n) => (typeof v === "number" && isFinite(v) ? v - Number(n) : v),
254
+ mul: (v, n) => (typeof v === "number" && isFinite(v) ? v * Number(n) : v),
255
+ div: (v, n) => (typeof v === "number" && isFinite(v) && Number(n) !== 0 ? v / Number(n) : v),
256
+ mod: (v, n) => (typeof v === "number" && isFinite(v) && Number(n) !== 0 ? v % Number(n) : v),
257
+ inc: (v) => (typeof v === "number" && isFinite(v) ? v + 1 : v),
258
+ dec: (v) => (typeof v === "number" && isFinite(v) ? v - 1 : v),
259
+ neg: (v) => (typeof v === "number" && isFinite(v) ? -v : v),
260
+ abs: (v) => (typeof v === "number" && isFinite(v) ? Math.abs(v) : v),
261
+ pow: (v, n) => (typeof v === "number" && isFinite(v) ? v ** Number(n) : v),
262
+ round: (v, p = 0) => (typeof v === "number" && isFinite(v) ? Number(v.toFixed(Number(p))) : v),
263
+ floor: (v) => (typeof v === "number" && isFinite(v) ? Math.floor(v) : v),
264
+ ceil: (v) => (typeof v === "number" && isFinite(v) ? Math.ceil(v) : v),
265
+ clamp: (v, lo, hi) =>
266
+ typeof v === "number" && isFinite(v)
267
+ ? Math.min(Math.max(v, Number(lo)), Number(hi))
268
+ : v,
269
+
270
+ // Aggregate — the whole input array collapses to one number.
271
+ sum: (v) => (Array.isArray(v) ? v.filter((n) => typeof n === "number").reduce((a, b) => a + b, 0) : v),
272
+ avg: (v) => {
273
+ if (!Array.isArray(v)) return v;
274
+ const nums = v.filter((n) => typeof n === "number");
275
+ return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : MISSING;
276
+ },
277
+ // These collapse an *array*; they do not compare against a bound. `clamp(lo,hi)`
278
+ // is the one that takes bounds. Ignoring a second argument silently was the
279
+ // reason `min(120, 60)` returned 120 instead of erroring, so it now returns the
280
+ // value untouched rather than pretending to have clamped anything.
281
+ min: (v, ...ignored) =>
282
+ ignored.length
283
+ ? v
284
+ : Array.isArray(v) && v.length
285
+ ? Math.min(...v.filter((n) => typeof n === "number"))
286
+ : v,
287
+ max: (v, ...ignored) =>
288
+ ignored.length
289
+ ? v
290
+ : Array.isArray(v) && v.length
291
+ ? Math.max(...v.filter((n) => typeof n === "number"))
292
+ : v,
293
+ // Sum one field across an array of objects — the common "total of the column".
294
+ sumBy: (v, key) => {
295
+ if (!Array.isArray(v)) return v;
296
+ return v
297
+ .map((r) => getPath(r, key))
298
+ .filter((n) => typeof n === "number")
299
+ .reduce((a, b) => a + b, 0);
300
+ },
301
+
302
+ // — comparison —
303
+ //
304
+ // These return real booleans, so they are the input to `then()` and to any tool
305
+ // taking a boolean. A non-matching value is `false`, never MISSING.
306
+ eq: (v, other) => looseEqual(v, other),
307
+ ne: (v, other) => !looseEqual(v, other),
308
+ gt: (v, n) => typeof v === "number" && isFinite(v) && v > Number(n),
309
+ gte: (v, n) => typeof v === "number" && isFinite(v) && v >= Number(n),
310
+ lt: (v, n) => typeof v === "number" && isFinite(v) && v < Number(n),
311
+ lte: (v, n) => typeof v === "number" && isFinite(v) && v <= Number(n),
312
+ between: (v, lo, hi) => typeof v === "number" && isFinite(v) && v >= Number(lo) && v <= Number(hi),
313
+ includes: (v, needle) =>
314
+ typeof v === "string"
315
+ ? v.includes(needle)
316
+ : Array.isArray(v)
317
+ ? v.some((x) => x === needle)
318
+ : false,
319
+ startsWith: (v, s) => typeof v === "string" && v.startsWith(s),
320
+ endsWith: (v, s) => typeof v === "string" && v.endsWith(s),
321
+ // Regex source only — a pattern with spaces or flags cannot be expressed, and a
322
+ // bad pattern is the user's typo rather than something to guess at.
323
+ matches: (v, re) => {
324
+ if (typeof v !== "string" || !re) return false;
325
+ try {
326
+ return new RegExp(String(re)).test(v);
327
+ } catch {
328
+ return false;
329
+ }
330
+ },
331
+ isEmpty: (v) =>
332
+ v === MISSING || v === undefined || v === null || v === "" ||
333
+ (Array.isArray(v) && v.length === 0) ||
334
+ (typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 0),
335
+
336
+ // — branching —
337
+ //
338
+ // `then(cond, whenTrue, whenFalse)` is what a linear pipeline was missing: pick
339
+ // between two values on a condition, without a step to do it. `coalesce` is the
340
+ // safe default chain that does not need a condition at all.
341
+ then: (v, cond, whenTrue, whenFalse) => {
342
+ const test = typeof cond === "boolean" ? cond : v === true;
343
+ return test ? whenTrue : whenFalse === undefined ? MISSING : whenFalse;
344
+ },
345
+ coalesce: (v, ...fallbacks) => {
346
+ if (v !== MISSING && v !== undefined && v !== null && v !== "") return v;
347
+ for (const f of fallbacks) {
348
+ if (f !== MISSING && f !== undefined && f !== null && f !== "") return f;
349
+ }
350
+ return MISSING;
351
+ },
352
+
353
+ // — arrays —
354
+ //
355
+ // `pluck` is the one that unlocks the most: it turns an array of records into a
356
+ // flat array of one field, which everything above (`len`, `sort`, `sumBy`,
357
+ // `join`) can then work on.
358
+ pluck: (v, key) =>
359
+ Array.isArray(v) ? v.map((row) => getPath(row, key)) : v,
360
+ unique: (v) => (Array.isArray(v) ? [...new Set(v)] : v),
361
+ compact: (v) =>
362
+ Array.isArray(v) ? v.filter((x) => x !== undefined && x !== null && x !== "") : v,
363
+ flatten: (v) => (Array.isArray(v) ? v.flat(Infinity) : v),
364
+ chunk: (v, n) => {
365
+ if (!Array.isArray(v)) return v;
366
+ const size = Math.max(1, Number(n) || 1);
367
+ const out = [];
368
+ for (let i = 0; i < v.length; i += size) out.push(v.slice(i, i + size));
369
+ return out;
370
+ },
371
+ without: (v, needle) => (Array.isArray(v) ? v.filter((x) => x !== needle) : v),
372
+ // Keep array elements whose field passes a comparison: where("status","eq",200).
373
+ where: (v, key, op, operand) => {
374
+ if (!Array.isArray(v)) return v;
375
+ const test = (row) => {
376
+ const got = getPath(row, key);
377
+ switch (op) {
378
+ case "eq": return looseEqual(got, operand);
379
+ case "ne": return !looseEqual(got, operand);
380
+ case "gt": return typeof got === "number" && got > Number(operand);
381
+ case "gte": return typeof got === "number" && got >= Number(operand);
382
+ case "lt": return typeof got === "number" && got < Number(operand);
383
+ case "lte": return typeof got === "number" && got <= Number(operand);
384
+ case "contains": return String(got ?? "").includes(String(operand));
385
+ case "exists": return got !== MISSING && got !== undefined && got !== null;
386
+ default: return false;
387
+ }
388
+ };
389
+ return v.filter(test);
390
+ },
391
+
392
+ // — naming and shape —
393
+ title: (v) => toStr(v).replace(/\b\w/g, (c) => c.toUpperCase()),
394
+ camel: (v) => toStr(v).replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : "")).replace(/^(.)/, (c) => c.toLowerCase()),
395
+ snake: (v) => toStr(v).replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[-\s]+/g, "_").toLowerCase(),
396
+ kebab: (v) => toStr(v).replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").toLowerCase(),
397
+ slugify: (v) =>
398
+ toStr(v)
399
+ .toLowerCase()
400
+ .normalize("NFKD")
401
+ .replace(/[\u0300-\u036f]/g, "")
402
+ .replace(/[^a-z0-9]+/g, "-")
403
+ .replace(/^-+|-+$/g, ""),
404
+ truncate: (v, n, ellipsis = "…") => {
405
+ const s = toStr(v);
406
+ const max = Math.max(0, Number(n) || 0);
407
+ if (s.length <= max) return s;
408
+ // Prefer a word boundary, but only if one is close enough that truncating to
409
+ // it does not throw away most of what was asked for.
410
+ const hard = s.slice(0, max);
411
+ const space = hard.lastIndexOf(" ");
412
+ const cut = space > max * 0.6 ? hard.slice(0, space).replace(/\s+$/, "") : hard;
413
+ return cut + ellipsis;
414
+ },
415
+ repeat: (v, n) => toStr(v).repeat(Math.max(0, Number(n) || 0)),
416
+ // Show the first and last few characters, e.g. mask(4) → "jTI…Nj".
417
+ mask: (v, n = 3) => {
418
+ const s = toStr(v);
419
+ const keep = Math.max(0, Number(n) || 0);
420
+ if (s.length <= keep * 2) return s;
421
+ return `${s.slice(0, keep)}${"…".repeat(1)}${s.slice(-keep)}`;
422
+ },
423
+
424
+ // — encoding and identity —
425
+ //
426
+ // Hashing here is for change detection and cache keys, never for passwords.
427
+ md5: (v) => digest(v, "md5"),
428
+ sha1: (v) => digest(v, "sha1"),
429
+ sha256: (v) => digest(v, "sha256"),
430
+ base64: (v) => Buffer.from(toStr(v), "utf8").toString("base64"),
431
+ unbase64: (v) => {
432
+ const text = toStr(v).trim();
433
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(text)) return MISSING;
434
+ try {
435
+ const bytes = Buffer.from(text, "base64");
436
+ // Buffer.from silently drops anything outside the alphabet, so the only
437
+ // way to notice the input was never base64 is to re-encode and compare.
438
+ // Garbage bytes are worse than an honest MISSING.
439
+ const reencoded = bytes.toString("base64").replace(/=+$/, "");
440
+ if (reencoded !== text.replace(/=+$/, "")) return MISSING;
441
+ return bytes.toString("utf8");
442
+ } catch {
443
+ return MISSING;
444
+ }
445
+ },
446
+ urlEncode: (v) => encodeURIComponent(toStr(v)),
447
+ urlDecode: (v) => {
448
+ try {
449
+ return decodeURIComponent(toStr(v));
450
+ } catch {
451
+ return MISSING;
452
+ }
453
+ },
454
+ uuid: () => randomUUID(),
455
+
456
+ // — introspection —
457
+ type: (v) => (v === MISSING ? "missing" : Array.isArray(v) ? "array" : v === null ? "null" : typeof v),
458
+ isArray: (v) => Array.isArray(v),
459
+ isString: (v) => typeof v === "string",
460
+ isNumber: (v) => typeof v === "number" && isFinite(v),
461
+ isObject: (v) => v !== null && typeof v === "object" && !Array.isArray(v),
462
+ isBool: (v) => typeof v === "boolean",
207
463
  };
208
464
 
209
465
  /** Transforms that are no-ops on their argument order, used for plan hints. */
@@ -217,6 +473,20 @@ export const TRANSFORM_ARITY = {
217
473
  sort: 2,
218
474
  pick: 2,
219
475
  default: 2,
476
+
477
+ add: 2, sub: 2, mul: 2, div: 2, mod: 2, pow: 2,
478
+ round: 2, clamp: 3, sumBy: 2,
479
+ eq: 2, ne: 2, gt: 2, gte: 2, lt: 2, lte: 2,
480
+ between: 3, includes: 2, startsWith: 2, endsWith: 2, matches: 2,
481
+ then: 4,
482
+ // Arity is how many arguments the *builder* asks for, which is one less than
483
+ // the argument count of the hand-written form for a variadic transform.
484
+ // coalesce takes any number of fallbacks; prompting for a fixed count would
485
+ // understate it, so the builder asks for one and a written pipeline may list
486
+ // as many as it likes.
487
+ coalesce: 2,
488
+ pluck: 2, chunk: 2, without: 2, where: 4,
489
+ truncate: 3, repeat: 2, mask: 2,
220
490
  };
221
491
 
222
492
  export function transformNames() {
@@ -260,6 +530,71 @@ export const TRANSFORM_DOCS = {
260
530
  unjson: "parse a JSON string",
261
531
  pick: "one field out of an object",
262
532
  default: "value to use when the path is missing",
533
+ // The catalogue is grouped by purpose; `nxb pipe --transforms` and the
534
+ // builder's picker both read from here, so a transform and its description
535
+ // are always edited together.
536
+ add: "add a number",
537
+ sub: "subtract a number",
538
+ mul: "multiply by a number",
539
+ div: "divide by a number",
540
+ mod: "remainder after division",
541
+ pow: "raise to a power",
542
+ inc: "add one",
543
+ dec: "subtract one",
544
+ neg: "negate",
545
+ abs: "absolute value",
546
+ round: "round to N decimal places",
547
+ floor: "round down to an integer",
548
+ ceil: "round up to an integer",
549
+ clamp: "keep a number between a low and a high",
550
+ sum: "add up every number in an array",
551
+ avg: "average of every number in an array",
552
+ min: "smallest number in an array (to bound a single number use clamp)",
553
+ max: "largest number in an array (to bound a single number use clamp)",
554
+ sumBy: "add up one field across an array of objects",
555
+ eq: "is it equal to a value",
556
+ ne: "is it different from a value",
557
+ gt: "is it greater than a number",
558
+ gte: "is it at least a number",
559
+ lt: "is it less than a number",
560
+ lte: "is it at most a number",
561
+ between: "is a number within a low and a high",
562
+ includes: "does a string or array contain a value",
563
+ startsWith: "does a string start with text",
564
+ endsWith: "does a string end with text",
565
+ matches: "does a string match a regular expression",
566
+ isEmpty: "is the value empty — blank, [], or {}",
567
+ then: "choose between two values on a condition",
568
+ coalesce: "first value that is not empty",
569
+ pluck: "pull one field out of every object in an array",
570
+ unique: "remove duplicates from an array",
571
+ compact: "drop empty values from an array",
572
+ flatten: "flatten a nested array",
573
+ chunk: "split an array into fixed-size groups",
574
+ without: "remove every occurrence of a value",
575
+ where: "keep array items whose field passes a test",
576
+ title: "Title Case",
577
+ camel: "camelCase",
578
+ snake: "snake_case",
579
+ kebab: "kebab-case",
580
+ slugify: "url-safe slug",
581
+ truncate: "shorten to N characters",
582
+ repeat: "repeat a string N times",
583
+ mask: "hide the middle, e.g. jTI…Nj",
584
+ md5: "MD5 hex digest",
585
+ sha1: "SHA-1 hex digest",
586
+ sha256: "SHA-256 hex digest",
587
+ base64: "Base64-encode text",
588
+ unbase64: "decode Base64 text",
589
+ urlEncode: "percent-encode for a URL",
590
+ urlDecode: "decode percent-encoding",
591
+ uuid: "a random UUID",
592
+ type: "the value's type as text",
593
+ isArray: "is it an array",
594
+ isString: "is it text",
595
+ isNumber: "is it a number",
596
+ isObject: "is it an object",
597
+ isBool: "is it true or false",
263
598
  };
264
599
 
265
600
  /**
@@ -284,8 +619,32 @@ export const TRANSFORM_GROUPS = [
284
619
  names: ["int", "float", "num", "str", "bool"] },
285
620
  { label: "json / unjson", doc: "serialise / parse",
286
621
  names: ["json", "unjson"] },
287
- { label: "default(v)", doc: "supply a value when the path is missing",
288
- names: ["default"] },
622
+ { label: "default(v) / coalesce(a,b,…) / then(cond,a,b)", doc: "supply a value when one is missing, or choose on a condition",
623
+ names: ["default", "coalesce", "then"] },
624
+ { label: "eq / ne / gt / gte / lt / lte / between(a,b)", doc: "compare — the result is true or false",
625
+ names: ["eq", "ne", "gt", "gte", "lt", "lte", "between"] },
626
+ { label: "includes / startsWith / endsWith / matches(re) / isEmpty", doc: "test text and arrays",
627
+ names: ["includes", "startsWith", "endsWith", "matches", "isEmpty"] },
628
+ { label: "pluck(field) / where(field,op,value)", doc: "select out of an array of records",
629
+ names: ["pluck", "where"] },
630
+ { label: "unique / compact / flatten / chunk(n) / without(v)", doc: "reshape an array",
631
+ names: ["unique", "compact", "flatten", "chunk", "without"] },
632
+ { label: "add(n) / sub(n) / mul(n) / div(n) / mod(n) / pow(n)", doc: "arithmetic on a number",
633
+ names: ["add", "sub", "mul", "div", "mod", "pow"] },
634
+ { label: "inc / dec / neg / abs / round(p) / floor / ceil / clamp(lo,hi)", doc: "adjust a number",
635
+ names: ["inc", "dec", "neg", "abs", "round", "floor", "ceil", "clamp"] },
636
+ { label: "sum / avg / min / max / sumBy(field)", doc: "collapse an array to one number",
637
+ names: ["sum", "avg", "min", "max", "sumBy"] },
638
+ { label: "title / camel / snake / kebab / slugify", doc: "change a string's shape",
639
+ names: ["title", "camel", "snake", "kebab", "slugify"] },
640
+ { label: "truncate(n) / repeat(n) / mask(n)", doc: "shorten, repeat or hide part of a string",
641
+ names: ["truncate", "repeat", "mask"] },
642
+ { label: "md5 / sha1 / sha256 / base64 / unbase64", doc: "digest or encode — for fingerprints, not secrets",
643
+ names: ["md5", "sha1", "sha256", "base64", "unbase64"] },
644
+ { label: "urlEncode / urlDecode / uuid", doc: "URL and identity helpers",
645
+ names: ["urlEncode", "urlDecode", "uuid"] },
646
+ { label: "type / isArray / isString / isNumber / isObject / isBool", doc: "what is this value",
647
+ names: ["type", "isArray", "isString", "isNumber", "isObject", "isBool"] },
289
648
  ];
290
649
 
291
650
  /**
@@ -350,14 +709,18 @@ export function validateTransformArg(raw) {
350
709
  * @param {Array<{name: string, args: any[]}>} chain
351
710
  * @returns {any|typeof MISSING}
352
711
  */
712
+ /** Transforms permitted to replace a missing value with a fallback. */
713
+ const FALLBACK_TRANSFORMS = new Set(["default", "coalesce"]);
714
+
353
715
  export function applyTransforms(value, chain) {
354
716
  let cur = value;
355
717
  for (const step of chain) {
356
718
  const fn = TRANSFORMS[step.name];
357
719
  if (!fn) throw new Error(`Unknown transform "${step.name}". Run \`nxb pipe --transforms\`.`);
358
- // A missing value stays missing unless the transform can supply one —
359
- // only `default()` is allowed to invent a value.
360
- if (cur === MISSING && step.name !== "default") return MISSING;
720
+ // A missing value stays missing unless the transform is explicitly allowed to
721
+ // supply a fallback — `default(v)` and `coalesce(…)`. Anything else would
722
+ // be inventing a value the tool never produced.
723
+ if (cur === MISSING && !FALLBACK_TRANSFORMS.has(step.name)) return MISSING;
361
724
  // A step may legitimately take no argument; treat a missing `args` as
362
725
  // empty rather than failing to iterate undefined.
363
726
  cur = fn(cur, ...(step.args || []));
package/src/wizard.js CHANGED
@@ -246,7 +246,7 @@ async function wireFields(manifest, sources, P = defaultPrompts) {
246
246
  * The returned string is appended to `$path`, so it carries its own `|`.
247
247
  */
248
248
  async function promptTransformChain(field, P) {
249
- const picked = await P.select({
249
+ const picked = await P.search({
250
250
  message: `${field}: transform${chalk.gray(" (optional)")}`,
251
251
  source: (term) => transformChoices(term, { withNone: true }),
252
252
  pageSize: 15,
@@ -262,7 +262,7 @@ async function promptTransformChain(field, P) {
262
262
  default: false,
263
263
  });
264
264
  if (!more) break;
265
- const next = await P.select({
265
+ const next = await P.search({
266
266
  message: `${field}: another transform`,
267
267
  source: (term) => transformChoices(term, { withNone: true }),
268
268
  pageSize: 15,
@@ -284,7 +284,10 @@ function transformChoices(term, { withNone = false } = {}) {
284
284
  const hits = term ? rankTransforms(term) : transformNames();
285
285
  return [
286
286
  ...skip,
287
- ...hits.slice(0, 50).map((n) => ({
287
+ // No cap: this is a searchable list, so it paginates and scrolls. Truncating
288
+ // here silently hid every transform past the cut-off, which is unreachable
289
+ // by typing the name you can see.
290
+ ...hits.map((n) => ({
288
291
  value: n,
289
292
  name: `${chalk.cyan(n.padEnd(10))} ${chalk.dim(TRANSFORM_DOCS[n] || "")}`,
290
293
  })),