From a9bae6bb3f97264c364b63b85f24470a29c056ea Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 30 Jun 2026 21:17:00 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(explorer):=20=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=20Redis=20=E9=83=A8=E5=88=86=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E4=B8=8E=E5=BA=93=E8=8A=82=E7=82=B9=E5=8A=A0=E8=BD=BD=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Redis 导入预览与按 key 勾选导入能力 - 后端支持导入文件预读、选中 key 过滤与本地化错误提示 - 打开数据库节点时同步展示加载态并补齐前后端回归测试 Fixes #607 Fixes #609 --- .../src/components/RedisViewer.i18n.test.ts | 14 + .../RedisViewer.interaction.test.tsx | 166 +++- frontend/src/components/RedisViewer.tsx | 319 +++++++ .../Sidebar.locate-toolbar.test.tsx | 3 +- .../sidebar/useSidebarTreeLoaders.tsx | 1 + internal/app/methods_redis.go | 803 ++++++++++++++++++ internal/app/methods_redis_i18n_test.go | 18 + internal/app/methods_redis_test.go | 442 ++++++++++ shared/i18n/de-DE.json | 27 + shared/i18n/en-US.json | 27 + shared/i18n/ja-JP.json | 27 + shared/i18n/ru-RU.json | 27 + shared/i18n/zh-CN.json | 27 + shared/i18n/zh-TW.json | 27 + 14 files changed, 1926 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/RedisViewer.i18n.test.ts b/frontend/src/components/RedisViewer.i18n.test.ts index ce49ed79..a8c1152a 100644 --- a/frontend/src/components/RedisViewer.i18n.test.ts +++ b/frontend/src/components/RedisViewer.i18n.test.ts @@ -17,6 +17,9 @@ describe('RedisViewer i18n', () => { '模糊', '精确', '新建 Key', + '导出全部', + '导出已选', + '导入', '重命名 Key', ].forEach((snippet) => { expect(source).not.toContain(snippet); @@ -26,8 +29,19 @@ describe('RedisViewer i18n', () => { expect(source).toContain("tr('redis_viewer.message.load_keys_failed'"); expect(source).toContain("tr('redis_viewer.message.value_load_failed'"); expect(source).toContain("tr('redis_viewer.message.rename_success'"); + expect(source).toContain("tr('redis_viewer.message.export_success'"); + expect(source).toContain("tr('redis_viewer.message.import_summary'"); + expect(source).toContain("tr('redis_viewer.message.import_file_required'"); + expect(source).toContain("tr('redis_viewer.message.import_selection_required'"); expect(source).toContain("tr('redis_viewer.state.empty_selection'"); + expect(source).toContain("tr('redis_viewer.state.import_preview_empty'"); expect(source).toContain("tr('redis_viewer.action.copy_key_name'"); + expect(source).toContain("tr('redis_viewer.action.export_all'"); + expect(source).toContain("tr('redis_viewer.action.export_selected'"); + expect(source).toContain("tr('redis_viewer.action.import'"); + expect(source).toContain("tr('redis_viewer.action.select_import_file'"); + expect(source).toContain("tr('redis_viewer.action.change_import_file'"); + expect(source).toContain("tr('redis_viewer.action.select_all_import_keys'"); }); it('localizes TTL and table labels with catalog keys', () => { diff --git a/frontend/src/components/RedisViewer.interaction.test.tsx b/frontend/src/components/RedisViewer.interaction.test.tsx index c866c09b..d80f2e61 100644 --- a/frontend/src/components/RedisViewer.interaction.test.tsx +++ b/frontend/src/components/RedisViewer.interaction.test.tsx @@ -30,6 +30,9 @@ const storeState = vi.hoisted(() => ({ const redisBackend = vi.hoisted(() => ({ RedisScanKeys: vi.fn(), RedisGetValue: vi.fn(), + RedisExportKeys: vi.fn(), + RedisPreviewImportKeys: vi.fn(), + RedisImportKeys: vi.fn(), })); const antdState = vi.hoisted(() => ({ @@ -109,7 +112,17 @@ vi.mock('antd', async () => { }, Spin: ({ children }: any) => React.createElement(React.Fragment, null, children), message: antdState.message, - Modal: Object.assign(passthrough('div'), { confirm: vi.fn() }), + Modal: Object.assign(({ children, open, onOk, onCancel, okButtonProps, title, ...props }: any) => { + if (!open) { + return null; + } + return React.createElement('div', props, [ + React.createElement('div', { key: 'title', 'data-modal-title': true }, title), + children, + onOk ? React.createElement('button', { key: 'ok', onClick: onOk, disabled: okButtonProps?.disabled }, 'modal-ok') : null, + onCancel ? React.createElement('button', { key: 'cancel', onClick: onCancel }, 'modal-cancel') : null, + ]); + }, { confirm: vi.fn() }), Form: FormComponent, InputNumber: ({ ...props }: any) => React.createElement('input', props), Popconfirm: passthrough('span'), @@ -153,6 +166,24 @@ const countLeafNodes = (nodes: any[]): number => { }, 0); }; +const findFirstLeafNode = (nodes: any[]): any | null => { + for (const node of nodes) { + if (!node || typeof node !== 'object') { + continue; + } + if (node.nodeType === 'leaf') { + return node; + } + if (Array.isArray(node.children)) { + const nested = findFirstLeafNode(node.children); + if (nested) { + return nested; + } + } + } + return null; +}; + describe('RedisViewer tree interactions', () => { beforeEach(() => { vi.clearAllMocks(); @@ -184,6 +215,26 @@ describe('RedisViewer tree interactions', () => { success: true, data: { key: 'app:user:1', type: 'string', ttl: -1, value: 'demo' }, }); + redisBackend.RedisExportKeys.mockResolvedValue({ + success: true, + data: { exported: 2 }, + }); + redisBackend.RedisPreviewImportKeys.mockResolvedValue({ + success: true, + data: { + file: 'C:\\tmp\\redis-keys.json', + database: 0, + total: 2, + keys: [ + { key: 'app:user:1', type: 'string', ttl: -1 }, + { key: 'app:user:2', type: 'string', ttl: 120 }, + ], + }, + }); + redisBackend.RedisImportKeys.mockResolvedValue({ + success: true, + data: { imported: 1, skipped: 0, total: 1 }, + }); vi.stubGlobal('window', { innerWidth: 1280, innerHeight: 800, @@ -317,4 +368,117 @@ describe('RedisViewer tree interactions', () => { renderer!.unmount(); }); + + it('exports the current filtered key set when the export-all action is clicked', async () => { + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await flushEffects(); + + const exportAllButton = findButtonByText(renderer!, 'Export all'); + expect(exportAllButton).toBeTruthy(); + + await act(async () => { + exportAllButton!.props.onClick?.(); + }); + await flushEffects(); + + expect(redisBackend.RedisExportKeys).toHaveBeenCalledWith( + expect.objectContaining({ type: 'redis', host: '127.0.0.1', port: 6379, redisDB: 0 }), + { scope: 'all', keys: [], pattern: '*' }, + ); + + renderer!.unmount(); + }); + + it('exports checked leaf keys when the export-selected action is clicked', async () => { + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await flushEffects(); + + const leafNode = findFirstLeafNode(antdState.treeProps.treeData); + expect(leafNode?.rawKey).toBe('app:user:1'); + + await act(async () => { + antdState.treeProps.onCheck?.( + { checked: [leafNode.key], halfChecked: [] }, + { checked: true, node: leafNode }, + ); + }); + await flushEffects(); + + const exportSelectedButton = findButtonByText(renderer!, 'Export selected'); + expect(exportSelectedButton).toBeTruthy(); + expect(exportSelectedButton?.props.disabled).toBe(false); + + await act(async () => { + exportSelectedButton!.props.onClick?.(); + }); + await flushEffects(); + + expect(redisBackend.RedisExportKeys).toHaveBeenCalledWith( + expect.objectContaining({ type: 'redis', host: '127.0.0.1', port: 6379, redisDB: 0 }), + { scope: 'selected', keys: ['app:user:1'], pattern: '*' }, + ); + + renderer!.unmount(); + }); + + it('imports only the checked preview keys from the selected file', async () => { + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await flushEffects(); + + const importButton = findButtonByText(renderer!, 'Import'); + expect(importButton).toBeTruthy(); + + await act(async () => { + importButton!.props.onClick?.(); + }); + await flushEffects(); + + const chooseFileButton = findButtonByText(renderer!, 'Select import file'); + expect(chooseFileButton).toBeTruthy(); + + await act(async () => { + chooseFileButton!.props.onClick?.(); + }); + await flushEffects(); + + expect(redisBackend.RedisPreviewImportKeys).toHaveBeenCalledWith( + expect.objectContaining({ type: 'redis', host: '127.0.0.1', port: 6379, redisDB: 0 }), + ); + + const secondCheckbox = renderer!.root.findByProps({ 'data-import-key': 'app:user:2' }); + await act(async () => { + secondCheckbox.props.onChange?.({ target: { checked: false } }); + }); + await flushEffects(); + + const modalOkButton = findButtonByText(renderer!, 'modal-ok'); + expect(modalOkButton).toBeTruthy(); + expect(modalOkButton?.props.disabled).toBe(false); + + await act(async () => { + modalOkButton!.props.onClick?.(); + }); + await flushEffects(); + + expect(redisBackend.RedisImportKeys).toHaveBeenCalledWith( + expect.objectContaining({ type: 'redis', host: '127.0.0.1', port: 6379, redisDB: 0 }), + { + conflictMode: 'overwrite', + file: 'C:\\tmp\\redis-keys.json', + scope: 'selected', + keys: ['app:user:1'], + }, + ); + + renderer!.unmount(); + }); }); diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index 38e454d0..6fa5fa25 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -31,6 +31,7 @@ import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme'; import { noAutoCapInputProps } from '../utils/inputAutoCap'; import { normalizeRedisSearchDraftChange, normalizeRedisSearchInput, type RedisSearchMode } from '../utils/redisSearchPattern'; import { decodeRedisUtf8Value, formatRedisStringValue, toHexDisplay } from '../utils/redisValueDisplay'; +import { isConnectionDataImportRestricted } from '../utils/connectionReadOnly'; import { t, type I18nParams } from '../i18n'; import { useOptionalI18n } from '../i18n/provider'; @@ -53,6 +54,19 @@ interface RedisViewerProps { redisDB: number; } +type RedisExportScope = 'all' | 'selected'; +type RedisImportConflictMode = 'overwrite' | 'skip'; +type RedisImportPreview = { + file: string; + exportedAt?: string; + database: number; + scope?: string; + pattern?: string; + sourceAppName?: string; + total: number; + keys: RedisKeyInfo[]; +}; + // Draggable divider uses direct DOM updates to avoid resize lag. const ResizableDivider: React.FC<{ onResizeEnd: (newWidth: number) => void; @@ -149,6 +163,14 @@ const isRedisKeyGoneErrorMessage = (messageText: string): boolean => { }; const normalizeToolbarText = (value: unknown): string => String(value || '').trim(); +const extractFilenameFromPath = (value: unknown): string => { + const normalized = String(value || '').trim().replace(/\\/g, '/'); + if (!normalized) { + return ''; + } + const segments = normalized.split('/'); + return segments[segments.length - 1] || normalized; +}; const mergeRedisKeyInfoLists = (existing: RedisKeyInfo[], incoming: RedisKeyInfo[]): RedisKeyInfo[] => { const keyMap = new Map(); @@ -214,6 +236,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { const redisTopology = useMemo(() => resolveRedisTopology(connection), [connection]); const redisSeedAddresses = useMemo(() => buildRedisSeedAddresses(connection), [connection]); const redisSentinelMaster = normalizeToolbarText(connection?.config?.redisSentinelMaster); + const importRestricted = isConnectionDataImportRestricted(connection?.config); const [keys, setKeys] = useState([]); const [loading, setLoading] = useState(false); @@ -223,6 +246,8 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { const [cursor, setCursor] = useState('0'); const [hasMore, setHasMore] = useState(false); const [loadingAllKeys, setLoadingAllKeys] = useState(false); + const [exportingScope, setExportingScope] = useState(null); + const [importingKeys, setImportingKeys] = useState(false); const [selectedKey, setSelectedKey] = useState(null); const [keyValue, setKeyValue] = useState(null); const [valueLoading, setValueLoading] = useState(false); @@ -234,6 +259,11 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { const [renameTargetKey, setRenameTargetKey] = useState(null); const [ttlModalOpen, setTtlModalOpen] = useState(false); const [ttlForm] = Form.useForm(); + const [importModalOpen, setImportModalOpen] = useState(false); + const [importPreviewLoading, setImportPreviewLoading] = useState(false); + const [importConflictMode, setImportConflictMode] = useState('overwrite'); + const [importPreview, setImportPreview] = useState(null); + const [importSelectedKeys, setImportSelectedKeys] = useState([]); const [selectedKeys, setSelectedKeys] = useState([]); const [editValue, setEditValue] = useState(''); const [treeContextMenu, setTreeContextMenu] = useState<{ x: number; y: number; rawKey: string } | null>(null); @@ -502,6 +532,161 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { setSelectedKeys([]); }, []); + const handleExportKeys = useCallback(async (scope: RedisExportScope) => { + const config = getConfig(); + if (!config) return; + + if (scope === 'selected' && selectedKeys.length === 0) { + message.warning(tr('redis_viewer.message.export_selection_required')); + return; + } + + setExportingScope(scope); + try { + const res = await (window as any).go.app.App.RedisExportKeys( + buildRpcConnectionConfig(config), + { + scope, + keys: scope === 'selected' ? selectedKeys : [], + pattern: searchPattern, + }, + ); + if (res?.success) { + const exportedCount = Number(res?.data?.exported ?? (scope === 'selected' ? selectedKeys.length : 0)); + message.success(tr('redis_viewer.message.export_success', { count: exportedCount })); + return; + } + if (String(res?.message || '').trim() === '已取消') { + return; + } + message.error(tr('redis_viewer.message.export_failed', { detail: res?.message || 'Unknown error' })); + } catch (e: any) { + message.error(tr('redis_viewer.message.export_failed', { detail: e?.message || String(e) })); + } finally { + setExportingScope(null); + } + }, [getConfig, searchPattern, selectedKeys, tr]); + + const resetImportModalState = useCallback(() => { + setImportModalOpen(false); + setImportPreview(null); + setImportSelectedKeys([]); + setImportPreviewLoading(false); + setImportConflictMode('overwrite'); + }, []); + + const handleChooseImportFile = useCallback(async () => { + const config = getConfig(); + if (!config) return; + + setImportPreviewLoading(true); + try { + const res = await (window as any).go.app.App.RedisPreviewImportKeys( + buildRpcConnectionConfig(config), + ); + if (res?.success) { + const previewData = (res.data || {}) as RedisImportPreview; + const previewKeys = Array.isArray(previewData.keys) ? previewData.keys : []; + const nextPreview: RedisImportPreview = { + file: String(previewData.file || '').trim(), + exportedAt: previewData.exportedAt, + database: Number(previewData.database ?? redisDB), + scope: String(previewData.scope || '').trim(), + pattern: String(previewData.pattern || '').trim(), + sourceAppName: String(previewData.sourceAppName || '').trim(), + total: Number(previewData.total ?? previewKeys.length), + keys: previewKeys, + }; + setImportPreview(nextPreview); + setImportSelectedKeys(previewKeys.map((item) => item.key)); + return; + } + if (String(res?.message || '').trim() === '已取消') { + return; + } + message.error(tr('redis_viewer.message.import_failed', { detail: res?.message || 'Unknown error' })); + } catch (e: any) { + message.error(tr('redis_viewer.message.import_failed', { detail: e?.message || String(e) })); + } finally { + setImportPreviewLoading(false); + } + }, [getConfig, redisDB, tr]); + + const handleOpenImportModal = useCallback(() => { + setImportModalOpen(true); + setImportPreview(null); + setImportSelectedKeys([]); + setImportPreviewLoading(false); + setImportConflictMode('overwrite'); + }, []); + + const handleConfirmImportKeys = useCallback(async () => { + const config = getConfig(); + if (!config) return; + if (!importPreview) { + message.warning(tr('redis_viewer.message.import_file_required')); + return; + } + if (importSelectedKeys.length === 0) { + message.warning(tr('redis_viewer.message.import_selection_required')); + return; + } + + setImportingKeys(true); + try { + const scope = importSelectedKeys.length === importPreview.keys.length ? 'all' : 'selected'; + const res = await (window as any).go.app.App.RedisImportKeys( + buildRpcConnectionConfig(config), + { + conflictMode: importConflictMode, + file: importPreview.file, + scope, + keys: scope === 'selected' ? importSelectedKeys : [], + }, + ); + if (res?.success) { + const imported = Number(res?.data?.imported ?? 0); + const skipped = Number(res?.data?.skipped ?? 0); + message.success(tr('redis_viewer.message.import_summary', { + imported, + skipped, + })); + resetImportModalState(); + setSelectedKeys([]); + setSelectedKey(null); + setKeyValue(null); + setCursor('0'); + loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false)); + return; + } + if (String(res?.message || '').trim() === '已取消') { + return; + } + message.error(tr('redis_viewer.message.import_failed', { detail: res?.message || 'Unknown error' })); + } catch (e: any) { + message.error(tr('redis_viewer.message.import_failed', { detail: e?.message || String(e) })); + } finally { + setImportingKeys(false); + } + }, [getConfig, importConflictMode, importPreview, importSelectedKeys, loadKeys, resetImportModalState, searchPattern, tr]); + + const importSelectedKeySet = useMemo(() => new Set(importSelectedKeys), [importSelectedKeys]); + const handleToggleImportPreviewKey = useCallback((key: string, checked: boolean) => { + setImportSelectedKeys((prev) => { + if (checked) { + return prev.includes(key) ? prev : [...prev, key]; + } + return prev.filter((item) => item !== key); + }); + }, []); + const handleSelectAllImportPreviewKeys = useCallback(() => { + if (!importPreview) return; + setImportSelectedKeys(importPreview.keys.map((item) => item.key)); + }, [importPreview]); + const handleClearImportPreviewSelection = useCallback(() => { + setImportSelectedKeys([]); + }, []); + const removeMissingKeyFromView = useCallback((missingKey: string) => { setKeys(prev => prev.filter(item => item.key !== missingKey)); setSelectedKeys(prev => prev.filter(item => item !== missingKey)); @@ -1995,6 +2180,32 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { + + + = ({ connectionId, redisDB }) => { + void handleConfirmImportKeys()} + onCancel={() => { + if (importingKeys || importPreviewLoading) return; + resetImportModalState(); + }} + width={760} + styles={{ content: redisModalContentStyle, header: { background: 'transparent', borderBottom: 'none', color: workbenchTheme.textPrimary }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none' } }} + > +
+ + + + {importPreview && ( + <> + {extractFilenameFromPath(importPreview.file)} + {tr('redis_viewer.label.import_selection', { selected: importSelectedKeys.length, total: importPreview.total })} + + )} + + + {importPreview ? ( + +
+ + + + +
+ {tr('redis_viewer.label.import_database', { database: importPreview.database })} +
+
+
+ {importPreview.keys.map((item) => { + const checked = importSelectedKeySet.has(item.key); + return ( + + ); + })} +
+
+ ) : ( +
+ {tr('redis_viewer.state.import_preview_empty')} +
+ )} + + setImportConflictMode(event.target.value as RedisImportConflictMode)} + > + {tr('redis_viewer.option.import_overwrite')} + {tr('redis_viewer.option.import_skip_existing')} + + +
+
+ {/* TTL Modal */} { expect(css).toMatch(/\.gn-v2-explorer-filter-tabs button \{[^}]*flex: 0 0 auto;[^}]*white-space: nowrap;/s); }); - it('shows a pending state while a connection root is loading', () => { + it('shows a pending state while a database node is loading', () => { const css = readV2ThemeCss(); const source = readSidebarSource(); const treeLoaderSource = readSourceFile('./sidebar/useSidebarTreeLoaders.tsx'); @@ -1131,6 +1131,7 @@ describe('Sidebar locate toolbar', () => { expect(source).toContain("export type SidebarConnectionState = 'loading' | 'success' | 'error';"); expect(treeLoaderSource).toContain("setConnectionStates(prev => ({ ...prev, [conn.id]: 'loading' }));"); + expect(treeLoaderSource).toContain("setConnectionStates(prev => ({ ...prev, [key as string]: 'loading' }));"); expect(titleRenderSource).toContain("let status: 'loading' | 'success' | 'error' | 'default' = 'default';"); expect(titleRenderSource).toContain("if (connectionStates[node.key] === 'loading') status = 'loading';"); expect(v2ContextMenuSource).toContain("const statusMap = new Map();"); diff --git a/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx b/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx index 33a2da8b..bdda3a11 100644 --- a/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx +++ b/frontend/src/components/sidebar/useSidebarTreeLoaders.tsx @@ -449,6 +449,7 @@ export const useSidebarTreeLoaders = ({ const loadKey = `tables-${conn.id}-${dbName}`; if (loadingNodesRef.current.has(loadKey)) return; loadingNodesRef.current.add(loadKey); + setConnectionStates(prev => ({ ...prev, [key as string]: 'loading' })); const dbQueries = savedQueries.filter(q => q.connectionId === conn.id && q.dbName === dbName); const queriesNode: TreeNode = { diff --git a/internal/app/methods_redis.go b/internal/app/methods_redis.go index 62357871..9f5fba9e 100644 --- a/internal/app/methods_redis.go +++ b/internal/app/methods_redis.go @@ -4,16 +4,23 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "math" "net/url" + "os" + "path/filepath" + "sort" "strconv" "strings" "sync" + "time" "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/logger" "GoNavi-Wails/internal/redis" + + "github.com/wailsapp/wails/v2/pkg/runtime" ) // Redis client cache @@ -24,6 +31,648 @@ var ( newRedisClientFunc = redis.NewRedisClient ) +const ( + redisTransferFileFormat = "gonavi.redis.keys" + redisTransferFileVersion = 1 + redisExportScanBatchSize int64 = 500 + redisExportStreamPageSize int64 = 500 +) + +var errRedisExportNoKeys = errors.New("redis export scope matched no keys") +var errRedisImportNoKeysSelected = errors.New("redis import scope selected no keys") + +type RedisExportKeysOptions struct { + Scope string `json:"scope,omitempty"` + Keys []string `json:"keys,omitempty"` + Pattern string `json:"pattern,omitempty"` +} + +type RedisImportKeysOptions struct { + ConflictMode string `json:"conflictMode,omitempty"` + Scope string `json:"scope,omitempty"` + Keys []string `json:"keys,omitempty"` + File string `json:"file,omitempty"` +} + +type RedisImportPreview struct { + File string `json:"file"` + ExportedAt string `json:"exportedAt,omitempty"` + Database int `json:"database"` + Scope string `json:"scope,omitempty"` + Pattern string `json:"pattern,omitempty"` + SourceAppName string `json:"sourceAppName,omitempty"` + Total int `json:"total"` + Keys []redis.RedisKeyInfo `json:"keys"` +} + +type redisTransferFile struct { + Format string `json:"format"` + Version int `json:"version"` + ExportedAt string `json:"exportedAt"` + Database int `json:"database"` + Scope string `json:"scope,omitempty"` + Pattern string `json:"pattern,omitempty"` + Keys []redisTransferEntry `json:"keys"` + SourceAppName string `json:"sourceAppName,omitempty"` +} + +type redisTransferEntry struct { + Key string `json:"key"` + Type string `json:"type"` + TTL int64 `json:"ttl"` + Value interface{} `json:"value"` +} + +func normalizeRedisExportScope(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "selected": + return "selected" + default: + return "all" + } +} + +func normalizeRedisImportConflictMode(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "skip": + return "skip" + default: + return "overwrite" + } +} + +func normalizeRedisImportScope(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "selected": + return "selected" + default: + return "all" + } +} + +func normalizeRedisTransferFilename(filename string) string { + trimmed := strings.TrimSpace(filename) + if trimmed == "" { + return "" + } + if strings.EqualFold(filepath.Ext(trimmed), ".json") { + return trimmed + } + return trimmed + ".json" +} + +func normalizeRedisTransferKeys(keys []string) []string { + seen := make(map[string]struct{}, len(keys)) + normalized := make([]string, 0, len(keys)) + for _, raw := range keys { + key := strings.TrimSpace(raw) + if key == "" { + continue + } + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + normalized = append(normalized, key) + } + return normalized +} + +func collectRedisKeysByPattern(client redis.RedisClient, pattern string) ([]string, error) { + normalizedPattern := strings.TrimSpace(pattern) + if normalizedPattern == "" { + normalizedPattern = "*" + } + + seen := make(map[string]struct{}) + keys := make([]string, 0) + var cursor uint64 + for { + result, err := client.ScanKeys(normalizedPattern, cursor, redisExportScanBatchSize) + if err != nil { + return nil, err + } + if result == nil { + break + } + for _, item := range result.Keys { + key := strings.TrimSpace(item.Key) + if key == "" { + continue + } + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + keys = append(keys, key) + } + nextCursor, err := parseRedisScanCursor(result.Cursor) + if err != nil { + return nil, err + } + if nextCursor == 0 { + break + } + cursor = nextCursor + } + + sort.Strings(keys) + return keys, nil +} + +func loadAllRedisStreamEntries(client redis.RedisClient, key string, pageSize int64) ([]redis.StreamEntry, error) { + if pageSize <= 0 { + pageSize = redisExportStreamPageSize + } + + streamEntries := make([]redis.StreamEntry, 0) + start := "-" + lastID := "" + + for { + batch, err := client.GetStream(key, start, "+", pageSize+1) + if err != nil { + return nil, err + } + rawCount := len(batch) + if rawCount == 0 { + break + } + if lastID != "" && batch[0].ID == lastID { + batch = batch[1:] + } + if len(batch) == 0 { + break + } + streamEntries = append(streamEntries, batch...) + lastID = batch[len(batch)-1].ID + if rawCount < int(pageSize+1) { + break + } + start = lastID + } + + return streamEntries, nil +} + +func buildRedisTransferEntry(client redis.RedisClient, key string) (redisTransferEntry, error) { + key = strings.TrimSpace(key) + if key == "" { + return redisTransferEntry{}, fmt.Errorf("redis key is empty") + } + + keyType, err := client.GetKeyType(key) + if err != nil { + return redisTransferEntry{}, err + } + + ttl, err := client.GetTTL(key) + if err != nil { + return redisTransferEntry{}, err + } + if ttl < -1 { + ttl = -1 + } + + entry := redisTransferEntry{ + Key: key, + Type: strings.TrimSpace(keyType), + TTL: ttl, + } + + switch entry.Type { + case "string": + value, err := client.GetString(key) + if err != nil { + return redisTransferEntry{}, err + } + entry.Value = value + case "hash": + value, err := client.GetHash(key) + if err != nil { + return redisTransferEntry{}, err + } + entry.Value = value + case "list": + value, err := client.GetList(key, 0, -1) + if err != nil { + return redisTransferEntry{}, err + } + entry.Value = value + case "set": + value, err := client.GetSet(key) + if err != nil { + return redisTransferEntry{}, err + } + sort.Strings(value) + entry.Value = value + case "zset": + value, err := client.GetZSet(key, 0, -1) + if err != nil { + return redisTransferEntry{}, err + } + entry.Value = value + case "stream": + value, err := loadAllRedisStreamEntries(client, key, redisExportStreamPageSize) + if err != nil { + return redisTransferEntry{}, err + } + entry.Value = value + default: + return redisTransferEntry{}, fmt.Errorf("unsupported redis type: %s", entry.Type) + } + + return entry, nil +} + +func buildRedisExportPayload(client redis.RedisClient, dbIndex int, options RedisExportKeysOptions) (redisTransferFile, error) { + scope := normalizeRedisExportScope(options.Scope) + pattern := strings.TrimSpace(options.Pattern) + + var ( + keys []string + err error + ) + if scope == "selected" { + keys = normalizeRedisTransferKeys(options.Keys) + } else { + keys, err = collectRedisKeysByPattern(client, pattern) + if err != nil { + return redisTransferFile{}, err + } + } + if len(keys) == 0 { + return redisTransferFile{}, errRedisExportNoKeys + } + + entries := make([]redisTransferEntry, 0, len(keys)) + for _, key := range keys { + entry, err := buildRedisTransferEntry(client, key) + if err != nil { + return redisTransferFile{}, fmt.Errorf("%s: %w", key, err) + } + entries = append(entries, entry) + } + + return redisTransferFile{ + Format: redisTransferFileFormat, + Version: redisTransferFileVersion, + ExportedAt: time.Now().UTC().Format(time.RFC3339), + Database: dbIndex, + Scope: scope, + Pattern: pattern, + Keys: entries, + SourceAppName: "GoNavi", + }, nil +} + +func normalizeRedisTransferStringMap(raw interface{}) (map[string]string, error) { + switch value := raw.(type) { + case map[string]string: + result := make(map[string]string, len(value)) + for key, item := range value { + result[key] = item + } + return result, nil + case map[string]interface{}: + result := make(map[string]string, len(value)) + for key, item := range value { + result[strings.TrimSpace(key)] = fmt.Sprint(item) + } + return result, nil + default: + return nil, fmt.Errorf("expected object value") + } +} + +func normalizeRedisTransferStringSlice(raw interface{}) ([]string, error) { + switch value := raw.(type) { + case []string: + return append([]string(nil), value...), nil + case []interface{}: + items := make([]string, 0, len(value)) + for _, item := range value { + items = append(items, fmt.Sprint(item)) + } + return items, nil + default: + return nil, fmt.Errorf("expected array value") + } +} + +func normalizeRedisTransferFloat(raw interface{}) (float64, error) { + switch value := raw.(type) { + case float64: + return value, nil + case float32: + return float64(value), nil + case int: + return float64(value), nil + case int64: + return float64(value), nil + case int32: + return float64(value), nil + case json.Number: + return value.Float64() + case string: + parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil { + return 0, err + } + return parsed, nil + default: + return 0, fmt.Errorf("expected numeric score") + } +} + +func normalizeRedisTransferZSetMembers(raw interface{}) ([]redis.ZSetMember, error) { + switch value := raw.(type) { + case []redis.ZSetMember: + return append([]redis.ZSetMember(nil), value...), nil + case []interface{}: + members := make([]redis.ZSetMember, 0, len(value)) + for _, item := range value { + row, ok := item.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("expected zset member object") + } + score, err := normalizeRedisTransferFloat(row["score"]) + if err != nil { + return nil, err + } + members = append(members, redis.ZSetMember{ + Member: fmt.Sprint(row["member"]), + Score: score, + }) + } + return members, nil + default: + return nil, fmt.Errorf("expected array value") + } +} + +func normalizeRedisTransferStreamEntries(raw interface{}) ([]redis.StreamEntry, error) { + switch value := raw.(type) { + case []redis.StreamEntry: + return append([]redis.StreamEntry(nil), value...), nil + case []interface{}: + entries := make([]redis.StreamEntry, 0, len(value)) + for _, item := range value { + row, ok := item.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("expected stream entry object") + } + fields, err := normalizeRedisTransferStringMap(row["fields"]) + if err != nil { + return nil, err + } + entries = append(entries, redis.StreamEntry{ + ID: strings.TrimSpace(fmt.Sprint(row["id"])), + Fields: fields, + }) + } + return entries, nil + default: + return nil, fmt.Errorf("expected array value") + } +} + +func normalizeRedisTransferEntry(entry redisTransferEntry) (redisTransferEntry, error) { + entry.Key = strings.TrimSpace(entry.Key) + entry.Type = strings.ToLower(strings.TrimSpace(entry.Type)) + if entry.Key == "" { + return redisTransferEntry{}, fmt.Errorf("redis key is empty") + } + if entry.TTL < -1 { + entry.TTL = -1 + } + + switch entry.Type { + case "string": + entry.Value = fmt.Sprint(entry.Value) + case "hash": + value, err := normalizeRedisTransferStringMap(entry.Value) + if err != nil { + return redisTransferEntry{}, err + } + entry.Value = value + case "list", "set": + value, err := normalizeRedisTransferStringSlice(entry.Value) + if err != nil { + return redisTransferEntry{}, err + } + entry.Value = value + case "zset": + value, err := normalizeRedisTransferZSetMembers(entry.Value) + if err != nil { + return redisTransferEntry{}, err + } + entry.Value = value + case "stream": + value, err := normalizeRedisTransferStreamEntries(entry.Value) + if err != nil { + return redisTransferEntry{}, err + } + entry.Value = value + default: + return redisTransferEntry{}, fmt.Errorf("unsupported redis type: %s", entry.Type) + } + + return entry, nil +} + +func parseRedisTransferFile(raw []byte) (redisTransferFile, error) { + if strings.TrimSpace(string(raw)) == "" { + return redisTransferFile{}, fmt.Errorf("redis transfer file is empty") + } + + var payload redisTransferFile + if err := json.Unmarshal(raw, &payload); err != nil { + return redisTransferFile{}, err + } + if strings.TrimSpace(payload.Format) != redisTransferFileFormat { + return redisTransferFile{}, fmt.Errorf("unsupported redis transfer format: %s", strings.TrimSpace(payload.Format)) + } + if payload.Version != redisTransferFileVersion { + return redisTransferFile{}, fmt.Errorf("unsupported redis transfer version: %d", payload.Version) + } + + normalizedKeys := make([]redisTransferEntry, 0, len(payload.Keys)) + for _, entry := range payload.Keys { + normalized, err := normalizeRedisTransferEntry(entry) + if err != nil { + return redisTransferFile{}, fmt.Errorf("%s: %w", strings.TrimSpace(entry.Key), err) + } + normalizedKeys = append(normalizedKeys, normalized) + } + payload.Keys = normalizedKeys + payload.Scope = normalizeRedisExportScope(payload.Scope) + payload.Pattern = strings.TrimSpace(payload.Pattern) + + return payload, nil +} + +func readRedisTransferFileFromPath(path string) (redisTransferFile, error) { + content, err := os.ReadFile(path) + if err != nil { + return redisTransferFile{}, err + } + return parseRedisTransferFile(content) +} + +func buildRedisImportPreview(file string, payload redisTransferFile) RedisImportPreview { + keys := make([]redis.RedisKeyInfo, 0, len(payload.Keys)) + for _, entry := range payload.Keys { + keys = append(keys, redis.RedisKeyInfo{ + Key: entry.Key, + Type: entry.Type, + TTL: entry.TTL, + }) + } + sort.Slice(keys, func(i, j int) bool { + return strings.ToLower(keys[i].Key) < strings.ToLower(keys[j].Key) + }) + return RedisImportPreview{ + File: strings.TrimSpace(file), + ExportedAt: payload.ExportedAt, + Database: payload.Database, + Scope: payload.Scope, + Pattern: payload.Pattern, + SourceAppName: payload.SourceAppName, + Total: len(keys), + Keys: keys, + } +} + +func selectRedisTransferEntriesForImport(payload redisTransferFile, options RedisImportKeysOptions) ([]redisTransferEntry, error) { + scope := normalizeRedisImportScope(options.Scope) + if scope != "selected" { + return append([]redisTransferEntry(nil), payload.Keys...), nil + } + + selectedKeys := normalizeRedisTransferKeys(options.Keys) + if len(selectedKeys) == 0 { + return nil, errRedisImportNoKeysSelected + } + + selectedKeySet := make(map[string]struct{}, len(selectedKeys)) + for _, key := range selectedKeys { + selectedKeySet[key] = struct{}{} + } + + entries := make([]redisTransferEntry, 0, len(selectedKeys)) + for _, entry := range payload.Keys { + if _, ok := selectedKeySet[entry.Key]; !ok { + continue + } + entries = append(entries, entry) + } + if len(entries) == 0 { + return nil, errRedisImportNoKeysSelected + } + return entries, nil +} + +func setRedisImportedTTL(client redis.RedisClient, key string, ttl int64) error { + if ttl < 0 { + return nil + } + return client.SetTTL(key, ttl) +} + +func importRedisTransferEntry(client redis.RedisClient, entry redisTransferEntry) error { + switch entry.Type { + case "string": + return client.SetString(entry.Key, entry.Value.(string), entry.TTL) + case "hash": + fields := entry.Value.(map[string]string) + fieldNames := make([]string, 0, len(fields)) + for field := range fields { + fieldNames = append(fieldNames, field) + } + sort.Strings(fieldNames) + for _, field := range fieldNames { + if err := client.SetHashField(entry.Key, field, fields[field]); err != nil { + return err + } + } + return setRedisImportedTTL(client, entry.Key, entry.TTL) + case "list": + items := entry.Value.([]string) + if len(items) == 0 { + return fmt.Errorf("redis list payload is empty") + } + if err := client.ListPush(entry.Key, items...); err != nil { + return err + } + return setRedisImportedTTL(client, entry.Key, entry.TTL) + case "set": + items := entry.Value.([]string) + if len(items) == 0 { + return fmt.Errorf("redis set payload is empty") + } + if err := client.SetAdd(entry.Key, items...); err != nil { + return err + } + return setRedisImportedTTL(client, entry.Key, entry.TTL) + case "zset": + items := entry.Value.([]redis.ZSetMember) + if len(items) == 0 { + return fmt.Errorf("redis zset payload is empty") + } + if err := client.ZSetAdd(entry.Key, items...); err != nil { + return err + } + return setRedisImportedTTL(client, entry.Key, entry.TTL) + case "stream": + items := entry.Value.([]redis.StreamEntry) + if len(items) == 0 { + return fmt.Errorf("redis stream payload is empty") + } + for _, item := range items { + if _, err := client.StreamAdd(entry.Key, item.Fields, item.ID); err != nil { + return err + } + } + return setRedisImportedTTL(client, entry.Key, entry.TTL) + default: + return fmt.Errorf("unsupported redis type: %s", entry.Type) + } +} + +func importRedisTransferPayload(client redis.RedisClient, payload redisTransferFile, options RedisImportKeysOptions) (map[string]int, error) { + conflictMode := normalizeRedisImportConflictMode(options.ConflictMode) + entries, err := selectRedisTransferEntriesForImport(payload, options) + if err != nil { + return nil, err + } + result := map[string]int{ + "total": len(entries), + } + + for _, entry := range entries { + exists, err := client.KeyExists(entry.Key) + if err != nil { + return nil, fmt.Errorf("%s: %w", entry.Key, err) + } + if exists { + if conflictMode == "skip" { + result["skipped"]++ + continue + } + if _, err := client.DeleteKeys([]string{entry.Key}); err != nil { + return nil, fmt.Errorf("%s: %w", entry.Key, err) + } + } + if err := importRedisTransferEntry(client, entry); err != nil { + return nil, fmt.Errorf("%s: %w", entry.Key, err) + } + result["imported"]++ + } + + return result, nil +} + // getRedisClient gets or creates a Redis client from cache func (a *App) getRedisClient(config connection.ConnectionConfig) (redis.RedisClient, error) { resolvedConfig, err := a.resolveConnectionSecrets(config) @@ -824,6 +1473,160 @@ func (a *App) RedisFlushDB(config connection.ConnectionConfig) connection.QueryR return connection.QueryResult{Success: true, Message: a.appText("redis.backend.message.flush_success", nil)} } +func (a *App) RedisExportKeys(config connection.ConnectionConfig, options RedisExportKeysOptions) connection.QueryResult { + config.Type = "redis" + scope := normalizeRedisExportScope(options.Scope) + if scope == "selected" && len(normalizeRedisTransferKeys(options.Keys)) == 0 { + return connection.QueryResult{Success: false, Message: a.appText("redis.backend.error.export_no_keys", nil)} + } + + defaultName := fmt.Sprintf("redis-db%d-keys.json", config.RedisDB) + if scope == "selected" { + defaultName = fmt.Sprintf("redis-db%d-selected-keys.json", config.RedisDB) + } + filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{ + Title: a.appText("file.backend.dialog.export_data", nil), + DefaultFilename: defaultName, + Filters: []runtime.FileFilter{ + { + DisplayName: a.appText("file.backend.filter.json_files", nil), + Pattern: "*.json", + }, + }, + }) + if err != nil || strings.TrimSpace(filename) == "" { + return connection.QueryResult{Success: false, Message: "已取消"} + } + filename = normalizeRedisTransferFilename(filename) + + client, err := a.getRedisClient(config) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + payload, err := buildRedisExportPayload(client, config.RedisDB, options) + if err != nil { + if errors.Is(err, errRedisExportNoKeys) { + return connection.QueryResult{Success: false, Message: a.appText("redis.backend.error.export_no_keys", nil)} + } + return connection.QueryResult{Success: false, Message: err.Error()} + } + + content, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return connection.QueryResult{Success: false, Message: a.appText("file.backend.error.write_failed", map[string]any{"detail": err.Error()})} + } + if err := os.WriteFile(filename, content, 0o644); err != nil { + return connection.QueryResult{Success: false, Message: a.appText("file.backend.error.write_failed", map[string]any{"detail": err.Error()})} + } + + return connection.QueryResult{ + Success: true, + Message: a.appText("redis.backend.message.export_success", nil), + Data: map[string]any{ + "exported": len(payload.Keys), + "file": filename, + }, + } +} + +func (a *App) openRedisImportTransferFileDialog(dbIndex int) (string, error) { + return runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ + Title: a.appText("file.backend.dialog.import_data", map[string]any{ + "table": fmt.Sprintf("db%d", dbIndex), + }), + Filters: []runtime.FileFilter{ + { + DisplayName: a.appText("file.backend.filter.json_files", nil), + Pattern: "*.json", + }, + { + DisplayName: a.appText("file.backend.filter.all_files", nil), + Pattern: "*", + }, + }, + }) +} + +func (a *App) RedisPreviewImportKeys(config connection.ConnectionConfig) connection.QueryResult { + config.Type = "redis" + selection, err := a.openRedisImportTransferFileDialog(config.RedisDB) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + if strings.TrimSpace(selection) == "" { + return connection.QueryResult{Success: false, Message: "已取消"} + } + + payload, err := readRedisTransferFileFromPath(selection) + if err != nil { + if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) { + return connection.QueryResult{Success: false, Message: a.appText("file.backend.error.open_file_failed", map[string]any{"detail": err.Error()})} + } + var syntaxErr *json.SyntaxError + if errors.As(err, &syntaxErr) { + return connection.QueryResult{Success: false, Message: a.appText("file.backend.error.import_json_parse_failed", map[string]any{"detail": err.Error()})} + } + return connection.QueryResult{Success: false, Message: a.appText("redis.backend.error.import_payload_invalid", map[string]any{"detail": err.Error()})} + } + + return connection.QueryResult{ + Success: true, + Data: buildRedisImportPreview(selection, payload), + } +} + +func (a *App) RedisImportKeys(config connection.ConnectionConfig, options RedisImportKeysOptions) connection.QueryResult { + config.Type = "redis" + selection := strings.TrimSpace(options.File) + var err error + if selection == "" { + selection, err = a.openRedisImportTransferFileDialog(config.RedisDB) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + } + if strings.TrimSpace(selection) == "" { + return connection.QueryResult{Success: false, Message: "已取消"} + } + + payload, err := readRedisTransferFileFromPath(selection) + if err != nil { + if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) { + return connection.QueryResult{Success: false, Message: a.appText("file.backend.error.open_file_failed", map[string]any{"detail": err.Error()})} + } + var syntaxErr *json.SyntaxError + if errors.As(err, &syntaxErr) { + return connection.QueryResult{Success: false, Message: a.appText("file.backend.error.import_json_parse_failed", map[string]any{"detail": err.Error()})} + } + return connection.QueryResult{Success: false, Message: a.appText("redis.backend.error.import_payload_invalid", map[string]any{"detail": err.Error()})} + } + + client, err := a.getRedisClient(config) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + + result, err := importRedisTransferPayload(client, payload, options) + if err != nil { + if errors.Is(err, errRedisImportNoKeysSelected) { + return connection.QueryResult{Success: false, Message: a.appText("redis.backend.error.import_no_keys_selected", nil)} + } + return connection.QueryResult{Success: false, Message: err.Error()} + } + + return connection.QueryResult{ + Success: true, + Message: a.appText("redis.backend.message.import_success", nil), + Data: map[string]any{ + "imported": result["imported"], + "skipped": result["skipped"], + "total": result["total"], + "file": selection, + }, + } +} + // CloseAllRedisClients closes all cached Redis clients (called on shutdown) func CloseAllRedisClients() { redisCacheMu.Lock() diff --git a/internal/app/methods_redis_i18n_test.go b/internal/app/methods_redis_i18n_test.go index 3ea0acf9..bd0c29ce 100644 --- a/internal/app/methods_redis_i18n_test.go +++ b/internal/app/methods_redis_i18n_test.go @@ -114,6 +114,19 @@ func TestRedisBackendOperationMessagesUseLocalizedText(t *testing.T) { rawMessages: []string{`Message: "清空成功"`}, keys: []string{"redis.backend.message.flush_success"}, }, + "func (a *App) RedisExportKeys": { + keys: []string{ + "redis.backend.error.export_no_keys", + "redis.backend.message.export_success", + }, + }, + "func (a *App) RedisImportKeys": { + keys: []string{ + "redis.backend.error.import_no_keys_selected", + "redis.backend.error.import_payload_invalid", + "redis.backend.message.import_success", + }, + }, } for signature, check := range checks { @@ -146,9 +159,14 @@ func TestRedisBackendOperationMessageCatalogKeysExist(t *testing.T) { "redis.backend.message.delete_success", "redis.backend.message.add_success", "redis.backend.message.flush_success", + "redis.backend.message.export_success", + "redis.backend.message.import_success", "redis.backend.error.command_required", "redis.backend.error.argument_required", "redis.backend.error.argument_invalid_type", + "redis.backend.error.export_no_keys", + "redis.backend.error.import_no_keys_selected", + "redis.backend.error.import_payload_invalid", "redis.backend.label.topology_sentinel", "redis.backend.label.topology_cluster", "redis.backend.label.topology_multi_node", diff --git a/internal/app/methods_redis_test.go b/internal/app/methods_redis_test.go index ce77ed38..23b906ba 100644 --- a/internal/app/methods_redis_test.go +++ b/internal/app/methods_redis_test.go @@ -2,6 +2,9 @@ package app import ( "errors" + "fmt" + "reflect" + "sort" "testing" "GoNavi-Wails/internal/connection" @@ -124,6 +127,243 @@ func (c *scriptedRedisClient) Connect(config connection.ConnectionConfig) error return c.connectErr } +type redisTransferTestClient struct { + capturingRedisClient + scanPages []*redislib.RedisScanResult + scanCallCount int + keyTypes map[string]string + ttls map[string]int64 + stringValues map[string]string + hashValues map[string]map[string]string + listValues map[string][]string + setValues map[string][]string + zsetValues map[string][]redislib.ZSetMember + streamValues map[string][]redislib.StreamEntry + existingKeys map[string]bool + deletedKeys []string + setStringCalls []struct { + key string + value string + ttl int64 + } + setTTLCalls []struct { + key string + ttl int64 + } + setHashFieldCalls []struct { + key string + field string + value string + } + listPushCalls []struct { + key string + values []string + } + setAddCalls []struct { + key string + members []string + } + zsetAddCalls []struct { + key string + members []redislib.ZSetMember + } + streamAddCalls []struct { + key string + fields map[string]string + id string + } +} + +func (c *redisTransferTestClient) ScanKeys(pattern string, cursor uint64, count int64) (*redislib.RedisScanResult, error) { + if c.scanCallCount >= len(c.scanPages) { + return &redislib.RedisScanResult{Cursor: "0"}, nil + } + page := c.scanPages[c.scanCallCount] + c.scanCallCount++ + return page, nil +} + +func (c *redisTransferTestClient) GetKeyType(key string) (string, error) { + if value, ok := c.keyTypes[key]; ok { + return value, nil + } + return "", fmt.Errorf("missing key type for %s", key) +} + +func (c *redisTransferTestClient) GetTTL(key string) (int64, error) { + if value, ok := c.ttls[key]; ok { + return value, nil + } + return -1, nil +} + +func (c *redisTransferTestClient) DeleteKeys(keys []string) (int64, error) { + c.deletedKeys = append(c.deletedKeys, keys...) + for _, key := range keys { + if c.existingKeys != nil { + delete(c.existingKeys, key) + } + } + return int64(len(keys)), nil +} + +func (c *redisTransferTestClient) KeyExists(key string) (bool, error) { + if c.existingKeys == nil { + return false, nil + } + return c.existingKeys[key], nil +} + +func (c *redisTransferTestClient) GetString(key string) (string, error) { + if value, ok := c.stringValues[key]; ok { + return value, nil + } + return "", fmt.Errorf("missing string value for %s", key) +} + +func (c *redisTransferTestClient) SetString(key, value string, ttl int64) error { + c.setStringCalls = append(c.setStringCalls, struct { + key string + value string + ttl int64 + }{key: key, value: value, ttl: ttl}) + if c.existingKeys == nil { + c.existingKeys = make(map[string]bool) + } + c.existingKeys[key] = true + return nil +} + +func (c *redisTransferTestClient) GetHash(key string) (map[string]string, error) { + if value, ok := c.hashValues[key]; ok { + cloned := make(map[string]string, len(value)) + for field, item := range value { + cloned[field] = item + } + return cloned, nil + } + return nil, fmt.Errorf("missing hash value for %s", key) +} + +func (c *redisTransferTestClient) SetHashField(key, field, value string) error { + c.setHashFieldCalls = append(c.setHashFieldCalls, struct { + key string + field string + value string + }{key: key, field: field, value: value}) + if c.existingKeys == nil { + c.existingKeys = make(map[string]bool) + } + c.existingKeys[key] = true + return nil +} + +func (c *redisTransferTestClient) GetList(key string, start, stop int64) ([]string, error) { + if value, ok := c.listValues[key]; ok { + return append([]string(nil), value...), nil + } + return nil, fmt.Errorf("missing list value for %s", key) +} + +func (c *redisTransferTestClient) ListPush(key string, values ...string) error { + c.listPushCalls = append(c.listPushCalls, struct { + key string + values []string + }{key: key, values: append([]string(nil), values...)}) + if c.existingKeys == nil { + c.existingKeys = make(map[string]bool) + } + c.existingKeys[key] = true + return nil +} + +func (c *redisTransferTestClient) GetSet(key string) ([]string, error) { + if value, ok := c.setValues[key]; ok { + return append([]string(nil), value...), nil + } + return nil, fmt.Errorf("missing set value for %s", key) +} + +func (c *redisTransferTestClient) SetAdd(key string, members ...string) error { + c.setAddCalls = append(c.setAddCalls, struct { + key string + members []string + }{key: key, members: append([]string(nil), members...)}) + if c.existingKeys == nil { + c.existingKeys = make(map[string]bool) + } + c.existingKeys[key] = true + return nil +} + +func (c *redisTransferTestClient) GetZSet(key string, start, stop int64) ([]redislib.ZSetMember, error) { + if value, ok := c.zsetValues[key]; ok { + return append([]redislib.ZSetMember(nil), value...), nil + } + return nil, fmt.Errorf("missing zset value for %s", key) +} + +func (c *redisTransferTestClient) ZSetAdd(key string, members ...redislib.ZSetMember) error { + c.zsetAddCalls = append(c.zsetAddCalls, struct { + key string + members []redislib.ZSetMember + }{key: key, members: append([]redislib.ZSetMember(nil), members...)}) + if c.existingKeys == nil { + c.existingKeys = make(map[string]bool) + } + c.existingKeys[key] = true + return nil +} + +func (c *redisTransferTestClient) GetStream(key, start, stop string, count int64) ([]redislib.StreamEntry, error) { + values, ok := c.streamValues[key] + if !ok { + return nil, fmt.Errorf("missing stream value for %s", key) + } + startIndex := 0 + if start != "" && start != "-" { + for index, item := range values { + if item.ID == start { + startIndex = index + break + } + } + } + if startIndex >= len(values) { + return []redislib.StreamEntry{}, nil + } + endIndex := len(values) + if count > 0 && startIndex+int(count) < endIndex { + endIndex = startIndex + int(count) + } + return append([]redislib.StreamEntry(nil), values[startIndex:endIndex]...), nil +} + +func (c *redisTransferTestClient) StreamAdd(key string, fields map[string]string, id string) (string, error) { + cloned := make(map[string]string, len(fields)) + for field, value := range fields { + cloned[field] = value + } + c.streamAddCalls = append(c.streamAddCalls, struct { + key string + fields map[string]string + id string + }{key: key, fields: cloned, id: id}) + if c.existingKeys == nil { + c.existingKeys = make(map[string]bool) + } + c.existingKeys[key] = true + return id, nil +} + +func (c *redisTransferTestClient) SetTTL(key string, ttl int64) error { + c.setTTLCalls = append(c.setTTLCalls, struct { + key string + ttl int64 + }{key: key, ttl: ttl}) + return nil +} + func TestRedisTestConnectionUsesIsolatedClientAndClosesIt(t *testing.T) { originalNewRedisClientFunc := newRedisClientFunc originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc @@ -601,3 +841,205 @@ func TestRedisDeleteHashFieldAcceptsStringSlice(t *testing.T) { t.Fatalf("unexpected deleted hash fields: %v", client.deletedHashFields) } } + +func TestBuildRedisExportPayloadCollectsPagedKeysAndSerializesAllSupportedShapes(t *testing.T) { + client := &redisTransferTestClient{ + scanPages: []*redislib.RedisScanResult{ + { + Keys: []redislib.RedisKeyInfo{ + {Key: "beta"}, + {Key: "alpha"}, + }, + Cursor: "1", + }, + { + Keys: []redislib.RedisKeyInfo{ + {Key: "alpha"}, + {Key: "events"}, + }, + Cursor: "0", + }, + }, + keyTypes: map[string]string{ + "alpha": "string", + "beta": "set", + "events": "stream", + }, + ttls: map[string]int64{ + "alpha": 120, + "beta": -1, + "events": 45, + }, + stringValues: map[string]string{ + "alpha": "value-1", + }, + setValues: map[string][]string{ + "beta": {"member-b", "member-a"}, + }, + streamValues: map[string][]redislib.StreamEntry{ + "events": { + {ID: "1710000000000-0", Fields: map[string]string{"kind": "start"}}, + {ID: "1710000000001-0", Fields: map[string]string{"kind": "finish"}}, + }, + }, + } + + payload, err := buildRedisExportPayload(client, 7, RedisExportKeysOptions{ + Scope: "all", + Pattern: "app:*", + }) + if err != nil { + t.Fatalf("buildRedisExportPayload returned error: %v", err) + } + + if payload.Format != redisTransferFileFormat || payload.Version != redisTransferFileVersion { + t.Fatalf("unexpected redis transfer header: %+v", payload) + } + if payload.Database != 7 || payload.Scope != "all" || payload.Pattern != "app:*" { + t.Fatalf("unexpected redis transfer metadata: %+v", payload) + } + if got := len(payload.Keys); got != 3 { + t.Fatalf("expected 3 exported keys, got %d", got) + } + if payload.Keys[0].Key != "alpha" || payload.Keys[1].Key != "beta" || payload.Keys[2].Key != "events" { + t.Fatalf("expected sorted exported keys, got %+v", payload.Keys) + } + + if value, ok := payload.Keys[0].Value.(string); !ok || value != "value-1" { + t.Fatalf("expected string payload for alpha, got %#v", payload.Keys[0].Value) + } + + setMembers, ok := payload.Keys[1].Value.([]string) + if !ok { + t.Fatalf("expected set payload slice, got %#v", payload.Keys[1].Value) + } + if !reflect.DeepEqual(setMembers, []string{"member-a", "member-b"}) { + t.Fatalf("expected sorted set members, got %v", setMembers) + } + + streamEntries, ok := payload.Keys[2].Value.([]redislib.StreamEntry) + if !ok { + t.Fatalf("expected stream payload slice, got %#v", payload.Keys[2].Value) + } + if len(streamEntries) != 2 || streamEntries[1].Fields["kind"] != "finish" { + t.Fatalf("unexpected stream payload: %+v", streamEntries) + } +} + +func TestImportRedisTransferPayloadHonorsConflictMode(t *testing.T) { + basePayload := redisTransferFile{ + Keys: []redisTransferEntry{ + {Key: "existing", Type: "string", TTL: 90, Value: "updated"}, + {Key: "profile", Type: "hash", TTL: 30, Value: map[string]string{"name": "neo", "role": "admin"}}, + {Key: "events", Type: "stream", TTL: -1, Value: []redislib.StreamEntry{{ID: "1710000000000-0", Fields: map[string]string{"status": "ok"}}}}, + }, + } + + skipClient := &redisTransferTestClient{ + existingKeys: map[string]bool{ + "existing": true, + }, + } + skipSummary, err := importRedisTransferPayload(skipClient, basePayload, RedisImportKeysOptions{ConflictMode: "skip"}) + if err != nil { + t.Fatalf("importRedisTransferPayload(skip) returned error: %v", err) + } + if skipSummary["imported"] != 2 || skipSummary["skipped"] != 1 || skipSummary["total"] != 3 { + t.Fatalf("unexpected skip import summary: %+v", skipSummary) + } + if len(skipClient.deletedKeys) != 0 { + t.Fatalf("skip mode should not delete existing keys, got %v", skipClient.deletedKeys) + } + if len(skipClient.setStringCalls) != 0 { + t.Fatalf("skip mode should not overwrite existing string keys, got %+v", skipClient.setStringCalls) + } + if len(skipClient.setHashFieldCalls) != 2 { + t.Fatalf("expected hash fields to import in skip mode, got %+v", skipClient.setHashFieldCalls) + } + sort.Slice(skipClient.setHashFieldCalls, func(i, j int) bool { + return skipClient.setHashFieldCalls[i].field < skipClient.setHashFieldCalls[j].field + }) + if skipClient.setHashFieldCalls[0].field != "name" || skipClient.setHashFieldCalls[1].field != "role" { + t.Fatalf("unexpected imported hash fields: %+v", skipClient.setHashFieldCalls) + } + if len(skipClient.setTTLCalls) != 1 || skipClient.setTTLCalls[0].key != "profile" || skipClient.setTTLCalls[0].ttl != 30 { + t.Fatalf("expected imported hash ttl to be restored, got %+v", skipClient.setTTLCalls) + } + if len(skipClient.streamAddCalls) != 1 || skipClient.streamAddCalls[0].id != "1710000000000-0" { + t.Fatalf("expected stream entry import in skip mode, got %+v", skipClient.streamAddCalls) + } + + overwriteClient := &redisTransferTestClient{ + existingKeys: map[string]bool{ + "existing": true, + }, + } + overwriteSummary, err := importRedisTransferPayload(overwriteClient, basePayload, RedisImportKeysOptions{ConflictMode: "overwrite"}) + if err != nil { + t.Fatalf("importRedisTransferPayload(overwrite) returned error: %v", err) + } + if overwriteSummary["imported"] != 3 || overwriteSummary["skipped"] != 0 || overwriteSummary["total"] != 3 { + t.Fatalf("unexpected overwrite import summary: %+v", overwriteSummary) + } + if !reflect.DeepEqual(overwriteClient.deletedKeys, []string{"existing"}) { + t.Fatalf("expected overwrite mode to delete the existing key first, got %v", overwriteClient.deletedKeys) + } + if len(overwriteClient.setStringCalls) != 1 { + t.Fatalf("expected overwrite mode to restore one string key, got %+v", overwriteClient.setStringCalls) + } + if overwriteClient.setStringCalls[0].key != "existing" || overwriteClient.setStringCalls[0].value != "updated" || overwriteClient.setStringCalls[0].ttl != 90 { + t.Fatalf("unexpected overwrite string restore call: %+v", overwriteClient.setStringCalls[0]) + } +} + +func TestImportRedisTransferPayloadHonorsSelectedScope(t *testing.T) { + payload := redisTransferFile{ + Keys: []redisTransferEntry{ + {Key: "existing", Type: "string", TTL: 90, Value: "updated"}, + {Key: "profile", Type: "hash", TTL: 30, Value: map[string]string{"name": "neo"}}, + {Key: "events", Type: "stream", TTL: -1, Value: []redislib.StreamEntry{{ID: "1710000000000-0", Fields: map[string]string{"status": "ok"}}}}, + }, + } + + client := &redisTransferTestClient{ + existingKeys: map[string]bool{ + "existing": true, + }, + } + result, err := importRedisTransferPayload(client, payload, RedisImportKeysOptions{ + Scope: "selected", + Keys: []string{"profile", "events"}, + ConflictMode: "overwrite", + }) + if err != nil { + t.Fatalf("importRedisTransferPayload(selected) returned error: %v", err) + } + if result["imported"] != 2 || result["skipped"] != 0 || result["total"] != 2 { + t.Fatalf("unexpected selected import summary: %+v", result) + } + if len(client.deletedKeys) != 0 { + t.Fatalf("selected import should not touch unselected keys, got %v", client.deletedKeys) + } + if len(client.setStringCalls) != 0 { + t.Fatalf("selected import should not restore unselected string keys, got %+v", client.setStringCalls) + } + if len(client.setHashFieldCalls) != 1 || client.setHashFieldCalls[0].key != "profile" { + t.Fatalf("expected only selected hash key import, got %+v", client.setHashFieldCalls) + } + if len(client.streamAddCalls) != 1 || client.streamAddCalls[0].key != "events" { + t.Fatalf("expected only selected stream key import, got %+v", client.streamAddCalls) + } +} + +func TestSelectRedisTransferEntriesForImportRequiresSelectedKeys(t *testing.T) { + payload := redisTransferFile{ + Keys: []redisTransferEntry{ + {Key: "alpha", Type: "string", TTL: -1, Value: "1"}, + }, + } + + _, err := selectRedisTransferEntriesForImport(payload, RedisImportKeysOptions{Scope: "selected"}) + if !errors.Is(err, errRedisImportNoKeysSelected) { + t.Fatalf("expected errRedisImportNoKeysSelected, got %v", err) + } +} diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index cc838fd9..7cadcfe1 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -6351,11 +6351,17 @@ "redis_viewer.action.delete_key": "Key löschen", "redis_viewer.action.delete_selected": "Auswahl löschen ({{count}})", "redis_viewer.action.edit": "Bearbeiten", + "redis_viewer.action.export_all": "Alle exportieren", + "redis_viewer.action.export_selected": "Auswahl exportieren", + "redis_viewer.action.change_import_file": "Importdatei wechseln", + "redis_viewer.action.import": "Importieren", "redis_viewer.action.load_all": "Alle laden", "redis_viewer.action.load_more": "Mehr laden", "redis_viewer.action.new_key": "Neu", "redis_viewer.action.refresh": "Aktualisieren", "redis_viewer.action.rename_key": "Key umbenennen", + "redis_viewer.action.select_all_import_keys": "Alle Import-Keys auswählen", + "redis_viewer.action.select_import_file": "Importdatei auswählen", "redis_viewer.action.select_all_loaded": "Alle geladenen auswählen", "redis_viewer.action.select_group": "Auswählen", "redis_viewer.action.set_ttl": "TTL setzen", @@ -6368,6 +6374,9 @@ "redis_viewer.confirm.delete_stream_entry": "Diesen Stream-Eintrag löschen?", "redis_viewer.field.field_name": "Feldname", "redis_viewer.field.fields_json": "Felder-JSON:", + "redis_viewer.field.import_conflict_mode": "Umgang mit vorhandenen Keys", + "redis_viewer.field.import_file": "Importdatei", + "redis_viewer.field.import_keys": "Zu importierende Keys", "redis_viewer.field.key": "Key", "redis_viewer.field.member": "Mitglied:", "redis_viewer.field.new_key_name": "Neuer Key-Name", @@ -6383,6 +6392,8 @@ "redis_viewer.label.keys_count": "Geladen: {{count}} Schlüssel", "redis_viewer.label.length": "Länge: {{count}}", "redis_viewer.label.node_count": "{{count}} Knoten", + "redis_viewer.label.import_database": "Quelldatenbank: db{{database}}", + "redis_viewer.label.import_selection": "{{selected}} / {{total}} Keys ausgewählt", "redis_viewer.label.original_key": "Ursprünglicher Key: {{key}}", "redis_viewer.message.add_failed": "Hinzufügen fehlgeschlagen: {{detail}}", "redis_viewer.message.add_success": "Hinzugefügt", @@ -6394,9 +6405,16 @@ "redis_viewer.message.delete_failed": "Löschen fehlgeschlagen: {{detail}}", "redis_viewer.message.delete_success": "Gelöscht", "redis_viewer.message.deleted_keys": "{{count}} Keys gelöscht", + "redis_viewer.message.export_failed": "Export fehlgeschlagen: {{detail}}", + "redis_viewer.message.export_selection_required": "Wählen Sie mindestens einen Key zum Exportieren aus", + "redis_viewer.message.export_success": "{{count}} Keys exportiert", "redis_viewer.message.fields_json_invalid": "Felder-JSON ist ungültig", "redis_viewer.message.fields_must_be_json_object": "Felder müssen ein JSON-Objekt sein", "redis_viewer.message.fields_required": "Geben Sie mindestens ein Feld an", + "redis_viewer.message.import_failed": "Import fehlgeschlagen: {{detail}}", + "redis_viewer.message.import_file_required": "Wählen Sie zuerst eine Importdatei aus", + "redis_viewer.message.import_selection_required": "Wählen Sie mindestens einen Key zum Importieren aus", + "redis_viewer.message.import_summary": "{{imported}} Keys importiert, {{skipped}} vorhandene Keys übersprungen", "redis_viewer.message.key_check_failed": "Ziel-Key konnte nicht geprüft werden: {{detail}}", "redis_viewer.message.key_missing_removed": "Der Key existiert nicht mehr oder ist abgelaufen und wurde aus der Liste entfernt", "redis_viewer.message.key_name_copied": "Key-Name kopiert", @@ -6422,10 +6440,13 @@ "redis_viewer.modal.edit_field": "Feld bearbeiten: {{field}}", "redis_viewer.modal.edit_index": "Index {{index}} bearbeiten", "redis_viewer.modal.edit_value": "Wert bearbeiten", + "redis_viewer.modal.import_keys": "Redis-Keys importieren", "redis_viewer.modal.new_key": "Neuer Key", "redis_viewer.modal.rename_key": "Key umbenennen", "redis_viewer.modal.set_ttl": "TTL setzen", "redis_viewer.modal.update_score": "Score aktualisieren", + "redis_viewer.option.import_overwrite": "Vorhandene Keys überschreiben", + "redis_viewer.option.import_skip_existing": "Vorhandene Keys überspringen", "redis_viewer.notice.large_keyspace_mode": "Leistungsmodus für große Keyspaces ist aktiviert. Die Knotendarstellung ist vereinfacht, und bis zu {{count}} ausgeklappte Gruppen bleiben erhalten.", "redis_viewer.placeholder.key_name": "Key-Name", "redis_viewer.placeholder.member_value": "Mitgliedswert eingeben", @@ -6440,6 +6461,7 @@ "redis_viewer.search.fuzzy": "Unscharf", "redis_viewer.state.connection_not_found": "Verbindung nicht gefunden", "redis_viewer.state.empty_selection": "Wählen Sie einen Key aus, um Details anzuzeigen", + "redis_viewer.state.import_preview_empty": "Wählen Sie eine Redis-Exportdatei aus, prüfen Sie die Vorschau und wählen Sie die zu importierenden Keys aus", "redis_viewer.table.action": "Aktionen", "redis_viewer.table.field": "Feld", "redis_viewer.table.fields": "Felder", @@ -6480,6 +6502,9 @@ "redis.backend.error.connect_attempt_failed": "Verbindungsversuch {{attempt}} fehlgeschlagen: {{detail}}", "redis.backend.error.connect_failed": "Redis-Verbindung fehlgeschlagen: {{detail}}", "redis.backend.error.connect_tls_setup_failed": "TLS-Einrichtung bei Versuch {{attempt}} fehlgeschlagen: {{detail}}", + "redis.backend.error.export_no_keys": "Keine Keys für den Export gefunden", + "redis.backend.error.import_no_keys_selected": "Es wurden keine Keys zum Importieren ausgewählt", + "redis.backend.error.import_payload_invalid": "Redis-Importdatei ist ungültig: {{detail}}", "redis.backend.error.invalid_node_address": "Ungültige Redis-Knotenadresse: {{address}}", "redis.backend.error.invalid_port": "Ungültiger Redis-Port: {{address}}", "redis.backend.error.node_address_required": "Redis-Knotenadresse darf nicht leer sein", @@ -6497,7 +6522,9 @@ "redis.backend.message.add_success": "Hinzufügen erfolgreich", "redis.backend.message.connect_success": "Verbindung erfolgreich", "redis.backend.message.delete_success": "Löschen erfolgreich", + "redis.backend.message.export_success": "Export erfolgreich", "redis.backend.message.flush_success": "Leeren erfolgreich", + "redis.backend.message.import_success": "Import erfolgreich", "redis.backend.message.rename_success": "Umbenennen erfolgreich", "redis.backend.message.select_db_success": "Datenbank gewechselt", "redis.backend.message.set_success": "Setzen erfolgreich", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 5ba87ce2..98600194 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -6351,11 +6351,17 @@ "redis_viewer.action.delete_key": "Delete Key", "redis_viewer.action.delete_selected": "Delete selected ({{count}})", "redis_viewer.action.edit": "Edit", + "redis_viewer.action.export_all": "Export all", + "redis_viewer.action.export_selected": "Export selected", + "redis_viewer.action.change_import_file": "Change import file", + "redis_viewer.action.import": "Import", "redis_viewer.action.load_all": "Load all", "redis_viewer.action.load_more": "Load more", "redis_viewer.action.new_key": "New", "redis_viewer.action.refresh": "Refresh", "redis_viewer.action.rename_key": "Rename Key", + "redis_viewer.action.select_all_import_keys": "Select all import Keys", + "redis_viewer.action.select_import_file": "Select import file", "redis_viewer.action.select_all_loaded": "Select all loaded", "redis_viewer.action.select_group": "Select", "redis_viewer.action.set_ttl": "Set TTL", @@ -6368,6 +6374,9 @@ "redis_viewer.confirm.delete_stream_entry": "Delete this Stream entry?", "redis_viewer.field.field_name": "Field name", "redis_viewer.field.fields_json": "Fields JSON:", + "redis_viewer.field.import_conflict_mode": "Existing Key handling", + "redis_viewer.field.import_file": "Import file", + "redis_viewer.field.import_keys": "Keys to import", "redis_viewer.field.key": "Key", "redis_viewer.field.member": "Member:", "redis_viewer.field.new_key_name": "New Key name", @@ -6383,6 +6392,8 @@ "redis_viewer.label.keys_count": "Loaded {{count}} Keys", "redis_viewer.label.length": "Length: {{count}}", "redis_viewer.label.node_count": "{{count}} nodes", + "redis_viewer.label.import_database": "Source DB: db{{database}}", + "redis_viewer.label.import_selection": "Selected {{selected}} / {{total}} Keys", "redis_viewer.label.original_key": "Original Key: {{key}}", "redis_viewer.message.add_failed": "Add failed: {{detail}}", "redis_viewer.message.add_success": "Added", @@ -6394,9 +6405,16 @@ "redis_viewer.message.delete_failed": "Delete failed: {{detail}}", "redis_viewer.message.delete_success": "Deleted", "redis_viewer.message.deleted_keys": "Deleted {{count}} Keys", + "redis_viewer.message.export_failed": "Export failed: {{detail}}", + "redis_viewer.message.export_selection_required": "Select at least one Key to export", + "redis_viewer.message.export_success": "Exported {{count}} Keys", "redis_viewer.message.fields_json_invalid": "Fields JSON is invalid", "redis_viewer.message.fields_must_be_json_object": "Fields must be a JSON object", "redis_viewer.message.fields_required": "Provide at least one field", + "redis_viewer.message.import_failed": "Import failed: {{detail}}", + "redis_viewer.message.import_file_required": "Select an import file first", + "redis_viewer.message.import_selection_required": "Select at least one Key to import", + "redis_viewer.message.import_summary": "Imported {{imported}} Keys, skipped {{skipped}} existing Keys", "redis_viewer.message.key_check_failed": "Failed to check target Key: {{detail}}", "redis_viewer.message.key_missing_removed": "Key no longer exists or has expired, and was removed from the list", "redis_viewer.message.key_name_copied": "Key name copied", @@ -6421,11 +6439,14 @@ "redis_viewer.modal.add_stream_entry": "Add Stream entry", "redis_viewer.modal.edit_field": "Edit field: {{field}}", "redis_viewer.modal.edit_index": "Edit index {{index}}", + "redis_viewer.modal.import_keys": "Import Redis Keys", "redis_viewer.modal.edit_value": "Edit value", "redis_viewer.modal.new_key": "New Key", "redis_viewer.modal.rename_key": "Rename Key", "redis_viewer.modal.set_ttl": "Set TTL", "redis_viewer.modal.update_score": "Update score", + "redis_viewer.option.import_overwrite": "Overwrite existing Keys", + "redis_viewer.option.import_skip_existing": "Skip existing Keys", "redis_viewer.notice.large_keyspace_mode": "Large keyspace performance mode is enabled. Node rendering is simplified and up to {{count}} expanded groups are kept.", "redis_viewer.placeholder.key_name": "Key name", "redis_viewer.placeholder.member_value": "Enter member value", @@ -6440,6 +6461,7 @@ "redis_viewer.search.fuzzy": "Fuzzy", "redis_viewer.state.connection_not_found": "Connection not found", "redis_viewer.state.empty_selection": "Select a Key to view details", + "redis_viewer.state.import_preview_empty": "Select a Redis export file to preview and choose Keys to import", "redis_viewer.table.action": "Actions", "redis_viewer.table.field": "Field", "redis_viewer.table.fields": "Fields", @@ -6480,6 +6502,9 @@ "redis.backend.error.connect_attempt_failed": "Attempt {{attempt}} connection failed: {{detail}}", "redis.backend.error.connect_failed": "Redis connection failed: {{detail}}", "redis.backend.error.connect_tls_setup_failed": "Attempt {{attempt}} TLS setup failed: {{detail}}", + "redis.backend.error.export_no_keys": "No Keys matched the export scope", + "redis.backend.error.import_no_keys_selected": "No Keys were selected for import", + "redis.backend.error.import_payload_invalid": "Redis import file is invalid: {{detail}}", "redis.backend.error.invalid_node_address": "Invalid Redis node address: {{address}}", "redis.backend.error.invalid_port": "Invalid Redis port: {{address}}", "redis.backend.error.node_address_required": "Redis node address cannot be empty", @@ -6497,7 +6522,9 @@ "redis.backend.message.add_success": "Add succeeded", "redis.backend.message.connect_success": "Connection succeeded", "redis.backend.message.delete_success": "Delete succeeded", + "redis.backend.message.export_success": "Export succeeded", "redis.backend.message.flush_success": "Flush succeeded", + "redis.backend.message.import_success": "Import succeeded", "redis.backend.message.rename_success": "Rename succeeded", "redis.backend.message.select_db_success": "Database switched", "redis.backend.message.set_success": "Set succeeded", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index 7e25ba24..09902d0c 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -6351,11 +6351,17 @@ "redis_viewer.action.delete_key": "Key を削除", "redis_viewer.action.delete_selected": "選択項目を削除({{count}})", "redis_viewer.action.edit": "編集", + "redis_viewer.action.export_all": "すべてエクスポート", + "redis_viewer.action.export_selected": "選択した Key をエクスポート", + "redis_viewer.action.change_import_file": "インポートファイルを変更", + "redis_viewer.action.import": "インポート", "redis_viewer.action.load_all": "すべて読み込む", "redis_viewer.action.load_more": "さらに読み込む", "redis_viewer.action.new_key": "新規", "redis_viewer.action.refresh": "更新", "redis_viewer.action.rename_key": "Key 名を変更", + "redis_viewer.action.select_all_import_keys": "インポート対象をすべて選択", + "redis_viewer.action.select_import_file": "インポートファイルを選択", "redis_viewer.action.select_all_loaded": "読み込み済みをすべて選択", "redis_viewer.action.select_group": "選択", "redis_viewer.action.set_ttl": "TTL を設定", @@ -6368,6 +6374,9 @@ "redis_viewer.confirm.delete_stream_entry": "この Stream エントリを削除しますか?", "redis_viewer.field.field_name": "フィールド名", "redis_viewer.field.fields_json": "フィールド JSON:", + "redis_viewer.field.import_conflict_mode": "既存 Key の扱い", + "redis_viewer.field.import_file": "インポートファイル", + "redis_viewer.field.import_keys": "インポートする Key", "redis_viewer.field.key": "Key", "redis_viewer.field.member": "メンバー:", "redis_viewer.field.new_key_name": "新しい Key 名", @@ -6383,6 +6392,8 @@ "redis_viewer.label.keys_count": "{{count}} 件の Key を読み込み済み", "redis_viewer.label.length": "長さ: {{count}}", "redis_viewer.label.node_count": "{{count}} ノード", + "redis_viewer.label.import_database": "元の DB: db{{database}}", + "redis_viewer.label.import_selection": "{{selected}} / {{total}} 件の Key を選択中", "redis_viewer.label.original_key": "元の Key: {{key}}", "redis_viewer.message.add_failed": "追加に失敗しました: {{detail}}", "redis_viewer.message.add_success": "追加しました", @@ -6394,9 +6405,16 @@ "redis_viewer.message.delete_failed": "削除に失敗しました: {{detail}}", "redis_viewer.message.delete_success": "削除しました", "redis_viewer.message.deleted_keys": "{{count}} 件の Key を削除しました", + "redis_viewer.message.export_failed": "エクスポートに失敗しました: {{detail}}", + "redis_viewer.message.export_selection_required": "エクスポートする Key を少なくとも 1 つ選択してください", + "redis_viewer.message.export_success": "{{count}} 個の Key をエクスポートしました", "redis_viewer.message.fields_json_invalid": "フィールド JSON が無効です", "redis_viewer.message.fields_must_be_json_object": "フィールドは JSON オブジェクトである必要があります", "redis_viewer.message.fields_required": "少なくとも 1 つのフィールドを指定してください", + "redis_viewer.message.import_failed": "インポートに失敗しました: {{detail}}", + "redis_viewer.message.import_file_required": "先にインポートファイルを選択してください", + "redis_viewer.message.import_selection_required": "インポートする Key を少なくとも 1 つ選択してください", + "redis_viewer.message.import_summary": "{{imported}} 個の Key をインポートし、既存の {{skipped}} 個をスキップしました", "redis_viewer.message.key_check_failed": "対象 Key の確認に失敗しました: {{detail}}", "redis_viewer.message.key_missing_removed": "Key は存在しないか期限切れのため、一覧から削除されました", "redis_viewer.message.key_name_copied": "Key 名をコピーしました", @@ -6422,10 +6440,13 @@ "redis_viewer.modal.edit_field": "フィールドを編集: {{field}}", "redis_viewer.modal.edit_index": "インデックス {{index}} を編集", "redis_viewer.modal.edit_value": "値を編集", + "redis_viewer.modal.import_keys": "Redis Key をインポート", "redis_viewer.modal.new_key": "新しい Key", "redis_viewer.modal.rename_key": "Key 名を変更", "redis_viewer.modal.set_ttl": "TTL を設定", "redis_viewer.modal.update_score": "スコアを更新", + "redis_viewer.option.import_overwrite": "既存 Key を上書き", + "redis_viewer.option.import_skip_existing": "既存 Key をスキップ", "redis_viewer.notice.large_keyspace_mode": "大規模 keyspace パフォーマンスモードが有効です。ノード描画は簡略化され、展開済みグループは最大 {{count}} 件まで保持されます。", "redis_viewer.placeholder.key_name": "Key 名", "redis_viewer.placeholder.member_value": "メンバー値を入力", @@ -6440,6 +6461,7 @@ "redis_viewer.search.fuzzy": "あいまい", "redis_viewer.state.connection_not_found": "接続が見つかりません", "redis_viewer.state.empty_selection": "詳細を表示する Key を選択してください", + "redis_viewer.state.import_preview_empty": "Redis エクスポートファイルを選択し、プレビューしてインポートする Key を選択してください", "redis_viewer.table.action": "操作", "redis_viewer.table.field": "フィールド", "redis_viewer.table.fields": "フィールド", @@ -6480,6 +6502,9 @@ "redis.backend.error.connect_attempt_failed": "{{attempt}} 回目の接続に失敗しました: {{detail}}", "redis.backend.error.connect_failed": "Redis への接続に失敗しました: {{detail}}", "redis.backend.error.connect_tls_setup_failed": "{{attempt}} 回目の TLS 設定に失敗しました: {{detail}}", + "redis.backend.error.export_no_keys": "エクスポート対象の Key がありません", + "redis.backend.error.import_no_keys_selected": "インポートする Key が選択されていません", + "redis.backend.error.import_payload_invalid": "Redis インポートファイルが無効です: {{detail}}", "redis.backend.error.invalid_node_address": "無効な Redis ノードアドレス: {{address}}", "redis.backend.error.invalid_port": "無効な Redis ポート: {{address}}", "redis.backend.error.node_address_required": "Redis ノードアドレスは空にできません", @@ -6497,7 +6522,9 @@ "redis.backend.message.add_success": "追加しました", "redis.backend.message.connect_success": "接続に成功しました", "redis.backend.message.delete_success": "削除しました", + "redis.backend.message.export_success": "エクスポートに成功しました", "redis.backend.message.flush_success": "消去しました", + "redis.backend.message.import_success": "インポートに成功しました", "redis.backend.message.rename_success": "名前を変更しました", "redis.backend.message.select_db_success": "データベースを切り替えました", "redis.backend.message.set_success": "設定しました", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index e931f31c..84e69e3a 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -6351,11 +6351,17 @@ "redis_viewer.action.delete_key": "Удалить Key", "redis_viewer.action.delete_selected": "Удалить выбранное ({{count}})", "redis_viewer.action.edit": "Редактировать", + "redis_viewer.action.export_all": "Экспортировать все", + "redis_viewer.action.export_selected": "Экспортировать выбранное", + "redis_viewer.action.change_import_file": "Изменить файл импорта", + "redis_viewer.action.import": "Импортировать", "redis_viewer.action.load_all": "Загрузить все", "redis_viewer.action.load_more": "Загрузить еще", "redis_viewer.action.new_key": "Создать", "redis_viewer.action.refresh": "Обновить", "redis_viewer.action.rename_key": "Переименовать Key", + "redis_viewer.action.select_all_import_keys": "Выбрать все Key для импорта", + "redis_viewer.action.select_import_file": "Выбрать файл импорта", "redis_viewer.action.select_all_loaded": "Выбрать все загруженные", "redis_viewer.action.select_group": "Выбрать", "redis_viewer.action.set_ttl": "Задать TTL", @@ -6368,6 +6374,9 @@ "redis_viewer.confirm.delete_stream_entry": "Удалить эту запись Stream?", "redis_viewer.field.field_name": "Имя поля", "redis_viewer.field.fields_json": "Поля JSON:", + "redis_viewer.field.import_conflict_mode": "Обработка существующих Key", + "redis_viewer.field.import_file": "Файл импорта", + "redis_viewer.field.import_keys": "Keys для импорта", "redis_viewer.field.key": "Key", "redis_viewer.field.member": "Участник:", "redis_viewer.field.new_key_name": "Новое имя Key", @@ -6383,6 +6392,8 @@ "redis_viewer.label.keys_count": "Загружено ключей: {{count}}", "redis_viewer.label.length": "Длина: {{count}}", "redis_viewer.label.node_count": "Узлов: {{count}}", + "redis_viewer.label.import_database": "Исходная БД: db{{database}}", + "redis_viewer.label.import_selection": "Выбрано {{selected}} из {{total}} Keys", "redis_viewer.label.original_key": "Исходный Key: {{key}}", "redis_viewer.message.add_failed": "Не удалось добавить: {{detail}}", "redis_viewer.message.add_success": "Добавлено", @@ -6394,9 +6405,16 @@ "redis_viewer.message.delete_failed": "Не удалось удалить: {{detail}}", "redis_viewer.message.delete_success": "Удалено", "redis_viewer.message.deleted_keys": "Удалено Keys: {{count}}", + "redis_viewer.message.export_failed": "Не удалось экспортировать: {{detail}}", + "redis_viewer.message.export_selection_required": "Выберите хотя бы один Key для экспорта", + "redis_viewer.message.export_success": "Экспортировано Keys: {{count}}", "redis_viewer.message.fields_json_invalid": "Поля JSON недействительны", "redis_viewer.message.fields_must_be_json_object": "Поля должны быть объектом JSON", "redis_viewer.message.fields_required": "Укажите хотя бы одно поле", + "redis_viewer.message.import_failed": "Не удалось импортировать: {{detail}}", + "redis_viewer.message.import_file_required": "Сначала выберите файл импорта", + "redis_viewer.message.import_selection_required": "Выберите хотя бы один Key для импорта", + "redis_viewer.message.import_summary": "Импортировано {{imported}} Keys, пропущено {{skipped}} существующих Keys", "redis_viewer.message.key_check_failed": "Не удалось проверить целевой Key: {{detail}}", "redis_viewer.message.key_missing_removed": "Key больше не существует или истек и был удален из списка", "redis_viewer.message.key_name_copied": "Имя Key скопировано", @@ -6422,10 +6440,13 @@ "redis_viewer.modal.edit_field": "Редактировать поле: {{field}}", "redis_viewer.modal.edit_index": "Редактировать индекс {{index}}", "redis_viewer.modal.edit_value": "Редактировать значение", + "redis_viewer.modal.import_keys": "Импортировать Redis Key", "redis_viewer.modal.new_key": "Новый Key", "redis_viewer.modal.rename_key": "Переименовать Key", "redis_viewer.modal.set_ttl": "Задать TTL", "redis_viewer.modal.update_score": "Обновить оценку", + "redis_viewer.option.import_overwrite": "Перезаписывать существующие Key", + "redis_viewer.option.import_skip_existing": "Пропускать существующие Key", "redis_viewer.notice.large_keyspace_mode": "Включен режим производительности для большого keyspace. Отрисовка узлов упрощена, сохраняется до {{count}} развернутых групп.", "redis_viewer.placeholder.key_name": "Имя Key", "redis_viewer.placeholder.member_value": "Введите значение участника", @@ -6440,6 +6461,7 @@ "redis_viewer.search.fuzzy": "Нечетко", "redis_viewer.state.connection_not_found": "Подключение не найдено", "redis_viewer.state.empty_selection": "Выберите Key, чтобы просмотреть детали", + "redis_viewer.state.import_preview_empty": "Выберите файл экспорта Redis, просмотрите его и отметьте Keys для импорта", "redis_viewer.table.action": "Действия", "redis_viewer.table.field": "Поле", "redis_viewer.table.fields": "Поля", @@ -6480,6 +6502,9 @@ "redis.backend.error.connect_attempt_failed": "Попытка подключения {{attempt}} завершилась ошибкой: {{detail}}", "redis.backend.error.connect_failed": "Не удалось подключиться к Redis: {{detail}}", "redis.backend.error.connect_tls_setup_failed": "Сбой настройки TLS на попытке {{attempt}}: {{detail}}", + "redis.backend.error.export_no_keys": "Нет Key для экспорта", + "redis.backend.error.import_no_keys_selected": "Не выбраны Keys для импорта", + "redis.backend.error.import_payload_invalid": "Файл импорта Redis недействителен: {{detail}}", "redis.backend.error.invalid_node_address": "Недопустимый адрес узла Redis: {{address}}", "redis.backend.error.invalid_port": "Недопустимый порт Redis: {{address}}", "redis.backend.error.node_address_required": "Адрес узла Redis не может быть пустым", @@ -6497,7 +6522,9 @@ "redis.backend.message.add_success": "Добавление выполнено", "redis.backend.message.connect_success": "Подключение выполнено", "redis.backend.message.delete_success": "Удаление выполнено", + "redis.backend.message.export_success": "Экспорт выполнен", "redis.backend.message.flush_success": "Очистка выполнена", + "redis.backend.message.import_success": "Импорт выполнен", "redis.backend.message.rename_success": "Переименование выполнено", "redis.backend.message.select_db_success": "База данных переключена", "redis.backend.message.set_success": "Значение задано", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index ceb18ac9..7ecef403 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -6351,11 +6351,17 @@ "redis_viewer.action.delete_key": "删除 Key", "redis_viewer.action.delete_selected": "删除选中项({{count}})", "redis_viewer.action.edit": "编辑", + "redis_viewer.action.export_all": "导出全部", + "redis_viewer.action.export_selected": "导出已选", + "redis_viewer.action.change_import_file": "更换导入文件", + "redis_viewer.action.import": "导入", "redis_viewer.action.load_all": "加载全部", "redis_viewer.action.load_more": "加载更多", "redis_viewer.action.new_key": "新建", "redis_viewer.action.refresh": "刷新", "redis_viewer.action.rename_key": "重命名 Key", + "redis_viewer.action.select_all_import_keys": "全选导入项", + "redis_viewer.action.select_import_file": "选择导入文件", "redis_viewer.action.select_all_loaded": "全选已加载", "redis_viewer.action.select_group": "选择", "redis_viewer.action.set_ttl": "设置 TTL", @@ -6368,6 +6374,9 @@ "redis_viewer.confirm.delete_stream_entry": "删除此 Stream 条目?", "redis_viewer.field.field_name": "字段名称", "redis_viewer.field.fields_json": "字段 JSON:", + "redis_viewer.field.import_conflict_mode": "同名 Key 处理", + "redis_viewer.field.import_file": "导入文件", + "redis_viewer.field.import_keys": "待导入 Key", "redis_viewer.field.key": "Key", "redis_viewer.field.member": "成员:", "redis_viewer.field.new_key_name": "新 Key 名称", @@ -6383,6 +6392,8 @@ "redis_viewer.label.keys_count": "已加载 {{count}} 个 Key", "redis_viewer.label.length": "长度:{{count}}", "redis_viewer.label.node_count": "{{count}} 个节点", + "redis_viewer.label.import_database": "来源库:db{{database}}", + "redis_viewer.label.import_selection": "已选择 {{selected}} / {{total}} 个 Key", "redis_viewer.label.original_key": "原 Key:{{key}}", "redis_viewer.message.add_failed": "新增失败:{{detail}}", "redis_viewer.message.add_success": "已新增", @@ -6394,9 +6405,16 @@ "redis_viewer.message.delete_failed": "删除失败:{{detail}}", "redis_viewer.message.delete_success": "已删除", "redis_viewer.message.deleted_keys": "已删除 {{count}} 个 Key", + "redis_viewer.message.export_failed": "导出失败:{{detail}}", + "redis_viewer.message.export_selection_required": "请先选择至少一个 Key", + "redis_viewer.message.export_success": "已导出 {{count}} 个 Key", "redis_viewer.message.fields_json_invalid": "字段 JSON 无效", "redis_viewer.message.fields_must_be_json_object": "字段必须是 JSON 对象", "redis_viewer.message.fields_required": "请至少提供一个字段", + "redis_viewer.message.import_failed": "导入失败:{{detail}}", + "redis_viewer.message.import_file_required": "请先选择导入文件", + "redis_viewer.message.import_selection_required": "至少选择一个要导入的 Key", + "redis_viewer.message.import_summary": "已导入 {{imported}} 个 Key,跳过 {{skipped}} 个已存在 Key", "redis_viewer.message.key_check_failed": "检查目标 Key 失败:{{detail}}", "redis_viewer.message.key_missing_removed": "Key 不再存在或已过期,已从列表中移除", "redis_viewer.message.key_name_copied": "Key 名称已复制", @@ -6422,10 +6440,13 @@ "redis_viewer.modal.edit_field": "编辑字段:{{field}}", "redis_viewer.modal.edit_index": "编辑索引 {{index}}", "redis_viewer.modal.edit_value": "编辑值", + "redis_viewer.modal.import_keys": "导入 Redis Key", "redis_viewer.modal.new_key": "新建 Key", "redis_viewer.modal.rename_key": "重命名 Key", "redis_viewer.modal.set_ttl": "设置 TTL", "redis_viewer.modal.update_score": "更新分数", + "redis_viewer.option.import_overwrite": "覆盖已存在 Key", + "redis_viewer.option.import_skip_existing": "跳过已存在 Key", "redis_viewer.notice.large_keyspace_mode": "大 Keyspace 性能模式已启用。节点渲染已简化,最多保留 {{count}} 个展开分组。", "redis_viewer.placeholder.key_name": "Key 名称", "redis_viewer.placeholder.member_value": "输入成员值", @@ -6440,6 +6461,7 @@ "redis_viewer.search.fuzzy": "模糊", "redis_viewer.state.connection_not_found": "未找到连接", "redis_viewer.state.empty_selection": "选择一个 Key 查看详情", + "redis_viewer.state.import_preview_empty": "请选择 Redis 导出文件,预览后勾选要导入的 Key", "redis_viewer.table.action": "操作", "redis_viewer.table.field": "字段", "redis_viewer.table.fields": "字段", @@ -6480,6 +6502,9 @@ "redis.backend.error.connect_attempt_failed": "第{{attempt}}次连接失败:{{detail}}", "redis.backend.error.connect_failed": "Redis 连接失败:{{detail}}", "redis.backend.error.connect_tls_setup_failed": "第{{attempt}}次 TLS 配置失败:{{detail}}", + "redis.backend.error.export_no_keys": "没有可导出的 Key", + "redis.backend.error.import_no_keys_selected": "未选择要导入的 Key", + "redis.backend.error.import_payload_invalid": "Redis 导入文件无效:{{detail}}", "redis.backend.error.invalid_node_address": "无效 Redis 节点地址:{{address}}", "redis.backend.error.invalid_port": "无效 Redis 端口:{{address}}", "redis.backend.error.node_address_required": "Redis 节点地址不能为空", @@ -6497,7 +6522,9 @@ "redis.backend.message.add_success": "添加成功", "redis.backend.message.connect_success": "连接成功", "redis.backend.message.delete_success": "删除成功", + "redis.backend.message.export_success": "导出成功", "redis.backend.message.flush_success": "清空成功", + "redis.backend.message.import_success": "导入成功", "redis.backend.message.rename_success": "重命名成功", "redis.backend.message.select_db_success": "切换成功", "redis.backend.message.set_success": "设置成功", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index 1773f155..424b85b7 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -6351,11 +6351,17 @@ "redis_viewer.action.delete_key": "刪除 Key", "redis_viewer.action.delete_selected": "刪除已選項目({{count}})", "redis_viewer.action.edit": "編輯", + "redis_viewer.action.export_all": "匯出全部", + "redis_viewer.action.export_selected": "匯出已選", + "redis_viewer.action.change_import_file": "更換匯入檔案", + "redis_viewer.action.import": "匯入", "redis_viewer.action.load_all": "載入全部", "redis_viewer.action.load_more": "載入更多", "redis_viewer.action.new_key": "新增", "redis_viewer.action.refresh": "重新整理", "redis_viewer.action.rename_key": "重新命名 Key", + "redis_viewer.action.select_all_import_keys": "全選匯入項目", + "redis_viewer.action.select_import_file": "選擇匯入檔案", "redis_viewer.action.select_all_loaded": "全選已載入", "redis_viewer.action.select_group": "選取", "redis_viewer.action.set_ttl": "設定 TTL", @@ -6368,6 +6374,9 @@ "redis_viewer.confirm.delete_stream_entry": "要刪除此 Stream 項目嗎?", "redis_viewer.field.field_name": "欄位名稱", "redis_viewer.field.fields_json": "欄位 JSON:", + "redis_viewer.field.import_conflict_mode": "同名 Key 處理", + "redis_viewer.field.import_file": "匯入檔案", + "redis_viewer.field.import_keys": "待匯入 Key", "redis_viewer.field.key": "Key", "redis_viewer.field.member": "成員:", "redis_viewer.field.new_key_name": "新 Key 名稱", @@ -6383,6 +6392,8 @@ "redis_viewer.label.keys_count": "已載入 {{count}} 個 Key", "redis_viewer.label.length": "長度:{{count}}", "redis_viewer.label.node_count": "{{count}} 個節點", + "redis_viewer.label.import_database": "來源庫:db{{database}}", + "redis_viewer.label.import_selection": "已選擇 {{selected}} / {{total}} 個 Key", "redis_viewer.label.original_key": "原 Key:{{key}}", "redis_viewer.message.add_failed": "新增失敗:{{detail}}", "redis_viewer.message.add_success": "已新增", @@ -6394,9 +6405,16 @@ "redis_viewer.message.delete_failed": "刪除失敗:{{detail}}", "redis_viewer.message.delete_success": "已刪除", "redis_viewer.message.deleted_keys": "已刪除 {{count}} 個 Key", + "redis_viewer.message.export_failed": "匯出失敗:{{detail}}", + "redis_viewer.message.export_selection_required": "請先選擇至少一個 Key", + "redis_viewer.message.export_success": "已匯出 {{count}} 個 Key", "redis_viewer.message.fields_json_invalid": "欄位 JSON 無效", "redis_viewer.message.fields_must_be_json_object": "欄位必須是 JSON 物件", "redis_viewer.message.fields_required": "請至少提供一個欄位", + "redis_viewer.message.import_failed": "匯入失敗:{{detail}}", + "redis_viewer.message.import_file_required": "請先選擇匯入檔案", + "redis_viewer.message.import_selection_required": "至少選擇一個要匯入的 Key", + "redis_viewer.message.import_summary": "已匯入 {{imported}} 個 Key,略過 {{skipped}} 個已存在 Key", "redis_viewer.message.key_check_failed": "檢查目標 Key 失敗:{{detail}}", "redis_viewer.message.key_missing_removed": "Key 已不存在或已過期,已從清單移除", "redis_viewer.message.key_name_copied": "Key 名稱已複製", @@ -6422,10 +6440,13 @@ "redis_viewer.modal.edit_field": "編輯欄位:{{field}}", "redis_viewer.modal.edit_index": "編輯索引 {{index}}", "redis_viewer.modal.edit_value": "編輯值", + "redis_viewer.modal.import_keys": "匯入 Redis Key", "redis_viewer.modal.new_key": "新增 Key", "redis_viewer.modal.rename_key": "重新命名 Key", "redis_viewer.modal.set_ttl": "設定 TTL", "redis_viewer.modal.update_score": "更新分數", + "redis_viewer.option.import_overwrite": "覆蓋已存在 Key", + "redis_viewer.option.import_skip_existing": "略過已存在 Key", "redis_viewer.notice.large_keyspace_mode": "大型 Keyspace 效能模式已啟用。節點渲染已簡化,最多保留 {{count}} 個展開群組。", "redis_viewer.placeholder.key_name": "Key 名稱", "redis_viewer.placeholder.member_value": "輸入成員值", @@ -6440,6 +6461,7 @@ "redis_viewer.search.fuzzy": "模糊", "redis_viewer.state.connection_not_found": "找不到連線", "redis_viewer.state.empty_selection": "選取一個 Key 以查看詳細資料", + "redis_viewer.state.import_preview_empty": "請選擇 Redis 匯出檔案,預覽後勾選要匯入的 Key", "redis_viewer.table.action": "操作", "redis_viewer.table.field": "欄位", "redis_viewer.table.fields": "欄位", @@ -6480,6 +6502,9 @@ "redis.backend.error.connect_attempt_failed": "第 {{attempt}} 次連線失敗:{{detail}}", "redis.backend.error.connect_failed": "Redis 連線失敗:{{detail}}", "redis.backend.error.connect_tls_setup_failed": "第 {{attempt}} 次 TLS 設定失敗:{{detail}}", + "redis.backend.error.export_no_keys": "沒有可匯出的 Key", + "redis.backend.error.import_no_keys_selected": "未選擇要匯入的 Key", + "redis.backend.error.import_payload_invalid": "Redis 匯入檔案無效:{{detail}}", "redis.backend.error.invalid_node_address": "無效 Redis 節點地址:{{address}}", "redis.backend.error.invalid_port": "無效 Redis 連接埠:{{address}}", "redis.backend.error.node_address_required": "Redis 節點地址不能為空", @@ -6497,7 +6522,9 @@ "redis.backend.message.add_success": "新增成功", "redis.backend.message.connect_success": "連線成功", "redis.backend.message.delete_success": "刪除成功", + "redis.backend.message.export_success": "匯出成功", "redis.backend.message.flush_success": "清空成功", + "redis.backend.message.import_success": "匯入成功", "redis.backend.message.rename_success": "重新命名成功", "redis.backend.message.select_db_success": "切換成功", "redis.backend.message.set_success": "設定成功",