fix(import): preserve DataGrip groups and Kingbase connections
This commit is contained in:
parent
71090d56c0
commit
7b95f0ca17
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -1652,6 +1652,8 @@ export default withEnglishFallback({
|
|||
importLayoutConfirm: "インポートされたファイルに接続グループが含まれています。適用しますか?",
|
||||
importLayoutTitle: "グループをインポート",
|
||||
importLayoutApply: "適用",
|
||||
importDatagripSelectFiles: "dataSources.xml(必須)を選択してください。dataSources.local.xml と db-forest-config.xml も同時に選択できます。",
|
||||
importDatagripDialogTitle: "DataGrip 設定ファイルを選択",
|
||||
},
|
||||
ai: {
|
||||
placeholder: "自然言語でクエリを説明してください...",
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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: "导入文件包含连接分组信息,是否一并应用?",
|
||||
|
|
|
|||
|
|
@ -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: "匯入檔案包含連線群組資訊,是否一併套用?",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue