1363 lines
40 KiB
TypeScript
1363 lines
40 KiB
TypeScript
import {
|
|
ChangeDetectorRef,
|
|
Component,
|
|
ElementRef,
|
|
HostBinding,
|
|
OnInit,
|
|
AfterViewInit,
|
|
ViewChild,
|
|
ViewEncapsulation
|
|
} from '@angular/core'
|
|
import {
|
|
EventService,
|
|
HelperService,
|
|
LicenceService,
|
|
SasService,
|
|
SasStoreService
|
|
} from '../services'
|
|
import { globals } from '../_globals'
|
|
import { EditorsGetDataServiceResponse } from '../models/sas/editors-getdata.model'
|
|
import { DcValidator } from '../shared/dc-validator/dc-validator'
|
|
import {
|
|
prepareDeleteRecordColumn,
|
|
seedDeleteRecordColumn
|
|
} from '../shared/dc-validator/utils/deleteRecordColumn'
|
|
import { DELETE_RECORD_COLUMN_NAME } from '../shared/dc-validator/utils/deleteRecordColumnRule'
|
|
import { ExcelRule } from '../models/TableData'
|
|
import { HotTableInterface } from '../models/HotTable.interface'
|
|
import { Col } from '../shared/dc-validator/models/col.model'
|
|
import { SpreadsheetService } from '../services/spreadsheet.service'
|
|
import Handsontable, { CellChange, ChangeSource } from 'handsontable'
|
|
import { HotTableComponent } from '@handsontable/angular-wrapper'
|
|
import { EditorsStageDataSASResponse } from '../models/sas/editors-stagedata.model'
|
|
import { baseAfterGetColHeader } from '../shared/utils/hot.utils'
|
|
import { ColumnSettings } from 'handsontable/settings'
|
|
import { UploadFile } from '@sasjs/adapter'
|
|
import { UploadFileResponse } from '../models/UploadFile'
|
|
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
|
|
import { ParseResult } from '../models/ParseResult.interface'
|
|
import XLSX from 'xlsx'
|
|
import { observeGridAria } from '../shared/utils/grid-aria'
|
|
|
|
/** The sheet utility's rejection for a sheet that parsed but holds no rows. */
|
|
const isEmptySheet = (error: unknown): boolean =>
|
|
typeof error === 'string' && error.startsWith('No relevant data found')
|
|
|
|
enum FileLoadingState {
|
|
reading = 'Reading the file',
|
|
scanning = 'Searching for the data in the file',
|
|
ready = 'File ready to load',
|
|
fetching = 'Fetching the dataset details',
|
|
extracting = 'Extracting the data',
|
|
extracted = 'Extracting the data finished',
|
|
/** Some sheets could not be extracted, so the file is not fully loaded. */
|
|
extractionFailed = 'Extracting the data finished with a problem',
|
|
/**
|
|
* Default value
|
|
*/
|
|
notSelected = 'File not selected'
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-multi-dataset',
|
|
templateUrl: './multi-dataset.component.html',
|
|
styleUrls: ['./multi-dataset.component.scss'],
|
|
encapsulation: ViewEncapsulation.None,
|
|
standalone: false
|
|
})
|
|
export class MultiDatasetComponent implements OnInit, AfterViewInit {
|
|
@HostBinding('class.content-container') contentContainerClass = true
|
|
@ViewChild('contentArea', { static: true }) contentAreaRef!: ElementRef
|
|
|
|
public licenceState = this.licenceService.licenceState
|
|
public Infinity = Infinity
|
|
|
|
public workbookInterval: any
|
|
public fileLoadingState: FileLoadingState = FileLoadingState.notSelected
|
|
/** The sheets the last extraction could not read, so the stage can name them. */
|
|
public sheetsNotExtracted: string[] = []
|
|
/** The sheets the last extraction read but found no rows in. */
|
|
public sheetsWithoutRows: string[] = []
|
|
|
|
/**
|
|
* True while the file is being worked on. `ready`, `extracted` and
|
|
* `extractionFailed` are idle states, so the spinner and the stage line are
|
|
* only shown for the phases that actually have work running.
|
|
*/
|
|
public get isFileBusy(): boolean {
|
|
return (
|
|
this.fileLoadingState === FileLoadingState.reading ||
|
|
this.fileLoadingState === FileLoadingState.scanning ||
|
|
this.fileLoadingState === FileLoadingState.fetching ||
|
|
this.fileLoadingState === FileLoadingState.extracting
|
|
)
|
|
}
|
|
|
|
public hotTableLicenseKey: string | undefined = undefined
|
|
public hotTableMaxRows =
|
|
this.licenceState.value.viewer_rows_allowed || Infinity
|
|
|
|
public csvFiles: UploadFile[] = []
|
|
public csvSubmitting: boolean = false
|
|
|
|
public autoDetectingColumns: boolean = false
|
|
|
|
public selectedFile: SelectedFile | null = null
|
|
public parsedDatasets: ParsedDataset[] = []
|
|
public submittedCsvDatasets: SubmittedCsvDatasetResult[] = []
|
|
|
|
public datasetsLoading: boolean = false
|
|
public uploadLoading: boolean = false
|
|
public submitLoading: boolean = false
|
|
|
|
public matchedDatasets: string[] = []
|
|
public sheetNames: string[] = []
|
|
|
|
public userInputDatasets: string = ''
|
|
|
|
public libsAndTables: {
|
|
[key: string]: string[]
|
|
} = {}
|
|
|
|
public hotInstance!: Handsontable
|
|
public hotInstanceUserDataset!: Handsontable
|
|
@ViewChild('hotInstanceMain', { static: false })
|
|
hotTableMainComponent!: HotTableComponent
|
|
@ViewChild('hotInstanceUserDataset', { static: false })
|
|
hotTableUserDatasetComponent!: HotTableComponent
|
|
|
|
public hotMainTableSettings: Handsontable.GridSettings = {}
|
|
public hotUserDatasetsSettings: Handsontable.GridSettings = {}
|
|
|
|
public showSubmitReasonModal: boolean = false
|
|
public submitReasonMessage: string = ''
|
|
|
|
public hotUserDatasets: Handsontable.GridSettings = {
|
|
colHeaders: ['Library', 'Table'],
|
|
data: [
|
|
['', ''],
|
|
['', ''],
|
|
['', ''],
|
|
['', ''],
|
|
['', ''],
|
|
['', ''],
|
|
['', ''],
|
|
['', ''],
|
|
['', ''],
|
|
['', ''],
|
|
['', ''],
|
|
['', '']
|
|
],
|
|
columns: [
|
|
{
|
|
type: 'autocomplete',
|
|
filter: false,
|
|
source: []
|
|
},
|
|
{
|
|
type: 'autocomplete',
|
|
filter: false,
|
|
source: []
|
|
}
|
|
],
|
|
width: '100%',
|
|
height: '305px',
|
|
className: ['htDark'],
|
|
contextMenu: {
|
|
items: {
|
|
row_above: {
|
|
name: 'Insert Row above'
|
|
},
|
|
row_below: {
|
|
name: 'Insert Row below'
|
|
}
|
|
}
|
|
},
|
|
manualRowMove: true,
|
|
columnSorting: true,
|
|
afterGetColHeader: baseAfterGetColHeader,
|
|
stretchH: 'all'
|
|
}
|
|
|
|
private initializeHotSettings() {
|
|
this.hotMainTableSettings = {
|
|
className: ['htDark'],
|
|
licenseKey: this.hotTableLicenseKey,
|
|
multiColumnSorting: true,
|
|
viewportRowRenderingOffset: 50,
|
|
manualColumnResize: true,
|
|
autoColumnSize: true,
|
|
filters: true,
|
|
stretchH: 'all',
|
|
afterGetColHeader: baseAfterGetColHeader,
|
|
modifyColWidth: this.maxWidthCheker,
|
|
theme: 'ht-theme-classic'
|
|
}
|
|
|
|
// Exclude data from settings for HOT v16 - it will be loaded manually
|
|
const { data, ...settingsWithoutData } = this.hotUserDatasets
|
|
this.hotUserDatasetsSettings = {
|
|
...settingsWithoutData,
|
|
licenseKey: this.hotTableLicenseKey,
|
|
theme: 'ht-theme-classic'
|
|
}
|
|
}
|
|
|
|
public maxWidthCheker(width: any, col: any) {
|
|
if (width > 200) return 200
|
|
else return width
|
|
}
|
|
|
|
public afterGetColHeader = baseAfterGetColHeader
|
|
|
|
constructor(
|
|
private eventService: EventService,
|
|
private licenceService: LicenceService,
|
|
private helperService: HelperService,
|
|
private sasStoreService: SasStoreService,
|
|
private spreadsheetService: SpreadsheetService,
|
|
private sasService: SasService,
|
|
private cdr: ChangeDetectorRef
|
|
) {}
|
|
|
|
ngOnInit() {
|
|
this.licenceService.hot_license_key.subscribe(
|
|
(hot_license_key: string | undefined) => {
|
|
this.hotTableLicenseKey = hot_license_key
|
|
this.initializeHotSettings()
|
|
}
|
|
)
|
|
this.initializeHotSettings()
|
|
}
|
|
|
|
ngAfterViewInit() {
|
|
// Ensure HOT instances are properly initialized after view is ready
|
|
setTimeout(() => {
|
|
if (this.hotTableUserDatasetComponent && !this.hotInstanceUserDataset) {
|
|
this.initUserInputHot()
|
|
}
|
|
if (this.hotTableMainComponent && !this.hotInstance) {
|
|
this.initHot()
|
|
}
|
|
}, 50)
|
|
}
|
|
|
|
ngAfterContentInit(): void {
|
|
if (globals.editor.startupSet) {
|
|
this.getFromGlobals()
|
|
} else {
|
|
this.eventService.onStartupDataLoaded.subscribe(() => {
|
|
this.getFromGlobals()
|
|
})
|
|
}
|
|
}
|
|
|
|
public getFromGlobals() {
|
|
this.libsAndTables = globals.editor.libsAndTables
|
|
|
|
const libs: string[] = Object.keys(this.libsAndTables)
|
|
|
|
if (this.hotUserDatasets?.columns) {
|
|
;(this.hotUserDatasets.columns as ColumnSettings[])[0].source = libs
|
|
}
|
|
}
|
|
|
|
async onFileChange(event: any) {
|
|
const files = event?.target?.files || []
|
|
|
|
if (files.length < 1) {
|
|
this.eventService.showAbortModal(
|
|
null,
|
|
'No file found.',
|
|
null,
|
|
'File Upload'
|
|
)
|
|
return
|
|
}
|
|
|
|
let matchedExtension = ''
|
|
|
|
for (let file of files) {
|
|
const fileExtension = file.name.split('.').pop()
|
|
|
|
if (!matchedExtension) {
|
|
matchedExtension = fileExtension
|
|
}
|
|
|
|
if (matchedExtension !== fileExtension) {
|
|
this.eventService.showInfoModal(
|
|
'Mixed extensions error',
|
|
'Please select files with same extension.'
|
|
)
|
|
return
|
|
}
|
|
|
|
matchedExtension = fileExtension
|
|
}
|
|
|
|
if (['xlsx', 'xlsm', 'xlm'].includes(matchedExtension)) {
|
|
// For EXCEL if multiple files, we only take one (the first one)
|
|
this.selectedFile = event.target.files[0]
|
|
|
|
if (this.selectedFile) {
|
|
this.fileLoadingState = FileLoadingState.reading
|
|
this.sheetsNotExtracted = []
|
|
this.sheetsWithoutRows = []
|
|
|
|
this.selectedFile.sizeMB = this.spreadsheetService.bytesToMB(
|
|
this.selectedFile.size
|
|
)
|
|
|
|
// Read the excel file to be ready
|
|
this.spreadsheetService.xlsxReadFile(this.selectedFile!).then((wb) => {
|
|
this.selectedFile!.workbook = wb
|
|
|
|
// The sheet scan runs in parallel and can finish first, so only
|
|
// advance the stage if the read is still the phase in progress.
|
|
if (this.fileLoadingState === FileLoadingState.reading) {
|
|
this.fileLoadingState = FileLoadingState.scanning
|
|
}
|
|
})
|
|
}
|
|
|
|
this.initUserInputHot()
|
|
// Call onAutoDetectColumns after HOT is initialized
|
|
setTimeout(() => {
|
|
this.onAutoDetectColumns()
|
|
}, 100)
|
|
} else if (matchedExtension === 'csv') {
|
|
this.onMultiCsvFiles(event.target.files)
|
|
} else {
|
|
this.eventService.showAbortModal(
|
|
null,
|
|
'Only excel extensions are allowed. (xlsx)',
|
|
null,
|
|
'Extension Error'
|
|
)
|
|
return
|
|
}
|
|
|
|
event.target.value = '' // Reset the upload input
|
|
}
|
|
|
|
async onMultiCsvFiles(files: File[]) {
|
|
for (let file of files) {
|
|
const fileNameNoExtension = this.parseDatasetFromCsvName(file.name)
|
|
|
|
if (
|
|
this.isValidDatasetFormat(fileNameNoExtension) &&
|
|
this.isValidDatasetReference(fileNameNoExtension)
|
|
) {
|
|
this.csvFiles.push({
|
|
file: file,
|
|
fileName: file.name
|
|
})
|
|
}
|
|
}
|
|
|
|
if (!this.csvFiles.length) {
|
|
this.eventService.showInfoModal(
|
|
'CSV Upload',
|
|
'None of the attached CSV file names matched an actual dataset.'
|
|
)
|
|
return
|
|
}
|
|
|
|
this.csvSubmitting = true
|
|
|
|
await this.submitCsvFiles()
|
|
|
|
this.csvSubmitting = false
|
|
}
|
|
|
|
onDiscardFile() {
|
|
this.selectedFile = null
|
|
this.userInputDatasets = ''
|
|
this.fileLoadingState = FileLoadingState.notSelected
|
|
}
|
|
|
|
async onStartParsingFile() {
|
|
this.uploadLoading = true
|
|
this.fileLoadingState = FileLoadingState.fetching
|
|
|
|
const datasetFetchingPromises: Promise<
|
|
EditorsGetDataServiceResponse | undefined
|
|
>[] = []
|
|
|
|
let datasets: EditorsGetDataServiceResponse[] = []
|
|
|
|
for (let datasetLibds of this.matchedDatasets) {
|
|
const promise = this.fetchDataset(datasetLibds)
|
|
|
|
datasetFetchingPromises.push(promise)
|
|
}
|
|
|
|
Promise.allSettled(datasetFetchingPromises).then((res) => {
|
|
res.forEach((promise) => {
|
|
if (promise.status === 'fulfilled' && promise.value)
|
|
datasets.push(promise.value)
|
|
})
|
|
|
|
this.uploadLoading = false
|
|
|
|
// buildDatasetsObjects throws when a getdata response carries the
|
|
// reserved delete-record column (see deleteRecordColumn.ts). This chain
|
|
// has no .catch, so an uncaught throw here would become an unhandled
|
|
// rejection that aborts the loop with nothing shown. Surface it on
|
|
// screen, the way the editor flow refuses the same response, rather
|
|
// than leaving the user on an empty table selection with the file
|
|
// still attached.
|
|
let datasetObjects: DatasetsObject[] = []
|
|
try {
|
|
datasetObjects = this.buildDatasetsObjects(datasets)
|
|
} catch (err) {
|
|
console.warn('Error building dataset objects', err)
|
|
this.fileLoadingState = FileLoadingState.ready
|
|
this.eventService.showInfoModal('Upload error', (err as Error).message)
|
|
return
|
|
}
|
|
|
|
datasetObjects.forEach((datasetObject) => {
|
|
this.parsedDatasets.push({
|
|
libds: datasetObject.libds,
|
|
includeInSubmission: true,
|
|
datasetInfo: datasetObject,
|
|
parsingTable: true
|
|
})
|
|
})
|
|
|
|
this.workbookLoaded().then((workbook) => {
|
|
this.fileLoadingState = FileLoadingState.extracting
|
|
this.sheetsNotExtracted = []
|
|
this.sheetsWithoutRows = []
|
|
|
|
const parsingPromises: Promise<void>[] = []
|
|
|
|
for (let parsedDataset of this.parsedDatasets) {
|
|
parsingPromises.push(this.parseDatasetSheet(parsedDataset, workbook))
|
|
}
|
|
|
|
// The stage belongs to the whole file, not to the sheet that finished
|
|
// last, so it only moves on once every sheet has been extracted - and a
|
|
// sheet that could not be extracted is not a clean finish.
|
|
Promise.allSettled(parsingPromises).then(() => this.finishExtraction())
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* The stage the file finishes at: a clean finish only when every sheet was
|
|
* extracted, and a named failure when one was left out of the submission.
|
|
*/
|
|
public finishExtraction(): void {
|
|
const leftOut =
|
|
this.sheetsNotExtracted.length > 0 || this.sheetsWithoutRows.length > 0
|
|
|
|
this.fileLoadingState = leftOut
|
|
? FileLoadingState.extractionFailed
|
|
: FileLoadingState.extracted
|
|
}
|
|
|
|
/**
|
|
* Takes a sheet out of the submission and names it, under the reason it left:
|
|
* a sheet that could not be read is not the same as one that was read and
|
|
* holds no rows, and the two read differently to the user.
|
|
*/
|
|
public leaveSheetOut(
|
|
parsedDataset: ParsedDataset,
|
|
reason: 'failed' | 'empty'
|
|
): void {
|
|
parsedDataset.datasource = []
|
|
parsedDataset.includeInSubmission = false
|
|
parsedDataset.parsingTable = false
|
|
|
|
const sheets =
|
|
reason === 'empty' ? this.sheetsWithoutRows : this.sheetsNotExtracted
|
|
|
|
sheets.push(parsedDataset.libds)
|
|
}
|
|
|
|
/**
|
|
* Extracts one sheet into its dataset. A sheet that cannot be extracted is
|
|
* left out of the submission and named, so the stage the file finishes at can
|
|
* say that it did not fully extract rather than reporting a clean finish over
|
|
* a sheet that was silently dropped.
|
|
*/
|
|
public async parseDatasetSheet(
|
|
parsedDataset: ParsedDataset,
|
|
workbook: XLSX.WorkBook
|
|
): Promise<void> {
|
|
let parseResult: ParseResult | undefined
|
|
|
|
try {
|
|
parseResult = await this.spreadsheetService.parseExcelFile({
|
|
file: this.selectedFile!,
|
|
workbook: workbook,
|
|
password: this.selectedFile!.password || undefined,
|
|
dcValidator: parsedDataset.datasetInfo.dcValidator!,
|
|
headerPks: parsedDataset.datasetInfo.headerPks,
|
|
headerArray: parsedDataset.datasetInfo.headerArray,
|
|
headerShow: [],
|
|
timeHeaders: parsedDataset.datasetInfo.timeHeaders,
|
|
dateHeaders: parsedDataset.datasetInfo.dateHeaders,
|
|
dateTimeHeaders: parsedDataset.datasetInfo.dateTimeHeaders,
|
|
xlRules: parsedDataset.datasetInfo.xlRules
|
|
})
|
|
|
|
// A resolve carrying no rows did extract - it just holds nothing - so it
|
|
// is named as empty rather than as unreadable, and the stage still does
|
|
// not call the file cleanly loaded.
|
|
if (!parseResult || !parseResult.data || parseResult.data.length === 0) {
|
|
this.leaveSheetOut(parsedDataset, 'empty')
|
|
|
|
return
|
|
}
|
|
|
|
// The derivation is inside the try on purpose: a row shape the sheet
|
|
// utility did not expect throws here, and outside it the throw would
|
|
// escape as a rejection that allSettled swallows - leaving the sheet in
|
|
// the submission with a spinner and a clean finish reported.
|
|
parsedDataset.datasource = this.buildDatasource(parseResult)
|
|
parsedDataset.parseResult = parseResult
|
|
parsedDataset.parsingTable = false
|
|
} catch (error) {
|
|
console.warn('Parsing excel file error.', error)
|
|
|
|
// The sheet utility reports a sheet that parsed to nothing by rejecting
|
|
// with "No relevant data found", which is a different thing to a sheet it
|
|
// could not read - the reader is sent looking for a corrupt file by the
|
|
// second, and to the sheet itself by the first.
|
|
this.leaveSheetOut(
|
|
parsedDataset,
|
|
isEmptySheet(error) ? 'empty' : 'failed'
|
|
)
|
|
}
|
|
}
|
|
|
|
/** The rows the grid renders, built from a parsed sheet. */
|
|
private buildDatasource(parseResult: ParseResult): any[] {
|
|
const datasource: any[] = []
|
|
const rows = parseResult.data!
|
|
|
|
rows.map((item) => {
|
|
let itemObject: any = {}
|
|
|
|
parseResult.headerShow!.map((header: any, index: number) => {
|
|
itemObject[header] = item[index]
|
|
})
|
|
|
|
// If Delete? column is not set in the file, we set it to NO
|
|
if (!itemObject['_____DELETE__THIS__RECORD_____'])
|
|
itemObject['_____DELETE__THIS__RECORD_____'] = 'No'
|
|
|
|
datasource.push(itemObject)
|
|
})
|
|
|
|
return datasource
|
|
}
|
|
|
|
onSubmitAll() {
|
|
if (this.tablesToSubmit.length) {
|
|
this.showSubmitReasonModal = true
|
|
} else {
|
|
this.eventService.showInfoModal(
|
|
'No tables to submit',
|
|
'Please include at least one table to proceed.'
|
|
)
|
|
}
|
|
}
|
|
|
|
onDiscard() {
|
|
this.parsedDatasets = []
|
|
this.matchedDatasets = []
|
|
this.selectedFile = null
|
|
this.userInputDatasets = ''
|
|
this.submitReasonMessage = ''
|
|
this.fileLoadingState = FileLoadingState.notSelected
|
|
}
|
|
|
|
initHot() {
|
|
setTimeout(() => {
|
|
if (this.hotTableMainComponent?.hotInstance) {
|
|
this.hotInstance = this.hotTableMainComponent.hotInstance
|
|
observeGridAria(this.hotInstance)
|
|
|
|
// Set height of parsed data to full height of the page content area
|
|
const contentAreaHeight = this.contentAreaRef.nativeElement.clientHeight
|
|
const hotHeight = `${contentAreaHeight - 160}px`
|
|
|
|
if (this.activeParsedDataset) {
|
|
// Update settings without data - data will be loaded manually
|
|
this.hotInstance.updateSettings({
|
|
colHeaders: this.activeParsedDataset.datasetInfo.headerColumns,
|
|
columns:
|
|
this.activeParsedDataset.datasetInfo.dcValidator?.getRules(),
|
|
readOnly: true,
|
|
height: hotHeight || '300px',
|
|
className: ['htDark']
|
|
})
|
|
|
|
// Trigger change detection to avoid ExpressionChangedAfterItHasBeenCheckedError
|
|
this.cdr.detectChanges()
|
|
|
|
// Load data manually - this is required for HOT v16 Angular wrapper
|
|
setTimeout(() => {
|
|
if (
|
|
this.activeParsedDataset &&
|
|
this.activeParsedDataset.datasource
|
|
) {
|
|
this.hotInstance.loadData(this.activeParsedDataset.datasource)
|
|
this.hotInstance.render()
|
|
}
|
|
}, 100)
|
|
}
|
|
}
|
|
}, 100)
|
|
}
|
|
|
|
initUserInputHot() {
|
|
setTimeout(() => {
|
|
if (this.hotTableUserDatasetComponent?.hotInstance) {
|
|
this.hotInstanceUserDataset =
|
|
this.hotTableUserDatasetComponent.hotInstance
|
|
observeGridAria(this.hotInstanceUserDataset)
|
|
|
|
// Load initial data manually after instance is ready
|
|
setTimeout(() => {
|
|
if (this.hotUserDatasets.data) {
|
|
this.hotInstanceUserDataset.loadData(this.hotUserDatasets.data)
|
|
this.hotInstanceUserDataset.render()
|
|
}
|
|
}, 50)
|
|
|
|
this.hotInstanceUserDataset.addHook(
|
|
'beforeChange',
|
|
(changes: (CellChange | null)[], source: ChangeSource) => {
|
|
if (changes) {
|
|
for (let change of changes) {
|
|
if (change && change[3]) {
|
|
// CellValue is `unknown` in HOT 18 (was a concrete union before);
|
|
// this column is always a dataset name string.
|
|
change[3] = (change[3] as string).toUpperCase()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
this.hotInstanceUserDataset.addHook(
|
|
'afterChange',
|
|
async (changes: CellChange[] | null, source: ChangeSource) => {
|
|
if (changes) {
|
|
if (source === 'edit') {
|
|
await this.onUserInputDatasetsChange()
|
|
}
|
|
|
|
for (let change of changes) {
|
|
const row = change[0] as number
|
|
|
|
this.markUnmatchedRows(row)
|
|
}
|
|
|
|
this.dynamicCellValidations()
|
|
|
|
this.hotInstanceUserDataset.render()
|
|
}
|
|
}
|
|
)
|
|
|
|
this.hotInstanceUserDataset.addHook(
|
|
'afterRemoveRow',
|
|
async (
|
|
index: number,
|
|
amount: number,
|
|
physicalRows: number[],
|
|
source?: Handsontable.ChangeSource | undefined
|
|
) => {
|
|
await this.onUserInputDatasetsChange()
|
|
|
|
for (let row of physicalRows) {
|
|
this.markUnmatchedRows(row)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}, 100)
|
|
}
|
|
|
|
dynamicCellValidations() {
|
|
if (!this.hotInstanceUserDataset) return
|
|
|
|
// getData() returns unknown[][] in HOT 18 (was CellValue[][]); this grid's
|
|
// data is always library/table name strings.
|
|
const hotData = this.hotInstanceUserDataset.getData() as string[][]
|
|
|
|
hotData.forEach((row, rowIndex) => {
|
|
const library = row[0]
|
|
|
|
if (library && library.length) {
|
|
const tables = this.libsAndTables[library]
|
|
this.hotInstanceUserDataset.setCellMeta(rowIndex, 1, 'source', tables)
|
|
}
|
|
})
|
|
}
|
|
|
|
markUnmatchedRows(row: number) {
|
|
if (!this.hotInstanceUserDataset) return
|
|
|
|
const dataAtRow = this.hotInstanceUserDataset.getDataAtRow(row) as number[]
|
|
const dataset = `${dataAtRow[0]}.${dataAtRow[1]}`
|
|
const cellMetaAtRow = this.hotInstanceUserDataset.getCellMetaAtRow(row)
|
|
|
|
if (dataAtRow && dataAtRow[0] && dataAtRow[1]) {
|
|
if (!this.matchedDatasets.includes(dataset)) {
|
|
// getCellMetaAtRow() returns Record<string, unknown>[] in HOT 18, so
|
|
// `.col` needs casting back to number for setCellMeta() (same below).
|
|
cellMetaAtRow.forEach((cellMeta) => {
|
|
this.hotInstanceUserDataset.setCellMeta(
|
|
row,
|
|
cellMeta.col as number,
|
|
'className',
|
|
'not-matched'
|
|
)
|
|
})
|
|
} else {
|
|
cellMetaAtRow.forEach((cellMeta) => {
|
|
this.hotInstanceUserDataset.setCellMeta(
|
|
row,
|
|
cellMeta.col as number,
|
|
'className',
|
|
''
|
|
)
|
|
})
|
|
}
|
|
} else {
|
|
cellMetaAtRow.forEach((cellMeta) => {
|
|
this.hotInstanceUserDataset.setCellMeta(
|
|
row,
|
|
cellMeta.col as number,
|
|
'className',
|
|
''
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
onUserInputDatasetsChange() {
|
|
return new Promise((resolve, reject) => {
|
|
this.helperService.debounceCall(100, () => {
|
|
// Parse datasets
|
|
const inputDatasets = this.getDatasetsFromHot()
|
|
|
|
this.matchedDatasets = []
|
|
|
|
inputDatasets.forEach((dataset: string) => {
|
|
const trimmedDataset = dataset.trim()
|
|
|
|
if (
|
|
this.isValidDatasetFormat(trimmedDataset) &&
|
|
this.isValidDatasetReference(trimmedDataset) &&
|
|
!this.matchedDatasets.includes(trimmedDataset)
|
|
) {
|
|
this.matchedDatasets.push(trimmedDataset)
|
|
} else {
|
|
console.warn(
|
|
`Sheet name: ${trimmedDataset} is not an actual dataset reference.`
|
|
)
|
|
}
|
|
})
|
|
|
|
this.cdr.detectChanges()
|
|
|
|
resolve(undefined)
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Try to find the datasets in the provided file by looking in the
|
|
* sheet names and testing if they are in the scope of the dataset naming
|
|
* convention. {@link isValidDatasetFormat}
|
|
*/
|
|
async onAutoDetectColumns() {
|
|
// Wait for hotInstanceUserDataset to be ready
|
|
if (!this.hotInstanceUserDataset) {
|
|
let attempts = 0
|
|
const maxAttempts = 20
|
|
while (!this.hotInstanceUserDataset && attempts < maxAttempts) {
|
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
attempts++
|
|
}
|
|
if (!this.hotInstanceUserDataset) {
|
|
console.warn('hotInstanceUserDataset not ready after waiting')
|
|
this.fileLoadingState = FileLoadingState.ready
|
|
return
|
|
}
|
|
}
|
|
|
|
let passwordError = false
|
|
|
|
await this.parseExcelSheetNames()
|
|
.then((sheetNames) => {
|
|
this.sheetNames = sheetNames
|
|
})
|
|
.catch((err) => {
|
|
if (err.includes('password')) {
|
|
passwordError = true
|
|
}
|
|
})
|
|
|
|
if (passwordError) {
|
|
this.onDiscardFile()
|
|
this.eventService.showInfoModal(
|
|
'Locked file',
|
|
'We failed to unlock the file.'
|
|
)
|
|
return
|
|
}
|
|
|
|
if (this.sheetNames) {
|
|
this.matchedDatasets = []
|
|
this.userInputDatasets = ''
|
|
|
|
this.sheetNames.forEach((sheetName: string, index: number) => {
|
|
const trimmedSheetname = sheetName.trim()
|
|
|
|
if (
|
|
this.isValidDatasetFormat(trimmedSheetname) &&
|
|
this.isValidDatasetReference(trimmedSheetname)
|
|
) {
|
|
this.matchedDatasets.push(trimmedSheetname)
|
|
} else {
|
|
console.warn(
|
|
`Sheet name: ${trimmedSheetname} is not an actual dataset reference.`
|
|
)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Set matched datasets to textarea, dataset per row
|
|
this.userInputDatasets = this.matchedDatasets.join('\n')
|
|
|
|
const hotReadyData = this.matchedDatasets.map((matchedDs) => {
|
|
return [matchedDs.split('.')[0], matchedDs.split('.')[1]]
|
|
})
|
|
|
|
// Add empty rows to fill initial number of rows if data has less rows
|
|
// The reason is to fill the height of the table
|
|
const initialNumberOfRows = this.hotUserDatasets.data!.length
|
|
|
|
if (hotReadyData.length < initialNumberOfRows) {
|
|
const missingRows = initialNumberOfRows - hotReadyData.length
|
|
|
|
for (let i = 0; i < missingRows; i++) {
|
|
hotReadyData.push(['', ''])
|
|
}
|
|
}
|
|
|
|
if (this.hotInstanceUserDataset) {
|
|
// Load data manually - this is required for HOT v16 Angular wrapper
|
|
setTimeout(() => {
|
|
this.hotInstanceUserDataset.loadData(hotReadyData)
|
|
this.hotInstanceUserDataset.render()
|
|
}, 100)
|
|
}
|
|
this.dynamicCellValidations()
|
|
|
|
// The sheet scan is done; the file waits for the user to confirm the
|
|
// dataset list, so no stage is running any more.
|
|
this.fileLoadingState = FileLoadingState.ready
|
|
}
|
|
|
|
onParsedDatasetClick(parsedDataset: ParsedDataset) {
|
|
this.deselectAllParsedDatasets()
|
|
|
|
parsedDataset.active = true
|
|
|
|
this.cdr.detectChanges()
|
|
this.initHot()
|
|
}
|
|
|
|
onSubmittedCsvDatasetClick(submittedDataset: SubmittedCsvDatasetResult) {
|
|
this.deselectAllSubmittedCsvDatasets()
|
|
|
|
submittedDataset.active = true
|
|
}
|
|
|
|
public get activeParsedDataset(): ParsedDataset | undefined {
|
|
return this.parsedDatasets.find((dataset) => dataset.active)
|
|
}
|
|
|
|
public get activeSubmittedCsvDataset():
|
|
| SubmittedCsvDatasetResult
|
|
| undefined {
|
|
return this.submittedCsvDatasets.find((dataset) => dataset.active)
|
|
}
|
|
|
|
public get notFoundDatasets(): string[] {
|
|
const userDatasets = this.getDatasetsFromHot()
|
|
|
|
return userDatasets
|
|
.filter((userDs) => !this.matchedDatasets.includes(userDs.trim()))
|
|
.filter((userDs) => userDs.length)
|
|
}
|
|
|
|
public get isHotHidden(): boolean {
|
|
if (!this.hotInstance) return true
|
|
|
|
try {
|
|
const className = this.hotInstance.getSettings().className
|
|
|
|
return !!className && className.includes('htCustomHidden')
|
|
} catch (err) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
public get tablesToSubmit(): ParsedDataset[] {
|
|
return this.parsedDatasets.filter(
|
|
(dataset) =>
|
|
dataset.datasource && dataset.parseResult && dataset.includeInSubmission
|
|
)
|
|
}
|
|
|
|
public get submittingCsv(): boolean {
|
|
return this.csvFiles.length > 0
|
|
}
|
|
|
|
public get excelsSubmitted(): boolean {
|
|
return !!this.parsedDatasets.filter((ds) => ds.submitResult).length
|
|
}
|
|
|
|
public downloadFile(response: any) {
|
|
const filename = `stagedata-${this.activeSubmittedCsvDataset?.libds}-log`
|
|
this.helperService.downloadTextFile(filename, JSON.stringify(response))
|
|
}
|
|
|
|
/**
|
|
* Submits attached CSVs which are matched with existing datasets
|
|
*/
|
|
async submitCsvFiles() {
|
|
let requestsResults: SubmittedCsvDatasetResult[] = []
|
|
|
|
for (let file of this.csvFiles) {
|
|
const libds = this.parseDatasetFromCsvName(file.fileName)
|
|
|
|
let error
|
|
let success
|
|
|
|
await this.sasService
|
|
.uploadFile('services/editors/loadfile', [file], { table: libds })
|
|
.then(
|
|
(res: UploadFileResponse) => {
|
|
if (typeof res.adapterResponse.sasjsAbort !== 'undefined') {
|
|
error = res.adapterResponse.sasjsAbort
|
|
} else {
|
|
success = res.adapterResponse
|
|
}
|
|
},
|
|
(err: any) => {
|
|
console.error('err', err)
|
|
|
|
error = err.adapterResponse
|
|
}
|
|
)
|
|
|
|
requestsResults.push({
|
|
success,
|
|
error,
|
|
libds: libds
|
|
})
|
|
}
|
|
|
|
this.submittedCsvDatasets = requestsResults
|
|
}
|
|
|
|
/**
|
|
* Fetches the table for given datasets params LIBRARY.TABLE
|
|
*/
|
|
async fetchDataset(
|
|
libds: string
|
|
): Promise<EditorsGetDataServiceResponse | undefined> {
|
|
let myParams: any = {
|
|
LIBDS: libds,
|
|
OUTDEST: 'WEB'
|
|
}
|
|
|
|
if (libds) {
|
|
// this.getdataError = false
|
|
return this.sasStoreService
|
|
.callService(myParams, 'SASControlTable', 'editors/getdata', libds)
|
|
.then((res: EditorsGetDataServiceResponse) => {
|
|
// getdata serves a selection over DC_MAXOBS_WEBEDIT / DC_MAXCELLS_WEBEDIT
|
|
// truncated, rather than aborting the way it used to. This flow compares
|
|
// the whole table against the upload, so a partial read would compute
|
|
// the changes against the wrong starting state - refuse the table
|
|
// instead of submitting differences nobody asked for.
|
|
const sasParams = res?.data?.sasparams?.[0]
|
|
|
|
if (sasParams && Number(sasParams.TRUNCATED) === 1) {
|
|
this.eventService.showInfoModal(
|
|
'Upload error',
|
|
`${libds} holds more than the EDIT limit allows (${sasParams.TRUNC_MSG}), so it cannot be compared with the upload. Raise DC_MAXOBS_WEBEDIT / DC_MAXCELLS_WEBEDIT, or submit the table without the multi-table upload.`
|
|
)
|
|
return undefined
|
|
}
|
|
|
|
return res
|
|
})
|
|
.catch((err: any) => {
|
|
console.warn(`Error fetching ${libds}`, err)
|
|
return undefined
|
|
})
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Sends tables found in an excel to the SAS sequentially
|
|
*
|
|
* @param explicitDatasets if empty all datasets will be sent, otherwise only datasets
|
|
* in the array will be sent. eg. ['lib1.table_1', 'lib1.table_2']
|
|
*/
|
|
async submitTables(explicitDatasets?: string[]) {
|
|
console.info('Submitting multiple tables', this.parsedDatasets)
|
|
|
|
this.submitLoading = true
|
|
|
|
for (let table of this.parsedDatasets) {
|
|
// Skip the table if no data inside
|
|
if (!table.parseResult || !table.datasource) continue
|
|
// Skip the table if toggle switch is off
|
|
if (!table.includeInSubmission) continue
|
|
// Skip the table if datasets is present and this table not defined in it
|
|
if (explicitDatasets && !explicitDatasets.includes(table.libds)) continue
|
|
|
|
let updateParams: any = {}
|
|
|
|
this.submitReasonMessage = this.submitReasonMessage.replace(/\n/g, '. ')
|
|
updateParams.ACTION = 'LOAD'
|
|
updateParams.MESSAGE = this.submitReasonMessage
|
|
updateParams.LIBDS = table.libds
|
|
|
|
let data = table.datasource
|
|
|
|
if (data) {
|
|
data = data.map((row: any) => {
|
|
let deleteColValue = row['_____DELETE__THIS__RECORD_____']
|
|
|
|
delete row['_____DELETE__THIS__RECORD_____']
|
|
row['_____DELETE__THIS__RECORD_____'] = deleteColValue
|
|
|
|
// If cell is numeric and value is dot `.` we change it to `null`
|
|
Object.keys(row).map((key: string) => {
|
|
const colRule = table.datasetInfo.dcValidator?.getRule(key)
|
|
|
|
if (colRule?.type === 'numeric' && row[key] === '.') row[key] = null
|
|
})
|
|
|
|
return row
|
|
})
|
|
|
|
const submitData = data.slice(
|
|
0,
|
|
this.licenceState.value.submit_rows_limit
|
|
)
|
|
|
|
let error
|
|
let success
|
|
let log
|
|
|
|
await this.sasStoreService
|
|
.updateTable(
|
|
updateParams,
|
|
submitData,
|
|
'SASControlTable',
|
|
'editors/stagedata',
|
|
table.datasetInfo.data.$sasdata,
|
|
true,
|
|
{
|
|
debug: true
|
|
}
|
|
)
|
|
.then((res: RequestWrapperResponse<EditorsStageDataSASResponse>) => {
|
|
success = res.adapterResponse
|
|
log = res.log
|
|
})
|
|
.catch((err: any) => {
|
|
console.error('err', err)
|
|
|
|
error = err.adapterResponse
|
|
log = err.log
|
|
})
|
|
|
|
const requestResult = {
|
|
success,
|
|
error,
|
|
log,
|
|
parseResult: table.parseResult,
|
|
libds: table.libds
|
|
}
|
|
|
|
table.submitResult = requestResult
|
|
}
|
|
}
|
|
this.showSubmitReasonModal = false
|
|
this.submitLoading = false
|
|
this.deselectAllParsedDatasets()
|
|
}
|
|
|
|
async reSubmitTable(activeSubmittedDataset: ParsedDataset) {
|
|
// Submit only particular table
|
|
await this.submitTables([activeSubmittedDataset.libds])
|
|
|
|
// Activate new resubmitted table
|
|
const newSubmittedDataset = this.parsedDatasets.find(
|
|
(sd) => sd.libds === activeSubmittedDataset.libds
|
|
)
|
|
|
|
if (newSubmittedDataset) newSubmittedDataset.active = true
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @returns Promise once workbook is loaded because use XLSX.read in the background
|
|
*/
|
|
private workbookLoaded(): Promise<XLSX.WorkBook> {
|
|
return new Promise((resolve, reject) => {
|
|
if (!this.selectedFile) reject('No file selected')
|
|
|
|
this.workbookInterval = setInterval(() => {
|
|
if (this.selectedFile!.workbook) {
|
|
clearInterval(this.workbookInterval)
|
|
resolve(this.selectedFile!.workbook)
|
|
}
|
|
}, 500)
|
|
})
|
|
}
|
|
|
|
private parseDatasetFromCsvName(fileName: string) {
|
|
const fileNameArr = fileName.split('.')
|
|
fileNameArr.pop()
|
|
return fileNameArr.join('.')
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @returns list of strings containing datasets in the HOT user input
|
|
* Format: LIBRARY.TABLE
|
|
*/
|
|
private getDatasetsFromHot(): string[] {
|
|
if (!this.hotInstanceUserDataset) return []
|
|
|
|
// getData() returns unknown[][] in HOT 18; see dynamicCellValidations() above.
|
|
const hotData = this.hotInstanceUserDataset.getData() as string[][]
|
|
|
|
return hotData
|
|
.filter((row) => row[0]?.length && row[1]?.length)
|
|
.map((row) => (row ? `${row[0]}.${row[1]}` : ''))
|
|
}
|
|
|
|
/**
|
|
* Read the file minimally just to get the sheet names, not reading full file
|
|
* to help boost the performance
|
|
*
|
|
* @returns sheet names in string array
|
|
*/
|
|
private async parseExcelSheetNames(): Promise<string[]> {
|
|
return new Promise((resolve, reject) => {
|
|
if (!this.selectedFile) return resolve([])
|
|
|
|
this.spreadsheetService
|
|
.parseExcelSheetNames(this.selectedFile)
|
|
.then((parsed) => {
|
|
if (parsed.password) this.selectedFile!.password = parsed.password
|
|
|
|
return resolve(parsed.sheetNames)
|
|
})
|
|
.catch((err) => {
|
|
return reject(err)
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Valid dataset format includes:
|
|
* - Name must contain a single period (.)
|
|
* - First part (before period) can be no more than 8 chars
|
|
* - Second part (after period) can be no more than 32 chars
|
|
* - (start with letter or underscore, and contain only letters / underscores / numbers)
|
|
* - can't start with a number
|
|
* - both left and right parts must be valid variable names
|
|
*
|
|
* example: LIB123.TABLE_123
|
|
*/
|
|
private isValidDatasetFormat(name: string) {
|
|
const regex = /^\w{1,8}\.\w{1,32}$/gim
|
|
const correctFormat = regex.test(name)
|
|
|
|
return correctFormat
|
|
}
|
|
|
|
/**
|
|
* Checks if @param datasetRef is valid variable which references library and table
|
|
*/
|
|
private isValidDatasetReference(datasetRef: string) {
|
|
const library = datasetRef.split('.')[0]
|
|
const table = datasetRef.split('.')[1]
|
|
|
|
const libTable = this.libsAndTables[library]?.includes(table)
|
|
|
|
if (libTable) return true
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Creates array of objects, containing all `getdata` responses per item
|
|
* every object will have headers and validations parsed, to be used for
|
|
* parsing sheet file and uploading it
|
|
*
|
|
* @param response
|
|
* @returns
|
|
*/
|
|
private buildDatasetsObjects(responses: EditorsGetDataServiceResponse[]) {
|
|
if (!responses) return []
|
|
|
|
const datasetObjects: DatasetsObject[] = []
|
|
|
|
for (let response of responses) {
|
|
if (response.data) {
|
|
// The delete column is client-owned (see deleteRecordColumnRule.ts):
|
|
// asserts getdata did not send it, then prepends it to COLHEADERS so
|
|
// it is index 0 for everything below.
|
|
prepareDeleteRecordColumn(response)
|
|
seedDeleteRecordColumn(response.data.sasdata)
|
|
|
|
const datasetObject: DatasetsObject = {
|
|
...response,
|
|
hotTable: {
|
|
data: response.data.sasdata,
|
|
settings: {}
|
|
},
|
|
cols: [],
|
|
headerColumns: [],
|
|
headerPks: [],
|
|
headerArray: [],
|
|
dateHeaders: [],
|
|
timeHeaders: [],
|
|
dateTimeHeaders: [],
|
|
xlRules: [],
|
|
columnHeader: []
|
|
}
|
|
|
|
datasetObject.cols = response.data.cols
|
|
datasetObject.headerColumns =
|
|
response.data.sasparams[0].COLHEADERS.split(',')
|
|
datasetObject.headerPks = response.data.sasparams[0].PK.split(' ')
|
|
|
|
if (
|
|
datasetObject.headerColumns.indexOf(DELETE_RECORD_COLUMN_NAME) !== -1
|
|
) {
|
|
datasetObject.headerColumns[
|
|
datasetObject.headerColumns.indexOf(DELETE_RECORD_COLUMN_NAME)
|
|
] = 'Delete?'
|
|
}
|
|
|
|
datasetObject.headerArray = datasetObject.headerColumns.slice(1)
|
|
|
|
if (response.data.sasparams[0].DTVARS !== '') {
|
|
datasetObject.dateHeaders =
|
|
response.data.sasparams[0].DTVARS.split(' ')
|
|
}
|
|
if (response.data.sasparams[0].TMVARS !== '') {
|
|
datasetObject.timeHeaders =
|
|
response.data.sasparams[0].TMVARS.split(' ')
|
|
}
|
|
if (response.data.sasparams[0].DTTMVARS !== '') {
|
|
datasetObject.dateTimeHeaders =
|
|
response.data.sasparams[0].DTTMVARS.split(' ')
|
|
}
|
|
if (response.data.xl_rules.length > 0) {
|
|
datasetObject.xlRules = this.helperService.deepClone(
|
|
response.data.xl_rules
|
|
)
|
|
}
|
|
|
|
datasetObject.dcValidator = new DcValidator(
|
|
response.data.sasparams[0],
|
|
response.data.$sasdata,
|
|
response.data.cols,
|
|
response.data.dqrules,
|
|
response.data.dqdata
|
|
)
|
|
|
|
datasetObject.columnHeader =
|
|
response.data.sasparams[0].COLHEADERS.split(',')
|
|
|
|
datasetObjects.push(datasetObject)
|
|
}
|
|
}
|
|
|
|
return datasetObjects
|
|
}
|
|
|
|
private deselectAllParsedDatasets() {
|
|
for (let parsedDataset of this.parsedDatasets) {
|
|
parsedDataset.active = false
|
|
}
|
|
}
|
|
|
|
private deselectAllSubmittedCsvDatasets() {
|
|
for (let submittedDataset of this.submittedCsvDatasets) {
|
|
submittedDataset.active = false
|
|
}
|
|
}
|
|
}
|
|
|
|
export interface DatasetsObject extends EditorsGetDataServiceResponse {
|
|
hotTable: HotTableInterface
|
|
cols: Col[]
|
|
headerColumns: string[]
|
|
headerPks: string[]
|
|
headerArray: string[]
|
|
dateHeaders: string[]
|
|
timeHeaders: string[]
|
|
dateTimeHeaders: string[]
|
|
xlRules: ExcelRule[]
|
|
dcValidator?: DcValidator
|
|
columnHeader: string[]
|
|
}
|
|
|
|
export interface ParsedDataset {
|
|
libds: string
|
|
parseResult?: ParseResult
|
|
datasetInfo: DatasetsObject
|
|
submitResult?: SubmittedDatasetResult
|
|
datasource?: any[]
|
|
includeInSubmission: boolean
|
|
status?: 'success' | 'error'
|
|
active?: boolean
|
|
parsingTable?: boolean
|
|
}
|
|
|
|
export interface SubmittedDatasetResult {
|
|
success: EditorsStageDataSASResponse | undefined
|
|
error: any
|
|
log?: string
|
|
}
|
|
|
|
export interface SubmittedCsvDatasetResult {
|
|
libds: string
|
|
success: EditorsStageDataSASResponse | undefined
|
|
error: any
|
|
active?: boolean
|
|
}
|
|
|
|
export interface SelectedFile extends File {
|
|
sizeMB?: number
|
|
password?: string
|
|
workbook?: XLSX.WorkBook
|
|
}
|