[
  {
    "path": ".github/CONTRIBUTING.md",
    "content": "## Tiny RDM Contribute Guide\n\n### Multi-language Contributions\n\n#### Adding New Language\n\n1. New file: Add a new JSON file in the [frontend/src/langs](../frontend/src/langs/), with the file naming format is \"\n   {language}-{region}.json\", e.g. English is \"en-us.json\", simplified Chinese is \"zh-cn.json\". Highly recommended to duplicate the [en-us.json](../frontend/src/langs/en-us.json) file and rename it.\n2. Fill content: Refer to [en-us.json](../frontend/src/langs/en-us.json), or duplicate the file and modify the language content.\n3. Update codes: Edit[frontend/src/langs/index.js](.../frontend/src/langs/index.js), import the new language data inside.\n    ```javascript\n    import en from './en-us'\n    // import your new localize file 'zh-cn' here\n    import zh from './zh-cn'\n    \n    export const lang = {\n        en,\n        // export new language data 'zh' here\n        zh,\n    }\n   ```\n4. Submit review once there are no issues with the translation context in the application. (learn how to submit)\n\n### Code Submission`(To be completed)`\n\n#### Pull Request Title\nThe format of PR's title like \"<type>: <description>\"\n- type: PR type\n- description: PR description\n\nPR type list below:\n\n| type     | description                                        |\n|----------|----------------------------------------------------|\n| revert   | Revert a commit                                    |\n| feat     | New features                                       |\n| perf     | Performance improvements                           |\n| fix      | Fix any bugs                                       |\n| style    | Style updates                                      |\n| docs     | Document updates                                   |\n| refactor | Code refactors                                     |\n| chore    | Some chores                                        |\n| ci       | Automation process configuration or script updates |\n"
  },
  {
    "path": ".github/CONTRIBUTING_zh.md",
    "content": "## Tiny RDM 代码贡献指南\n\n### 多国语言贡献\n\n#### 增加新的语言\n1. 创建文件：在[frontend/src/langs](../frontend/src/langs/)目录下新增语言配置JSON文件，文件名格式为“{语言}-{地区}.json”，如英文为“en-us.json”，简体中文为“zh-cn.json”，建议直接复制[en-us.json](../frontend/src/langs/en-us.json)文件进行改名。\n2. 填充内容：参考[en-us.json](../frontend/src/langs/en-us.json)，或者直接克隆一份文件，对语言部分内容进行修改。\n3. 代码修改：在[frontend/src/langs/index.js](.../frontend/src/langs/index.js)文件内导入新增的语言数据\n    ```javascript\n    import en from './en-us'\n    // import your new localize file 'zh-cn' here\n    import zh from './zh-cn'\n    \n    export const lang = {\n        en,\n        // export new language data 'zh' here\n        zh,\n    }\n    ```\n4. 检查应用中对应翻译语境无问题后，可提交审核（[查看如何提交](#pull_request)）\n\n### 代码提交`(待完善)`\n\n#### PR提交规范\nPR提交格式为“<type>: <description>”\n- type: 提交类型\n- description: 提交内容描述\n\n其中提交类型如下：\n\n| 提交类型     | 类型描述         |\n|----------|--------------|\n| revert   | 回退某个commit提交 |\n| feat     | 新功能/新特性      |\n| perf     | 功能、体验等方面的优化  |\n| fix      | 修复问题         |\n| style    | 样式相关修改       |\n| docs     | 文档更新         |\n| refactor | 代码重构         |\n| chore    | 杂项修改         |\n| ci       | 自动化流程配置或脚本修改 |\n\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: '[BUG]'\nlabels: ''\nassignees: ''\n---\n\n**Tiny RDM Version**\nWhat version of Tiny RDM are you using?\n\n**OS Version**\nWhich OS and version you launch? (Mac/Windows/Linux)\n\n**Redis Version**\nWhich version of Redis are you using?\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\nSteps to Reproduce:\n\n1.\n2.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: '[FEATURE]'\n---\n"
  },
  {
    "path": ".github/workflows/docker-publish.yml",
    "content": "name: Release Docker Image\nrun-name: ${{ github.event.release.tag_name || github.event.inputs.tag || 'manual' }}\n\non:\n  release:\n    types: [ published ]\n  workflow_dispatch:\n    inputs:\n      tag:\n        description: 'Version tag'\n        required: true\n        default: '1.0.0'\n\nenv:\n  REGISTRY: ghcr.io\n  IMAGE_NAME: tiny-craft/tiny-rdm\n\njobs:\n  build-and-push:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Log in to GitHub Container Registry\n        uses: docker/login-action@v3\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Extract metadata (tags, labels)\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n          tags: |\n            type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }}\n            type=raw,value=${{ github.event.inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }}\n            type=raw,value=latest\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Determine version\n        id: version\n        run: |\n          if [ \"${{ github.event_name }}\" = \"release\" ]; then\n            echo \"value=${GITHUB_REF_NAME#v}\" >> \"$GITHUB_OUTPUT\"\n          else\n            echo \"value=${{ github.event.inputs.tag }}\" >> \"$GITHUB_OUTPUT\"\n          fi\n\n      - name: Build and push Docker image\n        uses: docker/build-push-action@v6\n        with:\n          context: .\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          platforms: linux/amd64,linux/arm64\n          provenance: false\n          sbom: false\n          build-args: |\n            APP_VERSION=${{ steps.version.outputs.value }}\n"
  },
  {
    "path": ".github/workflows/release-linux-webkit2-41.yaml",
    "content": "name: Release Linux App\nrun-name: ${{ github.event.release.tag_name || github.event.inputs.tag }}\n\non:\n  release:\n    types: [ published ]\n  workflow_dispatch:\n    inputs:\n      tag:\n        description: 'Version tag'\n        required: true\n        default: '1.0.0'\n\njobs:\n  release:\n    name: Release Linux App\n    runs-on: ubuntu-24.04\n    strategy:\n      matrix:\n        platform:\n          - linux/amd64\n\n    steps:\n      - name: Checkout source code\n        uses: actions/checkout@v3\n\n      - name: Normalise platform tag\n        id: normalise_platform\n        shell: bash\n        run: |\n          tag=$(echo ${{ matrix.platform }} | sed -e 's/\\//_/g')\n          echo \"tag=$tag\" >> \"$GITHUB_OUTPUT\"\n\n      - name: Normalise platform arch\n        id: normalise_platform_arch\n        run: |\n           if [ \"${{ matrix.platform }}\" == \"linux/amd64\" ]; then\n             echo \"arch=x86_64\" >> \"$GITHUB_OUTPUT\"\n           elif [ \"${{ matrix.platform }}\" == \"linux/aarch64\" ]; then\n             echo \"arch=aarch64\" >> \"$GITHUB_OUTPUT\"\n           fi\n\n      - name: Normalise version tag\n        id: normalise_version\n        shell: bash\n        run: |\n          if [ \"${{ github.event.release.tag_name }}\" == \"\" ]; then\n            version=$(echo ${{ github.event.inputs.tag }} | sed -e 's/v//g')\n            echo \"version=$version\" >> \"$GITHUB_OUTPUT\"\n          else\n            version=$(echo ${{ github.event.release.tag_name }} | sed -e 's/v//g')\n            echo \"version=$version\" >> \"$GITHUB_OUTPUT\"\n          fi\n\n      - name: Setup Go\n        uses: actions/setup-go@v6\n        with:\n          go-version: stable\n\n      - name: Install wails\n        shell: bash\n        run: go install github.com/wailsapp/wails/v2/cmd/wails@latest\n\n      - name: Install Ubuntu prerequisites\n        shell: bash\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libfuse-dev libfuse2\n\n      - name: Setup Node\n        uses: actions/setup-node@v3\n        with:\n          node-version: 22\n\n      - name: Build frontend assets\n        shell: bash\n        run: |\n          npm install -g npm@9\n          jq '.info.productVersion = \"${{ steps.normalise_version.outputs.version }}\"' wails.json > tmp.json\n          mv tmp.json wails.json\n          cd frontend\n          jq '.version = \"${{ steps.normalise_version.outputs.version }}\"' package.json > tmp.json\n          mv tmp.json package.json\n          npm install\n\n      - name: Build wails app for Linux\n        shell: bash\n        run: |\n          CGO_ENABLED=1 wails build -platform ${{ matrix.platform }} \\\n          -ldflags \"-X main.version=v${{ steps.normalise_version.outputs.version }} -X main.gaMeasurementID=${{ secrets.GA_MEASUREMENT_ID }} -X main.gaSecretKey=${{ secrets.LINUX_GA_SECRET }}\" \\\n          -tags webkit2_41 \\\n          -o tiny-rdm\n\n      - name: Setup control template\n        shell: bash\n        run: |\n          content=$(cat build/linux/tiny-rdm_0.0.0_amd64/DEBIAN/control)\n          name=$(jq -r '.name' wails.json | tr -d ' ' | tr '[:upper:]' '[:lower:]')\n          content=$(echo \"$content\" | sed -e \"s/{{.Name}}/$name/g\")\n          content=$(echo \"$content\" | sed -e \"s/{{.Info.ProductVersion}}/$(jq -r '.info.productVersion' wails.json)/g\")\n          content=$(echo \"$content\" | sed -e \"s/{{.Author.Name}}/$(jq -r '.author.name' wails.json)/g\")\n          content=$(echo \"$content\" | sed -e \"s/{{.Author.Email}}/$(jq -r '.author.email' wails.json)/g\")\n          content=$(echo \"$content\" | sed -e \"s/{{.Info.Comments}}/$(jq -r '.info.comments' wails.json)/g\")\n          content=$(echo \"$content\" | sed -e \"s/{{.libwebkit2gtk.PackageName}}/libwebkit2gtk-4.1-0/g\")\n          echo $content\n          echo \"$content\" > build/linux/tiny-rdm_0.0.0_amd64/DEBIAN/control\n\n      - name: Setup app template\n        shell: bash\n        run: |\n          content=$(cat build/linux/tiny-rdm_0.0.0_amd64/usr/share/applications/tiny-rdm.desktop)\n          content=$(echo \"$content\" | sed -e \"s/{{.Info.ProductName}}/$(jq -r '.info.productName' wails.json)/g\")\n          content=$(echo \"$content\" | sed -e \"s/{{.Info.Comments}}/$(jq -r '.info.comments' wails.json)/g\")\n          echo $content\n          echo \"$content\" > build/linux/tiny-rdm_0.0.0_amd64/usr/share/applications/tiny-rdm.desktop\n\n      - name: Package up deb file\n        shell: bash\n        run: |\n          mv build/bin/tiny-rdm build/linux/tiny-rdm_0.0.0_amd64/usr/local/bin/\n          cd build/linux\n          mv tiny-rdm_0.0.0_amd64 \"tiny-rdm_${{ steps.normalise_version.outputs.version }}_amd64\"\n          sed -i 's/0.0.0/${{ steps.normalise_version.outputs.version }}/g' \"tiny-rdm_${{ steps.normalise_version.outputs.version }}_amd64/DEBIAN/control\"\n          dpkg-deb --build -Zxz \"tiny-rdm_${{ steps.normalise_version.outputs.version }}_amd64\"\n\n      - name: Rename deb\n        working-directory: ./build/linux\n        run: mv \"tiny-rdm_${{ steps.normalise_version.outputs.version }}_amd64.deb\" \"tiny-rdm_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}_webkit2_41.deb\"\n\n      - name: Upload release asset\n        uses: softprops/action-gh-release@v1\n        with:\n          tag_name: v${{ steps.normalise_version.outputs.version }}\n          files: |\n            ./build/linux/tiny-rdm_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}_webkit2_41.deb\n          token: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/release-linux.yaml",
    "content": "name: Release Linux App\nrun-name: ${{ github.event.release.tag_name || github.event.inputs.tag }}\n\non:\n  release:\n    types: [ published ]\n  workflow_dispatch:\n    inputs:\n      tag:\n        description: 'Version tag'\n        required: true\n        default: '1.0.0'\n\njobs:\n  release:\n    name: Release Linux App\n    runs-on: ubuntu-22.04\n    strategy:\n      matrix:\n        platform:\n          - linux/amd64\n\n    steps:\n      - name: Checkout source code\n        uses: actions/checkout@v3\n\n      - name: Normalise platform tag\n        id: normalise_platform\n        shell: bash\n        run: |\n          tag=$(echo ${{ matrix.platform }} | sed -e 's/\\//_/g')\n          echo \"tag=$tag\" >> \"$GITHUB_OUTPUT\"\n\n      - name: Normalise platform arch\n        id: normalise_platform_arch\n        run: |\n           if [ \"${{ matrix.platform }}\" == \"linux/amd64\" ]; then\n             echo \"arch=x86_64\" >> \"$GITHUB_OUTPUT\"\n           elif [ \"${{ matrix.platform }}\" == \"linux/aarch64\" ]; then\n             echo \"arch=aarch64\" >> \"$GITHUB_OUTPUT\"\n           fi\n\n      - name: Normalise version tag\n        id: normalise_version\n        shell: bash\n        run: |\n          if [ \"${{ github.event.release.tag_name }}\" == \"\" ]; then\n            version=$(echo ${{ github.event.inputs.tag }} | sed -e 's/v//g')\n            echo \"version=$version\" >> \"$GITHUB_OUTPUT\"\n          else\n            version=$(echo ${{ github.event.release.tag_name }} | sed -e 's/v//g')\n            echo \"version=$version\" >> \"$GITHUB_OUTPUT\"\n          fi\n\n      - name: Setup Go\n        uses: actions/setup-go@v6\n        with:\n          go-version: stable\n\n      - name: Install wails\n        shell: bash\n        run: go install github.com/wailsapp/wails/v2/cmd/wails@latest\n\n      - name: Install Ubuntu prerequisites\n        shell: bash\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libfuse-dev libfuse2\n\n      - name: Setup Node\n        uses: actions/setup-node@v3\n        with:\n          node-version: 22\n\n      - name: Build frontend assets\n        shell: bash\n        run: |\n          npm install -g npm@9\n          jq '.info.productVersion = \"${{ steps.normalise_version.outputs.version }}\"' wails.json > tmp.json\n          mv tmp.json wails.json\n          cd frontend\n          jq '.version = \"${{ steps.normalise_version.outputs.version }}\"' package.json > tmp.json\n          mv tmp.json package.json\n          npm install\n\n      - name: Build wails app for Linux\n        shell: bash\n        run: |\n          CGO_ENABLED=1 wails build -platform ${{ matrix.platform }} \\\n          -ldflags \"-X main.version=v${{ steps.normalise_version.outputs.version }} -X main.gaMeasurementID=${{ secrets.GA_MEASUREMENT_ID }} -X main.gaSecretKey=${{ secrets.LINUX_GA_SECRET }}\" \\\n          -o tiny-rdm\n\n      - name: Setup control template\n        shell: bash\n        run: |\n          content=$(cat build/linux/tiny-rdm_0.0.0_amd64/DEBIAN/control)\n          name=$(jq -r '.name' wails.json | tr -d ' ' | tr '[:upper:]' '[:lower:]')\n          content=$(echo \"$content\" | sed -e \"s/{{.Name}}/$name/g\")\n          content=$(echo \"$content\" | sed -e \"s/{{.Info.ProductVersion}}/$(jq -r '.info.productVersion' wails.json)/g\")\n          content=$(echo \"$content\" | sed -e \"s/{{.Author.Name}}/$(jq -r '.author.name' wails.json)/g\")\n          content=$(echo \"$content\" | sed -e \"s/{{.Author.Email}}/$(jq -r '.author.email' wails.json)/g\")\n          content=$(echo \"$content\" | sed -e \"s/{{.Info.Comments}}/$(jq -r '.info.comments' wails.json)/g\")\n          content=$(echo \"$content\" | sed -e \"s/{{.libwebkit2gtk.PackageName}}/libwebkit2gtk-4.0-37/g\")\n          echo $content\n          echo \"$content\" > build/linux/tiny-rdm_0.0.0_amd64/DEBIAN/control\n\n      - name: Setup app template\n        shell: bash\n        run: |\n          content=$(cat build/linux/tiny-rdm_0.0.0_amd64/usr/share/applications/tiny-rdm.desktop)\n          content=$(echo \"$content\" | sed -e \"s/{{.Info.ProductName}}/$(jq -r '.info.productName' wails.json)/g\")\n          content=$(echo \"$content\" | sed -e \"s/{{.Info.Comments}}/$(jq -r '.info.comments' wails.json)/g\")\n          echo $content\n          echo \"$content\" > build/linux/tiny-rdm_0.0.0_amd64/usr/share/applications/tiny-rdm.desktop\n\n      - name: Package up deb file\n        shell: bash\n        run: |\n          mv build/bin/tiny-rdm build/linux/tiny-rdm_0.0.0_amd64/usr/local/bin/\n          cd build/linux\n          mv tiny-rdm_0.0.0_amd64 \"tiny-rdm_${{ steps.normalise_version.outputs.version }}_amd64\"\n          sed -i 's/0.0.0/${{ steps.normalise_version.outputs.version }}/g' \"tiny-rdm_${{ steps.normalise_version.outputs.version }}_amd64/DEBIAN/control\"\n          dpkg-deb --build -Zxz \"tiny-rdm_${{ steps.normalise_version.outputs.version }}_amd64\"\n\n      - name: Package up appimage file\n        run: |\n          curl https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20240109-1/linuxdeploy-${{ steps.normalise_platform_arch.outputs.arch }}.AppImage \\\n                -o linuxdeploy \\\n                -L\n          chmod u+x linuxdeploy\n\n          ./linuxdeploy --appdir AppDir\n\n          pushd AppDir\n          # Copy WebKit files.\n          find /usr/lib* -name WebKitNetworkProcess -exec mkdir -p $(dirname '{}') \\; -exec cp --parents '{}' \".\" \\; || true\n          find /usr/lib* -name WebKitWebProcess -exec mkdir -p $(dirname '{}') \\; -exec cp --parents '{}' \".\" \\; || true\n          find /usr/lib* -name libwebkit2gtkinjectedbundle.so -exec mkdir -p $(dirname '{}') \\; -exec cp --parents '{}' \".\" \\; || true\n          popd\n\n\n          mkdir -p AppDir/usr/share/icons/hicolor/512x512/apps\n          build_dir=\"build/linux/tiny-rdm_${{ steps.normalise_version.outputs.version }}_amd64\"\n\n          cp -r $build_dir/usr/share/icons/hicolor/512x512/apps/tiny-rdm.png AppDir/usr/share/icons/hicolor/512x512/apps/\n          cp $build_dir/usr/local/bin/tiny-rdm AppDir/usr/bin/\n\n\n          sed -i 's#/usr/local/bin/tiny-rdm#tiny-rdm#g' $build_dir/usr/share/applications/tiny-rdm.desktop\n\n          curl -o linuxdeploy-plugin-gtk.sh \"https://raw.githubusercontent.com/tauri-apps/linuxdeploy-plugin-gtk/master/linuxdeploy-plugin-gtk.sh\"\n\n          sed -i '/XDG_DATA_DIRS/a export WEBKIT_DISABLE_COMPOSITING_MODE=1' linuxdeploy-plugin-gtk.sh\n          chmod +x linuxdeploy-plugin-gtk.sh\n\n          curl -o AppDir/AppRun https://github.com/AppImage/AppImageKit/releases/download/continuous/AppRun-${{ steps.normalise_platform_arch.outputs.arch }} -L\n\n          ./linuxdeploy --appdir AppDir \\\n             --output=appimage \\\n             --plugin=gtk \\\n             -e $build_dir/usr/local/bin/tiny-rdm \\\n             -d $build_dir/usr/share/applications/tiny-rdm.desktop\n\n      - name: Rename deb\n        working-directory: ./build/linux\n        run: mv \"tiny-rdm_${{ steps.normalise_version.outputs.version }}_amd64.deb\" \"tiny-rdm_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.deb\"\n\n      - name: Rename appimage\n        run: mv Tiny_RDM-${{ steps.normalise_platform_arch.outputs.arch }}.AppImage \"tiny-rdm_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.AppImage\"\n\n      - name: Upload release asset\n        uses: softprops/action-gh-release@v1\n        with:\n          tag_name: v${{ steps.normalise_version.outputs.version }}\n          files: |\n            ./build/linux/tiny-rdm_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.deb\n            tiny-rdm_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.AppImage\n          token: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/release-macos.yaml",
    "content": "name: Release macOS App\nrun-name: ${{ github.event.release.tag_name || github.event.inputs.tag }}\n\non:\n  release:\n    types: [ published ]\n  workflow_dispatch:\n    inputs:\n      tag:\n        description: 'Version tag'\n        required: true\n        default: '1.0.0'\n\njobs:\n  release:\n    name: Release macOS App\n    runs-on: macos-latest # We can cross compile but need to be on macOS to notarise\n    strategy:\n      matrix:\n        platform:\n          - darwin/amd64\n          - darwin/arm64\n    #          - darwin/universal\n    steps:\n      - name: Checkout source code\n        uses: actions/checkout@v3\n\n      - name: Normalise platform tag\n        id: normalise_platform\n        shell: bash\n        run: |\n          tag=$(echo ${{ matrix.platform }} | sed -e 's/\\//_/g' -e 's/darwin/mac/g' -e 's/amd64/intel/g')\n          echo \"tag=$tag\" >> \"$GITHUB_OUTPUT\"\n\n      - name: Normalise version tag\n        id: normalise_version\n        shell: bash\n        run: |\n          if [ \"${{ github.event.release.tag_name }}\" == \"\" ]; then\n            version=$(echo ${{ github.event.inputs.tag }} | sed -e 's/v//g')\n            echo \"version=$version\" >> \"$GITHUB_OUTPUT\"\n          else\n            version=$(echo ${{ github.event.release.tag_name }} | sed -e 's/v//g')\n            echo \"version=$version\" >> \"$GITHUB_OUTPUT\"\n          fi\n\n      - name: Setup Go\n        uses: actions/setup-go@v6\n        with:\n          go-version: stable\n\n      #      - name: Install gon for macOS notarisation\n      #        shell: bash\n      #        run: wget https://github.com/mitchellh/gon/releases/download/v0.2.5/gon_macos.zip && unzip gon_macos.zip && mv gon /usr/local/bin\n      #\n      #      - name: Import code signing certificate from Github Secrets\n      #        uses: Apple-Actions/import-codesign-certs@v1\n      #        with:\n      #          p12-file-base64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }}\n      #          p12-password: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }}\n\n      - name: Install wails\n        shell: bash\n        run: go install github.com/wailsapp/wails/v2/cmd/wails@latest\n\n      - name: Setup Node\n        uses: actions/setup-node@v3\n        with:\n          node-version: 22\n\n      - name: Build frontend assets\n        shell: bash\n        run: |\n          npm install -g npm@9\n          jq '.info.productVersion = \"${{ steps.normalise_version.outputs.version }}\"' wails.json > tmp.json\n          mv tmp.json wails.json\n          cd frontend\n          jq '.version = \"${{ steps.normalise_version.outputs.version }}\"' package.json > tmp.json\n          mv tmp.json package.json\n          npm install\n\n      - name: Build wails app for macOS\n        shell: bash\n        run: |\n          CGO_ENABLED=1 wails build -platform ${{ matrix.platform }} \\\n          -ldflags \"-X main.version=${{ steps.normalise_version.outputs.version }} -X main.gaMeasurementID=${{ secrets.GA_MEASUREMENT_ID }} -X main.gaSecretKey=${{ secrets.MAC_GA_SECRET }}\"\n\n      #      - name: Notarise macOS app + create dmg\n      #        shell: bash\n      #        run: gon -log-level=info gon.config.json\n      #        env:\n      #          AC_USERNAME: ${{ secrets.AC_USERNAME }}\n      #          AC_PASSWORD: ${{ secrets.AC_PASSWORD }}\n\n      - name: Checkout create-image\n        uses: actions/checkout@v2\n        with:\n          repository: create-dmg/create-dmg\n          path: ./build/create-dmg\n          ref: master\n\n      - name: Build macOS DMG\n        shell: bash\n        working-directory: ./build\n        run: |\n          mv bin/tinyrdm.app \"bin/Tiny RDM.app\"\n          ./create-dmg/create-dmg \\\n            --no-internet-enable \\\n            --volname \"Tiny RDM\" \\\n            --volicon \"bin/Tiny RDM.app/Contents/Resources/iconfile.icns\" \\\n            --background \"dmg/background.tiff\" \\\n            --text-size 12 \\\n            --window-pos 400 400 \\\n            --window-size 660 450 \\\n            --icon-size 80 \\\n            --icon \"Tiny RDM.app\" 180 180 \\\n            --hide-extension \"Tiny RDM.app\" \\\n            --app-drop-link 480 180 \\\n            --add-file \"Repair\" \"dmg/fix-app\" 230 290 \\\n            --add-file \"损坏修复\" \"dmg/fix-app_zh\" 430 290 \\\n            \"bin/TinyRDM-${{ steps.normalise_platform.outputs.tag }}.dmg\" \\\n            \"bin\"\n\n      - name: Rename dmg\n        working-directory: ./build/bin\n        run: mv \"TinyRDM-${{ steps.normalise_platform.outputs.tag }}.dmg\" \"TinyRDM_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.dmg\"\n\n      - name: Upload release asset (DMG Package)\n        uses: softprops/action-gh-release@v1\n        with:\n          tag_name: v${{ steps.normalise_version.outputs.version }}\n          files: ./build/bin/TinyRDM_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.dmg\n          token: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/release-windows.yaml",
    "content": "name: Release Windows App\nrun-name: ${{ github.event.release.tag_name || github.event.inputs.tag }}\n\non:\n  release:\n    types: [ published ]\n  workflow_dispatch:\n    inputs:\n      tag:\n        description: 'Version tag'\n        required: true\n        default: '1.0.0'\n\njobs:\n  release:\n    name: Release Windows App\n    runs-on: windows-latest\n    strategy:\n      matrix:\n        platform:\n          - windows/amd64\n          - windows/arm64\n    steps:\n      - name: Checkout source code\n        uses: actions/checkout@v3\n\n      - name: Normalise platform tag\n        id: normalise_platform\n        shell: bash\n        run: |\n          tag=$(echo ${{ matrix.platform }} | sed -e 's/\\//_/g' -e 's/amd64/x64/g')\n          echo \"tag=$tag\" >> \"$GITHUB_OUTPUT\"\n\n      - name: Normalise platform name\n        id: normalise_platform_name\n        shell: bash\n        run: |\n          pname=$(echo \"${{ matrix.platform }}\" | sed 's/windows\\///g')\n          echo \"pname=$pname\" >> \"$GITHUB_OUTPUT\"\n\n      - name: Normalise version tag\n        id: normalise_version\n        shell: bash\n        run: |\n          if [ \"${{ github.event.release.tag_name }}\" == \"\" ]; then\n            version=$(echo ${{ github.event.inputs.tag }} | sed -e 's/v//g')\n            echo \"version=$version\" >> \"$GITHUB_OUTPUT\"\n          else\n            version=$(echo ${{ github.event.release.tag_name }} | sed -e 's/v//g')\n            echo \"version=$version\" >> \"$GITHUB_OUTPUT\"\n          fi\n\n      - name: Setup Go\n        uses: actions/setup-go@v6\n        with:\n          go-version: stable\n\n      - name: Install chocolatey\n        uses: crazy-max/ghaction-chocolatey@v2\n        with:\n          args: install nsis jq\n\n      - name: Install wails\n        shell: bash\n        run: go install github.com/wailsapp/wails/v2/cmd/wails@latest\n\n      - name: Setup Node\n        uses: actions/setup-node@v3\n        with:\n          node-version: 22\n\n      - name: Build frontend assets\n        shell: bash\n        run: |\n          npm install -g npm@9\n          jq '.info.productVersion = \"${{ steps.normalise_version.outputs.version }}\"' wails.json > tmp.json\n          mv tmp.json wails.json\n          cd frontend\n          jq '.version = \"${{ steps.normalise_version.outputs.version }}\"' package.json > tmp.json\n          mv tmp.json package.json\n          npm install\n\n      - name: Build Windows portable app\n        shell: bash\n        run: |\n          CGO_ENABLED=1 wails build -clean -platform ${{ matrix.platform }} \\\n          -webview2 embed \\\n          -ldflags \"-X main.version=v${{ steps.normalise_version.outputs.version }} -X main.gaMeasurementID=${{ secrets.GA_MEASUREMENT_ID }} -X main.gaSecretKey=${{ secrets.WINDOWS_GA_SECRET }}\"\n\n      - name: Compress portable binary\n        working-directory: ./build/bin\n        run: Compress-Archive \"Tiny RDM.exe\" \"TinyRDM_Portable_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.zip\"\n\n      - name: Upload release asset (Portable)\n        uses: softprops/action-gh-release@v1\n        with:\n          tag_name: v${{ steps.normalise_version.outputs.version }}\n          files: ./build/bin/TinyRDM_Portable_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.zip\n          token: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Build Windows NSIS installer\n        shell: bash\n        run: |\n          export PATH=\"/c/Program Files (x86)/NSIS:$PATH\"\n          which makensis && echo \"makensis found\" || echo \"makensis NOT found\"\n          CGO_ENABLED=1 wails build -clean -platform ${{ matrix.platform }} \\\n          -nsis -webview2 embed \\\n          -ldflags \"-X main.version=v${{ steps.normalise_version.outputs.version }} -X main.gaMeasurementID=${{ secrets.GA_MEASUREMENT_ID }} -X main.gaSecretKey=${{ secrets.WINDOWS_GA_SECRET }}\"\n\n      - name: Sign the installer\n        uses: dlemstra/code-sign-action@v1\n        with:\n          certificate: ${{ secrets.WIN_SIGNING_CERT }}\n          password: ${{ secrets.WIN_SIGNING_CERT_PASSWORD }}\n          folder: ./build/bin\n\n      - name: Rename installer\n        working-directory: ./build/bin\n        run: Rename-Item -Path \"tinyrdm-${{ steps.normalise_platform_name.outputs.pname }}-installer.exe\" -NewName \"TinyRDM_Setup_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.exe\"\n\n      - name: Upload release asset (Installer)\n        uses: softprops/action-gh-release@v1\n        with:\n          tag_name: v${{ steps.normalise_version.outputs.version }}\n          files: ./build/bin/TinyRDM_Setup_${{ steps.normalise_version.outputs.version }}_${{ steps.normalise_platform.outputs.tag }}.exe\n          token: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": "build/bin\nnode_modules\nfrontend/dist\nfrontend/wailsjs\nfrontend/package.json.md5\ndesign/\n.vscode\n.idea\ntest\n"
  },
  {
    "path": ".prettierignore",
    "content": "/frontend/wailsjs/**\n"
  },
  {
    "path": "Dockerfile",
    "content": "# ============================================================\n# Stage 1: Build frontend\n# ============================================================\nFROM --platform=linux/amd64 node:22-alpine AS frontend-builder\n\nWORKDIR /app/frontend\nCOPY frontend/package.json frontend/package-lock.json ./\nRUN npm ci --ignore-scripts\nCOPY frontend/ ./\nENV NODE_OPTIONS=--max-old-space-size=4096\nENV VITE_WEB=true\nRUN npm run build\n\n# ============================================================\n# Stage 2: Build Go backend (web mode)\n# ============================================================\nFROM golang:1.25-alpine AS backend-builder\n\nWORKDIR /app\nCOPY go.mod go.sum ./\nENV GOPROXY=https://goproxy.cn,https://goproxy.io,direct\nRUN GOFLAGS=\"-mod=mod\" go mod download\n\nCOPY backend/ ./backend/\nCOPY main_web.go ./\n\nARG APP_VERSION=1.0.0\nRUN CGO_ENABLED=0 GOOS=linux GOFLAGS=\"-mod=mod\" go build -tags web -ldflags \"-s -w -X main.version=${APP_VERSION}\" -o /app/tinyrdm-server .\n\n# ============================================================\n# Stage 3: Runtime (nginx + Go backend)\n# ============================================================\nFROM alpine:3.21\n\nRUN apk add --no-cache ca-certificates tzdata nginx \\\n    && rm -rf /var/cache/apk/* /tmp/*\n\n# Frontend static files\nCOPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html\n\n# Nginx config\nCOPY docker/nginx.conf /etc/nginx/http.d/default.conf\n\n# Go backend binary\nWORKDIR /app\nCOPY --from=backend-builder /app/tinyrdm-server .\nCOPY docker/entrypoint.sh /entrypoint.sh\nRUN chmod +x /entrypoint.sh\n\nEXPOSE 8086\n\nENV PORT=8088\nENV GIN_MODE=release\nENV XDG_CONFIG_HOME=/app\n\nENTRYPOINT [\"/entrypoint.sh\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<https://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<https://www.gnu.org/licenses/why-not-lgpl.html>.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n<a href=\"https://github.com/tiny-craft/tiny-rdm/\"><img src=\"build/appicon.png\" width=\"120\"/></a>\n</div>\n<h1 align=\"center\">Tiny RDM</h1>\n<h4 align=\"center\"><strong>English</strong> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_zh.md\">简体中文</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_tw.md\">繁體中文</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ja.md\">日本語</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ko.md\">한국어</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_fr.md\">Français</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_es.md\">Español</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_pt.md\">Português (BR)</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ru.md\">Русский</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_tr.md\">Türkçe</a></h4>\n<div align=\"center\">\n\n[![License](https://img.shields.io/github/license/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE)\n[![GitHub release](https://img.shields.io/github/release/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/releases)\n![GitHub All Releases](https://img.shields.io/github/downloads/tiny-craft/tiny-rdm/total)\n[![GitHub stars](https://img.shields.io/github/stars/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/stargazers)\n[![GitHub forks](https://img.shields.io/github/forks/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/fork)\n[![Discord](https://img.shields.io/discord/1170373259133456434?label=Discord&color=5865F2)](https://discord.gg/VTFbBMGjWh)\n[![X](https://img.shields.io/badge/Twitter-black?logo=x&logoColor=white)](https://twitter.com/Lykin53448)\n\n<strong>Tiny RDM is a modern lightweight cross-platform Redis desktop manager available for Mac, Windows, and Linux. It also provides a web version that can be deployed via Docker.</strong>\n</div>\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"screenshots/dark_en.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"screenshots/light_en.png\">\n <img alt=\"screenshot\" src=\"screenshots/dark_en.png\">\n</picture>\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"screenshots/dark_en2.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"screenshots/light_en2.png\">\n <img alt=\"screenshot\" src=\"screenshots/dark_en2.png\">\n</picture>\n\n## Feature\n\n* Super lightweight, built on Webview2, without embedded browsers (Thanks\n  to [Wails](https://github.com/wailsapp/wails)).\n* Provides visually and user-friendly UI, light and dark themes (Thanks to [Naive UI](https://github.com/tusen-ai/naive-ui)\n  and [IconPark](https://iconpark.oceanengine.com)).\n* Multi-language support ([Need more languages ? Click here to contribute](.github/CONTRIBUTING.md)).\n* Better connection management: supports SSH Tunnel/SSL/Sentinel Mode/Cluster Mode/HTTP proxy/SOCKS5 proxy.\n* Visualize key value operations, CRUD support for Lists, Hashes, Strings, Sets, Sorted Sets, and Streams.\n* Support multiple data viewing format and decode/decompression methods.\n* Use SCAN for segmented loading, making it easy to list millions of keys.\n* Logs list for command operation history.\n* Provides command-line mode.\n* Provides slow logs list.\n* Segmented loading and querying for List/Hash/Set/Sorted Set.\n* Provide value decode/decompression for List/Hash/Set/Sorted Set.\n* Integrate with Monaco Editor\n* Support real-time commands monitoring.\n* Support import/export data.\n* Support publish/subscribe.\n* Support import/export connection profile.\n* Custom data encoder and decoder for value display ([Here are the instructions](https://tinyrdm.com/guide/custom-decoder/)).\n\n## Installation\n\nAvailable to download for free from [here](https://github.com/tiny-craft/tiny-rdm/releases).\n\n> If you can't open it after installation on macOS, exec the following command then reopen:\n> ``` shell\n>  sudo xattr -d com.apple.quarantine /Applications/Tiny\\ RDM.app\n> ```\n\n## Build Guidelines\n\n### Prerequisites\n\n* Go (latest version)\n* Node.js >= 20\n* NPM >= 9\n\n### Install Wails\n\n```bash\ngo install github.com/wailsapp/wails/v2/cmd/wails@latest\n```\n\n### Pull the Code\n\n```bash\ngit clone https://github.com/tiny-craft/tiny-rdm --depth=1\n```\n\n### Build Frontend\n\n```bash\nnpm install --prefix ./frontend\n```\n\nor\n\n```bash\ncd frontend\nnpm install\n```\n\n### Compile and Run\n\n```bash\nwails dev\n```\n\n## Docker Deployment\n\nIn addition to the desktop client, Tiny RDM also provides a web version that can be quickly deployed via Docker.\n\n### Using Docker Compose (Recommended)\n\nCreate a `docker-compose.yml` file:\n\n```yaml\nservices:\n  tinyrdm:\n    image: ghcr.io/tiny-craft/tiny-rdm:latest\n    container_name: tinyrdm\n    restart: unless-stopped\n    ports:\n      - \"8086:8086\"\n    environment:\n      - ADMIN_USERNAME=admin\n      - ADMIN_PASSWORD=tinyrdm\n    volumes:\n      - ./data:/app/tinyrdm\n```\n\nStart the service:\n\n```bash\ndocker compose up -d\n```\n\nOnce started, visit `http://localhost:8086` and log in with the credentials configured above.\n\n### Using Docker Command\n\n```bash\ndocker run -d --name tinyrdm \\\n  -p 8086:8086 \\\n  -e ADMIN_USERNAME=admin \\\n  -e ADMIN_PASSWORD=tinyrdm \\\n  -v ./data:/app/tinyrdm \\\n  ghcr.io/tiny-craft/tiny-rdm:latest\n```\n\n### Environment Variables\n\n| Variable | Description | Default |\n|----------|-------------|---------|\n| `ADMIN_USERNAME` | Login username | - |\n| `ADMIN_PASSWORD` | Login password | - |\n\n## About\n\n### Wechat Official Account\n\n<img src=\"docs/images/wechat_official.png\" alt=\"wechat\" width=\"360\" />\n\n### Sponsor\n\nIf this project helpful for you, feel free to buy me a cup of coffee ☕️.\n\n* Wechat Sponsor\n\n<img src=\"docs/images/wechat_sponsor.jpg\" alt=\"wechat\" width=\"200\" />\n\n### Thanks\n\nThanks to the following service providers for hosting sponsorship\n\n[![Powered by NotiDC](docs/images/notidc_logo.png)](https://www.notidc.com/ \"Powered by NotiDC\")\n"
  },
  {
    "path": "README_es.md",
    "content": "<div align=\"center\">\n<a href=\"https://github.com/tiny-craft/tiny-rdm/\"><img src=\"build/appicon.png\" width=\"120\"/></a>\n</div>\n<h1 align=\"center\">Tiny RDM</h1>\n<h4 align=\"center\"><a href=\"/\">English</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_zh.md\">简体中文</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_tw.md\">繁體中文</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ja.md\">日本語</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ko.md\">한국어</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_fr.md\">Français</a> | <strong>Español</strong> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_pt.md\">Português (BR)</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ru.md\">Русский</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_tr.md\">Türkçe</a></h4>\n<div align=\"center\">\n\n[![License](https://img.shields.io/github/license/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE)\n[![GitHub release](https://img.shields.io/github/release/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/releases)\n![GitHub All Releases](https://img.shields.io/github/downloads/tiny-craft/tiny-rdm/total)\n[![GitHub stars](https://img.shields.io/github/stars/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/stargazers)\n[![GitHub forks](https://img.shields.io/github/forks/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/fork)\n\n<strong>Tiny RDM es un gestor Redis moderno, ligero y multiplataforma, disponible para Mac, Windows y Linux. También ofrece una versión web que se puede desplegar mediante Docker.</strong>\n</div>\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"screenshots/dark_en.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"screenshots/light_en.png\">\n <img alt=\"screenshot\" src=\"screenshots/dark_en.png\">\n</picture>\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"screenshots/dark_en2.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"screenshots/light_en2.png\">\n <img alt=\"screenshot\" src=\"screenshots/dark_en2.png\">\n</picture>\n\n## Características\n\n* Ultra ligero, basado en Webview2, sin navegador integrado (Gracias a [Wails](https://github.com/wailsapp/wails))\n* Interfaz visual y fácil de usar, temas claro y oscuro (Gracias a [Naive UI](https://github.com/tusen-ai/naive-ui) e [IconPark](https://iconpark.oceanengine.com))\n* Soporte multilingüe ([¿Necesitas más idiomas? Haz clic aquí para contribuir](.github/CONTRIBUTING.md))\n* Gestión mejorada de conexiones: túnel SSH/SSL/modo Sentinel/modo Cluster/proxy HTTP/proxy SOCKS5\n* Visualización de operaciones clave-valor, soporte CRUD para List, Hash, String, Set, Sorted Set y Stream\n* Soporte de múltiples formatos de visualización y métodos de decodificación/descompresión\n* Carga segmentada con SCAN para listar fácilmente millones de claves\n* Lista de registros del historial de comandos\n* Modo línea de comandos\n* Lista de registros lentos\n* Carga segmentada y consultas para List/Hash/Set/Sorted Set\n* Decodificación/descompresión de valores para List/Hash/Set/Sorted Set\n* Integración con Monaco Editor\n* Monitoreo de comandos en tiempo real\n* Importación/exportación de datos\n* Publicación/suscripción\n* Importación/exportación de perfiles de conexión\n* Codificador y decodificador de datos personalizados para la visualización de valores ([Instrucciones aquí](https://tinyrdm.com/guide/custom-decoder/))\n\n## Instalación\n\nDisponible para descargar gratis [aquí](https://github.com/tiny-craft/tiny-rdm/releases).\n\n> Si no puedes abrirlo después de la instalación en macOS, ejecuta el siguiente comando y vuelve a abrirlo:\n> ``` shell\n>  sudo xattr -d com.apple.quarantine /Applications/Tiny\\ RDM.app\n> ```\n\n## Guía de compilación\n\n### Requisitos previos\n\n* Go (última versión)\n* Node.js >= 20\n* NPM >= 9\n\n### Instalar Wails\n\n```bash\ngo install github.com/wailsapp/wails/v2/cmd/wails@latest\n```\n\n### Obtener el código\n\n```bash\ngit clone https://github.com/tiny-craft/tiny-rdm --depth=1\n```\n\n### Compilar el frontend\n\n```bash\nnpm install --prefix ./frontend\n```\n\no\n\n```bash\ncd frontend\nnpm install\n```\n\n### Compilar y ejecutar\n\n```bash\nwails dev\n```\n\n## Despliegue con Docker\n\nAdemás del cliente de escritorio, Tiny RDM también ofrece una versión web que se puede desplegar rápidamente con Docker.\n\n### Usando Docker Compose (recomendado)\n\nCrea un archivo `docker-compose.yml`:\n\n```yaml\nservices:\n  tinyrdm:\n    image: ghcr.io/tiny-craft/tiny-rdm:latest\n    container_name: tinyrdm\n    restart: unless-stopped\n    ports:\n      - \"8086:8086\"\n    environment:\n      - ADMIN_USERNAME=admin\n      - ADMIN_PASSWORD=tinyrdm\n    volumes:\n      - ./data:/app/tinyrdm\n```\n\nInicia el servicio:\n\n```bash\ndocker compose up -d\n```\n\nUna vez iniciado, visita `http://localhost:8086` e inicia sesión con las credenciales configuradas arriba.\n\n### Usando el comando Docker\n\n```bash\ndocker run -d --name tinyrdm \\\n  -p 8086:8086 \\\n  -e ADMIN_USERNAME=admin \\\n  -e ADMIN_PASSWORD=tinyrdm \\\n  -v ./data:/app/tinyrdm \\\n  ghcr.io/tiny-craft/tiny-rdm:latest\n```\n\n### Variables de entorno\n\n| Variable | Descripción | Valor por defecto |\n|----------|-------------|-------------------|\n| `ADMIN_USERNAME` | Nombre de usuario | - |\n| `ADMIN_PASSWORD` | Contraseña | - |\n\n## Acerca de\n\n### Patrocinar\n\nSi este proyecto te resulta útil, no dudes en invitar al autor a un café ☕️\n\n* Wechat Sponsor\n\n<img src=\"docs/images/wechat_sponsor.jpg\" alt=\"wechat\" width=\"200\" />\n\n### Agradecimientos\n\nGracias a los siguientes proveedores de servicios por el patrocinio de alojamiento\n\n[![Powered by NotiDC](docs/images/notidc_logo.png)](https://www.notidc.com/ \"Powered by NotiDC\")\n"
  },
  {
    "path": "README_fr.md",
    "content": "<div align=\"center\">\n<a href=\"https://github.com/tiny-craft/tiny-rdm/\"><img src=\"build/appicon.png\" width=\"120\"/></a>\n</div>\n<h1 align=\"center\">Tiny RDM</h1>\n<h4 align=\"center\"><a href=\"/\">English</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_zh.md\">简体中文</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_tw.md\">繁體中文</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ja.md\">日本語</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ko.md\">한국어</a> | <strong>Français</strong> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_es.md\">Español</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_pt.md\">Português (BR)</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ru.md\">Русский</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_tr.md\">Türkçe</a></h4>\n<div align=\"center\">\n\n[![License](https://img.shields.io/github/license/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE)\n[![GitHub release](https://img.shields.io/github/release/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/releases)\n![GitHub All Releases](https://img.shields.io/github/downloads/tiny-craft/tiny-rdm/total)\n[![GitHub stars](https://img.shields.io/github/stars/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/stargazers)\n[![GitHub forks](https://img.shields.io/github/forks/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/fork)\n\n<strong>Tiny RDM est un gestionnaire Redis moderne, léger et multiplateforme, disponible pour Mac, Windows et Linux. Une version web déployable via Docker est également proposée.</strong>\n</div>\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"screenshots/dark_en.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"screenshots/light_en.png\">\n <img alt=\"screenshot\" src=\"screenshots/dark_en.png\">\n</picture>\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"screenshots/dark_en2.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"screenshots/light_en2.png\">\n <img alt=\"screenshot\" src=\"screenshots/dark_en2.png\">\n</picture>\n\n## Fonctionnalités\n\n* Ultra léger, basé sur Webview2, sans navigateur intégré (Merci à [Wails](https://github.com/wailsapp/wails))\n* Interface visuelle et conviviale, thèmes clair et sombre (Merci à [Naive UI](https://github.com/tusen-ai/naive-ui) et [IconPark](https://iconpark.oceanengine.com))\n* Support multilingue ([Besoin de plus de langues ? Cliquez ici pour contribuer](.github/CONTRIBUTING.md))\n* Gestion améliorée des connexions : tunnel SSH/SSL/mode Sentinelle/mode Cluster/proxy HTTP/proxy SOCKS5\n* Visualisation des opérations clé-valeur, support CRUD pour List, Hash, String, Set, Sorted Set et Stream\n* Support de multiples formats d'affichage et méthodes de décodage/décompression\n* Chargement segmenté avec SCAN pour lister facilement des millions de clés\n* Liste des journaux d'historique des commandes\n* Mode ligne de commande\n* Liste des journaux lents\n* Chargement segmenté et requêtes pour List/Hash/Set/Sorted Set\n* Décodage/décompression des valeurs pour List/Hash/Set/Sorted Set\n* Intégration de Monaco Editor\n* Surveillance des commandes en temps réel\n* Import/export de données\n* Publication/abonnement\n* Import/export de profils de connexion\n* Encodeur et décodeur de données personnalisés pour l'affichage des valeurs ([Instructions ici](https://tinyrdm.com/guide/custom-decoder/))\n\n## Installation\n\nDisponible en téléchargement gratuit [ici](https://github.com/tiny-craft/tiny-rdm/releases).\n\n> Si vous ne pouvez pas l'ouvrir après l'installation sur macOS, exécutez la commande suivante puis relancez :\n> ``` shell\n>  sudo xattr -d com.apple.quarantine /Applications/Tiny\\ RDM.app\n> ```\n\n## Guide de compilation\n\n### Prérequis\n\n* Go (dernière version)\n* Node.js >= 20\n* NPM >= 9\n\n### Installer Wails\n\n```bash\ngo install github.com/wailsapp/wails/v2/cmd/wails@latest\n```\n\n### Récupérer le code\n\n```bash\ngit clone https://github.com/tiny-craft/tiny-rdm --depth=1\n```\n\n### Compiler le frontend\n\n```bash\nnpm install --prefix ./frontend\n```\n\nou\n\n```bash\ncd frontend\nnpm install\n```\n\n### Compiler et exécuter\n\n```bash\nwails dev\n```\n\n## Déploiement Docker\n\nEn plus du client de bureau, Tiny RDM propose une version web déployable rapidement via Docker.\n\n### Avec Docker Compose (recommandé)\n\nCréez un fichier `docker-compose.yml` :\n\n```yaml\nservices:\n  tinyrdm:\n    image: ghcr.io/tiny-craft/tiny-rdm:latest\n    container_name: tinyrdm\n    restart: unless-stopped\n    ports:\n      - \"8086:8086\"\n    environment:\n      - ADMIN_USERNAME=admin\n      - ADMIN_PASSWORD=tinyrdm\n    volumes:\n      - ./data:/app/tinyrdm\n```\n\nDémarrez le service :\n\n```bash\ndocker compose up -d\n```\n\nUne fois démarré, accédez à `http://localhost:8086` et connectez-vous avec les identifiants configurés ci-dessus.\n\n### Avec la commande Docker\n\n```bash\ndocker run -d --name tinyrdm \\\n  -p 8086:8086 \\\n  -e ADMIN_USERNAME=admin \\\n  -e ADMIN_PASSWORD=tinyrdm \\\n  -v ./data:/app/tinyrdm \\\n  ghcr.io/tiny-craft/tiny-rdm:latest\n```\n\n### Variables d'environnement\n\n| Variable | Description | Valeur par défaut |\n|----------|-------------|-------------------|\n| `ADMIN_USERNAME` | Nom d'utilisateur | - |\n| `ADMIN_PASSWORD` | Mot de passe | - |\n\n## À propos\n\n### Sponsor\n\nSi ce projet vous est utile, n'hésitez pas à offrir un café ☕️\n\n* Wechat Sponsor\n\n<img src=\"docs/images/wechat_sponsor.jpg\" alt=\"wechat\" width=\"200\" />\n\n### Remerciements\n\nMerci aux fournisseurs de services suivants pour le parrainage d'hébergement\n\n[![Powered by NotiDC](docs/images/notidc_logo.png)](https://www.notidc.com/ \"Powered by NotiDC\")\n"
  },
  {
    "path": "README_ja.md",
    "content": "<div align=\"center\">\n<a href=\"https://github.com/tiny-craft/tiny-rdm/\"><img src=\"build/appicon.png\" width=\"120\"/></a>\n</div>\n<h1 align=\"center\">Tiny RDM</h1>\n<h4 align=\"center\"><a href=\"/\">English</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_zh.md\">简体中文</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_tw.md\">繁體中文</a> | <strong>日本語</strong> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ko.md\">한국어</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_fr.md\">Français</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_es.md\">Español</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_pt.md\">Português (BR)</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ru.md\">Русский</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_tr.md\">Türkçe</a></h4>\n<div align=\"center\">\n\n[![License](https://img.shields.io/github/license/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE)\n[![GitHub release](https://img.shields.io/github/release/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/releases)\n![GitHub All Releases](https://img.shields.io/github/downloads/tiny-craft/tiny-rdm/total)\n[![GitHub stars](https://img.shields.io/github/stars/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/stargazers)\n[![GitHub forks](https://img.shields.io/github/forks/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/fork)\n[![Discord](https://img.shields.io/discord/1170373259133456434?label=Discord&color=5865F2)](https://discord.gg/VTFbBMGjWh)\n[![X](https://img.shields.io/badge/Twitter-black?logo=x&logoColor=white)](https://twitter.com/Lykin53448)\n\n<strong>Tiny RDMは、Mac、Windows、Linuxで利用可能な、モダンで軽量なクロスプラットフォームのRedisデスクトップマネージャーです。Docker経由でデプロイ可能なWeb版も提供しています。</strong>\n</div>\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"screenshots/dark_en.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"screenshots/light_en.png\">\n <img alt=\"screenshot\" src=\"screenshots/dark_en.png\">\n</picture>\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"screenshots/dark_en2.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"screenshots/light_en2.png\">\n <img alt=\"screenshot\" src=\"screenshots/dark_en2.png\">\n</picture>\n\n## 特徴\n\n* 超軽量、Webview2をベースにしており、埋め込みブラウザなし（[Wails](https://github.com/wailsapp/wails)に感謝）。\n* 視覚的でユーザーフレンドリーなUI、ライトとダークテーマを提供（[Naive UI](https://github.com/tusen-ai/naive-ui)と[IconPark](https://iconpark.oceanengine.com)に感謝）。\n* 多言語サポート（[もっと多くの言語が必要ですか？ここをクリックして貢献してください](.github/CONTRIBUTING.md)）。\n* より良い接続管理：SSHトンネル/SSL/センチネルモード/クラスターモード/HTTPプロキシ/SOCKS5プロキシをサポート。\n* キー値操作の可視化、リスト、ハッシュ、文字列、セット、ソートセット、ストリームのCRUDサポート。\n* 複数のデータ表示形式とデコード/解凍方法をサポート。\n* SCANを使用してセグメント化された読み込みを行い、数百万のキーを簡単にリスト化。\n* コマンド操作履歴のログリスト。\n* コマンドラインモードを提供。\n* スローログリストを提供。\n* リスト/ハッシュ/セット/ソートセットのセグメント化された読み込みとクエリ。\n* リスト/ハッシュ/セット/ソートセットの値のデコード/解凍を提供。\n* Monaco Editorと統合。\n* リアルタイムコマンド監視をサポート。\n* データのインポート/エクスポートをサポート。\n* パブリッシュ/サブスクライブをサポート。\n* 接続プロファイルのインポート/エクスポートをサポート。\n* 値表示のためのカスタムデータエンコーダーとデコーダーをサポート（[こちらが手順です](https://tinyrdm.com/guide/custom-decoder/)）。\n\n## インストール\n\n[こちら](https://github.com/tiny-craft/tiny-rdm/releases)から無料でダウンロードできます。\n\n> macOSにインストール後に開けない場合、**信頼されていない**または**ゴミ箱に移動**というエラーが表示された場合は、以下のコマンドを実行してから再度開いてください：\n> ``` shell\n>  sudo xattr -d com.apple.quarantine /Applications/Tiny\\ RDM.app\n> ```\n\n## ビルドガイドライン\n\n### 前提条件\n\n* Go（最新バージョン）\n* Node.js >= 20\n* NPM >= 9\n\n### Wailsのインストール\n\n```bash\ngo install github.com/wailsapp/wails/v2/cmd/wails@latest\n```\n\n### コードの取得\n\n```bash\ngit clone https://github.com/tiny-craft/tiny-rdm --depth=1\n```\n\n### フロントエンドのビルド\n\n```bash\nnpm install --prefix ./frontend\n```\n\nまたは\n\n```bash\ncd frontend\nnpm install\n```\n\n### コンパイルと実行\n\n```bash\nwails dev\n```\n\n## Dockerデプロイ\n\nデスクトップクライアントに加えて、Tiny RDMはDockerで素早くデプロイできるWeb版も提供しています。\n\n### Docker Composeを使用（推奨）\n\n`docker-compose.yml` ファイルを作成します：\n\n```yaml\nservices:\n  tinyrdm:\n    image: ghcr.io/tiny-craft/tiny-rdm:latest\n    container_name: tinyrdm\n    restart: unless-stopped\n    ports:\n      - \"8086:8086\"\n    environment:\n      - ADMIN_USERNAME=admin\n      - ADMIN_PASSWORD=tinyrdm\n    volumes:\n      - ./data:/app/tinyrdm\n```\n\nサービスを起動します：\n\n```bash\ndocker compose up -d\n```\n\n起動後、`http://localhost:8086` にアクセスし、上記で設定したユーザー名とパスワードでログインしてください。\n\n### Dockerコマンドを使用\n\n```bash\ndocker run -d --name tinyrdm \\\n  -p 8086:8086 \\\n  -e ADMIN_USERNAME=admin \\\n  -e ADMIN_PASSWORD=tinyrdm \\\n  -v ./data:/app/tinyrdm \\\n  ghcr.io/tiny-craft/tiny-rdm:latest\n```\n\n### 環境変数の説明\n\n| 変数 | 説明 | デフォルト値 |\n|------|------|-------------|\n| `ADMIN_USERNAME` | ログインユーザー名 | - |\n| `ADMIN_PASSWORD` | ログインパスワード | - |\n\n## について\n\n### Wechat公式アカウント\n\n<img src=\"docs/images/wechat_official.png\" alt=\"wechat\" width=\"360\" />\n\n### スポンサー\n\nこのプロジェクトが役立つ場合は、コーヒーを一杯おごってください ☕️。\n\n* Wechatスポンサー\n\n<img src=\"docs/images/wechat_sponsor.jpg\" alt=\"wechat\" width=\"200\" />\n\n### 謝辞\n\n以下のサービスプロバイダーによるホスティングスポンサーシップに感謝いたします\n\n[![Powered by NotiDC](docs/images/notidc_logo.png)](https://www.notidc.com/ \"Powered by NotiDC\")\n"
  },
  {
    "path": "README_ko.md",
    "content": "<div align=\"center\">\n<a href=\"https://github.com/tiny-craft/tiny-rdm/\"><img src=\"build/appicon.png\" width=\"120\"/></a>\n</div>\n<h1 align=\"center\">Tiny RDM</h1>\n<h4 align=\"center\"><a href=\"/\">English</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_zh.md\">简体中文</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_tw.md\">繁體中文</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ja.md\">日本語</a> | <strong>한국어</strong> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_fr.md\">Français</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_es.md\">Español</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_pt.md\">Português (BR)</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ru.md\">Русский</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_tr.md\">Türkçe</a></h4>\n<div align=\"center\">\n\n[![License](https://img.shields.io/github/license/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE)\n[![GitHub release](https://img.shields.io/github/release/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/releases)\n![GitHub All Releases](https://img.shields.io/github/downloads/tiny-craft/tiny-rdm/total)\n[![GitHub stars](https://img.shields.io/github/stars/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/stargazers)\n[![GitHub forks](https://img.shields.io/github/forks/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/fork)\n\n<strong>Tiny RDM은 Mac, Windows, Linux에서 사용할 수 있는 현대적이고 가벼운 크로스 플랫폼 Redis 데스크톱 관리자입니다. Docker를 통해 배포할 수 있는 웹 버전도 제공합니다.</strong>\n</div>\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"screenshots/dark_en.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"screenshots/light_en.png\">\n <img alt=\"screenshot\" src=\"screenshots/dark_en.png\">\n</picture>\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"screenshots/dark_en2.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"screenshots/light_en2.png\">\n <img alt=\"screenshot\" src=\"screenshots/dark_en2.png\">\n</picture>\n\n## 기능\n\n* 초경량, Webview2 기반으로 내장 브라우저 없음 ([Wails](https://github.com/wailsapp/wails) 감사합니다)\n* 시각적이고 사용자 친화적인 UI, 라이트/다크 테마 제공 ([Naive UI](https://github.com/tusen-ai/naive-ui) 및 [IconPark](https://iconpark.oceanengine.com) 감사합니다)\n* 다국어 지원 ([더 많은 언어가 필요하신가요? 여기를 클릭하여 기여하세요](.github/CONTRIBUTING.md))\n* 향상된 연결 관리: SSH 터널/SSL/센티널 모드/클러스터 모드/HTTP 프록시/SOCKS5 프록시 지원\n* 키-값 작업 시각화, List, Hash, String, Set, Sorted Set, Stream의 CRUD 지원\n* 다양한 데이터 보기 형식 및 디코딩/압축 해제 방법 지원\n* SCAN을 사용한 분할 로딩으로 수백만 개의 키를 쉽게 나열\n* 명령 실행 이력 로그 목록\n* 명령줄 모드 제공\n* 슬로우 로그 목록 제공\n* List/Hash/Set/Sorted Set의 분할 로딩 및 쿼리\n* List/Hash/Set/Sorted Set 값의 디코딩/압축 해제 제공\n* Monaco Editor 통합\n* 실시간 명령 모니터링 지원\n* 데이터 가져오기/내보내기 지원\n* 발행/구독 지원\n* 연결 프로필 가져오기/내보내기 지원\n* 값 표시를 위한 사용자 정의 데이터 인코더 및 디코더 ([사용 방법](https://tinyrdm.com/guide/custom-decoder/))\n\n## 설치\n\n[여기](https://github.com/tiny-craft/tiny-rdm/releases)에서 무료로 다운로드할 수 있습니다.\n\n> macOS에서 설치 후 열 수 없는 경우, 다음 명령을 실행한 후 다시 열어주세요:\n> ``` shell\n>  sudo xattr -d com.apple.quarantine /Applications/Tiny\\ RDM.app\n> ```\n\n## 빌드 가이드\n\n### 사전 요구 사항\n\n* Go (최신 버전)\n* Node.js >= 20\n* NPM >= 9\n\n### Wails 설치\n\n```bash\ngo install github.com/wailsapp/wails/v2/cmd/wails@latest\n```\n\n### 코드 가져오기\n\n```bash\ngit clone https://github.com/tiny-craft/tiny-rdm --depth=1\n```\n\n### 프론트엔드 빌드\n\n```bash\nnpm install --prefix ./frontend\n```\n\n또는\n\n```bash\ncd frontend\nnpm install\n```\n\n### 컴파일 및 실행\n\n```bash\nwails dev\n```\n\n## Docker 배포\n\n데스크톱 클라이언트 외에도 Tiny RDM은 Docker를 통해 빠르게 배포할 수 있는 웹 버전을 제공합니다.\n\n### Docker Compose 사용 (권장)\n\n`docker-compose.yml` 파일을 생성합니다:\n\n```yaml\nservices:\n  tinyrdm:\n    image: ghcr.io/tiny-craft/tiny-rdm:latest\n    container_name: tinyrdm\n    restart: unless-stopped\n    ports:\n      - \"8086:8086\"\n    environment:\n      - ADMIN_USERNAME=admin\n      - ADMIN_PASSWORD=tinyrdm\n    volumes:\n      - ./data:/app/tinyrdm\n```\n\n서비스를 시작합니다:\n\n```bash\ndocker compose up -d\n```\n\n시작 후 `http://localhost:8086`에 접속하여 위에서 설정한 사용자 이름과 비밀번호로 로그인하세요.\n\n### Docker 명령 사용\n\n```bash\ndocker run -d --name tinyrdm \\\n  -p 8086:8086 \\\n  -e ADMIN_USERNAME=admin \\\n  -e ADMIN_PASSWORD=tinyrdm \\\n  -v ./data:/app/tinyrdm \\\n  ghcr.io/tiny-craft/tiny-rdm:latest\n```\n\n### 환경 변수\n\n| 변수 | 설명 | 기본값 |\n|------|------|--------|\n| `ADMIN_USERNAME` | 로그인 사용자 이름 | - |\n| `ADMIN_PASSWORD` | 로그인 비밀번호 | - |\n\n## 소개\n\n### 스폰서\n\n이 프로젝트가 도움이 되셨다면 커피 한 잔 사주세요 ☕️\n\n* Wechat 후원\n\n<img src=\"docs/images/wechat_sponsor.jpg\" alt=\"wechat\" width=\"200\" />\n\n### 감사\n\n호스팅 후원을 제공해 주신 다음 서비스 제공업체에 감사드립니다\n\n[![Powered by NotiDC](docs/images/notidc_logo.png)](https://www.notidc.com/ \"Powered by NotiDC\")\n"
  },
  {
    "path": "README_pt.md",
    "content": "<div align=\"center\">\n<a href=\"https://github.com/tiny-craft/tiny-rdm/\"><img src=\"build/appicon.png\" width=\"120\"/></a>\n</div>\n<h1 align=\"center\">Tiny RDM</h1>\n<h4 align=\"center\"><a href=\"/\">English</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_zh.md\">简体中文</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_tw.md\">繁體中文</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ja.md\">日本語</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ko.md\">한국어</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_fr.md\">Français</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_es.md\">Español</a> | <strong>Português (BR)</strong> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ru.md\">Русский</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_tr.md\">Türkçe</a></h4>\n<div align=\"center\">\n\n[![License](https://img.shields.io/github/license/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE)\n[![GitHub release](https://img.shields.io/github/release/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/releases)\n![GitHub All Releases](https://img.shields.io/github/downloads/tiny-craft/tiny-rdm/total)\n[![GitHub stars](https://img.shields.io/github/stars/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/stargazers)\n[![GitHub forks](https://img.shields.io/github/forks/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/fork)\n\n<strong>Tiny RDM é um gerenciador Redis moderno, leve e multiplataforma, disponível para Mac, Windows e Linux. Também oferece uma versão web que pode ser implantada via Docker.</strong>\n</div>\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"screenshots/dark_en.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"screenshots/light_en.png\">\n <img alt=\"screenshot\" src=\"screenshots/dark_en.png\">\n</picture>\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"screenshots/dark_en2.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"screenshots/light_en2.png\">\n <img alt=\"screenshot\" src=\"screenshots/dark_en2.png\">\n</picture>\n\n## Funcionalidades\n\n* Ultra leve, baseado em Webview2, sem navegador embutido (Graças ao [Wails](https://github.com/wailsapp/wails))\n* Interface visual e amigável, temas claro e escuro (Graças ao [Naive UI](https://github.com/tusen-ai/naive-ui) e [IconPark](https://iconpark.oceanengine.com))\n* Suporte multilíngue ([Precisa de mais idiomas? Clique aqui para contribuir](.github/CONTRIBUTING.md))\n* Gerenciamento aprimorado de conexões: túnel SSH/SSL/modo Sentinel/modo Cluster/proxy HTTP/proxy SOCKS5\n* Visualização de operações chave-valor, suporte CRUD para List, Hash, String, Set, Sorted Set e Stream\n* Suporte a múltiplos formatos de visualização e métodos de decodificação/descompressão\n* Carregamento segmentado com SCAN para listar facilmente milhões de chaves\n* Lista de logs do histórico de comandos\n* Modo linha de comando\n* Lista de logs lentos\n* Carregamento segmentado e consultas para List/Hash/Set/Sorted Set\n* Decodificação/descompressão de valores para List/Hash/Set/Sorted Set\n* Integração com Monaco Editor\n* Monitoramento de comandos em tempo real\n* Importação/exportação de dados\n* Publicação/assinatura\n* Importação/exportação de perfis de conexão\n* Codificador e decodificador de dados personalizados para exibição de valores ([Instruções aqui](https://tinyrdm.com/guide/custom-decoder/))\n\n## Instalação\n\nDisponível para download gratuito [aqui](https://github.com/tiny-craft/tiny-rdm/releases).\n\n> Se não conseguir abrir após a instalação no macOS, execute o seguinte comando e reabra:\n> ``` shell\n>  sudo xattr -d com.apple.quarantine /Applications/Tiny\\ RDM.app\n> ```\n\n## Guia de compilação\n\n### Pré-requisitos\n\n* Go (versão mais recente)\n* Node.js >= 20\n* NPM >= 9\n\n### Instalar Wails\n\n```bash\ngo install github.com/wailsapp/wails/v2/cmd/wails@latest\n```\n\n### Obter o código\n\n```bash\ngit clone https://github.com/tiny-craft/tiny-rdm --depth=1\n```\n\n### Compilar o frontend\n\n```bash\nnpm install --prefix ./frontend\n```\n\nou\n\n```bash\ncd frontend\nnpm install\n```\n\n### Compilar e executar\n\n```bash\nwails dev\n```\n\n## Implantação com Docker\n\nAlém do cliente desktop, o Tiny RDM também oferece uma versão web que pode ser implantada rapidamente via Docker.\n\n### Usando Docker Compose (recomendado)\n\nCrie um arquivo `docker-compose.yml`:\n\n```yaml\nservices:\n  tinyrdm:\n    image: ghcr.io/tiny-craft/tiny-rdm:latest\n    container_name: tinyrdm\n    restart: unless-stopped\n    ports:\n      - \"8086:8086\"\n    environment:\n      - ADMIN_USERNAME=admin\n      - ADMIN_PASSWORD=tinyrdm\n    volumes:\n      - ./data:/app/tinyrdm\n```\n\nInicie o serviço:\n\n```bash\ndocker compose up -d\n```\n\nApós iniciar, acesse `http://localhost:8086` e faça login com as credenciais configuradas acima.\n\n### Usando o comando Docker\n\n```bash\ndocker run -d --name tinyrdm \\\n  -p 8086:8086 \\\n  -e ADMIN_USERNAME=admin \\\n  -e ADMIN_PASSWORD=tinyrdm \\\n  -v ./data:/app/tinyrdm \\\n  ghcr.io/tiny-craft/tiny-rdm:latest\n```\n\n### Variáveis de ambiente\n\n| Variável | Descrição | Padrão |\n|----------|-----------|--------|\n| `ADMIN_USERNAME` | Nome de usuário | - |\n| `ADMIN_PASSWORD` | Senha | - |\n\n## Sobre\n\n### Patrocinar\n\nSe este projeto foi útil para você, sinta-se à vontade para pagar um café ☕️\n\n* Wechat Sponsor\n\n<img src=\"docs/images/wechat_sponsor.jpg\" alt=\"wechat\" width=\"200\" />\n\n### Agradecimentos\n\nAgradecemos aos seguintes provedores de serviços pelo patrocínio de hospedagem\n\n[![Powered by NotiDC](docs/images/notidc_logo.png)](https://www.notidc.com/ \"Powered by NotiDC\")\n"
  },
  {
    "path": "README_ru.md",
    "content": "<div align=\"center\">\n<a href=\"https://github.com/tiny-craft/tiny-rdm/\"><img src=\"build/appicon.png\" width=\"120\"/></a>\n</div>\n<h1 align=\"center\">Tiny RDM</h1>\n<h4 align=\"center\"><a href=\"/\">English</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_zh.md\">简体中文</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_tw.md\">繁體中文</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ja.md\">日本語</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ko.md\">한국어</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_fr.md\">Français</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_es.md\">Español</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_pt.md\">Português (BR)</a> | <strong>Русский</strong> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_tr.md\">Türkçe</a></h4>\n<div align=\"center\">\n\n[![License](https://img.shields.io/github/license/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE)\n[![GitHub release](https://img.shields.io/github/release/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/releases)\n![GitHub All Releases](https://img.shields.io/github/downloads/tiny-craft/tiny-rdm/total)\n[![GitHub stars](https://img.shields.io/github/stars/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/stargazers)\n[![GitHub forks](https://img.shields.io/github/forks/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/fork)\n\n<strong>Tiny RDM — современный легковесный кроссплатформенный менеджер Redis для Mac, Windows и Linux. Также доступна веб-версия с возможностью развёртывания через Docker.</strong>\n</div>\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"screenshots/dark_en.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"screenshots/light_en.png\">\n <img alt=\"screenshot\" src=\"screenshots/dark_en.png\">\n</picture>\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"screenshots/dark_en2.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"screenshots/light_en2.png\">\n <img alt=\"screenshot\" src=\"screenshots/dark_en2.png\">\n</picture>\n\n## Возможности\n\n* Сверхлёгкий, на базе Webview2, без встроенного браузера (Спасибо [Wails](https://github.com/wailsapp/wails))\n* Визуально приятный и удобный интерфейс, светлая и тёмная темы (Спасибо [Naive UI](https://github.com/tusen-ai/naive-ui) и [IconPark](https://iconpark.oceanengine.com))\n* Поддержка нескольких языков ([Нужно больше языков? Нажмите здесь, чтобы помочь](.github/CONTRIBUTING.md))\n* Улучшенное управление подключениями: SSH-туннель/SSL/режим Sentinel/режим Cluster/HTTP-прокси/SOCKS5-прокси\n* Визуализация операций с ключами, поддержка CRUD для List, Hash, String, Set, Sorted Set и Stream\n* Поддержка множества форматов отображения и методов декодирования/распаковки\n* Сегментированная загрузка через SCAN для удобной работы с миллионами ключей\n* Журнал истории выполненных команд\n* Режим командной строки\n* Список медленных запросов\n* Сегментированная загрузка и запросы для List/Hash/Set/Sorted Set\n* Декодирование/распаковка значений для List/Hash/Set/Sorted Set\n* Интеграция с Monaco Editor\n* Мониторинг команд в реальном времени\n* Импорт/экспорт данных\n* Публикация/подписка\n* Импорт/экспорт профилей подключений\n* Пользовательские кодировщики и декодировщики для отображения значений ([Инструкция](https://tinyrdm.com/guide/custom-decoder/))\n\n## Установка\n\nДоступно для бесплатного скачивания [здесь](https://github.com/tiny-craft/tiny-rdm/releases).\n\n> Если после установки на macOS приложение не открывается, выполните следующую команду и попробуйте снова:\n> ``` shell\n>  sudo xattr -d com.apple.quarantine /Applications/Tiny\\ RDM.app\n> ```\n\n## Руководство по сборке\n\n### Требования\n\n* Go (последняя версия)\n* Node.js >= 20\n* NPM >= 9\n\n### Установка Wails\n\n```bash\ngo install github.com/wailsapp/wails/v2/cmd/wails@latest\n```\n\n### Получение кода\n\n```bash\ngit clone https://github.com/tiny-craft/tiny-rdm --depth=1\n```\n\n### Сборка фронтенда\n\n```bash\nnpm install --prefix ./frontend\n```\n\nили\n\n```bash\ncd frontend\nnpm install\n```\n\n### Компиляция и запуск\n\n```bash\nwails dev\n```\n\n## Развёртывание через Docker\n\nПомимо десктопного клиента, Tiny RDM предоставляет веб-версию, которую можно быстро развернуть через Docker.\n\n### С помощью Docker Compose (рекомендуется)\n\nСоздайте файл `docker-compose.yml`:\n\n```yaml\nservices:\n  tinyrdm:\n    image: ghcr.io/tiny-craft/tiny-rdm:latest\n    container_name: tinyrdm\n    restart: unless-stopped\n    ports:\n      - \"8086:8086\"\n    environment:\n      - ADMIN_USERNAME=admin\n      - ADMIN_PASSWORD=tinyrdm\n    volumes:\n      - ./data:/app/tinyrdm\n```\n\nЗапустите сервис:\n\n```bash\ndocker compose up -d\n```\n\nПосле запуска откройте `http://localhost:8086` и войдите с указанными выше учётными данными.\n\n### С помощью команды Docker\n\n```bash\ndocker run -d --name tinyrdm \\\n  -p 8086:8086 \\\n  -e ADMIN_USERNAME=admin \\\n  -e ADMIN_PASSWORD=tinyrdm \\\n  -v ./data:/app/tinyrdm \\\n  ghcr.io/tiny-craft/tiny-rdm:latest\n```\n\n### Переменные окружения\n\n| Переменная | Описание | По умолчанию |\n|------------|----------|--------------|\n| `ADMIN_USERNAME` | Имя пользователя | - |\n| `ADMIN_PASSWORD` | Пароль | - |\n\n## О проекте\n\n### Спонсорство\n\nЕсли этот проект оказался полезным, угостите автора чашкой кофе ☕️\n\n* Wechat Sponsor\n\n<img src=\"docs/images/wechat_sponsor.jpg\" alt=\"wechat\" width=\"200\" />\n\n### Благодарности\n\nБлагодарим следующих поставщиков услуг за спонсорство хостинга\n\n[![Powered by NotiDC](docs/images/notidc_logo.png)](https://www.notidc.com/ \"Powered by NotiDC\")\n"
  },
  {
    "path": "README_tr.md",
    "content": "<div align=\"center\">\n<a href=\"https://github.com/tiny-craft/tiny-rdm/\"><img src=\"build/appicon.png\" width=\"120\"/></a>\n</div>\n<h1 align=\"center\">Tiny RDM</h1>\n<h4 align=\"center\"><a href=\"/\">English</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_zh.md\">简体中文</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_tw.md\">繁體中文</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ja.md\">日本語</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ko.md\">한국어</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_fr.md\">Français</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_es.md\">Español</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_pt.md\">Português (BR)</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ru.md\">Русский</a> | <strong>Türkçe</strong></h4>\n<div align=\"center\">\n\n[![License](https://img.shields.io/github/license/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE)\n[![GitHub release](https://img.shields.io/github/release/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/releases)\n![GitHub All Releases](https://img.shields.io/github/downloads/tiny-craft/tiny-rdm/total)\n[![GitHub stars](https://img.shields.io/github/stars/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/stargazers)\n[![GitHub forks](https://img.shields.io/github/forks/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/fork)\n\n<strong>Tiny RDM, Mac, Windows ve Linux için kullanılabilen modern, hafif ve çapraz platform bir Redis masaüstü yöneticisidir. Docker ile dağıtılabilen bir web sürümü de sunmaktadır.</strong>\n</div>\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"screenshots/dark_en.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"screenshots/light_en.png\">\n <img alt=\"screenshot\" src=\"screenshots/dark_en.png\">\n</picture>\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"screenshots/dark_en2.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"screenshots/light_en2.png\">\n <img alt=\"screenshot\" src=\"screenshots/dark_en2.png\">\n</picture>\n\n## Özellikler\n\n* Ultra hafif, Webview2 tabanlı, gömülü tarayıcı yok ([Wails](https://github.com/wailsapp/wails)'e teşekkürler)\n* Görsel ve kullanıcı dostu arayüz, açık ve koyu tema desteği ([Naive UI](https://github.com/tusen-ai/naive-ui) ve [IconPark](https://iconpark.oceanengine.com)'a teşekkürler)\n* Çoklu dil desteği ([Daha fazla dil mi gerekiyor? Katkıda bulunmak için tıklayın](.github/CONTRIBUTING.md))\n* Gelişmiş bağlantı yönetimi: SSH Tüneli/SSL/Sentinel Modu/Cluster Modu/HTTP proxy/SOCKS5 proxy desteği\n* Anahtar-değer işlemlerinin görselleştirilmesi, List, Hash, String, Set, Sorted Set ve Stream için CRUD desteği\n* Çoklu veri görüntüleme formatı ve çözme/sıkıştırma açma yöntemleri desteği\n* SCAN ile segmentli yükleme, milyonlarca anahtarı kolayca listeleme\n* Komut işlem geçmişi günlük listesi\n* Komut satırı modu\n* Yavaş günlük listesi\n* List/Hash/Set/Sorted Set için segmentli yükleme ve sorgulama\n* List/Hash/Set/Sorted Set değerleri için çözme/sıkıştırma açma\n* Monaco Editor entegrasyonu\n* Gerçek zamanlı komut izleme desteği\n* Veri içe/dışa aktarma desteği\n* Yayınla/abone ol desteği\n* Bağlantı profili içe/dışa aktarma desteği\n* Değer görüntüleme için özel veri kodlayıcı ve çözücü ([Talimatlar burada](https://tinyrdm.com/guide/custom-decoder/))\n\n## Kurulum\n\n[Buradan](https://github.com/tiny-craft/tiny-rdm/releases) ücretsiz olarak indirilebilir.\n\n> macOS'ta kurulumdan sonra açamıyorsanız, aşağıdaki komutu çalıştırıp tekrar açın:\n> ``` shell\n>  sudo xattr -d com.apple.quarantine /Applications/Tiny\\ RDM.app\n> ```\n\n## Derleme Kılavuzu\n\n### Gereksinimler\n\n* Go (en son sürüm)\n* Node.js >= 20\n* NPM >= 9\n\n### Wails Kurulumu\n\n```bash\ngo install github.com/wailsapp/wails/v2/cmd/wails@latest\n```\n\n### Kodu Çekme\n\n```bash\ngit clone https://github.com/tiny-craft/tiny-rdm --depth=1\n```\n\n### Frontend Derleme\n\n```bash\nnpm install --prefix ./frontend\n```\n\nveya\n\n```bash\ncd frontend\nnpm install\n```\n\n### Derleme ve Çalıştırma\n\n```bash\nwails dev\n```\n\n## Docker ile Dağıtım\n\nMasaüstü istemcisinin yanı sıra, Tiny RDM Docker ile hızlıca dağıtılabilen bir web sürümü de sunmaktadır.\n\n### Docker Compose Kullanımı (önerilen)\n\nBir `docker-compose.yml` dosyası oluşturun:\n\n```yaml\nservices:\n  tinyrdm:\n    image: ghcr.io/tiny-craft/tiny-rdm:latest\n    container_name: tinyrdm\n    restart: unless-stopped\n    ports:\n      - \"8086:8086\"\n    environment:\n      - ADMIN_USERNAME=admin\n      - ADMIN_PASSWORD=tinyrdm\n    volumes:\n      - ./data:/app/tinyrdm\n```\n\nServisi başlatın:\n\n```bash\ndocker compose up -d\n```\n\nBaşlatıldıktan sonra `http://localhost:8086` adresini ziyaret edin ve yukarıda yapılandırılan kimlik bilgileriyle giriş yapın.\n\n### Docker Komutu Kullanımı\n\n```bash\ndocker run -d --name tinyrdm \\\n  -p 8086:8086 \\\n  -e ADMIN_USERNAME=admin \\\n  -e ADMIN_PASSWORD=tinyrdm \\\n  -v ./data:/app/tinyrdm \\\n  ghcr.io/tiny-craft/tiny-rdm:latest\n```\n\n### Ortam Değişkenleri\n\n| Değişken | Açıklama | Varsayılan |\n|----------|----------|------------|\n| `ADMIN_USERNAME` | Giriş kullanıcı adı | - |\n| `ADMIN_PASSWORD` | Giriş şifresi | - |\n\n## Hakkında\n\n### Sponsor\n\nBu proje işinize yaradıysa, bir kahve ısmarlayabilirsiniz ☕️\n\n* Wechat Sponsor\n\n<img src=\"docs/images/wechat_sponsor.jpg\" alt=\"wechat\" width=\"200\" />\n\n### Teşekkürler\n\nBarındırma sponsorluğu sağlayan aşağıdaki hizmet sağlayıcılara teşekkür ederiz\n\n[![Powered by NotiDC](docs/images/notidc_logo.png)](https://www.notidc.com/ \"Powered by NotiDC\")\n"
  },
  {
    "path": "README_tw.md",
    "content": "<div align=\"center\">\n<a href=\"https://github.com/tiny-craft/tiny-rdm/\"><img src=\"build/appicon.png\" width=\"120\"/></a>\n</div>\n<h1 align=\"center\">Tiny RDM</h1>\n<h4 align=\"center\"><a href=\"/\">English</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_zh.md\">简体中文</a> | <strong>繁體中文</strong> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ja.md\">日本語</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ko.md\">한국어</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_fr.md\">Français</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_es.md\">Español</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_pt.md\">Português (BR)</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ru.md\">Русский</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_tr.md\">Türkçe</a></h4>\n<div align=\"center\">\n\n[![License](https://img.shields.io/github/license/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE)\n[![GitHub release](https://img.shields.io/github/release/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/releases)\n![GitHub All Releases](https://img.shields.io/github/downloads/tiny-craft/tiny-rdm/total)\n[![GitHub stars](https://img.shields.io/github/stars/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/stargazers)\n[![GitHub forks](https://img.shields.io/github/forks/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/fork)\n\n<strong>Tiny RDM 是一款現代化輕量級的跨平台 Redis 桌面管理工具，支援 Mac、Windows 和 Linux，同時提供 Web 版本，可透過 Docker 快速部署</strong>\n</div>\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"screenshots/dark_en.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"screenshots/light_en.png\">\n <img alt=\"screenshot\" src=\"screenshots/dark_en.png\">\n</picture>\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"screenshots/dark_en2.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"screenshots/light_en2.png\">\n <img alt=\"screenshot\" src=\"screenshots/dark_en2.png\">\n</picture>\n\n## 功能特性\n\n* 極度輕量，基於 Webview2，無內嵌瀏覽器（感謝 [Wails](https://github.com/wailsapp/wails)）\n* 介面精美易用，提供淺色/深色主題（感謝 [Naive UI](https://github.com/tusen-ai/naive-ui) 和 [IconPark](https://iconpark.oceanengine.com)）\n* 多國語言支援（[需要更多語言支援？點此貢獻](.github/CONTRIBUTING.md)）\n* 更好的連線管理：支援 SSH 隧道/SSL/哨兵模式/叢集模式/HTTP 代理/SOCKS5 代理\n* 視覺化鍵值操作，支援 List、Hash、String、Set、Sorted Set 和 Stream 的 CRUD\n* 支援多種資料檢視格式及轉碼/解壓方式\n* 採用 SCAN 分段載入，可輕鬆處理數百萬鍵列表\n* 操作命令執行日誌展示\n* 提供命令列模式\n* 提供慢日誌展示\n* List/Hash/Set/Sorted Set 的分段載入和查詢\n* List/Hash/Set/Sorted Set 值的轉碼顯示\n* 內建高級編輯器 Monaco Editor\n* 支援命令即時監控\n* 支援匯入/匯出資料\n* 支援發布訂閱\n* 支援匯入/匯出連線設定\n* 自訂資料展示編碼/解碼（[操作指引](https://tinyrdm.com/guide/custom-decoder/)）\n\n## 安裝\n\n提供 Mac、Windows 和 Linux 安裝包，可[免費下載](https://github.com/tiny-craft/tiny-rdm/releases)。\n\n> 如果在 macOS 上安裝後無法開啟，出現**不受信任**或**移到垃圾桶**的錯誤，執行以下命令後再啟動即可：\n> ``` shell\n>  sudo xattr -d com.apple.quarantine /Applications/Tiny\\ RDM.app\n> ```\n\n## 建置專案\n\n### 環境需求\n\n* Go（最新版本）\n* Node.js >= 20\n* NPM >= 9\n\n### 安裝 Wails\n\n```bash\ngo install github.com/wailsapp/wails/v2/cmd/wails@latest\n```\n\n### 取得程式碼\n\n```bash\ngit clone https://github.com/tiny-craft/tiny-rdm --depth=1\n```\n\n### 建置前端\n\n```bash\nnpm install --prefix ./frontend\n```\n\n或\n\n```bash\ncd frontend\nnpm install\n```\n\n### 編譯並執行\n\n```bash\nwails dev\n```\n\n## Docker 部署\n\n除桌面客戶端外，Tiny RDM 還提供 Web 版本，可透過 Docker 快速部署。\n\n### 使用 Docker Compose（推薦）\n\n建立 `docker-compose.yml` 檔案：\n\n```yaml\nservices:\n  tinyrdm:\n    image: ghcr.io/tiny-craft/tiny-rdm:latest\n    container_name: tinyrdm\n    restart: unless-stopped\n    ports:\n      - \"8086:8086\"\n    environment:\n      - ADMIN_USERNAME=admin\n      - ADMIN_PASSWORD=tinyrdm\n    volumes:\n      - ./data:/app/tinyrdm\n```\n\n啟動服務：\n\n```bash\ndocker compose up -d\n```\n\n啟動後造訪 `http://localhost:8086`，使用上方設定的帳號密碼登入。\n\n### 使用 Docker 命令\n\n```bash\ndocker run -d --name tinyrdm \\\n  -p 8086:8086 \\\n  -e ADMIN_USERNAME=admin \\\n  -e ADMIN_PASSWORD=tinyrdm \\\n  -v ./data:/app/tinyrdm \\\n  ghcr.io/tiny-craft/tiny-rdm:latest\n```\n\n### 環境變數說明\n\n| 變數 | 說明 | 預設值 |\n|------|------|--------|\n| `ADMIN_USERNAME` | 登入帳號 | - |\n| `ADMIN_PASSWORD` | 登入密碼 | - |\n\n## 關於\n\n### 贊助\n\n如果此專案對您有幫助，歡迎請作者喝杯咖啡 ☕️\n\n* 微信贊賞\n\n<img src=\"docs/images/wechat_sponsor.jpg\" alt=\"wechat\" width=\"200\" />\n\n### 感謝\n\n感謝以下服務商提供主機贊助\n\n[![Powered by NotiDC](docs/images/notidc_logo.png)](https://www.notidc.com/ \"Powered by NotiDC\")\n"
  },
  {
    "path": "README_zh.md",
    "content": "<div align=\"center\">\n<a href=\"https://github.com/tiny-craft/tiny-rdm/\"><img src=\"build/appicon.png\" width=\"120\"/></a>\n</div>\n<h1 align=\"center\">Tiny RDM</h1>\n<h4 align=\"center\"><a href=\"/\">English</a> | <strong>简体中文</strong> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_tw.md\">繁體中文</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ja.md\">日本語</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ko.md\">한국어</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_fr.md\">Français</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_es.md\">Español</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_pt.md\">Português (BR)</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_ru.md\">Русский</a> | <a href=\"https://github.com/tiny-craft/tiny-rdm/blob/main/README_tr.md\">Türkçe</a></h4>\n<div align=\"center\">\n\n[![License](https://img.shields.io/github/license/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/blob/main/LICENSE)\n[![GitHub release](https://img.shields.io/github/release/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/releases)\n![GitHub All Releases](https://img.shields.io/github/downloads/tiny-craft/tiny-rdm/total)\n[![GitHub stars](https://img.shields.io/github/stars/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/stargazers)\n[![GitHub forks](https://img.shields.io/github/forks/tiny-craft/tiny-rdm)](https://github.com/tiny-craft/tiny-rdm/fork)\n\n<str>一个现代化轻量级的跨平台Redis桌面客户端，支持Mac、Windows和Linux，同时提供Web版本，可通过Docker快速部署</strong>\n</div>\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"screenshots/dark_zh.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"screenshots/light_zh.png\">\n <img alt=\"screenshot\" src=\"screenshots/dark_zh.png\">\n</picture>\n\n<picture>\n <source media=\"(prefers-color-scheme: dark)\" srcset=\"screenshots/dark_zh2.png\">\n <source media=\"(prefers-color-scheme: light)\" srcset=\"screenshots/light_zh2.png\">\n <img alt=\"screenshot\" src=\"screenshots/dark_zh2.png\">\n</picture>\n\n## 功能特性\n\n* 极度轻量，基于Webview2，无内嵌浏览器（感谢[Wails](https://github.com/wailsapp/wails)）\n* 界面精美易用，提供浅色/深色主题（感谢[Naive UI](https://github.com/tusen-ai/naive-ui)\n  和 [IconPark](https://iconpark.oceanengine.com)）\n* 多国语言支持：英文/中文（[需要更多语言支持？点我贡献语言](.github/CONTRIBUTING_zh.md)）\n* 更好用的连接管理：支持SSH隧道/SSL/哨兵模式/集群模式/HTTP代理/SOCKS5代理\n* 可视化键值操作，增删查改一应俱全\n* 支持多种数据查看格式以及转码/解压方式\n* 采用SCAN分段加载，可轻松处理数百万键列表\n* 操作命令执行日志展示\n* 提供命令行操作\n* 提供慢日志展示\n* List/Hash/Set/Sorted Set的分段加载和查询\n* List/Hash/Set/Sorted Set值的转码显示\n* 内置高级编辑器Monaco Editor\n* 支持命令实时监控\n* 支持导入/导出数据\n* 支持发布订阅\n* 支持导入/导出连接配置\n* 自定义数据展示编码/解码([这是操作指引](https://tinyrdm.com/zh/guide/custom-decoder/))\n\n## 安装\n\n提供Mac、Windows和Linux安装包，可[免费下载](https://github.com/tiny-craft/tiny-rdm/releases)。\n\n> 如果在macOS上安装后无法打开，报错**不受信任**或者**移到垃圾箱**，执行下面命令后再启动即可：\n> ``` shell\n>  sudo xattr -d com.apple.quarantine /Applications/Tiny\\ RDM.app\n> ```\n\n## 构建客户端\n\n### 运行环境要求\n\n* Go（最新版本）\n* Node.js >= 20\n* NPM >= 9\n\n### 安装wails\n\n```bash\ngo install github.com/wailsapp/wails/v2/cmd/wails@latest\n```\n\n### 拉取代码\n\n```bash\ngit clone https://github.com/tiny-craft/tiny-rdm --depth=1\n```\n\n### 构建前端代码\n\n```bash\nnpm install --prefix ./frontend\n```\n\n或者\n\n```bash\ncd frontend\nnpm install\n```\n\n### 编译运行开发版本\n\n```bash\nwails dev\n```\n\n## Docker 部署\n\n除桌面客户端外，Tiny RDM 还提供 Web 版本，可通过 Docker 快速部署。\n\n### 使用 Docker Compose（推荐）\n\n创建 `docker-compose.yml` 文件：\n\n```yaml\nservices:\n  tinyrdm:\n    image: ghcr.io/tiny-craft/tiny-rdm:latest\n    container_name: tinyrdm\n    restart: unless-stopped\n    ports:\n      - \"8086:8086\"\n    environment:\n      - ADMIN_USERNAME=admin\n      - ADMIN_PASSWORD=tinyrdm\n    volumes:\n      - ./data:/app/tinyrdm\n```\n\n启动服务：\n\n```bash\ndocker compose up -d\n```\n\n启动后访问 `http://localhost:8086`，使用上面配置的用户名密码登录。\n\n### 使用 Docker 命令\n\n```bash\ndocker run -d --name tinyrdm \\\n  -p 8086:8086 \\\n  -e ADMIN_USERNAME=admin \\\n  -e ADMIN_PASSWORD=tinyrdm \\\n  -v ./data:/app/tinyrdm \\\n  ghcr.io/tiny-craft/tiny-rdm:latest\n```\n\n### 环境变量说明\n\n| 变量 | 说明 | 默认值 |\n|------|------|--------|\n| `ADMIN_USERNAME` | 登录用户名 | - |\n| `ADMIN_PASSWORD` | 登录密码 | - |\n\n## 关于\n\n如果你也同为独立开发者（团队），喜欢开源，或者对Tiny Craft的相关产品感兴趣，可以关注微信公众号或者加入QQ群，探讨心得，反馈意见，交个朋友。\n\n### 微信公众号（用户交流微信群）\n\n我会不定期更新一些关于独立开发的思考和感悟，以及独立产品的介绍，欢迎扫码关注~👏\n\n<img src=\"docs/images/wechat_official.png\" alt=\"wechat\" width=\"360\" />\n\n### B站官方账号\n\n<img src=\"docs/images/bilibili_official.png\" alt=\"bilibili\" width=\"360\" />\n\n### 独立开发互助QQ群\n\n```\n831077639\n```\n\n### 赞助\n\n该项目完全为爱发电，如果对你有所帮助，可以请作者喝杯咖啡 ☕️\n\n* 微信赞赏\n\n<img src=\"docs/images/wechat_sponsor.jpg\" alt=\"wechat\" width=\"200\" />\n\n### 感谢\n\n感谢以下服务商提供主机赞助\n\n[![Powered by NotiDC](docs/images/notidc_logo.png)](https://www.notidc.com/ \"Powered by NotiDC\")\n"
  },
  {
    "path": "backend/api/auth.go",
    "content": "//go:build web\n\npackage api\n\nimport (\n\t\"crypto/hmac\"\n\t\"crypto/rand\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"os\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// Auth configuration\nvar (\n\tauthEnabled  bool\n\tauthUsername string\n\tauthPassword string\n\tjwtSecret    []byte\n\tsessionTTL   = 24 * time.Hour\n)\n\n// Rate limiter for login attempts\ntype rateLimiter struct {\n\tmu          sync.Mutex\n\tattempts    map[string][]time.Time // ip -> timestamps\n\tmaxRate     int                    // max attempts per window\n\twindow      time.Duration\n\tmaxEntries  int // max tracked IPs to prevent memory exhaustion\n\tlastCleanup time.Time\n}\n\nvar loginLimiter = &rateLimiter{\n\tattempts:    make(map[string][]time.Time),\n\tmaxRate:     5,\n\twindow:      time.Minute,\n\tmaxEntries:  10000,\n\tlastCleanup: time.Now(),\n}\n\nfunc (rl *rateLimiter) allow(ip string) bool {\n\trl.mu.Lock()\n\tdefer rl.mu.Unlock()\n\n\tnow := time.Now()\n\tcutoff := now.Add(-rl.window)\n\n\t// Periodic full cleanup every 5 minutes to prevent memory leak\n\tif now.Sub(rl.lastCleanup) > 5*time.Minute {\n\t\tfor k, times := range rl.attempts {\n\t\t\tvalid := times[:0]\n\t\t\tfor _, t := range times {\n\t\t\t\tif t.After(cutoff) {\n\t\t\t\t\tvalid = append(valid, t)\n\t\t\t\t}\n\t\t\t}\n\t\t\tif len(valid) == 0 {\n\t\t\t\tdelete(rl.attempts, k)\n\t\t\t} else {\n\t\t\t\trl.attempts[k] = valid\n\t\t\t}\n\t\t}\n\t\trl.lastCleanup = now\n\t}\n\n\t// Hard cap on tracked IPs to prevent memory exhaustion from distributed attacks\n\tif len(rl.attempts) >= rl.maxEntries {\n\t\tif _, exists := rl.attempts[ip]; !exists {\n\t\t\t// Too many tracked IPs, reject new ones as a safety measure\n\t\t\treturn false\n\t\t}\n\t}\n\n\t// Clean old entries for this IP\n\ttimes := rl.attempts[ip]\n\tvalid := times[:0]\n\tfor _, t := range times {\n\t\tif t.After(cutoff) {\n\t\t\tvalid = append(valid, t)\n\t\t}\n\t}\n\trl.attempts[ip] = valid\n\n\tif len(valid) >= rl.maxRate {\n\t\treturn false\n\t}\n\n\trl.attempts[ip] = append(valid, now)\n\treturn true\n}\n\n// InitAuth reads auth config from environment variables\nfunc InitAuth() {\n\tauthUsername = os.Getenv(\"ADMIN_USERNAME\")\n\tauthPassword = os.Getenv(\"ADMIN_PASSWORD\")\n\tauthEnabled = authUsername != \"\" && authPassword != \"\"\n\n\t// Generate random JWT secret on each startup\n\tsecret := make([]byte, 32)\n\trand.Read(secret)\n\tjwtSecret = secret\n\n\tif ttl := os.Getenv(\"SESSION_TTL\"); ttl != \"\" {\n\t\tif d, err := time.ParseDuration(ttl); err == nil {\n\t\t\tsessionTTL = d\n\t\t}\n\t}\n\n\tif authEnabled {\n\t\tfmt.Printf(\"Auth enabled for user: %s\\n\", authUsername)\n\t} else {\n\t\tfmt.Println(\"Auth disabled (set ADMIN_USERNAME and ADMIN_PASSWORD to enable)\")\n\t}\n}\n\n// IsAuthEnabled returns whether authentication is enabled\nfunc IsAuthEnabled() bool {\n\treturn authEnabled\n}\n\n// Simple JWT-like token: header.payload.signature (HMAC-SHA256)\ntype tokenPayload struct {\n\tUser string `json:\"u\"`\n\tExp  int64  `json:\"e\"`\n\tIP   string `json:\"ip\"`\n}\n\nfunc generateToken(username, ip string) (string, time.Time) {\n\texp := time.Now().Add(sessionTTL)\n\tpayload := tokenPayload{User: username, Exp: exp.Unix(), IP: ip}\n\tdata, _ := json.Marshal(payload)\n\tencoded := hex.EncodeToString(data)\n\n\tmac := hmac.New(sha256.New, jwtSecret)\n\tmac.Write(data)\n\tsig := hex.EncodeToString(mac.Sum(nil))\n\n\treturn encoded + \".\" + sig, exp\n}\n\nfunc validateToken(token, ip string) bool {\n\tparts := strings.SplitN(token, \".\", 2)\n\tif len(parts) != 2 {\n\t\treturn false\n\t}\n\n\tdata, err := hex.DecodeString(parts[0])\n\tif err != nil {\n\t\treturn false\n\t}\n\n\t// Verify signature\n\tmac := hmac.New(sha256.New, jwtSecret)\n\tmac.Write(data)\n\texpectedSig := hex.EncodeToString(mac.Sum(nil))\n\tif !hmac.Equal([]byte(parts[1]), []byte(expectedSig)) {\n\t\treturn false\n\t}\n\n\t// Parse and validate payload\n\tvar payload tokenPayload\n\tif err := json.Unmarshal(data, &payload); err != nil {\n\t\treturn false\n\t}\n\n\tif time.Now().Unix() > payload.Exp {\n\t\treturn false\n\t}\n\n\t// IP binding\n\tif payload.IP != ip {\n\t\treturn false\n\t}\n\n\treturn true\n}\n\nfunc getClientIP(c *gin.Context) string {\n\t// Cloudflare\n\tif ip := c.GetHeader(\"CF-Connecting-IP\"); ip != \"\" {\n\t\treturn ip\n\t}\n\tif ip := c.GetHeader(\"X-Real-IP\"); ip != \"\" {\n\t\treturn ip\n\t}\n\tif ip := c.GetHeader(\"X-Forwarded-For\"); ip != \"\" {\n\t\treturn strings.Split(ip, \",\")[0]\n\t}\n\treturn c.ClientIP()\n}\n\n// AuthMiddleware protects API routes\nfunc AuthMiddleware() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tif !authEnabled {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\t// Get token from cookie\n\t\ttoken, err := c.Cookie(\"rdm_token\")\n\t\tif err != nil || !validateToken(token, getClientIP(c)) {\n\t\t\tc.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{\n\t\t\t\t\"success\": false,\n\t\t\t\t\"msg\":     \"unauthorized\",\n\t\t\t})\n\t\t\treturn\n\t\t}\n\n\t\tc.Next()\n\t}\n}\n\n// SecurityHeaders adds security headers to all responses\nfunc SecurityHeaders() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tc.Header(\"X-Content-Type-Options\", \"nosniff\")\n\t\tc.Header(\"X-Frame-Options\", \"SAMEORIGIN\")\n\t\tc.Header(\"X-XSS-Protection\", \"1; mode=block\")\n\t\tc.Header(\"Referrer-Policy\", \"strict-origin-when-cross-origin\")\n\t\tc.Header(\"Content-Security-Policy\", \"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://static.cloudflareinsights.com https://analytics.tinycraft.cc; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss: https://static.cloudflareinsights.com https://analytics.tinycraft.cc; font-src 'self' data:;\")\n\t\tc.Next()\n\t}\n}\n\n// registerAuthRoutes registers login/logout/status endpoints\nfunc registerAuthRoutes(r *gin.Engine) {\n\tr.POST(\"/api/auth/login\", handleLogin)\n\tr.POST(\"/api/auth/logout\", handleLogout)\n\tr.GET(\"/api/auth/status\", handleAuthStatus)\n}\n\nfunc handleLogin(c *gin.Context) {\n\tip := getClientIP(c)\n\n\t// Rate limiting\n\tif !loginLimiter.allow(ip) {\n\t\tc.JSON(http.StatusTooManyRequests, gin.H{\n\t\t\t\"success\": false,\n\t\t\t\"msg\":     \"too many login attempts, please try again later\",\n\t\t})\n\t\treturn\n\t}\n\n\tvar req struct {\n\t\tUsername string `json:\"username\"`\n\t\tPassword string `json:\"password\"`\n\t}\n\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\tc.JSON(http.StatusBadRequest, gin.H{\"success\": false, \"msg\": \"invalid request\"})\n\t\treturn\n\t}\n\n\t// Constant-time comparison to prevent timing attacks\n\tuserOK := hmac.Equal([]byte(req.Username), []byte(authUsername))\n\tpassOK := hmac.Equal([]byte(req.Password), []byte(authPassword))\n\n\tif !userOK || !passOK {\n\t\t// Delay to slow down brute force\n\t\ttime.Sleep(500 * time.Millisecond)\n\t\tc.JSON(http.StatusUnauthorized, gin.H{\"success\": false, \"msg\": \"invalid credentials\"})\n\t\treturn\n\t}\n\n\ttoken, exp := generateToken(req.Username, ip)\n\n\t// Set httpOnly, secure cookie\n\tsecure := c.Request.TLS != nil || c.GetHeader(\"X-Forwarded-Proto\") == \"https\"\n\tc.SetSameSite(http.SameSiteStrictMode)\n\tc.SetCookie(\"rdm_token\", token, int(sessionTTL.Seconds()), \"/\", \"\", secure, true)\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"data\": gin.H{\n\t\t\t\"expires\": exp.Unix(),\n\t\t},\n\t})\n}\n\nfunc handleLogout(c *gin.Context) {\n\tc.SetSameSite(http.SameSiteStrictMode)\n\tc.SetCookie(\"rdm_token\", \"\", -1, \"/\", \"\", false, true)\n\tc.JSON(http.StatusOK, gin.H{\"success\": true})\n}\n\nfunc handleAuthStatus(c *gin.Context) {\n\tif !authEnabled {\n\t\tc.JSON(http.StatusOK, gin.H{\n\t\t\t\"success\": true,\n\t\t\t\"data\":    gin.H{\"enabled\": false, \"authenticated\": true},\n\t\t})\n\t\treturn\n\t}\n\n\ttoken, err := c.Cookie(\"rdm_token\")\n\tauthenticated := err == nil && validateToken(token, getClientIP(c))\n\n\tc.JSON(http.StatusOK, gin.H{\n\t\t\"success\": true,\n\t\t\"data\":    gin.H{\"enabled\": true, \"authenticated\": authenticated},\n\t})\n}\n"
  },
  {
    "path": "backend/api/browser_api.go",
    "content": "//go:build web\n\npackage api\n\nimport (\n\t\"net/http\"\n\t\"tinyrdm/backend/services\"\n\t\"tinyrdm/backend/types\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc registerBrowserRoutes(rg *gin.RouterGroup) {\n\tg := rg.Group(\"/browser\")\n\n\tg.POST(\"/open-connection\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tName string `json:\"name\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().OpenConnection(req.Name))\n\t})\n\n\tg.POST(\"/close-connection\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tName string `json:\"name\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().CloseConnection(req.Name))\n\t})\n\n\tg.POST(\"/open-database\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer string `json:\"server\"`\n\t\t\tDB     int    `json:\"db\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().OpenDatabase(req.Server, req.DB))\n\t})\n\n\tg.POST(\"/server-info\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tName string `json:\"name\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().ServerInfo(req.Name))\n\t})\n\n\tg.POST(\"/load-next-keys\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer     string `json:\"server\"`\n\t\t\tDB         int    `json:\"db\"`\n\t\t\tMatch      string `json:\"match\"`\n\t\t\tKeyType    string `json:\"keyType\"`\n\t\t\tExactMatch bool   `json:\"exactMatch\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().LoadNextKeys(req.Server, req.DB, req.Match, req.KeyType, req.ExactMatch))\n\t})\n\n\tg.POST(\"/load-next-all-keys\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer     string `json:\"server\"`\n\t\t\tDB         int    `json:\"db\"`\n\t\t\tMatch      string `json:\"match\"`\n\t\t\tKeyType    string `json:\"keyType\"`\n\t\t\tExactMatch bool   `json:\"exactMatch\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().LoadNextAllKeys(req.Server, req.DB, req.Match, req.KeyType, req.ExactMatch))\n\t})\n\n\tg.POST(\"/load-all-keys\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer     string `json:\"server\"`\n\t\t\tDB         int    `json:\"db\"`\n\t\t\tMatch      string `json:\"match\"`\n\t\t\tKeyType    string `json:\"keyType\"`\n\t\t\tExactMatch bool   `json:\"exactMatch\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().LoadAllKeys(req.Server, req.DB, req.Match, req.KeyType, req.ExactMatch))\n\t})\n\n\tg.POST(\"/get-key-type\", func(c *gin.Context) {\n\t\tvar param types.KeySummaryParam\n\t\tif err := c.ShouldBindJSON(&param); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().GetKeyType(param))\n\t})\n\n\tg.POST(\"/get-key-summary\", func(c *gin.Context) {\n\t\tvar param types.KeySummaryParam\n\t\tif err := c.ShouldBindJSON(&param); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().GetKeySummary(param))\n\t})\n\n\tg.POST(\"/get-key-detail\", func(c *gin.Context) {\n\t\tvar param types.KeyDetailParam\n\t\tif err := c.ShouldBindJSON(&param); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().GetKeyDetail(param))\n\t})\n\n\tg.POST(\"/convert-value\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tValue  any    `json:\"value\"`\n\t\t\tDecode string `json:\"decode\"`\n\t\t\tFormat string `json:\"format\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().ConvertValue(req.Value, req.Decode, req.Format))\n\t})\n\n\tg.POST(\"/set-key-value\", func(c *gin.Context) {\n\t\tvar param types.SetKeyParam\n\t\tif err := c.ShouldBindJSON(&param); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().SetKeyValue(param))\n\t})\n\n\tg.POST(\"/get-hash-value\", func(c *gin.Context) {\n\t\tvar param types.GetHashParam\n\t\tif err := c.ShouldBindJSON(&param); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().GetHashValue(param))\n\t})\n\n\tg.POST(\"/set-hash-value\", func(c *gin.Context) {\n\t\tvar param types.SetHashParam\n\t\tif err := c.ShouldBindJSON(&param); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().SetHashValue(param))\n\t})\n\n\tg.POST(\"/add-hash-field\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer     string `json:\"server\"`\n\t\t\tDB         int    `json:\"db\"`\n\t\t\tKey        any    `json:\"key\"`\n\t\t\tAction     int    `json:\"action\"`\n\t\t\tFieldItems []any  `json:\"fieldItems\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().AddHashField(req.Server, req.DB, req.Key, req.Action, req.FieldItems))\n\t})\n\n\tg.POST(\"/add-list-item\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer string `json:\"server\"`\n\t\t\tDB     int    `json:\"db\"`\n\t\t\tKey    any    `json:\"key\"`\n\t\t\tAction int    `json:\"action\"`\n\t\t\tItems  []any  `json:\"items\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().AddListItem(req.Server, req.DB, req.Key, req.Action, req.Items))\n\t})\n\n\tg.POST(\"/set-list-item\", func(c *gin.Context) {\n\t\tvar param types.SetListParam\n\t\tif err := c.ShouldBindJSON(&param); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().SetListItem(param))\n\t})\n\n\tg.POST(\"/set-set-item\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer  string `json:\"server\"`\n\t\t\tDB      int    `json:\"db\"`\n\t\t\tKey     any    `json:\"key\"`\n\t\t\tRemove  bool   `json:\"remove\"`\n\t\t\tMembers []any  `json:\"members\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().SetSetItem(req.Server, req.DB, req.Key, req.Remove, req.Members))\n\t})\n\n\tg.POST(\"/update-set-item\", func(c *gin.Context) {\n\t\tvar param types.SetSetParam\n\t\tif err := c.ShouldBindJSON(&param); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().UpdateSetItem(param))\n\t})\n\n\tg.POST(\"/update-zset-value\", func(c *gin.Context) {\n\t\tvar param types.SetZSetParam\n\t\tif err := c.ShouldBindJSON(&param); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().UpdateZSetValue(param))\n\t})\n\n\tg.POST(\"/add-zset-value\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer     string             `json:\"server\"`\n\t\t\tDB         int                `json:\"db\"`\n\t\t\tKey        any                `json:\"key\"`\n\t\t\tAction     int                `json:\"action\"`\n\t\t\tValueScore map[string]float64 `json:\"valueScore\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().AddZSetValue(req.Server, req.DB, req.Key, req.Action, req.ValueScore))\n\t})\n\n\tg.POST(\"/add-stream-value\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer     string `json:\"server\"`\n\t\t\tDB         int    `json:\"db\"`\n\t\t\tKey        any    `json:\"key\"`\n\t\t\tID         string `json:\"id\"`\n\t\t\tFieldItems []any  `json:\"fieldItems\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().AddStreamValue(req.Server, req.DB, req.Key, req.ID, req.FieldItems))\n\t})\n\n\tg.POST(\"/remove-stream-values\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer string   `json:\"server\"`\n\t\t\tDB     int      `json:\"db\"`\n\t\t\tKey    any      `json:\"key\"`\n\t\t\tIDs    []string `json:\"ids\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().RemoveStreamValues(req.Server, req.DB, req.Key, req.IDs))\n\t})\n\n\tg.POST(\"/set-key-ttl\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer string `json:\"server\"`\n\t\t\tDB     int    `json:\"db\"`\n\t\t\tKey    any    `json:\"key\"`\n\t\t\tTTL    int64  `json:\"ttl\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().SetKeyTTL(req.Server, req.DB, req.Key, req.TTL))\n\t})\n\n\tg.POST(\"/batch-set-ttl\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer   string `json:\"server\"`\n\t\t\tDB       int    `json:\"db\"`\n\t\t\tKeys     []any  `json:\"keys\"`\n\t\t\tTTL      int64  `json:\"ttl\"`\n\t\t\tSerialNo string `json:\"serialNo\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().BatchSetTTL(req.Server, req.DB, req.Keys, req.TTL, req.SerialNo))\n\t})\n\n\tg.POST(\"/delete-key\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer string `json:\"server\"`\n\t\t\tDB     int    `json:\"db\"`\n\t\t\tKey    any    `json:\"key\"`\n\t\t\tAsync  bool   `json:\"async\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().DeleteKey(req.Server, req.DB, req.Key, req.Async))\n\t})\n\n\tg.POST(\"/delete-keys\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer   string `json:\"server\"`\n\t\t\tDB       int    `json:\"db\"`\n\t\t\tKeys     []any  `json:\"keys\"`\n\t\t\tSerialNo string `json:\"serialNo\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().DeleteKeys(req.Server, req.DB, req.Keys, req.SerialNo))\n\t})\n\n\tg.POST(\"/delete-keys-by-pattern\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer  string `json:\"server\"`\n\t\t\tDB      int    `json:\"db\"`\n\t\t\tPattern string `json:\"pattern\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().DeleteKeysByPattern(req.Server, req.DB, req.Pattern))\n\t})\n\n\tg.POST(\"/rename-key\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer string `json:\"server\"`\n\t\t\tDB     int    `json:\"db\"`\n\t\t\tKey    string `json:\"key\"`\n\t\t\tNewKey string `json:\"newKey\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().RenameKey(req.Server, req.DB, req.Key, req.NewKey))\n\t})\n\n\tg.POST(\"/export-key\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer        string `json:\"server\"`\n\t\t\tDB            int    `json:\"db\"`\n\t\t\tKeys          []any  `json:\"keys\"`\n\t\t\tPath          string `json:\"path\"`\n\t\t\tIncludeExpire bool   `json:\"includeExpire\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().ExportKey(req.Server, req.DB, req.Keys, req.Path, req.IncludeExpire))\n\t})\n\n\tg.POST(\"/import-csv\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer   string `json:\"server\"`\n\t\t\tDB       int    `json:\"db\"`\n\t\t\tPath     string `json:\"path\"`\n\t\t\tConflict int    `json:\"conflict\"`\n\t\t\tTTL      int64  `json:\"ttl\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().ImportCSV(req.Server, req.DB, req.Path, req.Conflict, req.TTL))\n\t})\n\n\tg.POST(\"/flush-db\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer string `json:\"server\"`\n\t\t\tDB     int    `json:\"db\"`\n\t\t\tAsync  bool   `json:\"async\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().FlushDB(req.Server, req.DB, req.Async))\n\t})\n\n\tg.POST(\"/get-slow-logs\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer string `json:\"server\"`\n\t\t\tNum    int64  `json:\"num\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().GetSlowLogs(req.Server, req.Num))\n\t})\n\n\tg.POST(\"/get-client-list\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer string `json:\"server\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().GetClientList(req.Server))\n\t})\n\n\tg.POST(\"/get-cmd-history\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tPageNo   int `json:\"pageNo\"`\n\t\t\tPageSize int `json:\"pageSize\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tif req.PageSize <= 0 {\n\t\t\treq.PageSize = 50\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Browser().GetCmdHistory(req.PageNo, req.PageSize))\n\t})\n\n\tg.POST(\"/clean-cmd-history\", func(c *gin.Context) {\n\t\tc.JSON(http.StatusOK, services.Browser().CleanCmdHistory())\n\t})\n}\n"
  },
  {
    "path": "backend/api/cli_api.go",
    "content": "//go:build web\n\npackage api\n\nimport (\n\t\"net/http\"\n\t\"tinyrdm/backend/services\"\n\t\"tinyrdm/backend/types\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc registerCLIRoutes(rg *gin.RouterGroup) {\n\tg := rg.Group(\"/cli\")\n\n\tg.POST(\"/start\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer string `json:\"server\"`\n\t\t\tDB     int    `json:\"db\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Cli().StartCli(req.Server, req.DB))\n\t})\n\n\tg.POST(\"/close\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer string `json:\"server\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Cli().CloseCli(req.Server))\n\t})\n\n\t// CLI input is handled via WebSocket - the frontend sends\n\t// {\"event\": \"cmd:input:<server>\", \"data\": \"<command>\"} over WS\n}\n"
  },
  {
    "path": "backend/api/connection_api.go",
    "content": "//go:build web\n\npackage api\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"tinyrdm/backend/services\"\n\t\"tinyrdm/backend/types\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc registerConnectionRoutes(rg *gin.RouterGroup) {\n\tg := rg.Group(\"/connection\")\n\n\tg.GET(\"/list\", func(c *gin.Context) {\n\t\tc.JSON(http.StatusOK, services.Connection().ListConnection())\n\t})\n\n\tg.GET(\"/get\", func(c *gin.Context) {\n\t\tname := c.Query(\"name\")\n\t\tc.JSON(http.StatusOK, services.Connection().GetConnection(name))\n\t})\n\n\tg.POST(\"/save\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tName   string                 `json:\"name\"`\n\t\t\tParam  types.ConnectionConfig `json:\"param\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Connection().SaveConnection(req.Name, req.Param))\n\t})\n\n\tg.POST(\"/save-sorted\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tConns []types.Connection `json:\"conns\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Connection().SaveSortedConnection(req.Conns))\n\t})\n\n\tg.POST(\"/test\", func(c *gin.Context) {\n\t\tvar param types.ConnectionConfig\n\t\tif err := c.ShouldBindJSON(&param); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Connection().TestConnection(param))\n\t})\n\n\tg.DELETE(\"/delete\", func(c *gin.Context) {\n\t\tname := c.Query(\"name\")\n\t\tc.JSON(http.StatusOK, services.Connection().DeleteConnection(name))\n\t})\n\n\tg.POST(\"/group/create\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tName string `json:\"name\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Connection().CreateGroup(req.Name))\n\t})\n\n\tg.POST(\"/group/rename\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tName    string `json:\"name\"`\n\t\t\tNewName string `json:\"newName\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Connection().RenameGroup(req.Name, req.NewName))\n\t})\n\n\tg.DELETE(\"/group/delete\", func(c *gin.Context) {\n\t\tname := c.Query(\"name\")\n\t\tincludeConn := c.Query(\"includeConn\") == \"true\"\n\t\tc.JSON(http.StatusOK, services.Connection().DeleteGroup(name, includeConn))\n\t})\n\n\tg.POST(\"/save-last-db\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tName string `json:\"name\"`\n\t\t\tDB   int    `json:\"db\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Connection().SaveLastDB(req.Name, req.DB))\n\t})\n\n\tg.POST(\"/save-refresh-interval\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tName     string `json:\"name\"`\n\t\t\tInterval int    `json:\"interval\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Connection().SaveRefreshInterval(req.Name, req.Interval))\n\t})\n\n\tg.POST(\"/export\", func(c *gin.Context) {\n\t\tc.JSON(http.StatusOK, services.Connection().ExportConnections())\n\t})\n\n\tg.POST(\"/import\", func(c *gin.Context) {\n\t\tc.JSON(http.StatusOK, services.Connection().ImportConnections())\n\t})\n\n\t// Web-specific: download connections as zip file\n\tg.GET(\"/export-download\", func(c *gin.Context) {\n\t\tdata, filename, err := services.Connection().ExportConnectionsToBytes()\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusInternalServerError, types.JSResp{Msg: \"export failed\"})\n\t\t\treturn\n\t\t}\n\t\tc.Header(\"Content-Disposition\", \"attachment; filename=\"+filename)\n\t\tc.Header(\"Content-Type\", \"application/zip\")\n\t\tc.Header(\"Content-Length\", fmt.Sprintf(\"%d\", len(data)))\n\t\tc.Data(http.StatusOK, \"application/zip\", data)\n\t})\n\n\t// Web-specific: import connections from uploaded zip file\n\tg.POST(\"/import-upload\", func(c *gin.Context) {\n\t\tfile, err := c.FormFile(\"file\")\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid file\"})\n\t\t\treturn\n\t\t}\n\t\tsrc, err := file.Open()\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusInternalServerError, types.JSResp{Msg: \"failed to read file\"})\n\t\t\treturn\n\t\t}\n\t\tdefer src.Close()\n\n\t\tdata, err := io.ReadAll(src)\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusInternalServerError, types.JSResp{Msg: \"failed to read file\"})\n\t\t\treturn\n\t\t}\n\n\t\tresp := services.Connection().ImportConnectionsFromBytes(data)\n\t\tc.JSON(http.StatusOK, resp)\n\t})\n\n\tg.POST(\"/list-sentinel-masters\", func(c *gin.Context) {\n\t\tvar param types.ConnectionConfig\n\t\tif err := c.ShouldBindJSON(&param); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Connection().ListSentinelMasters(param))\n\t})\n\n\tg.POST(\"/parse-url\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tURL string `json:\"url\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Connection().ParseConnectURL(req.URL))\n\t})\n}\n"
  },
  {
    "path": "backend/api/monitor_api.go",
    "content": "//go:build web\n\npackage api\n\nimport (\n\t\"net/http\"\n\t\"tinyrdm/backend/services\"\n\t\"tinyrdm/backend/types\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc registerMonitorRoutes(rg *gin.RouterGroup) {\n\tg := rg.Group(\"/monitor\")\n\n\tg.POST(\"/start\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer string `json:\"server\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Monitor().StartMonitor(req.Server))\n\t})\n\n\tg.POST(\"/stop\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer string `json:\"server\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Monitor().StopMonitor(req.Server))\n\t})\n\n\tg.POST(\"/export-log\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tLogs []string `json:\"logs\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Monitor().ExportLog(req.Logs))\n\t})\n}\n"
  },
  {
    "path": "backend/api/preferences_api.go",
    "content": "//go:build web\n\npackage api\n\nimport (\n\t\"net/http\"\n\t\"tinyrdm/backend/services\"\n\t\"tinyrdm/backend/types\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc registerPreferencesRoutes(rg *gin.RouterGroup) {\n\tg := rg.Group(\"/preferences\")\n\n\tg.GET(\"/get\", func(c *gin.Context) {\n\t\tc.JSON(http.StatusOK, services.Preferences().GetPreferences())\n\t})\n\n\tg.POST(\"/set\", func(c *gin.Context) {\n\t\tvar pf types.Preferences\n\t\tif err := c.ShouldBindJSON(&pf); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Preferences().SetPreferences(pf))\n\t})\n\n\tg.POST(\"/update\", func(c *gin.Context) {\n\t\tvar value map[string]any\n\t\tif err := c.ShouldBindJSON(&value); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Preferences().UpdatePreferences(value))\n\t})\n\n\tg.POST(\"/restore\", func(c *gin.Context) {\n\t\tc.JSON(http.StatusOK, services.Preferences().RestorePreferences())\n\t})\n\n\tg.GET(\"/font-list\", func(c *gin.Context) {\n\t\tc.JSON(http.StatusOK, services.Preferences().GetFontList())\n\t})\n\n\tg.GET(\"/buildin-decoder\", func(c *gin.Context) {\n\t\tc.JSON(http.StatusOK, services.Preferences().GetBuildInDecoder())\n\t})\n\n\tg.GET(\"/check-update\", func(c *gin.Context) {\n\t\tc.JSON(http.StatusOK, services.Preferences().CheckForUpdate())\n\t})\n}\n"
  },
  {
    "path": "backend/api/pubsub_api.go",
    "content": "//go:build web\n\npackage api\n\nimport (\n\t\"net/http\"\n\t\"tinyrdm/backend/services\"\n\t\"tinyrdm/backend/types\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\nfunc registerPubsubRoutes(rg *gin.RouterGroup) {\n\tg := rg.Group(\"/pubsub\")\n\n\tg.POST(\"/publish\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer  string `json:\"server\"`\n\t\t\tChannel string `json:\"channel\"`\n\t\t\tPayload string `json:\"payload\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Pubsub().Publish(req.Server, req.Channel, req.Payload))\n\t})\n\n\tg.POST(\"/subscribe\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer string `json:\"server\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Pubsub().StartSubscribe(req.Server))\n\t})\n\n\tg.POST(\"/unsubscribe\", func(c *gin.Context) {\n\t\tvar req struct {\n\t\t\tServer string `json:\"server\"`\n\t\t}\n\t\tif err := c.ShouldBindJSON(&req); err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid request\"})\n\t\t\treturn\n\t\t}\n\t\tc.JSON(http.StatusOK, services.Pubsub().StopSubscribe(req.Server))\n\t})\n}\n"
  },
  {
    "path": "backend/api/router.go",
    "content": "//go:build web\n\npackage api\n\nimport (\n\t\"log\"\n\t\"net/http\"\n\t\"strings\"\n\t\"tinyrdm/backend/services\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// maxRequestBodySize limits request body to 10MB to prevent memory exhaustion\nconst maxRequestBodySize = 10 << 20 // 10MB\n\n// SetupRouter creates the Gin router with all API routes and static file serving\nfunc SetupRouter() *gin.Engine {\n\tgin.SetMode(gin.ReleaseMode)\n\tr := gin.Default()\n\n\t// Request body size limit\n\tr.Use(func(c *gin.Context) {\n\t\tc.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxRequestBodySize)\n\t\tc.Next()\n\t})\n\n\t// Security headers\n\tr.Use(SecurityHeaders())\n\n\t// CORS - validate origin for cross-origin requests\n\tr.Use(func(c *gin.Context) {\n\t\torigin := c.GetHeader(\"Origin\")\n\t\tif origin != \"\" {\n\t\t\tif isSameOrigin(c, origin) {\n\t\t\t\tc.Header(\"Access-Control-Allow-Origin\", origin)\n\t\t\t\tc.Header(\"Access-Control-Allow-Credentials\", \"true\")\n\t\t\t\tc.Header(\"Access-Control-Allow-Methods\", \"GET, POST, PUT, DELETE, OPTIONS\")\n\t\t\t\tc.Header(\"Access-Control-Allow-Headers\", \"Content-Type, X-Requested-With\")\n\t\t\t} else {\n\t\t\t\tlog.Printf(\"[cors] blocked origin=%s host=%s\", origin, getRequestHost(c))\n\t\t\t\tc.AbortWithStatus(http.StatusForbidden)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tif c.Request.Method == \"OPTIONS\" {\n\t\t\tc.AbortWithStatus(204)\n\t\t\treturn\n\t\t}\n\t\tc.Next()\n\t})\n\n\t// CSRF protection for state-changing requests\n\tr.Use(csrfProtection())\n\n\t// Public routes (no auth required)\n\tregisterAuthRoutes(r)\n\tr.GET(\"/api/preferences/version\", func(c *gin.Context) {\n\t\tc.JSON(http.StatusOK, services.Preferences().GetAppVersion())\n\t})\n\n\t// WebSocket endpoint (auth checked via cookie + origin)\n\tr.GET(\"/ws\", wsAuthCheck(), Hub().HandleWebSocket)\n\n\t// Protected API routes\n\tapi := r.Group(\"/api\")\n\tapi.Use(AuthMiddleware())\n\tregisterConnectionRoutes(api)\n\tregisterBrowserRoutes(api)\n\tregisterCLIRoutes(api)\n\tregisterMonitorRoutes(api)\n\tregisterPubsubRoutes(api)\n\tregisterPreferencesRoutes(api)\n\tregisterSystemRoutes(api)\n\n\treturn r\n}\n\n// getRequestHost returns the effective host, considering reverse proxy headers\nfunc getRequestHost(c *gin.Context) string {\n\tif fwdHost := c.GetHeader(\"X-Forwarded-Host\"); fwdHost != \"\" {\n\t\treturn fwdHost\n\t}\n\treturn c.Request.Host\n}\n\n// stripPort removes port from host string (\"example.com:8088\" -> \"example.com\")\nfunc stripPort(host string) string {\n\tif idx := strings.LastIndex(host, \":\"); idx >= 0 {\n\t\t// Make sure it's not part of IPv6 address\n\t\tif !strings.Contains(host, \"]\") || strings.LastIndex(host, \"]\") < idx {\n\t\t\treturn host[:idx]\n\t\t}\n\t}\n\treturn host\n}\n\n// extractOriginHost extracts hostname from Origin header value\nfunc extractOriginHost(origin string) string {\n\thost := origin\n\tif idx := strings.Index(host, \"://\"); idx >= 0 {\n\t\thost = host[idx+3:]\n\t}\n\thost = strings.TrimRight(host, \"/\")\n\treturn host\n}\n\n// isSameOrigin checks if the Origin header matches the request host.\n// Compares hostnames only (ignoring port) to support reverse proxy scenarios\n// where the external port differs from the internal port.\nfunc isSameOrigin(c *gin.Context, origin string) bool {\n\toriginHost := stripPort(extractOriginHost(origin))\n\trequestHost := stripPort(getRequestHost(c))\n\treturn originHost == requestHost\n}\n\n// csrfProtection validates Origin/Referer for state-changing requests\nfunc csrfProtection() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\tmethod := c.Request.Method\n\t\tif method == \"GET\" || method == \"HEAD\" || method == \"OPTIONS\" {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\t// Check Origin header first\n\t\torigin := c.GetHeader(\"Origin\")\n\t\tif origin != \"\" {\n\t\t\tif !isSameOrigin(c, origin) {\n\t\t\t\tlog.Printf(\"[csrf] blocked origin=%s host=%s\", origin, getRequestHost(c))\n\t\t\t\tc.AbortWithStatusJSON(http.StatusForbidden, gin.H{\"success\": false, \"msg\": \"cross-origin request blocked\"})\n\t\t\t\treturn\n\t\t\t}\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\n\t\t// Fallback: check Referer\n\t\treferer := c.GetHeader(\"Referer\")\n\t\tif referer != \"\" {\n\t\t\trefererHost := extractOriginHost(referer)\n\t\t\tif slashIdx := strings.Index(refererHost, \"/\"); slashIdx >= 0 {\n\t\t\t\trefererHost = refererHost[:slashIdx]\n\t\t\t}\n\t\t\trequestHost := stripPort(getRequestHost(c))\n\t\t\tif stripPort(refererHost) != requestHost {\n\t\t\t\tlog.Printf(\"[csrf] blocked referer=%s host=%s\", referer, getRequestHost(c))\n\t\t\t\tc.AbortWithStatusJSON(http.StatusForbidden, gin.H{\"success\": false, \"msg\": \"cross-origin request blocked\"})\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tc.Next()\n\t}\n}\n\n// wsAuthCheck validates auth and origin for WebSocket connections\nfunc wsAuthCheck() gin.HandlerFunc {\n\treturn func(c *gin.Context) {\n\t\torigin := c.GetHeader(\"Origin\")\n\t\tif origin != \"\" {\n\t\t\tif !isSameOrigin(c, origin) {\n\t\t\t\tlog.Printf(\"[ws] blocked origin=%s host=%s\", origin, getRequestHost(c))\n\t\t\t\tc.AbortWithStatus(http.StatusForbidden)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tif !IsAuthEnabled() {\n\t\t\tc.Next()\n\t\t\treturn\n\t\t}\n\t\ttoken, err := c.Cookie(\"rdm_token\")\n\t\tif err != nil || !validateToken(token, getClientIP(c)) {\n\t\t\tc.AbortWithStatus(http.StatusUnauthorized)\n\t\t\treturn\n\t\t}\n\t\tc.Next()\n\t}\n}\n"
  },
  {
    "path": "backend/api/system_api.go",
    "content": "//go:build web\n\npackage api\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"tinyrdm/backend/services\"\n\t\"tinyrdm/backend/types\"\n\n\t\"github.com/gin-gonic/gin\"\n)\n\n// safeTempPath validates that a path is within the OS temp directory.\n// Prevents directory traversal attacks.\nfunc safeTempPath(reqPath string) (string, error) {\n\ttmpDir := os.TempDir()\n\tcleaned := filepath.Clean(reqPath)\n\tabs, err := filepath.Abs(cleaned)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"invalid path\")\n\t}\n\t// Ensure the resolved path is within tmpDir\n\tif !strings.HasPrefix(abs, filepath.Clean(tmpDir)+string(os.PathSeparator)) && abs != filepath.Clean(tmpDir) {\n\t\treturn \"\", fmt.Errorf(\"access denied\")\n\t}\n\treturn abs, nil\n}\n\n// sanitizeFilename removes path separators and dangerous characters from filename\nfunc sanitizeFilename(name string) string {\n\t// Take only the base name to strip any directory components\n\tname = filepath.Base(name)\n\t// Remove any remaining path separators (extra safety)\n\tname = strings.ReplaceAll(name, \"..\", \"\")\n\tname = strings.ReplaceAll(name, \"/\", \"\")\n\tname = strings.ReplaceAll(name, \"\\\\\", \"\")\n\tif name == \"\" || name == \".\" {\n\t\tname = \"upload\"\n\t}\n\treturn name\n}\n\nfunc registerSystemRoutes(rg *gin.RouterGroup) {\n\tg := rg.Group(\"/system\")\n\n\tg.GET(\"/info\", func(c *gin.Context) {\n\t\tc.JSON(http.StatusOK, services.System().Info())\n\t})\n\n\t// Web replacement for native file dialog - select file\n\tg.POST(\"/select-file\", func(c *gin.Context) {\n\t\tfile, err := c.FormFile(\"file\")\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"invalid file upload\"})\n\t\t\treturn\n\t\t}\n\n\t\t// Sanitize filename to prevent path traversal\n\t\tsafeName := sanitizeFilename(file.Filename)\n\t\ttmpDir := os.TempDir()\n\t\tdst := filepath.Join(tmpDir, safeName)\n\n\t\tif err := c.SaveUploadedFile(file, dst); err != nil {\n\t\t\tc.JSON(http.StatusInternalServerError, types.JSResp{Msg: \"failed to save file\"})\n\t\t\treturn\n\t\t}\n\n\t\tc.JSON(http.StatusOK, types.JSResp{\n\t\t\tSuccess: true,\n\t\t\tData: map[string]any{\n\t\t\t\t\"path\": dst,\n\t\t\t},\n\t\t})\n\t})\n\n\t// Web replacement for native file dialog - download file\n\tg.GET(\"/download\", func(c *gin.Context) {\n\t\treqPath := c.Query(\"path\")\n\t\tif reqPath == \"\" {\n\t\t\tc.JSON(http.StatusBadRequest, types.JSResp{Msg: \"path is required\"})\n\t\t\treturn\n\t\t}\n\n\t\t// Validate path is within temp directory only\n\t\tsafePath, err := safeTempPath(reqPath)\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusForbidden, types.JSResp{Msg: \"access denied\"})\n\t\t\treturn\n\t\t}\n\n\t\tfile, err := os.Open(safePath)\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusNotFound, types.JSResp{Msg: \"file not found\"})\n\t\t\treturn\n\t\t}\n\t\tdefer file.Close()\n\n\t\tstat, err := file.Stat()\n\t\tif err != nil {\n\t\t\tc.JSON(http.StatusInternalServerError, types.JSResp{Msg: \"failed to read file\"})\n\t\t\treturn\n\t\t}\n\n\t\tc.Header(\"Content-Disposition\", \"attachment; filename=\"+filepath.Base(safePath))\n\t\tc.Header(\"Content-Type\", \"application/octet-stream\")\n\t\tc.Header(\"Content-Length\", fmt.Sprintf(\"%d\", stat.Size()))\n\t\tio.Copy(c.Writer, file)\n\t})\n}\n"
  },
  {
    "path": "backend/api/websocket_hub.go",
    "content": "//go:build web\n\npackage api\n\nimport (\n\t\"encoding/json\"\n\t\"log\"\n\t\"net/http\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/gin-gonic/gin\"\n\t\"github.com/gorilla/websocket\"\n)\n\nconst (\n\t// wsMaxMessageSize limits incoming WebSocket messages to 1MB\n\twsMaxMessageSize = 1 << 20\n\t// wsWriteWait is the time allowed to write a message\n\twsWriteWait = 10 * time.Second\n\t// wsMaxClients limits concurrent WebSocket connections\n\twsMaxClients = 50\n)\n\n// WSMessage represents a WebSocket message\ntype WSMessage struct {\n\tEvent string `json:\"event\"`\n\tData  any    `json:\"data\"`\n}\n\n// WSHub manages all WebSocket connections\ntype WSHub struct {\n\tclients map[*websocket.Conn]bool\n\tmutex   sync.RWMutex\n}\n\nvar hub *WSHub\nvar onceHub sync.Once\n\nfunc Hub() *WSHub {\n\tif hub == nil {\n\t\tonceHub.Do(func() {\n\t\t\thub = &WSHub{\n\t\t\t\tclients: make(map[*websocket.Conn]bool),\n\t\t\t}\n\t\t})\n\t}\n\treturn hub\n}\n\nvar upgrader = websocket.Upgrader{\n\tCheckOrigin: func(r *http.Request) bool {\n\t\t// Origin validation is handled by wsAuthCheck middleware\n\t\t// Allow all here to avoid double-checking\n\t\treturn true\n\t},\n}\n\n// Emit sends an event to all connected WebSocket clients\nfunc (h *WSHub) Emit(event string, data any) {\n\tmsg := WSMessage{Event: event, Data: data}\n\tjsonData, err := json.Marshal(msg)\n\tif err != nil {\n\t\treturn\n\t}\n\n\th.mutex.RLock()\n\tdefer h.mutex.RUnlock()\n\tfor conn := range h.clients {\n\t\tconn.SetWriteDeadline(time.Now().Add(wsWriteWait))\n\t\tif err := conn.WriteMessage(websocket.TextMessage, jsonData); err != nil {\n\t\t\tlog.Printf(\"ws write error: %v\", err)\n\t\t}\n\t}\n}\n\n// HandleWebSocket handles WebSocket upgrade and connection lifecycle\nfunc (h *WSHub) HandleWebSocket(c *gin.Context) {\n\t// Check max clients\n\th.mutex.RLock()\n\tclientCount := len(h.clients)\n\th.mutex.RUnlock()\n\tif clientCount >= wsMaxClients {\n\t\tc.JSON(http.StatusServiceUnavailable, gin.H{\"msg\": \"too many connections\"})\n\t\treturn\n\t}\n\n\tconn, err := upgrader.Upgrade(c.Writer, c.Request, nil)\n\tif err != nil {\n\t\tlog.Printf(\"ws upgrade error: %v\", err)\n\t\treturn\n\t}\n\n\t// Set read limits to prevent oversized messages\n\tconn.SetReadLimit(wsMaxMessageSize)\n\n\th.mutex.Lock()\n\th.clients[conn] = true\n\th.mutex.Unlock()\n\n\tdefer func() {\n\t\th.mutex.Lock()\n\t\tdelete(h.clients, conn)\n\t\th.mutex.Unlock()\n\t\tconn.Close()\n\t}()\n\n\t// read loop - handle incoming messages (e.g. CLI input)\n\tfor {\n\t\t_, message, err := conn.ReadMessage()\n\t\tif err != nil {\n\t\t\tbreak\n\t\t}\n\t\tvar msg WSMessage\n\t\tif err := json.Unmarshal(message, &msg); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\th.handleIncoming(msg)\n\t}\n}\n\n// handleIncoming processes messages from the client\nfunc (h *WSHub) handleIncoming(msg WSMessage) {\n\t// dispatch CLI input events etc.\n\tif handler, ok := incomingHandlers[msg.Event]; ok {\n\t\thandler(msg.Data)\n\t}\n}\n\nvar incomingHandlers = map[string]func(data any){}\n\n// RegisterHandler registers a handler for incoming WebSocket events\nfunc RegisterHandler(event string, handler func(data any)) {\n\tincomingHandlers[event] = handler\n}\n"
  },
  {
    "path": "backend/consts/app_name_desktop.go",
    "content": "//go:build !web\n\npackage consts\n\nconst APP_DATA_FOLDER = \"TinyRDM\"\n"
  },
  {
    "path": "backend/consts/app_name_web.go",
    "content": "//go:build web\n\npackage consts\n\nconst APP_DATA_FOLDER = \"tinyrdm\"\n"
  },
  {
    "path": "backend/consts/default_config.go",
    "content": "package consts\n\nconst DEFAULT_FONT_SIZE = 14\nconst DEFAULT_ASIDE_WIDTH = 300\nconst DEFAULT_WINDOW_WIDTH = 1024\nconst DEFAULT_WINDOW_HEIGHT = 768\nconst MIN_WINDOW_WIDTH = 960\nconst MIN_WINDOW_HEIGHT = 640\nconst DEFAULT_LOAD_SIZE = 10000\nconst DEFAULT_SCAN_SIZE = 3000\n"
  },
  {
    "path": "backend/services/browser_service.go",
    "content": "package services\n\nimport (\n\t\"context\"\n\t\"encoding/csv\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"math\"\n\t\"net/url\"\n\t\"os\"\n\t\"slices\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\t\"tinyrdm/backend/consts\"\n\t\"tinyrdm/backend/types\"\n\t\"tinyrdm/backend/utils/coll\"\n\tconvutil \"tinyrdm/backend/utils/convert\"\n\tmaputil \"tinyrdm/backend/utils/map\"\n\tredis2 \"tinyrdm/backend/utils/redis\"\n\tsliceutil \"tinyrdm/backend/utils/slice\"\n\tstrutil \"tinyrdm/backend/utils/string\"\n\n\t\"github.com/redis/go-redis/v9\"\n)\n\ntype slowLogItem struct {\n\tTimestamp int64  `json:\"timestamp\"`\n\tClient    string `json:\"client\"`\n\tAddr      string `json:\"addr\"`\n\tCmd       string `json:\"cmd\"`\n\tCost      int64  `json:\"cost\"`\n}\n\ntype entryCursor struct {\n\tDB      int\n\tType    string\n\tKey     string\n\tPattern string\n\tCursor  uint64\n\tXLast   string // last stream pos\n}\n\ntype connectionItem struct {\n\tclient      redis.UniversalClient\n\tctx         context.Context\n\tcancelFunc  context.CancelFunc\n\tcursor      map[int]uint64      // current cursor of databases\n\tentryCursor map[int]entryCursor // current entry cursor of databases\n\tstepSize    int64\n\tdb          int // current database index\n}\n\ntype browserService struct {\n\tctx        context.Context\n\tconnMap    map[string]*connectionItem\n\tcmdHistory []cmdHistoryItem\n\tmutex      sync.Mutex\n}\n\nvar browser *browserService\nvar onceBrowser sync.Once\n\nfunc Browser() *browserService {\n\tif browser == nil {\n\t\tonceBrowser.Do(func() {\n\t\t\tbrowser = &browserService{\n\t\t\t\tconnMap: map[string]*connectionItem{},\n\t\t\t}\n\t\t})\n\t}\n\treturn browser\n}\n\nfunc (b *browserService) Start(ctx context.Context) {\n\tb.ctx = ctx\n}\n\nfunc (b *browserService) Stop() {\n\tfor _, item := range b.connMap {\n\t\tif item.client != nil {\n\t\t\tif item.cancelFunc != nil {\n\t\t\t\titem.cancelFunc()\n\t\t\t}\n\t\t\titem.client.Close()\n\t\t}\n\t}\n\tb.connMap = map[string]*connectionItem{}\n}\n\n// OpenConnection open redis server connection\nfunc (b *browserService) OpenConnection(name string) (resp types.JSResp) {\n\t// get connection config\n\tselConn := Connection().getConnection(name)\n\t// correct last database index\n\tlastDB := selConn.LastDB\n\tif selConn.DBFilterType == \"show\" && !slices.Contains(selConn.DBFilterList, lastDB) {\n\t\tlastDB = selConn.DBFilterList[0]\n\t} else if selConn.DBFilterType == \"hide\" && slices.Contains(selConn.DBFilterList, lastDB) {\n\t\tlastDB = selConn.DBFilterList[0]\n\t}\n\n\titem, db, err := b.getRedisClient2(name, lastDB)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tif lastDB != db {\n\t\tlastDB = db\n\t}\n\n\tclient, ctx := item.client, item.ctx\n\tvar totaldb int\n\tif selConn.DBFilterType == \"\" || selConn.DBFilterType == \"none\" {\n\t\t// get total databases\n\t\tif config, err := client.ConfigGet(ctx, \"databases\").Result(); err == nil {\n\t\t\tif total, err := strconv.Atoi(config[\"databases\"]); err == nil {\n\t\t\t\ttotaldb = total\n\t\t\t}\n\t\t}\n\t}\n\n\t// parse all db, response content like below\n\tvar dbs []types.ConnectionDB\n\tvar clusterKeyCount int64\n\tcluster, isCluster := client.(*redis.ClusterClient)\n\tif isCluster {\n\t\tvar keyCount atomic.Int64\n\t\terr = cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error {\n\t\t\tif size, serr := cli.DBSize(ctx).Result(); serr != nil {\n\t\t\t\treturn serr\n\t\t\t} else {\n\t\t\t\tkeyCount.Add(size)\n\t\t\t}\n\t\t\treturn nil\n\t\t})\n\t\tif err != nil {\n\t\t\tresp.Msg = \"get db size error:\" + err.Error()\n\t\t\treturn\n\t\t}\n\t\tclusterKeyCount = keyCount.Load()\n\n\t\t// only one database in cluster mode\n\t\tdbs = []types.ConnectionDB{\n\t\t\t{\n\t\t\t\tName:    \"db0\",\n\t\t\t\tIndex:   0,\n\t\t\t\tMaxKeys: int(clusterKeyCount),\n\t\t\t},\n\t\t}\n\t} else {\n\t\t// get database info\n\t\tvar res string\n\t\tinfo := map[string]map[string]string{}\n\t\tif res, err = client.Info(ctx, \"keyspace\").Result(); err != nil {\n\t\t\t//resp.Msg = \"get server info fail:\" + err.Error()\n\t\t\t//return\n\t\t} else {\n\t\t\tinfo = b.parseInfo(res)\n\t\t}\n\n\t\tif totaldb <= 0 {\n\t\t\t// cannot retrieve the database count by \"CONFIG GET databases\", try to get max index from keyspace\n\t\t\tkeyspace := info[\"Keyspace\"]\n\t\t\tvar db, maxDB int\n\t\t\tfor dbName := range keyspace {\n\t\t\t\tif db, err = strconv.Atoi(strings.TrimLeft(dbName, \"db\")); err == nil {\n\t\t\t\t\tif maxDB < db {\n\t\t\t\t\t\tmaxDB = db\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\ttotaldb = maxDB + 1\n\t\t}\n\n\t\tqueryDB := func(idx int) types.ConnectionDB {\n\t\t\tdbName := \"db\" + strconv.Itoa(idx)\n\t\t\tdbInfoStr := info[\"Keyspace\"][dbName]\n\t\t\tvar alias string\n\t\t\tif selConn.Alias != nil {\n\t\t\t\talias = selConn.Alias[idx]\n\t\t\t}\n\t\t\tif len(dbInfoStr) > 0 {\n\t\t\t\tdbInfo := b.parseDBItemInfo(dbInfoStr)\n\t\t\t\treturn types.ConnectionDB{\n\t\t\t\t\tName:    dbName,\n\t\t\t\t\tAlias:   alias,\n\t\t\t\t\tIndex:   idx,\n\t\t\t\t\tMaxKeys: dbInfo[\"keys\"],\n\t\t\t\t\tExpires: dbInfo[\"expires\"],\n\t\t\t\t\tAvgTTL:  dbInfo[\"avg_ttl\"],\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\treturn types.ConnectionDB{\n\t\t\t\t\tName:  dbName,\n\t\t\t\t\tAlias: alias,\n\t\t\t\t\tIndex: idx,\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tswitch selConn.DBFilterType {\n\t\tcase \"show\":\n\t\t\tfilterList := sliceutil.Unique(selConn.DBFilterList)\n\t\t\tfor _, idx := range filterList {\n\t\t\t\tdbs = append(dbs, queryDB(idx))\n\t\t\t}\n\t\tcase \"hide\":\n\t\t\thiddenList := coll.NewSet(selConn.DBFilterList...)\n\t\t\tfor idx := 0; idx < totaldb; idx++ {\n\t\t\t\tif !hiddenList.Contains(idx) {\n\t\t\t\t\tdbs = append(dbs, queryDB(idx))\n\t\t\t\t}\n\t\t\t}\n\t\tdefault:\n\t\t\tfor idx := 0; idx < totaldb; idx++ {\n\t\t\t\tdbs = append(dbs, queryDB(idx))\n\t\t\t}\n\t\t}\n\t}\n\n\t// get redis server version\n\tvar version string\n\tif res, err := client.Info(ctx, \"server\").Result(); err == nil || errors.Is(err, redis.Nil) {\n\t\tinfo := b.parseInfo(res)\n\t\tserverInfo := maputil.Get(info, \"Server\", map[string]string{})\n\t\tversion = maputil.Get(serverInfo, \"redis_version\", \"1.0.0\")\n\t}\n\n\tresp.Success = true\n\tresp.Data = map[string]any{\n\t\t\"db\":      dbs,\n\t\t\"view\":    selConn.KeyView,\n\t\t\"lastDB\":  selConn.LastDB,\n\t\t\"version\": version,\n\t}\n\treturn\n}\n\n// CloseConnection close redis server connection\nfunc (b *browserService) CloseConnection(name string) (resp types.JSResp) {\n\tif item, ok := b.connMap[name]; ok {\n\t\tdelete(b.connMap, name)\n\t\tif item.cancelFunc != nil {\n\t\t\titem.cancelFunc()\n\t\t}\n\t\tif item.client != nil {\n\t\t\titem.client.Close()\n\t\t}\n\t}\n\tresp.Success = true\n\treturn\n}\n\nfunc (b *browserService) createRedisClient(ctx context.Context, selConn types.ConnectionConfig) (client redis.UniversalClient, err error) {\n\thook := redis2.NewHook(selConn.Name, func(cmd string, cost int64) {\n\t\tnow := time.Now()\n\t\t//last := strings.LastIndex(cmd, \":\")\n\t\t//if last != -1 {\n\t\t//\tcmd = cmd[:last]\n\t\t//}\n\t\tb.cmdHistory = append(b.cmdHistory, cmdHistoryItem{\n\t\t\tTimestamp: now.UnixMilli(),\n\t\t\tServer:    selConn.Name,\n\t\t\tCmd:       cmd,\n\t\t\tCost:      cost,\n\t\t})\n\t})\n\n\tclient, err = Connection().createRedisClient(selConn)\n\tif err != nil {\n\t\terr = fmt.Errorf(\"create conenction error: %s\", err.Error())\n\t\treturn\n\t}\n\n\t_ = client.Do(ctx, \"CLIENT\", \"SETNAME\", url.QueryEscape(selConn.Name)).Err()\n\t// add hook to each node in cluster mode\n\tif cluster, ok := client.(*redis.ClusterClient); ok {\n\t\terr = cluster.ForEachShard(ctx, func(ctx context.Context, cli *redis.Client) error {\n\t\t\tcli.AddHook(hook)\n\t\t\treturn nil\n\t\t})\n\t\tif err != nil {\n\t\t\terr = fmt.Errorf(\"get cluster nodes error: %s\", err.Error())\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tclient.AddHook(hook)\n\t}\n\n\tif _, err = client.Ping(ctx).Result(); err != nil && !errors.Is(err, redis.Nil) {\n\t\terr = errors.New(\"can not connect to redis server:\" + err.Error())\n\t\treturn\n\t}\n\treturn\n}\n\n// get a redis client from local cache or create a new one\n// if db >= 0, it will also switch to target database index\nfunc (b *browserService) getRedisClient(server string, db int) (item *connectionItem, err error) {\n\tb.mutex.Lock()\n\tdefer b.mutex.Unlock()\n\n\tvar ok bool\n\tvar client redis.UniversalClient\n\tif item, ok = b.connMap[server]; ok {\n\t\tif item.db == db || db < 0 {\n\t\t\t// return without switch database directly\n\t\t\treturn\n\t\t}\n\n\t\t// close previous connection if database is not the same\n\t\tif item.cancelFunc != nil {\n\t\t\titem.cancelFunc()\n\t\t}\n\t\titem.client.Close()\n\t\tdelete(b.connMap, server)\n\t}\n\n\t// recreate new connection after switch database\n\tselConn := Connection().getConnection(server)\n\tif selConn == nil {\n\t\terr = fmt.Errorf(\"no match connection \\\"%s\\\"\", server)\n\t\tdelete(b.connMap, server)\n\t\treturn\n\t}\n\n\tctx, cancelFunc := context.WithCancel(b.ctx)\n\tb.connMap[server] = &connectionItem{\n\t\tctx:        ctx,\n\t\tcancelFunc: cancelFunc,\n\t}\n\tvar connConfig = selConn.ConnectionConfig\n\tconnConfig.LastDB = db\n\tclient, err = b.createRedisClient(ctx, connConfig)\n\tif err != nil {\n\t\tdelete(b.connMap, server)\n\t\treturn\n\t}\n\titem = &connectionItem{\n\t\tclient:      client,\n\t\tctx:         ctx,\n\t\tcancelFunc:  cancelFunc,\n\t\tcursor:      map[int]uint64{},\n\t\tentryCursor: map[int]entryCursor{},\n\t\tstepSize:    int64(selConn.LoadSize),\n\t\tdb:          db,\n\t}\n\tif item.stepSize <= 0 {\n\t\titem.stepSize = consts.DEFAULT_LOAD_SIZE\n\t}\n\tb.connMap[server] = item\n\treturn\n}\n\n// get redis client and try to reset selected database when not exists\nfunc (b *browserService) getRedisClient2(server string, db int) (item *connectionItem, selecetdDB int, err error) {\n\tselecetdDB = db\n\titem, err = b.getRedisClient(server, db)\n\tif err != nil {\n\t\tif strings.Contains(err.Error(), \"DB index is out of range\") && db != 0 {\n\t\t\tif item, err = b.getRedisClient(server, 0); err != nil {\n\t\t\t\titem = nil\n\t\t\t} else {\n\t\t\t\tselecetdDB = 0\n\t\t\t}\n\t\t}\n\t}\n\treturn\n}\n\n// load current database size\nfunc (b *browserService) loadDBSize(ctx context.Context, client redis.UniversalClient) int64 {\n\tkeyCount, _ := client.DBSize(ctx).Result()\n\treturn keyCount\n}\n\n// save current scan cursor\nfunc (b *browserService) setClientCursor(server string, db int, cursor uint64) {\n\tif _, ok := b.connMap[server]; ok {\n\t\tif cursor == 0 {\n\t\t\tdelete(b.connMap[server].cursor, db)\n\t\t} else {\n\t\t\tb.connMap[server].cursor[db] = cursor\n\t\t}\n\t}\n}\n\n// parse command response content which use \"redis info\"\n// # Keyspace\\r\\ndb0:keys=2,expires=1,avg_ttl=1877111749\\r\\ndb1:keys=33,expires=0,avg_ttl=0\\r\\ndb3:keys=17,expires=0,avg_ttl=0\\r\\ndb5:keys=3,expires=0,avg_ttl=0\\r\\n\nfunc (b *browserService) parseInfo(info string) map[string]map[string]string {\n\tparsedInfo := map[string]map[string]string{}\n\tlines := strings.Split(info, \"\\r\\n\")\n\tif len(lines) > 0 {\n\t\tvar subInfo map[string]string\n\t\tfor _, line := range lines {\n\t\t\tif strings.HasPrefix(line, \"#\") {\n\t\t\t\tsubInfo = map[string]string{}\n\t\t\t\tparsedInfo[strings.TrimSpace(strings.TrimLeft(line, \"#\"))] = subInfo\n\t\t\t} else {\n\t\t\t\titems := strings.SplitN(line, \":\", 2)\n\t\t\t\tif len(items) < 2 {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tsubInfo[items[0]] = items[1]\n\t\t\t}\n\t\t}\n\t}\n\treturn parsedInfo\n}\n\n// parse db item value, content format like below\n// keys=2,expires=1,avg_ttl=1877111749\nfunc (b *browserService) parseDBItemInfo(info string) map[string]int {\n\tret := map[string]int{}\n\titems := strings.Split(info, \",\")\n\tfor _, item := range items {\n\t\tkv := strings.SplitN(item, \"=\", 2)\n\t\tif len(kv) > 1 {\n\t\t\tret[kv[0]], _ = strconv.Atoi(kv[1])\n\t\t}\n\t}\n\treturn ret\n}\n\n// ServerInfo get server info\nfunc (b *browserService) ServerInfo(name string) (resp types.JSResp) {\n\titem, err := b.getRedisClient(name, -1)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tclient, ctx := item.client, item.ctx\n\t// get database info\n\tres, err := client.Info(ctx).Result()\n\tif err != nil {\n\t\tresp.Msg = \"get server info fail:\" + err.Error()\n\t\treturn\n\t}\n\n\tresp.Success = true\n\tresp.Data = b.parseInfo(res)\n\treturn\n}\n\n// OpenDatabase open select database, and list all keys\n// @param path contain connection name and db name\nfunc (b *browserService) OpenDatabase(server string, db int) (resp types.JSResp) {\n\tb.setClientCursor(server, db, 0)\n\n\titem, err := b.getRedisClient(server, db)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tclient, ctx := item.client, item.ctx\n\tmaxKeys := b.loadDBSize(ctx, client)\n\n\tresp.Success = true\n\tresp.Data = map[string]any{\n\t\t\"maxKeys\": maxKeys,\n\t}\n\treturn\n}\n\n// scan keys\n// @return loaded keys\n// @return next cursor\n// @return scan error\nfunc (b *browserService) scanKeys(ctx context.Context, client redis.UniversalClient, match, keyType string, cursor uint64, count int64) ([]any, uint64, error) {\n\tvar err error\n\tfilterType := len(keyType) > 0\n\tscanSize := int64(Preferences().GetScanSize())\n\t// define sub scan function\n\tscan := func(ctx context.Context, cli redis.UniversalClient, count int64, appendFunc func(k []any)) error {\n\t\tvar loadedKey []string\n\t\tvar scanCount int64\n\t\tfor {\n\t\t\tif filterType {\n\t\t\t\tloadedKey, cursor, err = cli.ScanType(ctx, cursor, match, scanSize, keyType).Result()\n\t\t\t} else {\n\t\t\t\tloadedKey, cursor, err = cli.Scan(ctx, cursor, match, scanSize).Result()\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t} else {\n\t\t\t\tks := sliceutil.Map(loadedKey, func(i int) any {\n\t\t\t\t\treturn strutil.EncodeRedisKey(loadedKey[i])\n\t\t\t\t})\n\t\t\t\tscanCount += int64(len(ks))\n\t\t\t\tappendFunc(ks)\n\t\t\t}\n\n\t\t\tif (count > 0 && scanCount > count) || cursor == 0 {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\n\tkeys := make([]any, 0)\n\tif cluster, ok := client.(*redis.ClusterClient); ok {\n\t\t// cluster mode\n\t\tvar mutex sync.Mutex\n\t\tvar totalMaster int64\n\t\tcluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error {\n\t\t\ttotalMaster += 1\n\t\t\treturn nil\n\t\t})\n\t\tpartCount := count / max(totalMaster, 1)\n\t\terr = cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error {\n\t\t\t// FIXME: BUG? can not fully load in cluster mode? maybe remove the shared \"cursor\"\n\t\t\treturn scan(ctx, cli, partCount, func(k []any) {\n\t\t\t\tmutex.Lock()\n\t\t\t\tkeys = append(keys, k...)\n\t\t\t\tmutex.Unlock()\n\t\t\t})\n\t\t})\n\t} else {\n\t\terr = scan(ctx, client, count, func(k []any) {\n\t\t\tkeys = append(keys, k...)\n\t\t})\n\t}\n\tif err != nil {\n\t\treturn keys, cursor, err\n\t}\n\treturn keys, cursor, nil\n}\n\n// check if key exists\nfunc (b *browserService) existsKey(ctx context.Context, client redis.UniversalClient, key, keyType string) bool {\n\tvar keyExists atomic.Bool\n\tif cluster, ok := client.(*redis.ClusterClient); ok {\n\t\t// cluster mode\n\t\tcluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error {\n\t\t\tif n := cli.Exists(ctx, key).Val(); n > 0 {\n\t\t\t\tif len(keyType) <= 0 || strings.ToLower(keyType) == cli.Type(ctx, key).Val() {\n\t\t\t\t\tkeyExists.Store(true)\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn nil\n\t\t})\n\t} else {\n\t\tif n := client.Exists(ctx, key).Val(); n > 0 {\n\t\t\tif len(keyType) <= 0 || strings.ToLower(keyType) == client.Type(ctx, key).Val() {\n\t\t\t\tkeyExists.Store(true)\n\t\t\t}\n\t\t}\n\t}\n\treturn keyExists.Load()\n}\n\n// LoadNextKeys load next key from saved cursor\nfunc (b *browserService) LoadNextKeys(server string, db int, match, keyType string, exactMatch bool) (resp types.JSResp) {\n\titem, err := b.getRedisClient(server, db)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tif match == \"*\" {\n\t\texactMatch = false\n\t}\n\n\tclient, ctx, count := item.client, item.ctx, item.stepSize\n\tvar matchKeys []any\n\tvar maxKeys int64\n\tcursor := item.cursor[db]\n\tfullScan := match == \"*\" || match == \"\"\n\tif exactMatch && !fullScan {\n\t\tif b.existsKey(ctx, client, match, keyType) {\n\t\t\tmatchKeys = []any{match}\n\t\t\tmaxKeys = 1\n\t\t}\n\t\tb.setClientCursor(server, db, 0)\n\t} else {\n\t\tmatchKeys, cursor, err = b.scanKeys(ctx, client, match, keyType, cursor, count)\n\t\tif err != nil {\n\t\t\tresp.Msg = err.Error()\n\t\t\treturn\n\t\t}\n\t\tb.setClientCursor(server, db, cursor)\n\t\tif fullScan {\n\t\t\tmaxKeys = b.loadDBSize(ctx, client)\n\t\t} else {\n\t\t\tmaxKeys = int64(len(matchKeys))\n\t\t}\n\t}\n\n\tresp.Success = true\n\tresp.Data = map[string]any{\n\t\t\"keys\":    matchKeys,\n\t\t\"end\":     cursor == 0,\n\t\t\"maxKeys\": maxKeys,\n\t}\n\treturn\n}\n\n// LoadNextAllKeys load next all keys\nfunc (b *browserService) LoadNextAllKeys(server string, db int, match, keyType string, exactMatch bool) (resp types.JSResp) {\n\titem, err := b.getRedisClient(server, db)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tclient, ctx := item.client, item.ctx\n\tvar matchKeys []any\n\tvar maxKeys int64\n\tfullScan := match == \"*\" || match == \"\"\n\tif exactMatch && !fullScan {\n\t\tif b.existsKey(ctx, client, match, keyType) {\n\t\t\tmatchKeys = []any{match}\n\t\t\tmaxKeys = 1\n\t\t}\n\t} else {\n\t\tcursor := item.cursor[db]\n\t\tmatchKeys, _, err = b.scanKeys(ctx, client, match, keyType, cursor, 0)\n\t\tif err != nil {\n\t\t\tresp.Msg = err.Error()\n\t\t\treturn\n\t\t}\n\t\tb.setClientCursor(server, db, 0)\n\t\tif fullScan {\n\t\t\tmaxKeys = b.loadDBSize(ctx, client)\n\t\t} else {\n\t\t\tmaxKeys = int64(len(matchKeys))\n\t\t}\n\t}\n\n\tresp.Success = true\n\tresp.Data = map[string]any{\n\t\t\"keys\":    matchKeys,\n\t\t\"maxKeys\": maxKeys,\n\t}\n\treturn\n}\n\n// LoadAllKeys load all keys\nfunc (b *browserService) LoadAllKeys(server string, db int, match, keyType string, exactMatch bool) (resp types.JSResp) {\n\titem, err := b.getRedisClient(server, db)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tclient, ctx := item.client, item.ctx\n\tvar matchKeys []any\n\tfullScan := match == \"*\" || match == \"\"\n\tif exactMatch && !fullScan {\n\t\tif b.existsKey(ctx, client, match, keyType) {\n\t\t\tmatchKeys = []any{match}\n\t\t}\n\t} else {\n\t\tmatchKeys, _, err = b.scanKeys(ctx, client, match, keyType, 0, 0)\n\t\tif err != nil {\n\t\t\tresp.Msg = err.Error()\n\t\t\treturn\n\t\t}\n\t}\n\n\tresp.Success = true\n\tresp.Data = map[string]any{\n\t\t\"keys\": matchKeys,\n\t}\n\treturn\n}\n\nfunc (b *browserService) GetKeyType(param types.KeySummaryParam) (resp types.JSResp) {\n\titem, err := b.getRedisClient(param.Server, param.DB)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tclient, ctx := item.client, item.ctx\n\tkey := strutil.DecodeRedisKey(param.Key)\n\tvar keyType string\n\tkeyType, err = client.Type(ctx, key).Result()\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tif keyType == \"none\" {\n\t\tresp.Msg = \"key not exists\"\n\t\treturn\n\t}\n\n\tvar data types.KeySummary\n\tswitch keyType {\n\tcase \"ReJSON-RL\":\n\t\tdata.Type = \"JSON\"\n\tdefault:\n\t\tdata.Type = strings.ToLower(keyType)\n\t}\n\n\tresp.Success = true\n\tresp.Data = data\n\treturn\n}\n\n// GetKeySummary get key summary info\nfunc (b *browserService) GetKeySummary(param types.KeySummaryParam) (resp types.JSResp) {\n\titem, err := b.getRedisClient(param.Server, param.DB)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tclient, ctx := item.client, item.ctx\n\tkey := strutil.DecodeRedisKey(param.Key)\n\n\tpipe := client.Pipeline()\n\ttypeVal := pipe.Type(ctx, key)\n\tttlVal := pipe.TTL(ctx, key)\n\t_, err = pipe.Exec(ctx)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tif typeVal.Err() != nil {\n\t\tresp.Msg = typeVal.Err().Error()\n\t\treturn\n\t}\n\tsize, _ := client.MemoryUsage(ctx, key, 0).Result()\n\tdata := types.KeySummary{\n\t\tType: typeVal.Val(),\n\t\tSize: size,\n\t}\n\tif data.Type == \"none\" {\n\t\tresp.Msg = \"key not exists\"\n\t\treturn\n\t}\n\n\tif ttlVal.Err() != nil {\n\t\tdata.TTL = -1\n\t} else {\n\t\tif ttlVal.Val() < 0 {\n\t\t\tdata.TTL = -1\n\t\t} else {\n\t\t\tdata.TTL = int64(ttlVal.Val().Seconds())\n\t\t}\n\t}\n\n\tswitch data.Type {\n\tcase \"string\":\n\t\tdata.Length, err = client.StrLen(ctx, key).Result()\n\tcase \"list\":\n\t\tdata.Length, err = client.LLen(ctx, key).Result()\n\tcase \"hash\":\n\t\tdata.Length, err = client.HLen(ctx, key).Result()\n\tcase \"set\":\n\t\tdata.Length, err = client.SCard(ctx, key).Result()\n\tcase \"zset\":\n\t\tdata.Length, err = client.ZCard(ctx, key).Result()\n\tcase \"stream\":\n\t\tdata.Length, err = client.XLen(ctx, key).Result()\n\tcase \"ReJSON-RL\":\n\t\tdata.Type = \"JSON\"\n\t\tdata.Length = 0\n\tdefault:\n\t\terr = errors.New(\"unknown key type\")\n\t}\n\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tresp.Success = true\n\tresp.Data = data\n\treturn\n}\n\n// GetKeyDetail get key detail\nfunc (b *browserService) GetKeyDetail(param types.KeyDetailParam) (resp types.JSResp) {\n\titem, err := b.getRedisClient(param.Server, param.DB)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tclient, ctx, entryCors := item.client, item.ctx, item.entryCursor\n\tkey := strutil.DecodeRedisKey(param.Key)\n\tvar keyType string\n\tkeyType, err = client.Type(ctx, key).Result()\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tif keyType == \"none\" {\n\t\tresp.Msg = \"key not exists\"\n\t\treturn\n\t}\n\tvar doConvert bool\n\tif (len(param.Decode) > 0 && param.Decode != types.DECODE_NONE) ||\n\t\t(len(param.Format) > 0 && param.Format != types.FORMAT_RAW) {\n\t\tdoConvert = true\n\t}\n\n\tvar data types.KeyDetail\n\tdata.KeyType = strings.ToLower(keyType)\n\t//var cursor uint64\n\tmatchPattern := param.MatchPattern\n\tif len(matchPattern) <= 0 {\n\t\tmatchPattern = \"*\"\n\t}\n\n\t// define get entry cursor function\n\tgetEntryCursor := func() (uint64, string, bool) {\n\t\tif entry, ok := entryCors[param.DB]; !ok || entry.Key != key || entry.Pattern != matchPattern {\n\t\t\t// not the same key or match pattern, reset cursor\n\t\t\tentry = entryCursor{\n\t\t\t\tDB:      param.DB,\n\t\t\t\tKey:     key,\n\t\t\t\tPattern: matchPattern,\n\t\t\t\tCursor:  0,\n\t\t\t}\n\t\t\tentryCors[param.DB] = entry\n\t\t\treturn 0, \"\", true\n\t\t} else {\n\t\t\treturn entry.Cursor, entry.XLast, false\n\t\t}\n\t}\n\t// define set entry cursor function\n\tsetEntryCursor := func(cursor uint64) {\n\t\tentryCors[param.DB] = entryCursor{\n\t\t\tDB:      param.DB,\n\t\t\tType:    \"\",\n\t\t\tKey:     key,\n\t\t\tPattern: matchPattern,\n\t\t\tCursor:  cursor,\n\t\t}\n\t}\n\t// define set last stream pos function\n\tsetEntryXLast := func(last string) {\n\t\tentryCors[param.DB] = entryCursor{\n\t\t\tDB:      param.DB,\n\t\t\tType:    \"\",\n\t\t\tKey:     key,\n\t\t\tPattern: matchPattern,\n\t\t\tXLast:   last,\n\t\t}\n\t}\n\n\tdecoder := Preferences().GetDecoder()\n\n\tswitch data.KeyType {\n\tcase \"string\":\n\t\tvar str string\n\t\tstr, err = client.Get(ctx, key).Result()\n\t\tdata.Value = strutil.EncodeRedisKey(str)\n\t\t//data.Value, data.Decode, data.Format = convutil.ConvertTo(str, param.Decode, param.Format, decoder)\n\n\tcase \"list\":\n\t\tloadListHandle := func() ([]types.ListEntryItem, bool, bool, error) {\n\t\t\tvar loadVal []string\n\t\t\tvar cursor uint64\n\t\t\tvar reset bool\n\t\t\tvar subErr error\n\t\t\tdoFilter := matchPattern != \"*\"\n\t\t\tif param.Full || doFilter {\n\t\t\t\t// load all\n\t\t\t\tcursor, reset = 0, true\n\t\t\t\tloadVal, subErr = client.LRange(ctx, key, 0, -1).Result()\n\t\t\t} else {\n\t\t\t\tif param.Reset {\n\t\t\t\t\tcursor, reset = 0, true\n\t\t\t\t} else {\n\t\t\t\t\tcursor, _, reset = getEntryCursor()\n\t\t\t\t}\n\t\t\t\tscanSize := int64(Preferences().GetScanSize())\n\t\t\t\tloadVal, subErr = client.LRange(ctx, key, int64(cursor), int64(cursor)+scanSize-1).Result()\n\t\t\t\tcursor = cursor + uint64(scanSize)\n\t\t\t\tif len(loadVal) < int(scanSize) {\n\t\t\t\t\tcursor = 0\n\t\t\t\t}\n\t\t\t}\n\t\t\tsetEntryCursor(cursor)\n\n\t\t\titems := make([]types.ListEntryItem, 0, len(loadVal))\n\t\t\tfor _, val := range loadVal {\n\t\t\t\tif doFilter && !strings.Contains(val, param.MatchPattern) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\titems = append(items, types.ListEntryItem{\n\t\t\t\t\tIndex: len(items),\n\t\t\t\t\tValue: strutil.EncodeRedisKey(val),\n\t\t\t\t})\n\t\t\t\tif doConvert {\n\t\t\t\t\tif dv, _, _ := convutil.ConvertTo(val, param.Decode, param.Format, decoder); dv != val {\n\t\t\t\t\t\titems[len(items)-1].DisplayValue = dv\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tif subErr != nil {\n\t\t\t\treturn items, reset, false, subErr\n\t\t\t}\n\t\t\treturn items, reset, cursor == 0, nil\n\t\t}\n\n\t\tdata.Value, data.Reset, data.End, err = loadListHandle()\n\t\tdata.Match, data.Decode, data.Format = param.MatchPattern, param.Decode, param.Format\n\t\tif err != nil {\n\t\t\tresp.Msg = err.Error()\n\t\t\treturn\n\t\t}\n\n\tcase \"hash\":\n\t\tif !strings.HasPrefix(matchPattern, \"*\") {\n\t\t\tmatchPattern = \"*\" + matchPattern\n\t\t}\n\t\tif !strings.HasSuffix(matchPattern, \"*\") {\n\t\t\tmatchPattern = matchPattern + \"*\"\n\t\t}\n\t\tloadHashHandle := func() ([]types.HashEntryItem, bool, bool, error) {\n\t\t\tvar items []types.HashEntryItem\n\t\t\tvar loadedVal []string\n\t\t\tvar cursor uint64\n\t\t\tvar reset bool\n\t\t\tvar subErr error\n\t\t\tscanSize := int64(Preferences().GetScanSize())\n\t\t\tif param.Full || matchPattern != \"*\" {\n\t\t\t\t// load all\n\t\t\t\tcursor, reset = 0, true\n\t\t\t\titems = []types.HashEntryItem{}\n\t\t\t\tfor {\n\t\t\t\t\tloadedVal, cursor, subErr = client.HScan(ctx, key, cursor, matchPattern, scanSize).Result()\n\t\t\t\t\tif subErr != nil {\n\t\t\t\t\t\treturn nil, reset, false, subErr\n\t\t\t\t\t}\n\t\t\t\t\tfor i := 0; i < len(loadedVal); i += 2 {\n\t\t\t\t\t\titems = append(items, types.HashEntryItem{\n\t\t\t\t\t\t\tKey:   loadedVal[i],\n\t\t\t\t\t\t\tValue: strutil.EncodeRedisKey(loadedVal[i+1]),\n\t\t\t\t\t\t})\n\t\t\t\t\t\tif doConvert {\n\t\t\t\t\t\t\tif dv, _, _ := convutil.ConvertTo(loadedVal[i+1], param.Decode, param.Format, decoder); dv != loadedVal[i+1] {\n\t\t\t\t\t\t\t\titems[len(items)-1].DisplayValue = dv\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif cursor == 0 {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif param.Reset {\n\t\t\t\t\tcursor, reset = 0, true\n\t\t\t\t} else {\n\t\t\t\t\tcursor, _, reset = getEntryCursor()\n\t\t\t\t}\n\t\t\t\tloadedVal, cursor, subErr = client.HScan(ctx, key, cursor, matchPattern, scanSize).Result()\n\t\t\t\tif subErr != nil {\n\t\t\t\t\treturn nil, reset, false, subErr\n\t\t\t\t}\n\t\t\t\tloadedLen := len(loadedVal)\n\t\t\t\titems = make([]types.HashEntryItem, loadedLen/2)\n\t\t\t\tfor i := 0; i < loadedLen; i += 2 {\n\t\t\t\t\titems[i/2].Key = loadedVal[i]\n\t\t\t\t\titems[i/2].Value = strutil.EncodeRedisKey(loadedVal[i+1])\n\t\t\t\t\tif doConvert {\n\t\t\t\t\t\tif dv, _, _ := convutil.ConvertTo(loadedVal[i+1], param.Decode, param.Format, decoder); dv != loadedVal[i+1] {\n\t\t\t\t\t\t\titems[i/2].DisplayValue = dv\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tsetEntryCursor(cursor)\n\t\t\treturn items, reset, cursor == 0, nil\n\t\t}\n\n\t\tdata.Value, data.Reset, data.End, err = loadHashHandle()\n\t\tdata.Match, data.Decode, data.Format = param.MatchPattern, param.Decode, param.Format\n\t\tif err != nil {\n\t\t\tresp.Msg = err.Error()\n\t\t\treturn\n\t\t}\n\n\tcase \"set\":\n\t\tif !strings.HasPrefix(matchPattern, \"*\") {\n\t\t\tmatchPattern = \"*\" + matchPattern\n\t\t}\n\t\tif !strings.HasSuffix(matchPattern, \"*\") {\n\t\t\tmatchPattern = matchPattern + \"*\"\n\t\t}\n\t\tloadSetHandle := func() ([]types.SetEntryItem, bool, bool, error) {\n\t\t\tvar items []types.SetEntryItem\n\t\t\tvar cursor uint64\n\t\t\tvar reset bool\n\t\t\tvar subErr error\n\t\t\tvar loadedKey []string\n\t\t\tscanSize := int64(Preferences().GetScanSize())\n\t\t\tif param.Full || matchPattern != \"*\" {\n\t\t\t\t// load all\n\t\t\t\tcursor, reset = 0, true\n\t\t\t\titems = []types.SetEntryItem{}\n\t\t\t\tfor {\n\t\t\t\t\tloadedKey, cursor, subErr = client.SScan(ctx, key, cursor, matchPattern, scanSize).Result()\n\t\t\t\t\tif subErr != nil {\n\t\t\t\t\t\treturn items, reset, false, subErr\n\t\t\t\t\t}\n\t\t\t\t\tfor _, val := range loadedKey {\n\t\t\t\t\t\titems = append(items, types.SetEntryItem{\n\t\t\t\t\t\t\tValue: strutil.EncodeRedisKey(val),\n\t\t\t\t\t\t})\n\t\t\t\t\t\tif doConvert {\n\t\t\t\t\t\t\tif dv, _, _ := convutil.ConvertTo(val, param.Decode, param.Format, decoder); dv != val {\n\t\t\t\t\t\t\t\titems[len(items)-1].DisplayValue = dv\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif cursor == 0 {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif param.Reset {\n\t\t\t\t\tcursor, reset = 0, true\n\t\t\t\t} else {\n\t\t\t\t\tcursor, _, reset = getEntryCursor()\n\t\t\t\t}\n\t\t\t\tloadedKey, cursor, subErr = client.SScan(ctx, key, cursor, matchPattern, scanSize).Result()\n\t\t\t\titems = make([]types.SetEntryItem, len(loadedKey))\n\t\t\t\tfor i, val := range loadedKey {\n\t\t\t\t\titems[i].Value = strutil.EncodeRedisKey(val)\n\t\t\t\t\tif doConvert {\n\t\t\t\t\t\tif dv, _, _ := convutil.ConvertTo(val, param.Decode, param.Format, decoder); dv != val {\n\t\t\t\t\t\t\titems[i].DisplayValue = dv\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tsetEntryCursor(cursor)\n\t\t\treturn items, reset, cursor == 0, nil\n\t\t}\n\n\t\tdata.Value, data.Reset, data.End, err = loadSetHandle()\n\t\tdata.Match, data.Decode, data.Format = param.MatchPattern, param.Decode, param.Format\n\t\tif err != nil {\n\t\t\tresp.Msg = err.Error()\n\t\t\treturn\n\t\t}\n\n\tcase \"zset\":\n\t\tif !strings.HasPrefix(matchPattern, \"*\") {\n\t\t\tmatchPattern = \"*\" + matchPattern\n\t\t}\n\t\tif !strings.HasSuffix(matchPattern, \"*\") {\n\t\t\tmatchPattern = matchPattern + \"*\"\n\t\t}\n\t\tloadZSetHandle := func() ([]types.ZSetEntryItem, bool, bool, error) {\n\t\t\tvar items []types.ZSetEntryItem\n\t\t\tvar reset bool\n\t\t\tvar cursor uint64\n\t\t\tscanSize := int64(Preferences().GetScanSize())\n\t\t\tdoFilter := matchPattern != \"*\"\n\t\t\tif param.Full || doFilter {\n\t\t\t\t// load all\n\t\t\t\tvar loadedVal []string\n\t\t\t\tcursor, reset = 0, true\n\t\t\t\titems = []types.ZSetEntryItem{}\n\t\t\t\tfor {\n\t\t\t\t\tloadedVal, cursor, err = client.ZScan(ctx, key, cursor, matchPattern, scanSize).Result()\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn items, reset, false, err\n\t\t\t\t\t}\n\t\t\t\t\tvar score float64\n\t\t\t\t\tfor i := 0; i < len(loadedVal); i += 2 {\n\t\t\t\t\t\tif score, err = strconv.ParseFloat(loadedVal[i+1], 64); err == nil {\n\t\t\t\t\t\t\titems = append(items, types.ZSetEntryItem{\n\t\t\t\t\t\t\t\tValue: strutil.EncodeRedisKey(loadedVal[i]),\n\t\t\t\t\t\t\t\tScore: score,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\tif doConvert {\n\t\t\t\t\t\t\t\tif dv, _, _ := convutil.ConvertTo(loadedVal[i], param.Decode, param.Format, decoder); dv != loadedVal[i] {\n\t\t\t\t\t\t\t\t\titems[len(items)-1].DisplayValue = dv\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif cursor == 0 {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif param.Reset {\n\t\t\t\t\tcursor, reset = 0, true\n\t\t\t\t} else {\n\t\t\t\t\tcursor, _, reset = getEntryCursor()\n\t\t\t\t}\n\t\t\t\tvar loadedVal []redis.Z\n\t\t\t\tloadedVal, err = client.ZRangeWithScores(ctx, key, int64(cursor), int64(cursor)+scanSize-1).Result()\n\t\t\t\tcursor = cursor + uint64(scanSize)\n\t\t\t\tif len(loadedVal) < int(scanSize) {\n\t\t\t\t\tcursor = 0\n\t\t\t\t}\n\n\t\t\t\titems = make([]types.ZSetEntryItem, 0, len(loadedVal))\n\t\t\t\tfor _, z := range loadedVal {\n\t\t\t\t\tval := strutil.AnyToString(z.Member, \"\", 0)\n\t\t\t\t\tif doFilter && !strings.Contains(val, param.MatchPattern) {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t\tentry := types.ZSetEntryItem{\n\t\t\t\t\t\tValue: strutil.EncodeRedisKey(val),\n\t\t\t\t\t}\n\t\t\t\t\tif math.IsInf(z.Score, 1) {\n\t\t\t\t\t\tentry.ScoreStr = \"+inf\"\n\t\t\t\t\t} else if math.IsInf(z.Score, -1) {\n\t\t\t\t\t\tentry.ScoreStr = \"-inf\"\n\t\t\t\t\t} else {\n\t\t\t\t\t\tentry.Score = z.Score\n\t\t\t\t\t}\n\t\t\t\t\titems = append(items, entry)\n\t\t\t\t\tif doConvert {\n\t\t\t\t\t\tif dv, _, _ := convutil.ConvertTo(val, param.Decode, param.Format, decoder); dv != val {\n\t\t\t\t\t\t\titems[len(items)-1].DisplayValue = dv\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tsetEntryCursor(cursor)\n\t\t\treturn items, reset, cursor == 0, nil\n\t\t}\n\n\t\tdata.Value, data.Reset, data.End, err = loadZSetHandle()\n\t\tdata.Match, data.Decode, data.Format = param.MatchPattern, param.Decode, param.Format\n\t\tif err != nil {\n\t\t\tresp.Msg = err.Error()\n\t\t\treturn\n\t\t}\n\n\tcase \"stream\":\n\t\tloadStreamHandle := func() ([]types.StreamEntryItem, bool, bool, error) {\n\t\t\tvar msgs []redis.XMessage\n\t\t\tvar last string\n\t\t\tvar reset bool\n\t\t\tdoFilter := matchPattern != \"*\"\n\t\t\tif param.Full || doFilter {\n\t\t\t\t// load all\n\t\t\t\tlast, reset = \"\", true\n\t\t\t\tmsgs, err = client.XRevRange(ctx, key, \"+\", \"-\").Result()\n\t\t\t} else {\n\t\t\t\tscanSize := int64(Preferences().GetScanSize())\n\t\t\t\tif param.Reset {\n\t\t\t\t\tlast = \"\"\n\t\t\t\t} else {\n\t\t\t\t\t_, last, reset = getEntryCursor()\n\t\t\t\t}\n\t\t\t\tif len(last) <= 0 {\n\t\t\t\t\tlast = \"+\"\n\t\t\t\t}\n\t\t\t\tif last != \"+\" {\n\t\t\t\t\t// add 1 more item when continue scan\n\t\t\t\t\tmsgs, err = client.XRevRangeN(ctx, key, last, \"-\", scanSize+1).Result()\n\t\t\t\t\tmsgs = msgs[1:]\n\t\t\t\t} else {\n\t\t\t\t\tmsgs, err = client.XRevRangeN(ctx, key, last, \"-\", scanSize).Result()\n\t\t\t\t}\n\t\t\t\tscanCount := len(msgs)\n\t\t\t\tif scanCount <= 0 || scanCount < int(scanSize) {\n\t\t\t\t\tlast = \"\"\n\t\t\t\t} else if scanCount > 0 {\n\t\t\t\t\tlast = msgs[scanCount-1].ID\n\t\t\t\t}\n\t\t\t}\n\t\t\tsetEntryXLast(last)\n\t\t\titems := make([]types.StreamEntryItem, 0, len(msgs))\n\t\t\tfor _, msg := range msgs {\n\t\t\t\tit := types.StreamEntryItem{\n\t\t\t\t\tID:    msg.ID,\n\t\t\t\t\tValue: msg.Values,\n\t\t\t\t}\n\t\t\t\tvar displayValue strings.Builder\n\t\t\t\tfor k, v := range msg.Values {\n\t\t\t\t\tif displayValue.Len() > 0 {\n\t\t\t\t\t\tdisplayValue.WriteString(\", \")\n\t\t\t\t\t}\n\t\t\t\t\tif str, ok := v.(string); ok {\n\t\t\t\t\t\tdisplayValue.WriteByte('\"')\n\t\t\t\t\t\tdisplayValue.WriteString(k)\n\t\t\t\t\t\tdisplayValue.WriteByte('\"')\n\t\t\t\t\t\tdisplayValue.WriteByte(':')\n\t\t\t\t\t\tdisplayValue.WriteString(str)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tit.DisplayValue = displayValue.String()\n\t\t\t\tif doFilter && !strings.Contains(it.DisplayValue, param.MatchPattern) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\titems = append(items, it)\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\treturn items, reset, false, err\n\t\t\t}\n\t\t\treturn items, reset, last == \"\", nil\n\t\t}\n\n\t\tdata.Value, data.Reset, data.End, err = loadStreamHandle()\n\t\tdata.Match, data.Decode, data.Format = param.MatchPattern, param.Decode, param.Format\n\t\tif err != nil {\n\t\t\tresp.Msg = err.Error()\n\t\t\treturn\n\t\t}\n\n\tcase \"rejson-rl\":\n\t\tvar jsonStr string\n\t\tdata.KeyType = \"JSON\"\n\t\tjsonStr, err = client.JSONGet(ctx, key).Result()\n\t\tdata.Value, data.Decode, data.Format = convutil.ConvertTo(jsonStr, types.DECODE_NONE, types.FORMAT_JSON, nil)\n\t}\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tresp.Success = true\n\tresp.Data = data\n\treturn\n}\n\n// ConvertValue convert value with decode method and format\n// blank decode indicate auto decode\n// blank format indicate auto format\nfunc (b *browserService) ConvertValue(value any, decode, format string) (resp types.JSResp) {\n\tstr := strutil.DecodeRedisKey(value)\n\tvalue, decode, format = convutil.ConvertTo(str, decode, format, Preferences().GetDecoder())\n\tresp.Success = true\n\tresp.Data = map[string]any{\n\t\t\"value\":  value,\n\t\t\"decode\": decode,\n\t\t\"format\": format,\n\t}\n\treturn\n}\n\n// SetKeyValue set value by key\n// @param ttl <= 0 means keep current ttl\nfunc (b *browserService) SetKeyValue(param types.SetKeyParam) (resp types.JSResp) {\n\titem, err := b.getRedisClient(param.Server, param.DB)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tclient, ctx := item.client, item.ctx\n\tkey := strutil.DecodeRedisKey(param.Key)\n\tvar expiration time.Duration\n\tif param.TTL < 0 {\n\t\tif expiration, err = client.PTTL(ctx, key).Result(); err != nil {\n\t\t\texpiration = redis.KeepTTL\n\t\t}\n\t} else {\n\t\texpiration = time.Duration(param.TTL) * time.Second\n\t}\n\t// use default decode type and format\n\tif len(param.Decode) <= 0 {\n\t\tparam.Decode = types.DECODE_NONE\n\t}\n\tif len(param.Format) <= 0 {\n\t\tparam.Format = types.FORMAT_RAW\n\t}\n\tvar savedValue any\n\tswitch strings.ToLower(param.KeyType) {\n\tcase \"string\":\n\t\tif str, ok := param.Value.(string); !ok {\n\t\t\tresp.Msg = \"invalid string value\"\n\t\t\treturn\n\t\t} else {\n\t\t\tif savedValue, err = convutil.SaveAs(str, param.Format, param.Decode, Preferences().GetDecoder()); err != nil {\n\t\t\t\tresp.Msg = fmt.Sprintf(`save to type \"%s\" fail: %s`, param.Format, err.Error())\n\t\t\t\treturn\n\t\t\t}\n\t\t\t_, err = client.Set(ctx, key, savedValue, 0).Result()\n\t\t\t// set expiration lonely, not \"keepttl\"\n\t\t\tif err == nil && expiration > 0 {\n\t\t\t\tclient.Expire(ctx, key, expiration)\n\t\t\t}\n\t\t}\n\tcase \"list\":\n\t\tif strs, ok := param.Value.([]any); !ok {\n\t\t\tresp.Msg = \"invalid list value\"\n\t\t\treturn\n\t\t} else {\n\t\t\terr = client.LPush(ctx, key, strs...).Err()\n\t\t\tif err == nil && expiration > 0 {\n\t\t\t\tclient.Expire(ctx, key, expiration)\n\t\t\t}\n\t\t}\n\tcase \"hash\":\n\t\tif strs, ok := param.Value.([]any); !ok {\n\t\t\tresp.Msg = \"invalid hash value\"\n\t\t\treturn\n\t\t} else {\n\t\t\ttotal := len(strs)\n\t\t\tif total > 1 {\n\t\t\t\t_, err = client.Pipelined(ctx, func(pipe redis.Pipeliner) error {\n\t\t\t\t\tfor i := 0; i < total; i += 2 {\n\t\t\t\t\t\tpipe.HSet(ctx, key, strs[i], strs[i+1])\n\t\t\t\t\t}\n\t\t\t\t\tif expiration > 0 {\n\t\t\t\t\t\tpipe.Expire(ctx, key, expiration)\n\t\t\t\t\t}\n\t\t\t\t\treturn nil\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\tcase \"set\":\n\t\tif strs, ok := param.Value.([]any); !ok || len(strs) <= 0 {\n\t\t\tresp.Msg = \"invalid set value\"\n\t\t\treturn\n\t\t} else {\n\t\t\tif len(strs) > 0 {\n\t\t\t\terr = client.SAdd(ctx, key, strs...).Err()\n\t\t\t\tif err == nil && expiration > 0 {\n\t\t\t\t\tclient.Expire(ctx, key, expiration)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\tcase \"zset\":\n\t\tif strs, ok := param.Value.([]any); !ok || len(strs) <= 0 {\n\t\t\tresp.Msg = \"invalid zset value\"\n\t\t\treturn\n\t\t} else {\n\t\t\tif len(strs) > 1 {\n\t\t\t\tvar members []redis.Z\n\t\t\t\tfor i := 0; i < len(strs); i += 2 {\n\t\t\t\t\tscore, _ := strconv.ParseFloat(strs[i+1].(string), 64)\n\t\t\t\t\tmembers = append(members, redis.Z{\n\t\t\t\t\t\tScore:  score,\n\t\t\t\t\t\tMember: strs[i],\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t\terr = client.ZAdd(ctx, key, members...).Err()\n\t\t\t\tif err == nil && expiration > 0 {\n\t\t\t\t\tclient.Expire(ctx, key, expiration)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\tcase \"stream\":\n\t\tif strs, ok := param.Value.([]any); !ok {\n\t\t\tresp.Msg = \"invalid stream value\"\n\t\t\treturn\n\t\t} else {\n\t\t\tif len(strs) > 2 {\n\t\t\t\terr = client.XAdd(ctx, &redis.XAddArgs{\n\t\t\t\t\tStream: key,\n\t\t\t\t\tID:     strs[0].(string),\n\t\t\t\t\tValues: strs[1:],\n\t\t\t\t}).Err()\n\t\t\t\tif err == nil && expiration > 0 {\n\t\t\t\t\tclient.Expire(ctx, key, expiration)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\tcase \"json\":\n\t\terr = client.JSONSet(ctx, key, \".\", param.Value).Err()\n\t\tif err == nil && expiration > 0 {\n\t\t\tclient.Expire(ctx, key, expiration)\n\t\t}\n\t\tvar ok bool\n\t\tif savedValue, ok = param.Value.(string); !ok {\n\t\t\tsavedValue = \"\"\n\t\t}\n\t}\n\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tresp.Success = true\n\trespData := map[string]any{}\n\tif val, ok := savedValue.(string); ok {\n\t\trespData[\"value\"] = strutil.EncodeRedisKey(val)\n\t}\n\tresp.Data = respData\n\treturn\n}\n\n// GetHashValue get hash field\nfunc (b *browserService) GetHashValue(param types.GetHashParam) (resp types.JSResp) {\n\titem, err := b.getRedisClient(param.Server, param.DB)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tclient, ctx := item.client, item.ctx\n\tkey := strutil.DecodeRedisKey(param.Key)\n\tval, err := client.HGet(ctx, key, param.Field).Result()\n\tif errors.Is(err, redis.Nil) {\n\t\tresp.Msg = \"field in key not found\"\n\t\treturn\n\t}\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tvar displayVal string\n\tif (len(param.Decode) > 0 && param.Decode != types.DECODE_NONE) ||\n\t\t(len(param.Format) > 0 && param.Format != types.FORMAT_RAW) {\n\t\tdecoder := Preferences().GetDecoder()\n\t\tdisplayVal, _, _ = convutil.ConvertTo(val, param.Decode, param.Format, decoder)\n\t\tif displayVal == val {\n\t\t\tdisplayVal = \"\"\n\t\t}\n\t}\n\n\tresp.Data = types.HashEntryItem{\n\t\tKey:          param.Field,\n\t\tValue:        val,\n\t\tDisplayValue: displayVal,\n\t}\n\tresp.Success = true\n\treturn\n}\n\n// SetHashValue update hash field\nfunc (b *browserService) SetHashValue(param types.SetHashParam) (resp types.JSResp) {\n\titem, err := b.getRedisClient(param.Server, param.DB)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tclient, ctx := item.client, item.ctx\n\tkey := strutil.DecodeRedisKey(param.Key)\n\tstr := strutil.DecodeRedisKey(param.Value)\n\tvar saveStr, displayStr string\n\tdecoder := Preferences().GetDecoder()\n\tif saveStr, err = convutil.SaveAs(str, param.Format, param.Decode, decoder); err != nil {\n\t\tresp.Msg = fmt.Sprintf(`save to type \"%s\" fail: %s`, param.Format, err.Error())\n\t\treturn\n\t}\n\tif len(param.RetDecode) > 0 && len(param.RetFormat) > 0 {\n\t\tdisplayStr, _, _ = convutil.ConvertTo(saveStr, param.RetDecode, param.RetFormat, decoder)\n\t}\n\tvar updated, added, removed []types.HashEntryItem\n\tvar replaced []types.HashReplaceItem\n\tvar affect int64\n\tif len(param.NewField) <= 0 {\n\t\t// new field is empty, delete old field\n\t\t_, err = client.HDel(ctx, key, param.Field).Result()\n\t\tremoved = append(removed, types.HashEntryItem{\n\t\t\tKey: param.Field,\n\t\t})\n\t} else if len(param.Field) <= 0 || param.Field == param.NewField {\n\t\taffect, err = client.HSet(ctx, key, param.NewField, saveStr).Result()\n\t\tif affect <= 0 {\n\t\t\t// update field value\n\t\t\tupdated = append(updated, types.HashEntryItem{\n\t\t\t\tKey:          param.NewField,\n\t\t\t\tValue:        saveStr,\n\t\t\t\tDisplayValue: displayStr,\n\t\t\t})\n\t\t} else {\n\t\t\t// add new field\n\t\t\tadded = append(added, types.HashEntryItem{\n\t\t\t\tKey:          param.NewField,\n\t\t\t\tValue:        saveStr,\n\t\t\t\tDisplayValue: displayStr,\n\t\t\t})\n\t\t}\n\t} else {\n\t\t// remove old field and add new field\n\t\tif _, err = client.HDel(ctx, key, param.Field).Result(); err != nil {\n\t\t\tresp.Msg = err.Error()\n\t\t\treturn\n\t\t}\n\n\t\taffect, err = client.HSet(ctx, key, param.NewField, saveStr).Result()\n\t\tif affect <= 0 {\n\t\t\t// no new filed added, just update exists item\n\t\t\tremoved = append(removed, types.HashEntryItem{\n\t\t\t\tKey: param.Field,\n\t\t\t})\n\t\t\tupdated = append(updated, types.HashEntryItem{\n\t\t\t\tKey:          param.NewField,\n\t\t\t\tValue:        saveStr,\n\t\t\t\tDisplayValue: displayStr,\n\t\t\t})\n\t\t} else {\n\t\t\t// add new field\n\t\t\treplaced = append(replaced, types.HashReplaceItem{\n\t\t\t\tKey:          param.Field,\n\t\t\t\tNewKey:       param.NewField,\n\t\t\t\tValue:        saveStr,\n\t\t\t\tDisplayValue: displayStr,\n\t\t\t})\n\t\t}\n\t}\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tresp.Success = true\n\tresp.Data = struct {\n\t\tAdded    []types.HashEntryItem   `json:\"added,omitempty\"`\n\t\tRemoved  []types.HashEntryItem   `json:\"removed,omitempty\"`\n\t\tUpdated  []types.HashEntryItem   `json:\"updated,omitempty\"`\n\t\tReplaced []types.HashReplaceItem `json:\"replaced,omitempty\"`\n\t}{\n\t\tAdded:    added,\n\t\tRemoved:  removed,\n\t\tUpdated:  updated,\n\t\tReplaced: replaced,\n\t}\n\treturn\n}\n\n// AddHashField add or update hash field\nfunc (b *browserService) AddHashField(server string, db int, k any, action int, fieldItems []any) (resp types.JSResp) {\n\titem, err := b.getRedisClient(server, db)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tclient, ctx := item.client, item.ctx\n\tkey := strutil.DecodeRedisKey(k)\n\tvar updated []types.HashEntryItem\n\tvar added []types.HashEntryItem\n\tswitch action {\n\tcase 1:\n\t\t// ignore duplicated fields\n\t\tfor i := 0; i < len(fieldItems); i += 2 {\n\t\t\tfield, value := strutil.DecodeRedisKey(fieldItems[i]), strutil.DecodeRedisKey(fieldItems[i+1])\n\t\t\tif succ, _ := client.HSetNX(ctx, key, field, value).Result(); succ {\n\t\t\t\tadded = append(added, types.HashEntryItem{\n\t\t\t\t\tKey:          field,\n\t\t\t\t\tValue:        value,\n\t\t\t\t\tDisplayValue: \"\", // TODO: convert to display value\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\tdefault:\n\t\t// overwrite duplicated fields\n\t\ttotal := len(fieldItems)\n\t\tif total > 1 {\n\t\t\tfor i := 0; i < total; i += 2 {\n\t\t\t\tfield, value := strutil.DecodeRedisKey(fieldItems[i]), strutil.DecodeRedisKey(fieldItems[i+1])\n\t\t\t\tif affect, _ := client.HSet(ctx, key, field, value).Result(); affect > 0 {\n\t\t\t\t\tadded = append(added, types.HashEntryItem{\n\t\t\t\t\t\tKey:          field,\n\t\t\t\t\t\tValue:        value,\n\t\t\t\t\t\tDisplayValue: \"\", // TODO: convert to display value\n\t\t\t\t\t})\n\t\t\t\t} else {\n\t\t\t\t\tupdated = append(updated, types.HashEntryItem{\n\t\t\t\t\t\tKey:          field,\n\t\t\t\t\t\tValue:        value,\n\t\t\t\t\t\tDisplayValue: \"\", // TODO: convert to display value\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tresp.Success = true\n\tresp.Data = struct {\n\t\tAdded   []types.HashEntryItem `json:\"added,omitempty\"`\n\t\tUpdated []types.HashEntryItem `json:\"updated,omitempty\"`\n\t}{\n\t\tAdded:   added,\n\t\tUpdated: updated,\n\t}\n\treturn\n}\n\n// AddListItem add item to list or remove from it\nfunc (b *browserService) AddListItem(server string, db int, k any, action int, items []any) (resp types.JSResp) {\n\titem, err := b.getRedisClient(server, db)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tclient, ctx := item.client, item.ctx\n\tkey := strutil.DecodeRedisKey(k)\n\tvar leftPush, rightPush []types.ListEntryItem\n\tswitch action {\n\tcase 0:\n\t\t// push to head\n\t\tslices.Reverse(items)\n\t\t_, err = client.LPush(ctx, key, items...).Result()\n\t\tfor i := len(items) - 1; i >= 0; i-- {\n\t\t\tleftPush = append(leftPush, types.ListEntryItem{\n\t\t\t\tValue:        items[i],\n\t\t\t\tDisplayValue: \"\", // TODO: convert to display value\n\t\t\t})\n\t\t}\n\tdefault:\n\t\t// append to tail\n\t\t_, err = client.RPush(ctx, key, items...).Result()\n\t\tfor _, it := range items {\n\t\t\trightPush = append(rightPush, types.ListEntryItem{\n\t\t\t\tValue:        it,\n\t\t\t\tDisplayValue: \"\", // TODO: convert to display value\n\t\t\t})\n\t\t}\n\t}\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tresp.Success = true\n\tresp.Data = struct {\n\t\tLeft  []types.ListEntryItem `json:\"left,omitempty\"`\n\t\tRight []types.ListEntryItem `json:\"right,omitempty\"`\n\t}{\n\t\tLeft:  leftPush,\n\t\tRight: rightPush,\n\t}\n\treturn\n}\n\n// SetListItem update or remove list item by index\nfunc (b *browserService) SetListItem(param types.SetListParam) (resp types.JSResp) {\n\titem, err := b.getRedisClient(param.Server, param.DB)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tclient, ctx := item.client, item.ctx\n\tkey := strutil.DecodeRedisKey(param.Key)\n\tstr := strutil.DecodeRedisKey(param.Value)\n\tindex := int64(param.Index)\n\tvar replaced, removed []types.ListReplaceItem\n\tif len(str) <= 0 {\n\t\t// remove from list\n\t\terr = client.LSet(ctx, key, index, \"---VALUE_REMOVED_BY_TINY_RDM---\").Err()\n\t\tif err != nil {\n\t\t\tresp.Msg = err.Error()\n\t\t\treturn\n\t\t}\n\n\t\terr = client.LRem(ctx, key, 1, \"---VALUE_REMOVED_BY_TINY_RDM---\").Err()\n\t\tif err != nil {\n\t\t\tresp.Msg = err.Error()\n\t\t\treturn\n\t\t}\n\t\tremoved = append(removed, types.ListReplaceItem{\n\t\t\tIndex: param.Index,\n\t\t})\n\t} else {\n\t\t// replace index value\n\t\tvar saveStr string\n\t\tdecoder := Preferences().GetDecoder()\n\t\tif saveStr, err = convutil.SaveAs(str, param.Format, param.Decode, decoder); err != nil {\n\t\t\tresp.Msg = fmt.Sprintf(`save to type \"%s\" fail: %s`, param.Format, err.Error())\n\t\t\treturn\n\t\t}\n\t\terr = client.LSet(ctx, key, index, saveStr).Err()\n\t\tif err != nil {\n\t\t\tresp.Msg = err.Error()\n\t\t\treturn\n\t\t}\n\t\tvar displayStr string\n\t\tif len(param.RetDecode) > 0 && len(param.RetFormat) > 0 {\n\t\t\tdisplayStr, _, _ = convutil.ConvertTo(saveStr, param.RetDecode, param.RetFormat, decoder)\n\t\t}\n\t\treplaced = append(replaced, types.ListReplaceItem{\n\t\t\tIndex:        param.Index,\n\t\t\tValue:        saveStr,\n\t\t\tDisplayValue: displayStr,\n\t\t})\n\t}\n\n\tresp.Success = true\n\tresp.Data = struct {\n\t\tRemoved  []types.ListReplaceItem `json:\"removed,omitempty\"`\n\t\tReplaced []types.ListReplaceItem `json:\"replaced,omitempty\"`\n\t}{\n\t\tRemoved:  removed,\n\t\tReplaced: replaced,\n\t}\n\treturn\n}\n\n// SetSetItem add members to set or remove from set\nfunc (b *browserService) SetSetItem(server string, db int, k any, remove bool, members []any) (resp types.JSResp) {\n\titem, err := b.getRedisClient(server, db)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tclient, ctx := item.client, item.ctx\n\tkey := strutil.DecodeRedisKey(k)\n\tvar added, removed []types.SetEntryItem\n\tvar affected int64\n\tif remove {\n\t\tfor _, member := range members {\n\t\t\tif affected, _ = client.SRem(ctx, key, member).Result(); affected > 0 {\n\t\t\t\tremoved = append(removed, types.SetEntryItem{\n\t\t\t\t\tValue: member,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t} else {\n\t\tfor _, member := range members {\n\t\t\tif affected, _ = client.SAdd(ctx, key, member).Result(); affected > 0 {\n\t\t\t\tadded = append(added, types.SetEntryItem{\n\t\t\t\t\tValue:        member,\n\t\t\t\t\tDisplayValue: \"\", // TODO: convert to display value\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tresp.Success = true\n\tresp.Data = struct {\n\t\tAdded    []types.SetEntryItem `json:\"added,omitempty\"`\n\t\tRemoved  []types.SetEntryItem `json:\"removed,omitempty\"`\n\t\tAffected int64                `json:\"affected\"`\n\t}{\n\t\tAdded:    added,\n\t\tRemoved:  removed,\n\t\tAffected: affected,\n\t}\n\treturn\n}\n\n// UpdateSetItem replace member of set\nfunc (b *browserService) UpdateSetItem(param types.SetSetParam) (resp types.JSResp) {\n\titem, err := b.getRedisClient(param.Server, param.DB)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tclient, ctx := item.client, item.ctx\n\tkey := strutil.DecodeRedisKey(param.Key)\n\tvar added, removed []types.SetEntryItem\n\tvar affect int64\n\t// remove old value\n\tstr := strutil.DecodeRedisKey(param.Value)\n\tif affect, _ = client.SRem(ctx, key, str).Result(); affect > 0 {\n\t\tremoved = append(removed, types.SetEntryItem{\n\t\t\tValue: str,\n\t\t})\n\t}\n\n\t// insert new value\n\tstr = strutil.DecodeRedisKey(param.NewValue)\n\tdecoder := Preferences().GetDecoder()\n\tvar saveStr string\n\tif saveStr, err = convutil.SaveAs(str, param.Format, param.Decode, decoder); err != nil {\n\t\tresp.Msg = fmt.Sprintf(`save to type \"%s\" fail: %s`, param.Format, err.Error())\n\t\treturn\n\t}\n\tif affect, _ = client.SAdd(ctx, key, saveStr).Result(); affect > 0 {\n\t\t// add new item\n\t\tvar displayStr string\n\t\tif len(param.RetDecode) > 0 && len(param.RetFormat) > 0 {\n\t\t\tdisplayStr, _, _ = convutil.ConvertTo(saveStr, param.RetDecode, param.RetFormat, decoder)\n\t\t}\n\t\tadded = append(added, types.SetEntryItem{\n\t\t\tValue:        saveStr,\n\t\t\tDisplayValue: displayStr,\n\t\t})\n\t}\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tresp.Success = true\n\tresp.Data = struct {\n\t\tAdded   []types.SetEntryItem `json:\"added,omitempty\"`\n\t\tRemoved []types.SetEntryItem `json:\"removed,omitempty\"`\n\t}{\n\t\tAdded:   added,\n\t\tRemoved: removed,\n\t}\n\treturn\n}\n\n// UpdateZSetValue update value of sorted set member\nfunc (b *browserService) UpdateZSetValue(param types.SetZSetParam) (resp types.JSResp) {\n\titem, err := b.getRedisClient(param.Server, param.DB)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tclient, ctx := item.client, item.ctx\n\tkey := strutil.DecodeRedisKey(param.Key)\n\tval, newVal := strutil.DecodeRedisKey(param.Value), strutil.DecodeRedisKey(param.NewValue)\n\tvar added, updated, removed []types.ZSetEntryItem\n\tvar replaced []types.ZSetReplaceItem\n\tvar affect int64\n\tdecoder := Preferences().GetDecoder()\n\tif len(newVal) <= 0 {\n\t\t// no new value, delete value\n\t\tif affect, err = client.ZRem(ctx, key, val).Result(); affect > 0 {\n\t\t\t//removed = append(removed, val)\n\t\t\tremoved = append(removed, types.ZSetEntryItem{\n\t\t\t\tValue: val,\n\t\t\t})\n\t\t}\n\t} else {\n\t\tvar saveVal string\n\t\tif saveVal, err = convutil.SaveAs(newVal, param.Format, param.Decode, decoder); err != nil {\n\t\t\tresp.Msg = fmt.Sprintf(`save to type \"%s\" fail: %s`, param.Format, err.Error())\n\t\t\treturn\n\t\t}\n\n\t\tif saveVal == val {\n\t\t\taffect, err = client.ZAdd(ctx, key, redis.Z{\n\t\t\t\tScore:  param.Score,\n\t\t\t\tMember: saveVal,\n\t\t\t}).Result()\n\t\t\tdisplayValue, _, _ := convutil.ConvertTo(val, param.RetDecode, param.RetFormat, decoder)\n\t\t\tif affect > 0 {\n\t\t\t\t// add new item\n\t\t\t\tadded = append(added, types.ZSetEntryItem{\n\t\t\t\t\tScore:        param.Score,\n\t\t\t\t\tValue:        val,\n\t\t\t\t\tDisplayValue: displayValue,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\t// update score only\n\t\t\t\tupdated = append(updated, types.ZSetEntryItem{\n\t\t\t\t\tScore:        param.Score,\n\t\t\t\t\tValue:        val,\n\t\t\t\t\tDisplayValue: displayValue,\n\t\t\t\t})\n\t\t\t}\n\t\t} else {\n\t\t\t// remove old value and add new one\n\t\t\t_, err = client.ZRem(ctx, key, val).Result()\n\t\t\tif err != nil {\n\t\t\t\tresp.Msg = err.Error()\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\taffect, err = client.ZAdd(ctx, key, redis.Z{\n\t\t\t\tScore:  param.Score,\n\t\t\t\tMember: saveVal,\n\t\t\t}).Result()\n\t\t\tdisplayValue, _, _ := convutil.ConvertTo(saveVal, param.RetDecode, param.RetFormat, decoder)\n\t\t\tif affect <= 0 {\n\t\t\t\t// no new value added, just update exists item\n\t\t\t\tremoved = append(removed, types.ZSetEntryItem{\n\t\t\t\t\tValue: val,\n\t\t\t\t})\n\t\t\t\tupdated = append(updated, types.ZSetEntryItem{\n\t\t\t\t\tScore:        param.Score,\n\t\t\t\t\tValue:        saveVal,\n\t\t\t\t\tDisplayValue: displayValue,\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\t// add new field\n\t\t\t\treplaced = append(replaced, types.ZSetReplaceItem{\n\t\t\t\t\tScore:        param.Score,\n\t\t\t\t\tValue:        val,\n\t\t\t\t\tNewValue:     saveVal,\n\t\t\t\t\tDisplayValue: displayValue,\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tresp.Success = true\n\tresp.Data = struct {\n\t\tAdded    []types.ZSetEntryItem   `json:\"added,omitempty\"`\n\t\tUpdated  []types.ZSetEntryItem   `json:\"updated,omitempty\"`\n\t\tReplaced []types.ZSetReplaceItem `json:\"replaced,omitempty\"`\n\t\tRemoved  []types.ZSetEntryItem   `json:\"removed,omitempty\"`\n\t}{\n\t\tAdded:    added,\n\t\tUpdated:  updated,\n\t\tReplaced: replaced,\n\t\tRemoved:  removed,\n\t}\n\treturn\n}\n\n// AddZSetValue add item to sorted set\nfunc (b *browserService) AddZSetValue(server string, db int, k any, action int, valueScore map[string]float64) (resp types.JSResp) {\n\titem, err := b.getRedisClient(server, db)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tclient, ctx := item.client, item.ctx\n\tkey := strutil.DecodeRedisKey(k)\n\n\tvar added, updated []types.ZSetEntryItem\n\tswitch action {\n\tcase 1:\n\t\t// ignore duplicated fields\n\t\tfor m, s := range valueScore {\n\t\t\tif affect, _ := client.ZAddNX(ctx, key, redis.Z{Score: s, Member: m}).Result(); affect > 0 {\n\t\t\t\tadded = append(added, types.ZSetEntryItem{\n\t\t\t\t\tScore:        s,\n\t\t\t\t\tValue:        m,\n\t\t\t\t\tDisplayValue: \"\", // TODO: convert to display value\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\tdefault:\n\t\t// overwrite duplicated fields\n\t\tfor m, s := range valueScore {\n\t\t\tif affect, _ := client.ZAdd(ctx, key, redis.Z{Score: s, Member: m}).Result(); affect > 0 {\n\t\t\t\tadded = append(added, types.ZSetEntryItem{\n\t\t\t\t\tScore:        s,\n\t\t\t\t\tValue:        m,\n\t\t\t\t\tDisplayValue: \"\", // TODO: convert to display value\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tupdated = append(updated, types.ZSetEntryItem{\n\t\t\t\t\tScore:        s,\n\t\t\t\t\tValue:        m,\n\t\t\t\t\tDisplayValue: \"\", // TODO: convert to display value\n\t\t\t\t})\n\t\t\t}\n\t\t}\n\t}\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tresp.Success = true\n\tresp.Data = struct {\n\t\tAdded   []types.ZSetEntryItem `json:\"added,omitempty\"`\n\t\tUpdated []types.ZSetEntryItem `json:\"updated,omitempty\"`\n\t}{\n\t\tAdded:   added,\n\t\tUpdated: updated,\n\t}\n\treturn\n}\n\n// AddStreamValue add stream field\nfunc (b *browserService) AddStreamValue(server string, db int, k any, ID string, fieldItems []any) (resp types.JSResp) {\n\titem, err := b.getRedisClient(server, db)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tclient, ctx := item.client, item.ctx\n\tkey := strutil.DecodeRedisKey(k)\n\tvar updateID string\n\tupdateID, err = client.XAdd(ctx, &redis.XAddArgs{\n\t\tStream: key,\n\t\tID:     ID,\n\t\tValues: fieldItems,\n\t}).Result()\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tupdateValues := make(map[string]any, len(fieldItems)/2)\n\tfor i := 0; i < len(fieldItems)/2; i += 2 {\n\t\tupdateValues[fieldItems[i].(string)] = fieldItems[i+1]\n\t}\n\tvb, _ := json.Marshal(updateValues)\n\tdisplayValue, _, _ := convutil.ConvertTo(string(vb), types.DECODE_NONE, types.FORMAT_JSON, Preferences().GetDecoder())\n\n\tresp.Success = true\n\tresp.Data = struct {\n\t\tAdded []types.StreamEntryItem `json:\"added,omitempty\"`\n\t}{\n\t\tAdded: []types.StreamEntryItem{\n\t\t\t{\n\t\t\t\tID:           updateID,\n\t\t\t\tValue:        updateValues,\n\t\t\t\tDisplayValue: displayValue, // TODO: convert to display value\n\t\t\t},\n\t\t},\n\t}\n\treturn\n}\n\n// RemoveStreamValues remove stream values by id\nfunc (b *browserService) RemoveStreamValues(server string, db int, k any, IDs []string) (resp types.JSResp) {\n\titem, err := b.getRedisClient(server, db)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tclient, ctx := item.client, item.ctx\n\tkey := strutil.DecodeRedisKey(k)\n\n\tvar affected int64\n\taffected, err = client.XDel(ctx, key, IDs...).Result()\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tresp.Success = true\n\tresp.Data = struct {\n\t\tAffected int64 `json:\"affected\"`\n\t}{\n\t\tAffected: affected,\n\t}\n\treturn\n}\n\n// SetKeyTTL set ttl of key\nfunc (b *browserService) SetKeyTTL(server string, db int, k any, ttl int64) (resp types.JSResp) {\n\titem, err := b.getRedisClient(server, db)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tclient, ctx := item.client, item.ctx\n\tkey := strutil.DecodeRedisKey(k)\n\tif ttl < 0 {\n\t\tif err = client.Persist(ctx, key).Err(); err != nil {\n\t\t\tresp.Msg = err.Error()\n\t\t\treturn\n\t\t}\n\t} else {\n\t\texpiration := time.Duration(ttl) * time.Second\n\t\tif err = client.Expire(ctx, key, expiration).Err(); err != nil {\n\t\t\tresp.Msg = err.Error()\n\t\t\treturn\n\t\t}\n\t}\n\n\tresp.Success = true\n\treturn\n}\n\n// BatchSetTTL batch set ttl\nfunc (b *browserService) BatchSetTTL(server string, db int, ks []any, ttl int64, serialNo string) (resp types.JSResp) {\n\titem, err := b.getRedisClient(server, db)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tclient := item.client\n\tctx, cancelFunc := context.WithCancel(b.ctx)\n\tdefer cancelFunc()\n\n\t//cancelEvent := \"ttling:stop:\" + serialNo\n\t//runtime.EventsOnce(ctx, cancelEvent, func(data ...any) {\n\t//\tcancelFunc()\n\t//})\n\t//processEvent := \"ttling:\" + serialNo\n\ttotal := len(ks)\n\tvar failed, updated atomic.Int64\n\tvar canceled bool\n\n\texpiration := time.Now().Add(time.Duration(ttl) * time.Second)\n\tdel := func(ctx context.Context, cli redis.UniversalClient) error {\n\t\tstartTime := time.Now().Add(-10 * time.Second)\n\t\tfor i, k := range ks {\n\t\t\t// emit progress per second\n\t\t\t//param := map[string]any{\n\t\t\t//\t\"total\":      total,\n\t\t\t//\t\"progress\":   i + 1,\n\t\t\t//\t\"processing\": k,\n\t\t\t//}\n\t\t\tif i >= total-1 || time.Now().Sub(startTime).Milliseconds() > 100 {\n\t\t\t\tstartTime = time.Now()\n\t\t\t\t//EventsEmit(ctx, processEvent, param)\n\t\t\t\t// do some sleep to prevent blocking the Redis server\n\t\t\t\ttime.Sleep(10 * time.Millisecond)\n\t\t\t}\n\n\t\t\tkey := strutil.DecodeRedisKey(k)\n\t\t\tvar expErr error\n\t\t\tif ttl < 0 {\n\t\t\t\texpErr = cli.Persist(ctx, key).Err()\n\t\t\t} else {\n\t\t\t\texpErr = cli.ExpireAt(ctx, key, expiration).Err()\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\tfailed.Add(1)\n\t\t\t} else {\n\t\t\t\t// save deleted key\n\t\t\t\tupdated.Add(1)\n\t\t\t}\n\t\t\tif errors.Is(expErr, context.Canceled) || canceled {\n\t\t\t\tcanceled = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\n\tif cluster, ok := client.(*redis.ClusterClient); ok {\n\t\t// cluster mode\n\t\terr = cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error {\n\t\t\treturn del(ctx, cli)\n\t\t})\n\t} else {\n\t\terr = del(ctx, client)\n\t}\n\n\t//runtime.EventsOff(ctx, cancelEvent)\n\tresp.Success = true\n\tresp.Data = struct {\n\t\tCanceled bool  `json:\"canceled\"`\n\t\tUpdated  int64 `json:\"updated\"`\n\t\tFailed   int64 `json:\"failed\"`\n\t}{\n\t\tCanceled: canceled,\n\t\tUpdated:  updated.Load(),\n\t\tFailed:   failed.Load(),\n\t}\n\treturn\n}\n\n// DeleteKey remove redis key\nfunc (b *browserService) DeleteKey(server string, db int, k any, async bool) (resp types.JSResp) {\n\titem, err := b.getRedisClient(server, db)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tclient, ctx := item.client, item.ctx\n\tkey := strutil.DecodeRedisKey(k)\n\tvar deletedKeys []string\n\tif strings.HasSuffix(key, \"*\") {\n\t\t// delete by prefix\n\t\tvar mutex sync.Mutex\n\t\tsupportUnlink := true\n\t\tdel := func(ctx context.Context, cli redis.UniversalClient) error {\n\t\t\thandleDel := func(ks []string) error {\n\t\t\t\tvar delErr error\n\t\t\t\tif async && supportUnlink {\n\t\t\t\t\tif delErr = cli.Unlink(ctx, ks...).Err(); delErr != nil {\n\t\t\t\t\t\tsupportUnlink = false\n\t\t\t\t\t\t// not support unlink? try del command\n\t\t\t\t\t\tdelErr = cli.Del(ctx, ks...).Err()\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tdelErr = cli.Del(ctx, ks...).Err()\n\t\t\t\t}\n\n\t\t\t\tmutex.Lock()\n\t\t\t\tdeletedKeys = append(deletedKeys, ks...)\n\t\t\t\tmutex.Unlock()\n\n\t\t\t\treturn delErr\n\t\t\t}\n\n\t\t\tscanSize := int64(Preferences().GetScanSize())\n\t\t\titer := cli.Scan(ctx, 0, key, scanSize).Iterator()\n\t\t\tresultKeys := make([]string, 0, 100)\n\t\t\tfor iter.Next(ctx) {\n\t\t\t\tresultKeys = append(resultKeys, iter.Val())\n\t\t\t\tif len(resultKeys) >= 20 {\n\t\t\t\t\thandleDel(resultKeys)\n\t\t\t\t\tresultKeys = resultKeys[:0:cap(resultKeys)]\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif len(resultKeys) > 0 {\n\t\t\t\thandleDel(resultKeys)\n\t\t\t}\n\t\t\treturn nil\n\t\t}\n\n\t\tif cluster, ok := client.(*redis.ClusterClient); ok {\n\t\t\t// cluster mode\n\t\t\terr = cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error {\n\t\t\t\treturn del(ctx, cli)\n\t\t\t})\n\t\t} else {\n\t\t\terr = del(ctx, client)\n\t\t}\n\n\t\tif err != nil {\n\t\t\tresp.Msg = err.Error()\n\t\t\treturn\n\t\t}\n\t} else {\n\t\t// delete key only\n\t\tif async {\n\t\t\tif err = client.Unlink(ctx, key).Err(); err != nil {\n\t\t\t\tif err = client.Del(ctx, key).Err(); err != nil {\n\t\t\t\t\tresp.Msg = err.Error()\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tif err = client.Del(ctx, key).Err(); err != nil {\n\t\t\t\tresp.Msg = err.Error()\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tdeletedKeys = append(deletedKeys, key)\n\t}\n\n\tresp.Success = true\n\tresp.Data = map[string]any{\n\t\t\"deleted\":     deletedKeys,\n\t\t\"deleteCount\": len(deletedKeys),\n\t}\n\treturn\n}\n\n// DeleteOneKey delete one key\nfunc (b *browserService) DeleteOneKey(server string, db int, k any) (resp types.JSResp) {\n\titem, err := b.getRedisClient(server, db)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tclient, ctx := item.client, item.ctx\n\tkey := strutil.DecodeRedisKey(k)\n\tif cluster, ok := client.(*redis.ClusterClient); ok {\n\t\t// cluster mode\n\t\terr = cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error {\n\t\t\treturn cli.Del(ctx, key).Err()\n\t\t})\n\t} else {\n\t\terr = client.Del(ctx, key).Err()\n\t}\n\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tresp.Success = true\n\treturn\n}\n\n// DeleteKeys delete keys sync with notification\nfunc (b *browserService) DeleteKeys(server string, db int, ks []any, serialNo string) (resp types.JSResp) {\n\titem, err := b.getRedisClient(server, db)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tclient := item.client\n\tctx, cancelFunc := context.WithCancel(b.ctx)\n\tdefer cancelFunc()\n\n\tcancelEvent := \"delete:stop:\" + serialNo\n\tcancelStopEvent := EventsOnce(ctx, cancelEvent, func(data ...any) {\n\t\tcancelFunc()\n\t})\n\ttotal := len(ks)\n\tvar canceled bool\n\tvar deletedKeys = make([]any, 0, total)\n\tvar mutex sync.Mutex\n\tdel := func(ctx context.Context, cli redis.UniversalClient) error {\n\t\tconst batchSize = 1000\n\t\tfor i := 0; i < total; i += batchSize {\n\t\t\tpipe := cli.Pipeline()\n\t\t\tfor j := 0; j < batchSize; j++ {\n\t\t\t\tif i+j < total {\n\t\t\t\t\tpipe.Del(ctx, strutil.DecodeRedisKey(ks[i+j]))\n\t\t\t\t}\n\t\t\t}\n\t\t\tcmders, delErr := pipe.Exec(ctx)\n\t\t\tfor j, cmder := range cmders {\n\t\t\t\tif cmder.(*redis.IntCmd).Val() == 1 {\n\t\t\t\t\t// save deleted key\n\t\t\t\t\tmutex.Lock()\n\t\t\t\t\tdeletedKeys = append(deletedKeys, ks[i+j])\n\t\t\t\t\tmutex.Unlock()\n\t\t\t\t}\n\t\t\t}\n\t\t\tif errors.Is(delErr, context.Canceled) || canceled {\n\t\t\t\tcanceled = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\n\tif cluster, ok := client.(*redis.ClusterClient); ok {\n\t\t// cluster mode\n\t\terr = cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error {\n\t\t\treturn del(ctx, cli)\n\t\t})\n\t} else {\n\t\terr = del(ctx, client)\n\t}\n\n\tcancelStopEvent()\n\tresp.Success = true\n\tresp.Data = struct {\n\t\tCanceled bool `json:\"canceled\"`\n\t\tDeleted  any  `json:\"deleted\"`\n\t\tFailed   int  `json:\"failed\"`\n\t}{\n\t\tCanceled: canceled,\n\t\tDeleted:  deletedKeys,\n\t\tFailed:   len(ks) - len(deletedKeys),\n\t}\n\treturn\n}\n\n// DeleteKeysByPattern delete keys by pattern\nfunc (b *browserService) DeleteKeysByPattern(server string, db int, pattern string) (resp types.JSResp) {\n\titem, err := b.getRedisClient(server, db)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tclient := item.client\n\tctx, cancelFunc := context.WithCancel(b.ctx)\n\tdefer cancelFunc()\n\n\tvar ks []any\n\tks, _, err = b.scanKeys(ctx, client, pattern, \"\", 0, 0)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\ttotal := len(ks)\n\tvar canceled bool\n\tvar deletedKeys = make([]any, 0, total)\n\tvar mutex sync.Mutex\n\tdel := func(ctx context.Context, cli redis.UniversalClient) error {\n\t\tconst batchSize = 1000\n\t\tfor i := 0; i < total; i += batchSize {\n\t\t\tpipe := cli.Pipeline()\n\t\t\tfor j := 0; j < batchSize; j++ {\n\t\t\t\tif i+j < total {\n\t\t\t\t\tpipe.Del(ctx, strutil.DecodeRedisKey(ks[i+j]))\n\t\t\t\t}\n\t\t\t}\n\t\t\tcmders, delErr := pipe.Exec(ctx)\n\t\t\tfor j, cmder := range cmders {\n\t\t\t\tif cmder.(*redis.IntCmd).Val() == 1 {\n\t\t\t\t\t// save deleted key\n\t\t\t\t\tmutex.Lock()\n\t\t\t\t\tdeletedKeys = append(deletedKeys, ks[i+j])\n\t\t\t\t\tmutex.Unlock()\n\t\t\t\t}\n\t\t\t}\n\t\t\tif errors.Is(delErr, context.Canceled) || canceled {\n\t\t\t\tcanceled = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\n\tif cluster, ok := client.(*redis.ClusterClient); ok {\n\t\t// cluster mode\n\t\terr = cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error {\n\t\t\treturn del(ctx, cli)\n\t\t})\n\t} else {\n\t\terr = del(ctx, client)\n\t}\n\n\tresp.Success = true\n\tresp.Data = struct {\n\t\tCanceled bool `json:\"canceled\"`\n\t\tDeleted  any  `json:\"deleted\"`\n\t\tFailed   int  `json:\"failed\"`\n\t}{\n\t\tCanceled: canceled,\n\t\tDeleted:  deletedKeys,\n\t\tFailed:   len(ks) - len(deletedKeys),\n\t}\n\treturn\n}\n\n// ExportKey export keys\nfunc (b *browserService) ExportKey(server string, db int, ks []any, path string, includeExpire bool) (resp types.JSResp) {\n\titem, err := b.getRedisClient(server, db)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tclient := item.client\n\tctx, cancelFunc := context.WithCancel(b.ctx)\n\tdefer cancelFunc()\n\n\tfile, err := os.Create(path)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tdefer file.Close()\n\n\twriter := csv.NewWriter(file)\n\tdefer writer.Flush()\n\n\tcancelStopEvent := EventsOnce(ctx, \"export:stop:\"+path, func(data ...any) {\n\t\tcancelFunc()\n\t})\n\tprocessEvent := \"exporting:\" + path\n\ttotal := len(ks)\n\tvar exported, failed int64\n\tvar canceled bool\n\tstartTime := time.Now().Add(-10 * time.Second)\n\tfor i, k := range ks {\n\t\tif i >= total-1 || time.Now().Sub(startTime).Milliseconds() > 100 {\n\t\t\tstartTime = time.Now()\n\t\t\tparam := map[string]any{\n\t\t\t\t\"total\":      total,\n\t\t\t\t\"progress\":   i + 1,\n\t\t\t\t\"processing\": k,\n\t\t\t}\n\t\t\tEventsEmit(ctx, processEvent, param)\n\t\t}\n\n\t\tkey := strutil.DecodeRedisKey(k)\n\t\tcontent, dumpErr := client.Dump(ctx, key).Bytes()\n\t\tif errors.Is(dumpErr, context.Canceled) || canceled {\n\t\t\tcanceled = true\n\t\t\tbreak\n\t\t}\n\t\trecord := []string{hex.EncodeToString([]byte(key)), hex.EncodeToString(content)}\n\t\tif includeExpire {\n\t\t\tif dur, ttlErr := client.PTTL(ctx, key).Result(); ttlErr == nil && dur > 0 {\n\t\t\t\trecord = append(record, strconv.FormatInt(time.Now().Add(dur).UnixMilli(), 10))\n\t\t\t} else {\n\t\t\t\trecord = append(record, \"-1\")\n\t\t\t}\n\t\t}\n\t\tif err = writer.Write(record); err != nil {\n\t\t\tfailed += 1\n\t\t} else {\n\t\t\texported += 1\n\t\t}\n\t}\n\n\tcancelStopEvent()\n\tresp.Success = true\n\tresp.Data = struct {\n\t\tCanceled bool  `json:\"canceled\"`\n\t\tExported int64 `json:\"exported\"`\n\t\tFailed   int64 `json:\"failed\"`\n\t}{\n\t\tCanceled: canceled,\n\t\tExported: exported,\n\t\tFailed:   failed,\n\t}\n\treturn\n}\n\n// ImportCSV import data from csv file\nfunc (b *browserService) ImportCSV(server string, db int, path string, conflict int, ttl int64) (resp types.JSResp) {\n\titem, err := b.getRedisClient(server, db)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tclient := item.client\n\tctx, cancelFunc := context.WithCancel(b.ctx)\n\tdefer cancelFunc()\n\n\tfile, err := os.Open(path)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tdefer file.Close()\n\n\treader := csv.NewReader(file)\n\n\tcancelEvent := \"import:stop:\" + path\n\tcancelStopEvent := EventsOnce(ctx, cancelEvent, func(data ...any) {\n\t\tcancelFunc()\n\t})\n\tprocessEvent := \"importing:\" + path\n\tvar line []string\n\tvar readErr error\n\tvar key, value []byte\n\tvar ttlValue time.Duration\n\tvar imported, ignored int64\n\tvar canceled bool\n\tstartTime := time.Now().Add(-10 * time.Second)\n\tfor {\n\t\treadErr = nil\n\n\t\tttlValue = redis.KeepTTL\n\t\tline, readErr = reader.Read()\n\t\tif readErr != nil {\n\t\t\tbreak\n\t\t}\n\n\t\tif len(line) < 1 {\n\t\t\tcontinue\n\t\t}\n\t\tif key, readErr = hex.DecodeString(line[0]); readErr != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif value, readErr = hex.DecodeString(line[1]); readErr != nil {\n\t\t\tcontinue\n\t\t}\n\t\t// get ttl\n\t\tif ttl < 0 && len(line) > 2 {\n\t\t\t// use previous\n\t\t\tif expire, ttlErr := strconv.ParseInt(line[2], 10, 64); ttlErr == nil && expire > 0 {\n\t\t\t\tttlValue = time.UnixMilli(expire).Sub(time.Now())\n\t\t\t}\n\t\t} else if ttl > 0 {\n\t\t\t// custom ttl\n\t\t\tttlValue = time.Duration(ttl) * time.Second\n\t\t}\n\t\tif conflict == 0 {\n\t\t\treadErr = client.RestoreReplace(ctx, string(key), ttlValue, string(value)).Err()\n\t\t} else {\n\t\t\tkeyStr := string(key)\n\t\t\t// go-redis may crash when batch calling restore\n\t\t\t// use \"exists\" to filter first\n\t\t\tif n, _ := client.Exists(ctx, keyStr).Result(); n <= 0 {\n\t\t\t\treadErr = client.Restore(ctx, keyStr, ttlValue, string(value)).Err()\n\t\t\t} else {\n\t\t\t\treadErr = errors.New(\"key already existed\")\n\t\t\t}\n\t\t}\n\t\tif readErr != nil {\n\t\t\t// restore fail\n\t\t\tignored += 1\n\t\t} else {\n\t\t\timported += 1\n\t\t}\n\t\tif errors.Is(readErr, context.Canceled) || canceled {\n\t\t\tcanceled = true\n\t\t\tbreak\n\t\t}\n\n\t\tif time.Now().Sub(startTime).Milliseconds() > 100 {\n\t\t\tstartTime = time.Now()\n\t\t\tparam := map[string]any{\n\t\t\t\t\"imported\": imported,\n\t\t\t\t\"ignored\":  ignored,\n\t\t\t\t//\"processing\": string(key),\n\t\t\t}\n\t\t\tEventsEmit(ctx, processEvent, param)\n\t\t\t// do some sleep to prevent blocking the Redis server\n\t\t\ttime.Sleep(10 * time.Millisecond)\n\t\t}\n\t}\n\n\tcancelStopEvent()\n\tresp.Success = true\n\tresp.Data = struct {\n\t\tCanceled bool  `json:\"canceled\"`\n\t\tImported int64 `json:\"imported\"`\n\t\tIgnored  int64 `json:\"ignored\"`\n\t}{\n\t\tCanceled: canceled,\n\t\tImported: imported,\n\t\tIgnored:  ignored,\n\t}\n\treturn\n}\n\n// FlushDB flush database\nfunc (b *browserService) FlushDB(server string, db int, async bool) (resp types.JSResp) {\n\titem, err := b.getRedisClient(server, db)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tflush := func(ctx context.Context, cli redis.UniversalClient, async bool) error {\n\t\t_, e := cli.TxPipelined(ctx, func(pipe redis.Pipeliner) error {\n\t\t\tpipe.Select(ctx, db)\n\t\t\tif async {\n\t\t\t\tpipe.FlushDBAsync(ctx)\n\t\t\t} else {\n\t\t\t\tpipe.FlushDB(ctx)\n\t\t\t}\n\t\t\treturn nil\n\t\t})\n\t\treturn e\n\t}\n\n\tclient, ctx := item.client, item.ctx\n\tif cluster, ok := client.(*redis.ClusterClient); ok {\n\t\t// cluster mode\n\t\terr = cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error {\n\t\t\treturn flush(ctx, cli, async)\n\t\t})\n\t\t// try sync mode if error cause\n\t\tif err != nil && async {\n\t\t\terr = cluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error {\n\t\t\t\treturn flush(ctx, cli, false)\n\t\t\t})\n\t\t}\n\t} else {\n\t\tif err = flush(ctx, client, async); err != nil && async {\n\t\t\t// try sync mode if error cause\n\t\t\terr = flush(ctx, client, false)\n\t\t}\n\t}\n\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tresp.Success = true\n\treturn\n}\n\n// RenameKey rename key\nfunc (b *browserService) RenameKey(server string, db int, key, newKey string) (resp types.JSResp) {\n\titem, err := b.getRedisClient(server, db)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tclient, ctx := item.client, item.ctx\n\tif _, ok := client.(*redis.ClusterClient); ok {\n\t\tresp.Msg = \"RENAME not support in cluster mode yet\"\n\t\treturn\n\t}\n\n\tif _, err = client.RenameNX(ctx, key, newKey).Result(); err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tresp.Success = true\n\treturn\n}\n\n// GetCmdHistory get redis command history\nfunc (b *browserService) GetCmdHistory(pageNo, pageSize int) (resp types.JSResp) {\n\tresp.Success = true\n\tif pageSize <= 0 || pageNo <= 0 {\n\t\t// return all history\n\t\tresp.Data = map[string]any{\n\t\t\t\"list\":     b.cmdHistory,\n\t\t\t\"pageNo\":   1,\n\t\t\t\"pageSize\": -1,\n\t\t}\n\t} else {\n\t\ttotal := len(b.cmdHistory)\n\t\tstartIndex := total / pageSize * (pageNo - 1)\n\t\tendIndex := min(startIndex+pageSize, total)\n\t\tresp.Data = map[string]any{\n\t\t\t\"list\":     b.cmdHistory[startIndex:endIndex],\n\t\t\t\"pageNo\":   pageNo,\n\t\t\t\"pageSize\": pageSize,\n\t\t}\n\t}\n\treturn\n}\n\n// CleanCmdHistory clean redis command history\nfunc (b *browserService) CleanCmdHistory() (resp types.JSResp) {\n\tb.cmdHistory = []cmdHistoryItem{}\n\tresp.Success = true\n\treturn\n}\n\n// GetSlowLogs get slow log list\nfunc (b *browserService) GetSlowLogs(server string, num int64) (resp types.JSResp) {\n\titem, err := b.getRedisClient(server, -1)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tnum = max(1, num)\n\n\tclient, ctx := item.client, item.ctx\n\tvar logs []redis.SlowLog\n\tif cluster, ok := client.(*redis.ClusterClient); ok {\n\t\t// cluster mode\n\t\tvar mu sync.Mutex\n\t\terr = cluster.ForEachShard(ctx, func(ctx context.Context, cli *redis.Client) error {\n\t\t\tif subLogs, _ := client.SlowLogGet(ctx, num).Result(); len(subLogs) > 0 {\n\t\t\t\tmu.Lock()\n\t\t\t\tlogs = append(logs, subLogs...)\n\t\t\t\tmu.Unlock()\n\t\t\t}\n\t\t\treturn nil\n\t\t})\n\t} else {\n\t\tlogs, err = client.SlowLogGet(ctx, num).Result()\n\t}\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tsort.Slice(logs, func(i, j int) bool {\n\t\treturn logs[i].Time.UnixMilli() > logs[j].Time.UnixMilli()\n\t})\n\tif len(logs) > int(num) {\n\t\tlogs = logs[:num]\n\t}\n\n\tlist := sliceutil.Map(logs, func(i int) slowLogItem {\n\t\tvar name string\n\t\tvar e error\n\t\tif name, e = url.QueryUnescape(logs[i].ClientName); e != nil {\n\t\t\tname = logs[i].ClientName\n\t\t}\n\t\treturn slowLogItem{\n\t\t\tTimestamp: logs[i].Time.UnixMilli(),\n\t\t\tClient:    name,\n\t\t\tAddr:      logs[i].ClientAddr,\n\t\t\tCmd:       sliceutil.JoinString(logs[i].Args, \" \"),\n\t\t\tCost:      logs[i].Duration.Milliseconds(),\n\t\t}\n\t})\n\n\tresp.Success = true\n\tresp.Data = map[string]any{\n\t\t\"list\": list,\n\t}\n\treturn\n}\n\n// GetClientList get all connected client info\nfunc (b *browserService) GetClientList(server string) (resp types.JSResp) {\n\titem, err := b.getRedisClient(server, -1)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tparseContent := func(content string) []map[string]string {\n\t\tlines := strings.Split(content, \"\\n\")\n\t\tlist := make([]map[string]string, 0, len(lines))\n\t\tfor _, line := range lines {\n\t\t\tline = strings.TrimSpace(line)\n\t\t\tif len(line) > 0 {\n\t\t\t\titems := strings.Split(line, \" \")\n\t\t\t\titemKV := map[string]string{}\n\t\t\t\tfor _, it := range items {\n\t\t\t\t\tkv := strings.SplitN(it, \"=\", 2)\n\t\t\t\t\tif len(kv) > 1 {\n\t\t\t\t\t\titemKV[kv[0]] = kv[1]\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tlist = append(list, itemKV)\n\t\t\t}\n\t\t}\n\t\treturn list\n\t}\n\n\tclient, ctx := item.client, item.ctx\n\tvar fullList []map[string]string\n\tvar mutex sync.Mutex\n\tif cluster, ok := client.(*redis.ClusterClient); ok {\n\t\tcluster.ForEachMaster(ctx, func(ctx context.Context, cli *redis.Client) error {\n\t\t\tmutex.Lock()\n\t\t\tdefer mutex.Unlock()\n\t\t\tfullList = append(fullList, parseContent(cli.ClientList(ctx).Val())...)\n\t\t\treturn nil\n\t\t})\n\t} else {\n\t\tfullList = append(fullList, parseContent(client.ClientList(ctx).Val())...)\n\t}\n\n\tresp.Success = true\n\tresp.Data = map[string]any{\n\t\t\"list\": fullList,\n\t}\n\treturn\n}\n"
  },
  {
    "path": "backend/services/cli_service.go",
    "content": "package services\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\t\"sync\"\n\t\"tinyrdm/backend/types\"\n\tsliceutil \"tinyrdm/backend/utils/slice\"\n\tstrutil \"tinyrdm/backend/utils/string\"\n\n\t\"github.com/redis/go-redis/v9\"\n)\n\ntype cliService struct {\n\tctx        context.Context\n\tctxCancel  context.CancelFunc\n\tmutex      sync.Mutex\n\tclients    map[string]redis.UniversalClient\n\tselectedDB map[string]int\n}\n\ntype cliOutput struct {\n\tNull    bool     `json:\"null,omitempty\"`    // output content is null\n\tContent []string `json:\"content,omitempty\"` // output content\n\tPrompt  string   `json:\"prompt,omitempty\"`  // new line prompt, empty if not ready to input\n}\n\nvar cli *cliService\nvar onceCli sync.Once\n\nfunc Cli() *cliService {\n\tif cli == nil {\n\t\tonceCli.Do(func() {\n\t\t\tcli = &cliService{\n\t\t\t\tclients:    map[string]redis.UniversalClient{},\n\t\t\t\tselectedDB: map[string]int{},\n\t\t\t}\n\t\t})\n\t}\n\treturn cli\n}\n\nfunc (c *cliService) runCommand(server, data string) {\n\tif cmds := strutil.SplitCmd(data); len(cmds) > 0 && len(cmds[0]) > 0 {\n\t\tif client, err := c.getRedisClient(server); err == nil {\n\t\t\targs := sliceutil.Map(cmds, func(i int) any {\n\t\t\t\treturn cmds[i]\n\t\t\t})\n\t\t\tif result, err := client.Do(c.ctx, args...).Result(); err == nil || errors.Is(err, redis.Nil) {\n\t\t\t\tif strings.ToLower(cmds[0]) == \"select\" {\n\t\t\t\t\t// switch database\n\t\t\t\t\tif db, ok := strutil.AnyToInt(cmds[1]); ok {\n\t\t\t\t\t\tc.selectedDB[server] = db\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tc.echo(server, result, true)\n\t\t\t} else {\n\t\t\t\tc.echoError(server, err.Error())\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t}\n\n\tc.echoReady(server)\n}\n\nfunc (c *cliService) echo(server string, data any, newLineReady bool) {\n\tvar output cliOutput\n\tif data != nil {\n\t\tstr := strutil.AnyToString(data, \"\", 0)\n\t\toutput.Content = strings.Split(str, \"\\n\")\n\t}\n\tif newLineReady {\n\t\toutput.Prompt = fmt.Sprintf(\"%s:db%d> \", server, c.selectedDB[server])\n\t}\n\tEventsEmit(c.ctx, \"cmd:output:\"+server, output)\n}\n\nfunc (c *cliService) echoReady(server string) {\n\tc.echo(server, \"\", true)\n}\n\nfunc (c *cliService) echoError(server, data string) {\n\tc.echo(server, \"\\x1b[31m\"+data+\"\\x1b[0m\", true)\n}\n\nfunc (c *cliService) getRedisClient(server string) (redis.UniversalClient, error) {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\n\tclient, ok := c.clients[server]\n\tif !ok {\n\t\tvar err error\n\t\tconf := Connection().getConnection(server)\n\t\tif conf == nil {\n\t\t\treturn nil, fmt.Errorf(\"no connection profile named: %s\", server)\n\t\t}\n\t\tif client, err = Connection().createRedisClient(conf.ConnectionConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tc.clients[server] = client\n\t}\n\treturn client, nil\n}\n\nfunc (c *cliService) Start(ctx context.Context) {\n\tc.ctx, c.ctxCancel = context.WithCancel(ctx)\n}\n\n// StartCli start a cli session\nfunc (c *cliService) StartCli(server string, db int) (resp types.JSResp) {\n\tclient, err := c.getRedisClient(server)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tclient.Do(c.ctx, \"select\", db)\n\tc.selectedDB[server] = db\n\n\t// monitor input\n\tEventsOn(c.ctx, \"cmd:input:\"+server, func(data ...interface{}) {\n\t\tif len(data) > 0 {\n\t\t\tif str, ok := data[0].(string); ok {\n\t\t\t\tc.runCommand(server, str)\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t\tc.echoReady(server)\n\t})\n\n\t// echo prefix\n\tc.echoReady(server)\n\tresp.Success = true\n\treturn\n}\n\n// CloseCli close cli session\nfunc (c *cliService) CloseCli(server string) (resp types.JSResp) {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\n\tif client, ok := c.clients[server]; ok {\n\t\tclient.Close()\n\t\tdelete(c.clients, server)\n\t\tdelete(c.selectedDB, server)\n\t}\n\tEventsOff(c.ctx, \"cmd:input:\"+server)\n\tresp.Success = true\n\treturn\n}\n\n// CloseAll close all cli sessions\nfunc (c *cliService) CloseAll() {\n\tif c.ctxCancel != nil {\n\t\tc.ctxCancel()\n\t}\n\n\tfor server := range c.clients {\n\t\tc.CloseCli(server)\n\t}\n}\n"
  },
  {
    "path": "backend/services/connection_service.go",
    "content": "package services\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"crypto/x509\"\n\t\"errors\"\n\t\"io\"\n\t\"net\"\n\t\"net/url\"\n\t\"os\"\n\t\"path\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\t\"tinyrdm/backend/consts\"\n\t. \"tinyrdm/backend/storage\"\n\t\"tinyrdm/backend/types\"\n\t_ \"tinyrdm/backend/utils/proxy\"\n\n\t\"github.com/klauspost/compress/zip\"\n\t\"github.com/redis/go-redis/v9\"\n\t\"github.com/vrischmann/userdir\"\n\tsshagent \"github.com/xanzy/ssh-agent\"\n\t\"golang.org/x/crypto/ssh\"\n\t\"golang.org/x/net/proxy\"\n)\n\ntype cmdHistoryItem struct {\n\tTimestamp int64  `json:\"timestamp\"`\n\tServer    string `json:\"server\"`\n\tCmd       string `json:\"cmd\"`\n\tCost      int64  `json:\"cost\"`\n}\n\ntype connectionService struct {\n\tctx   context.Context\n\tconns *ConnectionsStorage\n}\n\nvar connection *connectionService\nvar onceConnection sync.Once\n\nfunc Connection() *connectionService {\n\tif connection == nil {\n\t\tonceConnection.Do(func() {\n\t\t\tconnection = &connectionService{\n\t\t\t\tconns: NewConnections(),\n\t\t\t}\n\t\t})\n\t}\n\treturn connection\n}\n\nfunc (c *connectionService) Start(ctx context.Context) {\n\tc.ctx = ctx\n}\n\nfunc (c *connectionService) buildOption(config types.ConnectionConfig) (*redis.Options, error) {\n\tvar dialer proxy.Dialer\n\tvar dialerErr error\n\tif config.Proxy.Type == 1 {\n\t\t// use system proxy\n\t\tdialer = proxy.FromEnvironment()\n\t} else if config.Proxy.Type == 2 {\n\t\t// use custom proxy\n\t\tproxyUrl := url.URL{\n\t\t\tHost: net.JoinHostPort(config.Proxy.Addr, strconv.Itoa(config.Proxy.Port)),\n\t\t}\n\t\tif len(config.Proxy.Username) > 0 {\n\t\t\tproxyUrl.User = url.UserPassword(config.Proxy.Username, config.Proxy.Password)\n\t\t}\n\t\tswitch config.Proxy.Schema {\n\t\tcase \"socks5\", \"socks5h\", \"http\", \"https\":\n\t\t\tproxyUrl.Scheme = config.Proxy.Schema\n\t\tdefault:\n\t\t\tproxyUrl.Scheme = \"http\"\n\t\t}\n\t\tif dialer, dialerErr = proxy.FromURL(&proxyUrl, proxy.Direct); dialerErr != nil {\n\t\t\treturn nil, dialerErr\n\t\t}\n\t}\n\n\tvar sshConfig *ssh.ClientConfig\n\tvar sshAddr string\n\tif config.SSH.Enable {\n\t\tsshConfig = &ssh.ClientConfig{\n\t\t\tUser:            config.SSH.Username,\n\t\t\tHostKeyCallback: ssh.InsecureIgnoreHostKey(),\n\t\t\tTimeout:         time.Duration(config.ConnTimeout) * time.Second,\n\t\t}\n\t\tswitch config.SSH.LoginType {\n\t\tcase \"pwd\":\n\t\t\tsshConfig.Auth = []ssh.AuthMethod{ssh.Password(config.SSH.Password)}\n\t\tcase \"pkfile\":\n\t\t\tkey, err := os.ReadFile(config.SSH.PKFile)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tvar signer ssh.Signer\n\t\t\tif len(config.SSH.Passphrase) > 0 {\n\t\t\t\tsigner, err = ssh.ParsePrivateKeyWithPassphrase(key, []byte(config.SSH.Passphrase))\n\t\t\t} else {\n\t\t\t\tsigner, err = ssh.ParsePrivateKey(key)\n\t\t\t}\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tsshConfig.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)}\n\t\tcase \"agent\":\n\t\t\tagent, conn, err := sshagent.New()\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tif conn != nil {\n\t\t\t\tdefer conn.Close()\n\t\t\t}\n\t\t\tsshConfig.Auth = []ssh.AuthMethod{ssh.PublicKeysCallback(agent.Signers)}\n\t\tdefault:\n\t\t\treturn nil, errors.New(\"invalid login type\")\n\t\t}\n\n\t\tsshAddr = net.JoinHostPort(config.SSH.Addr, strconv.Itoa(config.SSH.Port))\n\t}\n\n\tvar tlsConfig *tls.Config\n\tif config.SSL.Enable {\n\t\t// setup tls config\n\t\tvar certs []tls.Certificate\n\t\tif len(config.SSL.CertFile) > 0 && len(config.SSL.KeyFile) > 0 {\n\t\t\tif cert, err := tls.LoadX509KeyPair(config.SSL.CertFile, config.SSL.KeyFile); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t} else {\n\t\t\t\tcerts = []tls.Certificate{cert}\n\t\t\t}\n\t\t}\n\n\t\tvar caCertPool *x509.CertPool\n\t\tif len(config.SSL.CAFile) > 0 {\n\t\t\tca, err := os.ReadFile(config.SSL.CAFile)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tcaCertPool = x509.NewCertPool()\n\t\t\tcaCertPool.AppendCertsFromPEM(ca)\n\t\t}\n\n\t\ttlsConfig = &tls.Config{\n\t\t\tRootCAs:            caCertPool,\n\t\t\tInsecureSkipVerify: config.SSL.AllowInsecure,\n\t\t\tCertificates:       certs,\n\t\t\tServerName:         strings.TrimSpace(config.SSL.SNI),\n\t\t}\n\t}\n\n\toption := &redis.Options{\n\t\tUsername:        config.Username,\n\t\tPassword:        config.Password,\n\t\tDialTimeout:     time.Duration(config.ConnTimeout) * time.Second,\n\t\tReadTimeout:     time.Duration(config.ExecTimeout) * time.Second,\n\t\tWriteTimeout:    time.Duration(config.ExecTimeout) * time.Second,\n\t\tConnMaxIdleTime: 0,\n\t\tTLSConfig:       tlsConfig,\n\t\tDisableIdentity: true,\n\t\tIdentitySuffix:  \"tinyrdm_\",\n\t\tProtocol:        2,\n\t}\n\tif config.Network == \"unix\" {\n\t\toption.Network = \"unix\"\n\t\tif len(config.Sock) <= 0 {\n\t\t\toption.Addr = \"/tmp/redis.sock\"\n\t\t} else {\n\t\t\toption.Addr = config.Sock\n\t\t}\n\t} else {\n\t\toption.Network = \"tcp\"\n\t\tport := 6379\n\t\tif config.Port > 0 {\n\t\t\tport = config.Port\n\t\t}\n\t\tif len(config.Addr) <= 0 {\n\t\t\toption.Addr = net.JoinHostPort(\"127.0.0.1\", strconv.Itoa(port))\n\t\t} else {\n\t\t\toption.Addr = net.JoinHostPort(config.Addr, strconv.Itoa(port))\n\t\t}\n\t}\n\n\tif len(sshAddr) > 0 {\n\t\tif dialer != nil {\n\t\t\t// ssh with proxy\n\t\t\tconn, err := dialer.Dial(\"tcp\", sshAddr)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tsc, chans, reqs, err := ssh.NewClientConn(conn, sshAddr, sshConfig)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tdialer = ssh.NewClient(sc, chans, reqs)\n\t\t} else {\n\t\t\t// ssh without proxy\n\t\t\tsshClient, err := ssh.Dial(\"tcp\", sshAddr, sshConfig)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tdialer = sshClient\n\t\t}\n\t}\n\tif dialer != nil {\n\t\tdial := func(ctx context.Context, network, addr string) (net.Conn, error) {\n\t\t\treturn dialer.Dial(network, addr)\n\t\t}\n\n\t\tif tlsConfig != nil {\n\t\t\t// use dialer with tls config\n\t\t\toption.Dialer = func(ctx context.Context, network, addr string) (net.Conn, error) {\n\t\t\t\trawConn, err := dial(ctx, network, addr)\n\t\t\t\tif err != nil {\n\t\t\t\t\trawConn.Close()\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t\ttlsConn := tls.Client(rawConn, tlsConfig)\n\t\t\t\tif err = tlsConn.Handshake(); err != nil {\n\t\t\t\t\trawConn.Close()\n\t\t\t\t\treturn nil, err\n\t\t\t\t}\n\t\t\t\treturn tlsConn, nil\n\t\t\t}\n\t\t} else {\n\t\t\toption.Dialer = dial\n\t\t}\n\t\tif config.SSH.Enable {\n\t\t\toption.ReadTimeout = -2\n\t\t\toption.WriteTimeout = -2\n\t\t}\n\t}\n\treturn option, nil\n}\n\nfunc (c *connectionService) createRedisClient(config types.ConnectionConfig) (redis.UniversalClient, error) {\n\toption, err := c.buildOption(config)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif config.Sentinel.Enable {\n\t\t// get master address via sentinel node\n\t\tsentinel := redis.NewSentinelClient(option)\n\t\tdefer sentinel.Close()\n\n\t\tvar addr []string\n\t\taddr, err = sentinel.GetMasterAddrByName(c.ctx, config.Sentinel.Master).Result()\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif len(addr) < 2 {\n\t\t\treturn nil, errors.New(\"cannot get master address\")\n\t\t}\n\t\toption.Addr = net.JoinHostPort(addr[0], addr[1])\n\t\toption.Username = config.Sentinel.Username\n\t\toption.Password = config.Sentinel.Password\n\t\tif option.Dialer != nil && config.SSH.Enable {\n\t\t\toption.ReadTimeout = -2\n\t\t\toption.WriteTimeout = -2\n\t\t}\n\t}\n\n\tif config.LastDB > 0 {\n\t\toption.DB = config.LastDB\n\t}\n\n\trdb := redis.NewClient(option)\n\tif config.Cluster.Enable {\n\t\tdefer rdb.Close()\n\n\t\t// connect to cluster\n\t\tvar slots []redis.ClusterSlot\n\t\tif slots, err = rdb.ClusterSlots(c.ctx).Result(); err == nil {\n\t\t\tclusterOptions := &redis.ClusterOptions{\n\t\t\t\t//NewClient:             nil,\n\t\t\t\t//MaxRedirects:          0,\n\t\t\t\t//RouteByLatency:        false,\n\t\t\t\t//RouteRandomly:         false,\n\t\t\t\t//ClusterSlots:          nil,\n\t\t\t\tDialer:                option.Dialer,\n\t\t\t\tOnConnect:             option.OnConnect,\n\t\t\t\tProtocol:              option.Protocol,\n\t\t\t\tUsername:              option.Username,\n\t\t\t\tPassword:              option.Password,\n\t\t\t\tMaxRetries:            option.MaxRetries,\n\t\t\t\tMinRetryBackoff:       option.MinRetryBackoff,\n\t\t\t\tMaxRetryBackoff:       option.MaxRetryBackoff,\n\t\t\t\tDialTimeout:           option.DialTimeout,\n\t\t\t\tContextTimeoutEnabled: option.ContextTimeoutEnabled,\n\t\t\t\tPoolFIFO:              option.PoolFIFO,\n\t\t\t\tPoolSize:              option.PoolSize,\n\t\t\t\tPoolTimeout:           option.PoolTimeout,\n\t\t\t\tMinIdleConns:          option.MinIdleConns,\n\t\t\t\tMaxIdleConns:          option.MaxIdleConns,\n\t\t\t\tConnMaxIdleTime:       option.ConnMaxIdleTime,\n\t\t\t\tConnMaxLifetime:       option.ConnMaxLifetime,\n\t\t\t\tTLSConfig:             option.TLSConfig,\n\t\t\t\tDisableIdentity:       option.DisableIdentity,\n\t\t\t}\n\t\t\tif option.Dialer != nil && config.SSH.Enable {\n\t\t\t\tclusterOptions.Dialer = option.Dialer\n\t\t\t\tclusterOptions.ReadTimeout = -2\n\t\t\t\tclusterOptions.WriteTimeout = -2\n\t\t\t}\n\t\t\tvar addrs []string\n\t\t\tfor _, slot := range slots {\n\t\t\t\tfor _, node := range slot.Nodes {\n\t\t\t\t\taddrs = append(addrs, node.Addr)\n\t\t\t\t}\n\t\t\t}\n\t\t\tclusterOptions.Addrs = addrs\n\t\t\tclusterClient := redis.NewClusterClient(clusterOptions)\n\t\t\treturn clusterClient, nil\n\t\t} else {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn rdb, nil\n}\n\n// ListSentinelMasters list all master info by sentinel\nfunc (c *connectionService) ListSentinelMasters(config types.ConnectionConfig) (resp types.JSResp) {\n\toption, err := c.buildOption(config)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tif option.DialTimeout > 0 {\n\t\toption.DialTimeout = 10 * time.Second\n\t}\n\tsentinel := redis.NewSentinelClient(option)\n\tdefer sentinel.Close()\n\n\tvar retInfo []map[string]string\n\tmasterInfos, err := sentinel.Masters(c.ctx).Result()\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tfor _, info := range masterInfos {\n\t\tif infoMap, ok := info.(map[any]any); ok {\n\t\t\tretInfo = append(retInfo, map[string]string{\n\t\t\t\t\"name\": infoMap[\"name\"].(string),\n\t\t\t\t\"addr\": net.JoinHostPort(infoMap[\"ip\"].(string), infoMap[\"port\"].(string)),\n\t\t\t})\n\t\t}\n\t}\n\n\tresp.Data = retInfo\n\tresp.Success = true\n\treturn\n}\n\nfunc (c *connectionService) TestConnection(config types.ConnectionConfig) (resp types.JSResp) {\n\tclient, err := c.createRedisClient(config)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tdefer client.Close()\n\n\tif _, err = client.Ping(c.ctx).Result(); err != nil && !errors.Is(err, redis.Nil) {\n\t\tresp.Msg = err.Error()\n\t} else {\n\t\tresp.Success = true\n\t}\n\treturn\n}\n\n// ListConnection list all saved connection in local profile\nfunc (c *connectionService) ListConnection() (resp types.JSResp) {\n\tresp.Success = true\n\tresp.Data = c.conns.GetConnections()\n\treturn\n}\n\nfunc (c *connectionService) getConnection(name string) *types.Connection {\n\treturn c.conns.GetConnection(name)\n}\n\n// GetConnection get connection profile by name\nfunc (c *connectionService) GetConnection(name string) (resp types.JSResp) {\n\tconn := c.getConnection(name)\n\tresp.Success = conn != nil\n\tresp.Data = conn\n\treturn\n}\n\n// SaveConnection save connection config to local profile\nfunc (c *connectionService) SaveConnection(name string, param types.ConnectionConfig) (resp types.JSResp) {\n\tvar err error\n\tif strings.ContainsAny(param.Name, \"/\") {\n\t\terr = errors.New(\"connection name contains illegal characters\")\n\t} else {\n\t\tif len(name) > 0 {\n\t\t\t// update connection\n\t\t\terr = c.conns.UpdateConnection(name, param)\n\t\t} else {\n\t\t\terr = c.conns.CreateConnection(param)\n\t\t}\n\t}\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t} else {\n\t\tresp.Success = true\n\t}\n\treturn\n}\n\n// DeleteConnection remove connection by name\nfunc (c *connectionService) DeleteConnection(name string) (resp types.JSResp) {\n\terr := c.conns.DeleteConnection(name)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tresp.Success = true\n\treturn\n}\n\n// SaveSortedConnection save sorted connection after drag\nfunc (c *connectionService) SaveSortedConnection(sortedConns types.Connections) (resp types.JSResp) {\n\terr := c.conns.SaveSortedConnection(sortedConns)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tresp.Success = true\n\treturn\n}\n\n// CreateGroup create a new group\nfunc (c *connectionService) CreateGroup(name string) (resp types.JSResp) {\n\terr := c.conns.CreateGroup(name)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tresp.Success = true\n\treturn\n}\n\n// RenameGroup rename group\nfunc (c *connectionService) RenameGroup(name, newName string) (resp types.JSResp) {\n\terr := c.conns.RenameGroup(name, newName)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tresp.Success = true\n\treturn\n}\n\n// DeleteGroup remove a group by name\nfunc (c *connectionService) DeleteGroup(name string, includeConn bool) (resp types.JSResp) {\n\terr := c.conns.DeleteGroup(name, includeConn)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tresp.Success = true\n\treturn\n}\n\n// SaveLastDB save last selected database index\nfunc (c *connectionService) SaveLastDB(name string, db int) (resp types.JSResp) {\n\tparam := c.conns.GetConnection(name)\n\tif param == nil {\n\t\tresp.Msg = \"no connection named \\\"\" + name + \"\\\"\"\n\t\treturn\n\t}\n\n\tif param.LastDB != db {\n\t\tparam.LastDB = db\n\t\tif err := c.conns.UpdateConnection(name, param.ConnectionConfig); err != nil {\n\t\t\tresp.Msg = \"save connection fail:\" + err.Error()\n\t\t\treturn\n\t\t}\n\t}\n\tresp.Success = true\n\treturn\n}\n\n// SaveRefreshInterval save auto refresh interval\nfunc (c *connectionService) SaveRefreshInterval(name string, interval int) (resp types.JSResp) {\n\tparam := c.conns.GetConnection(name)\n\tif param == nil {\n\t\tresp.Msg = \"no connection named \\\"\" + name + \"\\\"\"\n\t\treturn\n\t}\n\tif param.RefreshInterval != interval {\n\t\tparam.RefreshInterval = interval\n\t\tif err := c.conns.UpdateConnection(name, param.ConnectionConfig); err != nil {\n\t\t\tresp.Msg = \"save connection fail:\" + err.Error()\n\t\t\treturn\n\t\t}\n\t}\n\tresp.Success = true\n\treturn\n}\n\n// ExportConnections export connections to zip file\nfunc (c *connectionService) ExportConnections() (resp types.JSResp) {\n\tdefaultFileName := \"connections_\" + time.Now().Format(\"20060102150405\") + \".zip\"\n\tfilepath, err := SaveFileDialog(c.ctx, SaveDialogOptions{\n\t\tShowHiddenFiles: true,\n\t\tDefaultFilename: defaultFileName,\n\t\tFilters: []FileFilter{\n\t\t\t{\n\t\t\t\tPattern: \"*.zip\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\t// compress the connections profile with zip\n\tconst connectionFilename = \"connections.yaml\"\n\tinputFile, err := os.Open(path.Join(userdir.GetConfigHome(), consts.APP_DATA_FOLDER, connectionFilename))\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tdefer inputFile.Close()\n\n\toutputFile, err := os.Create(filepath)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tdefer outputFile.Close()\n\n\tzipWriter := zip.NewWriter(outputFile)\n\tdefer zipWriter.Close()\n\n\theaderWriter, err := zipWriter.CreateHeader(&zip.FileHeader{\n\t\tName:   connectionFilename,\n\t\tMethod: zip.Deflate,\n\t})\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tif _, err = io.Copy(headerWriter, inputFile); err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tresp.Success = true\n\tresp.Data = struct {\n\t\tPath string `json:\"path\"`\n\t}{\n\t\tPath: filepath,\n\t}\n\treturn\n}\n\n// ImportConnections import connections from local zip file\nfunc (c *connectionService) ImportConnections() (resp types.JSResp) {\n\tfilepath, err := OpenFileDialog(c.ctx, OpenDialogOptions{\n\t\tShowHiddenFiles: true,\n\t\tFilters: []FileFilter{\n\t\t\t{\n\t\t\t\tPattern: \"*.zip\",\n\t\t\t},\n\t\t},\n\t})\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tconst connectionFilename = \"connections.yaml\"\n\tzipFile, err := zip.OpenReader(filepath)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tvar file *zip.File\n\tfor _, file = range zipFile.File {\n\t\tif file.Name == connectionFilename {\n\t\t\tbreak\n\t\t}\n\t}\n\tif file != nil {\n\t\tzippedFile, err := file.Open()\n\t\tif err != nil {\n\t\t\tresp.Msg = err.Error()\n\t\t\treturn\n\t\t}\n\t\tdefer zippedFile.Close()\n\n\t\toutputFile, err := os.Create(path.Join(userdir.GetConfigHome(), consts.APP_DATA_FOLDER, connectionFilename))\n\t\tif err != nil {\n\t\t\tresp.Msg = err.Error()\n\t\t\treturn\n\t\t}\n\t\tdefer outputFile.Close()\n\n\t\tif _, err = io.Copy(outputFile, zippedFile); err != nil {\n\t\t\tresp.Msg = err.Error()\n\t\t\treturn\n\t\t}\n\t}\n\n\tresp.Success = true\n\treturn\n}\n\n// ParseConnectURL parse connection url string\nfunc (c *connectionService) ParseConnectURL(url string) (resp types.JSResp) {\n\turlOpt, err := redis.ParseURL(url)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tvar network, addr string\n\tvar port int\n\tif urlOpt.Network == \"unix\" {\n\t\tnetwork = urlOpt.Network\n\t\taddr = urlOpt.Addr\n\t} else {\n\t\tnetwork = \"tcp\"\n\t\taddrPart := strings.Split(urlOpt.Addr, \":\")\n\t\taddr = addrPart[0]\n\t\tport = 6379\n\t\tif len(addrPart) > 1 {\n\t\t\tport, _ = strconv.Atoi(addrPart[1])\n\t\t}\n\t}\n\tvar sslServerName string\n\tif urlOpt.TLSConfig != nil {\n\t\tsslServerName = urlOpt.TLSConfig.ServerName\n\t}\n\tresp.Success = true\n\tresp.Data = struct {\n\t\tNetwork       string `json:\"network\"`\n\t\tSock          string `json:\"sock\"`\n\t\tAddr          string `json:\"addr\"`\n\t\tPort          int    `json:\"port\"`\n\t\tUsername      string `json:\"username\"`\n\t\tPassword      string `json:\"password\"`\n\t\tConnTimeout   int64  `json:\"connTimeout\"`\n\t\tExecTimeout   int64  `json:\"execTimeout\"`\n\t\tSSLServerName string `json:\"sslServerName,omitempty\"`\n\t}{\n\t\tNetwork:       network,\n\t\tAddr:          addr,\n\t\tPort:          port,\n\t\tUsername:      urlOpt.Username,\n\t\tPassword:      urlOpt.Password,\n\t\tConnTimeout:   int64(urlOpt.DialTimeout.Seconds()),\n\t\tExecTimeout:   int64(urlOpt.ReadTimeout.Seconds()),\n\t\tSSLServerName: sslServerName,\n\t}\n\treturn\n}\n"
  },
  {
    "path": "backend/services/connection_service_web.go",
    "content": "//go:build web\n\npackage services\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"os\"\n\t\"path\"\n\t\"time\"\n\t\"tinyrdm/backend/consts\"\n\t\"tinyrdm/backend/types\"\n\n\t\"github.com/klauspost/compress/zip\"\n\t\"github.com/vrischmann/userdir\"\n)\n\n// ExportConnectionsToBytes exports connections as zip bytes for web download\nfunc (c *connectionService) ExportConnectionsToBytes() ([]byte, string, error) {\n\tconst connectionFilename = \"connections.yaml\"\n\tfilename := \"connections_\" + time.Now().Format(\"20060102150405\") + \".zip\"\n\n\tinputFile, err := os.Open(path.Join(userdir.GetConfigHome(), consts.APP_DATA_FOLDER, connectionFilename))\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\tdefer inputFile.Close()\n\n\tvar buf bytes.Buffer\n\tzipWriter := zip.NewWriter(&buf)\n\n\theaderWriter, err := zipWriter.CreateHeader(&zip.FileHeader{\n\t\tName:   connectionFilename,\n\t\tMethod: zip.Deflate,\n\t})\n\tif err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\tif _, err = io.Copy(headerWriter, inputFile); err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\tif err = zipWriter.Close(); err != nil {\n\t\treturn nil, \"\", err\n\t}\n\n\treturn buf.Bytes(), filename, nil\n}\n\n// ImportConnectionsFromBytes imports connections from uploaded zip bytes\nfunc (c *connectionService) ImportConnectionsFromBytes(data []byte) (resp types.JSResp) {\n\tconst connectionFilename = \"connections.yaml\"\n\n\treader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))\n\tif err != nil {\n\t\tresp.Msg = \"invalid zip file\"\n\t\treturn\n\t}\n\n\tvar file *zip.File\n\tfor _, f := range reader.File {\n\t\tif f.Name == connectionFilename {\n\t\t\tfile = f\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif file == nil {\n\t\tresp.Msg = \"connections.yaml not found in zip\"\n\t\treturn\n\t}\n\n\tzippedFile, err := file.Open()\n\tif err != nil {\n\t\tresp.Msg = \"failed to read zip content\"\n\t\treturn\n\t}\n\tdefer zippedFile.Close()\n\n\toutputFile, err := os.Create(path.Join(userdir.GetConfigHome(), consts.APP_DATA_FOLDER, connectionFilename))\n\tif err != nil {\n\t\tresp.Msg = \"failed to save connections\"\n\t\treturn\n\t}\n\tdefer outputFile.Close()\n\n\tif _, err = io.Copy(outputFile, zippedFile); err != nil {\n\t\tresp.Msg = \"failed to write connections\"\n\t\treturn\n\t}\n\n\tresp.Success = true\n\treturn\n}\n"
  },
  {
    "path": "backend/services/ga_service.go",
    "content": "package services\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"tinyrdm/backend/storage\"\n\n\t\"github.com/google/uuid\"\n)\n\n// google analytics service\ntype gaService struct {\n\tmeasurementID string\n\tsecretKey     string\n\tclientID      string\n}\n\ntype GaDataItem struct {\n\tClientID string        `json:\"client_id\"`\n\tEvents   []GaEventItem `json:\"events\"`\n}\n\ntype GaEventItem struct {\n\tName   string         `json:\"name\"`\n\tParams map[string]any `json:\"params\"`\n}\n\nvar ga *gaService\nvar onceGA sync.Once\n\nfunc GA() *gaService {\n\tif ga == nil {\n\t\tonceGA.Do(func() {\n\t\t\t// get or create an unique user id\n\t\t\tst := storage.NewLocalStore(\"device.txt\")\n\t\t\tuidByte, err := st.Load()\n\t\t\tif err != nil {\n\t\t\t\tuidByte = []byte(strings.ReplaceAll(uuid.NewString(), \"-\", \"\"))\n\t\t\t\tst.Store(uidByte)\n\t\t\t}\n\n\t\t\tga = &gaService{\n\t\t\t\tclientID: string(uidByte),\n\t\t\t}\n\t\t})\n\t}\n\treturn ga\n}\n\nfunc (a *gaService) SetSecretKey(measurementID, secretKey string) {\n\ta.measurementID = measurementID\n\ta.secretKey = secretKey\n}\n\nfunc (a *gaService) isValid() bool {\n\treturn len(a.measurementID) > 0 && len(a.secretKey) > 0\n}\n\nfunc (a *gaService) sendEvent(events ...GaEventItem) error {\n\tbody, err := json.Marshal(GaDataItem{\n\t\tClientID: a.clientID,\n\t\tEvents:   events,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t//url := \"https://www.google-analytics.com/debug/mp/collect\"\n\turl := \"https://www.google-analytics.com/mp/collect\"\n\treq, err := http.NewRequest(\"POST\", url, bytes.NewBuffer(body))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tq := req.URL.Query()\n\tq.Add(\"measurement_id\", a.measurementID)\n\tq.Add(\"api_secret\", a.secretKey)\n\treq.URL.RawQuery = q.Encode()\n\n\tresponse, err := http.DefaultClient.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer response.Body.Close()\n\n\t//if dump, err := httputil.DumpResponse(response, true); err == nil {\n\t//\tlog.Println(string(dump))\n\t//}\n\n\treturn nil\n}\n\n// Startup sends application startup event\nfunc (a *gaService) Startup(version string) {\n\tif !a.isValid() {\n\t\treturn\n\t}\n\n\tgo a.sendEvent(GaEventItem{\n\t\tName: \"startup\",\n\t\tParams: map[string]any{\n\t\t\t\"os\":      runtime.GOOS,\n\t\t\t\"arch\":    runtime.GOARCH,\n\t\t\t\"version\": version,\n\t\t},\n\t})\n}\n"
  },
  {
    "path": "backend/services/monitor_service.go",
    "content": "package services\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"strconv\"\n\t\"sync\"\n\t\"time\"\n\t\"tinyrdm/backend/types\"\n\n\t\"github.com/redis/go-redis/v9\"\n)\n\ntype monitorItem struct {\n\tclient    *redis.Client\n\tcmd       *redis.MonitorCmd\n\tmutex     sync.Mutex\n\tch        chan string\n\tcloseCh   chan struct{}\n\teventName string\n}\n\ntype monitorService struct {\n\tctx       context.Context\n\tctxCancel context.CancelFunc\n\tmutex     sync.Mutex\n\titems     map[string]*monitorItem\n}\n\nvar monitor *monitorService\nvar onceMonitor sync.Once\n\nfunc Monitor() *monitorService {\n\tif monitor == nil {\n\t\tonceMonitor.Do(func() {\n\t\t\tmonitor = &monitorService{\n\t\t\t\titems: map[string]*monitorItem{},\n\t\t\t}\n\t\t})\n\t}\n\treturn monitor\n}\n\nfunc (c *monitorService) getItem(server string) (*monitorItem, error) {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\n\titem, ok := c.items[server]\n\tif !ok {\n\t\tvar err error\n\t\tconf := Connection().getConnection(server)\n\t\tif conf == nil {\n\t\t\treturn nil, fmt.Errorf(\"no connection profile named: %s\", server)\n\t\t}\n\t\tvar uniClient redis.UniversalClient\n\t\tif uniClient, err = Connection().createRedisClient(conf.ConnectionConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tvar client *redis.Client\n\t\tif client, ok = uniClient.(*redis.Client); !ok {\n\t\t\treturn nil, errors.New(\"create redis client fail\")\n\t\t}\n\t\titem = &monitorItem{\n\t\t\tclient: client,\n\t\t}\n\t\tc.items[server] = item\n\t}\n\treturn item, nil\n}\n\nfunc (c *monitorService) Start(ctx context.Context) {\n\tc.ctx, c.ctxCancel = context.WithCancel(ctx)\n}\n\n// StartMonitor start a monitor by server name\nfunc (c *monitorService) StartMonitor(server string) (resp types.JSResp) {\n\titem, err := c.getItem(server)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\titem.ch = make(chan string)\n\titem.closeCh = make(chan struct{})\n\titem.eventName = \"monitor:\" + strconv.Itoa(int(time.Now().Unix()))\n\titem.cmd = item.client.Monitor(c.ctx, item.ch)\n\titem.cmd.Start()\n\n\tgo c.processMonitor(&item.mutex, item.ch, item.closeCh, item.cmd, item.eventName)\n\tresp.Success = true\n\tresp.Data = struct {\n\t\tEventName string `json:\"eventName\"`\n\t}{\n\t\tEventName: item.eventName,\n\t}\n\treturn\n}\n\nfunc (c *monitorService) processMonitor(mutex *sync.Mutex, ch <-chan string, closeCh <-chan struct{}, cmd *redis.MonitorCmd, eventName string) {\n\tcache := make([]string, 0, 1000)\n\tticker := time.NewTicker(1 * time.Second)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase data := <-ch:\n\t\t\tif data != \"OK\" {\n\t\t\t\tgo func() {\n\t\t\t\t\tmutex.Lock()\n\t\t\t\t\tdefer mutex.Unlock()\n\t\t\t\t\tcache = append(cache, data)\n\t\t\t\t\tif len(cache) > 300 {\n\t\t\t\t\t\tEventsEmit(c.ctx, eventName, cache)\n\t\t\t\t\t\tcache = cache[:0:cap(cache)]\n\t\t\t\t\t}\n\t\t\t\t}()\n\t\t\t}\n\n\t\tcase <-ticker.C:\n\t\t\tfunc() {\n\t\t\t\tmutex.Lock()\n\t\t\t\tdefer mutex.Unlock()\n\t\t\t\tif len(cache) > 0 {\n\t\t\t\t\tEventsEmit(c.ctx, eventName, cache)\n\t\t\t\t\tcache = cache[:0:cap(cache)]\n\t\t\t\t}\n\t\t\t}()\n\n\t\tcase <-closeCh:\n\t\t\t// monitor stopped\n\t\t\tcmd.Stop()\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// StopMonitor stop monitor by server name\nfunc (c *monitorService) StopMonitor(server string) (resp types.JSResp) {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\n\titem, ok := c.items[server]\n\tif !ok || item.cmd == nil {\n\t\tresp.Success = true\n\t\treturn\n\t}\n\n\t//close(item.ch)\n\titem.client.Close()\n\tclose(item.closeCh)\n\tdelete(c.items, server)\n\tresp.Success = true\n\treturn\n}\n\n// StopAll stop all monitor\nfunc (c *monitorService) StopAll() {\n\tif c.ctxCancel != nil {\n\t\tc.ctxCancel()\n\t}\n\n\tfor server := range c.items {\n\t\tc.StopMonitor(server)\n\t}\n}\n\nfunc (c *monitorService) ExportLog(logs []string) (resp types.JSResp) {\n\tfilepath, err := SaveFileDialog(c.ctx, SaveDialogOptions{\n\t\tShowHiddenFiles: false,\n\t\tDefaultFilename: fmt.Sprintf(\"monitor_log_%s.txt\", time.Now().Format(\"20060102150405\")),\n\t\tFilters: []FileFilter{\n\t\t\t{Pattern: \"*.txt\"},\n\t\t},\n\t})\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tfile, err := os.Create(filepath)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tdefer file.Close()\n\n\twriter := bufio.NewWriter(file)\n\tfor _, line := range logs {\n\t\t_, _ = writer.WriteString(line + \"\\n\")\n\t}\n\twriter.Flush()\n\n\tresp.Success = true\n\treturn\n}\n"
  },
  {
    "path": "backend/services/platform_desktop.go",
    "content": "//go:build !web\n\npackage services\n\nimport (\n\t\"context\"\n\n\t\"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n// Type aliases for Wails runtime types\ntype OpenDialogOptions = runtime.OpenDialogOptions\ntype SaveDialogOptions = runtime.SaveDialogOptions\ntype FileFilter = runtime.FileFilter\ntype Screen = runtime.Screen\n\n// EventsEmit emits an event to the frontend (Wails desktop)\nfunc EventsEmit(ctx context.Context, event string, data ...any) {\n\truntime.EventsEmit(ctx, event, data...)\n}\n\n// EventsOnce registers a one-time event listener (Wails desktop)\nfunc EventsOnce(ctx context.Context, event string, callback func(data ...any)) func() {\n\treturn runtime.EventsOnce(ctx, event, callback)\n}\n\n// EventsOn registers an event listener (Wails desktop)\nfunc EventsOn(ctx context.Context, event string, callback func(data ...any)) {\n\truntime.EventsOn(ctx, event, callback)\n}\n\n// EventsOff removes an event listener (Wails desktop)\nfunc EventsOff(ctx context.Context, event string) {\n\truntime.EventsOff(ctx, event)\n}\n\n// OpenFileDialog opens a native file dialog (Wails desktop)\nfunc OpenFileDialog(ctx context.Context, opts OpenDialogOptions) (string, error) {\n\treturn runtime.OpenFileDialog(ctx, opts)\n}\n\n// SaveFileDialog opens a native save dialog (Wails desktop)\nfunc SaveFileDialog(ctx context.Context, opts SaveDialogOptions) (string, error) {\n\treturn runtime.SaveFileDialog(ctx, opts)\n}\n\n// ScreenGetAll gets all screens (Wails desktop)\nfunc ScreenGetAll(ctx context.Context) ([]Screen, error) {\n\treturn runtime.ScreenGetAll(ctx)\n}\n\n// WindowMaximise maximises the window (Wails desktop)\nfunc WindowMaximise(ctx context.Context) {\n\truntime.WindowMaximise(ctx)\n}\n\n// WindowIsFullscreen checks if window is fullscreen (Wails desktop)\nfunc WindowIsFullscreen(ctx context.Context) bool {\n\treturn runtime.WindowIsFullscreen(ctx)\n}\n\n// WindowGetSize gets window size (Wails desktop)\nfunc WindowGetSize(ctx context.Context) (int, int) {\n\treturn runtime.WindowGetSize(ctx)\n}\n\n// WindowIsMaximised checks if window is maximised (Wails desktop)\nfunc WindowIsMaximised(ctx context.Context) bool {\n\treturn runtime.WindowIsMaximised(ctx)\n}\n\n// WindowIsMinimised checks if window is minimised (Wails desktop)\nfunc WindowIsMinimised(ctx context.Context) bool {\n\treturn runtime.WindowIsMinimised(ctx)\n}\n\n// WindowIsNormal checks if window is normal (Wails desktop)\nfunc WindowIsNormal(ctx context.Context) bool {\n\treturn runtime.WindowIsNormal(ctx)\n}\n\n// IsWeb returns false in desktop mode\nfunc IsWeb() bool { return false }\n\n// IsDesktop returns true in desktop mode\nfunc IsDesktop() bool { return true }\n"
  },
  {
    "path": "backend/services/platform_web.go",
    "content": "//go:build web\n\npackage services\n\nimport (\n\t\"context\"\n)\n\n// Callback functions - set by api package at startup to avoid import cycle\nvar EmitEventFunc func(event string, data any)\nvar RegisterHandlerFunc func(event string, handler func(data any))\n\n// Stub types to replace Wails runtime types in web mode\n\ntype OpenDialogOptions struct {\n\tTitle           string\n\tShowHiddenFiles bool\n\tFilters         []FileFilter\n}\n\ntype SaveDialogOptions struct {\n\tTitle           string\n\tShowHiddenFiles bool\n\tDefaultFilename string\n\tFilters         []FileFilter\n}\n\ntype FileFilter struct {\n\tPattern string\n}\n\ntype Screen struct {\n\tIsCurrent bool\n\tSize      ScreenSize\n}\n\ntype ScreenSize struct {\n\tWidth  int\n\tHeight int\n}\n\n// EventsEmit emits an event via WebSocket (web mode)\nfunc EventsEmit(ctx context.Context, event string, data ...any) {\n\tif EmitEventFunc == nil {\n\t\treturn\n\t}\n\tif len(data) == 1 {\n\t\tEmitEventFunc(event, data[0])\n\t} else {\n\t\tEmitEventFunc(event, data)\n\t}\n}\n\n// EventsOnce registers a one-time event listener via WebSocket (web mode)\nfunc EventsOnce(ctx context.Context, event string, callback func(data ...any)) func() {\n\tif RegisterHandlerFunc == nil {\n\t\treturn func() {}\n\t}\n\tRegisterHandlerFunc(event, func(data any) {\n\t\tRegisterHandlerFunc(event, func(data any) {})\n\t\tcallback(data)\n\t})\n\treturn func() {\n\t\tRegisterHandlerFunc(event, func(data any) {})\n\t}\n}\n\n// EventsOn registers an event listener via WebSocket (web mode)\nfunc EventsOn(ctx context.Context, event string, callback func(data ...any)) {\n\tif RegisterHandlerFunc == nil {\n\t\treturn\n\t}\n\tRegisterHandlerFunc(event, func(data any) {\n\t\tcallback(data)\n\t})\n}\n\n// EventsOff removes an event listener (web mode)\nfunc EventsOff(ctx context.Context, event string) {\n\tif RegisterHandlerFunc == nil {\n\t\treturn\n\t}\n\tRegisterHandlerFunc(event, func(data any) {})\n}\n\n// OpenFileDialog is a no-op in web mode\nfunc OpenFileDialog(ctx context.Context, opts OpenDialogOptions) (string, error) {\n\treturn \"\", nil\n}\n\n// SaveFileDialog is a no-op in web mode\nfunc SaveFileDialog(ctx context.Context, opts SaveDialogOptions) (string, error) {\n\treturn \"\", nil\n}\n\n// ScreenGetAll returns empty in web mode\nfunc ScreenGetAll(ctx context.Context) ([]Screen, error) {\n\treturn nil, nil\n}\n\n// WindowMaximise is a no-op in web mode\nfunc WindowMaximise(ctx context.Context) {}\n\n// WindowIsFullscreen returns false in web mode\nfunc WindowIsFullscreen(ctx context.Context) bool { return false }\n\n// WindowGetSize returns defaults in web mode\nfunc WindowGetSize(ctx context.Context) (int, int) { return 1024, 768 }\n\n// WindowIsMaximised returns false in web mode\nfunc WindowIsMaximised(ctx context.Context) bool { return false }\n\n// WindowIsMinimised returns false in web mode\nfunc WindowIsMinimised(ctx context.Context) bool { return false }\n\n// WindowIsNormal returns true in web mode\nfunc WindowIsNormal(ctx context.Context) bool { return true }\n\n// IsWeb returns true in web mode\nfunc IsWeb() bool { return true }\n\n// IsDesktop returns false in web mode\nfunc IsDesktop() bool { return false }\n"
  },
  {
    "path": "backend/services/preferences_service.go",
    "content": "package services\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"os\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"tinyrdm/backend/consts\"\n\tstorage2 \"tinyrdm/backend/storage\"\n\t\"tinyrdm/backend/types\"\n\t\"tinyrdm/backend/utils/coll\"\n\tconvutil \"tinyrdm/backend/utils/convert\"\n\tsliceutil \"tinyrdm/backend/utils/slice\"\n\n\t\"github.com/adrg/sysfont\"\n)\n\ntype preferencesService struct {\n\tpref          *storage2.PreferencesStorage\n\tclientVersion string\n}\n\nvar preferences *preferencesService\nvar oncePreferences sync.Once\n\nfunc Preferences() *preferencesService {\n\tif preferences == nil {\n\t\toncePreferences.Do(func() {\n\t\t\tpreferences = &preferencesService{\n\t\t\t\tpref:          storage2.NewPreferences(),\n\t\t\t\tclientVersion: \"\",\n\t\t\t}\n\t\t})\n\t}\n\treturn preferences\n}\n\nfunc (p *preferencesService) GetPreferences() (resp types.JSResp) {\n\tresp.Data = p.pref.GetPreferences()\n\tresp.Success = true\n\treturn\n}\n\nfunc (p *preferencesService) SetPreferences(pf types.Preferences) (resp types.JSResp) {\n\terr := p.pref.SetPreferences(&pf)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tp.UpdateEnv()\n\tresp.Success = true\n\treturn\n}\n\nfunc (p *preferencesService) UpdatePreferences(value map[string]any) (resp types.JSResp) {\n\terr := p.pref.UpdatePreferences(value)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tresp.Success = true\n\treturn\n}\n\nfunc (p *preferencesService) RestorePreferences() (resp types.JSResp) {\n\tdefaultPref := p.pref.RestoreDefault()\n\tresp.Data = map[string]any{\n\t\t\"pref\": defaultPref,\n\t}\n\tresp.Success = true\n\treturn\n}\n\ntype FontItem struct {\n\tName string `json:\"name\"`\n\tPath string `json:\"path\"`\n}\n\nfunc (p *preferencesService) GetFontList() (resp types.JSResp) {\n\tfinder := sysfont.NewFinder(nil)\n\tfontSet := coll.NewSet[string]()\n\tvar fontList []FontItem\n\tfor _, font := range finder.List() {\n\t\tif len(font.Family) > 0 && !strings.HasPrefix(font.Family, \".\") && fontSet.Add(font.Family) {\n\t\t\tfontList = append(fontList, FontItem{\n\t\t\t\tName: font.Family,\n\t\t\t\tPath: font.Filename,\n\t\t\t})\n\t\t}\n\t}\n\tsort.Slice(fontList, func(i, j int) bool {\n\t\treturn fontList[i].Name < fontList[j].Name\n\t})\n\tresp.Data = map[string]any{\n\t\t\"fonts\": fontList,\n\t}\n\tresp.Success = true\n\treturn\n}\n\nfunc (p *preferencesService) GetBuildInDecoder() (resp types.JSResp) {\n\tbuildinDecoder := make([]string, 0, len(convutil.BuildInDecoders))\n\tfor name, convert := range convutil.BuildInDecoders {\n\t\tif convert.Enable() {\n\t\t\tbuildinDecoder = append(buildinDecoder, name)\n\t\t}\n\t}\n\tresp.Data = map[string]any{\n\t\t\"decoder\": buildinDecoder,\n\t}\n\tresp.Success = true\n\treturn\n}\n\nfunc (p *preferencesService) GetLanguage() string {\n\tpref := p.pref.GetPreferences()\n\treturn pref.General.Language\n}\n\nfunc (p *preferencesService) SetAppVersion(ver string) {\n\tif !strings.HasPrefix(ver, \"v\") {\n\t\tp.clientVersion = \"v\" + ver\n\t} else {\n\t\tp.clientVersion = ver\n\t}\n}\n\nfunc (p *preferencesService) GetAppVersion() (resp types.JSResp) {\n\tresp.Success = true\n\tresp.Data = map[string]any{\n\t\t\"version\": p.clientVersion,\n\t}\n\treturn\n}\n\nfunc (p *preferencesService) SaveWindowSize(width, height int, maximised bool) {\n\tif maximised {\n\t\t// do not update window size if maximised state\n\t\tp.UpdatePreferences(map[string]any{\n\t\t\t\"behavior.windowMaximised\": true,\n\t\t})\n\t} else if width >= consts.MIN_WINDOW_WIDTH && height >= consts.MIN_WINDOW_HEIGHT {\n\t\tp.UpdatePreferences(map[string]any{\n\t\t\t\"behavior.windowWidth\":     width,\n\t\t\t\"behavior.windowHeight\":    height,\n\t\t\t\"behavior.windowMaximised\": false,\n\t\t})\n\t}\n}\n\nfunc (p *preferencesService) GetWindowSize() (width, height int, maximised bool) {\n\tdata := p.pref.GetPreferences()\n\twidth, height, maximised = data.Behavior.WindowWidth, data.Behavior.WindowHeight, data.Behavior.WindowMaximised\n\tif width <= 0 {\n\t\twidth = consts.DEFAULT_WINDOW_WIDTH\n\t}\n\tif height <= 0 {\n\t\theight = consts.DEFAULT_WINDOW_HEIGHT\n\t}\n\treturn\n}\n\nfunc (p *preferencesService) GetWindowPosition(ctx context.Context) (x, y int) {\n\tdata := p.pref.GetPreferences()\n\tx, y = data.Behavior.WindowPosX, data.Behavior.WindowPosY\n\twidth, height := data.Behavior.WindowWidth, data.Behavior.WindowHeight\n\tvar screenWidth, screenHeight int\n\tif screens, err := ScreenGetAll(ctx); err == nil {\n\t\tfor _, screen := range screens {\n\t\t\tif screen.IsCurrent {\n\t\t\t\tscreenWidth, screenHeight = screen.Size.Width, screen.Size.Height\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\tif screenWidth <= 0 || screenHeight <= 0 {\n\t\tscreenWidth, screenHeight = consts.DEFAULT_WINDOW_WIDTH, consts.DEFAULT_WINDOW_HEIGHT\n\t}\n\tif x <= 0 || x+width > screenWidth || y <= 0 || y+height > screenHeight {\n\t\t// out of screen, reset to center\n\t\tx, y = (screenWidth-width)/2, (screenHeight-height)/2\n\t}\n\treturn\n}\n\nfunc (p *preferencesService) SaveWindowPosition(x, y int) {\n\tif x > 0 || y > 0 {\n\t\tp.UpdatePreferences(map[string]any{\n\t\t\t\"behavior.windowPosX\": x,\n\t\t\t\"behavior.windowPosY\": y,\n\t\t})\n\t}\n}\n\nfunc (p *preferencesService) GetScanSize() int {\n\tdata := p.pref.GetPreferences()\n\tsize := data.General.ScanSize\n\tif size <= 0 {\n\t\tsize = consts.DEFAULT_SCAN_SIZE\n\t}\n\treturn size\n}\n\nfunc (p *preferencesService) GetDecoder() []convutil.CmdConvert {\n\tdata := p.pref.GetPreferences()\n\treturn sliceutil.FilterMap(data.Decoder, func(i int) (convutil.CmdConvert, bool) {\n\t\t//if !data.Decoder[i].Enable {\n\t\t//\treturn convutil.CmdConvert{}, false\n\t\t//}\n\t\treturn convutil.CmdConvert{\n\t\t\tName:       data.Decoder[i].Name,\n\t\t\tAuto:       data.Decoder[i].Auto,\n\t\t\tDecodePath: data.Decoder[i].DecodePath,\n\t\t\tDecodeArgs: data.Decoder[i].DecodeArgs,\n\t\t\tEncodePath: data.Decoder[i].EncodePath,\n\t\t\tEncodeArgs: data.Decoder[i].EncodeArgs,\n\t\t}, true\n\t})\n}\n\ntype sponsorItem struct {\n\tName   string   `json:\"name\"`\n\tLink   string   `json:\"link\"`\n\tRegion []string `json:\"region\"`\n}\n\ntype upgradeInfo struct {\n\tVersion      string            `json:\"version\"`\n\tChangelog    map[string]string `json:\"changelog\"`\n\tDescription  map[string]string `json:\"description\"`\n\tDownloadURl  map[string]string `json:\"download_url\"`\n\tDownloadPage map[string]string `json:\"download_page\"`\n\tSponsor      []sponsorItem     `json:\"sponsor,omitempty\"`\n}\n\nfunc (p *preferencesService) CheckForUpdate() (resp types.JSResp) {\n\t// request latest version\n\t//res, err := http.Get(\"https://api.github.com/repos/tiny-craft/tiny-rdm/releases/latest\")\n\tres, err := http.Get(\"https://tinyrdm.com/client_version.json\")\n\tif err != nil || res.StatusCode != http.StatusOK {\n\t\tresp.Msg = \"network error\"\n\t\treturn\n\t}\n\n\tvar respObj upgradeInfo\n\terr = json.NewDecoder(res.Body).Decode(&respObj)\n\tif err != nil {\n\t\tresp.Msg = \"invalid content\"\n\t\treturn\n\t}\n\n\t// compare with current version\n\tresp.Success = true\n\tresp.Data = map[string]any{\n\t\t\"version\":       p.clientVersion,\n\t\t\"latest\":        respObj.Version,\n\t\t\"description\":   respObj.Description,\n\t\t\"download_page\": respObj.DownloadPage,\n\t\t\"sponsor\":       respObj.Sponsor,\n\t}\n\treturn\n}\n\n// UpdateEnv Update System Environment\nfunc (p *preferencesService) UpdateEnv() {\n\tif p.GetLanguage() == \"zh\" {\n\t\tos.Setenv(\"LANG\", \"zh_CN.UTF-8\")\n\t} else {\n\t\tos.Unsetenv(\"LANG\")\n\t}\n}\n"
  },
  {
    "path": "backend/services/pubsub_service.go",
    "content": "package services\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"sync\"\n\t\"time\"\n\t\"tinyrdm/backend/types\"\n\n\t\"github.com/redis/go-redis/v9\"\n)\n\ntype pubsubItem struct {\n\tclient    redis.UniversalClient\n\tpubsub    *redis.PubSub\n\tmutex     sync.Mutex\n\tcloseCh   chan struct{}\n\teventName string\n}\n\ntype subMessage struct {\n\tTimestamp int64  `json:\"timestamp\"`\n\tChannel   string `json:\"channel\"`\n\tMessage   string `json:\"message\"`\n}\n\ntype pubsubService struct {\n\tctx       context.Context\n\tctxCancel context.CancelFunc\n\tmutex     sync.Mutex\n\titems     map[string]*pubsubItem\n}\n\nvar pubsub *pubsubService\nvar oncePubsub sync.Once\n\nfunc Pubsub() *pubsubService {\n\tif pubsub == nil {\n\t\toncePubsub.Do(func() {\n\t\t\tpubsub = &pubsubService{\n\t\t\t\titems: map[string]*pubsubItem{},\n\t\t\t}\n\t\t})\n\t}\n\treturn pubsub\n}\n\nfunc (p *pubsubService) getItem(server string) (*pubsubItem, error) {\n\tp.mutex.Lock()\n\tdefer p.mutex.Unlock()\n\n\titem, ok := p.items[server]\n\tif !ok {\n\t\tvar err error\n\t\tconf := Connection().getConnection(server)\n\t\tif conf == nil {\n\t\t\treturn nil, fmt.Errorf(\"no connection profile named: %s\", server)\n\t\t}\n\t\tvar uniClient redis.UniversalClient\n\t\tif uniClient, err = Connection().createRedisClient(conf.ConnectionConfig); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\titem = &pubsubItem{\n\t\t\tclient: uniClient,\n\t\t}\n\t\tp.items[server] = item\n\t}\n\treturn item, nil\n}\n\nfunc (p *pubsubService) Start(ctx context.Context) {\n\tp.ctx, p.ctxCancel = context.WithCancel(ctx)\n}\n\n// Publish publish message to channel\nfunc (p *pubsubService) Publish(server, channel, payload string) (resp types.JSResp) {\n\trdb, err := Browser().getRedisClient(server, -1)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tvar received int64\n\treceived, err = rdb.client.Publish(p.ctx, channel, payload).Result()\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\tresp.Success = true\n\tresp.Data = struct {\n\t\tReceived int64 `json:\"received\"`\n\t}{\n\t\tReceived: received,\n\t}\n\treturn\n}\n\n// StartSubscribe start to subscribe a channel\nfunc (p *pubsubService) StartSubscribe(server string) (resp types.JSResp) {\n\titem, err := p.getItem(server)\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\n\titem.closeCh = make(chan struct{})\n\titem.eventName = \"sub:\" + strconv.Itoa(int(time.Now().Unix()))\n\titem.pubsub = item.client.PSubscribe(p.ctx, \"*\")\n\n\tgo p.processSubscribe(&item.mutex, item.pubsub.Channel(), item.closeCh, item.eventName)\n\tresp.Success = true\n\tresp.Data = struct {\n\t\tEventName string `json:\"eventName\"`\n\t}{\n\t\tEventName: item.eventName,\n\t}\n\treturn\n}\n\nfunc (p *pubsubService) processSubscribe(mutex *sync.Mutex, ch <-chan *redis.Message, closeCh <-chan struct{}, eventName string) {\n\tcache := make([]subMessage, 0, 1000)\n\tticker := time.NewTicker(300 * time.Millisecond)\n\tdefer ticker.Stop()\n\n\tfor {\n\t\tselect {\n\t\tcase data := <-ch:\n\t\t\tgo func() {\n\t\t\t\ttimestamp := time.Now().UnixMilli()\n\t\t\t\tmutex.Lock()\n\t\t\t\tdefer mutex.Unlock()\n\t\t\t\tcache = append(cache, subMessage{\n\t\t\t\t\tTimestamp: timestamp,\n\t\t\t\t\tChannel:   data.Channel,\n\t\t\t\t\tMessage:   data.Payload,\n\t\t\t\t})\n\t\t\t\tif len(cache) > 300 {\n\t\t\t\t\tEventsEmit(p.ctx, eventName, cache)\n\t\t\t\t\tcache = cache[:0:cap(cache)]\n\t\t\t\t}\n\t\t\t}()\n\n\t\tcase <-ticker.C:\n\t\t\tfunc() {\n\t\t\t\tmutex.Lock()\n\t\t\t\tdefer mutex.Unlock()\n\t\t\t\tif len(cache) > 0 {\n\t\t\t\t\tEventsEmit(p.ctx, eventName, cache)\n\t\t\t\t\tcache = cache[:0:cap(cache)]\n\t\t\t\t}\n\t\t\t}()\n\n\t\tcase <-closeCh:\n\t\t\t// subscribe stopped\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// StopSubscribe stop subscribe by server name\nfunc (p *pubsubService) StopSubscribe(server string) (resp types.JSResp) {\n\tp.mutex.Lock()\n\tdefer p.mutex.Unlock()\n\n\titem, ok := p.items[server]\n\tif !ok || item.pubsub == nil {\n\t\tresp.Success = true\n\t\treturn\n\t}\n\n\t//item.pubsub.Unsubscribe(p.ctx, \"*\")\n\titem.pubsub.Close()\n\tclose(item.closeCh)\n\tdelete(p.items, server)\n\tresp.Success = true\n\treturn\n}\n\n// StopAll stop all subscribe\nfunc (p *pubsubService) StopAll() {\n\tif p.ctxCancel != nil {\n\t\tp.ctxCancel()\n\t}\n\n\tfor server := range p.items {\n\t\tp.StopSubscribe(server)\n\t}\n}\n"
  },
  {
    "path": "backend/services/system_service.go",
    "content": "package services\n\nimport (\n\t\"context\"\n\truntime2 \"runtime\"\n\t\"sync\"\n\t\"time\"\n\t\"tinyrdm/backend/consts\"\n\t\"tinyrdm/backend/types\"\n\tsliceutil \"tinyrdm/backend/utils/slice\"\n)\n\ntype systemService struct {\n\tctx        context.Context\n\tappVersion string\n}\n\nvar system *systemService\nvar onceSystem sync.Once\n\nfunc System() *systemService {\n\tif system == nil {\n\t\tonceSystem.Do(func() {\n\t\t\tsystem = &systemService{\n\t\t\t\tappVersion: \"0.0.0\",\n\t\t\t}\n\t\t\tif IsDesktop() {\n\t\t\t\tgo system.loopWindowEvent()\n\t\t\t}\n\t\t})\n\t}\n\treturn system\n}\n\nfunc (s *systemService) Start(ctx context.Context, version string) {\n\tif !IsDesktop() {\n\t\treturn\n\t}\n\n\ts.ctx = ctx\n\ts.appVersion = version\n\n\t// maximize the window if screen size is lower than the minimum window size\n\tif screen, err := ScreenGetAll(ctx); err == nil && len(screen) > 0 {\n\t\tfor _, sc := range screen {\n\t\t\tif sc.IsCurrent {\n\t\t\t\tif sc.Size.Width < consts.MIN_WINDOW_WIDTH || sc.Size.Height < consts.MIN_WINDOW_HEIGHT {\n\t\t\t\t\tWindowMaximise(ctx)\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (s *systemService) Info() (resp types.JSResp) {\n\tresp.Success = true\n\tresp.Data = struct {\n\t\tOS      string `json:\"os\"`\n\t\tArch    string `json:\"arch\"`\n\t\tVersion string `json:\"version\"`\n\t}{\n\t\tOS:      runtime2.GOOS,\n\t\tArch:    runtime2.GOARCH,\n\t\tVersion: s.appVersion,\n\t}\n\treturn\n}\n\n// SelectFile open file dialog to select a file\nfunc (s *systemService) SelectFile(title string, extensions []string) (resp types.JSResp) {\n\tif !IsDesktop() {\n\t\treturn\n\t}\n\n\tfilters := sliceutil.Map(extensions, func(i int) FileFilter {\n\t\treturn FileFilter{\n\t\t\tPattern: \"*.\" + extensions[i],\n\t\t}\n\t})\n\tfilepath, err := OpenFileDialog(s.ctx, OpenDialogOptions{\n\t\tTitle:           title,\n\t\tShowHiddenFiles: true,\n\t\tFilters:         filters,\n\t})\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tresp.Success = true\n\tresp.Data = map[string]any{\n\t\t\"path\": filepath,\n\t}\n\treturn\n}\n\n// SaveFile open file dialog to save a file\nfunc (s *systemService) SaveFile(title string, defaultName string, extensions []string) (resp types.JSResp) {\n\tif !IsDesktop() {\n\t\treturn\n\t}\n\n\tfilters := sliceutil.Map(extensions, func(i int) FileFilter {\n\t\treturn FileFilter{\n\t\t\tPattern: \"*.\" + extensions[i],\n\t\t}\n\t})\n\tfilepath, err := SaveFileDialog(s.ctx, SaveDialogOptions{\n\t\tTitle:           title,\n\t\tShowHiddenFiles: true,\n\t\tDefaultFilename: defaultName,\n\t\tFilters:         filters,\n\t})\n\tif err != nil {\n\t\tresp.Msg = err.Error()\n\t\treturn\n\t}\n\tresp.Success = true\n\tresp.Data = map[string]any{\n\t\t\"path\": filepath,\n\t}\n\treturn\n}\n\nfunc (s *systemService) loopWindowEvent() {\n\tif !IsDesktop() {\n\t\treturn\n\t}\n\n\tvar fullscreen, maximised, minimised, normal bool\n\tvar width, height int\n\tvar dirty bool\n\tfor {\n\t\ttime.Sleep(300 * time.Millisecond)\n\t\tif s.ctx == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tdirty = false\n\t\tif f := WindowIsFullscreen(s.ctx); f != fullscreen {\n\t\t\t// full-screen switched\n\t\t\tfullscreen = f\n\t\t\tdirty = true\n\t\t}\n\n\t\tif w, h := WindowGetSize(s.ctx); w != width || h != height {\n\t\t\t// window size changed\n\t\t\twidth, height = w, h\n\t\t\tdirty = true\n\t\t}\n\n\t\tif m := WindowIsMaximised(s.ctx); m != maximised {\n\t\t\tmaximised = m\n\t\t\tdirty = true\n\t\t}\n\n\t\tif m := WindowIsMinimised(s.ctx); m != minimised {\n\t\t\tminimised = m\n\t\t\tdirty = true\n\t\t}\n\n\t\tif n := WindowIsNormal(s.ctx); n != normal {\n\t\t\tnormal = n\n\t\t\tdirty = true\n\t\t}\n\n\t\tif dirty {\n\t\t\tEventsEmit(s.ctx, \"window_changed\", map[string]any{\n\t\t\t\t\"fullscreen\": fullscreen,\n\t\t\t\t\"width\":      width,\n\t\t\t\t\"height\":     height,\n\t\t\t\t\"maximised\":  maximised,\n\t\t\t\t\"minimised\":  minimised,\n\t\t\t\t\"normal\":     normal,\n\t\t\t})\n\n\t\t\tif !fullscreen && !minimised {\n\t\t\t\t// save window size and position\n\t\t\t\tPreferences().SaveWindowSize(width, height, maximised)\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "backend/storage/connections.go",
    "content": "package storage\n\nimport (\n\t\"errors\"\n\t\"slices\"\n\t\"sync\"\n\t\"tinyrdm/backend/consts\"\n\t\"tinyrdm/backend/types\"\n\n\t\"gopkg.in/yaml.v3\"\n)\n\ntype ConnectionsStorage struct {\n\tstorage *localStorage\n\tmutex   sync.Mutex\n}\n\nfunc NewConnections() *ConnectionsStorage {\n\treturn &ConnectionsStorage{\n\t\tstorage: NewLocalStore(\"connections.yaml\"),\n\t}\n}\n\nfunc (c *ConnectionsStorage) defaultConnections() types.Connections {\n\treturn types.Connections{}\n}\n\nfunc (c *ConnectionsStorage) defaultConnectionItem() types.ConnectionConfig {\n\treturn types.ConnectionConfig{\n\t\tName:            \"\",\n\t\tNetwork:         \"tcp\",\n\t\tAddr:            \"127.0.0.1\",\n\t\tPort:            6379,\n\t\tUsername:        \"\",\n\t\tPassword:        \"\",\n\t\tDefaultFilter:   \"*\",\n\t\tKeySeparator:    \":\",\n\t\tConnTimeout:     60,\n\t\tExecTimeout:     60,\n\t\tDBFilterType:    \"none\",\n\t\tDBFilterList:    []int{},\n\t\tLoadSize:        consts.DEFAULT_LOAD_SIZE,\n\t\tMarkColor:       \"\",\n\t\tRefreshInterval: 5,\n\t\tSentinel: types.ConnectionSentinel{\n\t\t\tMaster: \"mymaster\",\n\t\t},\n\t}\n}\n\nfunc (c *ConnectionsStorage) getConnections() (ret types.Connections) {\n\tb, err := c.storage.Load()\n\tret = c.defaultConnections()\n\tif err != nil {\n\t\treturn\n\t}\n\n\tif err = yaml.Unmarshal(b, &ret); err != nil {\n\t\tret = c.defaultConnections()\n\t\treturn\n\t}\n\tif len(ret) <= 0 {\n\t\tret = c.defaultConnections()\n\t}\n\t//if !sliceutil.AnyMatch(ret, func(i int) bool {\n\t//\treturn ret[i].GroupName == \"\"\n\t//}) {\n\t//\tret = append(ret, c.defaultConnections()...)\n\t//}\n\treturn\n}\n\n// GetConnections get all store connections from local\nfunc (c *ConnectionsStorage) GetConnections() (ret types.Connections) {\n\treturn c.getConnections()\n}\n\n// GetConnectionsFlat get all store connections from local flat(exclude group level)\nfunc (c *ConnectionsStorage) GetConnectionsFlat() (ret types.Connections) {\n\tconns := c.getConnections()\n\tfor _, conn := range conns {\n\t\tif conn.Type == \"group\" {\n\t\t\tret = append(ret, conn.Connections...)\n\t\t} else {\n\t\t\tret = append(ret, conn)\n\t\t}\n\t}\n\treturn\n}\n\n// GetConnection get connection by name\nfunc (c *ConnectionsStorage) GetConnection(name string) *types.Connection {\n\tconns := c.getConnections()\n\n\tvar findConn func(string, string, types.Connections) *types.Connection\n\tfindConn = func(name, groupName string, conns types.Connections) *types.Connection {\n\t\tfor i, conn := range conns {\n\t\t\tif conn.Type != \"group\" {\n\t\t\t\tif conn.Name == name {\n\t\t\t\t\tconns[i].Group = groupName\n\t\t\t\t\treturn &conns[i]\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif ret := findConn(name, conn.Name, conn.Connections); ret != nil {\n\t\t\t\t\treturn ret\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\n\treturn findConn(name, \"\", conns)\n}\n\n// GetGroup get one connection group by name\nfunc (c *ConnectionsStorage) GetGroup(name string) *types.Connection {\n\tconns := c.getConnections()\n\n\tfor i, conn := range conns {\n\t\tif conn.Type == \"group\" && conn.Name == name {\n\t\t\treturn &conns[i]\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc (c *ConnectionsStorage) saveConnections(conns types.Connections) error {\n\tb, err := yaml.Marshal(&conns)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err = c.storage.Store(b); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// CreateConnection create new connection\nfunc (c *ConnectionsStorage) CreateConnection(param types.ConnectionConfig) error {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\n\tconn := c.GetConnection(param.Name)\n\tif conn != nil {\n\t\treturn errors.New(\"duplicated connection name\")\n\t}\n\n\tconns := c.getConnections()\n\tvar group *types.Connection\n\tif len(param.Group) > 0 {\n\t\tfor i, conn := range conns {\n\t\t\tif conn.Type == \"group\" && conn.Name == param.Group {\n\t\t\t\tgroup = &conns[i]\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\tif group != nil {\n\t\tgroup.Connections = append(group.Connections, types.Connection{\n\t\t\tConnectionConfig: param,\n\t\t})\n\t} else {\n\t\tif len(param.Group) > 0 {\n\t\t\t// no group matched, create new group\n\t\t\tconns = append(conns, types.Connection{\n\t\t\t\tType: \"group\",\n\t\t\t\tConnections: types.Connections{\n\t\t\t\t\ttypes.Connection{\n\t\t\t\t\t\tConnectionConfig: param,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t})\n\t\t} else {\n\t\t\tconns = append(conns, types.Connection{\n\t\t\t\tConnectionConfig: param,\n\t\t\t})\n\t\t}\n\t}\n\n\treturn c.saveConnections(conns)\n}\n\n// UpdateConnection update existing connection by name\nfunc (c *ConnectionsStorage) UpdateConnection(name string, param types.ConnectionConfig) error {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\n\tconns := c.getConnections()\n\tvar updated bool\n\tvar retrieve func(types.Connections, string, types.ConnectionConfig) error\n\tretrieve = func(conns types.Connections, name string, param types.ConnectionConfig) error {\n\t\tfor i, conn := range conns {\n\t\t\tif conn.Type != \"group\" {\n\t\t\t\tif name != param.Name && conn.Name == param.Name {\n\t\t\t\t\treturn errors.New(\"duplicated connection name\")\n\t\t\t\t} else if conn.Name == name && !updated {\n\t\t\t\t\tconns[i] = types.Connection{\n\t\t\t\t\t\tConnectionConfig: param,\n\t\t\t\t\t}\n\t\t\t\t\tupdated = true\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif err := retrieve(conn.Connections, name, param); err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\n\terr := retrieve(conns, name, param)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !updated {\n\t\treturn errors.New(\"connection not found\")\n\t}\n\n\treturn c.saveConnections(conns)\n}\n\n// DeleteConnection remove special connection\nfunc (c *ConnectionsStorage) DeleteConnection(name string) error {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\n\tconns := c.getConnections()\n\tvar updated bool\n\tfor i, conn := range conns {\n\t\tif conn.Type == \"group\" {\n\t\t\tfor j, subConn := range conn.Connections {\n\t\t\t\tif subConn.Name == name {\n\t\t\t\t\tconns[i].Connections = append(conns[i].Connections[:j], conns[i].Connections[j+1:]...)\n\t\t\t\t\tupdated = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t} else if conn.Name == name {\n\t\t\tconns = append(conns[:i], conns[i+1:]...)\n\t\t\tupdated = true\n\t\t\tbreak\n\t\t}\n\t\tif updated {\n\t\t\tbreak\n\t\t}\n\t}\n\tif !updated {\n\t\treturn errors.New(\"no match connection\")\n\t}\n\treturn c.saveConnections(conns)\n}\n\n// SaveSortedConnection save connection after sort\nfunc (c *ConnectionsStorage) SaveSortedConnection(sortedConns types.Connections) error {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\n\tconns := c.GetConnectionsFlat()\n\ttakeConn := func(name string) (types.Connection, bool) {\n\t\tidx := slices.IndexFunc(conns, func(connection types.Connection) bool {\n\t\t\treturn connection.Name == name\n\t\t})\n\t\tif idx >= 0 {\n\t\t\tret := conns[idx]\n\t\t\tconns = append(conns[:idx], conns[idx+1:]...)\n\t\t\treturn ret, true\n\t\t}\n\t\treturn types.Connection{}, false\n\t}\n\tvar replaceConn func(connections types.Connections) types.Connections\n\treplaceConn = func(cons types.Connections) types.Connections {\n\t\tvar newConns types.Connections\n\t\tfor _, conn := range cons {\n\t\t\tif conn.Type == \"group\" {\n\t\t\t\tnewConns = append(newConns, types.Connection{\n\t\t\t\t\tConnectionConfig: types.ConnectionConfig{\n\t\t\t\t\t\tName: conn.Name,\n\t\t\t\t\t},\n\t\t\t\t\tType:        \"group\",\n\t\t\t\t\tConnections: replaceConn(conn.Connections),\n\t\t\t\t})\n\t\t\t} else {\n\t\t\t\tif foundConn, ok := takeConn(conn.Name); ok {\n\t\t\t\t\tnewConns = append(newConns, foundConn)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn newConns\n\t}\n\tconns = replaceConn(sortedConns)\n\treturn c.saveConnections(conns)\n}\n\n// CreateGroup create a new group\nfunc (c *ConnectionsStorage) CreateGroup(name string) error {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\n\tconns := c.getConnections()\n\tfor _, conn := range conns {\n\t\tif conn.Type == \"group\" && conn.Name == name {\n\t\t\treturn errors.New(\"duplicated group name\")\n\t\t}\n\t}\n\n\tconns = append(conns, types.Connection{\n\t\tConnectionConfig: types.ConnectionConfig{\n\t\t\tName: name,\n\t\t},\n\t\tType: \"group\",\n\t})\n\treturn c.saveConnections(conns)\n}\n\n// RenameGroup rename group\nfunc (c *ConnectionsStorage) RenameGroup(name, newName string) error {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\n\tgroupIndex := -1\n\tconns := c.getConnections()\n\tfor i, conn := range conns {\n\t\tif conn.Type == \"group\" {\n\t\t\tif conn.Name == newName {\n\t\t\t\treturn errors.New(\"duplicated group name\")\n\t\t\t} else if conn.Name == name {\n\t\t\t\tgroupIndex = i\n\t\t\t}\n\t\t}\n\t}\n\n\tif groupIndex == -1 {\n\t\treturn errors.New(\"group not found\")\n\t}\n\n\tconns[groupIndex].Name = newName\n\treturn c.saveConnections(conns)\n}\n\n// DeleteGroup remove specified group, include all connections under it\nfunc (c *ConnectionsStorage) DeleteGroup(group string, includeConnection bool) error {\n\tc.mutex.Lock()\n\tdefer c.mutex.Unlock()\n\n\tconns := c.getConnections()\n\tfor i, conn := range conns {\n\t\tif conn.Type == \"group\" && conn.Name == group {\n\t\t\tconns = append(conns[:i], conns[i+1:]...)\n\t\t\tif includeConnection {\n\t\t\t\tconns = append(conns, conn.Connections...)\n\t\t\t}\n\t\t\treturn c.saveConnections(conns)\n\t\t}\n\t}\n\treturn errors.New(\"group not found\")\n}\n"
  },
  {
    "path": "backend/storage/local_storage.go",
    "content": "package storage\n\nimport (\n\t\"os\"\n\t\"path\"\n\n\t\"tinyrdm/backend/consts\"\n\n\t\"github.com/vrischmann/userdir\"\n)\n\n// localStorage provides reading and writing application data to the user's\n// configuration directory.\ntype localStorage struct {\n\tConfPath string\n}\n\n// NewLocalStore returns a localStore instance.\nfunc NewLocalStore(filename string) *localStorage {\n\treturn &localStorage{\n\t\tConfPath: path.Join(userdir.GetConfigHome(), consts.APP_DATA_FOLDER, filename),\n\t}\n}\n\n// Load reads the given file in the user's configuration directory and returns\n// its contents.\nfunc (l *localStorage) Load() ([]byte, error) {\n\td, err := os.ReadFile(l.ConfPath)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn d, err\n}\n\n// Store writes data to the user's configuration directory at the given\n// filename.\nfunc (l *localStorage) Store(data []byte) error {\n\tdir := path.Dir(l.ConfPath)\n\tif err := ensureDirExists(dir); err != nil {\n\t\treturn err\n\t}\n\tif err := os.WriteFile(l.ConfPath, data, 0600); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// ensureDirExists checks for the existence of the directory at the given path,\n// which is created if it does not exist.\nfunc ensureDirExists(path string) error {\n\t_, err := os.Stat(path)\n\tif os.IsNotExist(err) {\n\t\tif err = os.Mkdir(path, 0700); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "backend/storage/preferences.go",
    "content": "package storage\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"reflect\"\n\t\"strings\"\n\t\"sync\"\n\t\"tinyrdm/backend/consts\"\n\t\"tinyrdm/backend/types\"\n\n\t\"gopkg.in/yaml.v3\"\n)\n\ntype PreferencesStorage struct {\n\tstorage *localStorage\n\tmutex   sync.Mutex\n}\n\nfunc NewPreferences() *PreferencesStorage {\n\tstorage := NewLocalStore(\"preferences.yaml\")\n\tlog.Printf(\"preferences path: %s\\n\", storage.ConfPath)\n\treturn &PreferencesStorage{\n\t\tstorage: storage,\n\t}\n}\n\nfunc (p *PreferencesStorage) DefaultPreferences() types.Preferences {\n\treturn types.NewPreferences()\n}\n\nfunc (p *PreferencesStorage) getPreferences() (ret types.Preferences) {\n\tret = p.DefaultPreferences()\n\tb, err := p.storage.Load()\n\tif err != nil {\n\t\treturn\n\t}\n\n\tif err = yaml.Unmarshal(b, &ret); err != nil {\n\t\tret = p.DefaultPreferences()\n\t\treturn\n\t}\n\treturn\n}\n\n// GetPreferences Get preferences from local\nfunc (p *PreferencesStorage) GetPreferences() (ret types.Preferences) {\n\tp.mutex.Lock()\n\tdefer p.mutex.Unlock()\n\n\tret = p.getPreferences()\n\tif ret.General.ScanSize <= 0 {\n\t\tret.General.ScanSize = consts.DEFAULT_SCAN_SIZE\n\t}\n\tret.Behavior.AsideWidth = max(ret.Behavior.AsideWidth, consts.DEFAULT_ASIDE_WIDTH)\n\tret.Behavior.WindowWidth = max(ret.Behavior.WindowWidth, consts.MIN_WINDOW_WIDTH)\n\tret.Behavior.WindowHeight = max(ret.Behavior.WindowHeight, consts.MIN_WINDOW_HEIGHT)\n\treturn\n}\n\nfunc (p *PreferencesStorage) setPreferences(pf *types.Preferences, key string, value any) error {\n\tparts := strings.Split(key, \".\")\n\tif len(parts) > 0 {\n\t\tvar reflectValue reflect.Value\n\t\tif reflect.TypeOf(pf).Kind() == reflect.Ptr {\n\t\t\treflectValue = reflect.ValueOf(pf).Elem()\n\t\t} else {\n\t\t\treflectValue = reflect.ValueOf(pf)\n\t\t}\n\t\tfor i, part := range parts {\n\t\t\tpart = strings.ToUpper(part[:1]) + part[1:]\n\t\t\treflectValue = reflectValue.FieldByName(part)\n\t\t\tif reflectValue.IsValid() {\n\t\t\t\tif i == len(parts)-1 {\n\t\t\t\t\treflectValue.Set(reflect.ValueOf(value))\n\t\t\t\t\treturn nil\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\treturn fmt.Errorf(\"invalid key path(%s)\", key)\n}\n\nfunc (p *PreferencesStorage) savePreferences(pf *types.Preferences) error {\n\tb, err := yaml.Marshal(pf)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err = p.storage.Store(b); err != nil {\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// SetPreferences replace preferences\nfunc (p *PreferencesStorage) SetPreferences(pf *types.Preferences) error {\n\tp.mutex.Lock()\n\tdefer p.mutex.Unlock()\n\n\treturn p.savePreferences(pf)\n}\n\n// UpdatePreferences update values by key paths, the key path use \".\" to indicate multiple level\nfunc (p *PreferencesStorage) UpdatePreferences(values map[string]any) error {\n\tp.mutex.Lock()\n\tdefer p.mutex.Unlock()\n\n\tpf := p.getPreferences()\n\tfor path, v := range values {\n\t\tif err := p.setPreferences(&pf, path, v); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\tlog.Println(\"after save\", pf)\n\n\treturn p.savePreferences(&pf)\n}\n\nfunc (p *PreferencesStorage) RestoreDefault() types.Preferences {\n\tp.mutex.Lock()\n\tdefer p.mutex.Unlock()\n\n\tpf := p.DefaultPreferences()\n\tp.savePreferences(&pf)\n\treturn pf\n}\n"
  },
  {
    "path": "backend/types/connection.go",
    "content": "package types\n\ntype ConnectionCategory int\n\ntype ConnectionConfig struct {\n\tName            string             `json:\"name\" yaml:\"name\"`\n\tGroup           string             `json:\"group,omitempty\" yaml:\"-\"`\n\tLastDB          int                `json:\"lastDB\" yaml:\"last_db\"`\n\tNetwork         string             `json:\"network,omitempty\" yaml:\"network,omitempty\"`\n\tSock            string             `json:\"sock,omitempty\" yaml:\"sock,omitempty\"`\n\tAddr            string             `json:\"addr,omitempty\" yaml:\"addr,omitempty\"`\n\tPort            int                `json:\"port,omitempty\" yaml:\"port,omitempty\"`\n\tUsername        string             `json:\"username,omitempty\" yaml:\"username,omitempty\"`\n\tPassword        string             `json:\"password,omitempty\" yaml:\"password,omitempty\"`\n\tDefaultFilter   string             `json:\"defaultFilter,omitempty\" yaml:\"default_filter,omitempty\"`\n\tKeySeparator    string             `json:\"keySeparator,omitempty\" yaml:\"key_separator,omitempty\"`\n\tConnTimeout     int                `json:\"connTimeout,omitempty\" yaml:\"conn_timeout,omitempty\"`\n\tExecTimeout     int                `json:\"execTimeout,omitempty\" yaml:\"exec_timeout,omitempty\"`\n\tDBFilterType    string             `json:\"dbFilterType\" yaml:\"db_filter_type,omitempty\"`\n\tDBFilterList    []int              `json:\"dbFilterList\" yaml:\"db_filter_list,omitempty\"`\n\tKeyView         int                `json:\"keyView,omitempty\" yaml:\"key_view,omitempty\"`\n\tLoadSize        int                `json:\"loadSize,omitempty\" yaml:\"load_size,omitempty\"`\n\tMarkColor       string             `json:\"markColor,omitempty\" yaml:\"mark_color,omitempty\"`\n\tRefreshInterval int                `json:\"refreshInterval,omitempty\" yaml:\"refresh_interval,omitempty\"`\n\tAlias           map[int]string     `json:\"alias,omitempty\" yaml:\"alias,omitempty\"`\n\tSSL             ConnectionSSL      `json:\"ssl,omitempty\" yaml:\"ssl,omitempty\"`\n\tSSH             ConnectionSSH      `json:\"ssh,omitempty\" yaml:\"ssh,omitempty\"`\n\tSentinel        ConnectionSentinel `json:\"sentinel,omitempty\" yaml:\"sentinel,omitempty\"`\n\tCluster         ConnectionCluster  `json:\"cluster,omitempty\" yaml:\"cluster,omitempty\"`\n\tProxy           ConnectionProxy    `json:\"proxy,omitempty\" yaml:\"proxy,omitempty\"`\n}\n\ntype Connection struct {\n\tConnectionConfig `json:\",inline\" yaml:\",inline\"`\n\tType             string       `json:\"type,omitempty\" yaml:\"type,omitempty\"`\n\tConnections      []Connection `json:\"connections,omitempty\" yaml:\"connections,omitempty\"`\n}\n\ntype Connections []Connection\n\ntype ConnectionDB struct {\n\tName    string `json:\"name\"`\n\tAlias   string `json:\"alias,omitempty\"`\n\tIndex   int    `json:\"index\"`\n\tMaxKeys int    `json:\"maxKeys\"`\n\tExpires int    `json:\"expires,omitempty\"`\n\tAvgTTL  int    `json:\"avgTtl,omitempty\"`\n}\n\ntype ConnectionSSL struct {\n\tEnable        bool   `json:\"enable,omitempty\" yaml:\"enable,omitempty\"`\n\tKeyFile       string `json:\"keyFile,omitempty\" yaml:\"keyfile,omitempty\"`\n\tCertFile      string `json:\"certFile,omitempty\" yaml:\"certfile,omitempty\"`\n\tCAFile        string `json:\"caFile,omitempty\" yaml:\"cafile,omitempty\"`\n\tAllowInsecure bool   `json:\"allowInsecure,omitempty\" yaml:\"allow_insecure,omitempty\"`\n\tSNI           string `json:\"sni,omitempty\" yaml:\"sni,omitempty\"`\n}\n\ntype ConnectionSSH struct {\n\tEnable     bool   `json:\"enable,omitempty\" yaml:\"enable,omitempty\"`\n\tAddr       string `json:\"addr,omitempty\" yaml:\"addr,omitempty\"`\n\tPort       int    `json:\"port,omitempty\" yaml:\"port,omitempty\"`\n\tLoginType  string `json:\"loginType,omitempty\" yaml:\"login_type\"`\n\tUsername   string `json:\"username,omitempty\" yaml:\"username,omitempty\"`\n\tPassword   string `json:\"password,omitempty\" yaml:\"password,omitempty\"`\n\tPKFile     string `json:\"pkFile,omitempty\" yaml:\"pk_file,omitempty\"`\n\tPassphrase string `json:\"passphrase,omitempty\" yaml:\"passphrase,omitempty\"`\n}\n\ntype ConnectionSentinel struct {\n\tEnable   bool   `json:\"enable,omitempty\" yaml:\"enable,omitempty\"`\n\tMaster   string `json:\"master,omitempty\" yaml:\"master,omitempty\"`\n\tUsername string `json:\"username,omitempty\" yaml:\"username,omitempty\"`\n\tPassword string `json:\"password,omitempty\" yaml:\"password,omitempty\"`\n}\n\ntype ConnectionCluster struct {\n\tEnable bool `json:\"enable,omitempty\" yaml:\"enable,omitempty\"`\n}\n\ntype ConnectionProxy struct {\n\tType     int    `json:\"type,omitempty\" yaml:\"type,omitempty\"`\n\tSchema   string `json:\"schema,omitempty\" yaml:\"schema,omitempty\"`\n\tAddr     string `json:\"addr,omitempty\" yaml:\"addr,omitempty\"`\n\tPort     int    `json:\"port,omitempty\" yaml:\"port,omitempty\"`\n\tUsername string `json:\"username,omitempty\" yaml:\"username,omitempty\"`\n\tPassword string `json:\"password,omitempty\" yaml:\"password,omitempty\"`\n}\n"
  },
  {
    "path": "backend/types/js_resp.go",
    "content": "package types\n\ntype JSResp struct {\n\tSuccess bool   `json:\"success\"`\n\tMsg     string `json:\"msg\"`\n\tData    any    `json:\"data,omitempty\"`\n}\n\ntype KeySummaryParam struct {\n\tServer string `json:\"server\"`\n\tDB     int    `json:\"db\"`\n\tKey    any    `json:\"key\"`\n}\n\ntype KeySummary struct {\n\tType   string `json:\"type\"`\n\tTTL    int64  `json:\"ttl,omitempty\"`\n\tSize   int64  `json:\"size,omitempty\"`\n\tLength int64  `json:\"length,omitempty\"`\n}\n\ntype KeyDetailParam struct {\n\tServer       string `json:\"server\"`\n\tDB           int    `json:\"db\"`\n\tKey          any    `json:\"key\"`\n\tFormat       string `json:\"format,omitempty\"`\n\tDecode       string `json:\"decode,omitempty\"`\n\tMatchPattern string `json:\"matchPattern,omitempty\"`\n\tReset        bool   `json:\"reset\"`\n\tFull         bool   `json:\"full\"`\n}\n\ntype KeyDetail struct {\n\tValue   any    `json:\"value\"`\n\tKeyType string `json:\"key_type\"`\n\tLength  int64  `json:\"length,omitempty\"`\n\tFormat  string `json:\"format,omitempty\"`\n\tDecode  string `json:\"decode,omitempty\"`\n\tMatch   string `json:\"match,omitempty\"`\n\tReset   bool   `json:\"reset\"`\n\tEnd     bool   `json:\"end\"`\n}\n\ntype SetKeyParam struct {\n\tServer  string `json:\"server\"`\n\tDB      int    `json:\"db\"`\n\tKey     any    `json:\"key\"`\n\tKeyType string `json:\"keyType\"`\n\tValue   any    `json:\"value\"`\n\tTTL     int64  `json:\"ttl\"`\n\tFormat  string `json:\"format,omitempty\"`\n\tDecode  string `json:\"decode,omitempty\"`\n}\n\ntype SetListParam struct {\n\tServer    string `json:\"server\"`\n\tDB        int    `json:\"db\"`\n\tKey       any    `json:\"key\"`\n\tIndex     int    `json:\"index\"`\n\tValue     any    `json:\"value\"`\n\tFormat    string `json:\"format,omitempty\"`\n\tDecode    string `json:\"decode,omitempty\"`\n\tRetFormat string `json:\"retFormat,omitempty\"`\n\tRetDecode string `json:\"retDecode,omitempty\"`\n}\n\ntype SetHashParam struct {\n\tServer    string `json:\"server\"`\n\tDB        int    `json:\"db\"`\n\tKey       any    `json:\"key\"`\n\tField     string `json:\"field,omitempty\"`\n\tNewField  string `json:\"newField,omitempty\"`\n\tValue     any    `json:\"value\"`\n\tFormat    string `json:\"format,omitempty\"`\n\tDecode    string `json:\"decode,omitempty\"`\n\tRetFormat string `json:\"retFormat,omitempty\"`\n\tRetDecode string `json:\"retDecode,omitempty\"`\n}\n\ntype SetSetParam struct {\n\tServer    string `json:\"server\"`\n\tDB        int    `json:\"db\"`\n\tKey       any    `json:\"key\"`\n\tValue     any    `json:\"value\"`\n\tNewValue  any    `json:\"newValue\"`\n\tFormat    string `json:\"format,omitempty\"`\n\tDecode    string `json:\"decode,omitempty\"`\n\tRetFormat string `json:\"retFormat,omitempty\"`\n\tRetDecode string `json:\"retDecode,omitempty\"`\n}\n\ntype SetZSetParam struct {\n\tServer    string  `json:\"server\"`\n\tDB        int     `json:\"db\"`\n\tKey       any     `json:\"key\"`\n\tValue     any     `json:\"value\"`\n\tNewValue  any     `json:\"newValue\"`\n\tScore     float64 `json:\"score\"`\n\tFormat    string  `json:\"format,omitempty\"`\n\tDecode    string  `json:\"decode,omitempty\"`\n\tRetFormat string  `json:\"retFormat,omitempty\"`\n\tRetDecode string  `json:\"retDecode,omitempty\"`\n}\n\ntype GetHashParam struct {\n\tServer string `json:\"server\"`\n\tDB     int    `json:\"db\"`\n\tKey    any    `json:\"key\"`\n\tField  string `json:\"field,omitempty\"`\n\tFormat string `json:\"format,omitempty\"`\n\tDecode string `json:\"decode,omitempty\"`\n}\n"
  },
  {
    "path": "backend/types/preferences.go",
    "content": "package types\n\nimport \"tinyrdm/backend/consts\"\n\ntype Preferences struct {\n\tBehavior PreferencesBehavior  `json:\"behavior\" yaml:\"behavior\"`\n\tGeneral  PreferencesGeneral   `json:\"general\" yaml:\"general\"`\n\tEditor   PreferencesEditor    `json:\"editor\" yaml:\"editor\"`\n\tCli      PreferencesCli       `json:\"cli\" yaml:\"cli\"`\n\tDecoder  []PreferencesDecoder `json:\"decoder\" yaml:\"decoder,omitempty\"`\n}\n\nfunc NewPreferences() Preferences {\n\treturn Preferences{\n\t\tBehavior: PreferencesBehavior{\n\t\t\tAsideWidth:   consts.DEFAULT_ASIDE_WIDTH,\n\t\t\tWindowWidth:  consts.DEFAULT_WINDOW_WIDTH,\n\t\t\tWindowHeight: consts.DEFAULT_WINDOW_HEIGHT,\n\t\t},\n\t\tGeneral: PreferencesGeneral{\n\t\t\tTheme:        \"auto\",\n\t\t\tLanguage:     \"auto\",\n\t\t\tFontSize:     consts.DEFAULT_FONT_SIZE,\n\t\t\tScanSize:     consts.DEFAULT_SCAN_SIZE,\n\t\t\tKeyIconStyle: 0,\n\t\t\tCheckUpdate:  true,\n\t\t\tAllowTrack:   true,\n\t\t},\n\t\tEditor: PreferencesEditor{\n\t\t\tFontSize:       consts.DEFAULT_FONT_SIZE,\n\t\t\tShowLineNum:    true,\n\t\t\tShowFolding:    true,\n\t\t\tDropText:       true,\n\t\t\tLinks:          true,\n\t\t\tEntryTextAlign: 0,\n\t\t},\n\t\tCli: PreferencesCli{\n\t\t\tFontSize:    consts.DEFAULT_FONT_SIZE,\n\t\t\tCursorStyle: \"block\",\n\t\t},\n\t\tDecoder: []PreferencesDecoder{},\n\t}\n}\n\ntype PreferencesBehavior struct {\n\tWelcomed        bool `json:\"welcomed\" yaml:\"welcomed\"`\n\tAsideWidth      int  `json:\"asideWidth\" yaml:\"aside_width\"`\n\tWindowWidth     int  `json:\"windowWidth\" yaml:\"window_width\"`\n\tWindowHeight    int  `json:\"windowHeight\" yaml:\"window_height\"`\n\tWindowMaximised bool `json:\"windowMaximised\" yaml:\"window_maximised\"`\n\tWindowPosX      int  `json:\"windowPosX\" yaml:\"window_pos_x\"`\n\tWindowPosY      int  `json:\"windowPosY\" yaml:\"window_pos_y\"`\n}\n\ntype PreferencesGeneral struct {\n\tTheme           string   `json:\"theme\" yaml:\"theme\"`\n\tLanguage        string   `json:\"language\" yaml:\"language\"`\n\tFont            string   `json:\"font\" yaml:\"font,omitempty\"`\n\tFontFamily      []string `json:\"fontFamily\" yaml:\"font_family,omitempty\"`\n\tFontSize        int      `json:\"fontSize\" yaml:\"font_size\"`\n\tScanSize        int      `json:\"scanSize\" yaml:\"scan_size\"`\n\tKeyIconStyle    int      `json:\"keyIconStyle\" yaml:\"key_icon_style\"`\n\tUseSysProxy     bool     `json:\"useSysProxy\" yaml:\"use_sys_proxy,omitempty\"`\n\tUseSysProxyHttp bool     `json:\"useSysProxyHttp\" yaml:\"use_sys_proxy_http,omitempty\"`\n\tCheckUpdate     bool     `json:\"checkUpdate\" yaml:\"check_update\"`\n\tSkipVersion     string   `json:\"skipVersion\" yaml:\"skip_version,omitempty\"`\n\tAllowTrack      bool     `json:\"allowTrack\" yaml:\"allow_track\"`\n}\n\ntype PreferencesEditor struct {\n\tFont           string   `json:\"font\" yaml:\"font,omitempty\"`\n\tFontFamily     []string `json:\"fontFamily\" yaml:\"font_family,omitempty\"`\n\tFontSize       int      `json:\"fontSize\" yaml:\"font_size\"`\n\tShowLineNum    bool     `json:\"showLineNum\" yaml:\"show_line_num\"`\n\tShowFolding    bool     `json:\"showFolding\" yaml:\"show_folding\"`\n\tDropText       bool     `json:\"dropText\" yaml:\"drop_text\"`\n\tLinks          bool     `json:\"links\" yaml:\"links\"`\n\tEntryTextAlign int      `json:\"entryTextAlign\" yaml:\"entry_text_align\"`\n}\n\ntype PreferencesCli struct {\n\tFontFamily  []string `json:\"fontFamily\" yaml:\"font_family,omitempty\"`\n\tFontSize    int      `json:\"fontSize\" yaml:\"font_size\"`\n\tCursorStyle string   `json:\"cursorStyle\" yaml:\"cursor_style,omitempty\"`\n}\n\ntype PreferencesDecoder struct {\n\tName       string   `json:\"name\" yaml:\"name\"`\n\tEnable     bool     `json:\"enable\" yaml:\"enable\"`\n\tAuto       bool     `json:\"auto\" yaml:\"auto\"`\n\tDecodePath string   `json:\"decodePath\" yaml:\"decode_path\"`\n\tDecodeArgs []string `json:\"decodeArgs\" yaml:\"decode_args,omitempty\"`\n\tEncodePath string   `json:\"encodePath\" yaml:\"encode_path\"`\n\tEncodeArgs []string `json:\"encodeArgs\" yaml:\"encode_args,omitempty\"`\n}\n"
  },
  {
    "path": "backend/types/redis_wrapper.go",
    "content": "package types\n\ntype ListEntryItem struct {\n\tIndex        int    `json:\"index\"`\n\tValue        any    `json:\"v\"`\n\tDisplayValue string `json:\"dv,omitempty\"`\n}\n\ntype ListReplaceItem struct {\n\tIndex        int    `json:\"index\"`\n\tValue        any    `json:\"v,omitempty\"`\n\tDisplayValue string `json:\"dv,omitempty\"`\n}\n\ntype HashEntryItem struct {\n\tKey          string `json:\"k\"`\n\tValue        any    `json:\"v\"`\n\tDisplayValue string `json:\"dv,omitempty\"`\n}\n\ntype HashReplaceItem struct {\n\tKey          any    `json:\"k\"`\n\tNewKey       any    `json:\"nk\"`\n\tValue        any    `json:\"v\"`\n\tDisplayValue string `json:\"dv,omitempty\"`\n}\n\ntype SetEntryItem struct {\n\tValue        any    `json:\"v\"`\n\tDisplayValue string `json:\"dv,omitempty\"`\n}\n\ntype ZSetEntryItem struct {\n\tScore        float64 `json:\"s\"`\n\tScoreStr     string  `json:\"ss,omitempty\"`\n\tValue        any     `json:\"v\"`\n\tDisplayValue string  `json:\"dv,omitempty\"`\n}\n\ntype ZSetReplaceItem struct {\n\tScore        float64 `json:\"s\"`\n\tValue        string  `json:\"v\"`\n\tNewValue     string  `json:\"nv\"`\n\tDisplayValue string  `json:\"dv,omitempty\"`\n}\n\ntype StreamEntryItem struct {\n\tID           string         `json:\"id\"`\n\tValue        map[string]any `json:\"v\"`\n\tDisplayValue string         `json:\"dv,omitempty\"`\n}\n"
  },
  {
    "path": "backend/types/view_type.go",
    "content": "package types\n\nconst FORMAT_RAW = \"Raw\"\nconst FORMAT_JSON = \"JSON\"\nconst FORMAT_UNICODE_JSON = \"Unicode JSON\"\nconst FORMAT_YAML = \"YAML\"\nconst FORMAT_XML = \"XML\"\nconst FORMAT_HEX = \"Hex\"\nconst FORMAT_BINARY = \"Binary\"\nconst FORMAT_BITSET = \"BitSet\"\n\nconst DECODE_NONE = \"None\"\nconst DECODE_BASE64 = \"Base64\"\nconst DECODE_GZIP = \"GZip\"\nconst DECODE_DEFLATE = \"Deflate\"\nconst DECODE_ZSTD = \"ZStd\"\nconst DECODE_LZ4 = \"LZ4\"\nconst DECODE_BROTLI = \"Brotli\"\nconst DECODE_MSGPACK = \"Msgpack\"\nconst DECODE_PHP = \"PHP\"\nconst DECODE_PICKLE = \"Pickle\"\n"
  },
  {
    "path": "backend/utils/coll/set.go",
    "content": "package coll\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"sort\"\n\t. \"tinyrdm/backend/utils\"\n)\n\ntype Void struct{}\n\n// Set 集合, 存放不重复的元素\ntype Set[T Hashable] map[T]Void\n\n// type Set[T Hashable] struct {\n//\tdata map[T]Void\n// }\n\nfunc NewSet[T Hashable](elems ...T) Set[T] {\n\tif len(elems) > 0 {\n\t\tdata := make(Set[T], len(elems))\n\t\tfor _, e := range elems {\n\t\t\tdata[e] = Void{}\n\t\t}\n\t\treturn data\n\t} else {\n\t\treturn Set[T]{}\n\t}\n}\n\n// Add 添加元素\nfunc (s Set[T]) Add(elem T) bool {\n\tif s == nil {\n\t\treturn false\n\t}\n\tif _, exists := s[elem]; !exists {\n\t\ts[elem] = Void{}\n\t\treturn true\n\t}\n\treturn false\n}\n\n// AddN 添加多个元素\nfunc (s Set[T]) AddN(elems ...T) int {\n\tif s == nil {\n\t\treturn 0\n\t}\n\taddCount := 0\n\tvar exists bool\n\tfor _, elem := range elems {\n\t\tif _, exists = s[elem]; !exists {\n\t\t\ts[elem] = Void{}\n\t\t\taddCount += 1\n\t\t}\n\t}\n\treturn addCount\n}\n\n// Merge 合并其他集合\nfunc (s Set[T]) Merge(other Set[T]) int {\n\treturn s.AddN(other.ToSlice()...)\n}\n\n// Contains 判断是否存在指定元素\nfunc (s Set[T]) Contains(elem T) bool {\n\tif s == nil {\n\t\treturn false\n\t}\n\t_, exists := s[elem]\n\treturn exists\n}\n\n// ContainAny 判断是否包含任意元素\nfunc (s Set[T]) ContainAny(elems ...T) bool {\n\tif s == nil {\n\t\treturn false\n\t}\n\tvar exists bool\n\tfor _, elem := range elems {\n\t\tif _, exists = s[elem]; exists {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// Equals 判断两个集合内元素是否一致\nfunc (s Set[T]) Equals(other Set[T]) bool {\n\tif s.Size() != other.Size() {\n\t\treturn false\n\t}\n\tfor elem := range s {\n\t\tif !other.Contains(elem) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// ContainAll 判断是否包含所有元素\nfunc (s Set[T]) ContainAll(elems ...T) bool {\n\tif s == nil {\n\t\treturn false\n\t}\n\tvar exists bool\n\tfor _, elem := range elems {\n\t\tif _, exists = s[elem]; !exists {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// Remove 移除元素\nfunc (s Set[T]) Remove(elem T) bool {\n\tif s == nil {\n\t\treturn false\n\t}\n\tif _, exists := s[elem]; exists {\n\t\tdelete(s, elem)\n\t\treturn true\n\t}\n\treturn false\n}\n\n// RemoveN 移除多个元素\nfunc (s Set[T]) RemoveN(elems ...T) int {\n\tif s == nil {\n\t\treturn 0\n\t}\n\tvar exists bool\n\tremoveCnt := 0\n\tfor _, elem := range elems {\n\t\tif _, exists = s[elem]; exists {\n\t\t\tdelete(s, elem)\n\t\t\tremoveCnt += 1\n\t\t}\n\t}\n\treturn removeCnt\n}\n\n// RemoveSub 移除子集\nfunc (s Set[T]) RemoveSub(subSet Set[T]) int {\n\tif s == nil {\n\t\treturn 0\n\t}\n\tvar exists bool\n\tremoveCnt := 0\n\tfor elem := range subSet {\n\t\tif _, exists = s[elem]; exists {\n\t\t\tdelete(s, elem)\n\t\t\tremoveCnt += 1\n\t\t}\n\t}\n\treturn removeCnt\n}\n\n// Filter 根据条件筛出符合的元素\nfunc (s Set[T]) Filter(filterFunc func(i T) bool) []T {\n\tret := []T{}\n\tfor v := range s {\n\t\tif filterFunc(v) {\n\t\t\tret = append(ret, v)\n\t\t}\n\t}\n\treturn ret\n}\n\n// Size 集合长度\nfunc (s Set[T]) Size() int {\n\treturn len(s)\n}\n\n// IsEmpty 判断是否为空\nfunc (s Set[T]) IsEmpty() bool {\n\treturn len(s) <= 0\n}\n\n// Clear 清空集合\nfunc (s Set[T]) Clear() {\n\tfor elem := range s {\n\t\tdelete(s, elem)\n\t}\n}\n\n// ToSlice 转为切片\nfunc (s Set[T]) ToSlice() []T {\n\tsize := len(s)\n\tif size <= 0 {\n\t\treturn []T{}\n\t}\n\n\tret := make([]T, 0, size)\n\tfor elem := range s {\n\t\tret = append(ret, elem)\n\t}\n\treturn ret\n}\n\n// ToSortedSlice 转为排序好的切片\nfunc (s Set[T]) ToSortedSlice(sortFunc func(v1, v2 T) bool) []T {\n\tlist := s.ToSlice()\n\tsort.Slice(list, func(i, j int) bool {\n\t\treturn sortFunc(list[i], list[j])\n\t})\n\treturn list\n}\n\n// Each 遍历检索每个元素\nfunc (s Set[T]) Each(eachFunc func(T)) {\n\tif len(s) <= 0 {\n\t\treturn\n\t}\n\tfor elem := range s {\n\t\teachFunc(elem)\n\t}\n}\n\n// Clone 克隆\nfunc (s Set[T]) Clone() Set[T] {\n\tif s == nil {\n\t\treturn nil\n\t}\n\n\tother := NewSet[T]()\n\tfor elem := range s {\n\t\tother[elem] = Void{}\n\t}\n\treturn other\n}\n\nfunc (s Set[T]) String() string {\n\tarr := s.ToSlice()\n\treturn fmt.Sprintf(\"%v\", arr)\n}\n\n// MarshalJSON to output non base64 encoded []byte\nfunc (s Set[T]) MarshalJSON() ([]byte, error) {\n\tif s == nil {\n\t\treturn []byte(\"null\"), nil\n\t}\n\tt := s.ToSlice()\n\treturn json.Marshal(t)\n}\n\n// UnmarshalJSON to deserialize []byte\nfunc (s *Set[T]) UnmarshalJSON(b []byte) error {\n\tt := []T{}\n\terr := json.Unmarshal(b, &t)\n\tif err != nil {\n\t\t*s = NewSet[T]()\n\t} else {\n\t\t*s = NewSet[T](t...)\n\t}\n\treturn nil\n}\n\n// GormDataType gorm common data type\nfunc (s Set[T]) GormDataType() string {\n\treturn \"json\"\n}\n"
  },
  {
    "path": "backend/utils/constraints.go",
    "content": "package utils\n\ntype Hashable interface {\n\t~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr | ~float32 | ~float64 | ~string\n}\n\ntype SignedNumber interface {\n\t~int | ~int8 | ~int16 | ~int32 | ~int64 | ~float32 | ~float64\n}\n\ntype UnsignedNumber interface {\n\t~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64\n}\n"
  },
  {
    "path": "backend/utils/convert/base64_convert.go",
    "content": "package convutil\n\nimport (\n\t\"encoding/base64\"\n\tstrutil \"tinyrdm/backend/utils/string\"\n)\n\ntype Base64Convert struct{}\n\nfunc (Base64Convert) Enable() bool {\n\treturn true\n}\n\nfunc (Base64Convert) Encode(str string) (string, bool) {\n\treturn base64.StdEncoding.EncodeToString([]byte(str)), true\n}\n\nfunc (Base64Convert) Decode(str string) (string, bool) {\n\tif decodedStr, err := base64.StdEncoding.DecodeString(str); err == nil {\n\t\tif s := string(decodedStr); !strutil.ContainsBinary(s) {\n\t\t\treturn s, true\n\t\t}\n\t}\n\treturn str, false\n}\n"
  },
  {
    "path": "backend/utils/convert/binary_convert.go",
    "content": "package convutil\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n)\n\ntype BinaryConvert struct{}\n\nfunc (BinaryConvert) Enable() bool {\n\treturn true\n}\n\nfunc (BinaryConvert) Encode(str string) (string, bool) {\n\ttotal := len(str)\n\tif total%8 != 0 {\n\t\treturn str, false\n\t}\n\tvar result strings.Builder\n\tfor i := 0; i < total; i += 8 {\n\t\tb, err := strconv.ParseUint(str[i:i+8], 2, 8)\n\t\tif err != nil {\n\t\t\treturn str, false\n\t\t}\n\t\tresult.WriteByte(byte(b))\n\t}\n\treturn result.String(), true\n}\n\nfunc (BinaryConvert) Decode(str string) (string, bool) {\n\tvar binary strings.Builder\n\tfor _, char := range []byte(str) {\n\t\tbinary.WriteString(fmt.Sprintf(\"%08b\", int(char)))\n\t}\n\treturn binary.String(), true\n}\n"
  },
  {
    "path": "backend/utils/convert/bitset_convert.go",
    "content": "package convutil\n\nimport (\n\t\"fmt\"\n\t\"math\"\n\t\"strconv\"\n\t\"strings\"\n)\n\ntype BitSetConvert struct{}\n\nfunc (BitSetConvert) Enable() bool {\n\treturn true\n}\n\nfunc (BitSetConvert) Encode(str string) (string, bool) {\n\tvar result strings.Builder\n\n\tstr = strings.ReplaceAll(str, \"\\r\\n\", \"\\n\") // CRLF → LF\n\tstr = strings.ReplaceAll(str, \"\\r\", \"\\n\")   // CR → LF\n\n\tlines := strings.Split(str, \"\\n\")\n\tbytes := encodeToRedisBitset(lines)\n\tresult.Write(bytes)\n\n\treturn result.String(), true\n}\n\nfunc (BitSetConvert) Decode(str string) (string, bool) {\n\tbitset := getBitSet([]byte(str))\n\n\tvar binBuilder strings.Builder\n\tfor pos, value := range bitset {\n\t\tif value {\n\t\t\tif binBuilder.Len() > 0 {\n\t\t\t\tbinBuilder.WriteByte('\\n')\n\t\t\t}\n\t\t\tbinBuilder.WriteString(fmt.Sprintf(\"%d\", pos))\n\t\t}\n\t}\n\n\treturn binBuilder.String(), true\n}\n\n// encodeToRedisBitset encodes a list of strings with integers (positions) into a Redis bitset byte array.\n// The bit at position 'n' will be set to 1 if n is in the input array.\n// The resulting byte slice can be stored in Redis using SET command.\nfunc encodeToRedisBitset(numbers []string) []byte {\n\tif len(numbers) == 0 {\n\t\treturn []byte{}\n\t}\n\n\t// Find the maximum number to determine the required bit length and convert strings to numbers\n\tmaxNum := uint64(0)\n\tvar validNumbers []uint64\n\tfor _, s := range numbers {\n\t\tif s == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tnum, err := strconv.ParseUint(s, 10, 64)\n\t\tif err != nil || num < 0 || num > math.MaxUint32 {\n\t\t\tfmt.Printf(\"Warning: skipping invalid number '%s': %v\\n\", s, err)\n\t\t\tcontinue\n\t\t}\n\t\tvalidNumbers = append(validNumbers, num)\n\t\tif num > maxNum {\n\t\t\tmaxNum = num\n\t\t}\n\t}\n\n\tif len(validNumbers) == 0 {\n\t\treturn []byte{}\n\t}\n\n\t// Calculate required byte length (8 bits per byte)\n\tbyteLen := ((maxNum + 7) / 8) + 1\n\n\t// Initialize byte array\n\tbitset := make([]byte, byteLen)\n\n\t// Set bits for each number\n\tfor _, num := range validNumbers {\n\t\tbyteIndex := num / 8\n\t\tif byteIndex < byteLen {\n\t\t\tbitIndex := uint(num % 8)\n\t\t\t// Set the bit (big-endian bit order within byte)\n\t\t\tbitset[byteIndex] |= 1 << (7 - bitIndex)\n\t\t}\n\t}\n\n\treturn bitset\n}\n\nfunc getBitSet(redisResponse []byte) []bool {\n\tbitset := make([]bool, len(redisResponse)*8)\n\tfor i := range redisResponse {\n\t\tfor j := 7; j >= 0; j-- {\n\t\t\tbitPos := uint(i*8 + (7 - j))\n\t\t\tbitset[bitPos] = (redisResponse[i] & (1 << uint(j))) > 0\n\t\t}\n\t}\n\treturn bitset\n}\n"
  },
  {
    "path": "backend/utils/convert/brotli_convert.go",
    "content": "package convutil\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"strings\"\n\n\t\"github.com/andybalholm/brotli\"\n)\n\ntype BrotliConvert struct{}\n\nfunc (BrotliConvert) Enable() bool {\n\treturn true\n}\n\nfunc (BrotliConvert) Encode(str string) (string, bool) {\n\tvar compress = func(b []byte) (string, error) {\n\t\tvar buf bytes.Buffer\n\t\twriter := brotli.NewWriter(&buf)\n\t\tif _, err := writer.Write([]byte(str)); err != nil {\n\t\t\twriter.Close()\n\t\t\treturn \"\", err\n\t\t}\n\t\twriter.Close()\n\t\treturn string(buf.Bytes()), nil\n\t}\n\tif brotliStr, err := compress([]byte(str)); err == nil {\n\t\treturn brotliStr, true\n\t}\n\treturn str, false\n}\n\nfunc (BrotliConvert) Decode(str string) (string, bool) {\n\treader := brotli.NewReader(strings.NewReader(str))\n\tif decompressed, err := io.ReadAll(reader); err == nil {\n\t\treturn string(decompressed), true\n\t}\n\treturn str, false\n}\n"
  },
  {
    "path": "backend/utils/convert/cmd_convert.go",
    "content": "package convutil\n\nimport (\n\t\"encoding/base64\"\n\t\"strings\"\n\tsliceutil \"tinyrdm/backend/utils/slice\"\n)\n\ntype CmdConvert struct {\n\tName       string\n\tAuto       bool\n\tDecodePath string\n\tDecodeArgs []string\n\tEncodePath string\n\tEncodeArgs []string\n}\n\nconst replaceholder = \"{VALUE}\"\n\nfunc (c CmdConvert) Enable() bool {\n\treturn true\n}\n\nfunc (c CmdConvert) Encode(str string) (string, bool) {\n\tbase64Content := base64.StdEncoding.EncodeToString([]byte(str))\n\tvar containHolder bool\n\targs := sliceutil.Map(c.EncodeArgs, func(i int) string {\n\t\targ := strings.TrimSpace(c.EncodeArgs[i])\n\t\tif strings.Contains(arg, replaceholder) {\n\t\t\targ = strings.ReplaceAll(arg, replaceholder, base64Content)\n\t\t\tcontainHolder = true\n\t\t}\n\t\treturn arg\n\t})\n\tif len(args) <= 0 || !containHolder {\n\t\targs = append(args, base64Content)\n\t}\n\toutput, err := runCommand(c.EncodePath, args...)\n\tif err != nil || len(output) <= 0 || string(output) == \"[RDM-ERROR]\" {\n\t\treturn str, false\n\t}\n\n\toutputContent := make([]byte, base64.StdEncoding.DecodedLen(len(output)))\n\tn, err := base64.StdEncoding.Decode(outputContent, output)\n\tif err != nil {\n\t\treturn str, false\n\t}\n\treturn string(outputContent[:n]), true\n}\n\nfunc (c CmdConvert) Decode(str string) (string, bool) {\n\tbase64Content := base64.StdEncoding.EncodeToString([]byte(str))\n\tvar containHolder bool\n\targs := sliceutil.Map(c.DecodeArgs, func(i int) string {\n\t\targ := strings.TrimSpace(c.DecodeArgs[i])\n\t\tif strings.Contains(arg, replaceholder) {\n\t\t\targ = strings.ReplaceAll(arg, replaceholder, base64Content)\n\t\t\tcontainHolder = true\n\t\t}\n\t\treturn arg\n\t})\n\tif len(args) <= 0 || !containHolder {\n\t\targs = append(args, base64Content)\n\t}\n\toutput, err := runCommand(c.DecodePath, args...)\n\tif err != nil || len(output) <= 0 || string(output) == \"[RDM-ERROR]\" {\n\t\treturn str, false\n\t}\n\n\toutputContent := make([]byte, base64.StdEncoding.DecodedLen(len(output)))\n\tn, err := base64.StdEncoding.Decode(outputContent, output)\n\tif err != nil {\n\t\treturn str, false\n\t}\n\treturn string(outputContent[:n]), true\n}\n"
  },
  {
    "path": "backend/utils/convert/common.go",
    "content": "package convutil\n\nimport (\n\t\"os\"\n\t\"path\"\n\t\"tinyrdm/backend/consts\"\n\n\t\"github.com/vrischmann/userdir\"\n)\n\nfunc writeExecuteFile(content []byte, filename string) (string, error) {\n\tfilepath := path.Join(userdir.GetConfigHome(), consts.APP_DATA_FOLDER, \"decoder\", filename)\n\t_ = os.Mkdir(path.Dir(filepath), 0777)\n\terr := os.WriteFile(filepath, content, 0777)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn filepath, nil\n}\n"
  },
  {
    "path": "backend/utils/convert/common_nonwindows.go",
    "content": "//go:build !windows\n\npackage convutil\n\nimport (\n\t\"os/exec\"\n)\n\nfunc runCommand(name string, arg ...string) ([]byte, error) {\n\tcmd := exec.Command(name, arg...)\n\treturn cmd.Output()\n}\n"
  },
  {
    "path": "backend/utils/convert/common_windows.go",
    "content": "//go:build windows\n\npackage convutil\n\nimport (\n\t\"os/exec\"\n\t\"syscall\"\n)\n\nfunc runCommand(name string, arg ...string) ([]byte, error) {\n\tcmd := exec.Command(name, arg...)\n\tcmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}\n\treturn cmd.Output()\n}\n"
  },
  {
    "path": "backend/utils/convert/convert.go",
    "content": "package convutil\n\nimport (\n\t\"errors\"\n\t\"regexp\"\n\t\"tinyrdm/backend/types\"\n\tstrutil \"tinyrdm/backend/utils/string\"\n)\n\ntype DataConvert interface {\n\tEnable() bool\n\tEncode(string) (string, bool)\n\tDecode(string) (string, bool)\n}\n\nvar (\n\tjsonConv    JsonConvert\n\tuniJsonConv UnicodeJsonConvert\n\tyamlConv    YamlConvert\n\txmlConv     XmlConvert\n\tbase64Conv  Base64Convert\n\tbinaryConv  BinaryConvert\n\tbitSetConv  BitSetConvert\n\thexConv     HexConvert\n\tgzipConv    GZipConvert\n\tdeflateConv DeflateConvert\n\tzstdConv    ZStdConvert\n\tlz4Conv     LZ4Convert\n\tbrotliConv  BrotliConvert\n\tmsgpackConv MsgpackConvert\n\tphpConv     = NewPhpConvert()\n\tpickleConv  = NewPickleConvert()\n)\n\nvar BuildInFormatters = map[string]DataConvert{\n\ttypes.FORMAT_JSON:         jsonConv,\n\ttypes.FORMAT_UNICODE_JSON: uniJsonConv,\n\ttypes.FORMAT_YAML:         yamlConv,\n\ttypes.FORMAT_XML:          xmlConv,\n\ttypes.FORMAT_HEX:          hexConv,\n\ttypes.FORMAT_BINARY:       binaryConv,\n\ttypes.FORMAT_BITSET:       bitSetConv,\n}\n\nvar BuildInDecoders = map[string]DataConvert{\n\ttypes.DECODE_BASE64:  base64Conv,\n\ttypes.DECODE_GZIP:    gzipConv,\n\ttypes.DECODE_DEFLATE: deflateConv,\n\ttypes.DECODE_ZSTD:    zstdConv,\n\ttypes.DECODE_LZ4:     lz4Conv,\n\ttypes.DECODE_BROTLI:  brotliConv,\n\ttypes.DECODE_MSGPACK: msgpackConv,\n\ttypes.DECODE_PHP:     phpConv,\n\ttypes.DECODE_PICKLE:  pickleConv,\n}\n\n// ConvertTo convert string to specified type\n// @param decodeType empty string indicates automatic detection\n// @param formatType empty string indicates automatic detection\n// @param custom decoder if any\nfunc ConvertTo(str, decodeType, formatType string, customDecoder []CmdConvert) (value, resultDecode, resultFormat string) {\n\tif len(str) <= 0 {\n\t\t// empty content\n\t\tif len(formatType) <= 0 {\n\t\t\tresultFormat = types.FORMAT_RAW\n\t\t} else {\n\t\t\tresultFormat = formatType\n\t\t}\n\t\tif len(decodeType) <= 0 {\n\t\t\tresultDecode = types.DECODE_NONE\n\t\t} else {\n\t\t\tresultDecode = decodeType\n\t\t}\n\t\treturn\n\t}\n\n\t// decode first\n\tvalue, resultDecode = decodeWith(str, decodeType, customDecoder)\n\t// then format content\n\tif len(formatType) <= 0 {\n\t\tvalue, resultFormat = autoViewAs(value)\n\t} else {\n\t\tvalue, resultFormat = viewAs(value, formatType)\n\t}\n\treturn\n}\n\nfunc decodeWith(str, decodeType string, customDecoder []CmdConvert) (value, resultDecode string) {\n\tif len(decodeType) > 0 {\n\t\tvalue = str\n\n\t\tif buildinDecoder, ok := BuildInDecoders[decodeType]; ok {\n\t\t\tif decodedStr, ok := buildinDecoder.Decode(str); ok {\n\t\t\t\tvalue = decodedStr\n\t\t\t}\n\t\t} else if decodeType != types.DECODE_NONE {\n\t\t\tfor _, decoder := range customDecoder {\n\t\t\t\tif decoder.Name == decodeType {\n\t\t\t\t\tif decodedStr, ok := decoder.Decode(str); ok {\n\t\t\t\t\t\tvalue = decodedStr\n\t\t\t\t\t}\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tresultDecode = decodeType\n\t\treturn\n\t}\n\n\tvalue, resultDecode = autoDecode(str, customDecoder)\n\treturn\n}\n\n// attempt try possible decode method\n// if no decode is possible, it will return the origin string value and \"none\" decode type\nfunc autoDecode(str string, customDecoder []CmdConvert) (value, resultDecode string) {\n\tif len(str) > 0 {\n\t\t// pure digit content may incorrect regard as some encoded type, skip decode\n\t\tif match, _ := regexp.MatchString(`^\\d+$`, str); !match {\n\t\t\tvar ok bool\n\t\t\tif len(str)%4 == 0 && len(str) >= 12 && !strutil.IsSameChar(str) {\n\t\t\t\tif value, ok = base64Conv.Decode(str); ok {\n\t\t\t\t\tresultDecode = types.DECODE_BASE64\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif value, ok = gzipConv.Decode(str); ok {\n\t\t\t\tresultDecode = types.DECODE_GZIP\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// FIXME: skip decompress with deflate due to incorrect format checking\n\t\t\t//if value, ok = decodeDeflate(str); ok {\n\t\t\t//\tresultDecode = types.DECODE_DEFLATE\n\t\t\t//\treturn\n\t\t\t//}\n\n\t\t\tif value, ok = zstdConv.Decode(str); ok {\n\t\t\t\tresultDecode = types.DECODE_ZSTD\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif value, ok = lz4Conv.Decode(str); ok {\n\t\t\t\tresultDecode = types.DECODE_LZ4\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// FIXME: skip decompress with brotli due to incorrect format checking\n\t\t\t//if value, ok = decodeBrotli(str); ok {\n\t\t\t//\tresultDecode = types.DECODE_BROTLI\n\t\t\t//\treturn\n\t\t\t//}\n\n\t\t\tif value, ok = msgpackConv.Decode(str); ok {\n\t\t\t\tresultDecode = types.DECODE_MSGPACK\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif value, ok = phpConv.Decode(str); ok {\n\t\t\t\tresultDecode = types.DECODE_PHP\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif value, ok = pickleConv.Decode(str); ok {\n\t\t\t\tresultDecode = types.DECODE_PICKLE\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// try decode with custom decoder\n\t\t\tfor _, decoder := range customDecoder {\n\t\t\t\tif decoder.Auto {\n\t\t\t\t\tif value, ok = decoder.Decode(str); ok {\n\t\t\t\t\t\tresultDecode = decoder.Name\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tvalue = str\n\tresultDecode = types.DECODE_NONE\n\treturn\n}\n\nfunc viewAs(str, formatType string) (value, resultFormat string) {\n\tif len(formatType) > 0 {\n\t\tvalue = str\n\t\tif buildinFormatter, ok := BuildInFormatters[formatType]; ok {\n\t\t\tif formattedStr, ok := buildinFormatter.Decode(str); ok {\n\t\t\t\tvalue = formattedStr\n\t\t\t}\n\t\t}\n\t\tresultFormat = formatType\n\t\treturn\n\t}\n\treturn\n}\n\n// attempt automatic convert to possible types\n// if no conversion is possible, it will return the origin string value and \"plain text\" type\nfunc autoViewAs(str string) (value, resultFormat string) {\n\tif len(str) > 0 {\n\t\tvar ok bool\n\t\tif value, ok = jsonConv.Decode(str); ok {\n\t\t\tresultFormat = types.FORMAT_JSON\n\t\t\treturn\n\t\t}\n\n\t\tif value, ok = yamlConv.Decode(str); ok {\n\t\t\tresultFormat = types.FORMAT_YAML\n\t\t\treturn\n\t\t}\n\n\t\tif value, ok = xmlConv.Decode(str); ok {\n\t\t\tresultFormat = types.FORMAT_XML\n\t\t\treturn\n\t\t}\n\n\t\tif strutil.ContainsBinary(str) {\n\t\t\tif value, ok = hexConv.Decode(str); ok {\n\t\t\t\tresultFormat = types.FORMAT_HEX\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n\n\tvalue = str\n\tresultFormat = types.FORMAT_RAW\n\treturn\n}\n\nfunc SaveAs(str, format, decode string, customDecoder []CmdConvert) (value string, err error) {\n\tvalue = str\n\tif buildingFormatter, ok := BuildInFormatters[format]; ok {\n\t\tif formattedStr, ok := buildingFormatter.Encode(str); ok {\n\t\t\tvalue = formattedStr\n\t\t} else {\n\t\t\terr = errors.New(\"invalid \" + format + \" data\")\n\t\t\treturn\n\t\t}\n\t}\n\n\tif buildinDecoder, ok := BuildInDecoders[decode]; ok {\n\t\tif encodedValue, ok := buildinDecoder.Encode(str); ok {\n\t\t\tvalue = encodedValue\n\t\t} else {\n\t\t\terr = errors.New(\"fail to build \" + decode)\n\t\t}\n\t\treturn\n\t} else if decode != types.DECODE_NONE {\n\t\tfor _, decoder := range customDecoder {\n\t\t\tif decoder.Name == decode {\n\t\t\t\tif encodedStr, ok := decoder.Encode(str); ok {\n\t\t\t\t\tvalue = encodedStr\n\t\t\t\t} else {\n\t\t\t\t\terr = errors.New(\"fail to build \" + decode)\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n\treturn\n}\n"
  },
  {
    "path": "backend/utils/convert/deflate_convert.go",
    "content": "package convutil\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"strings\"\n\n\t\"github.com/klauspost/compress/flate\"\n)\n\ntype DeflateConvert struct{}\n\nfunc (d DeflateConvert) Enable() bool {\n\treturn true\n}\n\nfunc (d DeflateConvert) Encode(str string) (string, bool) {\n\tvar compress = func(b []byte) (string, error) {\n\t\tvar buf bytes.Buffer\n\t\twriter, err := flate.NewWriter(&buf, flate.DefaultCompression)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tif _, err = writer.Write([]byte(str)); err != nil {\n\t\t\twriter.Close()\n\t\t\treturn \"\", err\n\t\t}\n\t\twriter.Close()\n\t\treturn string(buf.Bytes()), nil\n\t}\n\tif deflateStr, err := compress([]byte(str)); err == nil {\n\t\treturn deflateStr, true\n\t}\n\treturn str, false\n}\n\nfunc (d DeflateConvert) Decode(str string) (string, bool) {\n\treader := flate.NewReader(strings.NewReader(str))\n\tdefer reader.Close()\n\tif decompressed, err := io.ReadAll(reader); err == nil {\n\t\treturn string(decompressed), true\n\t}\n\treturn str, false\n}\n"
  },
  {
    "path": "backend/utils/convert/gzip_convert.go",
    "content": "package convutil\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"strings\"\n\n\t\"github.com/klauspost/compress/gzip\"\n)\n\ntype GZipConvert struct{}\n\nfunc (GZipConvert) Enable() bool {\n\treturn true\n}\n\nfunc (GZipConvert) Encode(str string) (string, bool) {\n\tvar compress = func(b []byte) (string, error) {\n\t\tvar buf bytes.Buffer\n\t\twriter := gzip.NewWriter(&buf)\n\t\tif _, err := writer.Write([]byte(str)); err != nil {\n\t\t\twriter.Close()\n\t\t\treturn \"\", err\n\t\t}\n\t\twriter.Close()\n\t\treturn string(buf.Bytes()), nil\n\t}\n\n\tif gzipStr, err := compress([]byte(str)); err == nil {\n\t\treturn gzipStr, true\n\t}\n\treturn str, false\n}\n\nfunc (GZipConvert) Decode(str string) (string, bool) {\n\tif reader, err := gzip.NewReader(strings.NewReader(str)); err == nil {\n\t\tdefer reader.Close()\n\t\tvar decompressed []byte\n\t\tif decompressed, err = io.ReadAll(reader); err == nil {\n\t\t\treturn string(decompressed), true\n\t\t}\n\t}\n\treturn str, false\n}\n"
  },
  {
    "path": "backend/utils/convert/hex_convert.go",
    "content": "package convutil\n\nimport (\n\t\"encoding/hex\"\n\t\"strings\"\n)\n\ntype HexConvert struct{}\n\nfunc (HexConvert) Enable() bool {\n\treturn true\n}\n\nfunc (HexConvert) Encode(str string) (string, bool) {\n\thexStrArr := strings.Split(str, \"\\\\x\")\n\thexStr := strings.Join(hexStrArr, \"\")\n\tif decodeStr, err := hex.DecodeString(hexStr); err == nil {\n\t\treturn string(decodeStr), true\n\t}\n\n\treturn str, false\n}\n\nfunc (HexConvert) Decode(str string) (string, bool) {\n\tdecodeStr := hex.EncodeToString([]byte(str))\n\tdecodeStr = strings.ToUpper(decodeStr)\n\tvar resultStr strings.Builder\n\tfor i := 0; i < len(decodeStr); i += 2 {\n\t\tresultStr.WriteString(\"\\\\x\")\n\t\tresultStr.WriteString(decodeStr[i : i+2])\n\t}\n\treturn resultStr.String(), true\n}\n"
  },
  {
    "path": "backend/utils/convert/json_convert.go",
    "content": "package convutil\n\nimport (\n\t\"strings\"\n\tstrutil \"tinyrdm/backend/utils/string\"\n)\n\ntype JsonConvert struct{}\n\nfunc (JsonConvert) Enable() bool {\n\treturn true\n}\n\nfunc (JsonConvert) Decode(str string) (string, bool) {\n\ttrimedStr := strings.TrimSpace(str)\n\tif (strings.HasPrefix(trimedStr, \"{\") && strings.HasSuffix(trimedStr, \"}\")) ||\n\t\t(strings.HasPrefix(trimedStr, \"[\") && strings.HasSuffix(trimedStr, \"]\")) {\n\t\treturn strutil.JSONBeautify(trimedStr, \"  \"), true\n\t}\n\treturn str, false\n}\n\nfunc (JsonConvert) Encode(str string) (string, bool) {\n\treturn strutil.JSONMinify(str), true\n}\n"
  },
  {
    "path": "backend/utils/convert/lz4_convert.go",
    "content": "package convutil\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\n\t\"github.com/pierrec/lz4/v4\"\n)\n\ntype LZ4Convert struct{}\n\nfunc (LZ4Convert) Enable() bool {\n\treturn true\n}\n\nfunc (LZ4Convert) Encode(str string) (string, bool) {\n\tvar compress = func(b []byte) (string, error) {\n\t\tvar buf bytes.Buffer\n\t\twriter := lz4.NewWriter(&buf)\n\t\tif _, err := writer.Write([]byte(str)); err != nil {\n\t\t\twriter.Close()\n\t\t\treturn \"\", err\n\t\t}\n\t\twriter.Close()\n\t\treturn string(buf.Bytes()), nil\n\t}\n\n\tif gzipStr, err := compress([]byte(str)); err == nil {\n\t\treturn gzipStr, true\n\t}\n\treturn str, false\n}\n\nfunc (LZ4Convert) Decode(str string) (string, bool) {\n\treader := lz4.NewReader(bytes.NewReader([]byte(str)))\n\tif decompressed, err := io.ReadAll(reader); err == nil {\n\t\treturn string(decompressed), true\n\t}\n\treturn str, false\n}\n"
  },
  {
    "path": "backend/utils/convert/msgpack_convert.go",
    "content": "package convutil\n\nimport (\n\t\"encoding/json\"\n\n\t\"github.com/vmihailenco/msgpack/v5\"\n)\n\ntype MsgpackConvert struct{}\n\nfunc (MsgpackConvert) Enable() bool {\n\treturn true\n}\n\nfunc (c MsgpackConvert) Encode(str string) (string, bool) {\n\tvar obj map[string]any\n\tif err := json.Unmarshal([]byte(str), &obj); err == nil {\n\t\tfor k, v := range obj {\n\t\t\tobj[k] = c.TryFloatToInt(v)\n\t\t}\n\t\tif b, err := msgpack.Marshal(obj); err == nil {\n\t\t\treturn string(b), true\n\t\t}\n\t}\n\n\tif b, err := msgpack.Marshal(str); err != nil {\n\t\treturn string(b), true\n\t}\n\n\treturn str, false\n}\n\nfunc (MsgpackConvert) Decode(str string) (string, bool) {\n\tvar decodedStr string\n\tif err := msgpack.Unmarshal([]byte(str), &decodedStr); err == nil {\n\t\treturn decodedStr, true\n\t}\n\n\tvar obj map[string]any\n\tif err := msgpack.Unmarshal([]byte(str), &obj); err == nil {\n\t\tif b, err := json.Marshal(obj); err == nil {\n\t\t\tif len(b) >= 10 {\n\t\t\t\treturn string(b), true\n\t\t\t}\n\t\t}\n\t}\n\n\tvar arr []any\n\tif err := msgpack.Unmarshal([]byte(str), &arr); err == nil {\n\t\tif b, err := json.Marshal(arr); err == nil {\n\t\t\tif len(b) >= 10 {\n\t\t\t\treturn string(b), true\n\t\t\t}\n\t\t}\n\t}\n\n\treturn str, false\n}\n\nfunc (c MsgpackConvert) TryFloatToInt(input any) any {\n\tswitch val := input.(type) {\n\tcase map[string]any:\n\t\tfor k, v := range val {\n\t\t\tval[k] = c.TryFloatToInt(v)\n\t\t}\n\t\treturn val\n\tcase []any:\n\t\tfor i, v := range val {\n\t\t\tval[i] = c.TryFloatToInt(v)\n\t\t}\n\t\treturn val\n\tcase float64:\n\t\tif val == float64(int(val)) {\n\t\t\treturn int(val)\n\t\t}\n\t\treturn val\n\tdefault:\n\t\treturn val\n\t}\n}\n"
  },
  {
    "path": "backend/utils/convert/php_convert.go",
    "content": "package convutil\n\nimport (\n\t\"os/exec\"\n)\n\ntype PhpConvert struct {\n\tCmdConvert\n}\n\nconst phpDecodeCode = `\n<?php\n\n$action = strtolower($argv[1]);\n$content = $argv[2];\n\nif ($action === 'decode') {\n    $decoded = base64_decode($content);\n    if ($decoded !== false) {\n        $obj = unserialize($decoded);\n        if ($obj !== false) {\n            $unserialized = json_encode($obj, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n            if ($unserialized !== false) {\n                echo base64_encode($unserialized);\n                return;\n            }\n        }\n    }\n} elseif ($action === 'encode') {\n    $decoded = base64_decode($content);\n    if ($decoded !== false) {\n        $json = json_decode($decoded, true);\n        if ($json !== false) {\n            $serialized = serialize($json);\n            if ($serialized !== false) {\n                echo base64_encode($serialized);\n                return;\n            }\n        }\n    }\n}\necho '[RDM-ERROR]';\n`\n\nfunc NewPhpConvert() *PhpConvert {\n\tc := CmdConvert{\n\t\tName:       \"PHP\",\n\t\tAuto:       true,\n\t\tDecodePath: \"php\",\n\t\tEncodePath: \"php\",\n\t}\n\n\tvar err error\n\tif _, err = exec.LookPath(c.DecodePath); err != nil {\n\t\treturn nil\n\t}\n\n\tvar filepath string\n\tif filepath, err = writeExecuteFile([]byte(phpDecodeCode), \"php_decoder.php\"); err != nil {\n\t\treturn nil\n\t}\n\tc.DecodeArgs = []string{filepath, \"decode\"}\n\tc.EncodeArgs = []string{filepath, \"encode\"}\n\n\treturn &PhpConvert{\n\t\tCmdConvert: c,\n\t}\n}\n\nfunc (p *PhpConvert) Enable() bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc (p *PhpConvert) Encode(str string) (string, bool) {\n\tif !p.Enable() {\n\t\treturn str, false\n\t}\n\treturn p.CmdConvert.Encode(str)\n}\n\nfunc (p *PhpConvert) Decode(str string) (string, bool) {\n\tif !p.Enable() {\n\t\treturn str, false\n\t}\n\treturn p.CmdConvert.Decode(str)\n}\n"
  },
  {
    "path": "backend/utils/convert/pickle_convert.go",
    "content": "package convutil\n\nimport (\n\t\"os/exec\"\n\t\"runtime\"\n)\n\ntype PickleConvert struct {\n\tCmdConvert\n}\n\nconst pickleDecodeCode = `\nimport base64\nimport json\nimport pickle\nimport sys\nfrom datetime import datetime\n\ndef default_serializer(o):\n    if isinstance(o, datetime):\n        return o.isoformat()\n    return str(o)\n\ndef object_hook(obj):\n    for k, v in obj.items():\n        if isinstance(v, str):\n            try:\n                obj[k] = datetime.fromisoformat(v)\n            except ValueError:\n                pass\n    return obj\n\nif __name__ == \"__main__\":\n    if len(sys.argv) >= 3:\n        action = sys.argv[1].lower()\n        content = sys.argv[2]\n\n        try:\n            if action == 'decode':\n                decoded = base64.b64decode(content)\n                obj = pickle.loads(decoded)\n                unserialized = json.dumps(obj, ensure_ascii=False, default=default_serializer)\n                print(base64.b64encode(unserialized.encode('utf-8')).decode('utf-8'))\n            elif action == 'encode':\n                decoded = base64.b64decode(content)\n                obj = json.loads(decoded, object_hook=object_hook)\n                serialized = pickle.dumps(obj)\n                print(base64.b64encode(serialized).decode('utf-8'))\n        except:\n            print('[RDM-ERROR]')\n    else:\n        print('[RDM-ERROR]')\n\n`\n\nfunc NewPickleConvert() *PickleConvert {\n\tc := CmdConvert{\n\t\tName: \"Pickle\",\n\t\tAuto: true,\n\t}\n\tc.DecodePath, c.EncodePath = \"python3\", \"python3\"\n\tvar err error\n\tif _, err = exec.LookPath(c.DecodePath); err != nil {\n\t\tc.DecodePath, c.EncodePath = \"python\", \"python\"\n\t\tif _, err = exec.LookPath(c.DecodePath); err != nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\t// check if pickle available\n\tif runtime.GOOS == \"darwin\" {\n\t\t// the xcode-select installation prompt may appear on macOS\n\t\t// so check it manually in advance\n\t\tif _, err = exec.LookPath(\"xcode-select\"); err != nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\n\tif _, err = runCommand(c.DecodePath, \"-c\", \"import pickle\"); err != nil {\n\t\treturn nil\n\t}\n\tvar filepath string\n\tif filepath, err = writeExecuteFile([]byte(pickleDecodeCode), \"pickle_decoder.py\"); err != nil {\n\t\treturn nil\n\t}\n\tc.DecodeArgs = []string{filepath, \"decode\"}\n\tc.EncodeArgs = []string{filepath, \"encode\"}\n\n\treturn &PickleConvert{\n\t\tCmdConvert: c,\n\t}\n}\n\nfunc (p *PickleConvert) Enable() bool {\n\tif p == nil {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc (p *PickleConvert) Encode(str string) (string, bool) {\n\tif !p.Enable() {\n\t\treturn str, false\n\t}\n\treturn p.CmdConvert.Encode(str)\n}\n\nfunc (p *PickleConvert) Decode(str string) (string, bool) {\n\tif !p.Enable() {\n\t\treturn str, false\n\t}\n\treturn p.CmdConvert.Decode(str)\n}\n"
  },
  {
    "path": "backend/utils/convert/unicode_json_convert.go",
    "content": "package convutil\n\nimport (\n\t\"bytes\"\n\t\"strconv\"\n\t\"strings\"\n\tstrutil \"tinyrdm/backend/utils/string\"\n\t\"unicode\"\n\t\"unicode/utf16\"\n\t\"unicode/utf8\"\n)\n\ntype UnicodeJsonConvert struct{}\n\nfunc (UnicodeJsonConvert) Enable() bool {\n\treturn true\n}\n\nfunc (UnicodeJsonConvert) Decode(str string) (string, bool) {\n\ttrimedStr := strings.TrimSpace(str)\n\tif (strings.HasPrefix(trimedStr, \"{\") && strings.HasSuffix(trimedStr, \"}\")) ||\n\t\t(strings.HasPrefix(trimedStr, \"[\") && strings.HasSuffix(trimedStr, \"]\")) {\n\t\tresultStr := strutil.JSONBeautify(trimedStr, \"  \")\n\t\tif quoteStr, ok := UnquoteUnicodeJson([]byte(resultStr)); ok {\n\t\t\treturn string(quoteStr), true\n\t\t}\n\t}\n\treturn str, false\n}\n\nfunc (UnicodeJsonConvert) Encode(str string) (string, bool) {\n\treturn strutil.JSONMinify(str), true\n}\n\nfunc UnquoteUnicodeJson(s []byte) ([]byte, bool) {\n\tvar unquoted bytes.Buffer\n\tr := 0\n\tls := len(s)\n\tfor r < ls {\n\t\tc := s[r]\n\t\toffset := 1\n\t\tif c == '\"' {\n\t\t\t// find next '\"'\n\t\t\tfor ; r+offset < ls; offset++ {\n\t\t\t\tif s[r+offset] == '\"' && s[r+offset-1] != '\\\\' {\n\t\t\t\t\toffset += 1\n\t\t\t\t\tif ub, ok := unquoteBytes(s[r : r+offset]); ok {\n\t\t\t\t\t\tunquoted.WriteString(strconv.Quote(string(ub)))\n\t\t\t\t\t} else {\n\t\t\t\t\t\treturn nil, false\n\t\t\t\t\t}\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\t// can not find close '\"' until reach to the end of content\n\t\t\tif r+offset >= ls {\n\t\t\t\treturn nil, false\n\t\t\t}\n\t\t} else {\n\t\t\tunquoted.WriteByte(c)\n\t\t}\n\t\tr += offset\n\t}\n\treturn unquoted.Bytes(), true\n}\n\nfunc getu4(s []byte) rune {\n\tif len(s) < 6 || s[0] != '\\\\' || s[1] != 'u' {\n\t\treturn -1\n\t}\n\tvar r rune\n\tfor _, c := range s[2:6] {\n\t\tswitch {\n\t\tcase '0' <= c && c <= '9':\n\t\t\tc = c - '0'\n\t\tcase 'a' <= c && c <= 'f':\n\t\t\tc = c - 'a' + 10\n\t\tcase 'A' <= c && c <= 'F':\n\t\t\tc = c - 'A' + 10\n\t\tdefault:\n\t\t\treturn -1\n\t\t}\n\t\tr = r*16 + rune(c)\n\t}\n\treturn r\n}\n\nfunc unquoteBytes(s []byte) (t []byte, ok bool) {\n\tif len(s) < 2 || s[0] != '\"' || s[len(s)-1] != '\"' {\n\t\treturn\n\t}\n\ts = s[1 : len(s)-1]\n\n\t// Check for unusual characters. If there are none,\n\t// then no unquoting is needed, so return a slice of the\n\t// original bytes.\n\tr := 0\n\tfor r < len(s) {\n\t\tc := s[r]\n\t\tif c == '\\\\' || c == '\"' || c < ' ' {\n\t\t\tbreak\n\t\t}\n\t\tif c < utf8.RuneSelf {\n\t\t\tr++\n\t\t\tcontinue\n\t\t}\n\t\trr, size := utf8.DecodeRune(s[r:])\n\t\tif rr == utf8.RuneError && size == 1 {\n\t\t\tbreak\n\t\t}\n\t\tr += size\n\t}\n\tif r == len(s) {\n\t\treturn s, true\n\t}\n\n\tb := make([]byte, len(s)+2*utf8.UTFMax)\n\tw := copy(b, s[0:r])\n\tfor r < len(s) {\n\t\t// Out of room? Can only happen if s is full of\n\t\t// malformed UTF-8 and we're replacing each\n\t\t// byte with RuneError.\n\t\tif w >= len(b)-2*utf8.UTFMax {\n\t\t\tnb := make([]byte, (len(b)+utf8.UTFMax)*2)\n\t\t\tcopy(nb, b[0:w])\n\t\t\tb = nb\n\t\t}\n\t\tswitch c := s[r]; {\n\t\tcase c == '\\\\':\n\t\t\tr++\n\t\t\tif r >= len(s) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tswitch s[r] {\n\t\t\tdefault:\n\t\t\t\treturn\n\t\t\tcase '\"', '\\\\', '/', '\\'':\n\t\t\t\tb[w] = s[r]\n\t\t\t\tr++\n\t\t\t\tw++\n\t\t\tcase 'b':\n\t\t\t\tb[w] = '\\b'\n\t\t\t\tr++\n\t\t\t\tw++\n\t\t\tcase 'f':\n\t\t\t\tb[w] = '\\f'\n\t\t\t\tr++\n\t\t\t\tw++\n\t\t\tcase 'n':\n\t\t\t\tb[w] = '\\n'\n\t\t\t\tr++\n\t\t\t\tw++\n\t\t\tcase 'r':\n\t\t\t\tb[w] = '\\r'\n\t\t\t\tr++\n\t\t\t\tw++\n\t\t\tcase 't':\n\t\t\t\tb[w] = '\\t'\n\t\t\t\tr++\n\t\t\t\tw++\n\t\t\tcase 'u':\n\t\t\t\tr--\n\t\t\t\trr := getu4(s[r:])\n\t\t\t\tif rr < 0 {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tr += 6\n\t\t\t\tif utf16.IsSurrogate(rr) {\n\t\t\t\t\trr1 := getu4(s[r:])\n\t\t\t\t\tif dec := utf16.DecodeRune(rr, rr1); dec != unicode.ReplacementChar {\n\t\t\t\t\t\t// A valid pair; consume.\n\t\t\t\t\t\tr += 6\n\t\t\t\t\t\tw += utf8.EncodeRune(b[w:], dec)\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\t// Invalid surrogate; fall back to replacement rune.\n\t\t\t\t\trr = unicode.ReplacementChar\n\t\t\t\t}\n\t\t\t\tw += utf8.EncodeRune(b[w:], rr)\n\t\t\t}\n\n\t\t// Quote, control characters are invalid.\n\t\tcase c == '\"', c < ' ':\n\t\t\treturn\n\n\t\t// ASCII\n\t\tcase c < utf8.RuneSelf:\n\t\t\tb[w] = c\n\t\t\tr++\n\t\t\tw++\n\n\t\t// Coerce to well-formed UTF-8.\n\t\tdefault:\n\t\t\trr, size := utf8.DecodeRune(s[r:])\n\t\t\tr += size\n\t\t\tw += utf8.EncodeRune(b[w:], rr)\n\t\t}\n\t}\n\treturn b[0:w], true\n}\n"
  },
  {
    "path": "backend/utils/convert/xml_convert.go",
    "content": "package convutil\n\nimport (\n\t\"encoding/xml\"\n\t\"strings\"\n)\n\ntype XmlConvert struct{}\n\nfunc (XmlConvert) Enable() bool {\n\treturn true\n}\n\nfunc (XmlConvert) Encode(str string) (string, bool) {\n\treturn str, true\n}\n\nfunc (XmlConvert) Decode(str string) (string, bool) {\n\ttrimedStr := strings.TrimSpace(str)\n\tif !strings.HasPrefix(trimedStr, \"<\") && !strings.HasSuffix(trimedStr, \">\") {\n\t\treturn str, false\n\t}\n\tvar obj any\n\terr := xml.Unmarshal([]byte(trimedStr), &obj)\n\treturn str, err == nil\n}\n"
  },
  {
    "path": "backend/utils/convert/yaml_convert.go",
    "content": "package convutil\n\nimport (\n\t\"gopkg.in/yaml.v3\"\n)\n\ntype YamlConvert struct{}\n\nfunc (YamlConvert) Enable() bool {\n\treturn true\n}\n\nfunc (YamlConvert) Encode(str string) (string, bool) {\n\treturn str, true\n}\n\nfunc (YamlConvert) Decode(str string) (string, bool) {\n\tvar obj map[string]any\n\terr := yaml.Unmarshal([]byte(str), &obj)\n\treturn str, err == nil\n}\n"
  },
  {
    "path": "backend/utils/convert/zstd_convert.go",
    "content": "package convutil\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"strings\"\n\n\t\"github.com/klauspost/compress/zstd\"\n)\n\ntype ZStdConvert struct{}\n\nfunc (ZStdConvert) Enable() bool {\n\treturn true\n}\n\nfunc (ZStdConvert) Encode(str string) (string, bool) {\n\tvar compress = func(b []byte) (string, error) {\n\t\tvar buf bytes.Buffer\n\t\twriter, err := zstd.NewWriter(&buf)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\tif _, err = writer.Write([]byte(str)); err != nil {\n\t\t\twriter.Close()\n\t\t\treturn \"\", err\n\t\t}\n\t\twriter.Close()\n\t\treturn string(buf.Bytes()), nil\n\t}\n\tif zstdStr, err := compress([]byte(str)); err == nil {\n\t\treturn zstdStr, true\n\t}\n\treturn str, false\n}\n\nfunc (ZStdConvert) Decode(str string) (string, bool) {\n\tif reader, err := zstd.NewReader(strings.NewReader(str)); err == nil {\n\t\tdefer reader.Close()\n\t\tif decompressed, err := io.ReadAll(reader); err == nil {\n\t\t\treturn string(decompressed), true\n\t\t}\n\t}\n\treturn str, false\n}\n"
  },
  {
    "path": "backend/utils/map/map_util.go",
    "content": "package maputil\n\nimport (\n\t. \"tinyrdm/backend/utils\"\n\t\"tinyrdm/backend/utils/coll\"\n)\n\n// Get 获取键值对指定键的值, 如果不存在则返回自定默认值\nfunc Get[M ~map[K]V, K Hashable, V any](m M, key K, defaultVal V) V {\n\tif m != nil {\n\t\tif v, exists := m[key]; exists {\n\t\t\treturn v\n\t\t}\n\t}\n\treturn defaultVal\n}\n\n// ContainsKey 判断指定键是否存在\nfunc ContainsKey[M ~map[K]V, K Hashable, V any](m M, key K) bool {\n\tif m == nil {\n\t\treturn false\n\t}\n\t_, exists := m[key]\n\treturn exists\n}\n\n// MustGet 获取键值对指定键的值, 如果不存在则调用给定的函数进行获取\nfunc MustGet[M ~map[K]V, K Hashable, V any](m M, key K, getFunc func(K) V) V {\n\tif v, exists := m[key]; exists {\n\t\treturn v\n\t}\n\tif getFunc != nil {\n\t\treturn getFunc(key)\n\t}\n\tvar defaultV V\n\treturn defaultV\n}\n\n// Keys 获取键值对中所有键\nfunc Keys[M ~map[K]V, K Hashable, V any](m M) []K {\n\tif len(m) <= 0 {\n\t\treturn []K{}\n\t}\n\tkeys := make([]K, len(m))\n\tindex := 0\n\tfor k := range m {\n\t\tkeys[index] = k\n\t\tindex += 1\n\t}\n\treturn keys\n}\n\n// KeySet 获取键值对中所有键集合\nfunc KeySet[M ~map[K]V, K Hashable, V any](m M) coll.Set[K] {\n\tif len(m) <= 0 {\n\t\treturn coll.NewSet[K]()\n\t}\n\tkeySet := coll.NewSet[K]()\n\tfor k := range m {\n\t\tkeySet.Add(k)\n\t}\n\treturn keySet\n}\n\n// Values 获取键值对中所有值\nfunc Values[M ~map[K]V, K Hashable, V any](m M) []V {\n\tif len(m) <= 0 {\n\t\treturn []V{}\n\t}\n\tvalues := make([]V, len(m))\n\tindex := 0\n\tfor _, v := range m {\n\t\tvalues[index] = v\n\t\tindex += 1\n\t}\n\treturn values\n}\n\n// ValueSet 获取键值对中所有值集合\nfunc ValueSet[M ~map[K]V, K Hashable, V Hashable](m M) coll.Set[V] {\n\tif len(m) <= 0 {\n\t\treturn coll.NewSet[V]()\n\t}\n\tvalueSet := coll.NewSet[V]()\n\tfor _, v := range m {\n\t\tvalueSet.Add(v)\n\t}\n\treturn valueSet\n}\n\n// Fill 填充键值对\nfunc Fill[M ~map[K]V, K Hashable, V any](dest M, src M) M {\n\tfor k, v := range src {\n\t\tdest[k] = v\n\t}\n\treturn dest\n}\n\n// Merge 合并键值对, 后续键值对有重复键的元素会覆盖旧元素\nfunc Merge[M ~map[K]V, K Hashable, V any](mapArr ...M) M {\n\tresult := make(M, len(mapArr))\n\tfor _, m := range mapArr {\n\t\tfor k, v := range m {\n\t\t\tresult[k] = v\n\t\t}\n\t}\n\treturn result\n}\n\n// Omit 根据条件省略指定元素\nfunc Omit[M ~map[K]V, K Hashable, V any](m M, omitFunc func(k K, v V) bool) (M, []K) {\n\tresult := M{}\n\tvar removedKeys []K\n\tfor k, v := range m {\n\t\tif !omitFunc(k, v) {\n\t\t\tresult[k] = v\n\t\t} else {\n\t\t\tremovedKeys = append(removedKeys, k)\n\t\t}\n\t}\n\treturn result, removedKeys\n}\n\n// OmitKeys 省略指定键的的元素\nfunc OmitKeys[M ~map[K]V, K Hashable, V any](m M, keys ...K) M {\n\tomitKey := map[K]struct{}{}\n\tfor _, k := range keys {\n\t\tomitKey[k] = struct{}{}\n\t}\n\n\tresult := M{}\n\tvar exists bool\n\tfor k, v := range m {\n\t\tif _, exists = omitKey[k]; !exists {\n\t\t\tresult[k] = v\n\t\t}\n\t}\n\treturn result\n}\n\n// ContainsAnyKey 是否包含任意键\nfunc ContainsAnyKey[M ~map[K]V, K Hashable, V any](m M, keys ...K) bool {\n\tvar exists bool\n\tfor _, key := range keys {\n\t\tif _, exists = m[key]; exists {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// ContainsAllKey 是否包含所有键\nfunc ContainsAllKey[M ~map[K]V, K Hashable, V any](m M, keys ...K) bool {\n\tvar exists bool\n\tfor _, key := range keys {\n\t\tif _, exists = m[key]; !exists {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// AnyMatch 是否任意元素符合条件\nfunc AnyMatch[M ~map[K]V, K Hashable, V any](m M, matchFunc func(k K, v V) bool) bool {\n\tfor k, v := range m {\n\t\tif matchFunc(k, v) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// AllMatch 是否所有元素符合条件\nfunc AllMatch[M ~map[K]V, K Hashable, V any](m M, matchFunc func(k K, v V) bool) bool {\n\tfor k, v := range m {\n\t\tif !matchFunc(k, v) {\n\t\t\treturn false\n\t\t}\n\t}\n\treturn true\n}\n\n// Reduce 累计\nfunc Reduce[M ~map[K]V, K Hashable, V any, R any](m M, init R, reduceFunc func(R, K, V) R) R {\n\tresult := init\n\tfor k, v := range m {\n\t\tresult = reduceFunc(result, k, v)\n\t}\n\treturn result\n}\n\n// ToSlice 键值对转切片\nfunc ToSlice[M ~map[K]V, K Hashable, V any, R any](m M, mapFunc func(k K) R) []R {\n\tret := make([]R, 0, len(m))\n\tfor k := range m {\n\t\tret = append(ret, mapFunc(k))\n\t}\n\treturn ret\n}\n\n// Filter 筛选出指定条件的所有元素\nfunc Filter[M ~map[K]V, K Hashable, V any](m M, filterFunc func(k K) bool) M {\n\tret := make(M, len(m))\n\tfor k, v := range m {\n\t\tif filterFunc(k) {\n\t\t\tret[k] = v\n\t\t}\n\t}\n\treturn ret\n}\n\n// FilterToSlice 键值对筛选并转切片\nfunc FilterToSlice[M ~map[K]V, K Hashable, V any, R any](m M, mapFunc func(k K) (R, bool)) []R {\n\tret := make([]R, 0, len(m))\n\tfor k := range m {\n\t\tif v, filter := mapFunc(k); filter {\n\t\t\tret = append(ret, v)\n\t\t}\n\t}\n\treturn ret\n}\n\n// FilterKey 筛选出指定条件的所有键\nfunc FilterKey[M ~map[K]V, K Hashable, V any](m M, filterFunc func(k K) bool) []K {\n\tret := make([]K, 0, len(m))\n\tfor k := range m {\n\t\tif filterFunc(k) {\n\t\t\tret = append(ret, k)\n\t\t}\n\t}\n\treturn ret\n}\n\n// Clone 复制键值对\nfunc Clone[M ~map[K]V, K Hashable, V any](src M) M {\n\tdest := make(M, len(src))\n\tfor k, v := range src {\n\t\tdest[k] = v\n\t}\n\treturn dest\n}\n\n// Reverse 键->值映射翻转为值->键映射(如果重复则覆盖最后的)\nfunc Reverse[M ~map[K]V, K Hashable, V Hashable](src M) map[V]K {\n\tdest := make(map[V]K, len(src))\n\tfor k, v := range src {\n\t\tdest[v] = k\n\t}\n\treturn dest\n}\n\n// ReverseAll 键->值映射翻转为值->键列表映射\nfunc ReverseAll[M ~map[K]V, K Hashable, V Hashable](src M) map[V][]K {\n\tdest := make(map[V][]K, len(src))\n\tfor k, v := range src {\n\t\tdest[v] = append(dest[v], k)\n\t}\n\treturn dest\n}\n\n// RemoveIf 移除指定条件的键\nfunc RemoveIf[M ~map[K]V, K Hashable, V any](src M, cond func(key K) bool) {\n\tfor k := range src {\n\t\tif cond(k) {\n\t\t\tdelete(src, k)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "backend/utils/math/math_util.go",
    "content": "package mathutil\n\nimport (\n\t\"math\"\n\t. \"tinyrdm/backend/utils\"\n)\n\n// MaxWithIndex 查找所有元素中的最大值\nfunc MaxWithIndex[T Hashable](items ...T) (T, int) {\n\tselIndex := -1\n\tfor i, t := range items {\n\t\tif selIndex < 0 {\n\t\t\tselIndex = i\n\t\t} else {\n\t\t\tif t > items[selIndex] {\n\t\t\t\tselIndex = i\n\t\t\t}\n\t\t}\n\t}\n\treturn items[selIndex], selIndex\n}\n\n// MinWithIndex 查找所有元素中的最小值\nfunc MinWithIndex[T Hashable](items ...T) (T, int) {\n\tselIndex := -1\n\tfor i, t := range items {\n\t\tif selIndex < 0 {\n\t\t\tselIndex = i\n\t\t} else {\n\t\t\tif t < items[selIndex] {\n\t\t\t\tselIndex = i\n\t\t\t}\n\t\t}\n\t}\n\treturn items[selIndex], selIndex\n}\n\n// Clamp 返回限制在minVal和maxVal范围内的value\nfunc Clamp[T Hashable](value T, minVal T, maxVal T) T {\n\tif minVal > maxVal {\n\t\tminVal, maxVal = maxVal, minVal\n\t}\n\tif value < minVal {\n\t\tvalue = minVal\n\t} else if value > maxVal {\n\t\tvalue = maxVal\n\t}\n\treturn value\n}\n\n// Abs 计算绝对值\nfunc Abs[T SignedNumber](val T) T {\n\treturn T(math.Abs(float64(val)))\n}\n\n// Floor 向下取整\nfunc Floor[T SignedNumber | UnsignedNumber](val T) T {\n\treturn T(math.Floor(float64(val)))\n}\n\n// Ceil 向上取整\nfunc Ceil[T SignedNumber | UnsignedNumber](val T) T {\n\treturn T(math.Ceil(float64(val)))\n}\n\n// Round 四舍五入取整\nfunc Round[T SignedNumber | UnsignedNumber](val T) T {\n\treturn T(math.Round(float64(val)))\n}\n\n// Sum 计算所有元素总和\nfunc Sum[T SignedNumber | UnsignedNumber](items ...T) T {\n\tvar sum T\n\tfor _, item := range items {\n\t\tsum += item\n\t}\n\treturn sum\n}\n\n// Average 计算所有元素的平均值\nfunc Average[T SignedNumber | UnsignedNumber](items ...T) T {\n\treturn Sum(items...) / T(len(items))\n}\n"
  },
  {
    "path": "backend/utils/proxy/http.go",
    "content": "package proxy\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"time\"\n\n\t\"golang.org/x/net/proxy\"\n)\n\ntype HttpProxy struct {\n\tscheme  string       // HTTP Proxy scheme\n\thost    string       // HTTP Proxy host or host:port\n\tauth    *proxy.Auth  // authentication\n\tforward proxy.Dialer // forwarding Dialer\n}\n\nfunc (p *HttpProxy) Dial(network, addr string) (net.Conn, error) {\n\tc, err := p.forward.Dial(network, p.host)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\terr = c.SetDeadline(time.Now().Add(15 * time.Second))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treqUrl := &url.URL{\n\t\tScheme: \"\",\n\t\tHost:   addr,\n\t}\n\n\t// create with CONNECT method\n\treq, err := http.NewRequest(\"CONNECT\", reqUrl.String(), nil)\n\tif err != nil {\n\t\tc.Close()\n\t\treturn nil, err\n\t}\n\treq.Close = false\n\n\t// authentication\n\tif p.auth != nil {\n\t\treq.SetBasicAuth(p.auth.User, p.auth.Password)\n\t\treq.Header.Add(\"Proxy-Authorization\", req.Header.Get(\"Authorization\"))\n\t}\n\n\t// send request\n\terr = req.Write(c)\n\tif err != nil {\n\t\tc.Close()\n\t\treturn nil, err\n\t}\n\n\tres, err := http.ReadResponse(bufio.NewReader(c), req)\n\tif err != nil {\n\t\tres.Body.Close()\n\t\tc.Close()\n\t\treturn nil, err\n\t}\n\tres.Body.Close()\n\n\tif res.StatusCode != http.StatusOK {\n\t\tc.Close()\n\t\treturn nil, fmt.Errorf(\"proxy connection error: StatusCode[%d]\", res.StatusCode)\n\t}\n\n\treturn c, nil\n}\n\nfunc NewHttpProxyDialer(u *url.URL, forward proxy.Dialer) (proxy.Dialer, error) {\n\tvar auth *proxy.Auth\n\tif u.User != nil {\n\t\tpwd, _ := u.User.Password()\n\t\tauth = &proxy.Auth{\n\t\t\tUser:     u.User.Username(),\n\t\t\tPassword: pwd,\n\t\t}\n\t}\n\n\thp := &HttpProxy{\n\t\tscheme:  u.Scheme,\n\t\thost:    u.Host,\n\t\tauth:    auth,\n\t\tforward: forward,\n\t}\n\treturn hp, nil\n}\n\nfunc init() {\n\tproxy.RegisterDialerType(\"http\", NewHttpProxyDialer)\n\tproxy.RegisterDialerType(\"https\", NewHttpProxyDialer)\n}\n"
  },
  {
    "path": "backend/utils/redis/log_hook.go",
    "content": "package redis\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/redis/go-redis/v9\"\n)\n\ntype execCallback func(string, int64)\n\ntype LogHook struct {\n\tname    string\n\tcmdExec execCallback\n}\n\nfunc NewHook(name string, cmdExec execCallback) *LogHook {\n\treturn &LogHook{\n\t\tname:    name,\n\t\tcmdExec: cmdExec,\n\t}\n}\n\nfunc appendArg(b []byte, v interface{}) []byte {\n\tswitch v := v.(type) {\n\tcase nil:\n\t\treturn append(b, \"<nil>\"...)\n\tcase string:\n\t\treturn append(b, []byte(v)...)\n\tcase []byte:\n\t\treturn append(b, v...)\n\tcase int:\n\t\treturn strconv.AppendInt(b, int64(v), 10)\n\tcase int8:\n\t\treturn strconv.AppendInt(b, int64(v), 10)\n\tcase int16:\n\t\treturn strconv.AppendInt(b, int64(v), 10)\n\tcase int32:\n\t\treturn strconv.AppendInt(b, int64(v), 10)\n\tcase int64:\n\t\treturn strconv.AppendInt(b, v, 10)\n\tcase uint:\n\t\treturn strconv.AppendUint(b, uint64(v), 10)\n\tcase uint8:\n\t\treturn strconv.AppendUint(b, uint64(v), 10)\n\tcase uint16:\n\t\treturn strconv.AppendUint(b, uint64(v), 10)\n\tcase uint32:\n\t\treturn strconv.AppendUint(b, uint64(v), 10)\n\tcase uint64:\n\t\treturn strconv.AppendUint(b, v, 10)\n\tcase float32:\n\t\treturn strconv.AppendFloat(b, float64(v), 'f', -1, 64)\n\tcase float64:\n\t\treturn strconv.AppendFloat(b, v, 'f', -1, 64)\n\tcase bool:\n\t\tif v {\n\t\t\treturn append(b, \"true\"...)\n\t\t}\n\t\treturn append(b, \"false\"...)\n\tcase time.Time:\n\t\treturn v.AppendFormat(b, time.RFC3339Nano)\n\tdefault:\n\t\treturn append(b, fmt.Sprint(v)...)\n\t}\n}\n\nfunc (l *LogHook) DialHook(next redis.DialHook) redis.DialHook {\n\treturn func(ctx context.Context, network, addr string) (net.Conn, error) {\n\t\treturn next(ctx, network, addr)\n\t}\n}\n\nfunc (l *LogHook) ProcessHook(next redis.ProcessHook) redis.ProcessHook {\n\treturn func(ctx context.Context, cmd redis.Cmder) error {\n\t\tt := time.Now()\n\t\terr := next(ctx, cmd)\n\t\tb := make([]byte, 0, 64)\n\t\tfor i, arg := range cmd.Args() {\n\t\t\tif i > 0 {\n\t\t\t\tb = append(b, ' ')\n\t\t\t}\n\t\t\tb = appendArg(b, arg)\n\t\t}\n\t\tlog.Println(string(b))\n\t\tif l.cmdExec != nil {\n\t\t\tl.cmdExec(string(b), time.Since(t).Milliseconds())\n\t\t}\n\t\treturn err\n\t}\n}\n\nfunc (l *LogHook) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook {\n\treturn func(ctx context.Context, cmds []redis.Cmder) error {\n\t\tt := time.Now()\n\t\terr := next(ctx, cmds)\n\t\tcost := time.Since(t).Milliseconds()\n\t\tb := make([]byte, 0, 64)\n\t\tfor i, cmd := range cmds {\n\t\t\tlog.Println(\"pipeline: \", cmd)\n\t\t\tif l.cmdExec != nil {\n\t\t\t\tfor i, arg := range cmd.Args() {\n\t\t\t\t\tif i > 0 {\n\t\t\t\t\t\tb = append(b, ' ')\n\t\t\t\t\t}\n\t\t\t\t\tb = appendArg(b, arg)\n\t\t\t\t}\n\t\t\t\tif i != len(cmds) {\n\t\t\t\t\tb = append(b, '\\n')\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif l.cmdExec != nil {\n\t\t\tl.cmdExec(string(b), cost)\n\t\t}\n\t\treturn err\n\t}\n}\n"
  },
  {
    "path": "backend/utils/slice/slice_util.go",
    "content": "package sliceutil\n\nimport (\n\t\"strings\"\n\t. \"tinyrdm/backend/utils\"\n)\n\n// Map map items to new array\nfunc Map[S ~[]T, T any, R any](arr S, mappingFunc func(int) R) []R {\n\ttotal := len(arr)\n\tresult := make([]R, total)\n\tfor i := 0; i < total; i++ {\n\t\tresult[i] = mappingFunc(i)\n\t}\n\treturn result\n}\n\n// FilterMap filter and map items to new array\nfunc FilterMap[S ~[]T, T any, R any](arr S, mappingFunc func(int) (R, bool)) []R {\n\ttotal := len(arr)\n\tresult := make([]R, 0, total)\n\tvar filter bool\n\tvar mapItem R\n\tfor i := 0; i < total; i++ {\n\t\tif mapItem, filter = mappingFunc(i); filter {\n\t\t\tresult = append(result, mapItem)\n\t\t}\n\t}\n\treturn result\n}\n\n// Join join any array to a single string by custom function\nfunc Join[S ~[]T, T any](arr S, sep string, toStringFunc func(int) string) string {\n\ttotal := len(arr)\n\tif total <= 0 {\n\t\treturn \"\"\n\t}\n\tif total == 1 {\n\t\treturn toStringFunc(0)\n\t}\n\n\tsb := strings.Builder{}\n\tfor i := 0; i < total; i++ {\n\t\tif i != 0 {\n\t\t\tsb.WriteString(sep)\n\t\t}\n\t\tsb.WriteString(toStringFunc(i))\n\t}\n\treturn sb.String()\n}\n\n// JoinString join string array to a single string\nfunc JoinString(arr []string, sep string) string {\n\treturn Join(arr, sep, func(idx int) string {\n\t\treturn arr[idx]\n\t})\n}\n\n// Unique filter unique item\nfunc Unique[S ~[]T, T Hashable](arr S) S {\n\tresult := make(S, 0, len(arr))\n\tuniKeys := map[T]struct{}{}\n\tvar exists bool\n\tfor _, item := range arr {\n\t\tif _, exists = uniKeys[item]; !exists {\n\t\t\tuniKeys[item] = struct{}{}\n\t\t\tresult = append(result, item)\n\t\t}\n\t}\n\treturn result\n}\n"
  },
  {
    "path": "backend/utils/string/any_convert.go",
    "content": "package strutil\n\nimport (\n\t\"encoding/json\"\n\t\"strconv\"\n\t\"strings\"\n\tsliceutil \"tinyrdm/backend/utils/slice\"\n)\n\nfunc AnyToString(value interface{}, prefix string, layer int) (s string) {\n\tif value == nil {\n\t\treturn\n\t}\n\n\tswitch value.(type) {\n\tcase float64:\n\t\tft := value.(float64)\n\t\ts = strconv.FormatFloat(ft, 'f', -1, 64)\n\tcase float32:\n\t\tft := value.(float32)\n\t\ts = strconv.FormatFloat(float64(ft), 'f', -1, 64)\n\tcase int:\n\t\tit := value.(int)\n\t\ts = strconv.Itoa(it)\n\tcase uint:\n\t\tit := value.(uint)\n\t\ts = strconv.Itoa(int(it))\n\tcase int8:\n\t\tit := value.(int8)\n\t\ts = strconv.Itoa(int(it))\n\tcase uint8:\n\t\tit := value.(uint8)\n\t\ts = strconv.Itoa(int(it))\n\tcase int16:\n\t\tit := value.(int16)\n\t\ts = strconv.Itoa(int(it))\n\tcase uint16:\n\t\tit := value.(uint16)\n\t\ts = strconv.Itoa(int(it))\n\tcase int32:\n\t\tit := value.(int32)\n\t\ts = strconv.Itoa(int(it))\n\tcase uint32:\n\t\tit := value.(uint32)\n\t\ts = strconv.Itoa(int(it))\n\tcase int64:\n\t\tit := value.(int64)\n\t\ts = strconv.FormatInt(it, 10)\n\tcase uint64:\n\t\tit := value.(uint64)\n\t\ts = strconv.FormatUint(it, 10)\n\tcase string:\n\t\tif layer > 0 {\n\t\t\ts = \"\\\"\" + value.(string) + \"\\\"\"\n\t\t} else {\n\t\t\ts = value.(string)\n\t\t}\n\tcase bool:\n\t\tval, _ := value.(bool)\n\t\tif val {\n\t\t\ts = \"True\"\n\t\t} else {\n\t\t\ts = \"False\"\n\t\t}\n\tcase []byte:\n\t\ts = prefix + string(value.([]byte))\n\tcase []string:\n\t\tss := value.([]string)\n\t\tanyStr := sliceutil.Map(ss, func(i int) string {\n\t\t\tstr := AnyToString(ss[i], prefix, layer+1)\n\t\t\treturn prefix + strconv.Itoa(i+1) + \") \" + str\n\t\t})\n\t\ts = prefix + sliceutil.JoinString(anyStr, \"\\r\\n\")\n\tcase []any:\n\t\tas := value.([]any)\n\t\tanyItems := sliceutil.Map(as, func(i int) string {\n\t\t\tstr := AnyToString(as[i], prefix, layer+1)\n\t\t\treturn prefix + strconv.Itoa(i+1) + \") \" + str\n\t\t})\n\t\ts = sliceutil.JoinString(anyItems, \"\\r\\n\")\n\tcase map[any]any:\n\t\tam := value.(map[any]any)\n\t\tvar items []string\n\t\tindex := 0\n\t\tfor k, v := range am {\n\t\t\tkk := prefix + strconv.Itoa(index+1) + \") \" + AnyToString(k, prefix, layer+1)\n\t\t\tvv := prefix + strconv.Itoa(index+2) + \") \" + AnyToString(v, \"\\t\", layer+1)\n\t\t\tif layer > 0 {\n\t\t\t\tindent := layer\n\t\t\t\tif index == 0 {\n\t\t\t\t\tindent -= 1\n\t\t\t\t}\n\t\t\t\tfor i := 0; i < indent; i++ {\n\t\t\t\t\tvv = \"  \" + vv\n\t\t\t\t}\n\t\t\t}\n\t\t\tindex += 2\n\t\t\titems = append(items, kk, vv)\n\t\t}\n\t\ts = sliceutil.JoinString(items, \"\\r\\n\")\n\tdefault:\n\t\tb, _ := json.Marshal(value)\n\t\ts = prefix + string(b)\n\t}\n\n\treturn\n}\n\n//func AnyToHex(val any) (string, bool) {\n//\tvar src string\n//\tswitch val.(type) {\n//\tcase string:\n//\t\tsrc = val.(string)\n//\tcase []byte:\n//\t\tsrc = string(val.([]byte))\n//\t}\n//\n//\tif len(src) <= 0 {\n//\t\treturn \"\", false\n//\t}\n//\n//\tvar output strings.Builder\n//\tfor i := range src {\n//\t\tif !utf8.ValidString(src[i : i+1]) {\n//\t\t\toutput.WriteString(fmt.Sprintf(\"\\\\x%02x\", src[i:i+1]))\n//\t\t} else {\n//\t\t\toutput.WriteString(src[i : i+1])\n//\t\t}\n//\t}\n//\n//\treturn output.String(), true\n//}\n\nfunc SplitCmd(cmd string) []string {\n\tvar result []string\n\tvar curStr strings.Builder\n\tvar preChar int32\n\tvar quotesChar int32\n\n\tcmdRune := []rune(strings.TrimSpace(cmd))\n\tfor _, char := range cmdRune {\n\t\tif (char == '\"' || char == '\\'') && preChar != '\\\\' && (quotesChar == 0 || quotesChar == char) {\n\t\t\tif quotesChar != 0 {\n\t\t\t\tquotesChar = 0\n\t\t\t} else {\n\t\t\t\tquotesChar = char\n\t\t\t}\n\t\t} else if char == ' ' && quotesChar == 0 {\n\t\t\tresult = append(result, curStr.String())\n\t\t\tcurStr.Reset()\n\t\t} else {\n\t\t\tcurStr.WriteRune(char)\n\t\t}\n\t\tpreChar = char\n\t}\n\tif curStr.Len() > 0 {\n\t\tresult = append(result, curStr.String())\n\t}\n\n\tresult = sliceutil.FilterMap(result, func(i int) (string, bool) {\n\t\tvar part = result[i]\n\t\tif i == 0 && len(part) <= 0 {\n\t\t\treturn \"\", false\n\t\t}\n\t\tif strings.Contains(part, \"\\\\\") {\n\t\t\tif unquotePart, e := strconv.Unquote(`\"` + part + `\"`); e == nil {\n\t\t\t\treturn unquotePart, true\n\t\t\t}\n\t\t}\n\t\treturn part, true\n\t})\n\n\treturn result\n}\n"
  },
  {
    "path": "backend/utils/string/common.go",
    "content": "package strutil\n\nimport (\n\t\"unicode\"\n)\n\nfunc ContainsBinary(str string) bool {\n\t//buf := []byte(str)\n\t//size := 0\n\t//for start := 0; start < len(buf); start += size {\n\t//\tvar r rune\n\t//\tif r, size = utf8.DecodeRune(buf[start:]); r == utf8.RuneError {\n\t//\t\treturn true\n\t//\t}\n\t//}\n\trs := []rune(str)\n\tfor _, r := range rs {\n\t\tif r == unicode.ReplacementChar {\n\t\t\treturn true\n\t\t}\n\t\tif !unicode.IsPrint(r) && !unicode.IsSpace(r) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc IsSameChar(str string) bool {\n\tif len(str) <= 0 {\n\t\treturn false\n\t}\n\n\trs := []rune(str)\n\tfirst := rs[0]\n\tfor _, r := range rs {\n\t\tif r != first {\n\t\t\treturn false\n\t\t}\n\t}\n\n\treturn true\n}\n"
  },
  {
    "path": "backend/utils/string/json_formatter.go",
    "content": "package strutil\n\nimport (\n\t\"strings\"\n\t\"unicode\"\n)\n\n// Convert from https://github.com/ObuchiYuki/SwiftJSONFormatter\n\n// ArrayIterator defines the iterator for an array\ntype ArrayIterator[T any] struct {\n\tarray []T\n\thead  int\n}\n\n// NewArrayIterator initializes a new ArrayIterator with the given array\nfunc NewArrayIterator[T any](array []T) *ArrayIterator[T] {\n\treturn &ArrayIterator[T]{\n\t\tarray: array,\n\t\thead:  -1,\n\t}\n}\n\n// HasNext returns true if there are more elements to iterate over\nfunc (it *ArrayIterator[T]) HasNext() bool {\n\treturn it.head+1 < len(it.array)\n}\n\n// PeekNext returns the next element without advancing the iterator\nfunc (it *ArrayIterator[T]) PeekNext() *T {\n\tif it.head+1 < len(it.array) {\n\t\treturn &it.array[it.head+1]\n\t}\n\treturn nil\n}\n\n// Next returns the next element and advances the iterator\nfunc (it *ArrayIterator[T]) Next() *T {\n\tdefer func() {\n\t\tit.head++\n\t}()\n\treturn it.PeekNext()\n}\n\n// JSONBeautify formats a JSON string with indentation\nfunc JSONBeautify(value string, indent string) string {\n\tif len(indent) <= 0 {\n\t\tindent = \"    \"\n\t}\n\treturn format(value, indent, \"\\n\", \" \")\n}\n\n// JSONMinify formats a JSON string by removing all unnecessary whitespace\nfunc JSONMinify(value string) string {\n\treturn format(value, \"\", \"\", \"\")\n}\n\n// format applies the specified formatting to a JSON string\nfunc format(value string, indent string, newLine string, separator string) string {\n\tvar formatted strings.Builder\n\tchars := NewArrayIterator([]rune(value))\n\tindentLevel := 0\n\n\tfor chars.HasNext() {\n\t\tif char := chars.Next(); char != nil {\n\t\t\tswitch *char {\n\t\t\tcase '{', '[':\n\t\t\t\tformatted.WriteRune(*char)\n\t\t\t\tconsumeWhitespaces(chars)\n\t\t\t\tpeeked := chars.PeekNext()\n\t\t\t\tif peeked != nil && (*peeked == '}' || *peeked == ']') {\n\t\t\t\t\tchars.Next()\n\t\t\t\t\tformatted.WriteRune(*peeked)\n\t\t\t\t} else {\n\t\t\t\t\tindentLevel++\n\t\t\t\t\tformatted.WriteString(newLine)\n\t\t\t\t\tformatted.WriteString(strings.Repeat(indent, indentLevel))\n\t\t\t\t}\n\t\t\tcase '}', ']':\n\t\t\t\tindentLevel--\n\t\t\t\tformatted.WriteString(newLine)\n\t\t\t\tformatted.WriteString(strings.Repeat(indent, max(0, indentLevel)))\n\t\t\t\tformatted.WriteRune(*char)\n\t\t\tcase '\"':\n\t\t\t\tstr := consumeString(chars)\n\t\t\t\t//str = convertUnicodeString(str)\n\t\t\t\tformatted.WriteString(str)\n\t\t\tcase ',':\n\t\t\t\tconsumeWhitespaces(chars)\n\t\t\t\tformatted.WriteRune(',')\n\t\t\t\tpeeked := chars.PeekNext()\n\t\t\t\tif peeked != nil && *peeked != '}' && *peeked != ']' {\n\t\t\t\t\tformatted.WriteString(newLine)\n\t\t\t\t\tformatted.WriteString(strings.Repeat(indent, max(0, indentLevel)))\n\t\t\t\t}\n\t\t\tcase ':':\n\t\t\t\tformatted.WriteString(\":\" + separator)\n\t\t\tdefault:\n\t\t\t\tif !unicode.IsSpace(*char) {\n\t\t\t\t\tformatted.WriteRune(*char)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn formatted.String()\n}\n\n// consumeWhitespaces advances the iterator past any whitespace characters\nfunc consumeWhitespaces(iter *ArrayIterator[rune]) {\n\tfor iter.HasNext() {\n\t\tif peeked := iter.PeekNext(); peeked != nil && unicode.IsSpace(*peeked) {\n\t\t\titer.Next()\n\t\t} else {\n\t\t\tbreak\n\t\t}\n\t}\n}\n\n// consumeString consumes a JSON string value from the iterator\nfunc consumeString(iter *ArrayIterator[rune]) string {\n\tvar sb strings.Builder\n\tsb.WriteRune('\"')\n\tescaping := false\n\n\tfor iter.HasNext() {\n\t\tif char := iter.Next(); char != nil {\n\t\t\tif *char == '\\n' {\n\t\t\t\treturn sb.String() // Unterminated string\n\t\t\t}\n\n\t\t\tsb.WriteRune(*char)\n\n\t\t\tif escaping {\n\t\t\t\tescaping = false\n\t\t\t} else {\n\t\t\t\tif *char == '\\\\' {\n\t\t\t\t\tescaping = true\n\t\t\t\t}\n\t\t\t\tif *char == '\"' {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn sb.String()\n}\n\nfunc convertUnicodeString(str string) string {\n\t// TODO: quote UTF-16 characters\n\t//if len(str) > 2 {\n\t//\tif unqStr, err := strconv.Unquote(str); err == nil {\n\t//\t\treturn strconv.Quote(unqStr)\n\t//\t}\n\t//}\n\treturn str\n}\n"
  },
  {
    "path": "backend/utils/string/key_convert.go",
    "content": "package strutil\n\nimport (\n\t\"strconv\"\n\tsliceutil \"tinyrdm/backend/utils/slice\"\n)\n\n// EncodeRedisKey encode the redis key to integer array\n// if key contains binary which could not display on ui, convert the key to char array\nfunc EncodeRedisKey(key string) any {\n\tif ContainsBinary(key) {\n\t\tb := []byte(key)\n\t\tarr := make([]int, len(b))\n\t\tfor i, bb := range b {\n\t\t\tarr[i] = int(bb)\n\t\t}\n\t\treturn arr\n\t}\n\treturn key\n}\n\n// DecodeRedisKey decode redis key to readable string\nfunc DecodeRedisKey(key any) string {\n\tswitch key.(type) {\n\tcase string:\n\t\treturn key.(string)\n\n\tcase []any:\n\t\tarr := key.([]any)\n\t\tbytes := sliceutil.Map(arr, func(i int) byte {\n\t\t\tif c, ok := AnyToInt(arr[i]); ok {\n\t\t\t\treturn byte(c)\n\t\t\t}\n\t\t\treturn '0'\n\t\t})\n\t\treturn string(bytes)\n\n\tcase []int:\n\t\tarr := key.([]int)\n\t\tb := make([]byte, len(arr))\n\t\tfor i, bb := range arr {\n\t\t\tb[i] = byte(bb)\n\t\t}\n\t\treturn string(b)\n\t}\n\treturn \"\"\n}\n\n// AnyToInt convert any value to int\nfunc AnyToInt(val any) (int, bool) {\n\tswitch val.(type) {\n\tcase string:\n\t\tnum, err := strconv.Atoi(val.(string))\n\t\tif err != nil {\n\t\t\treturn 0, false\n\t\t}\n\t\treturn num, true\n\tcase float64:\n\t\treturn int(val.(float64)), true\n\tcase float32:\n\t\treturn int(val.(float32)), true\n\tcase int64:\n\t\treturn int(val.(int64)), true\n\tcase int32:\n\t\treturn int(val.(int32)), true\n\tcase int:\n\t\treturn val.(int), true\n\tcase bool:\n\t\tif val.(bool) {\n\t\t\treturn 1, true\n\t\t} else {\n\t\t\treturn 0, true\n\t\t}\n\t}\n\treturn 0, false\n}\n"
  },
  {
    "path": "build/README.md",
    "content": "# Build Directory\n\nThe build directory is used to house all the build files and assets for your application.\n\nThe structure is:\n\n* bin - Output directory\n* darwin - macOS specific files\n* windows - Windows specific files\n\n## Mac\n\nThe `darwin` directory holds files specific to Mac builds.\nThese may be customised and used as part of the build. To return these files to the default state, simply delete them\nand\nbuild with `wails build`.\n\nThe directory contains the following files:\n\n- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`.\n- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`.\n\n## Windows\n\nThe `windows` directory contains the manifest and rc files used when building with `wails build`.\nThese may be customised for your application. To return these files to the default state, simply delete them and\nbuild with `wails build`.\n\n- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to\n  use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file\n  will be created using the `appicon.png` file in the build directory.\n- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`.\n- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer,\n  as well as the application itself (right click the exe -> properties -> details)\n- `wails.exe.manifest` - The main application manifest file.\n"
  },
  {
    "path": "build/darwin/Info.dev.plist",
    "content": "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n    <dict>\n        <key>CFBundlePackageType</key>\n        <string>APPL</string>\n        <key>CFBundleName</key>\n        <string>{{.Info.ProductName}}</string>\n        <key>CFBundleExecutable</key>\n        <string>{{.Info.ProductName}}</string>\n        <key>CFBundleIdentifier</key>\n        <string>com.tinycraft.{{.Name}}</string>\n        <key>CFBundleVersion</key>\n        <string>{{.Info.ProductVersion}}</string>\n        <key>CFBundleGetInfoString</key>\n        <string>{{.Info.Comments}}</string>\n        <key>CFBundleShortVersionString</key>\n        <string>{{.Info.ProductVersion}}</string>\n        <key>CFBundleIconFile</key>\n        <string>iconfile</string>\n        <key>LSMinimumSystemVersion</key>\n        <string>11.7.0</string>\n        <key>NSHighResolutionCapable</key>\n        <string>true</string>\n        <key>NSHumanReadableCopyright</key>\n        <string>{{.Info.Copyright}}</string>\n        <key>NSAppTransportSecurity</key>\n        <dict>\n            <key>NSAllowsLocalNetworking</key>\n            <true/>\n        </dict>\n    </dict>\n</plist>\n"
  },
  {
    "path": "build/darwin/Info.plist",
    "content": "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n    <dict>\n        <key>CFBundlePackageType</key>\n        <string>APPL</string>\n        <key>CFBundleName</key>\n        <string>{{.Info.ProductName}}</string>\n        <key>CFBundleExecutable</key>\n        <string>{{.Info.ProductName}}</string>\n        <key>CFBundleIdentifier</key>\n        <string>com.tinycraft.{{.Name}}</string>\n        <key>CFBundleVersion</key>\n        <string>{{.Info.ProductVersion}}</string>\n        <key>CFBundleGetInfoString</key>\n        <string>{{.Info.Comments}}</string>\n        <key>CFBundleShortVersionString</key>\n        <string>{{.Info.ProductVersion}}</string>\n        <key>CFBundleIconFile</key>\n        <string>iconfile</string>\n        <key>LSMinimumSystemVersion</key>\n        <string>11.7.0</string>\n        <key>NSHighResolutionCapable</key>\n        <string>true</string>\n        <key>NSHumanReadableCopyright</key>\n        <string>{{.Info.Copyright}}</string>\n    </dict>\n</plist>\n"
  },
  {
    "path": "build/dmg/fix-app",
    "content": "#!/bin/bash\nclear\nBLACK=\"\\033[0;30m\"\nDARK_GRAY=\"\\033[1;30m\"\nBLUE=\"\\033[0;34m\"\nLIGHT_BLUE=\"\\033[1;34m\"\nGREEN=\"\\033[0;32m\"\nLIGHT_GREEN=\"\\033[1;32m\"\nCYAN=\"\\033[0;36m\"\nLIGHT_CYAN=\"\\033[1;36m\"\nRED=\"\\033[0;31m\"\nLIGHT_RED=\"\\033[1;31m\"\nPURPLE=\"\\033[0;35m\"\nLIGHT_PURPLE=\"\\033[1;35m\"\nBROWN=\"\\033[0;33m\"\nYELLOW=\"\\033[0;33m\"\nLIGHT_GRAY=\"\\033[0;37m\"\nWHITE=\"\\033[1;37m\"\nNC=\"\\033[0m\"\n\nparentPath=$( cd \"$(dirname \"${BASH_SOURCE[0]}\")\" ; pwd -P )\ncd \"$parentPath\"\nappPath=$( find \"$parentPath\" -name '*.app' -maxdepth 1)\nappName=${appPath##*/}\nappBashName=${appName// /\\ }\nappDIR=\"/Applications/${appBashName}\"\necho -e \"This tool fix these situations: \\\"${appBashName}\\\" is damaged and can't not be opened.\"\necho \"\"\nif [ ! -d \"$appDIR\" ];then\n  echo \"\"\n  echo -e \"Execution result: ${RED}You haven't installed ${appBashName} yet, please install it first.${NC}\"\n  else\n  echo -e \"${YELLOW}Please enter your login password, and then press enter. (The password is invisible during input)${NC}\"\n  sudo spctl --master-disable\n  sudo xattr -rd com.apple.quarantine /Applications/\"$appBashName\"\n  sudo xattr -rc /Applications/\"$appBashName\"\n  sudo codesign --sign - --force --deep /Applications/\"$appBashName\"\n  echo -e \"Execution result: ${GREEN}Already fixed! ${NC} ${appBashName} will work correctly.${NC}\"\nfi\necho -e \"You can close this window now\"\n"
  },
  {
    "path": "build/dmg/fix-app_zh",
    "content": "#!/bin/bash\nclear\nBLACK=\"\\033[0;30m\"\nDARK_GRAY=\"\\033[1;30m\"\nBLUE=\"\\033[0;34m\"\nLIGHT_BLUE=\"\\033[1;34m\"\nGREEN=\"\\033[0;32m\"\nLIGHT_GREEN=\"\\033[1;32m\"\nCYAN=\"\\033[0;36m\"\nLIGHT_CYAN=\"\\033[1;36m\"\nRED=\"\\033[0;31m\"\nLIGHT_RED=\"\\033[1;31m\"\nPURPLE=\"\\033[0;35m\"\nLIGHT_PURPLE=\"\\033[1;35m\"\nBROWN=\"\\033[0;33m\"\nYELLOW=\"\\033[0;33m\"\nLIGHT_GRAY=\"\\033[0;37m\"\nWHITE=\"\\033[1;37m\"\nNC=\"\\033[0m\"\n\nparentPath=$( cd \"$(dirname \"${BASH_SOURCE[0]}\")\" ; pwd -P )\ncd \"$parentPath\"\nappPath=$( find \"$parentPath\" -name '*.app' -maxdepth 1)\nappName=${appPath##*/}\nappBashName=${appName// /\\ }\nappDIR=\"/Applications/${appBashName}\"\necho -e \"『${appBashName} 提示已损坏，无法打开/ 来自身份不明的开发者』等问题修复工具\"\necho \"\"\n# 未安装APP时提醒安装，已安装绕过公证\nif [ ! -d \"$appDIR\" ];then\n  echo \"\"\n  echo -e \"执行结果：${RED}您还未安装 ${appBashName} ，请先安装${NC}\"\n  else\n  # 绕过公证\n  echo -e \"${YELLOW}请输入开机密码，输入完成后按下回车键（输入过程中密码是看不见的）${NC}\"\n  sudo spctl --master-disable\n  sudo xattr -rd com.apple.quarantine /Applications/\"$appBashName\"\n  sudo xattr -rc /Applications/\"$appBashName\"\n  sudo codesign --sign - --force --deep /Applications/\"$appBashName\"\n  echo -e \"执行结果：${GREEN}修复成功！${NC}您现在可以正常运行 ${appBashName} 了。${NC}\"\nfi\necho -e \"本窗口可以关闭啦！\"\n"
  },
  {
    "path": "build/linux/tiny-rdm_0.0.0_amd64/DEBIAN/control",
    "content": "Package: {{.Name}}\nVersion: {{.Info.ProductVersion}}\nSection: base\nPriority: optional\nArchitecture: amd64\nDepends: {{.libwebkit2gtk.PackageName}}\nMaintainer: {{.Author.Name}} <{{.Author.Email}}>\nHomepage: https://tinyrdm.com/\nDescription: {{.Info.Comments}}\n"
  },
  {
    "path": "build/linux/tiny-rdm_0.0.0_amd64/usr/local/bin/.gitkeep",
    "content": ""
  },
  {
    "path": "build/linux/tiny-rdm_0.0.0_amd64/usr/share/applications/tiny-rdm.desktop",
    "content": "[Desktop Entry]\nName={{.Info.ProductName}}\nExec=/usr/local/bin/tiny-rdm %U\nTerminal=false\nType=Application\nIcon=tiny-rdm\nStartupWMClass=tinyrdm\nComment={{.Info.Comments}}\nMimeType=x-scheme-handler/tinyrdm;\nCategories=Office;\n"
  },
  {
    "path": "build/windows/info.json",
    "content": "{\n  \"fixed\": {\n    \"file_version\": \"{{.Info.ProductVersion}}\"\n  },\n  \"info\": {\n    \"0000\": {\n      \"ProductVersion\": \"{{.Info.ProductVersion}}\",\n      \"CompanyName\": \"{{.Info.CompanyName}}\",\n      \"FileDescription\": \"{{.Info.ProductName}}\",\n      \"LegalCopyright\": \"{{.Info.Copyright}}\",\n      \"ProductName\": \"{{.Info.ProductName}}\",\n      \"Comments\": \"{{.Info.Comments}}\"\n    }\n  }\n}\n"
  },
  {
    "path": "build/windows/installer/project.nsi",
    "content": "Unicode true\n\n####\n## Please note: Template replacements don't work in this file. They are provided with default defines like\n## mentioned underneath.\n## If the keyword is not defined, \"wails_tools.nsh\" will populate them with the values from ProjectInfo. \n## If they are defined here, \"wails_tools.nsh\" will not touch them. This allows to use this project.nsi manually \n## from outside of Wails for debugging and development of the installer.\n## \n## For development first make a wails nsis build to populate the \"wails_tools.nsh\":\n## > wails build --target windows/amd64 --nsis\n## Then you can call makensis on this file with specifying the path to your binary:\n## For a AMD64 only installer:\n## > makensis -DARG_WAILS_AMD64_BINARY=..\\..\\bin\\app.exe\n## For a ARM64 only installer:\n## > makensis -DARG_WAILS_ARM64_BINARY=..\\..\\bin\\app.exe\n## For a installer with both architectures:\n## > makensis -DARG_WAILS_AMD64_BINARY=..\\..\\bin\\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\\..\\bin\\app-arm64.exe\n####\n## The following information is taken from the ProjectInfo file, but they can be overwritten here. \n####\n## !define INFO_PROJECTNAME    \"MyProject\" # Default \"{{.Name}}\"\n## !define INFO_COMPANYNAME    \"MyCompany\" # Default \"{{.Info.CompanyName}}\"\n## !define INFO_PRODUCTNAME    \"MyProduct\" # Default \"{{.Info.ProductName}}\"\n## !define INFO_PRODUCTVERSION \"1.0.0\"     # Default \"{{.Info.ProductVersion}}\"\n## !define INFO_COPYRIGHT      \"Copyright\" # Default \"{{.Info.Copyright}}\"\n###\n## !define PRODUCT_EXECUTABLE  \"Application.exe\"      # Default \"${INFO_PROJECTNAME}.exe\"\n## !define UNINST_KEY_NAME     \"UninstKeyInRegistry\"  # Default \"${INFO_COMPANYNAME}${INFO_PRODUCTNAME}\"\n####\n## !define REQUEST_EXECUTION_LEVEL \"admin\"            # Default \"admin\"  see also https://nsis.sourceforge.io/Docs/Chapter4.html\n####\n## Include the wails tools\n####\n!include \"wails_tools.nsh\"\n\n# The version information for this two must consist of 4 parts\nVIProductVersion \"${INFO_PRODUCTVERSION}.0\"\nVIFileVersion    \"${INFO_PRODUCTVERSION}.0\"\n\nVIAddVersionKey \"CompanyName\"     \"${INFO_COMPANYNAME}\"\nVIAddVersionKey \"FileDescription\" \"${INFO_PRODUCTNAME} Installer\"\nVIAddVersionKey \"ProductVersion\"  \"${INFO_PRODUCTVERSION}\"\nVIAddVersionKey \"FileVersion\"     \"${INFO_PRODUCTVERSION}\"\nVIAddVersionKey \"LegalCopyright\"  \"${INFO_COPYRIGHT}\"\nVIAddVersionKey \"ProductName\"     \"${INFO_PRODUCTNAME}\"\n\n!include \"MUI.nsh\"\n\n!define MUI_ICON \"..\\icon.ico\"\n!define MUI_UNICON \"..\\icon.ico\"\n# !define MUI_WELCOMEFINISHPAGE_BITMAP \"resources\\leftimage.bmp\" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314\n!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps\n!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.\n\n!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.\n# !insertmacro MUI_PAGE_LICENSE \"resources\\eula.txt\" # Adds a EULA page to the installer\n!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.\n!insertmacro MUI_PAGE_INSTFILES # Installing page.\n!insertmacro MUI_PAGE_FINISH # Finished installation page.\n\n!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page\n\n!insertmacro MUI_LANGUAGE \"English\" # Set the Language of the installer\n\n## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1\n#!uninstfinalize 'signtool --file \"%1\"'\n#!finalize 'signtool --file \"%1\"'\n\nName \"${INFO_PRODUCTNAME}\"\nOutFile \"..\\..\\bin\\${INFO_PROJECTNAME}-${ARCH}-installer.exe\" # Name of the installer's file.\nInstallDir \"$PROGRAMFILES64\\${INFO_COMPANYNAME}\\${INFO_PRODUCTNAME}\" # Default installing folder ($PROGRAMFILES is Program Files folder).\nShowInstDetails show # This will always show the installation details.\n\nFunction .onInit\n   !insertmacro wails.checkArchitecture\nFunctionEnd\n\nSection\n    !insertmacro wails.setShellContext\n\n    !insertmacro wails.webview2runtime\n\n    SetOutPath $INSTDIR\n    \n    !insertmacro wails.files\n\n    CreateShortcut \"$SMPROGRAMS\\${INFO_PRODUCTNAME}.lnk\" \"$INSTDIR\\${PRODUCT_EXECUTABLE}\"\n    CreateShortCut \"$DESKTOP\\${INFO_PRODUCTNAME}.lnk\" \"$INSTDIR\\${PRODUCT_EXECUTABLE}\"\n\n    !insertmacro wails.writeUninstaller\nSectionEnd\n\nSection \"uninstall\" \n    !insertmacro wails.setShellContext\n\n    RMDir /r \"$AppData\\${PRODUCT_EXECUTABLE}\" # Remove the WebView2 DataPath\n\n    RMDir /r $INSTDIR\n\n    Delete \"$SMPROGRAMS\\${INFO_PRODUCTNAME}.lnk\"\n    Delete \"$DESKTOP\\${INFO_PRODUCTNAME}.lnk\"\n\n    !insertmacro wails.deleteUninstaller\nSectionEnd\n"
  },
  {
    "path": "build/windows/installer/wails_tools.nsh",
    "content": "# DO NOT EDIT - Generated automatically by `wails build`\n\n!include \"x64.nsh\"\n!include \"WinVer.nsh\"\n!include \"FileFunc.nsh\"\n\n!ifndef INFO_PROJECTNAME\n    !define INFO_PROJECTNAME \"{{.Name}}\"\n!endif\n!ifndef INFO_COMPANYNAME\n    !define INFO_COMPANYNAME \"{{.Info.CompanyName}}\"\n!endif\n!ifndef INFO_PRODUCTNAME\n    !define INFO_PRODUCTNAME \"{{.Info.ProductName}}\"\n!endif\n!ifndef INFO_PRODUCTVERSION\n    !define INFO_PRODUCTVERSION \"{{.Info.ProductVersion}}\"\n!endif\n!ifndef INFO_COPYRIGHT\n    !define INFO_COPYRIGHT \"{{.Info.Copyright}}\"\n!endif\n!ifndef PRODUCT_EXECUTABLE\n    !define PRODUCT_EXECUTABLE \"${INFO_PROJECTNAME}.exe\"\n!endif\n!ifndef UNINST_KEY_NAME\n    !define UNINST_KEY_NAME \"${INFO_COMPANYNAME}${INFO_PRODUCTNAME}\"\n!endif\n!define UNINST_KEY \"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${UNINST_KEY_NAME}\"\n\n!ifndef REQUEST_EXECUTION_LEVEL\n    !define REQUEST_EXECUTION_LEVEL \"admin\"\n!endif\n\nRequestExecutionLevel \"${REQUEST_EXECUTION_LEVEL}\"\n\n!ifdef ARG_WAILS_AMD64_BINARY\n    !define SUPPORTS_AMD64\n!endif\n\n!ifdef ARG_WAILS_ARM64_BINARY\n    !define SUPPORTS_ARM64\n!endif\n\n!ifdef SUPPORTS_AMD64\n    !ifdef SUPPORTS_ARM64\n        !define ARCH \"amd64_arm64\"\n    !else\n        !define ARCH \"amd64\"\n    !endif\n!else\n    !ifdef SUPPORTS_ARM64\n        !define ARCH \"arm64\"\n    !else\n        !error \"Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY\"\n    !endif\n!endif\n\n!macro wails.checkArchitecture\n    !ifndef WAILS_WIN10_REQUIRED\n        !define WAILS_WIN10_REQUIRED \"This product is only supported on Windows 10 (Server 2016) and later.\"\n    !endif\n\n    !ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED\n        !define WAILS_ARCHITECTURE_NOT_SUPPORTED \"This product can't be installed on the current Windows architecture. Supports: ${ARCH}\"\n    !endif\n\n    ${If} ${AtLeastWin10}\n        !ifdef SUPPORTS_AMD64\n            ${if} ${IsNativeAMD64}\n                Goto ok\n            ${EndIf}\n        !endif\n\n        !ifdef SUPPORTS_ARM64\n            ${if} ${IsNativeARM64}\n                Goto ok\n            ${EndIf}\n        !endif\n\n        IfSilent silentArch notSilentArch\n        silentArch:\n            SetErrorLevel 65\n            Abort\n        notSilentArch:\n            MessageBox MB_OK \"${WAILS_ARCHITECTURE_NOT_SUPPORTED}\"\n            Quit\n    ${else}\n        IfSilent silentWin notSilentWin\n        silentWin:\n            SetErrorLevel 64\n            Abort\n        notSilentWin:\n            MessageBox MB_OK \"${WAILS_WIN10_REQUIRED}\"\n            Quit\n    ${EndIf}\n\n    ok:\n!macroend\n\n!macro wails.files\n    !ifdef SUPPORTS_AMD64\n        ${if} ${IsNativeAMD64}\n            File \"/oname=${PRODUCT_EXECUTABLE}\" \"${ARG_WAILS_AMD64_BINARY}\"\n        ${EndIf}\n    !endif\n\n    !ifdef SUPPORTS_ARM64\n        ${if} ${IsNativeARM64}\n            File \"/oname=${PRODUCT_EXECUTABLE}\" \"${ARG_WAILS_ARM64_BINARY}\"\n        ${EndIf}\n    !endif\n!macroend\n\n!macro wails.writeUninstaller\n    WriteUninstaller \"$INSTDIR\\uninstall.exe\"\n\n    SetRegView 64\n    WriteRegStr HKLM \"${UNINST_KEY}\" \"Publisher\" \"${INFO_COMPANYNAME}\"\n    WriteRegStr HKLM \"${UNINST_KEY}\" \"DisplayName\" \"${INFO_PRODUCTNAME}\"\n    WriteRegStr HKLM \"${UNINST_KEY}\" \"DisplayVersion\" \"${INFO_PRODUCTVERSION}\"\n    WriteRegStr HKLM \"${UNINST_KEY}\" \"DisplayIcon\" \"$INSTDIR\\${PRODUCT_EXECUTABLE}\"\n    WriteRegStr HKLM \"${UNINST_KEY}\" \"UninstallString\" \"$\\\"$INSTDIR\\uninstall.exe$\\\"\"\n    WriteRegStr HKLM \"${UNINST_KEY}\" \"QuietUninstallString\" \"$\\\"$INSTDIR\\uninstall.exe$\\\" /S\"\n\n    ${GetSize} \"$INSTDIR\" \"/S=0K\" $0 $1 $2\n    IntFmt $0 \"0x%08X\" $0\n    WriteRegDWORD HKLM \"${UNINST_KEY}\" \"EstimatedSize\" \"$0\"\n!macroend\n\n!macro wails.deleteUninstaller\n    Delete \"$INSTDIR\\uninstall.exe\"\n\n    SetRegView 64\n    DeleteRegKey HKLM \"${UNINST_KEY}\"\n!macroend\n\n!macro wails.setShellContext\n    ${If} ${REQUEST_EXECUTION_LEVEL} == \"admin\"\n        SetShellVarContext all\n    ${else}\n        SetShellVarContext current\n    ${EndIf}\n!macroend\n\n# Install webview2 by launching the bootstrapper\n# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment\n!macro wails.webview2runtime\n    !ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT\n        !define WAILS_INSTALL_WEBVIEW_DETAILPRINT \"Installing: WebView2 Runtime\"\n    !endif\n\n    SetRegView 64\n\t# If the admin key exists and is not empty then webview2 is already installed\n\tReadRegStr $0 HKLM \"SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}\" \"pv\"\n    ${If} $0 != \"\"\n        Goto ok\n    ${EndIf}\n\n    ${If} ${REQUEST_EXECUTION_LEVEL} == \"user\"\n        # If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed\n\t    ReadRegStr $0 HKCU \"Software\\Microsoft\\EdgeUpdate\\Clients{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}\" \"pv\"\n        ${If} $0 != \"\"\n            Goto ok\n        ${EndIf}\n     ${EndIf}\n    \n\tSetDetailsPrint both\n    DetailPrint \"${WAILS_INSTALL_WEBVIEW_DETAILPRINT}\"\n    SetDetailsPrint listonly\n    \n    InitPluginsDir\n    CreateDirectory \"$pluginsdir\\webview2bootstrapper\"\n    SetOutPath \"$pluginsdir\\webview2bootstrapper\"\n    File \"tmp\\MicrosoftEdgeWebview2Setup.exe\"\n    ExecWait '\"$pluginsdir\\webview2bootstrapper\\MicrosoftEdgeWebview2Setup.exe\" /silent /install'\n    \n    SetDetailsPrint both\n    ok:\n!macroend"
  },
  {
    "path": "build/windows/wails.exe.manifest",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<assembly manifestVersion=\"1.0\" xmlns=\"urn:schemas-microsoft-com:asm.v1\" xmlns:asmv3=\"urn:schemas-microsoft-com:asm.v3\">\n    <assemblyIdentity type=\"win32\" name=\"com.wails.{{.Name}}\" version=\"{{.Info.ProductVersion}}.0\" processorArchitecture=\"*\"/>\n    <dependency>\n        <dependentAssembly>\n            <assemblyIdentity type=\"win32\" name=\"Microsoft.Windows.Common-Controls\" version=\"6.0.0.0\" processorArchitecture=\"*\" publicKeyToken=\"6595b64144ccf1df\" language=\"*\"/>\n        </dependentAssembly>\n    </dependency>\n    <asmv3:application>\n        <asmv3:windowsSettings>\n            <dpiAware xmlns=\"http://schemas.microsoft.com/SMI/2005/WindowsSettings\">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->\n            <dpiAwareness xmlns=\"http://schemas.microsoft.com/SMI/2016/WindowsSettings\">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->\n        </asmv3:windowsSettings>\n    </asmv3:application>\n</assembly>"
  },
  {
    "path": "docker/entrypoint.sh",
    "content": "#!/bin/sh\nset -e\n\n# Start nginx in background (serves frontend + reverse proxy)\nnginx\n\n# Start Go backend in foreground\nexec ./tinyrdm-server\n"
  },
  {
    "path": "docker/nginx.conf",
    "content": "server {\n    listen 8086;\n    server_name _;\n\n    root /usr/share/nginx/html;\n    index index.html;\n\n    # SPA fallback\n    location / {\n        try_files $uri $uri/ /index.html;\n    }\n\n    # Proxy API to Go backend\n    location /api/ {\n        proxy_pass http://127.0.0.1:8088;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header X-Forwarded-Host $host;\n    }\n\n    # Proxy WebSocket\n    location /ws {\n        proxy_pass http://127.0.0.1:8088;\n        proxy_http_version 1.1;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Host $host;\n    }\n}\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  tinyrdm:\n    image: ghcr.io/tiny-craft/tiny-rdm:latest\n    container_name: tinyrdm\n    restart: unless-stopped\n    ports:\n      - \"8086:8086\"\n    environment:\n      - PORT=8088\n      - GIN_MODE=release\n      - ADMIN_USERNAME=admin\n      - ADMIN_PASSWORD=tinyrdm\n      # - SESSION_TTL=24h\n    volumes:\n      - ./data:/app/tinyrdm\n"
  },
  {
    "path": "docs/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta http-equiv=\"refresh\" content=\"0;url=https://github.com/tiny-craft/tiny-rdm\">\n    <title>Tiny RDM</title>\n</head>\n<body>\n\n</body>\n</html>\n"
  },
  {
    "path": "frontend/.prettierrc",
    "content": "{\n  \"printWidth\": 120,\n  \"tabWidth\": 4,\n  \"singleQuote\": true,\n  \"semi\": false,\n  \"bracketSameLine\": true,\n  \"endOfLine\": \"auto\",\n  \"htmlWhitespaceSensitivity\": \"ignore\"\n}\n"
  },
  {
    "path": "frontend/README.md",
    "content": "# Frontend of Tiny RDM\n\nUse Vue3 + Vite\n"
  },
  {
    "path": "frontend/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\" />\n    <meta content=\"width=device-width, initial-scale=1.0\" name=\"viewport\" />\n    <title>Tiny RDM</title>\n    <link href=\"/favicon.png\" rel=\"icon\" type=\"image/png\" />\n    <!--    <link href=\"./src/styles/style.scss\" rel=\"stylesheet\">-->\n</head>\n<body spellcheck=\"false\">\n<div id=\"app\"></div>\n<script src=\"./src/main.js\" type=\"module\"></script>\n</body>\n</html>\n\n"
  },
  {
    "path": "frontend/package.json",
    "content": "{\n    \"name\": \"frontend\",\n    \"private\": true,\n    \"version\": \"0.0.0\",\n    \"type\": \"module\",\n    \"scripts\": {\n        \"dev\": \"vite\",\n        \"build\": \"vite build\",\n        \"preview\": \"vite preview\"\n    },\n    \"dependencies\": {\n        \"chart.js\": \"^4.5.1\",\n        \"dayjs\": \"^1.11.19\",\n        \"lodash\": \"^4.17.23\",\n        \"monaco-editor\": \"^0.47.0\",\n        \"pinia\": \"^3.0.4\",\n        \"sass\": \"^1.97.3\",\n        \"vue\": \"^3.5.29\",\n        \"vue-chartjs\": \"^5.3.3\",\n        \"vue-i18n\": \"^11.2.8\",\n        \"wcwidth\": \"^1.0.1\",\n        \"xterm\": \"^5.3.0\",\n        \"xterm-addon-fit\": \"^0.8.0\"\n    },\n    \"devDependencies\": {\n        \"@vitejs/plugin-vue\": \"^6.0.4\",\n        \"naive-ui\": \"^2.43.2\",\n        \"prettier\": \"^3.8.1\",\n        \"unplugin-auto-import\": \"^21.0.0\",\n        \"unplugin-icons\": \"^23.0.1\",\n        \"unplugin-vue-components\": \"^31.0.0\",\n        \"vite\": \"^7.3.1\"\n    }\n}\n"
  },
  {
    "path": "frontend/src/App.vue",
    "content": "<script setup>\nimport ConnectionDialog from './components/dialogs/ConnectionDialog.vue'\nimport NewKeyDialog from './components/dialogs/NewKeyDialog.vue'\nimport PreferencesDialog from './components/dialogs/PreferencesDialog.vue'\nimport RenameKeyDialog from './components/dialogs/RenameKeyDialog.vue'\nimport SetTtlDialog from './components/dialogs/SetTtlDialog.vue'\nimport AddFieldsDialog from './components/dialogs/AddFieldsDialog.vue'\nimport AppContent from './AppContent.vue'\nimport GroupDialog from './components/dialogs/GroupDialog.vue'\nimport DeleteKeyDialog from './components/dialogs/DeleteKeyDialog.vue'\nimport { defineAsyncComponent, h, onMounted, onUnmounted, ref, watch } from 'vue'\nimport usePreferencesStore from './stores/preferences.js'\nimport useConnectionStore from './stores/connections.js'\nimport { useI18n } from 'vue-i18n'\nimport { darkTheme, NButton, NSpace } from 'naive-ui'\nimport KeyFilterDialog from './components/dialogs/KeyFilterDialog.vue'\nimport { Environment, WindowSetDarkTheme, WindowSetLightTheme } from 'wailsjs/runtime/runtime.js'\nimport { darkThemeOverrides, themeOverrides } from '@/utils/theme.js'\nimport AboutDialog from '@/components/dialogs/AboutDialog.vue'\nimport FlushDbDialog from '@/components/dialogs/FlushDbDialog.vue'\nimport ExportKeyDialog from '@/components/dialogs/ExportKeyDialog.vue'\nimport ImportKeyDialog from '@/components/dialogs/ImportKeyDialog.vue'\nimport { Info } from 'wailsjs/go/services/systemService.js'\nimport DecoderDialog from '@/components/dialogs/DecoderDialog.vue'\nimport { loadModule, trackEvent } from '@/utils/analytics.js'\nimport { isWeb } from '@/utils/platform.js'\nimport { STORAGE_LANG_KEY, STORAGE_THEME_KEY } from '@/consts/localstorage_key.js'\n\nconst prefStore = usePreferencesStore()\nconst connectionStore = useConnectionStore()\nconst i18n = useI18n()\nconst initializing = ref(true)\n\n// Web-only: lazy load LoginPage to avoid importing websocket.js in desktop mode\nconst LoginPage = isWeb() ? defineAsyncComponent(() => import('@/components/LoginPage.vue')) : null\n\n// Auth state (web mode only)\nconst authChecking = ref(isWeb()) // desktop: false (skip), web: true (checking)\nconst authenticated = ref(false)\nconst authEnabled = ref(false)\n\nconst checkAuth = async () => {\n    try {\n        const resp = await fetch('/api/auth/status', { credentials: 'same-origin' })\n        const result = await resp.json()\n        if (result.success) {\n            authEnabled.value = result.data.enabled\n            authenticated.value = result.data.authenticated\n        }\n    } catch {\n        authenticated.value = false\n    } finally {\n        authChecking.value = false\n    }\n}\n\nconst onLogin = async () => {\n    authenticated.value = true\n    // Reconnect WebSocket with auth cookie (dynamic import to avoid desktop issues)\n    try {\n        const runtime = await import('wailsjs/runtime/runtime.js')\n        if (runtime.ReconnectWebSocket) {\n            runtime.ReconnectWebSocket()\n        }\n    } catch {}\n    // Capture login page choices before loadPreferences overwrites them\n    const loginTheme = localStorage.getItem(STORAGE_THEME_KEY)\n    const loginLang = localStorage.getItem(STORAGE_LANG_KEY)\n    await initApp()\n    // Sync login page choices to preferences\n    let prefUpdated = false\n    if (loginTheme && prefStore.allThemes.includes(loginTheme)) {\n        prefStore.general.theme = loginTheme\n        prefUpdated = true\n    }\n    if (loginLang) {\n        if (prefStore.allLangs.includes(loginLang)) {\n            prefStore.general.language = loginLang\n            i18n.locale.value = prefStore.currentLanguage\n            prefUpdated = true\n        }\n    }\n    if (prefUpdated) {\n        prefStore.savePreferences()\n    }\n}\n\nconst initApp = async () => {\n    try {\n        initializing.value = true\n        if (isWeb()) {\n            const prefResult = await prefStore.loadPreferences()\n            // If loadPreferences failed (e.g. 401 from expired session),\n            // rdm:unauthorized event already fired → silently abort init\n            if (prefResult === false || !authenticated.value) {\n                return\n            }\n            i18n.locale.value = prefStore.currentLanguage\n        }\n        await prefStore.loadFontList()\n        await prefStore.loadBuildInDecoder()\n        await connectionStore.initConnections()\n        if (!isWeb() && prefStore.autoCheckUpdate) {\n            prefStore.checkForUpdate()\n        }\n        const env = await Environment()\n        loadModule(env.buildType !== 'dev' && prefStore.general.allowTrack !== false).then(() => {\n            Info().then(({ data }) => {\n                trackEvent('startup', data, true)\n            })\n        })\n\n        // show greetings and user behavior tracking statements\n        if (!!!prefStore.behavior.welcomed) {\n            const n = $notification.show({\n                title: () => i18n.t('dialogue.welcome.title'),\n                content: () => i18n.t('dialogue.welcome.content'),\n                // duration: 5000,\n                keepAliveOnHover: true,\n                closable: false,\n                meta: ' ',\n                action: () =>\n                    h(\n                        NSpace,\n                        {},\n                        {\n                            default: () => [\n                                h(\n                                    NButton,\n                                    {\n                                        secondary: true,\n                                        type: 'tertiary',\n                                        onClick: () => {\n                                            prefStore.setAsWelcomed(false)\n                                            n.destroy()\n                                        },\n                                    },\n                                    {\n                                        default: () => i18n.t('dialogue.welcome.reject'),\n                                    },\n                                ),\n                                h(\n                                    NButton,\n                                    {\n                                        secondary: true,\n                                        type: 'primary',\n                                        onClick: () => {\n                                            prefStore.setAsWelcomed(true)\n                                            n.destroy()\n                                        },\n                                    },\n                                    {\n                                        default: () => i18n.t('dialogue.welcome.accept'),\n                                    },\n                                ),\n                            ],\n                        },\n                    ),\n            })\n        }\n    } finally {\n        initializing.value = false\n    }\n}\n\nconst onUnauthorized = () => {\n    if (authEnabled.value) {\n        authenticated.value = false\n    }\n}\n\nonMounted(async () => {\n    if (isWeb()) {\n        // Force desktop layout on mobile (web only, desktop WebView unaffected)\n        const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent)\n        if (isMobile) {\n            const meta = document.querySelector('meta[name=\"viewport\"]')\n            if (meta) meta.setAttribute('content', 'width=1280, user-scalable=yes')\n        }\n\n        // Apply saved login theme before auth check to prevent flash\n        const savedTheme = localStorage.getItem(STORAGE_THEME_KEY)\n        if (savedTheme && prefStore.allThemes.includes(savedTheme)) {\n            prefStore.general.theme = savedTheme\n        }\n        window.addEventListener('rdm:unauthorized', onUnauthorized)\n        await checkAuth()\n        if (authEnabled.value && !authenticated.value) {\n            // Not authenticated — show login page, do NOT call any API\n        } else {\n            // Connect WebSocket before initApp\n            try {\n                const runtime = await import('wailsjs/runtime/runtime.js')\n                if (runtime.ReconnectWebSocket) {\n                    await runtime.ReconnectWebSocket()\n                }\n            } catch {}\n            await initApp()\n        }\n    } else {\n        // Desktop mode: original Wails flow, no auth needed\n        await initApp()\n    }\n})\n\nonUnmounted(() => {\n    if (isWeb()) {\n        window.removeEventListener('rdm:unauthorized', onUnauthorized)\n    }\n})\n\n// watch theme and dynamically switch\nwatch(\n    () => prefStore.isDark,\n    (isDark) => (isDark ? WindowSetDarkTheme() : WindowSetLightTheme()),\n)\n\n// watch language and dynamically switch\nwatch(\n    () => prefStore.general.language,\n    (lang) => (i18n.locale.value = prefStore.currentLanguage),\n)\n</script>\n\n<template>\n    <n-config-provider\n        :inline-theme-disabled=\"true\"\n        :locale=\"prefStore.themeLocale\"\n        :theme=\"prefStore.isDark ? darkTheme : undefined\"\n        :theme-overrides=\"prefStore.isDark ? darkThemeOverrides : themeOverrides\"\n        class=\"fill-height\">\n        <!-- Web mode: auth gate -->\n        <template v-if=\"isWeb() && authChecking\">\n            <div style=\"width: 100vw; height: 100vh\"></div>\n        </template>\n        <template v-else-if=\"isWeb() && authEnabled && !authenticated\">\n            <component :is=\"LoginPage\" @login=\"onLogin\" />\n        </template>\n        <template v-else>\n            <n-dialog-provider>\n                <app-content :loading=\"initializing\" />\n\n                <!-- top modal dialogs -->\n                <connection-dialog />\n                <group-dialog />\n                <new-key-dialog />\n                <key-filter-dialog />\n                <add-fields-dialog />\n                <rename-key-dialog />\n                <delete-key-dialog />\n                <export-key-dialog />\n                <import-key-dialog />\n                <flush-db-dialog />\n                <set-ttl-dialog />\n                <preferences-dialog />\n                <decoder-dialog />\n                <about-dialog />\n            </n-dialog-provider>\n        </template>\n    </n-config-provider>\n</template>\n\n<style lang=\"scss\"></style>\n"
  },
  {
    "path": "frontend/src/AppContent.vue",
    "content": "<script setup>\nimport ContentPane from './components/content/ContentPane.vue'\nimport BrowserPane from './components/sidebar/BrowserPane.vue'\nimport { computed, onMounted, onUnmounted, reactive, ref, watchEffect } from 'vue'\nimport { debounce } from 'lodash'\nimport { useThemeVars } from 'naive-ui'\nimport Ribbon from './components/sidebar/Ribbon.vue'\nimport ConnectionPane from './components/sidebar/ConnectionPane.vue'\nimport ContentServerPane from './components/content/ContentServerPane.vue'\nimport useTabStore from './stores/tab.js'\nimport usePreferencesStore from './stores/preferences.js'\nimport ContentLogPane from './components/content/ContentLogPane.vue'\nimport ContentValueTab from '@/components/content/ContentValueTab.vue'\nimport ToolbarControlWidget from '@/components/common/ToolbarControlWidget.vue'\nimport { EventsOn, WindowIsFullscreen, WindowIsMaximised, WindowToggleMaximise } from 'wailsjs/runtime/runtime.js'\nimport { isMacOS, isWeb, isWindows } from '@/utils/platform.js'\nimport iconUrl from '@/assets/images/icon.png'\nimport ResizeableWrapper from '@/components/common/ResizeableWrapper.vue'\nimport { extraTheme } from '@/utils/extra_theme.js'\n\nconst themeVars = useThemeVars()\n\nconst props = defineProps({\n    loading: Boolean,\n})\n\nconst data = reactive({\n    navMenuWidth: 50,\n    toolbarHeight: 38,\n})\n\nconst tabStore = useTabStore()\nconst prefStore = usePreferencesStore()\nconst logPaneRef = ref(null)\nconst exThemeVars = computed(() => {\n    return extraTheme(prefStore.isDark)\n})\n// const preferences = ref({})\n// provide('preferences', preferences)\n\nconst saveSidebarWidth = debounce(prefStore.savePreferences, 1000, { trailing: true })\nconst handleResize = () => {\n    saveSidebarWidth()\n}\n\nwatchEffect(() => {\n    if (tabStore.nav === 'log') {\n        logPaneRef.value?.refresh()\n    }\n})\n\nconst logoWrapperWidth = computed(() => {\n    return `${data.navMenuWidth + prefStore.behavior.asideWidth - 4}px`\n})\n\nconst logoPaddingLeft = ref(10)\nconst maximised = ref(false)\nconst hideRadius = ref(false)\nconst wrapperStyle = computed(() => {\n    if (isWindows() || isWeb()) {\n        return {}\n    }\n    return hideRadius.value\n        ? {}\n        : {\n              border: `1px solid ${themeVars.value.borderColor}`,\n              borderRadius: '10px',\n          }\n})\nconst spinStyle = computed(() => {\n    if (isWindows() || isWeb()) {\n        return {\n            backgroundColor: themeVars.value.bodyColor,\n        }\n    }\n    return hideRadius.value\n        ? {\n              backgroundColor: themeVars.value.bodyColor,\n          }\n        : {\n              backgroundColor: themeVars.value.bodyColor,\n              borderRadius: '10px',\n          }\n})\n\nconst onToggleFullscreen = (fullscreen) => {\n    hideRadius.value = fullscreen\n    if (fullscreen) {\n        logoPaddingLeft.value = 10\n    } else {\n        logoPaddingLeft.value = isMacOS() ? 70 : 10\n    }\n}\n\nconst onToggleMaximize = (isMaximised) => {\n    if (isMaximised) {\n        maximised.value = true\n        if (!isMacOS()) {\n            hideRadius.value = true\n        }\n    } else {\n        maximised.value = false\n        if (!isMacOS()) {\n            hideRadius.value = false\n        }\n    }\n}\n\nEventsOn('window_changed', (info) => {\n    const { fullscreen, maximised } = info\n    onToggleFullscreen(fullscreen === true)\n    onToggleMaximize(maximised)\n})\n\nonMounted(async () => {\n    const fullscreen = await WindowIsFullscreen()\n    onToggleFullscreen(fullscreen === true)\n    const maximised = await WindowIsMaximised()\n    onToggleMaximize(maximised)\n    window.addEventListener('keydown', onKeyShortcut)\n})\n\nonUnmounted(() => {\n    window.removeEventListener('keydown', onKeyShortcut)\n})\n\nconst onKeyShortcut = (e) => {\n    const isCtrlOn = isMacOS() ? e.metaKey : e.ctrlKey\n    switch (e.key) {\n        case 'w':\n            if (isCtrlOn) {\n                // close current tab\n                const tabStore = useTabStore()\n                const currentTab = tabStore.currentTab\n                if (currentTab != null) {\n                    tabStore.closeTab(currentTab.name)\n                }\n            }\n            break\n    }\n}\n</script>\n\n<template>\n    <!-- app content-->\n    <n-spin :show=\"props.loading\" :style=\"spinStyle\" :theme-overrides=\"{ opacitySpinning: 0 }\">\n        <div id=\"app-content-wrapper\" :style=\"wrapperStyle\" class=\"flex-box-v\">\n            <!-- title bar -->\n            <div\n                id=\"app-toolbar\"\n                :style=\"{ height: data.toolbarHeight + 'px' }\"\n                class=\"flex-box-h\"\n                style=\"--wails-draggable: drag\"\n                @dblclick=\"WindowToggleMaximise\">\n                <!-- title -->\n                <div\n                    id=\"app-toolbar-title\"\n                    :style=\"{\n                        width: logoWrapperWidth,\n                        minWidth: logoWrapperWidth,\n                        paddingLeft: `${logoPaddingLeft}px`,\n                    }\">\n                    <n-space :size=\"3\" :wrap=\"false\" :wrap-item=\"false\" align=\"center\">\n                        <n-avatar :size=\"32\" :src=\"iconUrl\" color=\"#0000\" style=\"min-width: 32px\" />\n                        <div style=\"min-width: 68px; white-space: nowrap; font-weight: 800\">Tiny RDM</div>\n                        <transition name=\"fade\">\n                            <n-text v-if=\"tabStore.nav === 'browser'\" class=\"ellipsis\" strong style=\"font-size: 13px\">\n                                - {{ tabStore.currentTabName }}\n                            </n-text>\n                        </transition>\n                    </n-space>\n                </div>\n                <!-- browser tabs -->\n                <div v-show=\"tabStore.nav === 'browser'\" class=\"app-toolbar-tab flex-item-expand\">\n                    <content-value-tab />\n                </div>\n                <div class=\"flex-item-expand\" style=\"min-width: 15px\"></div>\n                <!-- simulate window control buttons -->\n                <toolbar-control-widget\n                    v-if=\"!isMacOS() && !isWeb()\"\n                    :maximised=\"maximised\"\n                    :size=\"data.toolbarHeight\"\n                    style=\"align-self: flex-start\" />\n            </div>\n\n            <!-- content -->\n            <div\n                id=\"app-content\"\n                :style=\"prefStore.generalFont\"\n                class=\"flex-box-h flex-item-expand\"\n                style=\"--wails-draggable: none\">\n                <ribbon v-model:value=\"tabStore.nav\" :width=\"data.navMenuWidth\" />\n                <!-- browser page -->\n                <div v-show=\"tabStore.nav === 'browser'\" class=\"content-area flex-box-h flex-item-expand\">\n                    <resizeable-wrapper\n                        v-model:size=\"prefStore.behavior.asideWidth\"\n                        :min-size=\"300\"\n                        :offset=\"data.navMenuWidth\"\n                        class=\"flex-item\"\n                        @update:size=\"handleResize\">\n                        <browser-pane\n                            v-for=\"t in tabStore.tabs\"\n                            v-show=\"tabStore.currentTabName === t.name\"\n                            :key=\"t.name\"\n                            :db=\"t.db\"\n                            :server=\"t.name\"\n                            class=\"app-side flex-item-expand\" />\n                    </resizeable-wrapper>\n                    <content-pane\n                        v-for=\"t in tabStore.tabs\"\n                        v-show=\"tabStore.currentTabName === t.name\"\n                        :key=\"t.name\"\n                        :server=\"t.name\"\n                        class=\"flex-item-expand\" />\n                </div>\n\n                <!-- server list page -->\n                <div v-show=\"tabStore.nav === 'server'\" class=\"content-area flex-box-h flex-item-expand\">\n                    <resizeable-wrapper\n                        v-model:size=\"prefStore.behavior.asideWidth\"\n                        :min-size=\"300\"\n                        :offset=\"data.navMenuWidth\"\n                        class=\"flex-item\"\n                        @update:size=\"handleResize\">\n                        <connection-pane class=\"app-side flex-item-expand\" />\n                    </resizeable-wrapper>\n                    <content-server-pane class=\"flex-item-expand\" />\n                </div>\n\n                <!-- log page -->\n                <div v-show=\"tabStore.nav === 'log'\" class=\"content-area flex-box-h flex-item-expand\">\n                    <content-log-pane ref=\"logPaneRef\" class=\"flex-item-expand\" />\n                </div>\n            </div>\n        </div>\n    </n-spin>\n</template>\n\n<style lang=\"scss\" scoped>\n#app-content-wrapper {\n    width: 100vw;\n    height: 100vh;\n    height: 100dvh;\n    overflow: hidden;\n    box-sizing: border-box;\n    background-color: v-bind('themeVars.bodyColor');\n    color: v-bind('themeVars.textColorBase');\n\n    #app-toolbar {\n        background-color: v-bind('exThemeVars.titleColor');\n        border-bottom: 1px solid v-bind('exThemeVars.splitColor');\n\n        &-title {\n            padding-left: 10px;\n            padding-right: 10px;\n            box-sizing: border-box;\n            align-self: center;\n            align-items: baseline;\n        }\n    }\n\n    .app-toolbar-tab {\n        align-self: flex-end;\n        margin-bottom: -1px;\n        margin-left: 3px;\n        overflow: auto;\n    }\n\n    #app-content {\n        height: calc(100% - 60px);\n\n        .content-area {\n            overflow: hidden;\n        }\n    }\n\n    .app-side {\n        //overflow: hidden;\n        height: 100%;\n        background-color: v-bind('exThemeVars.sidebarColor');\n        border-right: 1px solid v-bind('exThemeVars.splitColor');\n    }\n}\n\n.fade-enter-from,\n.fade-leave-to {\n    opacity: 0;\n}\n\n.fade-enter-active,\n.fade-leave-active {\n    transition: opacity 0.3s ease;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/assets/fonts/OFL.txt",
    "content": "Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),\n\nThis Font Software is licensed under the SIL Open Font License, Version 1.1.\nThis license is copied below, and is also available with a FAQ at:\nhttp://scripts.sil.org/OFL\n\n\n-----------------------------------------------------------\nSIL OPEN FONT LICENSE Version 1.1 - 26 February 2007\n-----------------------------------------------------------\n\nPREAMBLE\nThe goals of the Open Font License (OFL) are to stimulate worldwide\ndevelopment of collaborative font projects, to support the font creation\nefforts of academic and linguistic communities, and to provide a free and\nopen framework in which fonts may be shared and improved in partnership\nwith others.\n\nThe OFL allows the licensed fonts to be used, studied, modified and\nredistributed freely as long as they are not sold by themselves. The\nfonts, including any derivative works, can be bundled, embedded, \nredistributed and/or sold with any software provided that any reserved\nnames are not used by derivative works. The fonts and derivatives,\nhowever, cannot be released under any other type of license. The\nrequirement for fonts to remain under this license does not apply\nto any document created using the fonts or their derivatives.\n\nDEFINITIONS\n\"Font Software\" refers to the set of files released by the Copyright\nHolder(s) under this license and clearly marked as such. This may\ninclude source files, build scripts and documentation.\n\n\"Reserved Font Name\" refers to any names specified as such after the\ncopyright statement(s).\n\n\"Original Version\" refers to the collection of Font Software components as\ndistributed by the Copyright Holder(s).\n\n\"Modified Version\" refers to any derivative made by adding to, deleting,\nor substituting -- in part or in whole -- any of the components of the\nOriginal Version, by changing formats or by porting the Font Software to a\nnew environment.\n\n\"Author\" refers to any designer, engineer, programmer, technical\nwriter or other person who contributed to the Font Software.\n\nPERMISSION & CONDITIONS\nPermission is hereby granted, free of charge, to any person obtaining\na copy of the Font Software, to use, study, copy, merge, embed, modify,\nredistribute, and sell modified and unmodified copies of the Font\nSoftware, subject to the following conditions:\n\n1) Neither the Font Software nor any of its individual components,\nin Original or Modified Versions, may be sold by itself.\n\n2) Original or Modified Versions of the Font Software may be bundled,\nredistributed and/or sold with any software, provided that each copy\ncontains the above copyright notice and this license. These can be\nincluded either as stand-alone text files, human-readable headers or\nin the appropriate machine-readable metadata fields within text or\nbinary files as long as those fields can be easily viewed by the user.\n\n3) No Modified Version of the Font Software may use the Reserved Font\nName(s) unless explicit written permission is granted by the corresponding\nCopyright Holder. This restriction only applies to the primary font name as\npresented to the users.\n\n4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font\nSoftware shall not be used to promote, endorse or advertise any\nModified Version, except to acknowledge the contribution(s) of the\nCopyright Holder(s) and the Author(s) or with their explicit written\npermission.\n\n5) The Font Software, modified or unmodified, in part or in whole,\nmust be distributed entirely under this license, and must not be\ndistributed under any other license. The requirement for fonts to\nremain under this license does not apply to any document created\nusing the Font Software.\n\nTERMINATION\nThis license becomes null and void if any of the above conditions are\nnot met.\n\nDISCLAIMER\nTHE FONT SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT\nOF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE\nCOPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\nINCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL\nDAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\nFROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM\nOTHER DEALINGS IN THE FONT SOFTWARE.\n"
  },
  {
    "path": "frontend/src/components/LoginPage.vue",
    "content": "<script setup>\nimport { computed, onMounted, ref, watch } from 'vue'\nimport { useThemeVars } from 'naive-ui'\nimport iconUrl from '@/assets/images/icon.png'\nimport usePreferencesStore from '@/stores/preferences.js'\nimport LangIcon from '@/components/icons/Lang.vue'\nimport Sun from '@/components/icons/Sun.vue'\nimport Moon from '@/components/icons/Moon.vue'\nimport ThemeAuto from '@/components/icons/ThemeAuto.vue'\n\nimport { Login } from '@/utils/api.js'\nimport { lang } from '@/langs/index.js'\nimport { useI18n } from 'vue-i18n'\nimport { useRender } from '@/utils/render.js'\nimport { STORAGE_LANG_KEY, STORAGE_THEME_KEY } from '@/consts/localstorage_key.js'\nimport { i18nGlobal } from '@/utils/i18n.js'\n\nconst themeVars = useThemeVars()\nconst prefStore = usePreferencesStore()\nconst i18n = useI18n()\nconst emit = defineEmits(['login'])\n\nconst bgGradient = computed(() =>\n    prefStore.isDark\n        ? 'linear-gradient(135deg, #2c1c1a 0%, #2a2219 20%, #1c2030 45%, #1a2535 65%, #251c28 85%, #2a1e1e 100%)'\n        : 'linear-gradient(135deg, #fce4e3 0%, #f5e6d8 20%, #e8eef8 45%, #dce8f8 65%, #f2e8f0 85%, #f8f0f0 100%)',\n)\n\n// --- Theme ---\nconst themeMode = ref(localStorage.getItem(STORAGE_THEME_KEY) || 'auto')\n\nonMounted(() => {\n    prefStore.general.theme = themeMode.value\n})\n\nconst getThemeLabels = (langKey) => {\n    const l = lang[langKey] || lang.en\n    const g = l.preferences?.general || {}\n    return {\n        auto: g.theme_auto || 'Auto',\n        light: g.theme_light || 'Light',\n        dark: g.theme_dark || 'Dark',\n    }\n}\n\nconst themeOptions = computed(() => {\n    const labels = getThemeLabels(currentLang.value)\n    return [\n        { label: labels.light, key: 'light', icon: Sun },\n        { label: labels.dark, key: 'dark', icon: Moon },\n        { label: labels.auto, key: 'auto', icon: ThemeAuto },\n    ]\n})\n\nconst currentThemeLabel = computed(() => {\n    const labels = getThemeLabels(currentLang.value)\n    return labels[themeMode.value]\n})\n\nconst onThemeSelect = (key) => {\n    if (!prefStore.allThemes.includes(key)) {\n        return\n    }\n    themeMode.value = key\n    prefStore.general.theme = key\n    localStorage.setItem(STORAGE_THEME_KEY, key)\n}\n\n// --- Language ---\nconst langNames = Object.fromEntries(Object.entries(lang).map(([k, v]) => [k, v.name]))\nconst autoLabel = Object.fromEntries(\n    Object.entries(lang).map(([k, v]) => [k, v.preferences?.general?.theme_auto || 'Auto']),\n)\n\nconst detectSystemLang = () => {\n    const sysLang = (navigator.language || '').toLowerCase()\n    if (sysLang.startsWith('zh-tw') || sysLang.startsWith('zh-hant')) {\n        return 'tw'\n    }\n    const prefix = sysLang.split('-')[0]\n    return langNames[prefix] ? prefix : 'en'\n}\n\nconst langSetting = ref(localStorage.getItem(STORAGE_LANG_KEY) || 'auto')\nconst currentLang = computed(() => (langSetting.value === 'auto' ? detectSystemLang() : langSetting.value))\n\nconst langOptions = computed(() => [\n    { label: autoLabel[currentLang.value] || 'Auto', key: 'auto' },\n    { type: 'divider' },\n    ...Object.entries(langNames).map(([k, v]) => ({ label: v, key: k })),\n])\n\nconst currentLangLabel = computed(() => {\n    if (langSetting.value === 'auto') return autoLabel[currentLang.value] || 'Auto'\n    return langNames[langSetting.value] || langSetting.value\n})\n\nconst onLangSelect = (key) => {\n    if (!prefStore.allLangs.includes(key)) {\n        return\n    }\n    langSetting.value = key\n    localStorage.setItem(STORAGE_LANG_KEY, key)\n}\n\nconst render = useRender()\n\n// --- i18n ---\nwatch(\n    currentLang,\n    (val) => {\n        i18n.locale.value = val\n    },\n    { immediate: true },\n)\n\n// --- Form ---\nconst username = ref('')\nconst password = ref('')\nconst loading = ref(false)\nconst errorMsg = ref('')\n\nconst canSubmit = computed(() => username.value.length > 0 && password.value.length > 0)\n\nconst handleLogin = async () => {\n    if (!canSubmit.value || loading.value) return\n    loading.value = true\n    errorMsg.value = ''\n\n    try {\n        const { msg, success = false } = await Login(username.value, password.value)\n        if (msg === 'too_many_attempts') {\n            errorMsg.value = i18nGlobal.t('login.too_many_attempts')\n            return\n        }\n        if (!success) {\n            errorMsg.value = i18nGlobal.t('login.invalid_credentials')\n            return\n        }\n        emit('login')\n    } catch (e) {\n        errorMsg.value = i18nGlobal.t('login.network_error')\n    } finally {\n        loading.value = false\n    }\n}\n</script>\n\n<template>\n    <div class=\"login-wrapper\">\n        <div class=\"login-card\">\n            <div class=\"login-header\">\n                <n-avatar :size=\"64\" :src=\"iconUrl\" color=\"#0000\" />\n                <div class=\"login-title\">Tiny RDM</div>\n                <!--                <n-text depth=\"3\" style=\"font-size: 13px\">Redis Web Manager</n-text>-->\n            </div>\n\n            <n-form class=\"login-form\" @submit.prevent=\"handleLogin\">\n                <n-form-item :label=\"$t('dialogue.connection.usr')\" :show-feedback=\"false\">\n                    <n-input\n                        v-model:value=\"username\"\n                        :placeholder=\"$t('login.username_placeholder')\"\n                        autofocus\n                        size=\"large\"\n                        @keydown.enter=\"handleLogin\" />\n                </n-form-item>\n                <n-form-item\n                    :feedback=\"errorMsg\"\n                    :label=\"$t('dialogue.connection.pwd')\"\n                    :validation-status=\"errorMsg ? 'error' : undefined\">\n                    <n-input\n                        v-model:value=\"password\"\n                        :placeholder=\"$t('login.password_placeholder')\"\n                        show-password-on=\"click\"\n                        size=\"large\"\n                        type=\"password\"\n                        @keydown.enter=\"handleLogin\" />\n                </n-form-item>\n\n                <n-button\n                    :disabled=\"!canSubmit\"\n                    :loading=\"loading\"\n                    attr-type=\"submit\"\n                    block\n                    size=\"large\"\n                    style=\"margin-top: 8px\"\n                    type=\"primary\"\n                    @click=\"handleLogin\">\n                    {{ $t('login.submit') }}\n                </n-button>\n            </n-form>\n\n            <div class=\"login-toolbar\">\n                <n-dropdown :options=\"langOptions\" size=\"small\" trigger=\"hover\" @select=\"onLangSelect\">\n                    <span class=\"toolbar-btn\">\n                        <n-icon :component=\"LangIcon\" :size=\"14\" />\n                        <span>{{ currentLangLabel }}</span>\n                    </span>\n                </n-dropdown>\n                <n-divider style=\"margin: 0 4px\" vertical />\n                <n-dropdown\n                    :options=\"themeOptions\"\n                    :render-icon=\"({ icon }) => render.renderIcon(icon)\"\n                    size=\"small\"\n                    trigger=\"hover\"\n                    @select=\"onThemeSelect\">\n                    <span class=\"toolbar-btn\">\n                        <n-icon\n                            :component=\"themeMode === 'dark' ? Moon : themeMode === 'light' ? Sun : ThemeAuto\"\n                            :size=\"14\" />\n                        <span>{{ currentThemeLabel }}</span>\n                    </span>\n                </n-dropdown>\n                <template v-if=\"prefStore.appVersion\">\n                    <n-divider style=\"margin: 0 4px\" vertical />\n                    <a\n                        class=\"toolbar-btn toolbar-link\"\n                        href=\"https://github.com/tiny-craft/tiny-rdm\"\n                        rel=\"noopener noreferrer\"\n                        target=\"_blank\">\n                        {{ prefStore.appVersion }}\n                    </a>\n                </template>\n            </div>\n        </div>\n    </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.login-wrapper {\n    width: 100vw;\n    height: 100vh;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    background: v-bind(bgGradient);\n    padding: 16px;\n    box-sizing: border-box;\n}\n\n.login-card {\n    width: 420px;\n    max-width: 100%;\n    padding: 48px 40px 36px;\n    border-radius: 10px;\n    border: 1px solid v-bind('themeVars.borderColor');\n    background-color: v-bind('themeVars.cardColor');\n    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);\n    box-sizing: border-box;\n}\n\n.login-header {\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    gap: 6px;\n    margin-bottom: 40px;\n}\n\n.login-title {\n    font-size: 24px;\n    font-weight: 800;\n    margin-top: 8px;\n    color: v-bind('themeVars.textColor1');\n}\n\n.login-form {\n    :deep(.n-form-item) {\n        margin-bottom: 18px;\n    }\n\n    :deep(.n-form-item-label) {\n        color: v-bind('themeVars.textColor1');\n        font-weight: 500;\n    }\n}\n\n.login-toolbar {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    margin-top: 28px;\n    flex-wrap: wrap;\n    gap: 2px;\n}\n\n.toolbar-btn {\n    display: inline-flex;\n    align-items: center;\n    gap: 4px;\n    font-size: 13px;\n    color: v-bind('themeVars.textColor3');\n    cursor: pointer;\n    padding: 2px 6px;\n    border-radius: 4px;\n    transition:\n        color 0.2s,\n        background-color 0.2s;\n    user-select: none;\n    white-space: nowrap;\n\n    &:hover {\n        color: v-bind('themeVars.textColor2');\n        background-color: v-bind('themeVars.buttonColor2Hover');\n    }\n}\n\n.toolbar-link {\n    text-decoration: none;\n    color: v-bind('themeVars.textColor3');\n\n    &:hover {\n        color: v-bind('themeVars.textColor2');\n    }\n}\n\n@media (max-width: 480px) {\n    .login-wrapper {\n        align-items: flex-start;\n        padding-top: 12vh;\n    }\n\n    .login-card {\n        padding: 32px 24px 28px;\n        border: none;\n        border-radius: 12px;\n    }\n\n    .login-header {\n        margin-bottom: 28px;\n    }\n\n    .login-toolbar {\n        margin-top: 20px;\n    }\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/common/AutoRefreshForm.vue",
    "content": "<script setup>\nimport { isNumber } from 'lodash'\n\nconst props = defineProps({\n    loading: {\n        type: Boolean,\n        default: false,\n    },\n    on: {\n        type: Boolean,\n        default: false,\n    },\n    defaultValue: {\n        type: Number,\n        default: 2,\n    },\n    interval: {\n        type: Number,\n        default: 2,\n    },\n    onRefresh: {\n        type: Function,\n        default: () => {},\n    },\n})\n\nconst emit = defineEmits(['toggle', 'update:on', 'update:interval'])\n\nconst onToggle = (on) => {\n    emit('update:on', on === true)\n    if (on) {\n        let interval = props.interval\n        if (!isNumber(interval)) {\n            interval = props.defaultValue\n        }\n        interval = Math.max(1, interval)\n        emit('update:interval', interval)\n        emit('toggle', true)\n    } else {\n        emit('toggle', false)\n    }\n}\n</script>\n\n<template>\n    <n-form :show-feedback=\"false\" label-align=\"right\" label-placement=\"left\" label-width=\"auto\" size=\"small\">\n        <n-form-item :label=\"$t('interface.auto_refresh')\">\n            <n-switch :loading=\"props.loading\" :value=\"props.on\" @update:value=\"onToggle\" />\n        </n-form-item>\n        <n-form-item :label=\"$t('interface.refresh_interval')\">\n            <n-input-number\n                :autofocus=\"false\"\n                :default-value=\"props.defaultValue\"\n                :disabled=\"props.on\"\n                :max=\"9999\"\n                :min=\"1\"\n                :show-button=\"false\"\n                :value=\"props.interval\"\n                style=\"max-width: 100px\"\n                @update:value=\"(val) => emit('update:interval', val)\">\n                <template #suffix>\n                    {{ $t('common.unit_second') }}\n                </template>\n            </n-input-number>\n        </n-form-item>\n    </n-form>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/common/DropdownSelector.vue",
    "content": "<script setup>\nimport { computed, h, ref } from 'vue'\nimport { get, isEmpty, some } from 'lodash'\nimport { NIcon, NText } from 'naive-ui'\nimport { useRender } from '@/utils/render.js'\nimport { useI18n } from 'vue-i18n'\n\nconst props = defineProps({\n    value: {\n        type: String,\n        value: '',\n    },\n    options: {\n        type: Array,\n        value: () => [],\n    },\n    menuOption: {\n        type: Array,\n        value: () => [],\n    },\n    tooltip: {\n        type: String,\n    },\n    icon: [String, Object],\n    default: String,\n    disabled: Boolean,\n})\n\nconst emit = defineEmits(['update:value', 'menu'])\nconst i18n = useI18n()\nconst render = useRender()\n\nconst renderHeader = () => {\n    return h('div', { class: 'type-selector-header' }, [h(NText, null, () => props.tooltip)])\n}\n\nconst dropdownOption = computed(() => {\n    const options = [\n        {\n            key: 'header',\n            type: 'render',\n            render: renderHeader,\n        },\n        {\n            key: 'header-divider',\n            type: 'divider',\n        },\n    ]\n    if (get(props.options, 0) instanceof Array) {\n        // multiple group\n        for (let i = 0; i < props.options.length; i++) {\n            if (i !== 0 && !isEmpty(props.options[i])) {\n                // add divider\n                options.push({\n                    key: 'header-divider' + (i + 1),\n                    type: 'divider',\n                })\n            }\n            for (const option of props.options[i]) {\n                options.push({\n                    key: option,\n                    label: option,\n                })\n            }\n        }\n    } else {\n        for (const option of props.options) {\n            options.push({\n                key: option,\n                label: option,\n            })\n        }\n    }\n\n    if (!isEmpty(props.menuOption)) {\n        options.push({\n            key: 'header-divider',\n            type: 'divider',\n        })\n        for (const { key, label } of props.menuOption) {\n            options.push({\n                key,\n                label: i18n.t(label),\n            })\n        }\n    }\n    return options\n})\n\nconst onDropdownSelect = (key) => {\n    if (some(props.menuOption, { key })) {\n        emit('menu', key)\n    } else {\n        emit('update:value', key)\n    }\n}\n\nconst buttonText = computed(() => {\n    return props.value || get(dropdownOption.value, [1, 'label'], props.default)\n})\n\nconst showDropdown = ref(false)\nconst onDropdownShow = (show) => {\n    showDropdown.value = show === true\n}\n</script>\n\n<template>\n    <n-dropdown\n        :disabled=\"props.disabled\"\n        :options=\"dropdownOption\"\n        :render-label=\"({ label }) => render.renderLabel(label, { class: 'type-selector-item' })\"\n        :show-arrow=\"true\"\n        :value=\"props.value\"\n        trigger=\"click\"\n        @select=\"onDropdownSelect\"\n        @update:show=\"onDropdownShow\">\n        <n-tooltip :disabled=\"showDropdown\" :show-arrow=\"false\">\n            {{ props.tooltip }}\n            <template #trigger>\n                <n-button :disabled=\"disabled\" :focusable=\"false\" quaternary>\n                    <template #icon>\n                        <n-icon>\n                            <component :is=\"icon\" />\n                        </n-icon>\n                    </template>\n                    {{ buttonText }}\n                </n-button>\n            </template>\n        </n-tooltip>\n    </n-dropdown>\n</template>\n\n<style lang=\"scss\">\n.type-selector-header {\n    height: 30px;\n    line-height: 30px;\n    font-size: 15px;\n    font-weight: bold;\n    text-align: center;\n    padding: 0 10px;\n}\n\n.type-selector-item {\n    min-width: 100px;\n    text-align: center;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/common/EditableTableColumn.vue",
    "content": "<script setup>\nimport IconButton from './IconButton.vue'\nimport Delete from '@/components/icons/Delete.vue'\nimport Edit from '@/components/icons/Edit.vue'\nimport Close from '@/components/icons/Close.vue'\nimport Save from '@/components/icons/Save.vue'\nimport Copy from '@/components/icons/Copy.vue'\nimport Refresh from '@/components/icons/Refresh.vue'\n\nconst props = defineProps({\n    bindKey: String,\n    editing: Boolean,\n    readonly: Boolean,\n    canRefresh: Boolean,\n})\n\nconst emit = defineEmits(['edit', 'delete', 'copy', 'refresh', 'save', 'cancel'])\n</script>\n\n<template>\n    <div v-if=\"props.editing\" class=\"flex-box-h edit-column-func\">\n        <icon-button :icon=\"Save\" @click=\"emit('save')\" />\n        <icon-button :icon=\"Close\" @click=\"emit('cancel')\" />\n    </div>\n    <div v-else class=\"flex-box-h edit-column-func\">\n        <icon-button :icon=\"Copy\" :title=\"$t('interface.copy_value')\" @click=\"emit('copy')\" />\n        <icon-button v-if=\"props.canRefresh\" :icon=\"Refresh\" :title=\"$t('interface.reload')\" @click=\"emit('refresh')\" />\n        <icon-button v-if=\"!props.readonly\" :icon=\"Edit\" :title=\"$t('interface.edit_row')\" @click=\"emit('edit')\" />\n        <n-popconfirm\n            v-if=\"props.bindKey\"\n            :negative-text=\"$t('common.cancel')\"\n            :positive-text=\"$t('common.confirm')\"\n            @positive-click=\"emit('delete')\">\n            <template #trigger>\n                <icon-button :icon=\"Delete\" :title=\"$t('interface.delete_row')\" />\n            </template>\n            {{ $t('dialogue.remove_tip', { name: props.bindKey }) }}\n        </n-popconfirm>\n    </div>\n</template>\n\n<style lang=\"scss\">\n.edit-column-func {\n    align-items: center;\n    justify-content: center;\n    gap: 10px;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/common/EditableTableRow.vue",
    "content": "<script setup>\nimport { NInput } from 'naive-ui'\n\nconst props = defineProps({\n    isEdit: Boolean,\n    value: [String, Number],\n    onUpdateValue: [Function, Array],\n})\n\nconst emit = defineEmits(['update:value'])\n\nconst handleUpdateValue = (val) => {\n    emit('update:value', val)\n}\n</script>\n\n<template>\n    <div style=\"min-height: 22px\">\n        <template v-if=\"props.isEdit\">\n            <n-input :value=\"props.value\" @update:value=\"handleUpdateValue\" />\n        </template>\n        <template v-else>\n            {{ props.value }}\n        </template>\n    </div>\n</template>\n"
  },
  {
    "path": "frontend/src/components/common/FileOpenInput.vue",
    "content": "<script setup>\nimport { SelectFile } from 'wailsjs/go/services/systemService.js'\nimport { get, isEmpty } from 'lodash'\n\nconst props = defineProps({\n    value: String,\n    placeholder: String,\n    disabled: Boolean,\n    ext: String,\n})\n\nconst emit = defineEmits(['update:value'])\n\nconst onInput = (val) => {\n    emit('update:value', val)\n}\n\nconst onClear = () => {\n    emit('update:value', '')\n}\n\nconst handleSelectFile = async () => {\n    const { success, data } = await SelectFile('', isEmpty(props.ext) ? null : [props.ext])\n    if (success) {\n        const path = get(data, 'path', '')\n        emit('update:value', path)\n    } else {\n        // emit('update:value', '')\n    }\n}\n</script>\n\n<template>\n    <n-input-group>\n        <n-input\n            :disabled=\"props.disabled\"\n            :placeholder=\"placeholder\"\n            :title=\"props.value\"\n            :value=\"props.value\"\n            clearable\n            @clear=\"onClear\"\n            @input=\"onInput\" />\n        <n-button :disabled=\"props.disabled\" :focusable=\"false\" @click=\"handleSelectFile\">...</n-button>\n    </n-input-group>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/common/FileSaveInput.vue",
    "content": "<script setup>\nimport { SaveFile } from 'wailsjs/go/services/systemService.js'\nimport { get } from 'lodash'\n\nconst props = defineProps({\n    value: String,\n    placeholder: String,\n    disabled: Boolean,\n    defaultPath: String,\n})\n\nconst emit = defineEmits(['update:value'])\n\nconst onInput = (val) => {\n    emit('update:value', val)\n}\n\nconst onClear = () => {\n    emit('update:value', '')\n}\n\nconst handleSaveFile = async () => {\n    const { success, data } = await SaveFile(null, props.defaultPath, ['csv'])\n    if (success) {\n        const path = get(data, 'path', '')\n        emit('update:value', path)\n    } else {\n        emit('update:value', '')\n    }\n}\n</script>\n\n<template>\n    <n-input-group>\n        <n-input\n            :disabled=\"props.disabled\"\n            :placeholder=\"placeholder\"\n            :value=\"props.value\"\n            clearable\n            @clear=\"onClear\"\n            @input=\"onInput\" />\n        <n-button :disabled=\"props.disabled\" :focusable=\"false\" @click=\"handleSaveFile\">...</n-button>\n    </n-input-group>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/common/IconButton.vue",
    "content": "<script setup>\nimport { computed, useSlots } from 'vue'\nimport { NIcon } from 'naive-ui'\n\nconst props = defineProps({\n    tooltip: String,\n    tTooltip: String,\n    tooltipDelay: {\n        type: Number,\n        default: 800,\n    },\n    type: String,\n    icon: [String, Object],\n    size: {\n        type: [Number, String],\n        default: 20,\n    },\n    color: {\n        type: String,\n        default: '',\n    },\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n    loading: Boolean,\n    border: Boolean,\n    disabled: Boolean,\n    buttonStyle: [String, Object],\n    buttonClass: [String, Object],\n    small: Boolean,\n    secondary: Boolean,\n    tertiary: Boolean,\n})\n\nconst emit = defineEmits(['click'])\n\nconst slots = useSlots()\n\nconst hasTooltip = computed(() => {\n    return props.tooltip || props.tTooltip || slots.tooltip\n})\n</script>\n\n<template>\n    <n-tooltip v-if=\"hasTooltip\" :delay=\"tooltipDelay\" :keep-alive-on-hover=\"false\" :show-arrow=\"false\">\n        <template #trigger>\n            <n-button\n                :class=\"props.buttonClass\"\n                :color=\"props.color\"\n                :disabled=\"props.disabled\"\n                :focusable=\"false\"\n                :loading=\"loading\"\n                :secondary=\"props.secondary\"\n                :size=\"props.small ? 'small' : ''\"\n                :style=\"props.buttonStyle\"\n                :tertiary=\"props.tertiary\"\n                :text=\"!props.border\"\n                :type=\"props.type\"\n                @click.prevent=\"emit('click')\">\n                <template #icon>\n                    <slot>\n                        <n-icon :color=\"props.color || 'currentColor'\" :size=\"props.size\">\n                            <component :is=\"props.icon\" :stroke-width=\"props.strokeWidth\" />\n                        </n-icon>\n                    </slot>\n                </template>\n            </n-button>\n        </template>\n        <slot name=\"tooltip\">\n            {{ props.tTooltip ? $t(props.tTooltip) : props.tooltip }}\n        </slot>\n    </n-tooltip>\n    <n-button\n        v-else\n        :class=\"props.buttonClass\"\n        :color=\"props.color\"\n        :disabled=\"props.disabled\"\n        :focusable=\"false\"\n        :loading=\"loading\"\n        :secondary=\"props.secondary\"\n        :size=\"props.small ? 'small' : ''\"\n        :style=\"props.buttonStyle\"\n        :tertiary=\"props.tertiary\"\n        :text=\"!props.border\"\n        :type=\"props.type\"\n        @click.prevent=\"emit('click')\">\n        <template #icon>\n            <slot>\n                <n-icon :color=\"props.color || 'currentColor'\" :size=\"props.size\">\n                    <component :is=\"props.icon\" :stroke-width=\"props.strokeWidth\" />\n                </n-icon>\n            </slot>\n        </template>\n    </n-button>\n</template>\n\n<style lang=\"scss\"></style>\n"
  },
  {
    "path": "frontend/src/components/common/RedisTypeSelector.vue",
    "content": "<script setup>\nimport { computed, h } from 'vue'\nimport { NSpace, useThemeVars } from 'naive-ui'\nimport { types, typesBgColor, typesColor, typesShortName } from '@/consts/support_redis_type.js'\nimport { get, isEmpty, map, toUpper } from 'lodash'\nimport RedisTypeTag from '@/components/common/RedisTypeTag.vue'\n\nconst props = defineProps({\n    value: {\n        type: String,\n        default: 'ALL',\n    },\n    placement: {\n        type: String,\n        default: 'bottom-start',\n    },\n    disabled: {\n        type: Boolean,\n        default: false,\n    },\n    disableTip: {\n        type: String,\n        default: '',\n    },\n})\n\nconst emit = defineEmits(['update:value', 'select'])\n\nconst options = computed(() => {\n    const opts = map(types, (v) => ({\n        label: v,\n        key: v,\n    }))\n    return [{ label: 'ALL', key: 'ALL' }, ...opts]\n})\n\nconst themeVars = useThemeVars()\nconst renderIcon = (option) => {\n    return h(RedisTypeTag, {\n        type: option.key,\n        defaultLabel: 'A',\n        short: true,\n        size: 'small',\n        inverse: option.key === props.value,\n    })\n}\n\nconst renderLabel = (option) => {\n    const children = [\n        h(\n            'div',\n            {\n                style: {\n                    fontWeight: option.key === props.value ? 'bold' : 'normal',\n                },\n            },\n            option.label,\n        ),\n        h(\n            'div',\n            { style: { width: '16px' } },\n            h(RedisTypeTag, {\n                type: toUpper(option.key),\n                point: true,\n                style: { display: option.key === props.value ? 'block' : 'none' },\n            }),\n        ),\n    ]\n    return h(NSpace, { align: 'center', wrapItem: false }, () => children)\n}\n\nconst fontColor = computed(() => {\n    return get(typesColor, props.value, '')\n})\n\nconst backgroundColor = computed(() => {\n    return get(typesBgColor, props.value, '')\n})\n\nconst displayValue = computed(() => {\n    return get(typesShortName, toUpper(props.value), 'A')\n})\n\nconst handleSelect = (select) => {\n    if (props.value !== select) {\n        emit('update:value', select)\n        emit('select', select)\n    }\n}\n</script>\n\n<template>\n    <template v-if=\"props.disabled\">\n        <n-tooltip :disabled=\"isEmpty(props.disableTip)\">\n            <div>{{ props.disableTip }}</div>\n            <template #trigger>\n                <n-tag\n                    :bordered=\"true\"\n                    :color=\"{ color: backgroundColor, textColor: fontColor }\"\n                    class=\"redis-tag\"\n                    disabled\n                    size=\"medium\"\n                    strong>\n                    {{ displayValue }}\n                </n-tag>\n            </template>\n        </n-tooltip>\n    </template>\n    <template v-else>\n        <n-dropdown\n            :disabled=\"props.disabled\"\n            :options=\"options\"\n            :placement=\"props.placement\"\n            :render-icon=\"renderIcon\"\n            :render-label=\"renderLabel\"\n            show-arrow\n            @select=\"handleSelect\">\n            <n-tag\n                :bordered=\"true\"\n                :color=\"{ color: backgroundColor, textColor: fontColor }\"\n                :disabled=\"props.disabled\"\n                class=\"redis-tag\"\n                size=\"medium\"\n                strong>\n                {{ displayValue }}\n            </n-tag>\n        </n-dropdown>\n    </template>\n</template>\n\n<style lang=\"scss\" scoped>\n.redis-tag {\n    padding: 0 10px;\n}\n\n:deep(.dropdown-type-item) {\n    padding: 10px;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/common/RedisTypeTag.vue",
    "content": "<script setup>\nimport { computed } from 'vue'\nimport { typesBgColor, typesColor, typesShortName } from '@/consts/support_redis_type.js'\nimport Binary from '@/components/icons/Binary.vue'\nimport { get, toUpper } from 'lodash'\nimport { useThemeVars } from 'naive-ui'\nimport Loading from '@/components/icons/Loading.vue'\n\nconst props = defineProps({\n    type: {\n        type: String,\n        default: 'STRING',\n    },\n    defaultLabel: String,\n    binaryKey: Boolean,\n    size: String,\n    short: Boolean,\n    point: Boolean,\n    pointSize: {\n        type: Number,\n        default: 14,\n    },\n    round: Boolean,\n    inverse: Boolean,\n    loading: Boolean,\n})\n\nconst themeVars = useThemeVars()\n\nconst fontColor = computed(() => {\n    if (props.inverse) {\n        return props.loading ? themeVars.value.tagColor : typesBgColor[props.type]\n    } else {\n        return props.loading ? themeVars.value.textColorBase : typesColor[props.type]\n    }\n})\n\nconst backgroundColor = computed(() => {\n    if (props.inverse) {\n        return props.loading ? themeVars.value.textColorBase : typesColor[props.type]\n    } else {\n        return props.loading ? themeVars.value.tagColor : typesBgColor[props.type]\n    }\n})\n\nconst label = computed(() => {\n    if (props.short) {\n        return get(typesShortName, toUpper(props.type), props.defaultLabel || 'N')\n    }\n    return toUpper(props.type)\n})\n</script>\n\n<template>\n    <div\n        v-if=\"props.point\"\n        :class=\"{ 'redis-type-tag-loading': props.loading }\"\n        :style=\"{\n            backgroundColor: fontColor,\n            width: Math.max(props.pointSize, 5) + 'px',\n            height: Math.max(props.pointSize, 5) + 'px',\n        }\"\n        class=\"redis-type-tag-round redis-type-tag-point\" />\n    <n-tag\n        v-else\n        :class=\"{\n            'redis-type-tag-normal': !props.short && props.size !== 'small',\n            'redis-type-tag-small': !props.short && props.size === 'small',\n            'redis-type-tag-round': props.round,\n            'redis-type-tag-loading': props.loading,\n            'redis-type-tag': props.short,\n        }\"\n        :color=\"{ color: backgroundColor, textColor: fontColor }\"\n        :size=\"props.size\"\n        bordered\n        strong>\n        <b v-if=\"!props.loading\">{{ label }}</b>\n        <n-icon v-else-if=\"props.short\" size=\"14\">\n            <loading stroke-width=\"4\" />\n        </n-icon>\n        <b v-else>LOADING</b>\n        <template #icon>\n            <n-icon v-if=\"binaryKey\" :component=\"Binary\" size=\"18\" />\n        </template>\n    </n-tag>\n</template>\n\n<style lang=\"scss\">\n.redis-type-tag-round {\n    border-radius: 9999px;\n}\n\n.redis-type-tag-normal {\n    padding: 0 12px;\n}\n\n.redis-type-tag-small {\n    padding: 0 5px;\n}\n\n.redis-type-tag-loading {\n    animation: fadeInOut 2s infinite;\n}\n\n@keyframes fadeInOut {\n    0% {\n        opacity: 0.4;\n    }\n    50% {\n        opacity: 1;\n    }\n    100% {\n        opacity: 0.4;\n    }\n}\n\n.redis-type-tag {\n    width: 22px;\n    height: 22px;\n    justify-content: center;\n    align-items: center;\n    text-align: center;\n    vertical-align: middle;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/common/ResizeableWrapper.vue",
    "content": "<script setup>\nimport { useThemeVars } from 'naive-ui'\nimport { ref } from 'vue'\n\n/**\n * Resizeable component wrapper\n */\nconst themeVars = useThemeVars()\n\nconst props = defineProps({\n    size: {\n        type: Number,\n        default: 100,\n    },\n    minSize: {\n        type: Number,\n        default: 300,\n    },\n    maxSize: {\n        type: Number,\n        default: 0,\n    },\n    offset: {\n        type: Number,\n        default: 0,\n    },\n    disabled: {\n        type: Boolean,\n        default: false,\n    },\n    borderWidth: {\n        type: Number,\n        default: 4,\n    },\n})\n\nconst emit = defineEmits(['update:size'])\n\nconst resizing = ref(false)\nconst hover = ref(false)\n\nconst handleResize = (evt) => {\n    if (resizing.value) {\n        let size = evt.clientX - props.offset\n        if (size < props.minSize) {\n            size = props.minSize\n        }\n        if (props.maxSize > 0 && size > props.maxSize) {\n            size = props.maxSize\n        }\n        emit('update:size', size)\n    }\n}\n\nconst stopResize = () => {\n    resizing.value = false\n    document.removeEventListener('mousemove', handleResize)\n    document.removeEventListener('mouseup', stopResize)\n}\n\nconst startResize = () => {\n    if (props.disabled) {\n        return\n    }\n    resizing.value = true\n    document.addEventListener('mousemove', handleResize)\n    document.addEventListener('mouseup', stopResize)\n}\n\nconst handleMouseOver = () => {\n    if (props.disabled) {\n        return\n    }\n    hover.value = true\n}\n</script>\n\n<template>\n    <div :style=\"{ width: props.size + 'px' }\" class=\"resize-wrapper flex-box-h\">\n        <slot></slot>\n        <div\n            :class=\"{\n                'resize-divider-hover': hover,\n                'resize-divider-drag': resizing,\n                dragging: hover || resizing,\n            }\"\n            :style=\"{ width: props.borderWidth + 'px', right: Math.floor(-props.borderWidth / 2) + 'px' }\"\n            class=\"resize-divider\"\n            @mousedown=\"startResize\"\n            @mouseout=\"hover = false\"\n            @mouseover=\"handleMouseOver\" />\n    </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.resize-wrapper {\n    position: relative;\n\n    .resize-divider {\n        position: absolute;\n        top: 0;\n        bottom: 0;\n        transition: background-color 0.3s ease-in;\n        z-index: 1;\n    }\n\n    .resize-divider-hide {\n        background-color: #0000;\n    }\n\n    .resize-divider-hover {\n        background-color: v-bind('themeVars.borderColor');\n    }\n\n    .resize-divider-drag {\n        background-color: v-bind('themeVars.primaryColor');\n    }\n\n    .dragging {\n        cursor: col-resize !important;\n    }\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/common/SwitchButton.vue",
    "content": "<script setup>\nimport { NIcon } from 'naive-ui'\n\nconst props = defineProps({\n    value: {\n        type: Number,\n        default: 0,\n    },\n    size: {\n        type: String,\n        default: 'small',\n    },\n    icons: Array,\n    tTooltips: Array,\n    tTooltipPlacement: {\n        type: String,\n        default: 'bottom',\n    },\n    iconSize: {\n        type: [Number, String],\n        default: 20,\n    },\n    color: {\n        type: String,\n        default: 'currentColor',\n    },\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n    unselectStrokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n\nconst emit = defineEmits(['update:value'])\n\nconst handleSwitch = (idx) => {\n    if (idx !== props.value) {\n        emit('update:value', idx)\n    }\n}\n</script>\n\n<template>\n    <n-button-group>\n        <n-tooltip\n            v-for=\"(icon, i) in props.icons\"\n            :key=\"i\"\n            :disabled=\"!(props.tTooltips && props.tTooltips[i])\"\n            :placement=\"props.tTooltipPlacement\"\n            :show-arrow=\"false\">\n            <template #trigger>\n                <n-button :focusable=\"false\" :size=\"props.size\" :tertiary=\"i !== props.value\" @click=\"handleSwitch(i)\">\n                    <template #icon>\n                        <n-icon :size=\"props.iconSize\">\n                            <component\n                                :is=\"icon\"\n                                :stroke-width=\"i !== props.value ? props.unselectStrokeWidth : props.strokeWidth\" />\n                        </n-icon>\n                    </template>\n                </n-button>\n            </template>\n            {{ props.tTooltips ? $t(props.tTooltips[i]) : '' }}\n        </n-tooltip>\n    </n-button-group>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/common/ToolbarControlWidget.vue",
    "content": "<script setup>\nimport WindowMin from '@/components/icons/WindowMin.vue'\nimport WindowMax from '@/components/icons/WindowMax.vue'\nimport WindowClose from '@/components/icons/WindowClose.vue'\nimport { computed } from 'vue'\nimport { useThemeVars } from 'naive-ui'\nimport { Quit, WindowMinimise, WindowToggleMaximise } from 'wailsjs/runtime/runtime.js'\nimport WindowRestore from '@/components/icons/WindowRestore.vue'\n\nconst themeVars = useThemeVars()\nconst props = defineProps({\n    size: {\n        type: Number,\n        default: 35,\n    },\n    maximised: {\n        type: Boolean,\n    },\n})\n\nconst buttonSize = computed(() => {\n    return props.size + 'px'\n})\n\nconst handleMinimise = async () => {\n    WindowMinimise()\n}\n\nconst handleMaximise = () => {\n    WindowToggleMaximise()\n}\n\nconst handleClose = () => {\n    Quit()\n}\n</script>\n\n<template>\n    <n-space :size=\"0\" :wrap-item=\"false\" align=\"center\" justify=\"center\">\n        <n-tooltip :delay=\"1000\" :show-arrow=\"false\">\n            {{ $t('menu.minimise') }}\n            <template #trigger>\n                <div class=\"btn-wrapper\" @click=\"handleMinimise\">\n                    <window-min />\n                </div>\n            </template>\n        </n-tooltip>\n        <n-tooltip v-if=\"maximised\" :delay=\"1000\" :show-arrow=\"false\">\n            {{ $t('menu.restore') }}\n            <template #trigger>\n                <div class=\"btn-wrapper\" @click=\"handleMaximise\">\n                    <window-restore />\n                </div>\n            </template>\n        </n-tooltip>\n        <n-tooltip v-else :delay=\"1000\" :show-arrow=\"false\">\n            {{ $t('menu.maximise') }}\n            <template #trigger>\n                <div class=\"btn-wrapper\" @click=\"handleMaximise\">\n                    <window-max />\n                </div>\n            </template>\n        </n-tooltip>\n        <n-tooltip :delay=\"1000\" :show-arrow=\"false\">\n            {{ $t('menu.close') }}\n            <template #trigger>\n                <div class=\"btn-wrapper\" @click=\"handleClose\">\n                    <window-close />\n                </div>\n            </template>\n        </n-tooltip>\n    </n-space>\n</template>\n\n<style lang=\"scss\" scoped>\n.btn-wrapper {\n    width: v-bind('buttonSize');\n    height: v-bind('buttonSize');\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n    --wails-draggable: none;\n\n    &:hover {\n        cursor: pointer;\n    }\n\n    &:not(:last-child) {\n        &:hover {\n            background-color: v-bind('themeVars.closeColorHover');\n        }\n\n        &:active {\n            background-color: v-bind('themeVars.closeColorPressed');\n        }\n    }\n\n    &:last-child {\n        &:hover {\n            background-color: v-bind('themeVars.primaryColorHover');\n        }\n\n        &:active {\n            background-color: v-bind('themeVars.primaryColorPressed');\n        }\n    }\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/common/TtlInput.vue",
    "content": "<script setup>\nimport { computed } from 'vue'\n\nconst props = defineProps({\n    value: {\n        type: Number,\n        default: -1,\n    },\n    unit: {\n        type: Number,\n        default: 1,\n    },\n})\n\nconst emit = defineEmits(['update:value', 'update:unit'])\n\nconst unit = [\n    {\n        value: 1,\n        label: 'common.second',\n    },\n    {\n        value: 60,\n        label: 'common.minute',\n    },\n    {\n        value: 3600,\n        label: 'common.hour',\n    },\n    {\n        value: 86400,\n        label: 'common.day',\n    },\n]\n\nconst unitValue = computed(() => {\n    switch (props.unit) {\n        case 60:\n            return 60\n        case 3600:\n            return 3600\n        case 86400:\n            return 86400\n        default:\n            return 1\n    }\n})\n</script>\n\n<template>\n    <n-input-group>\n        <n-input-number\n            :max=\"Number.MAX_SAFE_INTEGER\"\n            :min=\"-1\"\n            :show-button=\"false\"\n            :value=\"props.value\"\n            class=\"flex-item-expand\"\n            @update:value=\"(val) => emit('update:value', val)\" />\n        <n-select\n            :options=\"unit\"\n            :render-label=\"({ label }) => $t(label)\"\n            :value=\"unitValue\"\n            style=\"max-width: 150px\"\n            @update:value=\"(val) => emit('update:unit', val)\" />\n    </n-input-group>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/content/ContentLogPane.vue",
    "content": "<script setup>\nimport { computed, h, nextTick, reactive, ref } from 'vue'\nimport IconButton from '@/components/common/IconButton.vue'\nimport Refresh from '@/components/icons/Refresh.vue'\nimport { map, size, split, uniqBy } from 'lodash'\nimport { useI18n } from 'vue-i18n'\nimport Delete from '@/components/icons/Delete.vue'\nimport dayjs from 'dayjs'\nimport { useThemeVars } from 'naive-ui'\nimport useBrowserStore from 'stores/browser.js'\n\nconst themeVars = useThemeVars()\n\nconst browserStore = useBrowserStore()\nconst i18n = useI18n()\nconst data = reactive({\n    loading: false,\n    server: '',\n    keyword: '',\n    history: [],\n})\nconst filterServerOption = computed(() => {\n    const serverSet = uniqBy(data.history, 'server')\n    const options = map(serverSet, ({ server }) => ({\n        label: server,\n        value: server,\n    }))\n    options.splice(0, 0, {\n        label: 'common.all',\n        value: '',\n    })\n    return options\n})\n\nconst tableRef = ref(null)\n\nconst columns = computed(() => [\n    {\n        title: () => i18n.t('log.exec_time'),\n        key: 'timestamp',\n        defaultSortOrder: 'ascend',\n        sorter: 'default',\n        width: 180,\n        align: 'center',\n        titleAlign: 'center',\n        render: ({ timestamp }, index) => {\n            return dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')\n        },\n    },\n    {\n        title: () => i18n.t('log.server'),\n        key: 'server',\n        filterOptionValue: data.server,\n        filter: (value, row) => {\n            return value === '' || row.server === value.toString()\n        },\n        width: 150,\n        align: 'center',\n        titleAlign: 'center',\n        ellipsis: {\n            tooltip: true,\n        },\n    },\n    {\n        title: () => i18n.t('log.cmd'),\n        key: 'cmd',\n        titleAlign: 'center',\n        filterOptionValue: data.keyword,\n        resizable: true,\n        filter: (value, row) => {\n            return value === '' || !!~row.cmd.indexOf(value.toString())\n        },\n        render: ({ cmd }, index) => {\n            const cmdList = split(cmd, '\\n')\n            if (size(cmdList) > 1) {\n                return h(\n                    'div',\n                    null,\n                    map(cmdList, (c) => h('div', { class: 'cmd-line' }, c)),\n                )\n            }\n            return h('div', { class: 'cmd-line' }, cmd)\n        },\n    },\n    {\n        title: () => i18n.t('log.cost_time'),\n        key: 'cost',\n        width: 100,\n        align: 'center',\n        titleAlign: 'center',\n        render: ({ cost }, index) => {\n            const ms = dayjs.duration(cost).asMilliseconds()\n            if (ms < 1000) {\n                return `${ms} ms`\n            } else {\n                return `${Math.floor(ms / 1000)} s`\n            }\n        },\n    },\n])\n\nconst loadHistory = async () => {\n    try {\n        await nextTick()\n        data.loading = true\n        const list = await browserStore.getCmdHistory()\n        data.history = list || []\n    } finally {\n        data.loading = false\n        await nextTick()\n        tableRef.value?.scrollTo({ position: 'bottom' })\n    }\n}\n\nconst cleanHistory = async () => {\n    $dialog.warning(i18n.t('log.confirm_clean_log'), async () => {\n        try {\n            data.loading = true\n            const success = await browserStore.cleanCmdHistory()\n            if (success) {\n                data.history = []\n                await nextTick()\n                tableRef.value?.scrollTo({ position: 'top' })\n                $message.success(i18n.t('dialogue.handle_succ'))\n            }\n        } finally {\n            data.loading = false\n        }\n    })\n}\n\ndefineExpose({\n    refresh: loadHistory,\n})\n</script>\n\n<template>\n    <div class=\"content-log content-container content-value fill-height flex-box-v\">\n        <n-h3>{{ $t('log.title') }}</n-h3>\n        <n-form :disabled=\"data.loading\" class=\"flex-item\" inline>\n            <n-form-item :label=\"$t('log.filter_server')\">\n                <n-select\n                    v-model:value=\"data.server\"\n                    :consistent-menu-width=\"false\"\n                    :options=\"filterServerOption\"\n                    :render-label=\"({ label, value }) => (value === '' ? $t(label) : label)\"\n                    style=\"min-width: 100px\" />\n            </n-form-item>\n            <n-form-item :label=\"$t('log.filter_keyword')\">\n                <n-input v-model:value=\"data.keyword\" clearable placeholder=\"\" />\n            </n-form-item>\n            <n-form-item label=\"&nbsp;\">\n                <icon-button :icon=\"Refresh\" border t-tooltip=\"log.refresh\" @click=\"loadHistory\" />\n            </n-form-item>\n            <n-form-item label=\"&nbsp;\">\n                <icon-button :icon=\"Delete\" border t-tooltip=\"log.clean_log\" @click=\"cleanHistory\" />\n            </n-form-item>\n        </n-form>\n        <n-data-table\n            ref=\"tableRef\"\n            :columns=\"columns\"\n            :data=\"data.history\"\n            :loading=\"data.loading\"\n            class=\"flex-item-expand\"\n            flex-height\n            virtual-scroll />\n    </div>\n</template>\n\n<style lang=\"scss\" scoped>\n@use '@/styles/content';\n</style>\n"
  },
  {
    "path": "frontend/src/components/content/ContentPane.vue",
    "content": "<script setup>\nimport { computed, nextTick, ref, watch } from 'vue'\nimport { find, map, toUpper } from 'lodash'\nimport useTabStore from 'stores/tab.js'\nimport ContentServerStatus from '@/components/content_value/ContentServerStatus.vue'\nimport Status from '@/components/icons/Status.vue'\nimport { useThemeVars } from 'naive-ui'\nimport { BrowserTabType } from '@/consts/browser_tab_type.js'\nimport Terminal from '@/components/icons/Terminal.vue'\nimport Log from '@/components/icons/Log.vue'\nimport Detail from '@/components/icons/Detail.vue'\nimport ContentValueWrapper from '@/components/content_value/ContentValueWrapper.vue'\nimport ContentCli from '@/components/content_value/ContentCli.vue'\nimport Monitor from '@/components/icons/Monitor.vue'\nimport ContentSlog from '@/components/content_value/ContentSlog.vue'\nimport ContentMonitor from '@/components/content_value/ContentMonitor.vue'\nimport { decodeRedisKey } from '@/utils/key_convert.js'\nimport ContentPubsub from '@/components/content_value/ContentPubsub.vue'\nimport Subscribe from '@/components/icons/Subscribe.vue'\n\nconst themeVars = useThemeVars()\n\n/**\n * @typedef {Object} ServerStatusItem\n * @property {string} name\n * @property {Object} info\n * @property {boolean} autoRefresh\n * @property {boolean} loading loading status for refresh\n * @property {boolean} autoLoading loading status for auto refresh\n */\n\nconst props = defineProps({\n    server: String,\n})\n\nconst tabStore = useTabStore()\nconst tab = computed(() =>\n    map(tabStore.tabs, (item) => ({\n        key: item.name,\n        label: item.title,\n    })),\n)\n\nconst tabContent = computed(() => {\n    const tab = find(tabStore.tabs, { name: props.server })\n    if (tab == null) {\n        return {}\n    }\n    return {\n        name: tab.name,\n        subTab: tab.subTab,\n        type: toUpper(tab.type),\n        db: tab.db,\n        keyPath: tab.keyCode != null ? decodeRedisKey(tab.keyCode) : tab.key,\n        keyCode: tab.keyCode,\n        ttl: tab.ttl,\n        value: tab.value,\n        size: tab.size || 0,\n        length: tab.length || 0,\n        decode: tab.decode,\n        format: tab.format,\n        matchPattern: tab.matchPattern || '',\n        end: tab.end === true,\n        loading: tab.loading === true,\n    }\n})\n\nconst isBlankValue = computed(() => {\n    return tabContent.value?.keyPath == null\n})\n\nconst selectedSubTab = computed(() => {\n    const { subTab = BrowserTabType.Status } = tabStore.currentTab || {}\n    return subTab\n})\n\n// BUG: naive-ui tabs will set the bottom line to '0px' after switch to another page and back again\n// watch parent tabs' changing and call 'syncBarPosition' manually\nconst tabsRef = ref(null)\nconst cliRef = ref(null)\nwatch(\n    () => tabContent.value?.name,\n    (name) => {\n        if (name === props.server) {\n            nextTick().then(() => {\n                tabsRef.value?.syncBarPosition()\n                cliRef.value?.resizeTerm()\n            })\n        }\n    },\n)\n</script>\n\n<template>\n    <div class=\"content-container flex-box-v\">\n        <n-tabs\n            ref=\"tabsRef\"\n            :tabs-padding=\"5\"\n            :theme-overrides=\"{\n                tabFontWeightActive: 'normal',\n                tabGapSmallLine: '10px',\n                tabGapMediumLine: '10px',\n                tabGapLargeLine: '10px',\n            }\"\n            :value=\"selectedSubTab\"\n            class=\"content-sub-tab\"\n            :default-value=\"BrowserTabType.Status.toString()\"\n            pane-class=\"content-sub-tab-pane\"\n            placement=\"top\"\n            tab-style=\"padding-left: 10px; padding-right: 10px;\"\n            type=\"line\"\n            @update:value=\"tabStore.switchSubTab\">\n            <!-- server status pane -->\n            <n-tab-pane :name=\"BrowserTabType.Status.toString()\" display-directive=\"show:lazy\">\n                <template #tab>\n                    <n-space :size=\"5\" :wrap-item=\"false\" align=\"center\" inline justify=\"center\">\n                        <n-icon size=\"16\">\n                            <status\n                                :inverse=\"selectedSubTab === BrowserTabType.Status.toString()\"\n                                :stroke-color=\"themeVars.tabColor\"\n                                stroke-width=\"4\" />\n                        </n-icon>\n                        <span>{{ $t('interface.sub_tab.status') }}</span>\n                    </n-space>\n                </template>\n                <content-server-status\n                    :pause=\"selectedSubTab !== BrowserTabType.Status.toString()\"\n                    :server=\"props.server\" />\n            </n-tab-pane>\n\n            <!-- key detail pane -->\n            <n-tab-pane :name=\"BrowserTabType.KeyDetail.toString()\" display-directive=\"show:lazy\">\n                <template #tab>\n                    <n-space :size=\"5\" :wrap-item=\"false\" align=\"center\" inline justify=\"center\">\n                        <n-icon size=\"16\">\n                            <detail\n                                :inverse=\"selectedSubTab === BrowserTabType.KeyDetail.toString()\"\n                                :stroke-color=\"themeVars.tabColor\"\n                                stroke-width=\"4\" />\n                        </n-icon>\n                        <span>{{ $t('interface.sub_tab.key_detail') }}</span>\n                    </n-space>\n                </template>\n                <content-value-wrapper :blank=\"isBlankValue\" :content=\"tabContent\" />\n            </n-tab-pane>\n\n            <!-- cli pane -->\n            <n-tab-pane :name=\"BrowserTabType.Cli.toString()\" display-directive=\"show:lazy\">\n                <template #tab>\n                    <n-space :size=\"5\" :wrap-item=\"false\" align=\"center\" inline justify=\"center\">\n                        <n-icon size=\"16\">\n                            <terminal\n                                :inverse=\"selectedSubTab === BrowserTabType.Cli.toString()\"\n                                :stroke-color=\"themeVars.tabColor\"\n                                stroke-width=\"4\" />\n                        </n-icon>\n                        <span>{{ $t('interface.sub_tab.cli') }}</span>\n                    </n-space>\n                </template>\n                <content-cli ref=\"cliRef\" :name=\"props.server\" />\n            </n-tab-pane>\n\n            <!-- slow log pane -->\n            <n-tab-pane :name=\"BrowserTabType.SlowLog.toString()\" display-directive=\"show:lazy\">\n                <template #tab>\n                    <n-space :size=\"5\" :wrap-item=\"false\" align=\"center\" inline justify=\"center\">\n                        <n-icon size=\"16\">\n                            <log\n                                :inverse=\"selectedSubTab === BrowserTabType.SlowLog.toString()\"\n                                :stroke-color=\"themeVars.tabColor\"\n                                stroke-width=\"4\" />\n                        </n-icon>\n                        <span>{{ $t('interface.sub_tab.slow_log') }}</span>\n                    </n-space>\n                </template>\n                <content-slog :server=\"props.server\" />\n            </n-tab-pane>\n\n            <!-- command monitor pane -->\n            <n-tab-pane :name=\"BrowserTabType.CmdMonitor.toString()\" display-directive=\"show:lazy\">\n                <template #tab>\n                    <n-space :size=\"5\" :wrap-item=\"false\" align=\"center\" inline justify=\"center\">\n                        <n-icon size=\"16\">\n                            <monitor\n                                :inverse=\"selectedSubTab === BrowserTabType.CmdMonitor.toString()\"\n                                :stroke-color=\"themeVars.tabColor\"\n                                stroke-width=\"4\" />\n                        </n-icon>\n                        <span>{{ $t('interface.sub_tab.cmd_monitor') }}</span>\n                    </n-space>\n                </template>\n                <content-monitor :server=\"props.server\" />\n            </n-tab-pane>\n\n            <!-- pub/sub message pane -->\n            <n-tab-pane :name=\"BrowserTabType.PubMessage.toString()\" display-directive=\"show:lazy\">\n                <template #tab>\n                    <n-space :size=\"5\" :wrap-item=\"false\" align=\"center\" inline justify=\"center\">\n                        <n-icon size=\"16\">\n                            <subscribe\n                                :inverse=\"selectedSubTab === BrowserTabType.PubMessage.toString()\"\n                                :stroke-color=\"themeVars.tabColor\"\n                                stroke-width=\"4\" />\n                        </n-icon>\n                        <span>{{ $t('interface.sub_tab.pub_message') }}</span>\n                    </n-space>\n                </template>\n                <content-pubsub :server=\"props.server\" />\n            </n-tab-pane>\n        </n-tabs>\n    </div>\n</template>\n\n<style lang=\"scss\" scoped>\n@use '@/styles/content';\n\n.content-container {\n    //padding: 5px 5px 0;\n    //padding-top: 0;\n    box-sizing: border-box;\n    background-color: v-bind('themeVars.tabColor');\n}\n</style>\n\n<style lang=\"scss\">\n.content-sub-tab {\n    background-color: v-bind('themeVars.tabColor');\n    height: 100%;\n}\n\n.content-sub-tab-pane {\n    padding: 0 !important;\n    height: 100%;\n    background-color: v-bind('themeVars.bodyColor');\n    overflow: hidden;\n}\n\n.n-tabs .n-tabs-bar {\n    transition: none !important;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/content/ContentServerPane.vue",
    "content": "<script setup>\nimport { computed } from 'vue'\nimport AddLink from '@/components/icons/AddLink.vue'\nimport useDialogStore from 'stores/dialog.js'\nimport { NButton, useThemeVars } from 'naive-ui'\nimport { BrowserOpenURL } from 'wailsjs/runtime/runtime.js'\nimport { find, includes, isEmpty } from 'lodash'\nimport usePreferencesStore from 'stores/preferences.js'\n\nconst themeVars = useThemeVars()\nconst dialogStore = useDialogStore()\nconst prefStore = usePreferencesStore()\n\nconst onOpenSponsor = (link) => {\n    BrowserOpenURL(link)\n}\n\nconst openBanner = (link) => {\n    BrowserOpenURL(link)\n}\n\nconst skipBanner = () => {\n    // Show again after 30 days\n    localStorage.setItem('banner_next_time', Date.now() + 30 * 24 * 60 * 60 * 1000)\n}\n\nconst sponsorAd = computed(() => {\n    try {\n        const content = localStorage.getItem('sponsor_ad')\n        const ads = JSON.parse(content)\n        const ad = find(ads, ({ region }) => {\n            return isEmpty(region) || includes(region, prefStore.currentLanguage)\n        })\n        return ad || null\n    } catch {\n        return null\n    }\n})\n\nconst banner = computed(() => {\n    try {\n        const nextTime = localStorage.getItem('banner_next_time') || 0\n        if (nextTime > 0 && nextTime > Date.now()) {\n            return null\n        }\n\n        const content = localStorage.getItem('banner')\n        const banners = JSON.parse(content)\n        let banner = find(banners, ({ lang }) => {\n            return lang === prefStore.currentLanguage\n        })\n        if (banner == null) {\n            banner = find(banners, ({ lang }) => {\n                return lang === 'en'\n            })\n        }\n        return banner || null\n        // return {\n        //     lang: 'zh',\n        //     title: 'title',\n        //     content: 'content',\n        //     button: 'button',\n        //     link: 'https://tinyrdm.com',\n        // }\n    } catch {\n        return null\n    }\n})\n</script>\n\n<template>\n    <div class=\"content-container flex-box-v\">\n        <n-alert\n            v-if=\"banner != null\"\n            :bordered=\"false\"\n            :on-close=\"skipBanner\"\n            :title=\"banner.title\"\n            class=\"banner\"\n            closable\n            type=\"warning\">\n            <span style=\"margin: 0 10px 0 0\">{{ banner.content }}</span>\n            <n-button size=\"small\" tertiary type=\"warning\" @click=\"openBanner(banner.link)\">\n                {{ banner.button }}\n            </n-button>\n        </n-alert>\n\n        <!-- TODO: replace icon to app icon -->\n        <n-empty :description=\"$t('interface.empty_server_content')\">\n            <template #extra>\n                <n-button :focusable=\"false\" @click=\"dialogStore.openNewDialog()\">\n                    <template #icon>\n                        <n-icon :component=\"AddLink\" size=\"18\" />\n                    </template>\n                    {{ $t('interface.new_conn') }}\n                </n-button>\n            </template>\n        </n-empty>\n\n        <n-button v-if=\"sponsorAd != null\" class=\"sponsor-ad\" style=\"\" text @click=\"onOpenSponsor(sponsorAd.link)\">\n            {{ sponsorAd.name }}\n        </n-button>\n    </div>\n</template>\n\n<style lang=\"scss\" scoped>\n@use '@/styles/content';\n\n.content-container {\n    justify-content: center;\n    padding: 5px;\n    box-sizing: border-box;\n    position: relative;\n\n    & > .banner {\n        position: absolute;\n        top: 0;\n        left: 0;\n        width: 100%;\n    }\n\n    & > .sponsor-ad {\n        text-align: center;\n        margin-top: 20px;\n        vertical-align: bottom;\n        color: v-bind('themeVars.textColor3');\n    }\n}\n\n.color-preset-item {\n    width: 24px;\n    height: 24px;\n    margin-right: 2px;\n    border: white 3px solid;\n    cursor: pointer;\n\n    &_selected,\n    &:hover {\n        border-color: #cdd0d6;\n    }\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/content/ContentValueTab.vue",
    "content": "<script setup>\nimport Server from '@/components/icons/Server.vue'\nimport useTabStore from 'stores/tab.js'\nimport { computed } from 'vue'\nimport { get, map } from 'lodash'\nimport { useThemeVars } from 'naive-ui'\nimport useConnectionStore from 'stores/connections.js'\nimport { extraTheme } from '@/utils/extra_theme.js'\nimport usePreferencesStore from 'stores/preferences.js'\n\n/**\n * Value content tab on head\n */\n\nconst themeVars = useThemeVars()\nconst tabStore = useTabStore()\nconst connectionStore = useConnectionStore()\nconst prefStore = usePreferencesStore()\n\nconst onCloseTab = (tabIndex) => {\n    const tab = get(tabStore.tabs, tabIndex)\n    tabStore.closeTab(tab.name)\n}\n\nconst tabMarkColor = computed(() => {\n    const { name } = tabStore?.currentTab || {}\n    const { markColor = '' } = connectionStore.serverProfile[name] || {}\n    return markColor\n})\n\nconst tabClass = (idx) => {\n    if (tabStore.activatedIndex === idx) {\n        return ['value-tab', 'value-tab-active', tabMarkColor.value ? 'value-tab-active_mark' : '']\n    } else if (tabStore.activatedIndex - 1 === idx) {\n        return ['value-tab', 'value-tab-inactive']\n    } else {\n        return ['value-tab', 'value-tab-inactive', 'value-tab-inactive2']\n    }\n}\n\nconst tab = computed(() =>\n    map(tabStore.tabs, (item) => ({\n        key: item.name,\n        label: item.title,\n    })),\n)\n\nconst exThemeVars = computed(() => {\n    return extraTheme(prefStore.isDark)\n})\n</script>\n\n<template>\n    <n-tabs\n        v-model:value=\"tabStore.activatedIndex\"\n        :closable=\"true\"\n        :tabs-padding=\"3\"\n        :theme-overrides=\"{\n            tabFontWeightActive: 800,\n            tabGapSmallCard: 0,\n            tabGapMediumCard: 0,\n            tabGapLargeCard: 0,\n            tabColor: '#0000',\n            tabBorderColor: '#0000',\n            tabTextColorCard: themeVars.closeIconColor,\n        }\"\n        size=\"small\"\n        type=\"card\"\n        @close=\"onCloseTab\"\n        @update:value=\"(tabIndex) => tabStore.switchTab(tabIndex)\">\n        <n-tab v-for=\"(t, i) in tab\" :key=\"i\" :class=\"tabClass(i)\" :closable=\"true\" :name=\"i\" @dblclick.stop=\"() => {}\">\n            <n-space :size=\"5\" :wrap-item=\"false\" align=\"center\" inline justify=\"center\">\n                <n-icon size=\"18\">\n                    <server stroke-width=\"4\" />\n                </n-icon>\n                <n-ellipsis style=\"max-width: 150px\">{{ t.label }}</n-ellipsis>\n            </n-space>\n        </n-tab>\n    </n-tabs>\n</template>\n\n<style lang=\"scss\">\n.value-tab {\n    --wails-draggable: none;\n    position: relative;\n    border: 1px solid v-bind('exThemeVars.splitColor') !important;\n}\n\n.value-tab-active {\n    background-color: v-bind('themeVars.tabColor') !important;\n    border-bottom-color: v-bind('themeVars.tabColor') !important;\n\n    &_mark {\n        border-top: 3px solid v-bind('tabMarkColor') !important;\n    }\n}\n\n.value-tab-inactive {\n    border-color: #0000 !important;\n\n    &:hover {\n        background-color: v-bind('exThemeVars.splitColor') !important;\n    }\n}\n\n.value-tab-inactive2 {\n    &:after {\n        content: '';\n        position: absolute;\n        top: 25%;\n        height: 50%;\n        width: 1px;\n        background-color: v-bind('themeVars.borderColor');\n        right: -2px;\n    }\n\n    &:hover::after {\n        background-color: #0000;\n    }\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/content_value/ContentCli.vue",
    "content": "<script setup>\nimport { Terminal } from 'xterm'\nimport { FitAddon } from 'xterm-addon-fit'\nimport { computed, onMounted, onUnmounted, ref, watch } from 'vue'\nimport 'xterm/css/xterm.css'\nimport { EventsEmit, EventsOff, EventsOn } from 'wailsjs/runtime/runtime.js'\nimport { get, isEmpty, set, size, trim } from 'lodash'\nimport { CloseCli, StartCli } from 'wailsjs/go/services/cliService.js'\nimport usePreferencesStore from 'stores/preferences.js'\nimport { i18nGlobal } from '@/utils/i18n.js'\nimport wcwidth from 'wcwidth'\nimport { isWeb } from '@/utils/platform.js'\n\nconst props = defineProps({\n    name: String,\n    activated: Boolean,\n})\n\nconst prefStore = usePreferencesStore()\nconst termRef = ref(null)\n/**\n *\n * @type {xterm.Terminal|null}\n */\nlet termInst = null\n/**\n *\n * @type {xterm-addon-fit.FitAddon|null}\n */\nlet fitAddonInst = null\n\nconst nonPrintableKeys = [\n    'F1',\n    'F2',\n    'F3',\n    'F4',\n    'F5',\n    'F6',\n    'F7',\n    'F8',\n    'F9',\n    'F10',\n    'F11',\n    'F12',\n    'Shift',\n    'CapsLock',\n    'Tab',\n    'Escape',\n    'ScrollLock',\n    'Pause',\n    'Insert',\n    'NumLock',\n    'ContextMenu',\n    'Process',\n]\n\n/**\n *\n * @return {{fitAddon: xterm-addon-fit.FitAddon, term: Terminal}}\n */\nconst newTerm = () => {\n    const { fontSize = 14, fontFamily = 'Courier New' } = prefStore.cliFont\n    const term = new Terminal({\n        allowProposedApi: true,\n        fontFamily,\n        fontSize,\n        cursorStyle: prefStore.cli.cursorStyle || 'block',\n        cursorBlink: true,\n        disableStdin: false,\n        screenReaderMode: true,\n        // LogLevel: 'debug',\n        theme: {\n            // foreground: '#ECECEC',\n            background: '#000000',\n            // cursor: 'help',\n            // lineHeight: 20,\n        },\n    })\n    const fitAddon = new FitAddon()\n    term.open(termRef.value)\n    term.loadAddon(fitAddon)\n\n    term.onData(onTermData)\n    term.attachCustomKeyEventHandler(onTermKey)\n    return { term, fitAddon }\n}\n\nonMounted(async () => {\n    const { term, fitAddon } = newTerm()\n    termInst = term\n    fitAddonInst = fitAddon\n    window.addEventListener('resize', resizeTerm)\n\n    term.writeln('\\r\\n' + i18nGlobal.t('interface.cli_welcome'))\n    // term.write('\\x1b[4h') // insert mode\n\n    EventsOn(`cmd:output:${props.name}`, receiveTermOutput)\n\n    // Wait for WebSocket with timeout (CLI needs it for real-time I/O, web mode only)\n    if (isWeb()) {\n        try {\n            const { WaitForWebSocket } = await import('wailsjs/runtime/runtime.js')\n            await Promise.race([\n                WaitForWebSocket(),\n                new Promise((_, reject) => setTimeout(() => reject(new Error('ws timeout')), 5000)),\n            ])\n        } catch {\n            console.warn('[cli] WebSocket not connected, some features may be limited')\n        }\n    }\n\n    await CloseCli(props.name)\n    await StartCli(props.name, 0)\n\n    fitAddon.fit()\n    term.focus()\n})\n\nonUnmounted(() => {\n    window.removeEventListener('resize', resizeTerm)\n    EventsOff(`cmd:output:${props.name}`)\n    termInst.dispose()\n    termInst = null\n    console.warn('destroy term')\n})\n\nconst resizeTerm = () => {\n    if (fitAddonInst != null) {\n        fitAddonInst.fit()\n    }\n}\n\ndefineExpose({\n    resizeTerm,\n})\n\nwatch(\n    () => prefStore.cliFont,\n    ({ fontSize = 14, fontFamily = 'Courier New' }) => {\n        if (termInst != null) {\n            termInst.options.fontSize = fontSize\n            termInst.options.fontFamily = fontFamily\n        }\n        resizeTerm()\n    },\n)\n\nwatch(\n    () => prefStore.cli.cursorStyle,\n    (style) => {\n        if (termInst != null) {\n            termInst.options.cursorStyle = style || 'block'\n        }\n        resizeTerm()\n    },\n)\n\nconst prefixContent = computed(() => {\n    return '\\x1b[33m' + promptPrefix.value + '\\x1b[0m'\n})\n\nconst prefixLen = computed(() => {\n    let len = 0\n    for (let i = 0; i < promptPrefix.value.length; i++) {\n        const char = promptPrefix.value.charCodeAt(i)\n        if (char >= 0x0000 && char <= 0x00ff) {\n            // single byte ASCII char\n            len += 1\n        } else {\n            // multibyte Unicode char\n            len += 2\n        }\n    }\n    return len\n})\n\nlet promptPrefix = ref('')\nlet inputCursor = 0\nconst inputHistory = []\nlet historyIndex = 0\nlet waitForOutput = false\nlet isComposingBefore = false\nlet ignore229 = false\n\nconst onTermData = (data) => {\n    if (termInst == null) {\n        return\n    }\n\n    if (data) {\n        const cc = data.charCodeAt(0)\n        switch (cc) {\n            case 13: // enter\n                // try to process local command first\n                switch (getCurrentInput()) {\n                    case 'clear':\n                    case 'clr':\n                        termInst.clear()\n                        replaceTermInput()\n                        newInputLine()\n                        return\n\n                    default: // send command to server\n                        flushTermInput()\n                        return\n                }\n        }\n\n        // trim space prefix and suffix, it may be input via IME\n        if (size(data) > 1) {\n            data = trim(data)\n        }\n        updateInput(data)\n    }\n}\n\n/**\n *\n * @param e\n * @return {boolean}\n */\nconst onTermKey = (e) => {\n    // ignore first input when leave composing\n    if (!e.isComposing) {\n        if (isComposingBefore) {\n            ignore229 = true\n            isComposingBefore = false\n        } else {\n            ignore229 = false\n        }\n    } else if (e.isComposing) {\n        ignore229 = true\n        isComposingBefore = true\n    }\n\n    if (e.type === 'keydown') {\n        if (e.ctrlKey) {\n            switch (e.key) {\n                case 'a': // move to head of line\n                    moveInputCursorTo(0)\n                    return false\n\n                case 'e': // move to tail of line\n                    moveInputCursorTo(Number.MAX_SAFE_INTEGER)\n                    return false\n\n                case 'f': // move forward\n                    moveInputCursor(1)\n                    return false\n\n                case 'b': // move backward\n                    moveInputCursor(-1)\n                    return false\n\n                case 'd': // delete char\n                    deleteInput(false)\n                    return false\n\n                case 'h': // back delete\n                    deleteInput(true)\n                    return false\n\n                case 'u': // delete all text before cursor\n                    deleteInput2(false)\n                    return false\n\n                case 'k': // delete all text after cursor\n                    deleteInput2(true)\n                    return false\n\n                case 'w': // delete word before cursor\n                    deleteWord(false)\n                    return false\n\n                case 'p': // previous history\n                    changeHistory(true)\n                    return false\n\n                case 'n': // next history\n                    changeHistory(false)\n                    return false\n\n                case 'l': // clear screen\n                    termInst.clear()\n                    replaceTermInput()\n                    newInputLine()\n                    return false\n\n                case 'c': // interrupt and new line\n                    termInst.writeln('')\n                    replaceTermInput()\n                    newInputLine()\n                    return false\n            }\n            // block all ctrl key combinations input\n            return false\n        } else {\n            switch (e.key) {\n                case 'Home': // move to head of line\n                    moveInputCursorTo(0)\n                    return false\n\n                case 'End': // move to tail of line\n                    moveInputCursorTo(Number.MAX_SAFE_INTEGER)\n                    return false\n\n                case 'Backspace':\n                    deleteInput(true)\n                    return false\n\n                case 'ArrowUp': // arrow up\n                case 'PageUp':\n                    changeHistory(true)\n                    return false\n                case 'ArrowDown': // arrow down\n                case 'PageDown':\n                    changeHistory(false)\n                    return false\n                case 'ArrowRight': // arrow right ->\n                    moveInputCursor(1)\n                    return false\n                case 'ArrowLeft': // arrow left <-\n                    moveInputCursor(-1)\n                    return false\n                case 'Delete': // del\n                    deleteInput(false)\n                    return false\n\n                default:\n                    if (e.altKey || e.altGraphKey || e.ctrlKey || e.metaKey || nonPrintableKeys.includes(e.key)) {\n                        return false\n                    } else if (e.keyCode === 229 && !ignore229) {\n                        updateInput(e.key)\n                        return false\n                    }\n            }\n        }\n    }\n    return true\n}\n\n/**\n * move input cursor by step\n * @param {number} step above 0 indicate move right; 0 indicate move to last\n */\nconst moveInputCursor = (step) => {\n    if (termInst == null) {\n        return\n    }\n\n    let updateCursor = false\n    if (step > 0) {\n        // move right\n        const currentLine = getCurrentInput()\n        if (inputCursor + step <= currentLine.length) {\n            inputCursor += step\n            updateCursor = true\n        }\n    } else if (step < 0) {\n        // move left\n        if (inputCursor + step >= 0) {\n            inputCursor += step\n            updateCursor = true\n        }\n    }\n\n    if (updateCursor) {\n        moveInputCursorTo(inputCursor)\n    }\n}\n\n/**\n * move cursor to the end of current line\n */\nconst moveInputCursorToEnd = () => {\n    moveInputCursorTo(Number.MAX_SAFE_INTEGER)\n}\n\n/**\n * move cursor to pos\n * @param {number} pos\n */\nconst moveInputCursorTo = (pos) => {\n    const currentLine = getCurrentInput()\n    inputCursor = Math.min(Math.max(0, pos), currentLine.length)\n    const cursorPos = wcwidth(currentLine.substring(0, inputCursor))\n    termInst.write(`\\x1B[${prefixLen.value + cursorPos + 1}G`)\n}\n\n/**\n * update current input cache and refresh term\n * @param {string} data\n */\nconst updateInput = (data) => {\n    if (data == null || data.length <= 0) {\n        return\n    }\n\n    if (termInst == null) {\n        return\n    }\n\n    let currentLine = getCurrentInput()\n    if (inputCursor < currentLine.length) {\n        // insert\n        currentLine = currentLine.substring(0, inputCursor) + data + currentLine.substring(inputCursor)\n        replaceTermInput(currentLine)\n        moveInputCursor(data.length)\n    } else {\n        // append\n        currentLine += data\n        termInst.write(data)\n        inputCursor += data.length\n    }\n    updateCurrentInput(currentLine)\n}\n\n/**\n *\n * @param {boolean} back backspace or not\n */\nconst deleteInput = (back = false) => {\n    if (termInst == null) {\n        return\n    }\n\n    let currentLine = getCurrentInput()\n    if (inputCursor < currentLine.length) {\n        // delete middle part\n        if (back) {\n            currentLine = currentLine.substring(0, inputCursor - 1) + currentLine.substring(inputCursor)\n            inputCursor -= 1\n        } else {\n            currentLine = currentLine.substring(0, inputCursor) + currentLine.substring(inputCursor + 1)\n        }\n    } else {\n        if (back) {\n            // delete last one\n            currentLine = currentLine.slice(0, -1)\n            inputCursor -= 1\n        }\n    }\n\n    replaceTermInput(currentLine)\n    updateCurrentInput(currentLine)\n    moveInputCursorTo(inputCursor)\n}\n\n/**\n * delete to the end\n * @param back\n */\nconst deleteInput2 = (back = false) => {\n    if (termInst == null) {\n        return\n    }\n\n    let currentLine = getCurrentInput()\n    if (back) {\n        // delete until tail\n        currentLine = currentLine.substring(0, inputCursor)\n        inputCursor = currentLine.length\n    } else {\n        // delete until head\n        currentLine = currentLine.substring(inputCursor)\n        inputCursor = 0\n    }\n\n    replaceTermInput(currentLine)\n    updateCurrentInput(currentLine)\n    moveInputCursorTo(inputCursor)\n}\n\n/**\n * delete one word\n * @param back\n */\nconst deleteWord = (back = false) => {\n    if (termInst == null) {\n        return\n    }\n\n    let currentLine = getCurrentInput()\n    if (back) {\n        const prefix = currentLine.substring(0, inputCursor)\n        let firstNonChar = false\n        let cursor = inputCursor\n        while (cursor < currentLine.length) {\n            const isChar =\n                (currentLine[cursor] >= 'a' && currentLine[cursor] <= 'z') ||\n                (currentLine[cursor] >= 'A' && currentLine[cursor] <= 'Z') ||\n                (currentLine[cursor] >= '0' && currentLine[cursor] <= '9')\n            if (!firstNonChar || isChar) {\n                if (!isChar) {\n                    firstNonChar = true\n                }\n                cursor++\n            } else {\n                break\n            }\n        }\n        currentLine = prefix + currentLine.substring(cursor)\n    } else {\n        const suffix = currentLine.substring(inputCursor)\n        let firstNonChar = false\n        while (inputCursor >= 0) {\n            const isChar =\n                (currentLine[inputCursor] >= 'a' && currentLine[inputCursor] <= 'z') ||\n                (currentLine[inputCursor] >= 'A' && currentLine[inputCursor] <= 'Z') ||\n                (currentLine[inputCursor] >= '0' && currentLine[inputCursor] <= '9')\n            if (!firstNonChar || isChar) {\n                if (!isChar) {\n                    firstNonChar = true\n                }\n                inputCursor--\n            } else {\n                break\n            }\n        }\n        currentLine = currentLine.substring(0, inputCursor) + suffix\n    }\n\n    replaceTermInput(currentLine)\n    updateCurrentInput(currentLine)\n    moveInputCursorTo(inputCursor)\n}\n\nconst getCurrentInput = () => {\n    return get(inputHistory, historyIndex, '')\n}\n\nconst updateCurrentInput = (input) => {\n    set(inputHistory, historyIndex, input || '')\n}\n\nconst newInputLine = () => {\n    if (historyIndex >= 0 && historyIndex < inputHistory.length - 1) {\n        // edit prev history, move to last\n        const pop = inputHistory.splice(historyIndex, 1)\n        inputHistory[inputHistory.length - 1] = pop[0]\n    }\n    // remove adjacent duplicated history\n    if (inputHistory.length > 1 && inputHistory[inputHistory.length - 1] === inputHistory[inputHistory.length - 2]) {\n        inputHistory.pop()\n    }\n    if (get(inputHistory, inputHistory.length - 1, '')) {\n        historyIndex = inputHistory.length\n        updateCurrentInput('')\n    }\n}\n\n/**\n * get prev or next history record\n * @param prev\n * @return {*|null}\n */\nconst changeHistory = (prev) => {\n    let currentLine = null\n    if (prev) {\n        if (historyIndex > 0) {\n            historyIndex -= 1\n            currentLine = inputHistory[historyIndex]\n        }\n    } else {\n        if (historyIndex < inputHistory.length - 1) {\n            historyIndex += 1\n            currentLine = inputHistory[historyIndex]\n        }\n    }\n\n    if (currentLine != null) {\n        if (termInst == null) {\n            return\n        }\n\n        replaceTermInput(currentLine)\n        moveInputCursorToEnd()\n    }\n\n    return null\n}\n\n/**\n * flush terminal input and send current prompt to server\n * @param {boolean} flushCmd\n */\nconst flushTermInput = (flushCmd = false) => {\n    const currentLine = getCurrentInput()\n    EventsEmit(`cmd:input:${props.name}`, currentLine)\n    inputCursor = 0\n    // historyIndex = inputHistory.length\n    waitForOutput = true\n}\n\n/**\n * clear current input line and replace with new content\n * @param {string|null} [content]\n */\nconst replaceTermInput = (content = '') => {\n    if (termInst == null) {\n        return\n    }\n\n    // erase current line and write new content\n    termInst.write('\\r\\x1B[K' + prefixContent.value + (content || ''))\n}\n\n/**\n * process receive output content\n * @param {{content: string[], prompt: string}} data\n */\nconst receiveTermOutput = (data) => {\n    if (termInst == null) {\n        return\n    }\n\n    const { content, prompt } = data || {}\n    if (content instanceof Array) {\n        for (const line of content) {\n            termInst.write('\\r\\n' + line)\n        }\n    } else {\n        termInst.write('\\r\\n\\x1b[38;5;244m(nil)\\x1b[0m')\n    }\n    if (!isEmpty(prompt)) {\n        promptPrefix.value = prompt\n        termInst.write('\\r\\n' + prefixContent.value)\n        waitForOutput = false\n        inputCursor = 0\n        newInputLine()\n    }\n}\n</script>\n\n<template>\n    <div ref=\"termRef\" class=\"xterm\" />\n</template>\n\n<style lang=\"scss\" scoped>\n.xterm {\n    width: 100%;\n    min-height: 100%;\n    overflow: hidden;\n    background-color: #000000;\n}\n</style>\n\n<style lang=\"scss\">\n.xterm {\n    padding: 0 5px !important;\n}\n\n.xterm-viewport::-webkit-scrollbar {\n    background-color: #000000;\n    width: 5px;\n}\n\n.xterm-viewport::-webkit-scrollbar-thumb {\n    background: #000000;\n}\n\n.xterm-decoration-overview-ruler {\n    right: 1px;\n    pointer-events: none;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/content_value/ContentEditor.vue",
    "content": "<script setup>\nimport { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'\nimport * as monaco from 'monaco-editor'\nimport usePreferencesStore from 'stores/preferences.js'\nimport { useThemeVars } from 'naive-ui'\nimport { isEmpty } from 'lodash'\n\nconst props = defineProps({\n    content: {\n        type: String,\n    },\n    language: {\n        type: String,\n        default: 'json',\n    },\n    readonly: {\n        type: String,\n    },\n    loading: {\n        type: Boolean,\n    },\n    border: {\n        type: Boolean,\n        default: false,\n    },\n    resetKey: {\n        type: String,\n        default: '',\n    },\n    offsetKey: {\n        type: String,\n        default: '',\n    },\n    keepOffset: {\n        type: Boolean,\n        default: false,\n    },\n})\n\nconst emit = defineEmits(['reset', 'input', 'save'])\n\nconst themeVars = useThemeVars()\n/** @type {HTMLElement|null} */\nconst editorRef = ref(null)\n/** @type monaco.editor.IStandaloneCodeEditor */\nlet editorNode = null\nconst scrollOffset = { top: 0, left: 0 }\n\nconst updateScroll = () => {\n    if (editorNode != null) {\n        if (props.keepOffset && !isEmpty(props.offsetKey)) {\n            editorNode.setScrollPosition({ scrollTop: scrollOffset.top, scrollLeft: scrollOffset.left })\n        } else {\n            // reset offset if not needed\n            editorNode.setScrollPosition({ scrollTop: 0, scrollLeft: 0 })\n        }\n    }\n}\n\nconst destroyEditor = () => {\n    if (editorNode != null && editorNode.dispose != null) {\n        const model = editorNode.getModel()\n        if (model != null) {\n            model.dispose()\n        }\n        editorNode.dispose()\n        editorNode = null\n    }\n}\n\nconst readonlyValue = computed(() => {\n    return props.readonly || props.loading\n})\n\nconst pref = usePreferencesStore()\nonMounted(async () => {\n    if (editorRef.value != null) {\n        const { fontSize, fontFamily = ['monaco'] } = pref.editorFont\n        editorNode = monaco.editor.create(editorRef.value, {\n            // value: props.content,\n            theme: pref.isDark ? 'rdm-dark' : 'rdm-light',\n            language: props.language,\n            lineNumbers: pref.showLineNum ? 'on' : 'off',\n            links: pref.editorLinks,\n            readOnly: readonlyValue.value,\n            colorDecorators: true,\n            accessibilitySupport: 'off',\n            wordWrap: 'on',\n            tabSize: 2,\n            folding: pref.showFolding,\n            dragAndDrop: pref.dropText,\n            fontFamily,\n            fontSize,\n            scrollBeyondLastLine: false,\n            automaticLayout: true,\n            scrollbar: {\n                useShadows: false,\n                verticalScrollbarSize: '10px',\n            },\n            // formatOnType: true,\n            contextmenu: false,\n            lineNumbersMinChars: 2,\n            lineDecorationsWidth: 0,\n            minimap: {\n                enabled: false,\n            },\n            selectionHighlight: false,\n            renderLineHighlight: 'gutter',\n        })\n\n        // add shortcut for save\n        editorNode.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, (event) => {\n            emit('save')\n        })\n\n        editorNode.onDidScrollChange((event) => {\n            // save scroll offset when changes, ie. content changes\n            if (props.keepOffset && !event.scrollHeightChanged) {\n                scrollOffset.top = event.scrollTop\n                scrollOffset.left = event.scrollLeft\n            }\n        })\n\n        editorNode.onDidLayoutChange((event) => {\n            updateScroll()\n        })\n\n        // editorNode.onDidChangeModelLanguageConfiguration(() => {\n        //     editorNode?.getAction('editor.action.formatDocument')?.run()\n        // })\n\n        if (editorNode.onDidChangeModelContent) {\n            editorNode.onDidChangeModelContent(() => {\n                emit('input', editorNode.getValue())\n            })\n        }\n    }\n})\n\nwatch(\n    () => props.content,\n    async (content) => {\n        if (editorNode != null) {\n            editorNode.setValue(content)\n            await nextTick(() => emit('reset', content))\n            updateScroll()\n        }\n    },\n)\n\nwatch(\n    () => props.resetKey,\n    async () => {\n        if (editorNode != null) {\n            editorNode.setValue(props.content)\n            await nextTick(() => emit('reset', props.content))\n            updateScroll()\n        }\n    },\n)\n\nwatch(\n    () => props.offsetKey,\n    () => {\n        // reset scroll offset when key changed\n        if (editorNode != null) {\n            scrollOffset.top = 0\n            scrollOffset.left = 0\n            editorNode.setScrollPosition({ scrollTop: 0, scrollLeft: 0 })\n        }\n    },\n)\n\nwatch(\n    () => readonlyValue.value,\n    (readOnly) => {\n        if (editorNode != null) {\n            editorNode.updateOptions({\n                readOnly,\n            })\n        }\n    },\n)\n\nwatch(\n    () => props.language,\n    (language) => {\n        if (editorNode != null) {\n            const model = editorNode.getModel()\n            if (model != null) {\n                monaco.editor.setModelLanguage(model, language)\n            }\n        }\n    },\n)\n\nwatch(\n    () => pref.isDark,\n    (dark) => {\n        if (editorNode != null) {\n            editorNode.updateOptions({\n                theme: dark ? 'rdm-dark' : 'rdm-light',\n            })\n        }\n    },\n)\n\nwatch(\n    () => pref.editor,\n    ({ showLineNum = true, showFolding = true, dropText = true, links = true }) => {\n        if (editorNode != null) {\n            const { fontSize, fontFamily } = pref.editorFont\n            editorNode.updateOptions({\n                fontSize,\n                fontFamily,\n                lineNumbers: showLineNum ? 'on' : 'off',\n                folding: showFolding,\n                dragAndDrop: dropText,\n                links,\n            })\n        }\n    },\n    { deep: true },\n)\n\nonUnmounted(() => {\n    destroyEditor()\n})\n</script>\n\n<template>\n    <div :class=\"{ 'editor-border': props.border === true }\" style=\"position: relative\">\n        <div ref=\"editorRef\" class=\"editor-inst\" />\n    </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.editor-border {\n    border: 1px solid v-bind('themeVars.borderColor');\n    border-radius: v-bind('themeVars.borderRadius');\n    padding: 3px;\n    box-sizing: border-box;\n}\n\n.editor-inst {\n    position: absolute;\n    top: 2px;\n    bottom: 2px;\n    left: 2px;\n    right: 2px;\n}\n\n:deep(.line-numbers) {\n    white-space: nowrap;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/content_value/ContentEntryEditor.vue",
    "content": "<script setup>\nimport { computed, nextTick, reactive, ref, watchEffect } from 'vue'\nimport { useThemeVars } from 'naive-ui'\nimport Save from '@/components/icons/Save.vue'\nimport { decodeTypes, formatTypes } from '@/consts/value_view_type.js'\nimport { decodeRedisKey } from '@/utils/key_convert.js'\nimport useBrowserStore from 'stores/browser.js'\nimport FormatSelector from '@/components/content_value/FormatSelector.vue'\nimport IconButton from '@/components/common/IconButton.vue'\nimport FullScreen from '@/components/icons/FullScreen.vue'\nimport WindowClose from '@/components/icons/WindowClose.vue'\nimport Pin from '@/components/icons/Pin.vue'\nimport OffScreen from '@/components/icons/OffScreen.vue'\nimport ContentEditor from '@/components/content_value/ContentEditor.vue'\nimport { isEmpty, toString } from 'lodash'\n\nconst props = defineProps({\n    keyPath: String,\n    show: {\n        type: Boolean,\n    },\n    field: {\n        type: [String, Number],\n    },\n    value: {\n        type: [String, Array],\n    },\n    fieldLabel: {\n        type: String,\n    },\n    valueLabel: {\n        type: String,\n    },\n    decode: {\n        type: String,\n    },\n    format: {\n        type: String,\n    },\n    fieldReadonly: {\n        type: Boolean,\n    },\n    fullscreen: {\n        type: Boolean,\n    },\n})\n\nconst themeVars = useThemeVars()\nconst browserStore = useBrowserStore()\nconst emit = defineEmits([\n    'update:field',\n    'update:value',\n    'update:decode',\n    'update:format',\n    'update:fullscreen',\n    'save',\n    'close',\n])\n\nwatchEffect(\n    () => {\n        if (props.show && !isEmpty(props.keyPath)) {\n            onFormatChanged(props.decode, props.format)\n        } else {\n            viewAs.value = ''\n        }\n    },\n    {\n        flush: 'post',\n    },\n)\n\nconst loading = ref(false)\nconst isPin = ref(false)\nconst viewAs = reactive({\n    field: '',\n    value: '',\n    format: formatTypes.RAW,\n    decode: decodeTypes.NONE,\n})\nconst displayValue = computed(() => {\n    if (loading.value) {\n        return ''\n    }\n    if (viewAs.value == null) {\n        return decodeRedisKey(props.value)\n    }\n    return viewAs.value\n})\nconst editingContent = ref('')\nconst enableSave = computed(() => {\n    return toString(props.field) !== viewAs.field || editingContent.value !== viewAs.value\n})\n\nconst viewLanguage = computed(() => {\n    switch (viewAs.format) {\n        case formatTypes.JSON:\n        case formatTypes.UNICODE_JSON:\n            return 'json'\n        case formatTypes.YAML:\n            return 'yaml'\n        case formatTypes.XML:\n            return 'xml'\n        default:\n            return 'plaintext'\n    }\n})\n\n/**\n *\n * @param {decodeTypes|null} decode\n * @param {formatTypes|null} format\n * @return {Promise<void>}\n */\nconst onFormatChanged = async (decode = null, format = null) => {\n    try {\n        loading.value = true\n        const {\n            value,\n            decode: retDecode,\n            format: retFormat,\n        } = await browserStore.convertValue({\n            value: props.value,\n            decode,\n            format,\n        })\n        viewAs.field = props.field + ''\n        editingContent.value = viewAs.value = value\n        viewAs.decode = decode || retDecode\n        viewAs.format = format || retFormat\n        emit('update:decode', viewAs.decode)\n        emit('update:format', viewAs.format)\n    } finally {\n        loading.value = false\n    }\n}\n\nconst onInput = (content) => {\n    editingContent.value = content\n}\n\nconst onToggleFullscreen = () => {\n    emit('update:fullscreen', !!!props.fullscreen)\n}\n\nconst onClose = () => {\n    isPin.value = false\n    emit('close')\n}\n\nconst onSave = () => {\n    emit('save', viewAs.field, editingContent.value, viewAs.decode, viewAs.format)\n    if (!isPin.value) {\n        nextTick().then(onClose)\n    }\n}\n</script>\n\n<template>\n    <div v-show=\"show\" class=\"entry-editor flex-box-v\">\n        <n-card :title=\"$t('interface.edit_row')\" autofocus class=\"flex-item-expand\" size=\"small\">\n            <div class=\"editor-content flex-box-v flex-item-expand\">\n                <!-- field -->\n                <div class=\"editor-content-item flex-box-v\">\n                    <div class=\"editor-content-item-label\">{{ props.fieldLabel }}</div>\n                    <n-input\n                        v-model:value=\"viewAs.field\"\n                        :placeholder=\"props.field + ''\"\n                        :readonly=\"props.fieldReadonly\"\n                        class=\"editor-content-item-input\"\n                        type=\"text\" />\n                </div>\n\n                <!-- value -->\n                <div class=\"editor-content-item flex-box-v flex-item-expand\">\n                    <div class=\"editor-content-item-label\">{{ props.valueLabel }}</div>\n                    <content-editor\n                        :border=\"true\"\n                        :content=\"displayValue\"\n                        :key-path=\"viewAs.field\"\n                        :language=\"viewLanguage\"\n                        class=\"flex-item-expand\"\n                        @input=\"onInput\"\n                        @reset=\"onInput\"\n                        @save=\"onSave\" />\n                    <format-selector\n                        :decode=\"viewAs.decode\"\n                        :format=\"viewAs.format\"\n                        style=\"margin-top: 5px\"\n                        @format-changed=\"(d, f) => onFormatChanged(d, f)\" />\n                </div>\n            </div>\n            <template #header-extra>\n                <n-space :size=\"5\">\n                    <icon-button\n                        :button-class=\"{ 'pinable-btn': true, 'unpin-btn': !isPin, 'pin-btn': isPin }\"\n                        :icon=\"Pin\"\n                        :size=\"19\"\n                        :t-tooltip=\"isPin ? 'interface.unpin_edit' : 'interface.pin_edit'\"\n                        stroke-width=\"4\"\n                        @click=\"isPin = !isPin\" />\n                    <icon-button\n                        :button-class=\"['pinable-btn', 'unpin-btn']\"\n                        :icon=\"props.fullscreen ? OffScreen : FullScreen\"\n                        :size=\"18\"\n                        stroke-width=\"5\"\n                        t-tooltip=\"interface.fullscreen\"\n                        @click=\"onToggleFullscreen\" />\n                    <icon-button\n                        :button-class=\"['pinable-btn', 'unpin-btn']\"\n                        :icon=\"WindowClose\"\n                        :size=\"18\"\n                        stroke-width=\"5\"\n                        t-tooltip=\"menu.close\"\n                        @click=\"onClose\" />\n                </n-space>\n            </template>\n            <template #action>\n                <n-space :wrap=\"false\" :wrap-item=\"false\" justify=\"end\">\n                    <n-button :disabled=\"!enableSave\" :secondary=\"enableSave\" type=\"primary\" @click=\"onSave\">\n                        <template #icon>\n                            <n-icon :component=\"Save\" />\n                        </template>\n                        {{ $t('common.update') }}\n                    </n-button>\n                </n-space>\n            </template>\n        </n-card>\n    </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.entry-editor {\n    padding-left: 2px;\n    box-sizing: border-box;\n    position: absolute;\n    left: 0;\n    right: 0;\n    top: 0;\n    bottom: 0;\n    z-index: 100;\n\n    .editor-content {\n        &-item {\n            &:not(:last-child) {\n                margin-bottom: 16px;\n            }\n\n            &-label {\n                height: 18px;\n                color: v-bind('themeVars.textColor3');\n                font-size: 13px;\n                padding: 5px 0;\n            }\n\n            &-input {\n            }\n        }\n    }\n}\n\n:deep(.n-card__content) {\n    display: flex;\n    flex-direction: column;\n    flex-grow: 1;\n}\n\n:deep(.n-card__action) {\n    padding: 5px 10px;\n    background-color: unset;\n}\n\n:deep(.pinable-btn) {\n    padding: 3px;\n    border-style: solid;\n    border-width: 1px;\n    border-radius: 3px;\n}\n\n:deep(.unpin-btn) {\n    border-color: #0000;\n}\n\n:deep(.pin-btn) {\n    border-color: v-bind('themeVars.iconColorDisabled');\n    background-color: v-bind('themeVars.iconColorDisabled');\n}\n\n//:deep(.n-card--bordered) {\n//    border-radius: 0;\n//}\n</style>\n"
  },
  {
    "path": "frontend/src/components/content_value/ContentMonitor.vue",
    "content": "<script setup>\nimport { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'\nimport { debounce, filter, get, includes, isEmpty, join } from 'lodash'\nimport { useI18n } from 'vue-i18n'\nimport { useThemeVars } from 'naive-ui'\nimport Play from '@/components/icons/Play.vue'\nimport Pause from '@/components/icons/Pause.vue'\nimport { ExportLog, StartMonitor, StopMonitor } from 'wailsjs/go/services/monitorService.js'\nimport { ClipboardSetText, EventsOff, EventsOn } from 'wailsjs/runtime/runtime.js'\nimport Copy from '@/components/icons/Copy.vue'\nimport Export from '@/components/icons/Export.vue'\nimport Delete from '@/components/icons/Delete.vue'\nimport IconButton from '@/components/common/IconButton.vue'\nimport Bottom from '@/components/icons/Bottom.vue'\n\nconst themeVars = useThemeVars()\n\nconst i18n = useI18n()\nconst props = defineProps({\n    server: {\n        type: String,\n    },\n})\n\nconst data = reactive({\n    monitorEvent: '',\n    list: [],\n    listLimit: 20,\n    keyword: '',\n    autoShowLast: true,\n})\n\nconst listRef = ref(null)\n\nonMounted(() => {\n    // try to stop prev monitor first\n    onStopMonitor()\n})\n\nonUnmounted(() => {\n    onStopMonitor()\n})\n\nconst isMonitoring = computed(() => {\n    return !isEmpty(data.monitorEvent)\n})\n\nconst displayList = computed(() => {\n    if (!isEmpty(data.keyword)) {\n        return filter(data.list, (line) => includes(line, data.keyword))\n    }\n    return data.list\n})\n\nconst _scrollToBottom = () => {\n    nextTick(() => {\n        listRef.value?.scrollTo({ position: 'bottom' })\n    })\n}\nconst scrollToBottom = debounce(_scrollToBottom, 1000, { leading: true, trailing: true })\n\nconst onStartMonitor = async () => {\n    if (isMonitoring.value) {\n        return\n    }\n\n    const { data: ret, success, msg } = await StartMonitor(props.server)\n    if (!success) {\n        $message.error(msg)\n        return\n    }\n    data.monitorEvent = get(ret, 'eventName')\n    EventsOn(data.monitorEvent, (content) => {\n        if (content instanceof Array) {\n            data.list.push(...content)\n        } else {\n            data.list.push(content)\n        }\n        if (data.autoShowLast) {\n            scrollToBottom()\n        }\n    })\n}\nconst onStopMonitor = async () => {\n    const { success, msg } = await StopMonitor(props.server)\n    if (!success) {\n        $message.error(msg)\n        return\n    }\n\n    EventsOff(data.monitorEvent)\n    data.monitorEvent = ''\n}\n\nconst onCopyLog = async () => {\n    await ClipboardSetText(join(data.list, '\\n'))\n    $message.success(i18n.t('interface.copy_succ'))\n}\n\nconst onExportLog = () => {\n    ExportLog(data.list)\n}\n\nconst onCleanLog = () => {\n    data.list = []\n}\n</script>\n\n<template>\n    <div class=\"content-log content-container fill-height flex-box-v\">\n        <n-form class=\"flex-item\" label-align=\"left\" label-placement=\"left\" label-width=\"auto\" size=\"small\">\n            <n-form-item :feedback=\"$t('monitor.warning')\" :label=\"$t('monitor.actions')\">\n                <n-space :wrap=\"false\" :wrap-item=\"false\" style=\"width: 100%\">\n                    <n-button\n                        v-if=\"!isMonitoring\"\n                        :focusable=\"false\"\n                        secondary\n                        strong\n                        type=\"success\"\n                        @click=\"onStartMonitor\">\n                        <template #icon>\n                            <n-icon :component=\"Play\" size=\"18\" />\n                        </template>\n                        {{ $t('monitor.start') }}\n                    </n-button>\n                    <n-button v-else :focusable=\"false\" secondary strong type=\"warning\" @click=\"onStopMonitor\">\n                        <template #icon>\n                            <n-icon :component=\"Pause\" size=\"18\" />\n                        </template>\n                        {{ $t('monitor.stop') }}\n                    </n-button>\n                    <n-button-group>\n                        <icon-button\n                            :icon=\"Copy\"\n                            border\n                            size=\"18\"\n                            stroke-width=\"3.5\"\n                            t-tooltip=\"monitor.copy_log\"\n                            @click=\"onCopyLog\" />\n                        <icon-button\n                            :icon=\"Export\"\n                            border\n                            size=\"18\"\n                            stroke-width=\"3.5\"\n                            t-tooltip=\"monitor.save_log\"\n                            @click=\"onExportLog\" />\n                    </n-button-group>\n                    <icon-button\n                        :icon=\"Bottom\"\n                        :secondary=\"data.autoShowLast\"\n                        :type=\"data.autoShowLast ? 'primary' : 'default'\"\n                        border\n                        size=\"18\"\n                        stroke-width=\"3.5\"\n                        t-tooltip=\"monitor.always_show_last\"\n                        @click=\"data.autoShowLast = !data.autoShowLast\" />\n                    <div class=\"flex-item-expand\" />\n                    <icon-button\n                        :icon=\"Delete\"\n                        border\n                        size=\"18\"\n                        stroke-width=\"3.5\"\n                        t-tooltip=\"monitor.clean_log\"\n                        @click=\"onCleanLog\" />\n                </n-space>\n            </n-form-item>\n            <n-form-item :label=\"$t('monitor.search')\">\n                <n-input v-model:value=\"data.keyword\" clearable placeholder=\"\" />\n            </n-form-item>\n        </n-form>\n        <n-virtual-list ref=\"listRef\" :item-size=\"25\" :items=\"displayList\" class=\"list-wrapper\">\n            <template #default=\"{ item }\">\n                <div class=\"line-item content-value\">\n                    <b>&gt;</b>\n                    {{ item }}\n                </div>\n            </template>\n        </n-virtual-list>\n    </div>\n</template>\n\n<style lang=\"scss\" scoped>\n@use '@/styles/content';\n\n.line-item {\n    margin-bottom: 5px;\n}\n\n.list-wrapper {\n    background-color: v-bind('themeVars.codeColor');\n    border: solid 1px v-bind('themeVars.borderColor');\n    border-radius: 3px;\n    padding: 5px 10px;\n    box-sizing: border-box;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/content_value/ContentPubsub.vue",
    "content": "<script setup>\nimport { computed, h, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'\nimport { debounce, get, isEmpty, size, uniq } from 'lodash'\nimport { useI18n } from 'vue-i18n'\nimport { useThemeVars } from 'naive-ui'\nimport useBrowserStore from 'stores/browser.js'\nimport { ClipboardSetText, EventsOff, EventsOn } from 'wailsjs/runtime/runtime.js'\nimport dayjs from 'dayjs'\nimport Publish from '@/components/icons/Publish.vue'\nimport Subscribe from '@/components/icons/Subscribe.vue'\nimport Pause from '@/components/icons/Pause.vue'\nimport Delete from '@/components/icons/Delete.vue'\nimport { Publish as PublishSend, StartSubscribe, StopSubscribe } from 'wailsjs/go/services/pubsubService.js'\nimport Checked from '@/components/icons/Checked.vue'\nimport Bottom from '@/components/icons/Bottom.vue'\nimport IconButton from '@/components/common/IconButton.vue'\nimport EditableTableColumn from '@/components/common/EditableTableColumn.vue'\n\nconst themeVars = useThemeVars()\n\nconst browserStore = useBrowserStore()\nconst i18n = useI18n()\nconst props = defineProps({\n    server: {\n        type: String,\n    },\n})\n\nconst data = reactive({\n    subscribeEvent: '',\n    list: [],\n    keyword: '',\n    autoShowLast: true,\n    ellipsisMessage: false,\n    channelHistory: [],\n})\n\nconst publishData = reactive({\n    channel: '',\n    message: '',\n    received: 0,\n    lastShowReceived: -1,\n})\n\nconst tableRef = ref(null)\n\nconst columns = computed(() => [\n    {\n        title: () => i18n.t('pubsub.time'),\n        key: 'timestamp',\n        width: 200,\n        align: 'center',\n        titleAlign: 'center',\n        render: ({ timestamp }, index) => {\n            return dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss.SSS')\n        },\n    },\n    {\n        title: () => i18n.t('pubsub.channel'),\n        key: 'channel',\n        filterOptionValue: data.client,\n        resizable: true,\n        filter: (value, row) => {\n            return value === '' || row.client === value.toString() || row.addr === value.toString()\n        },\n        width: 200,\n        align: 'center',\n        titleAlign: 'center',\n        ellipsis: {\n            tooltip: {\n                style: {\n                    maxWidth: '50vw',\n                    maxHeight: '50vh',\n                },\n                scrollable: true,\n            },\n        },\n    },\n    {\n        title: () => i18n.t('pubsub.message'),\n        key: 'message',\n        titleAlign: 'center',\n        filterOptionValue: data.keyword,\n        resizable: true,\n        className: 'content-value',\n        ellipsis: data.ellipsisMessage\n            ? {\n                  tooltip: {\n                      style: {\n                          maxWidth: '50vw',\n                          maxHeight: '50vh',\n                      },\n                      scrollable: true,\n                  },\n              }\n            : undefined,\n        filter: (value, row) => {\n            return value === '' || !!~row.cmd.indexOf(value.toString())\n        },\n    },\n    {\n        title: () => i18n.t('interface.action'),\n        key: 'action',\n        width: 60,\n        titleAlign: 'center',\n        align: 'center',\n        fixed: 'right',\n        render: (row, index) => {\n            return h(EditableTableColumn, {\n                editing: false,\n                readonly: true,\n                canRefresh: false,\n                canDelete: false,\n                onCopy: async () => {\n                    await ClipboardSetText(row.message)\n                    $message.success(i18n.t('interface.copy_succ'))\n                },\n            })\n        },\n    },\n])\n\nonMounted(() => {\n    // try to stop prev subscribe first\n    onStopSubscribe()\n})\n\nonUnmounted(() => {\n    onStopSubscribe()\n})\n\nconst isSubscribing = computed(() => {\n    return !isEmpty(data.subscribeEvent)\n})\n\nconst publishEnable = computed(() => {\n    return !isEmpty(publishData.channel)\n})\n\nconst _scrollToBottom = () => {\n    nextTick(() => {\n        tableRef.value?.scrollTo({ position: 'bottom' })\n    })\n}\nconst scrollToBottom = debounce(_scrollToBottom, 300, { leading: true, trailing: true })\n\nconst onStartSubscribe = async () => {\n    if (isSubscribing.value) {\n        return\n    }\n\n    const { data: ret, success, msg } = await StartSubscribe(props.server)\n    if (!success) {\n        $message.error(msg)\n        return\n    }\n    data.subscribeEvent = get(ret, 'eventName')\n    EventsOn(data.subscribeEvent, (content) => {\n        if (content instanceof Array) {\n            data.list.push(...content)\n        } else {\n            data.list.push(content)\n        }\n        if (data.autoShowLast) {\n            scrollToBottom()\n        }\n    })\n}\nconst onStopSubscribe = async () => {\n    const { success, msg } = await StopSubscribe(props.server)\n    if (!success) {\n        $message.error(msg)\n        return\n    }\n\n    EventsOff(data.subscribeEvent)\n    data.subscribeEvent = ''\n}\n\nconst onCleanLog = () => {\n    data.list = []\n}\n\nconst onPublish = async () => {\n    if (isEmpty(publishData.channel)) {\n        return\n    }\n\n    const {\n        success,\n        msg,\n        data: { received = 0 },\n    } = await PublishSend(props.server, publishData.channel, publishData.message || '')\n    if (!success) {\n        publishData.received = 0\n        if (!isEmpty(msg)) {\n            $message.error(msg)\n        }\n        return\n    }\n    publishData.message = ''\n    publishData.received = received\n    publishData.lastShowReceived = Date.now()\n    // save channel history\n    data.channelHistory = uniq(data.channelHistory.concat(publishData.channel))\n\n    // hide send status after 2 seconds\n    setTimeout(() => {\n        if (publishData.lastShowReceived > 0 && Date.now() - publishData.lastShowReceived > 2000) {\n            publishData.lastShowReceived = -1\n        }\n    }, 2100)\n}\n</script>\n\n<template>\n    <div class=\"content-log content-container fill-height flex-box-v\">\n        <n-form class=\"flex-item\" label-align=\"left\" label-placement=\"left\" label-width=\"auto\" size=\"small\">\n            <n-form-item :show-label=\"false\">\n                <n-space :wrap=\"false\" :wrap-item=\"false\" style=\"width: 100%\">\n                    <n-button\n                        v-if=\"!isSubscribing\"\n                        :focusable=\"false\"\n                        secondary\n                        strong\n                        type=\"success\"\n                        @click=\"onStartSubscribe\">\n                        <template #icon>\n                            <n-icon :component=\"Subscribe\" size=\"18\" />\n                        </template>\n                        {{ $t('pubsub.subscribe') }}\n                    </n-button>\n                    <n-button v-else :focusable=\"false\" secondary strong type=\"warning\" @click=\"onStopSubscribe\">\n                        <template #icon>\n                            <n-icon :component=\"Pause\" size=\"18\" />\n                        </template>\n                        {{ $t('pubsub.unsubscribe') }}\n                    </n-button>\n                    <icon-button\n                        :icon=\"Bottom\"\n                        :secondary=\"data.autoShowLast\"\n                        :type=\"data.autoShowLast ? 'primary' : 'default'\"\n                        border\n                        size=\"18\"\n                        stroke-width=\"3.5\"\n                        t-tooltip=\"monitor.always_show_last\"\n                        @click=\"data.autoShowLast = !data.autoShowLast\" />\n                    <div class=\"flex-item-expand\" />\n                    <icon-button\n                        :icon=\"Delete\"\n                        border\n                        size=\"18\"\n                        stroke-width=\"3.5\"\n                        t-tooltip=\"pubsub.clear\"\n                        @click=\"onCleanLog\" />\n                </n-space>\n            </n-form-item>\n        </n-form>\n        <n-data-table\n            ref=\"tableRef\"\n            :columns=\"columns\"\n            :data=\"data.list\"\n            :loading=\"data.loading\"\n            class=\"flex-item-expand\"\n            flex-height\n            size=\"small\"\n            virtual-scroll />\n        <div class=\"total-message\">{{ $t('pubsub.receive_message', { total: size(data.list) }) }}</div>\n        <div class=\"flex-box-h publish-input\">\n            <n-input-group>\n                <n-auto-complete\n                    v-model:value=\"publishData.channel\"\n                    :get-show=\"() => true\"\n                    :options=\"data.channelHistory\"\n                    :placeholder=\"$t('pubsub.channel')\"\n                    style=\"width: 35%; max-width: 200px\"\n                    @keydown.enter=\"onPublish\" />\n                <n-input\n                    v-model:value=\"publishData.message\"\n                    :placeholder=\"$t('pubsub.message')\"\n                    @keydown.enter=\"onPublish\">\n                    <template #suffix>\n                        <transition mode=\"out-in\" name=\"fade\">\n                            <n-tag v-show=\"publishData.lastShowReceived > 0\" bordered size=\"small\" type=\"success\">\n                                <template #icon>\n                                    <n-icon :component=\"Checked\" size=\"16\" />\n                                </template>\n                                {{ publishData.received }}\n                            </n-tag>\n                        </transition>\n                    </template>\n                </n-input>\n            </n-input-group>\n            <n-button :disabled=\"!publishEnable\" type=\"info\" @click=\"onPublish\">\n                <template #icon>\n                    <n-icon :component=\"Publish\" size=\"18\" />\n                </template>\n                {{ $t('pubsub.publish') }}\n            </n-button>\n        </div>\n    </div>\n</template>\n\n<style lang=\"scss\" scoped>\n@use '@/styles/content';\n\n.total-message {\n    margin: 10px 0 0;\n}\n\n.publish-input {\n    margin: 10px 0 0;\n    gap: 10px;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/content_value/ContentSearchInput.vue",
    "content": "<script setup>\nimport { computed, nextTick, reactive } from 'vue'\nimport { debounce, isEmpty, trim } from 'lodash'\nimport { NButton, NInput } from 'naive-ui'\nimport IconButton from '@/components/common/IconButton.vue'\nimport SpellCheck from '@/components/icons/SpellCheck.vue'\n\nconst props = defineProps({\n    fullSearchIcon: {\n        type: [String, Object],\n        default: null,\n    },\n    debounceWait: {\n        type: Number,\n        default: 500,\n    },\n    small: {\n        type: Boolean,\n        default: false,\n    },\n    useGlob: {\n        type: Boolean,\n        default: false,\n    },\n    exact: {\n        type: Boolean,\n        default: false,\n    },\n})\n\nconst emit = defineEmits(['filterChanged', 'matchChanged', 'exactChanged'])\n\n/**\n *\n * @type {UnwrapNestedRefs<{filter: string, match: string, exact: boolean}>}\n */\nconst inputData = reactive({\n    match: '',\n    filter: '',\n    exact: false,\n})\n\nconst hasMatch = computed(() => {\n    return !isEmpty(trim(inputData.match))\n})\n\nconst hasFilter = computed(() => {\n    return !isEmpty(trim(inputData.filter))\n})\n\nconst onExactChecked = () => {\n    // update search search result\n    if (hasMatch.value) {\n        nextTick(() => onForceFullSearch())\n    }\n}\n\nconst onFullSearch = () => {\n    inputData.filter = trim(inputData.filter)\n    if (!isEmpty(inputData.filter)) {\n        inputData.match = inputData.filter\n        inputData.filter = ''\n        emit('matchChanged', inputData.match, inputData.filter, inputData.exact)\n    }\n}\n\nconst onForceFullSearch = () => {\n    inputData.filter = trim(inputData.filter)\n    emit('matchChanged', inputData.match, inputData.filter, inputData.exact)\n}\n\nconst _onInput = () => {\n    emit('filterChanged', inputData.filter, inputData.exact)\n}\nconst onInput = debounce(_onInput, props.debounceWait, { leading: true, trailing: true })\n\nconst onClearFilter = () => {\n    inputData.filter = ''\n    onClearMatch()\n}\n\nconst onUpdateMatch = () => {\n    inputData.filter = inputData.match\n    onClearMatch()\n}\n\nconst onClearMatch = () => {\n    const changed = !isEmpty(inputData.match)\n    inputData.match = ''\n    if (changed) {\n        emit('matchChanged', inputData.match, inputData.filter, inputData.exact)\n    } else {\n        emit('filterChanged', inputData.filter, inputData.exact)\n    }\n}\n\ndefineExpose({\n    reset: onClearFilter,\n})\n</script>\n\n<template>\n    <n-input-group style=\"overflow: hidden\">\n        <slot name=\"prepend\" />\n        <n-input\n            v-model:value=\"inputData.filter\"\n            :placeholder=\"$t('interface.filter')\"\n            :size=\"props.small ? 'small' : ''\"\n            :theme-overrides=\"{ paddingSmall: '0 3px', paddingMedium: '0 6px' }\"\n            clearable\n            @clear=\"onClearFilter\"\n            @input=\"onInput\"\n            @keyup.enter=\"onFullSearch\">\n            <template #prefix>\n                <slot name=\"prefix\" />\n                <n-tooltip v-if=\"hasMatch\" placement=\"bottom\">\n                    <template #trigger>\n                        <n-tag closable size=\"small\" @close=\"onClearMatch\" @dblclick=\"onUpdateMatch\">\n                            {{ inputData.match }}\n                        </n-tag>\n                    </template>\n                    {{\n                        $t('interface.full_search_result', {\n                            pattern: props.useGlob ? inputData.match : '*' + inputData.match + '*',\n                        })\n                    }}\n                </n-tooltip>\n            </template>\n            <template #suffix>\n                <template v-if=\"props.useGlob\">\n                    <n-tooltip placement=\"bottom\" trigger=\"hover\">\n                        <template #trigger>\n                            <n-tag\n                                v-model:checked=\"inputData.exact\"\n                                :checkable=\"true\"\n                                :type=\"props.exact ? 'primary' : 'default'\"\n                                size=\"small\"\n                                strong\n                                style=\"padding: 0 5px\"\n                                @updateChecked=\"onExactChecked\">\n                                <n-icon :size=\"14\">\n                                    <spell-check :stroke-width=\"2\" />\n                                </n-icon>\n                            </n-tag>\n                        </template>\n                        <div class=\"text-block\" style=\"max-width: 600px\">\n                            {{ $t('dialogue.filter.exact_match_tip') }}\n                        </div>\n                    </n-tooltip>\n                </template>\n            </template>\n        </n-input>\n\n        <icon-button\n            v-if=\"props.fullSearchIcon\"\n            :disabled=\"hasMatch && !hasFilter\"\n            :icon=\"props.fullSearchIcon\"\n            :size=\"small ? 16 : 20\"\n            :tooltip-delay=\"1\"\n            border\n            small\n            stroke-width=\"4\"\n            @click=\"onFullSearch\">\n            <template #tooltip>\n                <div class=\"text-block\" style=\"max-width: 600px\">\n                    {{ $t('dialogue.filter.filter_pattern_tip') }}\n                </div>\n            </template>\n        </icon-button>\n        <n-button v-else :disabled=\"hasMatch && !hasFilter\" :focusable=\"false\" @click=\"onFullSearch\">\n            {{ $t('interface.full_search') }}\n        </n-button>\n        <slot name=\"append\" />\n    </n-input-group>\n</template>\n\n<style lang=\"scss\" scoped>\n:deep(.n-input) {\n    width: 100%;\n    overflow: hidden;\n}\n\n:deep(.n-input__prefix) {\n    max-width: 50%;\n\n    & > div {\n        overflow: hidden;\n        text-overflow: ellipsis;\n    }\n}\n\n:deep(.n-tag__content) {\n    overflow: hidden;\n    max-width: 100%;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/content_value/ContentServerStatus.vue",
    "content": "<script setup>\nimport {\n    cloneDeep,\n    flatMap,\n    get,\n    isEmpty,\n    map,\n    mapValues,\n    pickBy,\n    random,\n    slice,\n    split,\n    sum,\n    toArray,\n    toNumber,\n} from 'lodash'\nimport { computed, h, onMounted, onUnmounted, reactive, ref, shallowRef, toRaw, watch } from 'vue'\nimport IconButton from '@/components/common/IconButton.vue'\nimport Filter from '@/components/icons/Filter.vue'\nimport Refresh from '@/components/icons/Refresh.vue'\nimport useBrowserStore from 'stores/browser.js'\nimport { timeout } from '@/utils/promise.js'\nimport AutoRefreshForm from '@/components/common/AutoRefreshForm.vue'\nimport { NButton, NIcon, NSpace, useThemeVars } from 'naive-ui'\nimport { Line } from 'vue-chartjs'\nimport dayjs from 'dayjs'\nimport { convertBytes, formatBytes } from '@/utils/byte_convert.js'\nimport usePreferencesStore from 'stores/preferences.js'\nimport { useI18n } from 'vue-i18n'\nimport useConnectionStore from 'stores/connections.js'\nimport { toHumanReadable } from '@/utils/date.js'\n\nconst props = defineProps({\n    server: String,\n    pause: Boolean,\n})\n\nconst browserStore = useBrowserStore()\nconst prefStore = usePreferencesStore()\nconst connectionStore = useConnectionStore()\nconst i18n = useI18n()\nconst themeVars = useThemeVars()\nconst serverInfo = ref({})\nconst pageState = reactive({\n    autoRefresh: false,\n    refreshInterval: 5,\n    loading: false, // loading status for refresh\n    autoLoading: false, // loading status for auto refresh\n})\nconst statusHistory = 5\n\n/**\n *\n * @param origin\n * @param {string[]} [labels]\n * @param {number[][]} [datalist]\n * @return {unknown}\n */\nconst generateData = (origin, labels, datalist) => {\n    let ret = toRaw(origin)\n    ret.labels = labels || ret.labels\n    if (datalist && datalist.length > 0) {\n        for (let i = 0; i < datalist.length; i++) {\n            ret.datasets[i].data = datalist[i]\n        }\n    }\n    return cloneDeep(ret)\n}\n\n/**\n * refresh server status info\n * @param {boolean} [force] force refresh will show loading indicator\n * @returns {Promise<void>}\n */\nconst refreshInfo = async (force) => {\n    if (force) {\n        pageState.loading = true\n    } else {\n        pageState.autoLoading = true\n    }\n    if (!isEmpty(props.server) && browserStore.isConnected(props.server)) {\n        try {\n            const info = await browserStore.getServerInfo(props.server, true)\n            if (!isEmpty(info)) {\n                serverInfo.value = info\n                _updateChart(info)\n            }\n        } finally {\n            pageState.loading = false\n            pageState.autoLoading = false\n        }\n    }\n}\n\nconst _updateChart = (info) => {\n    let timeLabels = toRaw(cmdRate.value.labels)\n    timeLabels = timeLabels.concat(dayjs().format('HH:mm:ss'))\n    timeLabels = slice(timeLabels, Math.max(0, timeLabels.length - statusHistory))\n\n    // commands per seconds\n    {\n        let dataset = toRaw(cmdRate.value.datasets[0].data)\n        const cmd = parseInt(get(info, 'Stats.instantaneous_ops_per_sec', '0'))\n        dataset = dataset.concat(cmd)\n        dataset = slice(dataset, Math.max(0, dataset.length - statusHistory))\n        cmdRate.value = generateData(cmdRate.value, timeLabels, [dataset])\n    }\n\n    // connected clients\n    {\n        let dataset = toRaw(connectedClients.value.datasets[0].data)\n        const count = parseInt(get(info, 'Clients.connected_clients', '0'))\n        dataset = dataset.concat(count)\n        dataset = slice(dataset, Math.max(0, dataset.length - statusHistory))\n        connectedClients.value = generateData(connectedClients.value, timeLabels, [dataset])\n    }\n\n    // memory usage\n    {\n        let dataset = toRaw(memoryUsage.value.datasets[0].data)\n        let size = parseInt(get(info, 'Memory.used_memory', '0'))\n        dataset = dataset.concat(size)\n        dataset = slice(dataset, Math.max(0, dataset.length - statusHistory))\n        memoryUsage.value = generateData(memoryUsage.value, timeLabels, [dataset])\n    }\n\n    // network input/output rate\n    {\n        let dataset1 = toRaw(networkRate.value.datasets[0].data)\n        const input = parseInt(get(info, 'Stats.instantaneous_input_kbps', '0'))\n        dataset1 = dataset1.concat(input)\n        dataset1 = slice(dataset1, Math.max(0, dataset1.length - statusHistory))\n\n        let dataset2 = toRaw(networkRate.value.datasets[1].data)\n        const output = parseInt(get(info, 'Stats.instantaneous_output_kbps', '0'))\n        dataset2 = dataset2.concat(output)\n        dataset2 = slice(dataset2, Math.max(0, dataset2.length - statusHistory))\n        networkRate.value = generateData(networkRate.value, timeLabels, [dataset1, dataset2])\n    }\n}\n\n/**\n * for mock activity data only\n * @private\n */\nconst _mockChart = () => {\n    const timeLabels = []\n    for (let i = 0; i < 5; i++) {\n        timeLabels.push(dayjs().add(5, 'seconds').format('HH:mm:ss'))\n    }\n\n    // commands per seconds\n    {\n        const dataset = []\n        for (let i = 0; i < 5; i++) {\n            dataset.push(random(10, 200))\n        }\n        cmdRate.value = generateData(cmdRate.value, timeLabels, [dataset])\n    }\n\n    // connected clients\n    {\n        const dataset = []\n        for (let i = 0; i < 5; i++) {\n            dataset.push(random(10, 20))\n        }\n        connectedClients.value = generateData(connectedClients.value, timeLabels, [dataset])\n    }\n\n    // memory usage\n    {\n        const dataset = []\n        for (let i = 0; i < 5; i++) {\n            dataset.push(random(120 * 1024 * 1024, 200 * 1024 * 1024))\n        }\n        memoryUsage.value = generateData(memoryUsage.value, timeLabels, [dataset])\n    }\n\n    // network input/output rate\n    {\n        const dataset1 = []\n        for (let i = 0; i < 5; i++) {\n            dataset1.push(random(100, 1500))\n        }\n\n        const dataset2 = []\n        for (let i = 0; i < 5; i++) {\n            dataset2.push(random(200, 3000))\n        }\n\n        networkRate.value = generateData(networkRate.value, timeLabels, [dataset1, dataset2])\n    }\n}\n\nconst isLoading = computed(() => {\n    return pageState.loading || pageState.autoLoading\n})\n\nconst startAutoRefresh = async () => {\n    // connectionStore.getRefreshInterval()\n    let lastExec = Date.now()\n    do {\n        if (!pageState.autoRefresh) {\n            break\n        }\n        await timeout(100)\n        if (\n            props.pause ||\n            pageState.loading ||\n            pageState.autoLoading ||\n            Date.now() - lastExec < pageState.refreshInterval * 1000\n        ) {\n            continue\n        }\n        lastExec = Date.now()\n        await refreshInfo()\n    } while (true)\n    stopAutoRefresh()\n}\n\nconst stopAutoRefresh = () => {\n    pageState.autoRefresh = false\n}\n\nconst onToggleRefresh = (on) => {\n    if (on) {\n        tabVal.value = 'activity'\n        connectionStore.saveRefreshInterval(props.server, pageState.refreshInterval || 5)\n        startAutoRefresh()\n    } else {\n        connectionStore.saveRefreshInterval(props.server, -1)\n        stopAutoRefresh()\n    }\n}\n\nonMounted(() => {\n    const interval = connectionStore.getRefreshInterval(props.server)\n    if (interval >= 0) {\n        pageState.autoRefresh = true\n        pageState.refreshInterval = interval === 0 ? 5 : interval\n        onToggleRefresh(true)\n    } else {\n        setTimeout(refreshInfo, 3000)\n        // setTimeout(_mockChart, 1000)\n    }\n    refreshInfo()\n})\n\nonUnmounted(() => {\n    stopAutoRefresh()\n})\n\nconst redisVersion = computed(() => {\n    return get(serverInfo.value, 'Server.redis_version', '')\n})\n\nconst redisMode = computed(() => {\n    return get(serverInfo.value, 'Server.redis_mode', '')\n})\n\nconst role = computed(() => {\n    return get(serverInfo.value, 'Replication.role', '')\n})\n\nconst timeUnit = ['common.unit_minute', 'common.unit_hour', 'common.unit_day']\nconst uptime = computed(() => {\n    let seconds = parseInt(get(serverInfo.value, 'Server.uptime_in_seconds', '0'))\n    seconds /= 60\n    if (seconds < 60) {\n        // minutes\n        return { value: Math.floor(seconds), unit: timeUnit[0] }\n    }\n    seconds /= 60\n    if (seconds < 60) {\n        // hours\n        return { value: Math.floor(seconds), unit: timeUnit[1] }\n    }\n    return { value: Math.floor(seconds / 24), unit: timeUnit[2] }\n})\n\nconst usedMemory = computed(() => {\n    let size = parseInt(get(serverInfo.value, 'Memory.used_memory', '0'))\n    const { value, unit } = convertBytes(size)\n    return [value, unit]\n})\n\nconst totalKeys = computed(() => {\n    const regex = /^db\\d+$/\n    const result = pickBy(serverInfo.value['Keyspace'], (value, key) => {\n        return regex.test(key)\n    })\n    const nums = mapValues(result, (v) => {\n        const keys = split(v, ',', 1)[0]\n        const num = split(keys, '=', 2)[1]\n        return toNumber(num)\n    })\n    return sum(toArray(nums))\n})\n\nconst tabVal = ref('activity')\nconst infoFilter = reactive({\n    keyword: '',\n    group: 'CPU',\n})\n\nconst info = computed(() => {\n    if (!isEmpty(infoFilter.group)) {\n        const val = serverInfo.value[infoFilter.group]\n        if (!isEmpty(val)) {\n            return map(val, (v, k) => ({\n                key: k,\n                value: v,\n            }))\n        }\n    }\n\n    return flatMap(serverInfo.value, (value, key) => {\n        return map(value, (v, k) => ({\n            group: key,\n            key: k,\n            value: v,\n        }))\n    })\n})\n\nconst onFilterGroup = (group) => {\n    if (group === infoFilter.group) {\n        infoFilter.group = ''\n    } else {\n        infoFilter.group = group\n    }\n}\n\nwatch(\n    () => prefStore.currentLanguage,\n    () => {\n        // force update labels of charts\n        cmdRate.value.datasets[0].label = i18n.t('status.act_cmd')\n        cmdRate.value = generateData(cmdRate.value)\n        connectedClients.value.datasets[0].label = i18n.t('status.connected_clients')\n        connectedClients.value = generateData(connectedClients.value)\n        memoryUsage.value.datasets[0].label = i18n.t('status.memory_used')\n        memoryUsage.value = generateData(memoryUsage.value)\n        networkRate.value.datasets[0].label = i18n.t('status.act_network_input')\n        networkRate.value.datasets[1].label = i18n.t('status.act_network_output')\n        networkRate.value = generateData(networkRate.value)\n    },\n)\n\nconst chartBGColor = [\n    'rgba(255, 99, 132, 0.2)',\n    'rgba(255, 159, 64, 0.2)',\n    'rgba(153, 102, 255, 0.2)',\n    'rgba(75, 192, 192, 0.2)',\n    'rgba(54, 162, 235, 0.2)',\n]\n\nconst chartBorderColor = [\n    'rgb(255, 99, 132)',\n    'rgb(255, 159, 64)',\n    'rgb(153, 102, 255)',\n    'rgb(75, 192, 192)',\n    'rgb(54, 162, 235)',\n]\n\nconst cmdRate = shallowRef({\n    labels: [],\n    datasets: [\n        {\n            label: i18n.t('status.act_cmd'),\n            data: [],\n            fill: true,\n            backgroundColor: chartBGColor[0],\n            borderColor: chartBorderColor[0],\n            tension: 0.4,\n        },\n    ],\n})\n\nconst connectedClients = shallowRef({\n    labels: [],\n    datasets: [\n        {\n            label: i18n.t('status.connected_clients'),\n            data: [],\n            fill: true,\n            backgroundColor: chartBGColor[1],\n            borderColor: chartBorderColor[1],\n            tension: 0.4,\n        },\n    ],\n})\n\nconst memoryUsage = shallowRef({\n    labels: [],\n    datasets: [\n        {\n            label: i18n.t('status.memory_used'),\n            data: [],\n            fill: true,\n            backgroundColor: chartBGColor[2],\n            borderColor: chartBorderColor[2],\n            tension: 0.4,\n        },\n    ],\n})\n\nconst networkRate = shallowRef({\n    labels: [],\n    datasets: [\n        {\n            label: i18n.t('status.act_network_input'),\n            data: [],\n            fill: true,\n            backgroundColor: chartBGColor[3],\n            borderColor: chartBorderColor[3],\n            tension: 0.4,\n        },\n        {\n            label: i18n.t('status.act_network_output'),\n            data: [],\n            fill: true,\n            backgroundColor: chartBGColor[4],\n            borderColor: chartBorderColor[4],\n            tension: 0.4,\n        },\n    ],\n})\n\nconst chartOption = computed(() => {\n    return {\n        animation: false,\n        responsive: true,\n        maintainAspectRatio: false,\n        events: [],\n        scales: {\n            x: {\n                grid: {\n                    color: themeVars.value.borderColor,\n                },\n                ticks: {\n                    color: themeVars.value.textColor3,\n                },\n            },\n            y: {\n                beginAtZero: true,\n                stepSize: 1024,\n                suggestedMin: 0,\n                grid: {\n                    color: themeVars.value.borderColor,\n                },\n                ticks: {\n                    color: themeVars.value.textColor3,\n                    precision: 0,\n                },\n            },\n        },\n        plugins: {\n            legend: {\n                labels: {\n                    color: themeVars.value.textColor2,\n                },\n            },\n        },\n    }\n})\n\nconst byteChartOption = computed(() => {\n    return {\n        animation: false,\n        responsive: true,\n        maintainAspectRatio: false,\n        events: [],\n        scales: {\n            x: {\n                grid: {\n                    color: themeVars.value.borderColor,\n                },\n                ticks: {\n                    color: themeVars.value.textColor3,\n                },\n            },\n            y: {\n                beginAtZero: true,\n                stepSize: 1024,\n                suggestedMin: 0,\n                grid: {\n                    color: themeVars.value.borderColor,\n                },\n                ticks: {\n                    color: themeVars.value.textColor3,\n                    precision: 0,\n                    // format display y axios tag\n                    callback: function (value, index, values) {\n                        return formatBytes(value, 1)\n                    },\n                },\n            },\n        },\n        plugins: {\n            legend: {\n                labels: {\n                    color: themeVars.value.textColor2,\n                },\n            },\n        },\n    }\n})\n\nconst clientInfo = reactive({\n    loading: false,\n    content: [],\n})\nconst onShowClients = async (show) => {\n    if (show) {\n        try {\n            clientInfo.loading = true\n            clientInfo.content = await browserStore.getClientList(props.server)\n        } finally {\n            clientInfo.loading = false\n        }\n    }\n}\n\nconst clientTableColumns = computed(() => {\n    return [\n        {\n            key: 'title',\n            title: () => {\n                return h(NSpace, { wrap: false, wrapItem: false, justify: 'center' }, () => [\n                    h('span', { style: { fontWeight: '550', fontSize: '15px' } }, i18n.t('status.client.title')),\n                    h(IconButton, {\n                        icon: Refresh,\n                        size: 16,\n                        onClick: () => onShowClients(true),\n                    }),\n                ])\n            },\n            align: 'center',\n            titleAlign: 'center',\n            children: [\n                {\n                    key: 'no',\n                    title: '#',\n                    width: 60,\n                    align: 'center',\n                    titleAlign: 'center',\n                    render: (row, index) => {\n                        return index + 1\n                    },\n                },\n                {\n                    key: 'addr',\n                    title: () => i18n.t('status.client.addr'),\n                    sorter: 'default',\n                    align: 'center',\n                    titleAlign: 'center',\n                },\n                {\n                    key: 'db',\n                    title: () => i18n.t('status.client.db'),\n                    align: 'center',\n                    titleAlign: 'center',\n                },\n                {\n                    key: 'age',\n                    title: () => i18n.t('status.client.age'),\n                    sorter: (row1, row2) => row1.age - row2.age,\n                    defaultSortOrder: 'descend',\n                    align: 'center',\n                    titleAlign: 'center',\n                    render: ({ age }, index) => {\n                        return toHumanReadable(age)\n                    },\n                },\n                {\n                    key: 'idle',\n                    title: () => i18n.t('status.client.idle'),\n                    sorter: (row1, row2) => row1.idle - row2.idle,\n                    align: 'center',\n                    titleAlign: 'center',\n                    render: ({ idle }, index) => {\n                        return toHumanReadable(idle)\n                    },\n                },\n            ],\n        },\n    ]\n})\n</script>\n\n<template>\n    <n-space :size=\"5\" :wrap-item=\"false\" style=\"padding: 5px; box-sizing: border-box; height: 100%\" vertical>\n        <n-card embedded>\n            <template #header>\n                <n-space :wrap-item=\"false\" align=\"center\" inline size=\"small\">\n                    {{ props.server }}\n                    <n-tooltip v-if=\"redisVersion\">\n                        Redis Version\n                        <template #trigger>\n                            <n-tag size=\"small\" type=\"primary\">v{{ redisVersion }}</n-tag>\n                        </template>\n                    </n-tooltip>\n                    <n-tooltip v-if=\"redisMode\">\n                        Mode\n                        <template #trigger>\n                            <n-tag size=\"small\" type=\"primary\">{{ redisMode }}</n-tag>\n                        </template>\n                    </n-tooltip>\n                    <n-tooltip v-if=\"role\">\n                        Role\n                        <template #trigger>\n                            <n-tag size=\"small\" type=\"primary\">{{ role }}</n-tag>\n                        </template>\n                    </n-tooltip>\n                </n-space>\n            </template>\n            <template #header-extra>\n                <n-popover keep-alive-on-hover placement=\"bottom-end\" trigger=\"hover\">\n                    <template #trigger>\n                        <n-button\n                            :loading=\"pageState.loading\"\n                            :type=\"isLoading ? 'primary' : 'default'\"\n                            circle\n                            size=\"small\"\n                            tertiary\n                            @click=\"refreshInfo(true)\">\n                            <template #icon>\n                                <n-icon :size=\"props.size\">\n                                    <refresh\n                                        :class=\"{\n                                            'auto-rotate': pageState.autoRefresh || isLoading,\n                                        }\"\n                                        :color=\"pageState.autoRefresh ? themeVars.primaryColor : undefined\"\n                                        :stroke-width=\"pageState.autoRefresh ? 6 : 3\" />\n                                </n-icon>\n                            </template>\n                        </n-button>\n                    </template>\n                    <auto-refresh-form\n                        v-model:interval=\"pageState.refreshInterval\"\n                        v-model:on=\"pageState.autoRefresh\"\n                        :default-value=\"5\"\n                        :loading=\"pageState.autoLoading\"\n                        @toggle=\"onToggleRefresh\" />\n                </n-popover>\n            </template>\n            <n-grid style=\"min-width: 500px\" x-gap=\"5\">\n                <n-gi :span=\"6\">\n                    <n-statistic :label=\"$t('status.uptime')\" :value=\"uptime.value\">\n                        <template #suffix>{{ $t(uptime.unit) }}</template>\n                    </n-statistic>\n                </n-gi>\n                <n-gi :span=\"6\">\n                    <n-statistic\n                        :label=\"$t('status.connected_clients')\"\n                        :value=\"get(serverInfo, 'Clients.connected_clients', '0')\">\n                        <template #suffix>\n                            <n-tooltip\n                                :content-style=\"{ backgroundColor: themeVars.tableColor }\"\n                                trigger=\"click\"\n                                width=\"70vw\"\n                                @update-show=\"onShowClients\">\n                                <template #trigger>\n                                    <n-button :bordered=\"false\" size=\"small\">&LowerRightArrow;</n-button>\n                                </template>\n                                <n-data-table\n                                    :columns=\"clientTableColumns\"\n                                    :data=\"clientInfo.content\"\n                                    :loading=\"clientInfo.loading\"\n                                    :single-column=\"false\"\n                                    :single-line=\"false\"\n                                    max-height=\"50vh\"\n                                    size=\"small\"\n                                    striped />\n                            </n-tooltip>\n                        </template>\n                    </n-statistic>\n                </n-gi>\n                <n-gi :span=\"6\">\n                    <n-statistic :value=\"totalKeys\">\n                        <template #label>\n                            {{ $t('status.total_keys') }}\n                        </template>\n                    </n-statistic>\n                </n-gi>\n                <n-gi :span=\"6\">\n                    <n-statistic :label=\"$t('status.memory_used')\" :value=\"usedMemory[0]\">\n                        <template #suffix>{{ usedMemory[1] }}</template>\n                    </n-statistic>\n                </n-gi>\n            </n-grid>\n        </n-card>\n        <n-card class=\"flex-item-expand\" content-style=\"padding: 0; height: 100%;\" embedded style=\"overflow: hidden\">\n            <n-tabs\n                v-model:value=\"tabVal\"\n                :tabs-padding=\"20\"\n                pane-style=\"padding: 10px; box-sizing: border-box; display: flex; flex-direction: column; flex-grow: 1;\"\n                size=\"large\"\n                style=\"height: 100%; overflow: hidden\"\n                type=\"line\">\n                <template #suffix>\n                    <div v-if=\"tabVal === 'info'\" style=\"padding-right: 10px\">\n                        <n-input v-model:value=\"infoFilter.keyword\" clearable placeholder=\"\">\n                            <template #prefix>\n                                <icon-button :icon=\"Filter\" size=\"18\" />\n                            </template>\n                        </n-input>\n                    </div>\n                </template>\n\n                <!-- activity tab pane -->\n                <n-tab-pane\n                    :tab=\"$t('status.activity_status')\"\n                    class=\"line-chart\"\n                    display-directive=\"show:lazy\"\n                    name=\"activity\">\n                    <div class=\"line-chart\">\n                        <div class=\"line-chart-item\">\n                            <Line :data=\"cmdRate\" :options=\"chartOption\" />\n                        </div>\n                        <div class=\"line-chart-item\">\n                            <Line :data=\"connectedClients\" :options=\"chartOption\" />\n                        </div>\n                        <div class=\"line-chart-item\">\n                            <Line :data=\"memoryUsage\" :options=\"byteChartOption\" />\n                        </div>\n                        <div class=\"line-chart-item\">\n                            <Line :data=\"networkRate\" :options=\"byteChartOption\" />\n                        </div>\n                    </div>\n                </n-tab-pane>\n\n                <!-- info tab pane -->\n                <n-tab-pane :tab=\"$t('status.server_info')\" name=\"info\">\n                    <n-space :wrap=\"false\" :wrap-item=\"false\" class=\"flex-item-expand\">\n                        <n-space align=\"end\" item-style=\"padding: 0 5px;\" vertical>\n                            <n-button\n                                v-for=\"(v, k) in serverInfo\"\n                                :key=\"k\"\n                                :disabled=\"isEmpty(v)\"\n                                :focusable=\"false\"\n                                :type=\"infoFilter.group === k ? 'primary' : 'default'\"\n                                secondary\n                                size=\"small\"\n                                @click=\"onFilterGroup(k)\">\n                                <span style=\"min-width: 80px\">{{ k }}</span>\n                            </n-button>\n                        </n-space>\n                        <n-data-table\n                            :columns=\"[\n                                {\n                                    title: $t('common.key'),\n                                    key: 'key',\n                                    defaultSortOrder: 'ascend',\n                                    minWidth: 80,\n                                    titleAlign: 'center',\n                                    filterOptionValue: infoFilter.keyword,\n                                    filter(value, row) {\n                                        return !!~row.key.indexOf(value.toString())\n                                    },\n                                },\n                                { title: $t('common.value'), titleAlign: 'center', key: 'value' },\n                            ]\"\n                            :data=\"info\"\n                            :loading=\"pageState.loading\"\n                            :single-line=\"false\"\n                            class=\"flex-item-expand\"\n                            flex-height\n                            striped />\n                    </n-space>\n                </n-tab-pane>\n            </n-tabs>\n        </n-card>\n    </n-space>\n</template>\n\n<style lang=\"scss\" scoped>\n@use '@/styles/content';\n\n.line-chart {\n    display: flex;\n    flex-wrap: wrap;\n    width: 100%;\n    height: 100%;\n\n    &-item {\n        display: flex;\n        flex-direction: column;\n        justify-content: center;\n        align-items: center;\n        width: 50%;\n        height: 50%;\n        padding: 10px;\n        box-sizing: border-box;\n    }\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/content_value/ContentSlog.vue",
    "content": "<script setup>\nimport { computed, h, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'\nimport Refresh from '@/components/icons/Refresh.vue'\nimport { debounce, isEmpty, map, size, split } from 'lodash'\nimport { useI18n } from 'vue-i18n'\nimport { NIcon, useThemeVars } from 'naive-ui'\nimport dayjs from 'dayjs'\nimport useBrowserStore from 'stores/browser.js'\nimport { timeout } from '@/utils/promise.js'\nimport AutoRefreshForm from '@/components/common/AutoRefreshForm.vue'\n\nconst themeVars = useThemeVars()\n\nconst browserStore = useBrowserStore()\nconst i18n = useI18n()\nconst props = defineProps({\n    server: {\n        type: String,\n    },\n})\n\nconst autoRefresh = reactive({\n    on: false,\n    interval: 5,\n})\n\nconst data = reactive({\n    list: [],\n    sortOrder: 'descend',\n    listLimit: 20,\n    loading: false,\n    client: '',\n    keyword: '',\n})\n\nconst tableRef = ref(null)\n\nconst columns = computed(() => [\n    {\n        title: () => i18n.t('slog.exec_time'),\n        key: 'timestamp',\n        sortOrder: data.sortOrder,\n        sorter: 'default',\n        width: 180,\n        align: 'center',\n        titleAlign: 'center',\n        render: ({ timestamp }, index) => {\n            return dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')\n        },\n    },\n    {\n        title: () => i18n.t('slog.client'),\n        key: 'client',\n        filterOptionValue: data.client,\n        resizable: true,\n        filter: (value, row) => {\n            return value === '' || row.client === value.toString() || row.addr === value.toString()\n        },\n        width: 200,\n        align: 'center',\n        titleAlign: 'center',\n        ellipsis: {\n            tooltip: {\n                style: {\n                    maxWidth: '50vw',\n                    maxHeight: '50vh',\n                },\n                scrollable: true,\n            },\n        },\n        render: ({ client, addr }, index) => {\n            let content = ''\n            if (!isEmpty(client)) {\n                content += client\n            }\n            if (!isEmpty(addr)) {\n                if (!isEmpty(content)) {\n                    content += ' - '\n                }\n                content += addr\n            }\n            return content\n        },\n    },\n    {\n        title: () => i18n.t('slog.cmd'),\n        key: 'cmd',\n        titleAlign: 'center',\n        filterOptionValue: data.keyword,\n        resizable: true,\n        filter: (value, row) => {\n            return value === '' || !!~row.cmd.indexOf(value.toString())\n        },\n        render: ({ cmd }, index) => {\n            const cmdList = split(cmd, '\\n')\n            if (size(cmdList) > 1) {\n                return h(\n                    'div',\n                    null,\n                    map(cmdList, (c) => h('div', { class: 'cmd-line' }, c)),\n                )\n            }\n            return h('div', { class: 'cmd-line' }, cmd)\n        },\n    },\n    {\n        title: () => i18n.t('slog.cost_time'),\n        key: 'cost',\n        width: 100,\n        align: 'center',\n        titleAlign: 'center',\n        render: ({ cost }, index) => {\n            const ms = dayjs.duration(cost).asMilliseconds()\n            if (ms < 1000) {\n                return `${ms} ms`\n            } else {\n                return `${Math.floor(ms / 1000)} s`\n            }\n        },\n    },\n])\n\nconst _loadSlowLog = () => {\n    data.loading = true\n    browserStore\n        .getSlowLog(props.server, data.listLimit)\n        .then((list) => {\n            data.list = list || []\n        })\n        .finally(async () => {\n            data.loading = false\n            await nextTick()\n            tableRef.value?.scrollTo({ position: data.sortOrder === 'ascend' ? 'bottom' : 'top' })\n        })\n}\nconst loadSlowLog = debounce(_loadSlowLog, 1000, { leading: true, trailing: true })\n\nconst startAutoRefresh = async () => {\n    let lastExec = Date.now()\n    do {\n        if (!autoRefresh.on) {\n            break\n        }\n        await timeout(100)\n        if (data.loading || Date.now() - lastExec < autoRefresh.interval * 1000) {\n            continue\n        }\n        lastExec = Date.now()\n        loadSlowLog()\n    } while (true)\n    stopAutoRefresh()\n}\n\nconst stopAutoRefresh = () => {\n    autoRefresh.on = false\n}\n\nonMounted(() => loadSlowLog())\n\nonUnmounted(() => stopAutoRefresh())\n\nconst onToggleRefresh = (on) => {\n    if (on) {\n        startAutoRefresh()\n    } else {\n        stopAutoRefresh()\n    }\n}\n\nconst onListLimitChanged = (limit) => {\n    loadSlowLog()\n}\n</script>\n\n<template>\n    <div class=\"content-log content-container content-value fill-height flex-box-v\">\n        <n-form :disabled=\"data.loading\" class=\"flex-item\" inline>\n            <n-form-item :label=\"$t('slog.limit')\">\n                <n-input-number\n                    v-model:value=\"data.listLimit\"\n                    :max=\"9999\"\n                    :min=\"1\"\n                    style=\"width: 120px\"\n                    @update:value=\"onListLimitChanged\" />\n            </n-form-item>\n            <n-form-item :label=\"$t('slog.filter')\">\n                <n-input v-model:value=\"data.keyword\" clearable placeholder=\"\" />\n            </n-form-item>\n            <n-form-item label=\"&nbsp;\">\n                <n-popover :delay=\"500\" keep-alive-on-hover placement=\"bottom\" trigger=\"hover\">\n                    <template #trigger>\n                        <n-button :loading=\"data.loading\" circle size=\"small\" tertiary @click=\"_loadSlowLog\">\n                            <template #icon>\n                                <n-icon :size=\"props.size\">\n                                    <refresh\n                                        :class=\"{ 'auto-rotate': autoRefresh.on }\"\n                                        :color=\"autoRefresh.on ? themeVars.primaryColor : undefined\"\n                                        :stroke-width=\"autoRefresh.on ? 6 : 3\" />\n                                </n-icon>\n                            </template>\n                        </n-button>\n                    </template>\n                    <auto-refresh-form\n                        v-model:interval=\"autoRefresh.interval\"\n                        v-model:on=\"autoRefresh.on\"\n                        :default-value=\"5\"\n                        :loading=\"data.loading\"\n                        @toggle=\"onToggleRefresh\" />\n                </n-popover>\n            </n-form-item>\n        </n-form>\n        <n-data-table\n            ref=\"tableRef\"\n            :columns=\"columns\"\n            :data=\"data.list\"\n            :loading=\"data.loading\"\n            class=\"flex-item-expand\"\n            flex-height\n            striped\n            @update:sorter=\"({ order }) => (data.sortOrder = order)\" />\n    </div>\n</template>\n\n<style lang=\"scss\" scoped>\n@use '@/styles/content';\n</style>\n"
  },
  {
    "path": "frontend/src/components/content_value/ContentToolbar.vue",
    "content": "<script setup>\nimport { validType } from '@/consts/support_redis_type.js'\nimport useDialog from 'stores/dialog.js'\nimport Delete from '@/components/icons/Delete.vue'\nimport Edit from '@/components/icons/Edit.vue'\nimport Refresh from '@/components/icons/Refresh.vue'\nimport Timer from '@/components/icons/Timer.vue'\nimport RedisTypeTag from '@/components/common/RedisTypeTag.vue'\nimport { useI18n } from 'vue-i18n'\nimport IconButton from '@/components/common/IconButton.vue'\nimport Copy from '@/components/icons/Copy.vue'\nimport { computed, onMounted, onUnmounted, reactive, watch } from 'vue'\nimport { NIcon, useThemeVars } from 'naive-ui'\nimport { timeout } from '@/utils/promise.js'\nimport AutoRefreshForm from '@/components/common/AutoRefreshForm.vue'\nimport { toHumanReadable } from '@/utils/date.js'\nimport { ClipboardSetText } from 'wailsjs/runtime/runtime.js'\n\nconst props = defineProps({\n    server: String,\n    db: Number,\n    keyType: {\n        type: String,\n        validator(value) {\n            return validType(value)\n        },\n        default: 'STRING',\n    },\n    keyPath: String,\n    keyCode: {\n        type: Array,\n        default: null,\n    },\n    ttl: {\n        type: Number,\n        default: -1,\n    },\n    loading: Boolean,\n})\n\nconst emit = defineEmits(['reload', 'rename', 'delete'])\n\nconst autoRefresh = reactive({\n    on: false,\n    interval: 2,\n})\n\nconst ttl = reactive({\n    value: 0,\n    expire: 0,\n    intervalID: 0,\n})\n\nconst themeVars = useThemeVars()\nconst dialogStore = useDialog()\nconst i18n = useI18n()\n\nconst binaryKey = computed(() => {\n    return !!props.keyCode\n})\n\nconst ttlString = computed(() => {\n    if (ttl.value > 0) {\n        return toHumanReadable(ttl.value)\n    } else if (ttl.value < 0) {\n        return i18n.t('interface.forever')\n    } else {\n        return '00:00:00'\n    }\n})\n\nconst startAutoRefresh = async () => {\n    let lastExec = Date.now()\n    do {\n        if (!autoRefresh.on) {\n            break\n        }\n        await timeout(100)\n        if (props.loading || Date.now() - lastExec < autoRefresh.interval * 1000) {\n            continue\n        }\n        lastExec = Date.now()\n        emit('reload')\n    } while (true)\n    stopAutoRefresh()\n}\n\nconst stopAutoRefresh = () => {\n    autoRefresh.on = false\n}\n\nconst syncTTL = (seconds) => {\n    ttl.value = seconds\n    if (seconds >= 0) {\n        ttl.expire = Math.floor(Date.now() / 1000 + seconds)\n    } else {\n        ttl.expire = 0\n    }\n}\n\nwatch(\n    () => props.keyPath,\n    () => {\n        stopAutoRefresh()\n    },\n)\n\nwatch(\n    () => props.ttl,\n    (seconds) => syncTTL(seconds),\n)\n\nonMounted(() => {\n    syncTTL(props.ttl)\n    ttl.intervalID = setInterval(() => {\n        if (ttl.expire > 0) {\n            const nowSeconds = Math.floor(Date.now() / 1000)\n            ttl.value = Math.max(0, ttl.expire - nowSeconds)\n        } else {\n            ttl.value = -1\n        }\n    }, 1000)\n})\n\nonUnmounted(() => {\n    stopAutoRefresh()\n    if (ttl.intervalID > 0) {\n        clearInterval(ttl.intervalID)\n        ttl.intervalID = 0\n    }\n})\n\nconst onToggleRefresh = (on) => {\n    if (on) {\n        startAutoRefresh()\n    } else {\n        stopAutoRefresh()\n    }\n}\n\nconst onCopyKey = async () => {\n    await ClipboardSetText(props.keyPath)\n    $message.success(i18n.t('interface.copy_succ'))\n}\n\nconst onTTL = () => {\n    dialogStore.openTTLDialog({\n        server: props.server,\n        db: props.db,\n        key: binaryKey.value ? props.keyCode : props.keyPath,\n        ttl: ttl.value,\n    })\n}\n</script>\n\n<template>\n    <div class=\"content-toolbar flex-box-h\">\n        <n-input-group>\n            <redis-type-tag :binary-key=\"binaryKey\" :type=\"props.keyType\" size=\"large\" />\n            <n-input v-model:value=\"props.keyPath\" :title=\"props.keyPath\" readonly @dblclick=\"onCopyKey\">\n                <template #suffix>\n                    <n-popover :delay=\"500\" keep-alive-on-hover placement=\"bottom\" trigger=\"hover\">\n                        <template #trigger>\n                            <icon-button\n                                :loading=\"props.loading\"\n                                size=\"18\"\n                                @click=\"emit('reload')\"\n                                @dblclick.stop=\"() => {}\">\n                                <n-icon :size=\"props.size\">\n                                    <refresh\n                                        :class=\"{ 'auto-rotate': autoRefresh.on }\"\n                                        :color=\"autoRefresh.on ? themeVars.primaryColor : undefined\"\n                                        :stroke-width=\"autoRefresh.on ? 6 : 3\" />\n                                </n-icon>\n                            </icon-button>\n                        </template>\n                        <auto-refresh-form\n                            v-model:interval=\"autoRefresh.interval\"\n                            v-model:on=\"autoRefresh.on\"\n                            :default-value=\"2\"\n                            :loading=\"props.loading\"\n                            @toggle=\"onToggleRefresh\" />\n                    </n-popover>\n                </template>\n            </n-input>\n            <icon-button :icon=\"Copy\" border size=\"18\" t-tooltip=\"interface.copy_key\" @click=\"onCopyKey\" />\n        </n-input-group>\n        <n-button-group>\n            <n-tooltip>\n                <template #trigger>\n                    <n-button :focusable=\"false\" @click=\"onTTL\">\n                        <template #icon>\n                            <n-icon :component=\"Timer\" size=\"18\" />\n                        </template>\n                        <span style=\"font-variant-numeric: tabular-nums\">{{ ttlString }}</span>\n                    </n-button>\n                </template>\n                TTL{{ `${ttl > 0 ? ': ' + ttl + $t('common.second') : ''}` }}\n            </n-tooltip>\n            <icon-button\n                :disabled=\"binaryKey\"\n                :icon=\"Edit\"\n                :t-tooltip=\"binaryKey ? 'dialogue.rename_binary_key_fail' : 'interface.rename_key'\"\n                border\n                size=\"18\"\n                @click=\"emit('rename')\" />\n        </n-button-group>\n        <n-tooltip :show-arrow=\"false\">\n            <template #trigger>\n                <n-button :focusable=\"false\" @click=\"emit('delete')\">\n                    <template #icon>\n                        <n-icon :component=\"Delete\" size=\"18\" />\n                    </template>\n                </n-button>\n            </template>\n            {{ $t('interface.delete_key') }}\n        </n-tooltip>\n    </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.content-toolbar {\n    align-items: center;\n    gap: 5px;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/content_value/ContentValueHash.vue",
    "content": "<script setup>\nimport { computed, h, reactive, ref } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport AddLink from '@/components/icons/AddLink.vue'\nimport { NButton, NIcon, useThemeVars } from 'naive-ui'\nimport { types, types as redisTypes } from '@/consts/support_redis_type.js'\nimport EditableTableColumn from '@/components/common/EditableTableColumn.vue'\nimport useDialogStore from 'stores/dialog.js'\nimport { isEmpty, size, truncate } from 'lodash'\nimport { decodeTypes, formatTypes } from '@/consts/value_view_type.js'\nimport useBrowserStore from 'stores/browser.js'\nimport LoadList from '@/components/icons/LoadList.vue'\nimport LoadAll from '@/components/icons/LoadAll.vue'\nimport IconButton from '@/components/common/IconButton.vue'\nimport ContentEntryEditor from '@/components/content_value/ContentEntryEditor.vue'\nimport Edit from '@/components/icons/Edit.vue'\nimport FormatSelector from '@/components/content_value/FormatSelector.vue'\nimport { decodeRedisKey, nativeRedisKey } from '@/utils/key_convert.js'\nimport ContentSearchInput from '@/components/content_value/ContentSearchInput.vue'\nimport { formatBytes } from '@/utils/byte_convert.js'\nimport { ClipboardSetText } from 'wailsjs/runtime/runtime.js'\nimport SwitchButton from '@/components/common/SwitchButton.vue'\nimport AlignLeft from '@/components/icons/AlignLeft.vue'\nimport AlignCenter from '@/components/icons/AlignCenter.vue'\nimport { TextAlignType } from '@/consts/text_align_type.js'\n\nconst i18n = useI18n()\nconst themeVars = useThemeVars()\n\nconst props = defineProps({\n    name: String,\n    db: Number,\n    keyPath: String,\n    keyCode: {\n        type: Array,\n        default: null,\n    },\n    ttl: {\n        type: Number,\n        default: -1,\n    },\n    value: {\n        type: [String, Array],\n        default: () => [],\n    },\n    size: Number,\n    length: Number,\n    format: String,\n    decode: String,\n    end: Boolean,\n    loading: Boolean,\n    textAlign: Number,\n})\n\nconst emit = defineEmits(['loadmore', 'loadall', 'reload', 'match', 'update:textAlign'])\n\n/**\n *\n * @type {ComputedRef<string|number[]>}\n */\nconst keyName = computed(() => {\n    return !isEmpty(props.keyCode) ? props.keyCode : props.keyPath\n})\n\nconst browserStore = useBrowserStore()\nconst dialogStore = useDialogStore()\nconst keyType = redisTypes.HASH\nconst currentEditRow = reactive({\n    no: 0,\n    key: '',\n    value: null,\n    format: formatTypes.RAW,\n    decode: decodeTypes.NONE,\n})\n\nconst inEdit = computed(() => {\n    return currentEditRow.no > 0\n})\nconst fullEdit = ref(false)\n\nconst tableRef = ref(null)\nconst fieldFilterOption = ref(null)\nconst fieldColumn = computed(() => ({\n    key: 'key',\n    title: () => i18n.t('common.field'),\n    align: props.textAlign !== TextAlignType.Left ? 'center' : 'left',\n    titleAlign: 'center',\n    resizable: true,\n    ellipsis: {\n        tooltip: {\n            style: {\n                maxWidth: '50vw',\n                maxHeight: '50vh',\n            },\n            scrollable: true,\n        },\n        lineClamp: 1,\n    },\n    filterOptionValue: fieldFilterOption.value,\n    className: inEdit.value ? 'clickable wordline' : 'wordline',\n    filter: (value, row) => {\n        if (isEmpty(value)) {\n            return true\n        }\n        return !!~row.k.indexOf(value.toString())\n    },\n    render: (row) => {\n        if (row.rm === true) {\n            return h('s', {}, decodeRedisKey(row.k))\n        }\n        return decodeRedisKey(row.k)\n    },\n}))\n\nconst isCode = computed(() => {\n    return props.format === formatTypes.JSON || props.format === formatTypes.UNICODE_JSON\n})\n// const valueFilterOption = ref(null)\nconst valueColumn = computed(() => ({\n    key: 'value',\n    title: () => i18n.t('common.value'),\n    align: isCode.value ? 'left' : props.textAlign !== TextAlignType.Left ? 'center' : 'left',\n    titleAlign: 'center',\n    resizable: true,\n    ellipsis: isCode.value\n        ? false\n        : {\n              tooltip: {\n                  style: {\n                      maxWidth: '50vw',\n                      maxHeight: '50vh',\n                  },\n                  scrollable: true,\n              },\n              lineClamp: 1,\n          },\n    // filterOptionValue: valueFilterOption.value,\n    className: inEdit.value ? 'clickable' : '',\n    // filter: (value, row) => {\n    //     if (row.dv) {\n    //         return !!~row.dv.indexOf(value.toString())\n    //     }\n    //     return !!~row.v.indexOf(value.toString())\n    // },\n    render: (row) => {\n        if (isCode.value) {\n            let val = row.dv || nativeRedisKey(row.v)\n            return h('pre', { class: 'pre-wrap' }, val)\n        } else {\n            let val = row.dv || nativeRedisKey(row.v, 500)\n            val = truncate(val, { length: 500 })\n            if (row.rm === true) {\n                return h('s', {}, val)\n            }\n            return val\n        }\n    },\n}))\n\nconst startEdit = async (no, key, value) => {\n    currentEditRow.no = no\n    currentEditRow.key = key\n    currentEditRow.value = value\n    currentEditRow.decode = props.decode\n    currentEditRow.format = props.format\n}\n\nconst saveEdit = async (field, value, decode, format) => {\n    try {\n        const row = props.value[currentEditRow.no - 1]\n        if (row == null) {\n            throw new Error('row not exists')\n        }\n\n        if (isEmpty(field)) {\n            field = currentEditRow.key\n        }\n\n        const { success, msg } = await browserStore.setHash({\n            server: props.name,\n            db: props.db,\n            key: keyName.value,\n            field: row.k,\n            newField: field,\n            value,\n            decode,\n            format,\n            retDecode: props.decode,\n            retFormat: props.format,\n            index: [currentEditRow.no - 1],\n        })\n        if (success) {\n            currentEditRow.value = value\n            $message.success(i18n.t('interface.save_value_succ'))\n        } else {\n            $message.error(msg)\n        }\n    } catch (e) {\n        $message.error(e.message)\n    }\n}\n\nconst resetEdit = () => {\n    currentEditRow.no = 0\n    currentEditRow.key = ''\n    currentEditRow.value = null\n    // if (currentEditRow.format !== props.format || currentEditRow.decode !== props.decode) {\n    //     nextTick(() => onFormatChanged(currentEditRow.decode, currentEditRow.format))\n    // }\n    // currentEditRow.format = formatTypes.RAW\n    // currentEditRow.decode = decodeTypes.NONE\n}\n\nconst actionColumn = {\n    key: 'action',\n    title: () => i18n.t('interface.action'),\n    width: 120,\n    align: 'center',\n    titleAlign: 'center',\n    fixed: 'right',\n    render: (row, index) => {\n        return h(EditableTableColumn, {\n            editing: false,\n            bindKey: row.k,\n            canRefresh: true,\n            onRefresh: async () => {\n                const { updated, success, msg } = await browserStore.getHashField({\n                    server: props.name,\n                    db: props.db,\n                    key: keyName.value,\n                    field: row.k,\n                    decode: props.decode,\n                    format: props.format,\n                })\n                if (success) {\n                    delete props.value[index]['rm']\n                    $message.success(i18n.t('dialogue.reload_succ'))\n                } else {\n                    // update fail, the key may have been deleted\n                    $message.error(msg)\n                    props.value[index]['rm'] = true\n                }\n            },\n            onCopy: async () => {\n                await ClipboardSetText(row.v)\n                $message.success(i18n.t('interface.copy_succ'))\n            },\n            onEdit: () => startEdit(index + 1, row.k, row.v),\n            onDelete: async () => {\n                try {\n                    const { removed, success, msg } = await browserStore.removeHashField({\n                        server: props.name,\n                        db: props.db,\n                        key: keyName.value,\n                        field: row.k,\n                        reload: false,\n                    })\n                    if (success) {\n                        props.value.splice(index, 1)\n                        $message.success(i18n.t('dialogue.delete.success', { key: row.k }))\n                    } else {\n                        $message.error(msg)\n                    }\n                } catch (e) {\n                    $message.error(e.message)\n                }\n            },\n        })\n    },\n}\n\nconst columns = computed(() => {\n    if (!inEdit.value) {\n        return [\n            {\n                key: 'no',\n                title: '#',\n                width: 80,\n                align: 'center',\n                titleAlign: 'center',\n                render: (row, index) => {\n                    return index + 1\n                },\n            },\n            fieldColumn.value,\n            valueColumn.value,\n            actionColumn,\n        ]\n    } else {\n        return [\n            {\n                key: 'no',\n                title: '#',\n                width: 80,\n                align: 'center',\n                titleAlign: 'center',\n                render: (row, index) => {\n                    if (index + 1 === currentEditRow.no) {\n                        // editing row, show edit state\n                        return h(NIcon, { size: 16, color: 'red' }, () => h(Edit, { strokeWidth: 5 }))\n                    } else {\n                        return index + 1\n                    }\n                },\n            },\n            fieldColumn.value,\n        ]\n    }\n})\n\nconst rowProps = (row, index) => {\n    return {\n        onClick: () => {\n            // in edit mode, switch edit row by click\n            if (inEdit.value) {\n                startEdit(index + 1, row.k, row.v)\n            }\n        },\n    }\n}\n\nconst entries = computed(() => {\n    const len = size(props.value)\n    return `${len} / ${Math.max(len, props.length)}`\n})\n\nconst loadProgress = computed(() => {\n    const len = size(props.value)\n    return (len * 100) / Math.max(len, props.length)\n})\n\nconst showMemoryUsage = computed(() => {\n    return !isNaN(props.size) && props.size > 0\n})\n\nconst onAddRow = () => {\n    dialogStore.openAddFieldsDialog(props.name, props.db, props.keyPath, props.keyCode, types.HASH)\n}\n\nconst onFilterInput = (val) => {\n    fieldFilterOption.value = val\n}\n\nconst onMatchInput = (matchVal, filterVal) => {\n    fieldFilterOption.value = filterVal\n    emit('match', matchVal)\n}\n\nconst onUpdateFilter = (filters, sourceColumn) => {\n    fieldFilterOption.value = filters[sourceColumn.key]\n}\n\nconst onFormatChanged = (selDecode, selFormat) => {\n    emit('reload', selDecode, selFormat)\n}\n\nconst searchInputRef = ref(null)\ndefineExpose({\n    reset: () => {\n        resetEdit()\n        searchInputRef.value?.reset()\n    },\n})\n</script>\n\n<template>\n    <div class=\"content-wrapper flex-box-v\">\n        <slot name=\"toolbar\" />\n        <div class=\"tb2 value-item-part flex-box-h\">\n            <div class=\"flex-box-h\" style=\"max-width: 50%\">\n                <content-search-input\n                    ref=\"searchInputRef\"\n                    @filter-changed=\"onFilterInput\"\n                    @match-changed=\"onMatchInput\" />\n            </div>\n            <div class=\"flex-item-expand\"></div>\n            <switch-button\n                :icons=\"[AlignCenter, AlignLeft]\"\n                :stroke-width=\"3.5\"\n                :t-tooltips=\"['interface.text_align_center', 'interface.text_align_left']\"\n                :value=\"props.textAlign\"\n                size=\"medium\"\n                unselect-stroke-width=\"3\"\n                @update:value=\"(val) => emit('update:textAlign', val)\" />\n            <n-divider vertical />\n            <n-button-group>\n                <icon-button\n                    :disabled=\"props.end || props.loading\"\n                    :icon=\"LoadList\"\n                    border\n                    size=\"18\"\n                    t-tooltip=\"interface.load_more_entries\"\n                    @click=\"emit('loadmore')\" />\n                <icon-button\n                    :disabled=\"props.end || props.loading\"\n                    :icon=\"LoadAll\"\n                    border\n                    size=\"18\"\n                    t-tooltip=\"interface.load_all_entries\"\n                    @click=\"emit('loadall')\" />\n            </n-button-group>\n            <n-button :focusable=\"false\" plain @click=\"onAddRow\">\n                <template #icon>\n                    <n-icon :component=\"AddLink\" size=\"18\" />\n                </template>\n                {{ $t('interface.add_row') }}\n            </n-button>\n        </div>\n        <!-- loaded progress -->\n        <n-progress\n            :border-radius=\"0\"\n            :color=\"props.end ? '#0000' : themeVars.primaryColor\"\n            :height=\"2\"\n            :percentage=\"loadProgress\"\n            :processing=\"props.loading\"\n            :show-indicator=\"false\"\n            status=\"success\"\n            type=\"line\" />\n        <div id=\"content-table\" class=\"value-wrapper value-item-part flex-box-h flex-item-expand\">\n            <!-- table -->\n            <n-data-table\n                ref=\"tableRef\"\n                :bordered=\"false\"\n                :bottom-bordered=\"false\"\n                :columns=\"columns\"\n                :data=\"props.value\"\n                :loading=\"props.loading\"\n                :row-key=\"(row) => row.k\"\n                :row-props=\"rowProps\"\n                :single-column=\"true\"\n                :single-line=\"false\"\n                class=\"flex-item-expand\"\n                flex-height\n                size=\"small\"\n                striped\n                virtual-scroll\n                @update:filters=\"onUpdateFilter\" />\n\n            <!-- edit pane -->\n            <div\n                v-show=\"inEdit\"\n                :style=\"{ position: fullEdit ? 'static' : 'relative' }\"\n                class=\"entry-editor-container flex-item-expand\"\n                style=\"width: 100%\">\n                <content-entry-editor\n                    v-model:decode=\"currentEditRow.decode\"\n                    v-model:format=\"currentEditRow.format\"\n                    v-model:fullscreen=\"fullEdit\"\n                    :field=\"currentEditRow.key\"\n                    :field-label=\"$t('common.field')\"\n                    :key-path=\"props.keyPath\"\n                    :show=\"inEdit\"\n                    :value=\"currentEditRow.value\"\n                    :value-label=\"$t('common.value')\"\n                    class=\"flex-item-expand\"\n                    style=\"width: 100%\"\n                    @close=\"resetEdit\"\n                    @save=\"saveEdit\" />\n            </div>\n        </div>\n        <div class=\"value-footer flex-box-h\">\n            <n-text v-if=\"!isNaN(props.length)\">{{ $t('interface.entries') }}: {{ entries }}</n-text>\n            <n-divider v-if=\"showMemoryUsage\" vertical />\n            <n-text v-if=\"showMemoryUsage\">{{ $t('interface.memory_usage') }}: {{ formatBytes(props.size) }}</n-text>\n            <div class=\"flex-item-expand\"></div>\n            <format-selector\n                v-show=\"!inEdit\"\n                :decode=\"props.decode\"\n                :disabled=\"inEdit\"\n                :format=\"props.format\"\n                @format-changed=\"onFormatChanged\" />\n        </div>\n    </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.value-footer {\n    border-top: v-bind('themeVars.borderColor') 1px solid;\n    background-color: v-bind('themeVars.tableHeaderColor');\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/content_value/ContentValueJson.vue",
    "content": "<script setup>\nimport { computed, ref } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport Copy from '@/components/icons/Copy.vue'\nimport Save from '@/components/icons/Save.vue'\nimport { useThemeVars } from 'naive-ui'\nimport { types as redisTypes } from '@/consts/support_redis_type.js'\nimport { isEmpty, toLower } from 'lodash'\nimport useBrowserStore from 'stores/browser.js'\nimport { decodeRedisKey } from '@/utils/key_convert.js'\nimport ContentEditor from '@/components/content_value/ContentEditor.vue'\nimport { decodeTypes, formatTypes } from '@/consts/value_view_type.js'\nimport { formatBytes } from '@/utils/byte_convert.js'\nimport { ClipboardSetText } from 'wailsjs/runtime/runtime.js'\n\nconst props = defineProps({\n    name: String,\n    db: Number,\n    keyPath: String,\n    keyCode: {\n        type: Array,\n        default: null,\n    },\n    ttl: {\n        type: Number,\n        default: -1,\n    },\n    value: String,\n    size: Number,\n    length: Number,\n    loading: Boolean,\n})\n\nconst i18n = useI18n()\nconst themeVars = useThemeVars()\n\n/**\n *\n * @type {ComputedRef<string|number[]>}\n */\nconst keyName = computed(() => {\n    return !isEmpty(props.keyCode) ? props.keyCode : props.keyPath\n})\n\nconst keyType = redisTypes.JSON\n\nconst editingContent = ref('')\n\nconst displayValue = computed(() => {\n    return decodeRedisKey(props.value) || ''\n})\n\nconst enableSave = computed(() => {\n    return editingContent.value !== displayValue.value && !props.loading\n})\n\nconst showMemoryUsage = computed(() => {\n    return !isNaN(props.size) && props.size > 0\n})\n\n/**\n * Copy value\n */\nconst onCopyValue = async () => {\n    await ClipboardSetText(displayValue.value)\n    $message.success(i18n.t('interface.copy_succ'))\n}\n\n/**\n * Save value\n */\nconst browserStore = useBrowserStore()\nconst saving = ref(false)\n\nconst onInput = (content) => {\n    editingContent.value = content\n}\n\nconst onSave = async () => {\n    saving.value = true\n    try {\n        const { success, msg } = await browserStore.setKey({\n            server: props.name,\n            db: props.db,\n            key: keyName.value,\n            keyType: toLower(keyType),\n            value: editingContent.value,\n            ttl: -1,\n            format: formatTypes.JSON,\n            decode: decodeTypes.NONE,\n        })\n        if (success) {\n            $message.success(i18n.t('interface.save_value_succ'))\n        } else {\n            $message.error(msg)\n        }\n    } catch (e) {\n        $message.error(e.message)\n    } finally {\n        saving.value = false\n    }\n}\n\ndefineExpose({\n    reset: () => {\n        editingContent.value = ''\n    },\n})\n</script>\n\n<template>\n    <div class=\"content-wrapper flex-box-v\">\n        <slot name=\"toolbar\" />\n        <div class=\"tb2 value-item-part flex-box-h\">\n            <div class=\"flex-item-expand\"></div>\n            <n-button-group>\n                <n-button :disabled=\"saving\" :focusable=\"false\" @click=\"onCopyValue\">\n                    <template #icon>\n                        <n-icon :component=\"Copy\" size=\"18\" />\n                    </template>\n                    {{ $t('interface.copy_value') }}\n                </n-button>\n                <n-button\n                    :disabled=\"!enableSave\"\n                    :loading=\"saving\"\n                    :secondary=\"enableSave\"\n                    :type=\"enableSave ? 'primary' : ''\"\n                    @click=\"onSave\">\n                    <template #icon>\n                        <n-icon :component=\"Save\" size=\"18\" />\n                    </template>\n                    {{ $t('common.save') }}\n                </n-button>\n            </n-button-group>\n        </div>\n        <div class=\"value-wrapper value-item-part flex-item-expand flex-box-v\">\n            <content-editor\n                :content=\"displayValue\"\n                :loading=\"props.loading\"\n                :offset-key=\"props.keyPath\"\n                class=\"flex-item-expand\"\n                keep-offset\n                language=\"json\"\n                style=\"height: 100%\"\n                @input=\"onInput\"\n                @reset=\"onInput\"\n                @save=\"onSave\" />\n            <n-spin v-show=\"props.loading\" />\n        </div>\n        <div class=\"value-footer flex-box-h\">\n            <n-text v-if=\"showMemoryUsage\">{{ $t('interface.memory_usage') }}: {{ formatBytes(props.size) }}</n-text>\n            <div class=\"flex-item-expand\" />\n        </div>\n    </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.value-wrapper {\n    //overflow: hidden;\n    border-top: v-bind('themeVars.borderColor') 1px solid;\n    padding: 5px;\n}\n\n.value-footer {\n    border-top: v-bind('themeVars.borderColor') 1px solid;\n    background-color: v-bind('themeVars.tableHeaderColor');\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/content_value/ContentValueList.vue",
    "content": "<script setup>\nimport { computed, h, reactive, ref } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport AddLink from '@/components/icons/AddLink.vue'\nimport { NButton, NIcon, useThemeVars } from 'naive-ui'\nimport { isEmpty, size, truncate } from 'lodash'\nimport { types, types as redisTypes } from '@/consts/support_redis_type.js'\nimport EditableTableColumn from '@/components/common/EditableTableColumn.vue'\nimport useDialogStore from 'stores/dialog.js'\nimport { decodeTypes, formatTypes } from '@/consts/value_view_type.js'\nimport useBrowserStore from 'stores/browser.js'\nimport LoadList from '@/components/icons/LoadList.vue'\nimport LoadAll from '@/components/icons/LoadAll.vue'\nimport IconButton from '@/components/common/IconButton.vue'\nimport ContentEntryEditor from '@/components/content_value/ContentEntryEditor.vue'\nimport FormatSelector from '@/components/content_value/FormatSelector.vue'\nimport Edit from '@/components/icons/Edit.vue'\nimport ContentSearchInput from '@/components/content_value/ContentSearchInput.vue'\nimport { formatBytes } from '@/utils/byte_convert.js'\nimport { ClipboardSetText } from 'wailsjs/runtime/runtime.js'\nimport { TextAlignType } from '@/consts/text_align_type.js'\nimport AlignLeft from '@/components/icons/AlignLeft.vue'\nimport AlignCenter from '@/components/icons/AlignCenter.vue'\nimport SwitchButton from '@/components/common/SwitchButton.vue'\nimport { nativeRedisKey } from '@/utils/key_convert.js'\n\nconst i18n = useI18n()\nconst themeVars = useThemeVars()\n\nconst props = defineProps({\n    name: String,\n    db: Number,\n    keyPath: String,\n    keyCode: {\n        type: Array,\n        default: null,\n    },\n    ttl: {\n        type: Number,\n        default: -1,\n    },\n    value: {\n        // [{v: string|number[], dv: string}]\n        type: Array,\n        default: () => {},\n    },\n    size: Number,\n    length: Number,\n    format: {\n        type: String,\n        default: formatTypes.RAW,\n    },\n    decode: {\n        type: String,\n        default: decodeTypes.NONE,\n    },\n    end: Boolean,\n    loading: Boolean,\n    textAlign: Number,\n})\n\nconst emit = defineEmits(['loadmore', 'loadall', 'reload', 'match', 'update:textAlign'])\n\n/**\n *\n * @type {ComputedRef<string|number[]>}\n */\nconst keyName = computed(() => {\n    return !isEmpty(props.keyCode) ? props.keyCode : props.keyPath\n})\n\nconst browserStore = useBrowserStore()\nconst dialogStore = useDialogStore()\nconst keyType = redisTypes.LIST\nconst currentEditRow = reactive({\n    no: 0,\n    value: null,\n    format: formatTypes.RAW,\n    decode: decodeTypes.NONE,\n})\nconst inEdit = computed(() => {\n    return currentEditRow.no > 0\n})\nconst fullEdit = ref(false)\n\nconst isCode = computed(() => {\n    return props.format === formatTypes.JSON || props.format === formatTypes.UNICODE_JSON\n})\nconst valueFilterOption = ref(null)\nconst valueColumn = computed(() => ({\n    key: 'value',\n    title: () => i18n.t('common.value'),\n    align: isCode.value ? 'left' : props.textAlign !== TextAlignType.Left ? 'center' : 'left',\n    titleAlign: 'center',\n    ellipsis: isCode.value\n        ? false\n        : {\n              tooltip: {\n                  style: {\n                      maxWidth: '50vw',\n                      maxHeight: '50vh',\n                  },\n                  scrollable: true,\n              },\n              lineClamp: 1,\n          },\n    filterOptionValue: valueFilterOption.value,\n    className: inEdit.value ? 'clickable' : '',\n    filter: (filterValue, row) => {\n        if (isEmpty(filterValue)) {\n            return true\n        }\n        const val = row.dv || nativeRedisKey(row.v)\n        return !!~val.indexOf(filterValue.toString())\n    },\n    render: (row) => {\n        if (isCode.value) {\n            const val = row.dv || nativeRedisKey(row.v)\n            return h('pre', { class: 'pre-wrap' }, val)\n        } else {\n            const val = row.dv || nativeRedisKey(row.v, 500)\n            return truncate(val, { length: 500 })\n        }\n    },\n}))\n\nconst startEdit = async (no, value) => {\n    currentEditRow.no = no\n    currentEditRow.value = value\n    currentEditRow.decode = props.decode\n    currentEditRow.format = props.format\n}\n\n/**\n *\n * @param {string|number} pos\n * @param {string} value\n * @param {decodeTypes} decode\n * @param {formatTypes} format\n * @return {Promise<void>}\n */\nconst saveEdit = async (pos, value, decode, format) => {\n    try {\n        const index = parseInt(pos) - 1\n        const row = props.value[index]\n        if (row == null) {\n            throw new Error('row not exists')\n        }\n\n        if (isEmpty(value)) {\n            value = currentEditRow.value\n        }\n\n        const { success, msg } = await browserStore.updateListItem({\n            server: props.name,\n            db: props.db,\n            key: keyName.value,\n            index: row.index,\n            value,\n            decode,\n            format,\n        })\n        if (success) {\n            $message.success(i18n.t('interface.save_value_succ'))\n        } else {\n            $message.error(msg)\n        }\n    } catch (e) {\n        $message.error(e.message)\n    }\n}\n\nconst resetEdit = () => {\n    currentEditRow.no = 0\n    currentEditRow.value = null\n    // if (currentEditRow.format !== props.format || currentEditRow.decode !== props.decode) {\n    //     nextTick(() => onFormatChanged(currentEditRow.decode, currentEditRow.format))\n    // }\n}\n\nconst actionColumn = {\n    key: 'action',\n    title: () => i18n.t('interface.action'),\n    width: 120,\n    align: 'center',\n    titleAlign: 'center',\n    fixed: 'right',\n    render: ({ index, v }, _) => {\n        return h(EditableTableColumn, {\n            editing: false,\n            bindKey: `#${index + 1}`,\n            onCopy: async () => {\n                await ClipboardSetText(v)\n                $message.success(i18n.t('interface.copy_succ'))\n            },\n            onEdit: () => {\n                startEdit(index + 1, v)\n            },\n            onDelete: async () => {\n                try {\n                    const { success, msg } = await browserStore.removeListItem({\n                        server: props.name,\n                        db: props.db,\n                        key: keyName.value,\n                        index,\n                    })\n                    if (success) {\n                        $message.success(i18n.t('dialogue.delete.success', { key: `#${index + 1}` }))\n                    } else {\n                        $message.error(msg)\n                    }\n                } catch (e) {\n                    $message.error(e.message)\n                }\n            },\n        })\n    },\n}\n\nconst columns = computed(() => {\n    if (!inEdit.value) {\n        return [\n            {\n                key: 'no',\n                title: '#',\n                width: 80,\n                align: 'center',\n                titleAlign: 'center',\n                render: ({ index }, _) => {\n                    return index + 1\n                },\n            },\n            valueColumn.value,\n            actionColumn,\n        ]\n    } else {\n        return [\n            {\n                key: 'no',\n                title: '#',\n                width: 80,\n                align: 'center',\n                titleAlign: 'center',\n                render: ({ index }, _) => {\n                    if (index + 1 === currentEditRow.no) {\n                        // editing row, show edit state\n                        return h(NIcon, { size: 16, color: 'red' }, () => h(Edit, { strokeWidth: 5 }))\n                    } else {\n                        return index + 1\n                    }\n                },\n            },\n            valueColumn.value,\n        ]\n    }\n})\n\nconst rowProps = ({ index, v }, _) => {\n    return {\n        onClick: () => {\n            // in edit mode, switch edit row by click\n            if (inEdit.value) {\n                startEdit(index + 1, v)\n            }\n        },\n    }\n}\n\nconst entries = computed(() => {\n    const len = size(props.value)\n    return `${len} / ${Math.max(len, props.length)}`\n})\n\nconst loadProgress = computed(() => {\n    const len = size(props.value)\n    return (len * 100) / Math.max(len, props.length)\n})\n\nconst showMemoryUsage = computed(() => {\n    return !isNaN(props.size) && props.size > 0\n})\n\nconst onAddValue = (value) => {\n    dialogStore.openAddFieldsDialog(props.name, props.db, props.keyPath, props.keyCode, types.LIST)\n}\n\nconst onFilterInput = (val) => {\n    valueFilterOption.value = val\n}\n\nconst onMatchInput = (matchVal, filterVal) => {\n    valueFilterOption.value = filterVal\n    emit('match', matchVal)\n}\n\nconst onUpdateFilter = (filters, sourceColumn) => {\n    valueFilterOption.value = filters[sourceColumn.key]\n}\n\nconst onFormatChanged = (selDecode, selFormat) => {\n    emit('reload', selDecode, selFormat)\n}\n\nconst searchInputRef = ref(null)\ndefineExpose({\n    reset: () => {\n        resetEdit()\n        searchInputRef.value?.reset()\n    },\n})\n</script>\n\n<template>\n    <div class=\"content-wrapper flex-box-v\">\n        <slot name=\"toolbar\" />\n        <div class=\"tb2 value-item-part flex-box-h\">\n            <div class=\"flex-box-h\" style=\"max-width: 50%\">\n                <content-search-input\n                    ref=\"searchInputRef\"\n                    @filter-changed=\"onFilterInput\"\n                    @match-changed=\"onMatchInput\" />\n            </div>\n            <div class=\"flex-item-expand\"></div>\n            <switch-button\n                :icons=\"[AlignCenter, AlignLeft]\"\n                :stroke-width=\"3.5\"\n                :t-tooltips=\"['interface.text_align_center', 'interface.text_align_left']\"\n                :value=\"props.textAlign\"\n                size=\"medium\"\n                unselect-stroke-width=\"3\"\n                @update:value=\"(val) => emit('update:textAlign', val)\" />\n            <n-divider vertical />\n            <n-button-group>\n                <icon-button\n                    :disabled=\"props.end || props.loading\"\n                    :icon=\"LoadList\"\n                    border\n                    size=\"18\"\n                    t-tooltip=\"interface.load_more_entries\"\n                    @click=\"emit('loadmore')\" />\n                <icon-button\n                    :disabled=\"props.end || props.loading\"\n                    :icon=\"LoadAll\"\n                    border\n                    size=\"18\"\n                    t-tooltip=\"interface.load_all_entries\"\n                    @click=\"emit('loadall')\" />\n            </n-button-group>\n            <n-button :focusable=\"false\" plain @click=\"onAddValue\">\n                <template #icon>\n                    <n-icon :component=\"AddLink\" size=\"18\" />\n                </template>\n                {{ $t('interface.add_row') }}\n            </n-button>\n        </div>\n        <!-- loaded progress -->\n        <n-progress\n            :border-radius=\"0\"\n            :color=\"props.end ? '#0000' : themeVars.primaryColor\"\n            :height=\"2\"\n            :percentage=\"loadProgress\"\n            :processing=\"props.loading\"\n            :show-indicator=\"false\"\n            status=\"success\"\n            type=\"line\" />\n        <div class=\"value-wrapper value-item-part flex-box-h flex-item-expand\">\n            <!-- table -->\n            <n-data-table\n                :bordered=\"false\"\n                :bottom-bordered=\"false\"\n                :columns=\"columns\"\n                :data=\"props.value\"\n                :loading=\"props.loading\"\n                :row-key=\"(row) => row.no\"\n                :row-props=\"rowProps\"\n                :single-column=\"true\"\n                :single-line=\"false\"\n                class=\"flex-item-expand\"\n                flex-height\n                size=\"small\"\n                striped\n                virtual-scroll\n                @update:filters=\"onUpdateFilter\" />\n\n            <!-- edit pane -->\n            <div\n                v-show=\"inEdit\"\n                :style=\"{ position: fullEdit ? 'static' : 'relative' }\"\n                class=\"entry-editor-container flex-item-expand\"\n                style=\"width: 100%\">\n                <content-entry-editor\n                    v-model:decode=\"currentEditRow.decode\"\n                    v-model:format=\"currentEditRow.format\"\n                    v-model:fullscreen=\"fullEdit\"\n                    :field=\"currentEditRow.no\"\n                    :field-label=\"$t('common.index')\"\n                    :field-readonly=\"true\"\n                    :key-path=\"props.keyPath\"\n                    :show=\"inEdit\"\n                    :value=\"currentEditRow.value\"\n                    :value-label=\"$t('common.value')\"\n                    class=\"flex-item-expand\"\n                    style=\"width: 100%\"\n                    @close=\"resetEdit\"\n                    @save=\"saveEdit\" />\n            </div>\n        </div>\n        <div class=\"value-footer flex-box-h\">\n            <n-text v-if=\"!isNaN(props.length)\">{{ $t('interface.entries') }}: {{ entries }}</n-text>\n            <n-divider v-if=\"showMemoryUsage\" vertical />\n            <n-text v-if=\"showMemoryUsage\">{{ $t('interface.memory_usage') }}: {{ formatBytes(props.size) }}</n-text>\n            <div class=\"flex-item-expand\"></div>\n            <format-selector\n                v-show=\"!inEdit\"\n                :decode=\"props.decode\"\n                :disabled=\"inEdit\"\n                :format=\"props.format\"\n                @format-changed=\"onFormatChanged\" />\n        </div>\n    </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.value-footer {\n    border-top: v-bind('themeVars.borderColor') 1px solid;\n    background-color: v-bind('themeVars.tableHeaderColor');\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/content_value/ContentValueSet.vue",
    "content": "<script setup>\nimport { computed, h, reactive, ref } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport AddLink from '@/components/icons/AddLink.vue'\nimport { NButton, NIcon, useThemeVars } from 'naive-ui'\nimport { isEmpty, size, truncate } from 'lodash'\nimport useDialogStore from 'stores/dialog.js'\nimport { types, types as redisTypes } from '@/consts/support_redis_type.js'\nimport EditableTableColumn from '@/components/common/EditableTableColumn.vue'\nimport { decodeTypes, formatTypes } from '@/consts/value_view_type.js'\nimport useBrowserStore from 'stores/browser.js'\nimport LoadList from '@/components/icons/LoadList.vue'\nimport LoadAll from '@/components/icons/LoadAll.vue'\nimport IconButton from '@/components/common/IconButton.vue'\nimport Edit from '@/components/icons/Edit.vue'\nimport ContentEntryEditor from '@/components/content_value/ContentEntryEditor.vue'\nimport FormatSelector from '@/components/content_value/FormatSelector.vue'\nimport ContentSearchInput from '@/components/content_value/ContentSearchInput.vue'\nimport { formatBytes } from '@/utils/byte_convert.js'\nimport { ClipboardSetText } from 'wailsjs/runtime/runtime.js'\nimport AlignLeft from '@/components/icons/AlignLeft.vue'\nimport AlignCenter from '@/components/icons/AlignCenter.vue'\nimport SwitchButton from '@/components/common/SwitchButton.vue'\nimport { TextAlignType } from '@/consts/text_align_type.js'\nimport { nativeRedisKey } from '@/utils/key_convert.js'\n\nconst i18n = useI18n()\nconst themeVars = useThemeVars()\n\nconst props = defineProps({\n    name: String,\n    db: Number,\n    keyPath: String,\n    keyCode: {\n        type: Array,\n        default: null,\n    },\n    ttl: {\n        type: Number,\n        default: -1,\n    },\n    value: {\n        type: Array,\n        default: () => [],\n    },\n    size: Number,\n    length: Number,\n    format: {\n        type: String,\n        default: formatTypes.RAW,\n    },\n    decode: {\n        type: String,\n        default: decodeTypes.NONE,\n    },\n    end: Boolean,\n    loading: Boolean,\n    textAlign: Number,\n})\n\nconst emit = defineEmits(['loadmore', 'loadall', 'reload', 'match', 'update:textAlign'])\n\n/**\n *\n * @type {ComputedRef<string|number[]>}\n */\nconst keyName = computed(() => {\n    return !isEmpty(props.keyCode) ? props.keyCode : props.keyPath\n})\n\nconst browserStore = useBrowserStore()\nconst dialogStore = useDialogStore()\nconst keyType = redisTypes.SET\nconst currentEditRow = reactive({\n    no: 0,\n    value: null,\n    format: formatTypes.RAW,\n    decode: decodeTypes.NONE,\n})\nconst inEdit = computed(() => {\n    return currentEditRow.no > 0\n})\nconst fullEdit = ref(false)\n\nconst isCode = computed(() => {\n    return props.format === formatTypes.JSON || props.format === formatTypes.UNICODE_JSON\n})\nconst valueFilterOption = ref(null)\nconst valueColumn = computed(() => ({\n    key: 'value',\n    title: () => i18n.t('common.value'),\n    align: isCode.value ? 'left' : props.textAlign !== TextAlignType.Left ? 'center' : 'left',\n    titleAlign: 'center',\n    ellipsis: isCode.value\n        ? false\n        : {\n              tooltip: {\n                  style: {\n                      maxWidth: '50vw',\n                      maxHeight: '50vh',\n                  },\n                  scrollable: true,\n              },\n              lineClamp: 1,\n          },\n    filterOptionValue: valueFilterOption.value,\n    className: inEdit.value ? 'clickable' : '',\n    filter: (filterValue, row) => {\n        if (isEmpty(filterValue)) {\n            return true\n        }\n        const val = row.dv || nativeRedisKey(row.v)\n        return !!~val.indexOf(filterValue.toString())\n    },\n    render: (row) => {\n        if (isCode.value) {\n            const val = row.dv || nativeRedisKey(row.v)\n            return h('pre', { class: 'pre-wrap' }, val)\n        } else {\n            const val = row.dv || nativeRedisKey(row.v, 500)\n            return truncate(val, { length: 500 })\n        }\n    },\n}))\n\nconst startEdit = async (no, value) => {\n    currentEditRow.no = no\n    currentEditRow.value = value\n    currentEditRow.decode = props.decode\n    currentEditRow.format = props.format\n}\n\n/**\n *\n * @param {string|number} pos\n * @param {string} value\n * @param {decodeTypes} decode\n * @param {formatTypes} format\n * @return {Promise<void>}\n */\nconst saveEdit = async (pos, value, decode, format) => {\n    try {\n        const index = parseInt(pos) - 1\n        const row = props.value[index]\n        if (row == null) {\n            throw new Error('row not exists')\n        }\n\n        const { success, msg } = await browserStore.updateSetItem({\n            server: props.name,\n            db: props.db,\n            key: keyName.value,\n            value: row.v,\n            newValue: value,\n            decode,\n            format,\n            retDecode: props.decode,\n            retFormat: props.format,\n        })\n        if (success) {\n            $message.success(i18n.t('interface.save_value_succ'))\n        } else {\n            $message.error(msg)\n        }\n    } catch (e) {\n        $message.error(e.message)\n    }\n}\n\nconst resetEdit = () => {\n    currentEditRow.no = 0\n    currentEditRow.value = null\n    // if (currentEditRow.format !== props.format || currentEditRow.decode !== props.decode) {\n    //     nextTick(() => onFormatChanged(currentEditRow.decode, currentEditRow.format))\n    // }\n}\n\nconst actionColumn = {\n    key: 'action',\n    title: () => i18n.t('interface.action'),\n    width: 120,\n    align: 'center',\n    titleAlign: 'center',\n    fixed: 'right',\n    render: (row, index) => {\n        return h(EditableTableColumn, {\n            editing: false,\n            bindKey: `#${index + 1}`,\n            onCopy: async () => {\n                await ClipboardSetText(row.v)\n                $message.success(i18n.t('interface.copy_succ'))\n            },\n            onEdit: () => {\n                startEdit(index + 1, row.v)\n            },\n            onDelete: async () => {\n                try {\n                    const { success, msg } = await browserStore.removeSetItem({\n                        server: props.name,\n                        db: props.db,\n                        key: keyName.value,\n                        value: row.v,\n                    })\n                    if (success) {\n                        $message.success(i18n.t('dialogue.delete.success', { key: row.v }))\n                    } else {\n                        $message.error(msg)\n                    }\n                } catch (e) {\n                    $message.error(e.message)\n                }\n            },\n        })\n    },\n}\n\nconst columns = computed(() => {\n    if (!inEdit.value) {\n        return [\n            {\n                key: 'no',\n                title: '#',\n                width: 80,\n                align: 'center',\n                titleAlign: 'center',\n                render: (row, index) => {\n                    return index + 1\n                },\n            },\n            valueColumn.value,\n            actionColumn,\n        ]\n    } else {\n        return [\n            {\n                key: 'no',\n                title: '#',\n                width: 80,\n                align: 'center',\n                titleAlign: 'center',\n                render: (row, index) => {\n                    if (index + 1 === currentEditRow.no) {\n                        // editing row, show edit state\n                        return h(NIcon, { size: 16, color: 'red' }, () => h(Edit, { strokeWidth: 5 }))\n                    } else {\n                        return index + 1\n                    }\n                },\n            },\n            valueColumn.value,\n        ]\n    }\n})\n\nconst rowProps = (row, index) => {\n    return {\n        onClick: () => {\n            // in edit mode, switch edit row by click\n            if (inEdit.value) {\n                startEdit(index + 1, row.v)\n            }\n        },\n    }\n}\n\nconst entries = computed(() => {\n    const len = size(props.value)\n    return `${len} / ${Math.max(len, props.length)}`\n})\n\nconst loadProgress = computed(() => {\n    const len = size(props.value)\n    return (len * 100) / Math.max(len, props.length)\n})\n\nconst showMemoryUsage = computed(() => {\n    return !isNaN(props.size) && props.size > 0\n})\n\nconst onAddValue = (value) => {\n    dialogStore.openAddFieldsDialog(props.name, props.db, props.keyPath, props.keyCode, types.SET)\n}\n\nconst onFilterInput = (val) => {\n    valueFilterOption.value = val\n}\n\nconst onMatchInput = (matchVal, filterVal) => {\n    valueFilterOption.value = filterVal\n    emit('match', matchVal)\n}\n\nconst onUpdateFilter = (filters, sourceColumn) => {\n    valueFilterOption.value = filters[sourceColumn.key]\n}\n\nconst onFormatChanged = (selDecode, selFormat) => {\n    emit('reload', selDecode, selFormat)\n}\n\nconst searchInputRef = ref(null)\ndefineExpose({\n    reset: () => {\n        resetEdit()\n        searchInputRef.value?.reset()\n    },\n})\n</script>\n\n<template>\n    <div class=\"content-wrapper flex-box-v\">\n        <slot name=\"toolbar\" />\n        <div class=\"tb2 value-item-part flex-box-h\">\n            <div class=\"flex-box-h\" style=\"max-width: 50%\">\n                <content-search-input\n                    ref=\"searchInputRef\"\n                    @filter-changed=\"onFilterInput\"\n                    @match-changed=\"onMatchInput\" />\n            </div>\n            <div class=\"flex-item-expand\"></div>\n            <switch-button\n                :icons=\"[AlignCenter, AlignLeft]\"\n                :stroke-width=\"3.5\"\n                :t-tooltips=\"['interface.text_align_center', 'interface.text_align_left']\"\n                :value=\"props.textAlign\"\n                size=\"medium\"\n                unselect-stroke-width=\"3\"\n                @update:value=\"(val) => emit('update:textAlign', val)\" />\n            <n-divider vertical />\n            <n-button-group>\n                <icon-button\n                    :disabled=\"props.end || props.loading\"\n                    :icon=\"LoadList\"\n                    border\n                    size=\"18\"\n                    t-tooltip=\"interface.load_more_entries\"\n                    @click=\"emit('loadmore')\" />\n                <icon-button\n                    :disabled=\"props.end || props.loading\"\n                    :icon=\"LoadAll\"\n                    border\n                    size=\"18\"\n                    t-tooltip=\"interface.load_all_entries\"\n                    @click=\"emit('loadall')\" />\n            </n-button-group>\n            <n-button :focusable=\"false\" plain @click=\"onAddValue\">\n                <template #icon>\n                    <n-icon :component=\"AddLink\" size=\"18\" />\n                </template>\n                {{ $t('interface.add_row') }}\n            </n-button>\n        </div>\n        <!-- loaded progress -->\n        <n-progress\n            :border-radius=\"0\"\n            :color=\"props.end ? '#0000' : themeVars.primaryColor\"\n            :height=\"2\"\n            :percentage=\"loadProgress\"\n            :processing=\"props.loading\"\n            :show-indicator=\"false\"\n            status=\"success\"\n            type=\"line\" />\n        <div class=\"value-wrapper value-item-part flex-box-h flex-item-expand\">\n            <!-- table -->\n            <n-data-table\n                :bordered=\"false\"\n                :bottom-bordered=\"false\"\n                :columns=\"columns\"\n                :data=\"props.value\"\n                :loading=\"props.loading\"\n                :row-key=\"(row) => row.v\"\n                :row-props=\"rowProps\"\n                :single-column=\"true\"\n                :single-line=\"false\"\n                class=\"flex-item-expand\"\n                flex-height\n                size=\"small\"\n                striped\n                virtual-scroll\n                @update:filters=\"onUpdateFilter\" />\n\n            <!-- edit pane -->\n            <div\n                v-show=\"inEdit\"\n                :style=\"{ position: fullEdit ? 'static' : 'relative' }\"\n                class=\"entry-editor-container flex-item-expand\"\n                style=\"width: 100%\">\n                <content-entry-editor\n                    v-model:decode=\"currentEditRow.decode\"\n                    v-model:format=\"currentEditRow.format\"\n                    v-model:fullscreen=\"fullEdit\"\n                    :field=\"currentEditRow.no\"\n                    :field-label=\"$t('common.index')\"\n                    :field-readonly=\"true\"\n                    :key-path=\"props.keyPath\"\n                    :show=\"inEdit\"\n                    :value=\"currentEditRow.value\"\n                    :value-label=\"$t('common.value')\"\n                    class=\"flex-item-expand\"\n                    style=\"width: 100%\"\n                    @close=\"resetEdit\"\n                    @save=\"saveEdit\" />\n            </div>\n        </div>\n        <div class=\"value-footer flex-box-h\">\n            <n-text v-if=\"!isNaN(props.length)\">{{ $t('interface.entries') }}: {{ entries }}</n-text>\n            <n-divider v-if=\"showMemoryUsage\" vertical />\n            <n-text v-if=\"showMemoryUsage\">{{ $t('interface.memory_usage') }}: {{ formatBytes(props.size) }}</n-text>\n            <div class=\"flex-item-expand\"></div>\n            <format-selector\n                v-show=\"!inEdit\"\n                :decode=\"props.decode\"\n                :disabled=\"inEdit\"\n                :format=\"props.format\"\n                @format-changed=\"onFormatChanged\" />\n        </div>\n    </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.value-footer {\n    border-top: v-bind('themeVars.borderColor') 1px solid;\n    background-color: v-bind('themeVars.tableHeaderColor');\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/content_value/ContentValueStream.vue",
    "content": "<script setup>\nimport { computed, h, ref } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport AddLink from '@/components/icons/AddLink.vue'\nimport { NButton, NIcon, useThemeVars } from 'naive-ui'\nimport { types, types as redisTypes } from '@/consts/support_redis_type.js'\nimport EditableTableColumn from '@/components/common/EditableTableColumn.vue'\nimport useDialogStore from 'stores/dialog.js'\nimport { includes, isEmpty, size } from 'lodash'\nimport { decodeTypes, formatTypes } from '@/consts/value_view_type.js'\nimport useBrowserStore from 'stores/browser.js'\nimport LoadList from '@/components/icons/LoadList.vue'\nimport LoadAll from '@/components/icons/LoadAll.vue'\nimport IconButton from '@/components/common/IconButton.vue'\nimport ContentSearchInput from '@/components/content_value/ContentSearchInput.vue'\nimport { formatBytes } from '@/utils/byte_convert.js'\nimport { ClipboardSetText } from 'wailsjs/runtime/runtime.js'\n\nconst i18n = useI18n()\nconst themeVars = useThemeVars()\n\nconst props = defineProps({\n    name: String,\n    db: Number,\n    keyPath: String,\n    keyCode: {\n        type: Array,\n        default: null,\n    },\n    ttl: {\n        type: Number,\n        default: -1,\n    },\n    value: {\n        type: Array,\n        default: () => [],\n    },\n    size: Number,\n    length: Number,\n    viewAs: {\n        type: String,\n        default: formatTypes.RAW,\n    },\n    decode: {\n        type: String,\n        default: decodeTypes.NONE,\n    },\n    end: Boolean,\n    loading: Boolean,\n})\n\nconst emit = defineEmits(['loadmore', 'loadall', 'match'])\n\n/**\n *\n * @type {ComputedRef<string|number[]>}\n */\nconst keyName = computed(() => {\n    return !isEmpty(props.keyCode) ? props.keyCode : props.keyPath\n})\nconst filterType = ref(1)\n\nconst browserStore = useBrowserStore()\nconst dialogStore = useDialogStore()\nconst keyType = redisTypes.STREAM\nconst idColumn = computed(() => ({\n    key: 'id',\n    title: 'ID',\n    align: 'center',\n    titleAlign: 'center',\n    resizable: true,\n}))\n\nconst valueFilterOption = ref(null)\nconst valueColumn = computed(() => ({\n    key: 'value',\n    title: () => i18n.t('common.value'),\n    align: 'left',\n    titleAlign: 'center',\n    resizable: true,\n    filterOptionValue: valueFilterOption.value,\n    filter: (value, row) => {\n        const v = value.toString()\n        if (isEmpty(v)) {\n            return true\n        }\n        if (row.dv) {\n            return includes(row.dv, v)\n        }\n        for (const k in row.v) {\n            if (includes(k, v) || includes(row.v[k], v)) {\n                return true\n            }\n        }\n        return false\n    },\n    // sorter: (row1, row2) => row1.value - row2.value,\n    render: (row) => {\n        return h('pre', { class: 'pre-wrap' }, row.dv)\n    },\n}))\nconst actionColumn = {\n    key: 'action',\n    title: () => i18n.t('interface.action'),\n    width: 80,\n    align: 'center',\n    titleAlign: 'center',\n    fixed: 'right',\n    render: (row) => {\n        return h(EditableTableColumn, {\n            bindKey: row.id,\n            readonly: true,\n            onCopy: async () => {\n                await ClipboardSetText(JSON.stringify(row.v))\n                $message.success(i18n.t('interface.copy_succ'))\n            },\n            onDelete: async () => {\n                try {\n                    const { success, msg } = await browserStore.removeStreamValues({\n                        server: props.name,\n                        db: props.db,\n                        key: keyName.value,\n                        ids: row.id,\n                    })\n                    if (success) {\n                        $message.success(i18n.t('dialogue.delete.success', { key: row.id }))\n                    } else {\n                        $message.error(msg)\n                    }\n                } catch (e) {\n                    $message.error(e.message)\n                }\n            },\n        })\n    },\n}\nconst columns = computed(() => [idColumn.value, valueColumn.value, actionColumn])\n\nconst entries = computed(() => {\n    const len = size(props.value)\n    return `${len} / ${Math.max(len, props.length)}`\n})\n\nconst loadProgress = computed(() => {\n    const len = size(props.value)\n    return (len * 100) / Math.max(len, props.length)\n})\n\nconst showMemoryUsage = computed(() => {\n    return !isNaN(props.size) && props.size > 0\n})\n\nconst onAddRow = () => {\n    dialogStore.openAddFieldsDialog(props.name, props.db, props.keyPath, props.keyCode, types.STREAM)\n}\n\nconst onFilterInput = (val) => {\n    valueFilterOption.value = val\n}\n\nconst onMatchInput = (matchVal, filterVal) => {\n    valueFilterOption.value = filterVal\n    emit('match', matchVal)\n}\n\nconst onUpdateFilter = (filters, sourceColumn) => {\n    valueFilterOption.value = filters[sourceColumn.key]\n}\n\nconst searchInputRef = ref(null)\ndefineExpose({\n    reset: () => {\n        searchInputRef.value?.reset()\n    },\n})\n</script>\n\n<template>\n    <div class=\"content-wrapper flex-box-v\">\n        <slot name=\"toolbar\" />\n        <div class=\"tb2 value-item-part flex-box-h\">\n            <div class=\"flex-box-h\">\n                <content-search-input\n                    ref=\"searchInputRef\"\n                    @filter-changed=\"onFilterInput\"\n                    @match-changed=\"onMatchInput\" />\n            </div>\n            <div class=\"flex-item-expand\"></div>\n            <n-button-group>\n                <icon-button\n                    :disabled=\"props.end || props.loading\"\n                    :icon=\"LoadList\"\n                    border\n                    size=\"18\"\n                    t-tooltip=\"interface.load_more_entries\"\n                    @click=\"emit('loadmore')\" />\n                <icon-button\n                    :disabled=\"props.end || props.loading\"\n                    :icon=\"LoadAll\"\n                    border\n                    size=\"18\"\n                    t-tooltip=\"interface.load_all_entries\"\n                    @click=\"emit('loadall')\" />\n            </n-button-group>\n            <n-button :focusable=\"false\" plain @click=\"onAddRow\">\n                <template #icon>\n                    <n-icon :component=\"AddLink\" size=\"18\" />\n                </template>\n                {{ $t('interface.add_row') }}\n            </n-button>\n        </div>\n        <!-- loaded progress -->\n        <n-progress\n            :border-radius=\"0\"\n            :color=\"props.end ? '#0000' : themeVars.primaryColor\"\n            :height=\"2\"\n            :percentage=\"loadProgress\"\n            :processing=\"props.loading\"\n            :show-indicator=\"false\"\n            status=\"success\"\n            type=\"line\" />\n        <div class=\"value-wrapper value-item-part flex-box-v flex-item-expand\">\n            <n-data-table\n                :bordered=\"false\"\n                :bottom-bordered=\"false\"\n                :columns=\"columns\"\n                :data=\"props.value\"\n                :loading=\"props.loading\"\n                :row-key=\"(row) => row.id\"\n                :single-column=\"true\"\n                :single-line=\"false\"\n                class=\"flex-item-expand\"\n                flex-height\n                size=\"small\"\n                striped\n                virtual-scroll\n                @update:filters=\"onUpdateFilter\" />\n        </div>\n\n        <div class=\"value-footer flex-box-h\">\n            <n-text v-if=\"!isNaN(props.length)\">{{ $t('interface.entries') }}: {{ entries }}</n-text>\n            <n-divider v-if=\"showMemoryUsage\" vertical />\n            <n-text v-if=\"showMemoryUsage\">{{ $t('interface.memory_usage') }}: {{ formatBytes(props.size) }}</n-text>\n            <div class=\"flex-item-expand\"></div>\n        </div>\n    </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.value-footer {\n    border-top: v-bind('themeVars.borderColor') 1px solid;\n    background-color: v-bind('themeVars.tableHeaderColor');\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/content_value/ContentValueString.vue",
    "content": "<script setup>\nimport { computed, reactive, ref, watch } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport Copy from '@/components/icons/Copy.vue'\nimport Save from '@/components/icons/Save.vue'\nimport { useThemeVars } from 'naive-ui'\nimport { formatTypes } from '@/consts/value_view_type.js'\nimport { types as redisTypes } from '@/consts/support_redis_type.js'\nimport { isEmpty, toLower } from 'lodash'\nimport useBrowserStore from 'stores/browser.js'\nimport { decodeRedisKey } from '@/utils/key_convert.js'\nimport FormatSelector from '@/components/content_value/FormatSelector.vue'\nimport ContentEditor from '@/components/content_value/ContentEditor.vue'\nimport { formatBytes } from '@/utils/byte_convert.js'\nimport { ClipboardSetText } from 'wailsjs/runtime/runtime.js'\n\nconst props = defineProps({\n    name: String,\n    db: Number,\n    keyPath: String,\n    keyCode: {\n        type: Array,\n        default: null,\n    },\n    ttl: {\n        type: Number,\n        default: -1,\n    },\n    value: [String, Array],\n    format: {\n        type: String,\n    },\n    decode: {\n        type: String,\n    },\n    size: Number,\n    length: Number,\n    loading: Boolean,\n})\n\nconst i18n = useI18n()\nconst themeVars = useThemeVars()\n\n/**\n *\n * @type {ComputedRef<string|number[]>}\n */\nconst keyName = computed(() => {\n    return !isEmpty(props.keyCode) ? props.keyCode : props.keyPath\n})\n\nconst keyType = redisTypes.STRING\nconst viewLanguage = computed(() => {\n    switch (viewAs.format) {\n        case formatTypes.JSON:\n        case formatTypes.UNICODE_JSON:\n            return 'json'\n        case formatTypes.YAML:\n            return 'yaml'\n        case formatTypes.XML:\n            return 'xml'\n        default:\n            return 'plaintext'\n    }\n})\n\nconst viewAs = reactive({\n    value: '',\n    format: '',\n    decode: '',\n})\n\nconst editingContent = ref('')\nconst resetKey = ref('')\n\nconst enableSave = computed(() => {\n    return editingContent.value !== viewAs.value && !props.loading\n})\n\nconst displayValue = computed(() => {\n    return viewAs.value || decodeRedisKey(props.value) || ''\n})\n\nconst showMemoryUsage = computed(() => {\n    return !isNaN(props.size) && props.size > 0\n})\n\nwatch(\n    () => props.value,\n    (val) => {\n        if (!isEmpty(val)) {\n            onFormatChanged(viewAs.decode, viewAs.format)\n        }\n    },\n)\n\nconst converting = ref(false)\nconst onFormatChanged = async (decode = '', format = '') => {\n    try {\n        converting.value = true\n        const {\n            value,\n            decode: retDecode,\n            format: retFormat,\n        } = await browserStore.convertValue({\n            value: props.value,\n            decode: decode || props.decode,\n            format: format || props.format,\n        })\n        editingContent.value = viewAs.value = value\n        viewAs.decode = decode || retDecode\n        viewAs.format = format || retFormat\n        browserStore.setSelectedFormat(props.name, props.keyPath, props.db, viewAs.format, viewAs.decode)\n        resetKey.value = Date.now().toString()\n    } finally {\n        converting.value = false\n    }\n}\n\n/**\n * Copy value\n */\nconst onCopyValue = async () => {\n    await ClipboardSetText(displayValue.value)\n    $message.success(i18n.t('interface.copy_succ'))\n}\n\n/**\n * Save value\n */\nconst browserStore = useBrowserStore()\nconst saving = ref(false)\n\nconst onInput = (content) => {\n    editingContent.value = content\n}\n\nconst onSave = async () => {\n    saving.value = true\n    try {\n        const { success, msg } = await browserStore.setKey({\n            server: props.name,\n            db: props.db,\n            key: keyName.value,\n            keyType: toLower(keyType),\n            value: editingContent.value,\n            ttl: -1,\n            format: viewAs.format,\n            decode: viewAs.decode,\n        })\n        if (success) {\n            viewAs.value = editingContent.value\n            $message.success(i18n.t('interface.save_value_succ'))\n        } else {\n            $message.error(msg)\n        }\n    } catch (e) {\n        $message.error(e.message)\n    } finally {\n        saving.value = false\n    }\n}\n\ndefineExpose({\n    reset: () => {\n        viewAs.value = ''\n        viewAs.decode = ''\n        viewAs.format = ''\n        editingContent.value = ''\n    },\n})\n</script>\n\n<template>\n    <div class=\"content-wrapper flex-box-v\">\n        <slot name=\"toolbar\" />\n        <div class=\"tb2 value-item-part flex-box-h\">\n            <div class=\"flex-item-expand\"></div>\n            <n-button-group>\n                <n-button :disabled=\"saving\" :focusable=\"false\" @click=\"onCopyValue\">\n                    <template #icon>\n                        <n-icon :component=\"Copy\" size=\"18\" />\n                    </template>\n                    {{ $t('interface.copy_value') }}\n                </n-button>\n                <n-button\n                    :disabled=\"!enableSave\"\n                    :loading=\"saving\"\n                    :secondary=\"enableSave\"\n                    :type=\"enableSave ? 'primary' : ''\"\n                    @click=\"onSave\">\n                    <template #icon>\n                        <n-icon :component=\"Save\" size=\"18\" />\n                    </template>\n                    {{ $t('common.save') }}\n                </n-button>\n            </n-button-group>\n        </div>\n        <div class=\"value-wrapper value-item-part flex-item-expand flex-box-v\">\n            <content-editor\n                :content=\"displayValue\"\n                :language=\"viewLanguage\"\n                :loading=\"props.loading\"\n                :offset-key=\"props.keyPath\"\n                :reset-key=\"resetKey\"\n                class=\"flex-item-expand\"\n                keep-offset\n                style=\"height: 100%\"\n                @input=\"onInput\"\n                @reset=\"onInput\"\n                @save=\"onSave\" />\n            <n-spin v-show=\"props.loading || converting\" />\n        </div>\n        <div class=\"value-footer flex-box-h\">\n            <n-text v-if=\"!isNaN(props.length)\">{{ $t('interface.length') }}: {{ props.length }}</n-text>\n            <n-divider v-if=\"showMemoryUsage\" vertical />\n            <n-text v-if=\"showMemoryUsage\">{{ $t('interface.memory_usage') }}: {{ formatBytes(props.size) }}</n-text>\n            <div class=\"flex-item-expand\" />\n            <format-selector\n                :decode=\"viewAs.decode\"\n                :disabled=\"enableSave\"\n                :format=\"viewAs.format\"\n                @format-changed=\"onFormatChanged\" />\n        </div>\n    </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.value-wrapper {\n    //overflow: hidden;\n    border-top: v-bind('themeVars.borderColor') 1px solid;\n    padding: 5px;\n}\n\n.value-footer {\n    border-top: v-bind('themeVars.borderColor') 1px solid;\n    background-color: v-bind('themeVars.tableHeaderColor');\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/content_value/ContentValueWrapper.vue",
    "content": "<script setup>\nimport { types, types as redisTypes } from '@/consts/support_redis_type.js'\nimport ContentValueString from '@/components/content_value/ContentValueString.vue'\nimport ContentValueHash from '@/components/content_value/ContentValueHash.vue'\nimport ContentValueList from '@/components/content_value/ContentValueList.vue'\nimport ContentValueSet from '@/components/content_value/ContentValueSet.vue'\nimport ContentValueZset from '@/components/content_value/ContentValueZSet.vue'\nimport ContentValueStream from '@/components/content_value/ContentValueStream.vue'\nimport { useThemeVars } from 'naive-ui'\nimport useBrowserStore from 'stores/browser.js'\nimport { computed, onMounted, ref, watch } from 'vue'\nimport { isEmpty } from 'lodash'\nimport useDialogStore from 'stores/dialog.js'\nimport { useI18n } from 'vue-i18n'\nimport ContentToolbar from '@/components/content_value/ContentToolbar.vue'\nimport ContentValueJson from '@/components/content_value/ContentValueJson.vue'\nimport usePreferencesStore from 'stores/preferences.js'\nimport { TextAlignType } from '@/consts/text_align_type.js'\nimport { isMacOS } from '@/utils/platform.js'\n\nconst themeVars = useThemeVars()\nconst browserStore = useBrowserStore()\nconst dialogStore = useDialogStore()\nconst prefStore = usePreferencesStore()\n\nconst props = defineProps({\n    blank: Boolean,\n    content: {\n        type: Object,\n        default: {},\n    },\n})\nconst i18n = useI18n()\n\n/**\n *\n * @type {ComputedRef<{\n *      type:\n *      String, name: String,\n *      db: Number,\n *      keyPath: String,\n *      keyCode: Array,\n *      ttl: Number,\n *      value: [String, Object],\n *      size: Number,\n *      length: Number,\n *      format: String,\n *      decode: String,\n *      end: Boolean\n *      loading: Boolean\n * }>}\n */\nconst data = computed(() => {\n    return props.content\n})\nconst initializing = ref(false)\n\nconst loading = computed(() => {\n    return data.value.loading === true || initializing.value\n})\n\nconst binaryKey = computed(() => {\n    return !!data.value.keyCode\n})\n\nconst valueComponents = {\n    [redisTypes.STRING]: ContentValueString,\n    [redisTypes.HASH]: ContentValueHash,\n    [redisTypes.LIST]: ContentValueList,\n    [redisTypes.SET]: ContentValueSet,\n    [redisTypes.ZSET]: ContentValueZset,\n    [redisTypes.STREAM]: ContentValueStream,\n    [redisTypes.JSON]: ContentValueJson,\n}\n\nconst keyName = computed(() => {\n    return !isEmpty(data.value.keyCode) ? data.value.keyCode : data.value.keyPath\n})\n\n/**\n *\n * @param {boolean} reset\n * @param {boolean} [full]\n * @param {string} [selMatch]\n * @return {Promise<void>}\n */\nconst loadData = async (reset, full, selMatch) => {\n    try {\n        if (!!props.blank) {\n            return\n        }\n        const { name, db, matchPattern } = data.value\n        reset = reset === true\n        await browserStore.loadKeyDetail({\n            server: name,\n            db: db,\n            key: keyName.value,\n            matchPattern: selMatch === undefined ? matchPattern : selMatch,\n            decode: '',\n            format: '',\n            reset,\n            full: full === true,\n        })\n    } finally {\n    }\n}\n\n/**\n * reload current key\n * @param {string} [selDecode]\n * @param {string} [selFormat]\n * @return {Promise<void>}\n */\nconst onReload = async (selDecode, selFormat) => {\n    try {\n        const { name, type, db, keyCode, keyPath, decode, format, matchPattern } = data.value\n        const targetFormat = selFormat || format\n        const targetDecode = selDecode || decode\n        browserStore.setSelectedFormat(name, keyPath, db, targetFormat, targetDecode)\n        await browserStore.reloadKey({\n            server: name,\n            db,\n            key: keyCode || keyPath,\n            decode: targetDecode,\n            format: targetFormat,\n            matchPattern,\n            showLoading: type !== types.STRING && type !== types.JSON,\n        })\n    } finally {\n    }\n}\n\nconst onKeyShortcut = (e) => {\n    const isCtrlOn = isMacOS() ? e.metaKey : e.ctrlKey\n    switch (e.key) {\n        case 'Delete':\n            onDelete()\n            return\n        case 'F5':\n            onReload()\n            return\n        case 'r':\n            if (isCtrlOn) {\n                onReload()\n            }\n            return\n        case 'F2':\n            onRename()\n            return\n    }\n}\n\nconst onRename = () => {\n    const { name, db, keyPath } = data.value\n    if (binaryKey.value) {\n        $message.error(i18n.t('dialogue.rename_binary_key_fail'))\n    } else {\n        dialogStore.openRenameKeyDialog(name, db, keyPath)\n    }\n}\n\nconst onDelete = () => {\n    $dialog.warning(i18n.t('dialogue.remove_tip', { name: data.value.keyPath }), () => {\n        const { name, db } = data.value\n        browserStore.deleteKey(name, db, keyName.value).then((success) => {\n            if (success) {\n                $message.success(i18n.t('dialogue.delete.success', { key: data.value.keyPath }))\n            }\n        })\n    })\n}\n\nconst onLoadMore = () => {\n    loadData(false, false)\n}\n\nconst onLoadAll = () => {\n    loadData(false, true)\n}\n\nconst onMatch = (match) => {\n    loadData(true, false, match || '')\n}\n\nconst onEntryTextAlignChanged = (align) => {\n    prefStore.editor.entryTextAlign = align !== TextAlignType.Left ? TextAlignType.Center : TextAlignType.Left\n    prefStore.savePreferences()\n}\n\nconst contentRef = ref(null)\nconst initContent = async () => {\n    // onReload()\n    try {\n        initializing.value = true\n        if (contentRef.value?.reset != null) {\n            contentRef.value?.reset()\n        }\n        await loadData(true, false, '')\n    } finally {\n        initializing.value = false\n    }\n}\n\nonMounted(() => {\n    // onReload()\n    initContent()\n})\n\nwatch(() => data.value?.keyPath, initContent)\n</script>\n\n<template>\n    <n-empty v-if=\"props.blank\" :description=\"$t('interface.nonexist_tab_content')\" class=\"empty-content\">\n        <template #extra>\n            <n-button :focusable=\"false\" @click=\"onReload\">{{ $t('interface.reload') }}</n-button>\n        </template>\n    </n-empty>\n    <!-- FIXME: keep alive may cause virtual list null value error. -->\n    <!-- <keep-alive v-else> -->\n    <component\n        :is=\"valueComponents[data.type]\"\n        v-else\n        ref=\"contentRef\"\n        :db=\"data.db\"\n        :decode=\"data.decode\"\n        :end=\"data.end\"\n        :format=\"data.format\"\n        :key-code=\"data.keyCode\"\n        :key-path=\"data.keyPath\"\n        :length=\"data.length\"\n        :loading=\"loading\"\n        :name=\"data.name\"\n        :size=\"data.size\"\n        :ttl=\"data.ttl\"\n        :value=\"data.value\"\n        tabindex=\"0\"\n        :text-align=\"prefStore.entryTextAlign\"\n        @delete=\"onDelete\"\n        @keydown=\"onKeyShortcut\"\n        @loadall=\"onLoadAll\"\n        @loadmore=\"onLoadMore\"\n        @match=\"onMatch\"\n        @reload=\"onReload\"\n        @update:text-align=\"onEntryTextAlignChanged\">\n        <template #toolbar>\n            <content-toolbar\n                :db=\"data.db\"\n                :key-code=\"data.keyCode\"\n                :key-path=\"data.keyPath\"\n                :key-type=\"data.type\"\n                :loading=\"loading\"\n                :server=\"data.name\"\n                :ttl=\"data.ttl\"\n                class=\"value-item-part\"\n                @delete=\"onDelete\"\n                @reload=\"onReload\"\n                @rename=\"onRename\" />\n        </template>\n    </component>\n    <!--    </keep-alive>-->\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/content_value/ContentValueZSet.vue",
    "content": "<script setup>\nimport { computed, h, reactive, ref } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport AddLink from '@/components/icons/AddLink.vue'\nimport { NButton, NIcon, useThemeVars } from 'naive-ui'\nimport { types, types as redisTypes } from '@/consts/support_redis_type.js'\nimport EditableTableColumn from '@/components/common/EditableTableColumn.vue'\nimport { isEmpty, size, truncate } from 'lodash'\nimport useDialogStore from 'stores/dialog.js'\nimport { decodeTypes, formatTypes } from '@/consts/value_view_type.js'\nimport useBrowserStore from 'stores/browser.js'\nimport LoadList from '@/components/icons/LoadList.vue'\nimport LoadAll from '@/components/icons/LoadAll.vue'\nimport IconButton from '@/components/common/IconButton.vue'\nimport ContentEntryEditor from '@/components/content_value/ContentEntryEditor.vue'\nimport FormatSelector from '@/components/content_value/FormatSelector.vue'\nimport Edit from '@/components/icons/Edit.vue'\nimport ContentSearchInput from '@/components/content_value/ContentSearchInput.vue'\nimport { formatBytes } from '@/utils/byte_convert.js'\nimport { ClipboardSetText } from 'wailsjs/runtime/runtime.js'\nimport { TextAlignType } from '@/consts/text_align_type.js'\nimport AlignLeft from '@/components/icons/AlignLeft.vue'\nimport AlignCenter from '@/components/icons/AlignCenter.vue'\nimport SwitchButton from '@/components/common/SwitchButton.vue'\nimport { nativeRedisKey } from '@/utils/key_convert.js'\n\nconst i18n = useI18n()\nconst themeVars = useThemeVars()\n\nconst props = defineProps({\n    name: String,\n    db: Number,\n    keyPath: String,\n    keyCode: {\n        type: Array,\n        default: null,\n    },\n    ttl: {\n        type: Number,\n        default: -1,\n    },\n    value: {\n        type: Array,\n        default: () => [],\n    },\n    size: Number,\n    length: Number,\n    format: {\n        type: String,\n        default: formatTypes.RAW,\n    },\n    decode: {\n        type: String,\n        default: decodeTypes.NONE,\n    },\n    end: Boolean,\n    loading: Boolean,\n    textAlign: Number,\n})\n\nconst emit = defineEmits(['loadmore', 'loadall', 'reload', 'match', 'update:textAlign'])\n\n/**\n *\n * @type {ComputedRef<string|number[]>}\n */\nconst keyName = computed(() => {\n    return !isEmpty(props.keyCode) ? props.keyCode : props.keyPath\n})\n\nconst browserStore = useBrowserStore()\nconst dialogStore = useDialogStore()\nconst keyType = redisTypes.ZSET\nconst currentEditRow = reactive({\n    no: 0,\n    score: 0,\n    value: null,\n    format: formatTypes.RAW,\n    decode: decodeTypes.NONE,\n})\n\nconst inEdit = computed(() => {\n    return currentEditRow.no > 0\n})\nconst fullEdit = ref(false)\n\n// const scoreFilterOption = ref(null)\nconst scoreColumn = computed(() => ({\n    key: 'score',\n    title: () => i18n.t('common.score'),\n    align: props.textAlign !== TextAlignType.Left ? 'center' : 'left',\n    titleAlign: 'center',\n    resizable: true,\n    sorter: (row1, row2) => row1.s - row2.s,\n    // filterOptionValue: scoreFilterOption.value,\n    // filter(value, row) {\n    //     const score = parseFloat(row.s)\n    //     if (isNaN(score)) {\n    //         return true\n    //     }\n    //\n    //     const regex = /^(>=|<=|>|<|=|!=)?(\\d+(\\.\\d*)?)?$/\n    //     const matches = value.match(regex)\n    //     if (matches) {\n    //         const operator = matches[1] || ''\n    //         const filterScore = parseFloat(matches[2] || '')\n    //         if (!isNaN(filterScore)) {\n    //             switch (operator) {\n    //                 case '>=':\n    //                     return score >= filterScore\n    //                 case '<=':\n    //                     return score <= filterScore\n    //                 case '>':\n    //                     return score > filterScore\n    //                 case '<':\n    //                     return score < filterScore\n    //                 case '=':\n    //                     return score === filterScore\n    //                 case '!=':\n    //                     return score !== filterScore\n    //             }\n    //         }\n    //     } else {\n    //         return !!~row.v.indexOf(value.toString())\n    //     }\n    //     return true\n    // },\n    render: (row) => {\n        return row.ss || row.s\n    },\n}))\n\nconst isCode = computed(() => {\n    return props.format === formatTypes.JSON || props.format === formatTypes.UNICODE_JSON\n})\nconst valueFilterOption = ref(null)\nconst valueColumn = computed(() => ({\n    key: 'value',\n    title: () => i18n.t('common.value'),\n    align: isCode.value ? 'left' : props.textAlign !== TextAlignType.Left ? 'center' : 'left',\n    titleAlign: 'center',\n    resizable: true,\n    ellipsis: isCode.value\n        ? false\n        : {\n              tooltip: {\n                  style: {\n                      maxWidth: '50vw',\n                      maxHeight: '50vh',\n                  },\n                  scrollable: true,\n              },\n              lineClamp: 1,\n          },\n    filterOptionValue: valueFilterOption.value,\n    className: inEdit.value ? 'clickable' : '',\n    filter(filterValue, row) {\n        const val = row.dv || nativeRedisKey(row.v)\n        return !!~val.indexOf(filterValue.toString())\n    },\n    // sorter: (row1, row2) => row1.value - row2.value,\n    render: (row) => {\n        if (isCode.value) {\n            const val = row.dv || nativeRedisKey(row.v)\n            return h('pre', { class: 'pre-wrap' }, val)\n        } else {\n            const val = row.dv || nativeRedisKey(row.v, 500)\n            return truncate(val, { length: 500 })\n        }\n    },\n}))\n\nconst startEdit = async (no, score, value) => {\n    currentEditRow.no = no\n    currentEditRow.score = score\n    currentEditRow.value = value\n    currentEditRow.decode = props.decode\n    currentEditRow.format = props.format\n}\n\nconst saveEdit = async (field, value, decode, format) => {\n    try {\n        const score = parseFloat(field)\n        const row = props.value[currentEditRow.no - 1]\n        if (row == null) {\n            throw new Error('row not exists')\n        }\n\n        if (isEmpty(value)) {\n            value = currentEditRow.value\n        }\n\n        const { success, msg } = await browserStore.updateZSetItem({\n            server: props.name,\n            db: props.db,\n            key: keyName.value,\n            value: row.v,\n            newValue: value,\n            score,\n            decode,\n            format,\n        })\n        if (success) {\n            $message.success(i18n.t('interface.save_value_succ'))\n        } else {\n            $message.error(msg)\n        }\n    } catch (e) {\n        $message.error(e.message)\n    }\n}\n\nconst resetEdit = () => {\n    currentEditRow.no = 0\n    currentEditRow.score = 0\n    currentEditRow.value = null\n    // if (currentEditRow.format !== props.format || currentEditRow.decode !== props.decode) {\n    //     nextTick(() => onFormatChanged(currentEditRow.decode, currentEditRow.format))\n    // }\n}\n\nconst actionColumn = {\n    key: 'action',\n    title: () => i18n.t('interface.action'),\n    width: 120,\n    align: 'center',\n    titleAlign: 'center',\n    fixed: 'right',\n    render: (row, index) => {\n        return h(EditableTableColumn, {\n            editing: false,\n            bindKey: row.v,\n            onCopy: async () => {\n                await ClipboardSetText(row.v)\n                $message.success(i18n.t('interface.copy_succ'))\n            },\n            onEdit: () => startEdit(index + 1, row.s, row.v),\n            onDelete: async () => {\n                try {\n                    const { success, msg } = await browserStore.removeZSetItem({\n                        server: props.name,\n                        db: props.db,\n                        key: keyName.value,\n                        value: row.v,\n                    })\n                    if (success) {\n                        $message.success(i18n.t('dialogue.delete.success', { key: row.v }))\n                    } else {\n                        $message.error(msg)\n                    }\n                } catch (e) {\n                    $message.error(e.message)\n                }\n            },\n        })\n    },\n}\n\nconst columns = computed(() => {\n    if (!inEdit.value) {\n        return [\n            {\n                key: 'no',\n                title: '#',\n                width: 80,\n                align: 'center',\n                titleAlign: 'center',\n                render: (row, index) => {\n                    return index + 1\n                },\n            },\n            valueColumn.value,\n            scoreColumn.value,\n            actionColumn,\n        ]\n    } else {\n        return [\n            {\n                key: 'no',\n                title: '#',\n                width: 80,\n                align: 'center',\n                titleAlign: 'center',\n                render: (row, index) => {\n                    if (index + 1 === currentEditRow.no) {\n                        // editing row, show edit state\n                        return h(NIcon, { size: 16, color: 'red' }, () => h(Edit, { strokeWidth: 5 }))\n                    } else {\n                        return index + 1\n                    }\n                },\n            },\n            valueColumn.value,\n        ]\n    }\n})\n\nconst entries = computed(() => {\n    const len = size(props.value)\n    return `${len} / ${Math.max(len, props.length)}`\n})\n\nconst loadProgress = computed(() => {\n    const len = size(props.value)\n    return (len * 100) / Math.max(len, props.length)\n})\n\nconst showMemoryUsage = computed(() => {\n    return !isNaN(props.size) && props.size > 0\n})\n\nconst onAddRow = () => {\n    dialogStore.openAddFieldsDialog(props.name, props.db, props.keyPath, props.keyCode, types.ZSET)\n}\n\nconst onFilterInput = (val) => {\n    valueFilterOption.value = val\n}\n\nconst onMatchInput = (matchVal, filterVal) => {\n    valueFilterOption.value = filterVal\n    emit('match', matchVal)\n}\n\nconst onUpdateFilter = (filters, sourceColumn) => {\n    valueFilterOption.value = filters[sourceColumn.key]\n}\n\nconst onFormatChanged = (selDecode, selFormat) => {\n    emit('reload', selDecode, selFormat)\n}\n\nconst searchInputRef = ref(null)\ndefineExpose({\n    reset: () => {\n        resetEdit()\n        searchInputRef.value?.reset()\n    },\n})\n</script>\n\n<template>\n    <div class=\"content-wrapper flex-box-v\">\n        <slot name=\"toolbar\" />\n        <div class=\"tb2 value-item-part flex-box-h\">\n            <div class=\"flex-box-h\" style=\"max-width: 50%\">\n                <content-search-input\n                    ref=\"searchInputRef\"\n                    @filter-changed=\"onFilterInput\"\n                    @match-changed=\"onMatchInput\" />\n            </div>\n            <div class=\"flex-item-expand\"></div>\n            <switch-button\n                :icons=\"[AlignCenter, AlignLeft]\"\n                :stroke-width=\"3.5\"\n                :t-tooltips=\"['interface.text_align_center', 'interface.text_align_left']\"\n                :value=\"props.textAlign\"\n                size=\"medium\"\n                unselect-stroke-width=\"3\"\n                @update:value=\"(val) => emit('update:textAlign', val)\" />\n            <n-divider vertical />\n            <n-button-group>\n                <icon-button\n                    :disabled=\"props.end || props.loading\"\n                    :icon=\"LoadList\"\n                    border\n                    size=\"18\"\n                    t-tooltip=\"interface.load_more_entries\"\n                    @click=\"emit('loadmore')\" />\n                <icon-button\n                    :disabled=\"props.end || props.loading\"\n                    :icon=\"LoadAll\"\n                    border\n                    size=\"18\"\n                    t-tooltip=\"interface.load_all_entries\"\n                    @click=\"emit('loadall')\" />\n            </n-button-group>\n            <n-button :focusable=\"false\" plain @click=\"onAddRow\">\n                <template #icon>\n                    <n-icon :component=\"AddLink\" size=\"18\" />\n                </template>\n                {{ $t('interface.add_row') }}\n            </n-button>\n        </div>\n        <!-- loaded progress -->\n        <n-progress\n            :border-radius=\"0\"\n            :color=\"props.end ? '#0000' : themeVars.primaryColor\"\n            :height=\"2\"\n            :percentage=\"loadProgress\"\n            :processing=\"props.loading\"\n            :show-indicator=\"false\"\n            status=\"success\"\n            type=\"line\" />\n        <div class=\"value-wrapper value-item-part flex-box-h flex-item-expand\">\n            <!-- table -->\n            <n-data-table\n                :bordered=\"false\"\n                :bottom-bordered=\"false\"\n                :columns=\"columns\"\n                :data=\"props.value\"\n                :loading=\"props.loading\"\n                :row-key=\"(row) => row.v\"\n                :single-column=\"true\"\n                :single-line=\"false\"\n                class=\"flex-item-expand\"\n                flex-height\n                size=\"small\"\n                striped\n                virtual-scroll\n                @update:filters=\"onUpdateFilter\" />\n\n            <!-- edit pane -->\n            <div\n                v-show=\"inEdit\"\n                :style=\"{ position: fullEdit ? 'static' : 'relative' }\"\n                class=\"entry-editor-container flex-item-expand\"\n                style=\"width: 100%\">\n                <content-entry-editor\n                    v-model:decode=\"currentEditRow.decode\"\n                    v-model:format=\"currentEditRow.format\"\n                    v-model:fullscreen=\"fullEdit\"\n                    :field=\"currentEditRow.score\"\n                    :field-label=\"$t('common.score')\"\n                    :key-path=\"props.keyPath\"\n                    :show=\"inEdit\"\n                    :value=\"currentEditRow.value\"\n                    :value-label=\"$t('common.value')\"\n                    class=\"flex-item-expand\"\n                    style=\"width: 100%\"\n                    @close=\"resetEdit\"\n                    @save=\"saveEdit\" />\n            </div>\n        </div>\n        <div class=\"value-footer flex-box-h\">\n            <n-text v-if=\"!isNaN(props.length)\">{{ $t('interface.entries') }}: {{ entries }}</n-text>\n            <n-divider v-if=\"showMemoryUsage\" vertical />\n            <n-text v-if=\"showMemoryUsage\">{{ $t('interface.memory_usage') }}: {{ formatBytes(props.size) }}</n-text>\n            <div class=\"flex-item-expand\"></div>\n            <format-selector\n                v-show=\"!inEdit\"\n                :decode=\"props.decode\"\n                :disabled=\"inEdit\"\n                :format=\"props.format\"\n                @format-changed=\"onFormatChanged\" />\n        </div>\n    </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.value-footer {\n    border-top: v-bind('themeVars.borderColor') 1px solid;\n    background-color: v-bind('themeVars.tableHeaderColor');\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/content_value/FormatSelector.vue",
    "content": "<script setup>\nimport { decodeTypes, formatTypes } from '@/consts/value_view_type.js'\nimport Code from '@/components/icons/Code.vue'\nimport Conversion from '@/components/icons/Conversion.vue'\nimport DropdownSelector from '@/components/common/DropdownSelector.vue'\nimport { includes, isEmpty, map, pull, some, values } from 'lodash'\nimport { computed } from 'vue'\nimport usePreferencesStore from 'stores/preferences.js'\nimport useDialogStore from 'stores/dialog.js'\n\nconst props = defineProps({\n    decode: {\n        type: String,\n        default: decodeTypes.NONE,\n    },\n    format: {\n        type: String,\n        default: formatTypes.RAW,\n    },\n    disabled: Boolean,\n})\n\nconst prefStore = usePreferencesStore()\nconst dialogStore = useDialogStore()\n\nconst formatTypeOption = computed(() => {\n    return map(formatTypes, (t) => t)\n})\n\nconst decodeTypeOption = computed(() => {\n    const buildinTypes = [decodeTypes.NONE],\n        customTypes = []\n    const typs = values(decodeTypes)\n    // build-in decoder\n    for (const typ of typs) {\n        if (includes(prefStore.buildInDecoder, typ)) {\n            buildinTypes.push(typ)\n        }\n    }\n    // custom decoder\n    if (!isEmpty(prefStore.decoder)) {\n        for (const decoder of prefStore.decoder) {\n            // replace build-in decoder if name conflicted\n            pull(buildinTypes, decoder.name)\n            customTypes.push(decoder.name)\n        }\n    }\n    return [buildinTypes, customTypes]\n})\n\nconst decodeMenuOption = computed(() => {\n    return [\n        {\n            key: 'new_rdm_decoder',\n            label: 'interface.custom_decoder',\n        },\n    ]\n})\n\nconst emit = defineEmits(['formatChanged', 'update:decode', 'update:format'])\nconst onFormatChanged = (selDecode, selFormat) => {\n    const [buildin, external] = decodeTypeOption.value\n    if (!some([...buildin, ...external], (val) => val === selDecode)) {\n        selDecode = decodeTypes.NONE\n    }\n    if (!some(formatTypes, (val) => val === selFormat)) {\n        // set to auto chose format\n        selFormat = ''\n    }\n    emit('formatChanged', selDecode, selFormat)\n    if (selDecode !== props.decode) {\n        emit('update:decode', selDecode)\n    }\n    if (selFormat !== props.format) {\n        emit('update:format', selFormat)\n    }\n}\n\nconst onDecodeMenu = (key) => {\n    switch (key) {\n        case 'new_rdm_decoder':\n            dialogStore.openPreferencesDialog('decoder')\n            break\n    }\n}\n</script>\n\n<template>\n    <n-space :size=\"0\" :wrap=\"false\" :wrap-item=\"false\" align=\"center\" justify=\"start\">\n        <dropdown-selector\n            :default=\"formatTypes.RAW\"\n            :disabled=\"props.disabled\"\n            :icon=\"Code\"\n            :options=\"formatTypeOption\"\n            :tooltip=\"$t('interface.view_as')\"\n            :value=\"props.format || formatTypes.RAW\"\n            @update:value=\"(f) => onFormatChanged(props.decode, f)\" />\n        <n-divider vertical />\n        <dropdown-selector\n            :default=\"decodeTypes.NONE\"\n            :disabled=\"props.disabled\"\n            :icon=\"Conversion\"\n            :menu-option=\"decodeMenuOption\"\n            :options=\"decodeTypeOption\"\n            :tooltip=\"$t('interface.decode_with')\"\n            :value=\"props.decode || decodeTypes.NONE\"\n            @menu=\"onDecodeMenu\"\n            @update:value=\"(d) => onFormatChanged(d, '')\" />\n    </n-space>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/dialogs/AboutDialog.vue",
    "content": "<script setup>\nimport iconUrl from '@/assets/images/icon.png'\nimport useDialog from 'stores/dialog.js'\nimport usePreferencesStore from 'stores/preferences.js'\nimport { useThemeVars } from 'naive-ui'\nimport { BrowserOpenURL } from 'wailsjs/runtime/runtime.js'\n\nconst themeVars = useThemeVars()\nconst dialogStore = useDialog()\nconst prefStore = usePreferencesStore()\n\nconst onOpenSource = () => {\n    BrowserOpenURL('https://github.com/tiny-craft/tiny-rdm')\n}\n\nconst onOpenWebsite = () => {\n    BrowserOpenURL('https://tinyrdm.com/')\n}\n</script>\n\n<template>\n    <n-modal v-model:show=\"dialogStore.aboutDialogVisible\" :show-icon=\"false\" preset=\"dialog\" transform-origin=\"center\">\n        <n-space :size=\"10\" :wrap=\"false\" :wrap-item=\"false\" align=\"center\" vertical>\n            <n-avatar :size=\"120\" :src=\"iconUrl\" color=\"#0000\"></n-avatar>\n            <div class=\"about-app-title\">Tiny RDM</div>\n            <n-text>{{ prefStore.appVersion }}</n-text>\n            <n-space :size=\"5\" :wrap=\"false\" :wrap-item=\"false\" align=\"center\">\n                <n-text class=\"about-link\" @click=\"onOpenSource\">{{ $t('dialogue.about.source') }}</n-text>\n                <n-divider vertical />\n                <n-text class=\"about-link\" @click=\"onOpenWebsite\">{{ $t('dialogue.about.website') }}</n-text>\n            </n-space>\n            <div :style=\"{ color: themeVars.textColor3 }\" class=\"about-copyright\">\n                Copyright © {{ new Date().getFullYear() }} Tinycraft.cc All rights reserved\n            </div>\n        </n-space>\n    </n-modal>\n</template>\n\n<style lang=\"scss\" scoped>\n.about-app-title {\n    font-weight: bold;\n    font-size: 18px;\n    margin: 5px;\n}\n\n.about-link {\n    cursor: pointer;\n\n    &:hover {\n        text-decoration: underline;\n    }\n}\n\n.about-copyright {\n    font-size: 12px;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/dialogs/AddFieldsDialog.vue",
    "content": "<script setup>\nimport { computed, reactive, watchEffect } from 'vue'\nimport { types } from '@/consts/support_redis_type.js'\nimport useDialog from 'stores/dialog'\nimport NewStringValue from '@/components/new_value/NewStringValue.vue'\nimport NewSetValue from '@/components/new_value/NewSetValue.vue'\nimport { useI18n } from 'vue-i18n'\nimport AddListValue from '@/components/new_value/AddListValue.vue'\nimport AddHashValue from '@/components/new_value/AddHashValue.vue'\nimport AddZSetValue from '@/components/new_value/AddZSetValue.vue'\nimport NewStreamValue from '@/components/new_value/NewStreamValue.vue'\nimport { get, isEmpty, size, slice } from 'lodash'\nimport useBrowserStore from 'stores/browser.js'\nimport useTabStore from 'stores/tab.js'\n\nconst i18n = useI18n()\nconst newForm = reactive({\n    server: '',\n    db: 0,\n    key: '',\n    keyCode: null,\n    type: '',\n    opType: 0,\n    value: null,\n    reload: false,\n})\n\nconst addValueComponent = {\n    [types.STRING]: NewStringValue,\n    [types.HASH]: AddHashValue,\n    [types.LIST]: AddListValue,\n    [types.SET]: NewSetValue,\n    [types.ZSET]: AddZSetValue,\n    [types.STREAM]: NewStreamValue,\n}\nconst defaultValue = {\n    [types.STRING]: '',\n    [types.HASH]: [],\n    [types.LIST]: [],\n    [types.SET]: [],\n    [types.ZSET]: [],\n    [types.STREAM]: ['*'],\n}\n\n/**\n * dialog title\n * @type {ComputedRef<string>}\n */\nconst title = computed(() => {\n    switch (newForm.type) {\n        case types.LIST:\n            return 'dialogue.field.new_item'\n        case types.HASH:\n            return 'dialogue.field.new'\n        case types.SET:\n            return 'dialogue.field.new'\n        case types.ZSET:\n            return 'dialogue.field.new'\n        case types.STREAM:\n            return 'dialogue.field.new'\n    }\n    return ''\n})\n\nconst dialogStore = useDialog()\nwatchEffect(() => {\n    if (dialogStore.addFieldsDialogVisible) {\n        const { server, db, key, keyCode, type } = dialogStore.addFieldParam\n        newForm.server = server\n        newForm.db = db\n        newForm.key = key\n        newForm.keyCode = keyCode\n        newForm.type = type\n        newForm.opType = 0\n        newForm.value = null\n    }\n})\n\nconst browserStore = useBrowserStore()\nconst tab = useTabStore()\nconst onAdd = async () => {\n    try {\n        const { server, db, key, keyCode, type } = newForm\n        let { value } = newForm\n        if (value == null) {\n            value = defaultValue[type]\n        }\n        const keyName = isEmpty(keyCode) ? key : keyCode\n        let success = false\n        let msg = ''\n        switch (type) {\n            case types.LIST:\n                {\n                    let data\n                    if (newForm.opType === 1) {\n                        data = await browserStore.prependListItem({\n                            server,\n                            db,\n                            key: keyName,\n                            values: value,\n                            reload: newForm.reload,\n                        })\n                    } else {\n                        data = await browserStore.appendListItem({\n                            server,\n                            db,\n                            key: keyName,\n                            values: value,\n                            reload: newForm.reload,\n                        })\n                    }\n                    success = get(data, 'success')\n                    msg = get(data, 'msg')\n                }\n                break\n\n            case types.HASH:\n                {\n                    const data = await browserStore.addHashField({\n                        server,\n                        db,\n                        key: keyName,\n                        action: newForm.opType,\n                        fieldItems: value,\n                        reload: newForm.reload,\n                    })\n                    success = get(data, 'success')\n                    msg = get(data, 'msg')\n                }\n                break\n\n            case types.SET:\n                {\n                    const data = await browserStore.addSetItem({\n                        server,\n                        db,\n                        key: keyName,\n                        value,\n                        reload: newForm.reload,\n                    })\n                    success = get(data, 'success')\n                    msg = get(data, 'msg')\n                }\n                break\n\n            case types.ZSET:\n                {\n                    const data = await browserStore.addZSetItem({\n                        server,\n                        db,\n                        key: keyName,\n                        action: newForm.opType,\n                        vs: value,\n                        reload: newForm.reload,\n                    })\n                    success = get(data, 'success')\n                    msg = get(data, 'msg')\n                }\n                break\n\n            case types.STREAM:\n                {\n                    if (size(value) > 2) {\n                        const data = await browserStore.addStreamValue({\n                            server,\n                            db,\n                            key: keyName,\n                            id: value[0],\n                            values: slice(value, 1),\n                            reload: newForm.reload,\n                        })\n                        success = get(data, 'success')\n                        msg = get(data, 'msg')\n                    }\n                }\n                break\n        }\n\n        if (success) {\n            $message.success(i18n.t('dialogue.handle_succ'))\n        } else if (!isEmpty(msg)) {\n            $message.error(msg)\n        }\n\n        dialogStore.closeAddFieldsDialog()\n    } catch (e) {\n        $message.error(e.message)\n    }\n}\n\nconst onClose = () => {\n    dialogStore.closeAddFieldsDialog()\n}\n</script>\n\n<template>\n    <n-modal\n        v-model:show=\"dialogStore.addFieldsDialogVisible\"\n        :closable=\"false\"\n        :mask-closable=\"false\"\n        :negative-button-props=\"{ size: 'medium' }\"\n        :negative-text=\"$t('common.cancel')\"\n        :positive-button-props=\"{ size: 'medium' }\"\n        :positive-text=\"$t('common.confirm')\"\n        :show-icon=\"false\"\n        :title=\"title ? $t(title) : ''\"\n        close-on-esc\n        preset=\"dialog\"\n        style=\"width: 600px\"\n        transform-origin=\"center\"\n        @esc=\"onClose\"\n        @positive-click=\"onAdd\"\n        @negative-click=\"onClose\">\n        <n-scrollbar style=\"max-height: 500px\">\n            <n-form :model=\"newForm\" :show-require-mark=\"false\" label-placement=\"top\" style=\"padding-right: 15px\">\n                <n-form-item :label=\"$t('common.key')\" path=\"key\" required>\n                    <n-input v-model:value=\"newForm.key\" placeholder=\"\" readonly />\n                </n-form-item>\n                <component\n                    :is=\"addValueComponent[newForm.type]\"\n                    v-model:type=\"newForm.opType\"\n                    v-model:value=\"newForm.value\" />\n                <n-form-item :show-label=\"false\" path=\"key\" required>\n                    <n-checkbox v-model:checked=\"newForm.reload\">\n                        {{ $t('dialogue.field.reload_when_succ') }}\n                    </n-checkbox>\n                </n-form-item>\n            </n-form>\n        </n-scrollbar>\n    </n-modal>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/dialogs/ConnectionDialog.vue",
    "content": "<script setup>\nimport { every, get, includes, isEmpty, map, reject, sortBy, toNumber, trim } from 'lodash'\nimport { computed, nextTick, ref, watch } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport { ListSentinelMasters, TestConnection } from 'wailsjs/go/services/connectionService.js'\nimport useDialog, { ConnDialogType } from 'stores/dialog'\nimport Close from '@/components/icons/Close.vue'\nimport useConnectionStore from 'stores/connections.js'\nimport FileOpenInput from '@/components/common/FileOpenInput.vue'\nimport { KeyViewType } from '@/consts/key_view_type.js'\nimport { useThemeVars } from 'naive-ui'\nimport useBrowserStore from 'stores/browser.js'\nimport Delete from '@/components/icons/Delete.vue'\nimport Add from '@/components/icons/Add.vue'\nimport IconButton from '@/components/common/IconButton.vue'\n\n/**\n * Dialog for new or edit connection\n */\n\nconst themeVars = useThemeVars()\nconst dialogStore = useDialog()\nconst connectionStore = useConnectionStore()\nconst browserStore = useBrowserStore()\nconst i18n = useI18n()\n\nconst editName = ref('')\nconst generalForm = ref(null)\nconst generalFormRules = () => {\n    const requiredMsg = i18n.t('dialogue.field_required')\n    const illegalChars = ['/', '\\\\']\n    return {\n        name: [\n            { required: true, message: requiredMsg, trigger: 'input' },\n            {\n                validator: (rule, value) => {\n                    return every(illegalChars, (c) => !includes(value, c))\n                },\n                message: i18n.t('dialogue.illegal_characters'),\n                trigger: 'input',\n            },\n        ],\n        addr: { required: true, message: requiredMsg, trigger: 'input' },\n        defaultFilter: { required: true, message: requiredMsg, trigger: 'input' },\n        keySeparator: { required: true, message: requiredMsg, trigger: 'input' },\n    }\n}\nconst isEditMode = computed(() => dialogStore.connType === ConnDialogType.EDIT)\nconst closingConnection = computed(() => {\n    if (isEmpty(editName.value)) {\n        return false\n    }\n    return browserStore.isConnected(editName.value)\n})\n\nconst groupOptions = computed(() => {\n    const options = map(connectionStore.groups, (group) => ({\n        label: group,\n        value: group,\n    }))\n    options.splice(0, 0, {\n        label: 'dialogue.connection.no_group',\n        value: '',\n    })\n    return options\n})\n\nconst dbFilterList = ref([])\nconst onUpdateDBFilterType = (t) => {\n    if (t !== 'none') {\n        // set default filter index if empty\n        if (isEmpty(dbFilterList.value)) {\n            dbFilterList.value = ['0']\n        }\n    }\n}\n\nconst aliasPair = ref([\n    /*{ db: 0, alias: '' }*/\n])\nconst onCreateAlias = () => {\n    return {\n        db: 0,\n        alias: '',\n    }\n}\nconst onUpdateAlias = () => {\n    const val = reject(aliasPair.value, (v) => v == null || isEmpty(v.alias))\n    const result = {}\n    for (const elem of val) {\n        result[elem.db] = elem.alias\n    }\n    generalForm.value.alias = result\n}\n\nwatch(\n    () => dbFilterList.value,\n    (list) => {\n        const dbList = map(list, (item) => {\n            const idx = toNumber(item)\n            return isNaN(idx) ? 0 : idx\n        })\n        generalForm.value.dbFilterList = sortBy(dbList)\n    },\n    { deep: true },\n)\n\nconst sshLoginType = computed(() => {\n    return get(generalForm.value, 'ssh.loginType', 'pwd')\n})\n\nconst loadingSentinelMaster = ref(false)\nconst masterNameOptions = ref([])\nconst onLoadSentinelMasters = async () => {\n    try {\n        loadingSentinelMaster.value = true\n        const { success, data, msg } = await ListSentinelMasters(generalForm.value)\n        if (!success || isEmpty(data)) {\n            $message.error(msg || 'list sentinel master fail')\n        } else {\n            const options = []\n            for (const m of data) {\n                options.push({\n                    label: m['name'],\n                    value: m['name'],\n                })\n            }\n\n            // select default names\n            if (!isEmpty(options)) {\n                generalForm.value.sentinel.master = options[0].value\n            }\n            masterNameOptions.value = options\n        }\n    } catch (e) {\n        $message.error(e.message)\n    } finally {\n        loadingSentinelMaster.value = false\n    }\n}\n\nconst tab = ref('general')\nconst testing = ref(false)\nconst testResult = ref(null)\nconst showTestResult = computed(() => {\n    return !testing.value && testResult.value != null\n})\nconst predefineColors = ref(['', '#F75B52', '#F7A234', '#F7CE33', '#4ECF60', '#348CF7', '#B270D3'])\nconst generalFormRef = ref(null)\nconst advanceFormRef = ref(null)\n\nconst onSaveConnection = async () => {\n    // validate general form\n    await generalFormRef.value?.validate((err) => {\n        if (err) {\n            nextTick(() => (tab.value = 'general'))\n        }\n    })\n\n    // validate advance form\n    await advanceFormRef.value?.validate((err) => {\n        if (err) {\n            nextTick(() => (tab.value = 'advanced'))\n        }\n    })\n\n    // trim addr by network type\n    if (get(generalForm.value, 'network', 'tcp') === 'unix') {\n        generalForm.value.network = 'unix'\n        generalForm.value.addr = ''\n        generalForm.value.port = 0\n        generalForm.value.sock = trim(generalForm.value.sock)\n    } else {\n        generalForm.value.network = 'tcp'\n        generalForm.value.sock = ''\n        generalForm.value.addr = trim(generalForm.value.addr)\n    }\n\n    // trim advance data\n    if (get(generalForm.value, 'dbFilterType', 'none') === 'none') {\n        generalForm.value.dbFilterList = []\n    }\n\n    // trim ssl data\n    if (!!!generalForm.value.ssl.enable) {\n        generalForm.value.ssl = {}\n    }\n\n    // trim ssh login data\n    if (!!generalForm.value.ssh.enable) {\n        switch (generalForm.value.ssh.loginType) {\n            case 'pkfile':\n                generalForm.value.ssh.password = ''\n                break\n            case 'agent':\n                generalForm.value.ssh.password = ''\n                generalForm.value.ssh.pkFile = ''\n                generalForm.value.ssh.passphrase = ''\n                break\n            default:\n                generalForm.value.ssh.pkFile = ''\n                generalForm.value.ssh.passphrase = ''\n                break\n        }\n    } else {\n        // ssh disabled, reset to default value\n        generalForm.value.ssh = {}\n    }\n\n    // trim sentinel data\n    if (!!!generalForm.value.sentinel.enable) {\n        generalForm.value.sentinel = {}\n    }\n\n    // trim cluster data\n    if (!!!generalForm.value.cluster.enable) {\n        generalForm.value.cluster = {}\n    }\n\n    // trim proxy data\n    if (generalForm.value.proxy.type !== 2) {\n        generalForm.value.proxy.schema = ''\n        generalForm.value.proxy.addr = ''\n        generalForm.value.proxy.port = 0\n        generalForm.value.proxy.auth = false\n        generalForm.value.proxy.username = ''\n        generalForm.value.proxy.password = ''\n    } else if (!generalForm.value.proxy.auth) {\n        generalForm.value.proxy.username = ''\n        generalForm.value.proxy.password = ''\n    }\n\n    // store new connection\n    const { success, msg } = await connectionStore.saveConnection(\n        isEditMode.value ? editName.value : null,\n        generalForm.value,\n    )\n    if (!success) {\n        $message.error(msg)\n        return\n    }\n\n    $message.success(i18n.t('dialogue.handle_succ'))\n    onClose()\n}\n\nconst resetForm = () => {\n    generalForm.value = connectionStore.newDefaultConnection()\n    generalFormRef.value?.restoreValidation()\n    testing.value = false\n    testResult.value = null\n    tab.value = 'general'\n    loadingSentinelMaster.value = false\n}\n\nwatch(\n    () => dialogStore.connDialogVisible,\n    (visible) => {\n        if (visible) {\n            resetForm()\n            editName.value = get(dialogStore.connParam, 'name', '')\n            generalForm.value = dialogStore.connParam || connectionStore.newDefaultConnection()\n            dbFilterList.value = map(generalForm.value.dbFilterList, (item) => item + '')\n            generalForm.value.ssh.loginType = generalForm.value.ssh.loginType || 'pwd'\n            // update alias display\n            const alias = get(generalForm.value, 'alias', {})\n            const pairs = []\n            for (const db in alias) {\n                pairs.push({ db: parseInt(db), alias: alias[db] })\n            }\n            aliasPair.value = pairs\n            generalForm.value.proxy.auth = !isEmpty(generalForm.value.proxy.username)\n        }\n    },\n)\n\nconst onTestConnection = async () => {\n    testResult.value = ''\n    testing.value = true\n    let result = ''\n    try {\n        const { success = false, msg } = await TestConnection(generalForm.value)\n        if (!success) {\n            result = msg\n        }\n    } catch (e) {\n        result = e.message\n    } finally {\n        testing.value = false\n    }\n\n    if (!isEmpty(result)) {\n        testResult.value = result\n    } else {\n        testResult.value = ''\n    }\n}\n\nconst onClose = () => {\n    dialogStore.closeConnDialog()\n}\n\nconst pasteFromClipboard = async () => {\n    // url example:\n    // rediss://user:password@localhost:6789/3?dial_timeout=3&db=1&read_timeout=6s&max_retries=2\n    let opt = {}\n    try {\n        opt = await connectionStore.parseUrlFromClipboard()\n    } catch (e) {\n        $message.error(i18n.t('dialogue.connection.parse_fail', { reason: e.message }))\n        return\n    }\n    generalForm.value.network = opt.network || 'tcp'\n    generalForm.value.name = generalForm.value.addr = opt.addr\n    generalForm.value.port = opt.port\n    generalForm.value.username = opt.username\n    generalForm.value.password = opt.password\n    if (opt.connTimeout > 0) {\n        generalForm.value.connTimeout = opt.connTimeout\n    }\n    if (opt.execTimeout > 0) {\n        generalForm.value.execTimeout = opt.execTimeout\n    }\n    const { sslServerName = null } = opt\n    if (sslServerName != null) {\n        generalForm.value.ssl.enable = true\n        if (!isEmpty(sslServerName)) {\n            generalForm.value.ssl.sni = sslServerName\n        }\n    }\n    $message.success(i18n.t('dialogue.connection.parse_pass', { url: opt.url }))\n}\n</script>\n\n<template>\n    <n-modal\n        v-model:show=\"dialogStore.connDialogVisible\"\n        :closable=\"false\"\n        :mask-closable=\"false\"\n        :on-after-leave=\"resetForm\"\n        :show-icon=\"false\"\n        :title=\"isEditMode ? $t('dialogue.connection.edit_title') : $t('dialogue.connection.new_title')\"\n        close-on-esc\n        preset=\"dialog\"\n        style=\"width: 600px\"\n        transform-origin=\"center\"\n        @esc=\"onClose\">\n        <n-spin :show=\"closingConnection\">\n            <n-tabs\n                v-model:value=\"tab\"\n                animated\n                pane-style=\"min-height: 50vh;\"\n                placement=\"left\"\n                tab-style=\"justify-content: right; font-weight: 420;\"\n                type=\"line\">\n                <!-- General pane -->\n                <n-tab-pane :tab=\"$t('dialogue.connection.general')\" display-directive=\"show:lazy\" name=\"general\">\n                    <n-form\n                        ref=\"generalFormRef\"\n                        :model=\"generalForm\"\n                        :rules=\"generalFormRules()\"\n                        :show-require-mark=\"false\"\n                        label-placement=\"top\">\n                        <n-grid :x-gap=\"10\">\n                            <n-form-item-gi\n                                :label=\"$t('dialogue.connection.conn_name')\"\n                                :span=\"24\"\n                                path=\"name\"\n                                required>\n                                <n-input\n                                    v-model:value=\"generalForm.name\"\n                                    :placeholder=\"$t('dialogue.connection.name_tip')\" />\n                            </n-form-item-gi>\n                            <n-form-item-gi\n                                v-if=\"!isEditMode\"\n                                :label=\"$t('dialogue.connection.group')\"\n                                :span=\"24\"\n                                required>\n                                <n-select\n                                    v-model:value=\"generalForm.group\"\n                                    :options=\"groupOptions\"\n                                    :render-label=\"({ label, value }) => (value === '' ? $t(label) : label)\" />\n                            </n-form-item-gi>\n                            <n-form-item-gi :label=\"$t('dialogue.connection.addr')\" :span=\"24\" path=\"addr\" required>\n                                <n-input-group>\n                                    <n-select\n                                        v-model:value=\"generalForm.network\"\n                                        :options=\"[\n                                            { value: 'tcp', label: 'TCP' },\n                                            { value: 'unix', label: 'UNIX' },\n                                        ]\"\n                                        style=\"max-width: 100px\" />\n                                    <template v-if=\"generalForm.network === 'unix'\">\n                                        <n-input\n                                            v-model:value=\"generalForm.sock\"\n                                            :placeholder=\"$t('dialogue.connection.sock_tip')\" />\n                                    </template>\n                                    <template v-else>\n                                        <n-input\n                                            v-model:value=\"generalForm.addr\"\n                                            :placeholder=\"$t('dialogue.connection.addr_tip')\" />\n                                        <n-text style=\"width: 40px; text-align: center\">:</n-text>\n                                        <n-input-number\n                                            v-model:value=\"generalForm.port\"\n                                            :max=\"65535\"\n                                            :min=\"1\"\n                                            :show-button=\"false\"\n                                            placeholder=\"6379\"\n                                            style=\"width: 200px\" />\n                                    </template>\n                                </n-input-group>\n                            </n-form-item-gi>\n                            <n-form-item-gi :label=\"$t('dialogue.connection.pwd')\" :span=\"12\" path=\"password\">\n                                <n-input\n                                    v-model:value=\"generalForm.password\"\n                                    :placeholder=\"$t('dialogue.connection.pwd_tip')\"\n                                    show-password-on=\"click\"\n                                    type=\"password\" />\n                            </n-form-item-gi>\n                            <n-form-item-gi :label=\"$t('dialogue.connection.usr')\" :span=\"12\" path=\"username\">\n                                <n-input\n                                    v-model:value=\"generalForm.username\"\n                                    :placeholder=\"$t('dialogue.connection.usr_tip')\" />\n                            </n-form-item-gi>\n                        </n-grid>\n                    </n-form>\n                </n-tab-pane>\n\n                <!-- Advance pane -->\n                <n-tab-pane :tab=\"$t('dialogue.connection.advn.title')\" display-directive=\"show\" name=\"advanced\">\n                    <n-form\n                        ref=\"advanceFormRef\"\n                        :model=\"generalForm\"\n                        :rules=\"generalFormRules()\"\n                        :show-require-mark=\"false\"\n                        label-placement=\"top\">\n                        <n-grid :x-gap=\"10\">\n                            <n-form-item-gi\n                                :label=\"$t('dialogue.connection.advn.filter')\"\n                                :span=\"12\"\n                                path=\"defaultFilter\">\n                                <n-input\n                                    v-model:value=\"generalForm.defaultFilter\"\n                                    :placeholder=\"$t('dialogue.connection.advn.filter_tip')\" />\n                            </n-form-item-gi>\n                            <n-form-item-gi\n                                :label=\"$t('dialogue.connection.advn.separator')\"\n                                :span=\"12\"\n                                path=\"keySeparator\">\n                                <n-input\n                                    v-model:value=\"generalForm.keySeparator\"\n                                    :placeholder=\"$t('dialogue.connection.advn.separator_tip')\" />\n                            </n-form-item-gi>\n                            <n-form-item-gi\n                                :label=\"$t('dialogue.connection.advn.conn_timeout')\"\n                                :span=\"12\"\n                                path=\"connTimeout\">\n                                <n-input-number\n                                    v-model:value=\"generalForm.connTimeout\"\n                                    :max=\"999999\"\n                                    :min=\"1\"\n                                    :show-button=\"false\"\n                                    style=\"width: 100%\">\n                                    <template #suffix>\n                                        {{ $t('common.second') }}\n                                    </template>\n                                </n-input-number>\n                            </n-form-item-gi>\n                            <n-form-item-gi\n                                :label=\"$t('dialogue.connection.advn.exec_timeout')\"\n                                :span=\"12\"\n                                path=\"execTimeout\">\n                                <n-input-number\n                                    v-model:value=\"generalForm.execTimeout\"\n                                    :max=\"999999\"\n                                    :min=\"1\"\n                                    :show-button=\"false\"\n                                    style=\"width: 100%\">\n                                    <template #suffix>\n                                        {{ $t('common.second') }}\n                                    </template>\n                                </n-input-number>\n                            </n-form-item-gi>\n                            <n-form-item-gi :label=\"$t('dialogue.connection.advn.key_view')\" :span=\"12\">\n                                <n-radio-group v-model:value=\"generalForm.keyView\">\n                                    <n-radio-button\n                                        :label=\"$t('dialogue.connection.advn.key_view_tree')\"\n                                        :value=\"KeyViewType.Tree\" />\n                                    <n-radio-button\n                                        :label=\"$t('dialogue.connection.advn.key_view_list')\"\n                                        :value=\"KeyViewType.List\" />\n                                </n-radio-group>\n                            </n-form-item-gi>\n                            <n-form-item-gi :label=\"$t('dialogue.connection.advn.load_size')\" :span=\"12\">\n                                <n-input-number\n                                    v-model:value=\"generalForm.loadSize\"\n                                    :min=\"0\"\n                                    :show-button=\"false\"\n                                    style=\"width: 100%\" />\n                            </n-form-item-gi>\n                            <n-form-item-gi :label=\"$t('dialogue.connection.advn.dbfilter_type')\" :span=\"24\">\n                                <n-radio-group\n                                    v-model:value=\"generalForm.dbFilterType\"\n                                    @update:value=\"onUpdateDBFilterType\">\n                                    <n-radio-button :label=\"$t('dialogue.connection.advn.dbfilter_all')\" value=\"none\" />\n                                    <n-radio-button\n                                        :label=\"$t('dialogue.connection.advn.dbfilter_show')\"\n                                        value=\"show\" />\n                                    <n-radio-button\n                                        :label=\"$t('dialogue.connection.advn.dbfilter_hide')\"\n                                        value=\"hide\" />\n                                </n-radio-group>\n                            </n-form-item-gi>\n                            <n-form-item-gi\n                                v-if=\"generalForm.dbFilterType !== 'none'\"\n                                :label=\"$t('dialogue.connection.advn.dbfilter_input')\"\n                                :span=\"24\">\n                                <n-select\n                                    v-model:value=\"dbFilterList\"\n                                    :clearable=\"true\"\n                                    :disabled=\"generalForm.dbFilterType === 'none'\"\n                                    :placeholder=\"$t('dialogue.connection.advn.dbfilter_input_tip')\"\n                                    :show=\"false\"\n                                    :show-arrow=\"false\"\n                                    filterable\n                                    multiple\n                                    tag />\n                            </n-form-item-gi>\n                            <n-form-item-gi\n                                :label=\"$t('dialogue.connection.advn.mark_color')\"\n                                :span=\"24\"\n                                path=\"markColor\">\n                                <div\n                                    v-for=\"color in predefineColors\"\n                                    :key=\"color\"\n                                    :style=\"{\n                                        backgroundColor: color,\n                                        borderColor:\n                                            generalForm.markColor === color\n                                                ? themeVars.textColorBase\n                                                : themeVars.borderColor,\n                                    }\"\n                                    class=\"color-preset-item\"\n                                    @click=\"generalForm.markColor = color\">\n                                    <n-icon v-if=\"isEmpty(color)\" :component=\"Close\" size=\"24\" />\n                                </div>\n                            </n-form-item-gi>\n                        </n-grid>\n                    </n-form>\n                </n-tab-pane>\n\n                <!-- Alias pane -->\n                <n-tab-pane :tab=\"$t('dialogue.connection.alias.title')\" display-directive=\"show:lazy\" name=\"alias\">\n                    <n-form\n                        :model=\"generalForm.alias\"\n                        :show-label=\"false\"\n                        :show-require-mark=\"false\"\n                        label-placement=\"top\">\n                        <n-form-item required>\n                            <n-dynamic-input\n                                v-model:value=\"aliasPair\"\n                                @create=\"onCreateAlias\"\n                                @update:value=\"onUpdateAlias\">\n                                <template #default=\"{ value }\">\n                                    <n-input-number\n                                        v-model:value=\"value.db\"\n                                        :min=\"0\"\n                                        :placeholder=\"$t('dialogue.connection.alias.db')\"\n                                        :show-button=\"false\"\n                                        @update:value=\"onUpdateAlias\" />\n                                    <n-text>:</n-text>\n                                    <n-input\n                                        v-model:value=\"value.alias\"\n                                        :placeholder=\"$t('dialogue.connection.alias.value')\"\n                                        type=\"text\"\n                                        @update:value=\"onUpdateAlias\" />\n                                </template>\n                                <template #action=\"{ index, create, remove, move }\">\n                                    <icon-button :icon=\"Delete\" size=\"18\" @click=\"() => remove(index)\" />\n                                    <icon-button :icon=\"Add\" size=\"18\" @click=\"() => create(index)\" />\n                                </template>\n                            </n-dynamic-input>\n                        </n-form-item>\n                    </n-form>\n                </n-tab-pane>\n\n                <!-- SSL pane -->\n                <n-tab-pane :tab=\"$t('dialogue.connection.ssl.title')\" display-directive=\"show:lazy\" name=\"ssl\">\n                    <n-form-item label-placement=\"left\">\n                        <n-checkbox v-model:checked=\"generalForm.ssl.enable\" size=\"medium\">\n                            {{ $t('dialogue.connection.ssl.enable') }}\n                        </n-checkbox>\n                    </n-form-item>\n                    <n-form\n                        :disabled=\"!generalForm.ssl.enable\"\n                        :model=\"generalForm.ssl\"\n                        :show-require-mark=\"false\"\n                        label-placement=\"top\">\n                        <n-form-item :label=\"$t('dialogue.connection.ssl.cert_file')\">\n                            <file-open-input\n                                v-model:value=\"generalForm.ssl.certFile\"\n                                :disabled=\"!generalForm.ssl.enable\"\n                                :placeholder=\"$t('dialogue.connection.ssl.cert_file_tip')\" />\n                        </n-form-item>\n                        <n-form-item :label=\"$t('dialogue.connection.ssl.key_file')\">\n                            <file-open-input\n                                v-model:value=\"generalForm.ssl.keyFile\"\n                                :disabled=\"!generalForm.ssl.enable\"\n                                :placeholder=\"$t('dialogue.connection.ssl.key_file_tip')\" />\n                        </n-form-item>\n                        <n-form-item :label=\"$t('dialogue.connection.ssl.ca_file')\" :show-feedback=\"false\">\n                            <file-open-input\n                                v-model:value=\"generalForm.ssl.caFile\"\n                                :disabled=\"!generalForm.ssl.enable\"\n                                :placeholder=\"$t('dialogue.connection.ssl.ca_file_tip')\" />\n                        </n-form-item>\n                        <n-form-item>\n                            <n-checkbox v-model:checked=\"generalForm.ssl.allowInsecure\" size=\"medium\">\n                                {{ $t('dialogue.connection.ssl.allow_insecure') }}\n                            </n-checkbox>\n                        </n-form-item>\n                        <n-form-item :label=\"$t('dialogue.connection.ssl.sni')\">\n                            <n-input\n                                v-model:value=\"generalForm.ssl.sni\"\n                                :placeholder=\"$t('dialogue.connection.ssl.sni')\" />\n                        </n-form-item>\n                    </n-form>\n                </n-tab-pane>\n\n                <!-- SSH pane -->\n                <n-tab-pane :tab=\"$t('dialogue.connection.ssh.title')\" display-directive=\"show:lazy\" name=\"ssh\">\n                    <n-form-item label-placement=\"left\">\n                        <n-checkbox v-model:checked=\"generalForm.ssh.enable\" size=\"medium\">\n                            {{ $t('dialogue.connection.ssh.enable') }}\n                        </n-checkbox>\n                    </n-form-item>\n                    <n-form\n                        :disabled=\"!generalForm.ssh.enable\"\n                        :model=\"generalForm.ssh\"\n                        :show-require-mark=\"false\"\n                        label-placement=\"top\">\n                        <n-form-item :label=\"$t('dialogue.connection.addr')\" required>\n                            <n-input\n                                v-model:value=\"generalForm.ssh.addr\"\n                                :placeholder=\"$t('dialogue.connection.ssh.addr_tip')\" />\n                            <n-text style=\"width: 40px; text-align: center\">:</n-text>\n                            <n-input-number\n                                v-model:value=\"generalForm.ssh.port\"\n                                :max=\"65535\"\n                                :min=\"1\"\n                                :show-button=\"false\"\n                                style=\"width: 200px\" />\n                        </n-form-item>\n                        <n-form-item :label=\"$t('dialogue.connection.ssh.login_type')\">\n                            <n-radio-group v-model:value=\"generalForm.ssh.loginType\">\n                                <n-radio-button :label=\"$t('dialogue.connection.pwd')\" value=\"pwd\" />\n                                <n-radio-button :label=\"$t('dialogue.connection.ssh.pkfile')\" value=\"pkfile\" />\n                                <n-radio-button :label=\"$t('dialogue.connection.ssh.agent')\" value=\"agent\" />\n                            </n-radio-group>\n                        </n-form-item>\n                        <n-form-item\n                            v-if=\"sshLoginType === 'pwd' || sshLoginType === 'pkfile' || sshLoginType === 'agent'\"\n                            :label=\"$t('dialogue.connection.usr')\">\n                            <n-input\n                                v-model:value=\"generalForm.ssh.username\"\n                                :placeholder=\"$t('dialogue.connection.ssh.usr_tip')\" />\n                        </n-form-item>\n                        <n-form-item v-if=\"sshLoginType === 'pwd'\" :label=\"$t('dialogue.connection.pwd')\">\n                            <n-input\n                                v-model:value=\"generalForm.ssh.password\"\n                                :placeholder=\"$t('dialogue.connection.ssh.pwd_tip')\"\n                                show-password-on=\"click\"\n                                type=\"password\" />\n                        </n-form-item>\n                        <n-form-item v-if=\"sshLoginType === 'pkfile'\" :label=\"$t('dialogue.connection.ssh.pkfile')\">\n                            <file-open-input\n                                v-model:value=\"generalForm.ssh.pkFile\"\n                                :disabled=\"!generalForm.ssh.enable\"\n                                :placeholder=\"$t('dialogue.connection.ssh.pkfile_tip')\" />\n                        </n-form-item>\n                        <n-form-item v-if=\"sshLoginType === 'pkfile'\" :label=\"$t('dialogue.connection.ssh.passphrase')\">\n                            <n-input\n                                v-model:value=\"generalForm.ssh.passphrase\"\n                                :placeholder=\"$t('dialogue.connection.ssh.passphrase_tip')\"\n                                show-password-on=\"click\"\n                                type=\"password\" />\n                        </n-form-item>\n                    </n-form>\n                </n-tab-pane>\n\n                <!-- Sentinel pane -->\n                <n-tab-pane\n                    :tab=\"$t('dialogue.connection.sentinel.title')\"\n                    display-directive=\"show:lazy\"\n                    name=\"sentinel\">\n                    <n-form-item label-placement=\"left\">\n                        <n-checkbox v-model:checked=\"generalForm.sentinel.enable\" size=\"medium\">\n                            {{ $t('dialogue.connection.sentinel.enable') }}\n                        </n-checkbox>\n                    </n-form-item>\n                    <n-form\n                        :disabled=\"!generalForm.sentinel.enable\"\n                        :model=\"generalForm.sentinel\"\n                        :show-require-mark=\"false\"\n                        label-placement=\"top\">\n                        <n-form-item :label=\"$t('dialogue.connection.sentinel.master')\">\n                            <n-input-group>\n                                <n-select\n                                    v-model:value=\"generalForm.sentinel.master\"\n                                    :options=\"masterNameOptions\"\n                                    filterable\n                                    tag />\n                                <n-button\n                                    :disabled=\"!generalForm.sentinel.enable\"\n                                    :loading=\"loadingSentinelMaster\"\n                                    @click=\"onLoadSentinelMasters\">\n                                    {{ $t('dialogue.connection.sentinel.auto_discover') }}\n                                </n-button>\n                            </n-input-group>\n                        </n-form-item>\n                        <n-form-item :label=\"$t('dialogue.connection.sentinel.password')\">\n                            <n-input\n                                v-model:value=\"generalForm.sentinel.password\"\n                                :placeholder=\"$t('dialogue.connection.sentinel.pwd_tip')\"\n                                show-password-on=\"click\"\n                                type=\"password\" />\n                        </n-form-item>\n                        <n-form-item :label=\"$t('dialogue.connection.sentinel.username')\">\n                            <n-input\n                                v-model:value=\"generalForm.sentinel.username\"\n                                :placeholder=\"$t('dialogue.connection.sentinel.usr_tip')\" />\n                        </n-form-item>\n                    </n-form>\n                </n-tab-pane>\n\n                <!-- Cluster pane -->\n                <n-tab-pane :tab=\"$t('dialogue.connection.cluster.title')\" display-directive=\"show:lazy\" name=\"cluster\">\n                    <n-form-item label-placement=\"left\">\n                        <n-checkbox v-model:checked=\"generalForm.cluster.enable\" size=\"medium\">\n                            {{ $t('dialogue.connection.cluster.enable') }}\n                        </n-checkbox>\n                    </n-form-item>\n                    <!--                    <n-form-->\n                    <!--                        :model=\"generalForm.cluster\"-->\n                    <!--                        :show-require-mark=\"false\"-->\n                    <!--                        :disabled=\"!generalForm.cluster.enable\"-->\n                    <!--                        label-placement=\"top\">-->\n                    <!--                    </n-form>-->\n                </n-tab-pane>\n\n                <!-- Proxy pane -->\n                <n-tab-pane :tab=\"$t('dialogue.connection.proxy.title')\" display-directive=\"show:lazy\" name=\"proxy\">\n                    <n-radio-group v-model:value=\"generalForm.proxy.type\" name=\"radiogroup\">\n                        <n-space size=\"large\" vertical>\n                            <n-radio :label=\"$t('dialogue.connection.proxy.type_none')\" :value=\"0\" />\n                            <n-radio :label=\"$t('dialogue.connection.proxy.type_system')\" :value=\"1\" />\n                            <n-radio :label=\"$t('dialogue.connection.proxy.type_custom')\" :value=\"2\" />\n                            <n-form\n                                :disabled=\"generalForm.proxy.type !== 2\"\n                                :model=\"generalForm.proxy\"\n                                :show-require-mark=\"false\"\n                                label-placement=\"top\">\n                                <n-grid :x-gap=\"10\">\n                                    <n-form-item-gi :show-label=\"false\" :span=\"24\" path=\"addr\" required>\n                                        <n-input-group>\n                                            <n-select\n                                                v-model:value=\"generalForm.proxy.schema\"\n                                                :consistent-menu-width=\"false\"\n                                                :options=\"[\n                                                    { value: 'http', label: 'HTTP' },\n                                                    { value: 'https', label: 'HTTPS' },\n                                                    { value: 'socks5', label: 'SOCKS5' },\n                                                    { value: 'socks5h', label: 'SOCKS5H' },\n                                                ]\"\n                                                default-value=\"http\"\n                                                style=\"max-width: 100px\" />\n                                            <n-input\n                                                v-model:value=\"generalForm.proxy.addr\"\n                                                :placeholder=\"$t('dialogue.connection.proxy.host')\" />\n                                            <n-text style=\"width: 40px; text-align: center\">:</n-text>\n                                            <n-input-number\n                                                v-model:value=\"generalForm.proxy.port\"\n                                                :max=\"65535\"\n                                                :min=\"0\"\n                                                :show-button=\"false\"\n                                                style=\"width: 200px\" />\n                                        </n-input-group>\n                                    </n-form-item-gi>\n                                    <n-form-item-gi :show-label=\"false\" :span=\"24\" path=\"auth\">\n                                        <n-checkbox v-model:checked=\"generalForm.proxy.auth\" size=\"medium\">\n                                            {{ $t('dialogue.connection.proxy.auth') }}\n                                        </n-checkbox>\n                                    </n-form-item-gi>\n                                    <n-form-item-gi :label=\"$t('dialogue.connection.usr')\" :span=\"12\" path=\"username\">\n                                        <n-input\n                                            v-model:value=\"generalForm.proxy.username\"\n                                            :disabled=\"!!!generalForm.proxy.auth\"\n                                            :placeholder=\"$t('dialogue.connection.proxy.usr_tip')\" />\n                                    </n-form-item-gi>\n                                    <n-form-item-gi :label=\"$t('dialogue.connection.pwd')\" :span=\"12\" path=\"password\">\n                                        <n-input\n                                            v-model:value=\"generalForm.proxy.password\"\n                                            :disabled=\"!!!generalForm.proxy.auth\"\n                                            :placeholder=\"$t('dialogue.connection.proxy.pwd_tip')\"\n                                            show-password-on=\"click\"\n                                            type=\"password\" />\n                                    </n-form-item-gi>\n                                </n-grid>\n                            </n-form>\n                        </n-space>\n                    </n-radio-group>\n                </n-tab-pane>\n            </n-tabs>\n\n            <!-- test result alert-->\n            <n-alert\n                v-if=\"showTestResult\"\n                :on-close=\"() => (testResult = '')\"\n                :title=\"isEmpty(testResult) ? '' : $t('dialogue.connection.test_fail')\"\n                :type=\"isEmpty(testResult) ? 'success' : 'error'\"\n                closable>\n                <template v-if=\"isEmpty(testResult)\">{{ $t('dialogue.connection.test_succ') }}</template>\n                <template v-else>{{ testResult }}</template>\n            </n-alert>\n        </n-spin>\n\n        <template #action>\n            <div class=\"flex-item-expand\">\n                <n-button :disabled=\"closingConnection\" :focusable=\"false\" :loading=\"testing\" @click=\"onTestConnection\">\n                    {{ $t('dialogue.connection.test') }}\n                </n-button>\n            </div>\n            <div class=\"flex-item n-dialog__action\">\n                <n-button :disabled=\"closingConnection\" :focusable=\"false\" @click=\"pasteFromClipboard\">\n                    {{ $t('dialogue.connection.parse_url_clipboard') }}\n                </n-button>\n                <n-button :disabled=\"closingConnection\" :focusable=\"false\" @click=\"onClose\">\n                    {{ $t('common.cancel') }}\n                </n-button>\n                <n-button :disabled=\"closingConnection\" :focusable=\"false\" type=\"primary\" @click=\"onSaveConnection\">\n                    {{ isEditMode ? $t('preferences.general.update') : $t('common.confirm') }}\n                </n-button>\n            </div>\n        </template>\n    </n-modal>\n</template>\n\n<style lang=\"scss\" scoped>\n.color-preset-item {\n    width: 24px;\n    height: 24px;\n    margin-right: 2px;\n    border-width: 3px;\n    border-style: solid;\n    cursor: pointer;\n    border-radius: 50%;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/dialogs/DecoderDialog.vue",
    "content": "<script setup>\nimport useDialog from 'stores/dialog.js'\nimport { computed, reactive, ref, toRaw, watch } from 'vue'\nimport FileOpenInput from '@/components/common/FileOpenInput.vue'\nimport Delete from '@/components/icons/Delete.vue'\nimport Add from '@/components/icons/Add.vue'\nimport IconButton from '@/components/common/IconButton.vue'\nimport { cloneDeep, get, isEmpty } from 'lodash'\nimport usePreferencesStore from 'stores/preferences.js'\nimport { joinCommand } from '@/utils/decoder_cmd.js'\nimport Help from '@/components/icons/Help.vue'\n\nconst editName = ref('')\nconst decoderForm = reactive({\n    name: '',\n    auto: true,\n    decodePath: '',\n    decodeArgs: [],\n    encodePath: '',\n    encodeArgs: [],\n})\n\nconst dialogStore = useDialog()\nconst prefStore = usePreferencesStore()\n\nwatch(\n    () => dialogStore.decodeDialogVisible,\n    (visible) => {\n        if (visible) {\n            const name = get(dialogStore.decodeParam, 'name', '')\n            if (!isEmpty(name)) {\n                editName.value = decoderForm.name = name\n                decoderForm.auto = dialogStore.decodeParam.auto !== false\n                decoderForm.decodePath = get(dialogStore.decodeParam, 'decodePath', '')\n                decoderForm.decodeArgs = get(dialogStore.decodeParam, 'decodeArgs', [])\n                decoderForm.encodePath = get(dialogStore.decodeParam, 'encodePath', '')\n                decoderForm.encodeArgs = get(dialogStore.decodeParam, 'encodeArgs', [])\n            } else {\n                editName.value = ''\n                decoderForm.decodePath = ''\n                decoderForm.encodePath = ''\n                decoderForm.decodeArgs = []\n                decoderForm.encodeArgs = []\n            }\n        } else {\n            editName.value = ''\n        }\n    },\n)\n\nconst decodeCmdPreview = computed(() => {\n    return joinCommand(decoderForm.decodePath, decoderForm.decodeArgs, '')\n})\n\nconst encodeCmdPreview = computed(() => {\n    return joinCommand(decoderForm.encodePath, decoderForm.encodeArgs, '')\n})\n\nconst onAddOrUpdate = () => {\n    if (isEmpty(editName.value)) {\n        // add decoder\n        prefStore.addCustomDecoder(toRaw(decoderForm))\n    } else {\n        // update decoder\n        const param = cloneDeep(toRaw(decoderForm))\n        param.newName = param.name\n        param.name = editName.value\n        prefStore.updateCustomDecoder(param)\n    }\n}\nconst onClose = () => {}\n</script>\n\n<template>\n    <n-modal\n        v-model:show=\"dialogStore.decodeDialogVisible\"\n        :closable=\"false\"\n        :mask-closable=\"false\"\n        :negative-button-props=\"{ focusable: false, size: 'medium' }\"\n        :negative-text=\"$t('common.cancel')\"\n        :positive-button-props=\"{ focusable: false, size: 'medium' }\"\n        :positive-text=\"$t('common.confirm')\"\n        :show-icon=\"false\"\n        :title=\"editName ? $t('dialogue.decoder.edit_name') : $t('dialogue.decoder.name')\"\n        close-on-esc\n        preset=\"dialog\"\n        transform-origin=\"center\"\n        @esc=\"onClose\"\n        @positive-click=\"onAddOrUpdate\"\n        @negative-click=\"onClose\">\n        <n-form :model=\"decoderForm\" :show-require-mark=\"false\" label-align=\"left\" label-placement=\"top\">\n            <n-form-item :label=\"$t('dialogue.decoder.decoder_name')\" required show-require-mark>\n                <n-input v-model:value=\"decoderForm.name\" />\n            </n-form-item>\n            <n-tabs type=\"line\">\n                <!-- decode pane -->\n                <n-tab-pane :tab=\"$t('dialogue.decoder.decoder')\" name=\"decode\">\n                    <n-form-item required show-require-mark>\n                        <template #label>\n                            <n-space :size=\"5\" :wrap-item=\"false\" align=\"center\" justify=\"center\">\n                                <span>{{ $t('dialogue.decoder.decode_path') }}</span>\n                                <n-tooltip trigger=\"hover\">\n                                    <template #trigger>\n                                        <n-icon :component=\"Help\" />\n                                    </template>\n                                    <div class=\"text-block\" style=\"max-width: 600px\">\n                                        {{ $t('dialogue.decoder.path_help') }}\n                                    </div>\n                                </n-tooltip>\n                            </n-space>\n                        </template>\n                        <file-open-input\n                            v-model:value=\"decoderForm.decodePath\"\n                            :placeholder=\"$t('dialogue.decoder.decode_path')\" />\n                    </n-form-item>\n                    <n-form-item required>\n                        <template #label>\n                            <n-space :size=\"5\" :wrap-item=\"false\" align=\"center\" justify=\"center\">\n                                <span>{{ $t('dialogue.decoder.args') }}</span>\n                                <n-tooltip trigger=\"hover\">\n                                    <template #trigger>\n                                        <n-icon :component=\"Help\" />\n                                    </template>\n                                    <div class=\"text-block\" style=\"max-width: 600px\">\n                                        {{ $t('dialogue.decoder.args_help').replace('[', '{').replace(']', '}') }}\n                                    </div>\n                                </n-tooltip>\n                            </n-space>\n                        </template>\n                        <n-dynamic-input v-model:value=\"decoderForm.decodeArgs\" @create=\"() => ''\">\n                            <template #action=\"{ index, create, remove, move }\">\n                                <icon-button :icon=\"Add\" size=\"18\" @click=\"() => create(index)\" />\n                                <icon-button :icon=\"Delete\" size=\"18\" @click=\"() => remove(index)\" />\n                            </template>\n                        </n-dynamic-input>\n                    </n-form-item>\n                    <n-card\n                        v-if=\"decodeCmdPreview\"\n                        content-class=\"cmd-line\"\n                        content-style=\"padding: 10px;\"\n                        embedded\n                        size=\"small\">\n                        {{ decodeCmdPreview }}\n                    </n-card>\n                </n-tab-pane>\n\n                <!-- encode pane -->\n                <n-tab-pane :tab=\"$t('dialogue.decoder.encoder')\" name=\"encode\">\n                    <n-form-item required show-require-mark>\n                        <template #label>\n                            <n-space :size=\"5\" :wrap-item=\"false\" align=\"center\" justify=\"center\">\n                                <span>{{ $t('dialogue.decoder.encode_path') }}</span>\n                                <n-tooltip trigger=\"hover\">\n                                    <template #trigger>\n                                        <n-icon :component=\"Help\" />\n                                    </template>\n                                    <div class=\"text-block\" style=\"max-width: 600px\">\n                                        {{ $t('dialogue.decoder.path_help') }}\n                                    </div>\n                                </n-tooltip>\n                            </n-space>\n                        </template>\n                        <file-open-input\n                            v-model:value=\"decoderForm.encodePath\"\n                            :placeholder=\"$t('dialogue.decoder.encode_path')\" />\n                    </n-form-item>\n                    <n-form-item :label=\"$t('dialogue.decoder.args')\" required>\n                        <template #label>\n                            <n-space :size=\"5\" :wrap-item=\"false\" align=\"center\" justify=\"center\">\n                                <span>{{ $t('dialogue.decoder.args') }}</span>\n                                <n-tooltip trigger=\"hover\">\n                                    <template #trigger>\n                                        <n-icon :component=\"Help\" />\n                                    </template>\n                                    <div class=\"text-block\" style=\"max-width: 600px\">\n                                        {{ $t('dialogue.decoder.args_help').replace('[', '{').replace(']', '}') }}\n                                    </div>\n                                </n-tooltip>\n                            </n-space>\n                        </template>\n                        <n-dynamic-input v-model:value=\"decoderForm.encodeArgs\" @create=\"() => ''\">\n                            <template #action=\"{ index, create, remove, move }\">\n                                <icon-button :icon=\"Add\" size=\"18\" @click=\"() => create(index)\" />\n                                <icon-button :icon=\"Delete\" size=\"18\" @click=\"() => remove(index)\" />\n                            </template>\n                        </n-dynamic-input>\n                    </n-form-item>\n                    <n-card\n                        v-if=\"encodeCmdPreview\"\n                        content-class=\"cmd-line\"\n                        content-style=\"padding: 10px;\"\n                        embedded\n                        size=\"small\">\n                        {{ encodeCmdPreview }}\n                    </n-card>\n                </n-tab-pane>\n            </n-tabs>\n            <n-form-item :show-feedback=\"false\">\n                <n-checkbox v-model:checked=\"decoderForm.auto\" :label=\"$t('dialogue.decoder.auto')\" />\n            </n-form-item>\n        </n-form>\n    </n-modal>\n</template>\n\n<style lang=\"scss\" scoped>\n@use '@/styles/content';\n</style>\n"
  },
  {
    "path": "frontend/src/components/dialogs/DeleteKeyDialog.vue",
    "content": "<script setup>\nimport { computed, nextTick, reactive, ref, watchEffect } from 'vue'\nimport useDialog from 'stores/dialog'\nimport { isEmpty, map, size } from 'lodash'\nimport useBrowserStore from 'stores/browser.js'\nimport { decodeRedisKey } from '@/utils/key_convert.js'\n\nconst deleteForm = reactive({\n    server: '',\n    db: 0,\n    key: '',\n    showAffected: false,\n    loadingAffected: false,\n    affectedKeys: [],\n    async: true,\n    direct: false,\n})\n\nconst dialogStore = useDialog()\nconst browserStore = useBrowserStore()\n\nwatchEffect(() => {\n    if (dialogStore.deleteKeyDialogVisible) {\n        const { server, db, key } = dialogStore.deleteKeyParam\n        deleteForm.server = server\n        deleteForm.db = db\n        deleteForm.key = key\n        deleteForm.loadingAffected = false\n        // deleteForm.async = true\n        loading.value = false\n        deleting.value = false\n        if (key instanceof Array) {\n            deleteForm.showAffected = true\n            deleteForm.affectedKeys = key\n        } else {\n            deleteForm.showAffected = false\n            deleteForm.affectedKeys = []\n        }\n    }\n})\n\nconst loading = ref(false)\nconst deleting = ref(false)\nconst scanAffectedKey = async () => {\n    try {\n        loading.value = true\n        deleteForm.loadingAffected = true\n        const { keys = [] } = await browserStore.scanKeys({\n            server: deleteForm.server,\n            db: deleteForm.db,\n            match: deleteForm.key,\n            loadType: 2,\n        })\n        deleteForm.affectedKeys = keys || []\n        deleteForm.showAffected = true\n    } finally {\n        deleteForm.loadingAffected = false\n        loading.value = false\n    }\n}\n\nconst resetAffected = () => {\n    deleteForm.showAffected = false\n    deleteForm.affectedKeys = []\n}\n\nconst keyLines = computed(() => {\n    return map(deleteForm.affectedKeys, (k) => decodeRedisKey(k))\n})\n\nconst onConfirmDelete = async () => {\n    try {\n        deleting.value = true\n        const { server, db, key, affectedKeys } = deleteForm\n        await nextTick()\n        browserStore.deleteKeys(server, db, affectedKeys).catch((e) => {})\n    } catch (e) {\n        $message.error(e.message)\n        return\n    } finally {\n        deleting.value = false\n    }\n    dialogStore.closeDeleteKeyDialog()\n}\n\nconst onConfirmDirectDelete = async () => {\n    try {\n        deleting.value = true\n        const { server, db, key } = deleteForm\n        await nextTick()\n        browserStore.deleteByPattern(server, db, key).catch((e) => {})\n    } catch (e) {\n        $message.error(e.message)\n        return\n    } finally {\n        deleting.value = false\n    }\n    dialogStore.closeDeleteKeyDialog()\n}\n\nconst onClose = () => {\n    dialogStore.closeDeleteKeyDialog()\n}\n</script>\n\n<template>\n    <n-modal\n        v-model:show=\"dialogStore.deleteKeyDialogVisible\"\n        :closable=\"false\"\n        :mask-closable=\"false\"\n        :show-icon=\"false\"\n        :title=\"$t('interface.batch_delete_key')\"\n        close-on-esc\n        preset=\"dialog\"\n        transform-origin=\"center\"\n        @esc=\"onClose\">\n        <n-spin :show=\"loading\">\n            <n-form :model=\"deleteForm\" :show-require-mark=\"false\" label-placement=\"top\">\n                <n-grid :x-gap=\"10\">\n                    <n-form-item-gi :label=\"$t('dialogue.key.server')\" :span=\"12\">\n                        <n-input :autofocus=\"false\" :value=\"deleteForm.server\" readonly />\n                    </n-form-item-gi>\n                    <n-form-item-gi :label=\"$t('dialogue.key.db_index')\" :span=\"12\">\n                        <n-input :autofocus=\"false\" :value=\"deleteForm.db.toString()\" readonly />\n                    </n-form-item-gi>\n                </n-grid>\n                <n-form-item\n                    v-if=\"!(deleteForm.key instanceof Array)\"\n                    :label=\"$t('dialogue.key.key_expression')\"\n                    required>\n                    <n-input v-model:value=\"deleteForm.key\" placeholder=\"\" @input=\"resetAffected\" />\n                </n-form-item>\n                <n-checkbox v-if=\"!deleteForm.showAffected\" v-model:checked=\"deleteForm.direct\">\n                    {{ $t('dialogue.key.direct_delete') }}\n                </n-checkbox>\n                <n-card\n                    v-if=\"deleteForm.showAffected\"\n                    :title=\"$t('dialogue.key.affected_key') + `(${size(deleteForm.affectedKeys)})`\"\n                    embedded\n                    size=\"small\">\n                    <n-skeleton v-if=\"deleteForm.loadingAffected\" :repeat=\"10\" text />\n                    <n-virtual-list v-else :item-size=\"25\" :items=\"keyLines\" class=\"list-wrapper\">\n                        <template #default=\"{ item }\">\n                            <div class=\"line-item content-value\">\n                                {{ item }}\n                            </div>\n                        </template>\n                    </n-virtual-list>\n                </n-card>\n            </n-form>\n        </n-spin>\n\n        <template #action>\n            <div class=\"flex-item n-dialog__action\">\n                <n-button :disabled=\"loading\" :focusable=\"false\" @click=\"onClose\">{{ $t('common.cancel') }}</n-button>\n                <n-button\n                    v-if=\"deleteForm.direct\"\n                    :focusable=\"false\"\n                    :loading=\"loading\"\n                    type=\"primary\"\n                    @click=\"onConfirmDirectDelete\">\n                    {{ $t('dialogue.key.confirm_delete') }}\n                </n-button>\n                <template v-else>\n                    <n-button\n                        v-if=\"!deleteForm.showAffected\"\n                        :focusable=\"false\"\n                        :loading=\"loading\"\n                        type=\"primary\"\n                        @click=\"scanAffectedKey\">\n                        {{ $t('dialogue.key.show_affected_key') }}\n                    </n-button>\n                    <n-button\n                        v-else\n                        :disabled=\"isEmpty(deleteForm.affectedKeys)\"\n                        :focusable=\"false\"\n                        :loading=\"loading\"\n                        type=\"primary\"\n                        @click=\"onConfirmDelete\">\n                        {{ $t('dialogue.key.confirm_delete_key', { num: size(deleteForm.affectedKeys) }) }}\n                    </n-button>\n                </template>\n            </div>\n        </template>\n    </n-modal>\n</template>\n\n<style lang=\"scss\" scoped>\n.line-item {\n    line-height: 1.6;\n}\n\n.list-wrapper {\n    box-sizing: border-box;\n    max-height: 180px;\n    user-select: text;\n    cursor: text;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/dialogs/ExportKeyDialog.vue",
    "content": "<script setup>\nimport { computed, reactive, ref, watchEffect } from 'vue'\nimport useDialog from 'stores/dialog'\nimport useBrowserStore from 'stores/browser.js'\nimport FileSaveInput from '@/components/common/FileSaveInput.vue'\nimport { isEmpty, map, size } from 'lodash'\nimport { decodeRedisKey } from '@/utils/key_convert.js'\nimport dayjs from 'dayjs'\n\nconst exportKeyForm = reactive({\n    server: '',\n    db: 0,\n    expire: false,\n    keys: [],\n    file: '',\n})\n\nconst dialogStore = useDialog()\nconst browserStore = useBrowserStore()\nconst loading = ref(false)\nconst exporting = ref(false)\nwatchEffect(() => {\n    if (dialogStore.exportKeyDialogVisible) {\n        const { server, db, keys } = dialogStore.exportKeyParam\n        exportKeyForm.server = server\n        exportKeyForm.db = db\n        exportKeyForm.ttl = false\n        exportKeyForm.keys = keys\n        exportKeyForm.file = ''\n        exporting.value = false\n    }\n})\n\nconst keyLines = computed(() => {\n    return map(exportKeyForm.keys, (k) => decodeRedisKey(k))\n})\n\nconst exportEnable = computed(() => {\n    return !isEmpty(exportKeyForm.keys) && !isEmpty(exportKeyForm.file)\n})\n\nconst onConfirmExport = async () => {\n    try {\n        exporting.value = true\n        const { server, db, keys, file, expire } = exportKeyForm\n        browserStore.exportKeys(server, db, keys, file, expire).catch((e) => {})\n    } catch (e) {\n        $message.error(e.message)\n        return\n    } finally {\n        exporting.value = false\n    }\n    dialogStore.closeExportKeyDialog()\n}\n\nconst onClose = () => {\n    dialogStore.closeExportKeyDialog()\n}\n</script>\n\n<template>\n    <n-modal\n        v-model:show=\"dialogStore.exportKeyDialogVisible\"\n        :closable=\"false\"\n        :mask-closable=\"false\"\n        :show-icon=\"false\"\n        :title=\"$t('dialogue.export.name')\"\n        close-on-esc\n        preset=\"dialog\"\n        transform-origin=\"center\"\n        @esc=\"onClose\">\n        <n-spin :show=\"loading\">\n            <n-form :model=\"exportKeyForm\" :show-require-mark=\"false\" label-placement=\"top\">\n                <n-grid :x-gap=\"10\">\n                    <n-form-item-gi :label=\"$t('dialogue.key.server')\" :span=\"12\">\n                        <n-input :autofocus=\"false\" :value=\"exportKeyForm.server\" readonly />\n                    </n-form-item-gi>\n                    <n-form-item-gi :label=\"$t('dialogue.key.db_index')\" :span=\"12\">\n                        <n-input :autofocus=\"false\" :value=\"exportKeyForm.db.toString()\" readonly />\n                    </n-form-item-gi>\n                </n-grid>\n                <n-form-item :label=\"$t('dialogue.export.export_expire_title')\">\n                    <n-checkbox v-model:checked=\"exportKeyForm.expire\" :autofocus=\"false\">\n                        {{ $t('dialogue.export.export_expire') }}\n                    </n-checkbox>\n                </n-form-item>\n                <n-form-item :label=\"$t('dialogue.export.save_file')\" required>\n                    <file-save-input\n                        v-model:value=\"exportKeyForm.file\"\n                        :default-path=\"`export_${dayjs().format('YYYYMMDDHHmmss')}.csv`\"\n                        :placeholder=\"$t('dialogue.export.save_file_tip')\" />\n                </n-form-item>\n                <n-card\n                    :title=\"$t('dialogue.key.affected_key') + `(${size(exportKeyForm.keys)})`\"\n                    embedded\n                    size=\"small\">\n                    <n-virtual-list :item-size=\"25\" :items=\"keyLines\" class=\"list-wrapper\">\n                        <template #default=\"{ item }\">\n                            <div class=\"line-item content-value\">\n                                {{ item }}\n                            </div>\n                        </template>\n                    </n-virtual-list>\n                </n-card>\n            </n-form>\n        </n-spin>\n\n        <template #action>\n            <div class=\"flex-item n-dialog__action\">\n                <n-button :disabled=\"loading\" :focusable=\"false\" @click=\"onClose\">\n                    {{ $t('common.cancel') }}\n                </n-button>\n                <n-button\n                    :disabled=\"!exportEnable\"\n                    :focusable=\"false\"\n                    :loading=\"loading\"\n                    type=\"primary\"\n                    @click=\"onConfirmExport\">\n                    {{ $t('dialogue.export.export') }}\n                </n-button>\n            </div>\n        </template>\n    </n-modal>\n</template>\n\n<style lang=\"scss\" scoped>\n.line-item {\n    line-height: 1.6;\n}\n\n.list-wrapper {\n    box-sizing: border-box;\n    max-height: 180px;\n    user-select: text;\n    cursor: text;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/dialogs/FlushDbDialog.vue",
    "content": "<script setup>\nimport { reactive, ref, watchEffect } from 'vue'\nimport useDialog from 'stores/dialog'\nimport { useI18n } from 'vue-i18n'\nimport useBrowserStore from 'stores/browser.js'\n\nconst flushForm = reactive({\n    server: '',\n    db: 0,\n    key: '',\n    async: false,\n    confirm: false,\n})\n\nconst dialogStore = useDialog()\nconst browserStore = useBrowserStore()\n\nwatchEffect(() => {\n    if (dialogStore.flushDBDialogVisible) {\n        const { server, db } = dialogStore.flushDBParam\n        flushForm.server = server\n        flushForm.db = db\n        flushForm.async = true\n        flushForm.confirm = false\n        loading.value = false\n    }\n})\n\nconst loading = ref(false)\nconst i18n = useI18n()\nconst onConfirmFlush = async () => {\n    try {\n        loading.value = true\n        const { server, db, async } = flushForm\n        const success = await browserStore.flushDatabase(server, db, async)\n        if (success) {\n            $message.success(i18n.t('dialogue.handle_succ'))\n        }\n    } catch (e) {\n        $message.error(e.message)\n        return\n    } finally {\n        loading.value = false\n    }\n    dialogStore.closeFlushDBDialog()\n}\n\nconst onClose = () => {\n    dialogStore.closeFlushDBDialog()\n}\n</script>\n\n<template>\n    <n-modal\n        v-model:show=\"dialogStore.flushDBDialogVisible\"\n        :closable=\"false\"\n        :mask-closable=\"false\"\n        :show-icon=\"false\"\n        :title=\"$t('interface.flush_db')\"\n        close-on-esc\n        preset=\"dialog\"\n        transform-origin=\"center\"\n        @esc=\"onClose\">\n        <n-spin :show=\"loading\">\n            <n-form :model=\"flushForm\" :show-require-mark=\"false\" label-placement=\"top\">\n                <n-form-item :label=\"$t('dialogue.key.server')\">\n                    <n-input :value=\"flushForm.server\" readonly />\n                </n-form-item>\n                <n-form-item :label=\"$t('dialogue.key.db_index')\">\n                    <n-input :value=\"flushForm.db.toString()\" readonly />\n                </n-form-item>\n                <n-form-item :label=\"$t('dialogue.key.async_delete')\" required>\n                    <n-checkbox v-model:checked=\"flushForm.async\">\n                        {{ $t('dialogue.key.async_delete_title') }}\n                    </n-checkbox>\n                </n-form-item>\n                <n-form-item :label=\"$t('common.warning')\" required>\n                    <n-checkbox v-model:checked=\"flushForm.confirm\">\n                        <span style=\"color: red; font-weight: bold\">{{ $t('dialogue.key.confirm_flush') }}</span>\n                    </n-checkbox>\n                </n-form-item>\n            </n-form>\n        </n-spin>\n\n        <template #action>\n            <n-button :disabled=\"loading\" :focusable=\"false\" @click=\"onClose\">{{ $t('common.cancel') }}</n-button>\n            <n-button\n                :disabled=\"!!!flushForm.confirm\"\n                :focusable=\"false\"\n                :loading=\"loading\"\n                type=\"primary\"\n                @click=\"onConfirmFlush\">\n                {{ $t('dialogue.key.confirm_flush_db') }}\n            </n-button>\n        </template>\n    </n-modal>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/dialogs/GroupDialog.vue",
    "content": "<script setup>\nimport { computed, reactive, ref, watchEffect } from 'vue'\nimport useDialog from 'stores/dialog'\nimport { useI18n } from 'vue-i18n'\nimport useConnectionStore from 'stores/connections.js'\nimport { every, get, includes, isEmpty } from 'lodash'\n\n/**\n * Dialog for create or rename group\n */\n\nconst i18n = useI18n()\nconst editGroup = ref('')\nconst groupForm = reactive({\n    name: '',\n})\nconst groupFormRef = ref(null)\n\nconst formRules = computed(() => {\n    const requiredMsg = i18n.t('dialogue.field_required')\n    const illegalChars = ['/', '\\\\']\n    return {\n        name: [\n            { required: true, message: requiredMsg, trigger: 'input' },\n            {\n                validator: (rule, value) => {\n                    return every(illegalChars, (c) => !includes(value, c))\n                },\n                message: i18n.t('dialogue.illegal_characters'),\n                trigger: 'input',\n            },\n        ],\n    }\n})\n\nconst isRenameMode = computed(() => !isEmpty(editGroup.value))\n\nconst dialogStore = useDialog()\nconst connectionStore = useConnectionStore()\nwatchEffect(() => {\n    if (dialogStore.groupDialogVisible) {\n        groupForm.name = editGroup.value = dialogStore.editGroup\n    }\n})\n\nconst onConfirm = async () => {\n    try {\n        await groupFormRef.value?.validate((errs) => {\n            const err = get(errs, '0.0.message')\n            if (err != null) {\n                $message.error(err)\n            }\n        })\n\n        const { name } = groupForm\n        if (isRenameMode.value) {\n            const { success, msg } = await connectionStore.renameGroup(editGroup.value, name)\n            if (success) {\n                $message.success(i18n.t('dialogue.handle_succ'))\n            } else {\n                $message.error(msg)\n            }\n        } else {\n            const { success, msg } = await connectionStore.createGroup(name)\n            if (success) {\n                $message.success(i18n.t('dialogue.handle_succ'))\n            } else {\n                $message.error(msg)\n            }\n        }\n    } catch (e) {\n        const msg = get(e, 'message')\n        if (!isEmpty(msg)) {\n            $message.error(msg)\n        }\n    }\n}\n\nconst onClose = () => {\n    if (isRenameMode.value) {\n        dialogStore.closeNewGroupDialog()\n    } else {\n        dialogStore.closeRenameGroupDialog()\n    }\n}\n</script>\n\n<template>\n    <n-modal\n        v-model:show=\"dialogStore.groupDialogVisible\"\n        :closable=\"false\"\n        :mask-closable=\"false\"\n        :negative-button-props=\"{ size: 'medium' }\"\n        :negative-text=\"$t('common.cancel')\"\n        :positive-button-props=\"{ size: 'medium' }\"\n        :positive-text=\"$t('common.confirm')\"\n        :show-icon=\"false\"\n        :title=\"isRenameMode ? $t('dialogue.group.rename') : $t('dialogue.group.new')\"\n        close-on-esc\n        preset=\"dialog\"\n        transform-origin=\"center\"\n        @esc=\"onClose\"\n        @positive-click=\"onConfirm\"\n        @negative-click=\"onClose\">\n        <n-form\n            ref=\"groupFormRef\"\n            :model=\"groupForm\"\n            :rules=\"formRules\"\n            :show-label=\"false\"\n            :show-require-mark=\"false\"\n            label-placement=\"top\">\n            <n-form-item :label=\"$t('dialogue.group.name')\" path=\"name\" required>\n                <n-input v-model:value=\"groupForm.name\" placeholder=\"\" />\n            </n-form-item>\n        </n-form>\n    </n-modal>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/dialogs/ImportKeyDialog.vue",
    "content": "<script setup>\nimport { computed, reactive, ref, watchEffect } from 'vue'\nimport useDialog from 'stores/dialog'\nimport { useI18n } from 'vue-i18n'\nimport useBrowserStore from 'stores/browser.js'\nimport { isEmpty } from 'lodash'\nimport FileOpenInput from '@/components/common/FileOpenInput.vue'\nimport TtlInput from '@/components/common/TtlInput.vue'\n\nconst importKeyForm = reactive({\n    server: '',\n    db: 0,\n    reload: true,\n    file: '',\n    type: 0,\n    conflict: 0,\n    ttlType: 0,\n    ttl: -1,\n    ttlUnit: 1,\n})\n\nconst dialogStore = useDialog()\nconst browserStore = useBrowserStore()\nconst loading = ref(false)\nconst importing = ref(false)\nwatchEffect(() => {\n    if (dialogStore.importKeyDialogVisible) {\n        const { server, db } = dialogStore.importKeyParam\n        importKeyForm.server = server\n        importKeyForm.db = db\n        importKeyForm.reload = true\n        importKeyForm.file = ''\n        importKeyForm.type = 0\n        importKeyForm.conflict = 0\n        importKeyForm.ttlType = 0\n        importKeyForm.ttl = -1\n        importing.value = false\n    }\n})\n\nconst i18n = useI18n()\nconst conflictOption = [\n    {\n        value: 0,\n        label: 'dialogue.import.conflict_overwrite',\n    },\n    {\n        value: 1,\n        label: 'dialogue.import.conflict_ignore',\n    },\n]\n\nconst ttlOption = [\n    {\n        value: 0,\n        label: 'dialogue.import.ttl_include',\n    },\n    {\n        value: 1,\n        label: 'dialogue.import.ttl_ignore',\n    },\n    {\n        value: 2,\n        label: 'dialogue.import.ttl_custom',\n    },\n]\n\nconst importEnable = computed(() => {\n    return !isEmpty(importKeyForm.file)\n})\n\nconst onConfirmImport = async () => {\n    try {\n        importing.value = true\n        const { server, db, file, conflict, ttlType, ttl, ttlUnit, reload } = importKeyForm\n        let ttlVal = 0\n        switch (ttlType) {\n            case 0:\n                ttlVal = -1\n                break\n            case 1:\n                ttlVal = 0\n                break\n            default:\n                ttlVal = ttl * (ttlUnit || 1)\n        }\n        browserStore.importKeysFromCSVFile(server, db, file, conflict, ttlVal, reload).catch((e) => {})\n    } catch (e) {\n        $message.error(e.message)\n        return\n    } finally {\n        importing.value = false\n    }\n    dialogStore.closeImportKeyDialog()\n}\n\nconst onClose = () => {\n    dialogStore.closeImportKeyDialog()\n}\n</script>\n\n<template>\n    <n-modal\n        v-model:show=\"dialogStore.importKeyDialogVisible\"\n        :closable=\"false\"\n        :mask-closable=\"false\"\n        :show-icon=\"false\"\n        :title=\"$t('dialogue.import.name')\"\n        close-on-esc\n        preset=\"dialog\"\n        transform-origin=\"center\"\n        @esc=\"onClose\">\n        <n-spin :show=\"loading\">\n            <n-form :model=\"importKeyForm\" :show-require-mark=\"false\" label-placement=\"top\">\n                <n-grid :x-gap=\"10\">\n                    <n-form-item-gi :label=\"$t('dialogue.key.server')\" :span=\"12\">\n                        <n-input :autofocus=\"false\" :value=\"importKeyForm.server\" readonly />\n                    </n-form-item-gi>\n                    <n-form-item-gi :label=\"$t('dialogue.key.db_index')\" :span=\"12\">\n                        <n-input :autofocus=\"false\" :value=\"importKeyForm.db.toString()\" readonly />\n                    </n-form-item-gi>\n                </n-grid>\n                <n-form-item :label=\"$t('dialogue.import.open_csv_file')\" required>\n                    <file-open-input\n                        v-model:value=\"importKeyForm.file\"\n                        :placeholder=\"$t('dialogue.import.open_csv_file_tip')\"\n                        ext=\"csv\" />\n                </n-form-item>\n                <n-form-item :label=\"$t('dialogue.import.conflict_handle')\">\n                    <n-radio-group v-model:value=\"importKeyForm.conflict\">\n                        <n-radio-button\n                            v-for=\"(op, i) in conflictOption\"\n                            :key=\"i\"\n                            :label=\"$t(op.label)\"\n                            :value=\"op.value\" />\n                    </n-radio-group>\n                </n-form-item>\n                <n-form-item :label=\"$t('dialogue.import.import_expire_title')\">\n                    <n-space :wrap-item=\"false\">\n                        <n-radio-group v-model:value=\"importKeyForm.ttlType\">\n                            <n-radio-button\n                                v-for=\"(op, i) in ttlOption\"\n                                :key=\"i\"\n                                :label=\"$t(op.label)\"\n                                :value=\"op.value\" />\n                        </n-radio-group>\n                        <ttl-input\n                            v-if=\"importKeyForm.ttlType === 2\"\n                            v-model:unit=\"importKeyForm.ttlUnit\"\n                            v-model:value=\"importKeyForm.ttl\" />\n                    </n-space>\n                </n-form-item>\n                <n-form-item :label=\"$t('dialogue.import.import_expire_title')\" :show-label=\"false\">\n                    <n-checkbox v-model:checked=\"importKeyForm.reload\" :autofocus=\"false\">\n                        {{ $t('dialogue.import.reload') }}\n                    </n-checkbox>\n                </n-form-item>\n            </n-form>\n        </n-spin>\n\n        <template #action>\n            <div class=\"flex-item n-dialog__action\">\n                <n-button :disabled=\"loading\" :focusable=\"false\" @click=\"onClose\">{{ $t('common.cancel') }}</n-button>\n                <n-button\n                    :disabled=\"!importEnable\"\n                    :focusable=\"false\"\n                    :loading=\"loading\"\n                    type=\"primary\"\n                    @click=\"onConfirmImport\">\n                    {{ $t('dialogue.import.import') }}\n                </n-button>\n            </div>\n        </template>\n    </n-modal>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/dialogs/KeyFilterDialog.vue",
    "content": "<script setup>\nimport { computed, reactive, ref, watchEffect } from 'vue'\nimport useDialog from 'stores/dialog'\nimport { types } from '@/consts/support_redis_type.js'\nimport useBrowserStore from 'stores/browser.js'\n\nconst filterForm = reactive({\n    server: '',\n    db: 0,\n    type: '',\n    pattern: '',\n})\nconst filterFormRef = ref(null)\nconst typeOptions = computed(() => {\n    const options = Object.keys(types).map((t) => ({\n        value: t,\n        label: t,\n    }))\n    options.splice(0, 0, {\n        value: '',\n        label: 'common.all',\n    })\n    return options\n})\n\nconst dialogStore = useDialog()\n\nwatchEffect(() => {\n    if (dialogStore.keyFilterDialogVisible) {\n        const { server, db, type, pattern } = dialogStore.keyFilterParam\n        filterForm.server = server\n        filterForm.db = db || 0\n        filterForm.type = type || ''\n        filterForm.pattern = pattern || '*'\n    }\n})\n\nconst browserStore = useBrowserStore()\nconst onConfirm = () => {}\n\nconst onClose = () => {\n    dialogStore.closeKeyFilterDialog()\n}\n</script>\n\n<template>\n    <n-modal\n        v-model:show=\"dialogStore.keyFilterDialogVisible\"\n        :closable=\"false\"\n        :mask-closable=\"false\"\n        :negative-button-props=\"{ size: 'medium' }\"\n        :negative-text=\"$t('common.cancel')\"\n        :positive-button-props=\"{ size: 'medium' }\"\n        :positive-text=\"$t('common.confirm')\"\n        :show-icon=\"false\"\n        :title=\"$t('dialogue.filter.set_key_filter')\"\n        close-on-esc\n        preset=\"dialog\"\n        style=\"width: 450px\"\n        transform-origin=\"center\"\n        @esc=\"onClose\"\n        @positive-click=\"onConfirm\"\n        @negative-click=\"onClose\">\n        <n-form\n            ref=\"filterFormRef\"\n            :model=\"filterForm\"\n            :show-require-mark=\"false\"\n            label-placement=\"top\"\n            style=\"padding-right: 15px\">\n            <n-form-item :label=\"$t('dialogue.key.server')\" path=\"key\">\n                <n-input :value=\"filterForm.server\" readonly></n-input>\n            </n-form-item>\n            <n-form-item :label=\"$t('dialogue.key.db_index')\" path=\"db\">\n                <n-input :value=\"filterForm.db + ''\" readonly></n-input>\n            </n-form-item>\n            <n-form-item :label=\"$t('interface.type')\" path=\"type\" required>\n                <n-select\n                    v-model:value=\"filterForm.type\"\n                    :options=\"typeOptions\"\n                    :render-label=\"({ label }) => $t(label)\" />\n            </n-form-item>\n            <n-form-item :label=\"$t('dialogue.filter.filter_pattern')\" required>\n                <n-input-group>\n                    <n-tooltip trigger=\"focus\">\n                        <template #trigger>\n                            <n-input\n                                v-model:value=\"filterForm.pattern\"\n                                :placeholder=\"$t('dialogue.filter.filter_pattern')\"\n                                clearable />\n                        </template>\n                        <div class=\"text-block\">{{ $t('dialogue.filter.filter_pattern_tip') }}</div>\n                    </n-tooltip>\n                    <n-button :focusable=\"false\" secondary type=\"primary\" @click=\"filterForm.pattern = '*'\">\n                        {{ $t('preferences.restore_defaults') }}\n                    </n-button>\n                </n-input-group>\n            </n-form-item>\n        </n-form>\n    </n-modal>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/dialogs/NewKeyDialog.vue",
    "content": "<script setup>\nimport { computed, h, nextTick, reactive, ref, watchEffect } from 'vue'\nimport { types, typesColor } from '@/consts/support_redis_type.js'\nimport useDialog from 'stores/dialog'\nimport { endsWith, get, isEmpty, keys, map } from 'lodash'\nimport NewStringValue from '@/components/new_value/NewStringValue.vue'\nimport NewHashValue from '@/components/new_value/NewHashValue.vue'\nimport NewListValue from '@/components/new_value/NewListValue.vue'\nimport NewZSetValue from '@/components/new_value/NewZSetValue.vue'\nimport NewSetValue from '@/components/new_value/NewSetValue.vue'\nimport { useI18n } from 'vue-i18n'\nimport { NSpace } from 'naive-ui'\nimport useTabStore from 'stores/tab.js'\nimport NewStreamValue from '@/components/new_value/NewStreamValue.vue'\nimport useBrowserStore from 'stores/browser.js'\nimport Import from '@/components/icons/Import.vue'\nimport NewJsonValue from '@/components/new_value/NewJsonValue.vue'\n\nconst i18n = useI18n()\nconst newForm = reactive({\n    server: '',\n    db: 0,\n    key: '',\n    type: '',\n    ttl: -1,\n    value: null,\n})\nconst formRules = computed(() => {\n    const requiredMsg = i18n.t('dialogue.field_required')\n    return {\n        key: { required: true, message: requiredMsg, trigger: 'input' },\n        type: { required: true, message: requiredMsg, trigger: 'input' },\n        ttl: { required: true, message: requiredMsg, trigger: 'input' },\n    }\n})\nconst dbOptions = computed(() =>\n    map(keys(browserStore.getDBList(newForm.server)), (key) => ({\n        label: key,\n        value: parseInt(key),\n    })),\n)\nconst newFormRef = ref(null)\nconst subFormRef = ref(null)\n\nconst options = computed(() => {\n    return Object.keys(types).map((t) => ({\n        value: t,\n        label: t,\n    }))\n})\nconst newValueComponent = {\n    [types.STRING]: NewStringValue,\n    [types.HASH]: NewHashValue,\n    [types.LIST]: NewListValue,\n    [types.SET]: NewSetValue,\n    [types.ZSET]: NewZSetValue,\n    [types.STREAM]: NewStreamValue,\n    [types.JSON]: NewJsonValue,\n}\nconst defaultValue = {\n    [types.STRING]: '',\n    [types.HASH]: [],\n    [types.LIST]: [],\n    [types.SET]: [],\n    [types.ZSET]: [],\n    [types.STREAM]: [],\n    [types.JSON]: '{}',\n}\n\nconst dialogStore = useDialog()\nconst scrollRef = ref(null)\nwatchEffect(() => {\n    if (dialogStore.newKeyDialogVisible) {\n        const { prefix, server, db } = dialogStore.newKeyParam\n        const separator = browserStore.getSeparator(server)\n        newForm.server = server\n        if (isEmpty(prefix)) {\n            newForm.key = ''\n        } else {\n            if (!endsWith(prefix, separator)) {\n                newForm.key = prefix + separator\n            } else {\n                newForm.key = prefix\n            }\n        }\n        newForm.db = db\n        newForm.type = options.value[0].value\n        newForm.ttl = -1\n        newForm.value = null\n    }\n})\n\nconst renderTypeLabel = (option) => {\n    return h(\n        NSpace,\n        {\n            align: 'center',\n            inline: true,\n            size: 3,\n            itemStyle: {\n                lineHeight: 'var(--n-blank-height)',\n            },\n        },\n        {\n            default: () => [\n                h('div', {\n                    style: {\n                        borderRadius: '9999px',\n                        backgroundColor: typesColor[option.value],\n                        width: '13px',\n                        height: '13px',\n                        border: '2px solid white',\n                    },\n                }),\n                option.value,\n            ],\n        },\n    )\n}\n\nconst onAppend = () => {\n    nextTick(() => {\n        scrollRef.value?.scrollTo({ position: 'bottom' })\n    })\n}\n\nconst onChangeType = () => {\n    newForm.value = null\n}\n\nconst browserStore = useBrowserStore()\nconst tabStore = useTabStore()\nconst onAdd = async () => {\n    await newFormRef.value?.validate((errs) => {\n        const err = get(errs, '0.0.message')\n        if (err != null) {\n            $message.error(err)\n        }\n    })\n    if (subFormRef.value?.validate) {\n        await subFormRef.value?.validate((errs) => {\n            const err = get(errs, '0.0.message')\n            if (err != null) {\n                $message.error(err)\n            } else {\n                $message.error(i18n.t('dialogue.spec_field_required', { key: i18n.t('dialogue.field.element') }))\n            }\n        })\n    }\n    try {\n        const { server, db, key, type, ttl } = newForm\n        let { value } = newForm\n        if (value == null) {\n            value = defaultValue[type]\n        }\n        // await browserStore.reloadKey({server, db, key: trim(key)})\n        const {\n            success,\n            msg,\n            nodeKey = '',\n        } = await browserStore.setKey({\n            server,\n            db,\n            key,\n            keyType: type,\n            value,\n            ttl,\n        })\n        if (success) {\n            // select current key\n            await nextTick()\n            const selectedDB = browserStore.getSelectedDB(server)\n            if (selectedDB === db) {\n                tabStore.setSelectedKeys(server, nodeKey)\n                browserStore.reloadKey({ server, db, key })\n            }\n            dialogStore.closeNewKeyDialog()\n        } else if (!isEmpty(msg)) {\n            $message.error(msg)\n        }\n    } catch (e) {\n        return false\n    }\n    return true\n}\n\nconst onClose = () => {\n    dialogStore.closeNewKeyDialog()\n}\n\nconst onImport = () => {\n    dialogStore.closeNewKeyDialog()\n    dialogStore.openImportKeyDialog(newForm.server, newForm.db)\n}\n</script>\n\n<template>\n    <n-modal\n        v-model:show=\"dialogStore.newKeyDialogVisible\"\n        :closable=\"false\"\n        :mask-closable=\"false\"\n        :show-icon=\"false\"\n        :title=\"$t('dialogue.key.new')\"\n        close-on-esc\n        preset=\"dialog\"\n        style=\"width: 600px\"\n        transform-origin=\"center\"\n        @esc=\"onClose\">\n        <n-scrollbar ref=\"scrollRef\" style=\"max-height: 500px\">\n            <n-form\n                ref=\"newFormRef\"\n                :model=\"newForm\"\n                :rules=\"formRules\"\n                :show-require-mark=\"false\"\n                label-placement=\"top\"\n                style=\"padding-right: 15px\">\n                <n-form-item :label=\"$t('common.key')\" path=\"key\" required>\n                    <n-input v-model:value=\"newForm.key\" placeholder=\"\" />\n                </n-form-item>\n                <n-form-item :label=\"$t('dialogue.key.db_index')\" path=\"db\" required>\n                    <n-select v-model:value=\"newForm.db\" :options=\"dbOptions\" filterable />\n                </n-form-item>\n                <n-form-item :label=\"$t('interface.type')\" path=\"type\" required>\n                    <n-select\n                        v-model:value=\"newForm.type\"\n                        :options=\"options\"\n                        :render-label=\"renderTypeLabel\"\n                        @update:value=\"onChangeType\" />\n                </n-form-item>\n                <n-form-item :label=\"$t('interface.ttl')\" required>\n                    <n-input-group>\n                        <n-input-number\n                            v-model:value=\"newForm.ttl\"\n                            :max=\"Number.MAX_SAFE_INTEGER\"\n                            :min=\"-1\"\n                            :show-button=\"false\"\n                            placeholder=\"TTL\">\n                            <template #suffix>\n                                {{ $t('common.second') }}\n                            </template>\n                        </n-input-number>\n                        <n-button :focusable=\"false\" secondary type=\"primary\" @click=\"() => (newForm.ttl = -1)\">\n                            {{ $t('interface.forever') }}\n                        </n-button>\n                    </n-input-group>\n                </n-form-item>\n                <component\n                    :is=\"newValueComponent[newForm.type]\"\n                    ref=\"subFormRef\"\n                    v-model:value=\"newForm.value\"\n                    @append=\"onAppend\" />\n                <!--  TODO: Add import from txt file option -->\n            </n-form>\n        </n-scrollbar>\n\n        <template #action>\n            <div class=\"flex-item-expand\">\n                <n-button :focusable=\"false\" size=\"medium\" @click=\"onImport\">\n                    <template #icon>\n                        <n-icon :component=\"Import\" />\n                    </template>\n                    {{ $t('interface.import_key') }}\n                </n-button>\n            </div>\n            <div class=\"flex-item n-dialog__action\">\n                <n-button :focusable=\"false\" size=\"medium\" @click=\"onClose\">\n                    {{ $t('common.cancel') }}\n                </n-button>\n                <n-button :focusable=\"false\" size=\"medium\" type=\"primary\" @click=\"onAdd\">\n                    {{ $t('common.confirm') }}\n                </n-button>\n            </div>\n        </template>\n    </n-modal>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/dialogs/PreferencesDialog.vue",
    "content": "<script setup>\nimport { computed, h, ref, watchEffect } from 'vue'\nimport { useI18n } from 'vue-i18n'\nimport useDialog from 'stores/dialog'\nimport usePreferencesStore from 'stores/preferences.js'\nimport { find, map, sortBy } from 'lodash'\nimport { typesIconStyle } from '@/consts/support_redis_type.js'\nimport Help from '@/components/icons/Help.vue'\nimport Delete from '@/components/icons/Delete.vue'\nimport IconButton from '@/components/common/IconButton.vue'\nimport { NButton, NEllipsis, NIcon, NSpace, NTooltip } from 'naive-ui'\nimport Edit from '@/components/icons/Edit.vue'\nimport { joinCommand } from '@/utils/decoder_cmd.js'\nimport AddLink from '@/components/icons/AddLink.vue'\nimport Checked from '@/components/icons/Checked.vue'\nimport { BrowserOpenURL } from 'wailsjs/runtime/runtime.js'\n\nconst prefStore = usePreferencesStore()\n\nconst prevPreferences = ref({})\nconst tab = ref('general')\nconst dialogStore = useDialog()\nconst i18n = useI18n()\nconst loading = ref(false)\n\nconst initPreferences = async () => {\n    try {\n        loading.value = true\n        tab.value = dialogStore.preferencesTag || 'general'\n        await prefStore.loadPreferences()\n        prevPreferences.value = {\n            general: prefStore.general,\n            editor: prefStore.editor,\n            cli: prefStore.cli,\n            decoder: prefStore.decoder,\n        }\n    } finally {\n        loading.value = false\n    }\n}\n\nwatchEffect(() => {\n    if (dialogStore.preferencesDialogVisible) {\n        initPreferences()\n    }\n})\n\nconst keyOptions = computed(() => {\n    const opts = map(typesIconStyle, (v) => ({\n        value: v,\n        label: 'preferences.general.key_icon_style' + v,\n    }))\n    return sortBy(opts, (o) => o.value)\n})\n\nconst decoderList = computed(() => {\n    const decoder = prefStore.decoder || []\n    const list = []\n    for (const d of decoder) {\n        // decode command\n        list.push({\n            name: d.name,\n            auto: d.auto,\n            decodeCmd: joinCommand(d.decodePath, d.decodeArgs),\n            encodeCmd: joinCommand(d.encodePath, d.encodeArgs),\n        })\n    }\n    return list\n})\n\nconst decoderColumns = computed(() => {\n    return [\n        {\n            key: 'name',\n            title: () => i18n.t('preferences.decoder.decoder_name'),\n            width: 120,\n            align: 'center',\n            titleAlign: 'center',\n        },\n        {\n            key: 'cmd',\n            title: () => i18n.t('preferences.decoder.cmd_preview'),\n            titleAlign: 'center',\n            render: ({ decodeCmd, encodeCmd }, index) => {\n                return h(NSpace, { vertical: true, wrapItem: false, wrap: false, justify: 'center', size: 15 }, () => [\n                    h(NEllipsis, {}, { default: () => decodeCmd, tooltip: () => decodeCmd + '\\n\\n' + encodeCmd }),\n                    h(NEllipsis, {}, { default: () => encodeCmd, tooltip: () => decodeCmd + '\\n\\n' + encodeCmd }),\n                ])\n            },\n        },\n        {\n            key: 'status',\n            title: () => i18n.t('preferences.decoder.status'),\n            width: 80,\n            align: 'center',\n            titleAlign: 'center',\n            render: ({ auto }, index) => {\n                if (auto) {\n                    return h(\n                        NTooltip,\n                        { delay: 0, showArrow: false },\n                        {\n                            default: () => i18n.t('preferences.decoder.auto_enabled'),\n                            trigger: () => h(NIcon, { component: Checked, size: 16 }),\n                        },\n                    )\n                }\n                return '-'\n            },\n        },\n        {\n            key: 'action',\n            title: () => i18n.t('interface.action'),\n            width: 80,\n            align: 'center',\n            titleAlign: 'center',\n            render: ({ name, auto }, index) => {\n                return h(NSpace, { wrapItem: false, wrap: false, justify: 'center', size: 'small' }, () => [\n                    h(IconButton, {\n                        icon: Delete,\n                        tTooltip: 'interface.delete_row',\n                        onClick: () => {\n                            prefStore.removeCustomDecoder(name)\n                        },\n                    }),\n                    h(IconButton, {\n                        icon: Edit,\n                        tTooltip: 'interface.edit_row',\n                        onClick: () => {\n                            const decoders = prefStore.decoder || []\n                            const decoder = find(decoders, { name })\n                            const { auto, decodePath, decodeArgs, encodePath, encodeArgs } = decoder\n                            dialogStore.openDecoderDialog({\n                                name,\n                                auto,\n                                decodePath,\n                                decodeArgs,\n                                encodePath,\n                                encodeArgs,\n                            })\n                        },\n                    }),\n                ])\n            },\n        },\n    ]\n})\n\nconst onOpenPrivacy = () => {\n    let helpUrl = ''\n    switch (prefStore.currentLanguage) {\n        case 'zh':\n            helpUrl = 'https://tinyrdm.com/zh/guide/privacy.html'\n            break\n        default:\n            helpUrl = 'https://tinyrdm.com/guide/privacy.html'\n            break\n    }\n    BrowserOpenURL(helpUrl)\n}\n\nconst openDecodeHelp = () => {\n    let helpUrl = ''\n    switch (prefStore.currentLanguage) {\n        case 'zh':\n            helpUrl = 'https://tinyrdm.com/zh/guide/custom-decoder.html'\n            break\n        default:\n            helpUrl = 'https://tinyrdm.com/guide/custom-decoder.html'\n            break\n    }\n    BrowserOpenURL(helpUrl)\n}\n\nconst onSavePreferences = async () => {\n    const success = await prefStore.savePreferences()\n    if (success) {\n        // $message.success(i18n.t('dialogue.handle_succ'))\n        dialogStore.closePreferencesDialog()\n    }\n}\n\nconst onClose = () => {\n    // restore to old preferences\n    prefStore.resetToLastPreferences()\n    dialogStore.closePreferencesDialog()\n}\n</script>\n\n<template>\n    <n-modal\n        v-model:show=\"dialogStore.preferencesDialogVisible\"\n        :auto-focus=\"false\"\n        :closable=\"false\"\n        :mask-closable=\"false\"\n        :show-icon=\"false\"\n        :title=\"$t('preferences.name')\"\n        close-on-esc\n        preset=\"dialog\"\n        style=\"width: 640px\"\n        transform-origin=\"center\"\n        @esc=\"onClose\">\n        <!-- FIXME: set loading will slow down appear animation of dialog in linux -->\n        <!-- <n-spin :show=\"loading\"> -->\n        <n-tabs\n            v-model:value=\"tab\"\n            animated\n            pane-style=\"min-height: 300px\"\n            placement=\"left\"\n            tab-style=\"justify-content: right; font-weight: 420;\"\n            type=\"line\">\n            <!-- general pane -->\n            <n-tab-pane :tab=\"$t('preferences.general.name')\" display-directive=\"show\" name=\"general\">\n                <n-form :disabled=\"loading\" :model=\"prefStore.general\" :show-require-mark=\"false\" label-placement=\"top\">\n                    <n-grid :x-gap=\"10\">\n                        <n-form-item-gi :label=\"$t('preferences.general.theme')\" :span=\"24\" required>\n                            <n-radio-group v-model:value=\"prefStore.general.theme\" name=\"theme\" size=\"medium\">\n                                <n-radio-button\n                                    v-for=\"opt in prefStore.themeOption\"\n                                    :key=\"opt.value\"\n                                    :value=\"opt.value\">\n                                    {{ $t(opt.label) }}\n                                </n-radio-button>\n                            </n-radio-group>\n                        </n-form-item-gi>\n                        <n-form-item-gi :label=\"$t('preferences.general.language')\" :span=\"24\" required>\n                            <n-select\n                                v-model:value=\"prefStore.general.language\"\n                                :options=\"prefStore.langOption\"\n                                :render-label=\"({ label, value }) => (value === 'auto' ? $t(label) : label)\"\n                                filterable />\n                        </n-form-item-gi>\n                        <n-form-item-gi :span=\"24\" required>\n                            <template #label>\n                                {{ $t('preferences.general.font') }}\n                                <n-tooltip trigger=\"hover\">\n                                    <template #trigger>\n                                        <n-icon :component=\"Help\" />\n                                    </template>\n                                    <div class=\"text-block\">\n                                        {{ $t('preferences.font_tip') }}\n                                    </div>\n                                </n-tooltip>\n                            </template>\n                            <n-select\n                                v-model:value=\"prefStore.general.fontFamily\"\n                                :options=\"prefStore.fontOption\"\n                                :placeholder=\"$t('preferences.general.font_tip')\"\n                                :render-label=\"({ label, value }) => (value === '' ? $t(label) : label)\"\n                                filterable\n                                multiple\n                                tag />\n                        </n-form-item-gi>\n                        <n-form-item-gi :label=\"$t('preferences.general.font_size')\" :span=\"24\">\n                            <n-input-number v-model:value=\"prefStore.general.fontSize\" :max=\"65535\" :min=\"1\" />\n                        </n-form-item-gi>\n                        <n-form-item-gi :span=\"12\">\n                            <template #label>\n                                {{ $t('preferences.general.scan_size') }}\n                                <n-tooltip trigger=\"hover\">\n                                    <template #trigger>\n                                        <n-icon :component=\"Help\" />\n                                    </template>\n                                    <div class=\"text-block\">\n                                        {{ $t('preferences.general.scan_size_tip') }}\n                                    </div>\n                                </n-tooltip>\n                            </template>\n                            <n-input-number\n                                v-model:value=\"prefStore.general.scanSize\"\n                                :min=\"1\"\n                                :show-button=\"false\"\n                                style=\"width: 100%\" />\n                        </n-form-item-gi>\n                        <n-form-item-gi :label=\"$t('preferences.general.key_icon_style')\" :span=\"12\">\n                            <n-select\n                                v-model:value=\"prefStore.general.keyIconStyle\"\n                                :options=\"keyOptions\"\n                                :render-label=\"({ label }) => $t(label)\" />\n                        </n-form-item-gi>\n                        <n-form-item-gi :label=\"$t('preferences.general.update')\" :span=\"24\">\n                            <n-checkbox v-model:checked=\"prefStore.general.checkUpdate\">\n                                {{ $t('preferences.general.auto_check_update') }}\n                            </n-checkbox>\n                        </n-form-item-gi>\n                        <n-form-item-gi :label=\"$t('preferences.general.privacy')\" :span=\"24\">\n                            <n-checkbox v-model:checked=\"prefStore.general.allowTrack\">\n                                {{ $t('preferences.general.allow_track') }}\n                                <n-button style=\"text-decoration: underline\" text type=\"primary\" @click=\"onOpenPrivacy\">\n                                    {{ $t('preferences.general.privacy') }}\n                                </n-button>\n                            </n-checkbox>\n                        </n-form-item-gi>\n                    </n-grid>\n                </n-form>\n            </n-tab-pane>\n\n            <!-- editor pane -->\n            <n-tab-pane :tab=\"$t('preferences.editor.name')\" display-directive=\"show\" name=\"editor\">\n                <n-form :disabled=\"loading\" :model=\"prefStore.editor\" :show-require-mark=\"false\" label-placement=\"top\">\n                    <n-grid :x-gap=\"10\">\n                        <n-form-item-gi :span=\"24\" required>\n                            <template #label>\n                                {{ $t('preferences.general.font') }}\n                                <n-tooltip trigger=\"hover\">\n                                    <template #trigger>\n                                        <n-icon :component=\"Help\" />\n                                    </template>\n                                    <div class=\"text-block\">\n                                        {{ $t('preferences.font_tip') }}\n                                    </div>\n                                </n-tooltip>\n                            </template>\n                            <n-select\n                                v-model:value=\"prefStore.editor.fontFamily\"\n                                :options=\"prefStore.fontOption\"\n                                :placeholder=\"$t('preferences.general.font_tip')\"\n                                :render-label=\"({ label, value }) => value || $t(label)\"\n                                filterable\n                                multiple\n                                tag />\n                        </n-form-item-gi>\n                        <n-form-item-gi :label=\"$t('preferences.general.font_size')\" :span=\"24\">\n                            <n-input-number v-model:value=\"prefStore.editor.fontSize\" :max=\"65535\" :min=\"1\" />\n                        </n-form-item-gi>\n                        <n-form-item-gi :show-feedback=\"false\" :show-label=\"false\" :span=\"24\">\n                            <n-checkbox v-model:checked=\"prefStore.editor.showLineNum\">\n                                {{ $t('preferences.editor.show_linenum') }}\n                            </n-checkbox>\n                        </n-form-item-gi>\n                        <n-form-item-gi :show-feedback=\"false\" :show-label=\"false\" :span=\"24\">\n                            <n-checkbox v-model:checked=\"prefStore.editor.showFolding\">\n                                {{ $t('preferences.editor.show_folding') }}\n                            </n-checkbox>\n                        </n-form-item-gi>\n                        <n-form-item-gi :show-feedback=\"false\" :show-label=\"false\" :span=\"24\">\n                            <n-checkbox v-model:checked=\"prefStore.editor.dropText\">\n                                {{ $t('preferences.editor.drop_text') }}\n                            </n-checkbox>\n                        </n-form-item-gi>\n                        <n-form-item-gi :show-feedback=\"false\" :show-label=\"false\" :span=\"24\">\n                            <n-checkbox v-model:checked=\"prefStore.editor.links\">\n                                {{ $t('preferences.editor.links') }}\n                            </n-checkbox>\n                        </n-form-item-gi>\n                    </n-grid>\n                </n-form>\n            </n-tab-pane>\n\n            <!-- cli pane -->\n            <n-tab-pane :tab=\"$t('preferences.cli.name')\" display-directive=\"show\" name=\"cli\">\n                <n-form :disabled=\"loading\" :model=\"prefStore.cli\" :show-require-mark=\"false\" label-placement=\"top\">\n                    <n-grid :x-gap=\"10\">\n                        <n-form-item-gi :span=\"24\" required>\n                            <template #label>\n                                {{ $t('preferences.general.font') }}\n                                <n-tooltip trigger=\"hover\">\n                                    <template #trigger>\n                                        <n-icon :component=\"Help\" />\n                                    </template>\n                                    <div class=\"text-block\">\n                                        {{ $t('preferences.font_tip') }}\n                                    </div>\n                                </n-tooltip>\n                            </template>\n                            <n-select\n                                v-model:value=\"prefStore.cli.fontFamily\"\n                                :options=\"prefStore.fontOption\"\n                                :placeholder=\"$t('preferences.general.font_tip')\"\n                                :render-label=\"({ label, value }) => value || $t(label)\"\n                                filterable\n                                multiple\n                                tag />\n                        </n-form-item-gi>\n                        <n-form-item-gi :label=\"$t('preferences.general.font_size')\" :span=\"24\">\n                            <n-input-number v-model:value=\"prefStore.cli.fontSize\" :max=\"65535\" :min=\"1\" />\n                        </n-form-item-gi>\n                        <n-form-item-gi :label=\"$t('preferences.cli.cursor_style')\" :span=\"24\">\n                            <n-radio-group v-model:value=\"prefStore.cli.cursorStyle\" name=\"theme\" size=\"medium\">\n                                <n-radio-button\n                                    v-for=\"opt in prefStore.cliCursorStyleOption\"\n                                    :key=\"opt.value\"\n                                    :value=\"opt.value\">\n                                    {{ $t(opt.label) }}\n                                </n-radio-button>\n                            </n-radio-group>\n                        </n-form-item-gi>\n                    </n-grid>\n                </n-form>\n            </n-tab-pane>\n\n            <!-- custom decoder pane -->\n            <n-tab-pane :tab=\"$t('preferences.decoder.name')\" display-directive=\"show:lazy\" name=\"decoder\">\n                <n-space vertical>\n                    <n-space justify=\"space-between\">\n                        <n-button @click=\"dialogStore.openDecoderDialog()\">\n                            <template #icon>\n                                <n-icon :component=\"AddLink\" size=\"18\" />\n                            </template>\n                            {{ $t('preferences.decoder.new') }}\n                        </n-button>\n                        <n-button @click=\"openDecodeHelp\">\n                            <template #icon>\n                                <n-icon :component=\"Help\" size=\"18\" />\n                            </template>\n                            {{ $t('preferences.decoder.help') }}\n                        </n-button>\n                    </n-space>\n                    <n-data-table\n                        :columns=\"decoderColumns\"\n                        :data=\"decoderList\"\n                        :single-line=\"false\"\n                        max-height=\"350px\" />\n                </n-space>\n            </n-tab-pane>\n        </n-tabs>\n        <!-- </n-spin> -->\n\n        <template #action>\n            <div class=\"flex-item-expand\">\n                <n-button :disabled=\"loading\" @click=\"prefStore.restorePreferences\">\n                    {{ $t('preferences.restore_defaults') }}\n                </n-button>\n            </div>\n            <div class=\"flex-item n-dialog__action\">\n                <n-button :disabled=\"loading\" @click=\"onClose\">{{ $t('common.cancel') }}</n-button>\n                <n-button :disabled=\"loading\" type=\"primary\" @click=\"onSavePreferences\">\n                    {{ $t('common.save') }}\n                </n-button>\n            </div>\n        </template>\n    </n-modal>\n</template>\n\n<style lang=\"scss\" scoped>\n.inline-form-item {\n    padding-right: 10px;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/dialogs/RenameKeyDialog.vue",
    "content": "<script setup>\nimport { reactive, watchEffect } from 'vue'\nimport useDialog from 'stores/dialog'\nimport { useI18n } from 'vue-i18n'\nimport useBrowserStore from 'stores/browser.js'\nimport useTabStore from 'stores/tab.js'\n\nconst renameForm = reactive({\n    server: '',\n    db: 0,\n    key: '',\n    newKey: '',\n})\n\nconst dialogStore = useDialog()\nconst browserStore = useBrowserStore()\nconst tab = useTabStore()\n\nwatchEffect(() => {\n    if (dialogStore.renameDialogVisible) {\n        const { server, db, key } = dialogStore.renameKeyParam\n        renameForm.server = server\n        renameForm.db = db\n        renameForm.key = key\n        renameForm.newKey = key\n    }\n})\n\nconst i18n = useI18n()\nconst onRename = async () => {\n    try {\n        const { server, db, key, newKey } = renameForm\n        const { success, msg, nodeKey } = await browserStore.renameKey(server, db, key, newKey)\n        if (success) {\n            tab.setSelectedKeys(server, nodeKey)\n            browserStore.loadKeySummary({ server, db, key: newKey })\n            $message.success(i18n.t('dialogue.handle_succ'))\n        } else {\n            $message.error(msg)\n        }\n    } catch (e) {\n        $message.error(e.message)\n    }\n    dialogStore.closeRenameKeyDialog()\n}\n\nconst onClose = () => {\n    dialogStore.closeRenameKeyDialog()\n}\n</script>\n\n<template>\n    <n-modal\n        v-model:show=\"dialogStore.renameDialogVisible\"\n        :closable=\"false\"\n        :mask-closable=\"false\"\n        :negative-button-props=\"{ focusable: false, size: 'medium' }\"\n        :negative-text=\"$t('common.cancel')\"\n        :positive-button-props=\"{ focusable: false, size: 'medium' }\"\n        :positive-text=\"$t('common.confirm')\"\n        :show-icon=\"false\"\n        :title=\"$t('interface.rename_key')\"\n        close-on-esc\n        preset=\"dialog\"\n        transform-origin=\"center\"\n        @esc=\"onClose\"\n        @positive-click=\"onRename\"\n        @negative-click=\"onClose\">\n        <n-form\n            :model=\"renameForm\"\n            :show-label=\"false\"\n            :show-require-mark=\"false\"\n            label-align=\"left\"\n            label-placement=\"top\">\n            <n-form-item :label=\"$t('dialogue.key.new_name')\" required>\n                <n-input v-model:value=\"renameForm.newKey\" />\n            </n-form-item>\n        </n-form>\n    </n-modal>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/dialogs/SetTtlDialog.vue",
    "content": "<script setup>\nimport { computed, reactive, ref, watchEffect } from 'vue'\nimport useDialog from 'stores/dialog'\nimport useBrowserStore from 'stores/browser.js'\nimport { useI18n } from 'vue-i18n'\nimport { isEmpty, size } from 'lodash'\nimport TtlInput from '@/components/common/TtlInput.vue'\n\nconst ttlForm = reactive({\n    server: '',\n    db: 0,\n    key: '',\n    keys: [],\n    ttl: -1,\n    unit: 1,\n})\n\nconst dialogStore = useDialog()\nconst browserStore = useBrowserStore()\n\nwatchEffect(() => {\n    if (dialogStore.ttlDialogVisible) {\n        // get ttl from current tab\n        const { server, db, key, keys, ttl } = dialogStore.ttlParam\n        ttlForm.server = server\n        ttlForm.db = db\n        ttlForm.key = key\n        ttlForm.keys = keys\n        ttlForm.unit = 1\n        if (ttl < 0) {\n            // forever\n            ttlForm.ttl = -1\n        } else {\n            ttlForm.ttl = ttl\n        }\n        procssing.value = false\n    }\n})\n\nconst procssing = ref(false)\nconst isBatchAction = computed(() => {\n    return !isEmpty(ttlForm.keys)\n})\n\nconst title = computed(() => {\n    if (isBatchAction.value) {\n        return () => i18n.t('dialogue.ttl.title_batch', { count: size(ttlForm.keys) })\n    } else {\n        return () => i18n.t('dialogue.ttl.title')\n    }\n})\n\nconst i18n = useI18n()\nconst quickOption = [\n    { value: -1, unit: 1, label: 'interface.forever' },\n    { value: 10, unit: 1, label: 'common.second' },\n    { value: 1, unit: 60, label: 'common.minute' },\n    { value: 1, unit: 3600, label: 'common.hour' },\n    { value: 1, unit: 86400, label: 'common.day' },\n]\n\nconst onQuickSet = (opt) => {\n    ttlForm.ttl = opt.value\n    ttlForm.unit = opt.unit\n}\n\nconst onClose = () => {\n    dialogStore.closeTTLDialog()\n}\n\nconst onConfirm = async () => {\n    try {\n        procssing.value = true\n        const ttl = ttlForm.ttl * (ttlForm.unit || 1)\n        let success = false\n        if (isBatchAction.value) {\n            success = await browserStore.setTTLs(ttlForm.server, ttlForm.db, ttlForm.keys, ttl)\n        } else {\n            success = await browserStore.setTTL(ttlForm.server, ttlForm.db, ttlForm.key, ttl)\n        }\n        if (success) {\n        }\n    } catch (e) {\n        $message.error(e.message || 'set ttl fail')\n    } finally {\n        procssing.value = false\n        dialogStore.closeTTLDialog()\n    }\n}\n</script>\n\n<template>\n    <n-modal\n        v-model:show=\"dialogStore.ttlDialogVisible\"\n        :closable=\"false\"\n        :mask-closable=\"false\"\n        :negative-button-props=\"{ focusable: false, size: 'medium' }\"\n        :negative-text=\"$t('common.cancel')\"\n        :on-negative-click=\"onClose\"\n        :on-positive-click=\"onConfirm\"\n        :positive-button-props=\"{ focusable: false, size: 'medium', loading: procssing }\"\n        :positive-text=\"$t('common.save')\"\n        :show-icon=\"false\"\n        :title=\"title\"\n        close-on-esc\n        preset=\"dialog\"\n        transform-origin=\"center\"\n        @esc=\"onClose\">\n        <n-form :model=\"ttlForm\" :show-require-mark=\"false\" label-placement=\"top\">\n            <n-form-item v-if=\"!isBatchAction\" :label=\"$t('common.key')\">\n                <n-input :value=\"ttlForm.key\" readonly />\n            </n-form-item>\n            <n-form-item :label=\"$t('interface.ttl')\" required>\n                <ttl-input v-model:unit=\"ttlForm.unit\" v-model:value=\"ttlForm.ttl\" />\n            </n-form-item>\n            <n-form-item :label=\"$t('dialogue.ttl.quick_set')\" :show-feedback=\"false\">\n                <n-space :wrap=\"true\" :wrap-item=\"false\">\n                    <n-button\n                        v-for=\"(opt, i) in quickOption\"\n                        :key=\"i\"\n                        round\n                        secondary\n                        size=\"small\"\n                        @click=\"onQuickSet(opt)\">\n                        {{ (opt.value > 0 ? opt.value + ' ' : '') + $t(opt.label) }}\n                    </n-button>\n                </n-space>\n            </n-form-item>\n        </n-form>\n    </n-modal>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Add.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M24 44C35.0457 44 44 35.0457 44 24C44 12.9543 35.0457 4 24 4C12.9543 4 4 12.9543 4 24C4 35.0457 12.9543 44 24 44Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M24 16V32\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M16 24L32 24\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/AddGroup.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M5 8C5 6.89543 5.89543 6 7 6H19L24 12H41C42.1046 12 43 12.8954 43 14V40C43 41.1046 42.1046 42 41 42H7C5.89543 42 5 41.1046 5 40V8Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path :stroke-width=\"props.strokeWidth\" d=\"M18 27H30\" stroke=\"currentColor\" stroke-linecap=\"round\" />\n        <path :stroke-width=\"props.strokeWidth\" d=\"M24 21L24 33\" stroke=\"currentColor\" stroke-linecap=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/AddLink.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M24.0605 10L24.0239 38\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M10 24L38 24\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/AlignCenter.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M36 19H12\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M42 9H6\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M42 29H6\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M36 39H12\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/AlignLeft.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M42 9H6\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M34 19H6\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M42 29H6\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M34 39H6\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Binary.vue",
    "content": "<script setup></script>\n\n<template>\n    <svg\n        fill=\"none\"\n        height=\"24\"\n        stroke=\"currentColor\"\n        stroke-linecap=\"round\"\n        stroke-linejoin=\"round\"\n        stroke-width=\"2\"\n        viewBox=\"0 0 24 24\"\n        width=\"24\"\n        xmlns=\"http://www.w3.org/2000/svg\">\n        <rect height=\"6\" rx=\"2\" width=\"4\" x=\"14\" y=\"14\" />\n        <rect height=\"6\" rx=\"2\" width=\"4\" x=\"6\" y=\"4\" />\n        <path d=\"M6 20h4\" />\n        <path d=\"M14 10h4\" />\n        <path d=\"M6 14h2v6\" />\n        <path d=\"M14 4h2v6\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Bottom.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 4,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M24.0083 33.8995V6\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M36 22L24 34L12 22\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M36 42H12\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Checkbox.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <g clip-path=\"url(#icon-25e88d94353e4f38)\">\n            <path\n                :stroke-width=\"props.strokeWidth\"\n                d=\"M42 20V39C42 40.6569 40.6569 42 39 42H9C7.34315 42 6 40.6569 6 39V9C6 7.34315 7.34315 6 9 6H30\"\n                stroke=\"currentColor\"\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\" />\n            <path\n                :stroke-width=\"props.strokeWidth\"\n                d=\"M16 20L26 28L41 7\"\n                stroke=\"currentColor\"\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\" />\n        </g>\n        <defs>\n            <clipPath id=\"icon-25e88d94353e4f38\">\n                <rect fill=\"currentColor\" height=\"48\" width=\"48\" />\n            </clipPath>\n        </defs>\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Checked.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 4,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M14 24L15.25 25.25M44 14L24 34L22.75 32.75\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M4 24L14 34L34 14\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Clear.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            clip-rule=\"evenodd\"\n            d=\"M20 5.91406H28V13.9141H43V21.9141H5V13.9141H20V5.91406Z\"\n            fill-rule=\"evenodd\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M8 40H40V22H8V40Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M16 39.8976V33.9141\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M24 39.8977V33.8977\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M32 39.8976V33.9141\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M12 40H36\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Close.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n    round: {\n        type: Boolean,\n        default: true,\n    },\n})\n</script>\n\n<template>\n    <svg v-if=\"round !== false\" fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M24 44C35.0457 44 44 35.0457 44 24C44 12.9543 35.0457 4 24 4C12.9543 4 4 12.9543 4 24C4 35.0457 12.9543 44 24 44Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M29.6567 18.3432L18.343 29.6569\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M18.3433 18.3432L29.657 29.6569\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n    <svg v-else fill=\"none\" height=\"24\" viewBox=\"0 0 48 48\" width=\"24\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M8 8L40 40\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M8 40L40 8\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Cluster.vue",
    "content": "<script setup>\nconst props = defineProps({\n    inverse: {\n        type: Boolean,\n        default: false,\n    },\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <rect\n            :fill=\"props.inverse ? 'currentColor' : 'none'\"\n            :stroke-width=\"props.strokeWidth\"\n            height=\"8\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            width=\"8\"\n            x=\"4\"\n            y=\"34\" />\n        <rect\n            :fill=\"props.inverse ? 'currentColor' : 'none'\"\n            :stroke-width=\"props.strokeWidth\"\n            height=\"12\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            width=\"32\"\n            x=\"8\"\n            y=\"6\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M24 34V18\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M8 34V26H40V34\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <rect\n            :fill=\"props.inverse ? 'currentColor' : 'none'\"\n            :stroke-width=\"props.strokeWidth\"\n            height=\"8\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            width=\"8\"\n            x=\"36\"\n            y=\"34\" />\n        <rect\n            :fill=\"props.inverse ? 'currentColor' : 'none'\"\n            :stroke-width=\"props.strokeWidth\"\n            height=\"8\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            width=\"8\"\n            x=\"20\"\n            y=\"34\" />\n        <path\n            :stroke=\"props.inverse ? '#FFF' : 'currentColor'\"\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M14 12H16\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Code.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M40 23V14L31 4H10C8.89543 4 8 4.89543 8 6V42C8 43.1046 8.89543 44 10 44H22\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M37 31L42 36L37 41\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M31 31L26 36L31 41\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M30 4V14H40\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Config.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M18.2838 43.1713C14.9327 42.1736 11.9498 40.3213 9.58787 37.867C10.469 36.8227 11 35.4734 11 34.0001C11 30.6864 8.31371 28.0001 5 28.0001C4.79955 28.0001 4.60139 28.01 4.40599 28.0292C4.13979 26.7277 4 25.3803 4 24.0001C4 21.9095 4.32077 19.8938 4.91579 17.9995C4.94381 17.9999 4.97188 18.0001 5 18.0001C8.31371 18.0001 11 15.3138 11 12.0001C11 11.0488 10.7786 10.1493 10.3846 9.35011C12.6975 7.1995 15.5205 5.59002 18.6521 4.72314C19.6444 6.66819 21.6667 8.00013 24 8.00013C26.3333 8.00013 28.3556 6.66819 29.3479 4.72314C32.4795 5.59002 35.3025 7.1995 37.6154 9.35011C37.2214 10.1493 37 11.0488 37 12.0001C37 15.3138 39.6863 18.0001 43 18.0001C43.0281 18.0001 43.0562 17.9999 43.0842 17.9995C43.6792 19.8938 44 21.9095 44 24.0001C44 25.3803 43.8602 26.7277 43.594 28.0292C43.3986 28.01 43.2005 28.0001 43 28.0001C39.6863 28.0001 37 30.6864 37 34.0001C37 35.4734 37.531 36.8227 38.4121 37.867C36.0502 40.3213 33.0673 42.1736 29.7162 43.1713C28.9428 40.752 26.676 39.0001 24 39.0001C21.324 39.0001 19.0572 40.752 18.2838 43.1713Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M24 31C27.866 31 31 27.866 31 24C31 20.134 27.866 17 24 17C20.134 17 17 20.134 17 24C17 27.866 20.134 31 24 31Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Connect.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M37 22.0001L34 25.0001L23 14.0001L26 11.0001C27.5 9.50002 33 7.00005 37 11.0001C41 15.0001 38.5 20.5 37 22.0001Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M42 6L37 11\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M11 25.9999L14 22.9999L25 33.9999L22 36.9999C20.5 38.5 15 41 11 36.9999C7 32.9999 9.5 27.5 11 25.9999Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M23 32L27 28\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M6 42L11 37\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M16 25L20 21\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Conversion.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M40 23V14L31 4H10C8.89543 4 8 4.89543 8 6V42C8 43.1046 8.89543 44 10 44H22\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M27 33H41\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M27 39H41\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M41 33L36 28\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M32 44L27 39\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M30 4V14H40\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Copy.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M13 38H41V16H30V4H13V38Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M30 4L41 16\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M7 20V44H28\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path :stroke-width=\"props.strokeWidth\" d=\"M19 20H23\" stroke=\"currentColor\" stroke-linecap=\"round\" />\n        <path :stroke-width=\"props.strokeWidth\" d=\"M19 28H31\" stroke=\"currentColor\" stroke-linecap=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/CopyLink.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M12 9.92704V7C12 5.34315 13.3431 4 15 4H41C42.6569 4 44 5.34315 44 7V33C44 34.6569 42.6569 36 41 36H38.0174\"\n            stroke=\"currentColor\" />\n        <rect\n            :stroke-width=\"props.strokeWidth\"\n            fill=\"none\"\n            height=\"34\"\n            rx=\"3\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\"\n            width=\"34\"\n            x=\"4\"\n            y=\"10\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M18.4394 23.1101L23.7319 17.6006C25.1835 16.1489 27.5691 16.1809 29.0602 17.672C30.5513 19.1631 30.5833 21.5487 29.1316 23.0003L27.2215 25.0231\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M13.4661 28.7472C12.9558 29.2575 11.9006 30.2765 11.9006 30.2765C10.4489 31.7281 10.4095 34.3155 11.9006 35.8066C13.3917 37.2977 15.7772 37.3296 17.2289 35.878L22.3931 31.1896\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M18.6626 28.3284C17.97 27.6358 17.5922 26.7502 17.5317 25.8548C17.4619 24.8226 17.8138 23.7774 18.5912 23.0001\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M22.3213 25.8613C23.8124 27.3524 23.8444 29.738 22.3927 31.1896\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Database.vue",
    "content": "<script setup>\nconst props = defineProps({\n    inverse: {\n        type: Boolean,\n        default: false,\n    },\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M44.0001 11C44.0001 11 44 36.0623 44 38C44 41.3137 35.0457 44 24 44C12.9543 44 4.00003 41.3137 4.00003 38C4.00003 36.1423 4 11 4 11\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M44 29C44 32.3137 35.0457 35 24 35C12.9543 35 4 32.3137 4 29\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M44 20C44 23.3137 35.0457 26 24 26C12.9543 26 4 23.3137 4 20\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <ellipse\n            :fill=\"props.inverse ? 'currentColor' : 'none'\"\n            :stroke-width=\"props.strokeWidth\"\n            cx=\"24\"\n            cy=\"10\"\n            rx=\"20\"\n            ry=\"6\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Delete.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M15 12L16.2 5H31.8L33 12\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path :stroke-width=\"props.strokeWidth\" d=\"M6 12H42\" stroke=\"currentColor\" stroke-linecap=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            clip-rule=\"evenodd\"\n            d=\"M37 12L35 43H13L11 12H37Z\"\n            fill=\"none\"\n            fill-rule=\"evenodd\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path :stroke-width=\"props.strokeWidth\" d=\"M20 22V34\" stroke=\"currentColor\" stroke-linecap=\"round\" />\n        <path :stroke-width=\"props.strokeWidth\" d=\"M28 22V34\" stroke=\"currentColor\" stroke-linecap=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Detail.vue",
    "content": "<script setup>\nconst props = defineProps({\n    inverse: {\n        type: Boolean,\n        default: false,\n    },\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n    strokeColor: {\n        type: String,\n        default: '#FFF',\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <rect\n            :fill=\"props.inverse ? 'currentColor' : 'none'\"\n            :stroke-width=\"props.strokeWidth\"\n            height=\"36\"\n            rx=\"3\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\"\n            width=\"36\"\n            x=\"6\"\n            y=\"6\" />\n        <rect\n            :fill=\"props.inverse ? props.strokeColor : 'none'\"\n            :stroke=\"props.inverse ? props.strokeColor : 'currentColor'\"\n            :stroke-width=\"props.strokeWidth\"\n            height=\"8\"\n            stroke-linejoin=\"round\"\n            width=\"8\"\n            x=\"13\"\n            y=\"13\" />\n        <path\n            :stroke=\"props.inverse ? props.strokeColor : 'currentColor'\"\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M27 13L35 13\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke=\"props.inverse ? props.strokeColor : 'currentColor'\"\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M27 20L35 20\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke=\"props.inverse ? props.strokeColor : 'currentColor'\"\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M13 28L35 28\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke=\"props.inverse ? props.strokeColor : 'currentColor'\"\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M13 35H35\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Down.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M36 18L24 30L12 18\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Edit.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M7 42H43\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M11 26.7199V34H18.3172L39 13.3081L31.6951 6L11 26.7199Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/EditFile.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M40 23V14L31 4H10C8.89543 4 8 4.89543 8 6V42C8 43.1046 8.89543 44 10 44H22\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M32 44L42 34L38 30L28 40V44H32Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M30 4V14H40\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Export.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M6 24.0083V42H42V24\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M33 23L24 32L15 23\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M23.9917 6V32\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Filter.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M6 9L20.4 25.8178V38.4444L27.6 42V25.8178L42 9H6Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n"
  },
  {
    "path": "frontend/src/components/icons/Folder.vue",
    "content": "<script setup>\nconst props = defineProps({\n    open: {\n        type: Boolean,\n        default: false,\n    },\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n    fillColor: {\n        type: String,\n        default: '#ffce78',\n    },\n})\n</script>\n\n<template>\n    <svg v-if=\"props.open\" fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M4 9V41L9 21H39.5V15C39.5 13.8954 38.6046 13 37.5 13H24L19 7H6C4.89543 7 4 7.89543 4 9Z\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :fill=\"props.fillColor || 'currentColor'\"\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M40 41L44 21H8.8125L4 41H40Z\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n    <svg v-else fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :fill=\"props.fillColor || 'currentColor'\"\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M5 8C5 6.89543 5.89543 6 7 6H19L24 12H41C42.1046 12 43 12.8954 43 14V40C43 41.1046 42.1046 42 41 42H7C5.89543 42 5 41.1046 5 40V8Z\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path :stroke-width=\"props.strokeWidth\" d=\"M43 22H5\" stroke=\"currentColor\" stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/FullScreen.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M33 6H42V15\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M42 33V42H33\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M15 42H6V33\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M6 15V6H15\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Github.vue",
    "content": "<script setup>\n// const props = defineProps({\n//     strokeWidth: {\n//         type: [Number, String],\n//         default: 3,\n//     },\n// })\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            clip-rule=\"evenodd\"\n            d=\"M24 4C12.9543 4 4 12.9543 4 24C4 35.0457 12.9543 44 24 44C35.0457 44 44 35.0457 44 24C44 12.9543 35.0457 4 24 4ZM0 24C0 10.7452 10.7452 0 24 0C37.2548 0 48 10.7452 48 24C48 37.2548 37.2548 48 24 48C10.7452 48 0 37.2548 0 24Z\"\n            fill=\"currentColor\"\n            fill-rule=\"evenodd\" />\n        <path\n            clip-rule=\"evenodd\"\n            d=\"M19.1833 45.4716C18.9898 45.2219 18.9898 42.9973 19.1833 38.798C17.1114 38.8696 15.8024 38.7258 15.2563 38.3667C14.437 37.828 13.6169 36.1667 12.8891 34.9959C12.1614 33.8251 10.5463 33.64 9.89405 33.3783C9.24182 33.1165 9.07809 32.0496 11.6913 32.8565C14.3044 33.6634 14.4319 35.8607 15.2563 36.3745C16.0806 36.8883 18.0515 36.6635 18.9448 36.2519C19.8382 35.8403 19.7724 34.3078 19.9317 33.7007C20.1331 33.134 19.4233 33.0083 19.4077 33.0037C18.5355 33.0037 13.9539 32.0073 12.6955 27.5706C11.437 23.134 13.0581 20.2341 13.9229 18.9875C14.4995 18.1564 14.4485 16.3852 13.7699 13.6737C16.2335 13.3589 18.1347 14.1343 19.4734 16.0001C19.4747 16.0108 21.2285 14.9572 24.0003 14.9572C26.772 14.9572 27.7553 15.8154 28.5142 16.0001C29.2731 16.1848 29.88 12.7341 34.5668 13.6737C33.5883 15.5969 32.7689 18.0001 33.3943 18.9875C34.0198 19.9749 36.4745 23.1147 34.9666 27.5706C33.9614 30.5413 31.9853 32.3523 29.0384 33.0037C28.7005 33.1115 28.5315 33.2855 28.5315 33.5255C28.5315 33.8856 28.9884 33.9249 29.6465 35.6117C30.0853 36.7362 30.117 39.948 29.7416 45.247C28.7906 45.4891 28.0508 45.6516 27.5221 45.7347C26.5847 45.882 25.5669 45.9646 24.5669 45.9965C23.5669 46.0284 23.2196 46.0248 21.837 45.8961C20.9154 45.8103 20.0308 45.6688 19.1833 45.4716Z\"\n            fill=\"currentColor\"\n            fill-rule=\"evenodd\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Help.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M24 44C29.5228 44 34.5228 41.7614 38.1421 38.1421C41.7614 34.5228 44 29.5228 44 24C44 18.4772 41.7614 13.4772 38.1421 9.85786C34.5228 6.23858 29.5228 4 24 4C18.4772 4 13.4772 6.23858 9.85786 9.85786C6.23858 13.4772 4 18.4772 4 24C4 29.5228 6.23858 34.5228 9.85786 38.1421C13.4772 41.7614 18.4772 44 24 44Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M24 28.6248V24.6248C27.3137 24.6248 30 21.9385 30 18.6248C30 15.3111 27.3137 12.6248 24 12.6248C20.6863 12.6248 18 15.3111 18 18.6248\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            clip-rule=\"evenodd\"\n            d=\"M24 37.6248C25.3807 37.6248 26.5 36.5055 26.5 35.1248C26.5 33.7441 25.3807 32.6248 24 32.6248C22.6193 32.6248 21.5 33.7441 21.5 35.1248C21.5 36.5055 22.6193 37.6248 24 37.6248Z\"\n            fill=\"currentColor\"\n            fill-rule=\"evenodd\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Import.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M6 24V42H42V24\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M33 15L24 6L15 15\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M24 6V32\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Key.vue",
    "content": "<script setup>\nconst props = defineProps({\n    fillColor: {\n        type: String,\n        default: '#f2c55c',\n    },\n})\n</script>\n\n<template>\n    <svg\n        fill=\"none\"\n        stroke=\"currentColor\"\n        stroke-linecap=\"round\"\n        stroke-linejoin=\"round\"\n        stroke-width=\"1.4\"\n        viewBox=\"0 0 24 24\"\n        xmlns=\"http://www.w3.org/2000/svg\">\n        <path :fill=\"props.fillColor\" d=\"M2 18v3c0 .6.4 1 1 1h4v-3h3v-3h2l1.4-1.4a6.5 6.5 0 1 0-4-4Z\" />\n        <circle cx=\"16.5\" cy=\"7.5\" r=\"1.5\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Lang.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M28.2857 37H39.7143M42 42L39.7143 37L42 42ZM26 42L28.2857 37L26 42ZM28.2857 37L34 24L39.7143 37H28.2857Z\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M16 6L17 9\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M6 11H28\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M10 16C10 16 11.7895 22.2609 16.2632 25.7391C20.7368 29.2174 28 32 28 32\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M24 11C24 11 22.2105 19.2174 17.7368 23.7826C13.2632 28.3478 6 32 6 32\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Layer.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n    fillColor: {\n        type: String,\n        default: '#f2c55c',\n    },\n})\n</script>\n\n<template>\n    <svg\n        stroke=\"currentColor\"\n        stroke-linecap=\"round\"\n        stroke-linejoin=\"round\"\n        stroke-width=\"1.5\"\n        viewBox=\"0 0 24 24\"\n        xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :fill=\"props.fillColor\"\n            d=\"M10 20H4a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h3.93a2 2 0 0 1 1.66.9l.82 1.2a2 2 0 0 0 1.66.9H20a2 2 0 0 1 2 2v2\" />\n        <circle :fill=\"props.fillColor\" cx=\"16\" cy=\"20\" r=\"2\" />\n        <path d=\"m22 14-4.5 4.5\" />\n        <path d=\"m21 15 1 1\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/ListView.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M9 42C11.2091 42 13 40.2091 13 38C13 35.7909 11.2091 34 9 34C6.79086 34 5 35.7909 5 38C5 40.2091 6.79086 42 9 42Z\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M9 14C11.2091 14 13 12.2092 13 10C13 7.79086 11.2091 6 9 6C6.79086 6 5 7.79086 5 10C5 12.2092 6.79086 14 9 14Z\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M9 28C11.2091 28 13 26.2092 13 24C13 21.7908 11.2091 20 9 20C6.79086 20 5 21.7908 5 24C5 26.2092 6.79086 28 9 28Z\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M21 24H43\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M21 38H43\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M21 10H43\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/LoadAll.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            clip-rule=\"evenodd\"\n            d=\"M23.9999 31L12 19L19.9999 19L19.9999 8L27.9999 8L27.9999 19L35.9999 19L23.9999 31Z\"\n            fill=\"none\"\n            fill-rule=\"evenodd\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path :stroke-width=\"props.strokeWidth\" d=\"M42 38L6 38\" stroke=\"currentColor\" stroke-linecap=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/LoadList.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M8 28H24\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M8 37H24\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M8 19H40\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M8 10H40\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M37 40V28\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M33 36L37 40L41 36\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Loading.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M7 4H41\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M7 44H41\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M11 44C13.6667 30.6611 18 23.9944 24 24C30 24.0056 34.3333 30.6722 37 44H11Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M37 4C34.3333 17.3389 30 24.0056 24 24C18 23.9944 13.6667 17.3278 11 4H37Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M21 15H27\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M19 38H29\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Log.vue",
    "content": "<script setup>\nconst props = defineProps({\n    inverse: {\n        type: Boolean,\n        default: false,\n    },\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n    strokeColor: {\n        type: String,\n        default: '#FFF',\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <rect\n            :fill=\"props.inverse ? 'currentColor' : 'none'\"\n            :stroke-width=\"props.strokeWidth\"\n            height=\"34\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\"\n            width=\"28\"\n            x=\"13\"\n            y=\"10\" />\n        <path\n            d=\"M35 10V4H8C7.44772 4 7 4.44772 7 5V38H13\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            stroke-width=\"3\" />\n        <path\n            :stroke=\"props.inverse ? props.strokeColor : 'currentColor'\"\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M21 22H33\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke=\"props.inverse ? props.strokeColor : 'currentColor'\"\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M21 30H33\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Logout.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M23.9917 6H6V42H24\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M33 33L42 24L33 15\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M16 23.9917H42\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Monitor.vue",
    "content": "<script setup>\nconst props = defineProps({\n    inverse: {\n        type: Boolean,\n        default: false,\n    },\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n    strokeColor: {\n        type: String,\n        default: '#FFF',\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <rect\n            :fill=\"props.inverse ? 'currentColor' : 'none'\"\n            :stroke-width=\"props.strokeWidth\"\n            height=\"36\"\n            rx=\"3\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            width=\"36\"\n            x=\"6\"\n            y=\"6\" />\n        <path\n            :stroke=\"props.inverse ? props.strokeColor : 'currentColor'\"\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M12 25H15L19 14L22 36L27 23L31 29L34 25H37\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Moon.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 2,\n    },\n})\n</script>\n\n<template>\n    <svg\n        :stroke-width=\"props.strokeWidth\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        stroke-linecap=\"round\"\n        stroke-linejoin=\"round\"\n        viewBox=\"0 0 24 24\"\n        xmlns=\"http://www.w3.org/2000/svg\">\n        <path d=\"M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/More.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <circle :r=\"props.strokeWidth\" cx=\"12\" cy=\"24\" fill=\"currentColor\" />\n        <circle :r=\"props.strokeWidth\" cx=\"24\" cy=\"24\" fill=\"currentColor\" />\n        <circle :r=\"props.strokeWidth\" cx=\"36\" cy=\"24\" fill=\"currentColor\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/OffScreen.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M33 6V15H42\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M15 6V15H6\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M15 42V33H6\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M33 42V33H41.8995\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Pause.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M16 12V36\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M32 12V36\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Pin.vue",
    "content": "<script setup>\nconst props = defineProps({\n    inverse: {\n        type: Boolean,\n        default: false,\n    },\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <g clip-path=\"url(#icon-36c2b93b4e0022c)\">\n            <path\n                :fill=\"inverse ? 'currentColor' : 'none'\"\n                :stroke-width=\"props.strokeWidth\"\n                d=\"M10.6963 17.5042C13.3347 14.8657 16.4701 14.9387 19.8781 16.8076L32.62 9.74509L31.8989 4.78683L43.2126 16.1005L38.2656 15.3907L31.1918 28.1214C32.9752 31.7589 33.1337 34.6647 30.4953 37.3032C30.4953 37.3032 26.235 33.0429 22.7171 29.525L6.44305 41.5564L18.4382 25.2461C14.9202 21.7281 10.6963 17.5042 10.6963 17.5042Z\"\n                stroke=\"currentColor\"\n                stroke-linejoin=\"round\" />\n        </g>\n        <defs>\n            <clipPath id=\"icon-36c2b93b4e0022c\">\n                <rect fill=\"#FFF\" height=\"48\" width=\"48\" />\n            </clipPath>\n        </defs>\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Play.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M15 24V11.8756L25.5 17.9378L36 24L25.5 30.0622L15 36.1244V24Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Plus.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 4,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M24 8L24 40\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M8 24L40 24\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Publish.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M43 5L29.7 43L22.1 25.9L5 18.3L43 5Z\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M43.0001 5L22.1001 25.9\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/QRCode.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M20 6H6V20H20V6Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M20 28H6V42H20V28Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M42 6H28V20H42V6Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path :stroke-width=\"props.strokeWidth\" d=\"M29 28V42\" stroke=\"currentColor\" stroke-linecap=\"round\" />\n        <path :stroke-width=\"props.strokeWidth\" d=\"M41 28V42\" stroke=\"currentColor\" stroke-linecap=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Record.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M5.81836 6.72729V14H13.0911\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M4 24C4 35.0457 12.9543 44 24 44V44C35.0457 44 44 35.0457 44 24C44 12.9543 35.0457 4 24 4C16.598 4 10.1351 8.02111 6.67677 13.9981\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M24.005 12L24.0038 24.0088L32.4832 32.4882\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Refresh.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n    color: {\n        type: String,\n        default: 'currentColor',\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke=\"color\"\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M36.7279 36.7279C33.4706 39.9853 28.9706 42 24 42C14.0589 42 6 33.9411 6 24C6 14.0589 14.0589 6 24 6C28.9706 6 33.4706 8.01472 36.7279 11.2721C38.3859 12.9301 42 17 42 17\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke=\"color\"\n            :stroke-width=\"props.strokeWidth\"\n            class=\"default-stroke\"\n            d=\"M42 8V17H33\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Save.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M39.3 6H8.7C7.20883 6 6 7.20883 6 8.7V39.3C6 40.7912 7.20883 42 8.7 42H39.3C40.7912 42 42 40.7912 42 39.3V8.7C42 7.20883 40.7912 6 39.3 6Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M32 6V24H15V6H32Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path :stroke-width=\"props.strokeWidth\" d=\"M26 13V17\" stroke=\"currentColor\" stroke-linecap=\"round\" />\n        <path :stroke-width=\"props.strokeWidth\" d=\"M10.9971 6H35.9986\" stroke=\"currentColor\" stroke-linecap=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Search.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M21 38C30.3888 38 38 30.3888 38 21C38 11.6112 30.3888 4 21 4C11.6112 4 4 11.6112 4 21C4 30.3888 11.6112 38 21 38Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M33 33L42 42\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Server.vue",
    "content": "<script setup>\nconst props = defineProps({\n    inverse: {\n        type: Boolean,\n        default: false,\n    },\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :fill=\"props.inverse ? 'currentColor' : 'none'\"\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M41 4H7C5.34315 4 4 5.34315 4 7V41C4 42.6569 5.34315 44 7 44H41C42.6569 44 44 42.6569 44 41V7C44 5.34315 42.6569 4 41 4Z\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke=\"props.inverse ? '#FFF' : 'currentColor'\"\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M4 32H44\"\n            stroke-linecap=\"round\" />\n        <path\n            :stroke=\"props.inverse ? '#FFF' : 'currentColor'\"\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M10 38H11\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke=\"props.inverse ? '#FFF' : 'currentColor'\"\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M26 38H38\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Sort.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M25 14L16 5L7 14\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M15.9917 31V5\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M42 34L33 43L24 34\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M32.9917 17V43\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/SpellCheck.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg\n        :stroke-width=\"props.strokeWidth\"\n        class=\"lucide lucide-spell-check\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        stroke-linecap=\"round\"\n        stroke-linejoin=\"round\"\n        viewBox=\"0 0 24 24\"\n        xmlns=\"http://www.w3.org/2000/svg\">\n        <path d=\"m6 16 6-12 6 12\" />\n        <path d=\"M8 12h8\" />\n        <path d=\"m16 20 2 2 4-4\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Status.vue",
    "content": "<script setup>\nconst props = defineProps({\n    inverse: {\n        type: Boolean,\n        default: false,\n    },\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n    strokeColor: {\n        type: String,\n        default: '#FFF',\n    },\n})\n</script>\n\n<template>\n    <svg v-if=\"props.inverse\" fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            d=\"M42 8H6C4.89543 8 4 8.89543 4 10V38C4 39.1046 4.89543 40 6 40H42C43.1046 40 44 39.1046 44 38V10C44 8.89543 43.1046 8 42 8Z\"\n            fill=\"currentColor\"\n            stroke=\"currentColor\"\n            stroke-width=\"3\" />\n        <path :stroke=\"props.strokeColor\" :stroke-width=\"props.strokeWidth\" d=\"M24 17V31\" stroke-linecap=\"round\" />\n        <path :stroke=\"props.strokeColor\" :stroke-width=\"props.strokeWidth\" d=\"M32 24V31\" stroke-linecap=\"round\" />\n        <path :stroke=\"props.strokeColor\" :stroke-width=\"props.strokeWidth\" d=\"M16 22V31\" stroke-linecap=\"round\" />\n    </svg>\n    <svg v-else fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <rect\n            :stroke-width=\"props.strokeWidth\"\n            fill=\"none\"\n            height=\"36\"\n            rx=\"2\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            width=\"40\"\n            x=\"4\"\n            y=\"6\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M32 25V32\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M24 16V32\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M16 20V32\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Structure.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M38 20H18V28H38V20Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M32 6H18V14H32V6Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M44 34H18V42H44V34Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M17 10H5\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M17 24H5\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M17 38H5\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M5 44V4\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Subscribe.vue",
    "content": "<script setup>\nconst props = defineProps({\n    inverse: {\n        type: Boolean,\n        default: false,\n    },\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :fill=\"props.inverse ? 'currentColor' : 'none'\"\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M24 28.6292C26.5104 28.6292 28.5455 26.6004 28.5455 24.0979C28.5455 21.5954 26.5104 19.5667 24 19.5667C21.4897 19.5667 19.4546 21.5954 19.4546 24.0979C19.4546 26.6004 21.4897 28.6292 24 28.6292Z\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M16 15C10.6667 19.9706 10.6667 28.0294 16 33\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M32 33C37.3333 28.0294 37.3333 19.9706 32 15\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M9.85786 10C2.04738 17.7861 2.04738 30.4098 9.85786 38.1959\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M38.1421 38.1959C45.9526 30.4098 45.9526 17.7861 38.1421 10\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Sun.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 2,\n    },\n})\n</script>\n\n<template>\n    <svg\n        :stroke-width=\"props.strokeWidth\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        stroke-linecap=\"round\"\n        stroke-linejoin=\"round\"\n        viewBox=\"0 0 24 24\"\n        xmlns=\"http://www.w3.org/2000/svg\">\n        <circle cx=\"12\" cy=\"12\" r=\"5\" />\n        <path\n            d=\"M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Terminal.vue",
    "content": "<script setup>\nconst props = defineProps({\n    inverse: {\n        type: Boolean,\n        default: false,\n    },\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n    strokeColor: {\n        type: String,\n        default: '#FFF',\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <rect\n            :fill=\"props.inverse ? 'currentColor' : 'none'\"\n            :stroke-width=\"props.strokeWidth\"\n            height=\"32\"\n            rx=\"2\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\"\n            width=\"40\"\n            x=\"4\"\n            y=\"8\" />\n        <path\n            :stroke=\"props.inverse ? props.strokeColor : 'currentColor'\"\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M12 18L19 24L12 30\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke=\"props.inverse ? props.strokeColor : 'currentColor'\"\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M23 32H36\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/ThemeAuto.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 2,\n    },\n})\n</script>\n\n<template>\n    <svg\n        :stroke-width=\"props.strokeWidth\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        stroke-linecap=\"round\"\n        stroke-linejoin=\"round\"\n        viewBox=\"0 0 24 24\"\n        xmlns=\"http://www.w3.org/2000/svg\">\n        <circle cx=\"12\" cy=\"12\" r=\"10\" />\n        <path d=\"M12 2a10 10 0 0 1 0 20V2\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Timer.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <circle :stroke-width=\"props.strokeWidth\" cx=\"24\" cy=\"28\" fill=\"none\" r=\"16\" stroke=\"currentColor\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M28 4L20 4\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M24 4V12\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M35 16L38 13\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M24 28V22\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M24 28H18\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/TreeView.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M38 20H18V28H38V20Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M32 6H18V14H32V6Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M44 34H18V42H44V34Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M17 10H5\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M17 24H5\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M17 38H5\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M5 44V4\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Twitter.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"currentColor\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M8 2H1L9.26086 13.0145L1.44995 21.9999H4.09998L10.4883 14.651L16 22H23L14.3917 10.5223L21.8001 2H19.1501L13.1643 8.88578L8 2ZM17 20L5 4H7L19 20H17Z\"></path>\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Unlink.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M25.8927 16.0307L18.1145 8.2525C15.2506 5.38866 10.7031 5.29302 7.9572 8.0389C5.21132 10.7848 5.30696 15.3323 8.1708 18.1962L15.949 25.9744\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M31.9161 22.0707L39.6943 29.8489C42.5581 32.7127 42.9291 37.1233 39.9079 40.0062C36.8867 42.8891 32.6144 42.6564 29.7506 39.7926L21.9724 32.0144\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M21.2384 21.0759L17.3493 17.1868\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M30.3131 30.1504L26.424 26.2613\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/Update.vue",
    "content": "<script setup>\nconst props = defineProps({\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg fill=\"none\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\">\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M24 44C35.0457 44 44 35.0457 44 24C44 12.9543 35.0457 4 24 4C12.9543 4 4 12.9543 4 24C4 35.0457 12.9543 44 24 44Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M33.5424 27C32.2681 31.0571 28.4778 34 24.0002 34C19.5226 34 15.7323 31.0571 14.458 27V33\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n        <path\n            :stroke-width=\"strokeWidth\"\n            d=\"M33.5424 15V21C32.2681 16.9429 28.4778 14 24.0002 14C19.5226 14 15.7323 16.9429 14.458 21\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/WindowClose.vue",
    "content": "<script setup>\nconst props = defineProps({\n    size: {\n        type: [Number, String],\n        default: 14,\n    },\n    strokeWidth: {\n        type: [Number, String],\n        default: 3,\n    },\n})\n</script>\n\n<template>\n    <svg :height=\"props.size\" :width=\"props.size\" fill=\"none\" viewBox=\"0 0 48 48\">\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M8 8L40 40\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            stroke-width=\"4\" />\n        <path\n            :stroke-width=\"props.strokeWidth\"\n            d=\"M8 40L40 8\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            stroke-width=\"4\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/WindowMax.vue",
    "content": "<script setup>\nconst props = defineProps({\n    size: {\n        type: [Number, String],\n        default: 14,\n    },\n})\n</script>\n\n<template>\n    <svg :height=\"props.size\" :width=\"props.size\" fill=\"none\" viewBox=\"0 0 48 48\">\n        <path\n            d=\"M39 6H9C7.34315 6 6 7.34315 6 9V39C6 40.6569 7.34315 42 9 42H39C40.6569 42 42 40.6569 42 39V9C42 7.34315 40.6569 6 39 6Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-width=\"4\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/WindowMin.vue",
    "content": "<script setup>\nconst props = defineProps({\n    size: {\n        type: [Number, String],\n        default: 14,\n    },\n})\n</script>\n\n<template>\n    <svg :height=\"props.size\" :width=\"props.size\" fill=\"none\" viewBox=\"0 0 48 48\">\n        <path d=\"M8 24L40 24\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"4\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/icons/WindowRestore.vue",
    "content": "<script setup>\nconst props = defineProps({\n    size: {\n        type: [Number, String],\n        default: 14,\n    },\n})\n</script>\n\n<template>\n    <svg :height=\"props.size\" :width=\"props.size\" fill=\"none\" viewBox=\"0 0 48 48\">\n        <path\n            d=\"M13 12.4316V7.8125C13 6.2592 14.2592 5 15.8125 5H40.1875C41.7408 5 43 6.2592 43 7.8125V32.1875C43 33.7408 41.7408 35 40.1875 35H35.5163\"\n            stroke=\"currentColor\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n            stroke-width=\"4\" />\n        <path\n            d=\"M32.1875 13H7.8125C6.2592 13 5 14.2592 5 15.8125V40.1875C5 41.7408 6.2592 43 7.8125 43H32.1875C33.7408 43 35 41.7408 35 40.1875V15.8125C35 14.2592 33.7408 13 32.1875 13Z\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-linejoin=\"round\"\n            stroke-width=\"4\" />\n    </svg>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/new_value/AddHashValue.vue",
    "content": "<script setup>\nimport { ref } from 'vue'\nimport { flatMap, reject } from 'lodash'\nimport Add from '@/components/icons/Add.vue'\nimport Delete from '@/components/icons/Delete.vue'\nimport IconButton from '@/components/common/IconButton.vue'\n\nconst props = defineProps({\n    type: Number,\n    value: Array,\n})\nconst emit = defineEmits(['update:value', 'update:type'])\n\nconst updateOption = [\n    {\n        value: 0,\n        label: 'dialogue.field.overwrite_field',\n    },\n    {\n        value: 1,\n        label: 'dialogue.field.ignore_field',\n    },\n]\n\n/**\n * @typedef Hash\n * @property {string} key\n * @property {string} [value]\n */\nconst kvList = ref([{ key: '', value: '' }])\n\n/**\n *\n * @param {Hash[]} val\n */\nconst onUpdate = (val) => {\n    val = reject(val, { key: '' })\n    emit(\n        'update:value',\n        flatMap(val, (item) => [item.key, item.value]),\n    )\n}\n</script>\n\n<template>\n    <n-form-item :label=\"$t('dialogue.field.conflict_handle')\">\n        <n-radio-group :value=\"props.type\" @update:value=\"(val) => emit('update:type', val)\">\n            <n-radio-button v-for=\"(op, i) in updateOption\" :key=\"i\" :label=\"$t(op.label)\" :value=\"op.value\" />\n        </n-radio-group>\n    </n-form-item>\n    <n-form-item\n        :label=\"$t('dialogue.field.element') + ' (' + $t('common.field') + ':' + $t('common.value') + ')'\"\n        required>\n        <n-dynamic-input\n            v-model:value=\"kvList\"\n            :key-placeholder=\"$t('dialogue.field.enter_field')\"\n            :value-placeholder=\"$t('dialogue.field.enter_value')\"\n            preset=\"pair\"\n            @update:value=\"onUpdate\">\n            <template #action=\"{ index, create, remove, move }\">\n                <icon-button v-if=\"kvList.length > 1\" :icon=\"Delete\" size=\"18\" @click=\"() => remove(index)\" />\n                <icon-button :icon=\"Add\" size=\"18\" @click=\"() => create(index)\" />\n            </template>\n        </n-dynamic-input>\n    </n-form-item>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/new_value/AddListValue.vue",
    "content": "<script setup>\nimport { ref } from 'vue'\nimport { compact } from 'lodash'\nimport Add from '@/components/icons/Add.vue'\nimport Delete from '@/components/icons/Delete.vue'\nimport IconButton from '@/components/common/IconButton.vue'\n\nconst props = defineProps({\n    type: Number,\n    value: Array,\n})\nconst emit = defineEmits(['update:value', 'update:type'])\n\nconst insertOption = [\n    {\n        value: 0,\n        label: 'dialogue.field.append_item',\n    },\n    {\n        value: 1,\n        label: 'dialogue.field.prepend_item',\n    },\n]\n\nconst list = ref([''])\nconst onUpdate = (val) => {\n    val = compact(val)\n    emit('update:value', val)\n}\n</script>\n\n<template>\n    <n-form-item :label=\"$t('interface.type')\">\n        <n-radio-group :value=\"props.type\" @update:value=\"(val) => emit('update:type', val)\">\n            <n-radio-button v-for=\"(op, i) in insertOption\" :key=\"i\" :label=\"$t(op.label)\" :value=\"op.value\" />\n        </n-radio-group>\n    </n-form-item>\n    <n-form-item :label=\"$t('dialogue.field.element')\" required>\n        <n-dynamic-input v-model:value=\"list\" :placeholder=\"$t('dialogue.field.enter_elem')\" @update:value=\"onUpdate\">\n            <template #action=\"{ index, create, remove, move }\">\n                <icon-button v-if=\"list.length > 1\" :icon=\"Delete\" size=\"18\" @click=\"() => remove(index)\" />\n                <icon-button :icon=\"Add\" size=\"18\" @click=\"() => create(index)\" />\n            </template>\n        </n-dynamic-input>\n    </n-form-item>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/new_value/AddZSetValue.vue",
    "content": "<script setup>\nimport { ref } from 'vue'\nimport { isEmpty, reject } from 'lodash'\nimport Add from '@/components/icons/Add.vue'\nimport Delete from '@/components/icons/Delete.vue'\nimport IconButton from '@/components/common/IconButton.vue'\n\nconst props = defineProps({\n    type: Number,\n    value: Object,\n})\ndefineOptions({\n    inheritAttrs: false,\n})\nconst emit = defineEmits(['update:value', 'update:type'])\n\nconst updateOption = [\n    {\n        value: 0,\n        label: 'dialogue.field.overwrite_field',\n    },\n    {\n        value: 1,\n        label: 'dialogue.field.ignore_field',\n    },\n]\n\nconst zset = ref([{ value: '', score: 0 }])\nconst onCreate = () => {\n    return {\n        value: '',\n        score: 0,\n    }\n}\n/**\n * update input items\n */\nconst onUpdate = () => {\n    const val = reject(zset.value, (v) => v == null || isEmpty(v.value))\n    const result = {}\n    for (const elem of val) {\n        result[elem.value] = elem.score\n    }\n    emit('update:value', result)\n}\n</script>\n\n<template>\n    <n-form-item :label=\"$t('interface.type')\">\n        <n-radio-group :value=\"props.type\" @update:value=\"(val) => emit('update:type', val)\">\n            <n-radio-button v-for=\"(op, i) in updateOption\" :key=\"i\" :label=\"$t(op.label)\" :value=\"op.value\" />\n        </n-radio-group>\n    </n-form-item>\n    <n-form-item\n        :label=\"$t('dialogue.field.element') + ' (' + $t('common.value') + ':' + $t('common.score') + ')'\"\n        required>\n        <n-dynamic-input v-model:value=\"zset\" @create=\"onCreate\" @update:value=\"onUpdate\">\n            <template #default=\"{ value }\">\n                <n-input\n                    v-model:value=\"value.value\"\n                    :placeholder=\"$t('dialogue.field.enter_value')\"\n                    type=\"text\"\n                    @update:value=\"onUpdate\" />\n                <n-text>:</n-text>\n                <n-input-number\n                    v-model:value=\"value.score\"\n                    :placeholder=\"$t('dialogue.field.enter_score')\"\n                    :show-button=\"false\"\n                    @update:value=\"onUpdate\" />\n            </template>\n            <template #action=\"{ index, create, remove, move }\">\n                <icon-button v-if=\"zset.length > 1\" :icon=\"Delete\" size=\"18\" @click=\"() => remove(index)\" />\n                <icon-button :icon=\"Add\" size=\"18\" @click=\"() => create(index)\" />\n            </template>\n        </n-dynamic-input>\n    </n-form-item>\n</template>\n\n<style lang=\"scss\"></style>\n"
  },
  {
    "path": "frontend/src/components/new_value/NewHashValue.vue",
    "content": "<script setup>\nimport { ref } from 'vue'\nimport { flatMap, isEmpty, reject } from 'lodash'\nimport Add from '@/components/icons/Add.vue'\nimport Delete from '@/components/icons/Delete.vue'\nimport IconButton from '@/components/common/IconButton.vue'\n\nconst props = defineProps({\n    value: Array,\n})\nconst emit = defineEmits(['update:value', 'append'])\n\n/**\n * @typedef Hash\n * @property {string} key\n * @property {string} [value]\n */\nconst kvList = ref([{ key: '', value: '' }])\n\n/**\n *\n * @param {Hash[]} val\n */\nconst onUpdate = (val) => {\n    val = reject(val, { key: '' })\n    emit(\n        'update:value',\n        flatMap(val, (item) => [item.key, item.value]),\n    )\n}\n\ndefineExpose({\n    validate: () => {\n        return !isEmpty(props.value)\n    },\n})\n</script>\n\n<template>\n    <n-form-item :label=\"$t('dialogue.field.element')\" required>\n        <n-dynamic-input\n            v-model:value=\"kvList\"\n            :key-placeholder=\"$t('dialogue.field.enter_field')\"\n            :value-placeholder=\"$t('dialogue.field.enter_value')\"\n            preset=\"pair\"\n            @update:value=\"onUpdate\">\n            <template #action=\"{ index, create, remove, move }\">\n                <icon-button v-if=\"kvList.length > 1\" :icon=\"Delete\" size=\"18\" @click=\"() => remove(index)\" />\n                <icon-button\n                    :icon=\"Add\"\n                    size=\"18\"\n                    @click=\"\n                        () => {\n                            create(index)\n                            emit('append')\n                        }\n                    \" />\n            </template>\n        </n-dynamic-input>\n    </n-form-item>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/new_value/NewJsonValue.vue",
    "content": "<script setup>\nconst props = defineProps({\n    value: String,\n})\n\nconst emit = defineEmits(['update:value'])\n</script>\n\n<template>\n    <n-form-item :label=\"$t('common.value')\">\n        <n-input\n            :rows=\"6\"\n            :value=\"props.value\"\n            placeholder=\"\"\n            type=\"textarea\"\n            @input=\"(val) => emit('update:value', val)\" />\n    </n-form-item>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/new_value/NewListValue.vue",
    "content": "<script setup>\nimport { ref } from 'vue'\nimport { compact, isEmpty } from 'lodash'\nimport Add from '@/components/icons/Add.vue'\nimport Delete from '@/components/icons/Delete.vue'\nimport IconButton from '@/components/common/IconButton.vue'\n\nconst props = defineProps({\n    value: Array,\n})\nconst emit = defineEmits(['update:value', 'append'])\n\nconst list = ref([''])\nconst onUpdate = (val) => {\n    val = compact(val)\n    emit('update:value', val)\n}\n\ndefineExpose({\n    validate: () => {\n        return !isEmpty(props.value)\n    },\n})\n</script>\n\n<template>\n    <n-form-item :label=\"$t('dialogue.field.element')\" required>\n        <n-dynamic-input v-model:value=\"list\" :placeholder=\"$t('dialogue.field.enter_elem')\" @update:value=\"onUpdate\">\n            <template #action=\"{ index, create, remove, move }\">\n                <icon-button v-if=\"list.length > 1\" :icon=\"Delete\" size=\"18\" @click=\"() => remove(index)\" />\n                <icon-button\n                    :icon=\"Add\"\n                    size=\"18\"\n                    @click=\"\n                        () => {\n                            create(index)\n                            emit('append')\n                        }\n                    \" />\n            </template>\n        </n-dynamic-input>\n    </n-form-item>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/new_value/NewSetValue.vue",
    "content": "<script setup>\nimport { ref } from 'vue'\nimport { compact, isEmpty, uniq } from 'lodash'\nimport Add from '@/components/icons/Add.vue'\nimport Delete from '@/components/icons/Delete.vue'\nimport IconButton from '@/components/common/IconButton.vue'\n\nconst props = defineProps({\n    value: Array,\n})\nconst emit = defineEmits(['update:value', 'append'])\n\nconst set = ref([''])\nconst onUpdate = (val) => {\n    val = uniq(compact(val))\n    emit('update:value', val)\n}\n\ndefineExpose({\n    validate: () => {\n        return !isEmpty(props.value)\n    },\n})\n</script>\n\n<template>\n    <n-form-item :label=\"$t('dialogue.field.element')\" required>\n        <n-dynamic-input v-model:value=\"set\" :placeholder=\"$t('dialogue.field.enter_elem')\" @update:value=\"onUpdate\">\n            <template #action=\"{ index, create, remove, move }\">\n                <icon-button v-if=\"set.length > 1\" :icon=\"Delete\" size=\"18\" @click=\"() => remove(index)\" />\n                <icon-button\n                    :icon=\"Add\"\n                    size=\"18\"\n                    @click=\"\n                        () => {\n                            create(index)\n                            emit('append')\n                        }\n                    \" />\n            </template>\n        </n-dynamic-input>\n    </n-form-item>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/new_value/NewStreamValue.vue",
    "content": "<script setup>\nimport { ref } from 'vue'\nimport { flatMap, isEmpty, reject } from 'lodash'\nimport Add from '@/components/icons/Add.vue'\nimport Delete from '@/components/icons/Delete.vue'\nimport IconButton from '@/components/common/IconButton.vue'\n\nconst props = defineProps({\n    value: Array,\n})\ndefineOptions({\n    inheritAttrs: false,\n})\nconst id = ref('*')\nconst emit = defineEmits(['update:value', 'append'])\n\n/**\n * @typedef Hash\n * @property {string} key\n * @property {string} [value]\n */\nconst kvList = ref([{ key: '', value: '' }])\n\n/**\n *\n * @param {Hash[]} val\n */\nconst onUpdate = (val) => {\n    val = reject(val, { key: '' })\n    const vals = flatMap(val, (item) => [item.key, item.value])\n    vals.splice(0, 0, id.value || '*')\n    emit('update:value', vals)\n}\n\ndefineExpose({\n    validate: () => {\n        return !isEmpty(props.value)\n    },\n})\n</script>\n\n<template>\n    <n-form-item label=\"ID\">\n        <n-input v-model:value=\"id\" />\n    </n-form-item>\n    <n-form-item :label=\"$t('common.field') + ':' + $t('common.value')\" required>\n        <n-dynamic-input\n            v-model:value=\"kvList\"\n            :key-placeholder=\"$t('dialogue.field.enter_field')\"\n            :value-placeholder=\"$t('dialogue.field.enter_value')\"\n            preset=\"pair\"\n            @update:value=\"onUpdate\">\n            <template #action=\"{ index, create, remove, move }\">\n                <icon-button v-if=\"kvList.length > 1\" :icon=\"Delete\" size=\"18\" @click=\"() => remove(index)\" />\n                <icon-button\n                    :icon=\"Add\"\n                    size=\"18\"\n                    @click=\"\n                        () => {\n                            create(index)\n                            emit('append')\n                        }\n                    \" />\n            </template>\n        </n-dynamic-input>\n    </n-form-item>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/new_value/NewStringValue.vue",
    "content": "<script setup>\nconst props = defineProps({\n    value: String,\n})\n\nconst emit = defineEmits(['update:value'])\n</script>\n\n<template>\n    <n-form-item :label=\"$t('common.value')\">\n        <n-input\n            :rows=\"6\"\n            :value=\"props.value\"\n            placeholder=\"\"\n            type=\"textarea\"\n            @input=\"(val) => emit('update:value', val)\" />\n    </n-form-item>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "frontend/src/components/new_value/NewZSetValue.vue",
    "content": "<script setup>\nimport { ref } from 'vue'\nimport { flatMap, isEmpty, reject } from 'lodash'\nimport Add from '@/components/icons/Add.vue'\nimport Delete from '@/components/icons/Delete.vue'\nimport IconButton from '@/components/common/IconButton.vue'\n\nconst props = defineProps({\n    value: Array,\n})\nconst emit = defineEmits(['update:value', 'append'])\n\n/**\n * @typedef ZSetItem\n * @property {string} value\n * @property {string} score\n */\nconst zset = ref([{ value: '', score: 0 }])\nconst onCreate = () => {\n    return {\n        value: '',\n        score: 0,\n    }\n}\n/**\n * update input items\n */\nconst onUpdate = () => {\n    const val = reject(zset.value, (v) => v == null || isEmpty(v.value))\n    emit(\n        'update:value',\n        flatMap(val, (item) => [item.value, item.score.toString()]),\n    )\n}\n\ndefineExpose({\n    validate: () => {\n        return !isEmpty(props.value)\n    },\n})\n</script>\n\n<template>\n    <n-form-item :label=\"$t('dialogue.field.conflict_handle')\" required>\n        <n-dynamic-input v-model:value=\"zset\" @create=\"onCreate\" @update:value=\"onUpdate\">\n            <template #default=\"{ value }\">\n                <n-input\n                    v-model:value=\"value.value\"\n                    :placeholder=\"$t('dialogue.field.enter_member')\"\n                    type=\"text\"\n                    @update:value=\"onUpdate\" />\n                <n-input-number\n                    v-model:value=\"value.score\"\n                    :placeholder=\"$t('dialogue.field.enter_score')\"\n                    :show-button=\"false\"\n                    @update:value=\"onUpdate\" />\n            </template>\n            <template #action=\"{ index, create, remove, move }\">\n                <icon-button v-if=\"zset.length > 1\" :icon=\"Delete\" size=\"18\" @click=\"() => remove(index)\" />\n                <icon-button\n                    :icon=\"Add\"\n                    size=\"18\"\n                    @click=\"\n                        () => {\n                            create(index)\n                            emit('append')\n                        }\n                    \" />\n            </template>\n        </n-dynamic-input>\n    </n-form-item>\n</template>\n\n<style lang=\"scss\"></style>\n"
  },
  {
    "path": "frontend/src/components/sidebar/BrowserPane.vue",
    "content": "<script setup>\nimport { useThemeVars } from 'naive-ui'\nimport BrowserTree from './BrowserTree.vue'\nimport IconButton from '@/components/common/IconButton.vue'\nimport useTabStore from 'stores/tab.js'\nimport { computed, nextTick, onMounted, reactive, ref, unref, watch } from 'vue'\nimport { find, get, isEmpty, map, size } from 'lodash'\nimport Refresh from '@/components/icons/Refresh.vue'\nimport useDialogStore from 'stores/dialog.js'\nimport { useI18n } from 'vue-i18n'\nimport Search from '@/components/icons/Search.vue'\nimport Unlink from '@/components/icons/Unlink.vue'\nimport ContentSearchInput from '@/components/content_value/ContentSearchInput.vue'\nimport LoadAll from '@/components/icons/LoadAll.vue'\nimport LoadList from '@/components/icons/LoadList.vue'\nimport Delete from '@/components/icons/Delete.vue'\nimport useBrowserStore from 'stores/browser.js'\nimport { useRender } from '@/utils/render.js'\nimport RedisTypeSelector from '@/components/common/RedisTypeSelector.vue'\nimport { types } from '@/consts/support_redis_type.js'\nimport Plus from '@/components/icons/Plus.vue'\nimport useConnectionStore from 'stores/connections.js'\nimport Close from '@/components/icons/Close.vue'\nimport More from '@/components/icons/More.vue'\nimport Export from '@/components/icons/Export.vue'\nimport { ConnectionType } from '@/consts/connection_type.js'\nimport Import from '@/components/icons/Import.vue'\nimport Checkbox from '@/components/icons/Checkbox.vue'\nimport Timer from '@/components/icons/Timer.vue'\nimport { toVersionArray } from '@/utils/version.js'\n\nconst props = defineProps({\n    server: String,\n    db: {\n        type: Number,\n        default: 0,\n    },\n})\n\nconst themeVars = useThemeVars()\nconst i18n = useI18n()\nconst dialogStore = useDialogStore()\nconst tabStore = useTabStore()\nconst browserStore = useBrowserStore()\nconst connectionStore = useConnectionStore()\nconst render = useRender()\nconst browserTreeRef = ref(null)\nconst filterInputRef = ref(null)\nconst loading = ref(false)\nconst fullyLoaded = ref(false)\nconst inCheckState = ref(false)\n\nconst dbSelectOptions = computed(() => {\n    const dblist = browserStore.getDBList(props.server)\n    const hasPattern = !isEmpty(filterForm.pattern)\n    return map(dblist, ({ db, alias, keyCount, maxKeys }) => {\n        keyCount = Math.max(0, keyCount)\n        maxKeys = Math.max(keyCount, maxKeys)\n        let label\n        if (!isEmpty(alias)) {\n            // has alias\n            label = `${alias}[${db}]`\n        } else {\n            label = `db${db}`\n        }\n        if (props.db === db) {\n            if (hasPattern) {\n                label += ` (${keyCount})`\n            } else {\n                label += ` (${keyCount}/${maxKeys})`\n            }\n        } else {\n            label += ` (${maxKeys})`\n        }\n        return {\n            value: db,\n            label: label,\n        }\n    })\n})\n\nconst showTypeFilter = computed(() => {\n    const version = browserStore.getServerVersion(props.server)\n    const verArr = toVersionArray(version)\n    return verArr[0] > 5\n})\n\nconst moreOptions = [\n    { key: 'import', label: 'interface.import_key', icon: Import },\n    { key: 'divider1', type: 'divider' },\n    { key: 'delete', label: 'interface.batch_delete_key', icon: Delete },\n    { key: 'flush', label: 'interface.flush_db', icon: Delete },\n    { key: 'divider2', type: 'divider' },\n    { key: 'disconnect', label: 'interface.disconnect', icon: Unlink },\n]\n\nconst loadProgress = computed(() => {\n    const hasPattern = !isEmpty(filterForm.pattern)\n    if (hasPattern) {\n        return 100\n    }\n\n    const db = browserStore.getDatabase(props.server, props.db)\n    if (db.maxKeys <= 0) {\n        return 100\n    }\n    return (db.keyCount * 100) / Math.max(db.keyCount, db.maxKeys)\n})\n\nconst checkedCount = computed(() => {\n    return size(tabStore.getCheckedKeys(props.server))\n})\n\nconst checkedTip = computed(() => {\n    const dblist = browserStore.getDBList(props.server)\n    const db = find(dblist, { db: props.db })\n    return `${checkedCount.value} / ${Math.max(db.keyCount, checkedCount.value)}`\n})\n\nconst onReload = async () => {\n    try {\n        loading.value = true\n        // tabStore.setSelectedKeys(props.server)\n        const db = props.db\n        browserStore.closeDatabase(props.server, db)\n\n        let matchType = unref(filterForm.type)\n        if (!types.hasOwnProperty(matchType)) {\n            matchType = ''\n        }\n        browserStore.setKeyFilter(props.server, {\n            type: matchType,\n            pattern: unref(filterForm.pattern),\n            exact: unref(filterForm.exact) === true,\n        })\n        await browserStore.openDatabase(props.server, db)\n        fullyLoaded.value = await browserStore.loadMoreKeys(props.server, db)\n        // $message.success(i18n.t('dialogue.reload_succ'))\n    } catch (e) {\n        console.warn(e)\n    } finally {\n        loading.value = false\n    }\n}\n\nconst onAddKey = () => {\n    const selectedKey = get(browserTreeRef.value?.getSelectedKey(), 0)\n    if (selectedKey != null) {\n        const node = browserStore.getNode(selectedKey)\n        if (node != null) {\n            const { type = ConnectionType.RedisValue, redisKey } = node\n            if (type === ConnectionType.RedisKey) {\n                // has prefix\n                dialogStore.openNewKeyDialog(redisKey, props.server, props.db)\n                return\n            }\n        }\n    }\n    dialogStore.openNewKeyDialog('', props.server, props.db)\n}\n\nconst onLoadMore = async () => {\n    try {\n        loading.value = true\n        fullyLoaded.value = await browserStore.loadMoreKeys(props.server, props.db)\n    } catch (e) {\n        $message.error(e.message)\n    } finally {\n        loading.value = false\n    }\n}\n\nconst onLoadAll = async () => {\n    try {\n        loading.value = true\n        await browserStore.loadAllKeys(props.server, props.db)\n        fullyLoaded.value = true\n    } catch (e) {\n        $message.error(e.message)\n    } finally {\n        loading.value = false\n    }\n}\n\nconst onDeleteChecked = () => {\n    browserTreeRef.value?.deleteCheckedItems()\n}\n\nconst onExportChecked = () => {\n    browserTreeRef.value?.exportCheckedItems()\n}\n\nconst onUpdateTTLChecked = () => {\n    browserTreeRef.value?.updateTTLCheckedItems()\n}\n\nconst onImportData = () => {\n    dialogStore.openImportKeyDialog(props.server, props.db)\n}\n\nconst onFlush = () => {\n    dialogStore.openFlushDBDialog(props.server, props.db)\n}\n\nconst onDisconnect = () => {\n    browserStore.closeConnection(props.server)\n}\n\nconst handleSelectDB = async (db) => {\n    if (db === props.db) {\n        return\n    }\n\n    try {\n        loading.value = true\n        browserStore.setKeyFilter(props.server, {})\n        browserStore.closeDatabase(props.server, props.db)\n        filterInputRef.value?.reset()\n        await browserStore.openDatabase(props.server, db)\n        await nextTick()\n        await connectionStore.saveLastDB(props.server, db)\n        tabStore.upsertTab({ server: props.server, db, clearValue: true })\n        fullyLoaded.value = await browserStore.loadMoreKeys(props.server, db)\n        browserTreeRef.value?.refreshTree()\n        tabStore.setSelectedKeys(props.server)\n    } catch (e) {\n        $message.error(e.message)\n    } finally {\n        loading.value = false\n    }\n}\n\nconst filterForm = reactive({\n    type: '',\n    exact: false,\n    pattern: '',\n    filter: '',\n})\nconst onSelectFilterType = (select) => {\n    onReload()\n}\n\nconst onFilterInput = (val, exact) => {\n    filterForm.filter = val\n    filterForm.exact = exact\n}\n\nconst onMatchInput = (matchVal, filterVal, exact) => {\n    filterForm.pattern = matchVal\n    filterForm.filter = filterVal\n    filterForm.exact = exact\n    onReload()\n}\n\nconst onSelectOptions = (select) => {\n    switch (select) {\n        case 'import':\n            onImportData()\n            break\n        case 'delete':\n            let key = '*'\n            const selectedKey = get(browserTreeRef.value?.getSelectedKey(), 0)\n            if (selectedKey != null) {\n                const node = browserStore.getNode(selectedKey)\n                if (node != null) {\n                    const { type = ConnectionType.RedisValue, redisKey } = node\n                    if (type === ConnectionType.RedisKey) {\n                        // has prefix\n                        key = redisKey + browserStore.getSeparator(props.server) + '*'\n                    }\n                }\n            }\n            dialogStore.openDeleteKeyDialog(props.server, props.db, key)\n            break\n        case 'flush':\n            onFlush()\n            break\n        case 'disconnect':\n            onDisconnect()\n            break\n    }\n}\n\nonMounted(() => onReload())\n\nwatch(\n    () => browserStore.getReloadKey(props.server),\n    (key) => onReload(),\n)\n// forbid dynamic switch key view due to performance issues\n// const viewType = ref(0)\n// const onSwitchView = (selectView) => {\n//     const { server } = tabStore.currentTab\n//     browserStore.switchKeyView(server, selectView)\n// }\n</script>\n\n<template>\n    <div class=\"nav-pane-container flex-box-v\">\n        <!-- top function bar -->\n        <div class=\"flex-box-h nav-pane-func\" style=\"height: 36px\">\n            <content-search-input\n                ref=\"filterInputRef\"\n                :debounce-wait=\"1000\"\n                :full-search-icon=\"Search\"\n                small\n                use-glob\n                @filter-changed=\"onFilterInput\"\n                @match-changed=\"onMatchInput\">\n                <template #prepend>\n                    <redis-type-selector\n                        v-model:value=\"filterForm.type\"\n                        :disabled=\"!showTypeFilter\"\n                        :disable-tip=\"$t('dialogue.filter.filter_type_not_support')\"\n                        @update:value=\"onSelectFilterType\" />\n                </template>\n            </content-search-input>\n            <n-button-group>\n                <icon-button\n                    :disabled=\"loading\"\n                    :icon=\"Refresh\"\n                    border\n                    size=\"18\"\n                    small\n                    stroke-width=\"4\"\n                    t-tooltip=\"interface.reload\"\n                    @click=\"onReload\" />\n                <icon-button\n                    :disabled=\"loading\"\n                    :icon=\"Plus\"\n                    border\n                    size=\"18\"\n                    small\n                    stroke-width=\"4\"\n                    t-tooltip=\"interface.new_key\"\n                    @click=\"onAddKey\" />\n            </n-button-group>\n        </div>\n\n        <!-- loaded progress -->\n        <n-progress\n            :border-radius=\"0\"\n            :color=\"loadProgress >= 100 ? '#0000' : themeVars.primaryColor\"\n            :height=\"2\"\n            :percentage=\"loadProgress\"\n            :processing=\"loading\"\n            :show-indicator=\"false\"\n            status=\"success\"\n            type=\"line\" />\n\n        <!-- tree view -->\n        <browser-tree\n            ref=\"browserTreeRef\"\n            :check-mode=\"inCheckState\"\n            :db=\"props.db\"\n            :full-loaded=\"fullyLoaded\"\n            :loading=\"loading && loadProgress <= 0\"\n            :pattern=\"filterForm.filter\"\n            :server=\"props.server\" />\n        <!-- bottom function bar -->\n        <div class=\"nav-pane-bottom flex-box-v\">\n            <!--            <switch-button-->\n            <!--                v-model:value=\"viewType\"-->\n            <!--                :icons=\"[TreeView, ListView]\"-->\n            <!--                :t-tooltips=\"['interface.tree_view', 'interface.list_view']\"-->\n            <!--                :stroke-width=\"3.5\"-->\n            <!--                unselect-stroke-width=\"3\"-->\n            <!--                @update:value=\"onSwitchView\" />-->\n            <transition mode=\"out-in\" name=\"fade\">\n                <div v-if=\"!inCheckState\" class=\"flex-box-h nav-pane-func\">\n                    <n-select\n                        :consistent-menu-width=\"false\"\n                        :filter=\"(pattern, option) => option.value.toString() === pattern\"\n                        :options=\"dbSelectOptions\"\n                        :value=\"props.db\"\n                        filterable\n                        size=\"small\"\n                        style=\"min-width: 100px; max-width: 200px\"\n                        @update:value=\"handleSelectDB\" />\n                    <icon-button\n                        :button-class=\"['nav-pane-func-btn']\"\n                        :disabled=\"fullyLoaded\"\n                        :icon=\"LoadList\"\n                        :loading=\"loading\"\n                        :stroke-width=\"3.5\"\n                        size=\"21\"\n                        t-tooltip=\"interface.load_more\"\n                        @click=\"onLoadMore\" />\n                    <icon-button\n                        :button-class=\"['nav-pane-func-btn']\"\n                        :disabled=\"fullyLoaded\"\n                        :icon=\"LoadAll\"\n                        :loading=\"loading\"\n                        :stroke-width=\"3.5\"\n                        size=\"21\"\n                        t-tooltip=\"interface.load_all\"\n                        @click=\"onLoadAll\" />\n                    <div class=\"flex-item-expand\" style=\"min-width: 10px\" />\n                    <icon-button\n                        :button-class=\"['nav-pane-func-btn']\"\n                        :icon=\"Checkbox\"\n                        :stroke-width=\"3.5\"\n                        size=\"19\"\n                        t-tooltip=\"interface.check_mode\"\n                        @click=\"inCheckState = true\" />\n                    <n-dropdown\n                        :options=\"moreOptions\"\n                        :render-icon=\"({ icon }) => render.renderIcon(icon, { strokeWidth: 3.5 })\"\n                        :render-label=\"({ label }) => $t(label)\"\n                        placement=\"top-end\"\n                        style=\"min-width: 130px\"\n                        trigger=\"click\"\n                        @select=\"onSelectOptions\">\n                        <icon-button :button-class=\"['nav-pane-func-btn']\" :icon=\"More\" :stroke-width=\"3.5\" size=\"20\" />\n                    </n-dropdown>\n                </div>\n\n                <!-- check mode function bar -->\n                <div v-else class=\"flex-box-h nav-pane-func\">\n                    <icon-button\n                        :button-class=\"['nav-pane-func-btn']\"\n                        :disabled=\"checkedCount <= 0\"\n                        :icon=\"Export\"\n                        :stroke-width=\"3.5\"\n                        size=\"20\"\n                        t-tooltip=\"interface.export_checked\"\n                        @click=\"onExportChecked\" />\n                    <icon-button\n                        :button-class=\"['nav-pane-func-btn']\"\n                        :disabled=\"checkedCount <= 0\"\n                        :icon=\"Timer\"\n                        :stroke-width=\"3.5\"\n                        size=\"20\"\n                        t-tooltip=\"interface.ttl_checked\"\n                        @click=\"onUpdateTTLChecked\" />\n                    <icon-button\n                        :button-class=\"['nav-pane-func-btn']\"\n                        :disabled=\"checkedCount <= 0\"\n                        :icon=\"Delete\"\n                        :stroke-width=\"3.5\"\n                        size=\"20\"\n                        t-tooltip=\"interface.delete_checked\"\n                        @click=\"onDeleteChecked\" />\n                    <div class=\"flex-item-expand ellipsis\" style=\"text-align: center; margin: 0 5px\">\n                        <n-text>{{ checkedTip }}</n-text>\n                    </div>\n                    <icon-button\n                        :button-class=\"['nav-pane-func-btn']\"\n                        :icon=\"Close\"\n                        :stroke-width=\"3.5\"\n                        size=\"20\"\n                        t-tooltip=\"interface.quit_check_mode\"\n                        @click=\"inCheckState = false\" />\n                </div>\n            </transition>\n        </div>\n    </div>\n</template>\n\n<style lang=\"scss\" scoped>\n@use '@/styles/style' as style;\n\n:deep(.toggle-btn) {\n    border-style: solid;\n    border-width: 1px;\n    border-radius: 3px;\n    padding: 4px;\n}\n\n:deep(.toggle-on) {\n    border-color: v-bind('themeVars.iconColorDisabled');\n    background-color: v-bind('themeVars.iconColorDisabled');\n}\n\n:deep(.toggle-off) {\n    border-color: #0000;\n}\n\n.nav-pane-top {\n    //@include bottom-shadow(0.1);\n    color: v-bind('themeVars.iconColor');\n    border-bottom: v-bind('themeVars.borderColor') 1px solid;\n}\n\n.nav-pane-bottom {\n    @include style.top-shadow(0.1);\n    color: v-bind('themeVars.iconColor');\n    border-top: v-bind('themeVars.borderColor') 1px solid;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/sidebar/BrowserTree.vue",
    "content": "<script setup>\nimport { computed, h, markRaw, nextTick, reactive, ref, watchEffect } from 'vue'\nimport { ConnectionType } from '@/consts/connection_type.js'\nimport { NIcon, NSpace, NText, useThemeVars } from 'naive-ui'\nimport Key from '@/components/icons/Key.vue'\nimport Binary from '@/components/icons/Binary.vue'\nimport Database from '@/components/icons/Database.vue'\nimport { filter, find, first, get, includes, isEmpty, last, map, size, toUpper } from 'lodash'\nimport { useI18n } from 'vue-i18n'\nimport Refresh from '@/components/icons/Refresh.vue'\nimport CopyLink from '@/components/icons/CopyLink.vue'\nimport Add from '@/components/icons/Add.vue'\nimport Layer from '@/components/icons/Layer.vue'\nimport Delete from '@/components/icons/Delete.vue'\nimport useDialogStore from 'stores/dialog.js'\nimport useConnectionStore from 'stores/connections.js'\nimport useTabStore from 'stores/tab.js'\nimport IconButton from '@/components/common/IconButton.vue'\nimport { parseHexColor } from '@/utils/rgb.js'\nimport LoadList from '@/components/icons/LoadList.vue'\nimport LoadAll from '@/components/icons/LoadAll.vue'\nimport useBrowserStore from 'stores/browser.js'\nimport { useRender } from '@/utils/render.js'\nimport RedisTypeTag from '@/components/common/RedisTypeTag.vue'\nimport usePreferencesStore from 'stores/preferences.js'\nimport { typesIconStyle } from '@/consts/support_redis_type.js'\nimport { nativeRedisKey } from '@/utils/key_convert.js'\nimport { ClipboardSetText } from 'wailsjs/runtime/runtime.js'\nimport { isMacOS } from '@/utils/platform.js'\n\nconst props = defineProps({\n    server: String,\n    db: Number,\n    keyView: String,\n    loading: Boolean,\n    pattern: String,\n    fullLoaded: Boolean,\n    checkMode: Boolean,\n})\n\nconst themeVars = useThemeVars()\nconst render = useRender()\nconst i18n = useI18n()\nconst connectionStore = useConnectionStore()\nconst browserStore = useBrowserStore()\nconst prefStore = usePreferencesStore()\nconst tabStore = useTabStore()\nconst dialogStore = useDialogStore()\n\n/**\n *\n * @type {ComputedRef<string[]>}\n */\nconst expandedKeys = computed(() => {\n    const tab = find(tabStore.tabList, { name: props.server })\n    if (tab != null) {\n        return get(tab, 'expandedKeys', [])\n    }\n    return []\n})\n\n/**\n *\n * @type {ComputedRef<string[]>}\n */\nconst selectedKeys = computed(() => {\n    const tab = find(tabStore.tabList, { name: props.server })\n    if (tab != null) {\n        return get(tab, 'selectedKeys', [])\n    }\n    return []\n})\n\n/**\n *\n * @type {ComputedRef<string[]>}\n */\nconst checkedKeys = computed(() => {\n    const tab = find(tabStore.tabList, { name: props.server })\n    if (tab != null) {\n        const checkedKeys = get(tab, 'checkedKeys', [])\n        return map(checkedKeys, 'key')\n    }\n    return []\n})\n\nconst data = computed(() => {\n    return browserStore.getKeyStruct(props.server, props.checkMode)\n})\n\nconst backgroundColor = computed(() => {\n    const { markColor: hex = '' } = connectionStore.serverProfile[props.server] || {}\n    if (isEmpty(hex)) {\n        return ''\n    }\n    const { r, g, b } = parseHexColor(hex)\n    return `rgba(${r}, ${g}, ${b}, 0.2)`\n})\n\nconst contextMenuParam = reactive({\n    show: false,\n    x: 0,\n    y: 0,\n    options: null,\n})\n\nconst menuOptions = {\n    [ConnectionType.RedisKey]: [\n        // {\n        //     key: 'key_reload',\n        //     label: 'interface.reload'),\n        //     icon: Refresh,\n        // },\n        {\n            key: 'key_newkey',\n            label: 'interface.new_key',\n            icon: Add,\n        },\n        {\n            key: 'key_copy',\n            label: 'interface.copy_path',\n            icon: CopyLink,\n        },\n        {\n            type: 'divider',\n            key: 'd1',\n        },\n        {\n            key: 'key_remove',\n            label: 'interface.batch_delete_key',\n            icon: Delete,\n        },\n    ],\n    [ConnectionType.RedisValue]: [\n        {\n            key: 'value_reload',\n            label: 'interface.reload',\n            icon: Refresh,\n        },\n        {\n            key: 'value_copy',\n            label: 'interface.copy_key',\n            icon: CopyLink,\n        },\n        {\n            type: 'divider',\n            key: 'd1',\n        },\n        {\n            key: 'value_remove',\n            label: 'interface.remove_key',\n            icon: Delete,\n        },\n    ],\n}\n\nconst handleKeyUp = () => {\n    const selectedKey = get(selectedKeys.value, 0)\n    if (selectedKey == null) {\n        return\n    }\n    let node = browserStore.getNode(selectedKey)\n    if (node == null) {\n        return\n    }\n\n    let parentNode = browserStore.getParentNode(selectedKey)\n    if (parentNode == null) {\n        return\n    }\n    const nodeIndex = parentNode.children.indexOf(node)\n    if (nodeIndex <= 0) {\n        if (parentNode.type === ConnectionType.RedisKey || parentNode.type === ConnectionType.RedisValue) {\n            onUpdateSelectedKeys([parentNode.key])\n            updateKeyDetail(parentNode)\n        }\n        return\n    }\n\n    // try select pre node\n    let preNode = parentNode.children[nodeIndex - 1]\n    while (preNode.expanded && !isEmpty(preNode.children)) {\n        preNode = last(preNode.children)\n    }\n    onUpdateSelectedKeys([preNode.key])\n    updateKeyDetail(preNode)\n}\n\nconst handleKeyDown = () => {\n    const selectedKey = get(selectedKeys.value, 0)\n    if (selectedKey == null) {\n        return\n    }\n    let node = browserStore.getNode(selectedKey)\n    if (node == null) {\n        return\n    }\n    // try select first child if expanded\n    if (node.expanded && !isEmpty(node.children)) {\n        const childNode = get(node.children, 0)\n        onUpdateSelectedKeys([childNode.key])\n        updateKeyDetail(childNode)\n        return\n    }\n\n    let travelCount = 0\n    let childKey = selectedKey\n    do {\n        if (travelCount++ > 20) {\n            return\n        }\n        // find out parent node\n        const parentNode = browserStore.getParentNode(childKey)\n        if (parentNode == null) {\n            return\n        }\n        const nodeIndex = parentNode.children.indexOf(node)\n        if (nodeIndex < 0 || nodeIndex >= parentNode.children.length - 1) {\n            // last child, try select parent's neighbor node\n            childKey = parentNode.key\n            node = parentNode\n        } else {\n            // select next node\n            const childNode = parentNode.children[nodeIndex + 1]\n            onUpdateSelectedKeys([childNode.key])\n            updateKeyDetail(childNode)\n            return\n        }\n    } while (true)\n}\n\nconst handleKeyLeft = () => {\n    const selectedKey = get(selectedKeys.value, 0)\n    if (selectedKey == null) {\n        return\n    }\n    let node = browserStore.getNode(selectedKey)\n    if (node == null) {\n        return\n    }\n\n    if (node.type === ConnectionType.RedisKey) {\n        if (node.expanded) {\n            // try collapse\n            onUpdateExpanded([node.key], null, { node, action: 'collapse' })\n            return\n        }\n    }\n\n    // try select parent node\n    let parentNode = browserStore.getParentNode(selectedKey)\n    if (parentNode == null || parentNode.type !== ConnectionType.RedisKey) {\n        return\n    }\n    onUpdateSelectedKeys([parentNode.key])\n    updateKeyDetail(parentNode)\n}\n\nconst handleKeyRight = () => {\n    const selectedKey = get(selectedKeys.value, 0)\n    if (selectedKey == null) {\n        return\n    }\n    let node = browserStore.getNode(selectedKey)\n    if (node == null) {\n        return\n    }\n\n    if (node.type === ConnectionType.RedisKey) {\n        if (!node.expanded) {\n            // try expand\n            onUpdateExpanded([node.key], null, { node, action: 'expand' })\n        } else if (!isEmpty(node.children)) {\n            // try select first child\n            const childNode = first(node.children)\n            onUpdateSelectedKeys([childNode.key])\n            updateKeyDetail(childNode)\n        }\n    } else if (node.type === ConnectionType.RedisValue) {\n        handleKeyDown()\n    }\n}\n\nconst handleKeyDelete = () => {\n    const selectedKey = get(selectedKeys.value, 0)\n    if (selectedKey == null) {\n        return\n    }\n    let node = browserStore.getNode(selectedKey)\n    if (node == null) {\n        return\n    }\n\n    const { db = 0, key: nodeKey, redisKey: rk = '', redisKeyCode: rkc, label } = node || {}\n    const redisKey = rkc || rk\n    const redisKeyName = !!rkc ? label : redisKey\n    switch (node.type) {\n        case ConnectionType.RedisKey:\n            dialogStore.openDeleteKeyDialog(props.server, db, isEmpty(redisKey) ? '*' : redisKey + ':*')\n            break\n        case ConnectionType.RedisValue:\n            $dialog.warning(i18n.t('dialogue.remove_tip', { name: redisKeyName }), () => {\n                browserStore.deleteKey(props.server, db, redisKey).then((success) => {\n                    if (success) {\n                        $message.success(i18n.t('dialogue.delete.success', { key: redisKeyName }))\n                    }\n                })\n            })\n            break\n    }\n}\n\nconst handleKeyCopy = () => {\n    const selectedKey = get(selectedKeys.value, 0)\n    if (selectedKey == null) {\n        return\n    }\n    let node = browserStore.getNode(selectedKey)\n    if (node == null) {\n        return\n    }\n\n    if (node.type === ConnectionType.RedisValue) {\n        ClipboardSetText(nativeRedisKey(node.redisKeyCode || node.redisKey))\n        $message.success(i18n.t('interface.copy_succ'))\n    }\n}\n\nconst onKeyShortcut = (e) => {\n    const isCtrlOn = isMacOS() ? e.metaKey : e.ctrlKey\n    switch (e.key) {\n        case 'ArrowUp':\n            handleKeyUp()\n            break\n        case 'ArrowDown':\n            handleKeyDown()\n            break\n        case 'ArrowLeft':\n            handleKeyLeft()\n            break\n        case 'ArrowRight':\n            handleKeyRight()\n            break\n        case 'c':\n            if (isCtrlOn) {\n                handleKeyCopy()\n            }\n            break\n        case 'Delete':\n            handleKeyDelete()\n            break\n        case 'F5':\n            handleSelectContextMenu('value_reload')\n            break\n        case 'r':\n            if (isCtrlOn) {\n                handleSelectContextMenu('value_reload')\n            }\n            break\n    }\n}\n\nconst handleSelectContextMenu = (action) => {\n    contextMenuParam.show = false\n    const selectedKey = get(selectedKeys.value, 0)\n    if (selectedKey == null) {\n        return\n    }\n    const node = browserStore.getNode(selectedKey)\n    const { db = 0, key: nodeKey, redisKey: rk = '', redisKeyCode: rkc, label } = node || {}\n    const redisKey = rkc || rk\n    const redisKeyName = !!rkc ? label : redisKey\n    switch (action) {\n        case 'key_newkey':\n            dialogStore.openNewKeyDialog(redisKey, props.server, db)\n            break\n        case 'db_filter':\n            // const { match: pattern, type } = browserStore.getKeyFilter(props.server)\n            // dialogStore.openKeyFilterDialog(props.server, db, pattern, type)\n            break\n        case 'key_reload':\n            if (node != null && !!!node.loading) {\n                node.loading = true\n                browserStore.reloadLayer(props.server, db, redisKey).finally(() => {\n                    delete node.loading\n                })\n            }\n            break\n        case 'value_reload':\n            browserStore.reloadKey({\n                server: props.server,\n                db,\n                key: redisKey,\n            })\n            break\n        case 'key_remove':\n            dialogStore.openDeleteKeyDialog(props.server, db, isEmpty(redisKey) ? '*' : redisKey + ':*')\n            break\n        case 'value_remove':\n            $dialog.warning(i18n.t('dialogue.remove_tip', { name: redisKeyName }), () => {\n                browserStore.deleteKey(props.server, db, redisKey).then((success) => {\n                    if (success) {\n                        $message.success(i18n.t('dialogue.delete.success', { key: redisKeyName }))\n                    }\n                })\n            })\n            break\n        case 'key_copy':\n        case 'value_copy':\n            ClipboardSetText(nativeRedisKey(redisKey))\n            $message.success(i18n.t('interface.copy_succ'))\n            break\n        case 'db_loadall':\n            if (node != null && !!!node.loading) {\n                node.loading = true\n                browserStore\n                    .loadAllKeys(props.server, db)\n                    .catch((e) => {\n                        $message.error(e.message)\n                    })\n                    .finally(() => {\n                        delete node.loading\n                        node.fullLoaded = true\n                    })\n            }\n            break\n        case 'more_action':\n        default:\n            console.warn('TODO: handle context menu:' + action)\n    }\n}\n\nconst onUpdateSelectedKeys = (keys, options) => {\n    if (!isEmpty(keys)) {\n        tabStore.setSelectedKeys(props.server, keys)\n    } else {\n        // default is load blank key to display server status\n        // tabStore.openBlank(props.server)\n    }\n}\n\nconst onUpdateExpanded = (value, option, meta) => {\n    const expand = meta.action === 'expand'\n    if (expand) {\n        tabStore.addExpandedKey(props.server, value)\n    } else {\n        tabStore.removeExpandedKey(props.server, value)\n    }\n    let node = meta.node\n    if (!node) {\n        return\n    }\n\n    // keep expand or collapse children while they own more than 1 child\n    do {\n        const key = node.key\n        if (expand) {\n            if (node.type === ConnectionType.RedisKey) {\n                node.expanded = true\n                tabStore.addExpandedKey(props.server, key)\n            }\n        } else {\n            node.expanded = false\n            tabStore.removeExpandedKey(props.server, key)\n        }\n        if (size(node.children) === 1) {\n            node = node.children[0]\n        } else {\n            break\n        }\n    } while (true)\n}\n\n/**\n *\n * @param {string[]} keys\n * @param {TreeOption[]} options\n */\nconst onUpdateCheckedKeys = (keys, options) => {\n    const hasFilter = !isEmpty(props.pattern)\n    const checkedKeys = map(\n        filter(\n            options,\n            (o) => o.type === ConnectionType.RedisValue && (!hasFilter || includes(o.redisKey, props.pattern)),\n        ),\n        (o) => ({ key: o.key, redisKey: o.redisKeyCode || o.redisKey }),\n    )\n    tabStore.setCheckedKeys(props.server, checkedKeys)\n}\n\nconst renderPrefix = ({ option }) => {\n    switch (option.type) {\n        // case ConnectionType.Server:\n        //     const icon = option.cluster === true ? ToggleCluster : ToggleServer\n        //     return h(\n        //         NIcon,\n        //         { size: 20 },\n        //         {\n        //             default: () => h(icon, { modelValue: false }),\n        //         },\n        //     )\n        case ConnectionType.RedisDB:\n            return h(\n                NIcon,\n                { size: 20, color: option.opened === true ? '#dc423c' : undefined },\n                {\n                    default: () => h(Database, { inverse: option.opened === true }),\n                },\n            )\n\n        case ConnectionType.RedisKey:\n            return h(\n                NIcon,\n                { size: 20 },\n                {\n                    default: () => h(Layer),\n                },\n            )\n\n        case ConnectionType.RedisValue:\n            if (prefStore.keyIconType === typesIconStyle.ICON) {\n                return h(NIcon, { size: 20 }, () => h(Key))\n            }\n            const loading = isEmpty(option.redisType) || option.redisType === 'loading'\n            if (loading) {\n                browserStore.loadKeyType({\n                    server: props.server,\n                    db: option.db,\n                    key: option.redisKeyCode || option.redisKey,\n                })\n            }\n            switch (prefStore.keyIconType) {\n                case typesIconStyle.FULL:\n                    return h(RedisTypeTag, {\n                        type: toUpper(option.redisType),\n                        short: false,\n                        size: 'small',\n                        inverse: includes(selectedKeys.value, option.key),\n                        loading,\n                    })\n\n                case typesIconStyle.POINT:\n                    return h(RedisTypeTag, {\n                        type: toUpper(option.redisType),\n                        point: true,\n                        loading,\n                    })\n\n                case typesIconStyle.SHORT:\n                default:\n                    return h(RedisTypeTag, {\n                        type: toUpper(option.redisType),\n                        short: true,\n                        size: 'small',\n                        loading,\n                        inverse: includes(selectedKeys.value, option.key),\n                    })\n            }\n    }\n}\n\n// render tree item label\nconst renderLabel = ({ option }) => {\n    switch (option.type) {\n        case ConnectionType.RedisKey:\n            if (option.label === '') {\n                // blank label name\n                return h('div', [\n                    h(NText, { italic: true, depth: 3 }, () => '[NO NAME]'),\n                    h('span', () => ` (${option.keyCount || 0})`),\n                ])\n            }\n            return `${option.label} (${option.keyCount || 0})`\n        // case ConnectionType.RedisValue:\n        //   return `[${option.keyType}]${option.label}`\n    }\n    return option.label\n}\n\n// render horizontal item\nconst renderIconMenu = (items) => {\n    return h(\n        NSpace,\n        {\n            align: 'center',\n            inline: true,\n            size: 3,\n            wrapItem: false,\n            wrap: false,\n            style: 'margin-right: 5px',\n        },\n        () => items,\n    )\n}\n\nconst calcDBMenu = (opened, loading, end) => {\n    const btns = []\n    if (opened) {\n        btns.push(\n            h(IconButton, {\n                tTooltip: 'interface.load_more',\n                icon: LoadList,\n                disabled: end === true,\n                loading: loading === true,\n                color: loading === true ? themeVars.value.primaryColor : '',\n                onClick: () => handleSelectContextMenu('db_loadmore'),\n            }),\n            h(IconButton, {\n                tTooltip: 'interface.load_all',\n                icon: LoadAll,\n                disabled: end === true,\n                loading: loading === true,\n                color: loading === true ? themeVars.value.primaryColor : '',\n                onClick: () => handleSelectContextMenu('db_loadall'),\n            }),\n            // h(IconButton, {\n            //     tTooltip: 'interface.more_action',\n            //     icon: More,\n            //     onClick: () => handleSelectContextMenu('more_action'),\n            // }),\n        )\n    }\n    return btns\n}\n\nconst calcLayerMenu = (loading) => {\n    return [\n        // reload layer enable only full loaded\n        h(IconButton, {\n            tTooltip: props.fullLoaded ? 'interface.reload' : 'interface.reload_disable',\n            icon: Refresh,\n            loading: loading === true,\n            disabled: !props.fullLoaded,\n            onClick: () => handleSelectContextMenu('key_reload'),\n        }),\n        h(IconButton, {\n            tTooltip: 'interface.new_key',\n            icon: Add,\n            onClick: () => handleSelectContextMenu('key_newkey'),\n        }),\n        h(IconButton, {\n            tTooltip: 'interface.batch_delete_key',\n            icon: Delete,\n            onClick: () => handleSelectContextMenu('key_remove'),\n        }),\n    ]\n}\n\nconst calcValueMenu = () => {\n    return [\n        h(IconButton, {\n            tTooltip: 'interface.remove_key',\n            icon: Delete,\n            onClick: () => handleSelectContextMenu('value_remove'),\n        }),\n    ]\n}\n\n// render menu function icon\nconst renderSuffix = ({ option }) => {\n    const selected = includes(selectedKeys.value, option.key)\n    if (selected && !props.checkMode) {\n        switch (option.type) {\n            case ConnectionType.RedisDB:\n                return renderIconMenu(calcDBMenu(option.opened, option.loading, option.fullLoaded))\n            case ConnectionType.RedisKey:\n                return renderIconMenu(calcLayerMenu(option.loading))\n            case ConnectionType.RedisValue:\n                return renderIconMenu(calcValueMenu())\n        }\n    } else if (!selected && !!option.redisKeyCode && option.type === ConnectionType.RedisValue) {\n        // render binary icon\n        return renderIconMenu(h(NIcon, { size: 20 }, () => h(Binary)))\n    }\n    return null\n}\n\nconst lastLoadKey = ref(0)\n\n/**\n *\n * @param {RedisNodeItem} node\n */\nconst updateKeyDetail = (node) => {\n    if (node.type === ConnectionType.RedisValue) {\n        const preK = tabStore.getActivatedKey(props.server)\n        if (!isEmpty(preK) && preK === node.key && Date.now() - lastLoadKey.value > 1000) {\n            // reload key already activated\n            lastLoadKey.value = Date.now()\n            const { db, redisKey, redisKeyCode } = node\n            browserStore.reloadKey({\n                server: props.server,\n                db,\n                key: redisKeyCode || redisKey,\n            })\n        } else if (tabStore.setActivatedKey(props.server, node.key)) {\n            const { db, redisKey, redisKeyCode } = node\n            browserStore.loadKeySummary({\n                server: props.server,\n                db,\n                key: redisKeyCode || redisKey,\n                clearValue: true,\n            })\n        }\n    }\n}\n\nconst nodeProps = ({ option }) => {\n    return {\n        onClick: () => {\n            updateKeyDetail(option)\n        },\n        onDblclick: () => {\n            if (props.loading) {\n                console.warn('TODO: alert to ignore double click when loading')\n                return\n            }\n            if (!props.checkMode) {\n                // default handle is toggle expand current node\n                nextTick().then(() => tabStore.toggleExpandKey(props.server, option.key))\n            }\n        },\n        onContextmenu(e) {\n            e.preventDefault()\n            if (!menuOptions.hasOwnProperty(option.type)) {\n                return\n            }\n            contextMenuParam.show = false\n            nextTick().then(() => {\n                contextMenuParam.options = markRaw(menuOptions[option.type] || [])\n                contextMenuParam.x = e.clientX\n                contextMenuParam.y = e.clientY\n                contextMenuParam.show = true\n                onUpdateSelectedKeys([option.key], [option])\n            })\n        },\n        // onMouseover() {\n        //   console.log('mouse over')\n        // }\n    }\n}\n\nconst handleOutsideContextMenu = () => {\n    contextMenuParam.show = false\n}\n\nwatchEffect(\n    () => {\n        if (!props.checkMode) {\n            tabStore.setCheckedKeys(props.server)\n        } else {\n            tabStore.addExpandedKey(props.server, `${ConnectionType.RedisDB}`)\n        }\n    },\n    { flush: 'post' },\n)\n\n// the NTree node may get incorrect height after change data\n// add key property for force refresh the component so that everything back to normal\nconst treeKey = ref(0)\ndefineExpose({\n    handleSelectContextMenu,\n    refreshTree: () => {\n        treeKey.value = Date.now()\n        tabStore.setExpandedKeys(props.server)\n        tabStore.setCheckedKeys(props.server)\n    },\n    deleteCheckedItems: () => {\n        const checkedKeys = tabStore.currentCheckedKeys\n        const redisKeys = map(checkedKeys, 'redisKey')\n        if (!isEmpty(redisKeys)) {\n            dialogStore.openDeleteKeyDialog(props.server, props.db, redisKeys)\n        }\n    },\n    exportCheckedItems: () => {\n        const checkedKeys = tabStore.currentCheckedKeys\n        const redisKeys = map(checkedKeys, 'redisKey')\n        if (!isEmpty(redisKeys)) {\n            dialogStore.openExportKeyDialog(props.server, props.db, redisKeys)\n        }\n    },\n    updateTTLCheckedItems: () => {\n        const checkedKeys = tabStore.currentCheckedKeys\n        const redisKeys = map(checkedKeys, 'redisKey')\n        if (!isEmpty(redisKeys)) {\n            dialogStore.openTTLDialog({\n                server: props.server,\n                db: props.db,\n                keys: redisKeys,\n            })\n        }\n    },\n    getSelectedKey: () => {\n        return selectedKeys.value || []\n    },\n})\n</script>\n\n<template>\n    <div\n        :style=\"{ backgroundColor }\"\n        class=\"flex-box-v browser-tree-wrapper\"\n        @contextmenu=\"(e) => e.preventDefault()\"\n        @keydown.esc=\"contextMenuParam.show = false\">\n        <n-spin v-if=\"props.loading\" class=\"fill-height\" />\n        <n-empty v-else-if=\"!props.loading && isEmpty(data)\" class=\"empty-content\" />\n        <n-tree\n            v-show=\"!props.loading && !isEmpty(data)\"\n            :key=\"treeKey\"\n            :animated=\"false\"\n            :block-line=\"true\"\n            :block-node=\"true\"\n            :cancelable=\"false\"\n            :cascade=\"true\"\n            :checkable=\"props.checkMode\"\n            :checked-keys=\"checkedKeys\"\n            :data=\"data\"\n            :expand-on-click=\"false\"\n            :expanded-keys=\"expandedKeys\"\n            :filter=\"(pattern, node) => includes(node.redisKey, pattern)\"\n            :keyboard=\"false\"\n            :node-props=\"nodeProps\"\n            :pattern=\"props.pattern\"\n            :render-label=\"renderLabel\"\n            :render-prefix=\"renderPrefix\"\n            :render-suffix=\"renderSuffix\"\n            :selected-keys=\"selectedKeys\"\n            :show-irrelevant-nodes=\"false\"\n            check-strategy=\"child\"\n            class=\"fill-height\"\n            virtual-scroll\n            @keydown=\"onKeyShortcut\"\n            @update:selected-keys=\"onUpdateSelectedKeys\"\n            @update:expanded-keys=\"onUpdateExpanded\"\n            @update:checked-keys=\"onUpdateCheckedKeys\">\n            <template #empty>\n                <n-empty class=\"empty-content\" />\n            </template>\n        </n-tree>\n\n        <!-- context menu -->\n        <n-dropdown\n            :options=\"contextMenuParam.options\"\n            :render-icon=\"({ icon }) => render.renderIcon(icon)\"\n            :render-label=\"({ label }) => render.renderLabel($t(label), { class: 'context-menu-item' })\"\n            :show=\"contextMenuParam.show\"\n            :x=\"contextMenuParam.x\"\n            :y=\"contextMenuParam.y\"\n            placement=\"bottom-start\"\n            trigger=\"manual\"\n            @clickoutside=\"handleOutsideContextMenu\"\n            @select=\"handleSelectContextMenu\" />\n    </div>\n</template>\n\n<style lang=\"scss\" scoped>\n@use '@/styles/content';\n\n.browser-tree-wrapper {\n    height: 100%;\n    overflow: hidden;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/sidebar/ConnectionPane.vue",
    "content": "<script setup>\nimport useDialogStore from 'stores/dialog.js'\nimport { NIcon, useThemeVars } from 'naive-ui'\nimport AddGroup from '@/components/icons/AddGroup.vue'\nimport AddLink from '@/components/icons/AddLink.vue'\nimport IconButton from '@/components/common/IconButton.vue'\nimport Filter from '@/components/icons/Filter.vue'\nimport ConnectionTree from './ConnectionTree.vue'\nimport { ref } from 'vue'\nimport More from '@/components/icons/More.vue'\nimport Import from '@/components/icons/Import.vue'\nimport { useRender } from '@/utils/render.js'\nimport Export from '@/components/icons/Export.vue'\nimport useConnectionStore from 'stores/connections.js'\n\nconst themeVars = useThemeVars()\nconst dialogStore = useDialogStore()\nconst connectionStore = useConnectionStore()\nconst render = useRender()\nconst filterPattern = ref('')\n\nconst moreOptions = [\n    { key: 'import', label: 'interface.import_conn', icon: Import },\n    { key: 'export', label: 'interface.export_conn', icon: Export },\n]\n\nconst onSelectOptions = async (select) => {\n    switch (select) {\n        case 'import':\n            await connectionStore.importConnections()\n            await connectionStore.initConnections(true)\n            break\n        case 'export':\n            await connectionStore.exportConnections()\n            break\n    }\n}\n</script>\n\n<template>\n    <div class=\"nav-pane-container flex-box-v\">\n        <connection-tree :filter-pattern=\"filterPattern\" />\n\n        <!-- bottom function bar -->\n        <div class=\"nav-pane-bottom nav-pane-func flex-box-h\">\n            <icon-button\n                :button-class=\"['nav-pane-func-btn']\"\n                :icon=\"AddLink\"\n                :stroke-width=\"3.5\"\n                size=\"20\"\n                t-tooltip=\"interface.new_conn\"\n                @click=\"dialogStore.openNewDialog()\" />\n            <icon-button\n                :button-class=\"['nav-pane-func-btn']\"\n                :icon=\"AddGroup\"\n                :stroke-width=\"3.5\"\n                size=\"20\"\n                t-tooltip=\"interface.new_group\"\n                @click=\"dialogStore.openNewGroupDialog()\" />\n            <n-divider vertical />\n            <n-input v-model:value=\"filterPattern\" :autofocus=\"false\" :placeholder=\"$t('interface.filter')\" clearable>\n                <template #prefix>\n                    <n-icon :component=\"Filter\" size=\"20\" />\n                </template>\n            </n-input>\n            <n-dropdown\n                :options=\"moreOptions\"\n                :render-icon=\"({ icon }) => render.renderIcon(icon, { strokeWidth: 3.5 })\"\n                :render-label=\"({ label }) => $t(label)\"\n                placement=\"top-end\"\n                style=\"min-width: 130px\"\n                trigger=\"click\"\n                @select=\"onSelectOptions\">\n                <icon-button :button-class=\"['nav-pane-func-btn']\" :icon=\"More\" :stroke-width=\"3.5\" size=\"20\" />\n            </n-dropdown>\n        </div>\n    </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.nav-pane-bottom {\n    color: v-bind('themeVars.iconColor');\n    border-top: v-bind('themeVars.borderColor') 1px solid;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/sidebar/ConnectionTree.vue",
    "content": "<script setup>\nimport useDialogStore from 'stores/dialog.js'\nimport { h, markRaw, nextTick, reactive, ref } from 'vue'\nimport useConnectionStore from 'stores/connections.js'\nimport { NIcon, NSpace, NText, useThemeVars } from 'naive-ui'\nimport { ConnectionType } from '@/consts/connection_type.js'\nimport Folder from '@/components/icons/Folder.vue'\nimport Server from '@/components/icons/Server.vue'\nimport Cluster from '@/components/icons/Cluster.vue'\nimport { debounce, get, includes, indexOf, isEmpty, split } from 'lodash'\nimport Config from '@/components/icons/Config.vue'\nimport Delete from '@/components/icons/Delete.vue'\nimport Unlink from '@/components/icons/Unlink.vue'\nimport CopyLink from '@/components/icons/CopyLink.vue'\nimport Connect from '@/components/icons/Connect.vue'\nimport { useI18n } from 'vue-i18n'\nimport useTabStore from 'stores/tab.js'\nimport Edit from '@/components/icons/Edit.vue'\nimport { hexGammaCorrection, parseHexColor, toHexColor } from '@/utils/rgb.js'\nimport IconButton from '@/components/common/IconButton.vue'\nimport usePreferencesStore from 'stores/preferences.js'\nimport useBrowserStore from 'stores/browser.js'\nimport { useRender } from '@/utils/render.js'\n\nconst themeVars = useThemeVars()\nconst i18n = useI18n()\nconst render = useRender()\nconst connectingServer = ref('')\nconst connectionStore = useConnectionStore()\nconst browserStore = useBrowserStore()\nconst tabStore = useTabStore()\nconst prefStore = usePreferencesStore()\nconst dialogStore = useDialogStore()\n\nconst expandedKeys = ref([])\nconst selectedKeys = ref([])\n\nconst props = defineProps({\n    filterPattern: {\n        type: String,\n    },\n})\n\nconst contextMenuParam = reactive({\n    show: false,\n    x: 0,\n    y: 0,\n    options: null,\n    currentNode: null,\n})\n\nconst menuOptions = {\n    [ConnectionType.Group]: ({ opened }) => [\n        {\n            key: 'group_rename',\n            label: 'interface.rename_conn_group',\n            icon: Edit,\n        },\n        {\n            key: 'group_delete',\n            label: 'interface.remove_conn_group',\n            icon: Delete,\n        },\n    ],\n    [ConnectionType.Server]: ({ name }) => {\n        const connected = browserStore.isConnected(name)\n        if (connected) {\n            return [\n                {\n                    key: 'server_close',\n                    label: 'interface.disconnect',\n                    icon: Unlink,\n                },\n                {\n                    key: 'server_edit',\n                    label: 'interface.edit_conn',\n                    icon: Config,\n                },\n                {\n                    key: 'server_dup',\n                    label: 'interface.dup_conn',\n                    icon: CopyLink,\n                },\n                {\n                    type: 'divider',\n                    key: 'd1',\n                },\n                {\n                    key: 'server_remove',\n                    label: 'interface.remove_conn',\n                    icon: Delete,\n                },\n            ]\n        } else {\n            return [\n                {\n                    key: 'server_open',\n                    label: 'interface.open_connection',\n                    icon: Connect,\n                },\n                {\n                    key: 'server_edit',\n                    label: 'interface.edit_conn',\n                    icon: Config,\n                },\n                {\n                    key: 'server_dup',\n                    label: 'interface.dup_conn',\n                    icon: CopyLink,\n                },\n                {\n                    type: 'divider',\n                    key: 'd1',\n                },\n                {\n                    key: 'server_remove',\n                    label: 'interface.remove_conn',\n                    icon: Delete,\n                },\n            ]\n        }\n    },\n}\n\n/**\n * get mark color of server saved in preferences\n * @param name\n * @return {null|string}\n */\nconst getServerMarkColor = (name) => {\n    const { markColor = '' } = connectionStore.serverProfile[name] || {}\n    if (!isEmpty(markColor)) {\n        const rgb = parseHexColor(markColor)\n        const rgb2 = hexGammaCorrection(rgb, 0.75)\n        return toHexColor(rgb2)\n    }\n    return null\n}\n\nconst renderLabel = ({ option }) => {\n    if (option.type === ConnectionType.Server) {\n        const color = getServerMarkColor(option.name)\n        if (color != null) {\n            return h(\n                NText,\n                {\n                    style: {\n                        color,\n                        fontWeight: '450',\n                    },\n                },\n                () => option.label,\n            )\n        }\n    }\n    return option.label\n}\n\n// render horizontal item\nconst renderIconMenu = (items) => {\n    return h(\n        NSpace,\n        {\n            align: 'center',\n            inline: true,\n            size: 3,\n            wrapItem: false,\n            wrap: false,\n            style: 'margin-right: 5px',\n        },\n        () => items,\n    )\n}\n\nconst renderPrefix = ({ option }) => {\n    const iconTransparency = prefStore.isDark ? 0.75 : 1\n    switch (option.type) {\n        case ConnectionType.Group:\n            const opened = indexOf(expandedKeys.value, option.key) !== -1\n            return h(\n                NIcon,\n                { size: 20 },\n                {\n                    default: () =>\n                        h(Folder, {\n                            open: opened,\n                            fillColor: `rgba(255,206,120,${iconTransparency})`,\n                        }),\n                },\n            )\n        case ConnectionType.Server:\n            const connected = browserStore.isConnected(option.name)\n            const color = getServerMarkColor(option.name)\n            const icon = option.cluster === true ? Cluster : Server\n            return h(\n                NIcon,\n                { size: 20, color: !!!connected ? color : '#dc423c' },\n                {\n                    default: () =>\n                        h(icon, {\n                            inverse: !!connected,\n                            fillColor: `rgba(220,66,60,${iconTransparency})`,\n                        }),\n                },\n            )\n    }\n}\n\nconst getServerMenu = (connected) => {\n    const btns = []\n    if (connected) {\n        btns.push(\n            h(IconButton, {\n                tTooltip: 'interface.disconnect',\n                icon: Unlink,\n                onClick: () => handleSelectContextMenu('server_close'),\n            }),\n            h(IconButton, {\n                tTooltip: 'interface.edit_conn',\n                icon: Config,\n                onClick: () => handleSelectContextMenu('server_edit'),\n            }),\n        )\n    } else {\n        btns.push(\n            h(IconButton, {\n                tTooltip: 'interface.open_connection',\n                icon: Connect,\n                onClick: () => handleSelectContextMenu('server_open'),\n            }),\n            h(IconButton, {\n                tTooltip: 'interface.edit_conn',\n                icon: Config,\n                onClick: () => handleSelectContextMenu('server_edit'),\n            }),\n            h(IconButton, {\n                tTooltip: 'interface.remove_conn',\n                icon: Delete,\n                onClick: () => handleSelectContextMenu('server_remove'),\n            }),\n        )\n    }\n    return btns\n}\n\nconst getGroupMenu = () => {\n    return [\n        h(IconButton, {\n            tTooltip: 'interface.rename_conn_group',\n            icon: Config,\n            onClick: () => handleSelectContextMenu('group_rename'),\n        }),\n        h(IconButton, {\n            tTooltip: 'interface.remove_conn_group',\n            icon: Delete,\n            onClick: () => handleSelectContextMenu('group_delete'),\n        }),\n    ]\n}\n\nconst renderSuffix = ({ option }) => {\n    if (includes(selectedKeys.value, option.key)) {\n        switch (option.type) {\n            case ConnectionType.Server:\n                const connected = browserStore.isConnected(option.name)\n                return renderIconMenu(getServerMenu(connected))\n            case ConnectionType.Group:\n                return renderIconMenu(getGroupMenu())\n        }\n    }\n    return null\n}\n\nconst onUpdateExpandedKeys = (keys, option) => {\n    expandedKeys.value = keys\n}\n\nconst onUpdateSelectedKeys = (keys, option) => {\n    selectedKeys.value = keys\n}\n\n/**\n * Open connection\n * @param name\n * @returns {Promise<void>}\n */\nconst openConnection = async (name) => {\n    try {\n        connectingServer.value = name\n        if (!browserStore.isConnected(name)) {\n            await browserStore.openConnection(name)\n        }\n        // check if connection already canceled before finish open\n        if (!isEmpty(connectingServer.value)) {\n            tabStore.upsertTab({\n                server: name,\n                db: browserStore.getSelectedDB(name),\n                forceSwitch: true,\n            })\n        }\n    } catch (e) {\n        $message.error(e.message)\n        // node.isLeaf = undefined\n    } finally {\n        connectingServer.value = ''\n    }\n}\n\nconst removeConnection = (name) => {\n    $dialog.warning(\n        i18n.t('dialogue.remove_tip', { type: i18n.t('dialogue.connection.conn_name'), name }),\n        async () => {\n            connectionStore.deleteConnection(name).then(({ success, msg }) => {\n                if (!success) {\n                    $message.error(msg)\n                }\n            })\n        },\n    )\n}\n\nconst removeGroup = async (name) => {\n    $dialog.warning(i18n.t('dialogue.remove_group_tip', { name }), async () => {\n        connectionStore.deleteGroup(name).then(({ success, msg }) => {\n            if (!success) {\n                $message.error(msg)\n            }\n        })\n    })\n}\n\nconst expandKey = (key) => {\n    const idx = indexOf(expandedKeys.value, key)\n    if (idx === -1) {\n        expandedKeys.value.push(key)\n    } else {\n        expandedKeys.value.splice(idx, 1)\n    }\n}\n\nconst nodeProps = ({ option }) => {\n    return {\n        onDblclick: async () => {\n            if (option.type === ConnectionType.Server) {\n                openConnection(option.name).then(() => {})\n            } else if (option.type === ConnectionType.Group) {\n                // toggle expand\n                nextTick().then(() => expandKey(option.key))\n            }\n        },\n        onContextmenu(e) {\n            e.preventDefault()\n            const mop = menuOptions[option.type]\n            if (mop == null) {\n                return\n            }\n            contextMenuParam.show = false\n            nextTick().then(() => {\n                contextMenuParam.options = markRaw(mop(option))\n                contextMenuParam.currentNode = option\n                contextMenuParam.x = e.clientX\n                contextMenuParam.y = e.clientY\n                contextMenuParam.show = true\n                selectedKeys.value = [option.key]\n            })\n        },\n    }\n}\n\nconst handleSelectContextMenu = (key) => {\n    contextMenuParam.show = false\n    const selectedKey = get(selectedKeys.value, 0)\n    if (selectedKey == null) {\n        return\n    }\n    const [group, name] = split(selectedKey, '/')\n    if (isEmpty(group) && isEmpty(name)) {\n        return\n    }\n    switch (key) {\n        case 'server_open':\n            openConnection(name).then(() => {})\n            break\n        case 'server_edit':\n            // ask for close relevant connections before edit\n            if (browserStore.isConnected(name)) {\n                $dialog.warning(i18n.t('dialogue.edit_close_confirm'), () => {\n                    browserStore.closeConnection(name)\n                    dialogStore.openEditDialog(name)\n                })\n            } else {\n                dialogStore.openEditDialog(name)\n            }\n            break\n        case 'server_dup':\n            dialogStore.openDuplicateDialog(name)\n            break\n        case 'server_remove':\n            removeConnection(name)\n            break\n        case 'server_close':\n            browserStore.closeConnection(name).then((closed) => {\n                if (closed) {\n                    $message.success(i18n.t('dialogue.handle_succ'))\n                }\n            })\n            break\n        case 'group_rename':\n            if (!isEmpty(group)) {\n                dialogStore.openRenameGroupDialog(group)\n            }\n            break\n        case 'group_delete':\n            if (!isEmpty(group)) {\n                removeGroup(group)\n            }\n            break\n        default:\n            console.warn('TODO: handle context menu:' + key)\n    }\n}\n\nconst findSiblingsAndIndex = (node, nodes) => {\n    if (!nodes) {\n        return [null, null]\n    }\n    for (let i = 0; i < nodes.length; ++i) {\n        const siblingNode = nodes[i]\n        if (siblingNode.key === node.key) {\n            return [nodes, i]\n        }\n        const [siblings, index] = findSiblingsAndIndex(node, siblingNode.children)\n        if (siblings && index !== null) {\n            return [siblings, index]\n        }\n    }\n    return [null, null]\n}\n\n// delay save until drop stopped after 2 seconds\nconst saveSort = debounce(connectionStore.saveConnectionSorted, 1500, { trailing: true })\nconst handleDrop = ({ node, dragNode, dropPosition }) => {\n    const [dragNodeSiblings, dragNodeIndex] = findSiblingsAndIndex(dragNode, connectionStore.connections)\n    if (dragNodeSiblings === null || dragNodeIndex === null) {\n        return\n    }\n    if (node.type === ConnectionType.Group && dragNode.type === ConnectionType.Group) {\n        return\n    }\n    dragNodeSiblings.splice(dragNodeIndex, 1)\n    if (dropPosition === 'inside') {\n        if (node.children) {\n            node.children.unshift(dragNode)\n        } else {\n            node.children = [dragNode]\n        }\n    } else if (dropPosition === 'before') {\n        const [nodeSiblings, nodeIndex] = findSiblingsAndIndex(node, connectionStore.connections)\n        if (nodeSiblings === null || nodeIndex === null) {\n            return\n        }\n        nodeSiblings.splice(nodeIndex, 0, dragNode)\n    } else if (dropPosition === 'after') {\n        const [nodeSiblings, nodeIndex] = findSiblingsAndIndex(node, connectionStore.connections)\n        if (nodeSiblings === null || nodeIndex === null) {\n            return\n        }\n        nodeSiblings.splice(nodeIndex + 1, 0, dragNode)\n    }\n    connectionStore.connections = Array.from(connectionStore.connections)\n    saveSort()\n}\n\nconst onCancelOpen = () => {\n    if (!isEmpty(connectingServer.value)) {\n        browserStore.closeConnection(connectingServer.value)\n        connectingServer.value = ''\n    }\n}\n</script>\n\n<template>\n    <div class=\"connection-tree-wrapper\" @keydown.esc=\"contextMenuParam.show = false\">\n        <n-tree\n            :animated=\"false\"\n            :block-line=\"true\"\n            :block-node=\"true\"\n            :cancelable=\"false\"\n            :data=\"connectionStore.connections\"\n            :draggable=\"true\"\n            :expanded-keys=\"expandedKeys\"\n            :node-props=\"nodeProps\"\n            :pattern=\"props.filterPattern\"\n            :render-label=\"renderLabel\"\n            :render-prefix=\"renderPrefix\"\n            :render-suffix=\"renderSuffix\"\n            :selected-keys=\"selectedKeys\"\n            class=\"fill-height\"\n            virtual-scroll\n            @drop=\"handleDrop\"\n            @update:selected-keys=\"onUpdateSelectedKeys\"\n            @update:expanded-keys=\"onUpdateExpandedKeys\">\n            <template #empty>\n                <n-empty :description=\"$t('interface.empty_server_list')\" class=\"empty-content\" />\n            </template>\n        </n-tree>\n\n        <!-- status display modal -->\n        <n-modal :show=\"connectingServer !== ''\" transform-origin=\"center\">\n            <n-card\n                :bordered=\"false\"\n                :content-style=\"{ textAlign: 'center' }\"\n                aria-model=\"true\"\n                role=\"dialog\"\n                style=\"width: 400px\">\n                <n-spin>\n                    <template #description>\n                        <n-space vertical>\n                            <n-text strong>{{ $t('dialogue.opening_connection') }}</n-text>\n                            <n-button :focusable=\"false\" secondary size=\"small\" @click=\"onCancelOpen\">\n                                {{ $t('dialogue.interrupt_connection') }}\n                            </n-button>\n                        </n-space>\n                    </template>\n                </n-spin>\n            </n-card>\n        </n-modal>\n\n        <!-- context menu -->\n        <n-dropdown\n            :keyboard=\"true\"\n            :options=\"contextMenuParam.options\"\n            :render-icon=\"({ icon }) => render.renderIcon(icon)\"\n            :render-label=\"({ label }) => render.renderLabel($t(label), { class: 'context-menu-item' })\"\n            :show=\"contextMenuParam.show\"\n            :x=\"contextMenuParam.x\"\n            :y=\"contextMenuParam.y\"\n            placement=\"bottom-start\"\n            trigger=\"manual\"\n            @clickoutside=\"contextMenuParam.show = false\"\n            @select=\"handleSelectContextMenu\" />\n    </div>\n</template>\n\n<style lang=\"scss\" scoped>\n@use '@/styles/content';\n\n.connection-tree-wrapper {\n    height: 100%;\n    overflow: hidden;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/sidebar/ConnectionTreeItem.vue",
    "content": "<script setup>\nconst props = defineProps({\n    title: String,\n})\n</script>\n\n<template>\n    <div class=\"db-tree-item flex-box-v\">\n        <div class=\"tree-item-title\">{{ title }}</div>\n        <div class=\"tree-item-addr\">localhost:3306</div>\n    </div>\n</template>\n\n<style lang=\"scss\">\n.db-tree-item {\n    padding: 0 5px;\n\n    .tree-item-title {\n        font-size: 16px;\n    }\n\n    .tree-item-addr {\n        font-size: 12px;\n    }\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/sidebar/Ribbon.vue",
    "content": "<script setup>\nimport { computed, ref } from 'vue'\nimport { NIcon, useThemeVars } from 'naive-ui'\nimport Database from '@/components/icons/Database.vue'\nimport Server from '@/components/icons/Server.vue'\nimport IconButton from '@/components/common/IconButton.vue'\nimport Config from '@/components/icons/Config.vue'\nimport useDialogStore from 'stores/dialog.js'\nimport Github from '@/components/icons/Github.vue'\nimport { BrowserOpenURL } from 'wailsjs/runtime/runtime.js'\nimport usePreferencesStore from 'stores/preferences.js'\nimport Record from '@/components/icons/Record.vue'\nimport { extraTheme } from '@/utils/extra_theme.js'\nimport useBrowserStore from 'stores/browser.js'\nimport { useRender } from '@/utils/render.js'\nimport wechatUrl from '@/assets/images/wechat_official.png'\nimport bilibiliUrl from '@/assets/images/bilibili_official.png'\nimport QRCode from '@/components/icons/QRCode.vue'\nimport Twitter from '@/components/icons/Twitter.vue'\nimport { trackEvent } from '@/utils/analytics.js'\nimport LogoutIcon from '@/components/icons/Logout.vue'\nimport { isWeb } from '@/utils/platform.js'\nimport { Logout } from '@/utils/api.js'\n\nconst themeVars = useThemeVars()\nconst render = useRender()\n\nconst props = defineProps({\n    value: {\n        type: String,\n        default: 'server',\n    },\n    width: {\n        type: Number,\n        default: 60,\n    },\n})\n\nconst emit = defineEmits(['update:value'])\n\nconst iconSize = computed(() => Math.floor(props.width * 0.45))\n\nconst browserStore = useBrowserStore()\nconst showWechat = ref(false)\nconst menuOptions = computed(() => {\n    return [\n        {\n            label: 'ribbon.browser',\n            key: 'browser',\n            icon: Database,\n            show: browserStore.anyConnectionOpened,\n        },\n        {\n            label: 'ribbon.server',\n            key: 'server',\n            icon: Server,\n        },\n        {\n            label: 'ribbon.log',\n            key: 'log',\n            icon: Record,\n        },\n    ]\n})\n\nconst preferencesOptions = computed(() => {\n    return [\n        {\n            label: 'menu.preferences',\n            key: 'preferences',\n            icon: Config,\n        },\n        // {\n        //     label: 'menu.help',\n        //     key: 'help',\n        //     icon: Help,\n        // },\n        {\n            label: 'menu.report_bug',\n            key: 'report',\n        },\n        {\n            label: 'menu.user_guide',\n            key: 'help',\n        },\n        {\n            label: 'menu.check_update',\n            key: 'update',\n        },\n        {\n            type: 'divider',\n            key: 'd1',\n        },\n        {\n            label: 'menu.about',\n            key: 'about',\n        },\n    ]\n})\n\nconst dialogStore = useDialogStore()\nconst prefStore = usePreferencesStore()\nconst onSelectPreferenceMenu = (key) => {\n    switch (key) {\n        case 'preferences':\n            dialogStore.openPreferencesDialog()\n            break\n        case 'update':\n            prefStore.checkForUpdate(true)\n            break\n        case 'report':\n            BrowserOpenURL('https://github.com/tiny-craft/tiny-rdm/issues')\n            break\n        case 'help':\n            if (prefStore.currentLanguage === 'zh') {\n                BrowserOpenURL('https://tinyrdm.com/zh/guide/')\n            } else {\n                BrowserOpenURL('https://tinyrdm.com/guide/')\n            }\n            break\n        case 'about':\n            dialogStore.openAboutDialog()\n            break\n    }\n}\n\nconst openWechatOfficial = () => {\n    trackEvent('open', { target: 'wechat_official' })\n    showWechat.value = true\n}\n\nconst openX = () => {\n    trackEvent('open', { target: 'x' })\n    BrowserOpenURL('https://twitter.com/LykinHuang')\n}\n\nconst openGithub = () => {\n    trackEvent('open', { target: 'github' })\n    BrowserOpenURL('https://github.com/tiny-craft/tiny-rdm')\n}\n\nconst handleLogout = async () => {\n    try {\n        await Logout()\n    } catch {}\n    window.dispatchEvent(new Event('rdm:unauthorized'))\n}\n\nconst exThemeVars = computed(() => {\n    return extraTheme(prefStore.isDark)\n})\n</script>\n\n<template>\n    <div\n        id=\"app-ribbon\"\n        :style=\"{\n            width: props.width + 'px',\n            minWidth: props.width + 'px',\n        }\"\n        class=\"flex-box-v\">\n        <div class=\"ribbon-wrapper flex-box-v\">\n            <n-tooltip v-for=\"(m, i) in menuOptions\" :key=\"i\" :delay=\"2\" :show-arrow=\"false\" placement=\"right\">\n                <template #trigger>\n                    <div\n                        v-show=\"m.show !== false\"\n                        :class=\"{ 'ribbon-item-active': props.value === m.key }\"\n                        class=\"ribbon-item clickable\"\n                        @click=\"emit('update:value', m.key)\">\n                        <n-icon :size=\"iconSize\">\n                            <component :is=\"m.icon\" :stroke-width=\"3.5\" />\n                        </n-icon>\n                    </div>\n                </template>\n                {{ $t(m.label) }}\n            </n-tooltip>\n        </div>\n        <div class=\"flex-item-expand\"></div>\n        <div class=\"nav-menu-item flex-box-v\">\n            <n-dropdown\n                :options=\"preferencesOptions\"\n                :render-icon=\"({ icon }) => render.renderIcon(icon)\"\n                :render-label=\"({ label }) => render.renderLabel($t(label), { class: 'context-menu-item' })\"\n                trigger=\"click\"\n                @select=\"onSelectPreferenceMenu\">\n                <icon-button :icon=\"Config\" :size=\"iconSize\" :stroke-width=\"3\" />\n            </n-dropdown>\n            <icon-button\n                v-if=\"prefStore.currentLanguage === 'zh'\"\n                :icon=\"QRCode\"\n                :size=\"iconSize\"\n                :tooltip-delay=\"100\"\n                t-tooltip=\"ribbon.wechat_official\"\n                @click=\"openWechatOfficial\" />\n            <icon-button\n                v-else\n                :border=\"false\"\n                :icon=\"Twitter\"\n                :size=\"iconSize\"\n                :tooltip-delay=\"100\"\n                t-tooltip=\"ribbon.follow_x\"\n                @click=\"openX\" />\n            <icon-button\n                :icon=\"Github\"\n                :size=\"iconSize\"\n                :tooltip-delay=\"100\"\n                t-tooltip=\"ribbon.github\"\n                @click=\"openGithub\" />\n            <icon-button\n                v-if=\"isWeb()\"\n                :icon=\"LogoutIcon\"\n                :size=\"iconSize\"\n                :stroke-width=\"3.5\"\n                :tooltip-delay=\"100\"\n                t-tooltip=\"ribbon.logout\"\n                @click=\"handleLogout\" />\n        </div>\n\n        <!-- wechat official modal -->\n        <n-modal v-model:show=\"showWechat\" close-on-esc mask-closable transform-origin=\"center\">\n            <n-flex vertical>\n                <n-image :src=\"wechatUrl\" :width=\"400\" preview-disabled />\n                <n-image :src=\"bilibiliUrl\" :width=\"400\" preview-disabled />\n            </n-flex>\n        </n-modal>\n    </div>\n</template>\n\n<style lang=\"scss\">\n#app-ribbon {\n    //height: 100vh;\n    border-right: v-bind('exThemeVars.splitColor') solid 1px;\n    background-color: v-bind('exThemeVars.ribbonColor');\n    box-sizing: border-box;\n    color: v-bind('themeVars.textColor2');\n    --wails-draggable: drag;\n\n    .ribbon-wrapper {\n        gap: 2px;\n        margin-top: 5px;\n        justify-content: center;\n        align-items: center;\n        box-sizing: border-box;\n        padding-right: 3px;\n        --wails-draggable: none;\n\n        .ribbon-item {\n            width: 100%;\n            height: 100%;\n            text-align: center;\n            line-height: 1;\n            color: v-bind('themeVars.textColor3');\n            //border-left: 5px solid #000;\n            border-radius: v-bind('themeVars.borderRadius');\n            padding: 8px 0;\n            position: relative;\n\n            &:hover {\n                background-color: rgba(0, 0, 0, 0.05);\n                color: v-bind('themeVars.primaryColor');\n\n                &:before {\n                    position: absolute;\n                    width: 3px;\n                    left: 0;\n                    top: 24%;\n                    bottom: 24%;\n                    border-radius: 9999px;\n                    content: '';\n                    background-color: v-bind('themeVars.primaryColor');\n                }\n            }\n        }\n\n        .ribbon-item-active {\n            //background-color: v-bind('exThemeVars.ribbonActiveColor');\n            color: v-bind('themeVars.primaryColor');\n\n            &:hover {\n                color: v-bind('themeVars.primaryColor') !important;\n            }\n\n            &:before {\n                position: absolute;\n                width: 3px;\n                left: 0;\n                top: 24%;\n                bottom: 24%;\n                border-radius: 9999px;\n                content: '';\n                background-color: v-bind('themeVars.primaryColor');\n            }\n        }\n    }\n\n    .nav-menu-item {\n        align-items: center;\n        padding: 10px 0 15px;\n        --wails-draggable: none;\n\n        button {\n            margin: 10px 0;\n        }\n    }\n}\n</style>\n"
  },
  {
    "path": "frontend/src/consts/browser_tab_type.js",
    "content": "/**\n * all types of Browser sub tabs\n * @enum {string}\n */\nexport const BrowserTabType = {\n    Status: 'status',\n    KeyDetail: 'key_detail',\n    Cli: 'cli',\n    SlowLog: 'slow_log',\n    CmdMonitor: 'cmd_monitor',\n    PubMessage: 'pub_message',\n}\n"
  },
  {
    "path": "frontend/src/consts/connection_type.js",
    "content": "/**\n * all types of connection item\n * @enum {number}\n */\nexport const ConnectionType = {\n    Group: 0,\n    Server: 1,\n    RedisDB: 2,\n    RedisKey: 3,\n    RedisValue: 4,\n}\n"
  },
  {
    "path": "frontend/src/consts/key_view_type.js",
    "content": "/**\n * all types of redis key viewing\n * @enum {number}\n */\nexport const KeyViewType = {\n    Tree: 0,\n    List: 1,\n}\n"
  },
  {
    "path": "frontend/src/consts/localstorage_key.js",
    "content": "export const STORAGE_THEME_KEY = 'rdm_theme'\nexport const STORAGE_LANG_KEY = 'rdm_lang'\n"
  },
  {
    "path": "frontend/src/consts/support_redis_type.js",
    "content": "/**\n * all redis type\n * @enum {string}\n */\nexport const types = {\n    STRING: 'STRING',\n    HASH: 'HASH',\n    LIST: 'LIST',\n    SET: 'SET',\n    ZSET: 'ZSET',\n    STREAM: 'STREAM',\n    JSON: 'JSON',\n}\n\nexport const typesShortName = {\n    STRING: 'S',\n    HASH: 'H',\n    LIST: 'L',\n    SET: 'E',\n    ZSET: 'Z',\n    STREAM: 'X',\n    JSON: 'J',\n}\n\n/**\n * mark color for redis types\n * @enum {string}\n */\nexport const typesColor = {\n    [types.STRING]: '#8B5CF6',\n    [types.HASH]: '#3B82F6',\n    [types.LIST]: '#10B981',\n    [types.SET]: '#F59E0B',\n    [types.ZSET]: '#EF4444',\n    [types.STREAM]: '#EC4899',\n    [types.JSON]: '#828766',\n}\n\n/**\n * background mark color for redis types\n * @enum {string}\n */\nexport const typesBgColor = {\n    [types.STRING]: '#F2EDFB',\n    [types.HASH]: '#E4F0FC',\n    [types.LIST]: '#E3F3EB',\n    [types.SET]: '#FDF1DF',\n    [types.ZSET]: '#FAEAED',\n    [types.STREAM]: '#FDE6F1',\n    [types.JSON]: '#ECECD9',\n}\n\n// export const typesName = Object.fromEntries(Object.entries(types).map(([key, value]) => [key, value.name]))\n\nexport const validType = (t) => {\n    return types.hasOwnProperty(t)\n}\n\n/**\n * icon type in browser tree\n * @enum {string}\n */\nexport const typesIconStyle = {\n    SHORT: 0,\n    FULL: 1,\n    POINT: 2,\n    ICON: 3,\n}\n"
  },
  {
    "path": "frontend/src/consts/text_align_type.js",
    "content": "/**\n * all types of text alignment\n * @enum {number}\n */\nexport const TextAlignType = {\n    Center: 0,\n    Left: 1,\n}\n"
  },
  {
    "path": "frontend/src/consts/tree_context_menu.js",
    "content": "import { ConnectionType } from './connection_type.js'\n\nexport const contextMenuKey = {\n    [ConnectionType.Server]: {\n        key: '',\n        label: '',\n    },\n}\n"
  },
  {
    "path": "frontend/src/consts/value_view_type.js",
    "content": "/**\n * string format types\n * @enum {string}\n */\nexport const formatTypes = {\n    RAW: 'Raw',\n    JSON: 'JSON',\n    UNICODE_JSON: 'Unicode JSON',\n    YAML: 'YAML',\n    XML: 'XML',\n    HEX: 'Hex',\n    BINARY: 'Binary',\n    BITSET: 'BitSet',\n}\n\n/**\n * string decode types\n * @enum {string}\n */\nexport const decodeTypes = {\n    NONE: 'None',\n    BASE64: 'Base64',\n    GZIP: 'GZip',\n    DEFLATE: 'Deflate',\n    ZSTD: 'ZStd',\n    LZ4: 'LZ4',\n    BROTLI: 'Brotli',\n    MSGPACK: 'Msgpack',\n    PHP: 'PHP',\n    PICKLE: 'Pickle',\n    // Java: 'Java',\n}\n"
  },
  {
    "path": "frontend/src/langs/en-us.json",
    "content": "{\n  \"name\": \"English\",\n  \"common\": {\n    \"confirm\": \"Confirm\",\n    \"cancel\": \"Cancel\",\n    \"success\": \"Success\",\n    \"warning\": \"Warning\",\n    \"error\": \"Error\",\n    \"save\": \"Save\",\n    \"update\": \"Update\",\n    \"none\": \"None\",\n    \"second\": \"Second(s)\",\n    \"minute\": \"Minute(s)\",\n    \"hour\": \"Hour(s)\",\n    \"day\": \"Day(s)\",\n    \"unit_day\": \"d\",\n    \"unit_hour\": \"h\",\n    \"unit_minute\": \"m\",\n    \"unit_second\": \"s\",\n    \"all\": \"All\",\n    \"key\": \"Key\",\n    \"value\": \"Value\",\n    \"field\": \"Field\",\n    \"score\": \"Score\",\n    \"index\": \"Position\"\n  },\n  \"preferences\": {\n    \"name\": \"Preferences\",\n    \"restore_defaults\": \"Restore Defaults\",\n    \"font_tip\": \"Supports multi-selection. Manually input the font if it's not listed.\",\n    \"general\": {\n      \"name\": \"General\",\n      \"theme\": \"Theme\",\n      \"theme_light\": \"Light\",\n      \"theme_dark\": \"Dark\",\n      \"theme_auto\": \"Auto\",\n      \"language\": \"Language\",\n      \"system_lang\": \"Use System Language\",\n      \"font\": \"Font\",\n      \"font_tip\": \"Select or input font name\",\n      \"font_size\": \"Font Size\",\n      \"scan_size\": \"Default Size for SCAN\",\n      \"scan_size_tip\": \"Default return number of elements for SCAN/HSCAN/SSCAN/ZSCAN\",\n      \"key_icon_style\": \"Key Icon Style\",\n      \"key_icon_style0\": \"Compact\",\n      \"key_icon_style1\": \"Full Name\",\n      \"key_icon_style2\": \"Dot\",\n      \"key_icon_style3\": \"Common\",\n      \"update\": \"Update\",\n      \"auto_check_update\": \"Auto check for updates\",\n      \"privacy\": \"Privacy\",\n      \"allow_track\": \"Allows anonymous data to be collected\"\n    },\n    \"editor\": {\n      \"name\": \"Editor\",\n      \"show_linenum\": \"Show Line Numbers\",\n      \"show_folding\": \"Enable Code Folding\",\n      \"drop_text\": \"Allow Drag & Drop Text\",\n      \"links\": \"Support Links\"\n    },\n    \"cli\": {\n      \"name\": \"Command Line\",\n      \"cursor_style\": \"Cursor Style\",\n      \"cursor_style_block\": \"Block\",\n      \"cursor_style_underline\": \"Underline\",\n      \"cursor_style_bar\": \"Bar\"\n    },\n    \"decoder\": {\n      \"name\": \"Custom Decoder\",\n      \"new\": \"New Decoder\",\n      \"decoder_name\": \"Name\",\n      \"cmd_preview\": \"Preview\",\n      \"status\": \"Status\",\n      \"auto_enabled\": \"Auto Decoding Enabled\",\n      \"help\": \"Help\"\n    }\n  },\n  \"interface\": {\n    \"new_conn\": \"Add Connection\",\n    \"new_group\": \"Add Group\",\n    \"disconnect_all\": \"Disconnect All\",\n    \"status\": \"Status\",\n    \"filter\": \"Filter\",\n    \"sort_conn\": \"Sort Connections\",\n    \"new_conn_title\": \"New Connection\",\n    \"open_db\": \"Open Database\",\n    \"close_db\": \"Close Database\",\n    \"filter_key\": \"Filter Keys\",\n    \"disconnect\": \"Disconnect\",\n    \"dup_conn\": \"Duplicate Connection\",\n    \"remove_conn\": \"Remove Connection\",\n    \"edit_conn\": \"Edit Connection\",\n    \"edit_conn_group\": \"Edit Group\",\n    \"rename_conn_group\": \"Rename Group\",\n    \"remove_conn_group\": \"Remove Group\",\n    \"import_conn\": \"Import Connections...\",\n    \"export_conn\": \"Export Connections...\",\n    \"ttl\": \"TTL\",\n    \"forever\": \"Forever\",\n    \"rename_key\": \"Rename Key\",\n    \"delete_key\": \"Delete Key\",\n    \"batch_delete_key\": \"Batch Delete Keys\",\n    \"import_key\": \"Import Keys\",\n    \"flush_db\": \"Flush Database\",\n    \"check_mode\": \"Check Mode\",\n    \"quit_check_mode\": \"Exit Check Mode\",\n    \"delete_checked\": \"Delete Checked\",\n    \"export_checked\": \"Export Checked\",\n    \"ttl_checked\": \"Update TTL for Checked\",\n    \"copy_value\": \"Copy Value\",\n    \"edit_value\": \"Edit Value\",\n    \"save_update\": \"Save Changes\",\n    \"score_filter_tip\": \"Support operators:\\n= equal\\n!= not equal\\n> greater than\\n>= greater than or equal\\n< less than\\n<= less than or equal\\ne.g. >3 for scores greater than 3\",\n    \"add_row\": \"Insert Row\",\n    \"edit_row\": \"Edit Row\",\n    \"delete_row\": \"Delete Row\",\n    \"fullscreen\": \"Full Screen\",\n    \"offscreen\": \"Exit Full Screen\",\n    \"pin_edit\": \"Pin (Stay open after save)\",\n    \"unpin_edit\": \"Unpin\",\n    \"search\": \"Search\",\n    \"full_search\": \"Full Text Search\",\n    \"full_search_result\": \"Content matched '{pattern}'\",\n    \"filter_field\": \"Filter Field\",\n    \"filter_value\": \"Filter Value\",\n    \"length\": \"Length\",\n    \"entries\": \"Entries\",\n    \"memory_usage\": \"Memory Usage\",\n    \"text_align_left\": \"Text Align Left\",\n    \"text_align_center\": \"Text Align Center\",\n    \"view_as\": \"View As\",\n    \"decode_with\": \"Decode / Decompress\",\n    \"custom_decoder\": \"New Custom Decoder\",\n    \"reload\": \"Reload\",\n    \"reload_disable\": \"Reload after fully loaded\",\n    \"auto_refresh\": \"Auto Refresh\",\n    \"refresh_interval\": \"Refresh Interval\",\n    \"open_connection\": \"Open Connection\",\n    \"copy_path\": \"Copy Path\",\n    \"copy_key\": \"Copy Key\",\n    \"save_value_succ\": \"Value Saved!\",\n    \"copy_succ\": \"Copied to Clipboard!\",\n    \"binary_key\": \"Binary Key Name\",\n    \"remove_key\": \"Remove Key\",\n    \"new_key\": \"New Key\",\n    \"load_more\": \"Load More Keys\",\n    \"load_all\": \"Load Remaining Keys\",\n    \"load_more_entries\": \"Load More\",\n    \"load_all_entries\": \"Load All\",\n    \"more_action\": \"More Actions\",\n    \"nonexist_tab_content\": \"Selected key does not exist or none selected. Retry after refresh.\",\n    \"empty_server_content\": \"Select and open a connection from the left panel\",\n    \"empty_server_list\": \"No Redis server added\",\n    \"action\": \"Action\",\n    \"type\": \"Type\",\n    \"cli_welcome\": \"Welcome to Tiny RDM Redis Console\",\n    \"retrieving_version\": \"Checking for updates\",\n    \"sub_tab\": {\n      \"status\": \"Status\",\n      \"key_detail\": \"Key Detail\",\n      \"cli\": \"Console\",\n      \"slow_log\": \"Slow Log\",\n      \"cmd_monitor\": \"Monitor Commands\",\n      \"pub_message\": \"Pub/Sub\"\n    }\n  },\n  \"ribbon\": {\n    \"server\": \"Server\",\n    \"browser\": \"Data Browser\",\n    \"log\": \"Log\",\n    \"wechat_official\": \"WeChat Official Account\",\n    \"follow_x\": \"Follow \\uD835\\uDD4F\",\n    \"github\": \"Github\",\n    \"logout\": \"Sign Out\"\n  },\n  \"dialogue\": {\n    \"close_confirm\": \"Close this connection ({name})?\",\n    \"edit_close_confirm\": \"Please close relevant connections before editing. Continue?\",\n    \"opening_connection\": \"Opening Connection...\",\n    \"interrupt_connection\": \"Cancel\",\n    \"remove_tip\": \"{type} \\\"{name}\\\" will be deleted\",\n    \"remove_group_tip\": \"Group \\\"{name}\\\" and all its connections will be deleted\",\n    \"rename_binary_key_fail\": \"Renaming binary key is not supported\",\n    \"handle_succ\": \"Success!\",\n    \"handle_cancel\": \"Operation canceled.\",\n    \"reload_succ\": \"Reloaded!\",\n    \"field_required\": \"This field is required\",\n    \"spec_field_required\": \"\\\"{key}\\\" is required\",\n    \"illegal_characters\": \"Contains illegal characters\",\n    \"connection\": {\n      \"new_title\": \"New Connection\",\n      \"edit_title\": \"Edit Connection\",\n      \"general\": \"General\",\n      \"no_group\": \"No Group\",\n      \"group\": \"Group\",\n      \"conn_name\": \"Name\",\n      \"addr\": \"Address\",\n      \"usr\": \"Username\",\n      \"pwd\": \"Password\",\n      \"name_tip\": \"Connection name\",\n      \"addr_tip\": \"Redis server address\",\n      \"sock_tip\": \"Redis unix socket file\",\n      \"usr_tip\": \"(Optional) Auth username\",\n      \"pwd_tip\": \"(Optional) Auth password (Redis > 6.0)\",\n      \"test\": \"Test Connection\",\n      \"test_succ\": \"Successfully connected to Redis server\",\n      \"test_fail\": \"Connection failed\",\n      \"parse_url_clipboard\": \"Parse URL from Clipboard\",\n      \"parse_pass\": \"Redis URL parsed: {url}\",\n      \"parse_fail\": \"Failed to parse Redis URL: {reason}\",\n      \"advn\": {\n        \"title\": \"Advanced\",\n        \"filter\": \"Default Key Filter\",\n        \"filter_tip\": \"Pattern to filter loaded keys\",\n        \"separator\": \"Key Separator\",\n        \"separator_tip\": \"Separator for key path segments\",\n        \"conn_timeout\": \"Connection Timeout\",\n        \"exec_timeout\": \"Execution Timeout\",\n        \"dbfilter_type\": \"Database Filter\",\n        \"dbfilter_all\": \"Show All\",\n        \"dbfilter_show\": \"Show Selected\",\n        \"dbfilter_hide\": \"Hide Selected\",\n        \"dbfilter_show_title\": \"Databases to Show\",\n        \"dbfilter_hide_title\": \"Databases to Hide\",\n        \"dbfilter_input\": \"Input Database Index\",\n        \"dbfilter_input_tip\": \"Press Enter to confirm\",\n        \"key_view\": \"Default Key View\",\n        \"key_view_tree\": \"Tree View\",\n        \"key_view_list\": \"List View\",\n        \"load_size\": \"Keys Per Load\",\n        \"mark_color\": \"Mark Color\"\n      },\n      \"alias\": {\n        \"title\": \"Database Alias\",\n        \"db\": \"Input Database Index\",\n        \"value\": \"Input Database Alias\"\n      },\n      \"ssl\": {\n        \"title\": \"SSL/TLS\",\n        \"enable\": \"Enable SSL/TLS\",\n        \"allow_insecure\": \"Allow Insecure\",\n        \"sni\": \"Server Name (SNI)\",\n        \"sni_tip\": \"(Optional) Server name\",\n        \"cert_file\": \"Public Key File\",\n        \"key_file\": \"Private Key File\",\n        \"ca_file\": \"CA File\",\n        \"cert_file_tip\": \"Public Key File in PEM format(Cert)\",\n        \"key_file_tip\": \"Private Key File in PEM format(Key)\",\n        \"ca_file_tip\": \"Certificate Authority File in PEM format(CA)\"\n      },\n      \"ssh\": {\n        \"enable\": \"Enable SSH Tunnel\",\n        \"title\": \"SSH Tunnel\",\n        \"login_type\": \"Login Type\",\n        \"agent\": \"SSH Agent\",\n        \"pkfile\": \"Private Key File\",\n        \"passphrase\": \"Passphrase\",\n        \"addr_tip\": \"SSH Server Address\",\n        \"usr_tip\": \"SSH Username\",\n        \"pwd_tip\": \"SSH Password\",\n        \"pkfile_tip\": \"SSH private key file path\",\n        \"passphrase_tip\": \"(Optional) Passphrase for private key\"\n      },\n      \"sentinel\": {\n        \"title\": \"Sentinel\",\n        \"enable\": \"As Sentinel Node\",\n        \"master\": \"Master Group Name\",\n        \"auto_discover\": \"Auto Discover\",\n        \"password\": \"Master Password\",\n        \"username\": \"Master Username\",\n        \"pwd_tip\": \"(Optional) Master auth password (Redis > 6.0)\",\n        \"usr_tip\": \"(Optional) Master auth username\"\n      },\n      \"cluster\": {\n        \"title\": \"Cluster\",\n        \"enable\": \"As Cluster Node\"\n      },\n      \"proxy\": {\n        \"title\": \"Proxy\",\n        \"type_none\": \"No Proxy\",\n        \"type_system\": \"System Proxy\",\n        \"type_custom\": \"Manual Proxy\",\n        \"host\": \"Hostname\",\n        \"auth\": \"Proxy Authentication\",\n        \"usr_tip\": \"Proxy auth username\",\n        \"pwd_tip\": \"Proxy auth password\"\n      }\n    },\n    \"group\": {\n      \"name\": \"Group Name\",\n      \"rename\": \"Rename Group\",\n      \"new\": \"New Group\"\n    },\n    \"key\": {\n      \"new\": \"New Key\",\n      \"new_name\": \"New Key Name\",\n      \"server\": \"Connection\",\n      \"db_index\": \"Database Index\",\n      \"key_expression\": \"Key Pattern\",\n      \"affected_key\": \"Affected Keys\",\n      \"show_affected_key\": \"Show Affected Keys\",\n      \"confirm_delete_key\": \"Confirm delete {num} key(s)\",\n      \"direct_delete\": \"Delete match pattern directly\",\n      \"confirm_delete\": \"Confirm Delete\",\n      \"async_delete\": \"Async Execution\",\n      \"async_delete_title\": \"Don't wait for result\",\n      \"confirm_flush\": \"I know what I'm doing!\",\n      \"confirm_flush_db\": \"Confirm flush database\"\n    },\n    \"delete\": {\n      \"success\": \"\\\"{key}\\\" deleted\",\n      \"deleting\": \"Deleting\",\n      \"doing\": \"Deleting key ({index}/{count})\",\n      \"completed\": \"Deletion completed, {success} succeeded, {fail} failed\"\n    },\n    \"field\": {\n      \"new\": \"New Field\",\n      \"new_item\": \"New Item\",\n      \"conflict_handle\": \"On Field Conflict\",\n      \"overwrite_field\": \"Overwrite\",\n      \"ignore_field\": \"Ignore\",\n      \"insert_type\": \"Insert Type\",\n      \"append_item\": \"Append\",\n      \"prepend_item\": \"Prepend\",\n      \"enter_key\": \"Enter Key\",\n      \"enter_value\": \"Enter Value\",\n      \"enter_field\": \"Enter Field Name\",\n      \"enter_elem\": \"Enter Element\",\n      \"enter_member\": \"Enter Member\",\n      \"enter_score\": \"Enter Score\",\n      \"element\": \"Element\",\n      \"reload_when_succ\": \"Reload immediately if success\"\n    },\n    \"filter\": {\n      \"set_key_filter\": \"Set Key Filter\",\n      \"filter_pattern\": \"Pattern\",\n      \"filter_pattern_tip\": \"Filter by directly input, and scan by press 'Enter'.\\n\\n* matches 0 or more chars, e.g. 'key*'  \\n? matches single char, e.g. 'key?'\\n[] matches range, e.g. 'key[1-3]'\\n\\\\ escapes special chars\",\n      \"exact_match_tip\": \"Exact Match\",\n      \"filter_type_not_support\": \"Type filtering is not supported for Redis 5.x and below.\"\n    },\n    \"export\": {\n      \"name\": \"Export Data\",\n      \"export_expire_title\": \"Expiration\",\n      \"export_expire\": \"Include Expiration\",\n      \"export\": \"Export\",\n      \"save_file\": \"Export Path\",\n      \"save_file_tip\": \"Select path to save exported file\",\n      \"exporting\": \"Exporting keys ({index}/{count})\",\n      \"export_completed\": \"Export completed, {success} succeeded, {fail} failed\"\n    },\n    \"import\": {\n      \"name\": \"Import Data\",\n      \"import_expire_title\": \"Expiration\",\n      \"import\": \"Import\",\n      \"reload\": \"Reload After Import\",\n      \"open_csv_file\": \"Import File\",\n      \"open_csv_file_tip\": \"Select file to import\",\n      \"conflict_handle\": \"On Key Conflict\",\n      \"conflict_overwrite\": \"Overwrite\",\n      \"conflict_ignore\": \"Ignore\",\n      \"ttl_include\": \"Import From File\",\n      \"ttl_ignore\": \"Do Not Set\",\n      \"ttl_custom\": \"Custom\",\n      \"importing\": \"Importing keys imported/overwritten:{imported} conflict/failed:{conflict}\",\n      \"import_completed\": \"Import completed, {success} succeeded, {ignored} ignored\"\n    },\n    \"ttl\": {\n      \"title\": \"Update TTL\",\n      \"title_batch\": \"Batch Update TTL ({count})\",\n      \"quick_set\": \"Quick Set\",\n      \"success\": \"TTL updated for all keys\"\n    },\n    \"decoder\": {\n      \"name\": \"New Decoder/Encoder\",\n      \"edit_name\": \"Edit Decoder/Encoder\",\n      \"new\": \"New\",\n      \"decoder\": \"Decoder\",\n      \"encoder\": \"Encoder\",\n      \"decoder_name\": \"Name\",\n      \"auto\": \"Auto Decode\",\n      \"decode_path\": \"Decoder Path\",\n      \"encode_path\": \"Encoder Path\",\n      \"path_help\": \"Path to executable, or cli alias like 'sh/php/python'\",\n      \"args\": \"Arguments\",\n      \"args_help\": \"Use [VALUE] as placeholder for encoding/decoding content. The content will be appended to the end if no placeholder is provided.\"\n    },\n    \"upgrade\": {\n      \"title\": \"New Version Available\",\n      \"new_version_tip\": \"New version {ver} available, download now?\",\n      \"no_update\": \"You're up-to-date\",\n      \"download_now\": \"Download Now\",\n      \"later\": \"Later\",\n      \"skip\": \"Skip This Version\"\n    },\n    \"welcome\": {\n      \"title\": \"Welcome to Tiny RDM！\",\n      \"content\": \"In order to provide a better user experience, Tiny RDM collects some anonymous data to help optimize the software and improve the user experience, please rest assured that this does not involve your personal privacy information.\\n\\nIf you have any concerns, you can turn off this data collection feature at any time by going to Preferences. If you have any questions, feel free to contact the developer. I hope Tiny RDM can become your helpful assistant!\",\n      \"accept\": \"Help Improve\",\n      \"reject\": \"Reject\"\n    },\n    \"about\": {\n      \"source\": \"Source Code\",\n      \"website\": \"Official Website\"\n    }\n  },\n  \"login\": {\n    \"username_placeholder\": \"Enter username\",\n    \"password_placeholder\": \"Enter password\",\n    \"submit\": \"Sign In\",\n    \"too_many_attempts\": \"Too many attempts, please try later\",\n    \"invalid_credentials\": \"Invalid credentials\",\n    \"network_error\": \"Network error\"\n  },\n  \"menu\": {\n    \"minimise\": \"Minimise\",\n    \"maximise\": \"Maximise\",\n    \"restore\": \"Restore\",\n    \"close\": \"Close\",\n    \"preferences\": \"Preferences\",\n    \"help\": \"Help\",\n    \"user_guide\": \"User Guide\",\n    \"check_update\": \"Check for Updates...\",\n    \"report_bug\": \"Report Bug\",\n    \"about\": \"About\"\n  },\n  \"log\": {\n    \"title\": \"Launch Log\",\n    \"filter_server\": \"Filter Server\",\n    \"filter_keyword\": \"Filter Keyword\",\n    \"clean_log\": \"Clean Log\",\n    \"confirm_clean_log\": \"Confirm clean launch log\",\n    \"exec_time\": \"Exec Time\",\n    \"server\": \"Server\",\n    \"cmd\": \"Command\",\n    \"cost_time\": \"Cost\",\n    \"refresh\": \"Refresh\"\n  },\n  \"status\": {\n    \"uptime\": \"Uptime\",\n    \"connected_clients\": \"Clients\",\n    \"total_keys\": \"Keys\",\n    \"memory_used\": \"Memory\",\n    \"server_info\": \"Server Info\",\n    \"activity_status\": \"Activity\",\n    \"act_cmd\": \"Commands/Sec\",\n    \"act_network_input\": \"Network Input\",\n    \"act_network_output\": \"Network Output\",\n    \"client\": {\n      \"title\": \"Client List\",\n      \"addr\": \"Client Address\",\n      \"age\": \"Age (sec)\",\n      \"idle\": \"Idle (sec)\",\n      \"db\": \"Database\"\n    }\n  },\n  \"slog\": {\n    \"title\": \"Slow Log\",\n    \"limit\": \"Limit\",\n    \"filter\": \"Filter\",\n    \"exec_time\": \"Time\",\n    \"client\": \"Client\",\n    \"cmd\": \"Command\",\n    \"cost_time\": \"Cost\"\n  },\n  \"monitor\": {\n    \"title\": \"Monitor Commands\",\n    \"actions\": \"Actions\",\n    \"warning\": \"Command monitoring may cause server blocking, use with caution on production servers.\",\n    \"start\": \"Start\",\n    \"stop\": \"Stop\",\n    \"search\": \"Search\",\n    \"copy_log\": \"Copy Log\",\n    \"save_log\": \"Save Log\",\n    \"clean_log\": \"Clean Log\",\n    \"always_show_last\": \"Auto Scroll to Latest\"\n  },\n  \"pubsub\": {\n    \"title\": \"Pub/Sub\",\n    \"publish\": \"Publish\",\n    \"subscribe\": \"Subscribe\",\n    \"unsubscribe\": \"Unsubscribe\",\n    \"clear\": \"Clear Messages\",\n    \"time\": \"Time\",\n    \"filter\": \"Filter\",\n    \"channel\": \"Channel\",\n    \"message\": \"Message\",\n    \"receive_message\": \"Received {total} messages\",\n    \"always_show_last\": \"Auto Scroll to Latest\"\n  }\n}\n"
  },
  {
    "path": "frontend/src/langs/es-es.json",
    "content": "{\n  \"name\": \"Español\",\n  \"common\": {\n    \"confirm\": \"Confirmar\",\n    \"cancel\": \"Cancelar\",\n    \"success\": \"Éxito\",\n    \"warning\": \"Advertencia\",\n    \"error\": \"Error\",\n    \"save\": \"Guardar\",\n    \"update\": \"Actualizar\",\n    \"none\": \"Ninguno\",\n    \"second\": \"Segundo(s)\",\n    \"minute\": \"Minuto(s)\",\n    \"hour\": \"Hora(s)\",\n    \"day\": \"Día(s)\",\n    \"unit_day\": \"d\",\n    \"unit_hour\": \"h\",\n    \"unit_minute\": \"m\",\n    \"unit_second\": \"s\",\n    \"all\": \"Todos\",\n    \"key\": \"Clave\",\n    \"value\": \"Valor\",\n    \"field\": \"Campo\",\n    \"score\": \"Puntuación\",\n    \"index\": \"Posición\"\n  },\n  \"preferences\": {\n    \"name\": \"Preferencias\",\n    \"restore_defaults\": \"Restaurar valores predeterminados\",\n    \"font_tip\": \"Admite selección múltiple. Ingrese manualmente la fuente si no está en la lista.\",\n    \"general\": {\n      \"name\": \"General\",\n      \"theme\": \"Tema\",\n      \"theme_light\": \"Claro\",\n      \"theme_dark\": \"Oscuro\",\n      \"theme_auto\": \"Automático\",\n      \"language\": \"Idioma\",\n      \"system_lang\": \"Usar el idioma del sistema\",\n      \"font\": \"Fuente\",\n      \"font_tip\": \"Seleccione o ingrese el nombre de la fuente\",\n      \"font_size\": \"Tamaño de fuente\",\n      \"scan_size\": \"Tamaño predeterminado para SCAN\",\n      \"scan_size_tip\": \"Número de elementos devueltos por los comandos SCAN/HSCAN/SSCAN/ZSCAN\",\n      \"key_icon_style\": \"Estilo de icono de clave\",\n      \"key_icon_style0\": \"Compacto\",\n      \"key_icon_style1\": \"Nombre completo\",\n      \"key_icon_style2\": \"Punto\",\n      \"key_icon_style3\": \"Común\",\n      \"update\": \"Actualizar\",\n      \"auto_check_update\": \"Buscar actualizaciones automáticamente\",\n      \"privacy\": \"Política de Privacidad\",\n      \"allow_track\": \"Permitir recopilar datos anónimos\"\n    },\n    \"editor\": {\n      \"name\": \"Editor\",\n      \"show_linenum\": \"Mostrar números de línea\",\n      \"show_folding\": \"Habilitar plegado de código\",\n      \"drop_text\": \"Permitir arrastrar y soltar texto\",\n      \"links\": \"Compatibilidad con enlaces\"\n    },\n    \"cli\": {\n      \"name\": \"Línea de comandos\",\n      \"cursor_style\": \"Estilo del cursor\",\n      \"cursor_style_block\": \"Bloque\",\n      \"cursor_style_underline\": \"Subrayado\",\n      \"cursor_style_bar\": \"Barra\"\n    },\n    \"decoder\": {\n      \"name\": \"Decodificador personalizado\",\n      \"new\": \"Nuevo decodificador\",\n      \"decoder_name\": \"Nombre\",\n      \"cmd_preview\": \"Vista previa\",\n      \"status\": \"Estado\",\n      \"auto_enabled\": \"Decodificación automática habilitada\",\n      \"help\": \"Ayuda\"\n    }\n  },\n  \"interface\": {\n    \"new_conn\": \"Agregar conexión\",\n    \"new_group\": \"Agregar grupo\",\n    \"disconnect_all\": \"Desconectar todo\",\n    \"status\": \"Estado\",\n    \"filter\": \"Filtrar\",\n    \"sort_conn\": \"Ordenar conexiones\",\n    \"new_conn_title\": \"Nueva conexión\",\n    \"open_db\": \"Abrir base de datos\",\n    \"close_db\": \"Cerrar base de datos\",\n    \"filter_key\": \"Filtrar claves\",\n    \"disconnect\": \"Desconectar\",\n    \"dup_conn\": \"Duplicar conexión\",\n    \"remove_conn\": \"Eliminar conexión\",\n    \"edit_conn\": \"Editar conexión\",\n    \"edit_conn_group\": \"Editar grupo\",\n    \"rename_conn_group\": \"Renombrar grupo\",\n    \"remove_conn_group\": \"Eliminar grupo\",\n    \"import_conn\": \"Importar conexiones...\",\n    \"export_conn\": \"Exportar conexiones...\",\n    \"ttl\": \"TTL\",\n    \"forever\": \"Siempre\",\n    \"rename_key\": \"Renombrar clave\",\n    \"delete_key\": \"Eliminar clave\",\n    \"batch_delete_key\": \"Eliminar claves en lote\",\n    \"import_key\": \"Importar claves\",\n    \"flush_db\": \"Vaciar base de datos\",\n    \"check_mode\": \"Modo de selección\",\n    \"quit_check_mode\": \"Salir del modo de selección\",\n    \"delete_checked\": \"Eliminar seleccionados\",\n    \"export_checked\": \"Exportar seleccionados\",\n    \"ttl_checked\": \"Actualizar TTL para seleccionados\",\n    \"copy_value\": \"Copiar valor\",\n    \"edit_value\": \"Editar valor\",\n    \"save_update\": \"Guardar cambios\",\n    \"score_filter_tip\": \"Soporte para operadores:\\n= igual\\n!= distinto\\n> mayor que\\n>= mayor o igual\\n< menor que\\n<= menor o igual\\ne.j. >3 para puntuaciones mayores que 3\",\n    \"add_row\": \"Insertar fila\",\n    \"edit_row\": \"Editar fila\",\n    \"delete_row\": \"Eliminar fila\",\n    \"fullscreen\": \"Pantalla completa\",\n    \"offscreen\": \"Salir de pantalla completa\",\n    \"pin_edit\": \"Fijar (permanece abierto después de guardar)\",\n    \"unpin_edit\": \"Desfijar\",\n    \"search\": \"Buscar\",\n    \"full_search\": \"Búsqueda de texto completo\",\n    \"full_search_result\": \"Contenido coincidente '{pattern}'\",\n    \"filter_field\": \"Filtrar campo\",\n    \"filter_value\": \"Filtrar valor\",\n    \"length\": \"Longitud\",\n    \"entries\": \"Entradas\",\n    \"memory_usage\": \"Uso de memoria\",\n    \"text_align_left\": \"Alinear a la izquierda\",\n    \"text_align_center\": \"Centrar\",\n    \"view_as\": \"Ver como\",\n    \"decode_with\": \"Decodificar / Descomprimir\",\n    \"custom_decoder\": \"Nuevo decodificador personalizado\",\n    \"reload\": \"Recargar\",\n    \"reload_disable\": \"Recargar después de cargar completamente\",\n    \"auto_refresh\": \"Actualización automática\",\n    \"refresh_interval\": \"Intervalo de actualización\",\n    \"open_connection\": \"Abrir conexión\",\n    \"copy_path\": \"Copiar ruta\",\n    \"copy_key\": \"Copiar clave\",\n    \"save_value_succ\": \"¡Valor guardado!\",\n    \"copy_succ\": \"¡Copiado al portapapeles!\",\n    \"binary_key\": \"Clave binaria\",\n    \"remove_key\": \"Eliminar clave\",\n    \"new_key\": \"Nueva clave\",\n    \"load_more\": \"Cargar más claves\",\n    \"load_all\": \"Cargar todas las claves restantes\",\n    \"load_more_entries\": \"Cargar más\",\n    \"load_all_entries\": \"Cargar todo\",\n    \"more_action\": \"Más acciones\",\n    \"nonexist_tab_content\": \"La clave seleccionada no existe o ninguna seleccionada. Intente nuevamente después de actualizar.\",\n    \"empty_server_content\": \"Seleccione y abra una conexión desde el panel izquierdo\",\n    \"empty_server_list\": \"No se ha agregado ningún servidor Redis\",\n    \"action\": \"Acción\",\n    \"type\": \"Tipo\",\n    \"cli_welcome\": \"Bienvenido a la consola Redis de Tiny RDM\",\n    \"retrieving_version\": \"Buscando actualizaciones\",\n    \"sub_tab\": {\n      \"status\": \"Estado\",\n      \"key_detail\": \"Detalles de clave\",\n      \"cli\": \"Consola\",\n      \"slow_log\": \"Registro lento\",\n      \"cmd_monitor\": \"Monitorear comandos\",\n      \"pub_message\": \"Pub/Sub\"\n    }\n  },\n  \"ribbon\": {\n    \"server\": \"Servidor\",\n    \"browser\": \"Explorador de datos\",\n    \"log\": \"Registro\",\n    \"wechat_official\": \"Cuenta oficial de WeChat\",\n    \"follow_x\": \"Seguir \\uD835\\uDD4F\",\n    \"github\": \"Github\",\n    \"logout\": \"Cerrar sesión\"\n  },\n  \"dialogue\": {\n    \"close_confirm\": \"¿Cerrar esta conexión ({name})?\",\n    \"edit_close_confirm\": \"Cierre las conexiones relevantes antes de editar. ¿Continuar?\",\n    \"opening_connection\": \"Abriendo conexión...\",\n    \"interrupt_connection\": \"Cancelar\",\n    \"remove_tip\": \"{type} \\\"{name}\\\" será eliminado\",\n    \"remove_group_tip\": \"El grupo \\\"{name}\\\" y todas sus conexiones serán eliminados\",\n    \"rename_binary_key_fail\": \"No se admite renombrar claves binarias\",\n    \"handle_succ\": \"¡Éxito!\",\n    \"handle_cancel\": \"Operación cancelada.\",\n    \"reload_succ\": \"¡Recargado!\",\n    \"field_required\": \"Este campo es obligatorio\",\n    \"spec_field_required\": \"\\\"{key}\\\" es obligatorio\",\n    \"illegal_characters\": \"Contiene caracteres ilegales\",\n    \"connection\": {\n      \"new_title\": \"Nueva conexión\",\n      \"edit_title\": \"Editar conexión\",\n      \"general\": \"General\",\n      \"no_group\": \"Sin grupo\",\n      \"group\": \"Grupo\",\n      \"conn_name\": \"Nombre\",\n      \"addr\": \"Dirección\",\n      \"usr\": \"Usuario\",\n      \"pwd\": \"Contraseña\",\n      \"name_tip\": \"Nombre de la conexión\",\n      \"addr_tip\": \"Dirección del servidor Redis\",\n      \"sock_tip\": \"Archivo de socket Unix de Redis\",\n      \"usr_tip\": \"(Opcional) Usuario de autenticación\",\n      \"pwd_tip\": \"(Opcional) Contraseña de autenticación (Redis > 6.0)\",\n      \"test\": \"Probar conexión\",\n      \"test_succ\": \"Conectado con éxito al servidor Redis\",\n      \"test_fail\": \"Falló la conexión\",\n      \"parse_url_clipboard\": \"Analizar URL desde el portapapeles\",\n      \"parse_pass\": \"URL de Redis analizada: {url}\",\n      \"parse_fail\": \"Error al analizar la URL de Redis: {reason}\",\n      \"advn\": {\n        \"title\": \"Avanzado\",\n        \"filter\": \"Filtro de clave predeterminado\",\n        \"filter_tip\": \"Patrón para filtrar las claves cargadas\",\n        \"separator\": \"Separador de clave\",\n        \"separator_tip\": \"Separador para segmentos de ruta de clave\",\n        \"conn_timeout\": \"Tiempo de espera de conexión\",\n        \"exec_timeout\": \"Tiempo de espera de ejecución\",\n        \"dbfilter_type\": \"Filtro de base de datos\",\n        \"dbfilter_all\": \"Mostrar todo\",\n        \"dbfilter_show\": \"Mostrar seleccionados\",\n        \"dbfilter_hide\": \"Ocultar seleccionados\",\n        \"dbfilter_show_title\": \"Bases de datos a mostrar\",\n        \"dbfilter_hide_title\": \"Bases de datos a ocultar\",\n        \"dbfilter_input\": \"Ingresar índice de base de datos\",\n        \"dbfilter_input_tip\": \"Presione Enter para confirmar\",\n        \"key_view\": \"Vista de clave predeterminada\",\n        \"key_view_tree\": \"Vista de árbol\",\n        \"key_view_list\": \"Vista de lista\",\n        \"load_size\": \"Claves por carga\",\n        \"mark_color\": \"Color de marca\"\n      },\n      \"alias\": {\n        \"title\": \"Alias de base de datos\",\n        \"db\": \"Ingresar índice de base de datos\",\n        \"value\": \"Ingresar alias de base de datos\"\n      },\n      \"ssl\": {\n        \"title\": \"SSL/TLS\",\n        \"enable\": \"Habilitar SSL/TLS\",\n        \"allow_insecure\": \"Permitir inseguro\",\n        \"sni\": \"Nombre de servidor (SNI)\",\n        \"sni_tip\": \"(Opcional) Nombre del servidor\",\n        \"cert_file\": \"Archivo de clave pública\",\n        \"key_file\": \"Archivo de clave privada\",\n        \"ca_file\": \"Archivo CA\",\n        \"cert_file_tip\": \"Archivo de clave pública en formato PEM (Cert)\",\n        \"key_file_tip\": \"Archivo de clave privada en formato PEM (Key)\",\n        \"ca_file_tip\": \"Archivo de autoridad de certificación en formato PEM (CA)\"\n      },\n      \"ssh\": {\n        \"enable\": \"Habilitar túnel SSH\",\n        \"title\": \"Túnel SSH\",\n        \"login_type\": \"Tipo de inicio de sesión\",\n        \"agent\": \"Agente SSH\",\n        \"pkfile\": \"Archivo de clave privada\",\n        \"passphrase\": \"Frase de contraseña\",\n        \"addr_tip\": \"Dirección del servidor SSH\",\n        \"usr_tip\": \"Usuario SSH\",\n        \"pwd_tip\": \"Contraseña SSH\",\n        \"pkfile_tip\": \"Ruta del archivo de clave privada SSH\",\n        \"passphrase_tip\": \"(Opcional) Frase de contraseña para la clave privada\"\n      },\n      \"sentinel\": {\n        \"title\": \"Centinela\",\n        \"enable\": \"Como nodo centinela\",\n        \"master\": \"Nombre del grupo maestro\",\n        \"auto_discover\": \"Descubrimiento automático\",\n        \"password\": \"Contraseña del maestro\",\n        \"username\": \"Usuario del maestro\",\n        \"pwd_tip\": \"(Opcional) Contraseña de autenticación del maestro (Redis > 6.0)\",\n        \"usr_tip\": \"(Opcional) Usuario de autenticación del maestro\"\n      },\n      \"cluster\": {\n        \"title\": \"Clúster\",\n        \"enable\": \"Como nodo de clúster\"\n      },\n      \"proxy\": {\n        \"title\": \"Proxy\",\n        \"type_none\": \"Sin proxy\",\n        \"type_system\": \"Proxy del sistema\",\n        \"type_custom\": \"Proxy manual\",\n        \"host\": \"Nombre de host\",\n        \"auth\": \"Autenticación de proxy\",\n        \"usr_tip\": \"Usuario de autenticación de proxy\",\n        \"pwd_tip\": \"Contraseña de autenticación de proxy\"\n      }\n    },\n    \"group\": {\n      \"name\": \"Nombre del grupo\",\n      \"rename\": \"Renombrar grupo\",\n      \"new\": \"Nuevo grupo\"\n    },\n    \"key\": {\n      \"new\": \"Nueva clave\",\n      \"new_name\": \"Nuevo nombre de clave\",\n      \"server\": \"Conexión\",\n      \"db_index\": \"Índice de base de datos\",\n      \"key_expression\": \"Patrón de clave\",\n      \"affected_key\": \"Claves afectadas\",\n      \"show_affected_key\": \"Mostrar claves afectadas\",\n      \"confirm_delete_key\": \"Confirmar eliminar {num} clave(s)\",\n      \"direct_delete\": \"Eliminar el patrón coincidente directamente\",\n      \"confirm_delete\": \"Confirmar eliminación\",\n      \"async_delete\": \"Ejecución asíncrona\",\n      \"async_delete_title\": \"No esperar el resultado\",\n      \"confirm_flush\": \"¡Sé lo que estoy haciendo!\",\n      \"confirm_flush_db\": \"Confirmar vaciar la base de datos\"\n    },\n    \"delete\": {\n      \"success\": \"\\\"{key}\\\" eliminada\",\n      \"deleting\": \"Eliminando\",\n      \"doing\": \"Eliminando clave ({index}/{count})\",\n      \"completed\": \"Eliminación completada, {success} tuvieron éxito, {fail} fallaron\"\n    },\n    \"field\": {\n      \"new\": \"Nuevo campo\",\n      \"new_item\": \"Nuevo elemento\",\n      \"conflict_handle\": \"En conflicto de campo\",\n      \"overwrite_field\": \"Sobrescribir\",\n      \"ignore_field\": \"Ignorar\",\n      \"insert_type\": \"Tipo de inserción\",\n      \"append_item\": \"Anexar\",\n      \"prepend_item\": \"Anteponer\",\n      \"enter_key\": \"Ingresar clave\",\n      \"enter_value\": \"Ingresar valor\",\n      \"enter_field\": \"Ingresar nombre de campo\",\n      \"enter_elem\": \"Ingresar elemento\",\n      \"enter_member\": \"Ingresar miembro\",\n      \"enter_score\": \"Ingresar puntuación\",\n      \"element\": \"Elemento\",\n      \"reload_when_succ\": \"Recargar inmediatamente si tiene éxito\"\n    },\n    \"filter\": {\n      \"set_key_filter\": \"Establecer filtro de clave\",\n      \"filter_pattern\": \"Patrón\",\n      \"filter_pattern_tip\": \"Filtre la lista actual ingresando directamente, y escanee el servidor presionando 'Ingresar'.\\n\\n* coincide con 0 o más caracteres, ej. 'key*'\\n? coincide con un carácter, ej. 'key?'\\n[] coincide con un rango, ej. 'key[1-3]'\\n\\\\ escapa caracteres especiales\",\n      \"exact_match_tip\": \"Coincidencia exacta\",\n      \"filter_type_not_support\": \"El filtrado por tipo no es compatible con Redis 5.x y versiones anteriores\"\n    },\n    \"export\": {\n      \"name\": \"Exportar datos\",\n      \"export_expire_title\": \"Expiración\",\n      \"export_expire\": \"Incluir expiración\",\n      \"export\": \"Exportar\",\n      \"save_file\": \"Ruta de exportación\",\n      \"save_file_tip\": \"Seleccionar ruta para guardar archivo exportado\",\n      \"exporting\": \"Exportando claves ({index}/{count})\",\n      \"export_completed\": \"Exportación completada, {success} tuvieron éxito, {fail} fallaron\"\n    },\n    \"import\": {\n      \"name\": \"Importar datos\",\n      \"import_expire_title\": \"Expiración\",\n      \"import\": \"Importar\",\n      \"reload\": \"Recargar después de importar\",\n      \"open_csv_file\": \"Archivo de importación\",\n      \"open_csv_file_tip\": \"Seleccionar archivo a importar\",\n      \"conflict_handle\": \"En conflicto de clave\",\n      \"conflict_overwrite\": \"Sobrescribir\",\n      \"conflict_ignore\": \"Ignorar\",\n      \"ttl_include\": \"Importar desde archivo\",\n      \"ttl_ignore\": \"No establecer\",\n      \"ttl_custom\": \"Personalizado\",\n      \"importing\": \"Importando claves importadas/sobrescritas:{imported} conflicto/fallas:{conflict}\",\n      \"import_completed\": \"Importación completada, {success} tuvieron éxito, {ignored} ignoradas\"\n    },\n    \"ttl\": {\n      \"title\": \"Actualizar TTL\",\n      \"title_batch\": \"Actualizar TTL en lote ({count})\",\n      \"quick_set\": \"Configurar rápidamente\",\n      \"success\": \"TTL actualizado para todas las claves\"\n    },\n    \"decoder\": {\n      \"name\": \"Nuevo decodificador/codificador\",\n      \"edit_name\": \"Editar decodificador/codificador\",\n      \"new\": \"Nuevo\",\n      \"decoder\": \"Decodificador\",\n      \"encoder\": \"Codificador\",\n      \"decoder_name\": \"Nombre\",\n      \"auto\": \"Decodificar automáticamente\",\n      \"decode_path\": \"Ruta del decodificador\",\n      \"encode_path\": \"Ruta del codificador\",\n      \"path_help\": \"Ruta al ejecutable, o alias de cli como 'sh/php/python'\",\n      \"args\": \"Argumentos\",\n      \"args_help\": \"Use [VALUE] como marcador de posición para codificar/decodificar contenido. El contenido se agregará al final si no se proporciona marcador de posición.\"\n    },\n    \"upgrade\": {\n      \"title\": \"Nueva versión disponible\",\n      \"new_version_tip\": \"Nueva versión {ver} disponible, ¿descargar ahora?\",\n      \"no_update\": \"Está actualizado\",\n      \"download_now\": \"Descargar ahora\",\n      \"later\": \"Más tarde\",\n      \"skip\": \"Omitir esta versión\"\n    },\n    \"welcome\": {\n      \"title\": \"¡Bienvenido a Tiny RDM!\",\n      \"content\": \"Con el fin de brindar una mejor experiencia de usuario, Tiny RDM recopila algunos datos anónimos para ayudar a optimizar el software y mejorar la experiencia del usuario. Tenga la seguridad de que esto no involucrará su información de privacidad personal.\\n\\nSi tiene alguna inquietud, puede desactivar esta función de recopilación de datos en cualquier momento yendo a Preferencias. Si tiene alguna pregunta, no dude en ponerse en contacto con el desarrollador. ¡Espero que Tiny RDM pueda ser un buen asistente para usted!\",\n      \"accept\": \"Ayudar a Mejorar\",\n      \"reject\": \"Rechazar\"\n    },\n    \"about\": {\n      \"source\": \"Código fuente\",\n      \"website\": \"Sitio web oficial\"\n    }\n  },\n  \"login\": {\n    \"username_placeholder\": \"Ingrese usuario\",\n    \"password_placeholder\": \"Ingrese contraseña\",\n    \"submit\": \"Entrar\",\n    \"too_many_attempts\": \"Demasiados intentos, intente más tarde\",\n    \"invalid_credentials\": \"Credenciales inválidas\",\n    \"network_error\": \"Error de red\"\n  },\n  \"menu\": {\n    \"minimise\": \"Minimizar\",\n    \"maximise\": \"Maximizar\",\n    \"restore\": \"Restaurar\",\n    \"close\": \"Cerrar\",\n    \"preferences\": \"Preferencias\",\n    \"help\": \"Ayuda\",\n    \"user_guide\": \"Guía de usuario\",\n    \"check_update\": \"Buscar actualizaciones...\",\n    \"report_bug\": \"Reportar error\",\n    \"about\": \"Acerca de\"\n  },\n  \"log\": {\n    \"title\": \"Registro de lanzamiento\",\n    \"filter_server\": \"Filtrar servidor\",\n    \"filter_keyword\": \"Filtrar palabra clave\",\n    \"clean_log\": \"Limpiar registro\",\n    \"confirm_clean_log\": \"Confirmar limpiar registro de lanzamiento\",\n    \"exec_time\": \"Tiempo de ejecución\",\n    \"server\": \"Servidor\",\n    \"cmd\": \"Comando\",\n    \"cost_time\": \"Costo\",\n    \"refresh\": \"Actualizar\"\n  },\n  \"status\": {\n    \"uptime\": \"Tiempo activo\",\n    \"connected_clients\": \"Clientes\",\n    \"total_keys\": \"Claves\",\n    \"memory_used\": \"Memoria\",\n    \"server_info\": \"Información del servidor\",\n    \"activity_status\": \"Actividad\",\n    \"act_cmd\": \"Comandos/Seg\",\n    \"act_network_input\": \"Entrada de red\",\n    \"act_network_output\": \"Salida de red\",\n    \"client\": {\n      \"title\": \"Lista de clientes\",\n      \"addr\": \"Dirección del cliente\",\n      \"age\": \"Edad (seg)\",\n      \"idle\": \"Inactivo (seg)\",\n      \"db\": \"Base de datos\"\n    }\n  },\n  \"slog\": {\n    \"title\": \"Registro lento\",\n    \"limit\": \"Límite\",\n    \"filter\": \"Filtrar\",\n    \"exec_time\": \"Tiempo\",\n    \"client\": \"Cliente\",\n    \"cmd\": \"Comando\",\n    \"cost_time\": \"Costo\"\n  },\n  \"monitor\": {\n    \"title\": \"Monitorear comandos\",\n    \"actions\": \"Acciones\",\n    \"warning\": \"El monitoreo de comandos puede causar bloqueos en el servidor, úselo con precaución en servidores de producción.\",\n    \"start\": \"Iniciar\",\n    \"stop\": \"Detener\",\n    \"search\": \"Buscar\",\n    \"copy_log\": \"Copiar registro\",\n    \"save_log\": \"Guardar registro\",\n    \"clean_log\": \"Limpiar registro\",\n    \"always_show_last\": \"Desplazamiento automático al último\"\n  },\n  \"pubsub\": {\n    \"title\": \"Pub/Sub\",\n    \"publish\": \"Publicar\",\n    \"subscribe\": \"Suscribir\",\n    \"unsubscribe\": \"Cancelar suscripción\",\n    \"clear\": \"Limpiar mensajes\",\n    \"time\": \"Tiempo\",\n    \"filter\": \"Filtrar\",\n    \"channel\": \"Canal\",\n    \"message\": \"Mensaje\",\n    \"receive_message\": \"Recibidos {total} mensajes\",\n    \"always_show_last\": \"Desplazamiento automático al último\"\n  }\n}\n"
  },
  {
    "path": "frontend/src/langs/fr-fr.json",
    "content": "{\n  \"name\": \"Français\",\n  \"common\": {\n    \"confirm\": \"Confirmer\",\n    \"cancel\": \"Annuler\",\n    \"success\": \"Succès\",\n    \"warning\": \"Avertissement\",\n    \"error\": \"Erreur\",\n    \"save\": \"Enregistrer\",\n    \"update\": \"Mettre à jour\",\n    \"none\": \"Aucun\",\n    \"second\": \"Seconde(s)\",\n    \"minute\": \"Minute(s)\",\n    \"hour\": \"Heure(s)\",\n    \"day\": \"Jour(s)\",\n    \"unit_day\": \"j\",\n    \"unit_hour\": \"h\",\n    \"unit_minute\": \"m\",\n    \"unit_second\": \"s\",\n    \"all\": \"Tous\",\n    \"key\": \"Clé\",\n    \"value\": \"Valeur\",\n    \"field\": \"Champ\",\n    \"score\": \"Score\",\n    \"index\": \"Position\"\n  },\n  \"preferences\": {\n    \"name\": \"Préférences\",\n    \"restore_defaults\": \"Restaurer les valeurs par défaut\",\n    \"font_tip\": \"Supporte la sélection multiple. Saisir manuellement la police si elle n'est pas listée.\",\n    \"general\": {\n      \"name\": \"Général\",\n      \"theme\": \"Thème\",\n      \"theme_light\": \"Clair\",\n      \"theme_dark\": \"Sombre\",\n      \"theme_auto\": \"Automatique\",\n      \"language\": \"Langue\",\n      \"system_lang\": \"Utiliser la langue du système\",\n      \"font\": \"Police\",\n      \"font_tip\": \"Sélectionner ou saisir le nom de la police\",\n      \"font_size\": \"Taille de la police\",\n      \"scan_size\": \"Taille par défaut pour SCAN\",\n      \"scan_size_tip\": \"Nombre d'éléments retournés par les commandes SCAN/HSCAN/SSCAN/ZSCAN\",\n      \"key_icon_style\": \"Style d'icône de clé\",\n      \"key_icon_style0\": \"Compact\",\n      \"key_icon_style1\": \"Nom complet\",\n      \"key_icon_style2\": \"Point\",\n      \"key_icon_style3\": \"Commun\",\n      \"update\": \"Mise à jour\",\n      \"auto_check_update\": \"Vérifier automatiquement les mises à jour\",\n      \"privacy\": \"Politique de confidentialité\",\n      \"allow_track\": \"Autoriser la collecte de données anonymes\"\n    },\n    \"editor\": {\n      \"name\": \"Éditeur\",\n      \"show_linenum\": \"Afficher les numéros de ligne\",\n      \"show_folding\": \"Activer le repliage de code\",\n      \"drop_text\": \"Autoriser le glisser-déposer de texte\",\n      \"links\": \"Supporter les liens\"\n    },\n    \"cli\": {\n      \"name\": \"Ligne de commande\",\n      \"cursor_style\": \"Style du curseur\",\n      \"cursor_style_block\": \"Bloc\",\n      \"cursor_style_underline\": \"Soulignement\",\n      \"cursor_style_bar\": \"Barre\"\n    },\n    \"decoder\": {\n      \"name\": \"Décodeur personnalisé\",\n      \"new\": \"Nouveau décodeur\",\n      \"decoder_name\": \"Nom\",\n      \"cmd_preview\": \"Aperçu\",\n      \"status\": \"Statut\",\n      \"auto_enabled\": \"Décodage automatique activé\",\n      \"help\": \"Aide\"\n    }\n  },\n  \"interface\": {\n    \"new_conn\": \"Ajouter une connexion\",\n    \"new_group\": \"Ajouter un groupe\",\n    \"disconnect_all\": \"Déconnecter tout\",\n    \"status\": \"Statut\",\n    \"filter\": \"Filtre\",\n    \"sort_conn\": \"Trier les connexions\",\n    \"new_conn_title\": \"Nouvelle connexion\",\n    \"open_db\": \"Ouvrir la base de données\",\n    \"close_db\": \"Fermer la base de données\",\n    \"filter_key\": \"Filtrer les clés\",\n    \"disconnect\": \"Déconnecter\",\n    \"dup_conn\": \"Dupliquer la connexion\",\n    \"remove_conn\": \"Supprimer la connexion\",\n    \"edit_conn\": \"Éditer la connexion\",\n    \"edit_conn_group\": \"Éditer le groupe\",\n    \"rename_conn_group\": \"Renommer le groupe\",\n    \"remove_conn_group\": \"Supprimer le groupe\",\n    \"import_conn\": \"Importer des connexions...\",\n    \"export_conn\": \"Exporter des connexions...\",\n    \"ttl\": \"TTL\",\n    \"forever\": \"Pour toujours\",\n    \"rename_key\": \"Renommer la clé\",\n    \"delete_key\": \"Supprimer la clé\",\n    \"batch_delete_key\": \"Supprimer les clés par lot\",\n    \"import_key\": \"Importer des clés\",\n    \"flush_db\": \"Vider la base de données\",\n    \"check_mode\": \"Mode de vérification\",\n    \"quit_check_mode\": \"Quitter le mode de vérification\",\n    \"delete_checked\": \"Supprimer les éléments cochés\",\n    \"export_checked\": \"Exporter les éléments cochés\",\n    \"ttl_checked\": \"Mettre à jour le TTL des éléments cochés\",\n    \"copy_value\": \"Copier la valeur\",\n    \"edit_value\": \"Éditer la valeur\",\n    \"save_update\": \"Enregistrer les modifications\",\n    \"score_filter_tip\": \"Supporte les opérateurs :\\n= égal\\n!= différent\\n> supérieur à \\n>= supérieur ou égal\\n< inférieur à\\n<= inférieur ou égal\\nEx: >3 pour les scores supérieurs à 3\",\n    \"add_row\": \"Insérer une ligne\",\n    \"edit_row\": \"Éditer la ligne\",\n    \"delete_row\": \"Supprimer la ligne\",\n    \"fullscreen\": \"Plein écran\",\n    \"offscreen\": \"Quitter le plein écran\",\n    \"pin_edit\": \"Épingler (rester ouvert après enregistrement)\",\n    \"unpin_edit\": \"Désépingler\",\n    \"search\": \"Rechercher\",\n    \"full_search\": \"Recherche en texte intégral\",\n    \"full_search_result\": \"Contenu correspondant à '{pattern}'\",\n    \"filter_field\": \"Filtrer le champ\",\n    \"filter_value\": \"Valeur de filtrage\",\n    \"length\": \"Longueur\",\n    \"entries\": \"Entrées\",\n    \"memory_usage\": \"Utilisation de la mémoire\",\n    \"text_align_left\": \"Aligner à gauche\",\n    \"text_align_center\": \"Centrer\",\n    \"view_as\": \"Voir comme\",\n    \"decode_with\": \"Décoder / Décompresser\",\n    \"custom_decoder\": \"Nouveau décodeur personnalisé\",\n    \"reload\": \"Recharger\",\n    \"reload_disable\": \"Recharger après chargement complet\",\n    \"auto_refresh\": \"Rafraîchissement automatique\",\n    \"refresh_interval\": \"Intervalle de rafraîchissement\",\n    \"open_connection\": \"Ouvrir la connexion\",\n    \"copy_path\": \"Copier le chemin\",\n    \"copy_key\": \"Copier la clé\",\n    \"save_value_succ\": \"Valeur enregistrée !\",\n    \"copy_succ\": \"Copié dans le presse-papiers !\",\n    \"binary_key\": \"Clé binaire\",\n    \"remove_key\": \"Supprimer la clé\",\n    \"new_key\": \"Nouvelle clé\",\n    \"load_more\": \"Charger plus de clés\",\n    \"load_all\": \"Charger toutes les clés restantes\",\n    \"load_more_entries\": \"Charger plus\",\n    \"load_all_entries\": \"Charger tout\",\n    \"more_action\": \"Plus d'actions\",\n    \"nonexist_tab_content\": \"La clé sélectionnée n'existe pas ou aucune clé n'est sélectionnée. Réessayez après un rafraîchissement.\",\n    \"empty_server_content\": \"Sélectionnez et ouvrez une connexion depuis le panneau de gauche\",\n    \"empty_server_list\": \"Aucun serveur Redis ajouté\",\n    \"action\": \"Action\",\n    \"type\": \"Type\",\n    \"cli_welcome\": \"Bienvenue dans la console Redis de Tiny RDM\",\n    \"retrieving_version\": \"Vérification des mises à jour\",\n    \"sub_tab\": {\n      \"status\": \"Statut\",\n      \"key_detail\": \"Détails de la clé\",\n      \"cli\": \"Console\",\n      \"slow_log\": \"Journal lent\",\n      \"cmd_monitor\": \"Surveiller les commandes\",\n      \"pub_message\": \"Pub/Sub\"\n    }\n  },\n  \"ribbon\": {\n    \"server\": \"Serveur\",\n    \"browser\": \"Navigateur de données\",\n    \"log\": \"Journal\",\n    \"wechat_official\": \"Compte officiel WeChat\",\n    \"follow_x\": \"Suivre \\uD835\\uDD4F\",\n    \"github\": \"Github\",\n    \"logout\": \"Se déconnecter\"\n  },\n  \"dialogue\": {\n    \"close_confirm\": \"Fermer cette connexion ({name}) ?\",\n    \"edit_close_confirm\": \"Veuillez fermer les connexions appropriées avant l'édition. Continuer ?\",\n    \"opening_connection\": \"Ouverture de la connexion...\",\n    \"interrupt_connection\": \"Annuler\",\n    \"remove_tip\": \"{type} \\\"{name}\\\" sera supprimé\",\n    \"remove_group_tip\": \"Le groupe \\\"{name}\\\" et toutes ses connexions seront supprimés\",\n    \"rename_binary_key_fail\": \"Le renommage des clés binaires n'est pas pris en charge\",\n    \"handle_succ\": \"Succès !\",\n    \"handle_cancel\": \"Opération annulée.\",\n    \"reload_succ\": \"Rechargé !\",\n    \"field_required\": \"Ce champ est obligatoire\",\n    \"spec_field_required\": \"\\\"{key}\\\" est requis\",\n    \"illegal_characters\": \"Contient des caractères illégaux\",\n    \"connection\": {\n      \"new_title\": \"Nouvelle connexion\",\n      \"edit_title\": \"Éditer la connexion\",\n      \"general\": \"Général\",\n      \"no_group\": \"Aucun groupe\",\n      \"group\": \"Groupe\",\n      \"conn_name\": \"Nom\",\n      \"addr\": \"Adresse\",\n      \"usr\": \"Nom d'utilisateur\",\n      \"pwd\": \"Mot de passe\",\n      \"name_tip\": \"Nom de la connexion\",\n      \"addr_tip\": \"Adresse du serveur Redis\",\n      \"sock_tip\": \"Fichier de socket Unix Redis\",\n      \"usr_tip\": \"(Optionnel) Nom d'utilisateur d'authentification\",\n      \"pwd_tip\": \"(Optionnel) Mot de passe d'authentification (Redis > 6.0)\",\n      \"test\": \"Tester la connexion\",\n      \"test_succ\": \"Connecté avec succès au serveur Redis\",\n      \"test_fail\": \"Échec de la connexion\",\n      \"parse_url_clipboard\": \"Analyser l'URL depuis le presse-papiers\",\n      \"parse_pass\": \"URL Redis analysée : {url}\",\n      \"parse_fail\": \"Échec de l'analyse de l'URL Redis : {reason}\",\n      \"advn\": {\n        \"title\": \"Avancé\",\n        \"filter\": \"Filtre de clé par défaut\",\n        \"filter_tip\": \"Modèle pour filtrer les clés chargées\",\n        \"separator\": \"Séparateur de clé\",\n        \"separator_tip\": \"Séparateur pour les segments de chemin de clé\",\n        \"conn_timeout\": \"Délai d'expiration de la connexion\",\n        \"exec_timeout\": \"Délai d'exécution\",\n        \"dbfilter_type\": \"Filtre de base de données\",\n        \"dbfilter_all\": \"Tout afficher\",\n        \"dbfilter_show\": \"Afficher la sélection\",\n        \"dbfilter_hide\": \"Masquer la sélection\",\n        \"dbfilter_show_title\": \"Bases de données à afficher\",\n        \"dbfilter_hide_title\": \"Bases de données à masquer\",\n        \"dbfilter_input\": \"Saisir l'index de la base de données\",\n        \"dbfilter_input_tip\": \"Appuyer sur Entrée pour confirmer\",\n        \"key_view\": \"Vue de clé par défaut\",\n        \"key_view_tree\": \"Vue arborescente\",\n        \"key_view_list\": \"Vue liste\",\n        \"load_size\": \"Clés par chargement\",\n        \"mark_color\": \"Couleur de marquage\"\n      },\n      \"alias\": {\n        \"title\": \"Alias de base de données\",\n        \"db\": \"Saisir l'index de la base de données\",\n        \"value\": \"Saisir l'alias de la base de données\"\n      },\n      \"ssl\": {\n        \"title\": \"SSL/TLS\",\n        \"enable\": \"Activer SSL/TLS\",\n        \"allow_insecure\": \"Autoriser les connexions non sécurisées\",\n        \"sni\": \"Nom du serveur (SNI)\",\n        \"sni_tip\": \"(Optionnel) Nom du serveur\",\n        \"cert_file\": \"Fichier de clé publique\",\n        \"key_file\": \"Fichier de clé privée\",\n        \"ca_file\": \"Fichier CA\",\n        \"cert_file_tip\": \"Fichier de clé publique au format PEM(Cert)\",\n        \"key_file_tip\": \"Fichier de clé privée au format PEM(Key)\",\n        \"ca_file_tip\": \"Fichier d'autorité de certification au format PEM(CA)\"\n      },\n      \"ssh\": {\n        \"enable\": \"Activer le tunnel SSH\",\n        \"title\": \"Tunnel SSH\",\n        \"login_type\": \"Type de connexion\",\n        \"agent\": \"Agent SSH\",\n        \"pkfile\": \"Fichier de clé privée\",\n        \"passphrase\": \"Phrase secrète\",\n        \"addr_tip\": \"Adresse SSH\",\n        \"usr_tip\": \"Nom d'utilisateur SSH\",\n        \"pwd_tip\": \"Mot de passe SSH\",\n        \"pkfile_tip\": \"Chemin du fichier de clé privée SSH\",\n        \"passphrase_tip\": \"(Optionnel) Phrase secrète pour la clé privée\"\n      },\n      \"sentinel\": {\n        \"title\": \"Sentinelle\",\n        \"enable\": \"En tant que noeud sentinelle\",\n        \"master\": \"Nom du groupe principal\",\n        \"auto_discover\": \"Découverte automatique\",\n        \"password\": \"Mot de passe principal\",\n        \"username\": \"Nom d'utilisateur principal\",\n        \"pwd_tip\": \"(Optionnel) Mot de passe d'authentification principal (Redis > 6.0)\",\n        \"usr_tip\": \"(Optionnel) Nom d'utilisateur d'authentification principal\"\n      },\n      \"cluster\": {\n        \"title\": \"Cluster\",\n        \"enable\": \"En tant que noeud de cluster\"\n      },\n      \"proxy\": {\n        \"title\": \"Proxy\",\n        \"type_none\": \"Pas de proxy\",\n        \"type_system\": \"Proxy système\",\n        \"type_custom\": \"Proxy manuel\",\n        \"host\": \"Nom d'hôte\",\n        \"auth\": \"Authentification proxy\",\n        \"usr_tip\": \"Nom d'utilisateur d'authentification proxy\",\n        \"pwd_tip\": \"Mot de passe d'authentification proxy\"\n      }\n    },\n    \"group\": {\n      \"name\": \"Nom du groupe\",\n      \"rename\": \"Renommer le groupe\",\n      \"new\": \"Nouveau groupe\"\n    },\n    \"key\": {\n      \"new\": \"Nouvelle clé\",\n      \"new_name\": \"Nouveau nom de clé\",\n      \"server\": \"Connexion\",\n      \"db_index\": \"Index de la base de données\",\n      \"key_expression\": \"Modèle de clé\",\n      \"affected_key\": \"Clés affectées\",\n      \"show_affected_key\": \"Afficher les clés affectées\",\n      \"confirm_delete_key\": \"Confirmer la suppression de {num} clé(s)\",\n      \"direct_delete\": \"Supprimer le modèle correspondant directement\",\n      \"confirm_delete\": \"Confirmer la suppression\",\n      \"async_delete\": \"Exécution asynchrone\",\n      \"async_delete_title\": \"Ne pas attendre le résultat\",\n      \"confirm_flush\": \"Je sais ce que je fais !\",\n      \"confirm_flush_db\": \"Confirmer le vidage de la base de données\"\n    },\n    \"delete\": {\n      \"success\": \"\\\"{key}\\\" supprimé\",\n      \"deleting\": \"Suppression en cours\",\n      \"doing\": \"Suppression de la clé ({index}/{count})\",\n      \"completed\": \"Suppression terminée, {success} réussies, {fail} échouées\"\n    },\n    \"field\": {\n      \"new\": \"Nouveau champ\",\n      \"new_item\": \"Nouvel élément\",\n      \"conflict_handle\": \"En cas de conflit de champ\",\n      \"overwrite_field\": \"Écraser\",\n      \"ignore_field\": \"Ignorer\",\n      \"insert_type\": \"Type d'insertion\",\n      \"append_item\": \"Ajouter\",\n      \"prepend_item\": \"Insérer en tête\",\n      \"enter_key\": \"Saisir la clé\",\n      \"enter_value\": \"Saisir la valeur\",\n      \"enter_field\": \"Saisir le nom du champ\",\n      \"enter_elem\": \"Saisir l'élément\",\n      \"enter_member\": \"Saisir le membre\",\n      \"enter_score\": \"Saisir le score\",\n      \"element\": \"Élément\",\n      \"reload_when_succ\": \"Recharger immédiatement en cas de réussite\"\n    },\n    \"filter\": {\n      \"set_key_filter\": \"Définir le filtre de clé\",\n      \"filter_pattern\": \"Modèle\",\n      \"filter_pattern_tip\": \"Filtrez la liste actuelle en saisissant directement, et scannez le serveur en appuyant sur 'Entrée'.\\n\\n* correspond à 0 ou plusieurs caractères, ex : 'key*'\\n? correspond à un seul caractère, ex : 'key?'\\n[] correspond à une plage, ex : 'key[1-3]' échappe les caractères spéciaux\",\n      \"exact_match_tip\": \"Correspondance exacte\",\n      \"filter_type_not_support\": \"Le filtrage par type n’est pas pris en charge pour Redis 5.x et les versions antérieures\"\n    },\n    \"export\": {\n      \"name\": \"Exporter les données\",\n      \"export_expire_title\": \"Expiration\",\n      \"export_expire\": \"Inclure l'expiration\",\n      \"export\": \"Exporter\",\n      \"save_file\": \"Chemin d'exportation\",\n      \"save_file_tip\": \"Sélectionner le chemin pour enregistrer le fichier exporté\",\n      \"exporting\": \"Exportation des clés ({index}/{count})\",\n      \"export_completed\": \"Exportation terminée, {success} réussies, {fail} échouées\"\n    },\n    \"import\": {\n      \"name\": \"Importer des données\",\n      \"import_expire_title\": \"Expiration\",\n      \"reload\": \"Recharger après l'importation\",\n      \"import\": \"Importer\",\n      \"open_csv_file\": \"Fichier d'importation\",\n      \"open_csv_file_tip\": \"Sélectionner le fichier à importer\",\n      \"conflict_handle\": \"En cas de conflit de clé\",\n      \"conflict_overwrite\": \"Écraser\",\n      \"conflict_ignore\": \"Ignorer\",\n      \"ttl_include\": \"Importer depuis le fichier\",\n      \"ttl_ignore\": \"Ne pas définir\",\n      \"ttl_custom\": \"Personnalisé\",\n      \"importing\": \"Importation des clés importées/écrasées:{imported} conflit/échouées:{conflict}\",\n      \"import_completed\": \"Importation terminée, {success} réussies, {ignored} ignorées\"\n    },\n    \"ttl\": {\n      \"title\": \"Mettre à jour le TTL\",\n      \"title_batch\": \"Mise à jour par lot du TTL ({count})\",\n      \"quick_set\": \"Définition rapide\",\n      \"success\": \"TTL mis à jour pour toutes les clés\"\n    },\n    \"decoder\": {\n      \"name\": \"Nouveau décodeur/encodeur\",\n      \"edit_name\": \"Éditer le décodeur/encodeur\",\n      \"new\": \"Nouveau\",\n      \"decoder\": \"Décodeur\",\n      \"encoder\": \"Encodeur\",\n      \"decoder_name\": \"Nom\",\n      \"auto\": \"Décodage automatique\",\n      \"decode_path\": \"Chemin du décodeur\",\n      \"encode_path\": \"Chemin de l'encodeur\",\n      \"path_help\": \"Chemin de l'exécutable, ou alias cli comme 'sh/php/python'\",\n      \"args\": \"Arguments\",\n      \"args_help\": \"Utiliser [VALUE] comme espace réservé pour le contenu de codage/décodage. Le contenu sera ajouté à la fin si aucun espace réservé n'est fourni.\"\n    },\n    \"upgrade\": {\n      \"title\": \"Nouvelle version disponible\",\n      \"new_version_tip\": \"Nouvelle version {ver} disponible, télécharger maintenant ?\",\n      \"no_update\": \"Vous êtes à jour\",\n      \"download_now\": \"Télécharger maintenant\",\n      \"later\": \"Plus tard\",\n      \"skip\": \"Ignorer cette version\"\n    },\n    \"welcome\": {\n      \"title\": \"Bienvenue dans Tiny RDM!\",\n      \"content\": \"Afin d'offrir une meilleure expérience utilisateur, Tiny RDM collecte certaines données anonymes pour aider à optimiser le logiciel et améliorer l'expérience utilisateur. Soyez assuré que cela n'implique pas vos informations personnelles et privées.\\n\\nSi vous avez des inquiétudes, vous pouvez désactiver cette fonction de collecte de données à tout moment en allant dans les Préférences. Si vous avez des questions, n'hésitez pas à contacter le développeur. J'espère que Tiny RDM pourra être un bon assistant pour vous!\",\n      \"accept\": \"Aider à Améliorer\",\n      \"reject\": \"Rejeter\"\n    },\n    \"about\": {\n      \"source\": \"Code source\",\n      \"website\": \"Site officiel\"\n    }\n  },\n  \"login\": {\n    \"username_placeholder\": \"Entrez le nom d'utilisateur\",\n    \"password_placeholder\": \"Entrez le mot de passe\",\n    \"submit\": \"Se connecter\",\n    \"too_many_attempts\": \"Trop de tentatives, réessayez plus tard\",\n    \"invalid_credentials\": \"Identifiants invalides\",\n    \"network_error\": \"Erreur réseau\"\n  },\n  \"menu\": {\n    \"minimise\": \"Minimiser\",\n    \"maximise\": \"Maximiser\",\n    \"restore\": \"Restaurer\",\n    \"close\": \"Fermer\",\n    \"preferences\": \"Préférences\",\n    \"help\": \"Aide\",\n    \"user_guide\": \"Guide de l'utilisateur\",\n    \"check_update\": \"Vérifier les mises à jour...\",\n    \"report_bug\": \"Signaler un bug\",\n    \"about\": \"À propos\"\n  },\n  \"log\": {\n    \"title\": \"Journal de lancement\",\n    \"filter_server\": \"Filtrer le serveur\",\n    \"filter_keyword\": \"Filtrer les mots-clés\",\n    \"clean_log\": \"Nettoyer le journal\",\n    \"confirm_clean_log\": \"Confirmer le nettoyage du journal de lancement\",\n    \"exec_time\": \"Heure d'exécution\",\n    \"server\": \"Serveur\",\n    \"cmd\": \"Commande\",\n    \"cost_time\": \"Coût\",\n    \"refresh\": \"Rafraîchir\"\n  },\n  \"status\": {\n    \"uptime\": \"Temps de fonctionnement\",\n    \"connected_clients\": \"Clients connectés\",\n    \"total_keys\": \"Nombre total de clés\",\n    \"memory_used\": \"Mémoire utilisée\",\n    \"server_info\": \"Informations serveur\",\n    \"activity_status\": \"Activité\",\n    \"act_cmd\": \"Commandes/Sec\",\n    \"act_network_input\": \"Entrée réseau\",\n    \"act_network_output\": \"Sortie réseau\",\n    \"client\": {\n      \"title\": \"Liste des clients\",\n      \"addr\": \"Adresse client\",\n      \"age\": \"Âge (sec)\",\n      \"idle\": \"Inactif (sec)\",\n      \"db\": \"Base de données\"\n    }\n  },\n  \"slog\": {\n    \"title\": \"Journal lent\",\n    \"limit\": \"Limite\",\n    \"filter\": \"Filtre\",\n    \"exec_time\": \"Heure\",\n    \"client\": \"Client\",\n    \"cmd\": \"Commande\",\n    \"cost_time\": \"Coût\"\n  },\n  \"monitor\": {\n    \"title\": \"Surveiller les commandes\",\n    \"actions\": \"Actions\",\n    \"warning\": \"La surveillance des commandes peut bloquer le serveur, à utiliser avec prudence sur les serveurs de production.\",\n    \"start\": \"Démarrer\",\n    \"stop\": \"Arrêter\",\n    \"search\": \"Rechercher\",\n    \"copy_log\": \"Copier le journal\",\n    \"save_log\": \"Enregistrer le journal\",\n    \"clean_log\": \"Nettoyer le journal\",\n    \"always_show_last\": \"Défilement automatique vers le dernier message\"\n  },\n  \"pubsub\": {\n    \"title\": \"Pub/Sub\",\n    \"publish\": \"Publier\",\n    \"subscribe\": \"S'abonner\",\n    \"unsubscribe\": \"Se désabonner\",\n    \"clear\": \"Effacer les messages\",\n    \"time\": \"Heure\",\n    \"filter\": \"Filtre\",\n    \"channel\": \"Canal\",\n    \"message\": \"Message\",\n    \"receive_message\": \"{total} messages reçus\",\n    \"always_show_last\": \"Défilement automatique vers le dernier message\"\n  }\n}\n"
  },
  {
    "path": "frontend/src/langs/index.js",
    "content": "import en from './en-us'\nimport pt from './pt-br'\nimport zh from './zh-cn'\nimport tw from './zh-tw'\nimport ko from './ko-kr'\nimport ja from './ja-jp'\nimport es from './es-es'\nimport fr from './fr-fr'\nimport ru from './ru-ru'\nimport tr from './tr-tr'\n\nexport const lang = {\n    en,\n    es,\n    fr,\n    ja,\n    ko,\n    pt,\n    ru,\n    zh,\n    tw,\n    tr,\n}\n"
  },
  {
    "path": "frontend/src/langs/ja-jp.json",
    "content": "{\n  \"name\": \"日本語\",\n  \"common\": {\n    \"confirm\": \"確認\",\n    \"cancel\": \"キャンセル\",\n    \"success\": \"成功\",\n    \"warning\": \"警告\",\n    \"error\": \"エラー\",\n    \"save\": \"保存\",\n    \"update\": \"更新\",\n    \"none\": \"なし\",\n    \"second\": \"秒\",\n    \"minute\": \"分\",\n    \"hour\": \"時間\",\n    \"day\": \"日\",\n    \"unit_day\": \"日\",\n    \"unit_hour\": \"時間\",\n    \"unit_minute\": \"分\",\n    \"unit_second\": \"秒\",\n    \"all\": \"すべて\",\n    \"key\": \"キー\",\n    \"value\": \"値\",\n    \"field\": \"フィールド\",\n    \"score\": \"スコア\",\n    \"index\": \"位置\"\n  },\n  \"preferences\": {\n    \"name\": \"設定\",\n    \"restore_defaults\": \"デフォルトに戻す\",\n    \"font_tip\": \"複数選択可能、インストール済みのフォントがリストにない場合は手動で入力できます\",\n    \"general\": {\n      \"name\": \"一般設定\",\n      \"theme\": \"テーマ\",\n      \"theme_light\": \"ライトモード\",\n      \"theme_dark\": \"ダークモード\",\n      \"theme_auto\": \"自動\",\n      \"language\": \"言語\",\n      \"system_lang\": \"システム言語を使用\",\n      \"font\": \"フォント\",\n      \"font_tip\": \"フォント名を選択または入力してください\",\n      \"font_size\": \"フォントサイズ\",\n      \"scan_size\": \"SCANコマンドのデフォルトサイズ\",\n      \"scan_size_tip\": \"SCAN/HSCAN/SSCAN/ZSCAN コマンドで1回に返される要素の数\",\n      \"key_icon_style\": \"キーアイコンのスタイル\",\n      \"key_icon_style0\": \"コンパクトタイプ\",\n      \"key_icon_style1\": \"フルネームタイプ\",\n      \"key_icon_style2\": \"ドットタイプ\",\n      \"key_icon_style3\": \"共通アイコン\",\n      \"update\": \"更新\",\n      \"auto_check_update\": \"自動でアップデートを確認\",\n      \"privacy\": \"プライバシーポリシー\",\n      \"allow_track\": \"匿名データの収集を許可する\"\n    },\n    \"editor\": {\n      \"name\": \"エディター\",\n      \"show_linenum\": \"行番号を表示\",\n      \"show_folding\": \"コード折りたたみを有効化\",\n      \"drop_text\": \"テキストのドラッグ&ドロップを許可\",\n      \"links\": \"リンクをサポート\"\n    },\n    \"cli\": {\n      \"name\": \"コマンドライン\",\n      \"cursor_style\": \"カーソルスタイル\",\n      \"cursor_style_block\": \"ブロック\",\n      \"cursor_style_underline\": \"アンダーライン\",\n      \"cursor_style_bar\": \"バー\"\n    },\n    \"decoder\": {\n      \"name\": \"カスタムデコーダー\",\n      \"new\": \"新しいデコーダー\",\n      \"decoder_name\": \"名前\",\n      \"cmd_preview\": \"プレビュー\",\n      \"status\": \"ステータス\",\n      \"auto_enabled\": \"自動デコーディングが有効化されました\",\n      \"help\": \"ヘルプ\"\n    }\n  },\n  \"interface\": {\n    \"new_conn\": \"新しい接続を追加\",\n    \"new_group\": \"新しいグループを追加\",\n    \"disconnect_all\": \"すべての接続を切断\",\n    \"status\": \"ステータス\",\n    \"filter\": \"フィルター\",\n    \"sort_conn\": \"接続を並べ替え\",\n    \"new_conn_title\": \"新しい接続\",\n    \"open_db\": \"データベースを開く\",\n    \"close_db\": \"データベースを閉じる\",\n    \"filter_key\": \"キーをフィルター\",\n    \"disconnect\": \"切断\",\n    \"dup_conn\": \"接続を複製\",\n    \"remove_conn\": \"接続を削除\",\n    \"edit_conn\": \"接続設定を編集\",\n    \"edit_conn_group\": \"グループを編集\",\n    \"rename_conn_group\": \"グループ名を変更\",\n    \"remove_conn_group\": \"グループを削除\",\n    \"import_conn\": \"接続をインポート...\",\n    \"export_conn\": \"接続をエクスポート...\",\n    \"ttl\": \"TTL\",\n    \"forever\": \"永久\",\n    \"rename_key\": \"キー名を変更\",\n    \"delete_key\": \"キーを削除\",\n    \"batch_delete_key\": \"キーを一括削除\",\n    \"import_key\": \"キーをインポート\",\n    \"flush_db\": \"データベースをフラッシュ\",\n    \"check_mode\": \"チェックモード\",\n    \"quit_check_mode\": \"チェックモードを終了\",\n    \"delete_checked\": \"チェックされたものを削除\",\n    \"export_checked\": \"チェックされたものをエクスポート\",\n    \"ttl_checked\": \"チェックされたもののTTLを更新\",\n    \"copy_value\": \"値をコピー\",\n    \"edit_value\": \"値を編集\",\n    \"save_update\": \"変更を保存\",\n    \"score_filter_tip\": \"以下の演算子を使って範囲をフィルターできます\\n=：等しい\\n!=：等しくない\\n>：より大きい\\n>=：以上\\n<：より小さい\\n<=：以下\\n例）スコアが3よりも大きいものを検索する場合は、>3と入力します\",\n    \"add_row\": \"行を挿入\",\n    \"edit_row\": \"行を編集\",\n    \"delete_row\": \"行を削除\",\n    \"fullscreen\": \"全画面表示\",\n    \"offscreen\": \"全画面表示を終了\",\n    \"pin_edit\": \"編集ボックスを固定（保存後も閉じない）\",\n    \"unpin_edit\": \"ピン留めを解除\",\n    \"search\": \"検索\",\n    \"full_search\": \"全文検索\",\n    \"full_search_result\": \"コンテンツが'{pattern}'にマッチしました\",\n    \"filter_field\": \"フィールドをフィルター\",\n    \"filter_value\": \"値をフィルター\",\n    \"length\": \"長さ\",\n    \"entries\": \"エントリ\",\n    \"memory_usage\": \"メモリ使用量\",\n    \"text_align_left\": \"左揃え\",\n    \"text_align_center\": \"中央揃え\",\n    \"view_as\": \"表示形式\",\n    \"decode_with\": \"デコード/解凍\",\n    \"custom_decoder\": \"新しいカスタムデコーダー\",\n    \"reload\": \"再読み込み\",\n    \"reload_disable\": \"すべて読み込んだ後に再読み込みできます\",\n    \"auto_refresh\": \"自動更新\",\n    \"refresh_interval\": \"更新間隔\",\n    \"open_connection\": \"接続を開く\",\n    \"copy_path\": \"パスをコピー\",\n    \"copy_key\": \"キーをコピー\",\n    \"save_value_succ\": \"値を保存しました！\",\n    \"copy_succ\": \"クリップボードにコピーしました！\",\n    \"binary_key\": \"バイナリキー名\",\n    \"remove_key\": \"キーを削除\",\n    \"new_key\": \"新しいキー\",\n    \"load_more\": \"キーをさらに読み込む\",\n    \"load_all\": \"残りのすべてのキーを読み込む\",\n    \"load_more_entries\": \"さらに読み込む\",\n    \"load_all_entries\": \"すべて読み込む\",\n    \"more_action\": \"その他の操作\",\n    \"nonexist_tab_content\": \"選択したキーが存在しないか、キーが選択されていません。更新後に再試行してください。\",\n    \"empty_server_content\": \"左のパネルから接続を選択して開いてください\",\n    \"empty_server_list\": \"Redisサーバーが追加されていません\",\n    \"action\": \"アクション\",\n    \"type\": \"タイプ\",\n    \"cli_welcome\": \"Tiny RDMのRedisコンソールへようこそ\",\n    \"retrieving_version\": \"新しいバージョンを確認しています\",\n    \"sub_tab\": {\n      \"status\": \"ステータス\",\n      \"key_detail\": \"キーの詳細\",\n      \"cli\": \"コンソール\",\n      \"slow_log\": \"スロー ログ\",\n      \"cmd_monitor\": \"コマンドのモニタリング\",\n      \"pub_message\": \"パブリッシュ/サブスクライブ\"\n    }\n  },\n  \"ribbon\": {\n    \"server\": \"サーバー\",\n    \"browser\": \"データ ブラウザ\",\n    \"log\": \"ログ\",\n    \"wechat_official\": \"Wechat 公式アカウント\",\n    \"follow_x\": \"私の \\uD835\\uDD4F をフォローする\",\n    \"github\": \"Github\",\n    \"logout\": \"ログアウト\"\n  },\n  \"dialogue\": {\n    \"close_confirm\": \"この接続（{name}）を閉じますか？\",\n    \"edit_close_confirm\": \"編集する前に関連する接続を閉じる必要があります。続行しますか？\",\n    \"opening_connection\": \"接続を開いています...\",\n    \"interrupt_connection\": \"キャンセル\",\n    \"remove_tip\": \"{type} \\\"{name}\\\" が削除されます\",\n    \"remove_group_tip\": \"グループ \\\"{name}\\\" とそのすべての接続が削除されます\",\n    \"rename_binary_key_fail\": \"バイナリキーの名前は変更できません\",\n    \"handle_succ\": \"成功しました！\",\n    \"handle_cancel\": \"操作がキャンセルされました。\",\n    \"reload_succ\": \"再読み込みしました！\",\n    \"field_required\": \"この項目は必須です\",\n    \"spec_field_required\": \"\\\"{key}\\\" は必須です\",\n    \"illegal_characters\": \"不正な文字が含まれています\",\n    \"connection\": {\n      \"new_title\": \"新しい接続\",\n      \"edit_title\": \"接続を編集\",\n      \"general\": \"一般\",\n      \"no_group\": \"グループなし\",\n      \"group\": \"グループ\",\n      \"conn_name\": \"名前\",\n      \"addr\": \"アドレス\",\n      \"usr\": \"ユーザー名\",\n      \"pwd\": \"パスワード\",\n      \"name_tip\": \"接続名\",\n      \"addr_tip\": \"Redisサーバーのアドレス\",\n      \"sock_tip\": \"Redisのunixソケットファイル\",\n      \"usr_tip\": \"（オプション）認証ユーザー名\",\n      \"pwd_tip\": \"（オプション）認証パスワード (Redis > 6.0)\",\n      \"test\": \"接続をテスト\",\n      \"test_succ\": \"Redisサーバーに正常に接続しました\",\n      \"test_fail\": \"接続に失敗しました\",\n      \"parse_url_clipboard\": \"クリップボードからURLを解析\",\n      \"parse_pass\": \"RedisのURLを解析しました: {url}\",\n      \"parse_fail\": \"RedisのURLを解析できませんでした: {reason}\",\n      \"advn\": {\n        \"title\": \"高度な設定\",\n        \"filter\": \"デフォルトのキーフィルター\",\n        \"filter_tip\": \"読み込むキーのパターン\",\n        \"separator\": \"キーセパレーター\",\n        \"separator_tip\": \"キーパスのセグメントの区切り文字\",\n        \"conn_timeout\": \"接続タイムアウト\",\n        \"exec_timeout\": \"実行タイムアウト\",\n        \"dbfilter_type\": \"データベースフィルター\",\n        \"dbfilter_all\": \"すべて表示\",\n        \"dbfilter_show\": \"選択したものを表示\",\n        \"dbfilter_hide\": \"選択したものを非表示\",\n        \"dbfilter_show_title\": \"表示するデータベース\",\n        \"dbfilter_hide_title\": \"非表示にするデータベース\",\n        \"dbfilter_input\": \"データベースインデックスを入力\",\n        \"dbfilter_input_tip\": \"Enterキーで確定\",\n        \"key_view\": \"デフォルトのキービュー\",\n        \"key_view_tree\": \"ツリービュー\",\n        \"key_view_list\": \"リストビュー\",\n        \"load_size\": \"1回の読み込みキー数\",\n        \"mark_color\": \"マーク色\"\n      },\n      \"alias\": {\n        \"title\": \"データベースエイリアス\",\n        \"db\": \"データベースインデックスを入力\",\n        \"value\": \"エイリアスを入力\"\n      },\n      \"ssl\": {\n        \"title\": \"SSL/TLS\",\n        \"enable\": \"SSL/TLSを有効化\",\n        \"allow_insecure\": \"安全でない接続を許可\",\n        \"sni\": \"サーバー名(SNI)\",\n        \"sni_tip\": \"（オプション）サーバー名\",\n        \"cert_file\": \"公開鍵ファイル\",\n        \"key_file\": \"秘密鍵ファイル\",\n        \"ca_file\": \"CAファイル\",\n        \"cert_file_tip\": \"PEM形式の公開鍵ファイル(Cert)\",\n        \"key_file_tip\": \"PEM形式の秘密鍵ファイル(Key)\",\n        \"ca_file_tip\": \"PEM形式のCA証明書ファイル(CA)\"\n      },\n      \"ssh\": {\n        \"enable\": \"SSHトンネルを有効化\",\n        \"title\": \"SSHトンネル\",\n        \"login_type\": \"ログイン方式\",\n        \"agent\": \"SSHエージェント\",\n        \"pkfile\": \"秘密鍵ファイル\",\n        \"passphrase\": \"パスフレーズ\",\n        \"addr_tip\": \"SSHサーバーのアドレス\",\n        \"usr_tip\": \"SSHユーザー名\",\n        \"pwd_tip\": \"SSHパスワード\",\n        \"pkfile_tip\": \"SSHの秘密鍵ファイルのパス\",\n        \"passphrase_tip\": \"（オプション）秘密鍵のパスフレーズ\"\n      },\n      \"sentinel\": {\n        \"title\": \"センチネルモード\",\n        \"enable\": \"センチネルノードとして\",\n        \"master\": \"マスターグループ名\",\n        \"auto_discover\": \"自動検出\",\n        \"password\": \"マスターパスワード\",\n        \"username\": \"マスターユーザー名\",\n        \"pwd_tip\": \"（オプション）マスター認証パスワード (Redis > 6.0)\",\n        \"usr_tip\": \"（オプション）マスター認証,ユーザー名\"\n      },\n      \"cluster\": {\n        \"title\": \"クラスターモード\",\n        \"enable\": \"クラスターノードとして\"\n      },\n      \"proxy\": {\n        \"title\": \"プロキシ\",\n        \"type_none\": \"プロキシなし\",\n        \"type_system\": \"システムプロキシ設定を使用\",\n        \"type_custom\": \"手動でプロキシを設定\",\n        \"host\": \"ホスト名\",\n        \"auth\": \"プロキシ認証を使用\",\n        \"usr_tip\": \"プロキシ認証ユーザー名\",\n        \"pwd_tip\": \"プロキシ認証パスワード\"\n      }\n    },\n    \"group\": {\n      \"name\": \"グループ名\",\n      \"rename\": \"グループ名を変更\",\n      \"new\": \"新しいグループ\"\n    },\n    \"key\": {\n      \"new\": \"新しいキー\",\n      \"new_name\": \"新しいキー名\",\n      \"server\": \"接続\",\n      \"db_index\": \"データベースインデックス\",\n      \"key_expression\": \"キーパターン\",\n      \"affected_key\": \"影響を受けるキー\",\n      \"show_affected_key\": \"影響を受けるキーを表示\",\n      \"confirm_delete_key\": \"{num}個のキーを削除することを確認\",\n      \"direct_delete\": \"一致するパターンを直接削除\",\n      \"confirm_delete\": \"削除を確認\",\n      \"async_delete\": \"非同期実行\",\n      \"async_delete_title\": \"結果を待たない\",\n      \"confirm_flush\": \"自分が実行しようとしている操作を理解しています！\",\n      \"confirm_flush_db\": \"データベースをフラッシュすることを確認\"\n    },\n    \"delete\": {\n      \"success\": \"\\\"{key}\\\" を削除しました\",\n      \"deleting\": \"削除中\",\n      \"doing\": \"キーを削除中 ({index}/{count})\",\n      \"completed\": \"削除が完了しました。成功: {success}個、失敗: {fail}個\"\n    },\n    \"field\": {\n      \"new\": \"新しいフィールド\",\n      \"new_item\": \"新しい項目\",\n      \"conflict_handle\": \"フィールドが競合した場合\",\n      \"overwrite_field\": \"上書き\",\n      \"ignore_field\": \"無視\",\n      \"insert_type\": \"挿入タイプ\",\n      \"append_item\": \"末尾に追加\",\n      \"prepend_item\": \"先頭に挿入\",\n      \"enter_key\": \"キー名を入力\",\n      \"enter_value\": \"値を入力\",\n      \"enter_field\": \"フィールド名を入力\",\n      \"enter_elem\": \"新しい要素を入力\",\n      \"enter_member\": \"メンバーを入力\",\n      \"enter_score\": \"スコアを入力\",\n      \"element\": \"要素\",\n      \"reload_when_succ\": \"成功したら即座に再読み込み\"\n    },\n    \"filter\": {\n      \"set_key_filter\": \"キーフィルターを設定\",\n      \"filter_pattern\": \"パターン\",\n      \"filter_pattern_tip\": \"直接入力して現在のリストをフィルタリングし、Enterキーを押すとサーバーをスキャンできます。\\n\\n*：0文字以上にマッチ。例：\\\"key*\\\"は\\\"key\\\"で始まるすべてのキーにマッチ\\n?：1文字にマッチ。例：\\\"key?\\\"は\\\"key1\\\"、\\\"key2\\\"にマッチ\\n[ ]：指定範囲の1文字にマッチ。例：\\\"key[1-3]\\\"は\\\"key1\\\"、\\\"key2\\\"、\\\"key3\\\"にマッチ\\n\\\\：エスケープ文字。*、?、[、]をリテラルとして解釈したい場合は\\\"\\\\ \\\"をつける\",\n      \"exact_match_tip\": \"完全一致\",\n      \"filter_type_not_support\": \"タイプフィルタリングは、Redis 5.x 以前のバージョンには対応していません\"\n    },\n    \"export\": {\n      \"name\": \"データをエクスポート\",\n      \"export_expire_title\": \"有効期限\",\n      \"export_expire\": \"有効期限を含める\",\n      \"export\": \"エクスポート\",\n      \"save_file\": \"エクスポート先\",\n      \"save_file_tip\": \"エクスポートファイルの保存先を選択\",\n      \"exporting\": \"キーをエクスポート中 ({index}/{count})\",\n      \"export_completed\": \"エクスポートが完了しました。成功: {success}個、失敗: {fail}個\"\n    },\n    \"import\": {\n      \"name\": \"データをインポート\",\n      \"import_expire_title\": \"有効期限\",\n      \"import\": \"インポート\",\n      \"reload\": \"インポート後に再読み込み\",\n      \"open_csv_file\": \"インポートファイル\",\n      \"open_csv_file_tip\": \"インポートするファイルを選択\",\n      \"conflict_handle\": \"キーが競合した場合\",\n      \"conflict_overwrite\": \"上書き\",\n      \"conflict_ignore\": \"無視\",\n      \"ttl_include\": \"ファイルから読み込む\",\n      \"ttl_ignore\": \"設定しない\",\n      \"ttl_custom\": \"カスタム\",\n      \"importing\": \"キーをインポート中 インポート/上書き:{imported} 競合/失敗:{conflict}\",\n      \"import_completed\": \"インポートが完了しました。成功: {success}個、無視: {ignored}個\"\n    },\n    \"ttl\": {\n      \"title\": \"TTLを更新\",\n      \"title_batch\": \"TTLを一括更新 ({count})\",\n      \"quick_set\": \"クイック設定\",\n      \"success\": \"すべてのキーのTTLが更新されました\"\n    },\n    \"decoder\": {\n      \"name\": \"新しいデコーダー/エンコーダー\",\n      \"edit_name\": \"デコーダー/エンコーダーを編集\",\n      \"new\": \"新規\",\n      \"decoder\": \"デコーダー\",\n      \"encoder\": \"エンコーダー\",\n      \"decoder_name\": \"名前\",\n      \"auto\": \"自動デコード\",\n      \"decode_path\": \"デコーダーのパス\",\n      \"encode_path\": \"エンコーダーのパス\",\n      \"path_help\": \"実行ファイルのパスか、'sh/php/python'のようなCLIエイリアス\",\n      \"args\": \"引数\",\n      \"args_help\": \"エンコード/デコードするコンテンツの場所には[VALUE]を使ってください。プレースホルダーを指定しない場合は、コンテンツが最後に付加されます。\"\n    },\n    \"upgrade\": {\n      \"title\": \"新しいバージョンが利用可能です\",\n      \"new_version_tip\": \"新しいバージョン{ver}が利用可能です。今すぐダウンロードしますか？\",\n      \"no_update\": \"最新バージョンです\",\n      \"download_now\": \"今すぐダウンロード\",\n      \"later\": \"後で\",\n      \"skip\": \"このバージョンをスキップ\"\n    },\n    \"welcome\": {\n      \"title\": \"Tiny RDMをご利用いただきありがとうございます!\",\n      \"content\": \"ユーザーエクスペリエンスを改善するために、Tiny RDMは一部の匿名データを収集し、ソフトウェアの最適化とユーザーエクスペリエンスの向上に役立てています。個人プライバシー情報は含まれませんのでご安心ください。\\n\\nご不安な点がありましたら、いつでも「設定」から当該機能をオフにすることができます。ご不明な点がございましたら、開発者までお問い合わせください。Tiny RDMがお役に立てることを願っております。\",\n      \"accept\": \"改善の支援\",\n      \"reject\": \"拒否する\"\n    },\n    \"about\": {\n      \"source\": \"ソースコード\",\n      \"website\": \"公式ウェブサイト\"\n    }\n  },\n  \"login\": {\n    \"username_placeholder\": \"ユーザー名を入力\",\n    \"password_placeholder\": \"パスワードを入力\",\n    \"submit\": \"ログイン\",\n    \"too_many_attempts\": \"試行回数が多すぎます\",\n    \"invalid_credentials\": \"ユーザー名またはパスワードが正しくありません\",\n    \"network_error\": \"ネットワークエラー\"\n  },\n  \"menu\": {\n    \"minimise\": \"最小化\",\n    \"maximise\": \"最大化\",\n    \"restore\": \"元に戻す\",\n    \"close\": \"閉じる\",\n    \"preferences\": \"設定\",\n    \"help\": \"ヘルプ\",\n    \"user_guide\": \"ユーザーガイド\",\n    \"check_update\": \"アップデートを確認...\",\n    \"report_bug\": \"バグを報告\",\n    \"about\": \"このソフトについて\"\n  },\n  \"log\": {\n    \"title\": \"実行ログ\",\n    \"filter_server\": \"サーバーをフィルター\",\n    \"filter_keyword\": \"キーワードでフィルター\",\n    \"clean_log\": \"ログをクリア\",\n    \"confirm_clean_log\": \"実行ログをクリアしてよろしいですか？\",\n    \"exec_time\": \"実行時間\",\n    \"server\": \"サーバー\",\n    \"cmd\": \"コマンド\",\n    \"cost_time\": \"所要時間\",\n    \"refresh\": \"すぐに更新\"\n  },\n  \"status\": {\n    \"uptime\": \"稼働時間\",\n    \"connected_clients\": \"接続クライアント数\",\n    \"total_keys\": \"合計キー数\",\n    \"memory_used\": \"メモリ使用量\",\n    \"server_info\": \"サーバー情報\",\n    \"activity_status\": \"アクティビティ状況\",\n    \"act_cmd\": \"コマンド実行数(/秒)\",\n    \"act_network_input\": \"ネットワーク入力\",\n    \"act_network_output\": \"ネットワーク出力\",\n    \"client\": {\n      \"title\": \"クライアント一覧\",\n      \"addr\": \"クライアントアドレス\",\n      \"age\": \"接続時間（秒）\",\n      \"idle\": \"アイドル時間（秒）\",\n      \"db\": \"データベース\"\n    }\n  },\n  \"slog\": {\n    \"title\": \"スローログ\",\n    \"limit\": \"上限\",\n    \"filter\": \"フィルター\",\n    \"exec_time\": \"実行時間\",\n    \"client\": \"クライアント\",\n    \"cmd\": \"コマンド\",\n    \"cost_time\": \"所要時間\"\n  },\n  \"monitor\": {\n    \"title\": \"コマンドのモニタリング\",\n    \"actions\": \"アクション\",\n    \"warning\": \"コマンドのモニタリングは、サーバーをブロックする可能性があるため、運用環境のサーバーでは注意して使用してください。\",\n    \"start\": \"開始\",\n    \"stop\": \"停止\",\n    \"search\": \"検索\",\n    \"copy_log\": \"ログをコピー\",\n    \"save_log\": \"ログを保存\",\n    \"clean_log\": \"ログをクリア\",\n    \"always_show_last\": \"最新に自動スクロール\"\n  },\n  \"pubsub\": {\n    \"title\": \"パブ/サブ\",\n    \"publish\": \"パブリッシュ\",\n    \"subscribe\": \"サブスクライブ開始\",\n    \"unsubscribe\": \"サブスクライブ解除\",\n    \"clear\": \"メッセージをクリア\",\n    \"time\": \"時間\",\n    \"filter\": \"フィルター\",\n    \"channel\": \"チャンネル\",\n    \"message\": \"メッセージ\",\n    \"receive_message\": \"{total}件のメッセージを受信しました\",\n    \"always_show_last\": \"最新に自動スクロール\"\n  }\n}\n"
  },
  {
    "path": "frontend/src/langs/ko-kr.json",
    "content": "{\n  \"name\": \"한국어\",\n  \"common\": {\n    \"confirm\": \"확인\",\n    \"cancel\": \"취소\",\n    \"success\": \"성공\",\n    \"warning\": \"경고\",\n    \"error\": \"오류\",\n    \"save\": \"저장\",\n    \"update\": \"업데이트\",\n    \"none\": \"없음\",\n    \"second\": \"초\",\n    \"minute\": \"분\",\n    \"hour\": \"시간\",\n    \"day\": \"일\",\n    \"unit_day\": \"일\",\n    \"unit_hour\": \"시간\",\n    \"unit_minute\": \"분\",\n    \"unit_second\": \"초\",\n    \"all\": \"전체\",\n    \"key\": \"키\",\n    \"value\": \"값\",\n    \"field\": \"필드\",\n    \"score\": \"점수\",\n    \"index\": \"위치\"\n  },\n  \"preferences\": {\n    \"name\": \"설정\",\n    \"restore_defaults\": \"기본값 복원\",\n    \"font_tip\": \"다중 선택 지원, 목록에 없는 폰트는 직접 입력하세요\",\n    \"general\": {\n      \"name\": \"일반\",\n      \"theme\": \"테마\",\n      \"theme_light\": \"밝은 테마\",\n      \"theme_dark\": \"어두운 테마\",\n      \"theme_auto\": \"자동\",\n      \"language\": \"언어\",\n      \"system_lang\": \"시스템 언어 사용\",\n      \"font\": \"폰트\",\n      \"font_tip\": \"폰트 선택 또는 이름 입력\",\n      \"font_size\": \"폰트 크기\",\n      \"scan_size\": \"SCAN 기본 크기\",\n      \"scan_size_tip\": \"SCAN/HSCAN/SSCAN/ZSCAN 명령에서 한 번에 반환되는 요소 수\",\n      \"key_icon_style\": \"키 아이콘 스타일\",\n      \"key_icon_style0\": \"간략한 타입\",\n      \"key_icon_style1\": \"전체 이름\",\n      \"key_icon_style2\": \"점 타입\",\n      \"key_icon_style3\": \"일반 아이콘\",\n      \"update\": \"업데이트\",\n      \"auto_check_update\": \"자동 업데이트 확인\",\n      \"privacy\": \"개인 정보 보호 정책\",\n      \"allow_track\": \"익명 데이터 수집 허용\"\n    },\n    \"editor\": {\n      \"name\": \"에디터\",\n      \"show_linenum\": \"줄번호 표시\",\n      \"show_folding\": \"코드 폴딩 활성화\",\n      \"drop_text\": \"텍스트 드래그 앤 드롭 허용\",\n      \"links\": \"링크 지원\"\n    },\n    \"cli\": {\n      \"name\": \"명령줄\",\n      \"cursor_style\": \"커서 스타일\",\n      \"cursor_style_block\": \"블록\",\n      \"cursor_style_underline\": \"밑줄\",\n      \"cursor_style_bar\": \"바\"\n    },\n    \"decoder\": {\n      \"name\": \"사용자 정의 디코더\",\n      \"new\": \"새 디코더\",\n      \"decoder_name\": \"이름\",\n      \"cmd_preview\": \"미리보기\",\n      \"status\": \"상태\",\n      \"auto_enabled\": \"자동 디코딩 활성화\",\n      \"help\": \"도움말\"\n    }\n  },\n  \"interface\": {\n    \"new_conn\": \"새 연결 추가\",\n    \"new_group\": \"새 그룹 추가\",\n    \"disconnect_all\": \"모든 연결 끊기\",\n    \"status\": \"상태\",\n    \"filter\": \"필터\",\n    \"sort_conn\": \"연결 정렬\",\n    \"new_conn_title\": \"새 연결\",\n    \"open_db\": \"데이터베이스 열기\",\n    \"close_db\": \"데이터베이스 닫기\",\n    \"filter_key\": \"키 필터링\",\n    \"disconnect\": \"연결 끊기\",\n    \"dup_conn\": \"연결 복제\",\n    \"remove_conn\": \"연결 제거\",\n    \"edit_conn\": \"연결 편집\",\n    \"edit_conn_group\": \"그룹 편집\",\n    \"rename_conn_group\": \"그룹 이름 변경\",\n    \"remove_conn_group\": \"그룹 제거\",\n    \"import_conn\": \"연결 가져오기...\",\n    \"export_conn\": \"연결 내보내기...\",\n    \"ttl\": \"TTL\",\n    \"forever\": \"영구\",\n    \"rename_key\": \"키 이름 변경\",\n    \"delete_key\": \"키 삭제\",\n    \"batch_delete_key\": \"키 일괄 삭제\",\n    \"import_key\": \"키 가져오기\",\n    \"flush_db\": \"데이터베이스 플러시\",\n    \"check_mode\": \"선택 모드\",\n    \"quit_check_mode\": \"선택 모드 종료\",\n    \"delete_checked\": \"선택 항목 삭제\",\n    \"export_checked\": \"선택 항목 내보내기\",\n    \"ttl_checked\": \"선택 항목 TTL 업데이트\",\n    \"copy_value\": \"값 복사\",\n    \"edit_value\": \"값 편집\",\n    \"save_update\": \"변경사항 저장\",\n    \"score_filter_tip\": \"다음 연산자로 범위 일치 가능\\n=: 같음\\n!=: 다름\\n>: 큼\\n<: 작음\\n>=: 크거나 같음\\n<=: 작거나 같음\\n예) 점수가 3 이상인 결과: >3\",\n    \"add_row\": \"행 삽입\",\n    \"edit_row\": \"행 편집\",\n    \"delete_row\": \"행 삭제\",\n    \"fullscreen\": \"전체화면\",\n    \"offscreen\": \"전체화면 종료\",\n    \"pin_edit\": \"편집기 고정(저장 후 열린 상태 유지)\",\n    \"unpin_edit\": \"고정 해제\",\n    \"search\": \"검색\",\n    \"full_search\": \"전체 텍스트 검색\",\n    \"full_search_result\": \"'{pattern}'와 일치하는 내용\",\n    \"filter_field\": \"필드 필터링\",\n    \"filter_value\": \"값 필터링\",\n    \"length\": \"길이\",\n    \"entries\": \"항목 수\",\n    \"memory_usage\": \"메모리 사용량\",\n    \"text_align_left\": \"텍스트 왼쪽 정렬\",\n    \"text_align_center\": \"텍스트 가운데 정렬\",\n    \"view_as\": \"보기\",\n    \"decode_with\": \"디코딩/압축 해제\",\n    \"custom_decoder\": \"새 사용자 정의 디코더\",\n    \"reload\": \"새로고침\",\n    \"reload_disable\": \"전체 로드 후 새로고침 가능\",\n    \"auto_refresh\": \"자동 새로고침\",\n    \"refresh_interval\": \"새로고침 간격\",\n    \"open_connection\": \"연결 열기\",\n    \"copy_path\": \"경로 복사\",\n    \"copy_key\": \"키 복사\",\n    \"save_value_succ\": \"값 저장됨!\",\n    \"copy_succ\": \"클립보드에 복사됨!\",\n    \"binary_key\": \"바이너리 키 이름\",\n    \"remove_key\": \"키 제거\",\n    \"new_key\": \"새 키\",\n    \"load_more\": \"더 많은 키 불러오기\",\n    \"load_all\": \"남은 모든 키 불러오기\",\n    \"load_more_entries\": \"더 불러오기\",\n    \"load_all_entries\": \"모두 불러오기\",\n    \"more_action\": \"더 많은 작업\",\n    \"nonexist_tab_content\": \"선택한 키가 없거나 존재하지 않습니다. 새로고침 후 다시 시도하세요.\",\n    \"empty_server_content\": \"왼쪽 패널에서 연결을 선택하고 열기\",\n    \"empty_server_list\": \"추가된 Redis 서버 없음\",\n    \"action\": \"작업\",\n    \"type\": \"유형\",\n    \"cli_welcome\": \"Tiny RDM Redis 콘솔 환영합니다\",\n    \"retrieving_version\": \"업데이트 확인 중\",\n    \"sub_tab\": {\n      \"status\": \"상태\",\n      \"key_detail\": \"키 상세정보\",\n      \"cli\": \"콘솔\",\n      \"slow_log\": \"슬로우 로그\",\n      \"cmd_monitor\": \"명령 모니터링\",\n      \"pub_message\": \"Pub/Sub\"\n    }\n  },\n  \"ribbon\": {\n    \"server\": \"서버\",\n    \"browser\": \"데이터 브라우저\",\n    \"log\": \"로그\",\n    \"wechat_official\": \"공식 계정\",\n    \"follow_x\": \"팔로우 \\uD835\\uDD4F\",\n    \"github\": \"Github\",\n    \"logout\": \"로그아웃\"\n  },\n  \"dialogue\": {\n    \"close_confirm\": \"이 연결({name})을 종료하시겠습니까?\",\n    \"edit_close_confirm\": \"편집 전에 관련 연결을 종료해야 합니다. 계속하시겠습니까?\",\n    \"opening_connection\": \"연결 중...\",\n    \"interrupt_connection\": \"취소\",\n    \"remove_tip\": \"{type} \\\"{name}\\\"가 삭제됩니다\",\n    \"remove_group_tip\": \"그룹 \\\"{name}\\\"과 그 안의 모든 연결이 삭제됩니다\",\n    \"rename_binary_key_fail\": \"바이너리 키 이름 변경은 지원되지 않습니다\",\n    \"handle_succ\": \"성공!\",\n    \"handle_cancel\": \"작업이 취소되었습니다.\",\n    \"reload_succ\": \"새로고침 완료!\",\n    \"field_required\": \"이 필드는 필수입니다\",\n    \"spec_field_required\": \"\\\"{key}\\\"는 필수입니다\",\n    \"illegal_characters\": \"잘못된 문자가 포함되어 있습니다\",\n    \"connection\": {\n      \"new_title\": \"새 연결\",\n      \"edit_title\": \"연결 편집\",\n      \"general\": \"일반\",\n      \"no_group\": \"그룹 없음\",\n      \"group\": \"그룹\",\n      \"conn_name\": \"이름\",\n      \"addr\": \"주소\",\n      \"usr\": \"사용자 이름\",\n      \"pwd\": \"비밀번호\",\n      \"name_tip\": \"연결 이름\",\n      \"addr_tip\": \"Redis 서버 주소\",\n      \"sock_tip\": \"Redis 유닉스 소켓 파일\",\n      \"usr_tip\": \"(선택) 인증 사용자 이름\",\n      \"pwd_tip\": \"(선택) 인증 비밀번호 (Redis > 6.0)\",\n      \"test\": \"연결 테스트\",\n      \"test_succ\": \"Redis 서버에 성공적으로 연결되었습니다\",\n      \"test_fail\": \"연결 실패\",\n      \"parse_url_clipboard\": \"클립보드에서 URL 분석\",\n      \"parse_pass\": \"Redis URL이 분석되었습니다: {url}\",\n      \"parse_fail\": \"Redis URL 분석 실패: {reason}\",\n      \"advn\": {\n        \"title\": \"고급\",\n        \"filter\": \"기본 키 필터\",\n        \"filter_tip\": \"로드할 키 패턴\",\n        \"separator\": \"키 구분 기호\",\n        \"separator_tip\": \"키 경로 구분 기호\",\n        \"conn_timeout\": \"연결 시간 초과\",\n        \"exec_timeout\": \"실행 시간 초과\",\n        \"dbfilter_type\": \"데이터베이스 필터\",\n        \"dbfilter_all\": \"모두 표시\",\n        \"dbfilter_show\": \"선택 항목 표시\",\n        \"dbfilter_hide\": \"선택 항목 숨기기\",\n        \"dbfilter_show_title\": \"표시할 데이터베이스\",\n        \"dbfilter_hide_title\": \"숨길 데이터베이스\",\n        \"dbfilter_input\": \"데이터베이스 인덱스 입력\",\n        \"dbfilter_input_tip\": \"Enter를 눌러 확인\",\n        \"key_view\": \"기본 키 보기\",\n        \"key_view_tree\": \"트리 보기\",\n        \"key_view_list\": \"목록 보기\",\n        \"load_size\": \"불러올 키 수\",\n        \"mark_color\": \"표시 색상\"\n      },\n      \"alias\": {\n        \"title\": \"데이터베이스 별칭\",\n        \"db\": \"데이터베이스 인덱스 입력\",\n        \"value\": \"별칭 입력\"\n      },\n      \"ssl\": {\n        \"title\": \"SSL/TLS\",\n        \"enable\": \"SSL/TLS 활성화\",\n        \"allow_insecure\": \"안전하지 않은 연결 허용\",\n        \"sni\": \"서버 이름(SNI)\",\n        \"sni_tip\": \"(선택) 서버 이름\",\n        \"cert_file\": \"공개 키 파일\",\n        \"key_file\": \"개인 키 파일\",\n        \"ca_file\": \"CA 파일\",\n        \"cert_file_tip\": \"PEM 형식 공개 키 파일(Cert)\",\n        \"key_file_tip\": \"PEM 형식 개인 키 파일(Key)\",\n        \"ca_file_tip\": \"PEM 형식 CA 파일(CA)\"\n      },\n      \"ssh\": {\n        \"enable\": \"SSH 터널 활성화\",\n        \"title\": \"SSH 터널\",\n        \"login_type\": \"로그인 유형\",\n        \"agent\": \"SSH 에이전트\",\n        \"pkfile\": \"개인 키 파일\",\n        \"passphrase\": \"암호구문\",\n        \"addr_tip\": \"SSH 주소\",\n        \"usr_tip\": \"SSH 사용자 이름\",\n        \"pwd_tip\": \"SSH 비밀번호\",\n        \"pkfile_tip\": \"SSH 개인 키 파일 경로\",\n        \"passphrase_tip\": \"(선택) SSH 개인 키 암호구문\"\n      },\n      \"sentinel\": {\n        \"title\": \"센티널 모드\",\n        \"enable\": \"현재 센티널 노드\",\n        \"master\": \"마스터 그룹 이름\",\n        \"auto_discover\": \"자동 탐색\",\n        \"password\": \"마스터 비밀번호\",\n        \"username\": \"마스터 사용자 이름\",\n        \"pwd_tip\": \"(선택) 마스터 인증 비밀번호 (Redis > 6.0)\",\n        \"usr_tip\": \"(선택) 마스터 인증 사용자 이름\"\n      },\n      \"cluster\": {\n        \"title\": \"클러스터 모드\",\n        \"enable\": \"현재 클러스터 노드\"\n      },\n      \"proxy\": {\n        \"title\": \"프록시\",\n        \"type_none\": \"프록시 사용 안함\",\n        \"type_system\": \"시스템 프록시 설정 사용\",\n        \"type_custom\": \"프록시 수동 설정\",\n        \"host\": \"호스트명\",\n        \"auth\": \"인증 사용\",\n        \"usr_tip\": \"프록시 인증 사용자 이름\",\n        \"pwd_tip\": \"프록시 인증 비밀번호\"\n      }\n    },\n    \"group\": {\n      \"name\": \"그룹 이름\",\n      \"rename\": \"그룹 이름 변경\",\n      \"new\": \"새 그룹\"\n    },\n    \"key\": {\n      \"new\": \"새 키\",\n      \"new_name\": \"새 키 이름\",\n      \"server\": \"연결\",\n      \"db_index\": \"데이터베이스 인덱스\",\n      \"key_expression\": \"키 패턴\",\n      \"affected_key\": \"영향받는 키\",\n      \"show_affected_key\": \"영향받는 키 표시\",\n      \"confirm_delete_key\": \"{num}개의 키를 삭제하시겠습니까?\",\n      \"direct_delete\": \"일치하는 패턴 직접 삭제\",\n      \"confirm_delete\": \"삭제 확인\",\n      \"async_delete\": \"비동기 실행\",\n      \"async_delete_title\": \"결과를 기다리지 않음\",\n      \"confirm_flush\": \"진행 중인 작업을 알고 있습니다!\",\n      \"confirm_flush_db\": \"데이터베이스 플러시 확인\"\n    },\n    \"delete\": {\n      \"success\": \"\\\"{key}\\\"가 삭제되었습니다\",\n      \"deleting\": \"삭제 중\",\n      \"doing\": \"키 삭제 중 ({index}/{count})\",\n      \"completed\": \"삭제가 완료되었습니다. 성공: {success}개, 실패: {fail}개\"\n    },\n    \"field\": {\n      \"new\": \"새 필드\",\n      \"new_item\": \"새 항목\",\n      \"conflict_handle\": \"필드 충돌 시\",\n      \"overwrite_field\": \"덮어쓰기\",\n      \"ignore_field\": \"무시\",\n      \"insert_type\": \"삽입 유형\",\n      \"append_item\": \"추가\",\n      \"prepend_item\": \"앞에 추가\",\n      \"enter_key\": \"키 입력\",\n      \"enter_value\": \"값 입력\",\n      \"enter_field\": \"필드 이름 입력\",\n      \"enter_elem\": \"요소 입력\",\n      \"enter_member\": \"멤버 입력\",\n      \"enter_score\": \"점수 입력\",\n      \"element\": \"요소\",\n      \"reload_when_succ\": \"성공하면 즉시 새로고침\"\n    },\n    \"filter\": {\n      \"set_key_filter\": \"키 필터 설정\",\n      \"filter_pattern\": \"패턴\",\n      \"filter_pattern_tip\": \"직접 입력하여 현재 목록을 필터링하고, Enter키를 누르면 서버를 스캔할 수 있습니다.\\n\\n* 0개 이상의 문자 일치, 예) 'key*'\\n? 단일 문자 일치, 예) 'key?'\\n[] 범위 일치, 예) 'key[1-3]'\\n\\\\ 특수문자 이스케이프\",\n      \"exact_match_tip\": \"완전 일치\",\n      \"filter_type_not_support\": \"타입 필터링은 Redis 5.x 및 이전 버전을 지원하지 않습니다\"\n    },\n    \"export\": {\n      \"name\": \"데이터 내보내기\",\n      \"export_expire_title\": \"만료 시간\",\n      \"export_expire\": \"만료 시간 포함\",\n      \"export\": \"내보내기\",\n      \"save_file\": \"내보내기 경로\",\n      \"save_file_tip\": \"내보낼 파일 저장 경로 선택\",\n      \"exporting\": \"키 내보내는 중 ({index}/{count})\",\n      \"export_completed\": \"내보내기가 완료되었습니다. 성공: {success}개, 실패: {fail}개\"\n    },\n    \"import\": {\n      \"name\": \"데이터 가져오기\",\n      \"import_expire_title\": \"만료 시간\",\n      \"import\": \"가져오기\",\n      \"reload\": \"가져오기 후 새로고침\",\n      \"open_csv_file\": \"가져올 파일\",\n      \"open_csv_file_tip\": \"가져올 파일 선택\",\n      \"conflict_handle\": \"키 충돌 시\",\n      \"conflict_overwrite\": \"덮어쓰기\",\n      \"conflict_ignore\": \"무시\",\n      \"ttl_include\": \"파일에서 가져오기\",\n      \"ttl_ignore\": \"설정 안함\",\n      \"ttl_custom\": \"직접 설정\",\n      \"importing\": \"키 가져오는 중 가져오기/덮어쓰기:{imported} 충돌/실패:{conflict}\",\n      \"import_completed\": \"가져오기가 완료되었습니다. 성공: {success}개, 무시: {ignored}개\"\n    },\n    \"ttl\": {\n      \"title\": \"TTL 업데이트\",\n      \"title_batch\": \"TTL 일괄 업데이트 ({count})\",\n      \"quick_set\": \"빠른 설정\",\n      \"success\": \"모든 키의 TTL이 업데이트되었습니다\"\n    },\n    \"decoder\": {\n      \"name\": \"새 디코더/인코더\",\n      \"edit_name\": \"디코더/인코더 편집\",\n      \"new\": \"새로 만들기\",\n      \"decoder\": \"디코더\",\n      \"encoder\": \"인코더\",\n      \"decoder_name\": \"이름\",\n      \"auto\": \"자동 디코딩\",\n      \"decode_path\": \"디코더 경로\",\n      \"encode_path\": \"인코더 경로\",\n      \"path_help\": \"실행 파일 경로 또는 sh/php/python과 같은 CLI 별칭\",\n      \"args\": \"인수\",\n      \"args_help\": \"[VALUE]를 인코딩/디코딩 내용 자리 표시자로 사용하세요. 자리 표시자가 없으면 끝에 추가됩니다.\"\n    },\n    \"upgrade\": {\n      \"title\": \"새 버전 사용 가능\",\n      \"new_version_tip\": \"새 버전 {ver}이 있습니다. 지금 다운로드하시겠습니까?\",\n      \"no_update\": \"최신 버전입니다\",\n      \"download_now\": \"지금 다운로드\",\n      \"later\": \"나중에\",\n      \"skip\": \"이 버전 건너뛰기\"\n    },\n    \"welcome\": {\n      \"title\": \"Tiny RDM에 오신 것을 환영합니다!\",\n      \"content\": \"더 나은 사용자 경험을 제공하기 위해 Tiny RDM은 일부 익명 데이터를 수집하여 소프트웨어를 최적화하고 사용자 경험을 개선하는 데 사용합니다. 이는 개인 정보와는 무관함을 알려드립니다.\\n\\n만약 우려되는 점이 있다면 설정에서 이 데이터 수집 기능을 언제든 끌 수 있습니다. 문의 사항이 있으면 개발자에게 연락하시기 바랍니다. Tiny RDM이 좋은 도우미가 되길 바랍니다!\",\n      \"accept\": \"개선에 동의\",\n      \"reject\": \"거부\"\n    },\n    \"about\": {\n      \"source\": \"소스 코드\",\n      \"website\": \"공식 웹사이트\"\n    }\n  },\n  \"login\": {\n    \"username_placeholder\": \"사용자 이름 입력\",\n    \"password_placeholder\": \"비밀번호 입력\",\n    \"submit\": \"로그인\",\n    \"too_many_attempts\": \"시도 횟수 초과, 잠시 후 다시 시도하세요\",\n    \"invalid_credentials\": \"사용자 이름 또는 비밀번호가 올바르지 않습니다\",\n    \"network_error\": \"네트워크 오류\"\n  },\n  \"menu\": {\n    \"minimise\": \"최소화\",\n    \"maximise\": \"최대화\",\n    \"restore\": \"복원\",\n    \"close\": \"닫기\",\n    \"preferences\": \"설정\",\n    \"help\": \"도움말\",\n    \"user_guide\": \"사용자 가이드\",\n    \"check_update\": \"업데이트 확인...\",\n    \"report_bug\": \"버그 신고\",\n    \"about\": \"정보\"\n  },\n  \"log\": {\n    \"title\": \"실행 로그\",\n    \"filter_server\": \"서버 필터링\",\n    \"filter_keyword\": \"키워드 필터링\",\n    \"clean_log\": \"로그 지우기\",\n    \"confirm_clean_log\": \"실행 로그를 지우시겠습니까?\",\n    \"exec_time\": \"실행 시간\",\n    \"server\": \"서버\",\n    \"cmd\": \"명령\",\n    \"cost_time\": \"소요 시간\",\n    \"refresh\": \"새로고침\"\n  },\n  \"status\": {\n    \"uptime\": \"가동 시간\",\n    \"connected_clients\": \"클라이언트 수\",\n    \"total_keys\": \"키 수\",\n    \"memory_used\": \"메모리 사용량\",\n    \"server_info\": \"서버 정보\",\n    \"activity_status\": \"활동 현황\",\n    \"act_cmd\": \"명령/초\",\n    \"act_network_input\": \"네트워크 입력\",\n    \"act_network_output\": \"네트워크 출력\",\n    \"client\": {\n      \"title\": \"클라이언트 목록\",\n      \"addr\": \"클라이언트 주소\",\n      \"age\": \"시간(초)\",\n      \"idle\": \"유휴 시간(초)\",\n      \"db\": \"데이터베이스\"\n    }\n  },\n  \"slog\": {\n    \"title\": \"슬로우 로그\",\n    \"limit\": \"제한\",\n    \"filter\": \"필터\",\n    \"exec_time\": \"시간\",\n    \"client\": \"클라이언트\",\n    \"cmd\": \"명령\",\n    \"cost_time\": \"소요 시간\"\n  },\n  \"monitor\": {\n    \"title\": \"명령 모니터링\",\n    \"actions\": \"작업\",\n    \"warning\": \"명령 모니터링은 서버 차단을 유발할 수 있으므로 실서버에서는 주의해서 사용하세요.\",\n    \"start\": \"시작\",\n    \"stop\": \"정지\",\n    \"search\": \"검색\",\n    \"copy_log\": \"로그 복사\",\n    \"save_log\": \"로그 저장\",\n    \"clean_log\": \"로그 지우기\",\n    \"always_show_last\": \"최신 내용으로 자동 스크롤\"\n  },\n  \"pubsub\": {\n    \"title\": \"Pub/Sub\",\n    \"publish\": \"발행\",\n    \"subscribe\": \"구독\",\n    \"unsubscribe\": \"구독 취소\",\n    \"clear\": \"메시지 지우기\",\n    \"time\": \"시간\",\n    \"filter\": \"필터\",\n    \"channel\": \"채널\",\n    \"message\": \"메시지\",\n    \"receive_message\": \"{total}개의 메시지를 받았습니다\",\n    \"always_show_last\": \"최신 내용으로 자동 스크롤\"\n  }\n}\n"
  },
  {
    "path": "frontend/src/langs/pt-br.json",
    "content": "{\n  \"name\": \"Português\",\n  \"common\": {\n    \"confirm\": \"Confirmar\",\n    \"cancel\": \"Cancelar\",\n    \"success\": \"Sucesso\",\n    \"warning\": \"Aviso\",\n    \"error\": \"Erro\",\n    \"save\": \"Salvar\",\n    \"update\": \"Atualizar\",\n    \"none\": \"Nenhum\",\n    \"second\": \"Segundo(s)\",\n    \"minute\": \"Minuto(s)\",\n    \"hour\": \"Hora(s)\",\n    \"day\": \"Dia(s)\",\n    \"unit_day\": \"d\",\n    \"unit_hour\": \"h\",\n    \"unit_minute\": \"m\",\n    \"unit_second\": \"s\",\n    \"all\": \"Tudo\",\n    \"key\": \"Chave\",\n    \"value\": \"Valor\",\n    \"field\": \"Campo\",\n    \"score\": \"Pontuação\",\n    \"index\": \"Posição\"\n  },\n  \"preferences\": {\n    \"name\": \"Preferências\",\n    \"restore_defaults\": \"Restaurar Padrões\",\n    \"font_tip\": \"Suporta seleção múltipla. Digite manualmente a fonte se ela não estiver listada.\",\n    \"general\": {\n      \"name\": \"Geral\",\n      \"theme\": \"Tema\",\n      \"theme_light\": \"Claro\",\n      \"theme_dark\": \"Escuro\",\n      \"theme_auto\": \"Automático\",\n      \"language\": \"Idioma\",\n      \"system_lang\": \"Usar Idioma do Sistema\",\n      \"font\": \"Fonte\",\n      \"font_tip\": \"Selecione ou digite o nome da fonte\",\n      \"font_size\": \"Tamanho da Fonte\",\n      \"scan_size\": \"Tamanho Padrão para Comando SCAN\",\n      \"scan_size_tip\": \"Número de elementos retornados por vez pelos comandos SCAN/HSCAN/SSCAN/ZSCAN\",\n      \"key_icon_style\": \"Estilo do Ícone de Chave\",\n      \"key_icon_style0\": \"Compacto\",\n      \"key_icon_style1\": \"Nome Completo\",\n      \"key_icon_style2\": \"Ponto\",\n      \"key_icon_style3\": \"Comum\",\n      \"update\": \"Atualizar\",\n      \"auto_check_update\": \"Verificar atualizações automaticamente\",\n      \"privacy\": \"Política de Privacidade\",\n      \"allow_track\": \"Permitir a coleta de dados anônimos\"\n    },\n    \"editor\": {\n      \"name\": \"Editor\",\n      \"show_linenum\": \"Mostrar Números de Linha\",\n      \"show_folding\": \"Habilitar Dobra de Código\",\n      \"drop_text\": \"Permitir Arrastar e Soltar Texto\",\n      \"links\": \"Suportar Links\"\n    },\n    \"cli\": {\n      \"name\": \"Linha de Comando\",\n      \"cursor_style\": \"Estilo do Cursor\",\n      \"cursor_style_block\": \"Bloco\",\n      \"cursor_style_underline\": \"Sublinhado\",\n      \"cursor_style_bar\": \"Barra\"\n    },\n    \"decoder\": {\n      \"name\": \"Decodificador Personalizado\",\n      \"new\": \"Novo Decodificador\",\n      \"decoder_name\": \"Nome\",\n      \"cmd_preview\": \"Visualizar\",\n      \"status\": \"Status\",\n      \"auto_enabled\": \"Decodificação Automática Habilitada\",\n      \"help\": \"Ajuda\"\n    }\n  },\n  \"interface\": {\n    \"new_conn\": \"Adicionar Nova Conexão\",\n    \"new_group\": \"Adicionar Novo Grupo\",\n    \"disconnect_all\": \"Desconectar Tudo\",\n    \"status\": \"Status\",\n    \"filter\": \"Filtro\",\n    \"sort_conn\": \"Ordenar Conexões\",\n    \"new_conn_title\": \"Nova Conexão\",\n    \"open_db\": \"Abrir Banco de Dados\",\n    \"close_db\": \"Fechar Banco de Dados\",\n    \"filter_key\": \"Filtrar Chave\",\n    \"disconnect\": \"Desconectar\",\n    \"dup_conn\": \"Duplicar Conexão\",\n    \"remove_conn\": \"Excluir Conexão\",\n    \"edit_conn\": \"Editar Configuração da Conexão\",\n    \"edit_conn_group\": \"Editar Grupo de Conexão\",\n    \"rename_conn_group\": \"Renomear Grupo de Conexão\",\n    \"remove_conn_group\": \"Excluir Grupo de Conexão\",\n    \"import_conn\": \"Importar Conexões...\",\n    \"export_conn\": \"Exportar Conexões...\",\n    \"ttl\": \"TTL\",\n    \"forever\": \"Para Sempre\",\n    \"rename_key\": \"Renomear Chave\",\n    \"delete_key\": \"Excluir Chave\",\n    \"batch_delete_key\": \"Excluir Lotes de Chaves\",\n    \"import_key\": \"Importar Chaves\",\n    \"flush_db\": \"Limpar Banco de Dados\",\n    \"check_mode\": \"Modo de Seleção\",\n    \"quit_check_mode\": \"Sair do Modo de Seleção\",\n    \"delete_checked\": \"Excluir Selecionados\",\n    \"export_checked\": \"Exportar Selecionados\",\n    \"ttl_checked\": \"Atualizar TTL para Selecionados\",\n    \"copy_value\": \"Copiar Valor\",\n    \"edit_value\": \"Editar Valor\",\n    \"save_update\": \"Salvar Alterações\",\n    \"score_filter_tip\": \"Lista de operadores suportados abaixo:\\n= igual\\n!= diferente\\n> maior que\\n>= maior ou igual a\\n< menor que\\n<= menor ou igual a\\nPor exemplo, se você deseja filtrar resultados maiores que 3, insira: >3\",\n    \"add_row\": \"Adicionar Linha\",\n    \"edit_row\": \"Editar Linha\",\n    \"delete_row\": \"Excluir Linha\",\n    \"fullscreen\": \"Tela Cheia\",\n    \"offscreen\": \"Sair da Tela Cheia\",\n    \"pin_edit\": \"Fixar (Permanecer aberto após salvar)\",\n    \"unpin_edit\": \"Desafixar\",\n    \"search\": \"Buscar\",\n    \"full_search\": \"Busca Completa\",\n    \"full_search_result\": \"Conteúdo correspondente '{pattern}'\",\n    \"filter_field\": \"Filtrar Campo\",\n    \"filter_value\": \"Filtrar Valor\",\n    \"length\": \"Tamanho\",\n    \"entries\": \"Entradas\",\n    \"memory_usage\": \"Uso de Memória\",\n    \"text_align_left\": \"Alinhar à esquerda\",\n    \"text_align_center\": \"Centralizar\",\n    \"view_as\": \"Visualizar Como\",\n    \"decode_with\": \"Decodificar / Descompressão\",\n    \"custom_decoder\": \"Novo Decodificador Personalizado\",\n    \"reload\": \"Recarregar\",\n    \"reload_disable\": \"Recarregar após carregar completamente\",\n    \"auto_refresh\": \"Atualização Automática\",\n    \"refresh_interval\": \"Intervalo de Atualização\",\n    \"open_connection\": \"Abrir Conexão\",\n    \"copy_path\": \"Copiar Caminho\",\n    \"copy_key\": \"Copiar Chave\",\n    \"save_value_succ\": \"Valor Salvo!\",\n    \"copy_succ\": \"Copiado para a Área de Transferência!\",\n    \"binary_key\": \"Nome da Chave Binária\",\n    \"remove_key\": \"Remover Chave\",\n    \"new_key\": \"Adicionar Chave\",\n    \"load_more\": \"Carregar Mais Chaves\",\n    \"load_all\": \"Carregar Todas as Chaves Restantes\",\n    \"load_more_entries\": \"Carregar Mais\",\n    \"load_all_entries\": \"Carregar Tudo\",\n    \"more_action\": \"Mais Ação\",\n    \"nonexist_tab_content\": \"A chave selecionada não existe ou nenhuma chave está selecionada. Tente novamente após atualizar.\",\n    \"empty_server_content\": \"Selecione e abra uma conexão à esquerda\",\n    \"empty_server_list\": \"Nenhum servidor Redis adicionado\",\n    \"action\": \"Ação\",\n    \"type\": \"Tipo\",\n    \"cli_welcome\": \"Bem-vindo ao Console Redis Tiny RDM\",\n    \"retrieving_version\": \"Verificando atualizações\",\n    \"sub_tab\": {\n      \"status\": \"Status\",\n      \"key_detail\": \"Detalhes da Chave\",\n      \"cli\": \"Console\",\n      \"slow_log\": \"Log Lento\",\n      \"cmd_monitor\": \"Monitorar Comandos\",\n      \"pub_message\": \"Pub/Sub\"\n    }\n  },\n  \"ribbon\": {\n    \"server\": \"Servidor\",\n    \"browser\": \"Navegador de Dados\",\n    \"log\": \"Log\",\n    \"wechat_official\": \"Conta Oficial do WeChat\",\n    \"follow_x\": \"Siga \\uD835\\uDD4F\",\n    \"github\": \"Github\",\n    \"logout\": \"Sair\"\n  },\n  \"dialogue\": {\n    \"close_confirm\": \"Fechar esta conexão ({name})?\",\n    \"edit_close_confirm\": \"Por favor, feche as conexões relevantes antes de editar. Deseja continuar?\",\n    \"opening_connection\": \"Abrindo Conexão...\",\n    \"interrupt_connection\": \"Cancelar\",\n    \"remove_tip\": \"{type} \\\"{name}\\\" será excluído\",\n    \"remove_group_tip\": \"O grupo \\\"{name}\\\" e todas as conexões nele serão excluídos\",\n    \"rename_binary_key_fail\": \"Renomear nome de chave binária não é suportado\",\n    \"handle_succ\": \"Sucesso!\",\n    \"handle_cancel\": \"Operação cancelada.\",\n    \"reload_succ\": \"Recarregado!\",\n    \"field_required\": \"Este campo é obrigatório\",\n    \"spec_field_required\": \"\\\"{key}\\\" é obrigatório\",\n    \"illegal_characters\": \"Contém caracteres ilegais\",\n    \"connection\": {\n      \"new_title\": \"Nova Conexão\",\n      \"edit_title\": \"Editar Conexão\",\n      \"general\": \"Geral\",\n      \"no_group\": \"Sem Grupo\",\n      \"group\": \"Grupo\",\n      \"conn_name\": \"Nome\",\n      \"addr\": \"Endereço\",\n      \"usr\": \"Nome de Usuário\",\n      \"pwd\": \"Senha\",\n      \"name_tip\": \"Nome da Conexão\",\n      \"addr_tip\": \"Endereço do servidor Redis\",\n      \"sock_tip\": \"Arquivo de socket unix do Redis\",\n      \"usr_tip\": \"(Opcional) Nome de usuário para autenticação\",\n      \"pwd_tip\": \"(Opcional) Senha de autenticação (Redis > 6.0)\",\n      \"test\": \"Testar Conexão\",\n      \"test_succ\": \"Conectado com sucesso ao servidor Redis\",\n      \"test_fail\": \"Falha na Conexão\",\n      \"parse_url_clipboard\": \"Analisar URL da Área de Transferência\",\n      \"parse_pass\": \"URL Redis analisada: {url}\",\n      \"parse_fail\": \"Falha ao analisar URL Redis: {reason}\",\n      \"advn\": {\n        \"title\": \"Avançado\",\n        \"filter\": \"Filtro Padrão de Chave\",\n        \"filter_tip\": \"Padrão que define as chaves carregadas do servidor Redis\",\n        \"separator\": \"Separador de Chave\",\n        \"separator_tip\": \"Separador para segmento do caminho da chave\",\n        \"conn_timeout\": \"Tempo Limite de Conexão\",\n        \"exec_timeout\": \"Tempo Limite de Execução\",\n        \"dbfilter_type\": \"Filtro de Banco de Dados\",\n        \"dbfilter_all\": \"Mostrar Todos\",\n        \"dbfilter_show\": \"Mostrar Selecionados\",\n        \"dbfilter_hide\": \"Ocultar Selecionados\",\n        \"dbfilter_show_title\": \"Bancos de Dados a Mostrar\",\n        \"dbfilter_hide_title\": \"Bancos de Dados a Ocultar\",\n        \"dbfilter_input\": \"Índice do Banco de Dados de Entrada\",\n        \"dbfilter_input_tip\": \"Pressione Enter para confirmar\",\n        \"key_view\": \"Visualização Padrão de Chave\",\n        \"key_view_tree\": \"Visualização em Árvore\",\n        \"key_view_list\": \"Visualização em Lista\",\n        \"load_size\": \"Chaves Por Carga\",\n        \"mark_color\": \"Cor de Marcação\"\n      },\n      \"alias\": {\n        \"title\": \"Alias do Banco de Dados\",\n        \"db\": \"Índice do Banco de Dados de Entrada\",\n        \"value\": \"Alias do Banco de Dados de Entrada\"\n      },\n      \"ssl\": {\n        \"title\": \"SSL/TLS\",\n        \"enable\": \"Habilitar SSL/TLS\",\n        \"allow_insecure\": \"Permitir Inseguro\",\n        \"sni\": \"Nome do Servidor (SNI)\",\n        \"sni_tip\": \"(Opcional) Nome do servidor\",\n        \"cert_file\": \"Arquivo de Chave Pública\",\n        \"key_file\": \"Arquivo de Chave Privada\",\n        \"ca_file\": \"Arquivo CA\",\n        \"cert_file_tip\": \"Arquivo de Chave Pública no formato PEM (Cert)\",\n        \"key_file_tip\": \"Arquivo de Chave Privada no formato PEM (Chave)\",\n        \"ca_file_tip\": \"Arquivo de Autoridade de Certificação no formato PEM (CA)\"\n      },\n      \"ssh\": {\n        \"enable\": \"Habilitar Túnel SSH\",\n        \"title\": \"Túnel SSH\",\n        \"login_type\": \"Tipo de Login\",\n        \"agent\": \"Agente SSH\",\n        \"pkfile\": \"Arquivo de Chave Privada\",\n        \"passphrase\": \"Frase de Senha\",\n        \"addr_tip\": \"Endereço do Servidor SSH\",\n        \"usr_tip\": \"Nome de Usuário SSH\",\n        \"pwd_tip\": \"Senha SSH\",\n        \"pkfile_tip\": \"Caminho do Arquivo de Chave Privada SSH\",\n        \"passphrase_tip\": \"(Opcional) Frase de Senha para Chave Privada\"\n      },\n      \"sentinel\": {\n        \"title\": \"Sentinela\",\n        \"enable\": \"Atuar como Nó Sentinela\",\n        \"master\": \"Nome do Grupo Master\",\n        \"auto_discover\": \"Auto Descoberta\",\n        \"password\": \"Senha para Nó Master\",\n        \"username\": \"Nome de Usuário para Nó Master\",\n        \"pwd_tip\": \"(Opcional) Senha de autenticação no nó master (Redis > 6.0)\",\n        \"usr_tip\": \"(Opcional) Nome de usuário para autenticação no nó master\"\n      },\n      \"cluster\": {\n        \"title\": \"Cluster\",\n        \"enable\": \"Atuar como Nó Cluster\"\n      },\n      \"proxy\": {\n        \"title\": \"Proxy\",\n        \"type_none\": \"Sem Proxy\",\n        \"type_system\": \"Proxy do Sistema\",\n        \"type_custom\": \"Proxy Manual\",\n        \"host\": \"Nome do Host\",\n        \"auth\": \"Autenticação de Proxy\",\n        \"usr_tip\": \"Nome de usuário para autenticação de proxy\",\n        \"pwd_tip\": \"Senha para autenticação de proxy\"\n      }\n    },\n    \"group\": {\n      \"name\": \"Nome do Grupo\",\n      \"rename\": \"Renomear Grupo\",\n      \"new\": \"Novo Grupo\"\n    },\n    \"key\": {\n      \"new\": \"Nova Chave\",\n      \"new_name\": \"Novo Nome da Chave\",\n      \"server\": \"Conexão\",\n      \"db_index\": \"Índice do Banco de Dados\",\n      \"key_expression\": \"Expressão da Chave\",\n      \"affected_key\": \"Chaves Afetadas\",\n      \"show_affected_key\": \"Mostrar Chaves Afetadas\",\n      \"confirm_delete_key\": \"Confirmar Exclusão de {num} Chave(s)\",\n      \"direct_delete\": \"Excluir padrão correspondente diretamente\",\n      \"confirm_delete\": \"Confirmar exclusão\",\n      \"async_delete\": \"Execução Assíncrona\",\n      \"async_delete_title\": \"Não esperar pelo resultado da operação\",\n      \"confirm_flush\": \"Eu sei o que estou fazendo!\",\n      \"confirm_flush_db\": \"Confirmar Limpar Banco de Dados\"\n    },\n    \"delete\": {\n      \"success\": \"\\\"{key}\\\" excluída\",\n      \"deleting\": \"Excluindo\",\n      \"doing\": \"Excluindo chave ({index}/{count})\",\n      \"completed\": \"Exclusão concluída, {success} realizadas com sucesso, {fail} falharam\"\n    },\n    \"field\": {\n      \"new\": \"Novo Campo\",\n      \"new_item\": \"Novo Item\",\n      \"conflict_handle\": \"Em Conflito de Campo\",\n      \"overwrite_field\": \"Sobrescrever\",\n      \"ignore_field\": \"Ignorar\",\n      \"insert_type\": \"Tipo de Inserção\",\n      \"append_item\": \"Anexar\",\n      \"prepend_item\": \"Inserir no Início\",\n      \"enter_key\": \"Digite a Chave\",\n      \"enter_value\": \"Digite o Valor\",\n      \"enter_field\": \"Digite o Nome do Campo\",\n      \"enter_elem\": \"Digite o Elemento\",\n      \"enter_member\": \"Digite o Membro\",\n      \"enter_score\": \"Digite a Pontuação\",\n      \"element\": \"Elemento\",\n      \"reload_when_succ\": \"Recarregar imediatamente após o sucesso\"\n    },\n    \"filter\": {\n      \"set_key_filter\": \"Definir Filtro de Chave\",\n      \"filter_pattern\": \"Padrão\",\n      \"filter_pattern_tip\": \"Filtre a lista atual inserindo diretamente, e escaneie o servidor pressionando 'Enter'.\\n\\n* corresponde a 0 ou mais caracteres, ex: 'chave*'\\n? corresponde a um único caractere, ex: 'chave?'\\n[] corresponde a um intervalo, ex: 'chave[1-3]'\\n\\\\ escapa caracteres especiais\",\n      \"exact_match_tip\": \"Correspondência Exata\",\n      \"filter_type_not_support\": \"A filtragem por tipo não é suportada para Redis 5.x e versões anteriores\"\n    },\n    \"export\": {\n      \"name\": \"Exportar Dados\",\n      \"export_expire_title\": \"Expiração\",\n      \"export_expire\": \"Incluir Expiração\",\n      \"export\": \"Exportar\",\n      \"save_file\": \"Caminho de Exportação\",\n      \"save_file_tip\": \"Selecione o caminho para salvar o arquivo exportado\",\n      \"exporting\": \"Exportando chaves ({index}/{count})\",\n      \"export_completed\": \"Exportação concluída, {success} realizadas com sucesso, {fail} falharam\"\n    },\n    \"import\": {\n      \"name\": \"Importar Dados\",\n      \"import_expire_title\": \"Expiração\",\n      \"import\": \"Importar\",\n      \"reload\": \"Recarregar Após Importar\",\n      \"open_csv_file\": \"Arquivo de Importação\",\n      \"open_csv_file_tip\": \"Selecione o arquivo para importar\",\n      \"conflict_handle\": \"Em Conflito de Chave\",\n      \"conflict_overwrite\": \"Sobrescrever\",\n      \"conflict_ignore\": \"Ignorar\",\n      \"ttl_include\": \"Importar Do Arquivo\",\n      \"ttl_ignore\": \"Não Definir\",\n      \"ttl_custom\": \"Personalizado\",\n      \"importing\": \"Importando chaves importadas/sobrescritas:{imported} conflito/falha:{conflict}\",\n      \"import_completed\": \"Importação concluída, {success} realizadas com sucesso, {ignored} ignoradas\"\n    },\n    \"ttl\": {\n      \"title\": \"Atualizar TTL\",\n      \"title_batch\": \"Atualização em Lote de TTL ({count})\",\n      \"quick_set\": \"Definir Rapidamente\",\n      \"success\": \"TTL atualizado para todas as chaves\"\n    },\n    \"decoder\": {\n      \"name\": \"Novo Decodificador/Codificador\",\n      \"edit_name\": \"Editar Decodificador/Codificador\",\n      \"new\": \"Novo\",\n      \"decoder\": \"Decodificador\",\n      \"encoder\": \"Codificador\",\n      \"decoder_name\": \"Nome\",\n      \"auto\": \"Decodificação Automática\",\n      \"decode_path\": \"Caminho do Decodificador\",\n      \"encode_path\": \"Caminho do Codificador\",\n      \"path_help\": \"Caminho para executável ou alias de cli como 'sh/php/python'\",\n      \"args\": \"Argumentos\",\n      \"args_help\": \"Use [VALUE] como espaço reservado para conteúdo de codificação/decodificação. O conteúdo será anexado ao final se nenhum espaço reservado for fornecido.\"\n    },\n    \"upgrade\": {\n      \"title\": \"Nova Versão Disponível\",\n      \"new_version_tip\": \"Nova versão {ver} disponível, baixar agora?\",\n      \"no_update\": \"Você está atualizado\",\n      \"download_now\": \"Baixar Agora\",\n      \"later\": \"Depois\",\n      \"skip\": \"Ignorar Esta Versão\"\n    },\n    \"welcome\": {\n      \"title\": \"Bem-vindo ao Tiny RDM!\",\n      \"content\": \"Para fornecer uma melhor experiência ao usuário, o Tiny RDM coleta alguns dados anônimos para ajudar a otimizar o software e melhorar a experiência do usuário. Fique tranquilo, isso não envolve suas informações de privacidade pessoal.\\n\\nSe você tiver alguma preocupação, pode desativar esse recurso de coleta de dados a qualquer momento indo em Preferências. Se tiver alguma dúvida, sinta-se à vontade para entrar em contato com o desenvolvedor. Espero que o Tiny RDM possa se tornar um assistente útil para você!\",\n      \"accept\": \"Ajudar a Melhorar\",\n      \"reject\": \"Rejeitar\"\n    },\n    \"about\": {\n      \"source\": \"Código Fonte\",\n      \"website\": \"Site Oficial\"\n    }\n  },\n  \"login\": {\n    \"username_placeholder\": \"Digite o usuário\",\n    \"password_placeholder\": \"Digite a senha\",\n    \"submit\": \"Entrar\",\n    \"too_many_attempts\": \"Muitas tentativas, tente novamente mais tarde\",\n    \"invalid_credentials\": \"Credenciais inválidas\",\n    \"network_error\": \"Erro de rede\"\n  },\n  \"menu\": {\n    \"minimise\": \"Minimizar\",\n    \"maximise\": \"Maximizar\",\n    \"restore\": \"Restaurar\",\n    \"close\": \"Fechar\",\n    \"preferences\": \"Preferências\",\n    \"help\": \"Ajuda\",\n    \"user_guide\": \"Guia do Usuário\",\n    \"check_update\": \"Verificar Atualizações...\",\n    \"report_bug\": \"Reportar Erro\",\n    \"about\": \"Sobre\"\n  },\n  \"log\": {\n    \"title\": \"Log de Inicialização\",\n    \"filter_server\": \"Filtrar Servidor\",\n    \"filter_keyword\": \"Filtrar Palavra-chave\",\n    \"clean_log\": \"Limpar Log\",\n    \"confirm_clean_log\": \"Confirmar limpar log de inicialização\",\n    \"exec_time\": \"Tempo de Execução\",\n    \"server\": \"Servidor\",\n    \"cmd\": \"Comando\",\n    \"cost_time\": \"Custo\",\n    \"refresh\": \"Atualizar\"\n  },\n  \"status\": {\n    \"uptime\": \"Tempo de Atividade\",\n    \"connected_clients\": \"Clientes Conectados\",\n    \"total_keys\": \"Total de Chaves\",\n    \"memory_used\": \"Memória Usada\",\n    \"server_info\": \"Informações do Servidor\",\n    \"activity_status\": \"Status da Atividade\",\n    \"act_cmd\": \"Comandos/Seg\",\n    \"act_network_input\": \"Entrada de Rede\",\n    \"act_network_output\": \"Saída de Rede\",\n    \"client\": {\n      \"title\": \"Lista de Clientes\",\n      \"addr\": \"Endereço do Cliente\",\n      \"age\": \"Idade (seg)\",\n      \"idle\": \"Ocioso (seg)\",\n      \"db\": \"Banco de Dados\"\n    }\n  },\n  \"slog\": {\n    \"title\": \"Log Lento\",\n    \"limit\": \"Limite\",\n    \"filter\": \"Filtrar\",\n    \"exec_time\": \"Tempo\",\n    \"client\": \"Cliente\",\n    \"cmd\": \"Comando\",\n    \"cost_time\": \"Custo\"\n  },\n  \"monitor\": {\n    \"title\": \"Monitorar Comandos\",\n    \"actions\": \"Ações\",\n    \"warning\": \"O monitoramento de comandos pode causar bloqueio do servidor, use com cuidado em servidores de produção.\",\n    \"start\": \"Iniciar\",\n    \"stop\": \"Parar\",\n    \"search\": \"Buscar\",\n    \"copy_log\": \"Copiar Log\",\n    \"save_log\": \"Salvar Log\",\n    \"clean_log\": \"Limpar Log\",\n    \"always_show_last\": \"Rolar automaticamente para o mais recente\"\n  },\n  \"pubsub\": {\n    \"title\": \"Pub/Sub\",\n    \"publish\": \"Publicar\",\n    \"subscribe\": \"Inscrever\",\n    \"unsubscribe\": \"Cancelar Inscrição\",\n    \"clear\": \"Limpar Mensagens\",\n    \"time\": \"Tempo\",\n    \"filter\": \"Filtrar\",\n    \"channel\": \"Canal\",\n    \"message\": \"Mensagem\",\n    \"receive_message\": \"Recebidas {total} mensagens\",\n    \"always_show_last\": \"Rolar automaticamente para o mais recente\"\n  }\n}\n"
  },
  {
    "path": "frontend/src/langs/ru-ru.json",
    "content": "{\n  \"name\": \"Русский\",\n  \"common\": {\n    \"confirm\": \"Подтвердить\",\n    \"cancel\": \"Отменить\",\n    \"success\": \"Успех\",\n    \"warning\": \"Предупреждение\",\n    \"error\": \"Ошибка\",\n    \"save\": \"Сохранить\",\n    \"update\": \"Обновить\",\n    \"none\": \"Нет\",\n    \"second\": \"Секунда(ы)\",\n    \"minute\": \"Минута(ы)\",\n    \"hour\": \"Час(ы)\",\n    \"day\": \"День(и)\",\n    \"unit_day\": \"д\",\n    \"unit_hour\": \"ч\",\n    \"unit_minute\": \"м\",\n    \"unit_second\": \"с\",\n    \"all\": \"Все\",\n    \"key\": \"Ключ\",\n    \"value\": \"Значение\",\n    \"field\": \"Поле\",\n    \"score\": \"Счёт\",\n    \"index\": \"Позиция\"\n  },\n  \"preferences\": {\n    \"name\": \"Настройки\",\n    \"restore_defaults\": \"Восстановить настройки по умолчанию\",\n    \"font_tip\": \"Поддерживается множественный выбор. Если установленный шрифт не указан в списке, введите его вручную.\",\n    \"general\": {\n      \"name\": \"Общие\",\n      \"theme\": \"Тема\",\n      \"theme_light\": \"Светлая\",\n      \"theme_dark\": \"Тёмная\",\n      \"theme_auto\": \"Авто\",\n      \"language\": \"Язык\",\n      \"system_lang\": \"Использовать язык системы\",\n      \"font\": \"Шрифт\",\n      \"font_tip\": \"Выберите или введите название шрифта\",\n      \"font_size\": \"Размер шрифта\",\n      \"scan_size\": \"Размер по умолчанию для SCAN\",\n      \"scan_size_tip\": \"Количество элементов, возвращаемых за один раз командами SCAN/HSCAN/SSCAN/ZSCAN\",\n      \"key_icon_style\": \"Стиль значка ключа\",\n      \"key_icon_style0\": \"Компактный\",\n      \"key_icon_style1\": \"Полное название\",\n      \"key_icon_style2\": \"Точка\",\n      \"key_icon_style3\": \"Обычный\",\n      \"update\": \"Обновить\",\n      \"auto_check_update\": \"Автоматически проверять обновления\",\n      \"privacy\": \"Конфиденциальность\",\n      \"allow_track\": \"Разрешить сбор анонимных данных\"\n    },\n    \"editor\": {\n      \"name\": \"Редактор\",\n      \"show_linenum\": \"Показывать номера строк\",\n      \"show_folding\": \"Включить сворачивание кода\",\n      \"drop_text\": \"Разрешить перетаскивание текста\",\n      \"links\": \"Поддержка ссылок\"\n    },\n    \"cli\": {\n      \"name\": \"Командная строка\",\n      \"cursor_style\": \"Стиль курсора\",\n      \"cursor_style_block\": \"Блок\",\n      \"cursor_style_underline\": \"Подчёркнутый\",\n      \"cursor_style_bar\": \"Линия\"\n    },\n    \"decoder\": {\n      \"name\": \"Пользовательский декодер\",\n      \"new\": \"Новый декодер\",\n      \"decoder_name\": \"Название\",\n      \"cmd_preview\": \"Предпросмотр\",\n      \"status\": \"Статус\",\n      \"auto_enabled\": \"Автодекодирование включено\",\n      \"help\": \"Помощь\"\n    }\n  },\n  \"interface\": {\n    \"new_conn\": \"Добавить соединение\",\n    \"new_group\": \"Добавить группу\",\n    \"disconnect_all\": \"Отключить все\",\n    \"status\": \"Статус\",\n    \"filter\": \"Фильтр\",\n    \"sort_conn\": \"Сортировать соединения\",\n    \"new_conn_title\": \"Новое соединение\",\n    \"open_db\": \"Открыть базу данных\",\n    \"close_db\": \"Закрыть базу данных\",\n    \"filter_key\": \"Фильтр ключей\",\n    \"disconnect\": \"Отключить\",\n    \"dup_conn\": \"Дублировать соединение\",\n    \"remove_conn\": \"Удалить соединение\",\n    \"edit_conn\": \"Редактировать соединение\",\n    \"edit_conn_group\": \"Редактировать группу\",\n    \"rename_conn_group\": \"Переименовать группу\",\n    \"remove_conn_group\": \"Удалить группу\",\n    \"import_conn\": \"Импортировать соединения...\",\n    \"export_conn\": \"Экспортировать соединения...\",\n    \"ttl\": \"TTL\",\n    \"forever\": \"Навсегда\",\n    \"rename_key\": \"Переименовать ключ\",\n    \"delete_key\": \"Удалить ключ\",\n    \"batch_delete_key\": \"Пакетное удаление ключей\",\n    \"import_key\": \"Импортировать ключи\",\n    \"flush_db\": \"Очистить базу данных\",\n    \"check_mode\": \"Режим выбора\",\n    \"quit_check_mode\": \"Выйти из режима выбора\",\n    \"delete_checked\": \"Удалить выбранные\",\n    \"export_checked\": \"Экспортировать выбранные\",\n    \"ttl_checked\": \"Обновить TTL для выбранных\",\n    \"copy_value\": \"Копировать значение\",\n    \"edit_value\": \"Редактировать значение\",\n    \"save_update\": \"Сохранить изменения\",\n    \"score_filter_tip\": \"Поддерживаются операторы: \\n= равно\\n!= не равно\\n> больше\\n>= больше или равно\\n< меньше\\n<= меньше или равно\\nНапример, >3 для значений больше 3\",\n    \"add_row\": \"Вставить строку\",\n    \"edit_row\": \"Редактировать строку\",\n    \"delete_row\": \"Удалить строку\",\n    \"fullscreen\": \"Полноэкранный режим\",\n    \"offscreen\": \"Выйти из полноэкранного режима\",\n    \"pin_edit\": \"Закрепить (не закрывать после сохранения)\",\n    \"unpin_edit\": \"Открепить\",\n    \"search\": \"Поиск\",\n    \"full_search\": \"Полнотекстовый поиск\",\n    \"full_search_result\": \"Содержимое соответствует '{pattern}'\",\n    \"filter_field\": \"Фильтр полей\",\n    \"filter_value\": \"Фильтр значений\",\n    \"length\": \"Длина\",\n    \"entries\": \"Записи\",\n    \"memory_usage\": \"Использование памяти\",\n    \"text_align_left\": \"Выравнивание по левому краю\",\n    \"text_align_center\": \"Выравнивание по центру\",\n    \"view_as\": \"Вид\",\n    \"decode_with\": \"Декодировать/Распаковать\",\n    \"custom_decoder\": \"Новый пользовательский декодер\",\n    \"reload\": \"Перезагрузить\",\n    \"reload_disable\": \"Перезагрузить после полной загрузки\",\n    \"auto_refresh\": \"Автообновление\",\n    \"refresh_interval\": \"Интервал обновления\",\n    \"open_connection\": \"Открыть соединение\",\n    \"copy_path\": \"Копировать путь\",\n    \"copy_key\": \"Копировать ключ\",\n    \"save_value_succ\": \"Значение сохранено!\",\n    \"copy_succ\": \"Скопировано в буфер обмена!\",\n    \"binary_key\": \"Двоичное имя ключа\",\n    \"remove_key\": \"Удалить ключ\",\n    \"new_key\": \"Новый ключ\",\n    \"load_more\": \"Загрузить больше ключей\",\n    \"load_all\": \"Загрузить все оставшиеся ключи\",\n    \"load_more_entries\": \"Загрузить больше\",\n    \"load_all_entries\": \"Загрузить все\",\n    \"more_action\": \"Больше действий\",\n    \"nonexist_tab_content\": \"Выбранный ключ не существует или не выбран. Попробуйте обновить.\",\n    \"empty_server_content\": \"Выберите и откройте соединение с левой панели\",\n    \"empty_server_list\": \"Нет добавленных серверов Redis\",\n    \"action\": \"Действие\",\n    \"type\": \"Тип\",\n    \"cli_welcome\": \"Добро пожаловать в консоль Redis Tiny RDM\",\n    \"retrieving_version\": \"Проверка обновлений\",\n    \"sub_tab\": {\n      \"status\": \"Статус\",\n      \"key_detail\": \"Детали ключа\",\n      \"cli\": \"Консоль\",\n      \"slow_log\": \"Медленный лог\",\n      \"cmd_monitor\": \"Мониторинг команд\",\n      \"pub_message\": \"Публикация/Подписка\"\n    }\n  },\n  \"ribbon\": {\n    \"server\": \"Сервер\",\n    \"browser\": \"Браузер данных\",\n    \"log\": \"Лог\",\n    \"wechat_official\": \"Официальный аккаунт WeChat\",\n    \"follow_x\": \"Подписаться на \\uD835\\uDD4F\",\n    \"github\": \"Github\",\n    \"logout\": \"Выйти\"\n  },\n  \"dialogue\": {\n    \"close_confirm\": \"Закрыть это соединение ({name})?\",\n    \"edit_close_confirm\": \"Перед редактированием закройте соответствующие соединения. Продолжить?\",\n    \"opening_connection\": \"Открытие соединения...\",\n    \"interrupt_connection\": \"Отменить\",\n    \"remove_tip\": \"{type} \\\"{name}\\\" будет удален(а/о)\",\n    \"remove_group_tip\": \"Группа \\\"{name}\\\" и все её соединения будут удалены\",\n    \"rename_binary_key_fail\": \"Переименование двоичного ключа не поддерживается\",\n    \"handle_succ\": \"Успешно!\",\n    \"handle_cancel\": \"Операция отменена.\",\n    \"reload_succ\": \"Перезагружено!\",\n    \"field_required\": \"Это поле обязательно для заполнения\",\n    \"spec_field_required\": \"\\\"{key}\\\" требуется\",\n    \"illegal_characters\": \"Содержит недопустимые символы\",\n    \"connection\": {\n      \"new_title\": \"Новое соединение\",\n      \"edit_title\": \"Редактировать соединение\",\n      \"general\": \"Общие\",\n      \"no_group\": \"Без группы\",\n      \"group\": \"Группа\",\n      \"conn_name\": \"Название\",\n      \"addr\": \"Адрес\",\n      \"usr\": \"Имя пользователя\",\n      \"pwd\": \"Пароль\",\n      \"name_tip\": \"Название соединения\",\n      \"addr_tip\": \"Адрес сервера Redis\",\n      \"sock_tip\": \"Unix-сокет файл Redis\",\n      \"usr_tip\": \"(Опционально) Имя пользователя для авторизации\",\n      \"pwd_tip\": \"(Опционально) Пароль для авторизации (Redis > 6.0)\",\n      \"test\": \"Проверить соединение\",\n      \"test_succ\": \"Успешно подключено к серверу Redis\",\n      \"test_fail\": \"Не удалось подключиться\",\n      \"parse_url_clipboard\": \"Распарсить URL из буфера обмена\",\n      \"parse_pass\": \"Redis URL распарсен: {url}\",\n      \"parse_fail\": \"Не удалось распарсить Redis URL: {reason}\",\n      \"advn\": {\n        \"title\": \"Дополнительно\",\n        \"filter\": \"Фильтр ключей по умолчанию\",\n        \"filter_tip\": \"Шаблон для фильтрации загруженных ключей\",\n        \"separator\": \"Разделитель ключей\",\n        \"separator_tip\": \"Разделитель сегментов пути ключа\",\n        \"conn_timeout\": \"Тайм-аут соединения\",\n        \"exec_timeout\": \"Тайм-аут выполнения\",\n        \"dbfilter_type\": \"Фильтр баз данных\",\n        \"dbfilter_all\": \"Показать все\",\n        \"dbfilter_show\": \"Показать выбранные\",\n        \"dbfilter_hide\": \"Скрыть выбранные\",\n        \"dbfilter_show_title\": \"Базы данных для показа\",\n        \"dbfilter_hide_title\": \"Базы данных для скрытия\",\n        \"dbfilter_input\": \"Введите индекс базы данных\",\n        \"dbfilter_input_tip\": \"Нажмите Enter для подтверждения\",\n        \"key_view\": \"Вид ключей по умолчанию\",\n        \"key_view_tree\": \"Древовидный\",\n        \"key_view_list\": \"Списком\",\n        \"load_size\": \"Ключей за загрузку\",\n        \"mark_color\": \"Цвет маркера\"\n      },\n      \"alias\": {\n        \"title\": \"Псевдонимы баз данных\",\n        \"db\": \"Введите индекс базы данных\",\n        \"value\": \"Введите псевдоним\"\n      },\n      \"ssl\": {\n        \"title\": \"SSL/TLS\",\n        \"enable\": \"Включить SSL/TLS\",\n        \"allow_insecure\": \"Разрешить небезопасные соединения\",\n        \"sni\": \"Имя сервера (SNI)\",\n        \"sni_tip\": \"(Опционально) Имя сервера\",\n        \"cert_file\": \"Файл открытого ключа\",\n        \"key_file\": \"Файл закрытого ключа\",\n        \"ca_file\": \"Файл CA\",\n        \"cert_file_tip\": \"Файл открытого ключа в формате PEM (Cert)\",\n        \"key_file_tip\": \"Файл закрытого ключа в формате PEM (Key)\",\n        \"ca_file_tip\": \"Файл авторитета сертификации в формате PEM (CA)\"\n      },\n      \"ssh\": {\n        \"enable\": \"Включить SSH-туннель\",\n        \"title\": \"SSH-туннель\",\n        \"login_type\": \"Тип входа\",\n        \"agent\": \"SSH-агент\",\n        \"pkfile\": \"Файл закрытого ключа\",\n        \"passphrase\": \"Парольная фраза\",\n        \"addr_tip\": \"Адрес SSH-сервера\",\n        \"usr_tip\": \"Имя пользователя SSH\",\n        \"pwd_tip\": \"Пароль SSH\",\n        \"pkfile_tip\": \"Путь к файлу закрытого ключа SSH\",\n        \"passphrase_tip\": \"(Опционально) Парольная фраза для закрытого ключа\"\n      },\n      \"sentinel\": {\n        \"title\": \"Сентинель\",\n        \"enable\": \"В качестве узла Сентинеля\",\n        \"master\": \"Имя группы мастера\",\n        \"auto_discover\": \"Автоопределение\",\n        \"password\": \"Пароль мастера\",\n        \"username\": \"Имя пользователя мастера\",\n        \"pwd_tip\": \"(Опционально) Пароль мастера для авторизации (Redis > 6.0)\",\n        \"usr_tip\": \"(Опционально) Имя пользователя мастера для авторизации\"\n      },\n      \"cluster\": {\n        \"title\": \"Кластер\",\n        \"enable\": \"В качестве узла кластера\"\n      },\n      \"proxy\": {\n        \"title\": \"Прокси\",\n        \"type_none\": \"Без прокси\",\n        \"type_system\": \"Прокси системы\",\n        \"type_custom\": \"Ручная настройка прокси\",\n        \"host\": \"Имя хоста\",\n        \"auth\": \"Авторизация прокси\",\n        \"usr_tip\": \"Имя пользователя для авторизации прокси\",\n        \"pwd_tip\": \"Пароль для авторизации прокси\"\n      }\n    },\n    \"group\": {\n      \"name\": \"Имя группы\",\n      \"rename\": \"Переименовать группу\",\n      \"new\": \"Новая группа\"\n    },\n    \"key\": {\n      \"new\": \"Новый ключ\",\n      \"new_name\": \"Новое имя ключа\",\n      \"server\": \"Соединение\",\n      \"db_index\": \"Индекс базы данных\",\n      \"key_expression\": \"Шаблон ключей\",\n      \"affected_key\": \"Затронутые ключи\",\n      \"show_affected_key\": \"Показать затронутые ключи\",\n      \"confirm_delete_key\": \"Подтвердить удаление {num} ключ(ей/ей)\",\n      \"direct_delete\": \"Удалить совпадающий шаблон напрямую\",\n      \"confirm_delete\": \"Подтвердить удаление\",\n      \"async_delete\": \"Асинхронное выполнение\",\n      \"async_delete_title\": \"Не ждать результата\",\n      \"confirm_flush\": \"Я знаю, что делаю!\",\n      \"confirm_flush_db\": \"Подтвердить очистку базы данных\"\n    },\n    \"delete\": {\n      \"success\": \"\\\"{key}\\\" удален(а/о)\",\n      \"deleting\": \"Удаление\",\n      \"doing\": \"Удаление ключа ({index}/{count})\",\n      \"completed\": \"Удаление завершено, {success} успешно, {fail} с ошибкой\"\n    },\n    \"field\": {\n      \"new\": \"Новое поле\",\n      \"new_item\": \"Новый элемент\",\n      \"conflict_handle\": \"При конфликте полей\",\n      \"overwrite_field\": \"Перезаписать\",\n      \"ignore_field\": \"Пропустить\",\n      \"insert_type\": \"Тип вставки\",\n      \"append_item\": \"Добавить в конец\",\n      \"prepend_item\": \"Добавить в начало\",\n      \"enter_key\": \"Введите ключ\",\n      \"enter_value\": \"Введите значение\",\n      \"enter_field\": \"Введите имя поля\",\n      \"enter_elem\": \"Введите элемент\",\n      \"enter_member\": \"Введите элемент\",\n      \"enter_score\": \"Введите счёт\",\n      \"element\": \"Элемент\",\n      \"reload_when_succ\": \"Перезагрузить сразу после успеха\"\n    },\n    \"filter\": {\n      \"set_key_filter\": \"Установить фильтр ключей\",\n      \"filter_pattern\": \"Шаблон\",\n      \"filter_pattern_tip\": \"Отфильтруйте текущий список, введя напрямую, и выполните сканирование сервера, нажав 'Enter'.\\n\\n* соответствует 0 или более символов, напр. 'key*'\\n? соответствует одному символу, напр. 'key?'\\n[] соответствует диапазону, напр. 'key[1-3]'\\n\\\\ экранирует спецсимволы\",\n      \"exact_match_tip\": \"Точное совпадение\",\n      \"filter_type_not_support\": \"Фильтрация по типу не поддерживается для Redis версии 5.x и ниже\"\n    },\n    \"export\": {\n      \"name\": \"Экспорт данных\",\n      \"export_expire_title\": \"Срок истечения\",\n      \"export_expire\": \"Включить срок истечения\",\n      \"export\": \"Экспорт\",\n      \"save_file\": \"Путь для экспорта\",\n      \"save_file_tip\": \"Выберите путь для сохранения экспортируемого файла\",\n      \"exporting\": \"Экспорт ключей ({index}/{count})\",\n      \"export_completed\": \"Экспорт завершен, {success} успешно, {fail} с ошибкой\"\n    },\n    \"import\": {\n      \"name\": \"Импорт данных\",\n      \"import_expire_title\": \"Срок истечения\",\n      \"reload\": \"Перезагрузить после импорта\",\n      \"import\": \"Импорт\",\n      \"open_csv_file\": \"Импортировать файл\",\n      \"open_csv_file_tip\": \"Выберите файл для импорта\",\n      \"conflict_handle\": \"При конфликте ключей\",\n      \"conflict_overwrite\": \"Перезаписать\",\n      \"conflict_ignore\": \"Пропустить\",\n      \"ttl_include\": \"Импортировать из файла\",\n      \"ttl_ignore\": \"Не устанавливать\",\n      \"ttl_custom\": \"Пользовательское\",\n      \"importing\": \"Импорт ключей импортировано/перезаписано:{imported} конфликтов/ошибок:{conflict}\",\n      \"import_completed\": \"Импорт завершен, {success} успешно, {ignored} пропущено\"\n    },\n    \"ttl\": {\n      \"title\": \"Обновить TTL\",\n      \"title_batch\": \"Пакетное обновление TTL ({count})\",\n      \"quick_set\": \"Быстрая установка\",\n      \"success\": \"TTL обновлен для всех ключей\"\n    },\n    \"decoder\": {\n      \"name\": \"Новый декодер/энкодер\",\n      \"edit_name\": \"Редактировать декодер/энкодер\",\n      \"new\": \"Новый\",\n      \"decoder\": \"Декодер\",\n      \"encoder\": \"Энкодер\",\n      \"decoder_name\": \"Название\",\n      \"auto\": \"Автодекодирование\",\n      \"decode_path\": \"Путь декодера\",\n      \"encode_path\": \"Путь энкодера\",\n      \"path_help\": \"Путь к исполняемому файлу или алиасу cli, например 'sh/php/python'\",\n      \"args\": \"Аргументы\",\n      \"args_help\": \"Используйте [VALUE] в качестве заменителя для кодирования/декодирования. Если заменитель не указан, содержимое будет добавлено в конец.\"\n    },\n    \"upgrade\": {\n      \"title\": \"Доступна новая версия\",\n      \"new_version_tip\": \"Доступна новая версия {ver}, загрузить сейчас?\",\n      \"no_update\": \"У вас установлена последняя версия\",\n      \"download_now\": \"Загрузить сейчас\",\n      \"later\": \"Позже\",\n      \"skip\": \"Пропустить эту версию\"\n    },\n    \"welcome\": {\n      \"title\": \"Добро пожаловать в Tiny RDM！\",\n      \"content\": \"Для предоставления лучшего пользовательского опыта Tiny RDM собирает некоторые анонимные данные, чтобы помочь оптимизировать программное обеспечение и улучшить пользовательский опыт. Не беспокойтесь, это не будет затрагивать вашу личную конфиденциальную информацию.\\n\\nЕсли у вас есть какие-либо опасения, вы можете в любое время отключить сбор данных, перейдя в «Настройки». Если у вас есть какие-либо вопросы, обращайтесь к разработчику. Надеюсь, Tiny RDM станет вашим полезным помощником!\",\n      \"accept\": \"Помочь улучшить\",\n      \"reject\": \"Отклонить\"\n    },\n    \"about\": {\n      \"source\": \"Исходный код\",\n      \"website\": \"Официальный сайт\"\n    }\n  },\n  \"login\": {\n    \"username_placeholder\": \"Введите имя пользователя\",\n    \"password_placeholder\": \"Введите пароль\",\n    \"submit\": \"Войти\",\n    \"too_many_attempts\": \"Слишком много попыток, попробуйте позже\",\n    \"invalid_credentials\": \"Неверные учётные данные\",\n    \"network_error\": \"Ошибка сети\"\n  },\n  \"menu\": {\n    \"minimise\": \"Свернуть\",\n    \"maximise\": \"Развернуть\",\n    \"restore\": \"Восстановить\",\n    \"close\": \"Закрыть\",\n    \"preferences\": \"Настройки\",\n    \"help\": \"Помощь\",\n    \"user_guide\": \"Руководство пользователя\",\n    \"check_update\": \"Проверить обновления...\",\n    \"report_bug\": \"Сообщить об ошибке\",\n    \"about\": \"О программе\"\n  },\n  \"log\": {\n    \"title\": \"Журнал запуска\",\n    \"filter_server\": \"Фильтр сервера\",\n    \"filter_keyword\": \"Фильтр ключевых слов\",\n    \"clean_log\": \"Очистить журнал\",\n    \"confirm_clean_log\": \"Подтвердите очистку журнала запуска\",\n    \"exec_time\": \"Время выполнения\",\n    \"server\": \"Сервер\",\n    \"cmd\": \"Команда\",\n    \"cost_time\": \"Затраченное время\",\n    \"refresh\": \"Обновить\"\n  },\n  \"status\": {\n    \"uptime\": \"Uptime\",\n    \"connected_clients\": \"Клиенты\",\n    \"total_keys\": \"Ключи\",\n    \"memory_used\": \"Память\",\n    \"server_info\": \"Информация о сервере\",\n    \"activity_status\": \"Активность\",\n    \"act_cmd\": \"Команд/сек\",\n    \"act_network_input\": \"Входящий трафик\",\n    \"act_network_output\": \"Исходящий трафик\",\n    \"client\": {\n      \"title\": \"Список клиентов\",\n      \"addr\": \"Адрес клиента\",\n      \"age\": \"Время (сек)\",\n      \"idle\": \"Простой (сек)\",\n      \"db\": \"База данных\"\n    }\n  },\n  \"slog\": {\n    \"title\": \"Медленный журнал\",\n    \"limit\": \"Лимит\",\n    \"filter\": \"Фильтр\",\n    \"exec_time\": \"Время\",\n    \"client\": \"Клиент\",\n    \"cmd\": \"Команда\",\n    \"cost_time\": \"Затраченное время\"\n  },\n  \"monitor\": {\n    \"title\": \"Мониторинг команд\",\n    \"actions\": \"Действия\",\n    \"warning\": \"Мониторинг команд может вызвать блокировку сервера, используйте с осторожностью на производственных серверах.\",\n    \"start\": \"Старт\",\n    \"stop\": \"Стоп\",\n    \"search\": \"Поиск\",\n    \"copy_log\": \"Копировать журнал\",\n    \"save_log\": \"Сохранить журнал\",\n    \"clean_log\": \"Очистить журнал\",\n    \"always_show_last\": \"Автоматическая прокрутка к последнему\"\n  },\n  \"pubsub\": {\n    \"title\": \"Публикация/Подписка\",\n    \"publish\": \"Опубликовать\",\n    \"subscribe\": \"Подписаться\",\n    \"unsubscribe\": \"Отписаться\",\n    \"clear\": \"Очистить сообщения\",\n    \"time\": \"Время\",\n    \"filter\": \"Фильтр\",\n    \"channel\": \"Канал\",\n    \"message\": \"Сообщение\",\n    \"receive_message\": \"Получено сообщений: {total}\",\n    \"always_show_last\": \"Автоматическая прокрутка к последнему\"\n  }\n}\n"
  },
  {
    "path": "frontend/src/langs/tr-tr.json",
    "content": "{\n  \"name\": \"Türkçe\",\n  \"common\": {\n    \"confirm\": \"Onayla\",\n    \"cancel\": \"İptal\",\n    \"success\": \"Başarılı\",\n    \"warning\": \"Uyarı\",\n    \"error\": \"Hata\",\n    \"save\": \"Kaydet\",\n    \"update\": \"Güncelle\",\n    \"none\": \"Yok\",\n    \"second\": \"Saniye\",\n    \"minute\": \"Dakika\",\n    \"hour\": \"Saat\",\n    \"day\": \"Gün\",\n    \"unit_day\": \"g\",\n    \"unit_hour\": \"sa\",\n    \"unit_minute\": \"dk\",\n    \"unit_second\": \"sn\",\n    \"all\": \"Tümü\",\n    \"key\": \"Anahtar\",\n    \"value\": \"Değer\",\n    \"field\": \"Alan\",\n    \"score\": \"Puan\",\n    \"index\": \"Konum\"\n  },\n  \"preferences\": {\n    \"name\": \"Tercihler\",\n    \"restore_defaults\": \"Varsayılanlara Dön\",\n    \"font_tip\": \"Çoklu seçimi destekler. Listede yoksa yazı tipini manuel girin.\",\n    \"general\": {\n      \"name\": \"Genel\",\n      \"theme\": \"Tema\",\n      \"theme_light\": \"Açık\",\n      \"theme_dark\": \"Koyu\",\n      \"theme_auto\": \"Otomatik\",\n      \"language\": \"Dil\",\n      \"system_lang\": \"Sistem Dilini Kullan\",\n      \"font\": \"Yazı Tipi\",\n      \"font_tip\": \"Yazı tipi adını seçin veya girin\",\n      \"font_size\": \"Yazı Boyutu\",\n      \"scan_size\": \"SCAN için Varsayılan Boyut\",\n      \"scan_size_tip\": \"SCAN/HSCAN/SSCAN/ZSCAN için varsayılan döndürülecek eleman sayısı\",\n      \"key_icon_style\": \"Anahtar İkon Stili\",\n      \"key_icon_style0\": \"Kompakt\",\n      \"key_icon_style1\": \"Tam Ad\",\n      \"key_icon_style2\": \"Nokta\",\n      \"key_icon_style3\": \"Ortak\",\n      \"update\": \"Güncelle\",\n      \"auto_check_update\": \"Güncellemeleri otomatik kontrol et\",\n      \"privacy\": \"Gizlilik\",\n      \"allow_track\": \"Anonim veri toplanmasına izin ver\"\n    },\n    \"editor\": {\n      \"name\": \"Editör\",\n      \"show_linenum\": \"Satır Numaralarını Göster\",\n      \"show_folding\": \"Kod Katlamayı Etkinleştir\",\n      \"drop_text\": \"Sürükle ve Bırak Metnine İzin Ver\",\n      \"links\": \"Linkleri Destekle\"\n    },\n    \"cli\": {\n      \"name\": \"Komut Satırı\",\n      \"cursor_style\": \"İmleç Stili\",\n      \"cursor_style_block\": \"Blok\",\n      \"cursor_style_underline\": \"Alt Çizgi\",\n      \"cursor_style_bar\": \"Çubuk\"\n    },\n    \"decoder\": {\n      \"name\": \"Özel Kod Çözücü\",\n      \"new\": \"Yeni Kod Çözücü\",\n      \"decoder_name\": \"Ad\",\n      \"cmd_preview\": \"Önizleme\",\n      \"status\": \"Durum\",\n      \"auto_enabled\": \"Otomatik Kod Çözme Etkin\",\n      \"help\": \"Yardım\"\n    }\n  },\n  \"interface\": {\n    \"new_conn\": \"Bağlantı Ekle\",\n    \"new_group\": \"Grup Ekle\",\n    \"disconnect_all\": \"Tümünün Bağlantısını Kes\",\n    \"status\": \"Durum\",\n    \"filter\": \"Filtre\",\n    \"sort_conn\": \"Bağlantıları Sırala\",\n    \"new_conn_title\": \"Yeni Bağlantı\",\n    \"open_db\": \"Veritabanını Aç\",\n    \"close_db\": \"Veritabanını Kapat\",\n    \"filter_key\": \"Anahtarları Filtrele\",\n    \"disconnect\": \"Bağlantıyı Kes\",\n    \"dup_conn\": \"Bağlantıyı Çoğalt\",\n    \"remove_conn\": \"Bağlantıyı Kaldır\",\n    \"edit_conn\": \"Bağlantıyı Düzenle\",\n    \"edit_conn_group\": \"Grubu Düzenle\",\n    \"rename_conn_group\": \"Grubu Yeniden Adlandır\",\n    \"remove_conn_group\": \"Grubu Kaldır\",\n    \"import_conn\": \"Bağlantıları İçe Aktar...\",\n    \"export_conn\": \"Bağlantıları Dışa Aktar...\",\n    \"ttl\": \"TTL\",\n    \"forever\": \"Süresiz\",\n    \"rename_key\": \"Anahtarı Yeniden Adlandır\",\n    \"delete_key\": \"Anahtarı Sil\",\n    \"batch_delete_key\": \"Toplu Anahtar Sil\",\n    \"import_key\": \"Anahtarları İçe Aktar\",\n    \"flush_db\": \"Veritabanını Temizle\",\n    \"check_mode\": \"Seçim Modu\",\n    \"quit_check_mode\": \"Seçim Modundan Çık\",\n    \"delete_checked\": \"Seçilenleri Sil\",\n    \"export_checked\": \"Seçilenleri Dışa Aktar\",\n    \"ttl_checked\": \"Seçilenler için TTL Güncelle\",\n    \"copy_value\": \"Değeri Kopyala\",\n    \"edit_value\": \"Değeri Düzenle\",\n    \"save_update\": \"Değişiklikleri Kaydet\",\n    \"score_filter_tip\": \"Operatörleri destekler:\\n= eşit\\n!= eşit değil\\n> büyüktür\\n>= büyük eşittir\\n< küçüktür\\n<= küçük eşittir\\nörn. >3 üçten büyük puanlar için\",\n    \"add_row\": \"Satır Ekle\",\n    \"edit_row\": \"Satırı Düzenle\",\n    \"delete_row\": \"Satırı Sil\",\n    \"fullscreen\": \"Tam Ekran\",\n    \"offscreen\": \"Tam Ekrandan Çık\",\n    \"pin_edit\": \"Sabitle (Kaydedildikten sonra açık kal)\",\n    \"unpin_edit\": \"Sabitlemeyi Kaldır\",\n    \"search\": \"Ara\",\n    \"full_search\": \"Tam Metin Arama\",\n    \"full_search_result\": \"İçerik '{pattern}' ile eşleşti\",\n    \"filter_field\": \"Alan Filtrele\",\n    \"filter_value\": \"Değer Filtrele\",\n    \"length\": \"Uzunluk\",\n    \"entries\": \"Girişler\",\n    \"memory_usage\": \"Bellek Kullanımı\",\n    \"text_align_left\": \"Metni Sola Hizala\",\n    \"text_align_center\": \"Metni Ortala\",\n    \"view_as\": \"Farklı Görüntüle\",\n    \"decode_with\": \"Kod Çöz / Sıkıştırmayı Aç\",\n    \"custom_decoder\": \"Yeni Özel Kod Çözücü\",\n    \"reload\": \"Yeniden Yükle\",\n    \"reload_disable\": \"Tamamen yüklendikten sonra yeniden yükle\",\n    \"auto_refresh\": \"Otomatik Yenile\",\n    \"refresh_interval\": \"Yenileme Aralığı\",\n    \"open_connection\": \"Bağlantıyı Aç\",\n    \"copy_path\": \"Yolu Kopyala\",\n    \"copy_key\": \"Anahtarı Kopyala\",\n    \"save_value_succ\": \"Değer Kaydedildi!\",\n    \"copy_succ\": \"Panoya Kopyalandı!\",\n    \"binary_key\": \"İkili Anahtar Adı\",\n    \"remove_key\": \"Anahtarı Kaldır\",\n    \"new_key\": \"Yeni Anahtar\",\n    \"load_more\": \"Daha Fazla Anahtar Yükle\",\n    \"load_all\": \"Kalan Anahtarları Yükle\",\n    \"load_more_entries\": \"Daha Fazla Yükle\",\n    \"load_all_entries\": \"Tümünü Yükle\",\n    \"more_action\": \"Daha Fazla İşlem\",\n    \"nonexist_tab_content\": \"Seçilen anahtar mevcut değil veya hiç seçilmedi. Yeniledikten sonra tekrar deneyin.\",\n    \"empty_server_content\": \"Sol panelden bir bağlantı seçin ve açın\",\n    \"empty_server_list\": \"Redis sunucusu eklenmedi\",\n    \"action\": \"İşlem\",\n    \"type\": \"Tür\",\n    \"cli_welcome\": \"Tiny RDM Redis Konsoluna Hoş Geldiniz\",\n    \"retrieving_version\": \"Güncellemeler kontrol ediliyor\",\n    \"sub_tab\": {\n      \"status\": \"Durum\",\n      \"key_detail\": \"Anahtar Detayı\",\n      \"cli\": \"Konsol\",\n      \"slow_log\": \"Yavaş Log\",\n      \"cmd_monitor\": \"Komutları İzle\",\n      \"pub_message\": \"Pub/Sub\"\n    }\n  },\n  \"ribbon\": {\n    \"server\": \"Sunucu\",\n    \"browser\": \"Veri Tarayıcı\",\n    \"log\": \"Log\",\n    \"wechat_official\": \"WeChat Resmi Hesap\",\n    \"follow_x\": \"𝕏'i Takip Et\",\n    \"github\": \"Github\",\n    \"logout\": \"Çıkış Yap\"\n  },\n  \"dialogue\": {\n    \"close_confirm\": \"Bu bağlantı kapatılsın mı ({name})?\",\n    \"edit_close_confirm\": \"Düzenlemeden önce lütfen ilgili bağlantıları kapatın. Devam edilsin mi?\",\n    \"opening_connection\": \"Bağlantı Açılıyor...\",\n    \"interrupt_connection\": \"İptal\",\n    \"remove_tip\": \"{type} \\\"{name}\\\" silinecek\",\n    \"remove_group_tip\": \"Grup \\\"{name}\\\" ve tüm bağlantıları silinecek\",\n    \"rename_binary_key_fail\": \"İkili anahtarı yeniden adlandırma desteklenmiyor\",\n    \"handle_succ\": \"Başarılı!\",\n    \"handle_cancel\": \"İşlem iptal edildi.\",\n    \"reload_succ\": \"Yeniden yüklendi!\",\n    \"field_required\": \"Bu alan zorunludur\",\n    \"spec_field_required\": \"\\\"{key}\\\" zorunludur\",\n    \"illegal_characters\": \"Geçersiz karakterler içeriyor\",\n    \"connection\": {\n      \"new_title\": \"Yeni Bağlantı\",\n      \"edit_title\": \"Bağlantıyı Düzenle\",\n      \"general\": \"Genel\",\n      \"no_group\": \"Grup Yok\",\n      \"group\": \"Grup\",\n      \"conn_name\": \"Ad\",\n      \"addr\": \"Adres\",\n      \"usr\": \"Kullanıcı Adı\",\n      \"pwd\": \"Şifre\",\n      \"name_tip\": \"Bağlantı adı\",\n      \"addr_tip\": \"Redis sunucu adresi\",\n      \"sock_tip\": \"Redis unix socket dosyası\",\n      \"usr_tip\": \"(İsteğe bağlı) Kimlik doğrulama kullanıcı adı\",\n      \"pwd_tip\": \"(İsteğe bağlı) Kimlik doğrulama şifresi (Redis > 6.0)\",\n      \"test\": \"Bağlantıyı Test Et\",\n      \"test_succ\": \"Redis sunucusuna başarıyla bağlanıldı\",\n      \"test_fail\": \"Bağlantı başarısız\",\n      \"parse_url_clipboard\": \"Panodan URL'yi Ayrıştır\",\n      \"parse_pass\": \"Redis URL'si ayrıştırıldı: {url}\",\n      \"parse_fail\": \"Redis URL'si ayrıştırılamadı: {reason}\",\n      \"advn\": {\n        \"title\": \"Gelişmiş\",\n        \"filter\": \"Varsayılan Anahtar Filtresi\",\n        \"filter_tip\": \"Yüklenen anahtarları filtrelemek için desen\",\n        \"separator\": \"Anahtar Ayırıcı\",\n        \"separator_tip\": \"Anahtar yol segmentleri için ayırıcı\",\n        \"conn_timeout\": \"Bağlantı Zaman Aşımı\",\n        \"exec_timeout\": \"Çalıştırma Zaman Aşımı\",\n        \"dbfilter_type\": \"Veritabanı Filtresi\",\n        \"dbfilter_all\": \"Tümünü Göster\",\n        \"dbfilter_show\": \"Seçilenleri Göster\",\n        \"dbfilter_hide\": \"Seçilenleri Gizle\",\n        \"dbfilter_show_title\": \"Gösterilecek Veritabanları\",\n        \"dbfilter_hide_title\": \"Gizlenecek Veritabanları\",\n        \"dbfilter_input\": \"Veritabanı İndeksini Girin\",\n        \"dbfilter_input_tip\": \"Onaylamak için Enter'a basın\",\n        \"key_view\": \"Varsayılan Anahtar Görünümü\",\n        \"key_view_tree\": \"Ağaç Görünümü\",\n        \"key_view_list\": \"Liste Görünümü\",\n        \"load_size\": \"Her Yüklemede Anahtar Sayısı\",\n        \"mark_color\": \"İşaret Rengi\"\n      },\n      \"alias\": {\n        \"title\": \"Veritabanı Takma Adı\",\n        \"db\": \"Veritabanı İndeksini Girin\",\n        \"value\": \"Veritabanı Takma Adını Girin\"\n      },\n      \"ssl\": {\n        \"title\": \"SSL/TLS\",\n        \"enable\": \"SSL/TLS'yi Etkinleştir\",\n        \"allow_insecure\": \"Güvensiz Bağlantılara İzin Ver\",\n        \"sni\": \"Sunucu Adı (SNI)\",\n        \"sni_tip\": \"(İsteğe bağlı) Sunucu adı\",\n        \"cert_file\": \"Genel Anahtar Dosyası\",\n        \"key_file\": \"Özel Anahtar Dosyası\",\n        \"ca_file\": \"CA Dosyası\",\n        \"cert_file_tip\": \"PEM formatında Genel Anahtar Dosyası (Cert)\",\n        \"key_file_tip\": \"PEM formatında Özel Anahtar Dosyası (Key)\",\n        \"ca_file_tip\": \"PEM formatında Sertifika Yetkilisi Dosyası (CA)\"\n      },\n      \"ssh\": {\n        \"enable\": \"SSH Tünelini Etkinleştir\",\n        \"title\": \"SSH Tüneli\",\n        \"login_type\": \"Giriş Türü\",\n        \"agent\": \"SSH Ajanı\",\n        \"pkfile\": \"Özel Anahtar Dosyası\",\n        \"passphrase\": \"Parola\",\n        \"addr_tip\": \"SSH Sunucu Adresi\",\n        \"usr_tip\": \"SSH Kullanıcı Adı\",\n        \"pwd_tip\": \"SSH Şifresi\",\n        \"pkfile_tip\": \"SSH özel anahtar dosya yolu\",\n        \"passphrase_tip\": \"(İsteğe bağlı) Özel anahtar için parola\"\n      },\n      \"sentinel\": {\n        \"title\": \"Sentinel\",\n        \"enable\": \"Sentinel Düğümü Olarak\",\n        \"master\": \"Master Grup Adı\",\n        \"auto_discover\": \"Otomatik Keşfet\",\n        \"password\": \"Master Şifresi\",\n        \"username\": \"Master Kullanıcı Adı\",\n        \"pwd_tip\": \"(İsteğe bağlı) Master kimlik doğrulama şifresi (Redis > 6.0)\",\n        \"usr_tip\": \"(İsteğe bağlı) Master kimlik doğrulama kullanıcı adı\"\n      },\n      \"cluster\": {\n        \"title\": \"Küme\",\n        \"enable\": \"Küme Düğümü Olarak\"\n      },\n      \"proxy\": {\n        \"title\": \"Proxy\",\n        \"type_none\": \"Proxy Yok\",\n        \"type_system\": \"Sistem Proxy\",\n        \"type_custom\": \"Manuel Proxy\",\n        \"host\": \"Host Adı\",\n        \"auth\": \"Proxy Kimlik Doğrulama\",\n        \"usr_tip\": \"Proxy kimlik doğrulama kullanıcı adı\",\n        \"pwd_tip\": \"Proxy kimlik doğrulama şifresi\"\n      }\n    },\n    \"group\": {\n      \"name\": \"Grup Adı\",\n      \"rename\": \"Grubu Yeniden Adlandır\",\n      \"new\": \"Yeni Grup\"\n    },\n    \"key\": {\n      \"new\": \"Yeni Anahtar\",\n      \"new_name\": \"Yeni Anahtar Adı\",\n      \"server\": \"Bağlantı\",\n      \"db_index\": \"Veritabanı İndeksi\",\n      \"key_expression\": \"Anahtar Deseni\",\n      \"affected_key\": \"Etkilenen Anahtarlar\",\n      \"show_affected_key\": \"Etkilenen Anahtarları Göster\",\n      \"confirm_delete_key\": \"{num} anahtarın silinmesini onaylayın\",\n      \"direct_delete\": \"Eşleşen deseni doğrudan sil\",\n      \"confirm_delete\": \"Silmeyi Onayla\",\n      \"async_delete\": \"Asenkron Çalıştırma\",\n      \"async_delete_title\": \"Sonucu bekleme\",\n      \"confirm_flush\": \"Ne yaptığımı biliyorum!\",\n      \"confirm_flush_db\": \"Veritabanı temizlemeyi onayla\"\n    },\n    \"delete\": {\n      \"success\": \"\\\"{key}\\\" silindi\",\n      \"deleting\": \"Siliniyor\",\n      \"doing\": \"Anahtar siliniyor ({index}/{count})\",\n      \"completed\": \"Silme tamamlandı, {success} başarılı, {fail} başarısız\"\n    },\n    \"field\": {\n      \"new\": \"Yeni Alan\",\n      \"new_item\": \"Yeni Öğe\",\n      \"conflict_handle\": \"Alan Çakışmasında\",\n      \"overwrite_field\": \"Üzerine Yaz\",\n      \"ignore_field\": \"Yoksay\",\n      \"insert_type\": \"Ekleme Türü\",\n      \"append_item\": \"Sona Ekle\",\n      \"prepend_item\": \"Başa Ekle\",\n      \"enter_key\": \"Anahtar Girin\",\n      \"enter_value\": \"Değer Girin\",\n      \"enter_field\": \"Alan Adı Girin\",\n      \"enter_elem\": \"Eleman Girin\",\n      \"enter_member\": \"Üye Girin\",\n      \"enter_score\": \"Puan Girin\",\n      \"element\": \"Eleman\",\n      \"reload_when_succ\": \"Başarılı olursa hemen yeniden yükle\"\n    },\n    \"filter\": {\n      \"set_key_filter\": \"Anahtar Filtresi Ayarla\",\n      \"filter_pattern\": \"Desen\",\n      \"filter_pattern_tip\": \"Doğrudan girerek filtrele ve taramak için 'Enter'a basın.\\n\\n* 0 veya daha fazla karakterle eşleşir, örn. 'anahtar*'  \\n? tek karakterle eşleşir, örn. 'anahtar?'\\n[] aralıkla eşleşir, örn. 'anahtar[1-3]'\\n\\\\ özel karakterlerden kaçar\",\n      \"exact_match_tip\": \"Tam Eşleşme\",\n      \"filter_type_not_support\": \"Tür filtreleme Redis 5.x ve altı için desteklenmiyor.\"\n    },\n    \"export\": {\n      \"name\": \"Veriyi Dışa Aktar\",\n      \"export_expire_title\": \"Son Kullanma\",\n      \"export_expire\": \"Son Kullanmayı Dahil Et\",\n      \"export\": \"Dışa Aktar\",\n      \"save_file\": \"Dışa Aktarma Yolu\",\n      \"save_file_tip\": \"Dışa aktarılan dosyayı kaydetmek için yol seçin\",\n      \"exporting\": \"Anahtarlar dışa aktarılıyor ({index}/{count})\",\n      \"export_completed\": \"Dışa aktarma tamamlandı, {success} başarılı, {fail} başarısız\"\n    },\n    \"import\": {\n      \"name\": \"Veriyi İçe Aktar\",\n      \"import_expire_title\": \"Son Kullanma\",\n      \"import\": \"İçe Aktar\",\n      \"reload\": \"İçe Aktardıktan Sonra Yeniden Yükle\",\n      \"open_csv_file\": \"İçe Aktarma Dosyası\",\n      \"open_csv_file_tip\": \"İçe aktarılacak dosyayı seçin\",\n      \"conflict_handle\": \"Anahtar Çakışmasında\",\n      \"conflict_overwrite\": \"Üzerine Yaz\",\n      \"conflict_ignore\": \"Yoksay\",\n      \"ttl_include\": \"Dosyadan İçe Aktar\",\n      \"ttl_ignore\": \"Ayarlama\",\n      \"ttl_custom\": \"Özel\",\n      \"importing\": \"Anahtarlar içe aktarılıyor/üzerine yazılıyor:{imported} çakışma/başarısız:{conflict}\",\n      \"import_completed\": \"İçe aktarma tamamlandı, {success} başarılı, {ignored} yoksayıldı\"\n    },\n    \"ttl\": {\n      \"title\": \"TTL Güncelle\",\n      \"title_batch\": \"Toplu TTL Güncelle ({count})\",\n      \"quick_set\": \"Hızlı Ayarla\",\n      \"success\": \"Tüm anahtarlar için TTL güncellendi\"\n    },\n    \"decoder\": {\n      \"name\": \"Yeni Kod Çözücü/Kodlayıcı\",\n      \"edit_name\": \"Kod Çözücü/Kodlayıcıyı Düzenle\",\n      \"new\": \"Yeni\",\n      \"decoder\": \"Kod Çözücü\",\n      \"encoder\": \"Kodlayıcı\",\n      \"decoder_name\": \"Ad\",\n      \"auto\": \"Otomatik Kod Çöz\",\n      \"decode_path\": \"Kod Çözücü Yolu\",\n      \"encode_path\": \"Kodlayıcı Yolu\",\n      \"path_help\": \"Çalıştırılabilir dosya yolu veya 'sh/php/python' gibi cli takma adı\",\n      \"args\": \"Argümanlar\",\n      \"args_help\": \"Kodlama/kod çözme içeriği için [VALUE] yer tutucusu kullanın. Yer tutucu sağlanmazsa içerik sonuna eklenecektir.\"\n    },\n    \"upgrade\": {\n      \"title\": \"Yeni Sürüm Mevcut\",\n      \"new_version_tip\": \"Yeni {ver} sürümü mevcut, şimdi indirilsin mi?\",\n      \"no_update\": \"Güncelsiniz\",\n      \"download_now\": \"Şimdi İndir\",\n      \"later\": \"Sonra\",\n      \"skip\": \"Bu Sürümü Atla\"\n    },\n    \"welcome\": {\n      \"title\": \"Tiny RDM'e Hoş Geldiniz!\",\n      \"content\": \"Daha iyi bir kullanıcı deneyimi sağlamak için Tiny RDM, yazılımı optimize etmeye ve kullanıcı deneyimini iyileştirmeye yardımcı olmak için bazı anonim veriler toplamaktadır. Bunun kişisel gizlilik bilgilerinizi içermediğinden emin olabilirsiniz.\\n\\nHerhangi bir endişeniz varsa, Tercihler'e giderek bu veri toplama özelliğini istediğiniz zaman kapatabilirsiniz. Herhangi bir sorunuz varsa, geliştiriciye ulaşmaktan çekinmeyin. Umarım Tiny RDM yararlı asistanınız olur!\",\n      \"accept\": \"İyileştirmeye Yardım Et\",\n      \"reject\": \"Reddet\"\n    },\n    \"about\": {\n      \"source\": \"Kaynak Kod\",\n      \"website\": \"Resmi Web Sitesi\"\n    }\n  },\n  \"login\": {\n    \"username_placeholder\": \"Kullanıcı adını girin\",\n    \"password_placeholder\": \"Şifreyi girin\",\n    \"submit\": \"Giriş Yap\",\n    \"too_many_attempts\": \"Çok fazla deneme, lütfen daha sonra tekrar deneyin\",\n    \"invalid_credentials\": \"Geçersiz kimlik bilgileri\",\n    \"network_error\": \"Ağ hatası\"\n  },\n  \"menu\": {\n    \"minimise\": \"Küçült\",\n    \"maximise\": \"Büyüt\",\n    \"restore\": \"Geri Yükle\",\n    \"close\": \"Kapat\",\n    \"preferences\": \"Tercihler\",\n    \"help\": \"Yardım\",\n    \"user_guide\": \"Kullanıcı Kılavuzu\",\n    \"check_update\": \"Güncellemeleri Kontrol Et...\",\n    \"report_bug\": \"Hata Bildir\",\n    \"about\": \"Hakkında\"\n  },\n  \"log\": {\n    \"title\": \"Başlatma Logu\",\n    \"filter_server\": \"Sunucu Filtrele\",\n    \"filter_keyword\": \"Anahtar Kelime Filtrele\",\n    \"clean_log\": \"Logu Temizle\",\n    \"confirm_clean_log\": \"Başlatma logunu temizlemeyi onayla\",\n    \"exec_time\": \"Çalışma Zamanı\",\n    \"server\": \"Sunucu\",\n    \"cmd\": \"Komut\",\n    \"cost_time\": \"Süre\",\n    \"refresh\": \"Yenile\"\n  },\n  \"status\": {\n    \"uptime\": \"Çalışma Süresi\",\n    \"connected_clients\": \"İstemciler\",\n    \"total_keys\": \"Anahtarlar\",\n    \"memory_used\": \"Bellek\",\n    \"server_info\": \"Sunucu Bilgisi\",\n    \"activity_status\": \"Aktivite\",\n    \"act_cmd\": \"Komut/Saniye\",\n    \"act_network_input\": \"Ağ Girişi\",\n    \"act_network_output\": \"Ağ Çıkışı\",\n    \"client\": {\n      \"title\": \"İstemci Listesi\",\n      \"addr\": \"İstemci Adresi\",\n      \"age\": \"Yaş (saniye)\",\n      \"idle\": \"Boşta (saniye)\",\n      \"db\": \"Veritabanı\"\n    }\n  },\n  \"slog\": {\n    \"title\": \"Yavaş Log\",\n    \"limit\": \"Limit\",\n    \"filter\": \"Filtre\",\n    \"exec_time\": \"Zaman\",\n    \"client\": \"İstemci\",\n    \"cmd\": \"Komut\",\n    \"cost_time\": \"Süre\"\n  },\n  \"monitor\": {\n    \"title\": \"Komutları İzle\",\n    \"actions\": \"İşlemler\",\n    \"warning\": \"Komut izleme sunucu bloklarına neden olabilir, üretim sunucularında dikkatli kullanın.\",\n    \"start\": \"Başlat\",\n    \"stop\": \"Durdur\",\n    \"search\": \"Ara\",\n    \"copy_log\": \"Logu Kopyala\",\n    \"save_log\": \"Logu Kaydet\",\n    \"clean_log\": \"Logu Temizle\",\n    \"always_show_last\": \"Son Mesaja Otomatik Kaydır\"\n  },\n  \"pubsub\": {\n    \"title\": \"Pub/Sub\",\n    \"publish\": \"Yayınla\",\n    \"subscribe\": \"Abone Ol\",\n    \"unsubscribe\": \"Abonelikten Çık\",\n    \"clear\": \"Mesajları Temizle\",\n    \"time\": \"Zaman\",\n    \"filter\": \"Filtre\",\n    \"channel\": \"Kanal\",\n    \"message\": \"Mesaj\",\n    \"receive_message\": \"{total} mesaj alındı\",\n    \"always_show_last\": \"Son Mesaja Otomatik Kaydır\"\n  }\n}"
  },
  {
    "path": "frontend/src/langs/zh-cn.json",
    "content": "{\n  \"name\": \"简体中文\",\n  \"common\": {\n    \"confirm\": \"确认\",\n    \"cancel\": \"取消\",\n    \"success\": \"成功\",\n    \"warning\": \"警告\",\n    \"error\": \"错误\",\n    \"save\": \"保存\",\n    \"update\": \"更新\",\n    \"none\": \"无\",\n    \"second\": \"秒\",\n    \"minute\": \"分\",\n    \"hour\": \"小时\",\n    \"day\": \"天\",\n    \"unit_day\": \"天\",\n    \"unit_hour\": \"小时\",\n    \"unit_minute\": \"分钟\",\n    \"unit_second\": \"秒\",\n    \"all\": \"全部\",\n    \"key\": \"键\",\n    \"value\": \"值\",\n    \"field\": \"字段\",\n    \"score\": \"分值\",\n    \"index\": \"位置\"\n  },\n  \"preferences\": {\n    \"name\": \"偏好设置\",\n    \"restore_defaults\": \"重置为默认\",\n    \"font_tip\": \"支持多选，如列表没有已安装的字体，可自行手动输入\",\n    \"general\": {\n      \"name\": \"常规配置\",\n      \"theme\": \"主题\",\n      \"theme_light\": \"浅色\",\n      \"theme_dark\": \"深色\",\n      \"theme_auto\": \"自动\",\n      \"language\": \"语言\",\n      \"system_lang\": \"使用系统语言\",\n      \"font\": \"字体\",\n      \"font_tip\": \"请选择或手动输入字体名\",\n      \"font_size\": \"字体尺寸\",\n      \"scan_size\": \"SCAN命令默认数量\",\n      \"scan_size_tip\": \"SCAN/HSCAN/SSCAN/ZSCAN 命令每次返回数量\",\n      \"key_icon_style\": \"键图标样式\",\n      \"key_icon_style0\": \"紧凑类型\",\n      \"key_icon_style1\": \"全称类型\",\n      \"key_icon_style2\": \"圆点类型\",\n      \"key_icon_style3\": \"通用图标\",\n      \"update\": \"更新\",\n      \"auto_check_update\": \"自动检查更新\",\n      \"privacy\": \"隐私策略\",\n      \"allow_track\": \"允许收集匿名数据\"\n    },\n    \"editor\": {\n      \"name\": \"编辑器\",\n      \"show_linenum\": \"显示行号\",\n      \"show_folding\": \"启用代码折叠\",\n      \"drop_text\": \"允许拖放文本\",\n      \"links\": \"支持链接跳转\"\n    },\n    \"cli\": {\n      \"name\": \"命令行\",\n      \"cursor_style\": \"光标样式\",\n      \"cursor_style_block\": \"方块\",\n      \"cursor_style_underline\": \"下划线\",\n      \"cursor_style_bar\": \"竖线\"\n    },\n    \"decoder\": {\n      \"name\": \"自定义解码\",\n      \"new\": \"新增自定义解码\",\n      \"decoder_name\": \"解码器名称\",\n      \"cmd_preview\": \"命令预览\",\n      \"status\": \"状态\",\n      \"auto_enabled\": \"已加入自动解码\",\n      \"help\": \"帮助\"\n    }\n  },\n  \"interface\": {\n    \"new_conn\": \"添加新连接\",\n    \"new_group\": \"添加新分组\",\n    \"disconnect_all\": \"断开所有连接\",\n    \"status\": \"状态\",\n    \"filter\": \"筛选\",\n    \"sort_conn\": \"调整连接顺序\",\n    \"new_conn_title\": \"新建连接\",\n    \"open_db\": \"打开数据库\",\n    \"close_db\": \"关闭数据库\",\n    \"filter_key\": \"过滤键\",\n    \"disconnect\": \"断开连接\",\n    \"dup_conn\": \"复制连接\",\n    \"remove_conn\": \"删除连接\",\n    \"edit_conn\": \"编辑连接配置\",\n    \"edit_conn_group\": \"编辑分组\",\n    \"rename_conn_group\": \"重命名分组\",\n    \"remove_conn_group\": \"删除分组\",\n    \"import_conn\": \"导入连接...\",\n    \"export_conn\": \"导出连接...\",\n    \"ttl\": \"TTL\",\n    \"forever\": \"永久\",\n    \"rename_key\": \"重命名键\",\n    \"delete_key\": \"删除键\",\n    \"batch_delete_key\": \"批量删除键\",\n    \"import_key\": \"导入数据\",\n    \"flush_db\": \"清空数据库\",\n    \"check_mode\": \"勾选模式\",\n    \"quit_check_mode\": \"退出勾选模式\",\n    \"delete_checked\": \"删除所选项\",\n    \"export_checked\": \"导出所选项\",\n    \"ttl_checked\": \"为所选项更新TTL\",\n    \"copy_value\": \"复制值\",\n    \"edit_value\": \"修改值\",\n    \"save_update\": \"保存修改\",\n    \"score_filter_tip\": \"支持如下运算符比较匹配范围\\n＝：等于\\n!=：不等于\\n>：大于\\n<：小于\\n>=：大于等于\\n<=：小于等于\\n如查询分值大于3的结果，则输入：>3\",\n    \"add_row\": \"插入行\",\n    \"edit_row\": \"编辑行\",\n    \"delete_row\": \"删除行\",\n    \"fullscreen\": \"全屏显示\",\n    \"offscreen\": \"退出全屏显示\",\n    \"pin_edit\": \"固定编辑框（保存后不关闭）\",\n    \"unpin_edit\": \"取消固定\",\n    \"search\": \"搜索\",\n    \"full_search\": \"全文匹配\",\n    \"full_search_result\": \"内容已匹配为 {pattern}\",\n    \"filter_field\": \"筛选字段\",\n    \"filter_value\": \"筛选值\",\n    \"length\": \"长度\",\n    \"entries\": \"条目\",\n    \"memory_usage\": \"内存占用\",\n    \"text_align_left\": \"文本居左\",\n    \"text_align_center\": \"文本居中\",\n    \"view_as\": \"查看方式\",\n    \"decode_with\": \"解码/解压方式\",\n    \"custom_decoder\": \"添加自定义解码\",\n    \"reload\": \"重新载入\",\n    \"reload_disable\": \"全量加载后可重新载入\",\n    \"auto_refresh\": \"自动刷新\",\n    \"refresh_interval\": \"刷新间隔\",\n    \"open_connection\": \"打开连接\",\n    \"copy_path\": \"复制路径\",\n    \"copy_key\": \"复制键名\",\n    \"save_value_succ\": \"已保存值\",\n    \"copy_succ\": \"已复制到剪切板\",\n    \"binary_key\": \"二进制键名\",\n    \"remove_key\": \"删除键\",\n    \"new_key\": \"添加新键\",\n    \"load_more\": \"加载更多键\",\n    \"load_all\": \"加载剩余所有键\",\n    \"load_more_entries\": \"加载更多\",\n    \"load_all_entries\": \"加载全部\",\n    \"more_action\": \"更多操作\",\n    \"nonexist_tab_content\": \"所选键不存在或未选中任何键，请尝试刷新后重试\",\n    \"empty_server_content\": \"可以从左边选择并打开连接\",\n    \"empty_server_list\": \"还没添加Redis服务器\",\n    \"action\": \"操作\",\n    \"type\": \"类型\",\n    \"cli_welcome\": \"欢迎使用Tiny RDM的Redis命令行控制台\",\n    \"retrieving_version\": \"正在检索新版本\",\n    \"sub_tab\": {\n      \"status\": \"状态\",\n      \"key_detail\": \"键详情\",\n      \"cli\": \"命令行\",\n      \"slow_log\": \"慢日志\",\n      \"cmd_monitor\": \"监控命令\",\n      \"pub_message\": \"发布/订阅\"\n    }\n  },\n  \"ribbon\": {\n    \"server\": \"服务器\",\n    \"browser\": \"数据浏览\",\n    \"log\": \"日志\",\n    \"wechat_official\": \"微信公众号\",\n    \"follow_x\": \"关注我的\\uD835\\uDD4F\",\n    \"github\": \"Github\",\n    \"logout\": \"退出登录\"\n  },\n  \"dialogue\": {\n    \"close_confirm\": \"是否关闭此连接（{name}）\",\n    \"edit_close_confirm\": \"编辑前需要关闭相关连接，是否继续\",\n    \"opening_connection\": \"正在打开连接...\",\n    \"interrupt_connection\": \"中断连接\",\n    \"remove_tip\": \"{type} \\\"{name}\\\" 将会被删除\",\n    \"remove_group_tip\": \"分组 \\\"{name}\\\"及其所有连接将会被删除\",\n    \"rename_binary_key_fail\": \"不支持重命名二进制键名\",\n    \"handle_succ\": \"操作成功\",\n    \"handle_cancel\": \"操作已取消\",\n    \"reload_succ\": \"已重新载入\",\n    \"field_required\": \"此项不能为空\",\n    \"spec_field_required\": \"{key} 不能为空\",\n    \"illegal_characters\": \"包含非法字符\",\n    \"connection\": {\n      \"new_title\": \"新建连接\",\n      \"edit_title\": \"编辑连接\",\n      \"general\": \"常规配置\",\n      \"no_group\": \"无分组\",\n      \"group\": \"分组\",\n      \"conn_name\": \"连接名\",\n      \"addr\": \"连接地址\",\n      \"usr\": \"用户名\",\n      \"pwd\": \"密码\",\n      \"name_tip\": \"连接名\",\n      \"addr_tip\": \"Redis服务地址\",\n      \"sock_tip\": \"Redis套接字文件\",\n      \"usr_tip\": \"(可选)Redis服务授权用户名\",\n      \"pwd_tip\": \"(可选)Redis服务授权密码 (Redis > 6.0)\",\n      \"test\": \"测试连接\",\n      \"test_succ\": \"成功连接到Redis服务器\",\n      \"test_fail\": \"连接失败\",\n      \"parse_url_clipboard\": \"解析剪切板中的URL\",\n      \"parse_pass\": \"解析Redis URL完成: {url}\",\n      \"parse_fail\": \"解析Redis URL失败: {reason}\",\n      \"advn\": {\n        \"title\": \"高级配置\",\n        \"filter\": \"默认键过滤表达式\",\n        \"filter_tip\": \"需要加载的键名表达式\",\n        \"separator\": \"键分隔符\",\n        \"separator_tip\": \"键名路径分隔符\",\n        \"conn_timeout\": \"连接超时\",\n        \"exec_timeout\": \"执行超时\",\n        \"dbfilter_type\": \"数据库过滤方式\",\n        \"dbfilter_all\": \"显示所有\",\n        \"dbfilter_show\": \"显示指定\",\n        \"dbfilter_hide\": \"隐藏指定\",\n        \"dbfilter_show_title\": \"需要显示的数据库\",\n        \"dbfilter_hide_title\": \"需要隐藏的数据库\",\n        \"dbfilter_input\": \"输入数据库索引\",\n        \"dbfilter_input_tip\": \"按回车确认\",\n        \"key_view\": \"默认键视图\",\n        \"key_view_tree\": \"树形列表\",\n        \"key_view_list\": \"平铺列表\",\n        \"load_size\": \"单次加载键数量\",\n        \"mark_color\": \"标记颜色\"\n      },\n      \"alias\": {\n        \"title\": \"数据库别名\",\n        \"db\": \"输入数据库索引\",\n        \"value\": \"输入别名\"\n      },\n      \"ssl\": {\n        \"title\": \"SSL/TLS\",\n        \"enable\": \"启用SSL\",\n        \"allow_insecure\": \"允许不安全连接\",\n        \"sni\": \"服务器名(SNI)\",\n        \"sni_tip\": \"(可选)服务器名\",\n        \"cert_file\": \"公钥文件\",\n        \"key_file\": \"私钥文件\",\n        \"ca_file\": \"授权文件\",\n        \"cert_file_tip\": \"PEM格式公钥文件(Cert)\",\n        \"key_file_tip\": \"PEM格式私钥文件(Key)\",\n        \"ca_file_tip\": \"PEM格式授权文件(CA)\"\n      },\n      \"ssh\": {\n        \"enable\": \"启用SSH隧道\",\n        \"title\": \"SSH隧道\",\n        \"login_type\": \"登录类型\",\n        \"agent\": \"SSH代理\",\n        \"pkfile\": \"私钥文件\",\n        \"passphrase\": \"私钥密码\",\n        \"addr_tip\": \"SSH地址\",\n        \"usr_tip\": \"SSH登录用户名\",\n        \"pwd_tip\": \"SSH登录密码\",\n        \"pkfile_tip\": \"SSH私钥文件路径\",\n        \"passphrase_tip\": \"(可选)SSH私钥密码\"\n      },\n      \"sentinel\": {\n        \"title\": \"哨兵模式\",\n        \"enable\": \"当前为哨兵节点\",\n        \"master\": \"主节点组名\",\n        \"auto_discover\": \"自动查询组名\",\n        \"password\": \"主节点密码\",\n        \"username\": \"主节点用户名\",\n        \"pwd_tip\": \"(可选)主节点服务授权密码 (Redis > 6.0)\",\n        \"usr_tip\": \"(可选)主节点服务授权用户名\"\n      },\n      \"cluster\": {\n        \"title\": \"集群模式\",\n        \"enable\": \"当前为集群节点\"\n      },\n      \"proxy\": {\n        \"title\": \"网络代理\",\n        \"type_none\": \"不使用代理\",\n        \"type_system\": \"使用系统代理设置\",\n        \"type_custom\": \"手动配置代理\",\n        \"host\": \"主机名\",\n        \"auth\": \"使用身份验证\",\n        \"usr_tip\": \"代理授权用户名\",\n        \"pwd_tip\": \"代理授权密码\"\n      }\n    },\n    \"group\": {\n      \"name\": \"分组名\",\n      \"rename\": \"重命名分组\",\n      \"new\": \"添加新分组\"\n    },\n    \"key\": {\n      \"new\": \"添加新键\",\n      \"new_name\": \"新键名\",\n      \"server\": \"所属连接\",\n      \"db_index\": \"数据库编号\",\n      \"key_expression\": \"键名表达式\",\n      \"affected_key\": \"受影响的键名\",\n      \"show_affected_key\": \"查看受影响的键名\",\n      \"confirm_delete_key\": \"确认删除{num}个键\",\n      \"direct_delete\": \"直接匹配删除\",\n      \"confirm_delete\": \"确认删除\",\n      \"async_delete\": \"异步执行\",\n      \"async_delete_title\": \"不等待操作结果\",\n      \"confirm_flush\": \"我知道我正在执行的操作！\",\n      \"confirm_flush_db\": \"确认清空数据库\"\n    },\n    \"delete\": {\n      \"success\": \"{key} 已被删除\",\n      \"deleting\": \"正在删除\",\n      \"doing\": \"正在删除键({index}/{count})\",\n      \"completed\": \"已完成删除操作，成功{success}个，失败{fail}个\"\n    },\n    \"field\": {\n      \"new\": \"添加新字段\",\n      \"new_item\": \"添加新元素\",\n      \"conflict_handle\": \"字段冲突处理\",\n      \"overwrite_field\": \"覆盖\",\n      \"ignore_field\": \"忽略\",\n      \"insert_type\": \"插入类型\",\n      \"append_item\": \"尾部追加\",\n      \"prepend_item\": \"插入头部\",\n      \"enter_key\": \"输入键名\",\n      \"enter_value\": \"输入值\",\n      \"enter_field\": \"输入字段名\",\n      \"enter_elem\": \"输入新元素\",\n      \"enter_member\": \"输入成员\",\n      \"enter_score\": \"输入分值\",\n      \"element\": \"元素\",\n      \"reload_when_succ\": \"操作成功后立即重新加载\"\n    },\n    \"filter\": {\n      \"set_key_filter\": \"设置键过滤器\",\n      \"filter_pattern\": \"过滤表达式\",\n      \"filter_pattern_tip\": \"直接输入筛选当前列表，回车后可对服务器进行扫描。\\n\\n *：匹配零个或多个字符。例如：\\\"key*\\\"匹配到以\\\"key\\\"开头的所有键\\n?：匹配单个字符。例如：\\\"key?\\\"匹配\\\"key1\\\"、\\\"key2\\\"\\n[ ]：匹配指定范围内的单个字符。例如：\\\"key[1-3]\\\"可以匹配类似于 \\\"key1\\\"、\\\"key2\\\"、\\\"key3\\\" 的键\\n\\\\：转义字符。如果想要匹配 *、?、[、或]，需要使用反斜杠\\\"\\\\\\\"进行转义\",\n      \"exact_match_tip\": \"完全匹配\",\n      \"filter_type_not_support\": \"类型筛选不支持 Redis 5.x 及以下版本\"\n    },\n    \"export\": {\n      \"name\": \"导出数据\",\n      \"export_expire_title\": \"过期时间\",\n      \"export_expire\": \"同时导出过期时间\",\n      \"export\": \"确认导出\",\n      \"save_file\": \"导出路径\",\n      \"save_file_tip\": \"选择导出文件保存路径\",\n      \"exporting\": \"正在导出键({index}/{count})\",\n      \"export_completed\": \"已完成导出操作，成功{success}个，失败{fail}个\"\n    },\n    \"import\": {\n      \"name\": \"导入数据\",\n      \"import_expire_title\": \"过期时间\",\n      \"reload\": \"导入完成后重新载入\",\n      \"import\": \"确认导入\",\n      \"open_csv_file\": \"导入文件路径\",\n      \"open_csv_file_tip\": \"选择需要导入的文件\",\n      \"conflict_handle\": \"键冲突处理\",\n      \"conflict_overwrite\": \"覆盖\",\n      \"conflict_ignore\": \"忽略\",\n      \"ttl_include\": \"尝试导入\",\n      \"ttl_ignore\": \"不设置\",\n      \"ttl_custom\": \"自定义\",\n      \"importing\": \"正在导入数据 已导入/覆盖:{imported} 冲突/失败:{conflict}\",\n      \"import_completed\": \"已完成导入操作，成功{success}个，忽略{ignored}个\"\n    },\n    \"ttl\": {\n      \"title\": \"设置键存活时间\",\n      \"title_batch\": \"批量设置键存活时间({count})\",\n      \"quick_set\": \"快捷设置\",\n      \"success\": \"已全部更新TTL\"\n    },\n    \"decoder\": {\n      \"name\": \"新增解码/编码器\",\n      \"edit_name\": \"编辑解码/编码器\",\n      \"new\": \"新增\",\n      \"decoder\": \"解码器\",\n      \"encoder\": \"编码器\",\n      \"decoder_name\": \"解码器名称\",\n      \"auto\": \"自动解码\",\n      \"decode_path\": \"解码器执行路径\",\n      \"encode_path\": \"编码器执行路径\",\n      \"path_help\": \"执行文件路径，也可以直接填写命令行接口，如sh/php/python\",\n      \"args\": \"运行参数\",\n      \"args_help\": \"使用[VALUE]代替编码/解码内容占位符，如果不填内容占位则默认放最后\"\n    },\n    \"upgrade\": {\n      \"title\": \"有可用新版本\",\n      \"new_version_tip\": \"新版本（{ver}），是否立即下载\",\n      \"no_update\": \"当前已是最新版\",\n      \"download_now\": \"立即下载\",\n      \"later\": \"稍后提醒我\",\n      \"skip\": \"忽略本次更新\"\n    },\n    \"welcome\": {\n      \"title\": \"欢迎使用Tiny RDM！\",\n      \"content\": \"为了提供更好的用户体验，Tiny RDM会收集一些匿名的数据，以帮助优化软件和改进用户体验，请放心这不会涉及到您的个人隐私信息。\\n\\n如果您对此有任何顾虑，可以随时前往\\\"偏好设置\\\"中关闭此项数据收集功能。有任何问题可联系开发者，希望Tiny RDM可以成为您的好帮手！\",\n      \"accept\": \"帮助改进\",\n      \"reject\": \"不接受\"\n    },\n    \"about\": {\n      \"source\": \"源码地址\",\n      \"website\": \"官方网站\"\n    }\n  },\n  \"login\": {\n    \"username_placeholder\": \"请输入用户名\",\n    \"password_placeholder\": \"请输入密码\",\n    \"submit\": \"登录\",\n    \"too_many_attempts\": \"尝试次数过多，请稍后再试\",\n    \"invalid_credentials\": \"用户名或密码错误\",\n    \"network_error\": \"网络错误\"\n  },\n  \"menu\": {\n    \"minimise\": \"最小化\",\n    \"maximise\": \"最大化\",\n    \"restore\": \"还原\",\n    \"close\": \"关闭\",\n    \"preferences\": \"偏好设置\",\n    \"help\": \"帮助\",\n    \"user_guide\": \"使用指南\",\n    \"check_update\": \"检查更新...\",\n    \"report_bug\": \"报告错误\",\n    \"about\": \"关于\"\n  },\n  \"log\": {\n    \"title\": \"运行日志\",\n    \"filter_server\": \"筛选服务器\",\n    \"filter_keyword\": \"筛选关键字\",\n    \"clean_log\": \"清空运行日志\",\n    \"confirm_clean_log\": \"确定清空运行日志\",\n    \"exec_time\": \"执行时间\",\n    \"server\": \"服务器\",\n    \"cmd\": \"命令\",\n    \"cost_time\": \"耗时\",\n    \"refresh\": \"立即刷新\"\n  },\n  \"status\": {\n    \"uptime\": \"运行时间\",\n    \"connected_clients\": \"已连客户端\",\n    \"total_keys\": \"键总数\",\n    \"memory_used\": \"内存使用\",\n    \"server_info\": \"状态信息\",\n    \"activity_status\": \"活动状态\",\n    \"act_cmd\": \"命令执行数/秒\",\n    \"act_network_input\": \"网络输入\",\n    \"act_network_output\": \"网络输出\",\n    \"client\": {\n      \"title\": \"所有客户端列表\",\n      \"addr\": \"客户端地址\",\n      \"age\": \"连接时长(秒)\",\n      \"idle\": \"空闲时长(秒)\",\n      \"db\": \"数据库\"\n    }\n  },\n  \"slog\": {\n    \"title\": \"慢日志\",\n    \"limit\": \"条数\",\n    \"filter\": \"筛选\",\n    \"exec_time\": \"执行时间\",\n    \"client\": \"客户端\",\n    \"cmd\": \"命令\",\n    \"cost_time\": \"耗时\"\n  },\n  \"monitor\": {\n    \"title\": \"监控命令\",\n    \"actions\": \"操作\",\n    \"warning\": \"命令监控可能会造成服务端堵塞，请谨慎在生产环境的服务器使用\",\n    \"start\": \"开启监控\",\n    \"stop\": \"停止监控\",\n    \"search\": \"搜索\",\n    \"copy_log\": \"复制日志\",\n    \"save_log\": \"保存日志\",\n    \"clean_log\": \"清空日志\",\n    \"always_show_last\": \"自动滚到最新\"\n  },\n  \"pubsub\": {\n    \"title\": \"发布订阅\",\n    \"publish\": \"发布\",\n    \"subscribe\": \"开启订阅\",\n    \"unsubscribe\": \"取消订阅\",\n    \"clear\": \"清空消息\",\n    \"time\": \"时间\",\n    \"filter\": \"筛选\",\n    \"channel\": \"频道\",\n    \"message\": \"消息\",\n    \"receive_message\": \"已接收消息 {total} 条\",\n    \"always_show_last\": \"自动滚到最新\"\n  }\n}\n"
  },
  {
    "path": "frontend/src/langs/zh-tw.json",
    "content": "{\n  \"name\": \"繁體中文\",\n  \"common\": {\n    \"confirm\": \"確認\",\n    \"cancel\": \"取消\",\n    \"success\": \"成功\",\n    \"warning\": \"警告\",\n    \"error\": \"錯誤\",\n    \"save\": \"儲存\",\n    \"update\": \"更新\",\n    \"none\": \"無\",\n    \"second\": \"秒\",\n    \"minute\": \"分\",\n    \"hour\": \"小時\",\n    \"day\": \"天\",\n    \"unit_day\": \"天\",\n    \"unit_hour\": \"小時\",\n    \"unit_minute\": \"分鐘\",\n    \"unit_second\": \"秒\",\n    \"all\": \"全部\",\n    \"key\": \"鍵\",\n    \"value\": \"值\",\n    \"field\": \"欄位\",\n    \"score\": \"分數\",\n    \"index\": \"位置\"\n  },\n  \"preferences\": {\n    \"name\": \"偏好設定\",\n    \"restore_defaults\": \"重設為預設值\",\n    \"font_tip\": \"支援多選，如列表沒有已安裝的字型，可自行手動輸入\",\n    \"general\": {\n      \"name\": \"一般設定\",\n      \"theme\": \"主題\",\n      \"theme_light\": \"淺色\",\n      \"theme_dark\": \"深色\",\n      \"theme_auto\": \"自動\",\n      \"language\": \"語言\",\n      \"system_lang\": \"使用系統語言\",\n      \"font\": \"字型\",\n      \"font_tip\": \"請選擇或手動輸入字型名稱\",\n      \"font_size\": \"字型大小\",\n      \"scan_size\": \"SCAN命令預設數量\",\n      \"scan_size_tip\": \"SCAN/HSCAN/SSCAN/ZSCAN 命令每次返回的元素數量\",\n      \"key_icon_style\": \"鍵圖示樣式\",\n      \"key_icon_style0\": \"緊湊類型\",\n      \"key_icon_style1\": \"全稱類型\",\n      \"key_icon_style2\": \"點類型\",\n      \"key_icon_style3\": \"通用圖示\",\n      \"update\": \"更新\",\n      \"auto_check_update\": \"自動檢查更新\",\n      \"privacy\": \"隱私權政策\",\n      \"allow_track\": \"允許收集匿名數據\"\n    },\n    \"editor\": {\n      \"name\": \"編輯器\",\n      \"show_linenum\": \"顯示行號\",\n      \"show_folding\": \"啟用代碼折疊\",\n      \"drop_text\": \"允許拖放文字\",\n      \"links\": \"支援連結跳轉\"\n    },\n    \"cli\": {\n      \"name\": \"命令列\",\n      \"cursor_style\": \"游標樣式\",\n      \"cursor_style_block\": \"方塊\",\n      \"cursor_style_underline\": \"底線\",\n      \"cursor_style_bar\": \"直線\"\n    },\n    \"decoder\": {\n      \"name\": \"自定義解碼\",\n      \"new\": \"新增自定義解碼\",\n      \"decoder_name\": \"解碼器名稱\",\n      \"cmd_preview\": \"命令預覽\",\n      \"status\": \"狀態\",\n      \"auto_enabled\": \"已加入自動解碼\",\n      \"help\": \"説明\"\n    }\n  },\n  \"interface\": {\n    \"new_conn\": \"新增連線\",\n    \"new_group\": \"新增群組\",\n    \"disconnect_all\": \"斷開所有連線\",\n    \"status\": \"狀態\",\n    \"filter\": \"篩選\",\n    \"sort_conn\": \"調整連線順序\",\n    \"new_conn_title\": \"新建連線\",\n    \"open_db\": \"開啟資料庫\",\n    \"close_db\": \"關閉資料庫\",\n    \"filter_key\": \"過濾鍵\",\n    \"disconnect\": \"斷開連線\",\n    \"dup_conn\": \"複製連線\",\n    \"remove_conn\": \"移除連線\",\n    \"edit_conn\": \"編輯連線設定\",\n    \"edit_conn_group\": \"編輯群組\",\n    \"rename_conn_group\": \"重新命名群組\",\n    \"remove_conn_group\": \"刪除群組\",\n    \"import_conn\": \"匯入連線...\",\n    \"export_conn\": \"匯出連線...\",\n    \"ttl\": \"TTL\",\n    \"forever\": \"永久\",\n    \"rename_key\": \"重新命名鍵\",\n    \"delete_key\": \"刪除鍵\",\n    \"batch_delete_key\": \"批量刪除鍵\",\n    \"import_key\": \"匯入資料\",\n    \"flush_db\": \"清空資料庫\",\n    \"check_mode\": \"勾選模式\",\n    \"quit_check_mode\": \"退出勾選模式\",\n    \"delete_checked\": \"刪除所選項目\",\n    \"export_checked\": \"匯出所選項目\",\n    \"ttl_checked\": \"為所選項目更新TTL\",\n    \"copy_value\": \"複製值\",\n    \"edit_value\": \"修改值\",\n    \"save_update\": \"儲存修改\",\n    \"score_filter_tip\": \"支援以下運算子比較範圍\\n=：等於\\n!=：不等於\\n>：大於\\n<：小於\\n>=：大於等於\\n<=：小於等於\\n如查詢分數大於3的結果，則輸入：>3\",\n    \"add_row\": \"插入行\",\n    \"edit_row\": \"編輯行\",\n    \"delete_row\": \"刪除行\",\n    \"fullscreen\": \"全屏顯示\",\n    \"offscreen\": \"退出全屏顯示\",\n    \"pin_edit\": \"固定編輯框(儲存後不關閉)\",\n    \"unpin_edit\": \"取消固定\",\n    \"search\": \"搜尋\",\n    \"full_search\": \"全文匹配\",\n    \"full_search_result\": \"內容已匹配為 {pattern}\",\n    \"filter_field\": \"篩選欄位\",\n    \"filter_value\": \"篩選值\",\n    \"length\": \"長度\",\n    \"entries\": \"條目\",\n    \"memory_usage\": \"記憶體使用量\",\n    \"text_align_left\": \"文字靠左\",\n    \"text_align_center\": \"文字置中\",\n    \"view_as\": \"檢視方式\",\n    \"decode_with\": \"解碼/解壓方式\",\n    \"custom_decoder\": \"新增自定義解碼\",\n    \"reload\": \"重新載入\",\n    \"reload_disable\": \"全量加載後可重新載入\",\n    \"auto_refresh\": \"自動重新整理\",\n    \"refresh_interval\": \"重新整理間隔\",\n    \"open_connection\": \"開啟連線\",\n    \"copy_path\": \"複製路徑\",\n    \"copy_key\": \"複製鍵名\",\n    \"save_value_succ\": \"已儲存值\",\n    \"copy_succ\": \"已複製到剪貼簿\",\n    \"binary_key\": \"二進位鍵名\",\n    \"remove_key\": \"移除鍵\",\n    \"new_key\": \"新增鍵\",\n    \"load_more\": \"載入更多鍵\",\n    \"load_all\": \"載入剩餘所有鍵\",\n    \"load_more_entries\": \"載入更多\",\n    \"load_all_entries\": \"載入全部\",\n    \"more_action\": \"更多操作\",\n    \"nonexist_tab_content\": \"所選鍵不存在或未選中任何鍵，請嘗試重新整理後重試\",\n    \"empty_server_content\": \"可以從左邊選擇並開啟連線\",\n    \"empty_server_list\": \"還沒新增Redis伺服器\",\n    \"action\": \"操作\",\n    \"type\": \"類型\",\n    \"cli_welcome\": \"歡迎使用Tiny RDM的Redis命令列控制台\",\n    \"retrieving_version\": \"正在檢索新版本\",\n    \"sub_tab\": {\n      \"status\": \"狀態\",\n      \"key_detail\": \"鍵詳情\",\n      \"cli\": \"命令列\",\n      \"slow_log\": \"慢日誌\",\n      \"cmd_monitor\": \"監控命令\",\n      \"pub_message\": \"發佈/訂閱\"\n    }\n  },\n  \"ribbon\": {\n    \"server\": \"伺服器\",\n    \"browser\": \"資料瀏覽器\",\n    \"log\": \"日誌\",\n    \"wechat_official\": \"微信公眾號\",\n    \"follow_x\": \"關注我的\\uD835\\uDD4F\",\n    \"github\": \"Github\",\n    \"logout\": \"登出\"\n  },\n  \"dialogue\": {\n    \"close_confirm\": \"是否關閉此連線（{name}）\",\n    \"edit_close_confirm\": \"編輯前需要關閉相關連線，是否繼續\",\n    \"opening_connection\": \"正在開啟連線...\",\n    \"interrupt_connection\": \"中斷連線\",\n    \"remove_tip\": \"{type} \\\"{name}\\\"將會被刪除\",\n    \"remove_group_tip\": \"群組 \\\"{name}\\\"及其所有連線將會被刪除\",\n    \"rename_binary_key_fail\": \"不支援重新命名二進位鍵名\",\n    \"handle_succ\": \"操作成功\",\n    \"handle_cancel\": \"操作已取消\",\n    \"reload_succ\": \"已重新載入\",\n    \"field_required\": \"此項不能為空\",\n    \"spec_field_required\": \"{key} 不能為空\",\n    \"illegal_characters\": \"包含非法字元\",\n    \"connection\": {\n      \"new_title\": \"新建連線\",\n      \"edit_title\": \"編輯連線\",\n      \"general\": \"一般設定\",\n      \"no_group\": \"無群組\",\n      \"group\": \"群組\",\n      \"conn_name\": \"連線名\",\n      \"addr\": \"連線位址\",\n      \"usr\": \"使用者名稱\",\n      \"pwd\": \"密碼\",\n      \"name_tip\": \"連線名\",\n      \"addr_tip\": \"Redis伺服器位址\",\n      \"sock_tip\": \"Redis Socket文件\",\n      \"usr_tip\": \"(可選)Redis伺服器授權使用者名稱\",\n      \"pwd_tip\": \"(可選)Redis伺服器授權密碼 (Redis > 6.0)\",\n      \"test\": \"測試連線\",\n      \"test_succ\": \"成功連線到Redis伺服器\",\n      \"test_fail\": \"連線失敗\",\n      \"parse_url_clipboard\": \"解析剪貼簿中的URL\",\n      \"parse_pass\": \"解析Redis URL完成: {url}\",\n      \"parse_fail\": \"解析Redis URL失敗: {reason}\",\n      \"advn\": {\n        \"title\": \"進階設定\",\n        \"filter\": \"預設鍵過濾表示式\",\n        \"filter_tip\": \"需要載入的鍵名表示式\",\n        \"separator\": \"鍵分隔符\",\n        \"separator_tip\": \"鍵名路徑分隔符\",\n        \"conn_timeout\": \"連線逾時\",\n        \"exec_timeout\": \"執行逾時\",\n        \"dbfilter_type\": \"資料庫過濾方式\",\n        \"dbfilter_all\": \"顯示所有\",\n        \"dbfilter_show\": \"顯示指定\",\n        \"dbfilter_hide\": \"隱藏指定\",\n        \"dbfilter_show_title\": \"需要顯示的資料庫\",\n        \"dbfilter_hide_title\": \"需要隱藏的資料庫\",\n        \"dbfilter_input\": \"輸入資料庫索引\",\n        \"dbfilter_input_tip\": \"按Enter確認\",\n        \"key_view\": \"預設鍵檢視\",\n        \"key_view_tree\": \"樹形列表\",\n        \"key_view_list\": \"平鋪列表\",\n        \"load_size\": \"單次載入鍵數量\",\n        \"mark_color\": \"標記顏色\"\n      },\n      \"alias\": {\n        \"title\": \"資料庫別名\",\n        \"db\": \"輸入資料庫索引\",\n        \"value\": \"輸入別名\"\n      },\n      \"ssl\": {\n        \"title\": \"SSL/TLS\",\n        \"enable\": \"啟用SSL\",\n        \"allow_insecure\": \"允許不安全連線\",\n        \"sni\": \"伺服器名(SNI)\",\n        \"sni_tip\": \"(可選)伺服器名\",\n        \"cert_file\": \"公鑰文件\",\n        \"key_file\": \"私鑰文件\",\n        \"ca_file\": \"授權文件\",\n        \"cert_file_tip\": \"PEM格式公鑰文件(Cert)\",\n        \"key_file_tip\": \"PEM格式私鑰文件(Key)\",\n        \"ca_file_tip\": \"PEM格式授權文件(CA)\"\n      },\n      \"ssh\": {\n        \"enable\": \"啟用SSH隧道\",\n        \"title\": \"SSH隧道\",\n        \"login_type\": \"登入類型\",\n        \"agent\": \"SSH代理\",\n        \"pkfile\": \"私鑰文件\",\n        \"passphrase\": \"私鑰密碼\",\n        \"addr_tip\": \"SSH位址\",\n        \"usr_tip\": \"SSH登入使用者名稱\",\n        \"pwd_tip\": \"SSH登入密碼\",\n        \"pkfile_tip\": \"SSH私鑰文件路徑\",\n        \"passphrase_tip\": \"(可選)SSH私鑰密碼\"\n      },\n      \"sentinel\": {\n        \"title\": \"哨兵模式\",\n        \"enable\": \"目前為哨兵節點\",\n        \"master\": \"主節點組名\",\n        \"auto_discover\": \"自動查詢組名\",\n        \"password\": \"主節點密碼\",\n        \"username\": \"主節點使用者名稱\",\n        \"pwd_tip\": \"(可選)主節點伺服器授權密碼 (Redis > 6.0)\",\n        \"usr_tip\": \"(可選)主節點伺服器授權使用者名稱\"\n      },\n      \"cluster\": {\n        \"title\": \"集群模式\",\n        \"enable\": \"目前為集群節點\"\n      },\n      \"proxy\": {\n        \"title\": \"網路代理\",\n        \"type_none\": \"不使用代理\",\n        \"type_system\": \"使用系統代理設定\",\n        \"type_custom\": \"手動設定代理\",\n        \"host\": \"主機名稱\",\n        \"auth\": \"使用身份驗證\",\n        \"usr_tip\": \"代理授權使用者名稱\",\n        \"pwd_tip\": \"代理授權密碼\"\n      }\n    },\n    \"group\": {\n      \"name\": \"群組名稱\",\n      \"rename\": \"重新命名群組\",\n      \"new\": \"新增群組\"\n    },\n    \"key\": {\n      \"new\": \"新增鍵\",\n      \"new_name\": \"新鍵名\",\n      \"server\": \"所屬連線\",\n      \"db_index\": \"資料庫編號\",\n      \"key_expression\": \"鍵名表示式\",\n      \"affected_key\": \"受影響的鍵名\",\n      \"show_affected_key\": \"檢視受影響的鍵名\",\n      \"confirm_delete_key\": \"確認刪除{num}個鍵\",\n      \"direct_delete\": \"直接匹配刪除\",\n      \"confirm_delete\": \"確認刪除\",\n      \"async_delete\": \"異步執行\",\n      \"async_delete_title\": \"不等待操作結果\",\n      \"confirm_flush\": \"我知道我正在執行的操作！\",\n      \"confirm_flush_db\": \"確認清空資料庫\"\n    },\n    \"delete\": {\n      \"success\": \"\\\"{key}\\\" 已被刪除\",\n      \"deleting\": \"正在刪除\",\n      \"doing\": \"正在刪除鍵({index}/{count})\",\n      \"completed\": \"已完成刪除操作，成功{success}個，失敗{fail}個\"\n    },\n    \"field\": {\n      \"new\": \"新增欄位\",\n      \"new_item\": \"新增元素\",\n      \"conflict_handle\": \"欄位衝突處理\",\n      \"overwrite_field\": \"覆蓋\",\n      \"ignore_field\": \"忽略\",\n      \"insert_type\": \"插入類型\",\n      \"append_item\": \"尾部附加\",\n      \"prepend_item\": \"插入頭部\",\n      \"enter_key\": \"輸入鍵名\",\n      \"enter_value\": \"輸入值\",\n      \"enter_field\": \"輸入欄位名\",\n      \"enter_elem\": \"輸入新元素\",\n      \"enter_member\": \"輸入成員\",\n      \"enter_score\": \"輸入分數\",\n      \"element\": \"元素\",\n      \"reload_when_succ\": \"操作成功後立即重新載入\"\n    },\n    \"filter\": {\n      \"set_key_filter\": \"設定鍵過濾器\",\n      \"filter_pattern\": \"過濾表示式\",\n      \"filter_pattern_tip\": \"直接鍵入篩選目前清單，按Enter鍵後可對伺服器進行掃描。\\n\\n*：匹配零個或多個字元。例如：\\\"key*\\\"匹配到以\\\"key\\\"開頭的所有鍵\\n?：匹配單個字元。例如：\\\"key?\\\"匹配\\\"key1\\\", \\\"key2\\\"\\n[ ]：匹配指定範圍內的單個字元。例如：\\\"key[1-3]\\\"可以匹配類似於 \\\"key1\\\", \\\"key2\\\", \\\"key3\\\" 的鍵\\n\\\\：轉義字元。如果想要匹配 *, ?, [, 或]，需要使用反斜線\\\"\\\\\\\"進行轉義\",\n      \"exact_match_tip\": \"精準匹配\",\n      \"filter_type_not_support\": \"類型篩選不支援 Redis 5.x 以下版本\"\n    },\n    \"export\": {\n      \"name\": \"匯出資料\",\n      \"export_expire_title\": \"過期時間\",\n      \"export_expire\": \"同時匯出過期時間\",\n      \"export\": \"確認匯出\",\n      \"save_file\": \"匯出路徑\",\n      \"save_file_tip\": \"選擇匯出文件儲存路徑\",\n      \"exporting\": \"正在匯出鍵({index}/{count})\",\n      \"export_completed\": \"已完成匯出操作，成功{success}個，失敗{fail}個\"\n    },\n    \"import\": {\n      \"name\": \"匯入資料\",\n      \"import_expire_title\": \"過期時間\",\n      \"reload\": \"匯入完成後重新載入\",\n      \"import\": \"確認匯入\",\n      \"open_csv_file\": \"匯入文件路徑\",\n      \"open_csv_file_tip\": \"選擇需要匯入的文件\",\n      \"conflict_handle\": \"鍵衝突處理\",\n      \"conflict_overwrite\": \"覆蓋\",\n      \"conflict_ignore\": \"忽略\",\n      \"ttl_include\": \"嘗試匯入\",\n      \"ttl_ignore\": \"不設定\",\n      \"ttl_custom\": \"自定義\",\n      \"importing\": \"正在匯入資料 已匯入/覆蓋:{imported} 衝突/失敗:{conflict}\",\n      \"import_completed\": \"已完成匯入操作，成功{success}個，忽略{ignored}個\"\n    },\n    \"ttl\": {\n      \"title\": \"設定鍵存活時間\",\n      \"title_batch\": \"批量設定鍵存活時間({count})\",\n      \"quick_set\": \"快速設定\",\n      \"success\": \"已全部更新TTL\"\n    },\n    \"decoder\": {\n      \"name\": \"新增解碼/編碼器\",\n      \"edit_name\": \"編輯解碼/編碼器\",\n      \"new\": \"新增\",\n      \"decoder\": \"解碼器\",\n      \"encoder\": \"編碼器\",\n      \"decoder_name\": \"解碼器名稱\",\n      \"auto\": \"自動解碼\",\n      \"decode_path\": \"解碼器執行路徑\",\n      \"encode_path\": \"編碼器執行路徑\",\n      \"path_help\": \"執行文件路徑，也可以直接填寫命令列介面，如sh/php/python\",\n      \"args\": \"運行參數\",\n      \"args_help\": \"使用[VALUE]代替編碼/解碼內容佔位符，如果不填內容佔位則默認放最後\"\n    },\n    \"upgrade\": {\n      \"title\": \"有可用新版本\",\n      \"new_version_tip\": \"新版本（{ver}），是否立即下載\",\n      \"no_update\": \"目前已是最新版\",\n      \"download_now\": \"立即下載\",\n      \"later\": \"稍後提醒我\",\n      \"skip\": \"忽略本次更新\"\n    },\n    \"welcome\": {\n      \"title\": \"歡迎使用Tiny RDM！\",\n      \"content\": \"為了提供更好的使用者體驗，Tiny RDM 會收集一些匿名的數據，以幫助最佳化軟體和改進使用者體驗，請放心這不會涉及到您的個人隱私資訊。\\n\\n如果您對此有任何顧慮，可以隨時前往\\\"偏好設定\\\"中關閉此項數據收集功能。有任何問題可聯繫開發者，希望Tiny RDM可以成為您的好幫手！\",\n      \"accept\": \"幫助改進\",\n      \"reject\": \"不接受\"\n    },\n    \"about\": {\n      \"source\": \"源碼地址\",\n      \"website\": \"官方網站\"\n    }\n  },\n  \"login\": {\n    \"username_placeholder\": \"請輸入使用者名稱\",\n    \"password_placeholder\": \"請輸入密碼\",\n    \"submit\": \"登入\",\n    \"too_many_attempts\": \"嘗試次數過多，請稍後再試\",\n    \"invalid_credentials\": \"使用者名稱或密碼錯誤\",\n    \"network_error\": \"網路錯誤\"\n  },\n  \"menu\": {\n    \"minimise\": \"最小化\",\n    \"maximise\": \"最大化\",\n    \"restore\": \"還原\",\n    \"close\": \"關閉\",\n    \"preferences\": \"偏好設定\",\n    \"help\": \"説明\",\n    \"user_guide\": \"使用指南\",\n    \"check_update\": \"檢查更新...\",\n    \"report_bug\": \"回報錯誤\",\n    \"about\": \"關於\"\n  },\n  \"log\": {\n    \"title\": \"運行日誌\",\n    \"filter_server\": \"篩選伺服器\",\n    \"filter_keyword\": \"篩選關鍵字\",\n    \"clean_log\": \"清空運行日誌\",\n    \"confirm_clean_log\": \"確定清空運行日誌\",\n    \"exec_time\": \"執行時間\",\n    \"server\": \"伺服器\",\n    \"cmd\": \"命令\",\n    \"cost_time\": \"耗時\",\n    \"refresh\": \"立即重新整理\"\n  },\n  \"status\": {\n    \"uptime\": \"運行時間\",\n    \"connected_clients\": \"已連線客戶端\",\n    \"total_keys\": \"鍵總數\",\n    \"memory_used\": \"記憶體使用量\",\n    \"server_info\": \"狀態資訊\",\n    \"activity_status\": \"活動狀態\",\n    \"act_cmd\": \"命令執行數/秒\",\n    \"act_network_input\": \"網路輸入\",\n    \"act_network_output\": \"網路輸出\",\n    \"client\": {\n      \"title\": \"所有客戶端列表\",\n      \"addr\": \"客戶端位址\",\n      \"age\": \"連線時長(秒)\",\n      \"idle\": \"空閒時長(秒)\",\n      \"db\": \"資料庫\"\n    }\n  },\n  \"slog\": {\n    \"title\": \"慢日誌\",\n    \"limit\": \"條數\",\n    \"filter\": \"篩選\",\n    \"exec_time\": \"執行時間\",\n    \"client\": \"客戶端\",\n    \"cmd\": \"命令\",\n    \"cost_time\": \"耗時\"\n  },\n  \"monitor\": {\n    \"title\": \"監控命令\",\n    \"actions\": \"操作\",\n    \"warning\": \"命令監控可能會造成伺服器阻塞，請謹慎在生產環境的伺服器使用\",\n    \"start\": \"開啟監控\",\n    \"stop\": \"停止監控\",\n    \"search\": \"搜尋\",\n    \"copy_log\": \"複製日誌\",\n    \"save_log\": \"儲存日誌\",\n    \"clean_log\": \"清空日誌\",\n    \"always_show_last\": \"自動捲動到最新\"\n  },\n  \"pubsub\": {\n    \"title\": \"發佈訂閱\",\n    \"publish\": \"發佈\",\n    \"subscribe\": \"開啟訂閱\",\n    \"unsubscribe\": \"取消訂閱\",\n    \"clear\": \"清空訊息\",\n    \"time\": \"時間\",\n    \"filter\": \"篩選\",\n    \"channel\": \"頻道\",\n    \"message\": \"訊息\",\n    \"receive_message\": \"已接收訊息 {total} 條\",\n    \"always_show_last\": \"自動捲動到最新\"\n  }\n}\n"
  },
  {
    "path": "frontend/src/main.js",
    "content": "import { createPinia } from 'pinia'\nimport { createApp, nextTick } from 'vue'\nimport App from './App.vue'\nimport './styles/style.scss'\nimport dayjs from 'dayjs'\nimport duration from 'dayjs/plugin/duration'\nimport relativeTime from 'dayjs/plugin/relativeTime'\nimport { i18n } from '@/utils/i18n.js'\nimport { setupDiscreteApi } from '@/utils/discrete.js'\nimport usePreferencesStore from 'stores/preferences.js'\nimport { loadEnvironment } from '@/utils/platform.js'\nimport { setupMonaco } from '@/utils/monaco.js'\nimport { setupChart } from '@/utils/chart.js'\nimport { isWeb } from './utils/platform.js'\n\ndayjs.extend(duration)\ndayjs.extend(relativeTime)\n\nasync function setupApp() {\n    const app = createApp(App)\n    app.use(i18n)\n    app.use(createPinia())\n\n    await loadEnvironment()\n    setupMonaco()\n    setupChart()\n    const prefStore = usePreferencesStore()\n    if (isWeb()) {\n        await prefStore.loadAppVersion()\n    }\n    await prefStore.loadPreferences()\n    await setupDiscreteApi()\n    app.config.errorHandler = (err, instance, info) => {\n        // TODO: add \"send error message to author\" later\n        nextTick().then(() => {\n            try {\n                const content = err.toString()\n                $notification.error(content, {\n                    title: i18n.global.t('common.error'),\n                    meta: 'Please see console output for more detail',\n                })\n                console.error(err)\n            } catch (e) {}\n        })\n    }\n    // app.config.warnHandler = (message) => {\n    //     console.warn(message)\n    // }\n    app.mount('#app')\n}\n\nsetupApp()\n"
  },
  {
    "path": "frontend/src/objects/redisDatabaseItem.js",
    "content": "/**\n * redis database item\n */\nexport class RedisDatabaseItem {\n    constructor({ db = 0, alias = '', keyCount = 0, maxKeys = 0 }) {\n        this.db = db\n        this.alias = alias\n        this.keyCount = keyCount\n        this.maxKeys = maxKeys\n    }\n}\n"
  },
  {
    "path": "frontend/src/objects/redisNodeItem.js",
    "content": "import { isEmpty, remove, size, sumBy } from 'lodash'\nimport { ConnectionType } from '@/consts/connection_type.js'\n\n/**\n * redis node item in tree view\n */\nexport class RedisNodeItem {\n    /**\n     *\n     * @param {string} key - tree node unique key\n     * @param {string} label\n     * @param {string} [name] - server name, type != ConnectionType.Group only\n     * @param {ConnectionType} type\n     * @param {number} [db] - database index, type == ConnectionType.RedisDB only\n     * @param {string} [redisKey] - redis key, type == ConnectionType.RedisKey || type == ConnectionType.RedisValue only\n     * @param {number[]} [redisKeyCode] - redis key char code array, optional for redis key which contains binary data\n     * @param {number} [keyCount] - children key count\n     * @param {number} [maxKeys] - max key count for database\n     * @param {boolean} [isLeaf]\n     * @param {boolean} [opened] - redis db is opened, type == ConnectionType.RedisDB only\n     * @param {boolean} [expanded] - current node is expanded\n     * @param {RedisNodeItem[]} [children]\n     * @param {string} [redisType] - redis type name, 'loading' indicate that is in loading progress\n     */\n    constructor({\n        key,\n        label,\n        name,\n        type,\n        db = 0,\n        redisKey,\n        redisKeyCode,\n        keyCount = 0,\n        maxKeys = 0,\n        isLeaf = false,\n        opened = false,\n        expanded = false,\n        children,\n        redisType,\n    }) {\n        this.key = key\n        this.label = label\n        this.name = name\n        this.type = type\n        this.db = db\n        this.redisKey = redisKey\n        this.redisKeyCode = redisKeyCode\n        this.keyCount = keyCount\n        this.maxKeys = maxKeys\n        this.isLeaf = isLeaf\n        this.opened = opened\n        this.expanded = expanded\n        this.children = children\n        this.redisType = redisType\n    }\n\n    /**\n     * sort node list\n     * @param {RedisNodeItem[]} nodeList\n     * @private\n     */\n    _sortNodes(nodeList) {\n        if (nodeList == null) {\n            return\n        }\n        nodeList.sort((a, b) => {\n            return a.key > b.key ? 1 : -1\n        })\n    }\n\n    /**\n     * compare two items to determine the sort order\n     * @param {*} a\n     * @param {*} b\n     * @return {number}\n     */\n    _sortingCompare(a, b) {\n        if (a.type !== b.type) {\n            return a.type - b.type\n        }\n        const isANum = isNaN(a.label)\n        const isBNum = isNaN(b.label)\n        if (!isANum && !isBNum) {\n            return parseInt(a.label, 10) - parseInt(b.label, 10)\n        } else if (!isANum) {\n            return -1\n        } else if (!isBNum) {\n            return 1\n        }\n        return a.label.localeCompare(b.label)\n    }\n\n    /**\n     * calculate insert sorted index\n     * @param {[]} arr\n     * @param {*} item\n     * @return {number}\n     */\n    _sortedIndex(arr, item) {\n        for (let i = 0; i < arr.length; i++) {\n            const cmpResult = this._sortingCompare(arr[i], item)\n            if (cmpResult > 0) {\n                return i\n            } else if (cmpResult === 0) {\n                return i + 1\n            }\n        }\n        return arr.length\n    }\n\n    /**\n     * sort all node item's children and calculate keys count\n     * @param skipSort skip sorting children\n     * @returns {boolean} return whether key count changed\n     */\n    tidy(skipSort) {\n        if (this.type === ConnectionType.RedisValue) {\n            this.keyCount = 1\n        } else if (this.type === ConnectionType.RedisKey || this.type === ConnectionType.RedisDB) {\n            let keyCount = 0\n            if (!isEmpty(this.children)) {\n                if (!!!skipSort) {\n                    this.sortChildren()\n                }\n                for (const child of this.children) {\n                    child.tidy(skipSort)\n                    keyCount += child.keyCount\n                }\n            } else {\n                keyCount = 0\n            }\n            if (this.keyCount !== keyCount) {\n                this.keyCount = keyCount\n                return true\n            }\n        }\n        return false\n    }\n\n    sortChildren() {\n        this.children.sort(this._sortingCompare)\n    }\n\n    /**\n     *\n     * @param {RedisNodeItem} child\n     * @param {boolean} [sorted]\n     */\n    addChild(child, sorted) {\n        if (!!!sorted) {\n            this.children.push(child)\n        } else {\n            const idx = this._sortedIndex(this.children, child)\n            this.children.splice(idx, 0, child)\n        }\n    }\n\n    /**\n     *\n     * @param {{}} predicate\n     * @return {number}\n     */\n    removeChild(predicate) {\n        if (this.type !== ConnectionType.RedisKey && this.type !== ConnectionType.RedisDB) {\n            return 0\n        }\n        const removed = remove(this.children, predicate)\n        return size(removed)\n    }\n\n    getChildren() {\n        return this.children\n    }\n\n    reCalcKeyCount() {\n        if (this.type === ConnectionType.RedisValue) {\n            this.keyCount = 1\n        }\n        this.keyCount = sumBy(this.children, (c) => c.keyCount)\n        return this.keyCount\n    }\n}\n"
  },
  {
    "path": "frontend/src/objects/redisServerState.js",
    "content": "import { get, initial, isEmpty, join, last, mapValues, size, slice, sortBy, split, toUpper } from 'lodash'\nimport useConnectionStore from 'stores/connections.js'\nimport { ConnectionType } from '@/consts/connection_type.js'\nimport { RedisDatabaseItem } from '@/objects/redisDatabaseItem.js'\nimport { KeyViewType } from '@/consts/key_view_type.js'\nimport { RedisNodeItem } from '@/objects/redisNodeItem.js'\nimport { decodeRedisKey, nativeRedisKey } from '@/utils/key_convert.js'\n\n/**\n * server connection state\n */\nexport class RedisServerState {\n    /**\n     * @typedef {Object} LoadingState\n     * @property {boolean} loading indicated that is loading children now\n     * @property {boolean} fullLoaded indicated that all children already loaded\n     */\n\n    /**\n     * @param {string} name server name\n     * @param {number} db current opened database\n     * @param {number} reloadKey try to reload when changed\n     * @param {{}} stats current server status info\n     * @param {Object.<number, RedisDatabaseItem>} databases database list\n     * @param {string|null} patternFilter pattern filter\n     * @param {string|null} typeFilter redis type filter\n     * @param {boolean} exactFilter exact match filter keyword\n     * @param {LoadingState} loadingState all loading state in opened connections map by server and LoadingState\n     * @param {KeyViewType} viewType view type selection for all opened connections group by 'server'\n     * @param {Map<string, RedisNodeItem>} nodeMap map nodes by \"type#key\"\n     * @param {string} version redis server version\n     */\n    constructor({\n        name,\n        db = 0,\n        stats = {},\n        databases = {},\n        patternFilter = null,\n        typeFilter = null,\n        exactFilter = false,\n        loadingState = {},\n        viewType = KeyViewType.Tree,\n        nodeMap = new Map(),\n        version = '',\n    }) {\n        this.name = name\n        this.db = db\n        this.reloadKey = Date.now()\n        this.stats = stats\n        this.databases = databases\n        this.patternFilter = patternFilter\n        this.typeFilter = typeFilter\n        this.exactFilter = exactFilter\n        this.loadingState = loadingState\n        this.viewType = viewType\n        this.nodeMap = nodeMap\n        this.version = version\n        this.decodeHistory = new Map()\n        this.decodeHistoryLimit = 100\n        this.getRoot()\n\n        const connStore = useConnectionStore()\n        const keySeparator = connStore.getDefaultSeparator(name)\n        this.separator = isEmpty(keySeparator) ? ':' : keySeparator\n    }\n\n    dispose() {\n        this.stats = {}\n        this.patternFilter = null\n        this.typeFilter = null\n        this.exactFilter = false\n        this.nodeMap.clear()\n    }\n\n    closeDatabase() {\n        this.patternFilter = null\n        this.typeFilter = null\n        this.nodeMap.clear()\n    }\n\n    setDatabaseKeyCount(db, maxKeys) {\n        const dbInst = this.databases[db]\n        if (dbInst == null) {\n            this.databases[db] = new RedisDatabaseItem({ db, maxKeys })\n        } else {\n            dbInst.maxKeys = maxKeys\n        }\n        return dbInst\n    }\n\n    /**\n     * update max key by increase/decrease value\n     * @param {number} db\n     * @param {number} updateVal\n     */\n    updateDBKeyCount(db, updateVal) {\n        const dbInst = this.databases[db]\n        if (dbInst != null) {\n            dbInst.maxKeys = Math.max(0, dbInst.maxKeys + updateVal)\n        }\n    }\n\n    /**\n     * set db max keys value\n     * @param {number} db\n     * @param {number} count\n     */\n    setDBKeyCount(db, count) {\n        const dbInst = this.databases[db]\n        if (dbInst != null) {\n            dbInst.maxKeys = Math.max(0, count)\n        }\n    }\n\n    /**\n     * get tree root item\n     * @returns {RedisNodeItem}\n     */\n    getRoot() {\n        const rootKey = `${ConnectionType.RedisDB}`\n        let root = this.nodeMap.get(rootKey)\n        if (root == null) {\n            // create root node\n            root = new RedisNodeItem({\n                key: rootKey,\n                label: `db${this.db}`,\n                type: ConnectionType.RedisDB,\n                children: [],\n            })\n            this.nodeMap.set(rootKey, root)\n        }\n        return root\n    }\n\n    /**\n     * get database list sort by db asc\n     * @return {RedisDatabaseItem[]}\n     */\n    getDatabase() {\n        return sortBy(mapValues(this.databases), 'db')\n    }\n\n    /**\n     *\n     * @param {ConnectionType} type\n     * @param {string} keyPath\n     * @param {RedisNodeItem} node\n     */\n    addNode(type, keyPath, node) {\n        this.nodeMap.set(`${type}/${keyPath}`, node)\n    }\n\n    /**\n     * add keys to current opened database\n     * @param {Array<string|number[]>|Set<string|number[]>} keys\n     * @param {boolean} [sortInsert]\n     * @return {{newKey: number, newLayer: number, success: boolean, replaceKey: number}}\n     */\n    addKeyNodes(keys, sortInsert) {\n        const result = {\n            success: false,\n            newLayer: 0,\n            newKey: 0,\n            replaceKey: 0,\n        }\n        const root = this.getRoot()\n\n        if (this.viewType === KeyViewType.List) {\n            // construct list view data\n            for (const key of keys) {\n                const k = decodeRedisKey(key)\n                const isBinaryKey = k !== key\n                const nodeKey = `${ConnectionType.RedisValue}/${nativeRedisKey(key)}`\n                const replaceKey = this.nodeMap.has(nodeKey)\n                const selectedNode = new RedisNodeItem({\n                    key: `${this.name}/db${this.db}#${nodeKey}`,\n                    label: k,\n                    db: this.db,\n                    keyCount: 0,\n                    redisKey: k,\n                    redisKeyCode: isBinaryKey ? key : undefined,\n                    redisKeyType: undefined,\n                    type: ConnectionType.RedisValue,\n                    isLeaf: true,\n                })\n                this.nodeMap.set(nodeKey, selectedNode)\n                if (!replaceKey) {\n                    root.addChild(selectedNode, sortInsert)\n                    result.newKey += 1\n                } else {\n                    result.replaceKey += 1\n                }\n            }\n        } else {\n            // construct tree view data\n            for (const key of keys) {\n                const k = decodeRedisKey(key)\n                const isBinaryKey = k !== key\n                const keyParts = isBinaryKey ? [nativeRedisKey(key)] : split(k, this.separator)\n                const len = size(keyParts)\n                const lastIdx = len - 1\n                let handlePath = ''\n                let node = root\n                for (let i = 0; i < len; i++) {\n                    handlePath += keyParts[i]\n                    if (i !== lastIdx) {\n                        // layer\n                        const nodeKey = `${ConnectionType.RedisKey}/${handlePath}`\n                        let selectedNode = this.nodeMap.get(nodeKey)\n                        if (selectedNode == null) {\n                            selectedNode = new RedisNodeItem({\n                                key: `${this.name}/db${this.db}#${nodeKey}`,\n                                label: keyParts[i],\n                                db: this.db,\n                                keyCount: 0,\n                                redisKey: handlePath,\n                                type: ConnectionType.RedisKey,\n                                isLeaf: false,\n                                children: [],\n                            })\n                            this.nodeMap.set(nodeKey, selectedNode)\n                            node.addChild(selectedNode, sortInsert)\n                            result.newLayer += 1\n                        }\n                        node = selectedNode\n                        handlePath += this.separator\n                    } else {\n                        // key\n                        const nodeKey = `${ConnectionType.RedisValue}/${handlePath}`\n                        const replaceKey = this.nodeMap.has(nodeKey)\n                        const selectedNode = new RedisNodeItem({\n                            key: `${this.name}/db${this.db}#${nodeKey}`,\n                            label: isBinaryKey ? k : keyParts[i],\n                            db: this.db,\n                            keyCount: 0,\n                            redisKey: handlePath,\n                            redisKeyCode: isBinaryKey ? key : undefined,\n                            redisKeyType: undefined,\n                            type: ConnectionType.RedisValue,\n                            isLeaf: true,\n                        })\n                        this.nodeMap.set(nodeKey, selectedNode)\n                        if (!replaceKey) {\n                            node.addChild(selectedNode, sortInsert)\n                            result.newKey += 1\n                        } else {\n                            result.replaceKey += 1\n                        }\n                    }\n                }\n            }\n        }\n        return result\n    }\n\n    /**\n     * rename key to a new name\n     * @param {string} key\n     * @param {string} newKey\n     */\n    renameKey(key, newKey) {\n        const oldLayer = initial(key.split(this.separator)).join(this.separator)\n        const newLayer = initial(newKey.split(this.separator)).join(this.separator)\n        if (oldLayer !== newLayer) {\n            // also change layer\n            this.removeKeyNode(key, false)\n            const { success } = this.addKeyNodes([newKey], true)\n            if (success) {\n                this.tidyNode(newLayer)\n            }\n        } else {\n            // change key name only\n            const oldNodeKeyName = `${ConnectionType.RedisValue}/${key}`\n            const newNodeKeyName = `${ConnectionType.RedisValue}/${newKey}`\n            const keyNode = this.nodeMap.get(oldNodeKeyName)\n            keyNode.key = `${this.name}/db${this.db}#${newNodeKeyName}`\n            if (this.viewType === KeyViewType.Tree) {\n                keyNode.label = last(split(newKey, this.separator))\n            } else {\n                keyNode.label = newKey\n            }\n            keyNode.redisKey = newKey\n            // not support rename binary key name yet\n            // keyNode.redisKeyCode = []\n            this.nodeMap.set(newNodeKeyName, keyNode)\n            this.nodeMap.delete(oldNodeKeyName)\n        }\n    }\n\n    /**\n     * remove key node by key name\n     * @param {string} [key]\n     * @param {boolean} [isLayer]\n     * @return {boolean}\n     */\n    removeKeyNode(key, isLayer) {\n        if (isLayer === true) {\n            this.deleteChildrenKeyNodes(key)\n        } else {\n            const nodeKey = `${ConnectionType.RedisValue}/${key}`\n            this.nodeMap.delete(nodeKey)\n        }\n\n        const dbRoot = this.getRoot()\n        if (isEmpty(key)) {\n            // clear all key nodes\n            this.nodeMap.clear()\n            this.getRoot()\n            const dbInst = this.databases[this.db]\n            if (dbInst != null) {\n                dbInst.maxKeys = 0\n                dbInst.keyCount = 0\n            }\n        } else {\n            const keyParts = split(key, this.separator)\n            const totalParts = size(keyParts)\n            // remove from parent in tree node\n            const parentKey = slice(keyParts, 0, totalParts - 1)\n            let parentNode\n            if (isEmpty(parentKey)) {\n                parentNode = dbRoot\n            } else {\n                parentNode = this.nodeMap.get(`${ConnectionType.RedisKey}/${join(parentKey, this.separator)}`)\n            }\n\n            // not found parent node\n            if (parentNode == null) {\n                return false\n            }\n            parentNode.removeChild({\n                type: isLayer ? ConnectionType.RedisKey : ConnectionType.RedisValue,\n                redisKey: key,\n            })\n\n            // // check and remove empty layer node\n            // let i = totalParts - 1\n            // for (; i >= 0; i--) {\n            //     const anceKey = join(slice(keyParts, 0, i), this.separator)\n            //     if (i > 0) {\n            //         const anceNode = this.nodeMap.get(`${ConnectionType.RedisKey}/${anceKey}`)\n            //         const redisKey = join(slice(keyParts, 0, i + 1), this.separator)\n            //         anceNode.removeChild({ type: ConnectionType.RedisKey, redisKey })\n            //\n            //         if (isEmpty(anceNode.children)) {\n            //             this.nodeMap.delete(`${ConnectionType.RedisKey}/${anceKey}`)\n            //         } else {\n            //             break\n            //         }\n            //     } else {\n            //         // last one, remove from db node\n            //         dbRoot.removeChild({ type: ConnectionType.RedisKey, redisKey: keyParts[0] })\n            //         this.nodeMap.delete(`${ConnectionType.RedisValue}/${keyParts[0]}`)\n            //     }\n            // }\n        }\n\n        return true\n    }\n\n    /**\n     * tidy node by key\n     * @param {string} [key]\n     * @param {boolean} [skipResort]\n     * @return\n     */\n    tidyNode(key, skipResort) {\n        const rootNode = this.getRoot()\n        const keyParts = split(key, this.separator)\n        const totalParts = size(keyParts)\n        let node\n        // find last exists ancestor key\n        let i = totalParts - 1\n        for (; i > 0; i--) {\n            const parentKey = join(slice(keyParts, 0, i), this.separator)\n            node = this.nodeMap.get(`${ConnectionType.RedisKey}/${parentKey}`)\n            if (node != null) {\n                break\n            }\n        }\n        if (node == null) {\n            node = rootNode\n        }\n        const keyCountUpdated = node.tidy(skipResort)\n        if (keyCountUpdated) {\n            // update key count of parent and above\n            for (; i > 0; i--) {\n                const parentKey = join(slice(keyParts, 0, i), this.separator)\n                const parentNode = this.nodeMap.get(`${ConnectionType.RedisKey}/${parentKey}`)\n                if (parentNode == null) {\n                    break\n                }\n                const count = parentNode.reCalcKeyCount()\n                if (count <= 0) {\n                    let anceKeyNode = rootNode\n                    // remove from ancestor node\n                    if (i > 1) {\n                        const anceKey = join(slice(keyParts, 0, i - 1), this.separator)\n                        anceKeyNode = this.nodeMap.get(`${ConnectionType.RedisKey}/${anceKey}`)\n                    }\n                    if (anceKeyNode != null) {\n                        anceKeyNode.removeChild({ type: ConnectionType.RedisKey, redisKey: parentKey })\n                    }\n                }\n            }\n            // update key count of db\n            const dbInst = this.databases[this.db]\n            if (dbInst != null) {\n                dbInst.keyCount = rootNode.reCalcKeyCount()\n            }\n        }\n    }\n\n    /**\n     * add keys to current opened database\n     * @param {ConnectionType} type\n     * @param {string} keyPath\n     * @return {RedisNodeItem|null}\n     */\n    getNode(type, keyPath) {\n        return this.nodeMap.get(`${type}/${keyPath}`) || null\n    }\n\n    /**\n     * delete node and all it's children from nodeMap\n     * @param {string} [key] clean nodeMap if key is empty\n     * @private\n     */\n    deleteChildrenKeyNodes(key) {\n        if (isEmpty(key)) {\n            this.nodeMap.clear()\n            this.getRoot()\n        } else {\n            const nodeKey = `${ConnectionType.RedisKey}/${key}`\n            const node = this.nodeMap.get(nodeKey)\n            const children = node.children || []\n            for (const child of children) {\n                if (child.type === ConnectionType.RedisValue) {\n                    if (!this.nodeMap.delete(`${ConnectionType.RedisValue}/${child.redisKey}`)) {\n                        console.warn('delete:', `${ConnectionType.RedisValue}/${child.redisKey}`)\n                    }\n                } else if (child.type === ConnectionType.RedisKey) {\n                    this.deleteChildrenKeyNodes(child.redisKey)\n                }\n            }\n            if (!this.nodeMap.delete(nodeKey)) {\n                console.warn('delete map key', nodeKey)\n            }\n        }\n    }\n\n    getFilter() {\n        let pattern = this.patternFilter\n        if (isEmpty(pattern)) {\n            const conn = useConnectionStore()\n            pattern = conn.getDefaultKeyFilter(this.name)\n        }\n        return {\n            match: pattern,\n            type: toUpper(this.typeFilter),\n            exact: this.exactFilter === true,\n        }\n    }\n\n    /**\n     * set key filter\n     * @param {string} [pattern]\n     * @param {string} [type]\n     * @param {boolean} [exact]\n     */\n    setFilter({ pattern, type, exact = false }) {\n        this.patternFilter = pattern === null ? this.patternFilter : pattern\n        this.typeFilter = type === null ? this.typeFilter : type\n        this.exactFilter = exact === true\n    }\n\n    /**\n     * add manually selected decode type to history\n     * @param {string} key\n     * @param {number} db\n     * @param {string} format\n     * @param {string} decode\n     */\n    addDecodeHistory(key, db, format = '', decode = '') {\n        const decodeKey = `${key}#${db}`\n        this.decodeHistory.delete(decodeKey)\n        if (isEmpty(format) && isEmpty(decode)) {\n            // reset to default, remove from history\n            return\n        }\n\n        this.decodeHistory.set(decodeKey, [format, decode])\n        while (this.decodeHistory.size > this.decodeHistoryLimit) {\n            const k = this.decodeHistory.keys().next().value\n            this.decodeHistory.delete(k)\n        }\n    }\n\n    /**\n     * get manually selected decode type from history\n     * @param {string|number[]} key\n     * @param {number} db\n     * @return {[]}\n     */\n    getDecodeHistory(key, db) {\n        const h = this.decodeHistory.get(`${nativeRedisKey(key)}#${db}`) || []\n        return [get(h, 0, ''), get(h, 1, '')]\n    }\n}\n"
  },
  {
    "path": "frontend/src/objects/tabItem.js",
    "content": "/**\n * tab item\n */\nexport class TabItem {\n    /**\n     * @typedef {Object} CheckedKey\n     * @property {string} key\n     * @property {string} [redisKey]\n     */\n\n    /**\n     *\n     * @param {string} name connection name\n     * @param {string} title tab title\n     * @param {boolean} blank is blank tab\n     * @param {string} subTab secondary tab value\n     * @param {string} [title] tab title\n     * @param {string} [icon] tab icon\n     * @param {string} [activatedKey] current activated key on displaying\n     * @param {string[]} expandedKeys\n     * @param {string[]} selectedKeys\n     * @param {CheckedKey[]} checkedKeys\n     * @param {string} [type] key type\n     * @param {*} [value] key value\n     * @param {string} [server] server name\n     * @param {int} [db] database index\n     * @param {string} [key] current key name\n     * @param {number[]|null|undefined} [keyCode] current key name as char array\n     * @param {number} [size] memory usage\n     * @param {number} [length] length of content or entries\n     * @param {int} [ttl] ttl of current key\n     * @param {string} [decode]\n     * @param {string} [format]\n     * @param {string} [matchPattern]\n     * @param {boolean} [end]\n     * @param {boolean} [loading]\n     */\n    constructor({\n        name,\n        title,\n        blank,\n        subTab,\n        icon,\n        expandedKeys = [],\n        selectedKeys = [],\n        checkedKeys = [],\n        type,\n        value,\n        server,\n        db = 0,\n        key,\n        keyCode,\n        size = 0,\n        length = 0,\n        ttl = 0,\n        decode = '',\n        format = '',\n        matchPattern = '',\n        end = false,\n        loading = false,\n    }) {\n        this.name = name\n        this.title = title\n        this.blank = blank\n        this.subTab = subTab\n        this.icon = icon\n        this.activatedKey = ''\n        this.expandedKeys = expandedKeys\n        this.selectedKeys = selectedKeys\n        this.checkedKeys = checkedKeys\n        this.type = type\n        this.value = value\n        this.server = server\n        this.db = db\n        this.key = key\n        this.keyCode = keyCode\n        this.size = size\n        this.length = length\n        this.ttl = ttl\n        this.decode = decode\n        this.format = format\n        this.matchPattern = matchPattern\n        this.end = end\n        this.loading = loading\n    }\n}\n"
  },
  {
    "path": "frontend/src/stores/browser.js",
    "content": "import { defineStore } from 'pinia'\nimport { endsWith, get, isEmpty, join, map, now, size, slice, split, startsWith } from 'lodash'\nimport {\n    AddHashField,\n    AddListItem,\n    AddStreamValue,\n    AddZSetValue,\n    BatchSetTTL,\n    CleanCmdHistory,\n    CloseConnection,\n    ConvertValue,\n    DeleteKey,\n    DeleteKeys,\n    DeleteKeysByPattern,\n    ExportKey,\n    FlushDB,\n    GetClientList,\n    GetCmdHistory,\n    GetHashValue,\n    GetKeyDetail,\n    GetKeySummary,\n    GetKeyType,\n    GetSlowLogs,\n    ImportCSV,\n    LoadAllKeys,\n    LoadNextAllKeys,\n    LoadNextKeys,\n    OpenConnection,\n    OpenDatabase,\n    RemoveStreamValues,\n    RenameKey,\n    ServerInfo,\n    SetHashValue,\n    SetKeyTTL,\n    SetKeyValue,\n    SetListItem,\n    SetSetItem,\n    UpdateSetItem,\n    UpdateZSetValue,\n} from 'wailsjs/go/services/browserService.js'\nimport useTabStore from 'stores/tab.js'\nimport { nativeRedisKey } from '@/utils/key_convert.js'\nimport { BrowserTabType } from '@/consts/browser_tab_type.js'\nimport { KeyViewType } from '@/consts/key_view_type.js'\nimport { ConnectionType } from '@/consts/connection_type.js'\nimport useConnectionStore from 'stores/connections.js'\nimport { decodeTypes, formatTypes } from '@/consts/value_view_type.js'\nimport { isRedisGlob } from '@/utils/glob_pattern.js'\nimport { i18nGlobal } from '@/utils/i18n.js'\nimport { EventsEmit, EventsOn } from 'wailsjs/runtime/runtime.js'\nimport { RedisNodeItem } from '@/objects/redisNodeItem.js'\nimport { RedisServerState } from '@/objects/redisServerState.js'\nimport { RedisDatabaseItem } from '@/objects/redisDatabaseItem.js'\nimport { timeout } from '@/utils/promise.js'\n\nconst useBrowserStore = defineStore('browser', {\n    /**\n     * @typedef {Object} FilterItem\n     * @property {string} pattern key pattern filter\n     * @property {string} type type filter\n     */\n\n    /**\n     * @typedef {Object} HistoryItem\n     * @property {string} time\n     * @property {string} server\n     * @property {string} cmd\n     * @property {number} cost\n     */\n\n    /**\n     * @typedef {Object} BrowserState\n     * @property {Object.<string, RedisServerState>} servers\n     */\n\n    /**\n     *\n     * @returns {BrowserState}\n     */\n    state: () => ({\n        servers: {},\n    }),\n    getters: {\n        anyConnectionOpened() {\n            return !isEmpty(this.servers)\n        },\n    },\n    actions: {\n        /**\n         * check if connection is connected\n         * @param name\n         * @returns {boolean}\n         */\n        isConnected(name) {\n            return this.servers.hasOwnProperty(name)\n        },\n\n        /**\n         * close all connections\n         * @returns {Promise<void>}\n         */\n        async closeAllConnection() {\n            for (const serverName in this.servers) {\n                await CloseConnection(serverName)\n                this.servers[serverName].dispose()\n            }\n\n            const tabStore = useTabStore()\n            tabStore.removeAllTab()\n        },\n\n        /**\n         * get database info list\n         * @param {string} server\n         * @return {RedisDatabaseItem[]}\n         */\n        getDBList(server) {\n            const serverInst = this.servers[server]\n            if (serverInst != null) {\n                return serverInst.getDatabase()\n            }\n            return []\n        },\n\n        /**\n         * get server version\n         * @param {string} server\n         */\n        getServerVersion(server) {\n            const serverInst = this.servers[server]\n            if (serverInst != null) {\n                return serverInst.version\n            }\n            return '1.0.0'\n        },\n\n        /**\n         * get database by server name and database index\n         * @param {string} server\n         * @param {number} db\n         * @return {RedisDatabaseItem|null}\n         */\n        getDatabase(server, db) {\n            /** @type {RedisServerState} **/\n            const serverInst = this.servers[server]\n            if (serverInst != null) {\n                return serverInst.databases[db] || null\n            }\n            return null\n        },\n\n        /**\n         * get current selection database by server\n         * @param server\n         * @return {number}\n         */\n        getSelectedDB(server) {\n            /** @type {RedisServerState} **/\n            const serverInst = this.servers[server]\n            if (serverInst != null) {\n                return serverInst.db\n            }\n            return 0\n        },\n\n        /**\n         * get key struct in current database\n         * @param {string} server\n         * @param {boolean} [includeRoot]\n         * @return {RedisNodeItem[]}\n         */\n        getKeyStruct(server, includeRoot) {\n            /** @type {RedisServerState} **/\n            const serverInst = this.servers[server]\n            let rootNode = null\n            if (serverInst != null) {\n                rootNode = serverInst.getRoot()\n            }\n            if (includeRoot === true) {\n                return [rootNode]\n            }\n            return get(rootNode, 'children', [])\n        },\n\n        getReloadKey(server) {\n            /** @type {RedisServerState} **/\n            const serverInst = this.servers[server]\n            return serverInst != null ? serverInst.reloadKey : 0\n        },\n\n        reloadServer(server) {\n            /** @type {RedisServerState} **/\n            const serverInst = this.servers[server]\n            if (serverInst != null) {\n                serverInst.reloadKey = Date.now()\n            }\n        },\n\n        /**\n         * switch key view\n         * @param {string} connName\n         * @param {number} viewType\n         */\n        // async switchKeyView(connName, viewType) {\n        //     if (viewType !== KeyViewType.Tree && viewType !== KeyViewType.List) {\n        //         return\n        //     }\n        //\n        //     const t = get(this.viewType, connName, KeyViewType.Tree)\n        //     if (t === viewType) {\n        //         return\n        //     }\n        //\n        //     this.viewType[connName] = viewType\n        //     const dbs = get(this.databases, connName, [])\n        //     for (const dbItem of dbs) {\n        //         if (!dbItem.opened) {\n        //             continue\n        //         }\n        //\n        //         dbItem.children = undefined\n        //         dbItem.keyCount = 0\n        //         const { db = 0 } = dbItem\n        //         this._getNodeMap(connName, db).clear()\n        //         this._addKeyNodes(connName, db, keys)\n        //         this._tidyNode(connName, db, '')\n        //     }\n        // },\n\n        /**\n         * open connection\n         * @param {string} name\n         * @param {boolean} [reload]\n         * @returns {Promise<void>}\n         */\n        async openConnection(name, reload) {\n            if (this.isConnected(name)) {\n                if (reload !== true) {\n                    return\n                } else {\n                    // reload mode, try close connection first\n                    await CloseConnection(name)\n                }\n            }\n\n            const { data, success, msg } = await OpenConnection(name)\n            if (!success) {\n                throw new Error(msg)\n            }\n            // append to db node to current connection\n            // const connNode = this.getConnection(name)\n            // if (connNode == null) {\n            //     throw new Error('no such connection')\n            // }\n            const { db, view = KeyViewType.Tree, lastDB, version } = data\n            if (isEmpty(db)) {\n                throw new Error('no db loaded')\n            }\n            const serverInst = new RedisServerState({\n                name,\n                separator: this.getSeparator(name),\n                db: -1,\n                viewType: view,\n                version,\n            })\n            /** @type {Object.<number,RedisDatabaseItem>} **/\n            const databases = {}\n            for (const dbItem of db) {\n                databases[dbItem.index] = new RedisDatabaseItem({\n                    db: dbItem.index,\n                    alias: dbItem.alias,\n                    maxKeys: dbItem.maxKeys,\n                })\n                if (dbItem.index === lastDB) {\n                    // set last opened database as default\n                    serverInst.db = dbItem.index\n                } else if (serverInst.db === -1) {\n                    // set the first database as default\n                    serverInst.db = dbItem.index\n                }\n            }\n            serverInst.databases = databases\n            this.servers[name] = serverInst\n        },\n\n        /**\n         * close connection\n         * @param {string} name\n         * @returns {Promise<boolean>}\n         */\n        async closeConnection(name) {\n            const { success, msg } = await CloseConnection(name)\n            if (!success) {\n                // throw new Error(msg)\n                return false\n            }\n            delete this.servers[name]\n\n            const tabStore = useTabStore()\n            tabStore.removeTabByName(name)\n            return true\n        },\n\n        /**\n         * open database and load all keys\n         * @param server\n         * @param db\n         * @returns {Promise<void>}\n         */\n        async openDatabase(server, db) {\n            const { data, success, msg } = await OpenDatabase(server, db)\n            if (!success) {\n                throw new Error(msg)\n            }\n            const { keys = [], end = false, maxKeys = 0 } = data\n\n            /** @type {RedisServerState} **/\n            const serverInst = this.servers[server]\n            if (serverInst == null) {\n                return\n            }\n            serverInst.db = db\n            serverInst.setDatabaseKeyCount(db, maxKeys)\n            serverInst.loadingState.fullLoaded = end\n\n            if (isEmpty(keys)) {\n                serverInst.nodeMap.clear()\n            } else {\n                // append db node to current connection's children\n                serverInst.addKeyNodes(keys)\n            }\n            serverInst.tidyNode('', false)\n        },\n\n        /**\n         * close database\n         * @param server\n         * @param db\n         */\n        closeDatabase(server, db) {\n            /** @type {RedisServerState} **/\n            const serverInst = this.servers[server]\n            if (serverInst == null) {\n                return\n            }\n            if (serverInst.db !== db) {\n                return\n            }\n            serverInst.closeDatabase()\n\n            /** @type {RedisDatabaseItem} **/\n            const selDB = this.getDatabase(server, db)\n            if (selDB == null) {\n                return\n            }\n            selDB.keyCount = 0\n        },\n\n        /**\n         *\n         * @param {string} server\n         * @param {boolean} mute\n         * @returns {Promise<{}>}\n         */\n        async getServerInfo(server, mute) {\n            try {\n                const { success, data, msg } = await ServerInfo(server)\n                if (success) {\n                    /** @type {RedisServerState} **/\n                    const serverInst = this.servers[server]\n                    if (serverInst != null) {\n                        serverInst.stats = data\n                    }\n                    return data\n                } else if (!isEmpty(msg) && mute !== true) {\n                    $message.warning(msg)\n                }\n            } finally {\n            }\n            return {}\n        },\n\n        /**\n         * load key summary info\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number[]} [key] null or blank indicate that update tab to display normal content (blank content or server status)\n         * @param {boolean} [clearValue]\n         * @param {boolean} [redirect] redirect to key detail tab\n         * @return {Promise<void>}\n         */\n        async loadKeySummary({ server, db, key, clearValue, redirect = true }) {\n            try {\n                const tab = useTabStore()\n                if (!isEmpty(key)) {\n                    const { data, success, msg } = await GetKeySummary({\n                        server,\n                        db,\n                        key,\n                    })\n                    if (success) {\n                        const { type, ttl, size, length } = data\n                        const k = nativeRedisKey(key)\n                        const binaryKey = k !== key\n                        tab.upsertTab({\n                            subTab: redirect === false ? null : BrowserTabType.KeyDetail,\n                            server,\n                            db,\n                            type,\n                            ttl,\n                            keyCode: binaryKey ? key : undefined,\n                            key: k,\n                            size,\n                            length,\n                            clearValue,\n                        })\n                        return\n                    } else {\n                        if (!isEmpty(msg)) {\n                            $message.error('load key summary fail: ' + msg)\n                        }\n                        // its danger to delete \"non-exists\" key, just remove from tree view\n                        // await this.deleteKey(server, db, key, true)\n                        // TODO: show key not found page or check exists on server first?\n                    }\n                }\n\n                tab.upsertTab({\n                    subTab: BrowserTabType.Status,\n                    server,\n                    db,\n                    type: 'none',\n                    ttl: -1,\n                    key: null,\n                    keyCode: null,\n                    size: 0,\n                    length: 0,\n                    clearValue,\n                })\n            } catch (e) {\n                $message.error(e.message || 'unknown error')\n            } finally {\n            }\n        },\n\n        /**\n         * load key type\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number[]} key\n         * @param {number[]} keyCode\n         * @return {Promise<void>}\n         */\n        async loadKeyType({ server, db, key }) {\n            /** @type {RedisServerState} **/\n            const serverInst = this.servers[server]\n            if (serverInst == null) {\n                return\n            }\n            const node = serverInst.getNode(ConnectionType.RedisValue, nativeRedisKey(key))\n            if (node == null || !isEmpty(node.redisType)) {\n                return\n            }\n            try {\n                node.redisType = 'loading'\n                const { data, success, msg } = await GetKeyType({ server, db, key })\n                if (success) {\n                    const { type } = data || {}\n                    node.redisType = type\n                } else {\n                    node.redisType = 'NONE'\n                }\n            } catch (e) {\n                node.redisType = 'NONE'\n            } finally {\n            }\n        },\n\n        /**\n         * reload key\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number[]} key\n         * @param {string} [decode]\n         * @param {string} [format]\n         * @param {string} [matchPattern]\n         * @param {boolean} [showLoading]\n         * @return {Promise<void>}\n         */\n        async reloadKey({ server, db, key, decode, format, matchPattern, showLoading = true }) {\n            const tab = useTabStore()\n            try {\n                if (showLoading) {\n                    tab.updateLoading({ server, db, loading: true })\n                }\n                await this.loadKeySummary({ server, db, key, clearValue: true, redirect: false })\n                await this.loadKeyDetail({\n                    server,\n                    db,\n                    key,\n                    decode,\n                    format,\n                    matchPattern,\n                    reset: true,\n                    showLoading: false,\n                })\n            } finally {\n                if (showLoading) {\n                    tab.updateLoading({ server, db, loading: false })\n                }\n            }\n        },\n\n        /**\n         * load key content\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number[]} key\n         * @param {string} [format]\n         * @param {string} [decode]\n         * @param {string} [matchPattern]\n         * @param {boolean} [reset]\n         * @param {boolean} [full]\n         * @param {boolean} [showLoading]\n         * @return {Promise<void>}\n         */\n        async loadKeyDetail({ server, db, key, format, decode, matchPattern, reset, full, showLoading = true }) {\n            const tab = useTabStore()\n            const serverInst = this.servers[server]\n            if (serverInst == null) {\n                return\n            }\n            try {\n                if (showLoading) {\n                    tab.updateLoading({ server, db, loading: true })\n                }\n                const [storeFormat, storeDecode] = serverInst.getDecodeHistory(key, db)\n                const { data, success, msg } = await GetKeyDetail({\n                    server,\n                    db,\n                    key,\n                    format: isEmpty(format) ? storeFormat : format,\n                    decode: isEmpty(decode) ? storeDecode : decode,\n                    matchPattern,\n                    full: full === true,\n                    reset,\n                    lite: true,\n                })\n                if (success) {\n                    const {\n                        value,\n                        keyType,\n                        decode: retDecode,\n                        format: retFormat,\n                        match: retMatch,\n                        reset: retReset,\n                        end,\n                    } = data\n                    tab.updateValue({\n                        server,\n                        db,\n                        key: nativeRedisKey(key),\n                        value,\n                        decode: retDecode || storeDecode,\n                        format: retFormat || storeFormat,\n                        reset: retReset,\n                        matchPattern: retMatch || '',\n                        end,\n                    })\n                } else {\n                    $message.error('load key detail fail:' + msg)\n                }\n            } finally {\n                if (showLoading) {\n                    tab.updateLoading({ server, db, loading: false })\n                }\n            }\n        },\n\n        /**\n         * convert value by decode type or format\n         * @param {string|number[]} value\n         * @param {string} [decode]\n         * @param {string} [format]\n         * @return {Promise<{[format]: string, [decode]: string, value: string}>}\n         */\n        async convertValue({ value, decode, format }) {\n            try {\n                const { data, success } = await ConvertValue(value, decode, format)\n                if (success) {\n                    const { value: retVal, decode: retDecode, format: retFormat } = data\n                    return { value: retVal, decode: retDecode, format: retFormat }\n                }\n            } catch (e) {}\n            return { value, decode, format }\n        },\n\n        /**\n         * scan keys with prefix\n         * @param {string} server\n         * @param {number} db\n         * @param {string} match\n         * @param {boolean} exact\n         * @param {string} [matchType]\n         * @param {number} [loadType] 0.load next; 1.load next full; 2.reload load all\n         * @returns {Promise<{keys: string[], maxKeys: number, end: boolean}>}\n         */\n        async scanKeys({ server, db, match = '*', exact = false, matchType = '', loadType = 0 }) {\n            let resp\n            switch (loadType) {\n                case 0:\n                default:\n                    resp = await LoadNextKeys(server, db, match, matchType, exact)\n                    break\n                case 1:\n                    resp = await LoadNextAllKeys(server, db, match, matchType, exact)\n                    break\n                case 2:\n                    resp = await LoadAllKeys(server, db, match, matchType, exact)\n                    break\n            }\n            const { data, success, msg } = resp || {}\n            if (!success) {\n                throw new Error(msg)\n            }\n            const { keys = [], maxKeys, end } = data\n            return { keys, end, maxKeys, success }\n        },\n\n        /**\n         *\n         * @param {string} server\n         * @param {number} db\n         * @param {string|null} match\n         * @param {boolean} exact\n         * @param {string|null} matchType\n         * @param {boolean} [all]\n         * @return {Promise<{keys: Array<string|number[]>, maxKeys: number, end: boolean}>}\n         * @private\n         */\n        async _loadKeys({ server, db, match, exact, matchType, all }) {\n            if (isEmpty(match)) {\n                match = '*'\n            }\n\n            if (!isRedisGlob(match) && !exact) {\n                if (!startsWith(match, '*')) {\n                    match = '*' + match\n                }\n                if (!endsWith(match, '*')) {\n                    match = match + '*'\n                }\n            }\n            return this.scanKeys({ server, db, match, exact, matchType, loadType: all ? 1 : 0 })\n        },\n\n        /**\n         * load more keys within the database\n         * @param {string} server\n         * @param {number} db\n         * @return {Promise<boolean>}\n         */\n        async loadMoreKeys(server, db) {\n            const { match, type: keyType, exact } = this.getKeyFilter(server)\n            const { keys, maxKeys, end } = await this._loadKeys({\n                server,\n                db,\n                match,\n                exact,\n                matchType: keyType,\n                all: false,\n            })\n            /** @type RedisServerState **/\n            const serverInst = this.servers[server]\n            if (serverInst != null) {\n                serverInst.setDBKeyCount(db, maxKeys)\n                // remove current keys below prefix\n                serverInst.addKeyNodes(keys)\n                serverInst.tidyNode('')\n            }\n            return end\n        },\n\n        /**\n         * load all left keys within the database\n         * @param {string} server\n         * @param {number} db\n         * @return {Promise<void>}\n         */\n        async loadAllKeys(server, db) {\n            const { match, type: keyType, exact } = this.getKeyFilter(server)\n            const { keys, maxKeys } = await this._loadKeys({ server, db, match, exact, matchType: keyType, all: true })\n            /** @type RedisServerState **/\n            const serverInst = this.servers[server]\n            if (serverInst != null) {\n                serverInst.setDBKeyCount(db, maxKeys)\n                serverInst.addKeyNodes(keys)\n                serverInst.tidyNode('')\n            }\n        },\n\n        /**\n         * reload keys under layer\n         * @param {string} server\n         * @param {number} db\n         * @param {string} prefix\n         * @return {Promise<void>}\n         */\n        async reloadLayer(server, db, prefix) {\n            if (isEmpty(prefix)) {\n                return\n            }\n            let match = prefix\n            const separator = this.getSeparator(server)\n            if (!isEmpty(match)) {\n                if (!endsWith(match, separator)) {\n                    match += separator + '*'\n                } else {\n                    match += '*'\n                }\n            }\n            // FIXME: ignore original match pattern due to redis not support combination matching\n            const { match: originMatch, type: keyType, exact } = this.getKeyFilter(server)\n            const { keys, maxKeys, success } = await this._loadKeys({\n                server,\n                db,\n                match: match || originMatch,\n                exact: false,\n                matchType: keyType,\n                all: true,\n            })\n            if (!success) {\n                return\n            }\n\n            /** @type RedisServerState **/\n            const serverInst = this.servers[server]\n            if (serverInst != null) {\n                serverInst.setDBKeyCount(db, maxKeys)\n                // remove current keys below prefix\n                serverInst.removeKeyNode(prefix, true)\n                serverInst.addKeyNodes(keys)\n                serverInst.tidyNode(prefix)\n            }\n        },\n\n        /**\n         * get custom separator of connection\n         * @param server\n         * @returns {string}\n         * @private\n         */\n        getSeparator(server) {\n            const connStore = useConnectionStore()\n            const { keySeparator } = connStore.getDefaultSeparator(server)\n            if (isEmpty(keySeparator)) {\n                return ':'\n            }\n            return keySeparator\n        },\n\n        /**\n         * get tree node by key name\n         * @param {string} key format `<connection>/<db>#<key_type>/<key>`\n         * @return {RedisNodeItem|null}\n         */\n        getNode(key) {\n            const match = key.match(/db\\d+(?=#)/)\n            if (!match) {\n                return null\n            }\n            let idx = match.index + match[0].length\n            if (idx < 0) {\n                idx = size(key)\n            }\n            const dbPart = key.substring(0, idx)\n            // parse server and db index\n            const idx2 = dbPart.lastIndexOf('/db')\n            if (idx2 < 0) {\n                return null\n            }\n            const server = dbPart.substring(0, idx2)\n            /** @type {RedisServerState} **/\n            const serverInst = this.servers[server]\n            if (serverInst == null) {\n                return null\n            }\n\n            const db = parseInt(dbPart.substring(idx2 + 3))\n            if (isNaN(db)) {\n                return null\n            }\n\n            if (size(key) <= idx + 1) {\n                return null\n            }\n            // contains redis key\n            const keyPart = key.substring(idx + 1)\n            return serverInst.nodeMap.get(keyPart)\n        },\n\n        /**\n         * get parent tree node by key name\n         * @param {string} key\n         * @return {RedisNodeItem|null}\n         */\n        getParentNode(key) {\n            const i = key.indexOf('#')\n            if (i < 0) {\n                return null\n            }\n            const [server, db] = split(key.substring(0, i), '/')\n            if (isEmpty(server) || isEmpty(db)) {\n                return null\n            }\n            /** @type {RedisServerState} **/\n            const serverInst = this.servers[server]\n            if (serverInst == null) {\n                return null\n            }\n            const separator = this.getSeparator(server)\n            const keyPart = key.substring(i)\n            const keyStartIdx = keyPart.indexOf('/')\n            const redisKey = keyPart.substring(keyStartIdx + 1)\n            const redisKeyParts = split(redisKey, separator)\n            const parentKey = slice(redisKeyParts, 0, size(redisKeyParts) - 1)\n            if (isEmpty(parentKey)) {\n                return serverInst.getRoot()\n            }\n            return serverInst.nodeMap.get(`${ConnectionType.RedisKey}/${join(parentKey, separator)}`)\n        },\n\n        /**\n         * set redis key\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number[]} key\n         * @param {string} keyType\n         * @param {any} value\n         * @param {number} ttl\n         * @param {string} [format]\n         * @param {string} [decode]\n         * @returns {Promise<{[msg]: string, success: boolean, [nodeKey]: {string}}>}\n         */\n        async setKey({ server, db, key, keyType, value, ttl, format = formatTypes.RAW, decode = decodeTypes.NONE }) {\n            try {\n                const { data, success, msg } = await SetKeyValue({\n                    server,\n                    db,\n                    key,\n                    keyType,\n                    value,\n                    ttl,\n                    format,\n                    decode,\n                })\n                if (success) {\n                    /** @type RedisServerState **/\n                    const serverInst = this.servers[server]\n                    if (serverInst != null && serverInst.db === db) {\n                        // const { value } = data\n                        // update tree view data\n                        const { newKey = 0 } = serverInst.addKeyNodes([key], true)\n                        if (newKey > 0) {\n                            serverInst.tidyNode(key)\n                            serverInst.updateDBKeyCount(db, newKey)\n                        }\n\n                        const { value: updatedValue } = data\n                        if (updatedValue != null) {\n                            const tab = useTabStore()\n                            tab.updateValue({ server, db, key, value: updatedValue })\n                        }\n                    }\n                    // this.loadKeySummary({ server, db, key })\n                    return {\n                        success,\n                        nodeKey: `${server}/db${db}#${ConnectionType.RedisValue}/${key}`,\n                        updatedValue: value,\n                    }\n                } else {\n                    return { success, msg }\n                }\n            } catch (e) {\n                return { success: false, msg: e.message }\n            }\n        },\n\n        /**\n         * update hash entry\n         * when field is set, newField is null, delete field\n         * when field is null, newField is set, add new field\n         * when both field and newField are set, and field === newField, update field\n         * when both field and newField are set, and field !== newField, delete field and add newField\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number[]} key\n         * @param {string} field\n         * @param {string} [newField]\n         * @param {string} [value]\n         * @param {decodeTypes} [decode]\n         * @param {formatTypes} [format]\n         * @param {decodeTypes} [retDecode]\n         * @param {formatTypes} [retFormat]\n         * @param {boolean} [refresh]\n         * @param {number} [index] index for retrieve affect entries quickly\n         * @param {boolean} [reload]\n         * @returns {Promise<{[msg]: string, success: boolean, [updated]: {}}>}\n         */\n        async setHash({\n            server,\n            db,\n            key,\n            field,\n            newField = '',\n            value = '',\n            decode = decodeTypes.NONE,\n            format = formatTypes.RAW,\n            retDecode,\n            retFormat,\n            index,\n            reload,\n        }) {\n            try {\n                const { data, success, msg } = await SetHashValue({\n                    server,\n                    db,\n                    key,\n                    field,\n                    newField,\n                    value,\n                    decode,\n                    format,\n                    retDecode,\n                    retFormat,\n                })\n                if (success) {\n                    /**\n                     * @type {{updated: HashEntryItem[], removed: HashEntryItem[], updated: HashEntryItem[], replaced: HashReplaceItem[]}}\n                     */\n                    const { updated = [], removed = [], added = [], replaced = [] } = data\n                    const tab = useTabStore()\n                    if (!isEmpty(removed)) {\n                        const removedKeys = map(removed, 'k')\n                        tab.removeValueEntries({ server, db, key, type: 'hash', entries: removedKeys })\n                    }\n                    if (!isEmpty(updated)) {\n                        tab.updateValueEntries({ server, db, key, type: 'hash', entries: updated })\n                    }\n                    if (!isEmpty(added)) {\n                        tab.insertValueEntries({ server, db, key, type: 'hash', entries: added })\n                    }\n                    if (!isEmpty(replaced)) {\n                        tab.replaceValueEntries({\n                            server,\n                            db,\n                            key,\n                            type: 'hash',\n                            entries: replaced,\n                            index: [index],\n                        })\n                    }\n                    if (reload === true) {\n                        this.reloadKey({ server, db, key })\n                    } else {\n                        // reload summary only\n                        this.loadKeySummary({ server, db, key })\n                    }\n                    return { success, updated }\n                } else {\n                    return { success, msg }\n                }\n            } catch (e) {\n                return { success: false, msg: e.message }\n            }\n        },\n\n        /**\n         * insert or update hash field item\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number[]} key\n         * @param {number }action 0:ignore duplicated fields 1:overwrite duplicated fields\n         * @param {string[]} fieldItems field1, value1, filed2, value2...\n         * @param {boolean} [reload]\n         * @returns {Promise<{[msg]: string, success: boolean, [updated]: [], [added]: []}>}\n         */\n        async addHashField({ server, db, key, action, fieldItems, reload }) {\n            try {\n                const { data, success, msg } = await AddHashField(server, db, key, action, fieldItems)\n                if (success) {\n                    const { updated = [], added = [] } = data\n                    const tab = useTabStore()\n                    if (!isEmpty(updated)) {\n                        tab.updateValueEntries({ server, db, key, type: 'hash', entries: updated })\n                    }\n                    if (!isEmpty(added)) {\n                        tab.insertValueEntries({ server, db, key, type: 'hash', entries: added })\n                    }\n                    if (reload === true) {\n                        this.reloadKey({ server, db, key })\n                    } else {\n                        // reload summary only\n                        this.loadKeySummary({ server, db, key })\n                    }\n                    return { success, updated, added }\n                } else {\n                    return { success: false, msg }\n                }\n            } catch (e) {\n                return { success: false, msg: e.message }\n            }\n        },\n\n        /**\n         * get hash field\n         * @param {string} server\n         * @param {number} db\n         * @param {string} key\n         * @param {string} field\n         * @param {decodeTypes} [decode]\n         * @param {formatTypes} [format]\n         * @return {Promise<{{msg: string, success: boolean, updated: HashEntryItem[]}>}\n         */\n        async getHashField({ server, db, key, field, decode = decodeTypes.NONE, format = formatTypes.RAW }) {\n            try {\n                const { data, success, msg } = await GetHashValue({ server, db, key, field, decode, format })\n                if (success && !isEmpty(data)) {\n                    const tab = useTabStore()\n                    tab.updateValueEntries({ server, db, key, type: 'hash', entries: [data] })\n                    return { success, updated: data }\n                } else {\n                    return { success: false, msg }\n                }\n            } catch (e) {\n                return { success: false, msg: e.message }\n            }\n        },\n\n        /**\n         * remove hash field\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number[]} key\n         * @param {string} field\n         * @param {boolean} reload\n         * @returns {Promise<{[msg]: {}, success: boolean, [removed]: string[]}>}\n         */\n        async removeHashField({ server, db, key, field, reload }) {\n            try {\n                const { data, success, msg } = await SetHashValue({ server, db, key, field, newField: '' })\n                if (success) {\n                    const { removed = [] } = data\n                    // if (!isEmpty(removed)) {\n                    //     const tab = useTabStore()\n                    //     tab.removeValueEntries({ server, db, key, type: 'hash', entries: removed })\n                    // }\n                    if (reload === true) {\n                        this.reloadKey({ server, db, key })\n                    } else {\n                        // reload summary only\n                        this.loadKeySummary({ server, db, key })\n                    }\n                    return { success, removed }\n                } else {\n                    return { success, msg }\n                }\n            } catch (e) {\n                return { success: false, msg: e.message }\n            }\n        },\n\n        /**\n         * prepend item to head of list\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number[]} key\n         * @param {string[]} values\n         * @param {boolean} reload\n         * @returns {Promise<{[msg]: string, success: boolean, [item]: []}>}\n         */\n        async prependListItem({ server, db, key, values, reload }) {\n            try {\n                const { data, success, msg } = await AddListItem(server, db, key, 0, values)\n                if (success) {\n                    const { left = [] } = data\n                    if (!isEmpty(left)) {\n                        const tab = useTabStore()\n                        tab.insertValueEntries({\n                            server: server,\n                            db,\n                            key,\n                            type: 'list',\n                            entries: left,\n                            prepend: true,\n                        })\n                        if (reload === true) {\n                            this.reloadKey({ server, db, key })\n                        } else {\n                            // reload summary only\n                            this.loadKeySummary({ server, db, key })\n                        }\n                    }\n                    return { success, item: left }\n                } else {\n                    return { success: false, msg }\n                }\n            } catch (e) {\n                return { success: false, msg: e.message }\n            }\n        },\n\n        /**\n         * append item to tail of list\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number[]} key\n         * @param {string[]} values\n         * @param {boolean} [reload]\n         * @returns {Promise<{[msg]: string, success: boolean, [item]: any[]}>}\n         */\n        async appendListItem({ server, db, key, values, reload }) {\n            try {\n                const { data, success, msg } = await AddListItem(server, db, key, 1, values)\n                if (success) {\n                    const { right = [] } = data\n                    // FIXME: do not append items if not all items loaded\n                    if (!isEmpty(right)) {\n                        const tab = useTabStore()\n                        tab.insertValueEntries({\n                            server: server,\n                            db,\n                            key,\n                            type: 'list',\n                            entries: right,\n                            prepend: false,\n                        })\n                        if (reload === true) {\n                            this.reloadKey({ server, db, key })\n                        } else {\n                            // reload summary only\n                            this.loadKeySummary({ server, db, key })\n                        }\n                    }\n                    return { success, item: right }\n                } else {\n                    return { success: false, msg }\n                }\n            } catch (e) {\n                return { success: false, msg: e.message }\n            }\n        },\n\n        /**\n         * update value of list item by index\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number[]} key\n         * @param {number} index\n         * @param {string|number[]} value\n         * @param {decodeTypes} decode\n         * @param {formatTypes} format\n         * @param {decodeTypes} [retDecode]\n         * @param {formatTypes} [retFormat]\n         * @param {boolean} [reload]\n         * @returns {Promise<{[msg]: string, success: boolean}>}\n         */\n        async updateListItem({\n            server,\n            db,\n            key,\n            index,\n            value,\n            decode = decodeTypes.NONE,\n            format = formatTypes.RAW,\n            retDecode,\n            retFormat,\n            reload,\n        }) {\n            try {\n                const { data, success, msg } = await SetListItem({\n                    server,\n                    db,\n                    key,\n                    index,\n                    value,\n                    decode,\n                    format,\n                    retDecode,\n                    retFormat,\n                })\n                if (success) {\n                    /** @type {{replaced: ListReplaceItem[]}} **/\n                    const { replaced = [], removed = [] } = data\n                    const tab = useTabStore()\n                    if (!isEmpty(replaced)) {\n                        tab.replaceValueEntries({\n                            server,\n                            db,\n                            key,\n                            type: 'list',\n                            entries: replaced,\n                        })\n                    }\n                    if (!isEmpty(removed)) {\n                        const removedIndex = map(removed, 'index')\n                        tab.removeValueEntries({\n                            server,\n                            db,\n                            key,\n                            type: 'list',\n                            entries: removedIndex,\n                        })\n                    }\n                    if (reload === true) {\n                        this.reloadKey({ server, db, key })\n                    } else {\n                        // reload summary only\n                        this.loadKeySummary({ server, db, key })\n                    }\n                    return { success }\n                } else {\n                    return { success, msg }\n                }\n            } catch (e) {\n                return { success: false, msg: e.message }\n            }\n        },\n\n        /**\n         * remove list item\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number[]} key\n         * @param {number} index\n         * @param {boolean} [reload]\n         * @returns {Promise<{[msg]: string, success: boolean, [removed]: string[]}>}\n         */\n        async removeListItem({ server, db, key, index, reload }) {\n            try {\n                const { data, success, msg } = await SetListItem({ server, db, key, index })\n                if (success) {\n                    const { removed = [] } = data\n                    const tab = useTabStore()\n                    if (!isEmpty(removed)) {\n                        const removedIndexes = map(removed, 'index')\n                        tab.removeValueEntries({\n                            server,\n                            db,\n                            key,\n                            type: 'list',\n                            entries: removedIndexes,\n                        })\n                        if (reload === true) {\n                            this.reloadKey({ server, db, key })\n                        } else {\n                            // reload summary only\n                            this.loadKeySummary({ server, db, key })\n                        }\n                    }\n                    return { success, removed }\n                } else {\n                    return { success, msg }\n                }\n            } catch (e) {\n                return { success: false, msg: e.message }\n            }\n        },\n\n        /**\n         * add item to set\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number} key\n         * @param {string|string[]} value\n         * @param {boolean} [reload]\n         * @returns {Promise<{[msg]: string, success: boolean}>}\n         */\n        async addSetItem({ server, db, key, value, reload }) {\n            try {\n                if ((!value) instanceof Array) {\n                    value = [value]\n                }\n                const { data, success, msg } = await SetSetItem(server, db, key, false, value)\n                if (success) {\n                    const { added } = data\n                    if (!isEmpty(added)) {\n                        const tab = useTabStore()\n                        tab.insertValueEntries({ server, db, key, type: 'set', entries: added })\n                    }\n                    if (reload === true) {\n                        this.reloadKey({ server, db, key })\n                    } else {\n                        // reload summary only\n                        this.loadKeySummary({ server, db, key })\n                    }\n                    return { success }\n                } else {\n                    return { success, msg }\n                }\n            } catch (e) {\n                return { success: false, msg: e.message }\n            }\n        },\n\n        /**\n         * update value of set item\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number[]} key\n         * @param {string|number[]} value\n         * @param {string|number[]} newValue\n         * @param {decodeTypes} [decode]\n         * @param {formatTypes} [format]\n         * @param {decodeTypes} [retDecode]\n         * @param {formatTypes} [retFormat]\n         * @param {boolean} [reload]\n         * @returns {Promise<{[msg]: string, success: boolean}>}\n         */\n        async updateSetItem({\n            server,\n            db,\n            key,\n            value,\n            newValue,\n            decode = decodeTypes.NONE,\n            format = formatTypes.RAW,\n            retDecode,\n            retFormat,\n            reload,\n        }) {\n            try {\n                const { data, success, msg } = await UpdateSetItem({\n                    server,\n                    db,\n                    key,\n                    value,\n                    newValue,\n                    decode,\n                    format,\n                    retDecode,\n                    retFormat,\n                })\n                if (success) {\n                    const { added, removed } = data\n                    const tab = useTabStore()\n                    if (!isEmpty(removed)) {\n                        const removedValues = map(removed, 'v')\n                        tab.removeValueEntries({ server, db, key, type: 'set', entries: removedValues })\n                    }\n                    if (!isEmpty(added)) {\n                        tab.insertValueEntries({ server, db, key, type: 'set', entries: added })\n                    }\n                    if (reload === true) {\n                        this.reloadKey({ server, db, key })\n                    } else {\n                        // reload summary only\n                        this.loadKeySummary({ server, db, key })\n                    }\n                    return { success }\n                } else {\n                    return { success: false, msg }\n                }\n            } catch (e) {\n                return { success: false, msg: e.message }\n            }\n        },\n\n        /**\n         * remove item from set\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number[]} key\n         * @param {string} value\n         * @param {boolean} [reload]\n         * @returns {Promise<{[msg]: string, success: boolean}>}\n         */\n        async removeSetItem({ server, db, key, value, reload }) {\n            try {\n                const { data, success, msg } = await SetSetItem(server, db, key, true, [value])\n                if (success) {\n                    const { removed } = data\n                    const tab = useTabStore()\n                    if (!isEmpty(removed)) {\n                        const removedValues = map(removed, 'v')\n                        tab.removeValueEntries({ server, db, key, type: 'set', entries: removedValues })\n                    }\n                    if (reload === true) {\n                        this.reloadKey({ server, db, key })\n                    } else {\n                        // reload summary only\n                        this.loadKeySummary({ server, db, key })\n                    }\n                    return { success }\n                } else {\n                    return { success, msg }\n                }\n            } catch (e) {\n                return { success: false, msg: e.message }\n            }\n        },\n\n        /**\n         * add item to sorted set\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number[]} key\n         * @param {number} action\n         * @param {Object.<string, number>} vs value: score\n         * @param {boolean} [reload]\n         * @returns {Promise<{[msg]: string, success: boolean}>}\n         */\n        async addZSetItem({ server, db, key, action, vs, reload }) {\n            try {\n                const { data, success, msg } = await AddZSetValue(server, db, key, action, vs)\n                if (success) {\n                    const { added, updated } = data\n                    const tab = useTabStore()\n                    if (!isEmpty(added)) {\n                        tab.insertValueEntries({ server, db, key, type: 'zset', entries: added })\n                    }\n                    if (!isEmpty(updated)) {\n                        tab.updateValueEntries({ server, db, key, type: 'zset', entries: updated })\n                    }\n                    if (reload === true) {\n                        this.reloadKey({ server, db, key })\n                    } else {\n                        // reload summary only\n                        this.loadKeySummary({ server, db, key })\n                    }\n                    return { success }\n                } else {\n                    return { success, msg }\n                }\n            } catch (e) {\n                return { success: false, msg: e.message }\n            }\n        },\n\n        /**\n         * update item of sorted set\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number[]} key\n         * @param {string} value\n         * @param {string} newValue\n         * @param {number} score\n         * @param {decodeTypes} decode\n         * @param {formatTypes} format\n         * @param {decodeTypes} [retDecode]\n         * @param {formatTypes} [retFormat]\n         * @param {number} [index] index for retrieve affect entries quickly\n         * @param {boolean} [reload]\n         * @returns {Promise<{[msg]: string, success: boolean}>}\n         */\n        async updateZSetItem({\n            server,\n            db,\n            key,\n            value = '',\n            newValue,\n            score,\n            decode = decodeTypes.NONE,\n            format = formatTypes.RAW,\n            retDecode,\n            retFormat,\n            index,\n            reload,\n        }) {\n            try {\n                const { data, success, msg } = await UpdateZSetValue({\n                    server,\n                    db,\n                    key,\n                    value,\n                    newValue,\n                    score,\n                    decode,\n                    format,\n                    retDecode,\n                    retFormat,\n                })\n                if (success) {\n                    const { updated = [], added = [], removed = [], replaced = [] } = data\n                    const tab = useTabStore()\n                    if (!isEmpty(removed)) {\n                        const removedValues = map(removed, 'v')\n                        tab.removeValueEntries({ server, db, key, type: 'zset', entries: removedValues })\n                    }\n                    if (!isEmpty(updated)) {\n                        tab.updateValueEntries({ server, db, key, type: 'zset', entries: updated })\n                    }\n                    if (!isEmpty(added)) {\n                        tab.insertValueEntries({ server, db, key, type: 'zset', entries: added })\n                    }\n                    if (!isEmpty(replaced)) {\n                        tab.replaceValueEntries({ server, db, key, type: 'zset', entries: replaced, index: [index] })\n                    }\n                    if (reload === true) {\n                        this.reloadKey({ server, db, key })\n                    } else {\n                        // reload summary only\n                        this.loadKeySummary({ server, db, key })\n                    }\n                    return { success, updated, removed }\n                } else {\n                    return { success, msg }\n                }\n            } catch (e) {\n                return { success: false, msg: e.message }\n            }\n        },\n\n        /**\n         * remove item from sorted set\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number[]} key\n         * @param {string} value\n         * @param {boolean} [reload]\n         * @returns {Promise<{[msg]: string, success: boolean, [removed]: []}>}\n         */\n        async removeZSetItem({ server, db, key, value, reload }) {\n            try {\n                const { data, success, msg } = await UpdateZSetValue({ server, db, key, value, newValue: '', score: 0 })\n                if (success) {\n                    const { removed } = data\n                    const tab = useTabStore()\n                    if (!isEmpty(removed)) {\n                        const removeValues = map(removed, 'v')\n                        tab.removeValueEntries({ server, db, key, type: 'zset', entries: removeValues })\n                    }\n                    if (reload === true) {\n                        this.reloadKey({ server, db, key })\n                    } else {\n                        // reload summary only\n                        this.loadKeySummary({ server, db, key })\n                    }\n                    return { success, removed }\n                } else {\n                    return { success, msg }\n                }\n            } catch (e) {\n                return { success: false, msg: e.message }\n            }\n        },\n\n        /**\n         * insert new stream field item\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number[]} key\n         * @param {string} id\n         * @param {string[]} values field1, value1, filed2, value2...\n         * @param {boolean} [reload]\n         * @returns {Promise<{[msg]: string, success: boolean}>}\n         */\n        async addStreamValue({ server, db, key, id, values, reload }) {\n            try {\n                const { data = {}, success, msg } = await AddStreamValue(server, db, key, id, values)\n                if (success) {\n                    const { added = [] } = data\n                    if (!isEmpty(added)) {\n                        const tab = useTabStore()\n                        tab.insertValueEntries({\n                            server,\n                            db,\n                            key,\n                            type: 'stream',\n                            entries: added,\n                        })\n                        if (reload === true) {\n                            this.reloadKey({ server, db, key })\n                        } else {\n                            // reload summary only\n                            this.loadKeySummary({ server, db, key })\n                        }\n                    }\n                    return { success }\n                } else {\n                    return { success: false, msg }\n                }\n            } catch (e) {\n                return { success: false, msg: e.message }\n            }\n        },\n\n        /**\n         * remove stream field\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number[]} key\n         * @param {string[]|string} ids\n         * @param {boolean} [reload]\n         * @returns {Promise<{[msg]: {}, success: boolean}>}\n         */\n        async removeStreamValues({ server, db, key, ids, reload }) {\n            if (typeof ids === 'string') {\n                ids = [ids]\n            }\n            try {\n                const { data = {}, success, msg } = await RemoveStreamValues(server, db, key, ids)\n                if (success) {\n                    const tab = useTabStore()\n                    tab.removeValueEntries({ server, db, key, type: 'stream', entries: ids })\n                    if (reload === true) {\n                        this.reloadKey({ server, db, key })\n                    } else {\n                        // reload summary only\n                        this.loadKeySummary({ server, db, key })\n                    }\n                    return { success }\n                } else {\n                    return { success, msg }\n                }\n            } catch (e) {\n                return { success: false, msg: e.message }\n            }\n        },\n\n        /**\n         * reset key's ttl\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number[]} key\n         * @param {number} ttl\n         * @returns {Promise<boolean>}\n         */\n        async setTTL(server, db, key, ttl) {\n            try {\n                const { success, msg } = await SetKeyTTL(server, db, key, ttl)\n                if (success) {\n                    const tabStore = useTabStore()\n                    tabStore.updateTTL({\n                        server,\n                        db,\n                        key: nativeRedisKey(key),\n                        ttl,\n                    })\n                }\n                return success === true\n            } catch (e) {\n                return false\n            }\n        },\n\n        async setTTLs(server, db, keys, ttl) {\n            // const msgRef = $message.loading('', { duration: 0, closable: true })\n            // let updated = []\n            // let failCount = 0\n            // let canceled = false\n            const serialNo = Date.now().valueOf().toString()\n            // const eventName = 'ttling:' + serialNo\n            // const cancelEvent = 'ttling:stop:' + serialNo\n            try {\n                // let maxProgress = 0\n                // EventsOn(eventName, ({ total, progress, processing }) => {\n                //     // update delete progress\n                //     if (progress > maxProgress) {\n                //         maxProgress = progress\n                //     }\n                //     const k = decodeRedisKey(processing)\n                //     msgRef.content = i18nGlobal.t('dialogue.delete.doing', {\n                //         key: k,\n                //         index: maxProgress,\n                //         count: total,\n                //     })\n                // })\n                // msgRef.onClose = () => {\n                //     EventsEmit(cancelEvent)\n                // }\n                const { data, success, msg } = await BatchSetTTL(server, db, keys, ttl, serialNo)\n                if (success) {\n                    // canceled = get(data, 'canceled', false)\n                    // updated = get(data, 'updated', [])\n                    // failCount = get(data, 'failed', 0)\n                } else {\n                    $message.error(msg)\n                }\n            } finally {\n                // msgRef.destroy()\n                // EventsOff(eventName)\n            }\n            $message.success(i18nGlobal.t('dialogue.ttl.success'))\n            // const deletedCount = size(updated)\n            // if (canceled) {\n            //     $message.info(i18nGlobal.t('dialogue.handle_cancel'))\n            // } else if (failCount <= 0) {\n            //     // no fail\n            //     $message.success(i18nGlobal.t('dialogue.delete.completed', { success: deletedCount, fail: failCount }))\n            // } else if (failCount >= deletedCount) {\n            //     // all fail\n            //     $message.error(i18nGlobal.t('dialogue.delete.completed', { success: deletedCount, fail: failCount }))\n            // } else {\n            //     // some fail\n            //     $message.warning(i18nGlobal.t('dialogue.delete.completed', { success: deletedCount, fail: failCount }))\n            // }\n        },\n\n        /**\n         * delete redis key\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number[]} key\n         * @param {boolean} [soft] do not try to remove from redis if true, just remove from tree data\n         * @returns {Promise<boolean>}\n         */\n        async deleteKey(server, db, key, soft) {\n            try {\n                let deleteCount = 1\n                if (soft !== true) {\n                    const { data } = await DeleteKey(server, db, key)\n                    deleteCount = get(data, 'deleteCount', 0)\n                }\n\n                const k = nativeRedisKey(key)\n                // update tree view data\n                /** @type RedisServerState **/\n                const serverInst = this.servers[server]\n                if (serverInst != null) {\n                    serverInst.removeKeyNode(k)\n                    serverInst.tidyNode(k, true)\n                    serverInst.updateDBKeyCount(db, -deleteCount)\n                }\n\n                // set tab content empty\n                const tab = useTabStore()\n                tab.emptyTab(server)\n                tab.setSelectedKeys(server)\n                tab.setCheckedKeys(server)\n                return true\n            } finally {\n            }\n            return false\n        },\n\n        /**\n         * delete multiple keys\n         * @param {string} server\n         * @param {number} db\n         * @param {string[]|number[][]} keys\n         * @return {Promise<void>}\n         */\n        async deleteKeys(server, db, keys) {\n            const msgRef = $message.loading(i18nGlobal.t('dialogue.delete.deleting'), { duration: 0, closable: true })\n            let deleted = []\n            let failCount = 0\n            let canceled = false\n            const serialNo = Date.now().valueOf().toString()\n            msgRef.onClose = () => {\n                EventsEmit('delete:stop:' + serialNo)\n            }\n            try {\n                const { success, msg, data } = await DeleteKeys(server, db, keys, serialNo)\n                if (success) {\n                    canceled = get(data, 'canceled', false)\n                    deleted = get(data, 'deleted', [])\n                    failCount = get(data, 'failed', 0)\n                } else {\n                    $message.error(msg)\n                }\n            } finally {\n                msgRef.destroy()\n                // clear checked keys\n                const tab = useTabStore()\n                tab.setCheckedKeys(server)\n            }\n            // refresh model data\n            const deletedCount = size(deleted)\n            if (canceled) {\n                $message.info(i18nGlobal.t('dialogue.handle_cancel'))\n            } else if (failCount <= 0) {\n                // no fail\n                $message.success(i18nGlobal.t('dialogue.delete.completed', { success: deletedCount, fail: failCount }))\n            } else if (failCount >= deletedCount) {\n                // all fail\n                $message.error(i18nGlobal.t('dialogue.delete.completed', { success: deletedCount, fail: failCount }))\n            } else {\n                // some fail\n                $message.warning(i18nGlobal.t('dialogue.delete.completed', { success: deletedCount, fail: failCount }))\n            }\n            // update ui\n            timeout(100).then(async () => {\n                /** @type RedisServerState **/\n                const serverInst = this.servers[server]\n                if (serverInst != null) {\n                    let start = now()\n                    for (let i = 0; i < deleted.length; i++) {\n                        serverInst.removeKeyNode(deleted[i], false)\n                        if (now() - start > 300) {\n                            await timeout(100)\n                            start = now()\n                        }\n                    }\n                    serverInst.tidyNode('', true)\n                    serverInst.updateDBKeyCount(db, -deletedCount)\n                }\n            })\n        },\n\n        /**\n         * delete multiple keys by pattern\n         * @param server\n         * @param db\n         * @param pattern\n         * @return {Promise<void>}\n         */\n        async deleteByPattern(server, db, pattern) {\n            const msgRef = $message.loading(i18nGlobal.t('dialogue.delete.deleting'), { duration: 0, closable: true })\n            let deleted = []\n            let failCount = 0\n            let canceled = false\n            try {\n                const { success, msg, data } = await DeleteKeysByPattern(server, db, pattern)\n                if (success) {\n                    canceled = get(data, 'canceled', false)\n                    deleted = get(data, 'deleted', [])\n                    failCount = get(data, 'failed', 0)\n                } else {\n                    $message.error(msg)\n                }\n            } finally {\n                msgRef.destroy()\n                // clear checked keys\n                const tab = useTabStore()\n                tab.setCheckedKeys(server)\n            }\n            // refresh model data\n            const deletedCount = size(deleted)\n            if (canceled) {\n                $message.info(i18nGlobal.t('dialogue.handle_cancel'))\n            } else if (failCount <= 0) {\n                // no fail\n                $message.success(i18nGlobal.t('dialogue.delete.completed', { success: deletedCount, fail: failCount }))\n            } else if (failCount >= deletedCount) {\n                // all fail\n                $message.error(i18nGlobal.t('dialogue.delete.completed', { success: deletedCount, fail: failCount }))\n            } else {\n                // some fail\n                $message.warning(i18nGlobal.t('dialogue.delete.completed', { success: deletedCount, fail: failCount }))\n            }\n            // update ui\n            timeout(100).then(async () => {\n                /** @type RedisServerState **/\n                const serverInst = this.servers[server]\n                if (serverInst != null) {\n                    let start = now()\n                    for (let i = 0; i < deleted.length; i++) {\n                        serverInst.removeKeyNode(deleted[i], false)\n                        if (now() - start > 300) {\n                            await timeout(100)\n                            start = now()\n                        }\n                    }\n                    serverInst.tidyNode('', true)\n                    serverInst.updateDBKeyCount(db, -deletedCount)\n                }\n            })\n        },\n\n        /**\n         * export multiple keys\n         * @param {string} server\n         * @param {number} db\n         * @param {string[]|number[][]} keys\n         * @param {string} path\n         * @param {boolean} [expire]\n         * @returns {Promise<void>}\n         */\n        async exportKeys(server, db, keys, path, expire) {\n            const msgRef = $message.loading('', { duration: 0, closable: true })\n            let exported = 0\n            let failCount = 0\n            let canceled = false\n            const cancelEventFn = EventsOn('exporting:' + path, ({ total, progress, processing }) => {\n                // update export progress\n                msgRef.content = i18nGlobal.t('dialogue.export.exporting', {\n                    // key: decodeRedisKey(processing),\n                    index: progress,\n                    count: total,\n                })\n            })\n            msgRef.onClose = () => {\n                EventsEmit('export:stop:' + path)\n            }\n            try {\n                const { data, success, msg } = await ExportKey(server, db, keys, path, expire)\n                if (success) {\n                    canceled = get(data, 'canceled', false)\n                    exported = get(data, 'exported', 0)\n                    failCount = get(data, 'failed', 0)\n                } else {\n                    $message.error(msg)\n                }\n            } finally {\n                msgRef.destroy()\n                cancelEventFn()\n            }\n            if (canceled) {\n                $message.info(i18nGlobal.t('dialogue.handle_cancel'))\n            } else if (failCount <= 0) {\n                // no fail\n                $message.success(\n                    i18nGlobal.t('dialogue.export.export_completed', { success: exported, fail: failCount }),\n                )\n            } else if (failCount >= exported) {\n                // all fail\n                $message.error(i18nGlobal.t('dialogue.export.export_completed', { success: exported, fail: failCount }))\n            } else {\n                // some fail\n                $message.warning(\n                    i18nGlobal.t('dialogue.export.export_completed', {\n                        success: exported,\n                        fail: failCount,\n                    }),\n                )\n            }\n        },\n\n        /**\n         * import multiple keys from csv file\n         * @param {string} server\n         * @param {number} db\n         * @param {string} path\n         * @param {number} conflict\n         * @param {number} [ttl] <0:use previous; ==0: persist; >0: custom ttl\n         * @param {boolean} [reload]\n         * @return {Promise<void>}\n         */\n        async importKeysFromCSVFile(server, db, path, conflict, ttl, reload) {\n            const msgRef = $message.loading('', { duration: 0, closable: true })\n            let imported = 0\n            let ignored = 0\n            let canceled = false\n            const cancelEventFn = EventsOn('importing:' + path, ({ imported = 0, ignored = 0 }) => {\n                // update export progress\n                msgRef.content = i18nGlobal.t('dialogue.import.importing', {\n                    // key: decodeRedisKey(processing),\n                    imported,\n                    conflict: ignored,\n                })\n            })\n            msgRef.onClose = () => {\n                EventsEmit('import:stop:' + path)\n            }\n            try {\n                const { data, success, msg } = await ImportCSV(server, db, path, conflict, ttl)\n                if (success) {\n                    canceled = get(data, 'canceled', false)\n                    imported = get(data, 'imported', 0)\n                    ignored = get(data, 'ignored', 0)\n                } else {\n                    $message.error(msg)\n                }\n            } finally {\n                cancelEventFn()\n                msgRef.destroy()\n            }\n            if (canceled) {\n                $message.info(i18nGlobal.t('dialogue.handle_cancel'))\n            } else {\n                // finish\n                $message.success(i18nGlobal.t('dialogue.import.import_completed', { success: imported, ignored }))\n                if (reload) {\n                    this.reloadServer(server)\n                }\n            }\n        },\n\n        /**\n         * flush database\n         * @param {string} server\n         * @param {number} db\n         * @param {boolean} async\n         * @return {Promise<boolean>}\n         */\n        async flushDatabase(server, db, async) {\n            try {\n                const { success = false } = await FlushDB(server, db, async)\n\n                if (success === true) {\n                    /** @type RedisServerState **/\n                    const serverInst = this.servers[server]\n                    if (serverInst != null) {\n                        // update tree view data\n                        serverInst.removeKeyNode()\n                    }\n                    // set tab content empty\n                    const tab = useTabStore()\n                    tab.emptyTab(server)\n                    tab.setSelectedKeys(server)\n                    tab.setCheckedKeys(server)\n                    tab.setExpandedKeys(server)\n                    return true\n                }\n            } finally {\n            }\n            return true\n        },\n\n        /**\n         * rename key\n         * @param {string} server\n         * @param {number} db\n         * @param {string} key\n         * @param {string} newKey\n         * @returns {Promise<{[msg]: string, success: boolean, [nodeKey]: string}>}\n         */\n        async renameKey(server, db, key, newKey) {\n            const { success = false, msg } = await RenameKey(server, db, key, newKey)\n            if (success) {\n                // delete old key and add new key struct\n                /** @type RedisServerState **/\n                const serverInst = this.servers[server]\n                if (serverInst != null) {\n                    serverInst.renameKey(key, newKey)\n                }\n                return { success: true, nodeKey: `${server}/db${db}#${ConnectionType.RedisValue}/${newKey}` }\n            } else {\n                return { success: false, msg }\n            }\n        },\n\n        /**\n         * get command history\n         * @param {number} [pageNo]\n         * @param {number} [pageSize]\n         * @returns {Promise<HistoryItem[]>}\n         */\n        async getCmdHistory(pageNo, pageSize) {\n            if (pageNo === undefined || pageSize === undefined) {\n                pageNo = -1\n                pageSize = -1\n            }\n            try {\n                const { success, data = { list: [] } } = await GetCmdHistory(pageNo, pageSize)\n                const { list } = data\n                return list\n            } catch {\n                return []\n            }\n        },\n\n        /**\n         * clean cmd history\n         * @return {Promise<boolean>}\n         */\n        async cleanCmdHistory() {\n            try {\n                const { success } = await CleanCmdHistory()\n                return success === true\n            } catch {\n                return false\n            }\n        },\n\n        /**\n         * get client list info\n         * @param {string} server\n         * @return {Promise<{idle: number, name: string, addr: string, age: number, db: number}[]>}\n         */\n        async getClientList(server) {\n            const { success, msg, data } = await GetClientList(server)\n            if (success) {\n                const { list = [] } = data\n                return map(list, (item) => ({\n                    addr: item['addr'],\n                    name: item['name'],\n                    age: item['age'] || 0,\n                    idle: item['idle'] || 0,\n                    db: item['db'] || 0,\n                }))\n            }\n            return []\n        },\n\n        /**\n         * get slow log list\n         * @param {string} server\n         * @param {number} num\n         * @return {Promise<[]>}\n         */\n        async getSlowLog(server, num) {\n            try {\n                const { success, data = { list: [] } } = await GetSlowLogs(server, num)\n                const { list } = data\n                return list\n            } catch {\n                return []\n            }\n        },\n\n        /**\n         * get key filter pattern and filter type\n         * @param {string} server\n         * @returns {{match: string, type: string, exact: boolean}}\n         */\n        getKeyFilter(server) {\n            let serverInst = this.servers[server]\n            if (serverInst == null) {\n                serverInst = new RedisServerState({\n                    name: server,\n                    separator: this.getSeparator(server),\n                })\n            }\n            return serverInst.getFilter()\n        },\n\n        /**\n         *\n         * @param {string} server\n         * @param {string} [pattern]\n         * @param {string} [type]\n         * @param {boolean} [exact]\n         */\n        setKeyFilter(server, { pattern, type, exact = false }) {\n            const serverInst = this.servers[server]\n            if (serverInst != null) {\n                serverInst.setFilter({ pattern, type, exact })\n            }\n        },\n\n        /**\n         *\n         * @param {string} server\n         * @param {string} key\n         * @param {number} db\n         * @param {string} format\n         * @param {string} decode\n         */\n        setSelectedFormat(server, key, db, format, decode) {\n            const serverInst = this.servers[server]\n            if (serverInst == null) {\n                return\n            }\n            serverInst.addDecodeHistory(key, db, format, decode)\n        },\n    },\n})\n\nexport default useBrowserStore\n"
  },
  {
    "path": "frontend/src/stores/connections.js",
    "content": "import { defineStore } from 'pinia'\nimport { get, isEmpty, isObject, union, uniq } from 'lodash'\nimport {\n    CreateGroup,\n    DeleteConnection,\n    DeleteGroup,\n    ExportConnections,\n    GetConnection,\n    ImportConnections,\n    ListConnection,\n    ParseConnectURL,\n    RenameGroup,\n    SaveConnection,\n    SaveLastDB,\n    SaveRefreshInterval,\n    SaveSortedConnection\n} from 'wailsjs/go/services/connectionService.js'\nimport { ConnectionType } from '@/consts/connection_type.js'\nimport { KeyViewType } from '@/consts/key_view_type.js'\nimport useBrowserStore from 'stores/browser.js'\nimport { i18nGlobal } from '@/utils/i18n.js'\nimport { ClipboardGetText } from 'wailsjs/runtime/runtime.js'\n\nconst useConnectionStore = defineStore('connections', {\n    /**\n     * @typedef {Object} ConnectionItem\n     * @property {string} key\n     * @property {string} label display label\n     * @property {string} name database name\n     * @property {number} type\n     * @property {boolean} cluster is cluster node\n     * @property {ConnectionItem[]} children\n     */\n\n    /**\n     * @typedef {Object} ConnectionProfile\n     * @property {string} defaultFilter\n     * @property {string} keySeparator\n     * @property {string} markColor\n     * @property {number} refreshInterval\n     */\n\n    /**\n     * @typedef {Object} ConnectionState\n     * @property {string[]} groups\n     * @property {ConnectionItem[]} connections\n     * @property {Object.<string, ConnectionProfile>} serverProfile\n     */\n\n    /**\n     *\n     * @returns {ConnectionState}\n     */\n    state: () => ({\n        groups: [], // all group name set\n        connections: [], // all connections\n        serverProfile: {}, // all server profile in flat list\n    }),\n    getters: {},\n    actions: {\n        /**\n         * load all store connections struct from local profile\n         * @param {boolean} [force]\n         * @returns {Promise<void>}\n         */\n        async initConnections(force) {\n            if (!force && !isEmpty(this.connections)) {\n                return\n            }\n            const conns = []\n            const groups = []\n            const profiles = {}\n            const { data = [{ groupName: '', connections: [], refreshInterval: 5 }] } = await ListConnection()\n            for (const conn of data) {\n                if (conn.type !== 'group') {\n                    // top level\n                    conns.push({\n                        key: '/' + conn.name,\n                        label: conn.name,\n                        name: conn.name,\n                        type: ConnectionType.Server,\n                        cluster: get(conn, 'cluster.enable', false),\n                        // isLeaf: false,\n                    })\n                    profiles[conn.name] = {\n                        defaultFilter: conn.defaultFilter,\n                        keySeparator: conn.keySeparator,\n                        markColor: conn.markColor,\n                        refreshInterval: conn.refreshInterval,\n                    }\n                } else {\n                    // custom group\n                    groups.push(conn.name)\n                    const subConns = get(conn, 'connections', [])\n                    const children = []\n                    for (const item of subConns) {\n                        const value = conn.name + '/' + item.name\n                        children.push({\n                            key: value,\n                            label: item.name,\n                            name: item.name,\n                            type: ConnectionType.Server,\n                            cluster: get(item, 'cluster.enable', false),\n                            // isLeaf: false,\n                        })\n                        profiles[item.name] = {\n                            defaultFilter: item.defaultFilter,\n                            keySeparator: item.keySeparator,\n                            markColor: item.markColor,\n                            refreshInterval: item.refreshInterval,\n                        }\n                    }\n                    conns.push({\n                        key: conn.name + '/',\n                        label: conn.name,\n                        type: ConnectionType.Group,\n                        children,\n                    })\n                }\n            }\n            this.connections = conns\n            this.serverProfile = profiles\n            this.groups = uniq(groups)\n        },\n\n        /**\n         * get connection by name from local profile\n         * @param name\n         * @returns {Promise<ConnectionProfile|null>}\n         */\n        async getConnectionProfile(name) {\n            try {\n                const { data, success } = await GetConnection(name)\n                if (success) {\n                    this.serverProfile[name] = {\n                        defaultFilter: data.defaultFilter,\n                        keySeparator: data.keySeparator,\n                        markColor: data.markColor,\n                    }\n                    return data\n                }\n            } finally {\n            }\n            return null\n        },\n\n        /**\n         * create a new default connection\n         * @param {string} [name]\n         * @returns {{}}\n         */\n        newDefaultConnection(name) {\n            return {\n                group: '',\n                name: name || '',\n                network: 'tcp',\n                sock: '/tmp/redis.sock',\n                addr: '127.0.0.1',\n                port: 6379,\n                username: '',\n                password: '',\n                defaultFilter: '*',\n                keySeparator: ':',\n                connTimeout: 60,\n                execTimeout: 60,\n                dbFilterType: 'none',\n                dbFilterList: [],\n                keyView: KeyViewType.Tree,\n                loadSize: 10000,\n                markColor: '',\n                alias: {},\n                ssl: {\n                    enable: false,\n                    allowInsecure: true,\n                    sni: '',\n                    certFile: '',\n                    keyFile: '',\n                    caFile: '',\n                },\n                ssh: {\n                    enable: false,\n                    addr: '',\n                    port: 22,\n                    loginType: 'pwd',\n                    username: '',\n                    password: '',\n                    pkFile: '',\n                    passphrase: '',\n                },\n                sentinel: {\n                    enable: false,\n                    master: 'mymaster',\n                    username: '',\n                    password: '',\n                },\n                cluster: {\n                    enable: false,\n                },\n                proxy: {\n                    type: 0,\n                    schema: 'http',\n                    addr: '',\n                    port: 0,\n                    auth: false,\n                    username: '',\n                    password: '',\n                },\n            }\n        },\n\n        mergeConnectionProfile(dest, src) {\n            const mergeObj = (destObj, srcObj) => {\n                const keys = union(Object.keys(destObj), Object.keys(srcObj))\n                for (const k of keys) {\n                    const t = typeof srcObj[k]\n                    if (t === 'string') {\n                        destObj[k] = srcObj[k] || destObj[k] || ''\n                    } else if (t === 'number') {\n                        destObj[k] = srcObj[k] || destObj[k] || 0\n                    } else if (t === 'object') {\n                        mergeObj(destObj[k], srcObj[k] || {})\n                    } else {\n                        destObj[k] = srcObj[k]\n                    }\n                }\n                return destObj\n            }\n            return mergeObj(dest, src)\n        },\n\n        /**\n         * get database server by name\n         * @param name\n         * @returns {ConnectionItem|null}\n         */\n        getConnection(name) {\n            const conns = this.connections\n            for (let i = 0; i < conns.length; i++) {\n                if (conns[i].type === ConnectionType.Server && conns[i].key === name) {\n                    return conns[i]\n                } else if (conns[i].type === ConnectionType.Group) {\n                    const children = conns[i].children\n                    for (let j = 0; j < children.length; j++) {\n                        if (children[j].type === ConnectionType.Server && conns[i].key === name) {\n                            return children[j]\n                        }\n                    }\n                }\n            }\n            return null\n        },\n\n        /**\n         * create a new connection or update current connection profile\n         * @param {string} name set null if create a new connection\n         * @param {{}} param\n         * @returns {Promise<{success: boolean, [msg]: string}>}\n         */\n        async saveConnection(name, param) {\n            const { success, msg } = await SaveConnection(name, param)\n            if (!success) {\n                return { success: false, msg }\n            }\n\n            // reload connection list\n            await this.initConnections(true)\n            return { success: true }\n        },\n\n        /**\n         * save connection after sort\n         * @returns {Promise<void>}\n         */\n        async saveConnectionSorted() {\n            const mapToList = (conns) => {\n                const list = []\n                for (const conn of conns) {\n                    if (conn.type === ConnectionType.Group) {\n                        const children = mapToList(conn.children)\n                        list.push({\n                            name: conn.label,\n                            type: 'group',\n                            connections: children,\n                        })\n                    } else if (conn.type === ConnectionType.Server) {\n                        list.push({\n                            name: conn.name,\n                        })\n                    }\n                }\n                return list\n            }\n            const s = mapToList(this.connections)\n            SaveSortedConnection(s)\n        },\n\n        /**\n         * remove connection\n         * @param name\n         * @returns {Promise<{success: boolean, [msg]: string}>}\n         */\n        async deleteConnection(name) {\n            // close connection first\n            const browser = useBrowserStore()\n            await browser.closeConnection(name)\n            const { success, msg } = await DeleteConnection(name)\n            if (!success) {\n                return { success: false, msg }\n            }\n            await this.initConnections(true)\n            return { success: true }\n        },\n\n        /**\n         * create a connection group\n         * @param name\n         * @returns {Promise<{success: boolean, [msg]: string}>}\n         */\n        async createGroup(name) {\n            const { success, msg } = await CreateGroup(name)\n            if (!success) {\n                return { success: false, msg }\n            }\n            await this.initConnections(true)\n            return { success: true }\n        },\n\n        /**\n         * rename connection group\n         * @param name\n         * @param newName\n         * @returns {Promise<{success: boolean, [msg]: string}>}\n         */\n        async renameGroup(name, newName) {\n            if (name === newName) {\n                return { success: true }\n            }\n            const { success, msg } = await RenameGroup(name, newName)\n            if (!success) {\n                return { success: false, msg }\n            }\n            await this.initConnections(true)\n            return { success: true }\n        },\n\n        /**\n         * delete group by name\n         * @param {string} name\n         * @param {boolean} [includeConn]\n         * @returns {Promise<{success: boolean, [msg]: string}>}\n         */\n        async deleteGroup(name, includeConn) {\n            const { success, msg } = await DeleteGroup(name, includeConn === true)\n            if (!success) {\n                return { success: false, msg }\n            }\n            await this.initConnections(true)\n            return { success: true }\n        },\n\n        /**\n         * save last selected database\n         * @param {string} name\n         * @param {number} db\n         * @return {Promise<{success: boolean, [msg]: string}>}\n         */\n        async saveLastDB(name, db) {\n            const { success, msg } = await SaveLastDB(name, db)\n            if (!success) {\n                return { success: false, msg }\n            }\n            return { success: true }\n        },\n\n        /**\n         * get default key filter pattern by server name\n         * @param name\n         * @return {string}\n         */\n        getDefaultKeyFilter(name) {\n            const { defaultFilter = '*' } = this.serverProfile[name] || {}\n            return defaultFilter\n        },\n\n        /**\n         * get default key separator by server name\n         * @param name\n         * @return {string}\n         */\n        getDefaultSeparator(name) {\n            const { keySeparator = ':' } = this.serverProfile[name] || {}\n            return keySeparator\n        },\n\n        /**\n         * get default status refresh interval by server name\n         * @param {string} name\n         * @return {number}\n         */\n        getRefreshInterval(name) {\n            const { refreshInterval = 5 } = this.serverProfile[name] || {}\n            return refreshInterval\n        },\n\n        /**\n         * set and save default refresh interval\n         * @param {string} name\n         * @param {number} interval\n         * @return {Promise<{success: boolean}|{msg: undefined, success: boolean}>}\n         */\n        async saveRefreshInterval(name, interval) {\n            const profile = this.serverProfile[name] || {}\n            profile.refreshInterval = interval\n            const { success, msg } = await SaveRefreshInterval(name, interval)\n            if (!success) {\n                return { success: false, msg }\n            }\n            return { success: true }\n        },\n\n        /**\n         * export connections to zip\n         * @return {Promise<void>}\n         */\n        async exportConnections() {\n            const {\n                success,\n                msg,\n                data: { path = '' },\n            } = await ExportConnections()\n            if (!success) {\n                if (!isEmpty(msg)) {\n                    $message.error(msg)\n                }\n                return\n            }\n\n            $message.success(i18nGlobal.t('dialogue.handle_succ'))\n        },\n\n        /**\n         * import connections from zip\n         * @return {Promise<void>}\n         */\n        async importConnections() {\n            const { success, msg } = await ImportConnections()\n            if (!success) {\n                if (!isEmpty(msg)) {\n                    $message.error(msg)\n                }\n                return\n            }\n\n            $message.success(i18nGlobal.t('dialogue.handle_succ'))\n        },\n\n        /**\n         * parse redis url from text in clipboard\n         * @return {Promise<{}>}\n         */\n        async parseUrlFromClipboard() {\n            const urlString = await ClipboardGetText()\n            if (isEmpty(urlString)) {\n                throw new Error('no text in clipboard')\n            }\n\n            const { success, msg, data } = await ParseConnectURL(urlString)\n            if (!success || !isObject(data)) {\n                throw new Error(msg || 'unknown')\n            }\n\n            data.url = urlString\n            return data\n        },\n    },\n})\n\nexport default useConnectionStore\n"
  },
  {
    "path": "frontend/src/stores/dialog.js",
    "content": "import { defineStore } from 'pinia'\nimport useConnectionStore from './connections.js'\n\n/**\n * connection dialog type\n * @enum {number}\n */\nexport const ConnDialogType = {\n    NEW: 0,\n    EDIT: 1,\n}\n\nconst useDialogStore = defineStore('dialog', {\n    state: () => ({\n        connDialogVisible: false,\n        /** @type {ConnDialogType} **/\n        connType: ConnDialogType.NEW,\n        connParam: null,\n\n        groupDialogVisible: false,\n        editGroup: '',\n\n        /**\n         * @property {string} prefix\n         * @property {string} server\n         * @property {int} db\n         */\n        newKeyParam: {\n            prefix: '',\n            server: '',\n            db: 0,\n        },\n        newKeyDialogVisible: false,\n\n        keyFilterParam: {\n            server: '',\n            db: 0,\n            type: '',\n            pattern: '*',\n        },\n        keyFilterDialogVisible: false,\n\n        addFieldParam: {\n            server: '',\n            db: 0,\n            key: '',\n            keyCode: null,\n            type: null,\n        },\n        addFieldsDialogVisible: false,\n\n        renameKeyParam: {\n            server: '',\n            db: 0,\n            key: '',\n        },\n        renameDialogVisible: false,\n\n        deleteKeyParam: {\n            server: '',\n            db: 0,\n            key: '',\n        },\n        deleteKeyDialogVisible: false,\n\n        exportKeyParam: {\n            server: '',\n            db: 0,\n            keys: [],\n        },\n        exportKeyDialogVisible: false,\n\n        importKeyParam: {\n            server: '',\n            db: 0,\n        },\n        importKeyDialogVisible: false,\n\n        flushDBParam: {\n            server: '',\n            db: 0,\n        },\n        flushDBDialogVisible: false,\n\n        ttlDialogVisible: false,\n        ttlParam: {\n            server: '',\n            db: 0,\n            key: '',\n            keys: [],\n            ttl: 0,\n        },\n\n        decodeDialogVisible: false,\n        decodeParam: {\n            name: '',\n            auto: true,\n            decodePath: '',\n            decodeArgs: [],\n            encodePath: '',\n            encodeArgs: [],\n        },\n\n        preferencesDialogVisible: false,\n        preferencesTag: '',\n\n        aboutDialogVisible: false,\n    }),\n    actions: {\n        openNewDialog() {\n            this.connParam = null\n            this.connType = ConnDialogType.NEW\n            this.connDialogVisible = true\n        },\n        closeConnDialog() {\n            this.connDialogVisible = false\n        },\n\n        async openEditDialog(name) {\n            const connStore = useConnectionStore()\n            const profile = await connStore.getConnectionProfile(name)\n            this.connParam = connStore.mergeConnectionProfile(connStore.newDefaultConnection(name), profile)\n            this.connType = ConnDialogType.EDIT\n            this.connDialogVisible = true\n        },\n\n        async openDuplicateDialog(name) {\n            const connStore = useConnectionStore()\n            this.connParam = {}\n            let profile\n            let suffix = 1\n            do {\n                let profileName = name\n                if (suffix > 1) {\n                    profileName += suffix\n                }\n                profile = await connStore.getConnectionProfile(profileName)\n                if (profile != null) {\n                    suffix += 1\n                    if (profileName === name) {\n                        this.connParam = profile\n                    }\n                } else {\n                    this.connParam = connStore.mergeConnectionProfile(\n                        connStore.newDefaultConnection(profileName),\n                        this.connParam,\n                    )\n                    this.connParam.name = profileName\n                    break\n                }\n            } while (true)\n            this.connType = ConnDialogType.NEW\n            this.connDialogVisible = true\n        },\n\n        openNewGroupDialog() {\n            this.editGroup = ''\n            this.groupDialogVisible = true\n        },\n        closeNewGroupDialog() {\n            this.groupDialogVisible = false\n        },\n\n        /**\n         *\n         * @param {string} server\n         * @param {number} db\n         * @param {string} [pattern]\n         * @param {string} [type]\n         */\n        openKeyFilterDialog(server, db, pattern, type) {\n            this.keyFilterParam.server = server\n            this.keyFilterParam.db = db\n            this.keyFilterParam.type = type || ''\n            this.keyFilterParam.pattern = pattern || '*'\n            this.keyFilterDialogVisible = true\n        },\n        closeKeyFilterDialog() {\n            this.keyFilterDialogVisible = false\n        },\n\n        /**\n         *\n         * @param {string} name\n         */\n        openRenameGroupDialog(name) {\n            this.editGroup = name\n            this.groupDialogVisible = true\n        },\n        closeRenameGroupDialog() {\n            this.groupDialogVisible = false\n        },\n\n        /**\n         *\n         * @param {string} server\n         * @param {number} db\n         * @param {string} key\n         */\n        openRenameKeyDialog(server, db, key) {\n            this.renameKeyParam.server = server\n            this.renameKeyParam.db = db\n            this.renameKeyParam.key = key\n            this.renameDialogVisible = true\n        },\n        closeRenameKeyDialog() {\n            this.renameDialogVisible = false\n        },\n\n        /**\n         *\n         * @param {string} server\n         * @param {number} db\n         * @param {string|string[]} [key]\n         */\n        openDeleteKeyDialog(server, db, key = '*') {\n            this.deleteKeyParam.server = server\n            this.deleteKeyParam.db = db\n            this.deleteKeyParam.key = key\n            this.deleteKeyDialogVisible = true\n        },\n        closeDeleteKeyDialog() {\n            this.deleteKeyDialogVisible = false\n        },\n\n        /**\n         *\n         * @param {string} server\n         * @param {number} db\n         * @param {string|string[]} keys\n         */\n        openExportKeyDialog(server, db, keys) {\n            this.exportKeyParam.server = server\n            this.exportKeyParam.db = db\n            this.exportKeyParam.keys = keys\n            this.exportKeyDialogVisible = true\n        },\n        closeExportKeyDialog() {\n            this.exportKeyDialogVisible = false\n        },\n\n        /**\n         *\n         * @param {string} server\n         * @param {number} db\n         */\n        openImportKeyDialog(server, db) {\n            this.importKeyParam.server = server\n            this.importKeyParam.db = db\n            this.importKeyDialogVisible = true\n        },\n        closeImportKeyDialog() {\n            this.importKeyDialogVisible = false\n        },\n\n        openFlushDBDialog(server, db) {\n            this.flushDBParam.server = server\n            this.flushDBParam.db = db\n            this.flushDBDialogVisible = true\n        },\n        closeFlushDBDialog() {\n            this.flushDBDialogVisible = false\n        },\n\n        /**\n         *\n         * @param {string} prefix\n         * @param {string} server\n         * @param {number} db\n         */\n        openNewKeyDialog(prefix, server, db) {\n            this.newKeyParam.prefix = prefix\n            this.newKeyParam.server = server\n            this.newKeyParam.db = db\n            this.newKeyDialogVisible = true\n        },\n        closeNewKeyDialog() {\n            this.newKeyDialogVisible = false\n        },\n\n        /**\n         *\n         * @param {string} server\n         * @param {number} db\n         * @param {string} key\n         * @param {number[]|null} keyCode\n         * @param {string} type\n         */\n        openAddFieldsDialog(server, db, key, keyCode, type) {\n            this.addFieldParam.server = server\n            this.addFieldParam.db = db\n            this.addFieldParam.key = key\n            this.addFieldParam.keyCode = keyCode\n            this.addFieldParam.type = type\n            this.addFieldsDialogVisible = true\n        },\n        closeAddFieldsDialog() {\n            this.addFieldsDialogVisible = false\n        },\n\n        /**\n         *\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number[]} [key]\n         * @param {string[]|number[][]} [keys]\n         * @param {number} [ttl]\n         */\n        openTTLDialog({ server, db, key, keys, ttl = -1 }) {\n            this.ttlDialogVisible = true\n            this.ttlParam.server = server\n            this.ttlParam.db = db\n            this.ttlParam.key = key\n            this.ttlParam.keys = keys\n            this.ttlParam.ttl = ttl\n        },\n        closeTTLDialog() {\n            this.ttlDialogVisible = false\n        },\n\n        /**\n         *\n         * @param {string} name\n         * @param {boolean} auto\n         * @param {string} decodePath\n         * @param {string[]} decodeArgs\n         * @param {string} encodePath\n         * @param {string[]} encodeArgs\n         */\n        openDecoderDialog({\n            name = '',\n            auto = true,\n            decodePath = '',\n            decodeArgs = [],\n            encodePath = '',\n            encodeArgs = [],\n        } = {}) {\n            this.decodeDialogVisible = true\n            this.decodeParam.name = name\n            this.decodeParam.auto = auto !== false\n            this.decodeParam.decodePath = decodePath\n            this.decodeParam.decodeArgs = decodeArgs || []\n            this.decodeParam.encodePath = encodePath\n            this.decodeParam.encodeArgs = encodeArgs || []\n        },\n\n        closeDecoderDialog() {\n            this.decodeDialogVisible = false\n        },\n\n        openPreferencesDialog(tag = '') {\n            this.preferencesDialogVisible = true\n            this.preferencesTag = tag\n        },\n        closePreferencesDialog() {\n            this.preferencesDialogVisible = false\n            this.preferencesTag = ''\n        },\n\n        openAboutDialog() {\n            this.aboutDialogVisible = true\n        },\n        closeAboutDialog() {\n            this.aboutDialogVisible = false\n        },\n    },\n})\n\nexport default useDialogStore\n"
  },
  {
    "path": "frontend/src/stores/preferences.js",
    "content": "import { defineStore } from 'pinia'\nimport { lang } from '@/langs/index.js'\nimport { cloneDeep, findIndex, get, isEmpty, join, map, pick, set, some, split } from 'lodash'\nimport {\n    CheckForUpdate,\n    GetAppVersion,\n    GetBuildInDecoder,\n    GetFontList,\n    GetPreferences,\n    RestorePreferences,\n    SetPreferences,\n} from 'wailsjs/go/services/preferencesService.js'\nimport { BrowserOpenURL } from 'wailsjs/runtime/runtime.js'\nimport { i18nGlobal } from '@/utils/i18n.js'\nimport { enUS, NButton, NSpace, useOsTheme, zhCN } from 'naive-ui'\nimport { h, nextTick } from 'vue'\nimport { compareVersion } from '@/utils/version.js'\nimport { typesIconStyle } from '@/consts/support_redis_type.js'\nimport { TextAlignType } from '@/consts/text_align_type.js'\n\nconst osTheme = useOsTheme()\nconst usePreferencesStore = defineStore('preferences', {\n    /**\n     * @typedef {Object} FontItem\n     * @property {string} name\n     * @property {string} path\n     */\n    /**\n     * @typedef {Object} Preferences\n     * @property {Object} general\n     * @property {Object} editor\n     * @property {FontItem[]} fontList\n     */\n    /**\n     *\n     * @returns {Preferences}\n     */\n    state: () => ({\n        behavior: {\n            welcomed: false,\n            asideWidth: 300,\n            windowWidth: 0,\n            windowHeight: 0,\n            windowMaximised: false,\n        },\n        general: {\n            theme: 'auto',\n            language: 'auto',\n            font: '',\n            fontFamily: [],\n            fontSize: 14,\n            scanSize: 3000,\n            keyIconStyle: 0,\n            useSysProxy: false,\n            useSysProxyHttp: false,\n            checkUpdate: true,\n            skipVersion: '',\n            allowTrack: true,\n        },\n        editor: {\n            font: '',\n            fontFamily: [],\n            fontSize: 14,\n            showLineNum: true,\n            showFolding: true,\n            dropText: true,\n            links: true,\n            entryTextAlign: TextAlignType.Center,\n        },\n        cli: {\n            fontFamily: [],\n            fontSize: 14,\n            cursorStyle: 'block',\n        },\n        buildInDecoder: [],\n        decoder: [],\n        lastPref: {},\n        fontList: [],\n        appVersion: '',\n    }),\n    getters: {\n        getSeparator() {\n            return ':'\n        },\n\n        themeOption() {\n            return [\n                {\n                    value: 'light',\n                    label: 'preferences.general.theme_light',\n                },\n                {\n                    value: 'dark',\n                    label: 'preferences.general.theme_dark',\n                },\n                {\n                    value: 'auto',\n                    label: 'preferences.general.theme_auto',\n                },\n            ]\n        },\n\n        /**\n         * all themes' name\n         * @returns {string[]}\n         */\n        allThemes() {\n            return this.themeOption.map((o) => o.value)\n        },\n\n        /**\n         * all available language\n         * @returns {{label: string, value: string}[]}\n         */\n        langOption() {\n            const options = Object.entries(lang).map(([key, value]) => ({\n                value: key,\n                label: value['name'],\n            }))\n            options.splice(0, 0, {\n                value: 'auto',\n                label: 'preferences.general.system_lang',\n            })\n            return options\n        },\n\n        /**\n         * all languages' name\n         * @returns {string[]}\n         */\n        allLangs() {\n            return this.langOption.map((o) => o.value)\n        },\n\n        /**\n         * all system font list\n         * @returns {{path: string, label: string, value: string}[]}\n         */\n        fontOption() {\n            return map(this.fontList, (font) => ({\n                value: font.name,\n                label: font.name,\n                path: font.path,\n            }))\n        },\n\n        /**\n         * current font selection\n         * @returns {{fontSize: string, fontFamily?: string}}\n         */\n        generalFont() {\n            const fontStyle = {\n                fontSize: this.general.fontSize + 'px',\n            }\n            if (!isEmpty(this.general.fontFamily)) {\n                fontStyle['fontFamily'] = join(\n                    map(this.general.fontFamily, (f) => `\"${f}\"`),\n                    ',',\n                )\n            }\n            // compatible with old preferences\n            // if (isEmpty(fontStyle['fontFamily'])) {\n            //     if (!isEmpty(this.general.font) && this.general.font !== 'none') {\n            //         const font = find(this.fontList, { name: this.general.font })\n            //         if (font != null) {\n            //             fontStyle['fontFamily'] = `${font.name}`\n            //         }\n            //     }\n            // }\n            return fontStyle\n        },\n\n        /**\n         * current editor font\n         * @return {{fontSize: string, fontFamily?: string}}\n         */\n        editorFont() {\n            const fontStyle = {\n                fontSize: (this.editor.fontSize || 14) + 'px',\n            }\n            if (!isEmpty(this.editor.fontFamily)) {\n                fontStyle['fontFamily'] = join(\n                    map(this.editor.fontFamily, (f) => `\"${f}\"`),\n                    ',',\n                )\n            }\n            // compatible with old preferences\n            // if (isEmpty(fontStyle['fontFamily'])) {\n            //     if (!isEmpty(this.editor.font) && this.editor.font !== 'none') {\n            //         const font = find(this.fontList, { name: this.editor.font })\n            //         if (font != null) {\n            //             fontStyle['fontFamily'] = `${font.name}`\n            //         }\n            //     }\n            // }\n            if (isEmpty(fontStyle['fontFamily'])) {\n                fontStyle['fontFamily'] = ['monaco']\n            }\n            return fontStyle\n        },\n\n        /**\n         * current cli font\n         * @return {{fontSize: string, fontFamily?: string}}\n         */\n        cliFont() {\n            const fontStyle = {\n                fontSize: this.cli.fontSize || 14,\n            }\n            if (!isEmpty(this.cli.fontFamily)) {\n                fontStyle['fontFamily'] = join(\n                    map(this.cli.fontFamily, (f) => `\"${f}\"`),\n                    ',',\n                )\n            }\n            if (isEmpty(fontStyle['fontFamily'])) {\n                fontStyle['fontFamily'] = ['Courier New']\n            }\n            return fontStyle\n        },\n\n        cliCursorStyleOption() {\n            return [\n                {\n                    value: 'block',\n                    label: 'preferences.cli.cursor_style_block',\n                },\n                {\n                    value: 'underline',\n                    label: 'preferences.cli.cursor_style_underline',\n                },\n                {\n                    value: 'bar',\n                    label: 'preferences.cli.cursor_style_bar',\n                },\n            ]\n        },\n\n        /**\n         * get current language setting\n         * @return {string}\n         */\n        currentLanguage() {\n            let lang = get(this.general, 'language', 'auto')\n            if (lang === 'auto') {\n                const systemLang = navigator.language || navigator.userLanguage\n                lang = split(systemLang, '-')[0]\n            }\n            return lang || 'en'\n        },\n\n        isDark() {\n            const th = get(this.general, 'theme', 'auto')\n            if (th !== 'auto') {\n                return th === 'dark'\n            } else {\n                return osTheme.value === 'dark'\n            }\n        },\n\n        themeLocale() {\n            const lang = this.currentLanguage\n            switch (lang) {\n                case 'zh':\n                    return zhCN\n                default:\n                    return enUS\n            }\n        },\n\n        autoCheckUpdate() {\n            return get(this.general, 'checkUpdate', false)\n        },\n\n        showLineNum() {\n            return get(this.editor, 'showLineNum', true)\n        },\n\n        showFolding() {\n            return get(this.editor, 'showFolding', true)\n        },\n\n        dropText() {\n            return get(this.editor, 'dropText', true)\n        },\n\n        editorLinks() {\n            return get(this.editor, 'links', true)\n        },\n\n        keyIconType() {\n            return get(this.general, 'keyIconStyle', typesIconStyle.SHORT)\n        },\n\n        entryTextAlign() {\n            return get(this.editor, 'entryTextAlign', TextAlignType.Center)\n        },\n    },\n    actions: {\n        _applyPreferences(data) {\n            for (const key in data) {\n                set(this, key, data[key])\n            }\n        },\n\n        /**\n         * load preferences from local\n         * @returns {Promise<boolean>}\n         */\n        async loadPreferences() {\n            const { success, data } = await GetPreferences()\n            if (success) {\n                this.lastPref = cloneDeep(data)\n                this._applyPreferences(data)\n                // default value\n                const showLineNum = get(data, 'editor.showLineNum')\n                if (showLineNum === undefined) {\n                    set(data, 'editor.showLineNum', true)\n                }\n                const showFolding = get(data, 'editor.showFolding')\n                if (showFolding === undefined) {\n                    set(data, 'editor.showFolding', true)\n                }\n                const dropText = get(data, 'editor.dropText')\n                if (dropText === undefined) {\n                    set(data, 'editor.dropText', true)\n                }\n                const links = get(data, 'editor.links')\n                if (links === undefined) {\n                    set(data, 'editor.links', true)\n                }\n                i18nGlobal.locale.value = this.currentLanguage\n            }\n            return success\n        },\n\n        /**\n         * load system font list\n         * @returns {Promise<string[]>}\n         */\n        async loadFontList() {\n            const { success, data } = await GetFontList()\n            if (success) {\n                const { fonts = [] } = data\n                this.fontList = fonts\n            } else {\n                this.fontList = []\n            }\n            return this.fontList\n        },\n\n        /**\n         * get all available build-in decoder\n         * @return {Promise<void>}\n         */\n        async loadBuildInDecoder() {\n            const { success, data } = await GetBuildInDecoder()\n            if (success) {\n                const { decoder = [] } = data\n                this.buildInDecoder = decoder\n            } else {\n                this.buildInDecoder = []\n            }\n        },\n\n        /**\n         * load app version\n         * @return {Promise<void>}\n         */\n        async loadAppVersion() {\n            const { success, data } = await GetAppVersion()\n            if (success && data?.version) {\n                this.appVersion = data.version\n            }\n        },\n\n        /**\n         * save preferences to local\n         * @returns {Promise<boolean>}\n         */\n        async savePreferences() {\n            const pf = pick(this, ['behavior', 'general', 'editor', 'cli', 'decoder'])\n            const { success } = await SetPreferences(pf)\n            return success === true\n        },\n\n        /**\n         * reset to last-loaded preferences\n         * @returns {Promise<void>}\n         */\n        async resetToLastPreferences() {\n            if (!isEmpty(this.lastPref)) {\n                this._applyPreferences(this.lastPref)\n            }\n        },\n\n        /**\n         * restore preferences to default\n         * @returns {Promise<boolean>}\n         */\n        async restorePreferences() {\n            const { success, data } = await RestorePreferences()\n            if (success === true) {\n                const { pref } = data\n                this._applyPreferences(pref)\n                return true\n            }\n            return false\n        },\n\n        /**\n         * add a new custom decoder\n         * @param {string} name\n         * @param {boolean} enable\n         * @param {boolean} auto\n         * @param {string} encodePath\n         * @param {string[]} encodeArgs\n         * @param {string} decodePath\n         * @param {string[]} decodeArgs\n         */\n        addCustomDecoder({ name, enable = true, auto = true, encodePath, encodeArgs, decodePath, decodeArgs }) {\n            if (some(this.decoder, { name })) {\n                return false\n            }\n            this.decoder = this.decoder || []\n            this.decoder.push({ name, enable, auto, encodePath, encodeArgs, decodePath, decodeArgs })\n            return true\n        },\n\n        /**\n         * update an existing custom decoder\n         * @param {string} newName\n         * @param {boolean} enable\n         * @param {boolean} auto\n         * @param {string} name\n         * @param {string} encodePath\n         * @param {string[]} encodeArgs\n         * @param {string} decodePath\n         * @param {string[]} decodeArgs\n         */\n        updateCustomDecoder({\n            newName,\n            enable = true,\n            auto = true,\n            name,\n            encodePath,\n            encodeArgs,\n            decodePath,\n            decodeArgs,\n        }) {\n            const idx = findIndex(this.decoder, { name })\n            if (idx === -1) {\n                return false\n            }\n            // conflicted\n            if (newName !== name && some(this.decoder, { name: newName })) {\n                return false\n            }\n\n            let selDecoder = this.decoder[idx]\n            selDecoder.name = newName || name\n            selDecoder.enable = enable\n            selDecoder.auto = auto\n            selDecoder.encodePath = encodePath\n            selDecoder.encodeArgs = encodeArgs\n            selDecoder.decodePath = decodePath\n            selDecoder.decodeArgs = decodeArgs\n            this.decoder[idx] = selDecoder\n            return true\n        },\n\n        /**\n         * remove an existing custom decoder\n         * @param {string} name\n         * @return {boolean}\n         */\n        removeCustomDecoder(name) {\n            const idx = findIndex(this.decoder, { name })\n            if (idx === -1) {\n                return false\n            }\n            this.decoder.splice(idx, 1)\n            return true\n        },\n\n        setAsWelcomed(acceptTrack) {\n            this.behavior.welcomed = true\n            this.general.allowTrack = acceptTrack\n            this.savePreferences()\n        },\n\n        async checkForUpdate(manual = false) {\n            let msgRef = null\n            if (manual) {\n                msgRef = $message.loading(i18nGlobal.t('interface.retrieving_version'), { duration: 0 })\n            }\n            try {\n                const { success, data = {} } = await CheckForUpdate()\n                if (success) {\n                    const {\n                        version = 'v1.0.0',\n                        latest,\n                        download_page: pageUrl = {},\n                        description = {},\n                        sponsor = [],\n                        banner = [],\n                    } = data\n                    const downUrl = pageUrl[this.currentLanguage] || pageUrl['en']\n                    const descStr = description[this.currentLanguage] || description['en']\n                    // save sponsor ad\n                    if (!isEmpty(sponsor)) {\n                        localStorage.setItem('sponsor_ad', JSON.stringify(sponsor))\n                    }\n                    if (!isEmpty(banner)) {\n                        localStorage.setItem('banner', JSON.stringify(banner))\n                    }\n                    if (\n                        (manual || compareVersion(latest, this.general.skipVersion) !== 0) &&\n                        compareVersion(latest, version) > 0 &&\n                        !isEmpty(downUrl)\n                    ) {\n                        const notiRef = $notification.show({\n                            title: `${i18nGlobal.t('dialogue.upgrade.title')} - ${latest}`,\n                            content: descStr || i18nGlobal.t('dialogue.upgrade.new_version_tip', { ver: latest }),\n                            action: () =>\n                                h('div', { class: 'flex-box-h flex-item-expand' }, [\n                                    h(NSpace, { wrapItem: false }, () => [\n                                        h(\n                                            NButton,\n                                            {\n                                                size: 'small',\n                                                secondary: true,\n                                                onClick: () => {\n                                                    // skip this update\n                                                    this.general.skipVersion = latest\n                                                    this.savePreferences()\n                                                    notiRef.destroy()\n                                                },\n                                            },\n                                            () => i18nGlobal.t('dialogue.upgrade.skip'),\n                                        ),\n                                        h(\n                                            NButton,\n                                            {\n                                                size: 'small',\n                                                secondary: true,\n                                                onClick: notiRef.destroy,\n                                            },\n                                            () => i18nGlobal.t('dialogue.upgrade.later'),\n                                        ),\n                                        h(\n                                            NButton,\n                                            {\n                                                type: 'primary',\n                                                size: 'small',\n                                                secondary: true,\n                                                onClick: () => BrowserOpenURL(downUrl),\n                                            },\n                                            () => i18nGlobal.t('dialogue.upgrade.download_now'),\n                                        ),\n                                    ]),\n                                ]),\n                            onPositiveClick: () => BrowserOpenURL(downUrl),\n                        })\n                        return\n                    }\n                }\n\n                if (manual) {\n                    $message.info(i18nGlobal.t('dialogue.upgrade.no_update'))\n                }\n            } finally {\n                nextTick().then(() => {\n                    if (msgRef != null) {\n                        msgRef.destroy()\n                        msgRef = null\n                    }\n                })\n            }\n        },\n    },\n})\n\nexport default usePreferencesStore\n"
  },
  {
    "path": "frontend/src/stores/tab.js",
    "content": "import { assign, find, findIndex, get, includes, indexOf, isEmpty, pullAt, remove, set, size } from 'lodash'\nimport { defineStore } from 'pinia'\nimport { TabItem } from '@/objects/tabItem.js'\nimport useBrowserStore from 'stores/browser.js'\nimport { i18nGlobal } from '@/utils/i18n.js'\nimport { BrowserTabType } from '@/consts/browser_tab_type.js'\n\nconst useTabStore = defineStore('tab', {\n    /**\n     * @typedef {Object} ListEntryItem\n     * @property {string|number[]} v value\n     * @property {string} [dv] display value\n     */\n\n    /**\n     * @typedef {Object} ListReplaceItem\n     * @property {number} index\n     * @property {string|number[]} v value\n     * @property {string} [dv] display value\n     */\n\n    /**\n     * @typedef {Object} HashEntryItem\n     * @property {string} k field name\n     * @property {string|number[]} v value\n     * @property {string} [dv] display value\n     */\n\n    /**\n     * @typedef {Object} HashReplaceItem\n     * @property {string|number[]} k field name\n     * @property {string|number[]} nk new field name\n     * @property {string|number[]} v value\n     * @property {string} [dv] display value\n     */\n\n    /**\n     * @typedef {Object} SetEntryItem\n     * @property {string|number[]} v value\n     * @property {string} [dv] display value\n     */\n\n    /**\n     * @typedef {Object} ZSetEntryItem\n     * @property {number} s score\n     * @property {string|number[]} v value\n     * @property {string} [dv] display value\n     */\n\n    /**\n     * @typedef {Object} ZSetReplaceItem\n     * @property {number} s score\n     * @property {string|number[]} v value\n     * @property {string|number[]} nv new value\n     * @property {string} [dv] display value\n     */\n\n    /**\n     * @typedef {Object} StreamEntryItem\n     * @property {string} id\n     * @property {Object.<string, *>} v value\n     * @property {string} [dv] display value\n     */\n\n    /**\n     * @typedef {Object} TabState\n     * @property {string} nav\n     * @property {number} asideWidth\n     * @property {TabItem[]} tabList\n     * @property {number} activatedIndex\n     */\n\n    /**\n     *\n     * @returns {TabState}\n     */\n    state: () => ({\n        nav: 'server',\n        asideWidth: 300,\n        tabList: [],\n        activatedIndex: 0, // current activated tab index\n    }),\n    getters: {\n        /**\n         * get current tab list item\n         * @returns {TabItem[]}\n         */\n        tabs() {\n            // if (isEmpty(this.tabList)) {\n            //     this.newBlankTab()\n            // }\n            return this.tabList\n        },\n\n        /**\n         * get current activated tab item\n         * @returns {TabItem|null}\n         */\n        currentTab() {\n            return get(this.tabs, this.activatedIndex)\n        },\n\n        currentTabName() {\n            return get(this.tabs, [this.activatedIndex, 'name'])\n        },\n\n        currentCheckedKeys() {\n            const tab = this.currentTab\n            return get(tab, 'checkedKeys', [])\n        },\n    },\n    actions: {\n        /**\n         *\n         * @param idx\n         * @param {boolean} [switchNav]\n         * @param {string} [subTab]\n         * @private\n         */\n        _setActivatedIndex(idx, switchNav, subTab) {\n            this.activatedIndex = idx\n            if (switchNav === true) {\n                this.nav = idx >= 0 ? 'browser' : 'server'\n                if (!isEmpty(subTab)) {\n                    set(this.tabList, [idx, 'subTab'], subTab)\n                }\n            } else {\n                if (idx < 0) {\n                    this.nav = 'server'\n                }\n            }\n        },\n\n        openBlank(server) {\n            this.upsertTab({ server, clearValue: true })\n        },\n\n        /**\n         *\n         * @param {string} tabName\n         */\n        closeTab(tabName) {\n            $dialog.warning(i18nGlobal.t('dialogue.close_confirm', { name: tabName }), () => {\n                const browserStore = useBrowserStore()\n                browserStore.closeConnection(tabName)\n            })\n        },\n\n        /**\n         * update or insert a new tab if not exists with the same name\n         * @param {string} subTab\n         * @param {string} server\n         * @param {number} [db]\n         * @param {number} [type]\n         * @param {number} [ttl]\n         * @param {string} [key]\n         * @param {string} [keyCode]\n         * @param {number} [size]\n         * @param {number} [length]\n         * @param {string} [matchPattern]\n         * @param {boolean} [clearValue]\n         * @param {string} format\n         * @param {string} decode\n         * @param {boolean} forceSwitch\n         * @param {*} [value]\n         */\n        upsertTab({\n            subTab,\n            server,\n            db,\n            type,\n            ttl,\n            key,\n            keyCode,\n            size,\n            length,\n            matchPattern = '',\n            clearValue,\n            format = '',\n            decode = '',\n            forceSwitch = false,\n        }) {\n            let tabIndex = findIndex(this.tabList, { name: server })\n            if (tabIndex === -1) {\n                subTab = subTab || BrowserTabType.Status\n                const tabItem = new TabItem({\n                    name: server,\n                    title: server,\n                    subTab,\n                    server,\n                    db,\n                    type,\n                    ttl,\n                    key,\n                    keyCode,\n                    size,\n                    length,\n                    matchPattern,\n                    value: undefined,\n                    format,\n                    decode,\n                })\n                this.tabList.push(tabItem)\n                tabIndex = this.tabList.length - 1\n                this._setActivatedIndex(tabIndex, true, subTab)\n            } else {\n                const tab = this.tabList[tabIndex]\n                tab.blank = false\n                tab.subTab = subTab || tab.subTab\n                // tab.title = db !== undefined ? `${server}/db${db}` : `${server}`\n                tab.title = server\n                tab.server = server\n                tab.db = db == null ? tab.db : db\n                tab.type = type\n                tab.ttl = ttl\n                tab.key = key\n                tab.keyCode = keyCode\n                tab.size = size\n                tab.length = length\n                tab.matchPattern = matchPattern\n                tab.format = format\n                tab.decode = decode\n                if (clearValue === true) {\n                    tab.value = undefined\n                }\n                if (forceSwitch === true) {\n                    this._setActivatedIndex(tabIndex, true, subTab)\n                }\n            }\n        },\n\n        /**\n         * keep update value in tab\n         * @param {string} server\n         * @param {number} db\n         * @param {string} key\n         * @param {*} [value]\n         * @param {string} [format]\n         * @param {string] [decode]\n         * @param {string} [matchPattern]\n         * @param {boolean} [reset]\n         * @param {boolean} [end] keep end status if not set\n         * @param {number} [size]\n         * @param {number} [length]\n         */\n        updateValue({ server, db, key, value, format, decode, matchPattern, reset, end, size = -1, length = -1 }) {\n            const tabData = find(this.tabList, { name: server, db, key })\n            if (tabData == null) {\n                return\n            }\n\n            tabData.format = format || tabData.format\n            tabData.decode = decode || tabData.decode\n            tabData.matchPattern = matchPattern || ''\n            if (size >= 0) {\n                tabData.size = size\n            }\n            if (length >= 0) {\n                tabData.length = length\n            }\n            if (typeof end === 'boolean') {\n                tabData.end = end\n            }\n            if (!!!reset && typeof value === 'object') {\n                if (value instanceof Array) {\n                    tabData.value = tabData.value || []\n                    // direct deconstruction leads to 'Maximum call stack size exceeded'？\n                    // tabData.value.push(...value)\n                    for (let i = 0; i < value.length; i++) {\n                        tabData.value.push(value[i])\n                    }\n                } else {\n                    tabData.value = assign(value, tabData.value || {})\n                }\n            } else {\n                tabData.value = value\n            }\n        },\n\n        /**\n         * insert entries\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number[]} key\n         * @param {string} type\n         * @param {ListEntryItem[]|HashEntryItem[]|SetEntryItem[]|ZSetEntryItem[]|StreamEntryItem[]} entries\n         * @param {boolean} [prepend] for list only\n         */\n        insertValueEntries({ server, db, key, type, entries, prepend }) {\n            const tab = find(this.tabList, { name: server, db, key })\n            if (tab == null) {\n                return\n            }\n\n            switch (type.toLowerCase()) {\n                case 'list': // {v:string, dv:[string]}[]\n                    tab.value = tab.value || []\n                    if (prepend === true) {\n                        const originList = tab.value\n                        const list = []\n                        let starIndex = 0\n                        for (const entry of entries) {\n                            entry.index = starIndex++\n                            list.push(entry)\n                        }\n                        for (const entry of originList) {\n                            entry.index = starIndex++\n                            list.push(entry)\n                        }\n                        tab.value = list\n                    } else {\n                        const list = tab.value\n                        let starIndex = list.length\n                        for (const entry of entries) {\n                            entry.index = starIndex++\n                            list.push(entry)\n                        }\n                    }\n                    tab.length += size(entries)\n                    break\n\n                case 'hash': // {k:string, v:string, dv:[string]}[]\n                case 'set': // {v: string, s: number}[]\n                case 'zset': // {v: string, s: number}[]\n                    tab.value = tab.value || []\n                    tab.value.push(...entries)\n                    tab.length += size(entries)\n                    break\n\n                case 'stream': // {id: string, v: {}}[]\n                    tab.value = tab.value || []\n                    tab.value = [...entries, ...tab.value]\n                    tab.length += size(entries)\n                    break\n            }\n        },\n\n        /**\n         * update entries' value\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number[]} key\n         * @param {string} type\n         * @param {ListEntryItem[]|HashEntryItem[]|SetEntryItem[]|ZSetEntryItem[]|StreamEntryItem[]} entries\n         */\n        updateValueEntries({ server, db, key, type, entries }) {\n            const tab = find(this.tabList, { name: server, db, key })\n            if (tab == null) {\n                return\n            }\n\n            switch (type.toLowerCase()) {\n                case 'hash': // {k:string, v:string, dv:string}[]\n                    tab.value = tab.value || []\n                    for (const entry of entries) {\n                        let updated = false\n                        for (const val of tab.value) {\n                            if (val.k === entry.k) {\n                                val.v = entry.v\n                                val.dv = entry.dv\n                                updated = true\n                                break\n                            }\n                        }\n                        if (!updated) {\n                            // no match element, append\n                            tab.value.push(entry)\n                            tab.length += 1\n                        }\n                    }\n                    break\n\n                case 'zset': // {s:number, v:string, dv:string}[]\n                    tab.value = tab.value || []\n                    for (const entry of entries) {\n                        let updated = false\n                        for (const val of tab.value) {\n                            if (val.v === entry.v) {\n                                val.s = entry.s\n                                val.dv = entry.dv\n                                updated = true\n                                break\n                            }\n                        }\n                        if (!updated) {\n                            // no match element, append\n                            tab.value.push(entry)\n                            tab.length += 1\n                        }\n                    }\n                    break\n            }\n        },\n\n        /**\n         * replace entry item key or field in value(modify the index key)\n         * @param {string} server\n         * @param {number} db\n         * @param {string|number[]} key\n         * @param {string} type\n         * @param {ListReplaceItem[]|HashReplaceItem[]|ZSetReplaceItem[]} entries\n         * @param {number[]} [index] indexes for replacement, can improve search efficiency if configured\n         */\n        replaceValueEntries({ server, db, key, type, entries, index }) {\n            const tab = find(this.tabList, { name: server, db, key })\n            if (tab == null) {\n                return\n            }\n\n            switch (type.toLowerCase()) {\n                case 'list': // ListReplaceItem[]\n                    tab.value = tab.value || []\n                    for (const entry of entries) {\n                        if (size(tab.value) > entry.index) {\n                            tab.value[entry.index] = {\n                                index: entry.index,\n                                v: entry.v,\n                                dv: entry.dv,\n                            }\n                        } else {\n                            // out of range, append\n                            tab.value.push(entry)\n                            tab.length += 1\n                        }\n                    }\n                    break\n\n                case 'hash': // HashReplaceItem[]\n                    tab.value = tab.value || []\n                    for (const idx of index) {\n                        const entry = get(tab.value, idx)\n                        if (entry != null) {\n                            /** @type HashReplaceItem[] **/\n                            const replaceEntry = remove(entries, (e) => e.k === entry.k)\n                            if (!isEmpty(replaceEntry)) {\n                                entry.k = replaceEntry[0].nk\n                                entry.v = replaceEntry[0].v\n                                entry.dv = replaceEntry[0].dv\n                            }\n                        }\n                    }\n\n                    // the left entries do not included in index list, try to retrieve the whole list\n                    for (const entry of entries) {\n                        let updated = false\n                        for (const val of tab.value) {\n                            if (val.k === entry.k) {\n                                val.k = entry.nk\n                                val.v = entry.v\n                                val.dv = entry.dv\n                                updated = true\n                                break\n                            }\n                        }\n                        if (!updated) {\n                            // no match element, append\n                            tab.value.push({\n                                k: entry.nk,\n                                v: entry.v,\n                                dv: entry.dv,\n                            })\n                            tab.length += 1\n                        }\n                    }\n                    break\n\n                case 'zset': // ZSetReplaceItem[]\n                    tab.value = tab.value || []\n                    for (const idx of index) {\n                        const entry = get(tab.value, idx)\n                        if (entry != null) {\n                            /** @type ZSetReplaceItem[] **/\n                            const replaceEntry = remove(entries, ({ v }) => v === entry.k)\n                            if (!isEmpty(replaceEntry)) {\n                                entry.s = replaceEntry[0].s\n                                entry.v = replaceEntry[0].nv\n                                entry.dv = replaceEntry[0].dv\n                            }\n                        }\n                    }\n\n                    // the left entries do not included in index list, try to retrieve the whole list\n                    for (const entry of entries) {\n                        let updated = false\n                        for (const val of tab.value) {\n                            if (val.v === entry.v) {\n                                val.s = entry.s\n                                val.v = entry.nv\n                                val.dv = entry.dv\n                                updated = true\n                                break\n                            }\n                        }\n                        if (!updated) {\n                            // no match element, append\n                            tab.value.push({\n                                s: entry.s,\n                                v: entry.nv,\n                                dv: entry.dv,\n                            })\n                            tab.length += 1\n                        }\n                    }\n                    break\n            }\n        },\n\n        /**\n         * remove value entries\n         * @param {string} server\n         * @param {number} db\n         * @param {string} key\n         * @param {string} type\n         * @param {string[] | number[]} entries\n         */\n        removeValueEntries({ server, db, key, type, entries }) {\n            const tab = find(this.tabList, { name: server, db, key })\n            if (tab == null) {\n                return\n            }\n\n            switch (type.toLowerCase()) {\n                case 'list': // string[] | number[]\n                    tab.value = tab.value || []\n                    if (typeof entries[0] === 'number') {\n                        // remove by index, sort by desc first\n                        entries.sort((a, b) => b - a)\n                        const removed = pullAt(tab.value, ...entries)\n                        tab.length -= size(removed)\n                    } else {\n                        // append or prepend items\n                        for (const elem of entries) {\n                            if (!isEmpty(remove(tab.value, elem))) {\n                                tab.length -= 1\n                            }\n                        }\n                    }\n                    break\n\n                case 'hash': // string[]\n                    tab.value = tab.value || {}\n                    for (const k of entries) {\n                        for (let i = 0; i < tab.value.length; i++) {\n                            if (tab.value[i].k === k) {\n                                tab.value.splice(i, 1)\n                                tab.length -= 1\n                                break\n                            }\n                        }\n                    }\n                    break\n\n                case 'set': // string[]\n                case 'zset': // string[]\n                    tab.value = tab.value || []\n                    for (const v of entries) {\n                        for (let i = 0; i < tab.value.length; i++) {\n                            if (tab.value[i].v === v) {\n                                tab.value.splice(i, 1)\n                                tab.length -= 1\n                                break\n                            }\n                        }\n                    }\n                    break\n\n                case 'stream': // string[]\n                    tab.value = tab.value || []\n                    for (const id of entries) {\n                        for (let i = 0; i < tab.value.length; i++) {\n                            if (tab.value[i].id === id) {\n                                tab.value.splice(i, 1)\n                                tab.length -= 1\n                                break\n                            }\n                        }\n                    }\n                    break\n            }\n        },\n\n        /**\n         * update loading status of content in tab\n         * @param {string} server\n         * @param {number} db\n         * @param {boolean} loading\n         */\n        updateLoading({ server, db, loading }) {\n            const tab = find(this.tabList, { name: server, db })\n            if (tab == null) {\n                return\n            }\n\n            tab.loading = loading\n        },\n\n        /**\n         * update ttl in tab\n         * @param {string} server\n         * @param {number} db\n         * @param {string} key\n         * @param {number} ttl\n         */\n        updateTTL({ server, db, key, ttl }) {\n            let tab = find(this.tabList, { name: server, db, key })\n            if (tab == null) {\n                return\n            }\n            tab.ttl = ttl\n        },\n\n        /**\n         * set tab's content to empty\n         * @param {string} name\n         */\n        emptyTab(name) {\n            const tab = find(this.tabList, { name })\n            if (tab != null) {\n                tab.key = null\n                tab.value = null\n            }\n        },\n        switchTab(tabIndex) {\n            // const len = size(this.tabList)\n            // if (tabIndex < 0 || tabIndex >= len) {\n            //     tabIndex = 0\n            // }\n            // this.activatedIndex = tabIndex\n            // const tabIndex = findIndex(this.tabList, {name})\n            // if (tabIndex === -1) {\n            //     return\n            // }\n            // this.activatedIndex = tabIndex\n        },\n\n        switchSubTab(name) {\n            const tab = this.currentTab\n            if (tab == null) {\n                return\n            }\n            tab.subTab = name\n        },\n\n        /**\n         *\n         * @param {number} tabIndex\n         * @returns {*|null}\n         */\n        removeTab(tabIndex) {\n            const len = size(this.tabs)\n            // ignore remove last blank tab\n            if (len === 1 && this.tabs[0].blank) {\n                return null\n            }\n\n            if (tabIndex < 0 || tabIndex >= len) {\n                return null\n            }\n            const removed = this.tabList.splice(tabIndex, 1)\n\n            // update select index if removed index equal current selected\n            this.activatedIndex -= 1\n            if (this.activatedIndex < 0) {\n                if (this.tabList.length > 0) {\n                    this._setActivatedIndex(0, false)\n                } else {\n                    this._setActivatedIndex(-1, false)\n                }\n            } else {\n                this._setActivatedIndex(this.activatedIndex, false)\n            }\n\n            return size(removed) > 0 ? removed[0] : null\n        },\n\n        /**\n         *\n         * @param {string} tabName\n         */\n        removeTabByName(tabName) {\n            const idx = findIndex(this.tabs, { name: tabName })\n            if (idx !== -1) {\n                this.removeTab(idx)\n            }\n        },\n\n        /**\n         *\n         */\n        removeAllTab() {\n            this.tabList = []\n            this._setActivatedIndex(-1, false)\n        },\n\n        /**\n         * set expanded keys for server\n         * @param {string} server\n         * @param {string|string[]} keys\n         */\n        setExpandedKeys(server, keys = []) {\n            /** @type TabItem**/\n            let tab = find(this.tabList, { name: server })\n            if (tab != null) {\n                if (typeof keys === 'string') {\n                    keys = [keys]\n                }\n                if (isEmpty(keys)) {\n                    tab.expandedKeys = []\n                } else {\n                    tab.expandedKeys = keys\n                }\n            }\n        },\n\n        /**\n         *\n         * @param {string} server\n         * @param {string|string[]} keys\n         */\n        addExpandedKey(server, keys) {\n            /** @type TabItem**/\n            let tab = find(this.tabList, { name: server })\n            if (tab != null) {\n                if (typeof keys === 'string') {\n                    keys = [keys]\n                }\n                for (const k of keys) {\n                    if (!includes(tab.expandedKeys, k)) {\n                        tab.expandedKeys.push(k)\n                    }\n                }\n            }\n        },\n\n        /**\n         *\n         * @param {string} server\n         * @param {string} key\n         */\n        toggleExpandKey(server, key) {\n            /** @type TabItem**/\n            let tab = find(this.tabList, { name: server })\n            if (tab != null) {\n                const idx = indexOf(tab.expandedKeys, key)\n                if (idx === -1) {\n                    tab.expandedKeys.push(key)\n                } else {\n                    tab.expandedKeys.splice(idx, 1)\n                }\n            }\n        },\n\n        /**\n         *\n         * @param {string} server\n         * @param {string} key\n         */\n        removeExpandedKey(server, key) {\n            /** @type TabItem**/\n            let tab = find(this.tabList, { name: server })\n            if (tab != null) {\n                remove(tab.expandedKeys, (v) => v === key)\n            }\n        },\n\n        /**\n         * set selected keys for server\n         * @param {string} server\n         * @param {string|string[]} [keys]\n         */\n        setSelectedKeys(server, keys = null) {\n            /** @type TabItem**/\n            let tab = find(this.tabList, { name: server })\n            if (tab != null) {\n                if (keys == null) {\n                    // select nothing\n                    tab.selectedKeys = []\n                    tab.activatedKey = null\n                } else if (typeof keys === 'string') {\n                    tab.selectedKeys = [keys]\n                } else {\n                    tab.selectedKeys = keys\n                }\n            }\n        },\n\n        /**\n         * get checked keys\n         * @param server\n         * @returns {CheckedKey[]}\n         */\n        getCheckedKeys(server) {\n            /** @type TabItem**/\n            let tab = find(this.tabList, { name: server })\n            if (tab != null) {\n                return tab.checkedKeys || []\n            }\n            return []\n        },\n\n        /**\n         * set checked keys for server\n         * @param {string} server\n         * @param {CheckedKey[]} [keys]\n         */\n        setCheckedKeys(server, keys = null) {\n            /** @type TabItem**/\n            let tab = find(this.tabList, { name: server })\n            if (tab != null) {\n                if (isEmpty(keys)) {\n                    // select nothing\n                    tab.checkedKeys = []\n                } else {\n                    tab.checkedKeys = keys\n                }\n            }\n        },\n\n        /**\n         * get activated key\n         * @param {string} server\n         * @return {string|null}\n         */\n        getActivatedKey(server) {\n            let tab = find(this.tabList, { name: server })\n            return get(tab, 'activatedKey')\n        },\n\n        /**\n         * set activated key and return current activatedKey\n         * @param {string} server\n         * @param {string} key\n         * @return {boolean}\n         */\n        setActivatedKey(server, key) {\n            /** @type TabItem**/\n            let tab = find(this.tabList, { name: server })\n            if (tab != null) {\n                if (!isEmpty(key) && key !== tab.activatedKey) {\n                    tab.activatedKey = key\n                    return true\n                }\n            }\n            return false\n        },\n    },\n})\n\nexport default useTabStore\n"
  },
  {
    "path": "frontend/src/styles/content.scss",
    "content": ".content-container {\n    height: 100%;\n    overflow: hidden;\n    box-sizing: border-box;\n}\n\n.empty-content {\n    height: 100%;\n    justify-content: center;\n}\n\n.content-log {\n    padding: 20px;\n}\n\n.content-value {\n    user-select: text;\n    cursor: text;\n}\n\n.tab-content {\n}\n\n:deep(.cmd-line) {\n    word-wrap: break-word;\n    white-space: pre-wrap;\n    word-break: break-all;\n}\n"
  },
  {
    "path": "frontend/src/styles/style.scss",
    "content": ":root {\n    //--bg-color: #f8f8f8;\n    //--bg-color-accent: #fff;\n    //--bg-color-page: #f2f3f5;\n    //--text-color-regular: #606266;\n    //--border-color: #dcdfe6;\n    --transition-duration-fast: 0.2s;\n    --transition-function-ease-in-out-bezier: cubic-bezier(0.645, 0.045, 0.355, 1);\n}\n\nhtml {\n    //text-align: center;\n    cursor: default;\n    -webkit-user-select: none; /* Chrome, Safari */\n    -moz-user-select: none; /* Firefox */\n    user-select: none;\n    overscroll-behavior: none;\n}\n\nbody {\n    margin: 0;\n    padding: 0;\n    background-color: #0000;\n    line-height: 1.5;\n    font-family: v-sans, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n    overflow: hidden;\n    overscroll-behavior: none;\n}\n\n@mixin bottom-shadow($transparent) {\n    box-shadow: 0 5px 5px -5px rgba(0, 0, 0, $transparent);\n}\n\n@mixin top-shadow($transparent) {\n    box-shadow: 0 -5px 5px -5px rgba(0, 0, 0, $transparent);\n}\n\n#app {\n    height: 100vh;\n    height: 100dvh;\n}\n\n.flex-box {\n    display: flex;\n}\n\n.flex-box-v {\n    @extend .flex-box;\n    flex-direction: column;\n}\n\n.flex-box-h {\n    @extend .flex-box;\n    flex-direction: row;\n}\n\n.flex-item {\n    flex: 0 0 auto;\n}\n\n.flex-item-expand {\n    flex-grow: 1;\n}\n\n.clickable {\n    cursor: pointer;\n}\n\n.wordline {\n    word-break: break-all;\n}\n\n.icon-btn {\n    @extend .clickable;\n    line-height: 100%;\n}\n\n.ellipsis {\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n}\n\n.fill-height {\n    height: 100%;\n}\n\n.text-block {\n    white-space: pre-line;\n}\n\n.content-wrapper {\n    height: 100%;\n    flex-grow: 1;\n    overflow: hidden;\n    gap: 5px;\n    padding-top: 5px;\n    //padding: 5px;\n    box-sizing: border-box;\n    position: relative;\n\n    .tb2 {\n        gap: 5px;\n        justify-content: flex-end;\n        align-items: center;\n    }\n\n    .value-wrapper {\n        //border-top: v-bind('themeVars.borderColor') 1px solid;\n        user-select: text;\n        //height: 100%;\n        box-sizing: border-box;\n    }\n\n    .value-item-part {\n        padding: 0 5px;\n    }\n\n    .value-footer {\n        @include top-shadow(0.1);\n        align-items: center;\n        gap: 0;\n        padding: 3px 10px 3px 10px;\n        height: 30px;\n    }\n}\n\n.n-dynamic-input-item {\n    align-items: center;\n    gap: 10px;\n}\n\n.n-tree-node-content__text {\n    @extend .ellipsis;\n}\n\n.context-menu-item {\n    min-width: 100px;\n    padding-right: 10px;\n}\n\n.nav-pane-container {\n    overflow: hidden;\n\n    .nav-pane-func {\n        align-items: center;\n        justify-content: flex-end;\n        gap: 3px;\n        padding: 3px 8px;\n        min-height: 30px;\n\n        .nav-pane-func-btn {\n            padding: 3px;\n            border-radius: 3px;\n            box-sizing: border-box;\n        }\n    }\n}\n\n.n-modal-mask {\n    --wails-draggable: drag;\n}\n\n.n-tabs .n-tabs-nav {\n    line-height: 1.3;\n}\n\n// animations\n.fade-enter-active,\n.fade-leave-active {\n    transition: opacity 0.3s ease;\n}\n\n.fade-enter-from,\n.fade-leave-to {\n    opacity: 0;\n}\n\n.auto-rotate {\n    animation: rotate 2s steps(60) infinite;\n}\n\n.pre-wrap {\n    white-space: pre-wrap;\n}\n\n@keyframes rotate {\n    100% {\n        transform: rotate(360deg);\n    }\n}\n"
  },
  {
    "path": "frontend/src/utils/analytics.js",
    "content": "let inited = false\n\n/**\n * load umami analytics module\n * @param {boolean} allowTrack\n * @return {Promise<void>}\n */\nexport const loadModule = async (allowTrack = true) => {\n    try {\n        await new Promise((resolve, reject) => {\n            const script = document.createElement('script')\n            script.setAttribute('src', 'https://analytics.tinycraft.cc/script.js')\n            script.setAttribute('data-website-id', 'ad6de51d-1e27-44a5-958d-319679c56aec')\n            script.setAttribute('data-cache', 'true')\n            script.setAttribute('data-auto-track', allowTrack !== false ? 'true' : 'false')\n            script.onload = () => {\n                inited = true\n                resolve()\n            }\n            script.onerror = () => {\n                inited = false\n                reject()\n            }\n            document.body.appendChild(script)\n        })\n    } catch {\n        // Script blocked by CSP or network error — silently ignore\n    }\n}\n\nconst enable = () => {\n    return inited && typeof umami !== 'undefined'\n}\n\nexport const trackEvent = async (event, data) => {\n    if (!enable()) {\n        return\n    }\n    try {\n        umami.track(({ website, language }) => ({\n            language,\n            website,\n            name: event,\n            data,\n        }))\n    } catch {\n        // umami not available — silently ignore\n    }\n}\n"
  },
  {
    "path": "frontend/src/utils/api.js",
    "content": "/**\n * HTTP API adapter layer - replaces Wails RPC bindings for web mode.\n * All functions match the original Wails-generated function signatures.\n */\n\nconst API_BASE = '/api'\n\nasync function post(path, body = {}) {\n    const resp = await fetch(`${API_BASE}${path}`, {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        credentials: 'same-origin',\n        body: JSON.stringify(body),\n    })\n    if (resp.status === 401) {\n        window.dispatchEvent(new Event('rdm:unauthorized'))\n        return { success: false, msg: 'unauthorized' }\n    }\n    return resp.json()\n}\n\nasync function get(path, params = {}) {\n    const query = new URLSearchParams(params).toString()\n    const url = query ? `${API_BASE}${path}?${query}` : `${API_BASE}${path}`\n    const resp = await fetch(url, { credentials: 'same-origin' })\n    if (resp.status === 401) {\n        window.dispatchEvent(new Event('rdm:unauthorized'))\n        return { success: false, msg: 'unauthorized' }\n    }\n    return resp.json()\n}\n\nasync function del(path, params = {}) {\n    const query = new URLSearchParams(params).toString()\n    const url = query ? `${API_BASE}${path}?${query}` : `${API_BASE}${path}`\n    const resp = await fetch(url, { method: 'DELETE', credentials: 'same-origin' })\n    if (resp.status === 401) {\n        window.dispatchEvent(new Event('rdm:unauthorized'))\n        return { success: false, msg: 'unauthorized' }\n    }\n    return resp.json()\n}\n\n// ==================== Connection Service ====================\n\nexport function ListConnection() {\n    return get('/connection/list')\n}\n\nexport function GetConnection(name) {\n    return get('/connection/get', { name })\n}\n\nexport function SaveConnection(name, param) {\n    return post('/connection/save', { name, param })\n}\n\nexport function SaveSortedConnection(conns) {\n    return post('/connection/save-sorted', { conns })\n}\n\nexport function TestConnection(param) {\n    return post('/connection/test', param)\n}\n\nexport function DeleteConnection(name) {\n    return del('/connection/delete', { name })\n}\n\nexport function CreateGroup(name) {\n    return post('/connection/group/create', { name })\n}\n\nexport function RenameGroup(name, newName) {\n    return post('/connection/group/rename', { name, newName })\n}\n\nexport function DeleteGroup(name, includeConn) {\n    return del('/connection/group/delete', { name, includeConn })\n}\n\nexport function SaveLastDB(name, db) {\n    return post('/connection/save-last-db', { name, db })\n}\n\nexport function SaveRefreshInterval(name, interval) {\n    return post('/connection/save-refresh-interval', { name, interval })\n}\n\nexport async function ExportConnections() {\n    // Web mode: trigger browser download of connections zip\n    try {\n        const resp = await fetch(`${API_BASE}/connection/export-download`, {\n            credentials: 'same-origin',\n        })\n        if (resp.status === 401) {\n            window.dispatchEvent(new Event('rdm:unauthorized'))\n            return { success: false, msg: 'unauthorized' }\n        }\n        if (!resp.ok) {\n            const err = await resp.json().catch(() => ({}))\n            return { success: false, msg: err.msg || 'export failed' }\n        }\n        const blob = await resp.blob()\n        const url = URL.createObjectURL(blob)\n        const a = document.createElement('a')\n        const disposition = resp.headers.get('Content-Disposition') || ''\n        const match = disposition.match(/filename=(.+)/)\n        a.download = match ? match[1] : 'connections.zip'\n        a.href = url\n        a.click()\n        URL.revokeObjectURL(url)\n        return { success: true, data: { path: '' } }\n    } catch {\n        return { success: false, msg: 'export failed' }\n    }\n}\n\nexport async function ImportConnections() {\n    // Web mode: open file picker, upload zip to backend\n    return new Promise((resolve) => {\n        const input = document.createElement('input')\n        input.type = 'file'\n        input.accept = '.zip'\n        input.onchange = async () => {\n            if (input.files && input.files[0]) {\n                const formData = new FormData()\n                formData.append('file', input.files[0])\n                try {\n                    const resp = await fetch(`${API_BASE}/connection/import-upload`, {\n                        method: 'POST',\n                        credentials: 'same-origin',\n                        body: formData,\n                    })\n                    if (resp.status === 401) {\n                        window.dispatchEvent(new Event('rdm:unauthorized'))\n                        resolve({ success: false, msg: 'unauthorized' })\n                        return\n                    }\n                    resolve(await resp.json())\n                } catch {\n                    resolve({ success: false, msg: 'import failed' })\n                }\n            } else {\n                resolve({ success: false, msg: '' })\n            }\n        }\n        // User cancelled file picker\n        input.addEventListener('cancel', () => resolve({ success: false, msg: '' }))\n        input.click()\n    })\n}\n\nexport function ParseConnectURL(url) {\n    return post('/connection/parse-url', { url })\n}\n\nexport function ListSentinelMasters(param) {\n    return post('/connection/list-sentinel-masters', param)\n}\n\n// ==================== Browser Service ====================\n\nexport function OpenConnection(name) {\n    return post('/browser/open-connection', { name })\n}\n\nexport function CloseConnection(name) {\n    return post('/browser/close-connection', { name })\n}\n\nexport function OpenDatabase(server, db) {\n    return post('/browser/open-database', { server, db })\n}\n\nexport function ServerInfo(name) {\n    return post('/browser/server-info', { name })\n}\n\nexport function LoadNextKeys(server, db, match, keyType, exactMatch) {\n    return post('/browser/load-next-keys', { server, db, match, keyType, exactMatch })\n}\n\nexport function LoadNextAllKeys(server, db, match, keyType, exactMatch) {\n    return post('/browser/load-next-all-keys', { server, db, match, keyType, exactMatch })\n}\n\nexport function LoadAllKeys(server, db, match, keyType, exactMatch) {\n    return post('/browser/load-all-keys', { server, db, match, keyType, exactMatch })\n}\n\nexport function GetKeyType(param) {\n    return post('/browser/get-key-type', param)\n}\n\nexport function GetKeySummary(param) {\n    return post('/browser/get-key-summary', param)\n}\n\nexport function GetKeyDetail(param) {\n    return post('/browser/get-key-detail', param)\n}\n\nexport function ConvertValue(value, decode, format) {\n    return post('/browser/convert-value', { value, decode, format })\n}\n\nexport function SetKeyValue(param) {\n    return post('/browser/set-key-value', param)\n}\n\nexport function GetHashValue(param) {\n    return post('/browser/get-hash-value', param)\n}\n\nexport function SetHashValue(param) {\n    return post('/browser/set-hash-value', param)\n}\n\nexport function AddHashField(server, db, key, action, fieldItems) {\n    return post('/browser/add-hash-field', { server, db, key, action, fieldItems })\n}\n\nexport function AddListItem(server, db, key, action, items) {\n    return post('/browser/add-list-item', { server, db, key, action, items })\n}\n\nexport function SetListItem(param) {\n    return post('/browser/set-list-item', param)\n}\n\nexport function SetSetItem(server, db, key, remove, members) {\n    return post('/browser/set-set-item', { server, db, key, remove, members })\n}\n\nexport function UpdateSetItem(param) {\n    return post('/browser/update-set-item', param)\n}\n\nexport function UpdateZSetValue(param) {\n    return post('/browser/update-zset-value', param)\n}\n\nexport function AddZSetValue(server, db, key, action, valueScore) {\n    return post('/browser/add-zset-value', { server, db, key, action, valueScore })\n}\n\nexport function AddStreamValue(server, db, key, id, fieldItems) {\n    return post('/browser/add-stream-value', { server, db, key, id, fieldItems })\n}\n\nexport function RemoveStreamValues(server, db, key, ids) {\n    return post('/browser/remove-stream-values', { server, db, key, ids })\n}\n\nexport function SetKeyTTL(server, db, key, ttl) {\n    return post('/browser/set-key-ttl', { server, db, key, ttl })\n}\n\nexport function BatchSetTTL(server, db, keys, ttl, serialNo) {\n    return post('/browser/batch-set-ttl', { server, db, keys, ttl, serialNo })\n}\n\nexport function DeleteKey(server, db, key, async) {\n    return post('/browser/delete-key', { server, db, key, async })\n}\n\nexport function DeleteKeys(server, db, keys, serialNo) {\n    return post('/browser/delete-keys', { server, db, keys, serialNo })\n}\n\nexport function DeleteKeysByPattern(server, db, pattern) {\n    return post('/browser/delete-keys-by-pattern', { server, db, pattern })\n}\n\nexport function RenameKey(server, db, key, newKey) {\n    return post('/browser/rename-key', { server, db, key, newKey })\n}\n\nexport function ExportKey(server, db, keys, path, includeExpire) {\n    return post('/browser/export-key', { server, db, keys, path, includeExpire })\n}\n\nexport function ImportCSV(server, db, path, conflict, ttl) {\n    return post('/browser/import-csv', { server, db, path, conflict, ttl })\n}\n\nexport function FlushDB(server, db, async) {\n    return post('/browser/flush-db', { server, db, async })\n}\n\nexport function GetSlowLogs(server, db, num) {\n    return post('/browser/get-slow-logs', { server, db, num })\n}\n\nexport function GetClientList(server, db) {\n    return post('/browser/get-client-list', { server, db })\n}\n\nexport function GetCmdHistory() {\n    return post('/browser/get-cmd-history')\n}\n\nexport function CleanCmdHistory() {\n    return post('/browser/clean-cmd-history')\n}\n\n// ==================== CLI Service ====================\n\nexport function StartCli(server, db) {\n    return post('/cli/start', { server, db })\n}\n\nexport function CloseCli(server) {\n    return post('/cli/close', { server })\n}\n\n// ==================== Monitor Service ====================\n\nexport function StartMonitor(server) {\n    return post('/monitor/start', { server })\n}\n\nexport function StopMonitor(server) {\n    return post('/monitor/stop', { server })\n}\n\nexport function ExportLog(logs) {\n    return post('/monitor/export-log', { logs })\n}\n\n// ==================== Pubsub Service ====================\n\nexport function Publish(server, channel, payload) {\n    return post('/pubsub/publish', { server, channel, payload })\n}\n\nexport function StartSubscribe(server) {\n    return post('/pubsub/subscribe', { server })\n}\n\nexport function StopSubscribe(server) {\n    return post('/pubsub/unsubscribe', { server })\n}\n\n// ==================== Preferences Service ====================\n\nexport function GetPreferences() {\n    return get('/preferences/get')\n}\n\nexport function SetPreferences(pf) {\n    return post('/preferences/set', pf)\n}\n\nexport function UpdatePreferences(value) {\n    return post('/preferences/update', value)\n}\n\nexport function RestorePreferences() {\n    return post('/preferences/restore')\n}\n\n// Common fonts to probe when Local Font Access API is unavailable\nconst CANDIDATE_FONTS = [\n    // Sans-serif\n    'Arial', 'Helvetica', 'Helvetica Neue', 'Verdana', 'Geneva', 'Tahoma',\n    'Trebuchet MS', 'Lucida Grande', 'Lucida Sans Unicode', 'Segoe UI',\n    'Roboto', 'Noto Sans', 'Open Sans', 'Lato', 'Source Sans Pro',\n    // Serif\n    'Times New Roman', 'Georgia', 'Palatino', 'Book Antiqua', 'Cambria',\n    'Noto Serif',\n    // Monospace\n    'Courier New', 'Consolas', 'Monaco', 'Menlo', 'DejaVu Sans Mono',\n    'Fira Code', 'JetBrains Mono', 'Source Code Pro', 'Cascadia Code',\n    // CJK\n    'Microsoft YaHei', 'PingFang SC', 'PingFang TC', 'Hiragino Sans GB',\n    'Noto Sans SC', 'Noto Sans TC', 'Noto Sans JP', 'Noto Sans KR',\n    'Source Han Sans SC', 'Source Han Sans TC', 'WenQuanYi Micro Hei',\n    'Yu Gothic', 'Meiryo', 'Malgun Gothic',\n]\n\nasync function queryBrowserFonts() {\n    await document.fonts.ready\n    return CANDIDATE_FONTS.filter((f) => document.fonts.check(`16px \"${f}\"`)).map((name) => ({ name, path: '' }))\n}\n\nexport async function GetFontList() {\n    try {\n        const fonts = await queryBrowserFonts()\n        return { success: true, data: { fonts } }\n    } catch (_) {\n        return { success: true, data: { fonts: [] } }\n    }\n}\n\nexport function GetBuildInDecoder() {\n    return get('/preferences/buildin-decoder')\n}\n\nexport function GetAppVersion() {\n    return get('/preferences/version')\n}\n\nexport function CheckForUpdate() {\n    return get('/preferences/check-update')\n}\n\n// ==================== System Service ====================\n\n// Alias used in App.vue\nexport function Info() {\n    return get('/system/info')\n}\n\n// Web replacement for native file dialog\nexport async function SelectFile(title, ext) {\n    return new Promise((resolve) => {\n        const input = document.createElement('input')\n        input.type = 'file'\n        if (ext && Array.isArray(ext) && ext.length > 0) {\n            input.accept = ext.map((e) => '.' + e.replace(/^\\./, '')).join(',')\n        }\n        input.onchange = async () => {\n            if (input.files && input.files[0]) {\n                const formData = new FormData()\n                formData.append('file', input.files[0])\n                try {\n                    const resp = await fetch('/api/system/select-file', {\n                        method: 'POST',\n                        credentials: 'same-origin',\n                        body: formData,\n                    })\n                    if (resp.status === 401) {\n                        window.dispatchEvent(new Event('rdm:unauthorized'))\n                        resolve({ success: false, msg: 'unauthorized' })\n                        return\n                    }\n                    resolve(await resp.json())\n                } catch {\n                    resolve({ success: false, msg: 'upload failed' })\n                }\n            } else {\n                resolve({ success: false, msg: '' })\n            }\n        }\n        input.addEventListener('cancel', () => resolve({ success: false, msg: '' }))\n        input.click()\n    })\n}\n\nexport async function SaveFile(title, defaultName, ext) {\n    // In web mode, file save dialogs are not applicable\n    // The backend ExportLog etc. will handle download differently\n    return { success: true, data: { path: '' } }\n}\n\n// ==================== Auth Service ====================\n\nexport async function Login(username, password) {\n    return await post('/auth/login', { username, password })\n}\n\nexport async function Logout() {\n    return await post('/auth/logout')\n}\n"
  },
  {
    "path": "frontend/src/utils/byte_convert.js",
    "content": "const sizes = ['B', 'KB', 'MB', 'GB', 'TB']\n/**\n * convert byte value\n * @param {number} bytes\n * @param {number} decimals\n * @return {{unit: string, value: number}}\n */\nexport const convertBytes = (bytes, decimals = 2) => {\n    if (bytes <= 0) {\n        return {\n            value: 0,\n            unit: sizes[0],\n        }\n    }\n\n    const k = 1024\n    const i = Math.floor(Math.log(bytes) / Math.log(k))\n    const j = Math.min(i, sizes.length - 1)\n    return {\n        value: parseFloat((bytes / Math.pow(k, j)).toFixed(decimals)),\n        unit: sizes[j],\n    }\n}\n\n/**\n *\n * @param {number} bytes\n * @param {number} decimals\n * @return {string}\n */\nexport const formatBytes = (bytes, decimals = 2) => {\n    const res = convertBytes(bytes, decimals)\n    return res.value + res.unit\n}\n"
  },
  {
    "path": "frontend/src/utils/chart.js",
    "content": "import {\n    CategoryScale,\n    Chart as ChartJS,\n    Filler,\n    Legend,\n    LinearScale,\n    LineElement,\n    PointElement,\n    Title,\n    Tooltip,\n} from 'chart.js'\n\nexport const setupChart = () => {\n    ChartJS.register(Title, Tooltip, LineElement, CategoryScale, LinearScale, PointElement, Legend, Filler)\n}\n"
  },
  {
    "path": "frontend/src/utils/date.js",
    "content": "import { i18nGlobal } from '@/utils/i18n.js'\nimport { padStart } from 'lodash'\n\n/**\n * convert seconds number to human-readable string\n * @param {number} duration duration in seconds\n * @return {string}\n */\nexport const toHumanReadable = (duration) => {\n    const days = Math.floor(duration / 86400)\n    const hours = Math.floor((duration % 86400) / 3600)\n    const minutes = Math.floor((duration % 3600) / 60)\n    const seconds = duration % 60\n    const time = `${padStart(hours, 2, '0')}:${padStart(minutes, 2, '0')}:${padStart(seconds, 2, '0')}`\n    if (days > 0) {\n        return days + i18nGlobal.t('common.unit_day') + ' ' + time\n    } else {\n        return time\n    }\n}\n"
  },
  {
    "path": "frontend/src/utils/decoder_cmd.js",
    "content": "import { includes, isEmpty, toUpper, trim } from 'lodash'\n\n/**\n * join execute path and arguments into a command string\n * @param {string} path\n * @param {string[]} args\n * @param {string} [emptyContent]\n * @return {string}\n */\nexport const joinCommand = (path, args = [], emptyContent = '-') => {\n    let cmd = ''\n    if (!isEmpty(trim(path))) {\n        let containValuePlaceholder = false\n        cmd = includes(path, ' ') ? `\"${path}\"` : path\n        for (let part of args || []) {\n            part = trim(part)\n            if (isEmpty(part)) {\n                continue\n            }\n            if (includes(part, ' ')) {\n                cmd += ' \"' + part + '\"'\n            } else {\n                if (toUpper(part) === '{VALUE}') {\n                    part = '{VALUE}'\n                    containValuePlaceholder = true\n                }\n                cmd += ' ' + part\n            }\n        }\n        if (!containValuePlaceholder) {\n            cmd += ' {VALUE}'\n        }\n    }\n    return cmd || emptyContent\n}\n"
  },
  {
    "path": "frontend/src/utils/discrete.js",
    "content": "import usePreferencesStore from 'stores/preferences.js'\nimport { createDiscreteApi, darkTheme } from 'naive-ui'\nimport { darkThemeOverrides, themeOverrides } from '@/utils/theme.js'\nimport { i18nGlobal } from '@/utils/i18n.js'\nimport { computed } from 'vue'\n\nfunction setupMessage(message) {\n    return {\n        error: (content, option = null) => {\n            return message.error(content, option)\n        },\n        info: (content, option = null) => {\n            return message.info(content, option)\n        },\n        loading: (content, option = {}) => {\n            option.duration = option.duration != null ? option.duration : 30000\n            option.keepAliveOnHover = option.keepAliveOnHover !== undefined ? option.keepAliveOnHover : true\n            return message.loading(content, option)\n        },\n        success: (content, option = null) => {\n            return message.success(content, option)\n        },\n        warning: (content, option = null) => {\n            return message.warning(content, option)\n        },\n    }\n}\n\nfunction setupNotification(notification) {\n    return {\n        /**\n         * @param {NotificationOption} option\n         * @return {NotificationReactive}\n         */\n        show(option) {\n            return notification.create(option)\n        },\n        error: (content, option = {}) => {\n            option.content = content\n            option.title = option.title || i18nGlobal.t('common.error')\n            return notification.error(option)\n        },\n        info: (content, option = {}) => {\n            option.content = content\n            return notification.info(option)\n        },\n        success: (content, option = {}) => {\n            option.content = content\n            option.title = option.title || i18nGlobal.t('common.success')\n            return notification.success(option)\n        },\n        warning: (content, option = {}) => {\n            option.content = content\n            option.title = option.title || i18nGlobal.t('common.warning')\n            return notification.warning(option)\n        },\n    }\n}\n\n/**\n *\n * @param {DialogApiInjection} dialog\n * @return {*}\n */\nfunction setupDialog(dialog) {\n    return {\n        /**\n         * @param {DialogOptions} option\n         * @return {DialogReactive}\n         */\n        show(option) {\n            option.closable = option.closable === true\n            option.autoFocus = option.autoFocus === true\n            option.transformOrigin = 'center'\n            return dialog.create(option)\n        },\n        warning: (content, onConfirm) => {\n            return dialog.warning({\n                title: i18nGlobal.t('common.warning'),\n                content: content,\n                closable: false,\n                autoFocus: false,\n                transformOrigin: 'center',\n                positiveText: i18nGlobal.t('common.confirm'),\n                negativeText: i18nGlobal.t('common.cancel'),\n                onPositiveClick: () => {\n                    onConfirm && onConfirm()\n                },\n            })\n        },\n    }\n}\n\n/**\n * setup discrete api and bind global component (like dialog, message, alert) to window\n * @return {Promise<void>}\n */\nexport async function setupDiscreteApi() {\n    const prefStore = usePreferencesStore()\n    const configProviderProps = computed(() => ({\n        theme: prefStore.isDark ? darkTheme : undefined,\n        themeOverrides,\n    }))\n    const { message, dialog, notification } = createDiscreteApi(['message', 'notification', 'dialog'], {\n        configProviderProps,\n        messageProviderProps: {\n            placement: 'bottom',\n            keepAliveOnHover: true,\n            containerStyle: {\n                marginBottom: '38px',\n            },\n            themeOverrides: prefStore.isDark ? darkThemeOverrides.Message : themeOverrides.Message,\n        },\n        notificationProviderProps: {\n            max: 5,\n            placement: 'bottom-right',\n            keepAliveOnHover: true,\n            containerStyle: {\n                marginBottom: '38px',\n            },\n        },\n    })\n\n    window.$message = setupMessage(message)\n    window.$notification = setupNotification(notification)\n    window.$dialog = setupDialog(dialog)\n}\n"
  },
  {
    "path": "frontend/src/utils/extra_theme.js",
    "content": "/**\n * @typedef ExtraTheme\n * @property {string} titleColor\n * @property {string} sidebarColor\n * @property {string} splitColor\n */\n\n/**\n *\n * @type ExtraTheme\n */\nexport const extraLightTheme = {\n    titleColor: '#F2F2F2',\n    ribbonColor: '#F9F9F9',\n    ribbonActiveColor: '#E3E3E3',\n    sidebarColor: '#F2F2F2',\n    splitColor: '#DADADA',\n}\n\n/**\n *\n * @type ExtraTheme\n */\nexport const extraDarkTheme = {\n    titleColor: '#262626',\n    ribbonColor: '#2C2C2C',\n    ribbonActiveColor: '#363636',\n    sidebarColor: '#262626',\n    splitColor: '#474747',\n}\n\n/**\n *\n * @param {boolean} dark\n * @return ExtraTheme\n */\nexport const extraTheme = (dark) => {\n    return dark ? extraDarkTheme : extraLightTheme\n}\n"
  },
  {
    "path": "frontend/src/utils/glob_pattern.js",
    "content": "import { includes, isEmpty } from 'lodash'\n\nconst REDIS_GLOB_CHAR = ['?', '*', '[', ']', '{', '}']\nexport const isRedisGlob = (str) => {\n    if (!isEmpty(str)) {\n        for (const c of REDIS_GLOB_CHAR) {\n            if (includes(str, c)) {\n                return true\n            }\n        }\n    }\n    return false\n}\n"
  },
  {
    "path": "frontend/src/utils/i18n.js",
    "content": "import { createI18n } from 'vue-i18n'\nimport { lang } from '@/langs/index.js'\n\nexport const i18n = createI18n({\n    locale: 'en-us',\n    fallbackLocale: 'en-us',\n    globalInjection: true,\n    legacy: false,\n    messages: {\n        ...lang,\n    },\n})\n\nexport const i18nGlobal = i18n.global\n"
  },
  {
    "path": "frontend/src/utils/key_convert.js",
    "content": "import { join, map, take } from 'lodash'\n\n/**\n * converted binary data in strings to hex format\n * @param {string|number[]} key\n * @return {string}\n */\nexport function decodeRedisKey(key) {\n    if (key instanceof Array) {\n        // char array, convert to hex string\n        return join(\n            map(key, (k) => {\n                if (k >= 32 && k <= 126) {\n                    return String.fromCharCode(k)\n                }\n                return '\\\\x' + k.toString(16).toUpperCase().padStart(2, '0')\n            }),\n            '',\n        )\n    }\n\n    return key\n}\n\n/**\n * convert char code array to string\n * @param {string|number[]} key\n * @param {number|undefined} truncate\n * @return {string}\n */\nexport function nativeRedisKey(key, truncate) {\n    if (key instanceof Array) {\n        // truncate char code array\n        if (typeof truncate === 'number' && truncate > 0) {\n            key = take(key, truncate)\n        }\n        return map(key, (c) => String.fromCharCode(c)).join('')\n    }\n    return key\n}\n"
  },
  {
    "path": "frontend/src/utils/monaco.js",
    "content": "import * as monaco from 'monaco-editor'\nimport editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'\nimport jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'\nimport cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'\nimport htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'\nimport { BrowserOpenURL } from 'wailsjs/runtime/runtime.js'\n\nexport const setupMonaco = () => {\n    window.MonacoEnvironment = {\n        getWorker: (_, label) => {\n            switch (label) {\n                case 'json':\n                    return new jsonWorker()\n                case 'css':\n                case 'scss':\n                case 'less':\n                    return new cssWorker()\n                case 'html':\n                    return new htmlWorker()\n                default:\n                    return new editorWorker()\n            }\n        },\n    }\n\n    // setup light theme\n    monaco.editor.defineTheme('rdm-light', {\n        base: 'vs',\n        inherit: true,\n        rules: [],\n        colors: {\n            'editorLineNumber.foreground': '#BABBBD',\n            'editorLineNumber.activeForeground': '#777D83',\n        },\n    })\n\n    // setup dark theme\n    monaco.editor.defineTheme('rdm-dark', {\n        base: 'vs-dark',\n        inherit: true,\n        rules: [],\n        colors: {},\n    })\n\n    // register default link opening behavior\n    monaco.editor.registerLinkOpener({\n        open(resource) {\n            BrowserOpenURL(resource.toString())\n            return true\n        },\n    })\n}\n"
  },
  {
    "path": "frontend/src/utils/platform.js",
    "content": "import { Environment } from 'wailsjs/runtime/runtime.js'\n\nlet os = ''\n\nexport async function loadEnvironment() {\n    const env = await Environment()\n    os = env.platform\n}\n\nexport function isMacOS() {\n    return os === 'darwin'\n}\n\nexport function isWindows() {\n    return os === 'windows'\n}\n\nexport function isWeb() {\n    return os === 'web'\n}\n"
  },
  {
    "path": "frontend/src/utils/promise.js",
    "content": "export const timeout = (ms) => {\n    return new Promise((resolve) => setTimeout(resolve, ms))\n}\n"
  },
  {
    "path": "frontend/src/utils/render.js",
    "content": "import { h } from 'vue'\nimport { NIcon } from 'naive-ui'\n\nexport function useRender() {\n    return {\n        /**\n         *\n         * @param {string|Object} icon\n         * @param {{}} [props]\n         * @return {VNode}\n         */\n        renderIcon: (icon, props = {}) => {\n            if (icon == null) {\n                return undefined\n            }\n            return h(NIcon, null, {\n                default: () => h(icon, props),\n            })\n        },\n\n        /**\n         *\n         * @param {string} label\n         * @param {{}} [props]\n         * @return {VNode}\n         */\n        renderLabel: (label, props = {}) => {\n            return h('div', props, label)\n        },\n    }\n}\n"
  },
  {
    "path": "frontend/src/utils/rgb.js",
    "content": "import { padStart, size, startsWith } from 'lodash'\n\n/**\n * @typedef {Object} RGB\n * @property {number} r\n * @property {number} g\n * @property {number} b\n * @property {number} [a]\n */\n\n/**\n * parse hex color to rgb object\n * @param hex\n * @return {RGB}\n */\nexport function parseHexColor(hex) {\n    if (size(hex) < 6) {\n        return { r: 0, g: 0, b: 0 }\n    }\n    if (startsWith(hex, '#')) {\n        hex = hex.slice(1)\n    }\n    const bigint = parseInt(hex, 16)\n    const r = (bigint >> 16) & 255\n    const g = (bigint >> 8) & 255\n    const b = bigint & 255\n    return { r, g, b }\n}\n\n/**\n * do gamma correction with an RGB object\n * @param {RGB} rgb\n * @param {Number} gamma\n * @return {RGB}\n */\nexport function hexGammaCorrection(rgb, gamma) {\n    if (typeof rgb !== 'object') {\n        return { r: 0, g: 0, b: 0 }\n    }\n    return {\n        r: Math.max(0, Math.min(255, Math.round(rgb.r * gamma))),\n        g: Math.max(0, Math.min(255, Math.round(rgb.g * gamma))),\n        b: Math.max(0, Math.min(255, Math.round(rgb.b * gamma))),\n    }\n}\n\n/**\n * mix two colors\n * @param rgba1\n * @param rgba2\n * @param weight\n * @return {{a: number, r: number, b: number, g: number}}\n */\nexport function mixColors(rgba1, rgba2, weight = 0.5) {\n    if (rgba1.a === undefined) {\n        rgba1.a = 255\n    }\n    if (rgba2.a === undefined) {\n        rgba2.a = 255\n    }\n    return {\n        r: Math.floor(rgba1.r * (1 - weight) + rgba2.r * weight),\n        g: Math.floor(rgba1.g * (1 - weight) + rgba2.g * weight),\n        b: Math.floor(rgba1.b * (1 - weight) + rgba2.b * weight),\n        a: Math.floor(rgba1.a * (1 - weight) + rgba2.a * weight),\n    }\n}\n\n/**\n * RGB object to hex color string\n * @param {RGB} rgb\n * @return {string}\n */\nexport function toHexColor(rgb) {\n    return (\n        '#' +\n        padStart(rgb.r.toString(16), 2, '0') +\n        padStart(rgb.g.toString(16), 2, '0') +\n        padStart(rgb.b.toString(16), 2, '0')\n    )\n}\n"
  },
  {
    "path": "frontend/src/utils/theme.js",
    "content": "import { merge } from 'lodash'\n\n/**\n *\n * @type import('naive-ui').GlobalThemeOverrides\n */\nexport const themeOverrides = {\n    common: {\n        primaryColor: '#D33A31',\n        primaryColorHover: '#FF6B6B',\n        primaryColorPressed: '#D5271C',\n        primaryColorSuppl: '#FF6B6B',\n        borderRadius: '4px',\n        borderRadiusSmall: '3px',\n        heightMedium: '32px',\n        lineHeight: 1.5,\n        scrollbarWidth: '8px',\n        tabColor: '#FFFFFF',\n    },\n    Button: {\n        heightMedium: '32px',\n        paddingSmall: '0 8px',\n        paddingMedium: '0 12px',\n    },\n    Tag: {\n        borderRadius: '4px',\n        heightLarge: '32px',\n    },\n    Input: {\n        heightMedium: '32px',\n    },\n    Tabs: {\n        tabGapSmallCard: '2px',\n        tabGapMediumCard: '2px',\n        tabGapLargeCard: '2px',\n        tabFontWeightActive: 450,\n    },\n    Tree: {\n        nodeWrapperPadding: '0 3px',\n    },\n    Card: {\n        colorEmbedded: '#FAFAFA',\n    },\n    Form: {\n        labelFontSizeTopSmall: '12px',\n        labelFontSizeTopMedium: '13px',\n        labelFontSizeTopLarge: '13px',\n        labelHeightSmall: '18px',\n        labelHeightMedium: '18px',\n        labelHeightLarge: '18px',\n        labelPaddingVertical: '0 0 5px 2px',\n        feedbackHeightSmall: '18px',\n        feedbackHeightMedium: '18px',\n        feedbackHeightLarge: '20px',\n        feedbackFontSizeSmall: '11px',\n        feedbackFontSizeMedium: '12px',\n        feedbackFontSizeLarge: '12px',\n        labelTextColor: 'rgb(113,120,128)',\n        labelFontWeight: '450',\n    },\n    Radio: {\n        buttonColorActive: '#D13B37',\n        buttonTextColorActive: '#FFF',\n    },\n    DataTable: {\n        thPaddingSmall: '6px 8px',\n        tdPaddingSmall: '6px 8px',\n    },\n    Dropdown: {\n        borderRadius: '5px',\n        optionIconSizeMedium: '18px',\n        padding: '6px 2px',\n        optionColorHover: '#D33A31',\n        optionTextColorHover: '#FFF',\n        optionHeightMedium: '28px',\n    },\n    Divider: {\n        color: '#AAAAAB',\n    },\n}\n\n/**\n *\n * @type import('naive-ui').GlobalThemeOverrides\n */\nconst _darkThemeOverrides = {\n    common: {\n        bodyColor: '#1E1E1E',\n        tabColor: '#1E1E1E',\n        borderColor: '#515151',\n    },\n    Tree: {\n        nodeTextColor: '#CECED0',\n    },\n    Card: {\n        colorEmbedded: '#212121',\n    },\n    Dropdown: {\n        color: '#272727',\n    },\n    Popover: {\n        color: '#2C2C32',\n    },\n}\n\nexport const darkThemeOverrides = merge({}, themeOverrides, _darkThemeOverrides)\n"
  },
  {
    "path": "frontend/src/utils/version.js",
    "content": "import { get, isEmpty, map, size, split, trimStart } from 'lodash'\n\n/**\n * convert version string to number array\n * @param ver\n * @return {number[]}\n */\nexport const toVersionArray = (ver) => {\n    const v = trimStart(ver, 'v')\n    let vParts = split(v, '.')\n    if (isEmpty(vParts)) {\n        vParts = ['0']\n    }\n    return map(vParts, (v) => {\n        let vNum = parseInt(v)\n        return isNaN(vNum) ? 0 : vNum\n    })\n}\n\n/**\n * compare two version strings\n * @param {string} v1\n * @param {string} v2\n * @return {number}\n */\nexport const compareVersion = (v1, v2) => {\n    if (v1 !== v2) {\n        const v1Nums = toVersionArray(v1)\n        const v2Nums = toVersionArray(v2)\n        const length = Math.max(size(v1Nums), size(v2Nums))\n\n        for (let i = 0; i < length; i++) {\n            const num1 = get(v1Nums, i, 0)\n            const num2 = get(v2Nums, i, 0)\n            if (num1 !== num2) {\n                return num1 > num2 ? 1 : -1\n            }\n        }\n    }\n    return 0\n}\n"
  },
  {
    "path": "frontend/src/utils/wails_runtime.js",
    "content": "/**\n * Web-mode stubs for wailsjs/runtime/runtime.js\n * Replaces Wails desktop runtime functions with browser equivalents.\n */\n\nimport { offWsEvent, onWsEvent, reconnectWebSocket, sendWsMessage, waitForWebSocket } from '@/utils/websocket.js'\n\n// Don't auto-connect — wait for explicit call after login\n// connectWebSocket()\n\n// ==================== Events ====================\n\nexport function EventsOn(event, callback) {\n    onWsEvent(event, callback)\n}\n\nexport function EventsOnce(event, callback) {\n    const wrapper = (...args) => {\n        offWsEvent(event, wrapper)\n        callback(...args)\n    }\n    onWsEvent(event, wrapper)\n}\n\nexport function EventsEmit(event, ...data) {\n    sendWsMessage({ event, data: data.length === 1 ? data[0] : data })\n}\n\nexport function EventsOff(event) {\n    offWsEvent(event)\n}\n\n// ==================== Clipboard ====================\n\nexport async function ClipboardGetText() {\n    try {\n        return await navigator.clipboard.readText()\n    } catch {\n        // clipboard.readText() requires HTTPS + user permission grant\n        // Throw so callers can show a meaningful error instead of silent empty string\n        throw new Error('clipboard permission denied')\n    }\n}\n\nexport async function ClipboardSetText(text) {\n    try {\n        await navigator.clipboard.writeText(text)\n    } catch {\n        // fallback\n        const ta = document.createElement('textarea')\n        ta.value = text\n        ta.style.position = 'fixed'\n        ta.style.left = '-9999px'\n        document.body.appendChild(ta)\n        ta.select()\n        document.execCommand('copy')\n        document.body.removeChild(ta)\n    }\n}\n\n// ==================== Browser ====================\n\nexport function BrowserOpenURL(url) {\n    window.open(url, '_blank')\n}\n\n// ==================== Window Management (no-ops for web) ====================\n\nexport function WindowMinimise() {}\nexport function WindowMaximise() {}\nexport function WindowToggleMaximise() {}\nexport function WindowIsMaximised() { return false }\nexport function WindowIsFullscreen() { return false }\nexport function WindowSetDarkTheme() {}\nexport function WindowSetLightTheme() {}\nexport function Quit() {}\n\n// ==================== Environment ====================\n\nexport async function Environment() {\n    return {\n        buildType: 'production',\n        platform: 'web',\n        arch: 'web',\n    }\n}\n\n// ==================== WebSocket Management ====================\n\nexport { reconnectWebSocket as ReconnectWebSocket }\nexport { waitForWebSocket as WaitForWebSocket }\n"
  },
  {
    "path": "frontend/src/utils/websocket.js",
    "content": "/**\n * WebSocket client for real-time communication with the Go backend.\n * Replaces Wails event system in web mode.\n */\n\nlet ws = null\nlet reconnectTimer = null\nconst listeners = new Map() // event -> Set<callback>\nlet wsReadyResolve = null\nlet wsReadyPromise = null\n\nfunction resetReadyPromise() {\n    wsReadyPromise = new Promise((resolve) => {\n        wsReadyResolve = resolve\n    })\n}\nresetReadyPromise()\n\nfunction getWsUrl() {\n    const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'\n    return `${proto}//${location.host}/ws`\n}\n\nexport function connectWebSocket() {\n    if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {\n        return\n    }\n\n    ws = new WebSocket(getWsUrl())\n\n    ws.onopen = () => {\n        console.log('[ws] connected')\n        if (reconnectTimer) {\n            clearTimeout(reconnectTimer)\n            reconnectTimer = null\n        }\n        if (wsReadyResolve) {\n            wsReadyResolve()\n            wsReadyResolve = null\n        }\n    }\n\n    ws.onmessage = (evt) => {\n        try {\n            const msg = JSON.parse(evt.data)\n            if (msg.event) {\n                dispatch(msg.event, msg.data)\n            }\n        } catch (e) {\n            console.warn('[ws] parse error:', e)\n        }\n    }\n\n    ws.onclose = () => {\n        console.log('[ws] disconnected, reconnecting in 3s...')\n        scheduleReconnect()\n    }\n\n    ws.onerror = () => {\n        ws.close()\n    }\n}\n\n// Wait until WebSocket is connected\nexport function waitForWebSocket() {\n    if (ws && ws.readyState === WebSocket.OPEN) {\n        return Promise.resolve()\n    }\n    return wsReadyPromise\n}\n\n// Force reconnect (e.g. after login)\nexport function reconnectWebSocket() {\n    if (ws) {\n        ws.onclose = null // prevent auto-reconnect\n        ws.close()\n        ws = null\n    }\n    if (reconnectTimer) {\n        clearTimeout(reconnectTimer)\n        reconnectTimer = null\n    }\n    resetReadyPromise()\n    connectWebSocket()\n    return wsReadyPromise\n}\n\nfunction scheduleReconnect() {\n    if (!reconnectTimer) {\n        reconnectTimer = setTimeout(() => {\n            reconnectTimer = null\n            resetReadyPromise()\n            connectWebSocket()\n        }, 3000)\n    }\n}\n\nfunction dispatch(event, data) {\n    const cbs = listeners.get(event)\n    if (cbs) {\n        for (const cb of cbs) {\n            try {\n                cb(data)\n            } catch (e) {\n                console.error(`[ws] handler error for \"${event}\":`, e)\n            }\n        }\n    }\n}\n\nexport function onWsEvent(event, callback) {\n    if (!listeners.has(event)) {\n        listeners.set(event, new Set())\n    }\n    listeners.get(event).add(callback)\n}\n\nexport function offWsEvent(event, callback) {\n    if (!callback) {\n        listeners.delete(event)\n    } else {\n        const cbs = listeners.get(event)\n        if (cbs) {\n            cbs.delete(callback)\n        }\n    }\n}\n\nexport function sendWsMessage(msg) {\n    if (ws && ws.readyState === WebSocket.OPEN) {\n        ws.send(JSON.stringify(msg))\n    } else {\n        console.warn('[ws] not connected, message dropped:', msg.event)\n    }\n}\n"
  },
  {
    "path": "frontend/vite.config.js",
    "content": "import vue from '@vitejs/plugin-vue'\nimport AutoImport from 'unplugin-auto-import/vite'\nimport Icons from 'unplugin-icons/vite'\nimport { NaiveUiResolver } from 'unplugin-vue-components/resolvers'\nimport Components from 'unplugin-vue-components/vite'\nimport { defineConfig } from 'vite'\n\nconst rootPath = new URL('.', import.meta.url).pathname\nconst isWeb = process.env.VITE_WEB === 'true'\n\n// https://vitejs.dev/config/\nexport default defineConfig({\n    plugins: [\n        vue(),\n        AutoImport({\n            imports: [\n                {\n                    'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar'],\n                },\n            ],\n        }),\n        Components({\n            resolvers: [NaiveUiResolver()],\n        }),\n        Icons(),\n    ],\n    resolve: {\n        alias: {\n            '@': rootPath + 'src',\n            stores: rootPath + 'src/stores',\n            // Web mode: redirect wailsjs imports to HTTP/WebSocket adapters\n            // Desktop mode (wails build): use real Wails RPC bindings\n            ...(isWeb\n                ? {\n                      'wailsjs/runtime/runtime.js': rootPath + 'src/utils/wails_runtime.js',\n                      'wailsjs/go/services/connectionService.js': rootPath + 'src/utils/api.js',\n                      'wailsjs/go/services/browserService.js': rootPath + 'src/utils/api.js',\n                      'wailsjs/go/services/cliService.js': rootPath + 'src/utils/api.js',\n                      'wailsjs/go/services/monitorService.js': rootPath + 'src/utils/api.js',\n                      'wailsjs/go/services/pubsubService.js': rootPath + 'src/utils/api.js',\n                      'wailsjs/go/services/preferencesService.js': rootPath + 'src/utils/api.js',\n                      'wailsjs/go/services/systemService.js': rootPath + 'src/utils/api.js',\n                  }\n                : {}),\n            wailsjs: rootPath + 'wailsjs',\n        },\n    },\n    css: {\n        preprocessorOptions: {\n            scss: {\n                api: 'modern-compiler',\n            },\n        },\n    },\n    ...(isWeb\n        ? {\n              server: {\n                  proxy: {\n                      '/api': {\n                          target: 'http://localhost:8088',\n                          changeOrigin: true,\n                      },\n                      '/ws': {\n                          target: 'ws://localhost:8088',\n                          ws: true,\n                      },\n                  },\n              },\n          }\n        : {}),\n})\n"
  },
  {
    "path": "go.mod",
    "content": "module tinyrdm\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/adrg/sysfont v0.1.2\n\tgithub.com/andybalholm/brotli v1.2.0\n\tgithub.com/gin-gonic/gin v1.11.0\n\tgithub.com/google/uuid v1.6.0\n\tgithub.com/gorilla/websocket v1.5.3\n\tgithub.com/klauspost/compress v1.18.4\n\tgithub.com/pierrec/lz4/v4 v4.1.25\n\tgithub.com/redis/go-redis/v9 v9.18.0\n\tgithub.com/vmihailenco/msgpack/v5 v5.4.1\n\tgithub.com/vrischmann/userdir v0.0.0-20151206171402-20f291cebd68\n\tgithub.com/wailsapp/wails/v2 v2.11.0\n\tgithub.com/xanzy/ssh-agent v0.3.3\n\tgolang.org/x/crypto v0.48.0\n\tgolang.org/x/net v0.51.0\n\tgopkg.in/yaml.v3 v3.0.1\n)\n\nrequire (\n\tgithub.com/Microsoft/go-winio v0.6.2 // indirect\n\tgithub.com/adrg/strutil v0.3.1 // indirect\n\tgithub.com/adrg/xdg v0.5.3 // indirect\n\tgithub.com/bep/debounce v1.2.1 // indirect\n\tgithub.com/bytedance/sonic v1.14.0 // indirect\n\tgithub.com/bytedance/sonic/loader v0.3.0 // indirect\n\tgithub.com/cespare/xxhash/v2 v2.3.0 // indirect\n\tgithub.com/cloudwego/base64x v0.1.6 // indirect\n\tgithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect\n\tgithub.com/gabriel-vasile/mimetype v1.4.8 // indirect\n\tgithub.com/gin-contrib/sse v1.1.0 // indirect\n\tgithub.com/go-ole/go-ole v1.3.0 // indirect\n\tgithub.com/go-playground/locales v0.14.1 // indirect\n\tgithub.com/go-playground/universal-translator v0.18.1 // indirect\n\tgithub.com/go-playground/validator/v10 v10.27.0 // indirect\n\tgithub.com/goccy/go-json v0.10.5 // indirect\n\tgithub.com/goccy/go-yaml v1.18.0 // indirect\n\tgithub.com/godbus/dbus/v5 v5.2.1 // indirect\n\tgithub.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect\n\tgithub.com/json-iterator/go v1.1.12 // indirect\n\tgithub.com/klauspost/cpuid/v2 v2.3.0 // indirect\n\tgithub.com/labstack/echo/v4 v4.14.0 // indirect\n\tgithub.com/labstack/gommon v0.4.2 // indirect\n\tgithub.com/leaanthony/go-ansi-parser v1.6.1 // indirect\n\tgithub.com/leaanthony/gosod v1.0.4 // indirect\n\tgithub.com/leaanthony/slicer v1.6.0 // indirect\n\tgithub.com/leaanthony/u v1.1.1 // indirect\n\tgithub.com/leodido/go-urn v1.4.0 // indirect\n\tgithub.com/mattn/go-colorable v0.1.14 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect\n\tgithub.com/modern-go/reflect2 v1.0.2 // indirect\n\tgithub.com/pelletier/go-toml/v2 v2.2.4 // indirect\n\tgithub.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect\n\tgithub.com/pkg/errors v0.9.1 // indirect\n\tgithub.com/quic-go/qpack v0.5.1 // indirect\n\tgithub.com/quic-go/quic-go v0.54.0 // indirect\n\tgithub.com/rivo/uniseg v0.4.7 // indirect\n\tgithub.com/samber/lo v1.52.0 // indirect\n\tgithub.com/tkrajina/go-reflector v0.5.8 // indirect\n\tgithub.com/twitchyliquid64/golang-asm v0.15.1 // indirect\n\tgithub.com/ugorji/go/codec v1.3.0 // indirect\n\tgithub.com/valyala/bytebufferpool v1.0.0 // indirect\n\tgithub.com/valyala/fasttemplate v1.2.2 // indirect\n\tgithub.com/vmihailenco/tagparser/v2 v2.0.0 // indirect\n\tgithub.com/wailsapp/go-webview2 v1.0.23 // indirect\n\tgithub.com/wailsapp/mimetype v1.4.1 // indirect\n\tgo.uber.org/atomic v1.11.0 // indirect\n\tgo.uber.org/mock v0.5.0 // indirect\n\tgolang.org/x/arch v0.20.0 // indirect\n\tgolang.org/x/mod v0.32.0 // indirect\n\tgolang.org/x/sync v0.19.0 // indirect\n\tgolang.org/x/sys v0.41.0 // indirect\n\tgolang.org/x/text v0.34.0 // indirect\n\tgolang.org/x/tools v0.41.0 // indirect\n\tgoogle.golang.org/protobuf v1.36.9 // indirect\n\tgopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect\n)\n\n// install latest wails: go install github.com/wailsapp/wails/v2/cmd/wails@latest\n// replace github.com/wailsapp/wails/v2 v2.11.0 => ~/go/pkg/mod\n"
  },
  {
    "path": "go.sum",
    "content": "github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=\ngithub.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=\ngithub.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=\ngithub.com/adrg/strutil v0.2.2/go.mod h1:EF2fjOFlGTepljfI+FzgTG13oXthR7ZAil9/aginnNQ=\ngithub.com/adrg/strutil v0.3.1 h1:OLvSS7CSJO8lBii4YmBt8jiK9QOtB9CzCzwl4Ic/Fz4=\ngithub.com/adrg/strutil v0.3.1/go.mod h1:8h90y18QLrs11IBffcGX3NW/GFBXCMcNg4M7H6MspPA=\ngithub.com/adrg/sysfont v0.1.2 h1:MSU3KREM4RhsQ+7QgH7wPEPTgAgBIz0Hw6Nd4u7QgjE=\ngithub.com/adrg/sysfont v0.1.2/go.mod h1:6d3l7/BSjX9VaeXWJt9fcrftFaD/t7l11xgSywCPZGk=\ngithub.com/adrg/xdg v0.3.0/go.mod h1:7I2hH/IT30IsupOpKZ5ue7/qNi3CoKzD6tL3HwpaRMQ=\ngithub.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=\ngithub.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=\ngithub.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=\ngithub.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=\ngithub.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=\ngithub.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=\ngithub.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=\ngithub.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=\ngithub.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=\ngithub.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=\ngithub.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=\ngithub.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=\ngithub.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=\ngithub.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=\ngithub.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=\ngithub.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=\ngithub.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=\ngithub.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=\ngithub.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=\ngithub.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=\ngithub.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=\ngithub.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=\ngithub.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=\ngithub.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=\ngithub.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=\ngithub.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=\ngithub.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=\ngithub.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=\ngithub.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=\ngithub.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=\ngithub.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=\ngithub.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=\ngithub.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=\ngithub.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=\ngithub.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=\ngithub.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=\ngithub.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=\ngithub.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=\ngithub.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=\ngithub.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=\ngithub.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=\ngithub.com/godbus/dbus/v5 v5.2.1 h1:I4wwMdWSkmI57ewd+elNGwLRf2/dtSaFz1DujfWYvOk=\ngithub.com/godbus/dbus/v5 v5.2.1/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=\ngithub.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=\ngithub.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=\ngithub.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=\ngithub.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ngithub.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=\ngithub.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=\ngithub.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=\ngithub.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=\ngithub.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=\ngithub.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=\ngithub.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=\ngithub.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=\ngithub.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=\ngithub.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=\ngithub.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=\ngithub.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=\ngithub.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/labstack/echo/v4 v4.14.0 h1:+tiMrDLxwv6u0oKtD03mv+V1vXXB3wCqPHJqPuIe+7M=\ngithub.com/labstack/echo/v4 v4.14.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=\ngithub.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=\ngithub.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=\ngithub.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=\ngithub.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=\ngithub.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=\ngithub.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=\ngithub.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=\ngithub.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=\ngithub.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=\ngithub.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=\ngithub.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=\ngithub.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=\ngithub.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=\ngithub.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=\ngithub.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=\ngithub.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=\ngithub.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=\ngithub.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=\ngithub.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=\ngithub.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=\ngithub.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=\ngithub.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=\ngithub.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=\ngithub.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=\ngithub.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=\ngithub.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=\ngithub.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=\ngithub.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=\ngithub.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=\ngithub.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=\ngithub.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=\ngithub.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=\ngithub.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=\ngithub.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=\ngithub.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=\ngithub.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=\ngithub.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=\ngithub.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=\ngithub.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=\ngithub.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=\ngithub.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=\ngithub.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=\ngithub.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=\ngithub.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=\ngithub.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=\ngithub.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=\ngithub.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=\ngithub.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=\ngithub.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=\ngithub.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=\ngithub.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=\ngithub.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=\ngithub.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=\ngithub.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=\ngithub.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=\ngithub.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=\ngithub.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=\ngithub.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=\ngithub.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=\ngithub.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=\ngithub.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=\ngithub.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=\ngithub.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=\ngithub.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=\ngithub.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=\ngithub.com/vrischmann/userdir v0.0.0-20151206171402-20f291cebd68 h1:Ah2/69Z24rwD6OByyOdpJDmttftz0FTF8Q4QZ/SF1E4=\ngithub.com/vrischmann/userdir v0.0.0-20151206171402-20f291cebd68/go.mod h1:EqKqAeKddSL9XSGnfXd/7iLncccKhR16HBKVva7ENw8=\ngithub.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=\ngithub.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=\ngithub.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=\ngithub.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=\ngithub.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=\ngithub.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=\ngithub.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=\ngithub.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=\ngithub.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=\ngithub.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=\ngithub.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=\ngithub.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=\ngo.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=\ngo.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=\ngo.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=\ngo.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=\ngolang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=\ngolang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=\ngolang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=\ngolang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=\ngolang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=\ngolang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=\ngolang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=\ngolang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=\ngolang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=\ngolang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=\ngolang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=\ngolang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=\ngolang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=\ngolang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=\ngolang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=\ngolang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=\ngolang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=\ngolang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=\ngolang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=\ngolang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=\ngolang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=\ngolang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=\ngolang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=\ngoogle.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=\ngoogle.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\n"
  },
  {
    "path": "main.go",
    "content": "//go:build !web\n\npackage main\n\nimport (\n\t\"context\"\n\t\"embed\"\n\t\"fmt\"\n\t\"runtime\"\n\t\"time\"\n\t\"tinyrdm/backend/consts\"\n\t\"tinyrdm/backend/services\"\n\n\t\"github.com/wailsapp/wails/v2\"\n\t\"github.com/wailsapp/wails/v2/pkg/menu\"\n\t\"github.com/wailsapp/wails/v2/pkg/options\"\n\t\"github.com/wailsapp/wails/v2/pkg/options/assetserver\"\n\t\"github.com/wailsapp/wails/v2/pkg/options/linux\"\n\t\"github.com/wailsapp/wails/v2/pkg/options/mac\"\n\t\"github.com/wailsapp/wails/v2/pkg/options/windows\"\n\truntime2 \"github.com/wailsapp/wails/v2/pkg/runtime\"\n)\n\n//go:embed all:frontend/dist\nvar assets embed.FS\n\n//go:embed build/appicon.png\nvar icon []byte\n\nvar version = \"0.0.0\"\nvar gaMeasurementID, gaSecretKey string\n\nconst appName = \"Tiny RDM\"\n\nfunc main() {\n\t// Create an instance of the app structure\n\tsysSvc := services.System()\n\tconnSvc := services.Connection()\n\tbrowserSvc := services.Browser()\n\tcliSvc := services.Cli()\n\tmonitorSvc := services.Monitor()\n\tpubsubSvc := services.Pubsub()\n\tprefSvc := services.Preferences()\n\tprefSvc.SetAppVersion(version)\n\tprefSvc.UpdateEnv()\n\twindowWidth, windowHeight, maximised := prefSvc.GetWindowSize()\n\twindowStartState := options.Normal\n\tif maximised {\n\t\twindowStartState = options.Maximised\n\t}\n\n\t// menu\n\tisMacOS := runtime.GOOS == \"darwin\"\n\tappMenu := menu.NewMenu()\n\tif isMacOS {\n\t\tappMenu.Append(menu.AppMenu())\n\t\tappMenu.Append(menu.EditMenu())\n\t\tappMenu.Append(menu.WindowMenu())\n\t}\n\n\t// Create application with options\n\terr := wails.Run(&options.App{\n\t\tTitle:                    appName,\n\t\tWidth:                    windowWidth,\n\t\tHeight:                   windowHeight,\n\t\tMinWidth:                 consts.MIN_WINDOW_WIDTH,\n\t\tMinHeight:                consts.MIN_WINDOW_HEIGHT,\n\t\tWindowStartState:         windowStartState,\n\t\tFrameless:                !isMacOS,\n\t\tMenu:                     appMenu,\n\t\tEnableDefaultContextMenu: true,\n\t\tAssetServer: &assetserver.Options{\n\t\t\tAssets: assets,\n\t\t},\n\t\tBackgroundColour: options.NewRGBA(255, 255, 255, 0),\n\t\tStartHidden:      true,\n\t\tOnStartup: func(ctx context.Context) {\n\t\t\tsysSvc.Start(ctx, version)\n\t\t\tconnSvc.Start(ctx)\n\t\t\tbrowserSvc.Start(ctx)\n\t\t\tcliSvc.Start(ctx)\n\t\t\tmonitorSvc.Start(ctx)\n\t\t\tpubsubSvc.Start(ctx)\n\n\t\t\tservices.GA().SetSecretKey(gaMeasurementID, gaSecretKey)\n\t\t\tservices.GA().Startup(version)\n\t\t},\n\t\tOnDomReady: func(ctx context.Context) {\n\t\t\tx, y := prefSvc.GetWindowPosition(ctx)\n\t\t\truntime2.WindowSetPosition(ctx, x, y)\n\t\t\truntime2.WindowShow(ctx)\n\t\t},\n\t\tOnBeforeClose: func(ctx context.Context) (prevent bool) {\n\t\t\tx, y := runtime2.WindowGetPosition(ctx)\n\t\t\tprefSvc.SaveWindowPosition(x, y)\n\t\t\treturn false\n\t\t},\n\t\tOnShutdown: func(ctx context.Context) {\n\t\t\tbrowserSvc.Stop()\n\t\t\tcliSvc.CloseAll()\n\t\t\tmonitorSvc.StopAll()\n\t\t\tpubsubSvc.StopAll()\n\t\t},\n\t\tBind: []interface{}{\n\t\t\tsysSvc,\n\t\t\tconnSvc,\n\t\t\tbrowserSvc,\n\t\t\tcliSvc,\n\t\t\tmonitorSvc,\n\t\t\tpubsubSvc,\n\t\t\tprefSvc,\n\t\t},\n\t\tMac: &mac.Options{\n\t\t\tTitleBar: mac.TitleBarHiddenInset(),\n\t\t\tAbout: &mac.AboutInfo{\n\t\t\t\tTitle:   fmt.Sprintf(\"%s %s\", appName, version),\n\t\t\t\tMessage: \"A modern lightweight cross-platform Redis desktop client.\\n\\nCopyright © \" + time.Now().Format(\"2006\"),\n\t\t\t\tIcon:    icon,\n\t\t\t},\n\t\t\tWebviewIsTransparent: false,\n\t\t\tWindowIsTranslucent:  false,\n\t\t},\n\t\tWindows: &windows.Options{\n\t\t\tWebviewIsTransparent:              false,\n\t\t\tWindowIsTranslucent:               false,\n\t\t\tDisableFramelessWindowDecorations: false,\n\t\t},\n\t\tLinux: &linux.Options{\n\t\t\tProgramName:         appName,\n\t\t\tIcon:                icon,\n\t\t\tWebviewGpuPolicy:    linux.WebviewGpuPolicyOnDemand,\n\t\t\tWindowIsTranslucent: true,\n\t\t},\n\t})\n\n\tif err != nil {\n\t\tprintln(\"Error:\", err.Error())\n\t}\n}\n"
  },
  {
    "path": "main_web.go",
    "content": "//go:build web\n\npackage main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/signal\"\n\t\"syscall\"\n\t\"tinyrdm/backend/api\"\n\t\"tinyrdm/backend/services\"\n)\n\nvar version = \"0.0.0\"\n\nfunc main() {\n\t// Wire up event bridge callbacks (breaks import cycle: services -> api)\n\tservices.EmitEventFunc = func(event string, data any) {\n\t\tapi.Hub().Emit(event, data)\n\t}\n\tservices.RegisterHandlerFunc = api.RegisterHandler\n\n\t// Initialize all services with a background context\n\tctx := context.Background()\n\n\tsysSvc := services.System()\n\tconnSvc := services.Connection()\n\tbrowserSvc := services.Browser()\n\tcliSvc := services.Cli()\n\tmonitorSvc := services.Monitor()\n\tpubsubSvc := services.Pubsub()\n\tprefSvc := services.Preferences()\n\tprefSvc.SetAppVersion(version)\n\tprefSvc.UpdateEnv()\n\n\t// Start services\n\tsysSvc.Start(ctx, version)\n\tconnSvc.Start(ctx)\n\tbrowserSvc.Start(ctx)\n\tcliSvc.Start(ctx)\n\tmonitorSvc.Start(ctx)\n\tpubsubSvc.Start(ctx)\n\n\tservices.GA().SetSecretKey(\"\", \"\")\n\n\t// Initialize auth\n\tapi.InitAuth()\n\n\t// Get port from env or default\n\tport := os.Getenv(\"PORT\")\n\tif port == \"\" {\n\t\tport = \"8088\"\n\t}\n\n\t// Setup HTTP server\n\trouter := api.SetupRouter()\n\tsrv := &http.Server{\n\t\tAddr:    fmt.Sprintf(\"0.0.0.0:%s\", port),\n\t\tHandler: router,\n\t}\n\n\t// Graceful shutdown\n\tgo func() {\n\t\tsigCh := make(chan os.Signal, 1)\n\t\tsignal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)\n\t\t<-sigCh\n\t\tlog.Println(\"Shutting down...\")\n\t\tbrowserSvc.Stop()\n\t\tcliSvc.CloseAll()\n\t\tmonitorSvc.StopAll()\n\t\tpubsubSvc.StopAll()\n\t\tsrv.Close()\n\t}()\n\n\tlog.Printf(\"Tiny RDM Web starting on http://0.0.0.0:%s\", port)\n\tif err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {\n\t\tlog.Fatalf(\"Failed to start server: %v\", err)\n\t}\n}\n"
  },
  {
    "path": "wails.json",
    "content": "{\n  \"$schema\": \"https://wails.io/schemas/config.v2.json\",\n  \"name\": \"tinyrdm\",\n  \"outputfilename\": \"Tiny RDM\",\n  \"frontend:install\": \"npm install\",\n  \"frontend:build\": \"npm run build\",\n  \"frontend:dev:watcher\": \"npm run dev\",\n  \"frontend:dev:serverUrl\": \"auto\",\n  \"author\": {\n    \"name\": \"tiny-craft\",\n    \"email\": \"lykinhuang@outlook.com\"\n  },\n  \"info\": {\n    \"companyName\": \"Tiny Craft\",\n    \"productName\": \"Tiny RDM\",\n    \"productVersion\": \"1.0.0\",\n    \"copyright\": \"Copyright © 2026\",\n    \"comments\": \"Tiny Redis Desktop Manager\"\n  }\n}\n"
  }
]