From 9661c608128242faf68f92efa7b2d08b376624ca Mon Sep 17 00:00:00 2001
From: t8y2 <1156263951@qq.com>
Date: Sun, 10 May 2026 21:51:48 +0800
Subject: [PATCH] feat(connections): import Navicat NCX files
---
src/components/layout/AppSidebar.vue | 27 +++-
src/composables/useDialogSources.ts | 13 +-
src/i18n/locales/en.ts | 4 +
src/i18n/locales/zh-CN.ts | 3 +
src/lib/navicatImport.ts | 186 +++++++++++++++++++++++++++
src/stores/connectionStore.ts | 65 ++++++----
6 files changed, 263 insertions(+), 35 deletions(-)
create mode 100644 src/lib/navicatImport.ts
diff --git a/src/components/layout/AppSidebar.vue b/src/components/layout/AppSidebar.vue
index 8e438a2a5..5a95c3f68 100644
--- a/src/components/layout/AppSidebar.vue
+++ b/src/components/layout/AppSidebar.vue
@@ -3,6 +3,12 @@ import { useI18n } from "vue-i18n";
import { Upload, Download, RefreshCw } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
@@ -13,7 +19,7 @@ defineProps<{
}>();
const emit = defineEmits<{
- import: [];
+ import: [source: "dbx" | "navicat"];
export: [];
startResize: [event: MouseEvent];
}>();
@@ -52,14 +58,21 @@ async function refreshTree() {
{{ t("contextMenu.refreshChildren") }}
-
-
-
- {{ t("sidebar.import") }}
-
+
+
+
+ {{ t("sidebar.importDbx") }}
+
+
+ {{ t("sidebar.importNavicat") }}
+
+
+
diff --git a/src/composables/useDialogSources.ts b/src/composables/useDialogSources.ts
index df98d4150..ee0e47948 100644
--- a/src/composables/useDialogSources.ts
+++ b/src/composables/useDialogSources.ts
@@ -202,9 +202,9 @@ export function useDialogSources() {
}
}
- async function onImportClick() {
+ async function onImportClick(source: "dbx" | "navicat" = "dbx") {
try {
- const result = await connectionStore.readImportFile();
+ const result = await connectionStore.readImportFile(source);
if (!result) return;
pendingImportContent.value = result.content;
if (result.encrypted) {
@@ -213,7 +213,14 @@ export function useDialogSources() {
showConfigPassphraseDialog.value = true;
} else {
const { count, layout } = await connectionStore.importConnectionsFromFile(result.content, null);
- toast(count > 0 ? t("configExport.importSuccess", { count }) : t("configExport.importNone"), 2000);
+ toast(
+ count > 0
+ ? source === "navicat"
+ ? t("configExport.importNavicatSuccess", { count })
+ : t("configExport.importSuccess", { count })
+ : t("configExport.importNone"),
+ 4000,
+ );
if (layout && count > 0) {
pendingImportLayout.value = layout;
showImportLayoutConfirm.value = true;
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 5ba3a73ab..de7c7d925 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -60,6 +60,8 @@ export default {
connections: "CONNECTIONS",
noConnections: "No connections yet",
import: "Import Connections",
+ importDbx: "Import DBX Config",
+ importNavicat: "Import Navicat NCX",
export: "Export Connections",
showMore: "Show {count} more...",
filterByType: "Filter by type",
@@ -424,6 +426,8 @@ export default {
wrongPassphrase: "Wrong passphrase or corrupted file",
exportSuccess: "Connections exported successfully",
importSuccess: "Imported {count} connection(s)",
+ importNavicatSuccess:
+ "Imported {count} Navicat 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 04addc25f..304f404e3 100644
--- a/src/i18n/locales/zh-CN.ts
+++ b/src/i18n/locales/zh-CN.ts
@@ -59,6 +59,8 @@ export default {
connections: "连接",
noConnections: "暂无连接",
import: "导入连接",
+ importDbx: "导入 DBX 配置",
+ importNavicat: "导入 Navicat NCX",
export: "导出连接",
showMore: "加载更多 ({count})...",
filterByType: "按类型筛选",
@@ -417,6 +419,7 @@ export default {
wrongPassphrase: "密码短语错误或文件已损坏",
exportSuccess: "连接配置导出成功",
importSuccess: "已导入 {count} 个连接",
+ importNavicatSuccess: "已导入 {count} 个 Navicat 连接,若个别连接密码为空请补填后测试连接",
importNone: "没有新的连接需要导入",
importLayoutConfirm: "导入文件包含连接分组信息,是否一并应用?",
importLayoutTitle: "导入分组",
diff --git a/src/lib/navicatImport.ts b/src/lib/navicatImport.ts
new file mode 100644
index 000000000..8cab1a771
--- /dev/null
+++ b/src/lib/navicatImport.ts
@@ -0,0 +1,186 @@
+import type { ConnectionConfig, DatabaseType } from "@/types/database";
+import { uuid } from "@/lib/utils";
+
+type PartialConnection = Omit;
+
+type ParsedNode = {
+ tag: string;
+ values: Record;
+};
+
+const typeMap: 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" },
+ redis: { dbType: "redis", profile: "redis", label: "Redis", port: 6379, user: "" },
+ mongodb: { dbType: "mongodb", profile: "mongodb", label: "MongoDB", port: 27017, user: "" },
+ mongo: { dbType: "mongodb", profile: "mongodb", label: "MongoDB", port: 27017, user: "" },
+};
+
+const unsupportedTypes = new Set(["http", "https", "ftp", "sftp", "ssh"]);
+
+function normalizeKey(value: string) {
+ return value.toLowerCase().replace(/[^a-z0-9]/g, "");
+}
+
+function getAny(values: Record, keys: string[]) {
+ for (const key of keys) {
+ const value = values[normalizeKey(key)];
+ if (value?.trim()) return value.trim();
+ }
+ return "";
+}
+
+function hexToBytes(hex: string) {
+ const clean = hex.trim();
+ if (!clean || clean.length % 2 !== 0 || /[^0-9a-f]/i.test(clean)) return null;
+ const bytes = new Uint8Array(clean.length / 2);
+ for (let i = 0; i < clean.length; i += 2) {
+ bytes[i / 2] = Number.parseInt(clean.slice(i, i + 2), 16);
+ }
+ return bytes;
+}
+
+function stripPkcs7(bytes: Uint8Array) {
+ const pad = bytes[bytes.length - 1];
+ if (!pad || pad > 16 || pad > bytes.length) return bytes;
+ for (let i = bytes.length - pad; i < bytes.length; i++) {
+ if (bytes[i] !== pad) return bytes;
+ }
+ return bytes.slice(0, bytes.length - pad);
+}
+
+async function decryptNavicatPassword(value: string) {
+ const encrypted = hexToBytes(value);
+ if (!encrypted?.length) return "";
+
+ const key = new TextEncoder().encode("libcckeylibcckey");
+ const iv = new TextEncoder().encode("libcciv libcciv ");
+ try {
+ const cryptoKey = await crypto.subtle.importKey("raw", key, { name: "AES-CBC" }, false, ["decrypt"]);
+ const decrypted = new Uint8Array(await crypto.subtle.decrypt({ name: "AES-CBC", iv }, cryptoKey, encrypted));
+ return new TextDecoder().decode(stripPkcs7(decrypted));
+ } catch {
+ return "";
+ }
+}
+
+function inferProfile(rawType: string, tag: string) {
+ const key = normalizeKey(rawType || tag);
+ for (const [needle, profile] of Object.entries(typeMap)) {
+ if (key.includes(needle)) return profile;
+ }
+ if (unsupportedTypes.has(key)) return null;
+ return typeMap.mysql;
+}
+
+function readNode(element: Element): ParsedNode {
+ const values: Record = {};
+ for (const attr of Array.from(element.attributes)) {
+ values[normalizeKey(attr.name)] = attr.value;
+ }
+
+ for (const child of Array.from(element.children)) {
+ const key = getAny(valuesFromElement(child), ["name", "key", "property", "field"]);
+ const value = getAny(valuesFromElement(child), ["value", "val", "text", "data"]) || child.textContent?.trim() || "";
+ if (key && value) values[normalizeKey(key)] = value;
+
+ const tag = normalizeKey(child.tagName);
+ const text = child.children.length === 0 ? child.textContent?.trim() || "" : "";
+ if (text && !values[tag]) values[tag] = text;
+ for (const attr of Array.from(child.attributes)) {
+ values[`${tag}${normalizeKey(attr.name)}`] = attr.value;
+ }
+ }
+
+ return { tag: element.tagName, values };
+}
+
+function valuesFromElement(element: Element) {
+ const values: Record = {};
+ for (const attr of Array.from(element.attributes)) {
+ values[normalizeKey(attr.name)] = attr.value;
+ }
+ return values;
+}
+
+function isConnectionCandidate(node: ParsedNode) {
+ const type = getAny(node.values, ["type", "connType", "connectionType", "databaseType", "driver"]);
+ const name = getAny(node.values, ["name", "connectionName", "connName", "caption", "title"]);
+ const host = getAny(node.values, ["host", "server", "hostname", "serverHost", "address"]);
+ const file = getAny(node.values, ["databaseFile", "filename", "path", "databasePath"]);
+ return !!(name || host || file) && !!(type || host || file);
+}
+
+async function parseConnection(node: ParsedNode): Promise {
+ const rawType = getAny(node.values, ["type", "connType", "connectionType", "databaseType", "driver", "dbType"]);
+ const profile = inferProfile(rawType, node.tag);
+ if (!profile) return null;
+
+ const name =
+ getAny(node.values, ["name", "connectionName", "connName", "caption", "title"]) ||
+ getAny(node.values, ["host", "server", "hostname"]) ||
+ profile.label;
+ const host =
+ getAny(node.values, ["host", "server", "hostname", "serverHost", "address"]) ||
+ getAny(node.values, ["databaseFile", "filename", "path", "databasePath"]) ||
+ (profile.dbType === "sqlite" ? "" : "127.0.0.1");
+ const portValue = Number(getAny(node.values, ["port", "serverPort"]));
+ const database = getAny(node.values, ["database", "databaseName", "initialDatabase", "serviceName", "sid", "schema"]);
+ const username = getAny(node.values, ["user", "username", "userName", "uid"]) || profile.user;
+ const password = await decryptNavicatPassword(getAny(node.values, ["password"]));
+
+ const config: PartialConnection = {
+ name,
+ db_type: profile.dbType,
+ driver_profile: profile.profile,
+ driver_label: profile.label,
+ url_params: "",
+ host,
+ port: Number.isFinite(portValue) && portValue > 0 ? portValue : profile.port,
+ username,
+ password,
+ database: database || undefined,
+ 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: undefined,
+ jdbc_driver_class: undefined,
+ jdbc_driver_paths: [],
+ };
+
+ return { ...config, id: uuid() };
+}
+
+export async function parseNavicatConnections(content: string): Promise {
+ const doc = new DOMParser().parseFromString(content, "application/xml");
+ const parserError = doc.querySelector("parsererror");
+ if (parserError) throw new Error("Invalid Navicat connection file");
+
+ const seen = new Set();
+ const configs: ConnectionConfig[] = [];
+ for (const element of Array.from(doc.querySelectorAll("*"))) {
+ const node = readNode(element);
+ if (!isConnectionCandidate(node)) continue;
+ const config = await parseConnection(node);
+ 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 fc6d9853a..bf87e5fd8 100644
--- a/src/stores/connectionStore.ts
+++ b/src/stores/connectionStore.ts
@@ -1144,14 +1144,19 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
- async function readImportFile(): Promise<{ content: string; encrypted: boolean } | null> {
+ async function readImportFile(
+ source: "dbx" | "navicat" = "dbx",
+ ): Promise<{ content: string; encrypted: boolean } | null> {
let content: string;
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
const { readTextFile } = await import("@tauri-apps/plugin-fs");
const path = await open({
- filters: [{ name: "JSON", extensions: ["json"] }],
+ filters:
+ source === "navicat"
+ ? [{ name: "Navicat Connection Export", extensions: ["ncx", "xml"] }]
+ : [{ name: "DBX JSON", extensions: ["json"] }],
multiple: false,
});
if (!path) return null;
@@ -1160,7 +1165,7 @@ export const useConnectionStore = defineStore("connection", () => {
content = await new Promise((resolve, reject) => {
const input = document.createElement("input");
input.type = "file";
- input.accept = ".json";
+ input.accept = source === "navicat" ? ".ncx,.xml" : ".json";
input.onchange = () => {
const file = input.files?.[0];
if (!file) {
@@ -1176,6 +1181,10 @@ export const useConnectionStore = defineStore("connection", () => {
});
}
+ if (content.trimStart().startsWith("<")) {
+ return { content, encrypted: false };
+ }
+
const { isEncryptedConfig } = await import("@/lib/configCrypto");
const parsed = JSON.parse(content);
return { content, encrypted: isEncryptedConfig(parsed) };
@@ -1187,33 +1196,39 @@ export const useConnectionStore = defineStore("connection", () => {
): Promise<{ count: number; layout?: SidebarLayout }> {
let imported: ConnectionConfig[];
let importedLayout: SidebarLayout | undefined;
- const parsed = JSON.parse(content);
- if (passphrase) {
- const { decryptConfig } = await import("@/lib/configCrypto");
- const json = await decryptConfig(parsed, passphrase);
- const decrypted = JSON.parse(json);
- if (Array.isArray(decrypted)) {
- imported = decrypted;
- } else if (decrypted.connections) {
- imported = decrypted.connections;
- if (decrypted.layout?.groups && decrypted.layout?.order) {
- importedLayout = decrypted.layout;
+ if (!passphrase && content.trimStart().startsWith("<")) {
+ const { parseNavicatConnections } = await import("@/lib/navicatImport");
+ imported = await parseNavicatConnections(content);
+ } else {
+ const parsed = JSON.parse(content);
+
+ if (passphrase) {
+ const { decryptConfig } = await import("@/lib/configCrypto");
+ const json = await decryptConfig(parsed, passphrase);
+ const decrypted = JSON.parse(json);
+ if (Array.isArray(decrypted)) {
+ imported = decrypted;
+ } else if (decrypted.connections) {
+ imported = decrypted.connections;
+ if (decrypted.layout?.groups && decrypted.layout?.order) {
+ importedLayout = decrypted.layout;
+ }
+ } 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 = [];
}
- } 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 = [];
}
let count = 0;