feat(export): configurable CSV field quoting mode
This commit is contained in:
32 files changed
+442
-56
No files matched your search
@@ -153,6 +153,7 @@ import type {
|
||||
ElasticsearchDeleteByQueryResult,
|
||||
} from "@/lib/backend/tauri";
|
||||
import type { QueryEditability } from "@/lib/sql/sqlAnalysis";
|
||||
import type { CsvQuoteMode } from "@/lib/export/csvQuoteMode";
|
||||
import { isTerminalTransferProgress } from "@/lib/backend/transferProgress";
|
||||
import type {
|
||||
DataGridColumnDistinctValuesSqlOptions,
|
||||
@@ -2595,9 +2596,9 @@ export async function cancelQueryResultExport(exportId: string, executionId?: st
|
||||
});
|
||||
}
|
||||
|
||||
export async function exportQueryResultCsv(filePath: string, columns: string[], rows: readonly (readonly XlsxCellValue[])[]): Promise<void> {
|
||||
export async function exportQueryResultCsv(filePath: string, columns: string[], rows: readonly (readonly XlsxCellValue[])[], csvQuoteMode: CsvQuoteMode = "all"): Promise<void> {
|
||||
const { formatCsv } = await import("@/lib/export/exportFormats");
|
||||
const content = formatCsv(columns, rows as (string | number | boolean | null)[][]);
|
||||
const content = formatCsv(columns, rows as (string | number | boolean | null)[][], csvQuoteMode);
|
||||
const fileName = filePath.split(/[\\/]/).pop() || "export.csv";
|
||||
const blob = new Blob(["\uFEFF", content], {
|
||||
type: "text/csv;charset=utf-8",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
|
||||
import { decodeMeilisearchDocumentPage, decodeMeilisearchSearchResult, type MeilisearchDocumentPage, type MeilisearchDocumentPageWire, type MeilisearchSearchResult, type MeilisearchSearchWireResult } from "@/lib/backend/meilisearchTransport";
|
||||
import type { XuguTablespaceInfo } from "@/types/database";
|
||||
import type { CreatedKey, EnqueuedTaskSummary, KeyCreateInput, KeyListItem, KeyPage, KeyUpdateInput, MeilisearchSystemOverview, MeilisearchTask, TaskListInput, TaskPage, TaskSelector } from "@/types/meilisearchManagement";
|
||||
import type { CsvQuoteMode } from "@/lib/export/csvQuoteMode";
|
||||
|
||||
/** Normalize Tauri rejections once at the public backend boundary. */
|
||||
async function invokeBackend<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
@@ -4839,6 +4840,7 @@ export interface TableExportRequest {
|
||||
tableName: string;
|
||||
filePath: string;
|
||||
format: "csv" | "xlsx" | "json" | "markdown" | "sql" | "txt";
|
||||
csvQuoteMode?: CsvQuoteMode;
|
||||
columns?: string[];
|
||||
columnTypes?: Array<string | null | undefined>;
|
||||
columnComments?: Array<string | null> | null;
|
||||
@@ -4862,6 +4864,7 @@ export interface TableCsvExportOptions {
|
||||
columns?: string[];
|
||||
pageSize?: number;
|
||||
timeoutSecs?: number;
|
||||
csvQuoteMode?: CsvQuoteMode;
|
||||
}
|
||||
|
||||
export interface TableExportProgress {
|
||||
@@ -4886,6 +4889,7 @@ export interface QueryResultExportRequest {
|
||||
useAgentCursor: boolean;
|
||||
filePath: string;
|
||||
format: "csv" | "xlsx" | "txt" | "sql";
|
||||
csvQuoteMode?: CsvQuoteMode;
|
||||
includeSqlSheet?: boolean;
|
||||
pageSize: number;
|
||||
rowLimit?: number | null;
|
||||
@@ -5029,12 +5033,13 @@ export async function recordDatabaseExportDestination(directory: string): Promis
|
||||
await invoke("record_database_export_destination", { directory });
|
||||
}
|
||||
|
||||
export async function exportQueryResultCsv(filePath: string, columns: string[], rows: readonly (readonly XlsxCellValue[])[]): Promise<void> {
|
||||
export async function exportQueryResultCsv(filePath: string, columns: string[], rows: readonly (readonly XlsxCellValue[])[], csvQuoteMode: CsvQuoteMode = "all"): Promise<void> {
|
||||
return invoke("export_query_result_csv", {
|
||||
request: {
|
||||
filePath,
|
||||
columns,
|
||||
rows,
|
||||
csvQuoteMode,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export type CsvQuoteMode = "all" | "necessary";
|
||||
|
||||
export const DEFAULT_CSV_QUOTE_MODE: CsvQuoteMode = "all";
|
||||
|
||||
export function normalizeCsvQuoteMode(value: unknown): CsvQuoteMode {
|
||||
return value === "necessary" ? "necessary" : DEFAULT_CSV_QUOTE_MODE;
|
||||
}
|
||||
|
||||
export function csvFieldNeedsQuotes(value: string): boolean {
|
||||
return /[",\r\n]/.test(value);
|
||||
}
|
||||
|
||||
export function escapeCsvField(value: string, quoteMode: CsvQuoteMode): string {
|
||||
if (quoteMode === "necessary" && !csvFieldNeedsQuotes(value)) return value;
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { DatabaseType, QueryResult } from "@/types/database";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { escapeCsvField, type CsvQuoteMode } from "@/lib/export/csvQuoteMode";
|
||||
|
||||
export type ExportCellValue = string | number | boolean | null;
|
||||
|
||||
export function formatCsv(columns: string[], rows: ExportCellValue[][]): string {
|
||||
const esc = (v: string) => `"${v.replace(/"/g, '""')}"`;
|
||||
const header = columns.map(esc).join(",");
|
||||
const body = rows.map((row) => row.map((c) => (c === null ? "" : esc(String(c)))).join(",")).join("\n");
|
||||
export function formatCsv(columns: string[], rows: ExportCellValue[][], quoteMode: CsvQuoteMode = "all"): string {
|
||||
const header = columns.map((column) => escapeCsvField(column, quoteMode)).join(",");
|
||||
const body = rows.map((row) => row.map((cell) => (cell === null ? "" : escapeCsvField(String(cell), quoteMode))).join(",")).join("\n");
|
||||
return `${header}\n${body}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
|
||||
"sidebarCopyTableNameIncludeSchema",
|
||||
"redisKeyTemplates",
|
||||
"exportBatchSize",
|
||||
"csvQuoteMode",
|
||||
"exportRowLimitEnabled",
|
||||
"exportRowLimit",
|
||||
"queryExportKeysetOptimizationEnabled",
|
||||
|
||||
Reference in new issue
Block a user