diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 93e7e455eb..bf1c5ae1f5 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -92,7 +92,7 @@ import { treeNodeRowAction, treeNodeRowDoubleClickAction, } from "@/lib/treeNodeClick"; -import { formatJson, formatSqlInsert } from "@/lib/exportFormats"; +import { formatSqlInsert } from "@/lib/exportFormats"; import { fetchTableDataForExport } from "@/lib/tableDataExport"; import { buildCreateDatabaseSql, @@ -1199,33 +1199,6 @@ async function saveFileContent(content: string, defaultFileName: string, filterN } } -async function saveBinaryFileContent( - content: Uint8Array, - defaultFileName: string, - filterName: string, - filterExt: string, -) { - 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) await writeFile(path, content); - } 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); - } -} - async function exportStructure() { const node = props.node; if (!node.connectionId || !node.database) return; @@ -1282,18 +1255,24 @@ async function exportData(format: "csv" | "json" | "sql") { return; } - let content: string; - let ext: string; - if (format === "json") { - ext = "json"; - content = formatJson(result.columns, result.rows); - } else { - ext = "sql"; - content = formatSqlInsert(qualifiedName, result.columns, result.rows, quoteIdent); + let outputPath = `${node.label}.json`; + if (isTauriRuntime()) { + const { save } = await import("@tauri-apps/plugin-dialog"); + const path = await save({ + defaultPath: outputPath, + filters: [{ name: "JSON", extensions: ["json"] }], + }); + if (!path) return; + outputPath = path as string; + } + await api.exportQueryResultJson(outputPath, result.columns, result.rows); + toast(t("grid.exported")); + return; } - await saveFileContent(content, `${node.label}.${ext}`, ext.toUpperCase(), ext); + const content = formatSqlInsert(qualifiedName, result.columns, result.rows, quoteIdent); + await saveFileContent(content, `${node.label}.sql`, "SQL", "sql"); toast(t("grid.exported")); } catch (e: any) { toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000); diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index 9454b8c22f..525fbb143a 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -1,7 +1,6 @@ import { computed, type ComputedRef, type Ref } from "vue"; import { useI18n } from "vue-i18n"; import { isTauriRuntime } from "@/lib/tauriRuntime"; -import { formatJson } from "@/lib/exportFormats"; import * as api from "@/lib/api"; import { formatSelectionAsCsv, @@ -238,35 +237,6 @@ export function useDataGridExport(options: UseDataGridExportOptions) { await copyText(`${header}\n${body}`); } - // --- File save helpers --- - async function saveFileContent( - content: string, - defaultFileName: string, - filterName: string, - filterExt: string, - ): Promise { - if (isTauriRuntime()) { - const { save } = await import("@tauri-apps/plugin-dialog"); - const { writeTextFile } = await import("@tauri-apps/plugin-fs"); - const path = await save({ - defaultPath: defaultFileName, - filters: [{ name: filterName, extensions: [filterExt] }], - }); - if (!path) return false; - await writeTextFile(path, "" + content); - return true; - } else { - const blob = new Blob(["", content], { type: "text/csv;charset=utf-8" }); - 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) { const finish = tryStartExclusiveActivation(exportGuard); @@ -303,10 +273,22 @@ export function useDataGridExport(options: UseDataGridExportOptions) { async function exportJson(rowIds?: number[]) { await runExclusiveExport(async () => { try { - const rows = rowsToExport(rowIds).map((item) => item.data); - if (await saveFileContent(formatJson(columns.value, rows), "export.json", "JSON", "json")) { - toast(t("grid.exported")); + let outputPath = "export.json"; + if (isTauriRuntime()) { + const { save } = await import("@tauri-apps/plugin-dialog"); + const path = await save({ + defaultPath: outputPath, + filters: [{ name: "JSON", extensions: ["json"] }], + }); + if (!path) return; + outputPath = path as string; } + await api.exportQueryResultJson( + outputPath, + 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); } @@ -316,13 +298,22 @@ export function useDataGridExport(options: UseDataGridExportOptions) { async function exportMarkdown(rowIds?: number[]) { await runExclusiveExport(async () => { try { - const cols = columns.value; - const visibleRows = rowsToExport(rowIds).map((item) => item.data); - const { formatMarkdownTable } = await import("@/lib/markdownTable"); - const md = formatMarkdownTable({ columns: cols, rows: visibleRows }); - if (await saveFileContent(md, "export.md", "Markdown", "md")) { - toast(t("grid.exported")); + let outputPath = "export.md"; + if (isTauriRuntime()) { + const { save } = await import("@tauri-apps/plugin-dialog"); + const path = await save({ + defaultPath: outputPath, + filters: [{ name: "Markdown", extensions: ["md"] }], + }); + if (!path) return; + outputPath = path as string; } + await api.exportQueryResultMarkdown( + outputPath, + 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); } diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 62e47a0d00..2843fa9eb8 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -132,6 +132,8 @@ export const exportDatabaseSql = forward("exportDatabaseSql"); export const cancelDatabaseExport = forward("cancelDatabaseExport"); export const exportQueryResultCsv = forward("exportQueryResultCsv"); export const exportQueryResultXlsx = forward("exportQueryResultXlsx"); +export const exportQueryResultJson = forward("exportQueryResultJson"); +export const exportQueryResultMarkdown = forward("exportQueryResultMarkdown"); // Redis export const redisListDatabases = forward("redisListDatabases"); diff --git a/apps/desktop/src/lib/exportFormats.ts b/apps/desktop/src/lib/exportFormats.ts index c35a61af6d..1814aeeebd 100644 --- a/apps/desktop/src/lib/exportFormats.ts +++ b/apps/desktop/src/lib/exportFormats.ts @@ -7,17 +7,6 @@ export function formatCsv(columns: string[], rows: ExportCellValue[][]): string return `${header}\n${body}`; } -export function formatJson(columns: string[], rows: ExportCellValue[][]): string { - const data = rows.map((row) => { - const obj: Record = {}; - columns.forEach((col, i) => { - obj[col] = row[i]; - }); - return obj; - }); - return JSON.stringify(data, null, 2); -} - export function formatSqlInsert( qualifiedName: string, columns: string[], diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index aba854d2f0..9217ccb085 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -709,6 +709,17 @@ export async function exportQueryResultCsv( URL.revokeObjectURL(url); } +function downloadTextFile(filePath: string, fallbackFileName: string, content: string, mimeType: string): void { + const fileName = filePath.split(/[\\/]/).pop() || fallbackFileName; + const blob = new Blob(["\uFEFF", content], { type: mimeType }); + 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, @@ -733,6 +744,24 @@ export async function exportQueryResultXlsx( URL.revokeObjectURL(url); } +export async function exportQueryResultJson( + filePath: string, + columns: string[], + rows: readonly (readonly XlsxCellValue[])[], +): Promise { + const result = await post<{ content: string }>("/api/export/query-result-json", { columns, rows }); + downloadTextFile(filePath, "export.json", result.content, "application/json;charset=utf-8"); +} + +export async function exportQueryResultMarkdown( + filePath: string, + columns: string[], + rows: readonly (readonly XlsxCellValue[])[], +): Promise { + const result = await post<{ content: string }>("/api/export/query-result-markdown", { columns, rows }); + downloadTextFile(filePath, "export.md", result.content, "text/markdown;charset=utf-8"); +} + // --------------------------------------------------------------------------- // Redis // --------------------------------------------------------------------------- diff --git a/apps/desktop/src/lib/markdownTable.ts b/apps/desktop/src/lib/markdownTable.ts deleted file mode 100644 index 9321d85dcf..0000000000 --- a/apps/desktop/src/lib/markdownTable.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { GridCellValue } from "./dataGridSql.ts"; - -export interface MarkdownTableData { - columns: string[]; - rows: GridCellValue[][]; -} - -export function formatMarkdownTable(data: MarkdownTableData): string { - const normalizedColumns = data.columns.map(markdownCell); - const normalizedRows = data.rows.map((row) => row.map((cell) => markdownCell(displayCell(cell)))); - const widths = normalizedColumns.map((column, index) => - Math.max(column.length, ...normalizedRows.map((row) => row[index]?.length ?? 0), 3), - ); - const header = `| ${normalizedColumns.map((column, index) => pad(column, widths[index])).join(" | ")} |`; - const separator = `| ${widths.map((width) => "-".repeat(width)).join(" | ")} |`; - const body = normalizedRows - .map((row) => `| ${row.map((cell, index) => pad(cell, widths[index])).join(" | ")} |`) - .join("\n"); - return `${[header, separator, body].filter(Boolean).join("\n")}\n`; -} - -function displayCell(value: GridCellValue): string { - if (value === null) return "NULL"; - if (typeof value === "boolean") return value ? "true" : "false"; - return String(value); -} - -function markdownCell(value: string): string { - return value.replace(/\|/g, "\\|").replace(/\r?\n/g, "
"); -} - -function pad(value: string, width: number): string { - return value.padEnd(width); -} diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index dba02ed8fd..0a8f7d26f1 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -1023,3 +1023,31 @@ export async function exportQueryResultXlsx( }, }); } + +export async function exportQueryResultJson( + filePath: string, + columns: string[], + rows: readonly (readonly XlsxCellValue[])[], +): Promise { + return invoke("export_query_result_json", { + request: { + filePath, + columns, + rows, + }, + }); +} + +export async function exportQueryResultMarkdown( + filePath: string, + columns: string[], + rows: readonly (readonly XlsxCellValue[])[], +): Promise { + return invoke("export_query_result_markdown", { + request: { + filePath, + columns, + rows, + }, + }); +} diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs index 92dd2817b3..fd3c9a5b85 100644 --- a/crates/dbx-core/src/lib.rs +++ b/crates/dbx-core/src/lib.rs @@ -23,6 +23,7 @@ pub mod sql_analysis; pub mod sql_editability; pub mod storage; pub mod table_import; +pub mod text_export; pub mod transfer; pub mod types; pub mod update; diff --git a/crates/dbx-core/src/text_export.rs b/crates/dbx-core/src/text_export.rs new file mode 100644 index 0000000000..f02e105aa8 --- /dev/null +++ b/crates/dbx-core/src/text_export.rs @@ -0,0 +1,143 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QueryResultTextExportData { + pub columns: Vec, + pub rows: Vec>, +} + +pub fn format_json(data: &QueryResultTextExportData) -> Result { + let rows = data + .rows + .iter() + .map(|row| { + let mut object = Map::new(); + for (index, column) in data.columns.iter().enumerate() { + if let Some(value) = row.get(index) { + object.insert(column.clone(), value.clone()); + } + } + Value::Object(object) + }) + .collect::>(); + serde_json::to_string_pretty(&rows).map_err(|err| err.to_string()) +} + +pub fn format_markdown(data: &QueryResultTextExportData) -> String { + let normalized_columns = data.columns.iter().map(|column| markdown_cell(column)).collect::>(); + let normalized_rows = data + .rows + .iter() + .map(|row| row.iter().map(|cell| markdown_cell(&display_cell(cell))).collect::>()) + .collect::>(); + let widths = normalized_columns + .iter() + .enumerate() + .map(|(index, column)| { + let row_width = normalized_rows + .iter() + .map(|row| row.get(index).map(|cell| cell.chars().count()).unwrap_or(0)) + .max() + .unwrap_or(0); + column.chars().count().max(row_width).max(3) + }) + .collect::>(); + + let header = format!( + "| {} |", + normalized_columns + .iter() + .enumerate() + .map(|(index, column)| pad(column, widths[index])) + .collect::>() + .join(" | ") + ); + let separator = format!("| {} |", widths.iter().map(|width| "-".repeat(*width)).collect::>().join(" | ")); + let body = normalized_rows + .iter() + .map(|row| { + format!( + "| {} |", + row.iter() + .enumerate() + .map(|(index, cell)| pad(cell, widths.get(index).copied().unwrap_or(3))) + .collect::>() + .join(" | ") + ) + }) + .collect::>() + .join("\n"); + [header, separator, body].into_iter().filter(|part| !part.is_empty()).collect::>().join("\n") + "\n" +} + +fn display_cell(value: &Value) -> String { + match value { + Value::Null => "NULL".to_string(), + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + Value::String(value) => value.clone(), + other => other.to_string(), + } +} + +fn markdown_cell(value: &str) -> String { + value.replace('|', "\\|").replace("\r\n", "
").replace('\n', "
") +} + +fn pad(value: &str, width: usize) -> String { + let current = value.chars().count(); + if current >= width { + return value.to_string(); + } + format!("{value}{}", " ".repeat(width - current)) +} + +#[cfg(test)] +mod tests { + use serde_json::{json, Value}; + + use super::{format_json, format_markdown, QueryResultTextExportData}; + + #[test] + fn formats_json_rows_as_objects() { + let out = format_json(&QueryResultTextExportData { + columns: vec!["id".to_string(), "name".to_string(), "active".to_string(), "note".to_string()], + rows: vec![vec![json!(1), json!("Ada"), json!(true), Value::Null]], + }) + .unwrap(); + + assert_eq!( + out, + r#"[ + { + "id": 1, + "name": "Ada", + "active": true, + "note": null + } +]"# + ); + } + + #[test] + fn formats_markdown_with_escaped_pipes_and_newlines() { + let out = format_markdown(&QueryResultTextExportData { + columns: vec!["id".to_string(), "payload|kind".to_string()], + rows: vec![vec![json!(1), json!("a|b")], vec![json!(2), json!("line one\nline two")]], + }); + + assert_eq!( + out, + [ + "| id | payload\\|kind |", + "| --- | -------------------- |", + "| 1 | a\\|b |", + "| 2 | line one
line two |", + "", + ] + .join("\n") + ); + } +} diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 40147e101f..2264b1d026 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -128,6 +128,8 @@ async fn main() { .route("/query/prepare-data-grid-save", post(routes::query::prepare_data_grid_save)) .route("/query/cancel", post(routes::query::cancel_query)) .route("/query/close-session", post(routes::query::close_query_session)) + .route("/export/query-result-json", post(routes::text_export::export_query_result_json)) + .route("/export/query-result-markdown", post(routes::text_export::export_query_result_markdown)) // Redis .route("/redis/list-databases", post(routes::redis::list_databases)) .route("/redis/scan-keys", post(routes::redis::scan_keys)) diff --git a/crates/dbx-web/src/routes/mod.rs b/crates/dbx-web/src/routes/mod.rs index f2b385e3cb..e8e284454b 100644 --- a/crates/dbx-web/src/routes/mod.rs +++ b/crates/dbx-web/src/routes/mod.rs @@ -14,5 +14,6 @@ pub mod schema; pub mod schema_cache; pub mod sql_file; pub mod table_import; +pub mod text_export; pub mod transfer; pub mod update; diff --git a/crates/dbx-web/src/routes/text_export.rs b/crates/dbx-web/src/routes/text_export.rs new file mode 100644 index 0000000000..6ea4366a6f --- /dev/null +++ b/crates/dbx-web/src/routes/text_export.rs @@ -0,0 +1,38 @@ +use axum::Json; +use dbx_core::text_export::{format_json, format_markdown, QueryResultTextExportData}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::error::AppError; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QueryResultTextExportRequest { + pub columns: Vec, + pub rows: Vec>, +} + +impl QueryResultTextExportRequest { + fn data(self) -> QueryResultTextExportData { + QueryResultTextExportData { columns: self.columns, rows: self.rows } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct QueryResultTextExportResponse { + pub content: String, +} + +pub async fn export_query_result_json( + Json(req): Json, +) -> Result, AppError> { + let content = format_json(&req.data()).map_err(AppError::from)?; + Ok(Json(QueryResultTextExportResponse { content })) +} + +pub async fn export_query_result_markdown( + Json(req): Json, +) -> Json { + Json(QueryResultTextExportResponse { content: format_markdown(&req.data()) }) +} diff --git a/packages/app-tests/markdownTable.test.ts b/packages/app-tests/markdownTable.test.ts deleted file mode 100644 index d0dde7eeeb..0000000000 --- a/packages/app-tests/markdownTable.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { strict as assert } from "node:assert"; -import test from "node:test"; -import { formatMarkdownTable } from "../../apps/desktop/src/lib/markdownTable.ts"; - -test("escapes markdown table pipes and normalizes newlines", () => { - const markdown = formatMarkdownTable({ - columns: ["id", "payload|kind"], - rows: [ - [1, "a|b"], - [2, "line one\nline two"], - ], - }); - - assert.equal(markdown, [ - "| id | payload\\|kind |", - "| --- | -------------------- |", - "| 1 | a\\|b |", - "| 2 | line one
line two |", - "", - ].join("\n")); -}); diff --git a/packages/app-tests/textBackendExport.test.ts b/packages/app-tests/textBackendExport.test.ts new file mode 100644 index 0000000000..645f640321 --- /dev/null +++ b/packages/app-tests/textBackendExport.test.ts @@ -0,0 +1,45 @@ +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 tauriCommandsSource = readFileSync("src-tauri/src/commands/mod.rs", "utf8"); +const tauriLibSource = readFileSync("src-tauri/src/lib.rs", "utf8"); +const webRoutesSource = readFileSync("crates/dbx-web/src/routes/mod.rs", "utf8"); +const webMainSource = readFileSync("crates/dbx-web/src/main.rs", "utf8"); +const rustCoreLibSource = readFileSync("crates/dbx-core/src/lib.rs", "utf8"); + +test("frontend API exposes backend JSON and Markdown export functions", () => { + assert.match(apiSource, /export const exportQueryResultJson = forward\("exportQueryResultJson"\)/); + assert.match(apiSource, /export const exportQueryResultMarkdown = forward\("exportQueryResultMarkdown"\)/); + + assert.match(tauriSource, /export async function exportQueryResultJson\(/); + assert.match(tauriSource, /invoke\("export_query_result_json"/); + assert.match(tauriSource, /export async function exportQueryResultMarkdown\(/); + assert.match(tauriSource, /invoke\("export_query_result_markdown"/); + + assert.match(httpSource, /export async function exportQueryResultJson\(/); + assert.match(httpSource, /\/api\/export\/query-result-json/); + assert.match(httpSource, /export async function exportQueryResultMarkdown\(/); + assert.match(httpSource, /\/api\/export\/query-result-markdown/); +}); + +test("data grid JSON and Markdown exports use backend APIs", () => { + assert.match(gridExportSource, /api\.exportQueryResultJson\(/); + assert.match(gridExportSource, /api\.exportQueryResultMarkdown\(/); + assert.doesNotMatch(gridExportSource, /formatJson\(/); + assert.doesNotMatch(gridExportSource, /formatMarkdownTable/); +}); + +test("Rust backends register JSON and Markdown export modules", () => { + assert.match(rustCoreLibSource, /pub mod text_export/); + assert.match(tauriCommandsSource, /pub mod text_export/); + assert.match(tauriLibSource, /commands::text_export::export_query_result_json/); + assert.match(tauriLibSource, /commands::text_export::export_query_result_markdown/); + assert.match(webRoutesSource, /pub mod text_export/); + assert.match(webMainSource, /\/export\/query-result-json/); + assert.match(webMainSource, /\/export\/query-result-markdown/); +}); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 46aa9ec3b5..e654eb8899 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -21,6 +21,7 @@ pub mod schema_cache; pub mod sql_file; pub mod system_fonts; pub mod table_import; +pub mod text_export; pub mod transfer; pub mod update; pub mod xlsx_export; diff --git a/src-tauri/src/commands/text_export.rs b/src-tauri/src/commands/text_export.rs new file mode 100644 index 0000000000..7077f0a348 --- /dev/null +++ b/src-tauri/src/commands/text_export.rs @@ -0,0 +1,29 @@ +use dbx_core::text_export::{format_json, format_markdown, QueryResultTextExportData}; +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QueryResultTextExportRequest { + pub file_path: String, + pub columns: Vec, + pub rows: Vec>, +} + +impl QueryResultTextExportRequest { + fn data(&self) -> QueryResultTextExportData { + QueryResultTextExportData { columns: self.columns.clone(), rows: self.rows.clone() } + } +} + +#[tauri::command] +pub fn export_query_result_json(request: QueryResultTextExportRequest) -> Result<(), String> { + let content = format_json(&request.data())?; + std::fs::write(&request.file_path, format!("\u{FEFF}{content}")).map_err(|err| err.to_string()) +} + +#[tauri::command] +pub fn export_query_result_markdown(request: QueryResultTextExportRequest) -> Result<(), String> { + let content = format_markdown(&request.data()); + std::fs::write(&request.file_path, format!("\u{FEFF}{content}")).map_err(|err| err.to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7b3dcbaaa2..21bc3b0f4b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -341,6 +341,8 @@ pub fn run() { commands::database_export::cancel_database_export, commands::csv_export::export_query_result_csv, commands::xlsx_export::export_query_result_xlsx, + commands::text_export::export_query_result_json, + commands::text_export::export_query_result_markdown, commands::agents::list_installed_agents, commands::agents::list_installed_agents_local, commands::agents::install_agent,