@isrd-isi-edu/ermrestjs 2.9.1 → 2.11.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/js/hatrac.js +8 -3
- package/package.json +11 -10
- package/src/models/reference/reference.ts +7 -3
- package/src/models/reference-column/asset-pseudo-column.ts +13 -0
- package/src/services/active-list.ts +94 -2
- package/src/services/handlebars.ts +34 -2
- package/src/services/http.ts +4 -2
- package/src/utils/constants.ts +3 -0
- package/src/utils/format-utils.ts +6 -0
- package/src/utils/markdown-utils.ts +57 -11
- package/src/utils/template-utils.ts +19 -1
- package/tsconfig.json +2 -1
package/js/hatrac.js
CHANGED
|
@@ -4,7 +4,7 @@ import { ArrayBuffer } from 'spark-md5';
|
|
|
4
4
|
|
|
5
5
|
// models
|
|
6
6
|
// import DeferredPromise from '@isrd-isi-edu/ermrestjs/src/models/deferred-promise';
|
|
7
|
-
import { MalformedURIError } from '@isrd-isi-edu/ermrestjs/src/models/errors';
|
|
7
|
+
import { InvalidInputError, MalformedURIError } from '@isrd-isi-edu/ermrestjs/src/models/errors';
|
|
8
8
|
|
|
9
9
|
// services
|
|
10
10
|
import ErrorService from '@isrd-isi-edu/ermrestjs/src/services/error';
|
|
@@ -231,7 +231,7 @@ Upload.prototype.validateURL = function (row, linkedData, templateVariables) {
|
|
|
231
231
|
// TODO is this needed?
|
|
232
232
|
// make sure to add raw columns too.
|
|
233
233
|
ignoredColumns.forEach(function (col) {
|
|
234
|
-
ignoredColumns.push(
|
|
234
|
+
ignoredColumns.push('_' + col);
|
|
235
235
|
});
|
|
236
236
|
|
|
237
237
|
var keyValues = _getFormattedKeyValues(this.reference.table, this.reference._context, row, linkedData);
|
|
@@ -503,7 +503,12 @@ Upload.prototype.start = function (startChunkIdx, onProgress) {
|
|
|
503
503
|
this.chunks = [];
|
|
504
504
|
|
|
505
505
|
if (this.file.size === 0) {
|
|
506
|
-
|
|
506
|
+
// empty (0-byte) uploads are rejected unless the asset annotation opts in via allow_empty_file
|
|
507
|
+
if (this.column.allowEmptyFile) {
|
|
508
|
+
deferred.resolve(this.url);
|
|
509
|
+
} else {
|
|
510
|
+
deferred.reject(new InvalidInputError(`The file "${this.file.name}" is empty (0 bytes). Empty files are not allowed.`));
|
|
511
|
+
}
|
|
507
512
|
return deferred.promise;
|
|
508
513
|
} else {
|
|
509
514
|
while (start < this.file.size) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@isrd-isi-edu/ermrestjs",
|
|
3
3
|
"description": "ERMrest client library in JavaScript",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.11.0",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">= 20.0.0",
|
|
@@ -55,31 +55,32 @@
|
|
|
55
55
|
"@types/lodash-es": "^4.17.12",
|
|
56
56
|
"@types/markdown-it": "^14.1.2",
|
|
57
57
|
"@types/q": "^1.5.8",
|
|
58
|
-
"axios": "1.
|
|
58
|
+
"axios": "1.18.1",
|
|
59
59
|
"handlebars": "4.7.9",
|
|
60
60
|
"lodash-es": "^4.18.1",
|
|
61
61
|
"lz-string": "^1.5.0",
|
|
62
|
-
"markdown-it": "
|
|
62
|
+
"markdown-it": "14.2.0",
|
|
63
|
+
"markdown-it-attrs": "5.0.0",
|
|
63
64
|
"moment": "2.29.4",
|
|
64
65
|
"moment-timezone": "0.5.48",
|
|
65
66
|
"mustache": "x",
|
|
66
67
|
"q": "1.5.1",
|
|
67
68
|
"spark-md5": "^3.0.0",
|
|
68
69
|
"terser": "^5.44.1",
|
|
69
|
-
"typescript": "~
|
|
70
|
-
"vite": "^8.
|
|
70
|
+
"typescript": "~6.0.3",
|
|
71
|
+
"vite": "^8.1.2",
|
|
71
72
|
"vite-plugin-compression2": "^2.5.3"
|
|
72
73
|
},
|
|
73
74
|
"devDependencies": {
|
|
74
75
|
"@commitlint/cli": "^20.2.0",
|
|
75
76
|
"@commitlint/config-conventional": "^20.2.0",
|
|
76
|
-
"@eslint/js": "^
|
|
77
|
+
"@eslint/js": "^10.0.1",
|
|
77
78
|
"@isrd-isi-edu/ermrest-data-utils": "0.0.5",
|
|
78
|
-
"@types/node": "^
|
|
79
|
-
"eslint": "^
|
|
79
|
+
"@types/node": "^26.1.0",
|
|
80
|
+
"eslint": "^10.6.0",
|
|
80
81
|
"eslint-config-prettier": "^10.1.8",
|
|
81
82
|
"eslint-plugin-prettier": "^5.5.4",
|
|
82
|
-
"globals": "^17.
|
|
83
|
+
"globals": "^17.7.0",
|
|
83
84
|
"husky": "^9.1.7",
|
|
84
85
|
"jasmine": "2.5.3",
|
|
85
86
|
"jasmine-expect": "3.7.1",
|
|
@@ -88,6 +89,6 @@
|
|
|
88
89
|
"prettier": "^3.8.1",
|
|
89
90
|
"require-reload": "^0.2.2",
|
|
90
91
|
"rollup-plugin-visualizer": "^6.0.3",
|
|
91
|
-
"typescript-eslint": "^8.
|
|
92
|
+
"typescript-eslint": "^8.62.1"
|
|
92
93
|
}
|
|
93
94
|
}
|
|
@@ -930,18 +930,22 @@ export class Reference {
|
|
|
930
930
|
if (isDetailed) {
|
|
931
931
|
this.generateRelatedList(tuple).forEach((rel, i) => {
|
|
932
932
|
const rc = rel.pseudoColumn?.resolvedCondition;
|
|
933
|
+
// source hash of this related entity, used by the consolidation pass to match
|
|
934
|
+
// data consumers (wait_fors, citation, condition sources) of the same source.
|
|
935
|
+
// Mirrors how generateRelatedList computes fkName.
|
|
936
|
+
const relSourceName = rel.pseudoColumn ? rel.pseudoColumn.name : (_sourceColumnHelpers.generateForeignKeyName(rel.origFKR, true) as string);
|
|
933
937
|
/**
|
|
934
938
|
* if there's a condition, we have to capture the requests generated for the related entity as its dependencies.
|
|
935
|
-
* So when the condition is
|
|
939
|
+
* So when the condition is evaluated, we can decide whether to run those requests or not.
|
|
936
940
|
*/
|
|
937
941
|
const addRelatedToDeps = (deps: Array<ActiveListRequest | ActiveListRelatedEntityRequest>) => {
|
|
938
|
-
deps.push({ related: true, index: i });
|
|
942
|
+
deps.push({ related: true, index: i, entitysetSourceName: relSourceName });
|
|
939
943
|
rel.pseudoColumn?.waitFor.forEach((wf) => {
|
|
940
944
|
builder.addColToDependent(deps, wf, true, ActiveListRequestTypes.RELATED, i);
|
|
941
945
|
});
|
|
942
946
|
};
|
|
943
947
|
if (tryCondition(rc, { related: true, index: i }, addRelatedToDeps)) return;
|
|
944
|
-
builder.requests.push({ related: true, index: i });
|
|
948
|
+
builder.requests.push({ related: true, index: i, entitysetSourceName: relSourceName });
|
|
945
949
|
rel.pseudoColumn?.waitFor.forEach((wf) => {
|
|
946
950
|
builder.addCol(wf, true, ActiveListRequestTypes.RELATED, i);
|
|
947
951
|
});
|
|
@@ -41,6 +41,7 @@ type AssetMetadata = {
|
|
|
41
41
|
* @property {(Column|boolean|null)} md5 if it's string, then it is the name of column we want to store md5 inside of it. If it's true, that means we must use md5.
|
|
42
42
|
* @property {(Column|boolean|null)} sha256 if it's string, then it is the name of column we want to store sha256 inside of it. If it's true, that means we must use sha256.
|
|
43
43
|
* @property {(string[]|null)} filenameExtFilter set of filename extension filters for use by upload agents to indicate to the user the acceptable filename patterns.
|
|
44
|
+
* @property {boolean} allowEmptyFile whether empty (0-byte) file uploads are allowed. Defaults to false.
|
|
44
45
|
*
|
|
45
46
|
* @desc
|
|
46
47
|
* Constructor for AssetPseudoColumn.
|
|
@@ -73,6 +74,7 @@ export class AssetPseudoColumn extends ReferenceColumn {
|
|
|
73
74
|
private _filenamePattern?: string;
|
|
74
75
|
private _filenameExtFilter?: string[];
|
|
75
76
|
private _filenameExtRegexp?: string[];
|
|
77
|
+
private _allowEmptyFile?: boolean;
|
|
76
78
|
private _displayImagePreview?: boolean;
|
|
77
79
|
private _filePreview?: FilePreviewConfig | null;
|
|
78
80
|
|
|
@@ -438,6 +440,17 @@ export class AssetPseudoColumn extends ReferenceColumn {
|
|
|
438
440
|
return this._filenameExtRegexp;
|
|
439
441
|
}
|
|
440
442
|
|
|
443
|
+
/**
|
|
444
|
+
* Whether empty (0-byte) file uploads are allowed for this asset column.
|
|
445
|
+
* Defaults to false, meaning empty files are rejected.
|
|
446
|
+
*/
|
|
447
|
+
get allowEmptyFile(): boolean {
|
|
448
|
+
if (this._allowEmptyFile === undefined) {
|
|
449
|
+
this._allowEmptyFile = this._annotation.allow_empty_file === true;
|
|
450
|
+
}
|
|
451
|
+
return this._allowEmptyFile;
|
|
452
|
+
}
|
|
453
|
+
|
|
441
454
|
/**
|
|
442
455
|
* whether we should show the image preview or not
|
|
443
456
|
*/
|
|
@@ -68,6 +68,18 @@ export type ActiveListRelatedEntityRequest = {
|
|
|
68
68
|
related?: boolean;
|
|
69
69
|
/** the index of the related entity in the reference.related array */
|
|
70
70
|
index: number;
|
|
71
|
+
/**
|
|
72
|
+
* the source hash (pseudo-column name) of the displayed entity set. Set so the
|
|
73
|
+
* consolidation pass can match data consumers (values, wait_fors, citation,
|
|
74
|
+
* condition sources) of the same source onto this display request.
|
|
75
|
+
*/
|
|
76
|
+
entitysetSourceName?: string;
|
|
77
|
+
/**
|
|
78
|
+
* consumers folded onto this display request by the consolidation pass. When
|
|
79
|
+
* present, chaise routes the display read's page to these consumers instead of
|
|
80
|
+
* issuing a separate fetch. Mirrors `ActiveListRequest.objects`.
|
|
81
|
+
*/
|
|
82
|
+
objects?: Array<ActiveListRequestObject>;
|
|
71
83
|
};
|
|
72
84
|
|
|
73
85
|
/**
|
|
@@ -333,7 +345,7 @@ export class ActiveListBuilder {
|
|
|
333
345
|
*/
|
|
334
346
|
addInline(col: VisibleColumn, i: number): void {
|
|
335
347
|
if (isRelatedColumn(col)) {
|
|
336
|
-
this.requests.push({ inline: true, index: i });
|
|
348
|
+
this.requests.push({ inline: true, index: i, entitysetSourceName: col.name });
|
|
337
349
|
} else {
|
|
338
350
|
this.addCol(col, false, ActiveListRequestTypes.COLUMN, i);
|
|
339
351
|
}
|
|
@@ -346,7 +358,7 @@ export class ActiveListBuilder {
|
|
|
346
358
|
/** Same as addInline but adds to dependent requests in a conditional group */
|
|
347
359
|
addInlineToDependent(dependentRequests: Array<ActiveListRequest | ActiveListRelatedEntityRequest>, col: VisibleColumn, i: number): void {
|
|
348
360
|
if (isRelatedColumn(col)) {
|
|
349
|
-
dependentRequests.push({ inline: true, index: i });
|
|
361
|
+
dependentRequests.push({ inline: true, index: i, entitysetSourceName: col.name });
|
|
350
362
|
} else {
|
|
351
363
|
this.addColToDependent(dependentRequests, col, false, ActiveListRequestTypes.COLUMN, i);
|
|
352
364
|
}
|
|
@@ -423,10 +435,90 @@ export class ActiveListBuilder {
|
|
|
423
435
|
this.allOutBounds.push(fkCol);
|
|
424
436
|
}
|
|
425
437
|
|
|
438
|
+
/**
|
|
439
|
+
* consolidates the requests in this.requests and this.conditionalGroups.dependentRequests
|
|
440
|
+
* to avoid duplicate reads for the same source.
|
|
441
|
+
*
|
|
442
|
+
* - Uses similar dedup logic as `consideredSets` / `consideredAggregates`
|
|
443
|
+
* (group by source hash, first request wins, the rest attach their `objects`),
|
|
444
|
+
* plus one clause: a display (visible inline or related entities) wins when one exists,
|
|
445
|
+
* so a source that is both displayed and consumed is read once.
|
|
446
|
+
* - Runs as a final pass rather than an inline map because related displays are registered
|
|
447
|
+
* after the consumers that reference them, so at consumer time the display does not exist yet.
|
|
448
|
+
* - A fold removes the duplicate from wherever it lives (`requests` or a group's
|
|
449
|
+
* `dependentRequests`); a deferred merge target is promoted into `requests` when any
|
|
450
|
+
* request for its source is unconditional, so the read happens on load. Nothing is ever
|
|
451
|
+
* added to a group's `dependentRequests`.
|
|
452
|
+
*/
|
|
453
|
+
private consolidateRequests(): void {
|
|
454
|
+
if (!this.isDetailed) return;
|
|
455
|
+
|
|
456
|
+
type LocatedRequest = {
|
|
457
|
+
requestList: Array<ActiveListRequest | ActiveListRelatedEntityRequest>;
|
|
458
|
+
req: ActiveListRequest & ActiveListRelatedEntityRequest;
|
|
459
|
+
isRelatedOrInline: boolean;
|
|
460
|
+
isUnconditional: boolean;
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
// capture all the requests that we have (conditioned + unconditioned).
|
|
464
|
+
// null-prototype so a schema-controlled source hash like `__proto__` is a plain key.
|
|
465
|
+
const requestsBySource: { [hash: string]: LocatedRequest[] } = Object.create(null);
|
|
466
|
+
const collect = (requestList: Array<ActiveListRequest | ActiveListRelatedEntityRequest>, isUnconditional: boolean) => {
|
|
467
|
+
requestList.forEach((r) => {
|
|
468
|
+
const req = r as ActiveListRequest & ActiveListRelatedEntityRequest;
|
|
469
|
+
const isRelatedOrInline = !!(req.inline || req.related);
|
|
470
|
+
const hash = isRelatedOrInline ? req.entitysetSourceName : req.column?.name;
|
|
471
|
+
if (typeof hash !== 'string') return;
|
|
472
|
+
if (!(hash in requestsBySource)) requestsBySource[hash] = [];
|
|
473
|
+
requestsBySource[hash].push({ requestList, req, isRelatedOrInline, isUnconditional });
|
|
474
|
+
});
|
|
475
|
+
};
|
|
476
|
+
collect(this.requests, true);
|
|
477
|
+
this.conditionalGroups.forEach((group) => collect(group.dependentRequests, false));
|
|
478
|
+
|
|
479
|
+
Object.keys(requestsBySource).forEach((hash) => {
|
|
480
|
+
const entries = requestsBySource[hash];
|
|
481
|
+
if (entries.length < 2) return;
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Prefer a display as the merge target (even a conditional one): it renders the
|
|
485
|
+
* table AND yields the rows the data consumers need, so everyone reuses its read.
|
|
486
|
+
* A conditional display is promoted to run on load below when needed. With no
|
|
487
|
+
* display and no unconditional consumer there is no safe target, so skip: e.g.
|
|
488
|
+
* condition A gates column X and condition B gates column Y, both X and Y valued
|
|
489
|
+
* from the same entity set `H`. Folding B's `H` onto A would gate Y behind A, so
|
|
490
|
+
* if A is false but B is true, Y loses its data. Leave them as two reads.
|
|
491
|
+
*/
|
|
492
|
+
const displays = entries.filter((e) => e.isRelatedOrInline);
|
|
493
|
+
const mergeTarget = displays.find((e) => e.isUnconditional) || displays[0] || entries.find((e) => e.isUnconditional);
|
|
494
|
+
if (!mergeTarget) return;
|
|
495
|
+
|
|
496
|
+
const anyUnconditional = entries.some((e) => e.isUnconditional);
|
|
497
|
+
entries.forEach((e) => {
|
|
498
|
+
if (e === mergeTarget) return;
|
|
499
|
+
// when the merge target is deferred, only fold unconditional requests or ones in
|
|
500
|
+
// the merge target's own list (never another group's dependent)
|
|
501
|
+
if (!mergeTarget.isUnconditional && !e.isUnconditional && e.requestList !== mergeTarget.requestList) return;
|
|
502
|
+
if (!mergeTarget.req.objects) mergeTarget.req.objects = [];
|
|
503
|
+
if (Array.isArray(e.req.objects)) mergeTarget.req.objects.push(...e.req.objects);
|
|
504
|
+
const idx = e.requestList.indexOf(e.req);
|
|
505
|
+
if (idx !== -1) e.requestList.splice(idx, 1);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
// a deferred merge target that has an unconditional request must run on load.
|
|
509
|
+
if (!mergeTarget.isUnconditional && anyUnconditional) {
|
|
510
|
+
const idx = mergeTarget.requestList.indexOf(mergeTarget.req);
|
|
511
|
+
if (idx !== -1) mergeTarget.requestList.splice(idx, 1);
|
|
512
|
+
this.requests.push(mergeTarget.req);
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
426
517
|
/**
|
|
427
518
|
* Return the built ActiveList object. After calling this method, the builder should not be used anymore.
|
|
428
519
|
*/
|
|
429
520
|
build(): ActiveList {
|
|
521
|
+
this.consolidateRequests();
|
|
430
522
|
return {
|
|
431
523
|
requests: this.requests,
|
|
432
524
|
conditionalGroups: this.conditionalGroups,
|
|
@@ -400,7 +400,16 @@ export default class HandlebarsService {
|
|
|
400
400
|
precision = options.hash.precision;
|
|
401
401
|
tooltip = options.hash.tooltip;
|
|
402
402
|
}
|
|
403
|
-
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* humanizeBytes generates markup like this:
|
|
406
|
+
* :span:1.23 KB:/span:{data-chaise-tooltip="1.23 KB"}
|
|
407
|
+
* So we have to make sure handlebars is not HTML-escaping it. Doing so breaks the attribute markup.
|
|
408
|
+
* NOTE: this is safe only because humanizeBytes derives its output entirely from the numeric byte value
|
|
409
|
+
* (no user-provided text). If we ever let users supply their own tooltip text, the SafeString would expose
|
|
410
|
+
* an HTML-injection vector and we'd need a different solution (escape the user portion before returning).
|
|
411
|
+
*/
|
|
412
|
+
return new Handlebars.SafeString(_formatUtils.humanizeBytes(value, mode, precision, tooltip));
|
|
404
413
|
},
|
|
405
414
|
|
|
406
415
|
/**
|
|
@@ -422,7 +431,16 @@ export default class HandlebarsService {
|
|
|
422
431
|
direction = options.hash.direction;
|
|
423
432
|
tooltip = options.hash.tooltip;
|
|
424
433
|
}
|
|
425
|
-
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* datetimeDuration generates markup like this:
|
|
437
|
+
* :span:+1.1 months:/span:{data-chaise-tooltip="1.1 months"}
|
|
438
|
+
* So we have to make sure handlebars is not HTML-escaping it. Doing so breaks the attribute markup.
|
|
439
|
+
* NOTE: this is safe only because datetimeDuration derives its output entirely from the start/end values
|
|
440
|
+
* (no user-provided text). If we ever let users supply their own tooltip text, the SafeString would expose
|
|
441
|
+
* an HTML-injection vector and we'd need a different solution (escape the user portion before returning).
|
|
442
|
+
*/
|
|
443
|
+
return new Handlebars.SafeString(_formatUtils.datetimeDuration(start, end, unit, fraction, direction, tooltip));
|
|
426
444
|
},
|
|
427
445
|
|
|
428
446
|
/**
|
|
@@ -629,6 +647,20 @@ export default class HandlebarsService {
|
|
|
629
647
|
return Array.isArray(array) && array.includes(value);
|
|
630
648
|
},
|
|
631
649
|
|
|
650
|
+
/**
|
|
651
|
+
* {{#if (overlaps arrayCol "a" "b")}}
|
|
652
|
+
* {{#if (overlaps arrayCol someArray)}}
|
|
653
|
+
* {{#if (overlaps arrayCol (pluck $self "values.type"))}}
|
|
654
|
+
*
|
|
655
|
+
* Variadic candidate args; any array argument is flattened one level.
|
|
656
|
+
* @returns true if `array` shares at least one element with the candidates
|
|
657
|
+
*/
|
|
658
|
+
overlaps: function (array: unknown, ...args: unknown[]) {
|
|
659
|
+
if (!Array.isArray(array)) return false;
|
|
660
|
+
const candidates = flattenVariadicArgs(args);
|
|
661
|
+
return array.some((v) => candidates.includes(v));
|
|
662
|
+
},
|
|
663
|
+
|
|
632
664
|
/**
|
|
633
665
|
* {{pluck $self "values.type"}}
|
|
634
666
|
*
|
package/src/services/http.ts
CHANGED
|
@@ -304,8 +304,10 @@ export default class HTTPService {
|
|
|
304
304
|
}
|
|
305
305
|
} else {
|
|
306
306
|
// If we get an HTTP error with HTML in it, this means something the server returned as an error.
|
|
307
|
-
// Ermrest never produces HTML errors, so this was produced by the server itself
|
|
308
|
-
|
|
307
|
+
// Ermrest never produces HTML errors, so this was produced by the server itself.
|
|
308
|
+
// Callers that hit non-ermrest endpoints (e.g. checking an arbitrary image URL) can set
|
|
309
|
+
// `skipHTTPErrorStatusReplacement` to keep the raw status instead of masking it as 500.
|
|
310
|
+
if (contentType && contentType.indexOf('html') > -1 && !config.skipHTTPErrorStatusReplacement) {
|
|
309
311
|
response.status = _http_status_codes.internal_server_error;
|
|
310
312
|
// keep response.data the way it is, so client can provide more info to users
|
|
311
313
|
}
|
package/src/utils/constants.ts
CHANGED
|
@@ -298,6 +298,7 @@ export const _handlebarsHelpersList = [
|
|
|
298
298
|
// list/array helpers
|
|
299
299
|
'memberOf',
|
|
300
300
|
'hasMember',
|
|
301
|
+
'overlaps',
|
|
301
302
|
'pluck',
|
|
302
303
|
];
|
|
303
304
|
|
|
@@ -480,6 +481,8 @@ export const _classNames = Object.freeze({
|
|
|
480
481
|
showInPrintMode: 'video-info-in-print',
|
|
481
482
|
colorPreview: 'chaise-color-preview',
|
|
482
483
|
imagePreview: 'chaise-image-preview',
|
|
484
|
+
// chaise attaches an onerror handler to images with this class to show a fallback
|
|
485
|
+
imageFallback: 'chaise-image-fallback',
|
|
483
486
|
});
|
|
484
487
|
|
|
485
488
|
export const _specialSourceDefinitions = Object.freeze({
|
|
@@ -573,6 +573,9 @@ export function printColor(value: any, options?: any): string {
|
|
|
573
573
|
* output in a Chaise tooltip span showing the raw byte
|
|
574
574
|
* count and the unit conversion factor
|
|
575
575
|
* @returns the humanized byte string
|
|
576
|
+
*
|
|
577
|
+
* SECURITY: Do NOT interpolate raw, user-controlled strings into the output without escaping/validating them first.
|
|
578
|
+
* The handlebars `humanizeBytes` helper returns this output as a Handlebars.SafeString (unescaped).
|
|
576
579
|
*/
|
|
577
580
|
export function humanizeBytes(value: any, mode?: any, precision?: any, withTooltip?: any): string {
|
|
578
581
|
// we cannot use parseInt here since it won't allow larger numbers.
|
|
@@ -642,6 +645,9 @@ export function humanizeBytes(value: any, mode?: any, precision?: any, withToolt
|
|
|
642
645
|
* tooltip body would conflict with the calendar walk
|
|
643
646
|
* already visible in line 1)
|
|
644
647
|
* @returns the formatted duration string
|
|
648
|
+
*
|
|
649
|
+
* SECURITY: Do NOT interpolate raw, user-controlled strings into the output without escaping/validating them first.
|
|
650
|
+
* The handlebars `datetimeDuration` helper returns this output as a Handlebars.SafeString (unescaped).
|
|
645
651
|
*/
|
|
646
652
|
export function datetimeDuration(start: any, end: any, unit?: any, fraction?: any, direction?: any, withTooltip?: any): string {
|
|
647
653
|
const startM = moment(start);
|
|
@@ -12,9 +12,11 @@ import markdownItSub from '@isrd-isi-edu/ermrestjs/vendor/markdown-it-sub.min';
|
|
|
12
12
|
import markdownItSup from '@isrd-isi-edu/ermrestjs/vendor/markdown-it-sup.min';
|
|
13
13
|
import markdownItSpan from '@isrd-isi-edu/ermrestjs/vendor/markdown-it-span';
|
|
14
14
|
import markdownItEscape from '@isrd-isi-edu/ermrestjs/vendor/markdown-it-escape';
|
|
15
|
-
import markdownItAttrs from '@isrd-isi-edu/ermrestjs/vendor/markdown-it-attrs';
|
|
16
15
|
import MarkdownItContainer from '@isrd-isi-edu/ermrestjs/vendor/markdown-it-container.min';
|
|
17
16
|
|
|
17
|
+
// npm package (replaces the previously vendored markdown-it-attrs)
|
|
18
|
+
import markdownItAttrs from 'markdown-it-attrs';
|
|
19
|
+
|
|
18
20
|
let _markdownItDefaultImageRenderer: any = null;
|
|
19
21
|
export const MarkdownIt = markdownit({ typographer: true, breaks: true })
|
|
20
22
|
.use(markdownItSub)
|
|
@@ -32,11 +34,48 @@ _bindCustomMarkdownTags(MarkdownIt);
|
|
|
32
34
|
* @param throwError if true, it will throw the error instead of swalloing it
|
|
33
35
|
*/
|
|
34
36
|
export function renderMarkdown(value: string, inline?: boolean, throwError?: boolean) {
|
|
37
|
+
/*
|
|
38
|
+
* markdown-it-attrs 5.x catches errors thrown while applying an attribute
|
|
39
|
+
* block and only reports them via `console.error` (it has no error callback
|
|
40
|
+
* or opt-out). To preserve the old "if anything fails, show the raw value"
|
|
41
|
+
* behavior, trap that signal during the render and treat it as a failure so
|
|
42
|
+
* we fall back to the raw value below instead of returning a partial render.
|
|
43
|
+
*/
|
|
44
|
+
let attrsError: string | null = null;
|
|
45
|
+
// message of the failure we just captured; used to recognize its stack-trace follow-up
|
|
46
|
+
let pendingMessage: string | null = null;
|
|
47
|
+
const consoleError = console.error;
|
|
48
|
+
console.error = (...args: any[]) => {
|
|
49
|
+
if (typeof args[0] === 'string' && args[0].indexOf('markdown-it-attrs:') === 0) {
|
|
50
|
+
attrsError = args[0];
|
|
51
|
+
/*
|
|
52
|
+
* markdown-it-attrs logs the message above and then `console.error(error.stack)`
|
|
53
|
+
* on the very next call (see its index.js: the two calls are consecutive and
|
|
54
|
+
* synchronous). error.stack is a string, not an Error, so we match it by content:
|
|
55
|
+
* the stack embeds the same `error.message` that trails this message.
|
|
56
|
+
*/
|
|
57
|
+
pendingMessage = args[0].replace(/^markdown-it-attrs: Error in pattern '.*?': /, '');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// swallow only that stack-trace follow-up; anything else is unrelated and must pass through
|
|
61
|
+
if (pendingMessage !== null) {
|
|
62
|
+
const message = pendingMessage;
|
|
63
|
+
pendingMessage = null;
|
|
64
|
+
if (message.length > 0 && typeof args[0] === 'string' && args[0].indexOf(message) !== -1) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
consoleError.apply(console, args);
|
|
69
|
+
};
|
|
70
|
+
|
|
35
71
|
try {
|
|
36
|
-
|
|
37
|
-
|
|
72
|
+
const rendered = inline ? MarkdownIt.renderInline(value) : MarkdownIt.render(value);
|
|
73
|
+
pendingMessage = null; // render is done; don't let a dangling flag swallow our own catch logging
|
|
74
|
+
// markdown-it-attrs only logs (never throws), so re-throw here to fall back to the raw value
|
|
75
|
+
if (attrsError !== null) {
|
|
76
|
+
throw new Error(attrsError);
|
|
38
77
|
}
|
|
39
|
-
return
|
|
78
|
+
return rendered;
|
|
40
79
|
} catch (e) {
|
|
41
80
|
if (throwError) {
|
|
42
81
|
throw e;
|
|
@@ -45,6 +84,8 @@ export function renderMarkdown(value: string, inline?: boolean, throwError?: boo
|
|
|
45
84
|
$log.error(`Couldn't parse the given markdown value: ${value}`);
|
|
46
85
|
$log.error(e);
|
|
47
86
|
return value;
|
|
87
|
+
} finally {
|
|
88
|
+
console.error = consoleError;
|
|
48
89
|
}
|
|
49
90
|
}
|
|
50
91
|
|
|
@@ -476,8 +517,9 @@ function _bindCustomMarkdownTags(md: typeof MarkdownIt) {
|
|
|
476
517
|
if (attrs[0].children[0].type == 'link_open') {
|
|
477
518
|
let imageHTML = '<img ';
|
|
478
519
|
const openingLink = attrs[0].children[0];
|
|
479
|
-
let enlargeLink
|
|
480
|
-
|
|
520
|
+
let enlargeLink;
|
|
521
|
+
let posTop = true;
|
|
522
|
+
let imageClass = _classNames.imageFallback;
|
|
481
523
|
|
|
482
524
|
// Add all attributes to the image
|
|
483
525
|
openingLink.attrs!.forEach(function (attr) {
|
|
@@ -497,12 +539,15 @@ function _bindCustomMarkdownTags(md: typeof MarkdownIt) {
|
|
|
497
539
|
case 'pos':
|
|
498
540
|
posTop = attr[1].toLowerCase() == 'bottom' ? false : true;
|
|
499
541
|
break;
|
|
542
|
+
case 'class':
|
|
543
|
+
imageClass += ' ' + attr[1];
|
|
544
|
+
break;
|
|
500
545
|
default:
|
|
501
546
|
imageHTML += attr[0] + '="' + attr[1] + '" ';
|
|
502
547
|
}
|
|
503
548
|
});
|
|
504
549
|
|
|
505
|
-
html += imageHTML + '/>';
|
|
550
|
+
html += imageHTML + 'class="' + imageClass + '" />';
|
|
506
551
|
|
|
507
552
|
let captionHTML = '';
|
|
508
553
|
|
|
@@ -918,16 +963,17 @@ function _bindCustomMarkdownTags(md: typeof MarkdownIt) {
|
|
|
918
963
|
};
|
|
919
964
|
}
|
|
920
965
|
|
|
921
|
-
// the
|
|
922
|
-
|
|
966
|
+
// the classes that we should add (post-load for layout, image-fallback so chaise
|
|
967
|
+
// can swap in a fallback image when the image fails to load)
|
|
968
|
+
const imgClassName = _classNames.postLoad + ' ' + _classNames.imageFallback;
|
|
923
969
|
md.renderer.rules.image = function (tokens, idx, options, env, self) {
|
|
924
970
|
const token = tokens[idx];
|
|
925
971
|
|
|
926
972
|
const cIndex = token.attrIndex('class');
|
|
927
973
|
if (cIndex < 0) {
|
|
928
|
-
token.attrPush(['class',
|
|
974
|
+
token.attrPush(['class', imgClassName]);
|
|
929
975
|
} else {
|
|
930
|
-
token.attrs![cIndex][1] += ' ' +
|
|
976
|
+
token.attrs![cIndex][1] += ' ' + imgClassName;
|
|
931
977
|
}
|
|
932
978
|
|
|
933
979
|
return _markdownItDefaultImageRenderer(tokens, idx, options, env, self);
|
|
@@ -60,6 +60,18 @@ export function buildSelfTemplateVariables(column: ReferenceColumn, mainTuple: T
|
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// InboundForeignKeyPseudoColumn: a single-inbound-FK entity set. Like the
|
|
64
|
+
// isPathColumn entityset case, use the fetched page's templateVariables.
|
|
65
|
+
if ((column as any).isInboundForeignKey) {
|
|
66
|
+
if (columnValue && columnValue.templateVariables) {
|
|
67
|
+
if (Array.isArray(columnValue.templateVariables)) {
|
|
68
|
+
return { $self: columnValue.templateVariables };
|
|
69
|
+
}
|
|
70
|
+
return columnValue.templateVariables;
|
|
71
|
+
}
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
|
|
63
75
|
// PseudoColumn (path-based source column)
|
|
64
76
|
if ((column as any).isPathColumn) {
|
|
65
77
|
const pseudoCol = column as PseudoColumn;
|
|
@@ -88,8 +100,14 @@ export function buildSelfTemplateVariables(column: ReferenceColumn, mainTuple: T
|
|
|
88
100
|
};
|
|
89
101
|
}
|
|
90
102
|
|
|
91
|
-
// entityset
|
|
103
|
+
// entityset vs aggregate value:
|
|
104
|
+
// - an entityset's value is the page, whose templateVariables is the bare array
|
|
105
|
+
// of per-row template variables -> wrap it as $self.
|
|
106
|
+
// - an aggregate value already carries { $self, $_self } (chaise builds it) -> use as-is.
|
|
92
107
|
if (columnValue && columnValue.templateVariables) {
|
|
108
|
+
if (Array.isArray(columnValue.templateVariables)) {
|
|
109
|
+
return { $self: columnValue.templateVariables };
|
|
110
|
+
}
|
|
93
111
|
return columnValue.templateVariables;
|
|
94
112
|
}
|
|
95
113
|
|
package/tsconfig.json
CHANGED
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"noUnusedLocals": false,
|
|
24
24
|
"noUnusedParameters": false, // TODO change this back
|
|
25
25
|
"noFallthroughCasesInSwitch": true,
|
|
26
|
-
"noUncheckedSideEffectImports": true
|
|
26
|
+
"noUncheckedSideEffectImports": true,
|
|
27
|
+
"types": ["node"]
|
|
27
28
|
},
|
|
28
29
|
"include": ["src"]
|
|
29
30
|
}
|