| 1 | /** IPC handler for importing data into a selected chart element. */ |
| 2 | import { dialog, ipcMain } from 'electron' |
| 3 | import fs from 'fs' |
| 4 | import path from 'path' |
| 5 | import { createRequire } from 'module' |
| 6 | import type { IpcContext } from '../ipc/context' |
| 7 | import type { ParsedChartDataResult } from '../../shared/chart-data' |
| 8 | |
| 9 | const require = createRequire(import.meta.url) |
| 10 | const MAX_ROWS = 200 |
| 11 | const MAX_SERIES = 8 |
| 12 | const X_KEYS = ['x', 'label', 'category', 'name'] |
| 13 | const TABLE_X_HEADER_KEYS = [ |
| 14 | ...X_KEYS, |
| 15 | 'date', |
| 16 | 'time', |
| 17 | 'month', |
| 18 | 'quarter', |
| 19 | 'year', |
| 20 | '日期', |
| 21 | '时间', |
| 22 | '月份', |
| 23 | '季度', |
| 24 | '年份', |
| 25 | '分类', |
| 26 | '类别', |
| 27 | '名称', |
| 28 | '地区', |
| 29 | '产品' |
| 30 | ] |
| 31 | |
| 32 | type RawRow = Record<string, unknown> | unknown[] |
| 33 | type XlsxApi = { |
| 34 | readFile: (filename: string) => { SheetNames: string[]; Sheets: Record<string, unknown> } |
| 35 | utils: { |
| 36 | sheet_to_json: (sheet: unknown, options: { header: 1; defval: string }) => unknown[][] |
| 37 | } |
| 38 | } |
| 39 | type PapaApi = { |
| 40 | parse: ( |
| 41 | input: string, |
| 42 | options: Record<string, unknown> |
| 43 | ) => { |
| 44 | data: unknown[][] |
| 45 | errors?: Array<{ message?: string }> |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | function loadXlsx(): XlsxApi { |
| 50 | try { |
| 51 | return require('xlsx') as XlsxApi |
| 52 | } catch { |
| 53 | throw new Error('Excel 解析依赖 xlsx 尚未安装,请先安装项目依赖') |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | function loadPapa(): PapaApi { |
| 58 | try { |
| 59 | return require('papaparse') as PapaApi |
| 60 | } catch { |
| 61 | throw new Error('CSV 解析依赖 papaparse 尚未安装,请先安装项目依赖') |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | function toFiniteNumber(value: unknown): number | null { |
| 66 | if (typeof value === 'number') return Number.isFinite(value) ? value : null |
| 67 | const text = String(value ?? '').trim().replace(/,/g, '') |
| 68 | if (!text) return null |
| 69 | const parsed = Number(text) |
| 70 | return Number.isFinite(parsed) ? parsed : null |
| 71 | } |
| 72 | |
| 73 | function normalizeJsonInput(value: unknown): RawRow[] { |
| 74 | if (Array.isArray(value)) return value as RawRow[] |
| 75 | if (value && typeof value === 'object') { |
| 76 | const record = value as Record<string, unknown> |
| 77 | const nested = record.data ?? record.rows ?? record.items |
| 78 | if (Array.isArray(nested)) return nested as RawRow[] |
| 79 | } |
| 80 | return [] |
| 81 | } |
| 82 | |
| 83 | function rowsFromTable(table: unknown[][]): RawRow[] { |
| 84 | if (table.length === 0) return [] |
| 85 | const firstRow = table[0] || [] |
| 86 | const firstCell = String(firstRow[0] ?? '').trim().toLowerCase() |
| 87 | const hasHeader = |
| 88 | TABLE_X_HEADER_KEYS.includes(firstCell) || |
| 89 | firstRow.some((cell, index) => index > 0 && toFiniteNumber(cell) === null) |
| 90 | if (!hasHeader) return table |
| 91 | const headers = firstRow.map((cell, index) => |
| 92 | String(cell || (index === 0 ? 'x' : `Series ${index}`)).trim() |
| 93 | ) |
| 94 | return table.slice(1).map((row) => |
| 95 | headers.reduce<Record<string, unknown>>((record, header, index) => { |
| 96 | record[header || (index === 0 ? 'x' : `Series ${index}`)] = row[index] |
| 97 | return record |
| 98 | }, {}) |
| 99 | ) |
| 100 | } |
| 101 | |
| 102 | function normalizeChartRows(rows: RawRow[]): { |
| 103 | rows: Array<Record<string, string | number>> |
| 104 | seriesCount: number |
| 105 | labelCount: number |
| 106 | numericCellCount: number |
| 107 | } { |
| 108 | const labels: string[] = [] |
| 109 | const rowValues: Array<Record<string, unknown>> = [] |
| 110 | |
| 111 | rows.slice(0, MAX_ROWS).forEach((item) => { |
| 112 | if (Array.isArray(item)) { |
| 113 | const label = String(item[0] ?? '').trim() |
| 114 | if (!label) return |
| 115 | labels.push(label) |
| 116 | rowValues.push( |
| 117 | item.slice(1, MAX_SERIES + 1).reduce<Record<string, unknown>>((record, cell, index) => { |
| 118 | record[index === 0 ? 'Value' : `Series ${index + 1}`] = cell |
| 119 | return record |
| 120 | }, {}) |
| 121 | ) |
| 122 | return |
| 123 | } |
| 124 | |
| 125 | if (!item || typeof item !== 'object') return |
| 126 | const record = item as Record<string, unknown> |
| 127 | const keys = Object.keys(record) |
| 128 | const xKey = |
| 129 | X_KEYS.find((key) => key in record) ?? |
| 130 | keys.find((key) => toFiniteNumber(record[key]) === null) ?? |
| 131 | keys[0] |
| 132 | const label = String(record[xKey] ?? '').trim() |
| 133 | if (!label) return |
| 134 | labels.push(label) |
| 135 | rowValues.push( |
| 136 | keys.reduce<Record<string, unknown>>((row, key) => { |
| 137 | if (key !== xKey) row[key] = record[key] |
| 138 | return row |
| 139 | }, {}) |
| 140 | ) |
| 141 | }) |
| 142 | |
| 143 | const seriesKeys = Array.from(new Set(rowValues.flatMap((row) => Object.keys(row)))) |
| 144 | .filter((key) => key.trim() && rowValues.some((row) => toFiniteNumber(row[key]) !== null)) |
| 145 | .slice(0, MAX_SERIES) |
| 146 | if (seriesKeys.length === 0) { |
| 147 | return { |
| 148 | rows: [], |
| 149 | seriesCount: 0, |
| 150 | labelCount: labels.length, |
| 151 | numericCellCount: 0 |
| 152 | } |
| 153 | } |
| 154 | let numericCellCount = 0 |
| 155 | const normalizedRows = labels.map((label, rowIndex) => { |
| 156 | const source = rowValues[rowIndex] || {} |
| 157 | return seriesKeys.reduce<Record<string, string | number>>( |
| 158 | (record, key) => { |
| 159 | const value = toFiniteNumber(source[key]) |
| 160 | if (value !== null) numericCellCount += 1 |
| 161 | record[key] = value ?? 0 |
| 162 | return record |
| 163 | }, |
| 164 | { x: label } |
| 165 | ) |
| 166 | }) |
| 167 | |
| 168 | return { |
| 169 | rows: normalizedRows, |
| 170 | seriesCount: seriesKeys.length, |
| 171 | labelCount: labels.length, |
| 172 | numericCellCount |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | async function parseChartDataFile(filePath: string): Promise<ParsedChartDataResult> { |
| 177 | const ext = path.extname(filePath).toLowerCase() |
| 178 | let rawRows: RawRow[] = [] |
| 179 | |
| 180 | if (ext === '.json') { |
| 181 | try { |
| 182 | rawRows = normalizeJsonInput(JSON.parse(await fs.promises.readFile(filePath, 'utf-8'))) |
| 183 | } catch { |
| 184 | throw new Error('JSON 文件解析失败,请检查文件格式') |
| 185 | } |
| 186 | } else if (ext === '.csv' || ext === '.tsv' || ext === '.txt') { |
| 187 | const parsed = loadPapa().parse(await fs.promises.readFile(filePath, 'utf-8'), { |
| 188 | skipEmptyLines: 'greedy' |
| 189 | }) |
| 190 | if (parsed.errors?.length) { |
| 191 | throw new Error(parsed.errors[0].message || 'CSV 文件解析失败') |
| 192 | } |
| 193 | rawRows = rowsFromTable(parsed.data) |
| 194 | } else if (ext === '.xlsx' || ext === '.xls') { |
| 195 | const xlsx = loadXlsx() |
| 196 | const workbook = xlsx.readFile(filePath) |
| 197 | const firstSheetName = workbook.SheetNames[0] |
| 198 | if (!firstSheetName) throw new Error('Excel 文件没有可读取的工作表') |
| 199 | rawRows = rowsFromTable( |
| 200 | xlsx.utils.sheet_to_json(workbook.Sheets[firstSheetName], { header: 1, defval: '' }) |
| 201 | ) |
| 202 | } else { |
| 203 | throw new Error('不支持的图表数据文件格式') |
| 204 | } |
| 205 | |
| 206 | if (rawRows.length === 0) throw new Error('图表数据文件为空或格式不符合要求') |
| 207 | const normalized = normalizeChartRows(rawRows) |
| 208 | if (normalized.labelCount === 0) throw new Error('图表数据需要至少一列 X 轴标签') |
| 209 | if (normalized.seriesCount === 0 || normalized.numericCellCount === 0) { |
| 210 | throw new Error('图表数据需要至少一列可识别的数值列') |
| 211 | } |
| 212 | if (normalized.rows.length === 0) throw new Error('没有解析到可用的图表数据') |
| 213 | return { |
| 214 | canceled: false, |
| 215 | filePath, |
| 216 | dataJson: JSON.stringify(normalized.rows, null, 2), |
| 217 | rowCount: normalized.rows.length, |
| 218 | seriesCount: normalized.seriesCount |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | export function registerChartDataImportHandlers(ctx: IpcContext): void { |
| 223 | ipcMain.handle('chart-data:choose-and-parse', async (): Promise<ParsedChartDataResult> => { |
| 224 | const result = await dialog.showOpenDialog(ctx.mainWindow, { |
| 225 | title: '选择图表数据', |
| 226 | properties: ['openFile'], |
| 227 | filters: [ |
| 228 | { name: 'Chart Data', extensions: ['csv', 'tsv', 'txt', 'json', 'xlsx', 'xls'] }, |
| 229 | { name: 'All Files', extensions: ['*'] } |
| 230 | ] |
| 231 | }) |
| 232 | if (result.canceled || result.filePaths.length === 0) return { canceled: true } |
| 233 | return parseChartDataFile(result.filePaths[0]) |
| 234 | }) |
| 235 | } |
| 236 |