feat(multi load): implemented matching libds and parsing of the multiple sheets

This commit is contained in:
Mihajlo Medjedovic 2024-06-14 15:46:15 +02:00
parent eb7c44333c
commit efcdc694dd
6 changed files with 350 additions and 124 deletions

View File

@ -215,6 +215,7 @@ hot-table {
padding: 10px;
background: #dbdbdb;
border-radius: 5px;
color: black;
}
}

View File

@ -14,17 +14,6 @@ import Handsontable from 'handsontable'
import { Subject, Subscription } from 'rxjs'
import { SasStoreService } from '../services/sas-store.service'
import * as XLSX from '@sheet/crypto'
/**
* Used in combination with buffer
*/
const iconv = require('iconv-lite')
/**
* In combination with `iconv` is used for encoding json data captured with sheet js from excel file into a file again
* Which will be send to backend
*/
const Buffer = require('buffer/').Buffer
type AOA = any[][]
import { HotTableRegisterer } from '@handsontable/angular'
@ -42,7 +31,6 @@ import {
Version
} from '../models/sas/editors-getdata.model'
import { DataFormat } from '../models/sas/common/DateFormat'
import SheetInfo from '../models/SheetInfo'
import { Approver, ExcelRule } from '../models/TableData'
import { QueryComponent } from '../query/query.component'
import { EventService } from '../services/event.service'
@ -485,6 +473,7 @@ export class EditorComponent implements OnInit, AfterViewInit {
this.spreadsheetService.parseExcelFile({
file: file,
uploader: this.uploader,
dcValidator: this.dcValidator!,
headerPks: this.headerPks,
headerArray: this.headerArray,
@ -496,14 +485,23 @@ export class EditorComponent implements OnInit, AfterViewInit {
encoding: this.encoding
}, (uploadState: string) => {
this.appendUploadState(uploadState)
}, (tableFoundInfo: string) => {
this.eventService.showInfoModal('Table Found', tableFoundInfo)
}).then((parseResult: ParseResult | undefined) => {
if (parseResult) {
this.excelFileReady = true
this.data = parseResult.data
this.uploader = parseResult.uploader
this.getPendingExcelPreview()
if (parseResult.data && parseResult.headerShow) {
// If data is returned it means we parsed excel file
this.data = parseResult.data
this.headerShow = parseResult.headerShow
this.getPendingExcelPreview()
} else {
// otherwise it's csv file, and we send them directly
this.uploadParsedFiles()
}
}
}).catch((error: string) => {
this.eventService.showInfoModal('Error', error)

View File

@ -7,4 +7,5 @@ export default interface SheetInfo {
missingHeaders: string[]
rangeStartRow: number
rangeStartCol: number
rangeAddress?: string
}

View File

@ -15,18 +15,19 @@
/>
</div>
<ng-container *ngIf="datasets.length > 0">
<ng-container *ngIf="parsedDatasets.length > 0">
<p cds-text="caption" class="ml-10">Found tables:</p>
<clr-tree>
<clr-tree-node *ngFor="let dataset of datasets">
<clr-tree-node *ngFor="let dataset of parsedDatasets">
<button
(click)="onParsedDatasetClick(dataset)"
class="clr-treenode-link"
[class.active]="dataset.active"
>
<cds-icon *ngIf="dataset.status === 'error'" status="danger" shape="exclamation-circle"></cds-icon>
<cds-icon *ngIf="dataset.status === 'success'" status="success" shape="check-circle"></cds-icon>
<cds-icon shape="file"></cds-icon>
{{ dataset.name }}
{{ dataset.libds }}
</button>
</clr-tree-node>
</clr-tree>
@ -61,29 +62,61 @@
</p>
</div>
<div class="d-flex clr-justify-content-center mt-15">
<div class="dataset-input-wrapper">
<p cds-text="caption" class="mb-20">Selected file: <strong>{{ selectedFile?.name }}</strong></p>
<p cds-text="caption">Paste or type the list of datasets to upload:</p>
<ng-container *ngIf="!parsedDatasets.length">
<div class="d-flex clr-justify-content-center mt-15">
<div class="dataset-input-wrapper">
<p cds-text="secondary regular" class="mb-20">Selected file: <strong>{{ selectedFile?.name }}</strong></p>
<p cds-text="secondary regular">Paste or type the list of datasets to upload:</p>
<button (click)="onAutoDetectColumns()" class="mt-15 btn btn-primary-outline btn-sm">Auto detect</button>
<button (click)="onAutoDetectColumns()" class="mt-15 btn btn-primary-outline btn-sm">Auto detect</button>
<clr-textarea-container class="m-0">
<textarea clrTextarea [(ngModel)]="userInputDatasets" (input)="onUserInputDatasetsChange()" class="w-100-i"></textarea>
<clr-control-helper>Every row is one dataset. Format: LIBRARY.TABLE</clr-control-helper>
</clr-textarea-container>
<clr-textarea-container class="m-0">
<textarea clrTextarea [(ngModel)]="userInputDatasets" (input)="onUserInputDatasetsChange()" class="w-100-i"></textarea>
<clr-control-helper>Every row is one dataset. Format: LIBRARY.TABLE</clr-control-helper>
</clr-textarea-container>
<div class="text-right mt-10">
<button (click)="onDiscardFile()" class="btn btn-danger btn-sm">Discard file</button>
<button (click)="onUploadFile()" class="btn btn-primary btn-sm">Upload</button>
</div>
<div class="text-right mt-10">
<button (click)="onDiscardFile()" class="btn btn-danger btn-sm" [disabled]="uploadLoading">Discard file</button>
<button (click)="onUploadFile()" class="btn btn-primary btn-sm" [disabled]="!matchedDatasets.length" [clrLoading]="uploadLoading">Continue</button>
</div>
<div *ngIf="matchedDatasets.length">
<p><strong>Matched datasets:</strong></p>
<p *ngFor="let matchedDataset of matchedDatasets" class="m-0 ml-5-i">{{ matchedDataset }}</p>
<div *ngIf="matchedDatasets.length">
<p><strong>Matched datasets:</strong></p>
<p *ngFor="let matchedDataset of matchedDatasets" class="m-0 ml-5-i">{{ matchedDataset }}</p>
</div>
</div>
</div>
</div>
</ng-container>
<ng-container *ngIf="parsedDatasets.length">
<div
*ngIf="!activeParsedDataset"
class="no-table-selected pointer-events-none"
>
<clr-icon
shape="warning-standard"
size="40"
class="is-info icon-dc-fill"
></clr-icon>
<p class="text-center color-gray mt-10" cds-text="section">
Please select a dataset on the left
</p>
</div>
<div class="d-flex clr-justify-content-between p-10">
<div>
<p cds-text="secondary regular" class="mb-10">Found in range: <strong>"{{ activeParsedDataset?.parseResult?.rangeSheetRes?.sheetName }}"!{{ activeParsedDataset?.parseResult?.rangeSheetRes?.rangeAddress }}</strong></p>
<p cds-text="secondary regular">Matched with dataset: <strong>LIB1.MPE_X_DATA</strong></p>
</div>
<div>
<clr-toggle-wrapper>
<input type="checkbox" clrToggle name="options" required value="option1"/>
<label>Include in submission</label>
</clr-toggle-wrapper>
</div>
</div>
</ng-container>
<!--
<div *ngIf="!noData && !noDataReqErr && table" class="clr-flex-1">

View File

@ -14,6 +14,12 @@ import {
} from '../services'
import * as XLSX from '@sheet/crypto'
import { globals } from '../_globals'
import { EditorsGetDataServiceResponse } from '../models/sas/editors-getdata.model'
import { DcValidator } from '../shared/dc-validator/dc-validator'
import { ExcelRule } from '../models/TableData'
import { HotTableInterface } from '../models/HotTable.interface'
import { Col } from '../shared/dc-validator/models/col.model'
import { ParseResult, SpreadsheetService } from '../services/spreadsheet.service'
@Component({
selector: 'app-multi-dataset',
@ -30,25 +36,15 @@ export class MultiDatasetComponent implements OnInit {
this.licenceState.value.viewer_rows_allowed || Infinity
public selectedFile: File | null = null
public datasets: any[] = [
// {
// name: 'LIB1.TABLE21',
// status: 'error'
// },
// {
// name: 'LIB1.BLEJA',
// status: 'success'
// },
// {
// name: 'LIB1.NIDZA',
// status: 'success'
// }
]
public parsedDatasets: ParsedDataset[] = []
public datasetsLoading: boolean = false
public matchedDatasets: string[] = []
public userInputDatasets: string = ''
public uploadLoading: boolean = false
public libsAndTables: {
[key: string]: string[]
} = {}
@ -57,18 +53,10 @@ export class MultiDatasetComponent implements OnInit {
private eventService: EventService,
private licenceService: LicenceService,
private helperService: HelperService,
private loggerService: LoggerService,
private route: ActivatedRoute,
private router: Router,
private sasStoreService: SasStoreService,
private sasService: SasService
private spreadsheetService: SpreadsheetService
) {}
public afterGetColHeader(column: number, th: any) {
// Dark mode
th.classList.add('darkTH')
}
ngOnInit() {
this.licenceService.hot_license_key.subscribe(
(hot_license_key: string | undefined) => {
@ -92,22 +80,22 @@ export class MultiDatasetComponent implements OnInit {
}
onFileChange(event: any) {
// if (!event?.target?.files[0]) {
// this.eventService.showAbortModal(null, 'No file found.', null, 'File Upload')
// return
// }
if (!event?.target?.files[0]) {
this.eventService.showAbortModal(null, 'No file found.', null, 'File Upload')
return
}
// const file = event.target.files[0];
// const fileTitle = file.name;
// const fileExtension = fileTitle.split('.').pop()
const file = event.target.files[0];
const fileTitle = file.name;
const fileExtension = fileTitle.split('.').pop()
// if (!['xlsx', 'xlsm', 'xlm'].includes(fileExtension)) {
// this.eventService.showAbortModal(null, 'Only excel extensions are allowed. (xlsx)', null, 'Extension Error')
// return
// }
if (!['xlsx', 'xlsm', 'xlm'].includes(fileExtension)) {
this.eventService.showAbortModal(null, 'Only excel extensions are allowed. (xlsx)', null, 'Extension Error')
return
}
// this.selectedFile = event.target.files[0]
// event.target.value = '' // Reset the upload input
this.selectedFile = event.target.files[0]
event.target.value = '' // Reset the upload input
}
onDiscardFile() {
@ -115,8 +103,53 @@ export class MultiDatasetComponent implements OnInit {
this.userInputDatasets = ''
}
onUploadFile() {
async onUploadFile() {
this.uploadLoading = true
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
const datasetObjects = this.buildDatasetsObjects(datasets)
for (let datasetObject of datasetObjects) {
this.spreadsheetService.parseExcelFile({
file: this.selectedFile!,
dcValidator: datasetObject.dcValidator!,
headerPks: datasetObject.headerPks,
headerArray: datasetObject.headerArray,
headerShow: [],
timeHeaders: datasetObject.timeHeaders,
dateHeaders: datasetObject.dateHeaders,
dateTimeHeaders: datasetObject.dateTimeHeaders,
xlRules: datasetObject.xlRules
}).then((parseResult: ParseResult | undefined) => {
console.log('parseResult', parseResult)
if (parseResult && parseResult.data) {
this.parsedDatasets.push({
libds: datasetObject.libds,
parseResult: parseResult
})
}
}).catch((error: string) => {
this.eventService.showInfoModal('Error', error)
})
}
})
}
onUserInputDatasetsChange() {
@ -129,7 +162,7 @@ export class MultiDatasetComponent implements OnInit {
inputDatasets.forEach((dataset: string) => {
const trimmedDataset = dataset.trim()
if (this.isValidDatasetFormat(trimmedDataset) && this.isValidDatasetReference(trimmedDataset)) {
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.`)
@ -165,39 +198,42 @@ export class MultiDatasetComponent implements OnInit {
this.userInputDatasets = this.matchedDatasets.join('\n')
}
/**
* 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
*/
isValidDatasetFormat(sheetName: string) {
const regex = /^\w{1,8}\.\w{1,32}$/gmi
const correctFormat = regex.test(sheetName)
onParsedDatasetClick(parsedDataset: ParsedDataset) {
this.deselectAllParsedDatasets()
return correctFormat
parsedDataset.active = true
}
public get activeParsedDataset(): ParsedDataset | undefined {
return this.parsedDatasets.find(dataset => dataset.active)
}
/**
* Checks if @param datasetRef is valid variable which references library and table
*/
isValidDatasetReference(datasetRef: string) {
const library = datasetRef.split('.')[0]
const table = datasetRef.split('.')[1]
* Fetches the table for given datasets params LIBRARY.TABLE
*/
async fetchDataset(libds: string): Promise<EditorsGetDataServiceResponse | undefined> {
let myParams: any = {
LIBDS: libds,
OUTDEST: 'WEB'
}
const libTable = this.libsAndTables[library]?.includes(table)
if (libds) {
// this.getdataError = false
return this.sasStoreService
.callService(myParams, 'SASControlTable', 'editors/getdata', libds)
.then((res: EditorsGetDataServiceResponse) => {
return res
})
.catch((err: any) => {
console.warn(`Error fetching ${libds}`, err)
return undefined
})
}
if (libTable) return true
return false
return undefined
}
parseExcelSheetNames(): Promise<string[]> {
private parseExcelSheetNames(): Promise<string[]> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@ -234,4 +270,137 @@ export class MultiDatasetComponent implements OnInit {
reader.readAsBinaryString(this.selectedFile);
})
}
/**
* 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(sheetName: string) {
const regex = /^\w{1,8}\.\w{1,32}$/gmi
const correctFormat = regex.test(sheetName)
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) {
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__THIS__RECORD_____') !== -1) {
datasetObject.headerColumns[
datasetObject.headerColumns.indexOf('_____DELETE__THIS__RECORD_____')
] = '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
}
}
}
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
status?: 'success' | 'error'
active?: boolean
parseResult: ParseResult
}

View File

@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
import * as XLSX from '@sheet/crypto'
import { ExcelPasswordModalService, Result } from '../shared/excel-password-modal/excel-password-modal.service';
import { EventService } from './event.service';
import { isSpecialMissing } from '@sasjs/utils';
import { isSpecialMissing } from '@sasjs/utils/input/validators';
import { dateFormat, dateToUtcTime, dateToTime } from '../editor/utils/date.utils';
import { excelDateToJSDate, getMissingHeaders } from '../editor/utils/grid.utils';
import { isStringNumber, isStringDecimal } from '../editor/utils/types.utils';
@ -28,6 +28,11 @@ type AOA = any[][]
export interface ParseParams {
file: File,
dcValidator: DcValidator
/**
* Parse function will manipulate and return the uploader array which can be provided with files already in the queue
* Otherwise new empty instance will be created.
*/
uploader?: FileUploader
headerPks: string[]
headerArray: string[]
headerShow: string[]
@ -35,12 +40,20 @@ export interface ParseParams {
dateHeaders: string[]
dateTimeHeaders: string[]
xlRules: ExcelRule[]
encoding: FileUploadEncoding
encoding?: FileUploadEncoding
}
export interface ParseResult {
data: any[]
uploader: FileUploader
/**
* In case of CSV file, won't be returned
*/
data?: any[]
/**
* In case of CSV file, won't be returned
*/
headerShow?: string[]
rangeSheetRes?: SheetInfo
uploader: FileUploader,
}
@Injectable({
@ -62,18 +75,25 @@ export class SpreadsheetService {
* @param parseParams params required for parsing the file
* @param onParseStateChange callback used to inform about parsing state
* so the user of the function can update the UI with latest info
* @param onTableFoundEvent callback fired when table range is found in the file
*
* @returns parsed list of files to upload and JSON data ready for HOT usage
*/
public parseExcelFile(parseParams: ParseParams, onParseStateChange: (uploadState: string) => void): Promise<ParseResult | undefined> {
public parseExcelFile(
parseParams: ParseParams,
onParseStateChange?: (uploadState: string) => void,
onTableFoundEvent?: (info: string) => void
): Promise<ParseResult | undefined> {
return new Promise((resolve, reject) => {
let data: any[] = []
let uploader: FileUploader = new FileUploader()
let uploader: FileUploader = parseParams.uploader || new FileUploader()
let file: File = parseParams.file
let filename = file.name
onParseStateChange(`Loading ${filename} into the browser`)
if (!parseParams.encoding) parseParams.encoding = 'UTF-8'
if (onParseStateChange) onParseStateChange(`Loading ${filename} into the browser`)
let foundData = {
sheet: ''
@ -140,7 +160,6 @@ export class SpreadsheetService {
}
if (!wb) {
// RETURN ERROR: NO WB FOUND
return reject('No workbook found.')
}
@ -170,7 +189,7 @@ export class SpreadsheetService {
csvArrayHeadersMap = rangeSheetRes.csvArrayHeadersMap
const ws: XLSX.WorkSheet = wb.Sheets[rangeSheetRes.sheetName]
onParseStateChange(`Table found on sheet ${rangeSheetRes.sheetName} on row ${rangeSheetRes.startRow}`)
if (onParseStateChange) onParseStateChange(`Table found on sheet ${rangeSheetRes.sheetName} on row ${rangeSheetRes.startRow}`)
let startAddress = ''
let endAddress = ''
@ -189,9 +208,6 @@ export class SpreadsheetService {
c: rangeSheetRes.rangeStartCol + col
})
if (startAddress === '') startAddress = addr
endAddress = addr
let cell
if (!ws[addr]) {
@ -199,6 +215,10 @@ export class SpreadsheetService {
} else {
cell = ws[addr]
}
if (startAddress === '' && ws[addr]) startAddress = addr
endAddress = addr
arr.push(cell)
})
@ -209,10 +229,9 @@ export class SpreadsheetService {
if (arrNonEmptyValue) csvArrayData.push(arr)
}
this.eventService.showInfoModal(
'Table Found',
`Sheet: ${rangeSheetRes.sheetName}\nRange: ${startAddress}:${endAddress}`
)
rangeSheetRes.rangeAddress = `${startAddress}:${endAddress}`
if (onTableFoundEvent) onTableFoundEvent(`Sheet: ${rangeSheetRes.sheetName}\nRange: ${rangeSheetRes.rangeAddress}`)
} else {
missingHeaders = rangeSheetRes.missingHeaders
}
@ -341,16 +360,21 @@ export class SpreadsheetService {
// Prepend headers
csvContentClean = csvArrayHeaders.join(',') + '\n' + csvContentClean
// Blob from which CSV file will be created depending of the selected
// encoding
let blob: Blob
if (parseParams.encoding === 'WLATIN1') {
// WLATIN1
let encoded = iconv.decode(Buffer.from(csvContentClean), 'CP-1252')
let blob = new Blob([encoded], { type: 'application/csv' })
let newCSVFile: File = blobToFile(blob, filename + '.csv')
uploader.addToQueue([newCSVFile])
blob = new Blob([encoded], { type: 'application/csv' })
} else {
let blob = new Blob([csvContentClean], { type: 'application/csv' })
let newCSVFile: File = blobToFile(blob, filename + '.csv')
uploader.addToQueue([newCSVFile])
// UTF-8
blob = new Blob([csvContentClean], { type: 'application/csv' })
}
let newCSVFile: File = blobToFile(blob, filename + '.csv')
uploader.addToQueue([newCSVFile])
}
if (data.length === 0) {
@ -359,7 +383,9 @@ export class SpreadsheetService {
return resolve({
uploader,
data
data,
rangeSheetRes,
headerShow: parseParams.headerShow
})
}
reader.readAsArrayBuffer(file)
@ -384,16 +410,14 @@ export class SpreadsheetService {
uploader.addToQueue([encodedFile])
resolve({
uploader,
data
uploader
})
}
reader.readAsArrayBuffer(file)
} else {
return resolve({
uploader,
data
uploader
})
}
} else {