Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code 341de11b8b fix: add UTF-8 BOM to CSV export for proper non-Latin character display
https://sonarly.com/issue/21060?type=bug

CSV exports containing Arabic (or other non-Latin) characters display as garbled text when opened in spreadsheet applications because the exported file lacks a UTF-8 Byte Order Mark (BOM).

Fix: Added a UTF-8 Byte Order Mark (BOM) prefix (`\uFEFF`) to the CSV file content in the `downloader` function.

**What changed:**
1. `useRecordIndexExportRecords.ts`: Added a `UTF8_BOM` constant and prepended it to the generated CSV content when creating the Blob. This signals to Excel and other spreadsheet applications that the file uses UTF-8 encoding, which is required for proper rendering of Arabic, Chinese, Hebrew, and other non-Latin characters.

2. `useRecordIndexExportRecords.test.ts`: Added a test for `csvDownloader` that verifies the UTF-8 BOM is present at the start of the exported file and that Arabic characters are preserved in the output.

**Why this works:** JavaScript's `Blob` encodes strings as UTF-8, but spreadsheet applications like Excel default to the system's ANSI codepage when opening CSV files. The BOM character (U+FEFF) at the start of the file tells these applications to interpret the file as UTF-8, which correctly handles all Unicode characters including Arabic.
2026-04-02 06:33:06 +00:00
2 changed files with 38 additions and 1 deletions
@@ -4,10 +4,15 @@ import { CSV_INJECTION_PREVENTION_ZWJ } from '@/spreadsheet-import/constants/Csv
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
import {
csvDownloader,
displayedExportProgress,
generateCsv,
} from '@/object-record/record-index/export/hooks/useRecordIndexExportRecords';
jest.mock('file-saver', () => ({
saveAs: jest.fn(),
}));
jest.useFakeTimers();
describe('generateCsv', () => {
@@ -436,6 +441,36 @@ describe('generateCsv', () => {
});
});
describe('csvDownloader', () => {
it('should prepend UTF-8 BOM to the exported CSV file', async () => {
const { saveAs } = await import('file-saver');
const columns: Pick<
ColumnDefinition<FieldMetadata>,
'size' | 'label' | 'type' | 'metadata'
>[] = [
{
label: 'Name',
size: 100,
type: FieldMetadataType.TEXT,
metadata: { fieldName: 'name' },
},
];
const rows = [{ id: '1', name: 'محمد' }];
csvDownloader('test.csv', { rows, columns });
expect(saveAs).toHaveBeenCalledTimes(1);
const blob: Blob = (saveAs as jest.Mock).mock.calls[0][0];
const text = await blob.text();
expect(text.charCodeAt(0)).toBe(0xfeff);
expect(text).toContain('محمد');
});
});
describe('displayedExportProgress', () => {
it.each([
[undefined, undefined, 'percentage', 'Export'],
@@ -143,9 +143,11 @@ export const displayedExportProgress = (progress?: ExportProgress): string => {
return t`Export (${exportedCount})`;
};
const UTF8_BOM = '\uFEFF';
const downloader = (mimeType: string, generator: GenerateExport) => {
return (filename: string, data: GenerateExportOptions) => {
const blob = new Blob([generator(data)], { type: mimeType });
const blob = new Blob([UTF8_BOM + generator(data)], { type: mimeType });
saveAs(blob, filename);
};
};