mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
fix(ui): neutralize Usage CSV text cells [AI] (#111653)
* fix(ui): neutralize usage csv text cells * test(ui): align usage csv fixture types * fix(ui): preserve benign usage csv whitespace
This commit is contained in:
@@ -16,4 +16,92 @@ describe("usage query CSV export", () => {
|
||||
|
||||
expect(csv).toContain("session-1,Session 1,,,,,,,,,,,,,,,");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["equals", "=1+1", "'=1+1"],
|
||||
["plus", "+1+1", "'+1+1"],
|
||||
["minus", "-1+1", "'-1+1"],
|
||||
["at", "@SUM(A1:A2)", "'@SUM(A1:A2)"],
|
||||
["leading whitespace", " \t=1+1", "' \t=1+1"],
|
||||
["fullwidth equals", "\uFF1D1+1", "'\uFF1D1+1"],
|
||||
["fullwidth plus", "\uFF0B1+1", "'\uFF0B1+1"],
|
||||
["fullwidth minus", "\uFF0D1+1", "'\uFF0D1+1"],
|
||||
["fullwidth at", "\uFF20SUM(A1:A2)", "'\uFF20SUM(A1:A2)"],
|
||||
])("neutralizes spreadsheet formula labels with %s prefix", (_name, label, expected) => {
|
||||
const csv = buildSessionsCsv([
|
||||
{
|
||||
key: "session-1",
|
||||
label,
|
||||
updatedAt: 0,
|
||||
usage: null,
|
||||
} satisfies UsageSessionEntry,
|
||||
]);
|
||||
|
||||
expect(csv).toContain(`session-1,${expected},`);
|
||||
});
|
||||
|
||||
it("quotes carriage returns in formula-neutralized labels", () => {
|
||||
const csv = buildSessionsCsv([
|
||||
{
|
||||
key: "session-1",
|
||||
label: "\r=1+1",
|
||||
updatedAt: 0,
|
||||
usage: null,
|
||||
} satisfies UsageSessionEntry,
|
||||
]);
|
||||
|
||||
expect(csv).toContain('session-1,"\'\r=1+1",');
|
||||
});
|
||||
|
||||
it.each([
|
||||
["tab", "\tplain", "\tplain"],
|
||||
["carriage return", "\rplain", '"\rplain"'],
|
||||
["newline", "\nplain", '"\nplain"'],
|
||||
])("preserves benign labels with leading %s", (_name, label, expected) => {
|
||||
const csv = buildSessionsCsv([
|
||||
{
|
||||
key: "session-1",
|
||||
label,
|
||||
updatedAt: 0,
|
||||
usage: null,
|
||||
} satisfies UsageSessionEntry,
|
||||
]);
|
||||
|
||||
expect(csv).toContain(`session-1,${expected},`);
|
||||
});
|
||||
|
||||
it("keeps numeric cells numeric while neutralizing string labels", () => {
|
||||
const csv = buildSessionsCsv([
|
||||
{
|
||||
key: "session-1",
|
||||
label: "-remote-label",
|
||||
updatedAt: 0,
|
||||
usage: {
|
||||
durationMs: -1,
|
||||
messageCounts: {
|
||||
total: -2,
|
||||
user: -3,
|
||||
assistant: -4,
|
||||
toolCalls: -5,
|
||||
toolResults: -6,
|
||||
errors: -7,
|
||||
},
|
||||
input: -5,
|
||||
output: -6,
|
||||
cacheRead: -7,
|
||||
cacheWrite: -8,
|
||||
totalTokens: -9,
|
||||
totalCost: -10,
|
||||
inputCost: -11,
|
||||
outputCost: -12,
|
||||
cacheReadCost: -13,
|
||||
cacheWriteCost: -14,
|
||||
missingCostEntries: -15,
|
||||
},
|
||||
} satisfies UsageSessionEntry,
|
||||
]);
|
||||
|
||||
expect(csv).toContain("session-1,'-remote-label,,,");
|
||||
expect(csv).toContain(",-1,-2,-7,-5,-5,-6,-7,-8,-9,-10");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,11 +14,16 @@ function downloadTextFile(filename: string, content: string, type = "text/plain"
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function csvEscape(value: string): string {
|
||||
if (/[",\n]/.test(value)) {
|
||||
return `"${value.replaceAll('"', '""')}"`;
|
||||
function neutralizeSpreadsheetFormulaCell(value: string): string {
|
||||
return /^[ \t\r\n]*[=+\-@\uFF0B\uFF0D\uFF1D\uFF20]/u.test(value) ? `'${value}` : value;
|
||||
}
|
||||
|
||||
function csvEscape(value: string, neutralizeFormulas = true): string {
|
||||
const safeValue = neutralizeFormulas ? neutralizeSpreadsheetFormulaCell(value) : value;
|
||||
if (/[",\r\n]/.test(safeValue)) {
|
||||
return `"${safeValue.replaceAll('"', '""')}"`;
|
||||
}
|
||||
return value;
|
||||
return safeValue;
|
||||
}
|
||||
|
||||
function toCsvRow(values: Array<string | number | undefined | null>): string {
|
||||
@@ -27,7 +32,7 @@ function toCsvRow(values: Array<string | number | undefined | null>): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
return csvEscape(String(value));
|
||||
return csvEscape(String(value), typeof value === "string");
|
||||
})
|
||||
.join(",");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user