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 = []; } }