feat(diff): save, rename, copy, and delete data compare configs

This commit is contained in:
zipg
2026-10-04 09:53:31 +08:00
committed by GitHub
parent a3a5f4f9bd
commit 72a25f6e9f
18 changed files with 681 additions and 0 deletions
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Copy, FileDown, Plus, Trash2 } from "@lucide/vue";
import type { DataCompareConfig } from "@/composables/useDataCompareConfig";
const props = defineProps<{
configs: DataCompareConfig[];
activeConfigId: string;
disabled?: boolean;
}>();
const emit = defineEmits<{
(e: "update:activeConfigId", id: string): void;
(e: "create"): void;
(e: "rename", id: string, name: string): void;
(e: "delete", id: string): void;
(e: "duplicate", id: string): void;
}>();
const { t } = useI18n();
const renameDialogOpen = ref(false);
const renameValue = ref("");
const renamingId = ref("");
const activeConfig = computed(() => props.configs.find((config) => config.id === props.activeConfigId) ?? null);
function onSelectChange(value: unknown) {
emit("update:activeConfigId", String(value));
}
function startRename(config: DataCompareConfig) {
renamingId.value = config.id;
renameValue.value = config.name;
renameDialogOpen.value = true;
}
function confirmRename() {
if (renamingId.value && renameValue.value.trim()) {
emit("rename", renamingId.value, renameValue.value.trim());
}
renameDialogOpen.value = false;
}
</script>
<template>
<div class="flex items-center gap-2">
<Select :model-value="activeConfigId" :disabled="disabled" @update:model-value="onSelectChange">
<SelectTrigger class="h-8 min-w-[200px] text-xs" data-testid="data-compare-config-select">
<SelectValue :placeholder="t('dataCompare.configSelect')" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="config in configs" :key="config.id" :value="config.id"> {{ config.name }} ({{ t("dataCompare.configTableCount", { count: config.selectedSourceTables.length }) }}) </SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="icon" class="h-8 w-8" :disabled="disabled" :title="t('dataCompare.configNew')" @click="emit('create')">
<Plus class="h-4 w-4" />
</Button>
<Button v-if="activeConfig" variant="outline" size="icon" class="h-8 w-8" :disabled="disabled" :title="t('dataCompare.configRename')" @click="startRename(activeConfig)">
<Copy class="h-4 w-4" />
</Button>
<Button v-if="activeConfig" variant="outline" size="icon" class="h-8 w-8" :disabled="disabled" :title="t('dataCompare.configDuplicate')" @click="emit('duplicate', activeConfig.id)">
<FileDown class="h-4 w-4" />
</Button>
<Button v-if="activeConfig" variant="outline" size="icon" class="h-8 w-8" :disabled="disabled" :title="t('dataCompare.configDelete')" @click="emit('delete', activeConfig.id)">
<Trash2 class="h-4 w-4" />
</Button>
<Dialog v-model:open="renameDialogOpen">
<DialogContent class="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{{ t("dataCompare.configRename") }}</DialogTitle>
</DialogHeader>
<div class="py-4">
<Label class="text-sm">{{ t("dataCompare.configName") }}</Label>
<Input v-model="renameValue" class="mt-2" @keydown.enter="confirmRename" />
</div>
<DialogFooter>
<Button variant="outline" @click="renameDialogOpen = false">{{ t("common.cancel") }}</Button>
<Button @click="confirmRename">{{ t("common.save") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
@@ -28,6 +28,8 @@ import {
type SelectableDataCompareRow,
} from "@/composables/useDataCompareSession";
import CompareKeyColumnsSelect from "@/components/diff/CompareKeyColumnsSelect.vue";
import DataCompareConfigSelector from "@/components/diff/DataCompareConfigSelector.vue";
import { useDataCompareConfig, type DataCompareConfig, type DataCompareConfigSnapshot } from "@/composables/useDataCompareConfig";
import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { supportsTransaction } from "@/lib/database/databaseFeatureSupport";
@@ -41,6 +43,16 @@ const SYNC_EXECUTE_BATCH_SIZE = 500;
const { t } = useI18n();
const { toast } = useToast();
const store = useConnectionStore();
const {
configs: dataCompareConfigs,
activeConfigId: dataCompareConfigId,
activeConfig: activeDataCompareConfig,
createConfig: createDataCompareConfig,
updateConfig: updateDataCompareConfig,
renameConfig: renameDataCompareConfig,
deleteConfig: deleteDataCompareConfig,
duplicateConfig: duplicateDataCompareConfig,
} = useDataCompareConfig();
const open = defineModel<boolean>("open", { default: false });
const props = defineProps<{
@@ -111,6 +123,9 @@ const activeSessionId = ref<string | null>(props.sessionId ?? null);
let syncPlanRequestId = 0;
let initializingPrefill = false;
let initializingPrefillGeneration = 0;
/** Set while a saved config is being applied so programmatic form writes are not re-saved. */
let suppressConfigSave = false;
let configSaveTimer: ReturnType<typeof setTimeout> | undefined;
let componentUnmounted = false;
let shownSessionError = "";
@@ -457,6 +472,103 @@ async function restoreDataCompareSession(session: DataCompareSession): Promise<v
}
}
/** Selection-only snapshot persisted by the config selector. */
function dataCompareConfigSnapshot(): DataCompareConfigSnapshot {
return {
sourceConnectionId: sourceConnectionId.value,
sourceDatabase: sourceDatabase.value,
sourceSchema: sourceSchema.value,
selectedSourceTables: [...selectedSourceTables.value],
targetConnectionId: targetConnectionId.value,
targetDatabase: targetDatabase.value,
targetSchema: targetSchema.value,
targetTable: targetTable.value,
detailPreviewLimit: detailPreviewLimit.value,
keyColumnsByTable: buildSessionKeyColumnsByTable(),
};
}
/**
* Rebuild the dialog selection from a saved config. Option lists are re-read
* from the live connection (never restored from storage) so a config can not
* resurrect a table that no longer exists; the saved table selection is then
* intersected with what the connection actually returns.
*/
async function applyDataCompareConfig(config: DataCompareConfig): Promise<void> {
if (comparing.value || executionLocked.value) return;
const generation = ++initializingPrefillGeneration;
initializingPrefill = true;
suppressConfigSave = true;
clearResult();
shownSessionError = "";
try {
sourceConnectionId.value = config.sourceConnectionId;
if (!config.sourceConnectionId) return;
await loadDatabases(config.sourceConnectionId, "source");
if (generation !== initializingPrefillGeneration) return;
if (config.sourceDatabase) sourceDatabase.value = config.sourceDatabase;
if (config.sourceDatabase) await loadSchemas("source", config.sourceSchema);
if (generation !== initializingPrefillGeneration) return;
targetConnectionId.value = config.targetConnectionId;
if (config.targetConnectionId) {
await loadDatabases(config.targetConnectionId, "target");
if (generation !== initializingPrefillGeneration) return;
if (config.targetDatabase) targetDatabase.value = config.targetDatabase;
if (config.targetDatabase) await loadSchemas("target", config.targetSchema);
if (generation !== initializingPrefillGeneration) return;
}
resetSelectedSourceTables(config.selectedSourceTables);
if (sourceSchema.value) await loadTables("source");
if (targetSchema.value) await loadTables("target");
if (generation !== initializingPrefillGeneration) return;
// loadTables prefers the dialog's prefill table when present; the saved
// config must win, so re-apply the selection and drop tables that vanished.
const availableTables = config.selectedSourceTables.filter((table) => sourceTables.value.includes(table));
resetSelectedSourceTables(availableTables);
sourceTable.value = availableTables.length === 1 ? (availableTables[0] ?? "") : "";
if (config.targetTable && targetTables.value.includes(config.targetTable)) targetTable.value = config.targetTable;
keyColumnOverrides.value = normalizeKeyColumnOverrides(config.keyColumnsByTable);
resetTableColumnMetadata();
if (config.detailPreviewLimit) detailPreviewLimit.value = config.detailPreviewLimit;
void prefetchSelectedTableColumns(selectedSourceTableNames.value);
} catch (error) {
toast(String(error), 5000);
} finally {
await nextTick();
if (generation === initializingPrefillGeneration) initializingPrefill = false;
suppressConfigSave = false;
}
}
function handleDataCompareConfigSelect(id: string) {
dataCompareConfigId.value = id;
const config = dataCompareConfigs.value.find((item) => item.id === id);
if (config) void applyDataCompareConfig(config);
}
function handleSaveDataCompareConfig() {
createDataCompareConfig(t("dataCompare.defaultConfigName"), dataCompareConfigSnapshot());
toast(t("dataCompare.configSaved"), 3000);
}
function handleRenameDataCompareConfig(id: string, name: string) {
renameDataCompareConfig(id, name);
}
function handleDuplicateDataCompareConfig() {
const id = dataCompareConfigId.value;
if (id) duplicateDataCompareConfig(id);
}
function handleDeleteDataCompareConfig() {
const id = dataCompareConfigId.value;
// The form keeps the current selection; only the saved entry is removed so a
// delete can never clear what the user is looking at.
if (id) deleteDataCompareConfig(id);
}
function applyDataCompareSession(session: DataCompareSession | undefined): void {
if (!session) return;
batchResults.value = session.batchResults;
@@ -1002,6 +1114,21 @@ watch(targetTable, () => {
if (initializingPrefill) return;
clearResult();
});
// Keep the selected config in sync with the form so "saved" really means the
// current selection. Debounced because table multi-select toggles fire rapidly.
watch(
() => [sourceConnectionId.value, sourceDatabase.value, sourceSchema.value, sourceTableSelection.value.join("\u0000"), targetConnectionId.value, targetDatabase.value, targetSchema.value, targetTable.value, detailPreviewLimit.value, JSON.stringify(keyColumnOverrides.value)],
() => {
if (suppressConfigSave || initializingPrefill || !dataCompareConfigId.value) return;
if (configSaveTimer) clearTimeout(configSaveTimer);
const configId = dataCompareConfigId.value;
configSaveTimer = setTimeout(() => {
configSaveTimer = undefined;
if (componentUnmounted || configId !== dataCompareConfigId.value) return;
updateDataCompareConfig(configId, dataCompareConfigSnapshot());
}, 400);
},
);
watch(
[() => open.value, () => props.sessionId],
async ([value, sessionId]) => {
@@ -1025,6 +1152,10 @@ watch(
if (props.prefillConnectionId) {
const generation = ++initializingPrefillGeneration;
initializingPrefill = true;
// A prefill is an ad-hoc compare started from another screen; it must not
// be silently written into whichever config happened to be selected.
dataCompareConfigId.value = "";
suppressConfigSave = true;
try {
sourceConnectionId.value = props.prefillConnectionId;
await loadDatabases(props.prefillConnectionId, "source");
@@ -1041,7 +1172,13 @@ watch(
} finally {
await nextTick();
if (generation === initializingPrefillGeneration) initializingPrefill = false;
suppressConfigSave = false;
}
return;
}
const savedConfig = activeDataCompareConfig.value;
if (savedConfig) {
await applyDataCompareConfig(savedConfig);
}
},
{ immediate: true },
@@ -1058,6 +1195,7 @@ watch(
);
onBeforeUnmount(() => {
componentUnmounted = true;
if (configSaveTimer) clearTimeout(configSaveTimer);
if (!executing.value && txnSessionId.value) void finishTransaction(false);
});
</script>
@@ -1072,6 +1210,19 @@ onBeforeUnmount(() => {
</DialogTitle>
</DialogHeader>
<div class="flex flex-wrap items-center gap-2 border-b pb-2">
<DataCompareConfigSelector
:configs="dataCompareConfigs"
:active-config-id="dataCompareConfigId"
:disabled="comparing || executionLocked"
@update:active-config-id="handleDataCompareConfigSelect"
@create="handleSaveDataCompareConfig"
@rename="handleRenameDataCompareConfig"
@duplicate="handleDuplicateDataCompareConfig"
@delete="handleDeleteDataCompareConfig"
/>
</div>
<div class="flex-1 min-h-0 min-w-0 overflow-auto">
<fieldset :disabled="executionLocked" class="space-y-4 py-2">
<div class="grid grid-cols-[1fr_auto_1fr] gap-4 items-start">
@@ -0,0 +1,116 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from "vitest";
const CONFIG_KEY = "dbx-data-compare-configs";
function snapshot(overrides: Record<string, unknown> = {}) {
return {
sourceConnectionId: "src",
sourceDatabase: "app",
sourceSchema: "public",
selectedSourceTables: ["users", "orders"],
targetConnectionId: "dst",
targetDatabase: "app_copy",
targetSchema: "public",
targetTable: "users",
detailPreviewLimit: "100",
keyColumnsByTable: { users: ["user_id"] },
...overrides,
};
}
describe("useDataCompareConfig", () => {
beforeEach(() => {
localStorage.clear();
vi.resetModules();
});
it("creates a config and persists the whole selection", async () => {
const { useDataCompareConfig } = await import("../useDataCompareConfig");
const state = useDataCompareConfig();
const created = state.createConfig("Nightly", snapshot());
expect(state.activeConfigId.value).toBe(created.id);
expect(state.activeConfig.value?.name).toBe("Nightly");
const stored = JSON.parse(localStorage.getItem(CONFIG_KEY) ?? "[]") as Array<Record<string, unknown>>;
expect(stored).toHaveLength(1);
expect(stored[0]!.selectedSourceTables).toEqual(["users", "orders"]);
expect(stored[0]!.keyColumnsByTable).toEqual({ users: ["user_id"] });
});
it("keeps config names unique instead of silently overwriting", async () => {
const { useDataCompareConfig } = await import("../useDataCompareConfig");
const state = useDataCompareConfig();
state.createConfig("Same", snapshot());
state.createConfig("Same", snapshot());
expect(state.configs.value.map((config) => config.name)).toEqual(["Same (1)", "Same"]);
state.renameConfig(state.configs.value[1]!.id, "Same (1)");
expect(state.configs.value.map((config) => config.name)).toEqual(["Same (1)", "Same (1) (1)"]);
});
it("drops the active selection when the active config is deleted", async () => {
const { useDataCompareConfig } = await import("../useDataCompareConfig");
const state = useDataCompareConfig();
const created = state.createConfig("One", snapshot());
state.deleteConfig(created.id);
expect(state.configs.value).toHaveLength(0);
expect(state.activeConfigId.value).toBe("");
expect(state.activeConfig.value).toBeNull();
});
it("duplicates a config under a new id without sharing mutable arrays", async () => {
const { useDataCompareConfig } = await import("../useDataCompareConfig");
const state = useDataCompareConfig();
const created = state.createConfig("Base", snapshot());
const copy = state.duplicateConfig(created.id);
expect(copy?.id).not.toBe(created.id);
expect(copy?.name).toBe("Base (1)");
expect(state.activeConfigId.value).toBe(copy?.id);
copy!.selectedSourceTables.push("logs");
expect(created.selectedSourceTables).toEqual(["users", "orders"]);
});
it("updates a config in place and bumps updatedAt", async () => {
const { useDataCompareConfig } = await import("../useDataCompareConfig");
const state = useDataCompareConfig();
const created = state.createConfig("One", snapshot());
created.updatedAt = 1;
state.updateConfig(created.id, { selectedSourceTables: ["logs"], keyColumnsByTable: {} });
const updated = state.configs.value.find((config) => config.id === created.id);
expect(updated?.selectedSourceTables).toEqual(["logs"]);
expect(updated?.keyColumnsByTable).toEqual({});
expect(updated?.updatedAt).toBeGreaterThan(1);
});
it("sanitizes stored configs and preserves an explicitly cleared match-column list", async () => {
localStorage.setItem(CONFIG_KEY, JSON.stringify([{ name: "Valid", id: "a", selectedSourceTables: ["t", 5], keyColumnsByTable: { t: [], u: "bad" } }, { id: "no-name", name: "" }, "not-an-object"]));
const { useDataCompareConfig } = await import("../useDataCompareConfig");
const state = useDataCompareConfig();
expect(state.configs.value).toHaveLength(1);
expect(state.configs.value[0]!.selectedSourceTables).toEqual(["t"]);
expect(state.configs.value[0]!.keyColumnsByTable).toEqual({ t: [] });
expect(state.configs.value[0]!.detailPreviewLimit).toBe("");
});
it("recovers from unreadable stored JSON", async () => {
localStorage.setItem(CONFIG_KEY, "{not json");
const { useDataCompareConfig } = await import("../useDataCompareConfig");
const state = useDataCompareConfig();
expect(state.configs.value).toEqual([]);
expect(state.activeConfig.value).toBeNull();
});
});
@@ -0,0 +1,211 @@
import { computed, ref, watch } from "vue";
import { uuid } from "@/lib/common/utils";
/**
* Persisted "Compare Data" task configuration.
*
* Only the user's selection is stored (endpoints + the tables to compare +
* per-table match columns). Derived data such as the available database /
* schema / table lists, compare results and sync plans are intentionally not
* persisted: they are re-loaded from the live connection when a config is
* applied, so a stale config can never resurrect a deleted table.
*/
export interface DataCompareConfig {
id: string;
name: string;
createdAt: number;
updatedAt: number;
sourceConnectionId: string;
sourceDatabase: string;
sourceSchema: string;
selectedSourceTables: string[];
targetConnectionId: string;
targetDatabase: string;
targetSchema: string;
targetTable: string;
/** Preview row limit, stored alongside the selection so re-running reproduces the same view. */
detailPreviewLimit: string;
/** Explicit match columns per source table; absent entry means "use the primary key". */
keyColumnsByTable: Record<string, string[]>;
}
export interface DataCompareConfigSnapshot {
sourceConnectionId: string;
sourceDatabase: string;
sourceSchema: string;
selectedSourceTables: string[];
targetConnectionId: string;
targetDatabase: string;
targetSchema: string;
targetTable: string;
detailPreviewLimit: string;
keyColumnsByTable: Record<string, string[]>;
}
const STORAGE_KEY = "dbx-data-compare-configs";
const MAX_CONFIGS = 50;
const configs = ref<DataCompareConfig[]>(loadConfigsFromStorage());
const activeConfigId = ref("");
function sanitizeStringArray(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.length > 0) : [];
}
function sanitizeKeyColumns(value: unknown): Record<string, string[]> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const result: Record<string, string[]> = {};
for (const [table, columns] of Object.entries(value as Record<string, unknown>)) {
if (typeof table !== "string" || !table) continue;
// Keep an explicitly empty array: it means "the user cleared the match
// columns" and must not silently fall back to the primary key.
if (Array.isArray(columns)) result[table] = sanitizeStringArray(columns);
}
return result;
}
function sanitizeConfig(raw: unknown): DataCompareConfig | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const item = raw as Record<string, unknown>;
const id = typeof item.id === "string" && item.id ? item.id : uuid();
const name = typeof item.name === "string" && item.name.trim() ? item.name : "";
if (!name) return undefined;
const createdAt = typeof item.createdAt === "number" ? item.createdAt : Date.now();
const updatedAt = typeof item.updatedAt === "number" ? item.updatedAt : createdAt;
return {
id,
name,
createdAt,
updatedAt,
sourceConnectionId: typeof item.sourceConnectionId === "string" ? item.sourceConnectionId : "",
sourceDatabase: typeof item.sourceDatabase === "string" ? item.sourceDatabase : "",
sourceSchema: typeof item.sourceSchema === "string" ? item.sourceSchema : "",
selectedSourceTables: sanitizeStringArray(item.selectedSourceTables),
targetConnectionId: typeof item.targetConnectionId === "string" ? item.targetConnectionId : "",
targetDatabase: typeof item.targetDatabase === "string" ? item.targetDatabase : "",
targetSchema: typeof item.targetSchema === "string" ? item.targetSchema : "",
targetTable: typeof item.targetTable === "string" ? item.targetTable : "",
detailPreviewLimit: typeof item.detailPreviewLimit === "string" ? item.detailPreviewLimit : "",
keyColumnsByTable: sanitizeKeyColumns(item.keyColumnsByTable),
};
}
function loadConfigsFromStorage(): DataCompareConfig[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.map(sanitizeConfig).filter((config): config is DataCompareConfig => Boolean(config));
} catch {
return [];
}
}
function saveConfigsToStorage() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(configs.value));
} catch {
// Quota/private-mode failures must never break the compare dialog; the
// in-memory configs keep working for the current session.
}
}
export function useDataCompareConfig() {
const activeConfig = computed(() => configs.value.find((config) => config.id === activeConfigId.value) ?? null);
function uniqueName(base: string, excludeId = ""): string {
const trimmed = base.trim() || "Config";
if (!configs.value.some((config) => config.name === trimmed && config.id !== excludeId)) return trimmed;
let counter = 1;
let candidate = `${trimmed} (${counter})`;
while (configs.value.some((config) => config.name === candidate && config.id !== excludeId)) {
counter++;
candidate = `${trimmed} (${counter})`;
}
return candidate;
}
function createConfig(name: string, snapshot: DataCompareConfigSnapshot): DataCompareConfig {
const now = Date.now();
const config: DataCompareConfig = {
...snapshot,
selectedSourceTables: [...snapshot.selectedSourceTables],
keyColumnsByTable: sanitizeKeyColumns(snapshot.keyColumnsByTable),
id: uuid(),
name: uniqueName(name),
createdAt: now,
updatedAt: now,
};
configs.value = [config, ...configs.value].slice(0, MAX_CONFIGS);
activeConfigId.value = config.id;
saveConfigsToStorage();
return config;
}
function updateConfig(id: string, patch: Partial<DataCompareConfigSnapshot>) {
const index = configs.value.findIndex((config) => config.id === id);
if (index === -1) return;
const next: DataCompareConfig = {
...configs.value[index]!,
...patch,
...(patch.selectedSourceTables ? { selectedSourceTables: [...patch.selectedSourceTables] } : {}),
...(patch.keyColumnsByTable ? { keyColumnsByTable: sanitizeKeyColumns(patch.keyColumnsByTable) } : {}),
updatedAt: Date.now(),
};
configs.value = configs.value.map((config, position) => (position === index ? next : config));
saveConfigsToStorage();
}
function renameConfig(id: string, name: string) {
const trimmed = name.trim();
if (!trimmed) return;
const index = configs.value.findIndex((config) => config.id === id);
if (index === -1) return;
configs.value = configs.value.map((config) => (config.id === id ? { ...config, name: uniqueName(trimmed, id), updatedAt: Date.now() } : config));
saveConfigsToStorage();
}
function deleteConfig(id: string) {
configs.value = configs.value.filter((config) => config.id !== id);
if (activeConfigId.value === id) activeConfigId.value = "";
saveConfigsToStorage();
}
function duplicateConfig(id: string): DataCompareConfig | undefined {
const source = configs.value.find((config) => config.id === id);
if (!source) return undefined;
const now = Date.now();
const copy: DataCompareConfig = {
...source,
id: uuid(),
name: uniqueName(source.name),
createdAt: now,
updatedAt: now,
selectedSourceTables: [...source.selectedSourceTables],
keyColumnsByTable: sanitizeKeyColumns(source.keyColumnsByTable),
};
configs.value = [copy, ...configs.value].slice(0, MAX_CONFIGS);
activeConfigId.value = copy.id;
saveConfigsToStorage();
return copy;
}
function clearActiveConfig() {
activeConfigId.value = "";
}
watch(configs, saveConfigsToStorage, { deep: true });
return {
configs: computed(() => configs.value),
activeConfigId,
activeConfig,
createConfig,
updateConfig,
renameConfig,
deleteConfig,
duplicateConfig,
clearActiveConfig,
};
}
+9
View File
@@ -6793,6 +6793,15 @@ export default withEnglishFallback({
noTables: "Mövcud cədvəl yoxdur",
},
dataCompare: {
configSelect: "Konfiqurasiya seçin və ya saxlayın",
configNew: "Yeni konfiqurasiya kimi saxla",
configRename: "Konfiqurasiyanın adını dəyiş",
configDuplicate: "Konfiqurasiyanı kopyala",
configDelete: "Konfiqurasiyanı sil",
configName: "Konfiqurasiya adı",
configTableCount: "{count} cədvəl",
defaultConfigName: "Müqayisə konfiqurasiyası",
configSaved: "Konfiqurasiya saxlanıldı",
title: "Verilənləri müqayisə et",
selectTable: "Cədvəl seç",
searchTable: "Cədvəlləri axtar...",
+9
View File
@@ -7656,6 +7656,15 @@ export default {
noTables: "No tables available",
},
dataCompare: {
configSelect: "Select or save a config",
configNew: "Save as new config",
configRename: "Rename config",
configDuplicate: "Duplicate config",
configDelete: "Delete config",
configName: "Config name",
configTableCount: "{count} tables",
defaultConfigName: "Compare config",
configSaved: "Config saved",
manualTransactionHint: "Changes wait for Commit or Rollback. An error rolls back the transaction.",
pendingTransaction: "Awaiting Commit or Rollback",
rollbackBeforeClose: "There are uncommitted changes. Roll back and close?",
+9
View File
@@ -7002,6 +7002,15 @@ export default withEnglishFallback({
noTables: "No hay tablas disponibles",
},
dataCompare: {
configSelect: "Selecciona o guarda una configuración",
configNew: "Guardar como configuración nueva",
configRename: "Cambiar nombre de la configuración",
configDuplicate: "Duplicar configuración",
configDelete: "Eliminar configuración",
configName: "Nombre de la configuración",
configTableCount: "{count} tablas",
defaultConfigName: "Configuración de comparación",
configSaved: "Configuración guardada",
manualTransactionHint: "Los cambios esperan a Confirmar o Revertir. Un error revierte la transacción.",
pendingTransaction: "Esperando confirmación o reversión",
rollbackBeforeClose: "Hay cambios sin confirmar. ¿Revertir y cerrar?",
+9
View File
@@ -7449,6 +7449,15 @@ export default withEnglishFallback({
noTables: "Tidak ada tabel yang tersedia",
},
dataCompare: {
configSelect: "Pilih atau simpan konfigurasi",
configNew: "Simpan sebagai konfigurasi baru",
configRename: "Ganti nama konfigurasi",
configDuplicate: "Duplikat konfigurasi",
configDelete: "Hapus konfigurasi",
configName: "Nama konfigurasi",
configTableCount: "{count} tabel",
defaultConfigName: "Konfigurasi perbandingan",
configSaved: "Konfigurasi disimpan",
manualTransactionHint: "Perubahan menunggu Commit atau Rollback. Galat akan me-rollback transaksi.",
pendingTransaction: "Menunggu Commit atau Rollback",
rollbackBeforeClose: "Ada perubahan yang belum di-commit. Rollback dan tutup?",
+9
View File
@@ -6780,6 +6780,15 @@ export default withEnglishFallback({
noTables: "Nessuna tabella disponibile",
},
dataCompare: {
configSelect: "Seleziona o salva una configurazione",
configNew: "Salva come nuova configurazione",
configRename: "Rinomina configurazione",
configDuplicate: "Duplica configurazione",
configDelete: "Elimina configurazione",
configName: "Nome configurazione",
configTableCount: "{count} tabelle",
defaultConfigName: "Configurazione confronto",
configSaved: "Configurazione salvata",
manualTransactionHint: "Le modifiche attendono Conferma o Annulla. Un errore annulla la transazione.",
pendingTransaction: "In attesa di conferma o annullamento",
rollbackBeforeClose: "Ci sono modifiche non confermate. Annullare e chiudere?",
+9
View File
@@ -6811,6 +6811,15 @@ export default withEnglishFallback({
noTables: "利用可能なテーブルがありません",
},
dataCompare: {
configSelect: "設定を選択または保存",
configNew: "新しい設定として保存",
configRename: "設定の名前を変更",
configDuplicate: "設定を複製",
configDelete: "設定を削除",
configName: "設定名",
configTableCount: "{count} テーブル",
defaultConfigName: "データ比較設定",
configSaved: "設定を保存しました",
manualTransactionHint: "変更はコミットまたはロールバックを待ちます。エラー時はトランザクションをロールバックします。",
pendingTransaction: "コミットまたはロールバック待ち",
rollbackBeforeClose: "未コミットの変更があります。ロールバックして閉じますか?",
+9
View File
@@ -6576,6 +6576,15 @@ export default withEnglishFallback({
},
},
dataCompare: {
configSelect: "구성 선택 또는 저장",
configNew: "새 구성으로 저장",
configRename: "구성 이름 변경",
configDuplicate: "구성 복제",
configDelete: "구성 삭제",
configName: "구성 이름",
configTableCount: "테이블 {count}개",
defaultConfigName: "데이터 비교 구성",
configSaved: "구성이 저장되었습니다",
manualTransactionHint: "변경 사항은 커밋 또는 롤백을 기다립니다. 오류가 발생하면 트랜잭션을 롤백합니다.",
pendingTransaction: "커밋 또는 롤백 대기 중",
rollbackBeforeClose: "커밋되지 않은 변경 사항이 있습니다. 롤백하고 닫을까요?",
+9
View File
@@ -6782,6 +6782,15 @@ export default withEnglishFallback({
noTables: "Nenhuma tabela disponível",
},
dataCompare: {
configSelect: "Selecione ou salve uma configuração",
configNew: "Salvar como nova configuração",
configRename: "Renomear configuração",
configDuplicate: "Duplicar configuração",
configDelete: "Excluir configuração",
configName: "Nome da configuração",
configTableCount: "{count} tabelas",
defaultConfigName: "Configuração de comparação",
configSaved: "Configuração salva",
manualTransactionHint: "As alterações aguardam confirmação ou reversão. Um erro reverte a transação.",
pendingTransaction: "Aguardando confirmação ou reversão",
rollbackBeforeClose: "Há alterações não confirmadas. Reverter e fechar?",
+9
View File
@@ -7986,6 +7986,15 @@ export default withEnglishFallback({
noTables: "Нет доступных таблиц",
},
dataCompare: {
configSelect: "Выберите или сохраните конфигурацию",
configNew: "Сохранить как новую конфигурацию",
configRename: "Переименовать конфигурацию",
configDuplicate: "Дублировать конфигурацию",
configDelete: "Удалить конфигурацию",
configName: "Имя конфигурации",
configTableCount: "Таблиц: {count}",
defaultConfigName: "Конфигурация сравнения",
configSaved: "Конфигурация сохранена",
manualTransactionHint: "Изменения ожидают фиксации или отката. При ошибке транзакция откатывается.",
pendingTransaction: "Ожидание фиксации или отката",
rollbackBeforeClose: "Есть незафиксированные изменения. Откатить и закрыть?",
+9
View File
@@ -6674,6 +6674,15 @@ export default withEnglishFallback({
noTables: "Kullanılabilir tablo yok",
},
dataCompare: {
configSelect: "Yapılandırma seçin veya kaydedin",
configNew: "Yeni yapılandırma olarak kaydet",
configRename: "Yapılandırmayı yeniden adlandır",
configDuplicate: "Yapılandırmayı çoğalt",
configDelete: "Yapılandırmayı sil",
configName: "Yapılandırma adı",
configTableCount: "{count} tablo",
defaultConfigName: "Veri karşılaştırma yapılandırması",
configSaved: "Yapılandırma kaydedildi",
title: "Verileri Karşılaştır",
selectTable: "Tablo seçin",
searchTable: "Tablolarda ara...",
+9
View File
@@ -7642,6 +7642,15 @@ export default withEnglishFallback({
noTables: "没有可用表",
},
dataCompare: {
configSelect: "选择或保存配置",
configNew: "保存为新配置",
configRename: "重命名配置",
configDuplicate: "复制配置",
configDelete: "删除配置",
configName: "配置名称",
configTableCount: "{count} 张表",
defaultConfigName: "数据比较配置",
configSaved: "配置已保存",
manualTransactionHint: "修改等待手动提交或回滚,出错时回滚事务。",
pendingTransaction: "等待提交或回滚",
rollbackBeforeClose: "尚有未提交的修改。回滚并关闭?",
+9
View File
@@ -6135,6 +6135,15 @@ export default withEnglishFallback({
noTables: "沒有可用的資料表",
},
dataCompare: {
configSelect: "選擇或儲存設定",
configNew: "另存為新設定",
configRename: "重新命名設定",
configDuplicate: "複製設定",
configDelete: "刪除設定",
configName: "設定名稱",
configTableCount: "{count} 張表",
defaultConfigName: "資料比對設定",
configSaved: "設定已儲存",
manualTransactionHint: "修改等待手動提交或回滾,發生錯誤時回滾交易。",
pendingTransaction: "等待提交或回滾",
rollbackBeforeClose: "尚有未提交的修改。回滾並關閉?",
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB