diff --git a/src/ui/utils/csv.ts b/src/ui/utils/csv.ts index f2ece31..4d3f377 100644 --- a/src/ui/utils/csv.ts +++ b/src/ui/utils/csv.ts @@ -18,7 +18,10 @@ function neutralizeFormulaInjection(s: string): string { function escapeCsvValue(value: unknown): string { if (value === null || value === undefined) return ''; - const s = neutralizeFormulaInjection(String(value)); +// Serialize objects/arrays to JSON to avoid "[object Object]" in output, + // then neutralize formula-injection payloads on the resulting string. + const raw = typeof value === 'object' ? JSON.stringify(value) : String(value); + const s = neutralizeFormulaInjection(raw); if (/[",\n\r]/.test(s)) { return `"${s.replace(/"/g, '""')}"`; } diff --git a/src/ui/utils/dataDiff.ts b/src/ui/utils/dataDiff.ts index 4eb1214..109c0b6 100644 --- a/src/ui/utils/dataDiff.ts +++ b/src/ui/utils/dataDiff.ts @@ -26,6 +26,18 @@ export interface DataDiffResult { changed: RecordDiff[]; unchanged: RecordDiff[]; summary: { total: number; added: number; removed: number; changed: number }; + /** Number of duplicate match-key values silently collapsed during indexing. */ + duplicateKeyCount: number; +} + +/** + * Stringify a value for comparison. Objects and arrays are JSON-stringified + * so that nested structures compare by content rather than as "[object Object]". + */ +function stringifyForCompare(value: unknown): string { + if (value === null || value === undefined) return ''; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); } export function diffRecords( @@ -38,15 +50,20 @@ export function diffRecords( objectName: string, ): DataDiffResult { const sourceMap = new Map>(); + let duplicateKeyCount = 0; for (const rec of sourceRecords) { const key = String(rec[matchField] ?? ''); - if (key) sourceMap.set(key, rec); + if (!key) continue; + if (sourceMap.has(key)) duplicateKeyCount++; + sourceMap.set(key, rec); } const targetMap = new Map>(); for (const rec of targetRecords) { const key = String(rec[matchField] ?? ''); - if (key) targetMap.set(key, rec); + if (!key) continue; + if (targetMap.has(key)) duplicateKeyCount++; + targetMap.set(key, rec); } const allKeys = new Set([...sourceMap.keys(), ...targetMap.keys()]); @@ -70,7 +87,9 @@ export function diffRecords( for (const field of compareFields) { const sv = source[field]; const tv = target[field]; - if (String(sv ?? '') !== String(tv ?? '')) { + const sStr = stringifyForCompare(sv); + const tStr = stringifyForCompare(tv); + if (sStr !== tStr) { changedFields.push(field); fieldDiffs[field] = { source: sv, target: tv }; } @@ -95,6 +114,7 @@ export function diffRecords( changed, unchanged, summary: { total: allKeys.size, added: added.length, removed: removed.length, changed: changed.length }, + duplicateKeyCount, }; } @@ -103,9 +123,9 @@ export function diffToCsv(diff: DataDiffResult): string { const all = [...diff.added, ...diff.removed, ...diff.changed]; for (const d of all) { const fieldCols = diff.fields.map(f => { - const sv = d.sourceRecord?.[f] ?? ''; - const tv = d.targetRecord?.[f] ?? ''; - return `"${String(sv).replace(/"/g, '""')}","${String(tv).replace(/"/g, '""')}"`; + const sv = stringifyForCompare(d.sourceRecord?.[f]); + const tv = stringifyForCompare(d.targetRecord?.[f]); + return `"${sv.replace(/"/g, '""')}","${tv.replace(/"/g, '""')}"`; }).join(','); rows.push(`"${d.keyValue}","${d.status}","${d.changedFields.join('; ')}",${fieldCols}`); } diff --git a/src/ui/utils/fileParse.ts b/src/ui/utils/fileParse.ts index 56ae076..23034dc 100644 --- a/src/ui/utils/fileParse.ts +++ b/src/ui/utils/fileParse.ts @@ -59,7 +59,23 @@ export function inferHeaders(records: Array>): string[] export async function parseJsonFile(file: File): Promise { const text = await file.text(); const parsed = JSON.parse(text) as unknown; - const records = Array.isArray(parsed) ? parsed : [parsed]; + + // Unwrap metadata-wrapped exports (e.g. { exportedAt, records: [...] }) + // so that re-importing our own JSON output yields the original records. + let records: unknown[]; + if (Array.isArray(parsed)) { + records = parsed; + } else if ( + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) && + Array.isArray((parsed as Record).records) + ) { + records = (parsed as Record).records as unknown[]; + } else { + records = [parsed]; + } + const objects = records .filter(r => r && typeof r === 'object') .map(r => r as Record); @@ -123,14 +139,24 @@ export async function parseExcelFile(file: File): Promise { } const XLSX = await import(/* webpackChunkName: "xlsx" */ 'xlsx/dist/xlsx.mini.min.js'); const buf = await file.arrayBuffer(); - const wb = XLSX.read(buf, { type: 'array', sheetRows: MAX_EXCEL_ROWS + 1 }); + // cellDates converts Excel date serial numbers to JS Date objects at read time; + // raw: true on sheet_to_json preserves numeric precision (avoids scientific notation on long IDs). + const wb = XLSX.read(buf, { type: 'array', sheetRows: MAX_EXCEL_ROWS + 1, cellDates: true }); const sheetName = wb.SheetNames[0]; if (!sheetName) throw new Error('Excel file contains no sheets'); const sheet = wb.Sheets[sheetName]; - const rows = XLSX.utils.sheet_to_json>(sheet, { defval: null, raw: false }); - if (rows.length > MAX_EXCEL_ROWS) { + const rawRows = XLSX.utils.sheet_to_json>(sheet, { defval: null, raw: true }); + if (rawRows.length > MAX_EXCEL_ROWS) { throw new Error(`Excel worksheets must contain ${MAX_EXCEL_ROWS.toLocaleString()} rows or fewer`); } + // Convert Date objects to ISO strings for consistent round-trip fidelity. + const rows = rawRows.map(row => { + const out: Record = {}; + for (const [k, v] of Object.entries(row)) { + out[k] = v instanceof Date ? v.toISOString() : v; + } + return out; + }); return { records: rows, headers: inferHeaders(rows) }; } diff --git a/src/ui/utils/xml.ts b/src/ui/utils/xml.ts index 1e421d7..884d85a 100644 --- a/src/ui/utils/xml.ts +++ b/src/ui/utils/xml.ts @@ -14,7 +14,9 @@ */ function escapeXml(value: unknown): string { if (value === null || value === undefined) return ''; - return String(value) + // Serialize objects/arrays to JSON to avoid "[object Object]" in output. + const s = typeof value === 'object' ? JSON.stringify(value) : String(value); + return s .replace(/&/g, '&') .replace(//g, '>')