diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 5778ee4bbc..2b7f808432 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -197,6 +197,7 @@ const props = defineProps<{ cacheKey?: string; onExecuteSql?: (sql: string) => Promise; fullExportResult?: (onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => Promise; + allExportResults?: Array<{ sheetName: string; result: QueryResult }>; customSaveHandler?: import("@/composables/useDataGridEditor").CustomSaveHandler; }>(); @@ -4272,10 +4273,16 @@ const { copySelectionSqlInList, copySelectedRowsTsv, exportCsv, + exportCurrentPageCsv, exportJson, + exportCurrentPageJson, exportMarkdown, + exportCurrentPageMarkdown, exportXlsx, + exportCurrentPageXlsx, + exportAllResultsXlsx, exportSql, + exportCurrentPageSql, copySql, } = useDataGridExport({ columns: visibleColumns, @@ -4299,6 +4306,7 @@ const { selectedRowIds, hasRowSelection, fullExportResult: props.fullExportResult, + allExportResults: computed(() => props.allExportResults), exportProgressDialog, exportProgressState, }); @@ -4310,13 +4318,10 @@ const pageSizeMenuItems = computed(() => })), ); -const exportMenuItems = computed(() => [ - { value: "csv", label: t("grid.exportCsv") }, - { value: "xlsx", label: t("grid.exportXlsx") }, - { value: "json", label: t("grid.exportJson") }, - { value: "markdown", label: t("grid.exportMarkdown") }, - { value: "sql", label: t("grid.exportSql") }, - ...(isMultiRow.value +const exportMenuItems = computed(() => { + const hasFullResultExport = !!props.fullExportResult; + const allResultItems = (props.allExportResults?.length ?? 0) > 1 ? [{ value: "all-results-xlsx", label: t("grid.exportAllResultsXlsx"), separatorBefore: true }] : []; + const selectedItems = isMultiRow.value ? [ { value: "selected-csv", label: t("grid.exportSelectedRowsCsv"), separatorBefore: true }, { value: "selected-xlsx", label: t("grid.exportSelectedRowsXlsx") }, @@ -4324,8 +4329,27 @@ const exportMenuItems = computed(() => [ { value: "selected-markdown", label: t("grid.exportSelectedRowsMarkdown") }, { value: "selected-sql", label: t("grid.exportSelectedRowsSql") }, ] - : []), -]); + : []; + + if (!hasFullResultExport) { + return [{ value: "csv", label: t("grid.exportCsv") }, { value: "xlsx", label: t("grid.exportXlsx") }, { value: "json", label: t("grid.exportJson") }, { value: "markdown", label: t("grid.exportMarkdown") }, { value: "sql", label: t("grid.exportSql") }, ...allResultItems, ...selectedItems]; + } + + return [ + { value: "page-csv", label: t("grid.exportCurrentPageCsv") }, + { value: "page-xlsx", label: t("grid.exportCurrentPageXlsx") }, + { value: "page-json", label: t("grid.exportCurrentPageJson") }, + { value: "page-markdown", label: t("grid.exportCurrentPageMarkdown") }, + { value: "page-sql", label: t("grid.exportCurrentPageSql") }, + { value: "csv", label: t("grid.exportCurrentResultCsv"), separatorBefore: true }, + { value: "xlsx", label: t("grid.exportCurrentResultXlsx") }, + { value: "json", label: t("grid.exportCurrentResultJson") }, + { value: "markdown", label: t("grid.exportCurrentResultMarkdown") }, + { value: "sql", label: t("grid.exportCurrentResultSql") }, + ...allResultItems, + ...selectedItems, + ]; +}); function selectPageSizeMenuItem(value: string) { changePageSize(Number(value)); @@ -4333,8 +4357,14 @@ function selectPageSizeMenuItem(value: string) { function selectExportMenuItem(value: string) { const actions: Record void> = { + "page-csv": exportCurrentPageCsv, + "page-xlsx": exportCurrentPageXlsx, + "page-json": exportCurrentPageJson, + "page-markdown": exportCurrentPageMarkdown, + "page-sql": exportCurrentPageSql, csv: exportCsv, xlsx: exportXlsx, + "all-results-xlsx": exportAllResultsXlsx, json: exportJson, markdown: exportMarkdown, sql: exportSql, diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 1326162142..7fa5085e61 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -230,6 +230,12 @@ const activeQueryError = computed(() => { }); const hasQueryOutput = computed(() => !!props.activeTab.result || !!props.activeTab.explainPlan || !!props.activeTab.explainError || props.activeTab.isExecuting === true || props.activeTab.isExplaining === true); const tabularResults = computed(() => tabularResultItems(props.activeTab.results)); +const allResultExportSheets = computed(() => + tabularResults.value.map((item) => ({ + sheetName: t("tabs.resultN", { n: item.n }), + result: item.result, + })), +); const resultRuns = computed(() => resultRunItems(props.activeTab)); const activeResultRunItem = computed(() => resultRuns.value.find((run) => run.active)); const activeResultGridCacheKey = computed(() => resultGridCacheKey(props.activeTab)); @@ -763,6 +769,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget }); :total-row-count-loading="activeTab.resultTotalRowCountLoading" :on-execute-sql="async (sql: string) => emit('executeSql', sql)" :full-export-result="(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => queryStore.fetchTabResultForExport(activeTab.id, onProgress)" + :all-export-results="allResultExportSheets" @update:order-by-input="(v: string) => (activeTab.orderByInput = v)" @reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number) => emit('reload', sql, searchText, whereInput, orderBy, limit, offset)" @paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string) => emit('paginate', offset, limit, whereInput, orderBy)" diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index 3febb0fd90..849754b62f 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -45,6 +45,7 @@ export interface UseDataGridExportOptions { selectedRowIds: Ref> | ComputedRef>; hasRowSelection: ComputedRef; fullExportResult?: (onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => Promise; + allExportResults?: ComputedRef | undefined>; exportProgressDialog?: Ref; exportProgressState?: Ref<{ title: string; @@ -109,6 +110,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { selectedRowIds, hasRowSelection, fullExportResult, + allExportResults, exportProgressDialog, exportProgressState, } = options; @@ -128,8 +130,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) { return displayItems.value.filter((item) => rowIdSet.has(item.id)); } - async function resultToExport(rowIds?: number[], onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void): Promise<{ columns: string[]; rows: CellValue[][] }> { - if (!rowIds?.length && fullExportResult) { + async function resultToExport(rowIds?: number[], onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void, useFullExport = true): Promise<{ columns: string[]; rows: CellValue[][] }> { + if (useFullExport && !rowIds?.length && fullExportResult) { const result = await fullExportResult(onProgress); if (result) return { columns: result.columns, rows: result.rows }; } @@ -533,6 +535,28 @@ export function useDataGridExport(options: UseDataGridExportOptions) { }); } + async function exportCurrentPageCsv() { + await runExclusiveExport(async () => { + try { + let outputPath = "export-page.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; + } + const result = await resultToExport(undefined, undefined, false); + await api.exportQueryResultCsv(outputPath, result.columns, result.rows); + toast(t("grid.exported")); + } catch (e: any) { + toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000); + } + }); + } + async function exportJson(rowIds?: number[]) { await runExclusiveExport(async () => { try { @@ -557,6 +581,28 @@ export function useDataGridExport(options: UseDataGridExportOptions) { }); } + async function exportCurrentPageJson() { + await runExclusiveExport(async () => { + try { + let outputPath = "export-page.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; + } + const result = await resultToExport(undefined, undefined, false); + await api.exportQueryResultJson(outputPath, result.columns, result.rows); + toast(t("grid.exported")); + } catch (e: any) { + toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000); + } + }); + } + async function exportMarkdown(rowIds?: number[]) { await runExclusiveExport(async () => { try { @@ -581,6 +627,28 @@ export function useDataGridExport(options: UseDataGridExportOptions) { }); } + async function exportCurrentPageMarkdown() { + await runExclusiveExport(async () => { + try { + let outputPath = "export-page.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; + } + const result = await resultToExport(undefined, undefined, false); + await api.exportQueryResultMarkdown(outputPath, result.columns, result.rows); + toast(t("grid.exported")); + } catch (e: any) { + toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000); + } + }); + } + async function exportXlsx(rowIds?: number[]) { await runExclusiveExport(async () => { try { @@ -650,6 +718,60 @@ export function useDataGridExport(options: UseDataGridExportOptions) { }); } + async function exportCurrentPageXlsx() { + await runExclusiveExport(async () => { + try { + let outputPath = "export-page.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; + } + const result = await resultToExport(undefined, undefined, false); + await api.exportQueryResultXlsx(outputPath, tableMeta.value?.tableName || "Export", result.columns, result.rows); + toast(t("grid.exported")); + } catch (e: any) { + toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000); + } + }); + } + + async function exportAllResultsXlsx() { + await runExclusiveExport(async () => { + try { + const sheets = (allExportResults?.value ?? []).filter((sheet) => sheet.result.columns.length > 0); + if (sheets.length === 0) return; + + let outputPath = "query-results.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.exportQueryResultsXlsx( + outputPath, + sheets.map((sheet) => ({ + sheetName: sheet.sheetName, + columns: sheet.result.columns, + rows: sheet.result.rows, + })), + ); + toast(t("grid.exported")); + } catch (e: any) { + toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000); + } + }); + } + async function exportFullTableDataViaBackend(format: "csv" | "xlsx" | "json" | "markdown" | "sql", rowIds?: number[]): Promise { const meta = tableMeta.value; if (rowIds?.length || context.value !== "table-data" || !meta || !connectionId.value || !database.value) { @@ -739,6 +861,27 @@ export function useDataGridExport(options: UseDataGridExportOptions) { }); } + async function exportCurrentPageSql() { + await runExclusiveExport(async () => { + try { + const result = await resultToExport(undefined, undefined, false); + const exportData = sqlInsertExportData(result); + const content = await formatSqlInsert({ + databaseType: databaseType.value, + schema: tableMeta.value?.schema, + tableName: tableMeta.value?.tableName || "table_name", + columns: exportData.columns, + columnTypes: exportData.columnTypes, + rows: exportData.rows, + }); + await saveTextFile(content, "export-page.sql", "SQL", "sql"); + toast(t("grid.exported")); + } catch (e: any) { + toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000); + } + }); + } + async function copySql() { if (!sql.value) return; await copyText(sql.value); @@ -780,10 +923,16 @@ export function useDataGridExport(options: UseDataGridExportOptions) { copySelectionSqlInList, copySelectedRowsTsv, exportCsv, + exportCurrentPageCsv, exportJson, + exportCurrentPageJson, exportMarkdown, + exportCurrentPageMarkdown, exportXlsx, + exportCurrentPageXlsx, + exportAllResultsXlsx, exportSql, + exportCurrentPageSql, copySql, }; } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index af8e5af04b..1102549bb2 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -531,6 +531,17 @@ export default { export: "Export", exportCsv: "Export CSV", exportXlsx: "Export XLSX", + exportCurrentPageCsv: "Export Current Page as CSV", + exportCurrentPageXlsx: "Export Current Page as XLSX", + exportCurrentPageJson: "Export Current Page as JSON", + exportCurrentPageMarkdown: "Export Current Page as Markdown", + exportCurrentPageSql: "Export Current Page as SQL INSERT", + exportCurrentResultCsv: "Export Current Result Set All Data as CSV", + exportCurrentResultXlsx: "Export Current Result Set All Data as XLSX", + exportCurrentResultJson: "Export Current Result Set All Data as JSON", + exportCurrentResultMarkdown: "Export Current Result Set All Data as Markdown", + exportCurrentResultSql: "Export Current Result Set All Data as SQL INSERT", + exportAllResultsXlsx: "Export This Run's All Result Sets as XLSX", exportJson: "Export JSON", exportMarkdown: "Export Markdown", exportSql: "Export SQL INSERT", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index fac6003dce..0f62370516 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -472,6 +472,17 @@ export default { export: "Exportar", exportCsv: "Exportar CSV", exportXlsx: "Exportar XLSX", + exportCurrentPageCsv: "Exportar página actual como CSV", + exportCurrentPageXlsx: "Exportar página actual como XLSX", + exportCurrentPageJson: "Exportar página actual como JSON", + exportCurrentPageMarkdown: "Exportar página actual como Markdown", + exportCurrentPageSql: "Exportar página actual como SQL INSERT", + exportCurrentResultCsv: "Exportar todos los datos del resultado actual como CSV", + exportCurrentResultXlsx: "Exportar todos los datos del resultado actual como XLSX", + exportCurrentResultJson: "Exportar todos los datos del resultado actual como JSON", + exportCurrentResultMarkdown: "Exportar todos los datos del resultado actual como Markdown", + exportCurrentResultSql: "Exportar todos los datos del resultado actual como SQL INSERT", + exportAllResultsXlsx: "Exportar todos los resultados de esta ejecución como XLSX", exportJson: "Exportar JSON", exportMarkdown: "Exportar Markdown", exportSql: "Exportar SQL INSERT", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 3abd5ef259..bb1896a456 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -477,6 +477,17 @@ export default { export: "Esporta", exportCsv: "Esporta CSV", exportXlsx: "Esporta XLSX", + exportCurrentPageCsv: "Esporta pagina corrente come CSV", + exportCurrentPageXlsx: "Esporta pagina corrente come XLSX", + exportCurrentPageJson: "Esporta pagina corrente come JSON", + exportCurrentPageMarkdown: "Esporta pagina corrente come Markdown", + exportCurrentPageSql: "Esporta pagina corrente come SQL INSERT", + exportCurrentResultCsv: "Esporta tutti i dati del risultato corrente come CSV", + exportCurrentResultXlsx: "Esporta tutti i dati del risultato corrente come XLSX", + exportCurrentResultJson: "Esporta tutti i dati del risultato corrente come JSON", + exportCurrentResultMarkdown: "Esporta tutti i dati del risultato corrente come Markdown", + exportCurrentResultSql: "Esporta tutti i dati del risultato corrente come SQL INSERT", + exportAllResultsXlsx: "Esporta tutti i set di risultati di questa esecuzione come XLSX", exportJson: "Esporta JSON", exportMarkdown: "Esporta Markdown", exportSql: "Esporta SQL INSERT", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 534d2bd347..1c46e14fe4 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -527,6 +527,17 @@ export default { export: "エクスポート", exportCsv: "CSVエクスポート", exportXlsx: "XLSXエクスポート", + exportCurrentPageCsv: "現在のページをCSVでエクスポート", + exportCurrentPageXlsx: "現在のページをXLSXでエクスポート", + exportCurrentPageJson: "現在のページをJSONでエクスポート", + exportCurrentPageMarkdown: "現在のページをMarkdownでエクスポート", + exportCurrentPageSql: "現在のページをSQL INSERTでエクスポート", + exportCurrentResultCsv: "現在の結果セット全データをCSVでエクスポート", + exportCurrentResultXlsx: "現在の結果セット全データをXLSXでエクスポート", + exportCurrentResultJson: "現在の結果セット全データをJSONでエクスポート", + exportCurrentResultMarkdown: "現在の結果セット全データをMarkdownでエクスポート", + exportCurrentResultSql: "現在の結果セット全データをSQL INSERTでエクスポート", + exportAllResultsXlsx: "今回実行の全結果セットをXLSXでエクスポート", exportJson: "JSONエクスポート", exportMarkdown: "Markdownエクスポート", exportSql: "SQL INSERTエクスポート", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 9f76717b39..ca5fc6f9b9 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -477,6 +477,17 @@ export default { export: "Exportar", exportCsv: "Exportar CSV", exportXlsx: "Exportar XLSX", + exportCurrentPageCsv: "Exportar página atual como CSV", + exportCurrentPageXlsx: "Exportar página atual como XLSX", + exportCurrentPageJson: "Exportar página atual como JSON", + exportCurrentPageMarkdown: "Exportar página atual como Markdown", + exportCurrentPageSql: "Exportar página atual como SQL INSERT", + exportCurrentResultCsv: "Exportar todos os dados do resultado atual como CSV", + exportCurrentResultXlsx: "Exportar todos os dados do resultado atual como XLSX", + exportCurrentResultJson: "Exportar todos os dados do resultado atual como JSON", + exportCurrentResultMarkdown: "Exportar todos os dados do resultado atual como Markdown", + exportCurrentResultSql: "Exportar todos os dados do resultado atual como SQL INSERT", + exportAllResultsXlsx: "Exportar todos os conjuntos de resultados desta execução como XLSX", exportJson: "Exportar JSON", exportMarkdown: "Exportar Markdown", exportSql: "Exportar SQL INSERT", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 8f601d8097..b6eb599a11 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -532,6 +532,17 @@ export default { export: "导出", exportCsv: "导出 CSV", exportXlsx: "导出 XLSX", + exportCurrentPageCsv: "导出当前页 CSV", + exportCurrentPageXlsx: "导出当前页 XLSX", + exportCurrentPageJson: "导出当前页 JSON", + exportCurrentPageMarkdown: "导出当前页 Markdown", + exportCurrentPageSql: "导出当前页 SQL INSERT", + exportCurrentResultCsv: "导出当前结果集全部数据 CSV", + exportCurrentResultXlsx: "导出当前结果集全部数据 XLSX", + exportCurrentResultJson: "导出当前结果集全部数据 JSON", + exportCurrentResultMarkdown: "导出当前结果集全部数据 Markdown", + exportCurrentResultSql: "导出当前结果集全部数据 SQL INSERT", + exportAllResultsXlsx: "导出本次执行全部结果集为 XLSX", exportJson: "导出 JSON", exportMarkdown: "导出 Markdown", exportSql: "导出 SQL INSERT", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index cfab2ef54b..5b08b61241 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -478,6 +478,17 @@ export default { export: "匯出", exportCsv: "匯出 CSV", exportXlsx: "匯出 XLSX", + exportCurrentPageCsv: "匯出目前頁 CSV", + exportCurrentPageXlsx: "匯出目前頁 XLSX", + exportCurrentPageJson: "匯出目前頁 JSON", + exportCurrentPageMarkdown: "匯出目前頁 Markdown", + exportCurrentPageSql: "匯出目前頁 SQL INSERT", + exportCurrentResultCsv: "匯出目前結果集全部資料 CSV", + exportCurrentResultXlsx: "匯出目前結果集全部資料 XLSX", + exportCurrentResultJson: "匯出目前結果集全部資料 JSON", + exportCurrentResultMarkdown: "匯出目前結果集全部資料 Markdown", + exportCurrentResultSql: "匯出目前結果集全部資料 SQL INSERT", + exportAllResultsXlsx: "匯出本次執行全部結果集為 XLSX", exportJson: "匯出 JSON", exportMarkdown: "匯出 Markdown", exportSql: "匯出 SQL INSERT", diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 66e5f8730b..3e187e8c31 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -238,6 +238,7 @@ export const cancelDatabaseExport = forward("cancelDatabaseExport"); export const exportQueryResultCsv = forward("exportQueryResultCsv"); export const exportTableDataCsv = forward("exportTableDataCsv"); export const exportQueryResultXlsx = forward("exportQueryResultXlsx"); +export const exportQueryResultsXlsx = forward("exportQueryResultsXlsx"); export const exportQueryResultJson = forward("exportQueryResultJson"); export const exportQueryResultMarkdown = forward("exportQueryResultMarkdown"); export const startTableExport = forward("startTableExport"); diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index 93c62ab68e..4f9cf7f294 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -1310,6 +1310,21 @@ export async function exportQueryResultXlsx(filePath: string, sheetName: string URL.revokeObjectURL(url); } +export async function exportQueryResultsXlsx(filePath: string, worksheets: readonly { sheetName?: string; columns: string[]; rows: readonly (readonly XlsxCellValue[])[] }[]): Promise { + const { buildXlsxWorkbookMulti } = await import("./xlsxExport"); + const workbook = buildXlsxWorkbookMulti(worksheets); + const fileName = filePath.split(/[\\/]/).pop() || "export.xlsx"; + const blob = new Blob([new Uint8Array(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); +} + 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"); diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index f8423628d5..283850536a 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -1748,6 +1748,15 @@ export async function exportQueryResultXlsx(filePath: string, sheetName: string }); } +export async function exportQueryResultsXlsx(filePath: string, worksheets: readonly { sheetName?: string; columns: string[]; rows: readonly (readonly XlsxCellValue[])[] }[]): Promise { + return invoke("export_query_results_xlsx", { + request: { + filePath, + worksheets, + }, + }); +} + export async function exportQueryResultJson(filePath: string, columns: string[], rows: readonly (readonly XlsxCellValue[])[]): Promise { return invoke("export_query_result_json", { request: { diff --git a/apps/desktop/src/lib/xlsxExport.ts b/apps/desktop/src/lib/xlsxExport.ts index 2e043bd067..d61a466abb 100644 --- a/apps/desktop/src/lib/xlsxExport.ts +++ b/apps/desktop/src/lib/xlsxExport.ts @@ -79,6 +79,22 @@ function normalizeSheetName(value?: string): string { return name.slice(0, 31); } +function normalizeUniqueSheetNames(sheets: readonly XlsxWorksheetData[]): string[] { + const names: string[] = []; + sheets.forEach((sheet, index) => { + const base = normalizeSheetName(sheet.sheetName || `Sheet${index + 1}`); + let candidate = base; + let suffix = 2; + while (names.includes(candidate)) { + const suffixText = ` (${suffix})`; + candidate = `${base.slice(0, 31 - suffixText.length)}${suffixText}`; + suffix += 1; + } + names.push(candidate); + }); + return names; +} + function estimateColumnWidths(columns: readonly string[], rows: readonly (readonly XlsxCellValue[])[]): number[] { return columns.map((column, colIndex) => { const values = rows.slice(0, 100).map((row) => row[colIndex]); @@ -127,13 +143,14 @@ function worksheetXml(data: XlsxWorksheetData): string { `; } -function contentTypesXml(): string { +function contentTypesXml(sheetCount = 1): string { + const worksheetOverrides = Array.from({ length: sheetCount }, (_, index) => ` `).join("\n"); return ` - +${worksheetOverrides} `; } @@ -145,18 +162,20 @@ function rootRelsXml(): string { `; } -function workbookXml(sheetName: string): string { +function workbookXml(sheetNames: readonly string[]): string { + const sheets = sheetNames.map((sheetName, index) => ``).join(""); return ` - + ${sheets} `; } -function workbookRelsXml(): string { +function workbookRelsXml(sheetCount = 1): string { + const worksheetRels = Array.from({ length: sheetCount }, (_, index) => ` `).join("\n"); return ` - - +${worksheetRels} + `; } @@ -227,13 +246,18 @@ function createZip(files: Array<{ path: string; content: string }>): Uint8Array } export function buildXlsxWorkbook(data: XlsxWorksheetData): Uint8Array { - const sheetName = normalizeSheetName(data.sheetName); + return buildXlsxWorkbookMulti([data]); +} + +export function buildXlsxWorkbookMulti(sheets: readonly XlsxWorksheetData[]): Uint8Array { + if (sheets.length === 0) throw new Error("At least one worksheet is required"); + const sheetNames = normalizeUniqueSheetNames(sheets); return createZip([ - { path: "[Content_Types].xml", content: contentTypesXml() }, + { path: "[Content_Types].xml", content: contentTypesXml(sheets.length) }, { path: "_rels/.rels", content: rootRelsXml() }, - { path: "xl/workbook.xml", content: workbookXml(sheetName) }, - { path: "xl/_rels/workbook.xml.rels", content: workbookRelsXml() }, + { path: "xl/workbook.xml", content: workbookXml(sheetNames) }, + { path: "xl/_rels/workbook.xml.rels", content: workbookRelsXml(sheets.length) }, { path: "xl/styles.xml", content: stylesXml() }, - { path: "xl/worksheets/sheet1.xml", content: worksheetXml(data) }, + ...sheets.map((sheet, index) => ({ path: `xl/worksheets/sheet${index + 1}.xml`, content: worksheetXml(sheet) })), ]); } diff --git a/crates/dbx-core/src/xlsx_export.rs b/crates/dbx-core/src/xlsx_export.rs index 7026129505..015c966395 100644 --- a/crates/dbx-core/src/xlsx_export.rs +++ b/crates/dbx-core/src/xlsx_export.rs @@ -78,10 +78,10 @@ pub(crate) fn start_streaming_xlsx_workbook( let widths = estimate_header_widths(columns); let mut zip = zip::ZipWriter::new(writer); - write_zip_entry(&mut zip, "[Content_Types].xml", content_types_xml())?; + write_zip_entry(&mut zip, "[Content_Types].xml", &content_types_xml())?; write_zip_entry(&mut zip, "_rels/.rels", root_rels_xml())?; write_zip_entry(&mut zip, "xl/workbook.xml", &workbook_xml(&sheet_name))?; - write_zip_entry(&mut zip, "xl/_rels/workbook.xml.rels", workbook_rels_xml())?; + write_zip_entry(&mut zip, "xl/_rels/workbook.xml.rels", &workbook_rels_xml())?; write_zip_entry(&mut zip, "xl/styles.xml", styles_xml())?; // Begin the sheet1.xml entry with header, frozen pane, column widths and @@ -299,16 +299,30 @@ fn worksheet_xml(data: &XlsxWorksheetData) -> String { ) } -fn content_types_xml() -> &'static str { - concat!( - "", - "", - "", - "", - "", - "", - "", - "" +fn content_types_xml() -> String { + content_types_xml_for_sheet_count(1) +} + +fn content_types_xml_for_sheet_count(sheet_count: usize) -> String { + let worksheet_overrides = (1..=sheet_count) + .map(|index| { + format!( + "" + ) + }) + .collect::(); + format!( + concat!( + "", + "", + "", + "", + "", + "{}", + "", + "" + ), + worksheet_overrides ) } @@ -322,24 +336,51 @@ fn root_rels_xml() -> &'static str { } fn workbook_xml(sheet_name: &str) -> String { + workbook_xml_for_sheets(&[sheet_name.to_string()]) +} + +fn workbook_xml_for_sheets(sheet_names: &[String]) -> String { + let sheets = sheet_names + .iter() + .enumerate() + .map(|(index, sheet_name)| { + let sheet_id = index + 1; + format!("", escape_xml(sheet_name)) + }) + .collect::(); format!( concat!( "", "", - "", + "{}", "" ), - escape_xml(sheet_name) + sheets ) } -fn workbook_rels_xml() -> &'static str { - concat!( - "", - "", - "", - "", - "" +fn workbook_rels_xml() -> String { + workbook_rels_xml_for_sheet_count(1) +} + +fn workbook_rels_xml_for_sheet_count(sheet_count: usize) -> String { + let worksheet_rels = (1..=sheet_count) + .map(|index| { + format!( + "" + ) + }) + .collect::(); + let styles_id = sheet_count + 1; + format!( + concat!( + "", + "", + "{}", + "", + "" + ), + worksheet_rels, styles_id ) } @@ -357,15 +398,38 @@ fn styles_xml() -> &'static str { ) } +fn normalize_unique_sheet_names(sheets: &[XlsxWorksheetData]) -> Vec { + let mut names = Vec::with_capacity(sheets.len()); + for (index, sheet) in sheets.iter().enumerate() { + let base = normalize_sheet_name(sheet.sheet_name.as_deref().or(Some(&format!("Sheet{}", index + 1)))); + let mut candidate = base.clone(); + let mut suffix = 2; + while names.iter().any(|name| name == &candidate) { + let suffix_text = format!(" ({suffix})"); + let max_base_len = 31usize.saturating_sub(suffix_text.chars().count()); + candidate = format!("{}{}", base.chars().take(max_base_len).collect::(), suffix_text); + suffix += 1; + } + names.push(candidate); + } + names +} + pub fn build_xlsx_workbook(data: &XlsxWorksheetData) -> Result, String> { - let sheet_name = normalize_sheet_name(data.sheet_name.as_deref()); + build_xlsx_workbook_multi(std::slice::from_ref(data)) +} + +pub fn build_xlsx_workbook_multi(sheets: &[XlsxWorksheetData]) -> Result, String> { + if sheets.is_empty() { + return Err("At least one worksheet is required".to_string()); + } + let sheet_names = normalize_unique_sheet_names(sheets); let files = vec![ - ("[Content_Types].xml", content_types_xml().to_string()), + ("[Content_Types].xml", content_types_xml_for_sheet_count(sheets.len())), ("_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/workbook.xml", workbook_xml_for_sheets(&sheet_names)), + ("xl/_rels/workbook.xml.rels", workbook_rels_xml_for_sheet_count(sheets.len())), ("xl/styles.xml", styles_xml().to_string()), - ("xl/worksheets/sheet1.xml", worksheet_xml(data)), ]; let cursor = Cursor::new(Vec::::new()); @@ -376,6 +440,10 @@ pub fn build_xlsx_workbook(data: &XlsxWorksheetData) -> Result, String> zip.start_file(path, options).map_err(|err| err.to_string())?; zip.write_all(content.as_bytes()).map_err(|err| err.to_string())?; } + for (index, sheet) in sheets.iter().enumerate() { + zip.start_file(format!("xl/worksheets/sheet{}.xml", index + 1), options).map_err(|err| err.to_string())?; + zip.write_all(worksheet_xml(sheet).as_bytes()).map_err(|err| err.to_string())?; + } let output = zip.finish().map_err(|err| err.to_string())?; Ok(output.into_inner()) @@ -383,7 +451,7 @@ pub fn build_xlsx_workbook(data: &XlsxWorksheetData) -> Result, String> #[cfg(test)] mod tests { - use super::{build_xlsx_workbook, start_streaming_xlsx_workbook, XlsxWorksheetData}; + use super::{build_xlsx_workbook, build_xlsx_workbook_multi, start_streaming_xlsx_workbook, XlsxWorksheetData}; use calamine::{open_workbook_auto, Reader}; use serde_json::json; use std::fs; @@ -420,6 +488,34 @@ mod tests { assert!(text.contains("name=\"bad name with chars and-a-very-\"")); } + #[test] + fn builds_multi_sheet_xlsx_workbook() { + let path = std::env::temp_dir().join(format!("dbx-multi-sheet-test-{}.xlsx", uuid::Uuid::new_v4())); + let workbook = build_xlsx_workbook_multi(&[ + XlsxWorksheetData { + sheet_name: Some("Result 1".to_string()), + columns: vec!["id".to_string()], + rows: vec![vec![json!(1)]], + }, + XlsxWorksheetData { + sheet_name: Some("Result 2".to_string()), + columns: vec!["name".to_string()], + rows: vec![vec![json!("Ada")]], + }, + ]) + .expect("build multi-sheet workbook"); + fs::write(&path, workbook).expect("write temp workbook"); + + let mut workbook = open_workbook_auto(&path).expect("open workbook"); + let names = workbook.sheet_names().to_vec(); + assert_eq!(names, vec!["Result 1".to_string(), "Result 2".to_string()]); + let first = workbook.worksheet_range("Result 1").expect("read first worksheet"); + let second = workbook.worksheet_range("Result 2").expect("read second worksheet"); + assert_eq!(first.get_value((1, 0)).expect("first row"), &calamine::Data::Float(1.0)); + assert_eq!(second.get_value((1, 0)).expect("second row"), &calamine::Data::String("Ada".to_string())); + let _ = fs::remove_file(&path); + } + #[test] fn streams_xlsx_rows_to_a_readable_workbook() { let path = std::env::temp_dir().join(format!("dbx-stream-test-{}.xlsx", uuid::Uuid::new_v4())); diff --git a/src-tauri/src/commands/xlsx_export.rs b/src-tauri/src/commands/xlsx_export.rs index 78684f1b2b..266405fa76 100644 --- a/src-tauri/src/commands/xlsx_export.rs +++ b/src-tauri/src/commands/xlsx_export.rs @@ -1,4 +1,4 @@ -use dbx_core::xlsx_export::{build_xlsx_workbook, XlsxWorksheetData}; +use dbx_core::xlsx_export::{build_xlsx_workbook, build_xlsx_workbook_multi, XlsxWorksheetData}; use serde::Deserialize; use serde_json::Value; @@ -11,6 +11,13 @@ pub struct QueryResultXlsxExportRequest { pub rows: Vec>, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QueryResultsXlsxExportRequest { + pub file_path: String, + pub worksheets: Vec, +} + #[tauri::command] pub async fn export_query_result_xlsx(request: QueryResultXlsxExportRequest) -> Result<(), String> { tauri::async_runtime::spawn_blocking(move || { @@ -24,3 +31,13 @@ pub async fn export_query_result_xlsx(request: QueryResultXlsxExportRequest) -> .await .map_err(|err| err.to_string())? } + +#[tauri::command] +pub async fn export_query_results_xlsx(request: QueryResultsXlsxExportRequest) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let workbook = build_xlsx_workbook_multi(&request.worksheets)?; + std::fs::write(&request.file_path, workbook).map_err(|err| err.to_string()) + }) + .await + .map_err(|err| err.to_string())? +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5574d39510..f36090c00e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -614,6 +614,7 @@ pub fn run() { commands::csv_export::export_query_result_csv, commands::csv_export::export_table_data_csv, commands::xlsx_export::export_query_result_xlsx, + commands::xlsx_export::export_query_results_xlsx, commands::text_export::export_query_result_json, commands::text_export::export_query_result_markdown, commands::agents::list_installed_agents,