@isrd-isi-edu/ermrestjs 2.1.1 → 2.3.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/core.js CHANGED
@@ -22,7 +22,7 @@ import $log from '@isrd-isi-edu/ermrestjs/src/services/logger';
22
22
 
23
23
  // utils
24
24
  import { isObjectAndNotNull, isEmptyArray, isStringAndNotEmpty, isValidColorRGBHex } from '@isrd-isi-edu/ermrestjs/src/utils/type-utils';
25
- import { fixedEncodeURIComponent, escapeHTML } from '@isrd-isi-edu/ermrestjs/src/utils/value-utils';
25
+ import { fixedEncodeURIComponent, escapeHTML, updateObject } from '@isrd-isi-edu/ermrestjs/src/utils/value-utils';
26
26
  import {
27
27
  _annotations,
28
28
  _commentDisplayModes,
@@ -3238,11 +3238,12 @@ import {
3238
3238
  var defaultAnnotKey = _annotations.COLUMN_DEFAULTS;
3239
3239
  var ancestors = [this.table.schema.catalog, this.table.schema, this.table];
3240
3240
  // frist copy the by_type annots
3241
+ // frist copy the by_type annots
3241
3242
  ancestors.forEach(function (el) {
3242
3243
  if (el.annotations.contains(defaultAnnotKey)) {
3243
3244
  var tempAnnot = el.annotations.get(defaultAnnotKey).content;
3244
3245
  if (isObjectAndNotNull(tempAnnot) && isObjectAndNotNull(tempAnnot.by_type) && isObjectAndNotNull(tempAnnot.by_type[jsonColumn.type.typename])) {
3245
- Object.assign(annots, tempAnnot.by_type[jsonColumn.type.typename]);
3246
+ updateObject(annots, tempAnnot.by_type[jsonColumn.type.typename]);
3246
3247
  }
3247
3248
  }
3248
3249
  });
@@ -3251,7 +3252,7 @@ import {
3251
3252
  if (el.annotations.contains(defaultAnnotKey)) {
3252
3253
  var tempAnnot = el.annotations.get(defaultAnnotKey).content;
3253
3254
  if (isObjectAndNotNull(tempAnnot) && isObjectAndNotNull(tempAnnot.by_name) && isObjectAndNotNull(tempAnnot.by_name[jsonColumn.name])) {
3254
- Object.assign(annots, tempAnnot.by_name[jsonColumn.name]);
3255
+ updateObject(annots, tempAnnot.by_name[jsonColumn.name]);
3255
3256
  }
3256
3257
  }
3257
3258
  });
@@ -3262,14 +3263,14 @@ import {
3262
3263
  if (el.annotations.contains(defaultAnnotKey)) {
3263
3264
  var tempAnnot = el.annotations.get(defaultAnnotKey).content;
3264
3265
  if (isObjectAndNotNull(tempAnnot) && isObjectAndNotNull(tempAnnot.asset) && isObjectAndNotNull(tempAnnot.asset[assetCategory])) {
3265
- Object.assign(annots, tempAnnot.asset[assetCategory]);
3266
+ updateObject(annots, tempAnnot.asset[assetCategory]);
3266
3267
  }
3267
3268
  }
3268
3269
  });
3269
3270
  }
3270
3271
 
3271
3272
  // then copy the existing annots on the column
3272
- Object.assign(annots, jsonColumn.annotations);
3273
+ updateObject(annots, jsonColumn.annotations);
3273
3274
  for (var uri in annots) {
3274
3275
  var jsonAnnotation = annots[uri];
3275
3276
  this.annotations._push(new Annotation("column", uri, jsonAnnotation));
package/js/hatrac.js CHANGED
@@ -15,6 +15,7 @@ import ConfigService from '@isrd-isi-edu/ermrestjs/src/services/config';
15
15
  import { hexToBase64 } from '@isrd-isi-edu/ermrestjs/src/utils/value-utils';
16
16
  import { isObject, isObjectAndNotNull } from '@isrd-isi-edu/ermrestjs/src/utils/type-utils';
17
17
  import { contextHeaderName, ENV_IS_NODE } from '@isrd-isi-edu/ermrestjs/src/utils/constants';
18
+ import { getFilenameExtension } from '@isrd-isi-edu/ermrestjs/src/utils/file-utils';
18
19
 
19
20
  // legacy
20
21
  import { _validateTemplate, _renderTemplate, _getFormattedKeyValues, _parseUrl } from '@isrd-isi-edu/ermrestjs/js/utils/helpers';
@@ -138,65 +139,6 @@ const _generateContextHeader = function (contextHeaderParams) {
138
139
  return headers;
139
140
  };
140
141
 
141
- /**
142
- * given a filename, will return the extension
143
- * By default, it will extract the last of the filename after the last `.`.
144
- * The second parameter can be used for passing a regular expression
145
- * if we want a different method of extracting the extension.
146
- * @param {string} filename
147
- * @param {string[]} allowedExtensions
148
- * @param {string[]} regexArr
149
- * @returns the filename extension string. if we cannot find any matches, it will return null
150
- * @private
151
- * @ignore
152
- */
153
- const _getFilenameExtension = function (filename, allowedExtensions, regexArr) {
154
- if (typeof filename !== 'string' || filename.length === 0) {
155
- return null;
156
- }
157
-
158
- // first find in the list of allowed extensions
159
- var res = -1;
160
- var isInAllowed =
161
- Array.isArray(allowedExtensions) &&
162
- allowedExtensions.some(function (ext) {
163
- res = ext;
164
- return typeof ext === 'string' && ext.length > 0 && filename.endsWith(ext);
165
- });
166
- if (isInAllowed) {
167
- return res;
168
- }
169
-
170
- // we will return null if we cannot find anything
171
- res = null;
172
- // no matching allowed extension, try the regular expressions
173
- if (Array.isArray(regexArr) && regexArr.length > 0) {
174
- regexArr.some(function (regexp) {
175
- // since regular expression comes from annotation, it might not be valid
176
- try {
177
- var matches = filename.match(new RegExp(regexp, 'g'));
178
- if (matches && matches[0] && typeof matches[0] === 'string') {
179
- res = matches[0];
180
- } else {
181
- res = null;
182
- }
183
- return res;
184
- } catch {
185
- res = null;
186
- return false;
187
- }
188
- });
189
- } else {
190
- var dotIndex = filename.lastIndexOf('.');
191
- // it's only a valid filename if there's some string after `.`
192
- if (dotIndex !== -1 && dotIndex !== filename.length - 1) {
193
- res = filename.slice(dotIndex);
194
- }
195
- }
196
-
197
- return res;
198
- };
199
-
200
142
  /**
201
143
  * @desc upload Object
202
144
  * Create a new instance with new upload(file, otherInfo)
@@ -801,7 +743,7 @@ Upload.prototype._generateURL = function (row, linkedData, templateVariables) {
801
743
  row[this.column.name].md5_base64 = this.hash.md5_base64;
802
744
  row[this.column.name].sha256 = this.hash.sha256;
803
745
  row[this.column.name].filename = this.file.name;
804
- var filename_ext = _getFilenameExtension(this.file.name, this.column.filenameExtFilter, this.column.filenameExtRegexp);
746
+ var filename_ext = getFilenameExtension(this.file.name, this.column.filenameExtFilter, this.column.filenameExtRegexp);
805
747
  row[this.column.name].filename_ext = filename_ext;
806
748
  // filename_basename is everything from the file name except the last ext
807
749
  // For example if we have a file named "file.tar.zip"
package/js/parser.js CHANGED
@@ -227,6 +227,7 @@ import HistoryService from '@isrd-isi-edu/ermrestjs/src/services/history';
227
227
  this._compactUri = stripTrailingSlash(this._compactUri);
228
228
 
229
229
  // service
230
+ // codeql[js/polynomial-redos]: URL input limited by browser length
230
231
  parts = uri.match(/(.*)\/catalog\/([^\/]*)\/(entity|attribute|aggregate|attributegroup)\/(.*)/);
231
232
  this._service = parts[1];
232
233
 
@@ -263,11 +264,13 @@ import HistoryService from '@isrd-isi-edu/ermrestjs/src/services/history';
263
264
  // sort and paging
264
265
  if (modifiers) {
265
266
  if (modifiers.indexOf("@sort(") !== -1) {
267
+ // codeql[js/polynomial-redos]: URL input limited by browser length
266
268
  this._sort = modifiers.match(/(@sort\([^\)]*\))/)[1];
267
269
  }
268
270
  // sort must specified to use @before and @after
269
271
  if (modifiers.indexOf("@before(") !== -1) {
270
272
  if (this._sort) {
273
+ // codeql[js/polynomial-redos]: URL input limited by browser length
271
274
  this._before = modifiers.match(/(@before\([^\)]*\))/)[1];
272
275
  } else {
273
276
  throw new InvalidPageCriteria("Sort modifier is required with paging.", this._path);
@@ -276,6 +279,7 @@ import HistoryService from '@isrd-isi-edu/ermrestjs/src/services/history';
276
279
 
277
280
  if (modifiers.indexOf("@after(") !== -1) {
278
281
  if (this._sort) {
282
+ // codeql[js/polynomial-redos]: URL input limited by browser length
279
283
  this._after = modifiers.match(/(@after\([^\)]*\))/)[1];
280
284
  } else {
281
285
  throw new InvalidPageCriteria("Sort modifier is required with paging.", this._path);
@@ -300,6 +304,7 @@ import HistoryService from '@isrd-isi-edu/ermrestjs/src/services/history';
300
304
  }
301
305
 
302
306
  // pathParts: <joins/facet/cfacet/filter/>
307
+ // codeql[js/polynomial-redos]: URL input limited by browser length
303
308
  var joinRegExp = /(?:left|right|full|^)\((.*)\)=\((.*:.*:.*)\)/,
304
309
  facetsRegExp = /\*::facets::(.+)/,
305
310
  customFacetsRegExp = /\*::cfacets::(.+)/;
@@ -1770,6 +1775,7 @@ import HistoryService from '@isrd-isi-edu/ermrestjs/src/services/history';
1770
1775
  function _createParsedJoinFromStr (linking, table, schema) {
1771
1776
  var fromSchemaTable = schema ? [schema,table].join(":") : table;
1772
1777
  var fromCols = linking[1].split(",");
1778
+ // codeql[js/polynomial-redos]: URL input limited by browser length
1773
1779
  var toParts = linking[2].match(/([^:]*):([^:]*):([^\)]*)/);
1774
1780
  var toCols = toParts[3].split(",");
1775
1781
  var strReverse = "(" + toParts[3] + ")=(" + fromSchemaTable + ":" + linking[1] + ")";
@@ -404,7 +404,7 @@ import AuthnService from '@isrd-isi-edu/ermrestjs/src/services/authn';
404
404
 
405
405
  /**
406
406
  * @param {string} context the context that we want the value of.
407
- * @param {Object} annotation the annotation object.
407
+ * @param {any} annotation the annotation object.
408
408
  * @param {Boolean=} dontUseDefaultContext Whether we should use the default (*) context
409
409
  * @desc returns the annotation value based on the given context.
410
410
  */
@@ -782,14 +782,16 @@ import AuthnService from '@isrd-isi-edu/ermrestjs/src/services/authn';
782
782
 
783
783
  //get foreignkey data if available
784
784
  if (linkedData && typeof linkedData === "object" && table.sourceDefinitions.fkeys.length > 0) {
785
- keyValues.$fkeys = {};
785
+ // use a prototype-less object to avoid prototype pollution via constraint names
786
+ keyValues.$fkeys = Object.create(null);
786
787
  table.sourceDefinitions.fkeys.forEach(function (fk) {
787
788
  var p = _generateRowLinkProperties(fk.key, linkedData[fk.name], context);
788
789
  if (!p) return;
789
790
 
790
791
  cons = fk.constraint_names[0];
791
792
  if (!keyValues.$fkeys[cons[0]]) {
792
- keyValues.$fkeys[cons[0]] = {};
793
+ // per-schema map should also be prototype-less
794
+ keyValues.$fkeys[cons[0]] = Object.create(null);
793
795
  }
794
796
 
795
797
  var fkTempVal = {
@@ -19,34 +19,32 @@ import ConfigService from '@isrd-isi-edu/ermrestjs/src/services/config';
19
19
  import { createPseudoColumn } from '@isrd-isi-edu/ermrestjs/src/utils/column-utils';
20
20
  import { isObjectAndNotNull, isObject, isDefinedAndNotNull, isStringAndNotEmpty } from '@isrd-isi-edu/ermrestjs/src/utils/type-utils';
21
21
  import {
22
- fixedEncodeURIComponent,
23
- hexToBase64,
24
- simpleDeepCopy,
25
- shallowCopyExtras,
26
- urlEncodeBase64,
22
+ fixedEncodeURIComponent,
23
+ hexToBase64,
24
+ simpleDeepCopy,
25
+ shallowCopyExtras,
26
+ urlEncodeBase64,
27
27
  } from '@isrd-isi-edu/ermrestjs/src/utils/value-utils';
28
28
  import {
29
- _constraintTypes,
30
- _contexts,
31
- _ERMrestFeatures,
32
- _ERMrestFilterPredicates,
33
- _ERMrestLogicalOperators,
34
- _facetingErrors,
35
- _facetFilterTypes,
36
- _facetHeuristicIgnoredTypes,
37
- _FacetsLogicalOperators,
38
- _facetUnsupportedTypes,
39
- _pseudoColAggregateFns,
40
- _shorterVersion,
41
- _sourceDefinitionAttributes,
42
- _sourceProperties,
43
- _specialSourceDefinitions,
44
- _systemColumnNames,
45
- _warningMessages,
29
+ _constraintTypes,
30
+ _contexts,
31
+ _ERMrestFeatures,
32
+ _ERMrestFilterPredicates,
33
+ _ERMrestLogicalOperators,
34
+ _facetingErrors,
35
+ _facetFilterTypes,
36
+ _facetHeuristicIgnoredTypes,
37
+ _FacetsLogicalOperators,
38
+ _facetUnsupportedTypes, _shorterVersion,
39
+ _sourceDefinitionAttributes,
40
+ _sourceProperties,
41
+ _specialSourceDefinitions,
42
+ _systemColumnNames,
43
+ _warningMessages
46
44
  } from '@isrd-isi-edu/ermrestjs/src/utils/constants';
47
45
 
48
46
  // legacy
49
- import { generateKeyValueFilters, renameKey, _renderTemplate, _isEntryContext, _getFormattedKeyValues } from '@isrd-isi-edu/ermrestjs/js/utils/helpers';
47
+ import { generateKeyValueFilters, renameKey, _renderTemplate, _isEntryContext } from '@isrd-isi-edu/ermrestjs/js/utils/helpers';
50
48
  import { Table, Catalog } from '@isrd-isi-edu/ermrestjs/js/core';
51
49
  import { parse, _convertSearchTermToFilter } from '@isrd-isi-edu/ermrestjs/js/parser';
52
50
 
@@ -931,11 +929,6 @@ import { parse, _convertSearchTermToFilter } from '@isrd-isi-edu/ermrestjs/js/pa
931
929
  throw new Error(_facetingErrors.aggregateFnNowtAllowed);
932
930
  }
933
931
 
934
- // column type array is not supported
935
- if (col.type.isArray) {
936
- throw new Error(_facetingErrors.arrayColumnTypeNotSupported);
937
- }
938
-
939
932
  // check the column type
940
933
  if (_facetUnsupportedTypes.indexOf(col.type.name) !== -1) {
941
934
  throw new Error(`Facet of column type '${col.type.name}' is not supported.`);
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.1.1",
4
+ "version": "2.3.0",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
7
7
  "node": ">= 20.0.0",
@@ -26,7 +26,7 @@
26
26
  "lint": "eslint src js --quiet",
27
27
  "lint-w-warn": "eslint src js",
28
28
  "format": "prettier --write src",
29
- "prepare": "husky"
29
+ "prepare": "node .husky/install.mjs"
30
30
  },
31
31
  "repository": {
32
32
  "type": "git",
@@ -43,10 +43,12 @@
43
43
  "library"
44
44
  ],
45
45
  "dependencies": {
46
+ "@types/lodash-es": "^4.17.12",
46
47
  "@types/markdown-it": "^14.1.2",
47
48
  "@types/q": "^1.5.8",
48
49
  "axios": "1.13.2",
49
50
  "handlebars": "4.7.8",
51
+ "lodash-es": "^4.17.23",
50
52
  "lz-string": "^1.5.0",
51
53
  "markdown-it": "12.3.2",
52
54
  "moment": "2.29.4",
@@ -56,17 +58,18 @@
56
58
  "spark-md5": "^3.0.0",
57
59
  "terser": "^5.44.1",
58
60
  "typescript": "~5.9.3",
59
- "vite": "^6.4.1",
60
- "vite-plugin-compression2": "^2.2.1"
61
+ "vite": "^7.3.1",
62
+ "vite-plugin-compression2": "^2.2.1",
63
+ "vite-plugin-dts": "^4.5.4"
61
64
  },
62
65
  "devDependencies": {
63
66
  "@commitlint/cli": "^20.2.0",
64
67
  "@commitlint/config-conventional": "^20.2.0",
65
- "@eslint/js": "^9.36.0",
68
+ "@eslint/js": "^9.39.2",
66
69
  "@isrd-isi-edu/ermrest-data-utils": "0.0.5",
67
70
  "@semantic-release/git": "^10.0.1",
68
- "@types/node": "^24.6.1",
69
- "eslint": "^9.36.0",
71
+ "@types/node": "^25.0.3",
72
+ "eslint": "^9.39.2",
70
73
  "eslint-config-prettier": "^10.1.8",
71
74
  "eslint-plugin-prettier": "^5.5.4",
72
75
  "globals": "^16.4.0",
@@ -78,7 +81,6 @@
78
81
  "prettier": "^3.7.4",
79
82
  "require-reload": "^0.2.2",
80
83
  "rollup-plugin-visualizer": "^6.0.3",
81
- "typescript-eslint": "^8.45.0",
82
- "vite-plugin-dts": "^4.5.4"
84
+ "typescript-eslint": "^8.51.0"
83
85
  }
84
86
  }
package/src/index.ts CHANGED
@@ -77,6 +77,7 @@ import HandlebarsService from '@isrd-isi-edu/ermrestjs/src/services/handlebars';
77
77
  import { Exporter } from '@isrd-isi-edu/ermrestjs/js/export';
78
78
  import validateJSONLD from '@isrd-isi-edu/ermrestjs/js/json_ld_validator.js';
79
79
  import HistoryService from '@isrd-isi-edu/ermrestjs/src/services/history';
80
+ import FilePreviewService from '@isrd-isi-edu/ermrestjs/src/services/file-preview';
80
81
 
81
82
  const logError = ErrorService.logError;
82
83
  const responseToError = ErrorService.responseToError;
@@ -111,6 +112,7 @@ export {
111
112
  AuthnService,
112
113
  Exporter,
113
114
  HistoryService,
115
+ FilePreviewService,
114
116
 
115
117
  // constants
116
118
  contextHeaderName,
@@ -317,15 +317,15 @@ export class UnsupportedFilters extends ERMrestError {
317
317
 
318
318
  const removePageCondition = (path?: string) => {
319
319
  if (path !== undefined) {
320
- path = path.replace(/(@before\([^)]*\))/, '');
321
- path = path.replace(/(@after\([^)]*\))/, '');
320
+ path = path.replace(/(@before\([^)@]*\))/, '');
321
+ path = path.replace(/(@after\([^)@]*\))/, '');
322
322
  }
323
323
  return path;
324
324
  };
325
325
 
326
326
  const removeSortCondition = (path?: string) => {
327
327
  if (path !== undefined) {
328
- return path.replace(/(@sort\([^)]*\))/, '');
328
+ return path.replace(/(@sort\([^)@]*\))/, '');
329
329
  }
330
330
  return path;
331
331
  };
@@ -3,10 +3,14 @@ import { ReferenceColumn, ReferenceColumnTypes } from '@isrd-isi-edu/ermrestjs/s
3
3
  import type SourceObjectWrapper from '@isrd-isi-edu/ermrestjs/src/models/source-object-wrapper';
4
4
  import type { Reference, Tuple, VisibleColumn } from '@isrd-isi-edu/ermrestjs/src/models/reference';
5
5
 
6
+ // services
7
+ import { FilePreviewTypes, isFilePreviewType, USE_EXT_MAPPING } from '@isrd-isi-edu/ermrestjs/src/services/file-preview';
8
+
6
9
  // utils
7
10
  import { renderMarkdown } from '@isrd-isi-edu/ermrestjs/src/utils/markdown-utils';
8
11
  import { isDefinedAndNotNull, isObjectAndKeyExists, isObjectAndNotNull, isStringAndNotEmpty } from '@isrd-isi-edu/ermrestjs/src/utils/type-utils';
9
12
  import { _annotations, _contexts, _classNames } from '@isrd-isi-edu/ermrestjs/src/utils/constants';
13
+ import { getFilename } from '@isrd-isi-edu/ermrestjs/src/utils/file-utils';
10
14
 
11
15
  // legacy
12
16
  import { _getAnnotationValueByContext, _isEntryContext, _renderTemplate, _isSameHost } from '@isrd-isi-edu/ermrestjs/js/utils/helpers';
@@ -70,7 +74,7 @@ export class AssetPseudoColumn extends ReferenceColumn {
70
74
  private _filenameExtFilter?: string[];
71
75
  private _filenameExtRegexp?: string[];
72
76
  private _displayImagePreview?: boolean;
73
- private _filePreview?: null | { showCsvHeader: boolean };
77
+ private _filePreview?: FilePreviewConfig | null;
74
78
 
75
79
  constructor(reference: Reference, column: Column, sourceObjectWrapper?: SourceObjectWrapper, name?: string, mainTuple?: Tuple) {
76
80
  // call the parent constructor
@@ -168,17 +172,9 @@ export class AssetPseudoColumn extends ReferenceColumn {
168
172
 
169
173
  // if we're using the url as caption
170
174
  if (urlCaption) {
171
- // if caption matches the expected format, just show the file name
172
- // eslint-disable-next-line no-useless-escape
173
- const parts = caption.match(/^\/hatrac\/([^\/]+\/)*([^\/:]+)(:[^:]+)?$/);
174
- if (parts && parts.length === 4) {
175
- caption = parts[2];
176
- } else {
177
- // otherwise return the last part of url
178
- const newCaption = caption.split('/').pop();
179
- if (newCaption && newCaption.length !== 0) {
180
- caption = newCaption;
181
- }
175
+ const newCaption = getFilename(caption);
176
+ if (newCaption && newCaption.length !== 0) {
177
+ caption = newCaption;
182
178
  }
183
179
  }
184
180
 
@@ -457,7 +453,7 @@ export class AssetPseudoColumn extends ReferenceColumn {
457
453
  /**
458
454
  * whether we should show the file preview or not
459
455
  */
460
- get filePreview(): null | { showCsvHeader: boolean } {
456
+ get filePreview(): FilePreviewConfig | null {
461
457
  if (this._filePreview === undefined) {
462
458
  const disp = this._annotation.display;
463
459
  const currDisplay = isObjectAndNotNull(disp) ? _getAnnotationValueByContext(this._context, disp) : null;
@@ -465,12 +461,7 @@ export class AssetPseudoColumn extends ReferenceColumn {
465
461
  if (settings === false) {
466
462
  this._filePreview = null;
467
463
  } else {
468
- // by default we're hiding the CSV header.
469
- let showCsvHeader = false;
470
- if (isObjectAndKeyExists(settings, 'show_csv_header') && typeof settings.show_csv_header === 'boolean') {
471
- showCsvHeader = settings.show_csv_header;
472
- }
473
- this._filePreview = { showCsvHeader };
464
+ this._filePreview = new FilePreviewConfig(settings);
474
465
  }
475
466
  }
476
467
  return this._filePreview;
@@ -496,3 +487,234 @@ export class AssetPseudoColumn extends ReferenceColumn {
496
487
  return this._waitFor;
497
488
  }
498
489
  }
490
+
491
+ class FilePreviewConfig {
492
+ private static previewTypes = Object.values(FilePreviewTypes);
493
+
494
+ /**
495
+ * whether we should show the CSV header or not
496
+ * (default: false)
497
+ */
498
+ showCsvHeader: boolean = false;
499
+
500
+ /**
501
+ * the height of the preview container
502
+ */
503
+ defaultHeight: number | null = null;
504
+
505
+ private _prefetchBytes: { [key: string]: number | null } = {
506
+ image: null,
507
+ markdown: null,
508
+ csv: null,
509
+ tsv: null,
510
+ json: null,
511
+ text: null,
512
+ };
513
+
514
+ private _prefetchMaxFileSize: { [key: string]: number | null } = {
515
+ image: null,
516
+ markdown: null,
517
+ csv: null,
518
+ tsv: null,
519
+ json: null,
520
+ text: null,
521
+ };
522
+
523
+ filenameExtMapping: { [key: string]: FilePreviewTypes | false } | null = null;
524
+
525
+ contentTypeMapping: {
526
+ exactMatch: { [key: string]: FilePreviewTypes | typeof USE_EXT_MAPPING | false } | null;
527
+ prefixMatch: { [key: string]: FilePreviewTypes | typeof USE_EXT_MAPPING | false } | null;
528
+ default: FilePreviewTypes | typeof USE_EXT_MAPPING | false | null;
529
+ } | null = null;
530
+
531
+ disabledTypes: FilePreviewTypes[] = [];
532
+
533
+ /**
534
+ * populate the props based on the given annotation object.
535
+ * The supported annotation properties are:
536
+ * - show_csv_header
537
+ * - default_height
538
+ * - prefetch_bytes
539
+ * - prefetch_max_file_size
540
+ * - filename_ext_mapping
541
+ * - content_type_mapping
542
+ * - disabled
543
+ */
544
+ constructor(settings: any) {
545
+ if (isObjectAndKeyExists(settings, 'show_csv_header') && typeof settings.show_csv_header === 'boolean') {
546
+ this.showCsvHeader = settings.show_csv_header;
547
+ }
548
+
549
+ if (isObjectAndKeyExists(settings, 'default_height') && typeof settings.default_height === 'number' && settings.default_height >= 0) {
550
+ this.defaultHeight = settings.default_height;
551
+ }
552
+
553
+ this._prefetchBytes = this._populateProps<number>(settings, 'prefetch_bytes', (value: unknown) => {
554
+ return typeof value === 'number' && value >= 0;
555
+ });
556
+
557
+ this._prefetchMaxFileSize = this._populateProps<number>(settings, 'prefetch_max_file_size', (value: unknown) => {
558
+ return typeof value === 'number' && value >= 0;
559
+ });
560
+
561
+ if (isObjectAndKeyExists(settings, 'filename_ext_mapping')) {
562
+ this.filenameExtMapping = {};
563
+ for (const [key, val] of Object.entries(settings.filename_ext_mapping)) {
564
+ if (val === false || (typeof val === 'string' && isFilePreviewType(val))) {
565
+ this.filenameExtMapping[key] = val;
566
+ }
567
+ }
568
+ }
569
+
570
+ if (isObjectAndKeyExists(settings, 'content_type_mapping')) {
571
+ const exactMatch: { [key: string]: FilePreviewTypes | typeof USE_EXT_MAPPING | false } = {};
572
+ const prefixMatch: { [key: string]: FilePreviewTypes | typeof USE_EXT_MAPPING | false } = {};
573
+ let defaultMapping: FilePreviewTypes | typeof USE_EXT_MAPPING | false | null = null;
574
+ let hasExactMatch = false;
575
+ let hasPrefixMatch = false;
576
+ Object.keys(settings.content_type_mapping).forEach((key) => {
577
+ const val = settings.content_type_mapping[key];
578
+ const validValue = val === false || (typeof val === 'string' && (isFilePreviewType(val) || val === USE_EXT_MAPPING));
579
+ if (!validValue) return;
580
+ // * could be used for default mapping
581
+ if (key === '*') {
582
+ defaultMapping = val;
583
+ return;
584
+ }
585
+
586
+ // only type/ or type/subtype are valid
587
+ const parts = key.split('/');
588
+ if (parts.length !== 2 || parts[0].length === 0) return;
589
+
590
+ if (parts[1].length > 0) {
591
+ exactMatch[key] = val;
592
+ hasExactMatch = true;
593
+ } else {
594
+ prefixMatch[parts[0] + '/'] = val;
595
+ hasPrefixMatch = true;
596
+ }
597
+ });
598
+
599
+ if (hasPrefixMatch || hasExactMatch || defaultMapping !== null) {
600
+ this.contentTypeMapping = {
601
+ exactMatch: hasExactMatch ? exactMatch : null,
602
+ prefixMatch: hasPrefixMatch ? prefixMatch : null,
603
+ default: defaultMapping,
604
+ };
605
+ }
606
+ }
607
+
608
+ if (isObjectAndKeyExists(settings, 'disabled') && Array.isArray(settings.disabled)) {
609
+ this.disabledTypes = settings.disabled.filter(
610
+ (t: unknown) => typeof t === 'string' && FilePreviewConfig.previewTypes.includes(t as FilePreviewTypes),
611
+ );
612
+ }
613
+ }
614
+
615
+ /**
616
+ * return the number of bytes to prefetch for previewing the file
617
+ */
618
+ getPrefetchBytes(filePreviewType: FilePreviewTypes | null): number | null {
619
+ switch (filePreviewType) {
620
+ case FilePreviewTypes.IMAGE:
621
+ return this._prefetchBytes.image;
622
+ case FilePreviewTypes.MARKDOWN:
623
+ return this._prefetchBytes.markdown;
624
+ case FilePreviewTypes.CSV:
625
+ return this._prefetchBytes.csv;
626
+ case FilePreviewTypes.TSV:
627
+ return this._prefetchBytes.tsv;
628
+ case FilePreviewTypes.JSON:
629
+ return this._prefetchBytes.json;
630
+ case FilePreviewTypes.TEXT:
631
+ return this._prefetchBytes.text;
632
+ default:
633
+ return null;
634
+ }
635
+ }
636
+
637
+ /**
638
+ * return the max file size for previewing the file
639
+ */
640
+ getPrefetchMaxFileSize(filePreviewType: FilePreviewTypes | null): number | null {
641
+ switch (filePreviewType) {
642
+ case FilePreviewTypes.IMAGE:
643
+ return this._prefetchMaxFileSize.image;
644
+ case FilePreviewTypes.MARKDOWN:
645
+ return this._prefetchMaxFileSize.markdown;
646
+ case FilePreviewTypes.CSV:
647
+ return this._prefetchMaxFileSize.csv;
648
+ case FilePreviewTypes.TSV:
649
+ return this._prefetchMaxFileSize.tsv;
650
+ case FilePreviewTypes.JSON:
651
+ return this._prefetchMaxFileSize.json;
652
+ case FilePreviewTypes.TEXT:
653
+ return this._prefetchMaxFileSize.text;
654
+ default:
655
+ return null;
656
+ }
657
+ }
658
+
659
+ /**
660
+ * The settings could be either just one value or an object with different values for each type.
661
+ * This function will populate the result object with the appropriate values for each type.
662
+ */
663
+ private _populateProps<T>(settings: any, propName: string, validate?: (value: unknown) => boolean): { [key: string]: T | null } {
664
+ const res: { [key: string]: T | null } = {
665
+ text: null,
666
+ markdown: null,
667
+ csv: null,
668
+ tsv: null,
669
+ json: null,
670
+ image: null,
671
+ };
672
+ if (!isObjectAndKeyExists(settings, propName)) return res;
673
+
674
+ if (isObjectAndNotNull(settings[propName])) {
675
+ // for each preview type, try to get its value (which will fall back to * if not defined)
676
+ for (const key of FilePreviewConfig.previewTypes) {
677
+ const definedRes = this._getPropForType(key, settings[propName]);
678
+ if (!validate || validate(definedRes)) {
679
+ res[key] = definedRes;
680
+ }
681
+ }
682
+ } else {
683
+ const definedRes = settings[propName];
684
+ if (!validate || validate(definedRes)) {
685
+ for (const key of FilePreviewConfig.previewTypes) {
686
+ res[key] = definedRes;
687
+ }
688
+ }
689
+ }
690
+
691
+ return res;
692
+ }
693
+
694
+ /**
695
+ * Get the property for a specific file type. If not defined, will try to get the default value (*).
696
+ * Otherwise returns null.
697
+ */
698
+ private _getPropForType(fileType: string, settings: any): any {
699
+ const DEFAULT_TYPE = '*';
700
+
701
+ let isDefined = false;
702
+ let res;
703
+ if (isObjectAndKeyExists(settings, fileType)) {
704
+ if (typeof settings[fileType] === 'string' && settings[fileType] in FilePreviewConfig.previewTypes) {
705
+ res = this._getPropForType(settings[fileType], settings);
706
+ } else {
707
+ res = settings[fileType];
708
+ }
709
+
710
+ if (res !== null && res !== undefined) isDefined = true;
711
+ }
712
+
713
+ if (!isDefined && settings[DEFAULT_TYPE]) {
714
+ res = this._getPropForType(DEFAULT_TYPE, settings);
715
+ if (res !== null && res !== undefined) isDefined = true;
716
+ }
717
+
718
+ return isDefined ? res : null;
719
+ }
720
+ }
@@ -350,6 +350,11 @@ export class FacetColumn {
350
350
  return onlyChoice ? modes.CHOICE : modes.RANGE;
351
351
  }
352
352
 
353
+ // only check_presence can be supported for arrays.
354
+ if (self._column.type.isArray) {
355
+ return modes.PRESENCE;
356
+ }
357
+
353
358
  // use the defined ux_mode
354
359
  if (_facetUXModeNames.indexOf(self._facetObject.ux_mode) !== -1) {
355
360
  return self._facetObject.ux_mode;
@@ -1196,7 +1201,7 @@ export class FacetColumn {
1196
1201
  verify(Array.isArray(values), 'given argument must be an array');
1197
1202
 
1198
1203
  const filters = this.filters.slice();
1199
- values.forEach((v: any) => {
1204
+ values.forEach((v) => {
1200
1205
  filters.push(new ChoiceFacetFilter(v, this._column));
1201
1206
  });
1202
1207
 
@@ -1213,7 +1218,7 @@ export class FacetColumn {
1213
1218
  const filters = this.filters.slice().filter((f: FacetFilter | NotNullFacetFilter) => {
1214
1219
  return !(f instanceof ChoiceFacetFilter) && !(f instanceof NotNullFacetFilter);
1215
1220
  });
1216
- values.forEach((v: any) => {
1221
+ values.forEach((v) => {
1217
1222
  filters.push(new ChoiceFacetFilter(v, this._column));
1218
1223
  });
1219
1224
 
@@ -1342,7 +1347,7 @@ export class FacetColumn {
1342
1347
 
1343
1348
  // create a new FacetColumn so it doesn't reference to the current FacetColum
1344
1349
  // TODO can be refactored
1345
- const jsonFilters: any[] = [];
1350
+ const jsonFilters = [];
1346
1351
 
1347
1352
  // TODO might be able to imporve this. Instead of recreating the whole json file.
1348
1353
  // gather all the filters from the facetColumns
@@ -188,6 +188,7 @@ export default class ErrorService {
188
188
  let msgTail;
189
189
  const keyValueMap: Record<string, string> = {};
190
190
  let duplicateReference = null;
191
+ // codeql[js/polynomial-redos]: Parsing structured database error messages
191
192
  const columnRegExp = /\(([^)]+)\)/,
192
193
  valueRegExp = /=\(([^)]+)\)/,
193
194
  matches = columnRegExp.exec(generatedErrMessage),
@@ -0,0 +1,229 @@
1
+ import type { AssetPseudoColumn } from '@isrd-isi-edu/ermrestjs/src/models/reference-column';
2
+ import { FILE_PREVIEW } from '@isrd-isi-edu/ermrestjs/src/utils/constants';
3
+
4
+ import { getFilename, getFilenameExtension } from '@isrd-isi-edu/ermrestjs/src/utils/file-utils';
5
+ import $log from '@isrd-isi-edu/ermrestjs/src/services/logger';
6
+
7
+ /**
8
+ * The supported file preview types
9
+ */
10
+ export enum FilePreviewTypes {
11
+ IMAGE = 'image',
12
+ MARKDOWN = 'markdown',
13
+ CSV = 'csv',
14
+ TSV = 'tsv',
15
+ JSON = 'json',
16
+ TEXT = 'text',
17
+ }
18
+
19
+ /**
20
+ * Type guard to check if a value is a FilePreviewTypes
21
+ */
22
+ export const isFilePreviewType = (value: unknown): value is FilePreviewTypes => {
23
+ if (typeof value !== 'string') return false;
24
+ return Object.values(FilePreviewTypes).includes(value as FilePreviewTypes);
25
+ };
26
+
27
+ export const USE_EXT_MAPPING = 'use_ext_mapping';
28
+
29
+ const DEFAULT_CONTENT_TYPE_MAPPING: { [key: string]: FilePreviewTypes | typeof USE_EXT_MAPPING | false } = {
30
+ // image:
31
+ 'image/png': FilePreviewTypes.IMAGE,
32
+ 'image/jpeg': FilePreviewTypes.IMAGE,
33
+ 'image/jpg': FilePreviewTypes.IMAGE,
34
+ 'image/gif': FilePreviewTypes.IMAGE,
35
+ 'image/bmp': FilePreviewTypes.IMAGE,
36
+ 'image/webp': FilePreviewTypes.IMAGE,
37
+ 'image/svg+xml': FilePreviewTypes.IMAGE,
38
+ 'image/x-icon': FilePreviewTypes.IMAGE,
39
+ 'image/avif': FilePreviewTypes.IMAGE,
40
+ 'image/apng': FilePreviewTypes.IMAGE,
41
+ // markdown:
42
+ 'text/markdown': FilePreviewTypes.MARKDOWN,
43
+ // csv:
44
+ 'text/csv': FilePreviewTypes.CSV,
45
+ // tsv:
46
+ 'text/tab-separated-values': FilePreviewTypes.TSV,
47
+ // json:
48
+ 'application/json': FilePreviewTypes.JSON,
49
+ // text:
50
+ 'chemical/x-mmcif': FilePreviewTypes.TEXT,
51
+ 'chemical/x-cif': FilePreviewTypes.TEXT,
52
+ // generic:
53
+ 'text/plain': USE_EXT_MAPPING,
54
+ 'application/octet-stream': USE_EXT_MAPPING,
55
+ };
56
+
57
+ const DEFAULT_EXTENSION_MAPPING: { [key: string]: FilePreviewTypes | false } = {
58
+ // image:
59
+ '.png': FilePreviewTypes.IMAGE,
60
+ '.jpeg': FilePreviewTypes.IMAGE,
61
+ '.jpg': FilePreviewTypes.IMAGE,
62
+ '.gif': FilePreviewTypes.IMAGE,
63
+ '.bmp': FilePreviewTypes.IMAGE,
64
+ '.webp': FilePreviewTypes.IMAGE,
65
+ '.svg': FilePreviewTypes.IMAGE,
66
+ '.ico': FilePreviewTypes.IMAGE,
67
+ '.avif': FilePreviewTypes.IMAGE,
68
+ '.apng': FilePreviewTypes.IMAGE,
69
+ // markdown:
70
+ '.md': FilePreviewTypes.MARKDOWN,
71
+ '.markdown': FilePreviewTypes.MARKDOWN,
72
+ // csv:
73
+ '.csv': FilePreviewTypes.CSV,
74
+ // tsv:
75
+ '.tsv': FilePreviewTypes.TSV,
76
+ // json:
77
+ '.json': FilePreviewTypes.JSON,
78
+ '.mvsj': FilePreviewTypes.JSON, // MolViewSpec JSON (mol* viewer)
79
+ // text:
80
+ '.txt': FilePreviewTypes.TEXT,
81
+ '.log': FilePreviewTypes.TEXT,
82
+ '.cif': FilePreviewTypes.TEXT,
83
+ '.pdb': FilePreviewTypes.TEXT,
84
+ };
85
+
86
+ export default class FilePreviewService {
87
+ /**
88
+ * Returns the preview info based on the given file properties and the column's file preview settings.
89
+ * @param url the file url
90
+ * @param column the asset column
91
+ * @param storedFilename the stored filename
92
+ * @param contentDisposition content-disposition header value
93
+ * @param contentType content-type header value
94
+ */
95
+ static getFilePreviewInfo(
96
+ url: string,
97
+ column?: AssetPseudoColumn,
98
+ storedFilename?: string,
99
+ contentDisposition?: string,
100
+ contentType?: string,
101
+ ): {
102
+ previewType: FilePreviewTypes | null;
103
+ prefetchBytes: number | null;
104
+ prefetchMaxFileSize: number | null;
105
+ } {
106
+ const disabledValue = { previewType: null, prefetchBytes: null, prefetchMaxFileSize: null };
107
+ const previewType = FilePreviewService.getFilePreviewType(url, column, storedFilename, contentDisposition, contentType);
108
+ let prefetchBytes: number | null = null;
109
+ let prefetchMaxFileSize: number | null = null;
110
+
111
+ if (previewType === null) {
112
+ return disabledValue;
113
+ }
114
+
115
+ if (column && column.filePreview) {
116
+ if (column.filePreview.disabledTypes.includes(previewType)) {
117
+ return disabledValue;
118
+ }
119
+ prefetchBytes = column.filePreview.getPrefetchBytes(previewType);
120
+ prefetchMaxFileSize = column.filePreview.getPrefetchMaxFileSize(previewType);
121
+ }
122
+
123
+ if (typeof prefetchBytes !== 'number' || prefetchBytes < 0) {
124
+ prefetchBytes = FILE_PREVIEW.PREFETCH_BYTES;
125
+ }
126
+ if (typeof prefetchMaxFileSize !== 'number' || prefetchMaxFileSize < 0) {
127
+ prefetchMaxFileSize = FILE_PREVIEW.MAX_FILE_SIZE;
128
+ }
129
+
130
+ // if prefetchMaxFileSize is 0, we should not show the preview
131
+ if (prefetchMaxFileSize === 0) {
132
+ return disabledValue;
133
+ }
134
+
135
+ return { previewType, prefetchBytes, prefetchMaxFileSize };
136
+ }
137
+
138
+ /**
139
+ * Returns the preview type based on the given file properties and the column's file preview settings.
140
+ * @param url the file url
141
+ * @param column the asset column
142
+ * @param storedFilename the stored filename
143
+ * @param contentDisposition content-disposition header value
144
+ * @param contentType content-type header value
145
+ */
146
+ private static getFilePreviewType(
147
+ url: string,
148
+ column?: AssetPseudoColumn,
149
+ storedFilename?: string,
150
+ contentDisposition?: string,
151
+ contentType?: string,
152
+ ): FilePreviewTypes | null {
153
+ const filename = storedFilename || getFilename(url, contentDisposition);
154
+ const extension = getFilenameExtension(filename, column?.filenameExtFilter, column?.filenameExtRegexp);
155
+ let mappedFilePreviewType: FilePreviewTypes | typeof USE_EXT_MAPPING | false = USE_EXT_MAPPING;
156
+
157
+ // extend the mappings based on the annotations
158
+ let annotExtensionMapping;
159
+ let annotContentTypeMapping;
160
+ if (column && column.isAsset) {
161
+ // if file_preview is false, then no preview is allowed
162
+ if (!column.filePreview) return null;
163
+ if (column.filePreview.contentTypeMapping) {
164
+ annotContentTypeMapping = column.filePreview.contentTypeMapping;
165
+ }
166
+ if (column.filePreview.filenameExtMapping) {
167
+ annotExtensionMapping = column.filePreview.filenameExtMapping;
168
+ }
169
+ }
170
+
171
+ // if content-type is available, we must get the type from it.
172
+ if (typeof contentType === 'string' && contentType.length > 0) {
173
+ // remove any extra info like charset
174
+ contentType = contentType.split(';')[0].trim().toLowerCase();
175
+ let matched = false;
176
+
177
+ // first match through annotation mapping
178
+ if (annotContentTypeMapping) {
179
+ // if the exact match is found, use it
180
+ if (annotContentTypeMapping.exactMatch && contentType in annotContentTypeMapping.exactMatch) {
181
+ mappedFilePreviewType = annotContentTypeMapping.exactMatch[contentType];
182
+ matched = true;
183
+ }
184
+ // if exact match not found, try prefix matching
185
+ else if (annotContentTypeMapping.prefixMatch) {
186
+ const match = Object.keys(annotContentTypeMapping.prefixMatch).find((prefix) => contentType!.startsWith(prefix));
187
+ if (match) {
188
+ mappedFilePreviewType = annotContentTypeMapping.prefixMatch[match];
189
+ matched = true;
190
+ }
191
+ }
192
+
193
+ // if still not matched, check for default mapping (`*` in annotation)
194
+ if (!matched && annotContentTypeMapping.default !== null) {
195
+ mappedFilePreviewType = annotContentTypeMapping.default;
196
+ matched = true;
197
+ }
198
+ }
199
+
200
+ // if no match found through annotation, try the default mapping
201
+ if (!matched && contentType in DEFAULT_CONTENT_TYPE_MAPPING) {
202
+ mappedFilePreviewType = DEFAULT_CONTENT_TYPE_MAPPING[contentType];
203
+ matched = true;
204
+ }
205
+
206
+ // if no match found, disable the preview
207
+ if (!matched) {
208
+ mappedFilePreviewType = false;
209
+ }
210
+
211
+ $log.debug(`FilePreviewService: Mapped content-type '${contentType}' to preview type '${mappedFilePreviewType}'`);
212
+ }
213
+
214
+ // use extenstion mapping, if the content-type matching dictates so
215
+ if (mappedFilePreviewType === USE_EXT_MAPPING && typeof extension === 'string' && extension.length > 0) {
216
+ if (annotExtensionMapping && extension in annotExtensionMapping) {
217
+ mappedFilePreviewType = annotExtensionMapping[extension];
218
+ } else if (extension in DEFAULT_EXTENSION_MAPPING) {
219
+ mappedFilePreviewType = DEFAULT_EXTENSION_MAPPING[extension];
220
+ } else {
221
+ mappedFilePreviewType = false;
222
+ }
223
+
224
+ $log.debug(`FilePreviewService: Mapped extension '${extension}' to preview type '${mappedFilePreviewType}'`);
225
+ }
226
+
227
+ return isFilePreviewType(mappedFilePreviewType) ? mappedFilePreviewType : null;
228
+ }
229
+ }
@@ -114,11 +114,14 @@ export default class HandlebarsService {
114
114
  _addErmrestVarsToTemplate(keyValues, catalog);
115
115
 
116
116
  // If no conditional handlebars statements of the form {{#if VARNAME}}{{/if}} or {{^if VARNAME}}{{/if}} or {{#unless VARNAME}}{{/unless}} or {{^unless VARNAME}}{{/unless}} not found then do direct null check
117
+ // codeql[js/polynomial-redos]
117
118
  if (!conditionalRegex.exec(template)) {
118
119
  // Grab all placeholders ({{PROP_NAME}}) in the template
120
+ // codeql[js/polynomial-redos]: template is not user input
119
121
  const placeholders = template.match(/\{\{([^\{\}\(\)\s]+)\}\}/gi);
120
122
 
121
123
  // These will match the placeholders that are encapsulated in square brackets {{[string with space]}} or {{{[string with space]}}}
124
+ // codeql[js/polynomial-redos]: template is not user input
122
125
  const specialPlaceholders = template.match(/\{\{((\[[^\{\}]+\])|(\{\[[^\{\}]+\]\}))\}\}/gi);
123
126
 
124
127
  // If there are any placeholders
@@ -1,5 +1,4 @@
1
1
  /* eslint-disable @typescript-eslint/no-duplicate-enum-values */
2
- /* eslint-disable @typescript-eslint/no-explicit-any */
3
2
 
4
3
  import { ENV_IS_DEV_MODE } from '@isrd-isi-edu/ermrestjs/src/utils/constants';
5
4
 
@@ -32,32 +31,32 @@ class Logger {
32
31
  this._level = level;
33
32
  }
34
33
 
35
- public trace(...args: any[]): void {
34
+ public trace(...args: unknown[]): void {
36
35
  if (!this.isAllowed(LoggerLevels.TRACE)) return;
37
36
  console.trace(...args);
38
37
  }
39
38
 
40
- public debug(...args: any[]): void {
39
+ public debug(...args: unknown[]): void {
41
40
  if (!this.isAllowed(LoggerLevels.DEBUG)) return;
42
41
  console.debug(...args);
43
42
  }
44
43
 
45
- public info(...args: any[]): void {
44
+ public info(...args: unknown[]): void {
46
45
  if (!this.isAllowed(LoggerLevels.INFO)) return;
47
46
  console.info(...args);
48
47
  }
49
48
 
50
- public log(...args: any[]): void {
49
+ public log(...args: unknown[]): void {
51
50
  if (!this.isAllowed(LoggerLevels.LOG)) return;
52
51
  console.log(...args);
53
52
  }
54
53
 
55
- public warn(...args: any[]): void {
54
+ public warn(...args: unknown[]): void {
56
55
  if (!this.isAllowed(LoggerLevels.WARN)) return;
57
56
  console.warn(...args);
58
57
  }
59
58
 
60
- public error(...args: any[]): void {
59
+ public error(...args: unknown[]): void {
61
60
  if (!this.isAllowed(LoggerLevels.ERROR)) return;
62
61
  console.error(...args);
63
62
  }
@@ -27,6 +27,11 @@ export const URL_PATH_LENGTH_LIMIT = 4000;
27
27
  */
28
28
  export const CONTEXT_HEADER_LENGTH_LIMIT = 6500;
29
29
 
30
+ export const FILE_PREVIEW = {
31
+ PREFETCH_BYTES: 0.5 * 1024 * 1024,
32
+ MAX_FILE_SIZE: 1 * 1024 * 1024,
33
+ };
34
+
30
35
  export enum _constraintTypes {
31
36
  KEY = 'k',
32
37
  FOREIGN_KEY = 'fk',
@@ -0,0 +1,198 @@
1
+ /**
2
+ * given a url and optional content-disposition header, will return the filename.
3
+ *
4
+ * NOTE: might return an empty string if no filename is found.
5
+ */
6
+ export const getFilename = (url: string, contentDisposition?: string): string => {
7
+ if (contentDisposition) {
8
+ // try UTF-8 encoded filename first
9
+ const prefixUTF8 = "filename*=UTF-8''";
10
+ let filenameIndex = contentDisposition.indexOf(prefixUTF8);
11
+ if (filenameIndex !== -1) {
12
+ const filename = contentDisposition.substring(filenameIndex + prefixUTF8.length);
13
+ if (filename) return filename.replace(/"/g, '');
14
+ }
15
+
16
+ // try standard filename=
17
+ const prefixStandard = 'filename=';
18
+ filenameIndex = contentDisposition.indexOf(prefixStandard);
19
+ if (filenameIndex !== -1) {
20
+ let filename = contentDisposition.substring(filenameIndex + prefixStandard.length);
21
+ // remove quotes and any trailing content after semicolon
22
+ filename = filename.split(';')[0].replace(/"/g, '').trim();
23
+ if (filename) return filename;
24
+ }
25
+ }
26
+
27
+ // hatrac files have a different format
28
+ // eslint-disable-next-line no-useless-escape
29
+ const parts = url.match(/^\/hatrac\/([^\/]+\/)*([^\/:]+)(:[^:]+)?$/);
30
+ if (parts && parts.length === 4) {
31
+ return parts[2];
32
+ }
33
+
34
+ // strip query parameters and fragments from URL
35
+ const cleanUrl = url.split('?')[0].split('#')[0];
36
+ return cleanUrl.split('/').pop() || '';
37
+ };
38
+
39
+ /**
40
+ * given a filename, will return the extension
41
+ * By default, it will extract the last of the filename after the last `.` (including the dot).
42
+ * The second parameter can be used for passing a regular expression
43
+ * if we want a different method of extracting the extension.
44
+ * @param {string} filename
45
+ * @param {string[]} allowedExtensions
46
+ * @param {string[]} regexArr
47
+ * @returns the filename extension string. if we cannot find any matches, it will return null
48
+ * @private
49
+ * @ignore
50
+ */
51
+ export const getFilenameExtension = function (filename: string, allowedExtensions?: string[], regexArr?: string[]): string | null {
52
+ if (typeof filename !== 'string' || filename.length === 0) {
53
+ return null;
54
+ }
55
+
56
+ // first find in the list of allowed extensions (case-insensitive)
57
+ let res: string | null = null;
58
+ const filenameLower = filename.toLowerCase();
59
+ const isInAllowed =
60
+ Array.isArray(allowedExtensions) &&
61
+ allowedExtensions.some((ext) => {
62
+ res = ext;
63
+ return typeof ext === 'string' && ext.length > 0 && filenameLower.endsWith(ext.toLowerCase());
64
+ });
65
+ if (isInAllowed) {
66
+ return res;
67
+ }
68
+
69
+ // we will return null if we cannot find anything
70
+ res = null;
71
+ // no matching allowed extension, try the regular expressions
72
+ if (Array.isArray(regexArr) && regexArr.length > 0) {
73
+ regexArr.some((regexp) => {
74
+ // since regular expression comes from annotation, it might not be valid
75
+ try {
76
+ const matches = filename.match(new RegExp(regexp, 'g'));
77
+ if (matches && matches[0] && typeof matches[0] === 'string') {
78
+ res = matches[0];
79
+ } else {
80
+ res = null;
81
+ }
82
+ return res;
83
+ } catch {
84
+ res = null;
85
+ return false;
86
+ }
87
+ });
88
+ } else {
89
+ const dotIndex = filename.lastIndexOf('.');
90
+ // it's only a valid filename if there's some string after `.`
91
+ if (dotIndex !== -1 && dotIndex !== filename.length - 1) {
92
+ res = filename.slice(dotIndex).toLowerCase();
93
+ }
94
+ }
95
+
96
+ return res;
97
+ };
98
+
99
+ /**
100
+ * Check if file is a text-like file based on content type or extension
101
+ */
102
+ export const checkIsTextFile = (contentType?: string, extension?: string | null): boolean => {
103
+ // text-like files using content-type
104
+ if (
105
+ contentType &&
106
+ (contentType.startsWith('text/') ||
107
+ // cif files
108
+ contentType === 'chemical/x-mmcif' ||
109
+ contentType === 'chemical/x-cif')
110
+ ) {
111
+ return true;
112
+ }
113
+
114
+ // text-like files using extension
115
+ if (extension && ['.txt', '.js', '.log', '.cif', '.pdb'].includes(extension)) {
116
+ return true;
117
+ }
118
+
119
+ return false;
120
+ };
121
+
122
+ /**
123
+ * Check if file is markdown based on content type or extension
124
+ */
125
+ export const checkIsMarkdownFile = (contentType?: string, extension?: string | null): boolean => {
126
+ if (contentType && (contentType.includes('markdown') || contentType.includes('md'))) {
127
+ return true;
128
+ }
129
+
130
+ if (extension && ['.md', '.markdown'].includes(extension)) {
131
+ return true;
132
+ }
133
+
134
+ return false;
135
+ };
136
+
137
+ /**
138
+ * Check if file is CSV based on content type or extension
139
+ */
140
+ export const checkIsCsvFile = (contentType?: string, extension?: string | null): boolean => {
141
+ if (contentType && (contentType.includes('csv') || contentType.includes('comma-separated-values'))) {
142
+ return true;
143
+ }
144
+
145
+ if (extension && extension === '.csv') {
146
+ return true;
147
+ }
148
+
149
+ return false;
150
+ };
151
+
152
+ /**
153
+ * Check if file is TSV based on content type or extension
154
+ */
155
+ export const checkIsTsvFile = (contentType?: string, extension?: string | null): boolean => {
156
+ if (contentType && contentType.includes('tab-separated-values')) {
157
+ return true;
158
+ }
159
+
160
+ if (extension && extension === '.tsv') {
161
+ return true;
162
+ }
163
+
164
+ return false;
165
+ };
166
+
167
+ /**
168
+ * Check if file is JSON based on content type or extension
169
+ */
170
+ export const checkIsJSONFile = (contentType?: string, extension?: string | null): boolean => {
171
+ if (contentType && contentType.includes('application/json')) {
172
+ return true;
173
+ }
174
+
175
+ // mvsj: MolViewSpec JSON (mol* viewer)
176
+ if (extension && ['.json', '.mvsj'].includes(extension)) {
177
+ return true;
178
+ }
179
+
180
+ return false;
181
+ };
182
+
183
+ /**
184
+ * Check if file is an image based on content type or extension
185
+ * Checks for common image formats supported by HTML img tag
186
+ */
187
+ export const checkIsImageFile = (contentType?: string, extension?: string | null): boolean => {
188
+ // TODO this should be more specific based on supported image formats
189
+ if (contentType && contentType.startsWith('image/')) {
190
+ return true;
191
+ }
192
+
193
+ if (extension && ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.ico', '.avif', '.apng'].includes(extension)) {
194
+ return true;
195
+ }
196
+
197
+ return false;
198
+ };
@@ -121,8 +121,7 @@ function _bindCustomMarkdownTags(md: typeof MarkdownIt) {
121
121
  openingLink!.attrs!.forEach(function (attr) {
122
122
  switch (attr[0]) {
123
123
  case 'href':
124
- // eslint-disable-next-line no-useless-escape
125
- isYTlink = attr[1].match('^(http(s)?:\/\/)?((w){3}.)?youtu(be|.be)?(\.com)?\/.+') != null;
124
+ isYTlink = attr[1].match('^(http(s)?://)?((w){3}.)?youtu(be|.be)?(.com)?/.+') != null;
126
125
  iframeSrc = attr[1];
127
126
  iframeHTML += ' src="' + attr[1] + '"';
128
127
  videoText = 'Note: YouTube video ( ' + attr[1] + ' ) is hidden in print';
@@ -1,3 +1,5 @@
1
+ import { isArray, mergeWith } from 'lodash-es';
2
+
1
3
  import { ENV_IS_NODE } from '@isrd-isi-edu/ermrestjs/src/utils/constants';
2
4
 
3
5
  /**
@@ -70,7 +72,17 @@ export function stripTrailingSlash(str: string): string {
70
72
  * trim the slashes that might exist at the begining or end of the string
71
73
  */
72
74
  export function trimSlashes(str: string) {
73
- return str.replace(/^\/+|\/+$/g, '');
75
+ let start = 0;
76
+ let end = str.length - 1;
77
+
78
+ while (start <= end && str[start] === '/') {
79
+ start++;
80
+ }
81
+ while (end >= start && str[end] === '/') {
82
+ end--;
83
+ }
84
+
85
+ return start <= end ? str.slice(start, end + 1) : '';
74
86
  }
75
87
 
76
88
  /**
@@ -125,3 +137,18 @@ export function shallowCopyExtras(copyTo: any, copyFrom: any, enforcedList: stri
125
137
  export function simpleDeepCopy(source: object): any {
126
138
  return JSON.parse(JSON.stringify(source));
127
139
  }
140
+
141
+ /**
142
+ * update the target object with the updates provided in the updates object.
143
+ * NOTE: this will mutate the target object.
144
+ */
145
+ export function updateObject(target: any, updates: any) {
146
+ mergeWith(target, updates, (objValue: unknown, srcValue: unknown) => {
147
+ // by default lodash's mergeWith will concat arrays, we want to replace them instead.
148
+ if (isArray(objValue)) {
149
+ return srcValue;
150
+ }
151
+ });
152
+
153
+ return target;
154
+ }