diff --git a/apps/desktop/src/components/export/XlsxHeaderDialog.vue b/apps/desktop/src/components/export/XlsxHeaderDialog.vue index c1eaf00964..f2267e6cd3 100644 --- a/apps/desktop/src/components/export/XlsxHeaderDialog.vue +++ b/apps/desktop/src/components/export/XlsxHeaderDialog.vue @@ -3,20 +3,21 @@ import { ref } from "vue"; import { useI18n } from "vue-i18n"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; +import type { XlsxHeaderMode } from "@/lib/export/xlsxHeader"; const { t } = useI18n(); const open = defineModel("open", { default: false }); -const selected = ref<"original" | "comment">("original"); +const selected = ref("name"); const emit = defineEmits<{ - confirm: [useCommentHeader: boolean]; + confirm: [mode: XlsxHeaderMode]; cancel: []; }>(); function onConfirm() { open.value = false; - emit("confirm", selected.value === "comment"); + emit("confirm", selected.value); } function onCancel() { @@ -39,18 +40,22 @@ function onOpenChange(value: boolean) {

{{ t("grid.xlsxHeaderPrompt") }}

+
- + diff --git a/apps/desktop/src/components/export/__tests__/XlsxHeaderDialog.spec.ts b/apps/desktop/src/components/export/__tests__/XlsxHeaderDialog.spec.ts index e37c99fcb9..4236202773 100644 --- a/apps/desktop/src/components/export/__tests__/XlsxHeaderDialog.spec.ts +++ b/apps/desktop/src/components/export/__tests__/XlsxHeaderDialog.spec.ts @@ -30,4 +30,27 @@ describe("XlsxHeaderDialog", () => { expect(cancelCount).toBe(1); app.unmount(); }); + + it("emits the combined name and comment mode", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + let selectedMode = ""; + const app = createApp(XlsxHeaderDialog, { + open: true, + onConfirm: (mode: string) => { + selectedMode = mode; + }, + }); + app.use(i18n); + app.mount(container); + await nextTick(); + + document.querySelector('input[value="name-comment"]')!.click(); + await nextTick(); + document.querySelector("[data-xlsx-header-confirm]")!.click(); + await nextTick(); + + expect(selectedMode).toBe("name-comment"); + app.unmount(); + }); }); diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index b36d53a34b..2c34b05bf1 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -2576,13 +2576,8 @@ const allColumnTypes = computed(() => const visibleColumnTypes = computed(() => visibleColumnIndexes.value.map((index) => allColumnTypes.value[index])); const allColumnTypeVisualKinds = computed(() => allColumnTypes.value.map((type) => resolveDataGridTypeVisualKind(type, resolvedDatabaseType.value))); const visibleColumnTypeVisualKinds = computed(() => visibleColumnIndexes.value.map((index) => allColumnTypeVisualKinds.value[index] ?? "unknown")); -const visibleColumnComments = computed(() => - visibleColumnIndexes.value.map((index) => { - const ordinalComments = props.resultColumnComments; - if (ordinalComments) return ordinalComments[index]; - return dataGridColumnCommentFor(columnCommentMap.value, props.result.columns[index] ?? "", props.sourceColumns?.[index]); - }), -); +const allColumnComments = computed(() => props.result.columns.map((column, index) => resolvedColumnComment(column, index))); +const visibleColumnComments = computed(() => visibleColumnIndexes.value.map((index) => allColumnComments.value[index])); const visibleColumnCount = computed(() => visibleColumnIndexes.value.length); const numericColumnRightAlign = computed(() => (settingsStore.editorSettings.numericColumnRightAlign ?? true) && !showTranspose.value); @@ -7615,6 +7610,8 @@ const { database: computed(() => props.executionDatabase ?? props.database), context: computed(() => props.context), sourceColumns: visibleSourceColumns, + columnComments: visibleColumnComments, + allColumnComments, mongoDocuments: computed(() => props.result.mongo_copy_documents ?? props.result.mongo_documents), columnTypes: visibleColumnTypes, allColumnTypes, diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index af7435bbe6..32fbe4b795 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -84,6 +84,7 @@ import { buildExecutableObjectSourceStatements, buildRoutineRenameObjectSourceSt import { buildRenameObjectSql, supportsObjectRename } from "@/lib/table/objectRenameSql"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { generateDatabaseExportId } from "@/lib/export/databaseExport"; +import { buildXlsxHeaderOverrides, hasXlsxHeaderComments, type XlsxHeaderMode } from "@/lib/export/xlsxHeader"; import { copyToClipboard, eventTargetAllowsAppClipboardShortcut } from "@/lib/common/clipboard"; import { defaultPasteTableMode, pasteTableModeCopiesData, supportsWholeRowTableDataCopy, tableClipboardMatchesTarget, tableClipboardMenuState, tableClipboardSourceContext, tableDataCopyColumnOptions, type PasteTableMode, type TableClipboardContext } from "@/lib/table/tableClipboard"; import { buildSingleDdlExportFileContent } from "@/lib/export/ddlExport"; @@ -1923,16 +1924,16 @@ async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql") else await exportTableData(row, format); } -function showObjectBrowserXlsxHeaderDialog(hasComments: boolean): Promise { - if (!hasComments) return Promise.resolve(false); +function showObjectBrowserXlsxHeaderDialog(hasComments: boolean): Promise { + if (!hasComments) return Promise.resolve("name"); return new Promise((resolve) => { const container = document.createElement("div"); document.body.appendChild(container); const app = createApp(XlsxHeaderDialog, { open: true, - onConfirm: (useCommentHeader: boolean) => { - resolve(useCommentHeader); + onConfirm: (mode: XlsxHeaderMode) => { + resolve(mode); app.unmount(); document.body.removeChild(container); }, @@ -1949,24 +1950,24 @@ function showObjectBrowserXlsxHeaderDialog(hasComments: boolean): Promise col.comment && col.comment.trim().length > 0); + const hasComments = hasXlsxHeaderComments(columnInfos.map((column) => column.comment)); const result = await showObjectBrowserXlsxHeaderDialog(hasComments); if (result === null) return; - useCommentHeader = result; + headerMode = result; } catch { // Column fetch failed, fallback to export without comments columnInfos = undefined; } - await exportTableData(row, "xlsx", columnInfos, useCommentHeader); + await exportTableData(row, "xlsx", columnInfos, headerMode); } -async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | "sql", columnInfos?: ColumnInfo[], useCommentHeader = false) { +async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | "sql", columnInfos?: ColumnInfo[], headerMode: XlsxHeaderMode = "name") { const schema = row.schema || selectedSchema.value; // Save dialog first @@ -2004,8 +2005,9 @@ async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | " if (format === "csv") { await api.exportQueryResultCsv(filePath, result.columns, result.rows); } else { - const comments = useCommentHeader ? result.columns.map((name) => columnInfos?.find((column) => column.name.toLocaleLowerCase() === name.toLocaleLowerCase())?.comment ?? null) : undefined; - await api.exportQueryResultXlsx(filePath, row.name, result.columns, result.column_types ?? result.columns.map(() => ""), comments, result.rows); + const comments = result.columns.map((name) => columnInfos?.find((column) => column.name.toLocaleLowerCase() === name.toLocaleLowerCase())?.comment); + const headerOverrides = buildXlsxHeaderOverrides(result.columns, comments, headerMode); + await api.exportQueryResultXlsx(filePath, row.name, result.columns, result.column_types ?? result.columns.map(() => ""), headerOverrides, result.rows); } toast(t("grid.exported")); return; @@ -2015,8 +2017,12 @@ async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | " if (columnInfos) { columns = columnInfos.map((c) => c.name); - if (format === "xlsx" && useCommentHeader) { - columnComments = columnInfos.map((c) => c.comment ?? null); + if (format === "xlsx") { + columnComments = buildXlsxHeaderOverrides( + columns, + columnInfos.map((column) => column.comment), + headerMode, + ); } } else if (props.connection.db_type === "neo4j") { const infos = await api.getColumns(props.connection.id, props.database, schema || props.database, row.name, props.catalog); diff --git a/apps/desktop/src/composables/__tests__/useDataGridExport.xlsx.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridExport.xlsx.spec.ts index 4bc936ef4d..d8884d441c 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridExport.xlsx.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridExport.xlsx.spec.ts @@ -13,8 +13,8 @@ const mocks = vi.hoisted(() => ({ vi.mock("@/components/export/XlsxHeaderDialog.vue", () => ({ default: { emits: ["confirm"], - mounted(this: { $emit: (event: string, value: boolean) => void }) { - queueMicrotask(() => this.$emit("confirm", true)); + mounted(this: { $emit: (event: string, value: string) => void }) { + queueMicrotask(() => this.$emit("confirm", "name-comment")); }, render() { return null; @@ -120,11 +120,23 @@ describe("useDataGridExport XLSX headers", () => { vi.clearAllMocks(); }); - it("maps comments independently for every result sheet", async () => { + it("maps combined headers independently for every result sheet", async () => { const state = useDataGridExport(createOptions()); await state.exportAllResultsXlsx(); - expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledWith(expect.any(String), expect.arrayContaining([expect.objectContaining({ sheetName: "Ids", columnComments: ["Identifier"] }), expect.objectContaining({ sheetName: "Names", columnComments: ["Display name"] })])); + expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledWith(expect.any(String), expect.arrayContaining([expect.objectContaining({ sheetName: "Ids", columnComments: ["id (Identifier)"] }), expect.objectContaining({ sheetName: "Names", columnComments: ["name (Display name)"] })])); + }); + + it("uses ordinal result comments when single-table metadata is unavailable", async () => { + const options = createOptions(); + options.tableMeta = computed(() => undefined); + options.columnComments = computed(() => ["Joined identifier"]); + options.allColumnComments = computed(() => ["Joined identifier"]); + const state = useDataGridExport(options); + + await state.exportAllResultsXlsx(); + + expect(mocks.exportQueryResultsXlsx).toHaveBeenCalledWith(expect.any(String), expect.arrayContaining([expect.objectContaining({ sheetName: "Ids", columnComments: ["id (Joined identifier)"] })])); }); }); diff --git a/apps/desktop/src/composables/useDataGridExport.ts b/apps/desktop/src/composables/useDataGridExport.ts index 81291e05e2..9d04981f18 100644 --- a/apps/desktop/src/composables/useDataGridExport.ts +++ b/apps/desktop/src/composables/useDataGridExport.ts @@ -27,6 +27,7 @@ import { formatTemporalRowsForExport } from "@/lib/dataGrid/columnFormatter"; import { translateBackendError } from "@/i18n/backend-errors"; import XlsxHeaderDialog from "@/components/export/XlsxHeaderDialog.vue"; import i18n from "@/i18n"; +import { buildXlsxHeaderOverrides, hasXlsxHeaderComments, type XlsxHeaderMode } from "@/lib/export/xlsxHeader"; /** * Format metadata for backend table exports. Each entry maps a format key @@ -82,6 +83,8 @@ export interface UseDataGridExportOptions { database: ComputedRef; context: ComputedRef<"results" | "table-data" | undefined>; sourceColumns: ComputedRef | undefined>; + columnComments?: ComputedRef>; + allColumnComments?: ComputedRef>; mongoDocuments?: ComputedRef; columnTypes: ComputedRef | undefined>; allColumnTypes?: ComputedRef | undefined>; @@ -164,6 +167,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) { tableMeta, copyInsertTargetLabel, sourceColumns, + columnComments: columnCommentsOption, + allColumnComments: allColumnCommentsOption, databaseType, identifierQuote, connectionId, @@ -250,33 +255,49 @@ export function useDataGridExport(options: UseDataGridExportOptions) { return pattern ? { ...result, rows: formatTemporalRowsForExport(result.rows, result.columnTypes, pattern) } : result; } - function buildColumnComments(columns: string[]): (string | null)[] | undefined { + function tableCommentsForColumns(targetColumns: readonly string[]): Array { const meta = tableMeta.value; - if (!meta?.columns) return undefined; + if (!meta?.columns) return targetColumns.map(() => undefined); const commentMap = new Map(); for (const col of meta.columns) { - if (col.comment) commentMap.set(col.name, col.comment); + if (col.comment) commentMap.set(col.name.toLocaleLowerCase(), col.comment); } - const result = columns.map((col) => commentMap.get(col) ?? null); - return result.some((c) => c !== null) ? result : undefined; + return targetColumns.map((column) => commentMap.get(column.toLocaleLowerCase())); } - function hasTableComments(): boolean { - const meta = tableMeta.value; - if (!meta?.columns) return false; - return meta.columns.some((col) => col.comment && col.comment.trim().length > 0); + const visibleXlsxColumnComments = computed(() => columnCommentsOption?.value ?? tableCommentsForColumns(columns.value)); + const allXlsxColumnComments = computed(() => allColumnCommentsOption?.value ?? tableCommentsForColumns(allColumns.value)); + + function commentsForExportColumns(targetColumns: readonly string[], preferAllColumns = true): Array { + const sourceColumns = preferAllColumns ? allColumns.value : columns.value; + const sourceComments = preferAllColumns ? allXlsxColumnComments.value : visibleXlsxColumnComments.value; + if (targetColumns.length === sourceColumns.length && targetColumns.every((column, index) => column === sourceColumns[index])) { + return [...sourceComments]; + } + + const commentsByName = new Map(); + sourceColumns.forEach((column, index) => { + const comment = sourceComments[index]; + if (comment) commentsByName.set(column.toLocaleLowerCase(), comment); + }); + for (const column of tableMeta.value?.columns ?? []) { + if (column.comment && !commentsByName.has(column.name.toLocaleLowerCase())) { + commentsByName.set(column.name.toLocaleLowerCase(), column.comment); + } + } + return targetColumns.map((column) => commentsByName.get(column.toLocaleLowerCase())); } - function showXlsxHeaderDialog(): Promise { - if (!hasTableComments()) return Promise.resolve(false); + function showXlsxHeaderDialog(): Promise { + if (!hasXlsxHeaderComments(allXlsxColumnComments.value) && !hasXlsxHeaderComments(visibleXlsxColumnComments.value)) return Promise.resolve("name"); return new Promise((resolve) => { const container = document.createElement("div"); document.body.appendChild(container); const app = createApp(XlsxHeaderDialog, { open: true, - onConfirm: (useCommentHeader: boolean) => { - resolve(useCommentHeader); + onConfirm: (mode: XlsxHeaderMode) => { + resolve(mode); app.unmount(); document.body.removeChild(container); }, @@ -291,7 +312,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { }); } - function normalizeCompleteLocalResult(result: QueryResult): { columns: string[]; columnTypes: string[]; rows: CellValue[][] } { + function normalizeCompleteLocalResult(result: QueryResult): { columns: string[]; columnTypes: string[]; columnComments: Array; rows: CellValue[][] } { const hiddenColumnIndexes = new Set(result.hidden_column_indexes ?? []); const exportedColumnIndexes = result.columns.map((_, index) => index).filter((index) => !hiddenColumnIndexes.has(index)); const hasHiddenColumns = exportedColumnIndexes.length !== result.columns.length; @@ -303,6 +324,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { return { columns: hasHiddenColumns ? exportedColumnIndexes.map((index) => result.columns[index]!) : result.columns, columnTypes: hasHiddenColumns ? exportedColumnIndexes.map((index) => result.column_types?.[index] ?? "") : (result.column_types ?? []), + columnComments: hasHiddenColumns ? exportedColumnIndexes.map((index) => allXlsxColumnComments.value[index]) : [...allXlsxColumnComments.value], rows: hasHiddenColumns ? rows.map((row) => exportedColumnIndexes.map((index) => row[index])) : rows, }; } @@ -312,12 +334,12 @@ export function useDataGridExport(options: UseDataGridExportOptions) { onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void, useFullExport = true, formatDateTime = true, - useCommentHeader = false, + headerMode: XlsxHeaderMode = "name", ): Promise<{ columns: string[]; columnTypes: string[]; columnComments?: (string | null)[]; rows: CellValue[][] }> { if (useFullExport && rowIds === undefined && fullExportResult && !hasCompleteLocalResult?.value) { const result = await fullExportResult(onProgress); if (result) { - const columnComments = useCommentHeader ? buildColumnComments(result.columns) : undefined; + const columnComments = buildXlsxHeaderOverrides(result.columns, commentsForExportColumns(result.columns), headerMode); return { ...applyGlobalDateTimeExportFormat({ columns: result.columns, columnTypes: result.column_types ?? [], rows: result.rows }, formatDateTime), columnComments }; } } @@ -328,10 +350,10 @@ export function useDataGridExport(options: UseDataGridExportOptions) { // silently change what the export contains. if (useFullExport && rowIds === undefined && hasCompleteLocalResult?.value && completeLocalResult?.value) { const normalized = normalizeCompleteLocalResult(completeLocalResult.value); - const columnComments = useCommentHeader ? buildColumnComments(normalized.columns) : undefined; - return { ...applyGlobalDateTimeExportFormat(normalized, formatDateTime), columnComments }; + const columnComments = buildXlsxHeaderOverrides(normalized.columns, normalized.columnComments, headerMode); + return { ...applyGlobalDateTimeExportFormat({ columns: normalized.columns, columnTypes: normalized.columnTypes, rows: normalized.rows }, formatDateTime), columnComments }; } - const commentHeader = useCommentHeader ? buildColumnComments(columns.value) : undefined; + const commentHeader = buildXlsxHeaderOverrides(columns.value, visibleXlsxColumnComments.value, headerMode); return { ...applyGlobalDateTimeExportFormat( { @@ -861,13 +883,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) { } async function exportXlsxResult(rowIds: number[] | undefined, includeSqlSheet: boolean) { - const useCommentHeader = await showXlsxHeaderDialog(); - if (useCommentHeader === null) return; + const headerMode = await showXlsxHeaderDialog(); + if (headerMode === null) return; await runExclusiveExport(async () => { try { - if (await exportQueryResultViaBackend("xlsx", rowIds, includeSqlSheet, useCommentHeader)) return; - if (await exportFullTableDataViaBackend("xlsx", rowIds, useCommentHeader)) return; + if (await exportQueryResultViaBackend("xlsx", rowIds, includeSqlSheet, headerMode)) return; + if (await exportFullTableDataViaBackend("xlsx", rowIds, headerMode)) return; let outputPath = exportFileName("export", "xlsx"); if (isTauriRuntime()) { @@ -909,7 +931,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { }, true, true, - useCommentHeader, + headerMode, ); if (needsFullExport && exportProgressState) { exportProgressState.value = { @@ -952,8 +974,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) { } async function exportCurrentPageXlsxResult(includeSqlSheet: boolean) { - const useCommentHeader = await showXlsxHeaderDialog(); - if (useCommentHeader === null) return; + const headerMode = await showXlsxHeaderDialog(); + if (headerMode === null) return; await runExclusiveExport(async () => { try { @@ -967,7 +989,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { if (!path) return; outputPath = path as string; } - const result = await resultToExport(undefined, undefined, false, true, useCommentHeader); + const result = await resultToExport(undefined, undefined, false, true, headerMode); await writeXlsxResult(outputPath, result, includeSqlSheet); toast(t("grid.exported")); } catch (e: any) { @@ -985,8 +1007,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) { } async function exportAllResultsXlsxResult(includeSqlSheet: boolean) { - const useCommentHeader = await showXlsxHeaderDialog(); - if (useCommentHeader === null) return; + const headerMode = await showXlsxHeaderDialog(); + if (headerMode === null) return; await runExclusiveExport(async () => { try { @@ -1010,7 +1032,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { sheetName: sheet.sheetName, columns: sheet.result.columns, columnTypes: sheet.result.column_types ?? [], - columnComments: useCommentHeader ? buildColumnComments(sheet.result.columns) : undefined, + columnComments: buildXlsxHeaderOverrides(sheet.result.columns, commentsForExportColumns(sheet.result.columns), headerMode), rows: formatTemporalRowsForExport(sheet.result.rows, sheet.result.column_types ?? [], exportPattern), numericColumnRightAlign: rightAlign, })); @@ -1031,7 +1053,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { await exportAllResultsXlsxResult(true); } - async function exportFullTableDataViaBackend(format: "csv" | "xlsx" | "json" | "markdown" | "sql" | "txt", rowIds?: number[], useCommentHeader = false): Promise { + async function exportFullTableDataViaBackend(format: "csv" | "xlsx" | "json" | "markdown" | "sql" | "txt", rowIds?: number[], headerMode: XlsxHeaderMode = "name"): Promise { const meta = tableMeta.value; // The backend table exporter currently builds two-part table names. External // Doris/StarRocks catalogs need the data-tab paginator's three-part SQL. @@ -1092,7 +1114,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { format, columns: columns.value, columnTypes: columnTypes.value, - columnComments: useCommentHeader ? buildColumnComments(columns.value) : undefined, + columnComments: format === "xlsx" ? buildXlsxHeaderOverrides(columns.value, visibleXlsxColumnComments.value, headerMode) : undefined, primaryKeys: meta.primaryKeys, whereInput: whereInput.value, orderBy: orderBy.value, @@ -1128,7 +1150,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { return true; } - async function exportQueryResultViaBackend(format: "csv" | "xlsx" | "txt" | "sql", rowIds?: number[], includeSqlSheet = false, useCommentHeader = false): Promise { + async function exportQueryResultViaBackend(format: "csv" | "xlsx" | "txt" | "sql", rowIds?: number[], includeSqlSheet = false, headerMode: XlsxHeaderMode = "name"): Promise { if (rowIds !== undefined || context.value !== "results" || !queryResultExportRequest) { return false; } @@ -1160,7 +1182,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) { exportTableName: format === "sql" ? tableMeta.value?.tableName : undefined, exportColumnTypes: format === "sql" ? allColumnTypes.value?.map((type) => type ?? null) : undefined, }); - const columnComments = useCommentHeader ? buildColumnComments(columns.value) : undefined; + const columnComments = format === "xlsx" ? buildXlsxHeaderOverrides(allColumns.value, allXlsxColumnComments.value, headerMode) : undefined; const request = baseRequest ? { ...baseRequest, dateTimeFormat: useSettingsStore().editorSettings.globalDateTimeExportFormat || undefined, numericColumnRightAlign: useSettingsStore().editorSettings.numericColumnRightAlign ?? true, columnComments } : undefined; if (!request) throw new Error("Unable to build query result export request"); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 4d3c8ed12b..53675ad4cc 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1635,6 +1635,7 @@ export default { xlsxHeaderPrompt: "Choose the header format for the exported Excel file:", xlsxHeaderOriginal: "Use column names as headers", xlsxHeaderComment: "Use column comments as headers", + xlsxHeaderNameAndComment: "Use column names and comments as headers", copied: "Copied", copyFailed: "Copy failed: {message}", previewSqlEmpty: "No pending SQL changes to preview", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 5e293f04ab..5584f8d7d3 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2014,6 +2014,7 @@ export default withEnglishFallback({ xlsxHeaderPrompt: "Seleccione el formato de encabezado al exportar a Excel:", xlsxHeaderOriginal: "Encabezado usando nombres de campos", xlsxHeaderComment: "Encabezado usando comentarios", + xlsxHeaderNameAndComment: "Encabezado usando nombres de campos y comentarios", jumpToPage: "Ir a la página", importBinaryValue: "Importar desde archivo", binaryImportApplied: "Se importaron {count} bytes, que se escribirán en la base de datos al guardar los cambios.", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 7ad944d185..a98f73668b 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2012,6 +2012,7 @@ export default withEnglishFallback({ xlsxHeaderPrompt: "Seleziona il formato dell'intestazione da utilizzare per l'esportazione in Excel:", xlsxHeaderOriginal: "Intestazione con nome campo", xlsxHeaderComment: "Intestazione con commento", + xlsxHeaderNameAndComment: "Intestazione con nome campo e commento", jumpToPage: "Vai alla pagina", importBinaryValue: "Importa da file", binaryImportApplied: "Importati {count} byte, scritti nel database dopo il salvataggio delle modifiche.", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 8d2c27b64d..2dbda5f705 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2034,6 +2034,7 @@ export default withEnglishFallback({ xlsxHeaderPrompt: "Excel エクスポート時に使用するヘッダー形式を選択してください:", xlsxHeaderOriginal: "ヘッダーにフィールド名を使用", xlsxHeaderComment: "ヘッダーにコメントを使用", + xlsxHeaderNameAndComment: "ヘッダーにフィールド名とコメントを使用", searchMode: "検索モード", searchModeHint: "Ctrl+F 検索時に一致する行をフィルタリングするか、全行を表示したまま一致箇所をハイライトするかを選択します。", searchModeFilter: "フィルター", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index a33ef74bc2..7c95f8735a 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2014,6 +2014,7 @@ export default withEnglishFallback({ xlsxHeaderPrompt: "Selecione o formato do cabeçalho a ser usado ao exportar Excel:", xlsxHeaderOriginal: "Cabeçalho usa nome do campo", xlsxHeaderComment: "Cabeçalho usa comentário", + xlsxHeaderNameAndComment: "Cabeçalho usa nome do campo e comentário", jumpToPage: "Ir para a página", importBinaryValue: "Importar do arquivo", binaryImportApplied: "{count} bytes importados, gravados no banco de dados após salvar as alterações.", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index a5da4a627e..c073f15094 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1559,6 +1559,7 @@ export default withEnglishFallback({ xlsxHeaderPrompt: "请选择导出 Excel 时使用的表头格式:", xlsxHeaderOriginal: "表头使用字段名称", xlsxHeaderComment: "表头使用注释", + xlsxHeaderNameAndComment: "表头使用字段名称 + 注释", copied: "已复制", cut: "已剪切", copyFailed: "复制失败:{message}", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 1c33015b6c..bf6c922a0c 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2018,6 +2018,7 @@ export default withEnglishFallback({ xlsxHeaderPrompt: "請選擇匯出 Excel 時使用的表頭格式:", xlsxHeaderOriginal: "表頭使用欄位名稱", xlsxHeaderComment: "表頭使用註解", + xlsxHeaderNameAndComment: "表頭使用欄位名稱 + 註解", cut: "已剪下", }, exportProgress: { diff --git a/apps/desktop/src/lib/export/__tests__/xlsxHeader.spec.ts b/apps/desktop/src/lib/export/__tests__/xlsxHeader.spec.ts new file mode 100644 index 0000000000..9a7dd30c8a --- /dev/null +++ b/apps/desktop/src/lib/export/__tests__/xlsxHeader.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { buildXlsxHeaderOverrides, hasXlsxHeaderComments } from "../xlsxHeader"; + +describe("xlsxHeader", () => { + it("builds comment-only and combined header overrides", () => { + const columns = ["id", "name", "created_at"]; + const comments = [" Identifier ", "", undefined]; + + expect(buildXlsxHeaderOverrides(columns, comments, "comment")).toEqual(["Identifier", null, null]); + expect(buildXlsxHeaderOverrides(columns, comments, "name-comment")).toEqual(["id (Identifier)", null, null]); + }); + + it("keeps name mode and comment-less exports on the original headers", () => { + expect(buildXlsxHeaderOverrides(["id"], ["Identifier"], "name")).toBeUndefined(); + expect(buildXlsxHeaderOverrides(["id"], [" "], "name-comment")).toBeUndefined(); + expect(hasXlsxHeaderComments([undefined, " ", "Name"])).toBe(true); + expect(hasXlsxHeaderComments([undefined, " "])).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/export/xlsxHeader.ts b/apps/desktop/src/lib/export/xlsxHeader.ts new file mode 100644 index 0000000000..52d034cf1a --- /dev/null +++ b/apps/desktop/src/lib/export/xlsxHeader.ts @@ -0,0 +1,17 @@ +export type XlsxHeaderMode = "name" | "comment" | "name-comment"; + +export function hasXlsxHeaderComments(comments: readonly (string | null | undefined)[] | undefined): boolean { + return comments?.some((comment) => !!comment?.trim()) ?? false; +} + +export function buildXlsxHeaderOverrides(columns: readonly string[], comments: readonly (string | null | undefined)[] | undefined, mode: XlsxHeaderMode): (string | null)[] | undefined { + if (mode === "name") return undefined; + + const overrides = columns.map((column, index) => { + const comment = comments?.[index]?.trim(); + if (!comment) return null; + return mode === "comment" ? comment : `${column} (${comment})`; + }); + + return overrides.some((header) => header !== null) ? overrides : undefined; +}