From bce4e2323e0390117f0963301c009f5128ad60d3 Mon Sep 17 00:00:00 2001 From: Lykin <137850705+tiny-craft@users.noreply.github.com> Date: Mon, 18 Dec 2023 00:58:20 +0800 Subject: [PATCH] feat: add export capability for selected keys --- backend/services/browser_service.go | 75 ++++++++++++ backend/services/connection_service.go | 3 + backend/services/system_service.go | 27 ++++- frontend/src/App.vue | 2 + .../src/components/common/FileSaveInput.vue | 41 +++++++ .../components/dialogs/DeleteKeyDialog.vue | 4 +- .../components/dialogs/ExportKeyDialog.vue | 112 ++++++++++++++++++ frontend/src/components/icons/Export.vue | 45 +++++++ frontend/src/components/icons/Import.vue | 33 ++++++ .../src/components/sidebar/BrowserPane.vue | 13 ++ .../src/components/sidebar/BrowserTree.vue | 7 ++ frontend/src/langs/en-us.json | 10 ++ frontend/src/langs/zh-cn.json | 12 +- frontend/src/stores/browser.js | 56 +++++++++ frontend/src/stores/dialog.js | 29 ++++- 15 files changed, 461 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/common/FileSaveInput.vue create mode 100644 frontend/src/components/dialogs/ExportKeyDialog.vue create mode 100644 frontend/src/components/icons/Export.vue create mode 100644 frontend/src/components/icons/Import.vue diff --git a/backend/services/browser_service.go b/backend/services/browser_service.go index 50feb11..b0219b0 100644 --- a/backend/services/browser_service.go +++ b/backend/services/browser_service.go @@ -2,11 +2,15 @@ package services import ( "context" + "encoding/csv" + "encoding/hex" "encoding/json" "errors" "fmt" "github.com/redis/go-redis/v9" + "github.com/wailsapp/wails/v2/pkg/runtime" "net/url" + "os" "slices" "sort" "strconv" @@ -1936,6 +1940,77 @@ func (b *browserService) DeleteOneKey(server string, db int, k any) (resp types. return } +// ExportKey export keys +func (b *browserService) ExportKey(server string, db int, ks []any, path string) (resp types.JSResp) { + // connect a new connection to export keys + conf := Connection().getConnection(server) + if conf == nil { + resp.Msg = fmt.Sprintf("no connection profile named: %s", server) + return + } + var client redis.UniversalClient + var err error + var connConfig = conf.ConnectionConfig + connConfig.LastDB = db + if client, err = Connection().createRedisClient(connConfig); err != nil { + resp.Msg = err.Error() + return + } + // TODO: add cancel handle + ctx, cancelFunc := context.WithCancel(b.ctx) + defer cancelFunc() + + file, err := os.Create(path) + if err != nil { + resp.Msg = err.Error() + return + } + defer file.Close() + + writer := csv.NewWriter(file) + defer writer.Flush() + + runtime.EventsOnce(ctx, "export:stop:"+path, func(data ...any) { + cancelFunc() + }) + processEvent := "exporting:" + path + total := len(ks) + var exported, failed int64 + var canceled bool + for i, k := range ks { + param := map[string]any{ + "total": total, + "progress": i + 1, + "processing": k, + } + runtime.EventsEmit(b.ctx, processEvent, param) + + key := strutil.DecodeRedisKey(k) + content, dumpErr := client.Dump(ctx, key).Bytes() + if errors.Is(dumpErr, context.Canceled) { + canceled = true + break + } + if err = writer.Write([]string{hex.EncodeToString([]byte(key)), hex.EncodeToString(content)}); err != nil { + failed += 1 + } else { + exported += 1 + } + } + + resp.Success = true + resp.Data = struct { + Canceled bool `json:"canceled"` + Exported int64 `json:"exported"` + Failed int64 `json:"failed"` + }{ + Canceled: canceled, + Exported: exported, + Failed: failed, + } + return +} + // FlushDB flush database func (b *browserService) FlushDB(connName string, db int, async bool) (resp types.JSResp) { item, err := b.getRedisClient(connName, db) diff --git a/backend/services/connection_service.go b/backend/services/connection_service.go index 6deddfe..2b6ad8f 100644 --- a/backend/services/connection_service.go +++ b/backend/services/connection_service.go @@ -127,6 +127,9 @@ func (c *connectionService) buildOption(config types.ConnectionConfig) (*redis.O WriteTimeout: time.Duration(config.ExecTimeout) * time.Second, TLSConfig: tlsConfig, } + if config.LastDB > 0 { + option.DB = config.LastDB + } if sshClient != nil { option.Dialer = func(ctx context.Context, network, addr string) (net.Conn, error) { return sshClient.Dial(network, addr) diff --git a/backend/services/system_service.go b/backend/services/system_service.go index 453df81..3f73d7b 100644 --- a/backend/services/system_service.go +++ b/backend/services/system_service.go @@ -3,11 +3,11 @@ package services import ( "context" "github.com/wailsapp/wails/v2/pkg/runtime" - "log" "sync" "time" "tinyrdm/backend/consts" "tinyrdm/backend/types" + sliceutil "tinyrdm/backend/utils/slice" ) type systemService struct { @@ -50,7 +50,30 @@ func (s *systemService) SelectFile(title string) (resp types.JSResp) { ShowHiddenFiles: true, }) if err != nil { - log.Println(err) + resp.Msg = err.Error() + return + } + resp.Success = true + resp.Data = map[string]any{ + "path": filepath, + } + return +} + +// SaveFile open file dialog to save a file +func (s *systemService) SaveFile(title string, defaultName string, extensions []string) (resp types.JSResp) { + filters := sliceutil.Map(extensions, func(i int) runtime.FileFilter { + return runtime.FileFilter{ + Pattern: "*." + extensions[i], + } + }) + filepath, err := runtime.SaveFileDialog(s.ctx, runtime.SaveDialogOptions{ + Title: title, + ShowHiddenFiles: true, + DefaultFilename: defaultName, + Filters: filters, + }) + if err != nil { resp.Msg = err.Error() return } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d9cb7da..040ad45 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -18,6 +18,7 @@ import { WindowSetDarkTheme, WindowSetLightTheme } from 'wailsjs/runtime/runtime import { darkThemeOverrides, themeOverrides } from '@/utils/theme.js' import AboutDialog from '@/components/dialogs/AboutDialog.vue' import FlushDbDialog from '@/components/dialogs/FlushDbDialog.vue' +import ExportKeyDialog from '@/components/dialogs/ExportKeyDialog.vue' const prefStore = usePreferencesStore() const connectionStore = useConnectionStore() @@ -67,6 +68,7 @@ watch( + diff --git a/frontend/src/components/common/FileSaveInput.vue b/frontend/src/components/common/FileSaveInput.vue new file mode 100644 index 0000000..72046a0 --- /dev/null +++ b/frontend/src/components/common/FileSaveInput.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/frontend/src/components/dialogs/DeleteKeyDialog.vue b/frontend/src/components/dialogs/DeleteKeyDialog.vue index 76296a6..0fc260a 100644 --- a/frontend/src/components/dialogs/DeleteKeyDialog.vue +++ b/frontend/src/components/dialogs/DeleteKeyDialog.vue @@ -99,10 +99,10 @@ const onClose = () => { - + - + +import { computed, reactive, ref, watchEffect } from 'vue' +import useDialog from 'stores/dialog' +import { useI18n } from 'vue-i18n' +import useBrowserStore from 'stores/browser.js' +import FileSaveInput from '@/components/common/FileSaveInput.vue' +import { isEmpty, map, size } from 'lodash' +import { decodeRedisKey } from '@/utils/key_convert.js' +import dayjs from 'dayjs' + +const exportKeyForm = reactive({ + server: '', + db: 0, + keys: [], + file: '', +}) + +const dialogStore = useDialog() +const browserStore = useBrowserStore() +const loading = ref(false) +const deleting = ref(false) +watchEffect(() => { + if (dialogStore.exportKeyDialogVisible) { + const { server, db, keys } = dialogStore.exportKeyParam + exportKeyForm.server = server + exportKeyForm.db = db + exportKeyForm.keys = keys + // exportKeyForm.async = true + deleting.value = false + } +}) + +const keyLines = computed(() => { + return map(exportKeyForm.keys, (k) => decodeRedisKey(k)) +}) + +const exportEnable = computed(() => { + return !isEmpty(exportKeyForm.keys) && !isEmpty(exportKeyForm.file) +}) + +const i18n = useI18n() +const onConfirmDelete = async () => { + try { + deleting.value = true + const { server, db, keys, file } = exportKeyForm + browserStore.exportKeys(server, db, keys, file).catch((e) => {}) + } catch (e) { + $message.error(e.message) + return + } finally { + deleting.value = false + } + dialogStore.closeExportKeyDialog() +} + +const onClose = () => { + dialogStore.closeExportKeyDialog() +} + + + + + diff --git a/frontend/src/components/icons/Export.vue b/frontend/src/components/icons/Export.vue new file mode 100644 index 0000000..9d35eba --- /dev/null +++ b/frontend/src/components/icons/Export.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/frontend/src/components/icons/Import.vue b/frontend/src/components/icons/Import.vue new file mode 100644 index 0000000..bac6d7a --- /dev/null +++ b/frontend/src/components/icons/Import.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/frontend/src/components/sidebar/BrowserPane.vue b/frontend/src/components/sidebar/BrowserPane.vue index 3cb79e0..593cbc1 100644 --- a/frontend/src/components/sidebar/BrowserPane.vue +++ b/frontend/src/components/sidebar/BrowserPane.vue @@ -23,6 +23,7 @@ import useConnectionStore from 'stores/connections.js' import ListCheckbox from '@/components/icons/ListCheckbox.vue' import Close from '@/components/icons/Close.vue' import More from '@/components/icons/More.vue' +import Export from '@/components/icons/Export.vue' const props = defineProps({ server: String, @@ -146,6 +147,10 @@ const onDeleteChecked = () => { browserTreeRef.value?.deleteCheckedItems() } +const onExportChecked = () => { + browserTreeRef.value?.exportCheckedItems() +} + const onFlush = () => { dialogStore.openFlushDBDialog(props.server, props.db) } @@ -329,6 +334,14 @@ onMounted(() => onReload())