feat(grid): 新增原始数据复制模式并设为默认
* ✨ feat(grid): 新增原始数据复制模式并设为默认 - 原样输出单元格内容,不对引号、制表符和换行进行 DSV 转义 - 支持原始数据复制后的网格粘贴还原 - 补充多语言复制选项文案及前后端提取器实现与测试 * ✨ feat(grid): 优化新增智能复制选项 - 单个单元格复制保留原始单元格文本,多单元格自动使用转义 TSV - 默认复制设置由格式选择升级为复制行为选择,并支持预览 - 限制原始文本格式仅用于单单元格复制 - 更新多语言文案及相关测试
This commit is contained in:
parent
e749bdd42c
commit
930aa48c20
|
|
@ -1640,10 +1640,10 @@ defineExpose({ focusSearch });
|
|||
</span>
|
||||
</label>
|
||||
<DataGridCopyFormatControl
|
||||
:current-label="dataGridRef?.defaultCopyExtractorLabel ?? '-'"
|
||||
:current-value="dataGridRef?.defaultCopyExtractor ?? ''"
|
||||
:items="dataGridRef?.copyExtractorMenuItems ?? []"
|
||||
@select="dataGridRef?.setDefaultCopyExtractor($event)"
|
||||
:current-label="dataGridRef?.defaultCopyPreferenceLabel ?? '-'"
|
||||
:current-value="dataGridRef?.defaultCopyPreference ?? ''"
|
||||
:items="dataGridRef?.copyPreferenceMenuItems ?? []"
|
||||
@select="dataGridRef?.setDefaultCopyPreference($event)"
|
||||
@configure="openDataGridExtractorConfiguration"
|
||||
/>
|
||||
</PopoverContent>
|
||||
|
|
|
|||
|
|
@ -189,7 +189,7 @@ import { useDataGridExport, type MongoCopyUpdateTarget } from "@/composables/use
|
|||
import { eventTargetAllowsNativeClipboard, isPlainClipboardShortcut, readTextFromClipboard } from "@/lib/common/clipboard";
|
||||
import { claimDataGridPaste, claimDataGridSelectAll, clearDataGridClipboardCopy, parseDataGridClipboard, planDataGridPaste } from "@/lib/dataGrid/dataGridClipboard";
|
||||
import { beginDataGridNativeSelectionBlock, finishDataGridNativeSelectionBlock } from "@/lib/dataGrid/dataGridNativeSelection";
|
||||
import { DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS, DATA_GRID_COPY_EXTRACTOR_IDS, extractorUnavailableForDatabase, type DataGridCopyExtractorId } from "@/lib/dataGrid/dataGridCopyExtractor";
|
||||
import { DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS, DATA_GRID_COPY_EXTRACTOR_IDS, DATA_GRID_DEFAULT_COPY_PREFERENCES, extractorUnavailableForDatabase, type DataGridCopyExtractorId, type DataGridCopyPreference } from "@/lib/dataGrid/dataGridCopyExtractor";
|
||||
import { columnNamesForCopy } from "@/lib/dataGrid/dataGridColumnNameCopy";
|
||||
import { DATA_GRID_ROW_NUM_WIDTH, dataGridRowNumberColumnWidth, resolveDataGridMaxRowNumber, useDataGridColumnResize } from "@/composables/useDataGridColumnResize";
|
||||
import { createDataGridColumnStructureSignature } from "@/lib/dataGrid/dataGridColumnWidthState";
|
||||
|
|
@ -5497,7 +5497,8 @@ const {
|
|||
canCopyRow,
|
||||
copyAll,
|
||||
copyWithExtractor,
|
||||
previewWithExtractor,
|
||||
copyWithPreference,
|
||||
previewWithPreference,
|
||||
canCopyWithExtractor,
|
||||
exportCsv,
|
||||
exportCurrentPageCsv,
|
||||
|
|
@ -5566,6 +5567,7 @@ const {
|
|||
|
||||
function copyExtractorLabel(extractor: DataGridCopyExtractorId): string {
|
||||
const labels: Record<DataGridCopyExtractorId, string> = {
|
||||
raw: t("grid.copyExtractorRaw"),
|
||||
tsv: "TSV",
|
||||
"tsv-with-headers": t("grid.copyExtractorWithHeaders", { format: "TSV" }),
|
||||
csv: "CSV",
|
||||
|
|
@ -5587,28 +5589,34 @@ function copyExtractorLabel(extractor: DataGridCopyExtractorId): string {
|
|||
return labels[extractor];
|
||||
}
|
||||
|
||||
const selectedCopyExtractor = computed(() => settingsStore.editorSettings.dataGridCopyExtractor);
|
||||
const defaultCopyExtractorLabel = computed(() => {
|
||||
if (selectedCopyExtractor.value !== "sql-inserts") return copyExtractorLabel(selectedCopyExtractor.value);
|
||||
function copyPreferenceLabel(preference: DataGridCopyPreference): string {
|
||||
if (preference === "smart") return t("grid.copyExtractorSmart");
|
||||
if (preference !== "sql-inserts") return copyExtractorLabel(preference);
|
||||
return t(settingsStore.editorSettings.dataGridExtractorOptions.sql.excludePrimaryKeysFromInsert ? "grid.copyExtractorSqlInsertsWithoutPrimaryKeys" : "grid.copyExtractorSqlInsertsWithPrimaryKeys");
|
||||
});
|
||||
}
|
||||
|
||||
const selectedCopyPreference = computed(() => settingsStore.editorSettings.dataGridCopyExtractor);
|
||||
const defaultCopyPreferenceLabel = computed(() => copyPreferenceLabel(selectedCopyPreference.value));
|
||||
const extractorConfigOpen = ref(false);
|
||||
const extractorMenuItems = computed(() =>
|
||||
DATA_GRID_COPY_EXTRACTOR_IDS.map((extractor) => ({
|
||||
value: extractor,
|
||||
label: copyExtractorLabel(extractor),
|
||||
disabled: extractorUnavailableForDatabase(extractor, props.databaseType) || (extractor === "sql-updates" && !props.tableMeta?.primaryKeys.length),
|
||||
separatorBefore: DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor].separatorBefore,
|
||||
})),
|
||||
const copyPreferenceMenuItems = computed(() =>
|
||||
DATA_GRID_DEFAULT_COPY_PREFERENCES.map((preference) => {
|
||||
const extractor = preference === "smart" ? undefined : preference;
|
||||
return {
|
||||
value: preference,
|
||||
label: copyPreferenceLabel(preference),
|
||||
disabled: extractor ? extractorUnavailableForDatabase(extractor, props.databaseType) || (extractor === "sql-updates" && !props.tableMeta?.primaryKeys.length) : false,
|
||||
separatorBefore: extractor ? DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor].separatorBefore : false,
|
||||
};
|
||||
}),
|
||||
);
|
||||
function openExtractorConfiguration() {
|
||||
extractorConfigOpen.value = true;
|
||||
}
|
||||
|
||||
function setDefaultCopyExtractor(value: string) {
|
||||
const item = extractorMenuItems.value.find((candidate) => candidate.value === value);
|
||||
function setDefaultCopyPreference(value: string) {
|
||||
const item = copyPreferenceMenuItems.value.find((candidate) => candidate.value === value);
|
||||
if (!item || item.disabled) return;
|
||||
settingsStore.updateEditorSettings({ dataGridCopyExtractor: item.value });
|
||||
settingsStore.updateEditorSettings({ dataGridCopyExtractor: item.value as DataGridCopyPreference });
|
||||
}
|
||||
|
||||
function sqlInsertExtractorOptions(excludePrimaryKeysFromInsert: boolean) {
|
||||
|
|
@ -5619,8 +5627,8 @@ function sqlInsertExtractorOptions(excludePrimaryKeysFromInsert: boolean) {
|
|||
};
|
||||
}
|
||||
|
||||
function saveExtractorConfiguration(value: { extractor: DataGridCopyExtractorId; options: typeof settingsStore.editorSettings.dataGridExtractorOptions }) {
|
||||
settingsStore.updateEditorSettings({ dataGridCopyExtractor: value.extractor, dataGridExtractorOptions: value.options });
|
||||
function saveExtractorConfiguration(value: { preference: DataGridCopyPreference; options: typeof settingsStore.editorSettings.dataGridExtractorOptions }) {
|
||||
settingsStore.updateEditorSettings({ dataGridCopyExtractor: value.preference, dataGridExtractorOptions: value.options });
|
||||
}
|
||||
|
||||
const pageSizeMenuItems = computed(() =>
|
||||
|
|
@ -6575,7 +6583,7 @@ async function onGridKeydown(event: KeyboardEvent) {
|
|||
if (clipboardShortcut(event, "c")) {
|
||||
if (!hasCellSelection.value && !hasRowSelection.value) return;
|
||||
event.preventDefault();
|
||||
await copyWithExtractor(selectedCopyExtractor.value);
|
||||
await copyWithPreference(selectedCopyPreference.value);
|
||||
return;
|
||||
}
|
||||
if (clipboardShortcut(event, "a")) {
|
||||
|
|
@ -8187,10 +8195,10 @@ defineExpose({
|
|||
exportSql,
|
||||
exportXlsx,
|
||||
exportTxt,
|
||||
defaultCopyExtractor: selectedCopyExtractor,
|
||||
defaultCopyExtractorLabel,
|
||||
copyExtractorMenuItems: extractorMenuItems,
|
||||
setDefaultCopyExtractor,
|
||||
defaultCopyPreference: selectedCopyPreference,
|
||||
defaultCopyPreferenceLabel,
|
||||
copyPreferenceMenuItems,
|
||||
setDefaultCopyPreference,
|
||||
openExtractorConfiguration,
|
||||
});
|
||||
|
||||
|
|
@ -8228,11 +8236,12 @@ function buildExtractorContextItems(): ContextMenuItem[] {
|
|||
const items: ContextMenuItem[] = [];
|
||||
let separatorPending = false;
|
||||
for (const extractor of DATA_GRID_COPY_EXTRACTOR_IDS) {
|
||||
if (extractor === "raw" && !canCopyWithExtractor(extractor)) continue;
|
||||
const descriptor = DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor];
|
||||
if (descriptor.separatorBefore) {
|
||||
separatorPending = items.length > 0;
|
||||
}
|
||||
const selected = extractor === selectedCopyExtractor.value;
|
||||
const selected = extractor === selectedCopyPreference.value;
|
||||
const extractorItems: ContextMenuItem[] = [];
|
||||
if (extractor === "sql-inserts") {
|
||||
for (const excludePrimaryKeysFromInsert of [false, true]) {
|
||||
|
|
@ -8265,13 +8274,14 @@ function buildExtractorContextItems(): ContextMenuItem[] {
|
|||
return items;
|
||||
}
|
||||
|
||||
function defaultExtractorContextItem(): ContextMenuItem {
|
||||
const extractor = selectedCopyExtractor.value;
|
||||
const extractorOptions = extractor === "sql-inserts" ? sqlInsertExtractorOptions(settingsStore.editorSettings.dataGridExtractorOptions.sql.excludePrimaryKeysFromInsert) : undefined;
|
||||
function defaultCopyPreferenceContextItem(): ContextMenuItem {
|
||||
const preference = selectedCopyPreference.value;
|
||||
const extractorOptions = preference === "sql-inserts" ? sqlInsertExtractorOptions(settingsStore.editorSettings.dataGridExtractorOptions.sql.excludePrimaryKeysFromInsert) : undefined;
|
||||
const disabled = preference === "smart" ? !canCopyWithExtractor("raw", extractorOptions) && !canCopyWithExtractor("tsv", extractorOptions) : !canCopyWithExtractor(preference, extractorOptions);
|
||||
return {
|
||||
label: defaultCopyExtractorLabel.value,
|
||||
action: () => void copyWithExtractor(extractor, extractorOptions),
|
||||
disabled: !canCopyWithExtractor(extractor, extractorOptions),
|
||||
label: defaultCopyPreferenceLabel.value,
|
||||
action: () => void copyWithPreference(preference, extractorOptions),
|
||||
disabled,
|
||||
checked: true,
|
||||
};
|
||||
}
|
||||
|
|
@ -8283,7 +8293,7 @@ function copySubmenu(): ContextMenuItem {
|
|||
icon: Settings2,
|
||||
action: openExtractorConfiguration,
|
||||
},
|
||||
defaultExtractorContextItem(),
|
||||
defaultCopyPreferenceContextItem(),
|
||||
{ label: "", separator: true },
|
||||
];
|
||||
if (contextColumn.value) {
|
||||
|
|
@ -10047,7 +10057,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
|
|||
<DataGridBulkEditDialog v-if="bulkEditDialogMounted" v-model:open="bulkEditDialogOpen" v-model:value="bulkEditValue" :selected-cell-count="selectedCellCount" @apply="applyBulkEditValue" />
|
||||
<DataGridInsertRowsDialog v-if="insertRowsDialogMounted" v-model:open="insertRowsDialogOpen" :can-place-at-selection="canPlaceInsertAtSelection" :initial-position="insertPosition" @insert="insertRows" />
|
||||
|
||||
<DataGridExtractorDialog v-model:open="extractorConfigOpen" :extractor="selectedCopyExtractor" :options="settingsStore.editorSettings.dataGridExtractorOptions" :items="extractorMenuItems" :preview="previewWithExtractor" @save="saveExtractorConfiguration" />
|
||||
<DataGridExtractorDialog v-model:open="extractorConfigOpen" :preference="selectedCopyPreference" :options="settingsStore.editorSettings.dataGridExtractorOptions" :items="copyPreferenceMenuItems" :preview="previewWithPreference" @save="saveExtractorConfiguration" />
|
||||
<DataGridCopyColumnNamesDialog v-if="copyColumnNamesDialogMounted" v-model:open="copyColumnNamesDialogOpen" :column-names="copyColumnNamesDialogColumns" :database-type="resolvedDatabaseType" :column-comments="columnCommentMap" @copy="copyText" />
|
||||
|
||||
<Dialog v-model:open="generateIncrementDialogOpen">
|
||||
|
|
|
|||
|
|
@ -6,23 +6,32 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS, normalizeDataGridExtractorOptions, validateDataGridExtractorOptions, type DataGridCopyExtractorId, type DataGridExtractorOptions, type DataGridExtractPreview, type DataGridExtractWarningCode } from "@/lib/dataGrid/dataGridCopyExtractor";
|
||||
import {
|
||||
DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS,
|
||||
normalizeDataGridExtractorOptions,
|
||||
validateDataGridExtractorOptions,
|
||||
type DataGridCopyExtractorId,
|
||||
type DataGridCopyPreference,
|
||||
type DataGridExtractorOptions,
|
||||
type DataGridExtractPreview,
|
||||
type DataGridExtractWarningCode,
|
||||
} from "@/lib/dataGrid/dataGridCopyExtractor";
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
extractor: DataGridCopyExtractorId;
|
||||
preference: DataGridCopyPreference;
|
||||
options: DataGridExtractorOptions;
|
||||
items: Array<{ value: DataGridCopyExtractorId; label: string; disabled?: boolean }>;
|
||||
preview: (extractor: DataGridCopyExtractorId, options: DataGridExtractorOptions) => Promise<DataGridExtractPreview>;
|
||||
items: Array<{ value: DataGridCopyPreference; label: string; disabled?: boolean }>;
|
||||
preview: (preference: DataGridCopyPreference, options: DataGridExtractorOptions) => Promise<DataGridExtractPreview>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:open": [value: boolean];
|
||||
save: [value: { extractor: DataGridCopyExtractorId; options: DataGridExtractorOptions }];
|
||||
save: [value: { preference: DataGridCopyPreference; options: DataGridExtractorOptions }];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const draftExtractor = ref<DataGridCopyExtractorId>(props.extractor);
|
||||
const draftPreference = ref<DataGridCopyPreference>(props.preference);
|
||||
const draftOptions = ref<DataGridExtractorOptions>(normalizeDataGridExtractorOptions(props.options));
|
||||
const previewText = ref("");
|
||||
const previewError = ref("");
|
||||
|
|
@ -32,11 +41,13 @@ const previewTruncatedRows = ref(0);
|
|||
let previewSequence = 0;
|
||||
let previewTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const extractorCategory = computed(() => DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[draftExtractor.value].category);
|
||||
const draftExtractor = computed<DataGridCopyExtractorId | null>(() => (draftPreference.value === "smart" ? null : draftPreference.value));
|
||||
const extractorCategory = computed(() => (draftExtractor.value ? DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[draftExtractor.value].category : null));
|
||||
const isDsv = computed(() => extractorCategory.value === "delimited");
|
||||
const isSql = computed(() => draftExtractor.value === "sql-inserts" || draftExtractor.value === "sql-updates");
|
||||
const isJson = computed(() => extractorCategory.value === "json");
|
||||
const optionsError = computed(() => {
|
||||
if (!draftExtractor.value) return "";
|
||||
const code = validateDataGridExtractorOptions(draftExtractor.value, draftOptions.value);
|
||||
if (!code) return "";
|
||||
return t(`grid.copyExtractorValidation.${code}`);
|
||||
|
|
@ -70,7 +81,7 @@ async function refreshPreview() {
|
|||
return;
|
||||
}
|
||||
try {
|
||||
const result = await props.preview(draftExtractor.value, normalizeDataGridExtractorOptions(draftOptions.value));
|
||||
const result = await props.preview(draftPreference.value, normalizeDataGridExtractorOptions(draftOptions.value));
|
||||
if (sequence === previewSequence) {
|
||||
previewText.value = result.text;
|
||||
previewWarnings.value = (result.warnings ?? []).map((warning) => warningText(warning.code, result.omittedColumns));
|
||||
|
|
@ -105,14 +116,14 @@ watch(
|
|||
previewLoading.value = false;
|
||||
return;
|
||||
}
|
||||
draftExtractor.value = props.extractor;
|
||||
draftPreference.value = props.preference;
|
||||
draftOptions.value = normalizeDataGridExtractorOptions(props.options);
|
||||
schedulePreview();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
[draftExtractor, draftOptions],
|
||||
[draftPreference, draftOptions],
|
||||
() => {
|
||||
if (props.open) schedulePreview();
|
||||
},
|
||||
|
|
@ -126,7 +137,7 @@ onBeforeUnmount(() => {
|
|||
|
||||
function save() {
|
||||
if (optionsError.value) return;
|
||||
emit("save", { extractor: draftExtractor.value, options: normalizeDataGridExtractorOptions(draftOptions.value) });
|
||||
emit("save", { preference: draftPreference.value, options: normalizeDataGridExtractorOptions(draftOptions.value) });
|
||||
emit("update:open", false);
|
||||
}
|
||||
</script>
|
||||
|
|
@ -142,7 +153,7 @@ function save() {
|
|||
<div class="space-y-3">
|
||||
<div class="space-y-1.5">
|
||||
<Label>{{ t("grid.copyExtractorDefaultFormat") }}</Label>
|
||||
<Select v-model="draftExtractor">
|
||||
<Select v-model="draftPreference">
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="item in items" :key="item.value" :value="item.value" :disabled="item.disabled">{{ item.label }}</SelectItem>
|
||||
|
|
|
|||
|
|
@ -108,10 +108,10 @@ type DataGridHandle = DataGridColumnLayoutHandle & {
|
|||
allNullColumnCount: number;
|
||||
canToggleAllNullColumns: boolean;
|
||||
toggleAllNullColumns: () => void;
|
||||
defaultCopyExtractor: string;
|
||||
defaultCopyExtractorLabel: string;
|
||||
copyExtractorMenuItems: Array<{ value: string; label: string; disabled?: boolean; separatorBefore?: boolean }>;
|
||||
setDefaultCopyExtractor: (value: string) => void;
|
||||
defaultCopyPreference: string;
|
||||
defaultCopyPreferenceLabel: string;
|
||||
copyPreferenceMenuItems: Array<{ value: string; label: string; disabled?: boolean; separatorBefore?: boolean }>;
|
||||
setDefaultCopyPreference: (value: string) => void;
|
||||
openExtractorConfiguration: () => void;
|
||||
showDdl: boolean;
|
||||
toggleDdl: (tab?: TableInfoTab) => void;
|
||||
|
|
@ -1286,10 +1286,10 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
|
|||
<Switch size="sm" :model-value="!!dataGridRef?.nullColumnsHidden" :disabled="!dataGridRef?.canToggleAllNullColumns" :aria-label="t('grid.hideNullColumns')" @update:model-value="dataGridRef?.toggleAllNullColumns()" />
|
||||
</div>
|
||||
<DataGridCopyFormatControl
|
||||
:current-label="dataGridRef?.defaultCopyExtractorLabel ?? '-'"
|
||||
:current-value="dataGridRef?.defaultCopyExtractor ?? ''"
|
||||
:items="dataGridRef?.copyExtractorMenuItems ?? []"
|
||||
@select="dataGridRef?.setDefaultCopyExtractor($event)"
|
||||
:current-label="dataGridRef?.defaultCopyPreferenceLabel ?? '-'"
|
||||
:current-value="dataGridRef?.defaultCopyPreference ?? ''"
|
||||
:items="dataGridRef?.copyPreferenceMenuItems ?? []"
|
||||
@select="dataGridRef?.setDefaultCopyPreference($event)"
|
||||
@configure="openDataGridExtractorConfiguration"
|
||||
/>
|
||||
</PopoverContent>
|
||||
|
|
@ -1732,10 +1732,10 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
|
|||
<Switch size="sm" :model-value="!!dataGridRef?.nullColumnsHidden" :disabled="!dataGridRef?.canToggleAllNullColumns" :aria-label="t('grid.hideNullColumns')" @update:model-value="dataGridRef?.toggleAllNullColumns()" />
|
||||
</div>
|
||||
<DataGridCopyFormatControl
|
||||
:current-label="dataGridRef?.defaultCopyExtractorLabel ?? '-'"
|
||||
:current-value="dataGridRef?.defaultCopyExtractor ?? ''"
|
||||
:items="dataGridRef?.copyExtractorMenuItems ?? []"
|
||||
@select="dataGridRef?.setDefaultCopyExtractor($event)"
|
||||
:current-label="dataGridRef?.defaultCopyPreferenceLabel ?? '-'"
|
||||
:current-value="dataGridRef?.defaultCopyPreference ?? ''"
|
||||
:items="dataGridRef?.copyPreferenceMenuItems ?? []"
|
||||
@select="dataGridRef?.setDefaultCopyPreference($event)"
|
||||
@configure="openDataGridExtractorConfiguration"
|
||||
/>
|
||||
</PopoverContent>
|
||||
|
|
|
|||
|
|
@ -370,6 +370,87 @@ describe("useDataGridExport prepared row statements", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("uses escaped TSV for a multi-cell smart copy without relying on clipboard metadata", async () => {
|
||||
const rows = [
|
||||
[1, '{"msg":"success"}'],
|
||||
[2, "inside\ttab\nnext line"],
|
||||
];
|
||||
const matrix: CellSelectionMatrix = {
|
||||
rowIndexes: [0, 1],
|
||||
columnIndexes: [0, 1],
|
||||
columns: ["id", "name"],
|
||||
rows,
|
||||
};
|
||||
const text = '1\t"{""msg"":""success""}"\n2\t"inside\ttab\nnext line"';
|
||||
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text, mimeType: "text/tab-separated-values", fileExtension: "tsv", rowCount: 2, columnCount: 2 });
|
||||
const state = createExportState(editableTable, ["id", "name"], matrix, undefined, undefined, rows);
|
||||
|
||||
await expect(state.copyWithPreference("smart")).resolves.toBe(true);
|
||||
|
||||
expect(extractDataGridSelection).toHaveBeenCalledWith(expect.objectContaining({ extractor: "tsv" }));
|
||||
expect(copyToClipboard).toHaveBeenCalledWith(text);
|
||||
});
|
||||
|
||||
it("preserves JSON-column text in a single-cell smart copy", async () => {
|
||||
const tableMeta: DataGridTableMeta = {
|
||||
tableName: "events",
|
||||
primaryKeys: ["id"],
|
||||
columns: [
|
||||
{ name: "id", data_type: "int", is_nullable: false, is_primary_key: true },
|
||||
{ name: "payload", data_type: "json", is_nullable: true },
|
||||
],
|
||||
};
|
||||
const payload = '{ "id": 9007199254740993, "items": [ 1, 2 ] }';
|
||||
const matrix: CellSelectionMatrix = {
|
||||
rowIndexes: [0],
|
||||
columnIndexes: [1],
|
||||
columns: ["payload"],
|
||||
rows: [[payload]],
|
||||
};
|
||||
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: payload, mimeType: "text/plain", fileExtension: "txt", rowCount: 1, columnCount: 1 });
|
||||
const state = createExportState(tableMeta, ["id", "payload"], matrix, [7, payload]);
|
||||
|
||||
await expect(state.copyWithPreference("smart")).resolves.toBe(true);
|
||||
|
||||
expect(extractDataGridSelection).toHaveBeenCalledWith(expect.objectContaining({ extractor: "raw", rows: [[payload]] }));
|
||||
expect(copyToClipboard).toHaveBeenCalledWith(payload);
|
||||
});
|
||||
|
||||
it("does not allow raw output for a multi-cell selection", async () => {
|
||||
const matrix: CellSelectionMatrix = {
|
||||
rowIndexes: [0, 1],
|
||||
columnIndexes: [0],
|
||||
columns: ["id"],
|
||||
rows: [[1], [2]],
|
||||
};
|
||||
const state = createExportState(editableTable, ["id", "name"], matrix, undefined, undefined, [
|
||||
[1, "Ada"],
|
||||
[2, "Linus"],
|
||||
]);
|
||||
|
||||
expect(state.canCopyWithExtractor("raw")).toBe(false);
|
||||
await expect(state.copyWithExtractor("raw")).resolves.toBe(false);
|
||||
expect(extractDataGridSelection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the same TSV extractor for smart copy previews of multiple cells", async () => {
|
||||
const matrix: CellSelectionMatrix = {
|
||||
rowIndexes: [0, 1],
|
||||
columnIndexes: [0],
|
||||
columns: ["id"],
|
||||
rows: [[1], [2]],
|
||||
};
|
||||
vi.mocked(extractDataGridSelection).mockResolvedValueOnce({ text: "1\n2", mimeType: "text/tab-separated-values", fileExtension: "tsv", rowCount: 2, columnCount: 1 });
|
||||
const state = createExportState(editableTable, ["id", "name"], matrix, undefined, undefined, [
|
||||
[1, "Ada"],
|
||||
[2, "Linus"],
|
||||
]);
|
||||
|
||||
await expect(state.previewWithPreference("smart", DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS)).resolves.toEqual(expect.objectContaining({ text: "1\n2" }));
|
||||
|
||||
expect(extractDataGridSelection).toHaveBeenCalledWith(expect.objectContaining({ extractor: "tsv" }));
|
||||
});
|
||||
|
||||
it("rejects SQL UPDATE when the selection contains no writable non-key column", () => {
|
||||
const matrix: CellSelectionMatrix = {
|
||||
rowIndexes: [0],
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@ vi.mock("@/composables/useToast", () => ({
|
|||
vi.mock("@/composables/useDataGridExtractor", () => ({
|
||||
useDataGridExtractor: () => ({
|
||||
copyWithExtractor: vi.fn(),
|
||||
copyWithPreference: vi.fn(),
|
||||
previewWithExtractor: vi.fn(),
|
||||
previewWithPreference: vi.fn(),
|
||||
canCopyWithExtractor: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -558,7 +558,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
|||
});
|
||||
}
|
||||
|
||||
const { copyWithExtractor, previewWithExtractor, canCopyWithExtractor } = useDataGridExtractor({
|
||||
const { copyWithExtractor, copyWithPreference, previewWithExtractor, previewWithPreference, canCopyWithExtractor } = useDataGridExtractor({
|
||||
columns,
|
||||
displayItems,
|
||||
allColumns,
|
||||
|
|
@ -1291,7 +1291,9 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
|
|||
canCopyRow,
|
||||
copyAll,
|
||||
copyWithExtractor,
|
||||
copyWithPreference,
|
||||
previewWithExtractor,
|
||||
previewWithPreference,
|
||||
canCopyWithExtractor,
|
||||
exportCsv,
|
||||
exportCurrentPageCsv,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ import {
|
|||
DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS,
|
||||
extractorUnavailableForDatabase,
|
||||
normalizeDataGridExtractorOptions,
|
||||
resolveDataGridCopyPreference,
|
||||
type DataGridCopyExtractorId,
|
||||
type DataGridCopyPreference,
|
||||
type DataGridExtractPreview,
|
||||
type DataGridExtractRequest,
|
||||
type DataGridExtractorOptions,
|
||||
|
|
@ -133,9 +135,9 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
|
|||
sourceName: sourceNames?.[sourceIndex],
|
||||
sourceIndex: compactIndex,
|
||||
}));
|
||||
const selectedColumnIndexes = selectedSourceIndexes.map((sourceIndex) => compactIndexBySource.get(sourceIndex)).filter((index): index is number => index !== undefined);
|
||||
const rows = sourceRows.map((row) => requiredSourceIndexes.map((sourceIndex) => normalizeCellValue(row[sourceIndex], columnTypesBySource.get(sourceIndex))));
|
||||
const descriptor = DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS[extractor];
|
||||
const selectedColumnIndexes = selectedSourceIndexes.map((sourceIndex) => compactIndexBySource.get(sourceIndex)).filter((index): index is number => index !== undefined);
|
||||
const rows = sourceRows.map((row) => requiredSourceIndexes.map((sourceIndex) => (descriptor.category === "json" || descriptor.category === "sql" ? normalizeCellValue(row[sourceIndex], columnTypesBySource.get(sourceIndex)) : row[sourceIndex])));
|
||||
const tableMeta =
|
||||
descriptor.category === "sql"
|
||||
? compactTableMeta(
|
||||
|
|
@ -190,9 +192,19 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
|
|||
}
|
||||
return canBuildSqlUpdateRequest();
|
||||
}
|
||||
if (extractor === "raw") {
|
||||
const request = buildRequest(extractor, extractorOptions);
|
||||
return request !== null && request.rows.length * request.selectedColumnIndexes.length === 1;
|
||||
}
|
||||
return selectionData() !== null;
|
||||
}
|
||||
|
||||
function resolveCopyPreference(preference: DataGridCopyPreference, extractorOptions: DataGridExtractorOptions): DataGridCopyExtractorId | null {
|
||||
const request = buildRequest("raw", extractorOptions);
|
||||
if (!request) return null;
|
||||
return resolveDataGridCopyPreference(preference, request.rows.length * request.selectedColumnIndexes.length);
|
||||
}
|
||||
|
||||
async function resolveMongoExtractorResult(extractor: DataGridCopyExtractorId, request: DataGridExtractRequest, rowLimit?: number) {
|
||||
if (options.databaseType.value !== "mongodb") return undefined;
|
||||
if (extractor !== "sql-inserts" && extractor !== "sql-updates") return undefined;
|
||||
|
|
@ -214,11 +226,11 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
|
|||
if (!result.text) return false;
|
||||
// Derive the grid paste-back payload from the effective request schema so
|
||||
// hidden support columns, row headers, NULLs, tabs, and newlines keep the
|
||||
// same shape and values as the rendered TSV.
|
||||
const isTsv = extractor === "tsv" || extractor === "tsv-with-headers";
|
||||
const gridCopy = isTsv
|
||||
// same shape and values as the rendered raw text or TSV.
|
||||
const isGridTabularCopy = extractor === "raw" || extractor === "tsv" || extractor === "tsv-with-headers";
|
||||
const gridCopy = isGridTabularCopy
|
||||
? (() => {
|
||||
const includeRowHeader = request.options.dsv.includeRowHeader;
|
||||
const includeRowHeader = extractor !== "raw" && request.options.dsv.includeRowHeader;
|
||||
const rows = request.rows.map((row, rowIndex) => {
|
||||
const selectedRow = request.selectedColumnIndexes.map((index) => row[index]);
|
||||
return includeRowHeader ? [rowIndex + 1, ...selectedRow] : selectedRow;
|
||||
|
|
@ -250,6 +262,22 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
|
|||
return { ...result, sourceRowCount, truncated: sourceRowCount > result.rowCount };
|
||||
}
|
||||
|
||||
async function copyWithPreference(preference: DataGridCopyPreference, extractorOptions: DataGridExtractorOptions = options.extractorOptions?.value ?? DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS): Promise<boolean> {
|
||||
if (hasUnsupportedDiscreteSelection.value) {
|
||||
toast(t("grid.copyExtractorUnsupportedSelection"), 5000);
|
||||
return false;
|
||||
}
|
||||
const extractor = resolveCopyPreference(preference, extractorOptions);
|
||||
return extractor ? copyWithExtractor(extractor, extractorOptions) : false;
|
||||
}
|
||||
|
||||
async function previewWithPreference(preference: DataGridCopyPreference, extractorOptions: DataGridExtractorOptions): Promise<DataGridExtractPreview> {
|
||||
if (hasUnsupportedDiscreteSelection.value) throw new Error(t("grid.copyExtractorUnsupportedSelection"));
|
||||
const extractor = resolveCopyPreference(preference, extractorOptions);
|
||||
if (!extractor) throw new Error(t("grid.copyExtractorEmptySelection"));
|
||||
return previewWithExtractor(extractor, extractorOptions);
|
||||
}
|
||||
|
||||
function showWarnings(warnings: Array<{ code: DataGridExtractWarningCode; message: string }> | undefined, omittedColumns: string[] | undefined) {
|
||||
if (!warnings?.length) return;
|
||||
for (const warning of warnings) {
|
||||
|
|
@ -258,7 +286,7 @@ export function useDataGridExtractor(options: UseDataGridExtractorOptions) {
|
|||
}
|
||||
}
|
||||
|
||||
return { copyWithExtractor, previewWithExtractor, canCopyWithExtractor };
|
||||
return { copyWithExtractor, copyWithPreference, previewWithExtractor, previewWithPreference, canCopyWithExtractor };
|
||||
}
|
||||
|
||||
function compactTableMeta(tableMeta: DataGridTableMeta | undefined, requiredColumns: string[]): DataGridTableMeta | undefined {
|
||||
|
|
|
|||
|
|
@ -1123,6 +1123,8 @@ export default {
|
|||
noFilteredRows: "No rows match the filter",
|
||||
noFilteredRowsDescription: "Adjust the search text or row status filter.",
|
||||
copy: "Copy",
|
||||
copyExtractorSmart: "Smart Copy",
|
||||
copyExtractorRaw: "Cell Text",
|
||||
copyExtractorWithHeaders: "{format} with Headers",
|
||||
copyExtractorOneRow: "One-row",
|
||||
copyExtractorJsonArray: "JSON Array",
|
||||
|
|
@ -1135,7 +1137,7 @@ export default {
|
|||
copyExtractorPipeSeparated: "Pipe-separated",
|
||||
copyExtractorPretty: "Pretty Table",
|
||||
copyExtractorConfigure: "Configure Data Extractors…",
|
||||
copyExtractorDefaultFormat: "Default copy format",
|
||||
copyExtractorDefaultFormat: "Default copy behavior",
|
||||
copyExtractorFormat: "Extractor",
|
||||
copyExtractorColumnSeparator: "Column separator",
|
||||
copyExtractorRowSeparator: "Row separator",
|
||||
|
|
|
|||
|
|
@ -1442,6 +1442,8 @@ export default withEnglishFallback({
|
|||
truncatedHint: "Los resultados se truncaron después de cargar {count} filas. Usa la paginación inferior para explorar los datos cargados; al exportar el resultado completo se vuelve a consultar la base de datos.",
|
||||
filterBuilderSearchColumns: "Buscar columnas...",
|
||||
filterBuilderNoMatchingColumns: "Sin columnas coincidentes",
|
||||
copyExtractorSmart: "Copia inteligente",
|
||||
copyExtractorRaw: "Texto de celda",
|
||||
copyExtractorWithHeaders: "{format} (con encabezados)",
|
||||
copyExtractorOneRow: "CSV de una fila",
|
||||
copyExtractorJsonArray: "Array JSON",
|
||||
|
|
@ -1454,7 +1456,7 @@ export default withEnglishFallback({
|
|||
copyExtractorPipeSeparated: "Separado por barra vertical",
|
||||
copyExtractorPretty: "Tabla formateada",
|
||||
copyExtractorConfigure: "Configurar extractor de datos…",
|
||||
copyExtractorDefaultFormat: "Formato de copia predeterminado",
|
||||
copyExtractorDefaultFormat: "Comportamiento de copia predeterminado",
|
||||
copyExtractorFormat: "Extractor",
|
||||
copyExtractorColumnSeparator: "Separador de columnas",
|
||||
copyExtractorRowSeparator: "Separador de filas",
|
||||
|
|
|
|||
|
|
@ -1440,6 +1440,8 @@ export default withEnglishFallback({
|
|||
truncatedHint: "I risultati sono stati troncati dopo il caricamento di {count} righe. Usa la paginazione in basso per consultare i dati caricati; l'esportazione del risultato completo riesegue la query sul database.",
|
||||
filterBuilderSearchColumns: "Cerca campi...",
|
||||
filterBuilderNoMatchingColumns: "Nessun campo corrispondente",
|
||||
copyExtractorSmart: "Copia intelligente",
|
||||
copyExtractorRaw: "Testo della cella",
|
||||
copyExtractorWithHeaders: "{format} (con intestazione)",
|
||||
copyExtractorOneRow: "CSV a riga singola",
|
||||
copyExtractorJsonArray: "Array JSON",
|
||||
|
|
@ -1452,7 +1454,7 @@ export default withEnglishFallback({
|
|||
copyExtractorPipeSeparated: "Separato da pipe",
|
||||
copyExtractorPretty: "Testo tabellare",
|
||||
copyExtractorConfigure: "Configura estrattore dati…",
|
||||
copyExtractorDefaultFormat: "Formato di copia predefinito",
|
||||
copyExtractorDefaultFormat: "Comportamento di copia predefinito",
|
||||
copyExtractorFormat: "Estrattore",
|
||||
copyExtractorColumnSeparator: "Separatore di colonna",
|
||||
copyExtractorRowSeparator: "Separatore di riga",
|
||||
|
|
|
|||
|
|
@ -1462,6 +1462,8 @@ export default withEnglishFallback({
|
|||
redoChange: "変更をやり直す",
|
||||
filterBuilderSearchColumns: "フィールドを検索...",
|
||||
filterBuilderNoMatchingColumns: "一致するフィールドがありません",
|
||||
copyExtractorSmart: "スマートコピー",
|
||||
copyExtractorRaw: "セルの原文",
|
||||
copyExtractorWithHeaders: "{format}(ヘッダー付き)",
|
||||
copyExtractorOneRow: "単行CSV",
|
||||
copyExtractorJsonArray: "JSON 配列",
|
||||
|
|
@ -1474,7 +1476,7 @@ export default withEnglishFallback({
|
|||
copyExtractorPipeSeparated: "パイプ区切り",
|
||||
copyExtractorPretty: "テーブルテキスト",
|
||||
copyExtractorConfigure: "データ抽出の設定…",
|
||||
copyExtractorDefaultFormat: "デフォルトのコピー形式",
|
||||
copyExtractorDefaultFormat: "デフォルトのコピー動作",
|
||||
copyExtractorFormat: "抽出器",
|
||||
copyExtractorColumnSeparator: "列区切り文字",
|
||||
copyExtractorRowSeparator: "行区切り文字",
|
||||
|
|
|
|||
|
|
@ -1038,6 +1038,8 @@ export default withEnglishFallback({
|
|||
noFilteredRows: "필터와 일치하는 행이 없습니다",
|
||||
noFilteredRowsDescription: "검색 텍스트나 행 상태 필터를 조정하세요.",
|
||||
copy: "복사",
|
||||
copyExtractorSmart: "스마트 복사",
|
||||
copyExtractorRaw: "셀 원문",
|
||||
copyExtractorWithHeaders: "머리글 포함 {format}",
|
||||
copyExtractorOneRow: "단일 행",
|
||||
copyExtractorJsonArray: "JSON 배열",
|
||||
|
|
@ -1050,7 +1052,7 @@ export default withEnglishFallback({
|
|||
copyExtractorPipeSeparated: "파이프(|) 구분",
|
||||
copyExtractorPretty: "보기 좋은 표",
|
||||
copyExtractorConfigure: "데이터 추출기 설정…",
|
||||
copyExtractorDefaultFormat: "기본 복사 형식",
|
||||
copyExtractorDefaultFormat: "기본 복사 동작",
|
||||
copyExtractorFormat: "추출기",
|
||||
copyExtractorColumnSeparator: "열 구분자",
|
||||
copyExtractorRowSeparator: "행 구분자",
|
||||
|
|
|
|||
|
|
@ -1442,6 +1442,8 @@ export default withEnglishFallback({
|
|||
truncatedHint: "Os resultados foram truncados após carregar {count} linhas. Use a paginação no rodapé para navegar pelos dados carregados; exportar o resultado completo executa novamente a consulta no banco de dados.",
|
||||
filterBuilderSearchColumns: "Pesquisar campos...",
|
||||
filterBuilderNoMatchingColumns: "Nenhum campo correspondente",
|
||||
copyExtractorSmart: "Cópia inteligente",
|
||||
copyExtractorRaw: "Texto da célula",
|
||||
copyExtractorWithHeaders: "{format} (com cabeçalhos)",
|
||||
copyExtractorOneRow: "CSV de linha única",
|
||||
copyExtractorJsonArray: "Array JSON",
|
||||
|
|
@ -1454,7 +1456,7 @@ export default withEnglishFallback({
|
|||
copyExtractorPipeSeparated: "Separado por pipe",
|
||||
copyExtractorPretty: "Tabela formatada",
|
||||
copyExtractorConfigure: "Configurar extrator de dados…",
|
||||
copyExtractorDefaultFormat: "Formato de cópia padrão",
|
||||
copyExtractorDefaultFormat: "Comportamento de cópia padrão",
|
||||
copyExtractorFormat: "Extrator",
|
||||
copyExtractorColumnSeparator: "Separador de coluna",
|
||||
copyExtractorRowSeparator: "Separador de linha",
|
||||
|
|
|
|||
|
|
@ -1124,6 +1124,8 @@ export default withEnglishFallback({
|
|||
noFilteredRows: "没有符合条件的行",
|
||||
noFilteredRowsDescription: "调整搜索词或状态筛选试试。",
|
||||
copy: "复制",
|
||||
copyExtractorSmart: "智能复制",
|
||||
copyExtractorRaw: "单元格原文",
|
||||
copyExtractorWithHeaders: "{format}(含表头)",
|
||||
copyExtractorOneRow: "单行 CSV",
|
||||
copyExtractorJsonArray: "JSON 数组",
|
||||
|
|
@ -1135,8 +1137,8 @@ export default withEnglishFallback({
|
|||
copyExtractorWhereClause: "WHERE 条件",
|
||||
copyExtractorPipeSeparated: "竖线分隔",
|
||||
copyExtractorPretty: "表格文本",
|
||||
copyExtractorConfigure: "设置复制格式…",
|
||||
copyExtractorDefaultFormat: "默认复制格式",
|
||||
copyExtractorConfigure: "配置数据提取器…",
|
||||
copyExtractorDefaultFormat: "默认复制方式",
|
||||
copyExtractorFormat: "提取器",
|
||||
copyExtractorColumnSeparator: "列分隔符",
|
||||
copyExtractorRowSeparator: "行分隔符",
|
||||
|
|
|
|||
|
|
@ -1441,6 +1441,8 @@ export default withEnglishFallback({
|
|||
truncatedHint: "結果已截斷,已載入前 {count} 筆。可透過底部分頁瀏覽已載入資料;匯出完整結果時會重新查詢資料庫。",
|
||||
filterBuilderSearchColumns: "搜尋欄位...",
|
||||
filterBuilderNoMatchingColumns: "沒有匹配的欄位",
|
||||
copyExtractorSmart: "智慧複製",
|
||||
copyExtractorRaw: "儲存格原文",
|
||||
copyExtractorWithHeaders: "{format}(含表頭)",
|
||||
copyExtractorOneRow: "單行 CSV",
|
||||
copyExtractorJsonArray: "JSON 陣列",
|
||||
|
|
@ -1453,7 +1455,7 @@ export default withEnglishFallback({
|
|||
copyExtractorPipeSeparated: "豎線分隔",
|
||||
copyExtractorPretty: "表格文本",
|
||||
copyExtractorConfigure: "配置資料提取器…",
|
||||
copyExtractorDefaultFormat: "預設複製格式",
|
||||
copyExtractorDefaultFormat: "預設複製方式",
|
||||
copyExtractorFormat: "提取器",
|
||||
copyExtractorColumnSeparator: "列分隔符",
|
||||
copyExtractorRowSeparator: "行分隔符",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, normalizeDataGridExtractorOptions, validateDataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor";
|
||||
import { DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, normalizeDataGridCopyPreference, normalizeDataGridExtractorOptions, resolveDataGridCopyPreference, validateDataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor";
|
||||
|
||||
describe("data-grid extractor options", () => {
|
||||
it("resolves smart copy to raw for one cell and TSV otherwise", () => {
|
||||
expect(normalizeDataGridCopyPreference(undefined)).toBe("smart");
|
||||
expect(normalizeDataGridCopyPreference("smart")).toBe("smart");
|
||||
expect(resolveDataGridCopyPreference("smart", 1)).toBe("raw");
|
||||
expect(resolveDataGridCopyPreference("smart", 2)).toBe("tsv");
|
||||
expect(resolveDataGridCopyPreference("csv", 1)).toBe("csv");
|
||||
});
|
||||
|
||||
it("normalizes persisted values without sharing the default object", () => {
|
||||
const normalized = normalizeDataGridExtractorOptions(DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,37 @@
|
|||
import type { DatabaseType } from "@/types/database";
|
||||
import type { DataGridCopyInsertMode, DataGridTableMeta } from "@/lib/dataGrid/dataGridSql";
|
||||
|
||||
export const DATA_GRID_COPY_EXTRACTOR_IDS = ["tsv", "tsv-with-headers", "csv", "csv-with-headers", "pipe-separated", "dsv", "json", "json-lines", "one-row", "sql-in-list", "sql-inserts", "sql-updates", "where-clause", "markdown", "html", "xml", "pretty"] as const;
|
||||
export const DATA_GRID_COPY_EXTRACTOR_IDS = ["raw", "tsv", "tsv-with-headers", "csv", "csv-with-headers", "pipe-separated", "dsv", "json", "json-lines", "one-row", "sql-in-list", "sql-inserts", "sql-updates", "where-clause", "markdown", "html", "xml", "pretty"] as const;
|
||||
|
||||
export type DataGridCopyExtractorId = (typeof DATA_GRID_COPY_EXTRACTOR_IDS)[number];
|
||||
export type DataGridExtractorCategory = "delimited" | "json" | "sql" | "document";
|
||||
export type DataGridCopyPreference = "smart" | Exclude<DataGridCopyExtractorId, "raw">;
|
||||
export type DataGridExtractorCategory = "raw" | "delimited" | "json" | "sql" | "document";
|
||||
|
||||
export const DATA_GRID_DEFAULT_COPY_PREFERENCES: readonly DataGridCopyPreference[] = [
|
||||
"smart",
|
||||
"tsv",
|
||||
"tsv-with-headers",
|
||||
"csv",
|
||||
"csv-with-headers",
|
||||
"pipe-separated",
|
||||
"dsv",
|
||||
"json",
|
||||
"json-lines",
|
||||
"one-row",
|
||||
"sql-in-list",
|
||||
"sql-inserts",
|
||||
"sql-updates",
|
||||
"where-clause",
|
||||
"markdown",
|
||||
"html",
|
||||
"xml",
|
||||
"pretty",
|
||||
];
|
||||
|
||||
export const DATA_GRID_EXTRACTOR_CONTRACT_VERSION = 1 as const;
|
||||
|
||||
export const DATA_GRID_COPY_EXTRACTOR_DESCRIPTORS: Record<DataGridCopyExtractorId, { category: DataGridExtractorCategory; separatorBefore?: boolean }> = {
|
||||
raw: { category: "raw" },
|
||||
tsv: { category: "delimited" },
|
||||
"tsv-with-headers": { category: "delimited" },
|
||||
csv: { category: "delimited" },
|
||||
|
|
@ -154,10 +177,15 @@ export function normalizeDataGridExtractorOptions(value: unknown): DataGridExtra
|
|||
};
|
||||
}
|
||||
|
||||
const DATA_GRID_COPY_EXTRACTOR_ID_SET = new Set<string>(DATA_GRID_COPY_EXTRACTOR_IDS);
|
||||
const DATA_GRID_COPY_PREFERENCE_SET = new Set<string>(DATA_GRID_DEFAULT_COPY_PREFERENCES);
|
||||
|
||||
export function normalizeDataGridCopyExtractorId(value: unknown): DataGridCopyExtractorId {
|
||||
return typeof value === "string" && DATA_GRID_COPY_EXTRACTOR_ID_SET.has(value) ? (value as DataGridCopyExtractorId) : "tsv";
|
||||
export function normalizeDataGridCopyPreference(value: unknown): DataGridCopyPreference {
|
||||
return typeof value === "string" && DATA_GRID_COPY_PREFERENCE_SET.has(value) ? (value as DataGridCopyPreference) : "smart";
|
||||
}
|
||||
|
||||
export function resolveDataGridCopyPreference(preference: DataGridCopyPreference, selectedCellCount: number): DataGridCopyExtractorId {
|
||||
if (preference !== "smart") return preference;
|
||||
return selectedCellCount === 1 ? "raw" : "tsv";
|
||||
}
|
||||
|
||||
export function validateDataGridExtractorOptions(extractor: DataGridCopyExtractorId, options: DataGridExtractorOptions): DataGridExtractorOptionsError | null {
|
||||
|
|
|
|||
|
|
@ -131,11 +131,13 @@ describe("normalizeEditorSettings", () => {
|
|||
expect(normalizeEditorSettings({ dataGridSearchMode: "invalid" as any }).dataGridSearchMode).toBe("filter");
|
||||
});
|
||||
|
||||
it("defaults the global data grid copy extractor and preserves valid choices", () => {
|
||||
expect(normalizeEditorSettings({}).dataGridCopyExtractor).toBe("tsv");
|
||||
it("defaults the global data grid copy preference and preserves valid choices", () => {
|
||||
expect(normalizeEditorSettings({}).dataGridCopyExtractor).toBe("smart");
|
||||
expect(normalizeEditorSettings({ dataGridCopyExtractor: "smart" }).dataGridCopyExtractor).toBe("smart");
|
||||
expect(normalizeEditorSettings({ dataGridCopyExtractor: "tsv" }).dataGridCopyExtractor).toBe("tsv");
|
||||
expect(normalizeEditorSettings({ dataGridCopyExtractor: "sql-updates" }).dataGridCopyExtractor).toBe("sql-updates");
|
||||
expect(normalizeEditorSettings({ dataGridCopyExtractor: "markdown" }).dataGridCopyExtractor).toBe("markdown");
|
||||
expect(normalizeEditorSettings({ dataGridCopyExtractor: "invalid" as any }).dataGridCopyExtractor).toBe("tsv");
|
||||
expect(normalizeEditorSettings({ dataGridCopyExtractor: "invalid" as any }).dataGridCopyExtractor).toBe("smart");
|
||||
});
|
||||
|
||||
it("normalizes persistent extractor configuration fail-fast defaults", () => {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { generateId, getConfigKey, aiConfigToItem } from "@/lib/ai/aiConfigList"
|
|||
import { normalizeColumnFormatter, normalizeCustomColumnFormatter, normalizeGlobalDateTimePattern, type ColumnFormatterConfig, type CustomColumnFormatterConfig } from "@/lib/dataGrid/columnFormatter";
|
||||
import { normalizeShortcutSettings, type ShortcutSettings } from "@/lib/editor/shortcutRegistry";
|
||||
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
|
||||
import { DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, normalizeDataGridCopyExtractorId, normalizeDataGridExtractorOptions, type DataGridCopyExtractorId, type DataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor";
|
||||
import { DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, normalizeDataGridCopyPreference, normalizeDataGridExtractorOptions, type DataGridCopyPreference, type DataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor";
|
||||
import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay";
|
||||
import type { ConnectionListSortMode } from "@/lib/sidebar/connectionListSort";
|
||||
import { DEFAULT_SQL_FORMATTER_SETTINGS, normalizeSqlFormatterSettings, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig";
|
||||
|
|
@ -474,7 +474,7 @@ export interface EditorSettings {
|
|||
dataGridQuickEntry: boolean;
|
||||
dataGridRenderMode: DataGridRenderMode;
|
||||
dataGridSearchMode: DataGridSearchMode;
|
||||
dataGridCopyExtractor: DataGridCopyExtractorId;
|
||||
dataGridCopyExtractor: DataGridCopyPreference;
|
||||
dataGridExtractorOptions: DataGridExtractorOptions;
|
||||
resultRunDisplayMode: ResultRunDisplayMode;
|
||||
dataGridAutoTransposeSingleRow: boolean;
|
||||
|
|
@ -652,7 +652,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
|
|||
dataGridQuickEntry: false,
|
||||
dataGridRenderMode: "canvas",
|
||||
dataGridSearchMode: "filter",
|
||||
dataGridCopyExtractor: "tsv",
|
||||
dataGridCopyExtractor: "smart",
|
||||
dataGridExtractorOptions: normalizeDataGridExtractorOptions(DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS),
|
||||
resultRunDisplayMode: "tabs",
|
||||
dataGridAutoTransposeSingleRow: false,
|
||||
|
|
@ -961,7 +961,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
|
|||
dataGridQuickEntry: settings.dataGridQuickEntry ?? DEFAULT_EDITOR_SETTINGS.dataGridQuickEntry,
|
||||
dataGridRenderMode: normalizeDataGridRenderMode(settings.dataGridRenderMode),
|
||||
dataGridSearchMode: normalizeDataGridSearchMode(settings.dataGridSearchMode),
|
||||
dataGridCopyExtractor: normalizeDataGridCopyExtractorId(settings.dataGridCopyExtractor),
|
||||
dataGridCopyExtractor: normalizeDataGridCopyPreference(settings.dataGridCopyExtractor),
|
||||
dataGridExtractorOptions: normalizeDataGridExtractorOptions(settings.dataGridExtractorOptions),
|
||||
resultRunDisplayMode: normalizeResultRunDisplayMode(settings.resultRunDisplayMode),
|
||||
dataGridAutoTransposeSingleRow: settings.dataGridAutoTransposeSingleRow === true,
|
||||
|
|
@ -1454,7 +1454,7 @@ export const useSettingsStore = defineStore("settings", () => {
|
|||
if (partial.dataGridQuickEntry !== undefined) editorSettings.value.dataGridQuickEntry = partial.dataGridQuickEntry;
|
||||
if (partial.dataGridRenderMode !== undefined) editorSettings.value.dataGridRenderMode = normalizeDataGridRenderMode(partial.dataGridRenderMode);
|
||||
if (partial.dataGridSearchMode !== undefined) editorSettings.value.dataGridSearchMode = normalizeDataGridSearchMode(partial.dataGridSearchMode);
|
||||
if (partial.dataGridCopyExtractor !== undefined) editorSettings.value.dataGridCopyExtractor = normalizeDataGridCopyExtractorId(partial.dataGridCopyExtractor);
|
||||
if (partial.dataGridCopyExtractor !== undefined) editorSettings.value.dataGridCopyExtractor = normalizeDataGridCopyPreference(partial.dataGridCopyExtractor);
|
||||
if (partial.dataGridExtractorOptions !== undefined) editorSettings.value.dataGridExtractorOptions = normalizeDataGridExtractorOptions(partial.dataGridExtractorOptions);
|
||||
if (partial.resultRunDisplayMode !== undefined) editorSettings.value.resultRunDisplayMode = normalizeResultRunDisplayMode(partial.resultRunDisplayMode);
|
||||
if (partial.dataGridAutoTransposeSingleRow !== undefined) editorSettings.value.dataGridAutoTransposeSingleRow = partial.dataGridAutoTransposeSingleRow === true;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const DEFAULT_JSON_PRETTY: bool = true;
|
|||
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum DataGridExtractorId {
|
||||
Raw,
|
||||
Tsv,
|
||||
TsvWithHeaders,
|
||||
Csv,
|
||||
|
|
@ -196,6 +197,7 @@ pub struct DataGridExtractResult {
|
|||
pub enum DataGridExtractErrorCode {
|
||||
UnsupportedVersion,
|
||||
EmptySelection,
|
||||
InvalidRawSelection,
|
||||
InvalidColumnIndex,
|
||||
InvalidColumnMapping,
|
||||
MissingTableMetadata,
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@ mod contract;
|
|||
mod delimited;
|
||||
mod document;
|
||||
mod json;
|
||||
mod raw;
|
||||
mod sql;
|
||||
|
||||
pub use contract::*;
|
||||
use delimited::{write_dsv, write_one_row};
|
||||
use document::{write_html, write_markdown, write_pretty, write_xml};
|
||||
use json::{write_json, write_json_lines};
|
||||
use raw::write_raw;
|
||||
use sql::{write_sql_in_list, write_sql_inserts, write_sql_updates, write_where_clause};
|
||||
|
||||
const DATA_GRID_EXTRACTOR_MAX_OUTPUT_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
|
@ -251,6 +253,16 @@ fn write_extraction(
|
|||
) -> Result<WriteMetadata, DataGridExtractError> {
|
||||
let extractor = context.request.extractor;
|
||||
match extractor {
|
||||
DataGridExtractorId::Raw => {
|
||||
if context.request.rows.len() != 1 || context.selected_source_indexes.len() != 1 {
|
||||
return Err(DataGridExtractError::new(
|
||||
DataGridExtractErrorCode::InvalidRawSelection,
|
||||
"The raw extractor supports exactly one selected cell.",
|
||||
));
|
||||
}
|
||||
write_raw(context, output)?;
|
||||
Ok(text_metadata("text/plain", "txt"))
|
||||
}
|
||||
DataGridExtractorId::Tsv | DataGridExtractorId::TsvWithHeaders => {
|
||||
let options = DataGridDsvOptions {
|
||||
column_separator: "\t".to_string(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
use super::{value_text, write_bytes, DataGridExtractError, ExtractContext};
|
||||
use std::io::Write;
|
||||
|
||||
/// Writes selected cell values without DSV escaping. Tabs and newlines retain
|
||||
/// the selection shape, while each value itself remains unchanged.
|
||||
pub(super) fn write_raw(context: &ExtractContext<'_>, output: &mut dyn Write) -> Result<(), DataGridExtractError> {
|
||||
for (row_index, row) in context.request.rows.iter().enumerate() {
|
||||
if row_index > 0 {
|
||||
write_bytes(output, b"\n")?;
|
||||
}
|
||||
for (column_index, source_index) in context.selected_source_indexes.iter().enumerate() {
|
||||
if column_index > 0 {
|
||||
write_bytes(output, b"\t")?;
|
||||
}
|
||||
write_bytes(output, value_text(&row[*source_index]).as_bytes())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -50,6 +50,27 @@ fn extracts_csv_with_minimal_standard_quoting() {
|
|||
assert_eq!(result.text, "1,Ada\n2,\"Grace, Hopper\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_raw_values_without_escaping_quotes() {
|
||||
let mut request = request(DataGridExtractorId::Raw);
|
||||
request.columns = vec![column("payload", 0)];
|
||||
request.selected_column_indexes = vec![0];
|
||||
request.rows = vec![vec![json!(r#"{"msg":"success"}"#)]];
|
||||
|
||||
let result = extract_data_grid_selection(request).expect("raw extraction");
|
||||
|
||||
assert_eq!(result.text, r#"{"msg":"success"}"#);
|
||||
assert_eq!(result.mime_type, "text/plain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_rejects_multiple_selected_cells() {
|
||||
let error =
|
||||
extract_data_grid_selection(request(DataGridExtractorId::Raw)).expect_err("raw must reject multiple cells");
|
||||
|
||||
assert_eq!(error.code, DataGridExtractErrorCode::InvalidRawSelection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_distinguishes_null_from_null_string() {
|
||||
let mut request = request(DataGridExtractorId::Csv);
|
||||
|
|
|
|||
Loading…
Reference in New Issue