@c8y/ngx-components
Version:
Angular modules for Cumulocity IoT applications
1 lines • 185 kB
Source Map (JSON)
{"version":3,"file":"c8y-ngx-components-datapoints-export-selector.mjs","sources":["../../datapoints-export-selector/datapoints-export-selector.model.ts","../../datapoints-export-selector/datapoints-export-selector-modal/datapoints-export-selector-file-exporter/utils.service.ts","../../datapoints-export-selector/datapoints-export-selector-modal/datapoints-export-selector-file-exporter/data-fetching.service.ts","../../datapoints-export-selector/datapoints-export-selector-modal/datapoints-export-selector-file-exporter/datapoints-export-selector-file-exporter.service.ts","../../datapoints-export-selector/datapoints-export-selector-modal/datapoints-export-selector-file-exporter/datapoints-export-selector-preview/datapoints-export-selector-preview.component.ts","../../datapoints-export-selector/datapoints-export-selector-modal/datapoints-export-selector-file-exporter/datapoints-export-selector-preview/datapoints-export-selector-preview.component.html","../../datapoints-export-selector/datapoints-export-selector-modal/datapoints-export-selector-file-exporter/datapoints-exports-selector-data-scope/datapoints-exports-selector-data-scope.component.ts","../../datapoints-export-selector/datapoints-export-selector-modal/datapoints-export-selector-file-exporter/datapoints-exports-selector-data-scope/datapoints-exports-selector-data-scope.component.html","../../datapoints-export-selector/datapoints-export-selector-modal/datapoints-export-selector-file-exporter/datapoints-exports-selector-file-types/datapoints-exports-selector-file-types.component.ts","../../datapoints-export-selector/datapoints-export-selector-modal/datapoints-export-selector-file-exporter/datapoints-exports-selector-file-types/datapoints-exports-selector-file-types.component.html","../../datapoints-export-selector/datapoints-export-selector-modal/datapoints-export-selector-file-exporter/datapoints-exports-selector-time-range/datapoints-exports-selector-time-range.component.ts","../../datapoints-export-selector/datapoints-export-selector-modal/datapoints-export-selector-file-exporter/datapoints-exports-selector-time-range/datapoints-exports-selector-time-range.component.html","../../datapoints-export-selector/datapoints-export-selector-modal/datapoints-export-selector-file-exporter/generators/csv-generator.ts","../../datapoints-export-selector/datapoints-export-selector-modal/datapoints-export-selector-file-exporter/generators/excel-generator.ts","../../datapoints-export-selector/datapoints-export-selector-modal/datapoints-export-selector-file-exporter/datapoints-export-selector-file-exporter.component.ts","../../datapoints-export-selector/datapoints-export-selector-modal/datapoints-export-selector-file-exporter/datapoints-export-selector-file-exporter.component.html","../../datapoints-export-selector/datapoints-export-selector-modal/datapoints-export-selector-file-exporter/data-processing.service.ts","../../datapoints-export-selector/datapoints-export-selector-modal/datapoints-export-selector-modal.component.ts","../../datapoints-export-selector/datapoints-export-selector-modal/datapoints-export-selector-modal.component.html","../../datapoints-export-selector/datapoints-export-selector.component.ts","../../datapoints-export-selector/datapoints-export-selector.component.html","../../datapoints-export-selector/c8y-ngx-components-datapoints-export-selector.ts"],"sourcesContent":["import { gettext } from '@c8y/ngx-components/gettext';\nimport { AggregationOption } from '@c8y/ngx-components';\nimport { Measurement } from './datapoints-export-selector-modal/datapoints-export-selector-file-exporter/utils.service';\nimport { SupportedIconsSuggestions } from '@c8y/ngx-components/icon-selector/icons';\nexport const HAS_ERROR = 'has-error';\nexport const MEASUREMENTS_PREVIEW_ITEMS_LIMIT = 5;\nexport const SERIES_DATA_MERGED_FILE_NAME = 'seriesDataMergedFileName';\n\n/**\n * Represents counts of datapoints data categorized by their availability and delivery method.\n */\nexport interface DatapointCounts {\n /**\n * The number of datapoints whose data can be directly downloaded\n * through a web browser.\n */\n browserDownloadableCount: number;\n /**\n * The number of datapoints whose data will be sent via email because its record count exceeded 50_000.\n */\n emailDeliverableCount: number;\n /**\n * The number of datapoints whose data cannot be retrieved at all,\n * neither through direct download nor via email because its record count exceeded 1_000_000 API limit.\n */\n nonRetrievableCount: number;\n}\n\n/**\n * Represents a datapoints which number of records exceed a threshold where data will be precessed by a backend.\n */\nexport interface DatapointsExceedingLimit {\n datapointDetail: DatapointDetails;\n totalElements: number;\n}\n\nexport interface DateFetchConfig {\n date: string;\n shouldFetchData: boolean;\n}\n\nexport interface IDataTransformer {\n transformToMergedFormat(data: ExportData[]): TimeSeriesColumnData;\n}\n\nexport interface FileGenerator {\n generateMerged?: (exportData: ExportData[], mergedExportDetails: MergedExportDetails) => BlobPart;\n getFileExtension(): string;\n getMimeType(): string;\n getLabel(): string;\n getIcon(): string;\n getType(): string;\n getTitle(): string;\n getZipName(): string;\n getAcceptType(): string;\n}\n\nexport interface FileTypeMetadata {\n extension: string;\n icon: SupportedIconsSuggestions;\n label: string;\n title: string;\n type: string;\n zipName: string;\n}\n\n/**\n * Represents the data structure, which is used to be transformed into a file (series) or used for a preview (series and measurements).\n */\nexport interface ExportData {\n time?: string | null;\n source: string | number;\n device_name: string;\n fragment_series: string;\n /**\n * Represents the value of a measurement for /measurement data API.\n */\n value?: number | null;\n /**\n * Represents the min value of a measurement for /series data API.\n */\n value_min?: number | null;\n /**\n * Represents the value of a measurement for /series data API.\n * Measurement API data does not contain max value.\n */\n value_max?: number | null;\n unit?: string | null;\n}\n\nexport interface DatapointDetails {\n deviceName?: string;\n source: string | number;\n valueFragmentSeries: string;\n valueFragmentType: string;\n}\n\n/**\n * Represents the data structure, which is used to be\n * transformed into an ExportData.\n */\nexport interface DataToExport extends DatapointDetails {\n unit: string | undefined;\n timeValueMap: { [timestamp: string]: Measurement } | undefined;\n}\n/**\n * Represents the data to be exported along with the backend-created file.\n * Only measurements API generates a file on the backend.\n */\nexport interface DataToExportWithBackendCreatedFile {\n source: string | number;\n valueFragmentSeries: string;\n valueFragmentType: string;\n fetchedMeasurementsBlobFile: Blob;\n}\n/**\n * Base configuration for the export process.\n */\nexport interface ExportConfig {\n aggregation?: AggregationOption;\n datapointDetails: DatapointDetails[];\n dateFrom: string;\n dateTo: string;\n}\n/**\n * Represents the configuration on the basis of which the zip file is created.\n */\nexport interface FileExportConfig {\n fileType: string;\n zipName: string;\n}\n/**\n * Represents a required config properties used in a process of generating a measurements based export file.\n */\nexport interface MeasurementFileConfig {\n exportConfig: ExportConfig;\n acceptFileType: string;\n}\n/**\n * Represents a required config properties along with transformed data structures,\n * used in a process of generating a series based export file.\n */\nexport interface SeriesExportParams {\n flattenedAndSortedExportData: ExportData[];\n fileType: string;\n mergedExportDetails: MergedExportDetails;\n}\n\nexport interface TimeSeriesColumnData {\n timeSeries: Map<TimeStamp, ColumnValueMap>;\n uniqueColumnIdentifiers: string[];\n}\n/**\n * Represents a mapping of datapoints series values.\n * The key of the map is a source, and the value is the value data from requested series.\n */\nexport type DatapointsValuesDataMap = Map<SourceId | number, string[]>;\n\nexport type SourceId = string | number;\n\nexport type FileCompressionTypes = 'STORE' | 'DEFLATE';\n\nexport type TimeSeriesData = {\n [timestamp: string]: Measurement;\n};\n\nexport interface FileTypeConfig {\n extension: string;\n mimeType: string;\n acceptType: string;\n}\n\n/**\n * Represents the details unique for a merged file.\n */\nexport type MergedExportDetails = {\n aggregation: AggregationOption;\n dateFrom: string;\n dateTo: string;\n};\n\nexport type ExportedFile = {\n fileName: string;\n fileData: Blob;\n};\n\nexport type TimeStamp = string;\n\nexport type ReadingValue = number;\n/**\n * Represents the min and max values for a specific timestamp.\n * Both min and max values are available only when using series API.\n */\nexport type MinMaxValues = {\n min: number;\n max: number;\n};\n\n/**\n * Represents a merged export column header,\n * e.g. 'Temperature - 5 -> c8y_Temperature.T [ºC] (max)'\n */\ntype ColumnHeader = string;\n/**\n * Represents a mapping of column headers and their corresponding values.\n * e.g. 'Temperature - 5 -> c8y_Temperature.T [ºC] (max)': 25\n */\nexport type ColumnValueMap = {\n [key: ColumnHeader]: ReadingValue;\n};\n\nexport const EXPORT_MODE_LABELS = {\n FULL: gettext('Full`export type`') as 'Full',\n COMPACT: gettext('Compact`export type`') as 'Compact'\n} as const;\n\n/**\n * Each export type is based on a different API:\n * - COMPACT - series\n * - FULL - measurements\n * All differences between export modes:\n * Compact:\n * Processes up to 5,000 records per data point, or up to the data retention limit (API limit)\n * Creates a single merged file containing all the data\n * Provides minimum and maximum values (API feature)\n * Preview is not available\n * Supports optional data aggregation (API feature)\n * Full:\n * Processes up to 1,000,000 records per data point, or up to the data retention limit (API limit)\n * For exports exceeding 50,000 records, data will be sent via email\n * Creates a compressed ZIP file containing separate data files for each selected data point\n * Preview is available\n * Does not support data aggregation\n */\nexport const EXPORT_MODE_VALUES = {\n full: 'FULL',\n compact: 'COMPACT'\n} as const;\n\nexport const FILE_COMPRESSION_TYPES_VALUES = {\n store: 'STORE',\n deflate: 'DEFLATE'\n} as const;\n\nexport const PRODUCT_EXPERIENCE_DATAPOINTS_EXPORT_SELECTOR = {\n EVENTS: {\n EXPORT_SELECTOR: 'exportSelector'\n },\n COMPONENTS: {\n DATAPOINTS_EXPORT_SELECTOR: 'datapoints-export-selector',\n DATAPOINTS_EXPORT_SELECTOR_FILE_EXPORTER: 'datapoints-export-selector-file-exporter'\n },\n ACTIONS: {\n OPEN_MODAL: 'openModal',\n DOWNLOAD_STARTED: 'downloadStarted'\n },\n EXPORT_CONFIG: {\n FULL_EXPORT_TYPE: 'fullExportType',\n COMPACT_EXPORT_TYPE: 'compactExportType'\n }\n} as const;\n","import { Injectable } from '@angular/core';\n\nexport type Measurement = number | { min: number; max: number };\n\nexport interface SeriesInfo {\n unit: string | null | undefined;\n name: string;\n type: string | null | undefined;\n}\n\nexport interface SourceData {\n series: SeriesInfo[];\n values: {\n [timestamp: string]: (Measurement | null)[] | undefined;\n };\n truncated: boolean;\n}\n\nexport interface SourceItem {\n source: string | number;\n data: SourceData;\n}\n\nexport interface TransformedSeries {\n values: { [timestamp: string]: Measurement } | null;\n seriesDetails: SeriesInfo;\n}\n\nexport interface TransformedSource {\n [seriesType: string]: TransformedSeries;\n}\n\nexport interface TransformedData {\n [sourceId: string]: TransformedSource;\n}\n\n@Injectable({ providedIn: 'root' })\nexport class UtilsService {\n transformDataStructure(data: SourceItem[]): TransformedData {\n const result: TransformedData = {};\n\n data.forEach(sourceItem => {\n const { source: sourceId, data: sourceData } = sourceItem;\n result[sourceId] = {};\n\n sourceData.series.forEach((seriesInfo, index) => {\n const { type, name, unit } = seriesInfo;\n /**\n * Unique key to distinguish between different series from same source,\n * e.g.: c8y_Acceleration.accelerationX, c8y_Acceleration.accelerationY, c8y_Acceleration.accelerationZ\n */\n const seriesKey = `${type}.${name}`;\n\n result[sourceId][seriesKey] = {\n values: {},\n seriesDetails: { name, unit, type }\n };\n\n let hasValues = false;\n Object.entries(sourceData.values).forEach(([timestamp, measurements]) => {\n if (!measurements) {\n return;\n }\n const measurement = measurements[index];\n\n if (measurement !== null && measurement !== undefined) {\n result[sourceId][seriesKey].values[timestamp] = measurement;\n hasValues = true;\n }\n });\n\n if (!hasValues) {\n result[sourceId][seriesKey].values = null;\n }\n });\n });\n\n return result;\n }\n\n /**\n * Formats a date range into a specific string format, handling UTC dates correctly.\n *\n * @param fromDate - The start date in ISO format (e.g., \"2023-12-04T12:40:00.000Z\")\n * @param toDate - The end date in ISO format (e.g., \"2023-12-06T23:50:00.000Z\")\n * @returns A string representing the date range in the format \"ddmmmyyhhmm-ddmmmyyhhmm\"\n * where d=day, m=month, y=year, h=hour, m=minute\n *\n * Example\n * ```typescript\n * const fromDate = \"2023-12-04T12:40:00.000Z\";\n * const toDate = \"2023-12-06T23:50:00.000Z\";\n * const formattedRange = getFormattedDateRange(fromDate, toDate);\n * console.log(formattedRange); // Output: \"04dec231240-06dec232350\"\n * ```\n */\n getFormattedDateRange(fromDate: string, toDate: string): string {\n const formatDate = (dateString: string): string => {\n const date = new Date(dateString);\n if (isNaN(date.getTime())) {\n throw new Error(`Invalid date string: ${dateString}`);\n }\n const day = date.getUTCDate().toString().padStart(2, '0');\n const month = date.toLocaleString('en', { month: 'short', timeZone: 'UTC' }).toLowerCase();\n const hours = date.getUTCHours().toString().padStart(2, '0');\n const minutes = date.getUTCMinutes().toString().padStart(2, '0');\n const seconds = date.getUTCSeconds().toString().padStart(2, '0');\n return `${day}${month}${hours}${minutes}${seconds}`;\n };\n\n return `${formatDate(fromDate)}-${formatDate(toDate)}`;\n }\n}\n","import { Injectable } from '@angular/core';\nimport {\n aggregationType,\n IFetchResponse,\n IMeasurement,\n IMeasurementFilter,\n IResult,\n IResultList,\n ISeries,\n ISeriesFilter,\n MeasurementService\n} from '@c8y/client';\nimport { gettext } from '@c8y/ngx-components/gettext';\nimport { AGGREGATION_VALUES, AlertService } from '@c8y/ngx-components';\nimport { TranslateService } from '@ngx-translate/core';\nimport {\n DatapointDetails,\n DatapointsExceedingLimit,\n DatapointsValuesDataMap,\n DataToExport,\n DataToExportWithBackendCreatedFile,\n ExportConfig,\n MeasurementFileConfig,\n MEASUREMENTS_PREVIEW_ITEMS_LIMIT,\n TimeSeriesData\n} from '../../datapoints-export-selector.model';\nimport { SourceItem, UtilsService } from './utils.service';\n\n@Injectable({\n providedIn: 'root'\n})\nexport class DataFetchingService {\n constructor(\n private alertService: AlertService,\n private measurementService: MeasurementService,\n private translateService: TranslateService,\n private utilsService: UtilsService\n ) {}\n\n /**\n * Checks if any of measurements requests may exceeded the limit,\n * after which the export data is processed by the backend and the generated CSV/Excel file is sent by email.\n *\n * The threshold is set to 50_000 records in application's core properties (export.data.synchronous.limit).\n *\n * @param exportConfig - The export configuration.\n * @returns A promise that returns an array of objects representing datapoints files that will be sent by email.\n */\n async getDatapointsExceedingLimit(\n exportConfig: ExportConfig\n ): Promise<DatapointsExceedingLimit[]> {\n const backendProcessingLimit = 50_000;\n\n const promises: Promise<DatapointsExceedingLimit>[] = exportConfig.datapointDetails.map(\n async details => {\n try {\n const filter: IMeasurementFilter = {\n dateFrom: exportConfig.dateFrom,\n dateTo: exportConfig.dateTo,\n pageSize: 1,\n source: details.source,\n valueFragmentSeries: details.valueFragmentSeries,\n valueFragmentType: details.valueFragmentType,\n withTotalElements: true\n };\n\n const response = await this.measurementService.list(filter);\n\n if (response?.paging?.totalElements > backendProcessingLimit) {\n return {\n datapointDetail: {\n source: details.source,\n valueFragmentSeries: details.valueFragmentSeries,\n valueFragmentType: details.valueFragmentType\n },\n totalElements: response.paging.totalElements\n };\n } else {\n return null;\n }\n } catch (error) {\n this.alertService.addServerFailure(error);\n return null;\n }\n }\n );\n\n const results: DatapointsExceedingLimit[] = await Promise.all(promises);\n\n const datapointsExceedingLimit = results.filter(result => result !== null);\n\n return datapointsExceedingLimit;\n }\n\n /**\n * Retrieves the message to be displayed when the limit of datapoints is exceeded during file export.\n *\n * @param hasNoExportableData - Indicates if there is no exportable data because the date range of all selected datapoints exceeds 1,000,000 records.\n * @param emailDeliverableCount - The number of datapoint exports that exceed the limit, will be proceeded by the backend and sent by email.\n * @param browserDownloadableCount - The number of datapoint exports that can be downloaded directly.\n * @param skippedDatapointCount - The number of datapoint exports skipped due to exceeding the measurements API limit of 1,000,000 records.\n * @param totalDatapointsSelectedForExportCount - Total number of datapoint selected for exports.\n * @returns The message that can be injected.\n */\n getLimitExceededMessage(\n hasNoExportableData: boolean,\n emailDeliverableCount?: number,\n browserDownloadableCount?: number,\n nonRetrievableCount?: number,\n totalDatapointsSelectedForExportCount?: number\n ): string {\n if (hasNoExportableData) {\n const message = this.translateService.instant(\n gettext(\n `The data for selected datapoint(s) exceed 1,000,000 records, which is the limit for backend processing. To export this data, please reduce the date range.`\n )\n );\n return message;\n }\n\n const message = this.translateService.instant(\n gettext(\n `After clicking \"Download\":<br>\n <ul>\n <li><strong>{{$browserDownloadableCount}}</strong> datapoint(s) exports will be downloaded directly within one file: <em>exported_[csv/excel].zip</em></li>\n <li><strong>{{$emailDeliverableCount}}</strong> datapoint(s) exports require further processing. The files will be sent to you via separate emails once completed, which may take some time.</li>\n <li><strong>{{$nonRetrievableCount}}</strong> datapoint(s) exports exceeded 1,000,000 records, which is the limit for backend processing. To export these data, please reduce the date range. Otherwise, the data will neither be downloaded nor sent via email.</li>\n </ul>\n <p>The total number of data points that can be exported is: <strong>{{$totalExportableDatapointsCount}} out of {{$totalDatapointsSelectedForExportCount}}</strong>.</p>\n <p><strong>Note:</strong> The file name convention of files within zip file is: <code>[source]_[fragment_series].[csv/xls]</code></p>`\n ),\n {\n $browserDownloadableCount: browserDownloadableCount,\n $emailDeliverableCount: emailDeliverableCount,\n $nonRetrievableCount: nonRetrievableCount,\n $totalDatapointsSelectedForExportCount: totalDatapointsSelectedForExportCount,\n $totalExportableDatapointsCount: browserDownloadableCount + emailDeliverableCount\n }\n );\n\n const trimmedMessage = this.removeZeroCountListItems(message, [\n browserDownloadableCount,\n emailDeliverableCount,\n nonRetrievableCount\n ]);\n\n return trimmedMessage;\n }\n\n /**\n * Trims the given HTML message by removing list items that correspond to zero counts.\n *\n * @param message - The HTML string containing the message with list items.\n * @param counts - An array of number values corresponding to each list item.\n * @param countToTrim - A count that will be trimmed with corresponding list item.\n * @returns A trimmed HTML string with list items removed where the corresponding count is zero.\n *\n * Example:\n * ```typescript\n * const message = '<ul><li>Item 1</li><li>Item 2</li><li>Item 3</li></ul>';\n * const counts = [1, 0, 2];\n * const trimmedMessage = this.removeZeroCountListItems(message, counts);\n * // Result: '<ul><li>Item 1</li><li>Item 3</li></ul>'\n * ```\n */\n removeZeroCountListItems(message: string, counts: number[], countToTrim = 0): string {\n const parser = new DOMParser();\n const doc = parser.parseFromString(message, 'text/html');\n const listItems = doc.querySelectorAll('ul li');\n\n listItems.forEach((item, index) => {\n if (counts[index] === countToTrim) {\n item.remove();\n }\n });\n\n return doc.body.innerHTML;\n }\n\n /**\n * Displays an information alert about sending data via email.\n *\n * Only measurements API can send files via email.\n *\n * @param fileType - The type of file to be sent.\n * @param datapointsExceedingLimit - The array of datapoints exceeding the limit.\n */\n showSendViaEmailInfoAlert(\n fileType: string,\n datapointsExceedingLimit: DatapointsExceedingLimit[]\n ): void {\n const messageTemplate = this.translateService.instant(\n gettext(`You will get {{$fileTypeText}} file(s) for {{$count}} series via email.`),\n { $count: datapointsExceedingLimit.length, $fileTypeText: fileType }\n );\n const formattedList = datapointsExceedingLimit\n .map(\n datapoint =>\n `• ${datapoint.datapointDetail.source}_${datapoint.datapointDetail.valueFragmentType}_${datapoint.datapointDetail.valueFragmentSeries}`\n )\n .join('\\n');\n\n this.alertService.info(messageTemplate, formattedList);\n }\n\n async fetchMeasurementDataFilesAndPairWithSourceDetails(\n acceptFileType: string,\n exportConfig: ExportConfig\n ): Promise<DataToExportWithBackendCreatedFile[]> {\n const measurementFileConfig: MeasurementFileConfig = {\n exportConfig,\n acceptFileType\n };\n const filePromises = exportConfig.datapointDetails.map(details =>\n this.fetchAndProcessMeasurementFile(details, measurementFileConfig)\n );\n return Promise.all(filePromises);\n }\n\n async fetchAndProcessMeasurementFile(\n details: DatapointDetails,\n measurementFileConfig: MeasurementFileConfig,\n roundSeconds = false\n ): Promise<DataToExportWithBackendCreatedFile | undefined> {\n const filter: IMeasurementFilter = this.prepareMeasurementsFilter(\n details,\n measurementFileConfig.exportConfig,\n roundSeconds\n );\n const header = { accept: measurementFileConfig.acceptFileType };\n\n try {\n const measurementFileResponse: IFetchResponse =\n await this.measurementService.getMeasurementsFile(filter, header);\n\n // If the status is 200, then the file is in the response.\n // If the status is 202, then the file is being processed by the backend and will be sent by email.\n if (measurementFileResponse.status === 200) {\n return this.mergeMeasurementsWithItsSourceDetails(details, measurementFileResponse);\n }\n } catch (error) {\n this.alertService.addServerFailure(error);\n }\n }\n\n async mergeMeasurementsWithItsSourceDetails(\n details: DatapointDetails,\n measurementFile: IFetchResponse\n ): Promise<DataToExportWithBackendCreatedFile> {\n return {\n source: details.source,\n valueFragmentSeries: details.valueFragmentSeries,\n valueFragmentType: details.valueFragmentType,\n fetchedMeasurementsBlobFile: await measurementFile.blob()\n };\n }\n\n async fetchAndPrepareDataToExport(\n exportConfig: ExportConfig,\n isMeasurement: boolean\n ): Promise<DataToExport[]> {\n return isMeasurement\n ? await this.fetchAndPrepareMeasurementDataToExportForPreview(exportConfig)\n : await this.fetchAndPrepareSeriesDataToExport(exportConfig);\n }\n\n prepareMeasurementsFilter(\n details: DatapointDetails,\n exportConfig: ExportConfig,\n roundSeconds: boolean,\n pageSize?: number\n ): IMeasurementFilter {\n const filter: IMeasurementFilter = {\n dateFrom: this.adjustDate(exportConfig.dateFrom, 0, roundSeconds),\n dateTo: this.adjustDate(exportConfig.dateTo, 0, roundSeconds),\n source: details.source,\n valueFragmentSeries: details.valueFragmentSeries,\n valueFragmentType: details.valueFragmentType\n };\n\n if (pageSize) {\n filter.pageSize = pageSize;\n }\n\n return filter;\n }\n\n processMeasurementDataForPreview(details: DatapointDetails, data: IMeasurement[]): DataToExport {\n const unit = data[0][details.valueFragmentType][details.valueFragmentSeries]?.unit || '';\n\n const values: { [timestamp: string]: number } = {};\n data.forEach(measurement => {\n values[measurement.time] =\n measurement[details.valueFragmentType][details.valueFragmentSeries].value;\n });\n\n return { ...details, unit, timeValueMap: values };\n }\n\n /**\n * Returns a map of active data points device IDs with their corresponding series.\n *\n * Example output:\n * ```typescript\n * new Map([\n * ['844657202', ['c8y_Temperature.T']],\n * ['32666427', ['c8y_Battery.Battery']]\n * ]);\n * ```\n * @param datapointDetails - An array of data points details.\n * @returns A map where the key is the data point ID and the value is an array of data point series.\n */\n groupSeriesByDeviceId(datapointDetails: DatapointDetails[]): DatapointsValuesDataMap {\n return datapointDetails.reduce(\n (\n map: DatapointsValuesDataMap,\n { valueFragmentType, valueFragmentSeries, source }: DatapointDetails\n ) => {\n const value = `${valueFragmentType}.${valueFragmentSeries}`;\n const existingValue = map.get(source) ?? [];\n map.set(source, [...existingValue, value]);\n return map;\n },\n new Map<string, string[]>()\n );\n }\n\n /**\n * Processes the fetched series data and prepares it for export.\n *\n * @param datapointDetails - An array of data point details.\n * @param fetchedDataMap - A map of fetched series data grouped by source.\n * @returns An array of DataToExport objects.\n */\n processSeriesData(\n datapointDetails: DatapointDetails[],\n fetchedDataMap: SourceItem[]\n ): DataToExport[] {\n const valuesGroupedBySource = this.utilsService.transformDataStructure(fetchedDataMap);\n\n return datapointDetails.map((details: DatapointDetails) => {\n let unit: string | undefined;\n let data: TimeSeriesData;\n\n /**\n * Unique key to distinguish between different series from same source,\n * e.g.: c8y_Acceleration.accelerationX, c8y_Acceleration.accelerationY, c8y_Acceleration.accelerationZ\n */\n const seriesKey = `${details.valueFragmentType}.${details.valueFragmentSeries}`;\n\n if (valuesGroupedBySource[details.source][seriesKey]) {\n unit = valuesGroupedBySource[details.source][seriesKey].seriesDetails.unit;\n data = valuesGroupedBySource[details.source][seriesKey].values;\n }\n\n return {\n ...details,\n unit,\n timeValueMap: data\n };\n });\n }\n\n async getSourcesWithPermissionsToRead(datapointDetails: DatapointDetails[]): Promise<string[]> {\n const dateFrom = new Date();\n const dateTo = new Date(dateFrom);\n\n const fetchedDataPromises = datapointDetails.map(async datapoint => {\n const rawFilter: ISeriesFilter = {\n dateFrom,\n dateTo,\n source: datapoint.source\n };\n\n try {\n const { res }: IResult<ISeries> = await this.fetchSeriesData(rawFilter);\n if (res?.status === 200) {\n return datapoint.source;\n }\n return null;\n } catch (error) {\n // If an error occurs during fetch, return null to prevent promise rejection\n return null;\n }\n });\n\n const fetchedSources = await Promise.all(fetchedDataPromises);\n return fetchedSources.filter((source): source is string => source !== null);\n }\n\n /**\n * Adjusts the given date by adding the specified number of minutes and setting seconds to 0.\n *\n * @param date - The date to be adjusted in string format.\n * @param minutes - The number of minutes to add to the date.\n * @param roundSeconds - Whether to round the seconds or not.\n * If true, the seconds will be rounded to 0.\n * If false, the seconds will be displayed as they are.\n * @returns The adjusted date in ISO string format.\n */\n adjustDate(date: string | Date, minutes: number, roundSeconds = true): string {\n const dateTime = new Date(date);\n if (isNaN(dateTime.getTime())) {\n throw new Error('Invalid date');\n }\n\n dateTime.setUTCMinutes(dateTime.getUTCMinutes() + minutes);\n if (roundSeconds) {\n dateTime.setUTCSeconds(0, 0);\n }\n\n return dateTime.toISOString();\n }\n\n /**\n * Asynchronously loads series data based on the provided parameters.\n *\n * This method constructs a filter for retrieving series data within a specified date range,\n * from a specific source, and optionally applying an aggregation type.\n *\n * @param rawFilter - The parameters for loading series data, including date range, source, series names, and aggregation type.\n * @param roundSeconds - Indicates whether to round the seconds in the date range to the nearest whole number.\n * @returns A promise that resolves to series data wrapped in result object.\n */\n async fetchSeriesData(\n rawFilter: ISeriesFilter,\n roundSeconds = false\n ): Promise<IResult<ISeries>> | undefined {\n let seriesData: IResult<ISeries> | undefined;\n\n const filter: ISeriesFilter = this.prepareSeriesFilter(\n {\n dateFrom: rawFilter.dateFrom,\n dateTo: rawFilter.dateTo,\n source: rawFilter.source,\n series: rawFilter.series,\n aggregationType: rawFilter.aggregationType\n },\n roundSeconds\n );\n\n try {\n seriesData = await this.measurementService.listSeries(filter);\n } catch (error) {\n this.alertService.addServerFailure(error);\n }\n\n if (seriesData?.res?.status === 422) {\n throw {\n status: seriesData.res.status,\n message: seriesData.data['message'],\n data: seriesData.data\n };\n }\n\n return seriesData;\n }\n\n /**\n * Fetches and prepares measurement data for preview.\n *\n * Empty DataToExport object can be returned, because unlike series data,\n * measurement data is not further processed besides showing only in the preview.\n * CSV/Excel files are generated by the backend for measurements.\n *\n * @param exportConfig - The export configuration.\n * @param roundSeconds - Indicates whether to round the seconds in the date range to the nearest whole number.\n * @returns A promise that resolves to an array of DataToExport objects or null when no data is fetched.\n */\n private async fetchAndPrepareMeasurementDataToExportForPreview(\n exportConfig: ExportConfig,\n roundSeconds = false\n ): Promise<DataToExport[]> | null {\n const dataToExportPromises: Promise<DataToExport>[] = exportConfig.datapointDetails.map(\n async details => {\n const fetchedMeasurementData: IResultList<IMeasurement> =\n await this.fetchMeasurementDataForPreview(details, exportConfig, roundSeconds);\n\n if (fetchedMeasurementData?.data?.length) {\n return this.processMeasurementDataForPreview(details, fetchedMeasurementData.data);\n }\n return null;\n }\n );\n\n return await Promise.all(dataToExportPromises);\n }\n\n private async fetchMeasurementDataForPreview(\n details: DatapointDetails,\n exportConfig: ExportConfig,\n roundSeconds: boolean\n ): Promise<IResultList<IMeasurement>> | undefined {\n let measurements: IResultList<IMeasurement> | undefined;\n\n const filter: IMeasurementFilter = this.prepareMeasurementsFilter(\n details,\n exportConfig,\n roundSeconds,\n MEASUREMENTS_PREVIEW_ITEMS_LIMIT\n );\n\n try {\n measurements = await this.measurementService.list(filter);\n } catch (error) {\n this.alertService.addServerFailure(error);\n }\n\n return measurements;\n }\n\n private async fetchAndPrepareSeriesDataToExport(exportConfig: ExportConfig) {\n const datapointsValuesDataMap: DatapointsValuesDataMap = this.groupSeriesByDeviceId(\n exportConfig.datapointDetails\n );\n\n const fetchedDataPromises = Array.from(datapointsValuesDataMap).map(\n async ([source, series]) => {\n const { dateFrom, dateTo, aggregation } = exportConfig;\n\n const rawFilter: ISeriesFilter = {\n dateFrom,\n dateTo,\n source,\n series,\n aggregationType: aggregation as aggregationType\n };\n\n const { data }: IResult<ISeries> = await this.fetchSeriesData(rawFilter);\n\n return { source, data };\n }\n );\n\n const fetchedDataGroupedBySource: SourceItem[] = await Promise.all(fetchedDataPromises);\n\n return this.processSeriesData(exportConfig.datapointDetails, fetchedDataGroupedBySource);\n }\n\n private prepareSeriesFilter(filters: ISeriesFilter, roundSeconds: boolean): ISeriesFilter {\n const { dateFrom, dateTo, source, series, aggregationType } = filters;\n const from = this.adjustDate(dateFrom, 0, roundSeconds);\n const to = this.adjustDate(dateTo, 0, roundSeconds);\n\n const filter: ISeriesFilter = {\n dateFrom: from,\n dateTo: to,\n source: source,\n revert: true\n };\n\n // The 'NONE' aggregation type is not handled by a backend, so even when is selected, it is not passed as a filter parameter.\n if (aggregationType && aggregationType !== (AGGREGATION_VALUES.none as aggregationType)) {\n filter.aggregationType = aggregationType;\n }\n\n if (series) {\n filter.series = series;\n }\n\n return filter;\n }\n}\n","import { Injectable } from '@angular/core';\nimport { gettext } from '@c8y/ngx-components/gettext';\nimport { AlertService } from '@c8y/ngx-components';\nimport { TranslateService } from '@ngx-translate/core';\nimport {\n DataToExport,\n DataToExportWithBackendCreatedFile,\n ExportData,\n ExportedFile,\n MergedExportDetails,\n SeriesExportParams\n} from '../../datapoints-export-selector.model';\nimport { DataProcessingService } from './data-processing.service';\n\n@Injectable({\n providedIn: 'root'\n})\nexport class DatapointsExportSelectorFileExporterService {\n /**\n * Cached data is reused when both CSV and Excel files were selected for export, used for merged file.\n */\n private cachedFlatteredAndSortedSeriesExportData: ExportData[];\n /**\n * Cached data is reused when both CSV and Excel files were selected for export, used for raw files.\n */\n private cachedRawExportSeriesData: ExportData[][];\n\n constructor(\n private alertService: AlertService,\n private datapointsExportSelectorDataProcessingService: DataProcessingService,\n private translateService: TranslateService\n ) {}\n\n async getMeasurementExportedDataBlob(\n extension: string,\n dataToExport: DataToExportWithBackendCreatedFile[]\n ): Promise<Blob | null> {\n try {\n return await this.getMeasurementDataZipBlob(\n extension,\n dataToExport as DataToExportWithBackendCreatedFile[]\n );\n } catch (error) {\n this.showZipCreationErrorAlert();\n\n return null;\n }\n }\n\n async getSeriesExportedDataBlob(\n fileType: string,\n dataToExport: DataToExport[],\n mergedExportDetails: MergedExportDetails\n ): Promise<Blob | null> {\n try {\n return await this.getSeriesDataBlob(\n fileType,\n dataToExport as DataToExport[],\n mergedExportDetails\n );\n } catch (error) {\n this.showZipCreationErrorAlert();\n\n return null;\n }\n }\n\n cleanupCachedData(): void {\n this.cachedRawExportSeriesData = null;\n this.cachedFlatteredAndSortedSeriesExportData = null;\n }\n\n private showZipCreationErrorAlert(): void {\n const alertMessage = this.translateService.instant(gettext('Could not create zip file.'));\n this.alertService.danger(alertMessage);\n\n return null;\n }\n\n private getMeasurementDataZipBlob(\n extension: string,\n dataToExportWithBackendCreatedFile: DataToExportWithBackendCreatedFile[]\n ): Promise<Blob | null> {\n const files: ExportedFile[] = [];\n\n this.createRawMeasurementExportedFiles(dataToExportWithBackendCreatedFile, extension, files);\n\n return this.datapointsExportSelectorDataProcessingService.zipFiles(files);\n }\n\n private createRawMeasurementExportedFiles(\n dataToExportWithBackendCreatedFile: DataToExportWithBackendCreatedFile[],\n fileExtension: string,\n files: ExportedFile[]\n ): void {\n dataToExportWithBackendCreatedFile.forEach(data => {\n if (data) {\n const fragmentSeries = `${data.valueFragmentType}_${data.valueFragmentSeries}`;\n\n const fileName = this.datapointsExportSelectorDataProcessingService.createFileName(\n data.source,\n fragmentSeries,\n fileExtension\n );\n\n const fileData = data.fetchedMeasurementsBlobFile;\n\n const exportedFile: ExportedFile = { fileName, fileData };\n\n files.push(exportedFile);\n }\n });\n }\n\n /**\n * Converts data to a specified file type and returns the generated blob.\n *\n * Unlike measurements, data must be transformed to an exportable file structure before exporting.\n *\n * @param fileType - The type of file to which the data points should be exported. This can be 'csv' or 'excel'.\n * @param dataToExport - An array of processed measurement data combined with the respective properties of the datapoint.\n * @param mergedExportDetails - The details for the merged export, contains date range and aggregation.\n * @returns A promise that resolves to the generated ZIP blob or null if an error occurs.\n */\n private async getSeriesDataBlob(\n fileType: string,\n dataToExport: DataToExport[],\n mergedExportDetails: MergedExportDetails\n ): Promise<Blob | null> {\n this.transformSeriesDataToExportableFileStructure(dataToExport);\n\n const exportParams: SeriesExportParams = {\n flattenedAndSortedExportData: this.cachedFlatteredAndSortedSeriesExportData,\n fileType,\n mergedExportDetails: mergedExportDetails\n };\n\n return await this.datapointsExportSelectorDataProcessingService.exportSeriesData(exportParams);\n }\n\n private transformSeriesDataToExportableFileStructure(dataToExport: DataToExport[]): void {\n if (!this.cachedRawExportSeriesData) {\n this.cachedRawExportSeriesData =\n this.datapointsExportSelectorDataProcessingService.transformToExportFileStructure(\n dataToExport\n );\n this.cachedFlatteredAndSortedSeriesExportData = this.cachedRawExportSeriesData\n .flat()\n .sort((a, b) => {\n return new Date(a.time).getTime() - new Date(b.time).getTime();\n });\n }\n }\n}\n","import { A11yModule } from '@angular/cdk/a11y';\nimport { Component, Input } from '@angular/core';\nimport { CoreModule } from '@c8y/ngx-components';\nimport { PopoverModule } from 'ngx-bootstrap/popover';\nimport {\n ExportData,\n MEASUREMENTS_PREVIEW_ITEMS_LIMIT\n} from '../../../datapoints-export-selector.model';\n\n@Component({\n selector: 'c8y-datapoints-export-selector-preview',\n templateUrl: './datapoints-export-selector-preview.component.html',\n standalone: true,\n imports: [A11yModule, CoreModule, PopoverModule]\n})\nexport class DataPointsExportSelectorPreviewComponent {\n @Input() hasFetchedDataAnyValuesToExport: boolean;\n @Input() isPreviewLoading: boolean;\n @Input() previewTableData: ExportData[];\n\n readonly MEASUREMENTS_PREVIEW_ITEMS_LIMIT = MEASUREMENTS_PREVIEW_ITEMS_LIMIT;\n}\n","<div class=\"p-t-16 p-l-16 p-r-16 m-b-0\">\n <div class=\"d-flex a-i-center\">\n <label\n class=\"m-b-0 d-flex a-i-center gap-4\"\n [title]=\"'Preview`of exported file`' | translate\"\n >\n {{ 'Preview`of exported file`' | translate }}\n <button\n class=\"btn-help\"\n [attr.aria-label]=\"'Help' | translate\"\n [popover]=\"popoverPreviewTemplate\"\n placement=\"right\"\n triggers=\"focus\"\n data-cy=\"preview--help\"\n container=\"body\"\n type=\"button\"\n [adaptivePosition]=\"true\"\n ></button>\n <ng-template #popoverPreviewTemplate>\n <span translate>\n <p>The preview shows the structure of the raw file from a single source.</p>\n <p>If no data is available, only the column headers are visible.</p>\n <p>\n The preview is limited to\n <b>{{ MEASUREMENTS_PREVIEW_ITEMS_LIMIT }}</b>\n records.\n </p>\n </span>\n </ng-template>\n </label>\n </div>\n <div\n class=\"table-responsive\"\n style=\"min-height: 275px\"\n >\n <table class=\"table\">\n <thead>\n <tr>\n <th>{{ 'Time' | translate }}</th>\n <th>{{ 'Source' | translate }}</th>\n <th>{{ 'Device name' | translate }}</th>\n <th>\n {{ 'Fragment and series' | translate }}\n </th>\n <th>{{ 'Value' | translate }}</th>\n <th>{{ 'Unit' | translate }}</th>\n </tr>\n </thead>\n <ng-container *ngIf=\"hasFetchedDataAnyValuesToExport || isPreviewLoading; else emptyState\">\n <ng-container *ngIf=\"!isPreviewLoading; else loading\">\n <tbody>\n <tr *ngFor=\"let row of previewTableData\">\n <td>{{ row.time }}</td>\n <td>{{ row.source }}</td>\n <td>{{ row.device_name }}</td>\n <td>\n {{ row.fragment_series }}\n </td>\n <td>{{ row.value }}</td>\n <td>{{ row.unit }}</td>\n </tr>\n </tbody>\n </ng-container>\n </ng-container>\n <ng-template #emptyState>\n <tbody>\n <tr>\n <td colspan=\"8\">\n <div class=\"d-col a-i-center\">\n <c8y-ui-empty-state\n [icon]=\"'search'\"\n [title]=\"'No data available.' | translate\"\n [horizontal]=\"true\"\n data-cy=\"datapoints-table-list--empty-state\"\n ></c8y-ui-empty-state>\n </div>\n </td>\n </tr>\n </tbody>\n </ng-template>\n <ng-template #loading>\n <tbody>\n <tr>\n <td colspan=\"8\">\n <c8y-loading></c8y-loading>\n </td>\n </tr>\n </tbody>\n </ng-template>\n </table>\n </div>\n</div>\n","import { Component, EventEmitter, Input, Output } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { CoreModule } from '@c8y/ngx-components';\nimport {\n AGGREGATION_LABELS,\n AGGREGATION_VALUES_ARR,\n AggregationOption,\n AggregationOptionStatus\n} from '@c8y/ngx-components/global-context';\nimport { PopoverModule } from 'ngx-bootstrap/popover';\nimport { EXPORT_MODE_LABELS, EXPORT_MODE_VALUES } from '../../../datapoints-export-selector.model';\nimport { DatapointsExportSelectorFileExporterComponent } from '../datapoints-export-selector-file-exporter.component';\n\n@Component({\n selector: 'c8y-datapoints-export-selector-data-scope',\n templateUrl: './datapoints-exports-selector-data-scope.component.html',\n standalone: true,\n imports: [CoreModule, FormsModule, PopoverModule]\n})\nexport class DataPointsExportSelectorDataScopeComponent {\n @Input() disabledAggregationOptions: AggregationOptionStatus;\n @Input() formGroup: ReturnType<DatapointsExportSelectorFileExporterComponent['createForm']>;\n @Output() onAggregationChange: EventEmitter<AggregationOption> =\n new EventEmitter<AggregationOption>();\n @Output() onExportTypeChange: EventEmitter<string> = new EventEmitter<string>();\n\n readonly AGGREGATION_LABELS = AGGREGATION_LABELS;\n readonly AGGREGATION_VALUES_ARR = AGGREGATION_VALUES_ARR;\n readonly EXPORT_MODE_LABELS = EXPORT_MODE_LABELS;\n readonly EXPORT_MODE_VALUES_ARR = [EXPORT_MODE_VALUES.full, EXPORT_MODE_VALUES.compact] as const;\n\n emitAggregationChange(aggregation: AggregationOption): void {\n this.onAggregationChange.emit(aggregation);\n }\n\n emitExportTypeChange(exportType: string): void {\n this.onExportTypeChange.emit(exportType);\n }\n}\n","<fieldset class=\"c8y-fieldset\">\n <legend class=\"d-flex a-i-center\">\n {{ 'Data scope' | translate }}\n <button\n class=\"btn-help\"\n [attr.aria-label]=\"'Help' | translate\"\n [popover]=\"popoverDataScopeTemplate\"\n placement=\"right\"\n triggers=\"focus\"\n container=\"body\"\n type=\"button\"\n data-cy=\"data-scope--help\"\n [adaptivePosition]=\"true\"\n ></button>\n <ng-template #popoverDataScopeTemplate>\n <p\n class=\"m-b-8\"\n translate\n >\n Choose export type from available options:\n </p>\n <p><strong translate>Compact</strong></p>\n <ul class=\"p-l-16\">\n <li translate>Up to 5,000 records per data point or data retention limit</li>\n <li translate>Single merged file for all data</li>\n <li translate>No preview available</li>\n <li translate>Optional data aggregation supported</li>\n </ul>\n <p><strong translate>Full</strong></p>\n <ul class=\"p-l-16\">\n <li translate>Up to 1,000,000 records per data point or data retention limit</li>\n <li translate>Email delivery if exceeds 50,000 records</li>\n <li translate>Separate files for each data point in ZIP format</li>\n <li translate>Preview available</li>\n <li translate>No data aggregation</li>\n </ul>\n </ng-template>\n </legend>\n <c8y-form-group class=\"m-b-8\">\n <label>\n {{ 'Export mode' | translate }}\n </label>\n <div\n class=\"c8y-select-wrapper\"\n [formGroup]=\"formGroup\"\n data-cy=\"data-scope--export-selector\"\n >\n <select\n class=\"form-control text-12\"\n [title]=\"'Export mode' | translate\"\n id=\"exportMode\"\n formControlName=\"exportMode\"\n (ngModelChange)=\"emitExportTypeChange($event)\"\n >\n <option\n *ngFor=\"let exportModeValue of EXPORT_MODE_VALUES_ARR\"\n [ngValue]=\"exportModeValue\"\n >\n {{ EXPORT_MODE_LABELS[exportModeValue] | translate }}\n </option>\n </select>\n </div>\n </c8y-form-group>\n <c8y-form-group class=\"m-b-8\">\n <label>\n {{ 'Aggregation' | translate }}\n </label>\n <div\n class=\"c8y-select-wrapper\"\n [formGroup]=\"formGroup\"\n data-cy=\"data-scope--aggregation-selector\"\n >\n <select\n class=\"form-control text-12\"\n [title]=\"'Aggregation' | translate\"\n id=\"aggregation\"\n formControlName=\"aggregation\"\n (ngModelChange)=\"emitAggregationChange($event)\"\n >\n <option\n *ngFor=\"let aggregationValue of AGGREGATION_VALUES_ARR\"\n [ngValue]=\"aggregationValue\"\n [disabled]=\"disabledAggregationOptions[aggregationValue]\"\n >\n {{ AGGREGATION_LABELS[aggregationValue] | translate }}\n </option>\n </select>\n </div>\n </c8y-form-group>\n</fieldset>\n","import { Component, Input } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { CoreModule } from '@c8y/ngx-components';\nimport { FileTypeMetadata } from '../../../datapoints-export-selector.model';\nimport { DatapointsExportSelectorFileExporterComponent } from '../datapoints-export-selector-file-exporter.component';\n\n@Component({\n selector: 'c8y-datapoints-export-selector-file-types',\n templateUrl: './datapoints-exports-selector-file-types.component.html',\n standalone: true,\n imports: [CoreModule, FormsModule]\n})\nexport class DataPointsExportSelectorFileTypesComponent {\n @Input() dynamicFilesTypeMetadata: { [key: string]: FileTypeMetadata } = {};\n