@isrd-isi-edu/ermrestjs 2.2.0 → 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/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"
@@ -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
  */
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.2.0",
4
+ "version": "2.3.0",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
7
7
  "node": ">= 20.0.0",
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,
@@ -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
+ }
@@ -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
+ }
@@ -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
+ };