This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 4c697f07a fix(web): prefix CSV downloads with a UTF-8 BOM (#4743)
4c697f07a is described below
commit 4c697f07acde460e2344375cb1f82669f5b270fd
Author: Apulupie <[email protected]>
AuthorDate: Mon Sep 21 21:12:49 2026 +0800
fix(web): prefix CSV downloads with a UTF-8 BOM (#4743)
`downloadCsv` in `web/src/utils/download.ts` built its Blob from the raw
string and declared the encoding only in the MIME type. Spreadsheets sniff
bytes rather than trust that parameter, so an export with Chinese topic remarks
or group names was decoded as ANSI/GBK and came out as mojibake in Excel. Every
CSV download in the frontend goes through this one function, including the
pages that hand it a CSV built server-side by `MetadataService`.
The Blob is now prefixed with `\uFEFF`. No caller passes a string that
already carries a BOM, and the two exporters that prefix one themselves,
`AuditService` and `CloudCredentialService`, go through `downloadBlob`
untouched, so nothing is doubled. The formula guard runs per cell inside
`buildCsv`, so the prefix always lands outside the first quoted field, and
`resourceCsvImport` already strips a leading BOM, keeping the export-import
round trip byte-identical.
BOM placement is now split: audit and credential exports are prefixed
server-side, consumer-group and topic exports client-side, and those REST
bodies stay BOM-less for non-browser callers. That inconsistency predates this
change and is left alone, as is the `download.ts` comment that overstates which
server exporters prefix one.
Fixes #4744
---
web/src/utils/download.test.ts | 36 ++++++++++++++++++++++++++++++++++--
web/src/utils/download.ts | 6 +++++-
2 files changed, 39 insertions(+), 3 deletions(-)
diff --git a/web/src/utils/download.test.ts b/web/src/utils/download.test.ts
index 35d0ed6ca..1b3be406a 100644
--- a/web/src/utils/download.test.ts
+++ b/web/src/utils/download.test.ts
@@ -16,7 +16,7 @@
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
-import { buildCsv, downloadBlob } from './download';
+import { buildCsv, downloadBlob, downloadCsv } from './download';
describe('buildCsv', () => {
it('escapes quotes, empty values, and spreadsheet formulas', () => {
const csv = buildCsv(
@@ -36,12 +36,44 @@ describe('buildCsv', () => {
'"Name","Remark"',
'"\'=SUM(A1:A2)","hello, ""mq"""',
'"\'\nline-feed",""',
- '"\'\'=literal","\'\'\'+two-apostrophes"',
+ "\"''=literal\",\"'''+two-apostrophes\"",
].join('\n'),
);
});
});
+describe('downloadCsv', () => {
+ afterEach(() => {
+ document.body.innerHTML = '';
+ vi.restoreAllMocks();
+ });
+
+ it('prefixes a UTF-8 BOM so spreadsheet apps detect the encoding', async ()
=> {
+ const createObjectURL = vi.fn((_blob: Blob) => 'blob:csv');
+ Object.defineProperty(URL, 'createObjectURL', {
+ writable: true,
+ value: createObjectURL,
+ });
+ Object.defineProperty(URL, 'revokeObjectURL', {
+ writable: true,
+ value: vi.fn(),
+ });
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() =>
{});
+
+ const csv = '"Name","Remark"\n"topic-a","中文备注"';
+
+ downloadCsv('export.csv', csv);
+
+ const blob = createObjectURL.mock.calls[0][0];
+ const bytes = new Uint8Array(await blob.arrayBuffer());
+ // blob.text() strips a leading BOM (TextDecoder's default), so assert the
raw bytes:
+ // EF BB BF is the UTF-8 encoding of U+FEFF. Without it Excel decodes the
file as
+ // ANSI/GBK and garbles every non-ASCII cell.
+ expect(Array.from(bytes.slice(0, 3))).toEqual([0xef, 0xbb, 0xbf]);
+ expect(await blob.text()).toBe(csv);
+ });
+});
+
describe('downloadBlob', () => {
afterEach(() => {
vi.useRealTimers();
diff --git a/web/src/utils/download.ts b/web/src/utils/download.ts
index 51f502f96..83af33d54 100644
--- a/web/src/utils/download.ts
+++ b/web/src/utils/download.ts
@@ -51,5 +51,9 @@ export const buildCsv = <T>(columns: CsvColumn<T>[], rows:
T[]) =>
].join('\n');
export const downloadCsv = (filename: string, csv: string) => {
- downloadBlob(new Blob([csv], { type: 'text/csv;charset=utf-8' }), filename);
+ // Spreadsheet apps sniff the encoding instead of honouring the charset
parameter: without
+ // a BOM, Excel decodes the file as ANSI/GBK and garbles every non-ASCII
cell. The
+ // server-side CSV exporters already prefix \uFEFF, and resourceCsvImport
strips it again,
+ // so the in-app export → import round trip stays byte-identical.
+ downloadBlob(new Blob([`\uFEFF${csv}`], { type: 'text/csv;charset=utf-8' }),
filename);
};