@isrd-isi-edu/ermrestjs 2.6.1 → 2.8.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.
@@ -0,0 +1,755 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ /* eslint-disable @typescript-eslint/no-unused-vars */
3
+
4
+ import moment from 'moment-timezone';
5
+
6
+ // services
7
+ import $log from '@isrd-isi-edu/ermrestjs/src/services/logger';
8
+
9
+ // utils
10
+ import {
11
+ _classNames,
12
+ _dataFormats,
13
+ _datetimeDuration,
14
+ _specialPresentation,
15
+ DURATION_DIRECTION,
16
+ DURATION_UNIT,
17
+ type DurationDirection,
18
+ type DurationRealUnit,
19
+ type DurationUnit,
20
+ } from '@isrd-isi-edu/ermrestjs/src/utils/constants';
21
+ import { renderMarkdown } from '@isrd-isi-edu/ermrestjs/src/utils/markdown-utils';
22
+ import { isValidColorRGBHex } from '@isrd-isi-edu/ermrestjs/src/utils/type-utils';
23
+
24
+ // legacy
25
+ import { _escapeMarkdownCharacters } from '@isrd-isi-edu/ermrestjs/js/utils/helpers';
26
+
27
+ /**
28
+ * Truncate a number to a given number of significant digits.
29
+ *
30
+ * Note this **truncates** (`Math.floor`) rather than rounding, and operates on
31
+ * significant digits rather than decimal places — unlike `_toFixed`
32
+ * below. Used by `humanizeBytes` to keep byte counts from being padded.
33
+ *
34
+ * @param num the number to truncate
35
+ * @param precision target number of significant digits; coerced via `parseInt`
36
+ * and clamped to `minAllowedPrecision`
37
+ * @param minAllowedPrecision floor for `precision` (e.g. 3 for `si`, 4 for `binary`)
38
+ * @returns the truncated value as a string (sign-prefixed when negative). Falls
39
+ * back to the un-truncated number when the calculation overflows to NaN.
40
+ */
41
+ export function _toPrecision(num: number, precision: any, minAllowedPrecision: number): string | number {
42
+ precision = parseInt(precision);
43
+ precision = isNaN(precision) || precision < minAllowedPrecision ? minAllowedPrecision : precision;
44
+
45
+ const isNegative = num < 0;
46
+ if (isNegative) num = num * -1;
47
+
48
+ // Truncation here depends on `minAllowedPrecision`; relaxing the floor needs new math.
49
+ let displayedNum: any = num.toString();
50
+ const f = displayedNum.indexOf('.');
51
+ if (f !== -1) {
52
+ // find the number of digits after decimal point
53
+ const decimalPlaces = Math.pow(10, precision - f);
54
+
55
+ // truncate the value
56
+ displayedNum = Math.floor(num * decimalPlaces) / decimalPlaces;
57
+ }
58
+
59
+ // if precision is too large, the calculation might return NaN.
60
+ if (isNaN(displayedNum)) {
61
+ return (isNegative ? '-' : '') + num;
62
+ }
63
+
64
+ return (isNegative ? '-' : '') + displayedNum;
65
+ }
66
+
67
+ /**
68
+ * Round `value` to `fraction` decimal places and emit it as a string with
69
+ * trailing zeros preserved. Decimal-places + rounding semantics — NOT
70
+ * `_toPrecision`, which truncates on significant digits.
71
+ *
72
+ * Distinct from `Number.prototype.toFixed` in that the value is explicitly
73
+ * rounded via `Math.round` before formatting, avoiding the platform-specific
74
+ * banker's-rounding edge cases that `toFixed` exhibits.
75
+ *
76
+ * @param value the number to format
77
+ * @param fraction number of decimal places to retain
78
+ * @returns the rounded number as a fixed-precision string
79
+ */
80
+ function _toFixed(value: number, fraction: number): string {
81
+ const m = Math.pow(10, fraction);
82
+ return (Math.round(value * m) / m).toFixed(fraction);
83
+ }
84
+
85
+ /**
86
+ * Pick the largest unit (year > month > ... > millisecond) for which the
87
+ * given duration is at least one whole unit. Used by `unit="auto"`.
88
+ *
89
+ * @param absMs the absolute duration in milliseconds
90
+ * @returns the chosen unit name (e.g. `'year'`, `'second'`)
91
+ */
92
+ function _durationPickAutoUnit(absMs: number): DurationRealUnit {
93
+ for (let i = 0; i < _datetimeDuration.UNIT_ORDER.length; i++) {
94
+ const u = _datetimeDuration.UNIT_ORDER[i];
95
+ if (absMs / _datetimeDuration.CONVERSION_RATES[u] >= 1) return u;
96
+ }
97
+ return DURATION_UNIT.MILLISECOND;
98
+ }
99
+
100
+ /**
101
+ * Format a duration in a single named unit (e.g. `"1.5 days"`). When
102
+ * `unit === 'auto'`, the unit is picked via `_durationPickAutoUnit`. Always
103
+ * uses fixed Julian conversion rates (1Y = 365.25d, 1M = 30.4375d).
104
+ *
105
+ * @param absMs the absolute duration in milliseconds
106
+ * @param unit one of the unit names in `_datetimeDuration.UNIT_ORDER`, or `'auto'`
107
+ * @param fraction number of decimal places to show
108
+ * @returns the formatted duration with a pluralized unit suffix
109
+ */
110
+ function _durationFormatSingleUnit(absMs: number, unit: DurationUnit, fraction: number): string {
111
+ const realUnit: DurationRealUnit = unit === DURATION_UNIT.AUTO ? _durationPickAutoUnit(absMs) : (unit as DurationRealUnit);
112
+ const value = absMs / _datetimeDuration.CONVERSION_RATES[realUnit];
113
+ return _toFixed(value, fraction) + ' ' + realUnit + 's';
114
+ }
115
+
116
+ /**
117
+ * Format a duration as a chain of fixed-math unit components
118
+ * (e.g. `"1M 4D 13h 30m 0s"`). Walks year > month > ... > minute consuming
119
+ * whole units, then puts any remainder on seconds at the configured precision.
120
+ *
121
+ * Because the math is fixed-Julian rather than calendar-aware, round calendar
122
+ * diffs will show "lopover": e.g. 365 days renders as `"11M 30D 4h 30m"`
123
+ * since 1M = 30.4375d. For clean calendar output use `_durationFormatCalendarWalk`.
124
+ *
125
+ * @param absMs the absolute duration in milliseconds
126
+ * @param fraction decimal places shown on the seconds component
127
+ * @returns space-separated multi-unit string
128
+ */
129
+ function _durationFormatMultiFixed(absMs: number, fraction: number): string {
130
+ const parts: string[] = [];
131
+ let rest = absMs;
132
+ const midUnits: Array<keyof typeof _datetimeDuration.CONVERSION_RATES> = ['year', 'month', 'day', 'hour', 'minute'];
133
+ for (let i = 0; i < midUnits.length; i++) {
134
+ const u = midUnits[i];
135
+ const whole = Math.floor(rest / _datetimeDuration.CONVERSION_RATES[u]);
136
+ if (whole > 0) parts.push(whole + _datetimeDuration.ABBREVIATIONS[u]);
137
+ rest -= whole * _datetimeDuration.CONVERSION_RATES[u];
138
+ }
139
+ const sec = rest / _datetimeDuration.CONVERSION_RATES.second;
140
+ if (sec > 0 || parts.length === 0) {
141
+ parts.push(_toFixed(sec, fraction) + 's');
142
+ }
143
+ return parts.join(' ');
144
+ }
145
+
146
+ /**
147
+ * Format a duration as a chain of calendar-aware unit components using
148
+ * `moment.diff` + `moment.add`. Yields clean breakdowns for round calendar
149
+ * diffs (1 year → `"1Y"`, 1 month + 4 days → `"1M 4D"`), unlike the
150
+ * fixed-math variant.
151
+ *
152
+ * Two quirks to know about:
153
+ * - Operates in UTC so calendar boundaries don't shift with the host's local
154
+ * time zone (without this, "Jan 31Z → Feb 28Z" on an EST host would walk
155
+ * through "Jan 30 19:00 → Feb 27 19:00" and report 28 days instead of 1 month).
156
+ * - Inherits moment's month-end clamping: `Jan 31 → Feb 28` consumes a whole
157
+ * month, producing `"1M 0D"`. This is moment's documented behavior.
158
+ *
159
+ * @param startM the start moment (sign re-applied by the caller)
160
+ * @param endM the end moment
161
+ * @param fraction decimal places shown on the seconds component
162
+ * @returns space-separated multi-unit string
163
+ */
164
+ function _durationFormatCalendarWalk(startM: moment.Moment, endM: moment.Moment, fraction: number): string {
165
+ let a: moment.Moment, b: moment.Moment;
166
+ if (endM.isBefore(startM)) {
167
+ a = endM.clone().utc();
168
+ b = startM.clone().utc();
169
+ } else {
170
+ a = startM.clone().utc();
171
+ b = endM.clone().utc();
172
+ }
173
+
174
+ const cursor = a.clone();
175
+ const parts: string[] = [];
176
+ const midUnits: Array<'year' | 'month' | 'day' | 'hour' | 'minute'> = ['year', 'month', 'day', 'hour', 'minute'];
177
+ for (let i = 0; i < midUnits.length; i++) {
178
+ const u = midUnits[i];
179
+ const n = b.diff(cursor, u);
180
+ if (n > 0) parts.push(n + _datetimeDuration.ABBREVIATIONS[u]);
181
+ cursor.add(n, u);
182
+ }
183
+ const sec = b.diff(cursor) / 1000;
184
+ if (sec > 0 || parts.length === 0) {
185
+ parts.push(_toFixed(sec, fraction) + 's');
186
+ }
187
+ return parts.join(' ');
188
+ }
189
+
190
+ /**
191
+ * Apply the requested direction modifier to a formatted duration.
192
+ *
193
+ * Behavior:
194
+ * - `unsigned` returns the value unchanged.
195
+ * - `before/after` appends `"before"` or `"after"`.
196
+ * - `earlier/later` appends `"earlier"` or `"later"`.
197
+ * - `sign` (default) prepends `+` or `-`.
198
+ *
199
+ * @param visible the duration string to decorate
200
+ * @param signSign +1 when end is after start, -1 otherwise
201
+ * @param direction one of the values in `_datetimeDuration.VALID_DIRECTIONS`
202
+ * @returns the directionally-adorned duration string
203
+ */
204
+ function _durationApplyDirection(visible: string, signSign: number, direction: DurationDirection): string {
205
+ if (direction === DURATION_DIRECTION.UNSIGNED) return visible;
206
+ if (direction === DURATION_DIRECTION.BEFORE_AFTER) return visible + ' ' + (signSign > 0 ? 'after' : 'before');
207
+ if (direction === DURATION_DIRECTION.EARLIER_LATER) return visible + ' ' + (signSign > 0 ? 'later' : 'earlier');
208
+ return (signSign > 0 ? '+' : '-') + visible;
209
+ }
210
+
211
+ /**
212
+ * Wrap a formatted duration in a Chaise tooltip span. The tooltip body shows
213
+ * the fixed-math multi-unit form on line 1 and the Julian conversion rates
214
+ * for only the units that appear in line 1 on a second line.
215
+ *
216
+ * @param visible the visible (signed) duration text
217
+ * @param absMs the absolute duration in ms (used to derive the tooltip body)
218
+ * @returns the wrapped Chaise tooltip span markup
219
+ */
220
+ function _durationWrapTooltip(visible: string, absMs: number): string {
221
+ const multi = _durationFormatMultiFixed(absMs, 2);
222
+
223
+ const used = new Set<string>();
224
+ for (const tok of multi.split(' ')) {
225
+ const m = tok.match(/[a-zA-Z]+$/);
226
+ if (m) used.add(m[0]);
227
+ }
228
+ const ratesByAbbrev: ReadonlyArray<[string, string]> = [
229
+ ['Y', 'Y = 365.25 days'],
230
+ ['M', 'M = 30.4375 days'],
231
+ ['D', 'D = 24 hours'],
232
+ ['h', 'h = 60 minutes'],
233
+ ['m', 'm = 60 seconds'],
234
+ ];
235
+ const usedRates = ratesByAbbrev.filter(([abbrev]) => used.has(abbrev)).map(([, text]) => text);
236
+ // Two rates per line to keep the tooltip from getting too wide.
237
+ const rateLines: string[] = [];
238
+ for (let i = 0; i < usedRates.length; i += 2) {
239
+ rateLines.push(usedRates.slice(i, i + 2).join(', '));
240
+ }
241
+ const rates = rateLines.join('&#10;');
242
+
243
+ // `&#10;` instead of literal `\n`: a real newline inside `{...}` breaks
244
+ // markdown-it-attrs's parsing of the attribute block.
245
+ const tooltip = rates ? multi + '&#10;&#10;' + rates : multi;
246
+ return ':span:' + visible + ':/span:{data-chaise-tooltip="' + tooltip + '"}';
247
+ }
248
+
249
+ // --- Printable formatters (the contents of _formatUtils) ---
250
+
251
+ /**
252
+ * Format a boolean value for display.
253
+ *
254
+ * @param value the value to format; `null` returns an empty string
255
+ * @param options unused (kept for signature parity with the other formatters)
256
+ * @returns `"true"`, `"false"`, or `""` for null
257
+ */
258
+ export function printBoolean(value: any, options?: any): string {
259
+ options = typeof options === 'undefined' ? {} : options;
260
+ if (value === null) {
261
+ return '';
262
+ }
263
+ return Boolean(value).toString();
264
+ }
265
+
266
+ /**
267
+ * Format an integer value with thousands separators (e.g. `1234567` -> `"1,234,567"`).
268
+ * Any fractional part is rounded off via `Math.round`.
269
+ *
270
+ * @param value the value to format; `null` returns an empty string
271
+ * @param options unused (kept for signature parity with the other formatters)
272
+ * @returns the comma-separated integer string
273
+ */
274
+ export function printInteger(value: any, options?: any): string {
275
+ options = typeof options === 'undefined' ? {} : options;
276
+ if (value === null) {
277
+ return '';
278
+ }
279
+
280
+ // Remove fractional digits
281
+ value = Math.round(value);
282
+
283
+ // Add comma separators
284
+ return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
285
+ }
286
+
287
+ /**
288
+ * Format a timestamp value into the project's display format
289
+ * (`_dataFormats.DATETIME.display`, e.g. `"2017-01-08 15:06:02"`).
290
+ *
291
+ * @param value any value moment() can parse (also `.toString()`-able)
292
+ * @param options unused (kept for signature parity)
293
+ * @returns the formatted timestamp, or `""` when the input is invalid/null
294
+ */
295
+ export function printTimestamp(value: any, options?: any): string {
296
+ options = typeof options === 'undefined' ? {} : options;
297
+ if (value === null) {
298
+ return '';
299
+ }
300
+
301
+ try {
302
+ value = value.toString();
303
+ } catch (exception) {
304
+ $log.error("Couldn't extract timestamp from input: " + value);
305
+ $log.error(exception);
306
+ return '';
307
+ }
308
+
309
+ if (!moment(value).isValid()) {
310
+ $log.error("Couldn't transform input to a valid timestamp: " + value);
311
+ return '';
312
+ }
313
+
314
+ return moment(value).format(_dataFormats.DATETIME.display);
315
+ }
316
+
317
+ /**
318
+ * Format a date (or date-time) value into the project's date display format
319
+ * (`_dataFormats.DATE`). Any time component is dropped.
320
+ *
321
+ * @param value any value moment() can parse (also `.toString()`-able)
322
+ * @param options unused (kept for signature parity)
323
+ * @returns the formatted date, or `""` when the input is invalid/null
324
+ */
325
+ export function printDate(value: any, options?: any): string {
326
+ options = typeof options === 'undefined' ? {} : options;
327
+ if (value === null) {
328
+ return '';
329
+ }
330
+ try {
331
+ value = value.toString();
332
+ } catch (exception) {
333
+ $log.error("Couldn't extract date info from input: " + value);
334
+ $log.error(exception);
335
+ return '';
336
+ }
337
+
338
+ if (!moment(value).isValid()) {
339
+ $log.error("Couldn't transform input to a valid date: " + value);
340
+ return '';
341
+ }
342
+
343
+ return moment(value).format(_dataFormats.DATE);
344
+ }
345
+
346
+ /**
347
+ * Format a float for display: leading zeros stripped, thousands separators
348
+ * added. When no explicit precision is given, values >= 1e12 or < 1e-6 fall
349
+ * back to scientific notation; otherwise four fractional digits are shown.
350
+ *
351
+ * @param value the value to format; `null` returns an empty string
352
+ * @param options optional config:
353
+ * - `numFracDigits` — number of fractional digits to keep (`toFixed` rounds)
354
+ * @returns the formatted float string
355
+ */
356
+ export function printFloat(value: any, options?: any): string {
357
+ options = typeof options === 'undefined' ? {} : options;
358
+
359
+ if (value === null) {
360
+ return '';
361
+ }
362
+
363
+ value = parseFloat(value);
364
+ if (options.numFracDigits) {
365
+ value = value.toFixed(options.numFracDigits); // toFixed() rounds the value, is ok?
366
+ } else {
367
+ // >= 1e12 or < 1e-6: switch to scientific notation. toPrecision(5) ensures
368
+ // enough digits that the result formats as exponential rather than padded zeros.
369
+ if (Math.abs(value) >= 1000000000000 || Math.abs(value) < 0.000001) {
370
+ value = value.toPrecision(5);
371
+ } else {
372
+ value = value.toFixed(4);
373
+ }
374
+ }
375
+
376
+ // Remove leading zeroes
377
+ value = value.toString().replace(/^0+(?!\.|$)/, '');
378
+
379
+ // Add comma separators
380
+ const parts = value.split('.');
381
+ parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
382
+ return parts.join('.');
383
+ }
384
+
385
+ /**
386
+ * Format a text value for display. Objects are JSON-stringified; everything
387
+ * else goes through `.toString()`.
388
+ *
389
+ * @param value the value to format; `null` returns an empty string
390
+ * @param options unused (kept for signature parity)
391
+ * @returns the value as a string
392
+ */
393
+ export function printText(value: any, options?: any): string {
394
+ options = typeof options === 'undefined' ? {} : options;
395
+ if (value === null) {
396
+ return '';
397
+ }
398
+ if (typeof value === 'object') {
399
+ return JSON.stringify(value);
400
+ }
401
+ return value.toString();
402
+ }
403
+
404
+ /**
405
+ * Render a markdown string to HTML for display.
406
+ *
407
+ * @param value the markdown source; `null` returns an empty string
408
+ * @param options optional config:
409
+ * - `inline` — render in inline mode (no block wrapping)
410
+ * @returns the rendered HTML
411
+ */
412
+ export function printMarkdown(value: any, options?: any): string {
413
+ options = typeof options === 'undefined' ? {} : options;
414
+ if (value === null) {
415
+ return '';
416
+ }
417
+
418
+ return renderMarkdown(value, options.inline);
419
+ }
420
+
421
+ /**
422
+ * Format a JSON value as a pretty-printed string for display. An empty-string
423
+ * input is treated as `null` (so `""` renders as `"null"`).
424
+ *
425
+ * @param value any JSON-serializable value
426
+ * @param _options unused (kept for signature parity)
427
+ * @returns the JSON-stringified value (2-space indent)
428
+ */
429
+ export function printJSON(value: any, _options?: any): string {
430
+ return value === '' ? JSON.stringify(null) : JSON.stringify(value, undefined, 2);
431
+ }
432
+
433
+ /**
434
+ * Format a gene sequence in chunks separated by a delimiter, then render the
435
+ * result through markdown so the output appears in a fixed-width font.
436
+ * By default the sequence is split into 10-character chunks separated by a space.
437
+ *
438
+ * @param value the raw gene sequence string; `null` returns an empty string
439
+ * @param options optional config:
440
+ * - `increment` — number of characters per chunk (default 10, negatives clamp to 1, 0 disables chunking)
441
+ * - `separator` — chunk delimiter (default `' '`)
442
+ * @returns the chunked sequence rendered as inline markdown, or the original
443
+ * value when an exception bubbles out of the rendering layer
444
+ */
445
+ export function printGeneSeq(value: any, options?: any): string {
446
+ options = typeof options === 'undefined' ? {} : options;
447
+
448
+ if (value === null) {
449
+ return '';
450
+ }
451
+
452
+ try {
453
+ // Default separator is a space.
454
+ if (!options.separator) {
455
+ options.separator = ' ';
456
+ }
457
+ // Default increment is 10
458
+ if (!options.increment) {
459
+ options.increment = 10;
460
+ }
461
+ let inc = parseInt(options.increment, 10);
462
+
463
+ if (inc === 0) {
464
+ return value.toString();
465
+ }
466
+
467
+ // Reset the increment if it's negative
468
+ if (inc <= -1) {
469
+ inc = 1;
470
+ }
471
+
472
+ let formattedSeq = '`';
473
+ const separator = options.separator;
474
+ while (value.length >= inc) {
475
+ // Get the first inc number of chars
476
+ const chunk = value.slice(0, inc);
477
+ // Append the chunk and separator
478
+ formattedSeq += chunk + separator;
479
+ // Remove this chunk from value
480
+ value = value.slice(inc);
481
+ }
482
+
483
+ // Append any remaining chars from value that was too small to form an increment
484
+ formattedSeq += value;
485
+
486
+ // Slice off separator at the end
487
+ if (formattedSeq.slice(-1) == separator) {
488
+ formattedSeq = formattedSeq.slice(0, -1);
489
+ }
490
+
491
+ // Add the ending backtick at the end
492
+ formattedSeq += '`';
493
+
494
+ // Run it through renderMarkdown to get the sequence in a fixed-width font
495
+ return renderMarkdown(formattedSeq, true);
496
+ } catch (e) {
497
+ $log.error("Couldn't parse the given markdown value: " + value);
498
+ $log.error(e);
499
+ return value;
500
+ }
501
+ }
502
+
503
+ /**
504
+ * Format an array into a comma-separated string (or an array of formatted
505
+ * strings). `null` and `""` entries are replaced with their special markdown
506
+ * presentation tokens (`_specialPresentation.NULL` and `EMPTY_STR`).
507
+ *
508
+ * @param value the array to format; non-arrays / empty arrays return `""`
509
+ * @param options optional config:
510
+ * - `isMarkdown` — when true, do NOT markdown-escape the entries
511
+ * - `returnArray` — when true, return the formatted entries as an array
512
+ * instead of joining with `", "`
513
+ * @returns the joined string by default, or `string[]` when `returnArray` is set
514
+ */
515
+ export function printArray(value: any, options?: any): any {
516
+ options = typeof options === 'undefined' ? {} : options;
517
+
518
+ if (!value || !Array.isArray(value) || value.length === 0) {
519
+ return '';
520
+ }
521
+
522
+ const arr = value.map(function (v) {
523
+ let isMarkdown = options.isMarkdown === true;
524
+ let pv = v;
525
+ if (v === '') {
526
+ pv = _specialPresentation.EMPTY_STR;
527
+ isMarkdown = true;
528
+ } else if (v == null) {
529
+ pv = _specialPresentation.NULL;
530
+ isMarkdown = true;
531
+ }
532
+
533
+ if (!isMarkdown) pv = _escapeMarkdownCharacters(pv);
534
+ return pv;
535
+ });
536
+
537
+ if (options.returnArray) return arr;
538
+ return arr.join(', ');
539
+ }
540
+
541
+ /**
542
+ * Format a hex color string as a Chaise markdown span with a colored swatch
543
+ * followed by the uppercase hex code. Invalid colors return an empty string.
544
+ *
545
+ * @param value the hex color string (e.g. `"#ff8800"`)
546
+ * @param options unused (kept for signature parity)
547
+ * @returns the markdown swatch + label, or `""` when the input is not a valid hex color
548
+ */
549
+ export function printColor(value: any, options?: any): string {
550
+ options = typeof options === 'undefined' ? {} : options;
551
+
552
+ if (!isValidColorRGBHex(value)) {
553
+ return '';
554
+ }
555
+
556
+ value = value.toUpperCase();
557
+ return ':span: :/span:{.' + _classNames.colorPreview + ' style=background-color:' + value + '} ' + value;
558
+ }
559
+
560
+ /**
561
+ * Format a byte count in a human-readable unit (e.g. `1234567` -> `"1.23 MB"`).
562
+ *
563
+ * The value is **truncated** (never rounded up) to honor the requested
564
+ * precision via `_toPrecision`. The precision floor is 3 in `si` mode and 4
565
+ * in `binary` mode; smaller values are clamped. `raw` mode bypasses the unit
566
+ * conversion and returns the comma-separated integer.
567
+ *
568
+ * @param value the byte count (anything `parseFloat` accepts; `NaN` returns `""`)
569
+ * @param mode `'raw'`, `'si'`, or `'binary'` (default `'si'` for invalid/missing)
570
+ * @param precision number of significant digits to display (default 3, see
571
+ * the per-mode floor above)
572
+ * @param withTooltip when true and a unit conversion occurred, wrap the
573
+ * output in a Chaise tooltip span showing the raw byte
574
+ * count and the unit conversion factor
575
+ * @returns the humanized byte string
576
+ */
577
+ export function humanizeBytes(value: any, mode?: any, precision?: any, withTooltip?: any): string {
578
+ // we cannot use parseInt here since it won't allow larger numbers.
579
+ let v = parseFloat(value);
580
+ mode = ['raw', 'si', 'binary'].indexOf(mode) === -1 ? 'si' : mode;
581
+
582
+ if (isNaN(v)) return '';
583
+ if (v === 0 || mode === 'raw') {
584
+ return printInteger(value);
585
+ }
586
+
587
+ let divisor = 1000;
588
+ let units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
589
+ if (mode === 'binary') {
590
+ divisor = 1024;
591
+ units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
592
+ }
593
+
594
+ // find the closest power of the divisor to the given number ('u').
595
+ // in the end, 'v' will be the number that we should display.
596
+ let u = 0;
597
+ while (v >= divisor || -v >= divisor) {
598
+ v /= divisor;
599
+ u++;
600
+ }
601
+
602
+ // our units don't support this, so just return the "raw" mode value.
603
+ if (u >= units.length) {
604
+ return printInteger(value);
605
+ }
606
+
607
+ // we don't want to truncate the value, so we should set a minimum
608
+ const minP = mode === 'si' ? 3 : 4;
609
+
610
+ let res = (u ? _toPrecision(v, precision, minP) : v) + ' ' + units[u];
611
+ if (typeof withTooltip === 'boolean' && withTooltip && u > 0) {
612
+ const numBytes = printInteger(Math.pow(divisor, u));
613
+ let tooltip = printInteger(value);
614
+ tooltip += ' bytes (1 ' + units[u] + ' = ' + numBytes + ' bytes)';
615
+ res = ':span:' + res + ':/span:{data-chaise-tooltip="' + tooltip + '"}';
616
+ }
617
+ return res;
618
+ }
619
+
620
+ /**
621
+ * Format the duration between two datetimes for display
622
+ * (e.g. `"+1.1 months"`, `"35 days before"`, `"1Y 4D"`).
623
+ *
624
+ * Math is fixed-Julian (1Y = 365.25d, 1M = 30.4375d) for every single-unit
625
+ * mode and for `unit="multi"`. Calendar-aware walking via `moment.diff`/
626
+ * `moment.add` is used only for `unit="calendar"`. See the per-helper JSDocs
627
+ * above for the quirks of each mode (multi lopover, calendar month-end clamping).
628
+ *
629
+ * Edge cases:
630
+ * - `start === end` returns `"0 seconds"` regardless of direction.
631
+ * - Either side invalid (not moment-parseable) returns `""`.
632
+ *
633
+ * @param start any value moment() can parse
634
+ * @param end any value moment() can parse
635
+ * @param unit one of `"auto"` (default), `"year"`, `"month"`, `"day"`,
636
+ * `"hour"`, `"minute"`, `"second"`, `"millisecond"`, `"multi"`, `"calendar"`
637
+ * @param fraction number of decimal places (default 1; invalid -> 1)
638
+ * @param direction one of `"sign"` (default, prepends `+`/`-`),
639
+ * `"before/after"`, `"earlier/later"`, `"unsigned"`
640
+ * @param withTooltip when true, wrap the output in a tooltip span; suppressed
641
+ * only for `unit="calendar"` (the calendar walk in the
642
+ * tooltip body would conflict with the calendar walk
643
+ * already visible in line 1)
644
+ * @returns the formatted duration string
645
+ */
646
+ export function datetimeDuration(start: any, end: any, unit?: any, fraction?: any, direction?: any, withTooltip?: any): string {
647
+ const startM = moment(start);
648
+ const endM = moment(end);
649
+ if (!startM.isValid() || !endM.isValid()) return '';
650
+
651
+ // normalize args
652
+ const normUnit: DurationUnit = _datetimeDuration.VALID_UNITS.indexOf(unit) === -1 ? DURATION_UNIT.AUTO : unit;
653
+ const normDirection: DurationDirection = _datetimeDuration.VALID_DIRECTIONS.indexOf(direction) === -1 ? DURATION_DIRECTION.SIGN : direction;
654
+ const f = parseInt(fraction);
655
+ fraction = isNaN(f) || f < 0 ? 1 : f;
656
+
657
+ const ms = endM.diff(startM);
658
+ if (ms === 0) return '0 seconds';
659
+
660
+ const absMs = Math.abs(ms);
661
+ const signSign = ms > 0 ? 1 : -1;
662
+
663
+ let res: string;
664
+ if (normUnit === DURATION_UNIT.MULTI) {
665
+ res = _durationFormatMultiFixed(absMs, fraction);
666
+ } else if (normUnit === DURATION_UNIT.CALENDAR) {
667
+ res = _durationFormatCalendarWalk(startM, endM, fraction);
668
+ } else {
669
+ res = _durationFormatSingleUnit(absMs, normUnit, fraction);
670
+ }
671
+
672
+ res = _durationApplyDirection(res, signSign, normDirection);
673
+
674
+ if (withTooltip === true && normUnit !== DURATION_UNIT.CALENDAR) {
675
+ res = _durationWrapTooltip(res, absMs);
676
+ }
677
+ return res;
678
+ }
679
+
680
+ /**
681
+ * Pretty-print utility functions, exposed as the public `_formatUtils` object
682
+ * on the `ERMrest` global (`ERMrest._formatUtils.printX(...)`). Each method
683
+ * is also exported by name above for direct in-codebase imports.
684
+ */
685
+ export const _formatUtils = {
686
+ printBoolean,
687
+ printInteger,
688
+ printTimestamp,
689
+ printDate,
690
+ printFloat,
691
+ printText,
692
+ printMarkdown,
693
+ printJSON,
694
+ printGeneSeq,
695
+ printArray,
696
+ printColor,
697
+ humanizeBytes,
698
+ datetimeDuration,
699
+ };
700
+
701
+ /**
702
+ * Dispatch to the appropriate `print*` formatter based on a column type
703
+ * definition. Recurses into `type.baseType` when no case matches (and falls
704
+ * back to `printText` when even the base type is unknown). Markdown columns
705
+ * are passed through with `toString()` because final markdown rendering
706
+ * happens later in the pipeline.
707
+ *
708
+ * @param type the column type descriptor (uses `type.name` and `type.baseType`)
709
+ * @param data the raw value to format
710
+ * @param options forwarded to the underlying `print*` formatter
711
+ * @returns the formatted value (typically a string, but the type matches the underlying formatter)
712
+ */
713
+ export function _formatValueByType(type: any, data: any, options?: any): any {
714
+ switch (type.name) {
715
+ case 'timestamp':
716
+ case 'timestamptz':
717
+ data = printTimestamp(data, options);
718
+ break;
719
+ case 'date':
720
+ data = printDate(data, options);
721
+ break;
722
+ case 'numeric':
723
+ case 'float4':
724
+ case 'float8':
725
+ data = printFloat(data, options);
726
+ break;
727
+ case 'int2':
728
+ case 'int4':
729
+ case 'int8':
730
+ data = printInteger(data, options);
731
+ break;
732
+ case 'boolean':
733
+ data = printBoolean(data, options);
734
+ break;
735
+ case 'markdown':
736
+ // Do nothing as we will format markdown at the end of format
737
+ data = data.toString();
738
+ break;
739
+ case 'gene_sequence':
740
+ data = printGeneSeq(data, options);
741
+ break;
742
+ //Cases to support json and jsonb columns
743
+ case 'json':
744
+ case 'jsonb':
745
+ data = printJSON(data, options);
746
+ break;
747
+ case 'color_rgb_hex':
748
+ data = printColor(data, options);
749
+ break;
750
+ default: // includes 'text' and 'longtext' cases
751
+ data = type.baseType ? _formatValueByType(type.baseType, data, options) : printText(data, options);
752
+ break;
753
+ }
754
+ return data;
755
+ }