fix(import): preserve DataGrip groups and Kingbase connections

This commit is contained in:
Maxon Phong 2026-07-31 16:24:29 +08:00 committed by GitHub
parent 71090d56c0
commit 7b95f0ca17
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 208 additions and 19 deletions

View File

@ -1680,6 +1680,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.",
importDatagripSuccess: "Imported {count} DataGrip connection(s), filled {filled} password(s) from macOS Keychain.",
importDatagripSelectFiles: "Please select dataSources.xml (required), and optionally dataSources.local.xml / db-forest-config.xml",
importDatagripDialogTitle: "Select DataGrip configuration files",
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?",

View File

@ -1627,6 +1627,8 @@ export default withEnglishFallback({
importLayoutConfirm: "El archivo importado contiene grupos de conexiones. ¿Deseas aplicarlos?",
importLayoutTitle: "Importar grupos",
importLayoutApply: "Aplicar",
importDatagripSelectFiles: "Seleccione dataSources.xml (obligatorio); también puede seleccionar dataSources.local.xml y db-forest-config.xml.",
importDatagripDialogTitle: "Seleccionar archivos de configuración de DataGrip",
},
ai: {
placeholder: "Describe tu consulta en lenguaje natural...",

View File

@ -1625,6 +1625,8 @@ export default withEnglishFallback({
importLayoutConfirm: "Il file importato contiene gruppi di connessioni. Vuoi applicarli?",
importLayoutTitle: "Importa Gruppi",
importLayoutApply: "Applica",
importDatagripSelectFiles: "Seleziona dataSources.xml (obbligatorio), puoi selezionare anche dataSources.local.xml e db-forest-config.xml",
importDatagripDialogTitle: "Seleziona i file di configurazione DataGrip",
},
ai: {
placeholder: "Descrivi la tua query in linguaggio naturale...",

View File

@ -1652,6 +1652,8 @@ export default withEnglishFallback({
importLayoutConfirm: "インポートされたファイルに接続グループが含まれています。適用しますか?",
importLayoutTitle: "グループをインポート",
importLayoutApply: "適用",
importDatagripSelectFiles: "dataSources.xml必須を選択してください。dataSources.local.xml と db-forest-config.xml も同時に選択できます。",
importDatagripDialogTitle: "DataGrip 設定ファイルを選択",
},
ai: {
placeholder: "自然言語でクエリを説明してください...",

View File

@ -1627,6 +1627,8 @@ export default withEnglishFallback({
importLayoutConfirm: "O arquivo importado contém grupos de conexões. Aplicá-los?",
importLayoutTitle: "Importar Grupos",
importLayoutApply: "Aplicar",
importDatagripSelectFiles: "Selecione dataSources.xml (obrigatório), você também pode selecionar dataSources.local.xml e db-forest-config.xml",
importDatagripDialogTitle: "Selecione o arquivo de configuração do DataGrip",
},
ai: {
placeholder: "Descreva sua consulta em linguagem natural...",

View File

@ -1681,6 +1681,8 @@ export default withEnglishFallback({
importSuccess: "已导入 {count} 个连接",
importNavicatSuccess: "已导入 {count} 个 Navicat 连接,若个别连接密码为空请补填后测试连接",
importDatagripSuccess: "已导入 {count} 个 DataGrip 连接,已自动读取 {filled} 个密码(来自 macOS 钥匙串)",
importDatagripSelectFiles: "请选择 dataSources.xml必需可同时选择 dataSources.local.xml 和 db-forest-config.xml",
importDatagripDialogTitle: "选择 DataGrip 配置文件",
importDbeaverSuccess: "已导入 {count} 个 DBeaver 连接,若个别连接密码为空请补填后测试连接",
importNone: "没有新的连接需要导入",
importLayoutConfirm: "导入文件包含连接分组信息,是否一并应用?",

View File

@ -1621,6 +1621,8 @@ export default withEnglishFallback({
importSuccess: "已匯入 {count} 個連線",
importNavicatSuccess: "已匯入 {count} 個 Navicat 連線,若個別連線密碼為空請補填後測試連線",
importDatagripSuccess: "已匯入 {count} 個 DataGrip 連線,已自動讀取 {filled} 個密碼(來自 macOS 鑰匙圈)",
importDatagripSelectFiles: "請選擇 dataSources.xml必需可同時選擇 dataSources.local.xml 和 db-forest-config.xml",
importDatagripDialogTitle: "選擇 DataGrip 設定檔",
importDbeaverSuccess: "已匯入 {count} 個 DBeaver 連線,若個別連線密碼為空請補填後測試連線",
importNone: "沒有新的連線需要匯入",
importLayoutConfirm: "匯入檔案包含連線群組資訊,是否一併套用?",

View File

@ -2,7 +2,7 @@
import { describe, expect, it } from "vitest";
import type { SidebarLayout, SidebarOrderEntry } from "@/types/database";
import { parseDataGripConnections, parseDataGripImport, type DataGripImportPayload } from "@/lib/imports/datagripImport";
import { matchDataGripImportFiles, parseDataGripConnections, parseDataGripImport, type DataGripImportPayload } from "@/lib/imports/datagripImport";
function payload(dataSources: string, dataSourcesLocal?: string, dbForestConfig?: string): DataGripImportPayload {
return { format: "datagrip-import", dataSources, dataSourcesLocal, dbForestConfig };
@ -65,6 +65,59 @@ describe("DataGrip connection import", () => {
});
});
it("imports Kingbase data sources configured by URL without a driver-ref", () => {
// DataGrip has no built-in Kingbase driver, so every Kingbase connection is
// a custom driver. Real exports use <configured-by-url>true</configured-by-url>
// with NO <driver-ref> element — the driver identity lives only in
// <jdbc-driver> + <jdbc-url>. Such connections must still import.
const connections = parseDataGripConnections(
payload(`
<project>
<component name="DataSourceManagerImpl">
<data-source name="Kingbase Dev" uuid="kingbase-url">
<configured-by-url>true</configured-by-url>
<jdbc-driver>com.kingbase8.Driver</jdbc-driver>
<jdbc-url>jdbc:kingbase8://192.0.2.1:54321/app</jdbc-url>
</data-source>
</component>
</project>
`),
);
expect(connections).toHaveLength(1);
expect(connections[0]).toMatchObject({
name: "Kingbase Dev",
db_type: "kingbase",
driver_profile: "kingbase",
driver_label: "KingbaseES",
host: "192.0.2.1",
port: 54321,
database: "app",
username: "SYSTEM",
});
});
it("drops unknown custom drivers configured by URL", () => {
// No <driver-ref> + an unrecognised driver class/subprotocol must NOT leak
// in as a generic JDBC connection. It stays dropped. This guards the
// mergeFragments "" sentinel contract (see parseDataGripImport guard).
const connections = parseDataGripConnections(
payload(`
<project>
<component name="DataSourceManagerImpl">
<data-source name="Mystery DB" uuid="mystery-1">
<configured-by-url>true</configured-by-url>
<jdbc-driver>com.example.mystery.Driver</jdbc-driver>
<jdbc-url>jdbc:mystery://10.0.0.1:9999/db</jdbc-url>
</data-source>
</component>
</project>
`),
);
expect(connections).toHaveLength(0);
});
it("preserves DataGrip connection groups as sidebar groups", () => {
const result = parseDataGripImport(
payload(
@ -136,6 +189,34 @@ describe("DataGrip connection import", () => {
expect(layoutLabels(result.layout!, names)).toEqual([{ group: "Legacy Group", children: ["Legacy"] }]);
});
it("preserves the modern DataGrip group attribute", () => {
// Real DataGrip exports use a `group` attribute on <data-source> (not the
// legacy `group-name`). Connections sharing a group land under one folder.
const result = parseDataGripImport(
payload(`
<project>
<component name="DataSourceManagerImpl">
<data-source name="Prod" uuid="mysql-prod" group="production">
<driver-ref>mysql</driver-ref>
<jdbc-url>jdbc:mysql://prod.example.com:3306/app</jdbc-url>
</data-source>
<data-source name="Dev" uuid="mysql-dev" group="development">
<driver-ref>mysql</driver-ref>
<jdbc-url>jdbc:mysql://dev.example.com:3306/app</jdbc-url>
</data-source>
<data-source name="Lonely" uuid="mysql-solo">
<driver-ref>mysql</driver-ref>
<jdbc-url>jdbc:mysql://localhost:3306/app</jdbc-url>
</data-source>
</component>
</project>
`),
);
const names = new Map(result.connections.map((connection) => [connection.id, connection.name]));
expect(layoutLabels(result.layout!, names)).toEqual([{ group: "production", children: ["Prod"] }, { group: "development", children: ["Dev"] }, "Lonely"]);
});
it("keeps the connection-only API and empty layout behavior", () => {
const importPayload = payload(`
<project>
@ -152,3 +233,45 @@ describe("DataGrip connection import", () => {
expect(parseDataGripImport(importPayload).layout).toBeUndefined();
});
});
describe("matchDataGripImportFiles", () => {
it("picks the three DataGrip config files by name regardless of order", () => {
const paths = ["C:/proj/.idea/db-forest-config.xml", "C:/proj/.idea/dataSources.local.xml", "C:/proj/.idea/dataSources.xml"];
expect(matchDataGripImportFiles(paths)).toEqual({
dataSources: "C:/proj/.idea/dataSources.xml",
local: "C:/proj/.idea/dataSources.local.xml",
forest: "C:/proj/.idea/db-forest-config.xml",
});
});
it("allows missing optional local and forest files", () => {
expect(matchDataGripImportFiles(["C:/proj/.idea/dataSources.xml"])).toEqual({
dataSources: "C:/proj/.idea/dataSources.xml",
local: undefined,
forest: undefined,
});
});
it("throws a coded error when dataSources.xml is not among the selected files", () => {
let caught: unknown;
try {
matchDataGripImportFiles(["C:/proj/other.xml", "C:/proj/dataSources.local.xml"]);
} catch (error) {
caught = error;
}
expect((caught as Error).message).toMatch(/dataSources\.xml/i);
expect((caught as Error & { code?: string }).code).toBe("DATAGRIP_IMPORT_MISSING_DATASOURCES");
});
it("matches file names case-insensitively", () => {
const result = matchDataGripImportFiles(["C:/proj/.idea/DataSources.XML", "C:/proj/.idea/datasources.local.xml"]);
expect(result.dataSources).toBe("C:/proj/.idea/DataSources.XML");
expect(result.local).toBe("C:/proj/.idea/datasources.local.xml");
});
it("handles Windows backslash paths", () => {
const result = matchDataGripImportFiles(["C:\\proj\\.idea\\dataSources.xml", "C:\\proj\\.idea\\dataSources.local.xml"]);
expect(result.dataSources).toBe("C:\\proj\\.idea\\dataSources.xml");
expect(result.local).toBe("C:\\proj\\.idea\\dataSources.local.xml");
});
});

View File

@ -300,7 +300,9 @@ function parseDataSourcesXml(xml: string): Map<string, Partial<DataSourceFragmen
const userName = getText(element, "user-name");
if (userName) fragment.username = userName;
const groupName = element.getAttribute("group-name") || undefined;
// DataGrip stores the folder grouping as a `group` attribute on each
// <data-source>. `group-name` is kept as a legacy fallback for older exports.
const groupName = element.getAttribute("group") || element.getAttribute("group-name") || undefined;
if (groupName) fragment.groupName = groupName;
result.set(uuidVal, fragment);
@ -419,11 +421,11 @@ function mergeFragments(shared: Map<string, Partial<DataSourceFragment>>, local:
// Resolve and filter
const resolved: DataSourceFragment[] = [];
for (const frag of merged.values()) {
if (!frag.uuid || !frag.driverRef || !frag.jdbcUrl) continue;
if (!frag.uuid || !frag.jdbcUrl) continue;
resolved.push({
uuid: frag.uuid,
name: frag.name || frag.uuid,
driverRef: frag.driverRef,
driverRef: frag.driverRef || "",
jdbcUrl: frag.jdbcUrl,
driverClass: frag.driverClass || "",
username: frag.username || "",
@ -494,6 +496,13 @@ export function parseDataGripImport(payload: DataGripImportPayload): DataGripImp
for (const fragment of fragments) {
const config = buildConnection(fragment);
// Custom-driver sources without a <driver-ref> are kept only when a concrete
// database type is recognised. DataGrip ships no built-in Kingbase driver, so
// every Kingbase connection is custom (configured by URL, no driver-ref).
// An unrecognised custom driver falls back to "jdbc" and is dropped here to
// avoid importing unusable half-baked JDBC connections. `fragment.driverRef
// === ""` is the sentinel mergeFragments sets when <driver-ref> was absent.
if (fragment.driverRef === "" && config.db_type === "jdbc") continue;
const key = [config.name, config.db_type, config.host, config.port, config.database || ""].join("\u0000");
if (seen.has(key)) continue;
seen.add(key);
@ -516,6 +525,34 @@ export function parseDataGripConnections(payload: DataGripImportPayload): Connec
return parseDataGripImport(payload).connections;
}
/**
* Pick DataGrip config file paths by name from a dialog multi-select list.
* `dataSources.xml` is required; `dataSources.local.xml` (usernames) and
* `db-forest-config.xml` (legacy group tree) are optional and stay undefined
* when not selected. Names are matched case-insensitively on both path styles.
*/
export function matchDataGripImportFiles(paths: string[]): {
dataSources: string;
local?: string;
forest?: string;
} {
const fileName = (path: string) => (path.split(/[\\/]/).pop() ?? "").toLowerCase();
const find = (name: string) => paths.find((path) => fileName(path) === name.toLowerCase());
const dataSources = find("dataSources.xml");
if (!dataSources) {
// Library layer keeps a readable fallback message; UI layers translate the
// coded error (see readDataGripImportFile) instead of showing this raw text.
const error = new Error("Select dataSources.xml (required); optionally dataSources.local.xml and db-forest-config.xml");
(error as Error & { code?: string }).code = "DATAGRIP_IMPORT_MISSING_DATASOURCES";
throw error;
}
return {
dataSources,
local: find("dataSources.local.xml"),
forest: find("db-forest-config.xml"),
};
}
/** Returns a map of dedup key (name\0host\0port\0db) → DataGrip UUID for Keychain lookup. */
export function getDataGripUuidMap(payload: DataGripImportPayload): Map<string, string> {
const shared = parseDataSourcesXml(payload.dataSources);
@ -527,6 +564,9 @@ export function getDataGripUuidMap(payload: DataGripImportPayload): Map<string,
for (const fragment of fragments) {
const profile = inferProfile(fragment.driverRef, extractSubprotocol(fragment.jdbcUrl), fragment.driverClass, fragment.product);
// Stay in sync with parseDataGripImport: skip custom-driver sources (no
// <driver-ref>) that don't resolve to a concrete database type.
if (fragment.driverRef === "" && profile.dbType === "jdbc") continue;
const parsed = parseJdbcUrl(fragment.jdbcUrl);
const host = parsed.host || (profile.dbType === "sqlite" ? "" : "127.0.0.1");
const port = parsed.port || profile.port;

View File

@ -6332,23 +6332,35 @@ export const useConnectionStore = defineStore("connection", () => {
if (isTauriRuntime()) {
const { open } = await import("@tauri-apps/plugin-dialog");
const { readTextFile } = await import("@tauri-apps/plugin-fs");
const path = await open({
filters: [{ name: "DataGrip dataSources.xml", extensions: ["xml"] }],
multiple: false,
const { matchDataGripImportFiles } = await import("@/lib/imports/datagripImport");
const paths = await open({
multiple: true,
filters: [{ name: "DataGrip configuration files", extensions: ["xml"] }],
title: i18n.global.t("configExport.importDatagripDialogTitle"),
});
if (!path) return null;
dataSources = await readTextFile(path as string);
// Auto-load dataSources.local.xml from the same directory
const dir = (path as string).replace(/[^/\\]*$/, "");
if (!paths || paths.length === 0) return null;
// Tauri's fs scope authorizes only the exact paths picked in the dialog,
// so every file read below must be explicitly selected — sibling files in
// the same directory (e.g. dataSources.local.xml) are NOT readable.
let picked: { dataSources: string; local?: string; forest?: string };
try {
dataSourcesLocal = await readTextFile(dir + "dataSources.local.xml");
} catch {
dataSourcesLocal = "";
picked = matchDataGripImportFiles(Array.isArray(paths) ? paths : [paths]);
} catch (error) {
if ((error as Error & { code?: string })?.code === "DATAGRIP_IMPORT_MISSING_DATASOURCES") {
throw new Error(i18n.global.t("configExport.importDatagripSelectFiles"));
}
throw error;
}
try {
dbForestConfig = await readTextFile(dir + "db-forest-config.xml");
} catch {
dbForestConfig = "";
dataSources = await readTextFile(picked.dataSources);
if (picked.local) {
dataSourcesLocal = await readTextFile(picked.local);
} else {
console.warn("[DataGrip Import] dataSources.local.xml not selected; usernames will fall back to defaults");
}
if (picked.forest) {
dbForestConfig = await readTextFile(picked.forest);
} else {
console.warn("[DataGrip Import] db-forest-config.xml not selected; legacy group tree skipped");
}
} else {
const files = await new Promise<FileList>((resolve, reject) => {
@ -6366,7 +6378,7 @@ export const useConnectionStore = defineStore("connection", () => {
input.click();
});
const fileList = Array.from(files);
const dsFile = fileList.find((f) => /^dataSources\.xml$/i.test(f.name)) || fileList[0];
const dsFile = fileList.find((f) => /^dataSources\.xml$/i.test(f.name));
const localFile = fileList.find((f) => /^dataSources\.local\.xml$/i.test(f.name));
const forestFile = fileList.find((f) => /^db-forest-config\.xml$/i.test(f.name));
if (!dsFile) throw new Error("Select dataSources.xml");