From c6b6ba5769cdaf73e61764523b27039546a9a21b Mon Sep 17 00:00:00 2001
From: t8y2 <1156263951@qq.com>
Date: Sun, 10 May 2026 22:10:30 +0800
Subject: [PATCH] feat(connections): import DBeaver connections
---
src/components/layout/AppSidebar.vue | 5 +-
src/composables/useDialogSources.ts | 6 +-
src/i18n/locales/en.ts | 3 +
src/i18n/locales/zh-CN.ts | 2 +
src/lib/dbeaverImport.ts | 270 +++++++++++++++++++++++++++
src/stores/connectionStore.ts | 109 +++++++++--
6 files changed, 377 insertions(+), 18 deletions(-)
create mode 100644 src/lib/dbeaverImport.ts
diff --git a/src/components/layout/AppSidebar.vue b/src/components/layout/AppSidebar.vue
index 5a95c3f686..8383f83754 100644
--- a/src/components/layout/AppSidebar.vue
+++ b/src/components/layout/AppSidebar.vue
@@ -19,7 +19,7 @@ defineProps<{
}>();
const emit = defineEmits<{
- import: [source: "dbx" | "navicat"];
+ import: [source: "dbx" | "navicat" | "dbeaver"];
export: [];
startResize: [event: MouseEvent];
}>();
@@ -71,6 +71,9 @@ async function refreshTree() {
{{ t("sidebar.importNavicat") }}
+
+ {{ t("sidebar.importDbeaver") }}
+
diff --git a/src/composables/useDialogSources.ts b/src/composables/useDialogSources.ts
index ee0e479483..b27bf447ed 100644
--- a/src/composables/useDialogSources.ts
+++ b/src/composables/useDialogSources.ts
@@ -202,7 +202,7 @@ export function useDialogSources() {
}
}
- async function onImportClick(source: "dbx" | "navicat" = "dbx") {
+ async function onImportClick(source: "dbx" | "navicat" | "dbeaver" = "dbx") {
try {
const result = await connectionStore.readImportFile(source);
if (!result) return;
@@ -217,7 +217,9 @@ export function useDialogSources() {
count > 0
? source === "navicat"
? t("configExport.importNavicatSuccess", { count })
- : t("configExport.importSuccess", { count })
+ : source === "dbeaver"
+ ? t("configExport.importDbeaverSuccess", { count })
+ : t("configExport.importSuccess", { count })
: t("configExport.importNone"),
4000,
);
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index de7c7d9251..7025625983 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -62,6 +62,7 @@ export default {
import: "Import Connections",
importDbx: "Import DBX Config",
importNavicat: "Import Navicat NCX",
+ importDbeaver: "Import DBeaver",
export: "Export Connections",
showMore: "Show {count} more...",
filterByType: "Filter by type",
@@ -428,6 +429,8 @@ export default {
importSuccess: "Imported {count} connection(s)",
importNavicatSuccess:
"Imported {count} Navicat connection(s). Fill in any connection whose password is still empty before testing.",
+ importDbeaverSuccess:
+ "Imported {count} DBeaver connection(s). Fill in any connection whose password is still empty before testing.",
importNone: "No new connections to import",
importLayoutConfirm: "The imported file contains connection groups. Apply them?",
importLayoutTitle: "Import Groups",
diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts
index 304f404e3b..7ca1c11bc6 100644
--- a/src/i18n/locales/zh-CN.ts
+++ b/src/i18n/locales/zh-CN.ts
@@ -61,6 +61,7 @@ export default {
import: "导入连接",
importDbx: "导入 DBX 配置",
importNavicat: "导入 Navicat NCX",
+ importDbeaver: "导入 DBeaver",
export: "导出连接",
showMore: "加载更多 ({count})...",
filterByType: "按类型筛选",
@@ -420,6 +421,7 @@ export default {
exportSuccess: "连接配置导出成功",
importSuccess: "已导入 {count} 个连接",
importNavicatSuccess: "已导入 {count} 个 Navicat 连接,若个别连接密码为空请补填后测试连接",
+ importDbeaverSuccess: "已导入 {count} 个 DBeaver 连接,若个别连接密码为空请补填后测试连接",
importNone: "没有新的连接需要导入",
importLayoutConfirm: "导入文件包含连接分组信息,是否一并应用?",
importLayoutTitle: "导入分组",
diff --git a/src/lib/dbeaverImport.ts b/src/lib/dbeaverImport.ts
new file mode 100644
index 0000000000..dd9d630611
--- /dev/null
+++ b/src/lib/dbeaverImport.ts
@@ -0,0 +1,270 @@
+import type { ConnectionConfig, DatabaseType } from "@/types/database";
+import { uuid } from "@/lib/utils";
+
+type PartialConnection = Omit;
+
+type DbeaverImportPayload = {
+ format: "dbeaver-import";
+ dataSources: string;
+ credentialsBase64?: string;
+};
+
+type DbeaverConnectionEntry = {
+ id: string;
+ name?: string;
+ provider?: string;
+ driver?: string;
+ configuration?: Record;
+ [key: string]: any;
+};
+
+type ConnectionProfile = {
+ dbType: DatabaseType;
+ profile: string;
+ label: string;
+ port: number;
+ user: string;
+};
+
+const dbeaverKey = new Uint8Array([186, 187, 74, 159, 119, 74, 184, 83, 201, 108, 45, 101, 61, 254, 84, 74]);
+
+const profileMap: Record = {
+ mysql: { dbType: "mysql", profile: "mysql", label: "MySQL", port: 3306, user: "root" },
+ mariadb: { dbType: "mysql", profile: "mariadb", label: "MariaDB", port: 3306, user: "root" },
+ postgresql: { dbType: "postgres", profile: "postgres", label: "PostgreSQL", port: 5432, user: "postgres" },
+ postgres: { dbType: "postgres", profile: "postgres", label: "PostgreSQL", port: 5432, user: "postgres" },
+ sqlite: { dbType: "sqlite", profile: "sqlite", label: "SQLite", port: 0, user: "" },
+ sqlserver: { dbType: "sqlserver", profile: "sqlserver", label: "SQL Server", port: 1433, user: "sa" },
+ mssql: { dbType: "sqlserver", profile: "sqlserver", label: "SQL Server", port: 1433, user: "sa" },
+ oracle: { dbType: "oracle", profile: "oracle", label: "Oracle", port: 1521, user: "system" },
+ clickhouse: { dbType: "clickhouse", profile: "clickhouse", label: "ClickHouse", port: 8123, user: "default" },
+ duckdb: { dbType: "duckdb", profile: "duckdb", label: "DuckDB", port: 0, user: "" },
+ mongodb: { dbType: "mongodb", profile: "mongodb", label: "MongoDB", port: 27017, user: "" },
+ mongo: { dbType: "mongodb", profile: "mongodb", label: "MongoDB", port: 27017, user: "" },
+ redshift: { dbType: "redshift", profile: "redshift", label: "Redshift", port: 5439, user: "awsuser" },
+ elasticsearch: { dbType: "elasticsearch", profile: "elasticsearch", label: "Elasticsearch", port: 9200, user: "" },
+ doris: { dbType: "doris", profile: "doris", label: "Doris", port: 9030, user: "root" },
+ starrocks: { dbType: "starrocks", profile: "starrocks", label: "StarRocks", port: 9030, user: "root" },
+ dameng: { dbType: "dameng", profile: "dm", label: "DM (Dameng)", port: 5236, user: "SYSDBA" },
+ dm: { dbType: "dameng", profile: "dm", label: "DM (Dameng)", port: 5236, user: "SYSDBA" },
+ gaussdb: { dbType: "gaussdb", profile: "gaussdb", label: "GaussDB", port: 5432, user: "gaussdb" },
+ opengauss: { dbType: "gaussdb", profile: "opengauss", label: "openGauss", port: 5432, user: "gaussdb" },
+};
+
+function normalizeKey(value: unknown) {
+ return String(value || "")
+ .toLowerCase()
+ .replace(/[^a-z0-9]/g, "");
+}
+
+function getString(value: unknown) {
+ return typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim();
+}
+
+function getNumber(value: unknown) {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
+}
+
+function inferProfile(entry: DbeaverConnectionEntry): ConnectionProfile {
+ const candidates = [entry.provider, entry.driver, entry.configuration?.url, entry.name].map(normalizeKey).join(" ");
+ for (const [needle, profile] of Object.entries(profileMap)) {
+ if (candidates.includes(needle)) return profile;
+ }
+ return { dbType: "jdbc", profile: "jdbc", label: getString(entry.driver) || "JDBC", port: 0, user: "" };
+}
+
+function base64ToBytes(value: string) {
+ const binary = atob(value);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ return bytes;
+}
+
+async function decryptCredentialsFile(
+ base64?: string,
+): Promise>>> {
+ if (!base64) return {};
+ const bytes = base64ToBytes(base64);
+ if (bytes.length <= 16) return {};
+
+ try {
+ const iv = bytes.slice(0, 16);
+ const encrypted = bytes.slice(16);
+ const cryptoKey = await crypto.subtle.importKey("raw", dbeaverKey, { name: "AES-CBC" }, false, ["decrypt"]);
+ const decrypted = await crypto.subtle.decrypt({ name: "AES-CBC", iv }, cryptoKey, encrypted);
+ return JSON.parse(new TextDecoder().decode(decrypted));
+ } catch {
+ return {};
+ }
+}
+
+function readCredentials(
+ entry: DbeaverConnectionEntry,
+ credentials: Record>>,
+) {
+ const secure = credentials[entry.id]?.["#connection"] || {};
+ const inline = entry.configuration?.credentials || {};
+ return {
+ username: getString(secure.user || secure.username || inline.user || inline.username || entry.configuration?.user),
+ password: getString(secure.password || inline.password || entry.configuration?.password),
+ };
+}
+
+function parseJdbcUrl(url: string, profile: ConnectionProfile) {
+ const result: {
+ host?: string;
+ port?: number;
+ database?: string;
+ params?: string;
+ username?: string;
+ password?: string;
+ } = {};
+ const source = url.trim();
+ if (!source) return result;
+
+ const withoutJdbc = source.replace(/^jdbc:/i, "");
+ const sqlServerMatch = withoutJdbc.match(/^sqlserver:\/\/([^;:/]+)(?::(\d+))?(?:;(.*))?/i);
+ if (sqlServerMatch) {
+ result.host = sqlServerMatch[1];
+ result.port = getNumber(sqlServerMatch[2]);
+ for (const part of (sqlServerMatch[3] || "").split(";")) {
+ const [key, ...rest] = part.split("=");
+ const value = rest.join("=");
+ if (/^(databasename|database)$/i.test(key)) result.database = value;
+ else if (/^user$/i.test(key)) result.username = value;
+ else if (/^password$/i.test(key)) result.password = value;
+ }
+ return result;
+ }
+
+ const oracleServiceMatch = withoutJdbc.match(/^oracle:thin:@\/\/([^:/]+)(?::(\d+))?\/([^?]+)/i);
+ if (oracleServiceMatch) {
+ result.host = oracleServiceMatch[1];
+ result.port = getNumber(oracleServiceMatch[2]);
+ result.database = oracleServiceMatch[3];
+ return result;
+ }
+
+ const oracleSidMatch = withoutJdbc.match(/^oracle:thin:@([^:/]+)(?::(\d+))?:([^?]+)/i);
+ if (oracleSidMatch) {
+ result.host = oracleSidMatch[1];
+ result.port = getNumber(oracleSidMatch[2]);
+ result.database = oracleSidMatch[3];
+ return result;
+ }
+
+ const sqliteMatch = withoutJdbc.match(/^sqlite:(.+)$/i);
+ if (sqliteMatch) {
+ result.host = sqliteMatch[1];
+ result.database = sqliteMatch[1];
+ return result;
+ }
+
+ try {
+ const parsed = new URL(withoutJdbc);
+ result.host = parsed.hostname;
+ result.port = parsed.port ? Number(parsed.port) : profile.port;
+ result.database = parsed.pathname.replace(/^\/+/, "").split("/")[0] || undefined;
+ result.params = parsed.search.replace(/^\?/, "");
+ result.username = decodeURIComponent(parsed.username || "");
+ result.password = decodeURIComponent(parsed.password || "");
+ } catch {
+ return result;
+ }
+
+ return result;
+}
+
+function extractConnections(parsed: any): DbeaverConnectionEntry[] {
+ const source = parsed?.connections || parsed?.dataSources || parsed?.datasources || parsed;
+ if (!source || typeof source !== "object") return [];
+
+ if (Array.isArray(source)) {
+ return source
+ .filter((entry) => entry && typeof entry === "object")
+ .map((entry) => ({ ...entry, id: getString(entry.id || entry.uuid || entry.name) }));
+ }
+
+ return Object.entries(source)
+ .filter(([, entry]) => entry && typeof entry === "object")
+ .map(([id, entry]) => ({ ...(entry as Record), id: getString((entry as any).id || id) }));
+}
+
+function buildConnection(
+ entry: DbeaverConnectionEntry,
+ credentials: ReturnType,
+): ConnectionConfig | null {
+ const profile = inferProfile(entry);
+ const config = entry.configuration || {};
+ const url = getString(config.url);
+ const parsedUrl = parseJdbcUrl(url, profile);
+ const host = getString(
+ config.host || config["host-name"] || parsedUrl.host || (profile.dbType === "sqlite" ? "" : "127.0.0.1"),
+ );
+ const database = getString(config.database || config["database-name"] || config.schema || parsedUrl.database);
+ const name = getString(entry.name || database || host || profile.label);
+ if (!entry.id || !name) return null;
+
+ const partial: PartialConnection = {
+ name,
+ db_type: profile.dbType,
+ driver_profile: profile.profile,
+ driver_label: profile.label,
+ url_params: getString(parsedUrl.params),
+ host,
+ port: getNumber(config.port || config["host-port"] || parsedUrl.port) || profile.port,
+ username: credentials.username || getString(parsedUrl.username) || profile.user,
+ password: credentials.password || getString(parsedUrl.password),
+ database: database || undefined,
+ color: getString(config.color || config["connection-color"]),
+ ssh_enabled: false,
+ ssh_host: "",
+ ssh_port: 22,
+ ssh_user: "",
+ ssh_password: "",
+ ssh_key_path: "",
+ ssh_key_passphrase: "",
+ ssh_expose_lan: false,
+ ssh_connect_timeout_secs: 5,
+ ssl: false,
+ connection_string: profile.dbType === "jdbc" || profile.dbType === "mongodb" ? url || undefined : undefined,
+ jdbc_driver_class:
+ profile.dbType === "jdbc" ? getString(config["driver-class"] || entry.driver) || undefined : undefined,
+ jdbc_driver_paths: [],
+ };
+
+ return { ...partial, id: uuid() };
+}
+
+export function isDbeaverImportPayload(content: string) {
+ try {
+ const parsed = JSON.parse(content);
+ return parsed?.format === "dbeaver-import";
+ } catch {
+ return false;
+ }
+}
+
+export async function parseDbeaverConnections(content: string): Promise {
+ const payload = JSON.parse(content) as DbeaverImportPayload;
+ if (payload.format !== "dbeaver-import" || !payload.dataSources) {
+ throw new Error("Invalid DBeaver import payload");
+ }
+
+ const dataSources = JSON.parse(payload.dataSources);
+ const encryptedCredentials = await decryptCredentialsFile(payload.credentialsBase64);
+ const configs: ConnectionConfig[] = [];
+ const seen = new Set();
+
+ for (const entry of extractConnections(dataSources)) {
+ const config = buildConnection(entry, readCredentials(entry, encryptedCredentials));
+ if (!config) continue;
+ const key = [config.name, config.db_type, config.host, config.port, config.database || ""].join("\u0000");
+ if (seen.has(key)) continue;
+ seen.add(key);
+ configs.push(config);
+ }
+
+ return configs;
+}
diff --git a/src/stores/connectionStore.ts b/src/stores/connectionStore.ts
index bf87e5fd8b..5d10db6b5f 100644
--- a/src/stores/connectionStore.ts
+++ b/src/stores/connectionStore.ts
@@ -27,6 +27,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes";
const SIDEBAR_TABLE_NODE_LIMIT = 15;
+type ImportSource = "dbx" | "navicat" | "dbeaver";
interface LoadTreeOptions {
force?: boolean;
@@ -1144,9 +1145,78 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
- async function readImportFile(
- source: "dbx" | "navicat" = "dbx",
- ): Promise<{ content: string; encrypted: boolean } | null> {
+ function bytesToBase64(bytes: Uint8Array) {
+ let binary = "";
+ const chunkSize = 0x8000;
+ for (let i = 0; i < bytes.length; i += chunkSize) {
+ binary += String.fromCharCode(...bytes.slice(i, i + chunkSize));
+ }
+ return btoa(binary);
+ }
+
+ function siblingCredentialsPath(path: string) {
+ const fileName = path.split(/[\\/]/).pop() || "";
+ const credentialsFile = fileName.startsWith("data-sources-")
+ ? fileName.replace(/^data-sources/, "credentials-config")
+ : "credentials-config.json";
+ return path.replace(/[^\\/]+$/, credentialsFile);
+ }
+
+ async function readDbeaverImportFile(): Promise<{ content: string; encrypted: boolean } | null> {
+ let dataSources: string;
+ let credentialsBase64 = "";
+
+ if (isTauriRuntime()) {
+ const { open } = await import("@tauri-apps/plugin-dialog");
+ const { readTextFile, readFile } = await import("@tauri-apps/plugin-fs");
+ const path = await open({
+ filters: [{ name: "DBeaver Data Sources", extensions: ["json"] }],
+ multiple: false,
+ });
+ if (!path) return null;
+ const dataSourcesPath = path as string;
+ dataSources = await readTextFile(dataSourcesPath);
+ try {
+ credentialsBase64 = bytesToBase64(await readFile(siblingCredentialsPath(dataSourcesPath)));
+ } catch {
+ credentialsBase64 = "";
+ }
+ } else {
+ const files = await new Promise((resolve, reject) => {
+ const input = document.createElement("input");
+ input.type = "file";
+ input.accept = ".json";
+ input.multiple = true;
+ input.onchange = () => {
+ if (!input.files?.length) {
+ reject(new Error("No file selected"));
+ return;
+ }
+ resolve(input.files);
+ };
+ input.click();
+ });
+ const fileList = Array.from(files);
+ const dataSourcesFile =
+ fileList.find((file) => /^data-sources.*\.json$/i.test(file.name)) ||
+ fileList.find((file) => !/^credentials-config.*\.json$/i.test(file.name));
+ const credentialsFile = fileList.find((file) => /^credentials-config.*\.json$/i.test(file.name));
+ if (!dataSourcesFile) throw new Error("Select DBeaver data-sources.json");
+ dataSources = await dataSourcesFile.text();
+ if (credentialsFile) {
+ credentialsBase64 = bytesToBase64(new Uint8Array(await credentialsFile.arrayBuffer()));
+ }
+ }
+
+ return {
+ content: JSON.stringify({ format: "dbeaver-import", dataSources, credentialsBase64 }),
+ encrypted: false,
+ };
+ }
+
+ async function readImportFile(source: ImportSource = "dbx"): Promise<{ content: string; encrypted: boolean } | null> {
+ if (source === "dbeaver") return readDbeaverImportFile();
+
let content: string;
if (isTauriRuntime()) {
@@ -1194,12 +1264,32 @@ export const useConnectionStore = defineStore("connection", () => {
content: string,
passphrase: string | null,
): Promise<{ count: number; layout?: SidebarLayout }> {
- let imported: ConnectionConfig[];
+ let imported: ConnectionConfig[] = [];
let importedLayout: SidebarLayout | undefined;
if (!passphrase && content.trimStart().startsWith("<")) {
const { parseNavicatConnections } = await import("@/lib/navicatImport");
imported = await parseNavicatConnections(content);
+ } else if (!passphrase) {
+ const { isDbeaverImportPayload, parseDbeaverConnections } = await import("@/lib/dbeaverImport");
+ if (isDbeaverImportPayload(content)) {
+ imported = await parseDbeaverConnections(content);
+ } else {
+ const parsed = JSON.parse(content);
+
+ if (Array.isArray(parsed)) {
+ imported = parsed;
+ } else if (parsed.format === "dbx-config" && Array.isArray(parsed.connections)) {
+ imported = parsed.connections;
+ } else if (parsed.connections && Array.isArray(parsed.connections)) {
+ imported = parsed.connections;
+ if (parsed.layout?.groups && parsed.layout?.order) {
+ importedLayout = parsed.layout;
+ }
+ } else {
+ imported = [];
+ }
+ }
} else {
const parsed = JSON.parse(content);
@@ -1217,17 +1307,6 @@ export const useConnectionStore = defineStore("connection", () => {
} else {
imported = [];
}
- } else if (Array.isArray(parsed)) {
- imported = parsed;
- } else if (parsed.format === "dbx-config" && Array.isArray(parsed.connections)) {
- imported = parsed.connections;
- } else if (parsed.connections && Array.isArray(parsed.connections)) {
- imported = parsed.connections;
- if (parsed.layout?.groups && parsed.layout?.order) {
- importedLayout = parsed.layout;
- }
- } else {
- imported = [];
}
}