feat(export): move csv and xlsx generation to Rust backend
This commit is contained in:
parent
0655d309d8
commit
c736fe372a
|
|
@ -92,7 +92,7 @@ import {
|
|||
treeNodeRowAction,
|
||||
treeNodeRowDoubleClickAction,
|
||||
} from "@/lib/treeNodeClick";
|
||||
import { formatCsv, formatJson, formatSqlInsert } from "@/lib/exportFormats";
|
||||
import { formatJson, formatSqlInsert } from "@/lib/exportFormats";
|
||||
import { fetchTableDataForExport } from "@/lib/tableDataExport";
|
||||
import {
|
||||
buildCreateDatabaseSql,
|
||||
|
|
@ -1266,13 +1266,26 @@ async function exportData(format: "csv" | "json" | "sql") {
|
|||
executePage: (sql) => api.executeQuery(connectionId, database, sql),
|
||||
});
|
||||
|
||||
if (format === "csv") {
|
||||
let outputPath = `${node.label}.csv`;
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({
|
||||
defaultPath: outputPath,
|
||||
filters: [{ name: "CSV", extensions: ["csv"] }],
|
||||
});
|
||||
if (!path) return;
|
||||
outputPath = path as string;
|
||||
}
|
||||
await api.exportQueryResultCsv(outputPath, result.columns, result.rows);
|
||||
toast(t("grid.exported"));
|
||||
return;
|
||||
}
|
||||
|
||||
let content: string;
|
||||
let ext: string;
|
||||
|
||||
if (format === "csv") {
|
||||
ext = "csv";
|
||||
content = formatCsv(result.columns, result.rows);
|
||||
} else if (format === "json") {
|
||||
if (format === "json") {
|
||||
ext = "json";
|
||||
content = formatJson(result.columns, result.rows);
|
||||
} else {
|
||||
|
|
@ -1311,13 +1324,17 @@ async function exportDataXlsx() {
|
|||
executePage: (sql) => api.executeQuery(connectionId, database, sql),
|
||||
});
|
||||
|
||||
const { buildXlsxWorkbook } = await import("@/lib/xlsxExport");
|
||||
const workbook = buildXlsxWorkbook({
|
||||
sheetName: node.label,
|
||||
columns: result.columns,
|
||||
rows: result.rows,
|
||||
});
|
||||
await saveBinaryFileContent(workbook, `${node.label}.xlsx`, "Excel", "xlsx");
|
||||
let outputPath = `${node.label}.xlsx`;
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({
|
||||
defaultPath: outputPath,
|
||||
filters: [{ name: "Excel", extensions: ["xlsx"] }],
|
||||
});
|
||||
if (!path) return;
|
||||
outputPath = path as string;
|
||||
}
|
||||
await api.exportQueryResultXlsx(outputPath, node.label, result.columns, result.rows);
|
||||
toast(t("grid.exported"));
|
||||
} catch (e: any) {
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { computed, type ComputedRef, type Ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
import { formatCsv, formatJson } from "@/lib/exportFormats";
|
||||
import { formatJson } from "@/lib/exportFormats";
|
||||
import * as api from "@/lib/api";
|
||||
import {
|
||||
formatSelectionAsCsv,
|
||||
formatSelectionAsJson,
|
||||
|
|
@ -266,36 +267,6 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
|||
}
|
||||
}
|
||||
|
||||
async function saveBinaryFileContent(
|
||||
content: Uint8Array,
|
||||
defaultFileName: string,
|
||||
filterName: string,
|
||||
filterExt: string,
|
||||
): Promise<boolean> {
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeFile } = await import("@tauri-apps/plugin-fs");
|
||||
const path = await save({
|
||||
defaultPath: defaultFileName,
|
||||
filters: [{ name: filterName, extensions: [filterExt] }],
|
||||
});
|
||||
if (!path) return false;
|
||||
await writeFile(path, content);
|
||||
return true;
|
||||
} else {
|
||||
const blob = new Blob([content], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = defaultFileName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Export functions ---
|
||||
async function runExclusiveExport(action: () => Promise<void>) {
|
||||
const finish = tryStartExclusiveActivation(exportGuard);
|
||||
|
|
@ -311,9 +282,18 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
|||
await runExclusiveExport(async () => {
|
||||
try {
|
||||
const rows = rowsToExport(rowIds).map((item) => item.data.map((c) => displayCellValue(c)));
|
||||
if (await saveFileContent(formatCsv(columns.value, rows), "export.csv", "CSV", "csv")) {
|
||||
toast(t("grid.exported"));
|
||||
let outputPath = "export.csv";
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({
|
||||
defaultPath: outputPath,
|
||||
filters: [{ name: "CSV", extensions: ["csv"] }],
|
||||
});
|
||||
if (!path) return;
|
||||
outputPath = path as string;
|
||||
}
|
||||
await api.exportQueryResultCsv(outputPath, columns.value, rows);
|
||||
toast(t("grid.exported"));
|
||||
} catch (e: any) {
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
|
|
@ -352,15 +332,23 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
|||
async function exportXlsx(rowIds?: number[]) {
|
||||
await runExclusiveExport(async () => {
|
||||
try {
|
||||
const { buildXlsxWorkbook } = await import("@/lib/xlsxExport");
|
||||
const workbook = buildXlsxWorkbook({
|
||||
sheetName: tableMeta.value?.tableName || "Export",
|
||||
columns: columns.value,
|
||||
rows: rowsToExport(rowIds).map((item) => item.data),
|
||||
});
|
||||
if (await saveBinaryFileContent(workbook, "export.xlsx", "Excel", "xlsx")) {
|
||||
toast(t("grid.exported"));
|
||||
let outputPath = "export.xlsx";
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const path = await save({
|
||||
defaultPath: outputPath,
|
||||
filters: [{ name: "Excel", extensions: ["xlsx"] }],
|
||||
});
|
||||
if (!path) return;
|
||||
outputPath = path as string;
|
||||
}
|
||||
await api.exportQueryResultXlsx(
|
||||
outputPath,
|
||||
tableMeta.value?.tableName || "Export",
|
||||
columns.value,
|
||||
rowsToExport(rowIds).map((item) => item.data),
|
||||
);
|
||||
toast(t("grid.exported"));
|
||||
} catch (e: any) {
|
||||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,6 +128,8 @@ export const cancelTableImport = forward("cancelTableImport");
|
|||
// Database Export
|
||||
export const exportDatabaseSql = forward("exportDatabaseSql");
|
||||
export const cancelDatabaseExport = forward("cancelDatabaseExport");
|
||||
export const exportQueryResultCsv = forward("exportQueryResultCsv");
|
||||
export const exportQueryResultXlsx = forward("exportQueryResultXlsx");
|
||||
|
||||
// Redis
|
||||
export const redisListDatabases = forward("redisListDatabases");
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import type {
|
|||
TableImportProgress,
|
||||
DatabaseExportRequest,
|
||||
ExportProgress,
|
||||
XlsxCellValue,
|
||||
} from "./tauri";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -680,6 +681,47 @@ export async function cancelDatabaseExport(exportId: string): Promise<void> {
|
|||
await post("/api/export/database/cancel", { exportId });
|
||||
}
|
||||
|
||||
export async function exportQueryResultCsv(
|
||||
filePath: string,
|
||||
columns: string[],
|
||||
rows: readonly (readonly XlsxCellValue[])[],
|
||||
): Promise<void> {
|
||||
const { formatCsv } = await import("./exportFormats");
|
||||
const content = formatCsv(columns, rows as (string | number | boolean | null)[][]);
|
||||
const fileName = filePath.split(/[\\/]/).pop() || "export.csv";
|
||||
const blob = new Blob(["\uFEFF", content], { type: "text/csv;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export async function exportQueryResultXlsx(
|
||||
filePath: string,
|
||||
sheetName: string | undefined,
|
||||
columns: string[],
|
||||
rows: readonly (readonly XlsxCellValue[])[],
|
||||
): Promise<void> {
|
||||
const { buildXlsxWorkbook } = await import("./xlsxExport");
|
||||
const workbook = buildXlsxWorkbook({
|
||||
sheetName: sheetName || "Export",
|
||||
columns,
|
||||
rows,
|
||||
});
|
||||
const fileName = filePath.split(/[\\/]/).pop() || "export.xlsx";
|
||||
const blob = new Blob([workbook], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Redis
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ export interface DesktopSettings {
|
|||
show_tray_icon: boolean;
|
||||
}
|
||||
|
||||
export type XlsxCellValue = string | number | boolean | null;
|
||||
|
||||
export interface DriverInstallProgress {
|
||||
step: string;
|
||||
downloaded?: number;
|
||||
|
|
@ -974,3 +976,33 @@ export async function exportDatabaseSql(
|
|||
export async function cancelDatabaseExport(exportId: string): Promise<void> {
|
||||
await invoke("cancel_database_export", { exportId });
|
||||
}
|
||||
|
||||
export async function exportQueryResultCsv(
|
||||
filePath: string,
|
||||
columns: string[],
|
||||
rows: readonly (readonly XlsxCellValue[])[],
|
||||
): Promise<void> {
|
||||
return invoke("export_query_result_csv", {
|
||||
request: {
|
||||
filePath,
|
||||
columns,
|
||||
rows,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function exportQueryResultXlsx(
|
||||
filePath: string,
|
||||
sheetName: string | undefined,
|
||||
columns: string[],
|
||||
rows: readonly (readonly XlsxCellValue[])[],
|
||||
): Promise<void> {
|
||||
return invoke("export_query_result_xlsx", {
|
||||
request: {
|
||||
filePath,
|
||||
sheetName,
|
||||
columns,
|
||||
rows,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
use serde_json::Value;
|
||||
|
||||
fn escape_csv(value: &str) -> String {
|
||||
format!("\"{}\"", value.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
fn value_to_csv_text(value: &Value) -> String {
|
||||
match value {
|
||||
Value::Null => String::new(),
|
||||
Value::Bool(v) => v.to_string(),
|
||||
Value::Number(v) => v.to_string(),
|
||||
Value::String(v) => v.clone(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_csv(columns: &[String], rows: &[Vec<Value>]) -> String {
|
||||
let header = columns.iter().map(|col| escape_csv(col)).collect::<Vec<_>>().join(",");
|
||||
let body = rows
|
||||
.iter()
|
||||
.map(|row| row.iter().map(|cell| escape_csv(&value_to_csv_text(cell))).collect::<Vec<_>>().join(","))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!("{header}\n{body}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::format_csv;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn formats_csv_with_header_and_escaped_values() {
|
||||
let out = format_csv(&["id".to_string(), "name".to_string()], &[vec![json!(1), json!("Ada \"Lovelace\"")]]);
|
||||
assert_eq!(out, "\"id\",\"name\"\n\"1\",\"Ada \"\"Lovelace\"\"\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_null_as_empty_cell() {
|
||||
let out = format_csv(&["id".to_string(), "note".to_string()], &[vec![json!(1), Value::Null]]);
|
||||
assert_eq!(out, "\"id\",\"note\"\n\"1\",\"\"");
|
||||
}
|
||||
|
||||
use serde_json::Value;
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ pub mod agent_service;
|
|||
pub mod ai;
|
||||
pub mod connection;
|
||||
pub mod connection_secrets;
|
||||
pub mod csv_export;
|
||||
pub mod database_capabilities;
|
||||
pub mod database_export;
|
||||
pub mod db;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,302 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct XlsxWorksheetData {
|
||||
pub sheet_name: Option<String>,
|
||||
pub columns: Vec<String>,
|
||||
pub rows: Vec<Vec<Value>>,
|
||||
}
|
||||
|
||||
fn escape_xml(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|ch| {
|
||||
let code = *ch as u32;
|
||||
code == 9 || code == 10 || code == 13 || code >= 32
|
||||
})
|
||||
.flat_map(|ch| match ch {
|
||||
'&' => "&".chars().collect::<Vec<_>>(),
|
||||
'<' => "<".chars().collect::<Vec<_>>(),
|
||||
'>' => ">".chars().collect::<Vec<_>>(),
|
||||
'"' => """.chars().collect::<Vec<_>>(),
|
||||
_ => vec![ch],
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn column_name(index: usize) -> String {
|
||||
let mut out = String::new();
|
||||
let mut n = index + 1;
|
||||
while n > 0 {
|
||||
let rem = (n - 1) % 26;
|
||||
out.insert(0, (b'A' + rem as u8) as char);
|
||||
n = (n - 1) / 26;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn cell_ref(row_index: usize, col_index: usize) -> String {
|
||||
format!("{}{}", column_name(col_index), row_index + 1)
|
||||
}
|
||||
|
||||
fn sheet_range(column_count: usize, row_count: usize) -> String {
|
||||
if column_count == 0 || row_count == 0 {
|
||||
return "A1".to_string();
|
||||
}
|
||||
format!("A1:{}{}", column_name(column_count - 1), row_count)
|
||||
}
|
||||
|
||||
fn normalize_sheet_name(input: Option<&str>) -> String {
|
||||
let base = input.unwrap_or("Sheet1");
|
||||
let name: String = base
|
||||
.chars()
|
||||
.map(|ch| match ch {
|
||||
'[' | ']' | ':' | '*' | '?' | '/' | '\\' => ' ',
|
||||
_ => ch,
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string();
|
||||
let fallback = if name.is_empty() { "Sheet1" } else { &name };
|
||||
fallback.chars().take(31).collect()
|
||||
}
|
||||
|
||||
fn value_text(value: Option<&Value>) -> String {
|
||||
match value {
|
||||
Some(Value::Null) | None => String::new(),
|
||||
Some(Value::Bool(v)) => {
|
||||
if *v {
|
||||
"true".to_string()
|
||||
} else {
|
||||
"false".to_string()
|
||||
}
|
||||
}
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(other) => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn estimate_column_widths(columns: &[String], rows: &[Vec<Value>]) -> Vec<usize> {
|
||||
columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(col_index, column)| {
|
||||
let values = rows.iter().take(100).map(|row| value_text(row.get(col_index)));
|
||||
let max_len = std::iter::once(column.clone())
|
||||
.chain(values)
|
||||
.map(|v| v.chars().count().min(60))
|
||||
.fold(8usize, usize::max);
|
||||
(max_len + 2).clamp(10, 60)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn cell_xml(value: Option<&Value>, row_index: usize, col_index: usize, style: Option<usize>) -> String {
|
||||
let reference = cell_ref(row_index, col_index);
|
||||
let style_attr = style.map_or(String::new(), |s| format!(" s=\"{s}\""));
|
||||
match value {
|
||||
Some(Value::Null) | None => format!("<c r=\"{reference}\"{style_attr}/>"),
|
||||
Some(Value::Bool(v)) => {
|
||||
let bool_v = if *v { 1 } else { 0 };
|
||||
format!("<c r=\"{reference}\" t=\"b\"{style_attr}><v>{bool_v}</v></c>")
|
||||
}
|
||||
Some(Value::Number(n)) => {
|
||||
if n.as_f64().map_or(false, |f| f.is_finite()) {
|
||||
format!("<c r=\"{reference}\"{style_attr}><v>{}</v></c>", n)
|
||||
} else {
|
||||
format!(
|
||||
"<c r=\"{reference}\" t=\"inlineStr\"{style_attr}><is><t>{}</t></is></c>",
|
||||
escape_xml(&n.to_string())
|
||||
)
|
||||
}
|
||||
}
|
||||
Some(Value::String(s)) => {
|
||||
format!("<c r=\"{reference}\" t=\"inlineStr\"{style_attr}><is><t>{}</t></is></c>", escape_xml(s))
|
||||
}
|
||||
Some(other) => format!(
|
||||
"<c r=\"{reference}\" t=\"inlineStr\"{style_attr}><is><t>{}</t></is></c>",
|
||||
escape_xml(&other.to_string())
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn worksheet_xml(data: &XlsxWorksheetData) -> String {
|
||||
let total_rows = data.rows.len() + 1;
|
||||
let range = sheet_range(data.columns.len(), total_rows);
|
||||
let widths = estimate_column_widths(&data.columns, &data.rows);
|
||||
|
||||
let cols_xml = widths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, width)| {
|
||||
format!("<col min=\"{}\" max=\"{}\" width=\"{}\" customWidth=\"1\"/>", index + 1, index + 1, width)
|
||||
})
|
||||
.collect::<String>();
|
||||
|
||||
let header_xml = format!(
|
||||
"<row r=\"1\">{}</row>",
|
||||
data.columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, col)| cell_xml(Some(&Value::String(col.clone())), 0, index, Some(1)))
|
||||
.collect::<String>()
|
||||
);
|
||||
|
||||
let body_xml = data
|
||||
.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(row_index, row)| {
|
||||
let excel_row = row_index + 2;
|
||||
let cells = data
|
||||
.columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(col_index, _)| cell_xml(row.get(col_index), excel_row - 1, col_index, None))
|
||||
.collect::<String>();
|
||||
format!("<row r=\"{excel_row}\">{cells}</row>")
|
||||
})
|
||||
.collect::<String>();
|
||||
|
||||
format!(
|
||||
concat!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>",
|
||||
"<worksheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">",
|
||||
"<dimension ref=\"{range}\"/>",
|
||||
"<sheetViews><sheetView workbookViewId=\"0\"><pane ySplit=\"1\" topLeftCell=\"A2\" activePane=\"bottomLeft\" state=\"frozen\"/></sheetView></sheetViews>",
|
||||
"<sheetFormatPr defaultRowHeight=\"15\"/>",
|
||||
"<cols>{cols_xml}</cols>",
|
||||
"<sheetData>{header_xml}{body_xml}</sheetData>",
|
||||
"<autoFilter ref=\"{range}\"/>",
|
||||
"</worksheet>"
|
||||
),
|
||||
range = range,
|
||||
cols_xml = cols_xml,
|
||||
header_xml = header_xml,
|
||||
body_xml = body_xml,
|
||||
)
|
||||
}
|
||||
|
||||
fn content_types_xml() -> &'static str {
|
||||
concat!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>",
|
||||
"<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">",
|
||||
"<Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>",
|
||||
"<Default Extension=\"xml\" ContentType=\"application/xml\"/>",
|
||||
"<Override PartName=\"/xl/workbook.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml\"/>",
|
||||
"<Override PartName=\"/xl/worksheets/sheet1.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\"/>",
|
||||
"<Override PartName=\"/xl/styles.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml\"/>",
|
||||
"</Types>"
|
||||
)
|
||||
}
|
||||
|
||||
fn root_rels_xml() -> &'static str {
|
||||
concat!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>",
|
||||
"<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">",
|
||||
"<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"xl/workbook.xml\"/>",
|
||||
"</Relationships>"
|
||||
)
|
||||
}
|
||||
|
||||
fn workbook_xml(sheet_name: &str) -> String {
|
||||
format!(
|
||||
concat!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>",
|
||||
"<workbook xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">",
|
||||
"<sheets><sheet name=\"{}\" sheetId=\"1\" r:id=\"rId1\"/></sheets>",
|
||||
"</workbook>"
|
||||
),
|
||||
escape_xml(sheet_name)
|
||||
)
|
||||
}
|
||||
|
||||
fn workbook_rels_xml() -> &'static str {
|
||||
concat!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>",
|
||||
"<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">",
|
||||
"<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet\" Target=\"worksheets/sheet1.xml\"/>",
|
||||
"<Relationship Id=\"rId2\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles\" Target=\"styles.xml\"/>",
|
||||
"</Relationships>"
|
||||
)
|
||||
}
|
||||
|
||||
fn styles_xml() -> &'static str {
|
||||
concat!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>",
|
||||
"<styleSheet xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\">",
|
||||
"<fonts count=\"2\"><font><sz val=\"11\"/><name val=\"Calibri\"/></font><font><b/><sz val=\"11\"/><name val=\"Calibri\"/></font></fonts>",
|
||||
"<fills count=\"2\"><fill><patternFill patternType=\"none\"/></fill><fill><patternFill patternType=\"gray125\"/></fill></fills>",
|
||||
"<borders count=\"1\"><border><left/><right/><top/><bottom/><diagonal/></border></borders>",
|
||||
"<cellStyleXfs count=\"1\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\"/></cellStyleXfs>",
|
||||
"<cellXfs count=\"2\"><xf numFmtId=\"0\" fontId=\"0\" fillId=\"0\" borderId=\"0\" xfId=\"0\"/><xf numFmtId=\"0\" fontId=\"1\" fillId=\"0\" borderId=\"0\" xfId=\"0\" applyFont=\"1\"/></cellXfs>",
|
||||
"<cellStyles count=\"1\"><cellStyle name=\"Normal\" xfId=\"0\" builtinId=\"0\"/></cellStyles>",
|
||||
"</styleSheet>"
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_xlsx_workbook(data: &XlsxWorksheetData) -> Result<Vec<u8>, String> {
|
||||
let sheet_name = normalize_sheet_name(data.sheet_name.as_deref());
|
||||
let files = vec![
|
||||
("[Content_Types].xml", content_types_xml().to_string()),
|
||||
("_rels/.rels", root_rels_xml().to_string()),
|
||||
("xl/workbook.xml", workbook_xml(&sheet_name)),
|
||||
("xl/_rels/workbook.xml.rels", workbook_rels_xml().to_string()),
|
||||
("xl/styles.xml", styles_xml().to_string()),
|
||||
("xl/worksheets/sheet1.xml", worksheet_xml(data)),
|
||||
];
|
||||
|
||||
let cursor = Cursor::new(Vec::<u8>::new());
|
||||
let mut zip = zip::ZipWriter::new(cursor);
|
||||
let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
|
||||
for (path, content) in files {
|
||||
zip.start_file(path, options).map_err(|err| err.to_string())?;
|
||||
zip.write_all(content.as_bytes()).map_err(|err| err.to_string())?;
|
||||
}
|
||||
|
||||
let output = zip.finish().map_err(|err| err.to_string())?;
|
||||
Ok(output.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{build_xlsx_workbook, XlsxWorksheetData};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn builds_xlsx_zip_with_sheet_data() {
|
||||
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
|
||||
sheet_name: Some("Users".to_string()),
|
||||
columns: vec!["id".to_string(), "name".to_string(), "active".to_string()],
|
||||
rows: vec![vec![json!(1), json!("Ada & Bob"), json!(true)], vec![json!(2), json!(null), json!(false)]],
|
||||
})
|
||||
.expect("build workbook");
|
||||
let text = String::from_utf8_lossy(&workbook);
|
||||
|
||||
assert_eq!(workbook[0], 0x50);
|
||||
assert_eq!(workbook[1], 0x4b);
|
||||
assert!(text.contains("[Content_Types].xml"));
|
||||
assert!(text.contains("xl/worksheets/sheet1.xml"));
|
||||
assert!(text.contains("name=\"Users\""));
|
||||
assert!(text.contains("<c r=\"A2\"><v>1</v></c>"));
|
||||
assert!(text.contains("Ada & Bob"));
|
||||
assert!(text.contains("<c r=\"C2\" t=\"b\"><v>1</v></c>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitizes_invalid_sheet_name() {
|
||||
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
|
||||
sheet_name: Some("bad/name:with*chars?and-a-very-long-tail".to_string()),
|
||||
columns: vec!["value".to_string()],
|
||||
rows: vec![vec![json!("ok")]],
|
||||
})
|
||||
.expect("build workbook");
|
||||
let text = String::from_utf8_lossy(&workbook);
|
||||
assert!(text.contains("name=\"bad name with chars and-a-very-\""));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
|
||||
const apiSource = readFileSync("apps/desktop/src/lib/api.ts", "utf8");
|
||||
const tauriSource = readFileSync("apps/desktop/src/lib/tauri.ts", "utf8");
|
||||
const httpSource = readFileSync("apps/desktop/src/lib/http.ts", "utf8");
|
||||
const gridExportSource = readFileSync("apps/desktop/src/composables/useDataGridExport.ts", "utf8");
|
||||
const treeItemSource = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8");
|
||||
const tauriCommandsSource = readFileSync("src-tauri/src/commands/mod.rs", "utf8");
|
||||
const tauriLibSource = readFileSync("src-tauri/src/lib.rs", "utf8");
|
||||
const rustCoreLibSource = readFileSync("crates/dbx-core/src/lib.rs", "utf8");
|
||||
|
||||
const backendFn = "exportQueryResultCsv";
|
||||
|
||||
test("frontend API exposes backend CSV export function", () => {
|
||||
assert.match(apiSource, new RegExp(`export const ${backendFn} = forward\\("${backendFn}"\\)`));
|
||||
assert.match(tauriSource, /export async function exportQueryResultCsv\(/);
|
||||
assert.match(tauriSource, /invoke\("export_query_result_csv"/);
|
||||
assert.match(httpSource, /export async function exportQueryResultCsv\(/);
|
||||
});
|
||||
|
||||
test("CSV export entrypoints use backend API instead of frontend CSV formatter", () => {
|
||||
assert.match(gridExportSource, /api\.exportQueryResultCsv\(/);
|
||||
assert.doesNotMatch(gridExportSource, /formatCsv\(/);
|
||||
|
||||
assert.match(treeItemSource, /api\.exportQueryResultCsv\(/);
|
||||
assert.doesNotMatch(treeItemSource, /content = formatCsv/);
|
||||
});
|
||||
|
||||
test("Rust backend registers CSV export modules and command", () => {
|
||||
assert.match(rustCoreLibSource, /pub mod csv_export/);
|
||||
assert.match(tauriCommandsSource, /pub mod csv_export/);
|
||||
assert.match(tauriLibSource, /commands::csv_export::export_query_result_csv/);
|
||||
});
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { strict as assert } from "node:assert";
|
||||
import test from "node:test";
|
||||
|
||||
const apiSource = readFileSync("apps/desktop/src/lib/api.ts", "utf8");
|
||||
const tauriSource = readFileSync("apps/desktop/src/lib/tauri.ts", "utf8");
|
||||
const httpSource = readFileSync("apps/desktop/src/lib/http.ts", "utf8");
|
||||
const gridExportSource = readFileSync("apps/desktop/src/composables/useDataGridExport.ts", "utf8");
|
||||
const treeItemSource = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8");
|
||||
const tauriCommandsSource = readFileSync("src-tauri/src/commands/mod.rs", "utf8");
|
||||
const tauriLibSource = readFileSync("src-tauri/src/lib.rs", "utf8");
|
||||
|
||||
const backendFn = "exportQueryResultXlsx";
|
||||
|
||||
test("frontend API exposes backend XLSX export function", () => {
|
||||
assert.match(apiSource, new RegExp(`export const ${backendFn} = forward\\("${backendFn}"\\)`));
|
||||
assert.match(tauriSource, /export async function exportQueryResultXlsx\(/);
|
||||
assert.match(tauriSource, /invoke\("export_query_result_xlsx"/);
|
||||
assert.match(httpSource, /export async function exportQueryResultXlsx\(/);
|
||||
});
|
||||
|
||||
test("UI uses backend XLSX export instead of frontend workbook builder", () => {
|
||||
assert.match(gridExportSource, /api\.exportQueryResultXlsx\(/);
|
||||
assert.doesNotMatch(gridExportSource, /buildXlsxWorkbook/);
|
||||
|
||||
assert.match(treeItemSource, /api\.exportQueryResultXlsx\(/);
|
||||
assert.doesNotMatch(treeItemSource, /buildXlsxWorkbook/);
|
||||
});
|
||||
|
||||
test("Tauri registers backend XLSX export command", () => {
|
||||
assert.match(tauriCommandsSource, /pub mod xlsx_export/);
|
||||
assert.match(tauriLibSource, /commands::xlsx_export::export_query_result_xlsx/);
|
||||
});
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
use dbx_core::csv_export::format_csv;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QueryResultCsvExportRequest {
|
||||
pub file_path: String,
|
||||
pub columns: Vec<String>,
|
||||
pub rows: Vec<Vec<Value>>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn export_query_result_csv(request: QueryResultCsvExportRequest) -> Result<(), String> {
|
||||
let csv = format_csv(&request.columns, &request.rows);
|
||||
std::fs::write(&request.file_path, format!("\u{FEFF}{csv}")).map_err(|err| err.to_string())
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ pub mod app_settings;
|
|||
pub mod connection;
|
||||
#[allow(dead_code, unused_imports)]
|
||||
mod connection_secrets;
|
||||
pub mod csv_export;
|
||||
pub mod database_export;
|
||||
pub mod deep_link;
|
||||
pub mod external_sql;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
use dbx_core::xlsx_export::{build_xlsx_workbook, XlsxWorksheetData};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QueryResultXlsxExportRequest {
|
||||
pub file_path: String,
|
||||
pub sheet_name: Option<String>,
|
||||
pub columns: Vec<String>,
|
||||
pub rows: Vec<Vec<Value>>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn export_query_result_xlsx(request: QueryResultXlsxExportRequest) -> Result<(), String> {
|
||||
let workbook = build_xlsx_workbook(&XlsxWorksheetData {
|
||||
sheet_name: request.sheet_name,
|
||||
columns: request.columns,
|
||||
rows: request.rows,
|
||||
})?;
|
||||
std::fs::write(&request.file_path, workbook).map_err(|err| err.to_string())
|
||||
}
|
||||
|
|
@ -337,6 +337,7 @@ pub fn run() {
|
|||
commands::transfer::cancel_transfer,
|
||||
commands::database_export::export_database_sql,
|
||||
commands::database_export::cancel_database_export,
|
||||
commands::csv_export::export_query_result_csv,
|
||||
commands::xlsx_export::export_query_result_xlsx,
|
||||
commands::agents::list_installed_agents,
|
||||
commands::agents::list_installed_agents_local,
|
||||
|
|
|
|||
Loading…
Reference in New Issue