feat(connections): import Navicat NCX files

This commit is contained in:
t8y2 2026-05-10 21:51:48 +08:00
parent 6d513afc16
commit 9661c60812
6 changed files with 263 additions and 35 deletions

View File

@ -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() {
</TooltipTrigger>
<TooltipContent>{{ t("contextMenu.refreshChildren") }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="emit('import')">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="h-5 w-5" :title="t('sidebar.import')">
<Upload class="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("sidebar.import") }}</TooltipContent>
</Tooltip>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-44">
<DropdownMenuItem @select.prevent="emit('import', 'dbx')">
{{ t("sidebar.importDbx") }}
</DropdownMenuItem>
<DropdownMenuItem @select.prevent="emit('import', 'navicat')">
{{ t("sidebar.importNavicat") }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="emit('export')">

View File

@ -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;

View File

@ -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",

View File

@ -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: "导入分组",

186
src/lib/navicatImport.ts Normal file
View File

@ -0,0 +1,186 @@
import type { ConnectionConfig, DatabaseType } from "@/types/database";
import { uuid } from "@/lib/utils";
type PartialConnection = Omit<ConnectionConfig, "id">;
type ParsedNode = {
tag: string;
values: Record<string, string>;
};
const typeMap: Record<string, { dbType: DatabaseType; profile: string; label: string; port: number; user: string }> = {
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<string, string>, 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<string, string> = {};
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<string, string> = {};
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<ConnectionConfig | null> {
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<ConnectionConfig[]> {
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<string>();
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;
}

View File

@ -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<string>((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;