import ManagedObject from 'sap/ui/base/ManagedObject';
import Log from 'sap/base/Log';
import type ResourceBundle from 'sap/base/i18n/ResourceBundle';
import MessageBox from 'sap/m/MessageBox';
import type { DeepDownloadConfig, FireEventReturnType, RowData, UpdateConfig, ValueData } from '../types';
import type Component from '../Component';
import type { FieldMatchType } from '../enums';
import ObjectPool from 'sap/ui/base/ObjectPool';
import Event from 'sap/ui/base/Event';
import ts from 'sap/ui/model/odata/v4/ts';
import * as XLSX from 'xlsx';
/**
* @namespace cc.spreadsheetimporter.v2_4_0
*/
export default class Util extends ManagedObject {
private resourceBundle: ResourceBundle;
constructor(resourceBundle: ResourceBundle) {
super();
this.resourceBundle = resourceBundle;
}
static getValueFromRow(row: RowData, label: string, type: string, fieldMatchType: FieldMatchType): ValueData {
let value: ValueData | undefined;
if (fieldMatchType === 'label') {
value = row[label];
}
if (fieldMatchType === 'labelTypeBrackets') {
try {
value = Object.entries(row).find(([key]) => key.includes(`[${type}]`))[1] as ValueData;
} catch (error) {
Log.debug(`Not found ${type}`, undefined, 'SpreadsheetUpload: Util');
}
}
return value;
}
geti18nText(text: string, array?: any): string {
return this.resourceBundle.getText(text, array);
}
static changeDecimalSeperator(value: string): number {
// Replace thousands separator with empty string
value = value.replace(/[.]/g, '');
// Replace decimal separator with a dot
value = value.replace(/[,]/g, '.');
// Convert string to number
return parseFloat(value);
}
static showError(error: any, className: string, methodName: string) {
let detailsContent: any = '';
let errorMessage = '';
try {
// code error
if (error.stack) {
errorMessage = error.message;
// convert urls to links and to remove lines of the url
const regex = /(http[s]?:\/\/[^\s]+):(\d+):(\d+)/g;
const errorStack = error.stack
.replace(regex, '$1:$2:$3')
.replace(/\n/g, '
');
detailsContent = errorStack;
} else {
// OData error
const errorObject = JSON.parse(error.responseText);
errorMessage = errorObject.error.message.value;
detailsContent = errorObject;
}
} catch (error) {
errorMessage = 'Generic Error';
detailsContent = error;
}
Log.error(errorMessage, error, `${className}.${methodName}`);
MessageBox.error(errorMessage, {
details: detailsContent,
initialFocus: MessageBox.Action.CLOSE,
actions: [MessageBox.Action.OK]
});
}
static showErrorMessage(errorMessage: string, className: string, methodName: string) {
Log.error(errorMessage, `${className}.${methodName}`);
MessageBox.error(errorMessage, {
initialFocus: MessageBox.Action.CLOSE,
actions: [MessageBox.Action.CANCEL]
});
}
static getBrowserDecimalAndThousandSeparators(componentDecimalSeparator: string) {
let decimalSeparator = '';
let thousandSeparator = '';
if (componentDecimalSeparator === ',') {
thousandSeparator = '.';
decimalSeparator = ',';
return { thousandSeparator, decimalSeparator };
}
if (componentDecimalSeparator === '.') {
thousandSeparator = ',';
decimalSeparator = '.';
return { decimalSeparator, thousandSeparator };
}
const sampleNumber = 12345.6789;
const formatted = new Intl.NumberFormat(navigator.language).format(sampleNumber);
const withoutDigits = formatted.replace(/\d/g, '');
decimalSeparator = withoutDigits.charAt(withoutDigits.length - 1);
thousandSeparator = withoutDigits.charAt(0);
return { decimalSeparator, thousandSeparator };
}
static normalizeNumberString(numberString: string, component: Component) {
const { decimalSeparator, thousandSeparator } = this.getBrowserDecimalAndThousandSeparators(component.getDecimalSeparator());
// Remove thousand separators
const stringWithoutThousandSeparators = numberString.replace(new RegExp(`\\${thousandSeparator}`, 'g'), '');
// Replace the default decimal separator with the standard one
const standardNumberString = stringWithoutThousandSeparators.replace(decimalSeparator, '.');
return standardNumberString;
}
/**
* Rounds a number to the requested decimal places.
* Uses {@link Number#toFixed} to avoid common IEEE-754 artefacts.
*
* @param value The number to round
* @param decimalPlaces Number of decimal places
* @returns The rounded number
*/
static roundToDecimalPlaces(value: number, decimalPlaces: number): number {
if (!Number.isFinite(value)) {
return value;
}
const clampedPlaces = Math.min(Math.max(Math.trunc(decimalPlaces) || 0, 0), 100);
const fixed = value.toFixed(clampedPlaces);
return Number.parseFloat(fixed);
}
/**
* Removes IEEE-754 floating-point artefacts by normalising the number to a fixed
* amount of significant digits (defaults to 15 which matches JS double precision).
*
* @param value The number to sanitize
* @param significantDigits Amount of significant digits to preserve
* @returns A number with the same magnitude but without binary noise
*/
static sanitizeFloatingPoint(value: number, significantDigits: number = 15): number {
if (!Number.isFinite(value)) {
return value;
}
const clampedDigits = Math.min(Math.max(Math.trunc(significantDigits) || 0, 1), 100);
return Number.parseFloat(value.toPrecision(clampedDigits));
}
/**
* Fixes floating-point precision using metadata or pattern detection.
* Priority: Scale attribute > Precision attribute > Pattern detection > Original value
*
* @param value The number with potential floating-point errors
* @param scale Optional Scale attribute from metadata (for Decimal types)
* @param precision Optional Precision attribute from metadata
* @returns The corrected number
*/
static fixFloatingPointPrecision(value: number, scale?: number, precision?: number): number {
if (!Number.isFinite(value)) {
return value;
}
// Priority 1: Use Scale attribute (most reliable for Decimal types)
if (scale !== undefined && scale !== null) {
return this.roundToDecimalPlaces(value, scale);
}
// Priority 2: Use Precision attribute (less common but still valid, total digits)
if (precision !== undefined && precision !== null) {
return this.sanitizeFloatingPoint(value, precision);
}
// Priority 3: Fallback to generic sanitizing of IEEE-754 noise
return this.sanitizeFloatingPoint(value);
}
static getRandomString(length: number): string {
const characters: string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
let randomString: string = '';
for (let i: number = 0; i < length; i++) {
const randomIndex: number = Math.floor(Math.random() * characters.length);
randomString += characters.charAt(randomIndex);
}
return randomString;
}
static stringify(obj: any): string {
const seen = new WeakSet();
return JSON.stringify(obj, (key, value) => {
// Check if value is an object and not null
if (typeof value === 'object' && value !== null) {
// Handle circular references
if (seen.has(value)) {
return;
}
seen.add(value);
// Handle first-level objects
const keys = Object.keys(value);
if (keys.every(k => typeof value[k] !== 'object' || value[k] === null)) {
const simpleObject: { [key: string]: any } = {};
for (const k in value) {
if (typeof value[k] !== 'object' || value[k] === null) {
simpleObject[k] = value[k];
}
}
return simpleObject;
}
}
return value;
});
}
static extractObjects(objects: any[]): Record[] {
return objects.map(obj => obj.getObject());
}
static downloadSpreadsheetFile(arrayBuffer: ArrayBuffer, fileName: string): void {
const blob: Blob = new Blob([arrayBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url: string = URL.createObjectURL(blob);
const a: HTMLAnchorElement = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
static async getLanguage(): Promise {
try {
// getCore is not available in UI5 version 2.0 and above, prefer this over sap.ui.getCore().getConfiguration().getLanguage()
const Localization = await Util.loadUI5RessourceAsync('sap/base/i18n/Localization');
return Localization.getLanguage();
} catch (error) {
Log.debug('sap/base/i18n/Localization not found', undefined, 'SpreadsheetUpload: checkForODataErrors');
}
// ui5lint-disable-next-line -- fallback for UI5 versions below 2.0
return sap.ui.getCore().getConfiguration().getLanguage();
}
static async loadUI5RessourceAsync(moduleName: string): Promise {
const alreadyLoadedModule = sap.ui.require(moduleName);
if (alreadyLoadedModule) {
return Promise.resolve(alreadyLoadedModule);
}
return new Promise(function (resolve, reject) {
sap.ui.require(
[moduleName],
function (Module: unknown) {
resolve(Module);
},
function (err: any) {
reject(err);
}
);
});
}
/**
* Asynchronously fires an event with the given name and parameters on the specified component.
* With this method, async methods can be attached and also sync methods
* instead of the standard generated fireEvent methods, we call the methods directly
* using promises to wait for the event handlers to complete
*
* @param eventName - The name of the event to be fired.
* @param eventParameters - The parameters to be passed to the event handlers.
* @param component - The component on which the event is fired.
* @returns A promise that resolves when all event handlers have completed.
*/
static async fireEventAsync(eventName: string, eventParameters: object, component: Component): Promise {
let aEventListeners, event;
const promises = [];
// @ts-ignore
const eventPool = new ObjectPool(Event);
aEventListeners = (component as any).mEventRegistry[eventName];
if (Array.isArray(aEventListeners)) {
// Avoid issues with 'concurrent modification' (e.g. if an event listener unregisters itself).
aEventListeners = aEventListeners.slice();
event = eventPool.borrowObject(eventName, component, eventParameters); // borrow event lazily
for (const oInfo of aEventListeners) {
try {
// Assuming each handler returns a promise
promises.push(oInfo.fFunction.call(null, event));
} catch (error) {
Log.error('Error in event handler:', error as Error);
}
}
}
// Wait for all promises (i.e., async handlers) to resolve
await Promise.all(promises);
return {
bPreventDefault: (event as any)?.bPreventDefault,
mParameters: (event as any)?.mParameters,
returnValue: promises[0]
};
}
static mergeDeepDownloadConfig(defaultConfig: DeepDownloadConfig, providedConfig?: DeepDownloadConfig): DeepDownloadConfig {
if (!providedConfig) return defaultConfig;
// Deep merge for spreadsheetExportConfig
const mergedDeepDownloadConfig: DeepDownloadConfig = {
...defaultConfig,
...providedConfig
};
return mergedDeepDownloadConfig;
}
static mergeUpdateConfig(defaultConfig: UpdateConfig, providedConfig?: UpdateConfig): UpdateConfig {
if (!providedConfig) return defaultConfig;
const mergedUpdateConfig: UpdateConfig = {
...defaultConfig,
...providedConfig
};
return mergedUpdateConfig;
}
/**
* Converts coordinates from object format {s: {r: number, c: number}, e: {r: number, c: number}}
* to A1 notation string (e.g., "A1")
*
* @param coordinates Object with start (s) and end (e) coordinates
* @param includeEndCoordinate Whether to include the end coordinate in the result (e.g., "A1:C10")
* @returns A1 notation string or null if conversion fails
*/
static convertCoordinatesToA1Notation(coordinates: any, includeEndCoordinate: boolean = false): string | null {
try {
if (!coordinates || !coordinates.s || typeof coordinates.s.r !== 'number' || typeof coordinates.s.c !== 'number') {
return null;
}
// Create a new cell reference in A1 notation for the starting point
const startColLetter = XLSX.utils.encode_col(coordinates.s.c);
const startRowNumber = coordinates.s.r + 1; // XLSX is 0-based for rows
let a1Notation = `${startColLetter}${startRowNumber}`;
// Add end coordinates if requested and valid
if (includeEndCoordinate && coordinates.e && typeof coordinates.e.r === 'number' && typeof coordinates.e.c === 'number') {
const endColLetter = XLSX.utils.encode_col(coordinates.e.c);
const endRowNumber = coordinates.e.r + 1; // XLSX is 0-based for rows
a1Notation += `:${endColLetter}${endRowNumber}`;
}
return a1Notation;
} catch (error) {
Log.error('Error converting coordinates to A1 notation', error as Error, 'SpreadsheetUpload: Util');
return null;
}
}
/**
* Converts A1 notation string (e.g., "A1" or "A1:C10") to coordinates object
*
* @param a1Notation A1 notation string
* @returns Coordinates object or null if conversion fails
*/
static convertA1NotationToCoordinates(a1Notation: string): any | null {
try {
if (!a1Notation || typeof a1Notation !== 'string') {
return null;
}
// Handle range notation (e.g., "A1:C10")
if (a1Notation.includes(':')) {
return XLSX.utils.decode_range(a1Notation);
}
// Handle single cell notation (e.g., "A1")
else {
const cellCoords = XLSX.utils.decode_cell(a1Notation);
return {
s: { r: cellCoords.r, c: cellCoords.c },
e: { r: cellCoords.r, c: cellCoords.c }
};
}
} catch (error) {
Log.error('Error converting A1 notation to coordinates', error as Error, 'SpreadsheetUpload: Util');
return null;
}
}
/**
* Validates the component configuration for potential issues or incompatibilities.
* Logs configuration issues to the console.
*
* @param componentData The configuration data provided by the developer
* @returns True if the configuration is valid, false if critical issues were found
*/
static validateConfiguration(componentData: any): boolean {
if (!componentData) {
return true; // No config to validate
}
const errors: string[] = [];
const warnings: string[] = [];
// Check for unknown configuration options
const knownProperties = [
'spreadsheetFileName',
'action',
'context',
'columns',
'excludeColumns',
'tableId',
'odataType',
'mandatoryFields',
'fieldMatchType',
'activateDraft',
'batchSize',
'standalone',
'strict',
'decimalSeparator',
'hidePreview',
'previewColumns',
'skipMandatoryFieldCheck',
'skipColumnsCheck',
'skipMaxLengthCheck',
'showBackendErrorMessages',
'showOptions',
'availableOptions',
'hideSampleData',
'sampleData',
'spreadsheetTemplateFile',
'useTableSelector',
'readAllSheets',
'readSheet',
'spreadsheetRowPropertyName',
'continueOnError',
'createActiveEntity',
'i18nModel',
'debug',
'componentContainerData',
'bindingCustom',
'showDownloadButton',
'deepDownloadConfig',
'updateConfig',
'nullMarker',
'emptyStringMarker',
'skipEmptyHeadersCheck',
'readSheetCoordinates',
'directUploadConfig',
'useImportWizard',
'enablePaste'
];
// Find unknown properties in componentData
const unknownProperties = Object.keys(componentData).filter(prop => !knownProperties.includes(prop));
if (unknownProperties.length > 0) {
warnings.push(`Unknown configuration options found: ${unknownProperties.join(', ')}. These will be ignored.`);
}
// Validate marker configuration (apply defaults to catch conflicts with default values)
const effectiveNullMarker = componentData.nullMarker !== undefined ? componentData.nullMarker : '__NULL__';
const effectiveEmptyStringMarker = componentData.emptyStringMarker !== undefined ? componentData.emptyStringMarker : '__EMPTY__';
// Only validate if both markers are enabled (non-empty strings)
if (effectiveNullMarker !== '' && effectiveEmptyStringMarker !== '' && effectiveNullMarker === effectiveEmptyStringMarker) {
errors.push('nullMarker and emptyStringMarker cannot be the same value');
}
// Check for standalone mode configuration issues
if (componentData.standalone === true) {
if (!componentData.columns || !Array.isArray(componentData.columns) || componentData.columns.length === 0) {
errors.push("When 'standalone' is true, 'columns' must be specified.");
}
}
// Check for incompatible config combinations
if (componentData.activateDraft === true && componentData.createActiveEntity === true) {
errors.push("'activateDraft' and 'createActiveEntity' cannot both be true - they are mutually exclusive.");
}
// Check for read sheet configuration
if (componentData.readAllSheets === true && componentData.readSheet !== 0) {
warnings.push("'readAllSheets' is true, but 'readSheet' is also specified. 'readSheet' will be ignored.");
}
// Validate column configuration
if (componentData.columns && componentData.excludeColumns) {
const columnsSet = new Set(componentData.columns);
const excludedInColumns = componentData.excludeColumns.filter((col: string) => columnsSet.has(col));
if (excludedInColumns.length > 0) {
warnings.push(`Columns found in both 'columns' and 'excludeColumns': ${excludedInColumns.join(', ')}. These will be excluded.`);
}
}
// Check batch size for updates
if (componentData.action === 'UPDATE' && componentData.batchSize > 100) {
warnings.push('For UPDATE operations, batch size is limited to 100. Your configured value will be capped.');
}
// Validate spreadsheetTemplateFile with related config options
if (componentData.spreadsheetTemplateFile && !componentData.skipColumnsCheck) {
warnings.push("When using 'spreadsheetTemplateFile', it's recommended to set 'skipColumnsCheck' to true to avoid errors with custom columns.");
}
// Validate field match type
if (componentData.fieldMatchType && componentData.fieldMatchType !== 'label' && componentData.fieldMatchType !== 'labelTypeBrackets') {
errors.push(`Invalid 'fieldMatchType' value: '${componentData.fieldMatchType}'. Valid options are 'label' or 'labelTypeBrackets'.`);
}
// Validate decimal separator
if (componentData.decimalSeparator && componentData.decimalSeparator !== '.' && componentData.decimalSeparator !== ',') {
errors.push(`Invalid 'decimalSeparator' value: '${componentData.decimalSeparator}'. Valid options are '.' or ','.`);
}
// Handle configuration issues
if (errors.length > 0) {
const errorMessage = 'Spreadsheet Importer Configuration Errors:\n' + errors.join('\n');
if (warnings.length > 0) {
const warningText = '\nWarnings:\n' + warnings.join('\n');
Log.error(errorMessage + warningText, undefined, 'SpreadsheetUpload: Configuration');
} else {
Log.error(errorMessage, undefined, 'SpreadsheetUpload: Configuration');
}
return false;
} else if (warnings.length > 0) {
const warningMessage = 'Spreadsheet Importer Configuration Warnings:\n' + warnings.join('\n');
Log.warning(warningMessage, undefined, 'SpreadsheetUpload: Configuration');
return true;
}
return true;
}
/**
* Creates a deep copy of an XLSX workbook. This prevents accidental mutation of the original
* workbook object when downstream code (e.g. import wizard) renames attributes or changes cell values.
*/
static deepCopyWorkbook(workbook: XLSX.WorkBook): XLSX.WorkBook {
// Fast path: if workbook has no sheets return shallow clone
if (!workbook || !workbook.SheetNames || workbook.SheetNames.length === 0) {
return XLSX.utils.book_new();
}
// Create a binary string of the workbook and read it back – simplest reliable deep-clone
const binary = XLSX.write(workbook, { bookType: 'xlsx', type: 'binary' });
return XLSX.read(binary, { type: 'binary' });
}
}