feat(import): 添加 DataGrip 连接导入支持 (#936)
- 新增 datagripImport 模块解析 XML 配置 - 支持从 macOS Keychain 自动填充密码 - 添加 Keychain 读取的 Tauri 命令 - 更新多语言翻译文件 - 集成到侧边栏导入流程中
This commit is contained in:
parent
354d21d086
commit
3995524e5d
|
|
@ -16,7 +16,7 @@ defineProps<{
|
|||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
import: [source: "dbx" | "navicat" | "dbeaver"];
|
||||
import: [source: "dbx" | "navicat" | "dbeaver" | "datagrip"];
|
||||
export: [];
|
||||
startResize: [event: MouseEvent];
|
||||
collapse: [];
|
||||
|
|
@ -30,6 +30,7 @@ const importSourceItems = computed(() => [
|
|||
{ value: "dbx", label: t("sidebar.importDbx") },
|
||||
{ value: "navicat", label: t("sidebar.importNavicat") },
|
||||
{ value: "dbeaver", label: t("sidebar.importDbeaver") },
|
||||
{ value: "datagrip", label: t("sidebar.importDatagrip") },
|
||||
]);
|
||||
|
||||
async function refreshTree() {
|
||||
|
|
@ -82,7 +83,7 @@ defineExpose({ focusSearch });
|
|||
:highlight-selected="false"
|
||||
check-position="none"
|
||||
align="end"
|
||||
@update:model-value="(source) => emit('import', source as 'dbx' | 'navicat' | 'dbeaver')"
|
||||
@update:model-value="(source) => emit('import', source as 'dbx' | 'navicat' | 'dbeaver' | 'datagrip')"
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ export function useDialogSources() {
|
|||
}
|
||||
}
|
||||
|
||||
async function onImportClick(source: "dbx" | "navicat" | "dbeaver" = "dbx") {
|
||||
async function onImportClick(source: "dbx" | "navicat" | "dbeaver" | "datagrip" = "dbx") {
|
||||
try {
|
||||
const result = await connectionStore.readImportFile(source);
|
||||
if (!result) return;
|
||||
|
|
@ -216,13 +216,20 @@ export function useDialogSources() {
|
|||
showConfigPassphraseDialog.value = true;
|
||||
} else {
|
||||
const { count, layout } = await connectionStore.importConnectionsFromFile(result.content, null);
|
||||
// For DataGrip imports, read Keychain passwords
|
||||
let keychainFilled = 0;
|
||||
if (source === "datagrip" && count > 0) {
|
||||
keychainFilled = await connectionStore.applyDataGripKeychainPasswords();
|
||||
}
|
||||
toast(
|
||||
count > 0
|
||||
? source === "navicat"
|
||||
? t("configExport.importNavicatSuccess", { count })
|
||||
: source === "dbeaver"
|
||||
? t("configExport.importDbeaverSuccess", { count })
|
||||
: t("configExport.importSuccess", { count })
|
||||
: source === "datagrip"
|
||||
? t("configExport.importDatagripSuccess", { count: count, filled: keychainFilled })
|
||||
: t("configExport.importSuccess", { count })
|
||||
: t("configExport.importNone"),
|
||||
4000,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export default {
|
|||
importDbx: "Import DBX Config",
|
||||
importNavicat: "Import Navicat NCX",
|
||||
importDbeaver: "Import DBeaver",
|
||||
importDatagrip: "Import DataGrip",
|
||||
export: "Export Connections",
|
||||
collapse: "Collapse sidebar",
|
||||
expand: "Expand sidebar",
|
||||
|
|
@ -867,6 +868,7 @@ 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.",
|
||||
importDbeaverSuccess:
|
||||
"Imported {count} DBeaver connection(s). Fill in any connection whose password is still empty before testing.",
|
||||
importNone: "No new connections to import",
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export default {
|
|||
importDbx: "Importar configuración DBX",
|
||||
importNavicat: "Importar Navicat NCX",
|
||||
importDbeaver: "Importar DBeaver",
|
||||
importDatagrip: "Importar DataGrip",
|
||||
export: "Exportar conexiones",
|
||||
collapse: "Contraer barra lateral",
|
||||
expand: "Expandir barra lateral",
|
||||
|
|
@ -774,6 +775,8 @@ export default {
|
|||
importSuccess: "Se importaron {count} conexión(es)",
|
||||
importNavicatSuccess:
|
||||
"Se importaron {count} conexión(es) de Navicat. Completa la contraseña de las conexiones que aún la tengan vacía antes de probarlas.",
|
||||
importDatagripSuccess:
|
||||
"Se importaron {count} conexión(es) de DataGrip, se llenaron {filled} contraseña(s) del Keychain.",
|
||||
importDbeaverSuccess:
|
||||
"Se importaron {count} conexión(es) de DBeaver. Completa la contraseña de las conexiones que aún la tengan vacía antes de probarlas.",
|
||||
importNone: "No hay nuevas conexiones para importar",
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export default {
|
|||
importDbx: "Importa Configurazione DBX",
|
||||
importNavicat: "Importa Navicat NCX",
|
||||
importDbeaver: "Importa DBeaver",
|
||||
importDatagrip: "Importa DataGrip",
|
||||
export: "Esporta Connessioni",
|
||||
collapse: "Riduci barra laterale",
|
||||
expand: "Espandi barra laterale",
|
||||
|
|
@ -854,6 +855,7 @@ export default {
|
|||
importSuccess: "Importata/e {count} connessione/i",
|
||||
importNavicatSuccess:
|
||||
"Importata/e {count} connessione/i Navicat. Inserisci le password mancanti prima di testare la connessione.",
|
||||
importDatagripSuccess: "Importate {count} connessione/i DataGrip, riempite {filled} password dalla Keychain.",
|
||||
importDbeaverSuccess:
|
||||
"Importata/e {count} connessione/i DBeaver. Inserisci le password mancanti prima di testare la connessione.",
|
||||
importNone: "Nessuna nuova connessione da importare",
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ export default {
|
|||
importDbx: "Importar Configuração DBX",
|
||||
importNavicat: "Importar Navicat NCX",
|
||||
importDbeaver: "Importar DBeaver",
|
||||
importDatagrip: "Importar DataGrip",
|
||||
export: "Exportar Conexões",
|
||||
collapse: "Recolher barra lateral",
|
||||
expand: "Expandir barra lateral",
|
||||
|
|
@ -850,6 +851,7 @@ export default {
|
|||
importSuccess: "{count} conexão(ões) importada(s)",
|
||||
importNavicatSuccess:
|
||||
"{count} conexão(ões) do Navicat importada(s). Preencha qualquer conexão cuja senha ainda esteja vazia antes de testar.",
|
||||
importDatagripSuccess: "{count} conexão(ões) DataGrip importada(s), {filled} senha(s) preenchida(s) do Keychain.",
|
||||
importDbeaverSuccess:
|
||||
"{count} conexão(ões) do DBeaver importada(s). Preencha qualquer conexão cuja senha ainda esteja vazia antes de testar.",
|
||||
importNone: "Nenhuma conexão nova para importar",
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ export default {
|
|||
importDbx: "导入 DBX 配置",
|
||||
importNavicat: "导入 Navicat NCX",
|
||||
importDbeaver: "导入 DBeaver",
|
||||
importDatagrip: "导入 DataGrip",
|
||||
export: "导出连接",
|
||||
collapse: "收起侧边栏",
|
||||
expand: "展开侧边栏",
|
||||
|
|
@ -848,6 +849,7 @@ export default {
|
|||
exportSuccess: "连接配置导出成功",
|
||||
importSuccess: "已导入 {count} 个连接",
|
||||
importNavicatSuccess: "已导入 {count} 个 Navicat 连接,若个别连接密码为空请补填后测试连接",
|
||||
importDatagripSuccess: "已导入 {count} 个 DataGrip 连接,已自动读取 {filled} 个密码(来自 macOS 钥匙串)",
|
||||
importDbeaverSuccess: "已导入 {count} 个 DBeaver 连接,若个别连接密码为空请补填后测试连接",
|
||||
importNone: "没有新的连接需要导入",
|
||||
importLayoutConfirm: "导入文件包含连接分组信息,是否一并应用?",
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ export default {
|
|||
importDbx: "匯入 DBX 設定",
|
||||
importNavicat: "匯入 Navicat NCX",
|
||||
importDbeaver: "匯入 DBeaver",
|
||||
importDatagrip: "匯入 DataGrip",
|
||||
export: "匯出連線",
|
||||
collapse: "收合側邊欄",
|
||||
expand: "展開側邊欄",
|
||||
|
|
@ -797,6 +798,7 @@ export default {
|
|||
exportSuccess: "連線設定匯出成功",
|
||||
importSuccess: "已匯入 {count} 個連線",
|
||||
importNavicatSuccess: "已匯入 {count} 個 Navicat 連線,若個別連線密碼為空請補填後測試連線",
|
||||
importDatagripSuccess: "已匯入 {count} 個 DataGrip 連線,已自動讀取 {filled} 個密碼(來自 macOS 鑰匙圈)",
|
||||
importDbeaverSuccess: "已匯入 {count} 個 DBeaver 連線,若個別連線密碼為空請補填後測試連線",
|
||||
importNone: "沒有新的連線需要匯入",
|
||||
importLayoutConfirm: "匯入檔案包含連線群組資訊,是否一併套用?",
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ export const closeDatabaseConnection = forward("closeDatabaseConnection");
|
|||
export const refreshConnections = forward("refreshConnections");
|
||||
export const saveConnections = forward("saveConnections");
|
||||
export const loadConnections = forward("loadConnections");
|
||||
export const readKeychainPassword = forward("readKeychainPassword");
|
||||
export const readKeychainPasswords = forward("readKeychainPasswords");
|
||||
export const decryptConfig = forward("decryptConfig");
|
||||
export const listPlugins = forward("listPlugins");
|
||||
export const listJdbcDrivers = forward("listJdbcDrivers");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,472 @@
|
|||
import type { ConnectionConfig, DatabaseType } from "@/types/database";
|
||||
import { uuid } from "@/lib/utils";
|
||||
|
||||
type PartialConnection = Omit<ConnectionConfig, "id">;
|
||||
|
||||
export type DataGripImportPayload = {
|
||||
format: "datagrip-import";
|
||||
dataSources: string;
|
||||
dataSourcesLocal?: string;
|
||||
};
|
||||
|
||||
type DataSourceFragment = {
|
||||
uuid: string;
|
||||
name: string;
|
||||
driverRef: string;
|
||||
jdbcUrl: string;
|
||||
driverClass: string;
|
||||
username: string;
|
||||
product: string;
|
||||
groupName?: string;
|
||||
};
|
||||
|
||||
type DriverProfile = {
|
||||
dbType: DatabaseType;
|
||||
profile: string;
|
||||
label: string;
|
||||
port: number;
|
||||
user: string;
|
||||
};
|
||||
|
||||
// driver-ref prefix → dbx profile
|
||||
const driverRefMap: Record<string, DriverProfile> = {
|
||||
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" },
|
||||
jtds: { dbType: "sqlserver", profile: "sqlserver", label: "SQL Server", port: 1433, user: "sa" },
|
||||
oracle: { dbType: "oracle", profile: "oracle", label: "Oracle", port: 1521, user: "system" },
|
||||
mongo: { dbType: "mongodb", profile: "mongodb", label: "MongoDB", port: 27017, user: "" },
|
||||
mongodb: { dbType: "mongodb", profile: "mongodb", label: "MongoDB", port: 27017, user: "" },
|
||||
redis: { dbType: "redis", profile: "redis", label: "Redis", port: 6379, user: "" },
|
||||
clickhouse: { dbType: "clickhouse", profile: "clickhouse", label: "ClickHouse", port: 8123, user: "default" },
|
||||
cassandra: { dbType: "cassandra", profile: "cassandra", label: "Cassandra", port: 9042, user: "" },
|
||||
duckdb: { dbType: "duckdb", profile: "duckdb", label: "DuckDB", port: 0, user: "" },
|
||||
bigquery: { dbType: "bigquery", profile: "bigquery", label: "BigQuery", port: 443, user: "" },
|
||||
cockroach: { dbType: "postgres", profile: "cockroachdb", label: "CockroachDB", port: 26257, user: "root" },
|
||||
cockroachdb: { dbType: "postgres", profile: "cockroachdb", label: "CockroachDB", port: 26257, user: "root" },
|
||||
redshift: { dbType: "redshift", profile: "redshift", label: "Redshift", port: 5439, user: "awsuser" },
|
||||
elasticsearch: { dbType: "elasticsearch", profile: "elasticsearch", label: "Elasticsearch", port: 9200, user: "" },
|
||||
h2: { dbType: "h2", profile: "h2", label: "H2", port: 9092, user: "sa" },
|
||||
snowflake: { dbType: "snowflake", profile: "snowflake", label: "Snowflake", port: 443, user: "" },
|
||||
};
|
||||
|
||||
// product name from <database-info product="..."> → dbx profile
|
||||
const productMap: Record<string, DriverProfile> = {
|
||||
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: "" },
|
||||
oracle: { dbType: "oracle", profile: "oracle", label: "Oracle", port: 1521, user: "system" },
|
||||
"sql server": { dbType: "sqlserver", profile: "sqlserver", label: "SQL Server", port: 1433, user: "sa" },
|
||||
mongodb: { dbType: "mongodb", profile: "mongodb", label: "MongoDB", port: 27017, user: "" },
|
||||
redis: { dbType: "redis", profile: "redis", label: "Redis", port: 6379, user: "" },
|
||||
clickhouse: { dbType: "clickhouse", profile: "clickhouse", label: "ClickHouse", port: 8123, user: "default" },
|
||||
cassandra: { dbType: "cassandra", profile: "cassandra", label: "Cassandra", port: 9042, user: "" },
|
||||
duckdb: { dbType: "duckdb", profile: "duckdb", label: "DuckDB", port: 0, user: "" },
|
||||
bigquery: { dbType: "bigquery", profile: "bigquery", label: "BigQuery", port: 443, user: "" },
|
||||
redshift: { dbType: "redshift", profile: "redshift", label: "Redshift", port: 5439, user: "awsuser" },
|
||||
elasticsearch: { dbType: "elasticsearch", profile: "elasticsearch", label: "Elasticsearch", port: 9200, user: "" },
|
||||
snowflake: { dbType: "snowflake", profile: "snowflake", label: "Snowflake", port: 443, user: "" },
|
||||
};
|
||||
|
||||
// JDBC subprotocol → dbx profile (fallback when driver-ref and product are unknown)
|
||||
const subprotocolMap: Record<string, DriverProfile> = {
|
||||
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" },
|
||||
sqlite: { dbType: "sqlite", profile: "sqlite", label: "SQLite", port: 0, user: "" },
|
||||
sqlserver: { dbType: "sqlserver", profile: "sqlserver", label: "SQL Server", port: 1433, user: "sa" },
|
||||
jtds: { dbType: "sqlserver", profile: "sqlserver", label: "SQL Server", port: 1433, user: "sa" },
|
||||
oracle: { dbType: "oracle", profile: "oracle", label: "Oracle", port: 1521, user: "system" },
|
||||
mongodb: { dbType: "mongodb", profile: "mongodb", label: "MongoDB", port: 27017, user: "" },
|
||||
redis: { dbType: "redis", profile: "redis", label: "Redis", port: 6379, user: "" },
|
||||
clickhouse: { dbType: "clickhouse", profile: "clickhouse", label: "ClickHouse", port: 8123, user: "default" },
|
||||
cassandra: { dbType: "cassandra", profile: "cassandra", label: "Cassandra", port: 9042, user: "" },
|
||||
duckdb: { dbType: "duckdb", profile: "duckdb", label: "DuckDB", port: 0, user: "" },
|
||||
bigquery: { dbType: "bigquery", profile: "bigquery", label: "BigQuery", port: 443, user: "" },
|
||||
redshift: { dbType: "redshift", profile: "redshift", label: "Redshift", port: 5439, user: "awsuser" },
|
||||
};
|
||||
|
||||
function getNumber(value: string | undefined): number {
|
||||
if (!value) return 0;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
function getText(element: Element, tagName: string): string {
|
||||
const child = element.getElementsByTagName(tagName)[0];
|
||||
return child?.textContent?.trim() ?? "";
|
||||
}
|
||||
|
||||
function expandPathMacros(value: string): string {
|
||||
if (typeof window !== "undefined") {
|
||||
const home =
|
||||
(typeof process !== "undefined" && process.env?.HOME) ||
|
||||
(typeof process !== "undefined" && process.env?.USERPROFILE) ||
|
||||
"";
|
||||
if (home) return value.replace(/\$USER_HOME\$/g, home);
|
||||
}
|
||||
return value.replace(/\$USER_HOME\$/g, "~");
|
||||
}
|
||||
|
||||
// --- JDBC URL parser ---
|
||||
|
||||
function parseJdbcUrl(jdbcUrl: string): {
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
oracleConnectionType?: "service_name" | "sid";
|
||||
} {
|
||||
const url = jdbcUrl.replace(/^jdbc:/i, "").trim();
|
||||
const result = {
|
||||
host: "",
|
||||
port: 0,
|
||||
database: "",
|
||||
oracleConnectionType: undefined as "service_name" | "sid" | undefined,
|
||||
};
|
||||
|
||||
// SQL Server: jdbc:sqlserver://host[:port][;key=value]
|
||||
const sqlServerMatch = url.match(/^sqlserver:\/\/([^;:/]+)(?::(\d+))?(?:;(.*))?/i);
|
||||
if (sqlServerMatch) {
|
||||
result.host = sqlServerMatch[1];
|
||||
result.port = getNumber(sqlServerMatch[2]);
|
||||
for (const part of (sqlServerMatch[3] || "").split(";")) {
|
||||
const [key, ...rest] = part.split("=");
|
||||
if (/^(databasename|database)$/i.test(key)) result.database = rest.join("=");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Oracle thin with service_name: jdbc:oracle:thin:@//host:port/service
|
||||
const oracleService = url.match(/^oracle:thin:@\/\/([^:/]+)(?::(\d+))?\/([^?]+)/i);
|
||||
if (oracleService) {
|
||||
result.host = oracleService[1];
|
||||
result.port = getNumber(oracleService[2]);
|
||||
result.database = oracleService[3];
|
||||
result.oracleConnectionType = "service_name";
|
||||
return result;
|
||||
}
|
||||
|
||||
// Oracle thin with SID: jdbc:oracle:thin:@host:port:sid
|
||||
const oracleSid = url.match(/^oracle:thin:@([^:/]+)(?::(\d+))?:([^?]+)/i);
|
||||
if (oracleSid) {
|
||||
result.host = oracleSid[1];
|
||||
result.port = getNumber(oracleSid[2]);
|
||||
result.database = oracleSid[3];
|
||||
result.oracleConnectionType = "sid";
|
||||
return result;
|
||||
}
|
||||
|
||||
// SQLite / DuckDB file path: jdbc:sqlite:path/to/file.db
|
||||
const fileMatch = url.match(/^(sqlite|duckdb):(.+)$/i);
|
||||
if (fileMatch) {
|
||||
result.host = expandPathMacros(fileMatch[2].split("?")[0]);
|
||||
result.database = result.host;
|
||||
return result;
|
||||
}
|
||||
|
||||
// BigQuery: jdbc:bigquery://host;ProjectId=xxx
|
||||
const bigqueryMatch = url.match(/^bigquery:\/\/([^;]+)(?:;(.+))?/i);
|
||||
if (bigqueryMatch) {
|
||||
result.host = bigqueryMatch[1];
|
||||
for (const part of (bigqueryMatch[2] || "").split(";")) {
|
||||
const [key, ...rest] = part.split("=");
|
||||
if (/^(projectid|project)$/i.test(key)) result.database = rest.join("=");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Generic authority form: jdbc:<sub>://[user[:pass]@]host[:port][/database][?params]
|
||||
const schemeEnd = url.indexOf("://");
|
||||
if (schemeEnd === -1) return result;
|
||||
|
||||
let remainder = url.slice(schemeEnd + 3);
|
||||
remainder = remainder.split("?")[0];
|
||||
|
||||
const slashIndex = remainder.indexOf("/");
|
||||
const authority = (slashIndex >= 0 ? remainder.slice(0, slashIndex) : remainder).split("@").pop() || "";
|
||||
const database = slashIndex >= 0 ? remainder.slice(slashIndex + 1) : "";
|
||||
|
||||
const firstHost = authority.split(",")[0] || authority;
|
||||
if (firstHost.startsWith("[")) {
|
||||
const closing = firstHost.indexOf("]");
|
||||
if (closing > 0) {
|
||||
result.host = firstHost.slice(1, closing);
|
||||
if (firstHost[closing + 1] === ":") result.port = getNumber(firstHost.slice(closing + 2));
|
||||
}
|
||||
} else {
|
||||
const lastColon = firstHost.lastIndexOf(":");
|
||||
if (lastColon > 0) {
|
||||
result.host = firstHost.slice(0, lastColon);
|
||||
result.port = getNumber(firstHost.slice(lastColon + 1));
|
||||
} else {
|
||||
result.host = firstHost;
|
||||
}
|
||||
}
|
||||
|
||||
result.database = database;
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractSubprotocol(jdbcUrl: string): string {
|
||||
const url = jdbcUrl.trim();
|
||||
if (!url.toLowerCase().startsWith("jdbc:")) return "";
|
||||
let subprotocol = "";
|
||||
for (const char of url.slice(5)) {
|
||||
if (char === ":" || char === "/") break;
|
||||
subprotocol += char;
|
||||
}
|
||||
return subprotocol;
|
||||
}
|
||||
|
||||
function inferProfile(driverRef: string, subprotocol: string, driverClass: string, product: string): DriverProfile {
|
||||
// 1. Try driver-ref prefix (most specific)
|
||||
const refKey = driverRef.split(".")[0].toLowerCase();
|
||||
if (driverRefMap[refKey]) return driverRefMap[refKey];
|
||||
|
||||
// 2. Try product name from <database-info>
|
||||
const productKey = product.toLowerCase();
|
||||
for (const [needle, profile] of Object.entries(productMap)) {
|
||||
if (productKey.includes(needle)) return profile;
|
||||
}
|
||||
|
||||
// 3. Try JDBC subprotocol
|
||||
const subKey = subprotocol.toLowerCase();
|
||||
if (subprotocolMap[subKey]) return subprotocolMap[subKey];
|
||||
|
||||
// 4. Try driver class name
|
||||
const classLower = driverClass.toLowerCase();
|
||||
if (classLower.includes("mysql")) return driverRefMap.mysql;
|
||||
if (classLower.includes("postgres")) return driverRefMap.postgresql;
|
||||
if (classLower.includes("sqlite")) return driverRefMap.sqlite;
|
||||
if (classLower.includes("oracle")) return driverRefMap.oracle;
|
||||
if (classLower.includes("sqlserver") || classLower.includes("mssql")) return driverRefMap.sqlserver;
|
||||
if (classLower.includes("mongo")) return driverRefMap.mongodb;
|
||||
if (classLower.includes("redis")) return driverRefMap.redis;
|
||||
if (classLower.includes("clickhouse")) return driverRefMap.clickhouse;
|
||||
|
||||
// 5. Fallback to JDBC
|
||||
return { dbType: "jdbc", profile: "jdbc", label: driverClass || "JDBC", port: 0, user: "" };
|
||||
}
|
||||
|
||||
// --- XML parsing ---
|
||||
|
||||
function parseDataSourcesXml(xml: string): Map<string, Partial<DataSourceFragment>> {
|
||||
const doc = new DOMParser().parseFromString(xml, "application/xml");
|
||||
if (doc.querySelector("parsererror")) return new Map();
|
||||
|
||||
const result = new Map<string, Partial<DataSourceFragment>>();
|
||||
const elements = doc.getElementsByTagName("data-source");
|
||||
|
||||
for (const element of Array.from(elements)) {
|
||||
const uuidVal = element.getAttribute("uuid");
|
||||
if (!uuidVal) continue;
|
||||
|
||||
const fragment: Partial<DataSourceFragment> = {
|
||||
uuid: uuidVal,
|
||||
name: element.getAttribute("name") || undefined,
|
||||
};
|
||||
|
||||
const driverRef = getText(element, "driver-ref");
|
||||
if (driverRef) fragment.driverRef = driverRef;
|
||||
|
||||
const jdbcUrl = getText(element, "jdbc-url");
|
||||
if (jdbcUrl) fragment.jdbcUrl = jdbcUrl;
|
||||
|
||||
const driverClass = getText(element, "jdbc-driver");
|
||||
if (driverClass) fragment.driverClass = driverClass;
|
||||
|
||||
const userName = getText(element, "user-name");
|
||||
if (userName) fragment.username = userName;
|
||||
|
||||
const groupName = element.getAttribute("group-name") || undefined;
|
||||
if (groupName) fragment.groupName = groupName;
|
||||
|
||||
result.set(uuidVal, fragment);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseDataSourcesLocalXml(xml: string): Map<string, Partial<DataSourceFragment>> {
|
||||
const doc = new DOMParser().parseFromString(xml, "application/xml");
|
||||
if (doc.querySelector("parsererror")) return new Map();
|
||||
|
||||
const result = new Map<string, Partial<DataSourceFragment>>();
|
||||
const elements = doc.getElementsByTagName("data-source");
|
||||
|
||||
for (const element of Array.from(elements)) {
|
||||
const uuidVal = element.getAttribute("uuid");
|
||||
if (!uuidVal) continue;
|
||||
|
||||
const fragment: Partial<DataSourceFragment> = {
|
||||
uuid: uuidVal,
|
||||
name: element.getAttribute("name") || undefined,
|
||||
};
|
||||
|
||||
const userName = getText(element, "user-name");
|
||||
if (userName) fragment.username = userName;
|
||||
|
||||
// Extract product from <database-info product="...">
|
||||
const dbInfo = element.getElementsByTagName("database-info")[0];
|
||||
const product = dbInfo?.getAttribute("product") || "";
|
||||
if (product) fragment.product = product;
|
||||
|
||||
result.set(uuidVal, fragment);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function mergeFragments(
|
||||
shared: Map<string, Partial<DataSourceFragment>>,
|
||||
local: Map<string, Partial<DataSourceFragment>>,
|
||||
): DataSourceFragment[] {
|
||||
const merged = new Map<string, Partial<DataSourceFragment>>();
|
||||
|
||||
// Shared first (has driver-ref, jdbc-url, group-name)
|
||||
for (const [uuidVal, frag] of shared) {
|
||||
merged.set(uuidVal, { ...frag });
|
||||
}
|
||||
|
||||
// Merge local (has user-name, product — overrides name if present)
|
||||
for (const [uuidVal, localFrag] of local) {
|
||||
const existing = merged.get(uuidVal) || { uuid: uuidVal };
|
||||
merged.set(uuidVal, {
|
||||
...existing,
|
||||
...localFrag,
|
||||
// Keep shared name if local has it too (they're usually the same)
|
||||
name: existing.name || localFrag.name,
|
||||
// Keep shared driver-ref and jdbc-url (local doesn't have these)
|
||||
driverRef: existing.driverRef || localFrag.driverRef,
|
||||
jdbcUrl: existing.jdbcUrl || localFrag.jdbcUrl,
|
||||
driverClass: existing.driverClass || localFrag.driverClass,
|
||||
groupName: existing.groupName || localFrag.groupName,
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve and filter
|
||||
const resolved: DataSourceFragment[] = [];
|
||||
for (const frag of merged.values()) {
|
||||
if (!frag.uuid || !frag.driverRef || !frag.jdbcUrl) continue;
|
||||
resolved.push({
|
||||
uuid: frag.uuid,
|
||||
name: frag.name || frag.uuid,
|
||||
driverRef: frag.driverRef,
|
||||
jdbcUrl: frag.jdbcUrl,
|
||||
driverClass: frag.driverClass || "",
|
||||
username: frag.username || "",
|
||||
product: frag.product || "",
|
||||
groupName: frag.groupName,
|
||||
});
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function buildConnection(fragment: DataSourceFragment): ConnectionConfig {
|
||||
const subprotocol = extractSubprotocol(fragment.jdbcUrl);
|
||||
const profile = inferProfile(fragment.driverRef, subprotocol, fragment.driverClass, fragment.product);
|
||||
const parsed = parseJdbcUrl(fragment.jdbcUrl);
|
||||
|
||||
const host = parsed.host || (profile.dbType === "sqlite" ? "" : "127.0.0.1");
|
||||
const port = parsed.port || profile.port;
|
||||
const database = parsed.database || undefined;
|
||||
const username = fragment.username || profile.user;
|
||||
const name = fragment.name || database || host || profile.label;
|
||||
|
||||
const partial: PartialConnection = {
|
||||
name,
|
||||
db_type: profile.dbType,
|
||||
driver_profile: profile.profile,
|
||||
driver_label: profile.label,
|
||||
url_params: "",
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password: "",
|
||||
database,
|
||||
color: "",
|
||||
transport_layers: [],
|
||||
connect_timeout_secs: 5,
|
||||
query_timeout_secs: 30,
|
||||
ssl: false,
|
||||
oracle_connection_type: profile.dbType === "oracle" ? parsed.oracleConnectionType || "service_name" : undefined,
|
||||
connection_string:
|
||||
profile.dbType === "jdbc" || profile.dbType === "mongodb" ? fragment.jdbcUrl.replace(/^jdbc:/i, "") : undefined,
|
||||
jdbc_driver_class: profile.dbType === "jdbc" ? fragment.driverClass || undefined : undefined,
|
||||
jdbc_driver_paths: [],
|
||||
};
|
||||
|
||||
return { ...partial, id: uuid() };
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
export function isDataGripImportPayload(content: string): boolean {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
return parsed?.format === "datagrip-import";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDataGripConnections(payload: DataGripImportPayload): ConnectionConfig[] {
|
||||
const shared = parseDataSourcesXml(payload.dataSources);
|
||||
const local = payload.dataSourcesLocal ? parseDataSourcesLocalXml(payload.dataSourcesLocal) : new Map();
|
||||
const fragments = mergeFragments(shared, local);
|
||||
|
||||
const configs: ConnectionConfig[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const fragment of fragments) {
|
||||
const config = buildConnection(fragment);
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
const local = payload.dataSourcesLocal ? parseDataSourcesLocalXml(payload.dataSourcesLocal) : new Map();
|
||||
const fragments = mergeFragments(shared, local);
|
||||
|
||||
const uuidMap = new Map<string, string>();
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const fragment of fragments) {
|
||||
const profile = inferProfile(
|
||||
fragment.driverRef,
|
||||
extractSubprotocol(fragment.jdbcUrl),
|
||||
fragment.driverClass,
|
||||
fragment.product,
|
||||
);
|
||||
const parsed = parseJdbcUrl(fragment.jdbcUrl);
|
||||
const host = parsed.host || (profile.dbType === "sqlite" ? "" : "127.0.0.1");
|
||||
const port = parsed.port || profile.port;
|
||||
const database = parsed.database || "";
|
||||
const name = fragment.name || database || host || profile.label;
|
||||
const dedupKey = [name, host, port, database].join("\u0000");
|
||||
if (seen.has(dedupKey)) continue;
|
||||
seen.add(dedupKey);
|
||||
uuidMap.set(dedupKey, fragment.uuid);
|
||||
}
|
||||
|
||||
return uuidMap;
|
||||
}
|
||||
|
||||
/** Build the macOS Keychain service name for a DataGrip data source UUID. */
|
||||
export function datagripKeychainService(uuid: string): string {
|
||||
return `IntelliJ Platform DB — ${uuid}`;
|
||||
}
|
||||
|
|
@ -177,6 +177,14 @@ export async function loadConnections(): Promise<ConnectionConfig[]> {
|
|||
return get("/api/connection/list");
|
||||
}
|
||||
|
||||
export async function readKeychainPassword(_service: string): Promise<string> {
|
||||
return ""; // Not available in web backend
|
||||
}
|
||||
|
||||
export async function readKeychainPasswords(services: string[]): Promise<[string, string][]> {
|
||||
return services.map((s) => [s, ""]); // Not available in web backend
|
||||
}
|
||||
|
||||
export async function decryptConfig(payload: unknown, passphrase: string): Promise<string> {
|
||||
return post("/api/app-settings/config/decrypt", { payload, passphrase });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -870,6 +870,14 @@ export async function loadConnections(): Promise<ConnectionConfig[]> {
|
|||
return invoke("load_connections");
|
||||
}
|
||||
|
||||
export async function readKeychainPassword(service: string): Promise<string> {
|
||||
return invoke("read_keychain_password", { service, account: null });
|
||||
}
|
||||
|
||||
export async function readKeychainPasswords(services: string[]): Promise<[string, string][]> {
|
||||
return invoke("read_keychain_passwords", { services });
|
||||
}
|
||||
|
||||
export async function decryptConfig(payload: unknown, passphrase: string): Promise<string> {
|
||||
const { decryptConfig: decryptConfigPayload } = await import("@/lib/configCrypto");
|
||||
return decryptConfigPayload(payload as any, passphrase);
|
||||
|
|
|
|||
|
|
@ -73,7 +73,10 @@ import { useSettingsStore } from "@/stores/settingsStore";
|
|||
|
||||
const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes";
|
||||
const ACTIVE_CONNECTION_STORAGE_KEY = "dbx-active-connection";
|
||||
type ImportSource = "dbx" | "navicat" | "dbeaver";
|
||||
type ImportSource = "dbx" | "navicat" | "dbeaver" | "datagrip";
|
||||
|
||||
// Temporary storage for DataGrip import payload (used to read Keychain passwords after import)
|
||||
let pendingDataGripPayload: { format: "datagrip-import"; dataSources: string; dataSourcesLocal?: string } | null = null;
|
||||
|
||||
interface TreeClipboardTableStructure {
|
||||
kind: "table-structure";
|
||||
|
|
@ -2373,8 +2376,60 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
};
|
||||
}
|
||||
|
||||
async function readDataGripImportFile(): Promise<{ content: string; encrypted: boolean } | null> {
|
||||
let dataSources: string;
|
||||
let dataSourcesLocal = "";
|
||||
|
||||
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,
|
||||
});
|
||||
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(/[^/\\]*$/, "");
|
||||
try {
|
||||
dataSourcesLocal = await readTextFile(dir + "dataSources.local.xml");
|
||||
} catch {
|
||||
dataSourcesLocal = "";
|
||||
}
|
||||
} else {
|
||||
const files = await new Promise<FileList>((resolve, reject) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".xml";
|
||||
input.multiple = true;
|
||||
input.onchange = () => {
|
||||
if (!input.files?.length) {
|
||||
reject(new Error("No file selected"));
|
||||
return;
|
||||
}
|
||||
resolve(input.files);
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
const fileList = Array.from(files);
|
||||
const dsFile = fileList.find((f) => /^dataSources\.xml$/i.test(f.name)) || fileList[0];
|
||||
const localFile = fileList.find((f) => /^dataSources\.local\.xml$/i.test(f.name));
|
||||
if (!dsFile) throw new Error("Select dataSources.xml");
|
||||
dataSources = await dsFile.text();
|
||||
if (localFile) {
|
||||
dataSourcesLocal = await localFile.text();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: JSON.stringify({ format: "datagrip-import", dataSources, dataSourcesLocal }),
|
||||
encrypted: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function readImportFile(source: ImportSource = "dbx"): Promise<{ content: string; encrypted: boolean } | null> {
|
||||
if (source === "dbeaver") return readDbeaverImportFile();
|
||||
if (source === "datagrip") return readDataGripImportFile();
|
||||
|
||||
let content: string;
|
||||
|
||||
|
|
@ -2431,7 +2486,16 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
imported = await parseNavicatConnections(content);
|
||||
} else if (!passphrase) {
|
||||
const { isDbeaverImportPayload, parseDbeaverConnections } = await import("@/lib/dbeaverImport");
|
||||
if (isDbeaverImportPayload(content)) {
|
||||
const { isDataGripImportPayload, parseDataGripConnections } = await import("@/lib/datagripImport");
|
||||
if (isDataGripImportPayload(content)) {
|
||||
const payload = JSON.parse(content) as {
|
||||
format: "datagrip-import";
|
||||
dataSources: string;
|
||||
dataSourcesLocal?: string;
|
||||
};
|
||||
pendingDataGripPayload = payload;
|
||||
imported = parseDataGripConnections(payload);
|
||||
} else if (isDbeaverImportPayload(content)) {
|
||||
imported = await parseDbeaverConnections(content);
|
||||
} else {
|
||||
const parsed = JSON.parse(content);
|
||||
|
|
@ -2484,6 +2548,61 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
return { count, layout: importedLayout };
|
||||
}
|
||||
|
||||
/** Read macOS Keychain passwords for DataGrip connections and update them. */
|
||||
async function applyDataGripKeychainPasswords(): Promise<number> {
|
||||
const payload = pendingDataGripPayload;
|
||||
pendingDataGripPayload = null;
|
||||
if (!payload) return 0;
|
||||
|
||||
try {
|
||||
const { getDataGripUuidMap, datagripKeychainService } = await import("@/lib/datagripImport");
|
||||
// dedupKey → DataGrip UUID
|
||||
const uuidMap = getDataGripUuidMap(payload);
|
||||
if (uuidMap.size === 0) return 0;
|
||||
|
||||
// Build service names for batch Keychain read
|
||||
const dedupKeyToService = new Map<string, string>();
|
||||
const services: string[] = [];
|
||||
for (const [dedupKey, dgUuid] of uuidMap) {
|
||||
const service = datagripKeychainService(dgUuid);
|
||||
dedupKeyToService.set(dedupKey, service);
|
||||
services.push(service);
|
||||
}
|
||||
|
||||
// Call Tauri command to read Keychain
|
||||
const results: [string, string][] = await api.readKeychainPasswords(services);
|
||||
|
||||
// Build service → password map
|
||||
const passwordByService = new Map<string, string>();
|
||||
for (const [service, password] of results) {
|
||||
if (password) passwordByService.set(service, password);
|
||||
}
|
||||
|
||||
// Update connections that have passwords (match by name/host/port)
|
||||
let filled = 0;
|
||||
const updated = connections.value.map((conn) => {
|
||||
const dedupKey = [conn.name, conn.host, conn.port, conn.database || ""].join("\u0000");
|
||||
const service = dedupKeyToService.get(dedupKey);
|
||||
if (!service) return conn;
|
||||
const password = passwordByService.get(service);
|
||||
if (password) {
|
||||
filled++;
|
||||
return { ...conn, password };
|
||||
}
|
||||
return conn;
|
||||
});
|
||||
|
||||
if (filled > 0) {
|
||||
connections.value = updated;
|
||||
await persistConnections();
|
||||
}
|
||||
return filled;
|
||||
} catch (e) {
|
||||
console.warn("[DataGrip Import] Keychain read failed:", e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function applySidebarLayout(layout: SidebarLayout) {
|
||||
const reconciledLayout = reconcileLayout(
|
||||
connections.value.map((c) => c.id),
|
||||
|
|
@ -2597,6 +2716,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
exportConnectionsToFile,
|
||||
readImportFile,
|
||||
importConnectionsFromFile,
|
||||
applyDataGripKeychainPasswords,
|
||||
applySidebarLayout,
|
||||
transferSource,
|
||||
schemaDiffSource,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
use std::process::Command;
|
||||
|
||||
/// Read a macOS Keychain generic password by service name.
|
||||
/// Triggers a system authorization dialog (Touch ID / password) for each unique service.
|
||||
#[tauri::command]
|
||||
pub async fn read_keychain_password(service: String, account: Option<String>) -> Result<String, String> {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let _ = (service, account);
|
||||
return Err("Keychain access is only available on macOS".to_string());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let mut cmd = Command::new("security");
|
||||
cmd.args(["find-generic-password", "-s", &service, "-w"]);
|
||||
if let Some(ref acct) = account {
|
||||
cmd.args(["-a", acct]);
|
||||
}
|
||||
|
||||
let output = cmd.output().map_err(|e| format!("Failed to run security command: {e}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
let password = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
Ok(password)
|
||||
} else {
|
||||
// Exit code 44 = user cancelled the authorization dialog
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
if output.status.code() == Some(44) || stderr.contains("User canceled") {
|
||||
Ok(String::new()) // Return empty instead of error for cancelled
|
||||
} else if stderr.contains("could not be found") || stderr.contains("The specified item could not be found")
|
||||
{
|
||||
Ok(String::new()) // No entry found — not an error
|
||||
} else {
|
||||
Err(format!("Keychain read failed: {}", stderr.trim()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read multiple Keychain passwords in one call. Returns a map of service -> password.
|
||||
/// Services that fail or are cancelled get an empty string.
|
||||
#[tauri::command]
|
||||
pub async fn read_keychain_passwords(services: Vec<String>) -> Result<Vec<(String, String)>, String> {
|
||||
let mut results = Vec::with_capacity(services.len());
|
||||
for service in services {
|
||||
let password = read_keychain_password(service.clone(), None).await.unwrap_or_default();
|
||||
results.push((service, password));
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ pub mod etcd_cmd;
|
|||
pub mod external_db;
|
||||
pub mod external_sql;
|
||||
pub mod history;
|
||||
pub mod keychain;
|
||||
pub mod mcp;
|
||||
pub mod mcp_bridge;
|
||||
pub mod mongo_cmd;
|
||||
|
|
|
|||
|
|
@ -461,6 +461,8 @@ pub fn run() {
|
|||
commands::external_sql::pending_open_sql_files,
|
||||
commands::external_sql::read_external_sql_file,
|
||||
commands::external_db::pending_open_db_files,
|
||||
commands::keychain::read_keychain_password,
|
||||
commands::keychain::read_keychain_passwords,
|
||||
commands::deep_link::pending_open_connection_links,
|
||||
commands::table_import::preview_table_import_file,
|
||||
commands::table_import::import_table_file,
|
||||
|
|
|
|||
Loading…
Reference in New Issue