diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index bf161a93b..d363bc61c 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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?", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 13da31e6b..dc9fbd2a3 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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...", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index d2521043b..59353aefd 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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...", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index cb4ee9c80..341650145 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1652,6 +1652,8 @@ export default withEnglishFallback({ importLayoutConfirm: "インポートされたファイルに接続グループが含まれています。適用しますか?", importLayoutTitle: "グループをインポート", importLayoutApply: "適用", + importDatagripSelectFiles: "dataSources.xml(必須)を選択してください。dataSources.local.xml と db-forest-config.xml も同時に選択できます。", + importDatagripDialogTitle: "DataGrip 設定ファイルを選択", }, ai: { placeholder: "自然言語でクエリを説明してください...", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 74c0101d6..ed8918fef 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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...", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 893ee2fa9..b346deb0c 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -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: "导入文件包含连接分组信息,是否一并应用?", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 016840c90..ac243bd05 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -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: "匯入檔案包含連線群組資訊,是否一併套用?", diff --git a/apps/desktop/src/lib/__tests__/imports/datagripImport.spec.ts b/apps/desktop/src/lib/__tests__/imports/datagripImport.spec.ts index 8d5633f5a..e83e548ce 100644 --- a/apps/desktop/src/lib/__tests__/imports/datagripImport.spec.ts +++ b/apps/desktop/src/lib/__tests__/imports/datagripImport.spec.ts @@ -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 true + // with NO element — the driver identity lives only in + // + . Such connections must still import. + const connections = parseDataGripConnections( + payload(` + + + + true + com.kingbase8.Driver + jdbc:kingbase8://192.0.2.1:54321/app + + + + `), + ); + + 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 + 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(` + + + + true + com.example.mystery.Driver + jdbc:mystery://10.0.0.1:9999/db + + + + `), + ); + + 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 (not the + // legacy `group-name`). Connections sharing a group land under one folder. + const result = parseDataGripImport( + payload(` + + + + mysql + jdbc:mysql://prod.example.com:3306/app + + + mysql + jdbc:mysql://dev.example.com:3306/app + + + mysql + jdbc:mysql://localhost:3306/app + + + + `), + ); + + 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(` @@ -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"); + }); +}); diff --git a/apps/desktop/src/lib/imports/datagripImport.ts b/apps/desktop/src/lib/imports/datagripImport.ts index 22cbb8af9..9972a05db 100644 --- a/apps/desktop/src/lib/imports/datagripImport.ts +++ b/apps/desktop/src/lib/imports/datagripImport.ts @@ -300,7 +300,9 @@ function parseDataSourcesXml(xml: string): Map. `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>, 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 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 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 { const shared = parseDataSourcesXml(payload.dataSources); @@ -527,6 +564,9 @@ export function getDataGripUuidMap(payload: DataGripImportPayload): Map) 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; diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 04b15b3f9..95f8b522d 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -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((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");