feat(import): support Another Redis Desktop Manager connection import
This commit is contained in:
1 parent
64a49faf47
commit
a06144abe6
27 files changed
+353
-14
No files matched your search
@@ -27,7 +27,7 @@ defineProps<{
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
import: [source: "dbx" | "navicat" | "dbeaver" | "datagrip" | "rdm"];
|
||||
import: [source: "dbx" | "navicat" | "dbeaver" | "datagrip" | "rdm" | "another-redis"];
|
||||
export: [];
|
||||
startResize: [event: PointerEvent];
|
||||
collapse: [];
|
||||
@@ -37,7 +37,7 @@ const emit = defineEmits<{
|
||||
"open-dependency-object": [context: ObjectDependencyTarget, target: SqlObjectNavigationTarget];
|
||||
}>();
|
||||
|
||||
type ImportSource = "dbx" | "navicat" | "dbeaver" | "datagrip" | "rdm";
|
||||
type ImportSource = "dbx" | "navicat" | "dbeaver" | "datagrip" | "rdm" | "another-redis";
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
@@ -55,6 +55,7 @@ const importSourceItems = computed(() => [
|
||||
{ value: "dbeaver", label: t("sidebar.importDbeaver") },
|
||||
{ value: "datagrip", label: t("sidebar.importDatagrip") },
|
||||
{ value: "rdm", label: t("sidebar.importRdm") },
|
||||
{ value: "another-redis", label: t("sidebar.importAnotherRedis") },
|
||||
]);
|
||||
const connectionTransferItems = computed(() => [
|
||||
...importSourceItems.value.map((item) => ({
|
||||
|
||||
@@ -41,7 +41,7 @@ const emit = defineEmits<{
|
||||
"open-settings": [];
|
||||
}>();
|
||||
|
||||
type ImportSource = "dbx" | "navicat" | "dbeaver" | "datagrip" | "rdm";
|
||||
type ImportSource = "dbx" | "navicat" | "dbeaver" | "datagrip" | "rdm" | "another-redis";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -51,6 +51,7 @@ const importSourceItems = computed(() => [
|
||||
{ value: "dbeaver", label: t("sidebar.importDbeaver") },
|
||||
{ value: "datagrip", label: t("sidebar.importDatagrip") },
|
||||
{ value: "rdm", label: t("sidebar.importRdm") },
|
||||
{ value: "another-redis", label: t("sidebar.importAnotherRedis") },
|
||||
]);
|
||||
|
||||
function selectImportSource(source: string) {
|
||||
|
||||
@@ -72,7 +72,7 @@ async function mountDialogs() {
|
||||
}
|
||||
|
||||
describe("useDialogSources", () => {
|
||||
it.each(["dbx", "rdm"] as const)("runs the final %s import confirmation as a single flight", async (source) => {
|
||||
it.each(["dbx", "rdm", "another-redis"] as const)("runs the final %s import confirmation as a single flight", async (source) => {
|
||||
let resolveApply!: (value: { count: number }) => void;
|
||||
const applyPromise = new Promise<{ count: number }>((resolve) => {
|
||||
resolveApply = resolve;
|
||||
|
||||
@@ -45,7 +45,7 @@ const configConnectionSelectList = ref<ConnectionConfig[]>([]);
|
||||
const configConnectionSelectLayout = ref<SidebarLayout | null>(null);
|
||||
const pendingExportConnectionIds = ref<string[]>([]);
|
||||
const pendingImportPreview = ref<ConnectionConfigBundle | null>(null);
|
||||
const pendingImportSource = ref<"dbx" | "navicat" | "dbeaver" | "datagrip" | "rdm">("dbx");
|
||||
const pendingImportSource = ref<"dbx" | "navicat" | "dbeaver" | "datagrip" | "rdm" | "another-redis">("dbx");
|
||||
const applyingImportSelection = ref(false);
|
||||
|
||||
const transferPrefillConnectionId = ref("");
|
||||
@@ -435,7 +435,7 @@ export function useDialogSources() {
|
||||
showConfigConnectionSelectDialog.value = true;
|
||||
}
|
||||
|
||||
function importSuccessMessage(source: "dbx" | "navicat" | "dbeaver" | "datagrip" | "rdm", count: number, keychainFilled = 0) {
|
||||
function importSuccessMessage(source: "dbx" | "navicat" | "dbeaver" | "datagrip" | "rdm" | "another-redis", count: number, keychainFilled = 0) {
|
||||
if (count <= 0) return t("configExport.importNone");
|
||||
if (source === "navicat") return t("configExport.importNavicatSuccess", { count });
|
||||
if (source === "dbeaver") return t("configExport.importDbeaverSuccess", { count });
|
||||
@@ -443,7 +443,7 @@ export function useDialogSources() {
|
||||
return t("configExport.importSuccess", { count });
|
||||
}
|
||||
|
||||
async function finishImport(source: "dbx" | "navicat" | "dbeaver" | "datagrip" | "rdm", count: number, layout?: SidebarLayout) {
|
||||
async function finishImport(source: "dbx" | "navicat" | "dbeaver" | "datagrip" | "rdm" | "another-redis", count: number, layout?: SidebarLayout) {
|
||||
let keychainFilled = 0;
|
||||
if (source === "datagrip" && count > 0) {
|
||||
keychainFilled = await connectionStore.applyDataGripKeychainPasswords();
|
||||
@@ -527,7 +527,7 @@ export function useDialogSources() {
|
||||
if (!open) showConfigPassphraseDialog.value = true;
|
||||
}
|
||||
|
||||
async function onImportClick(source: "dbx" | "navicat" | "dbeaver" | "datagrip" | "rdm" = "dbx") {
|
||||
async function onImportClick(source: "dbx" | "navicat" | "dbeaver" | "datagrip" | "rdm" | "another-redis" = "dbx") {
|
||||
try {
|
||||
const result = await connectionStore.readImportFile(source);
|
||||
if (!result) return;
|
||||
@@ -542,7 +542,7 @@ export function useDialogSources() {
|
||||
}
|
||||
const preview = await connectionStore.parseConnectionsImport(result.content, null);
|
||||
pendingImportPreview.value = preview;
|
||||
if (source === "dbx" || source === "rdm") {
|
||||
if (source === "dbx" || source === "rdm" || source === "another-redis") {
|
||||
openConnectionSelect("import", preview.connections, preview.layout);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -481,6 +481,7 @@ export default withEnglishFallback({
|
||||
importDbeaver: "DBeaver-i idxal et",
|
||||
importDatagrip: "DataGrip-i idxal et",
|
||||
importRdm: "RedisDesktopManager-i idxal et",
|
||||
importAnotherRedis: "Another Redis Desktop Manager-i idxal et",
|
||||
export: "Əlaqələri ixrac et",
|
||||
collapseAll: "Hamısını yığ",
|
||||
collapse: "Yan paneli yığ",
|
||||
|
||||
@@ -1373,6 +1373,7 @@ export default {
|
||||
importDbeaver: "Import DBeaver",
|
||||
importDatagrip: "Import DataGrip",
|
||||
importRdm: "Import RedisDesktopManager",
|
||||
importAnotherRedis: "Import Another Redis Desktop Manager",
|
||||
export: "Export Connections",
|
||||
collapseAll: "Collapse all",
|
||||
collapse: "Collapse sidebar",
|
||||
|
||||
@@ -1172,6 +1172,7 @@ export default withEnglishFallback({
|
||||
importDbeaver: "Importar DBeaver",
|
||||
importDatagrip: "Importar DataGrip",
|
||||
importRdm: "Importar RedisDesktopManager",
|
||||
importAnotherRedis: "Importar Another Redis Desktop Manager",
|
||||
export: "Exportar conexiones",
|
||||
collapseAll: "Contraer todo",
|
||||
collapse: "Contraer barra lateral",
|
||||
|
||||
@@ -686,6 +686,7 @@ export default withEnglishFallback({
|
||||
importDbeaver: "Impor DBeaver",
|
||||
importDatagrip: "Impor DataGrip",
|
||||
importRdm: "Impor RedisDesktopManager",
|
||||
importAnotherRedis: "Impor Another Redis Desktop Manager",
|
||||
export: "Ekspor Koneksi",
|
||||
collapseAll: "Ciutkan semua",
|
||||
collapse: "Ciutkan bilah sisi",
|
||||
|
||||
@@ -1160,6 +1160,7 @@ export default withEnglishFallback({
|
||||
importDbeaver: "Importa DBeaver",
|
||||
importDatagrip: "Importa DataGrip",
|
||||
importRdm: "Importa RedisDesktopManager",
|
||||
importAnotherRedis: "Importa Another Redis Desktop Manager",
|
||||
export: "Esporta Connessioni",
|
||||
collapseAll: "Riduci tutto",
|
||||
collapse: "Riduci barra laterale",
|
||||
|
||||
@@ -1161,6 +1161,7 @@ export default withEnglishFallback({
|
||||
importDbeaver: "DBeaverをインポート",
|
||||
importDatagrip: "DataGripをインポート",
|
||||
importRdm: "RedisDesktopManager をインポート",
|
||||
importAnotherRedis: "Another Redis Desktop Manager をインポート",
|
||||
export: "接続をエクスポート",
|
||||
collapseAll: "すべて折りたたむ",
|
||||
collapse: "サイドバーを折りたたむ",
|
||||
|
||||
@@ -1167,6 +1167,7 @@ export default withEnglishFallback({
|
||||
importDbeaver: "DBeaver 가져오기",
|
||||
importDatagrip: "DataGrip 가져오기",
|
||||
importRdm: "RedisDesktopManager 가져오기",
|
||||
importAnotherRedis: "Another Redis Desktop Manager 가져오기",
|
||||
export: "연결 내보내기",
|
||||
collapseAll: "모두 접기",
|
||||
collapse: "사이드바 접기",
|
||||
|
||||
@@ -745,6 +745,7 @@ export default withEnglishFallback({
|
||||
importDbeaver: "Importuj DBeaver",
|
||||
importDatagrip: "Importuj DataGrip",
|
||||
importRdm: "Importuj RedisDesktopManager",
|
||||
importAnotherRedis: "Importuj Another Redis Desktop Manager",
|
||||
export: "Eksportuj połączenia",
|
||||
collapseAll: "Zwiń wszystko",
|
||||
collapse: "Zwiń pasek boczny",
|
||||
|
||||
@@ -1161,6 +1161,7 @@ export default withEnglishFallback({
|
||||
importDbeaver: "Importar DBeaver",
|
||||
importDatagrip: "Importar DataGrip",
|
||||
importRdm: "Importar RedisDesktopManager",
|
||||
importAnotherRedis: "Importar Another Redis Desktop Manager",
|
||||
export: "Exportar Conexões",
|
||||
collapseAll: "Recolher tudo",
|
||||
collapse: "Recolher barra lateral",
|
||||
|
||||
@@ -1141,6 +1141,7 @@ export default withEnglishFallback({
|
||||
importDbeaver: "Импорт DBeaver",
|
||||
importDatagrip: "Импорт DataGrip",
|
||||
importRdm: "Импорт RedisDesktopManager",
|
||||
importAnotherRedis: "Импорт Another Redis Desktop Manager",
|
||||
export: "Экспортировать подключения",
|
||||
collapseAll: "Свернуть всё",
|
||||
collapse: "Свернуть боковую панель",
|
||||
|
||||
@@ -1241,6 +1241,7 @@ export default withEnglishFallback({
|
||||
importDbeaver: "นำเข้า DBeaver",
|
||||
importDatagrip: "นำเข้า DataGrip",
|
||||
importRdm: "นำเข้า RedisDesktopManager",
|
||||
importAnotherRedis: "นำเข้า Another Redis Desktop Manager",
|
||||
export: "ส่งออกการเชื่อมต่อ",
|
||||
collapseAll: "ยุบทั้งหมด",
|
||||
collapse: "ยุบแถบด้านข้าง",
|
||||
|
||||
@@ -481,6 +481,7 @@ export default withEnglishFallback({
|
||||
importDbeaver: "DBeaver İçe Aktar",
|
||||
importDatagrip: "DataGrip İçe Aktar",
|
||||
importRdm: "RedisDesktopManager içe aktar",
|
||||
importAnotherRedis: "Another Redis Desktop Manager içe aktar",
|
||||
export: "Bağlantıları Dışa Aktar",
|
||||
collapseAll: "Tümünü daralt",
|
||||
collapse: "Kenar çubuğunu daralt",
|
||||
|
||||
@@ -1309,6 +1309,7 @@ export default withEnglishFallback({
|
||||
importDbeaver: "导入 DBeaver",
|
||||
importDatagrip: "导入 DataGrip",
|
||||
importRdm: "导入 RedisDesktopManager",
|
||||
importAnotherRedis: "导入 Another Redis Desktop Manager",
|
||||
export: "导出连接",
|
||||
collapseAll: "一键折叠",
|
||||
collapse: "收起侧边栏",
|
||||
|
||||
@@ -1144,6 +1144,7 @@ export default withEnglishFallback({
|
||||
importDbeaver: "匯入 DBeaver",
|
||||
importDatagrip: "匯入 DataGrip",
|
||||
importRdm: "匯入 RedisDesktopManager",
|
||||
importAnotherRedis: "匯入 Another Redis Desktop Manager",
|
||||
export: "匯出連線",
|
||||
collapseAll: "一鍵收合",
|
||||
collapse: "收合側邊欄",
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseAnotherRedisImport } from "@/lib/imports/anotherRedisImport";
|
||||
import { selectConnectionConfigBundle, prepareConnectionConfigImport } from "@/lib/connection/connectionConfigTransfer";
|
||||
|
||||
const encode = (value: unknown) => Buffer.from(JSON.stringify(value), "utf8").toString("base64");
|
||||
const parse = (value: unknown) => parseAnotherRedisImport(encode(value));
|
||||
|
||||
describe("Another Redis Desktop Manager .ano import", () => {
|
||||
it("handles large valid Base64 without recursive regular-expression matching and enforces count limits", () => {
|
||||
const password = "s".repeat(500000);
|
||||
expect(parse([{ host: "cache", auth: password }]).connections[0]!.password).toBe(password);
|
||||
expect(() => parse(Array.from({ length: 10001 }, () => ({ host: "cache" })))).toThrow("connections/groups");
|
||||
expect(() => parse([{ host: "cache", sshOptions: { host: "jump", username: "user", timeout: true } }])).toThrow("SSH timeout");
|
||||
});
|
||||
it("decodes official UTF-8 Base64 exports and preserves credentials without reusing identities", () => {
|
||||
const data = [{ host: " localhost ", port: "6380", name: "开发 Redis", username: " acl ", auth: " 密码 ", key: "runtime-id", db: 4 }];
|
||||
const first = parse(data).connections[0]!;
|
||||
expect(first).toMatchObject({ name: "开发 Redis", host: "localhost", port: 6380, username: " acl ", password: " 密码 ", db_type: "redis", redis_connection_mode: "standalone", ssl: false });
|
||||
expect(first.id).not.toBe("runtime-id");
|
||||
expect(parse(data).connections[0]!.id).not.toBe(first.id);
|
||||
expect(first).not.toHaveProperty("database");
|
||||
expect(data[0]!.key).toBe("runtime-id");
|
||||
expect(
|
||||
parseAnotherRedisImport(
|
||||
`\ufeff${encode(data)
|
||||
.match(/.{1,60}/g)!
|
||||
.join("\n")}\n`,
|
||||
).connections[0]!.name,
|
||||
).toBe("开发 Redis");
|
||||
});
|
||||
|
||||
it("preserves flat group order, duplicate names and dangling memberships as ungrouped", () => {
|
||||
const result = parse({
|
||||
groups: [
|
||||
{ id: "one", name: "Prod/EU" },
|
||||
{ id: "two", name: "Prod/EU" },
|
||||
],
|
||||
connections: [
|
||||
{ host: "first", groupId: "two" },
|
||||
{ host: "second", groupId: "one" },
|
||||
{ host: "root", groupId: "missing" },
|
||||
],
|
||||
});
|
||||
expect(result.layout!.groups.map((g) => g.name)).toEqual(["Prod/EU", "Prod/EU"]);
|
||||
expect(result.layout!.order).toEqual([
|
||||
{ type: "group", id: result.layout!.groups[0]!.id, children: [{ type: "connection", id: result.connections[1]!.id }] },
|
||||
{ type: "group", id: result.layout!.groups[1]!.id, children: [{ type: "connection", id: result.connections[0]!.id }] },
|
||||
{ type: "connection", id: result.connections[2]!.id },
|
||||
]);
|
||||
const selected = selectConnectionConfigBundle(result, [result.connections[0]!.id]);
|
||||
const imported = prepareConnectionConfigImport(selected, [], [], () => crypto.randomUUID());
|
||||
expect(imported.connections).toHaveLength(1);
|
||||
expect(JSON.stringify(imported.layout)).not.toContain(result.connections[1]!.id);
|
||||
});
|
||||
|
||||
it("maps SSH key/passphrase, TLS file paths and read-only safety", () => {
|
||||
const config = parse([
|
||||
{
|
||||
host: "cache",
|
||||
connectionReadOnly: true,
|
||||
separator: "::",
|
||||
sslOptions: { ca: "/ca.pem", cert: "/client.pem", key: "/client.key", servername: "" },
|
||||
sshOptions: { host: "bastion", port: "2222", username: "operator", password: " ssh-password ", privatekey: "/id_ed25519", passphrase: " key-password ", timeout: 45 },
|
||||
},
|
||||
]).connections[0]!;
|
||||
expect(config).toMatchObject({
|
||||
ssl: true,
|
||||
ca_cert_path: "/ca.pem",
|
||||
client_cert_path: "/client.pem",
|
||||
client_key_path: "/client.key",
|
||||
read_only: true,
|
||||
redis_key_separator: "::",
|
||||
transport_layers: [{ type: "ssh", host: "bastion", port: 2222, user: "operator", password: " ssh-password ", key_path: "/id_ed25519", key_passphrase: " key-password ", auth_method: "key+password", connect_timeout_secs: 45 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("distinguishes Sentinel and Redis node authentication and imports IPv6 cluster seeds", () => {
|
||||
const sentinel = parse([{ host: "sentinel", port: 26379, username: "node-user", auth: "sentinel-password", sentinelOptions: { masterName: "mymaster", nodePassword: "node-password" } }]).connections[0]!;
|
||||
expect(sentinel).toMatchObject({ redis_connection_mode: "sentinel", redis_sentinel_nodes: "sentinel:26379", redis_sentinel_master: "mymaster", redis_sentinel_password: "sentinel-password", password: "node-password", username: "node-user" });
|
||||
expect(parse([{ host: "::1", cluster: true }]).connections[0]).toMatchObject({ redis_connection_mode: "cluster", redis_cluster_nodes: "[::1]:6379" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
{},
|
||||
[],
|
||||
[null],
|
||||
[{ name: "missing host" }],
|
||||
[{ host: "cache", port: -1 }],
|
||||
[{ host: "cache", port: 65536 }],
|
||||
[{ host: "cache", cluster: "false" }],
|
||||
[{ host: "cache", sentinelOptions: {} }],
|
||||
[{ host: "cache", cluster: true, sentinelOptions: { masterName: "m" } }],
|
||||
[{ host: "cache", sslOptions: { cert: "/cert.pem" } }],
|
||||
[{ host: "cache", sslOptions: { servername: "other-host" } }],
|
||||
[{ host: "cache", natMap: { source: "target" } }],
|
||||
[{ host: "cache", sshOptions: { host: "jump" } }],
|
||||
{
|
||||
connections: [{ host: "cache" }],
|
||||
groups: [
|
||||
{ id: "g", name: "a" },
|
||||
{ id: "g", name: "b" },
|
||||
],
|
||||
},
|
||||
])("rejects invalid/unsupported configurations before writing %#", (value) => {
|
||||
expect(() => parse(value)).toThrow(/Another Redis/);
|
||||
});
|
||||
|
||||
it("rejects malformed/oversized exports without exposing credentials", () => {
|
||||
for (const raw of ["not-an-ano-file!", "e30", "", "////", Buffer.from("not-json secret-password").toString("base64"), "A".repeat(16 * 1024 * 1024 + 1)]) {
|
||||
expect(() => parseAnotherRedisImport(raw)).toThrow(/Another Redis/);
|
||||
try {
|
||||
parseAnotherRedisImport(raw);
|
||||
} catch (error) {
|
||||
expect(String(error)).not.toContain("secret-password");
|
||||
}
|
||||
}
|
||||
expect(() => parse([{ host: "valid" }, { host: "invalid", port: false, auth: "secret-password" }])).toThrow("port");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import type { ConnectionConfigBundle } from "@/lib/connection/connectionConfigTransfer";
|
||||
import type { ConnectionConfig, SidebarOrderEntry } from "@/types/database";
|
||||
|
||||
type Entry = Record<string, unknown>;
|
||||
const prefix = "Another Redis Desktop Manager";
|
||||
const fail = (field: string): never => {
|
||||
throw new Error(`${prefix}: invalid or unsupported ${field}.`);
|
||||
};
|
||||
function record(value: unknown): Entry {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return fail("object");
|
||||
return value as Entry;
|
||||
}
|
||||
function text(entry: Entry, key: string, fallback = ""): string {
|
||||
const value = entry[key];
|
||||
return value == null ? fallback : typeof value === "string" ? value : fail(key);
|
||||
}
|
||||
function flag(entry: Entry, key: string): boolean {
|
||||
const value = entry[key];
|
||||
if (value == null || value === false) return false;
|
||||
return value === true ? true : fail(key);
|
||||
}
|
||||
function port(entry: Entry, fallback: number): number {
|
||||
const value = entry.port;
|
||||
if (value == null || value === "") return fallback;
|
||||
const parsed = typeof value === "number" || typeof value === "string" ? Number(value) : NaN;
|
||||
return Number.isInteger(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fail("port");
|
||||
}
|
||||
function endpoint(host: string, value: number): string {
|
||||
return `${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}:${value}`;
|
||||
}
|
||||
function connection(entry: Entry): ConnectionConfig {
|
||||
const host = text(entry, "host").trim();
|
||||
if (!host || "db_type" in entry) return fail("host");
|
||||
if (entry.natMap != null && Object.keys(record(entry.natMap)).length) return fail("NAT mapping");
|
||||
const redisPort = port(entry, 6379);
|
||||
const cluster = flag(entry, "cluster");
|
||||
const sentinel = entry.sentinelOptions == null ? undefined : record(entry.sentinelOptions);
|
||||
if (cluster && sentinel) return fail("combined Sentinel/Cluster mode");
|
||||
const config: ConnectionConfig = {
|
||||
id: uuid(),
|
||||
name: text(entry, "name").trim() || endpoint(host, redisPort),
|
||||
db_type: "redis",
|
||||
driver_profile: "redis",
|
||||
driver_label: "Redis",
|
||||
host,
|
||||
port: redisPort,
|
||||
username: text(entry, "username"),
|
||||
password: text(entry, "auth"),
|
||||
save_password: true,
|
||||
read_only: flag(entry, "connectionReadOnly"),
|
||||
redis_key_separator: text(entry, "separator", ":"),
|
||||
redis_connection_mode: sentinel ? "sentinel" : cluster ? "cluster" : "standalone",
|
||||
ssl: entry.sslOptions != null,
|
||||
};
|
||||
if (cluster) config.redis_cluster_nodes = endpoint(host, redisPort);
|
||||
if (sentinel) {
|
||||
const master = text(sentinel, "masterName").trim();
|
||||
if (!master) return fail("Sentinel masterName");
|
||||
config.redis_sentinel_master = master;
|
||||
config.redis_sentinel_nodes = endpoint(host, redisPort);
|
||||
config.redis_sentinel_password = config.password;
|
||||
config.password = text(sentinel, "nodePassword");
|
||||
}
|
||||
if (entry.sslOptions != null) {
|
||||
const tls = record(entry.sslOptions);
|
||||
if (text(tls, "servername")) return fail("TLS servername override");
|
||||
config.ca_cert_path = text(tls, "ca") || undefined;
|
||||
config.client_cert_path = text(tls, "cert") || undefined;
|
||||
config.client_key_path = text(tls, "key") || undefined;
|
||||
if (!!config.client_cert_path !== !!config.client_key_path) return fail("TLS certificate/key pair");
|
||||
}
|
||||
if (entry.sshOptions != null) {
|
||||
const ssh = record(entry.sshOptions);
|
||||
const sshHost = text(ssh, "host").trim();
|
||||
const user = text(ssh, "username");
|
||||
if (!sshHost || !user.trim()) return fail("SSH host/username");
|
||||
const password = text(ssh, "password");
|
||||
const key = text(ssh, "privatekey");
|
||||
if (ssh.timeout != null && typeof ssh.timeout !== "number" && typeof ssh.timeout !== "string") return fail("SSH timeout");
|
||||
const timeout = ssh.timeout == null ? 30 : Number(ssh.timeout);
|
||||
if (!Number.isInteger(timeout) || timeout <= 0) return fail("SSH timeout");
|
||||
config.transport_layers = [
|
||||
{
|
||||
id: uuid(),
|
||||
type: "ssh",
|
||||
enabled: true,
|
||||
host: sshHost,
|
||||
port: port(ssh, 22),
|
||||
user,
|
||||
password,
|
||||
key_path: key || undefined,
|
||||
key_passphrase: text(ssh, "passphrase") || undefined,
|
||||
auth_method: key ? (password ? "key+password" : "key") : "password",
|
||||
connect_timeout_secs: timeout,
|
||||
},
|
||||
];
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Official .ano exports are UTF-8 JSON wrapped in Base64, not encrypted backups. */
|
||||
export function parseAnotherRedisImport(content: string): ConnectionConfigBundle {
|
||||
if (content.length > 16 * 1024 * 1024) return fail("file size (maximum 16 MiB)");
|
||||
const encoded = content.trim().replace(/\s/g, "");
|
||||
if (!encoded || encoded.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(encoded)) return fail("Base64 export");
|
||||
let input: unknown;
|
||||
try {
|
||||
const bytes = Uint8Array.from(atob(encoded), (char) => char.charCodeAt(0));
|
||||
input = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
||||
} catch {
|
||||
return fail("UTF-8 JSON export");
|
||||
}
|
||||
const bundle = Array.isArray(input) ? { connections: input, groups: [] } : record(input);
|
||||
const entries = bundle.connections;
|
||||
const groupEntries = bundle.groups ?? [];
|
||||
if (!Array.isArray(entries) || !entries.length || entries.length > 10000 || !Array.isArray(groupEntries) || groupEntries.length > 10000) return fail("connections/groups");
|
||||
const groups: NonNullable<ConnectionConfigBundle["layout"]>["groups"] = [];
|
||||
const groupIds = new Map<string, string>();
|
||||
const children = new Map<string, SidebarOrderEntry[]>();
|
||||
for (const value of groupEntries) {
|
||||
const entry = record(value);
|
||||
const originalId = text(entry, "id");
|
||||
const name = text(entry, "name");
|
||||
if (!originalId || !name.trim() || groupIds.has(originalId)) return fail("group identity/name");
|
||||
const id = uuid();
|
||||
groups.push({ id, name, collapsed: false });
|
||||
groupIds.set(originalId, id);
|
||||
children.set(id, []);
|
||||
}
|
||||
const connections: ConnectionConfig[] = [];
|
||||
const ungrouped: SidebarOrderEntry[] = [];
|
||||
for (const value of entries) {
|
||||
const entry = record(value);
|
||||
const config = connection(entry);
|
||||
connections.push(config);
|
||||
const group = groupIds.get(text(entry, "groupId"));
|
||||
(group ? children.get(group)! : ungrouped).push({ type: "connection", id: config.id });
|
||||
}
|
||||
const order: SidebarOrderEntry[] = [...groups.map(({ id }) => ({ type: "group" as const, id, children: children.get(id)! })), ...ungrouped];
|
||||
return { connections, ...(groups.length ? { layout: { groups, order } } : {}) };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ open: vi.fn(), readFile: vi.fn() }));
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.open }));
|
||||
vi.mock("@tauri-apps/plugin-fs", () => ({ readFile: mocks.readFile }));
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
localStorage.clear();
|
||||
mocks.open.mockReset();
|
||||
mocks.readFile.mockReset();
|
||||
});
|
||||
|
||||
describe("Another Redis native file import pipeline", () => {
|
||||
it("filters .ano files and parses them before any connections are saved", async () => {
|
||||
mocks.open.mockResolvedValue("C:/fixtures/connections.ano");
|
||||
const data = Buffer.from(JSON.stringify([{ host: "localhost", name: "开发", auth: "test-password" }])).toString("base64");
|
||||
mocks.readFile.mockResolvedValue(new TextEncoder().encode(data));
|
||||
const store = useConnectionStore();
|
||||
const file = await store.readImportFile("another-redis");
|
||||
expect(mocks.open).toHaveBeenCalledWith({ filters: [{ name: "Another Redis Desktop Manager", extensions: ["ano"] }], multiple: false });
|
||||
expect(mocks.readFile).toHaveBeenCalledWith("C:/fixtures/connections.ano");
|
||||
expect(file?.encrypted).toBe(false);
|
||||
const preview = await store.parseConnectionsImport(file!.content, null);
|
||||
expect(preview.connections[0]).toMatchObject({ host: "localhost", name: "开发", password: "test-password" });
|
||||
expect(store.connections).toHaveLength(0);
|
||||
});
|
||||
it("treats cancelling the file picker as a no-op", async () => {
|
||||
mocks.open.mockResolvedValue(null);
|
||||
expect(await useConnectionStore().readImportFile("another-redis")).toBeNull();
|
||||
expect(mocks.readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -334,7 +334,7 @@ function isFlatMqConnection(config: ConnectionConfig | undefined): boolean {
|
||||
return kind === "kafka" || kind === "rocketmq" || kind === "rabbitmq";
|
||||
}
|
||||
|
||||
type ImportSource = "dbx" | "navicat" | "dbeaver" | "datagrip" | "rdm";
|
||||
type ImportSource = "dbx" | "navicat" | "dbeaver" | "datagrip" | "rdm" | "another-redis";
|
||||
|
||||
interface LocateTableTarget {
|
||||
catalog?: string;
|
||||
@@ -11089,7 +11089,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const { readFile } = await import("@tauri-apps/plugin-fs");
|
||||
const { decodeImportFileText } = await import("@/lib/common/decodeText");
|
||||
const path = await open({
|
||||
filters: source === "navicat" ? [{ name: "Navicat Connection Export", extensions: ["ncx", "xml"] }] : [{ name: source === "rdm" ? "RedisDesktopManager JSON" : "DBX JSON", extensions: ["json"] }],
|
||||
filters: source === "another-redis" ? [{ name: "Another Redis Desktop Manager", extensions: ["ano"] }] : source === "navicat" ? [{ name: "Navicat Connection Export", extensions: ["ncx", "xml"] }] : [{ name: source === "rdm" ? "RedisDesktopManager JSON" : "DBX JSON", extensions: ["json"] }],
|
||||
multiple: false,
|
||||
});
|
||||
if (!path) return null;
|
||||
@@ -11101,7 +11101,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
content = await new Promise<string>((resolve, reject) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = source === "navicat" ? ".ncx,.xml" : ".json";
|
||||
input.accept = source === "another-redis" ? ".ano" : source === "navicat" ? ".ncx,.xml" : ".json";
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
@@ -11118,6 +11118,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
|
||||
if (source === "rdm") return { content: JSON.stringify({ format: "rdm-import", content }), encrypted: false };
|
||||
if (source === "another-redis") return { content: JSON.stringify({ format: "another-redis-import", content }), encrypted: false };
|
||||
if (content.trimStart().startsWith("<")) {
|
||||
return { content, encrypted: false };
|
||||
}
|
||||
@@ -11135,6 +11136,11 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
|
||||
if (!passphrase) {
|
||||
const payload: unknown = JSON.parse(content);
|
||||
if (payload && typeof payload === "object" && "format" in payload && payload.format === "another-redis-import") {
|
||||
if (!("content" in payload) || typeof payload.content !== "string") throw new Error("Another Redis Desktop Manager: invalid import payload.");
|
||||
const { parseAnotherRedisImport } = await import("@/lib/imports/anotherRedisImport");
|
||||
return parseAnotherRedisImport(payload.content);
|
||||
}
|
||||
if (payload && typeof payload === "object" && "format" in payload && payload.format === "rdm-import") {
|
||||
if (!("content" in payload) || typeof payload.content !== "string") throw new Error("RedisDesktopManager: invalid import payload.");
|
||||
const { parseRedisDesktopManagerImport } = await import("@/lib/imports/redisDesktopManagerImport");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 连接导入
|
||||
description: 从 Navicat、DBeaver、DataGrip 和 RedisDesktopManager 导入连接与分组。
|
||||
description: 从 Navicat、DBeaver、DataGrip、RedisDesktopManager 和 Another Redis Desktop Manager 导入连接与分组。
|
||||
---
|
||||
|
||||
DBX 可以从其他数据库工具导入连接,在切换工具时无需手动重新创建大量连接。
|
||||
@@ -13,6 +13,7 @@ DBX 可以从其他数据库工具导入连接,在切换工具时无需手动
|
||||
| DBeaver | 连接配置文件 | 主机、端口、用户名、数据库类型、连接名称 |
|
||||
| DataGrip | `dataSources.xml` 等项目/全局配置 | JDBC URL、驱动、连接名称、分组和可读取凭据 |
|
||||
| RedisDesktopManager | JSON 导出文件 | Redis 连接、嵌套分组、认证、TLS 和 SSH 配置 |
|
||||
| Another Redis Desktop Manager | `.ano` 导出文件 | Redis 连接、分组、ACL、SSH/TLS、Sentinel 和 Cluster 配置 |
|
||||
|
||||
<Callout type="info">导入的连接保留原始连接名称和类型。其他工具以加密格式存储的密码无法解密——导入后需要重新输入密码。</Callout>
|
||||
|
||||
@@ -111,6 +112,14 @@ DBX 可以从其他数据库工具导入连接,在切换工具时无需手动
|
||||
|
||||
TLS 的 CA、客户端证书和私钥路径会保留,但不会读取或复制文件,跨机器迁移后需要检查路径。不会继承 RDM 的忽略证书错误设置。导出文件可能含有明文密码,请妥善保管;导入不会自动连接数据库。
|
||||
|
||||
## 导入 Another Redis Desktop Manager 连接
|
||||
|
||||
在 Another Redis Desktop Manager 的设置中导出 `connections.ano`,在 DBX 侧边栏或欢迎页的导入菜单选择 **导入 Another Redis Desktop Manager**。勾选需要的连接并确认,再按提示恢复分组。支持旧版连接数组和新版带 `connections` / `groups` 的导出,保留普通、Sentinel、Cluster 模式及只读标记。
|
||||
|
||||
`.ano` 是 Base64 包装的 UTF-8 JSON,并不是加密文件,可能包含 Redis、SSH 和 Sentinel 的明文密码。请妥善保管。SSH 私钥口令、证书和私钥文件路径会保留,但不会读取或复制这些文件;跨机器后应重新检查。Sentinel 密码与 Redis 节点密码分别映射,不互相替代。
|
||||
|
||||
自定义 TLS `servername` 和非空 NAT 映射目前无法无损映射到 DBX,导入会明确拒绝,而不是静默丢弃。文件最大 16 MiB,连接和分组各最多 10,000 项;畸形文件在保存任何连接前报错。运行时的 `key` / 当前 `db` 不导入,原有连接不被覆盖,导入不会自动连接。
|
||||
|
||||
## 导入后
|
||||
|
||||
导入的连接与手动创建的连接一同出现在侧边栏中。你应该:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Connection Import
|
||||
description: Import connections and groups from Navicat, DBeaver, DataGrip, and RedisDesktopManager.
|
||||
description: Import connections and groups from Navicat, DBeaver, DataGrip, RedisDesktopManager, and Another Redis Desktop Manager.
|
||||
---
|
||||
|
||||
DBX can import connections from other database tools, saving you from manually re-creating dozens of connections when switching tools.
|
||||
@@ -13,6 +13,7 @@ DBX can import connections from other database tools, saving you from manually r
|
||||
| DBeaver | Connection configuration files | Host, port, username, database type, connection name |
|
||||
| DataGrip | Project or global files such as `dataSources.xml` | JDBC URL, driver, connection name, groups, and readable credentials |
|
||||
| RedisDesktopManager | JSON export | Redis connections, nested groups, authentication, TLS, and SSH settings |
|
||||
| Another Redis Desktop Manager | `.ano` export | Redis connections, groups, ACL, SSH/TLS, Sentinel, and Cluster settings |
|
||||
|
||||
<Callout type="info">Imported connections preserve the original connection name and type. Passwords stored in encrypted format by other tools cannot be decrypted — you will need to re-enter passwords after import.</Callout>
|
||||
|
||||
@@ -111,6 +112,14 @@ Standalone Redis host, port, ACL username, `auth` password, key separator and fi
|
||||
|
||||
TLS CA, client certificate and private-key paths are preserved, but files are not read or copied. Check paths after moving between machines. RDM's ignore-certificate-errors setting is not inherited. Export files may contain plaintext passwords; keep them secure. Importing does not automatically connect to Redis.
|
||||
|
||||
## Import Another Redis Desktop Manager Connections
|
||||
|
||||
Export `connections.ano` from Another Redis Desktop Manager settings. Choose **Import Another Redis Desktop Manager** in the sidebar or welcome-screen import menu, select connections, confirm, and optionally restore their groups. Both legacy connection arrays and newer `connections` / `groups` bundles are supported, including standalone, Sentinel, Cluster, and read-only settings.
|
||||
|
||||
The file is Base64-wrapped UTF-8 JSON, not encryption, and may contain plaintext Redis, SSH, and Sentinel credentials. Keep it private. SSH key passphrases and certificate/key file paths are preserved without reading or copying local files; review paths after moving machines. Sentinel credentials remain separate from Redis node credentials.
|
||||
|
||||
Custom TLS `servername` overrides and nonempty NAT mappings cannot be faithfully represented and are explicitly rejected rather than silently dropped. Limits are 16 MiB and 10,000 connections/groups each. Invalid files fail before saving any connections. Runtime `key` / current `db` fields are ignored; importing neither overwrites existing connections nor connects automatically.
|
||||
|
||||
## After Import
|
||||
|
||||
Imported connections appear in the sidebar alongside manually created ones. You should:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Reference in new issue
Block a user