feat(import): support Another Redis Desktop Manager connection import

This commit is contained in:
CSXFanMeng authored and GitHub committed 2026-10-27 15:08:04 +08:00
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;
}
+1
View File
@@ -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ığ",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -1161,6 +1161,7 @@ export default withEnglishFallback({
importDbeaver: "DBeaverをインポート",
importDatagrip: "DataGripをインポート",
importRdm: "RedisDesktopManager をインポート",
importAnotherRedis: "Another Redis Desktop Manager をインポート",
export: "接続をエクスポート",
collapseAll: "すべて折りたたむ",
collapse: "サイドバーを折りたたむ",
+1
View File
@@ -1167,6 +1167,7 @@ export default withEnglishFallback({
importDbeaver: "DBeaver 가져오기",
importDatagrip: "DataGrip 가져오기",
importRdm: "RedisDesktopManager 가져오기",
importAnotherRedis: "Another Redis Desktop Manager 가져오기",
export: "연결 내보내기",
collapseAll: "모두 접기",
collapse: "사이드바 접기",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -1141,6 +1141,7 @@ export default withEnglishFallback({
importDbeaver: "Импорт DBeaver",
importDatagrip: "Импорт DataGrip",
importRdm: "Импорт RedisDesktopManager",
importAnotherRedis: "Импорт Another Redis Desktop Manager",
export: "Экспортировать подключения",
collapseAll: "Свернуть всё",
collapse: "Свернуть боковую панель",
+1
View File
@@ -1241,6 +1241,7 @@ export default withEnglishFallback({
importDbeaver: "นำเข้า DBeaver",
importDatagrip: "นำเข้า DataGrip",
importRdm: "นำเข้า RedisDesktopManager",
importAnotherRedis: "นำเข้า Another Redis Desktop Manager",
export: "ส่งออกการเชื่อมต่อ",
collapseAll: "ยุบทั้งหมด",
collapse: "ยุบแถบด้านข้าง",
+1
View File
@@ -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",
+1
View File
@@ -1309,6 +1309,7 @@ export default withEnglishFallback({
importDbeaver: "导入 DBeaver",
importDatagrip: "导入 DataGrip",
importRdm: "导入 RedisDesktopManager",
importAnotherRedis: "导入 Another Redis Desktop Manager",
export: "导出连接",
collapseAll: "一键折叠",
collapse: "收起侧边栏",
+1
View File
@@ -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();
});
});
+9 -3
View File
@@ -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");
+10 -1
View File
@@ -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` 不导入,原有连接不被覆盖,导入不会自动连接。
## 导入后
导入的连接与手动创建的连接一同出现在侧边栏中。你应该:
+10 -1
View File
@@ -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