├── .assets ├── rimedm_修改.gif ├── rimedm_修改.mkv ├── rimedm_删词.gif ├── rimedm_删词.mkv ├── rimedm_加词.gif └── rimedm_加词.mkv ├── .envrc ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .goreleaser.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config └── config.yaml ├── core ├── core.go ├── options.go └── options_test.go ├── dict ├── dictionary.go ├── dictionary_test.go ├── loader.go ├── loader_test.go ├── matcher.go ├── output.go └── output_test.go ├── flake.lock ├── flake.nix ├── go.mod ├── go.sum ├── install.ps1 ├── install.sh ├── main.go ├── tui ├── event.go └── tui.go └── util ├── command.go ├── command_windows.go ├── debounce.go ├── id_generator.go ├── lock.go └── misc.go /.assets/rimedm_修改.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MapoMagpie/rimedm/be9f9251c95ca0864c22b89c69a601fb6a381152/.assets/rimedm_修改.gif -------------------------------------------------------------------------------- /.assets/rimedm_修改.mkv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MapoMagpie/rimedm/be9f9251c95ca0864c22b89c69a601fb6a381152/.assets/rimedm_修改.mkv -------------------------------------------------------------------------------- /.assets/rimedm_删词.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MapoMagpie/rimedm/be9f9251c95ca0864c22b89c69a601fb6a381152/.assets/rimedm_删词.gif -------------------------------------------------------------------------------- /.assets/rimedm_删词.mkv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MapoMagpie/rimedm/be9f9251c95ca0864c22b89c69a601fb6a381152/.assets/rimedm_删词.mkv -------------------------------------------------------------------------------- /.assets/rimedm_加词.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MapoMagpie/rimedm/be9f9251c95ca0864c22b89c69a601fb6a381152/.assets/rimedm_加词.gif -------------------------------------------------------------------------------- /.assets/rimedm_加词.mkv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MapoMagpie/rimedm/be9f9251c95ca0864c22b89c69a601fb6a381152/.assets/rimedm_加词.mkv -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | name: Release Build 4 | 5 | on: 6 | #push: 7 | #tags: 8 | #- '*' # 匹配所有 tag 9 | workflow_dispatch: # 添加手动触发选项 10 | inputs: 11 | tag_version: 12 | description: 'input version' 13 | required: true 14 | type: string 15 | 16 | jobs: 17 | build: 18 | name: Build and Release 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | with: 24 | fetch-depth: 0 25 | 26 | - name: Set up Go 27 | uses: actions/setup-go@v4 28 | with: 29 | go-version: 'stable' 30 | 31 | - name: Get tag version 32 | id: get_version 33 | run: echo "version=${{ github.event.inputs.tag_version }}" >> $GITHUB_OUTPUT 34 | 35 | - name: Create output directory 36 | run: mkdir -p dist 37 | 38 | - name: Build for Darwin (macOS) - arm64 39 | run: | 40 | GOOS=darwin GOARCH=arm64 go build -o rimedm 41 | tar czf dist/rimedm_Darwin_arm64.tar.gz rimedm 42 | rm rimedm 43 | 44 | - name: Build for Darwin (macOS) - x86_64 45 | run: | 46 | GOOS=darwin GOARCH=amd64 go build -o rimedm 47 | tar czf dist/rimedm_Darwin_x86_64.tar.gz rimedm 48 | rm rimedm 49 | 50 | - name: Build for Linux - arm64 51 | run: | 52 | GOOS=linux GOARCH=arm64 go build -o rimedm 53 | tar czf dist/rimedm_Linux_arm64.tar.gz rimedm 54 | rm rimedm 55 | 56 | - name: Build for Linux - x86_64 57 | run: | 58 | GOOS=linux GOARCH=amd64 go build -o rimedm 59 | tar czf dist/rimedm_Linux_x86_64.tar.gz rimedm 60 | rm rimedm 61 | 62 | - name: Build for Windows - arm64 63 | run: | 64 | GOOS=windows GOARCH=arm64 go build -o rimedm.exe 65 | zip -j dist/rimedm_Windows_arm64.zip rimedm.exe 66 | rm rimedm.exe 67 | 68 | - name: Build for Windows - x86_64 69 | run: | 70 | GOOS=windows GOARCH=amd64 go build -o rimedm.exe 71 | zip -j dist/rimedm_Windows_x86_64.zip rimedm.exe 72 | rm rimedm.exe 73 | 74 | - name: Generate checksums 75 | run: | 76 | cd dist 77 | sha256sum * > checksums.txt 78 | cd .. 79 | 80 | - name: Create release 81 | uses: softprops/action-gh-release@v1 82 | with: 83 | name: Release ${{ steps.get_version.outputs.version }} 84 | tag_name: ${{ steps.get_version.outputs.version }} 85 | body: "Pre-built binaries for ${{ steps.get_version.outputs.version }}" 86 | files: | 87 | dist/rimedm_* 88 | dist/checksums.txt 89 | env: 90 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 91 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go Test 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Set up Go 19 | uses: actions/setup-go@v4 20 | with: 21 | go-version: '1.21.6' 22 | - name: Install dependencies 23 | run: | 24 | go get . 25 | 26 | - name: Test 27 | run: go test -v ./... 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config 2 | rime 3 | dist 4 | rimedm 5 | result 6 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | 4 | # The lines below are called `modelines`. See `:help modeline` 5 | # Feel free to remove those if you don't want/need to use them. 6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 7 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 8 | 9 | version: 1 10 | 11 | before: 12 | hooks: 13 | # You may remove this if you don't use go modules. 14 | - go mod tidy 15 | # you may remove this if you don't need go generate 16 | - go generate ./... 17 | 18 | builds: 19 | - env: 20 | - CGO_ENABLED=0 21 | goos: 22 | - linux 23 | - windows 24 | - darwin 25 | 26 | archives: 27 | - format: tar.gz 28 | # this name template makes the OS and Arch compatible with the results of `uname`. 29 | name_template: >- 30 | {{ .ProjectName }}_ 31 | {{- title .Os }}_ 32 | {{- if eq .Arch "amd64" }}x86_64 33 | {{- else if eq .Arch "386" }}i386 34 | {{- else }}{{ .Arch }}{{ end }} 35 | {{- if .Arm }}v{{ .Arm }}{{ end }} 36 | # use zip for windows archives 37 | format_overrides: 38 | - goos: windows 39 | format: zip 40 | 41 | changelog: 42 | sort: asc 43 | filters: 44 | exclude: 45 | - "^docs:" 46 | - "^test:" 47 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | 1.0.1 5 | ------ 6 | - Release 1.0.1 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rime Dict Manager 2 | > rime词典管理器,通过TUI呈现,可对词库进行修改、删词、加词、查询。 3 | > 配合Rime的重新部署指令,可实现`修改既生效`的效果 4 | 5 | ## 演示 6 | ### 加词 7 | ![加词](.assets/rimedm_加词.gif) 8 | ### 修改 9 | ![修改](.assets/rimedm_修改.gif) 10 | ### 删词 11 | ![删词](.assets/rimedm_删词.gif) 12 | 13 | ## 安装 14 | 15 | ### 通过一键脚本安装 16 | #### Windows (可能需要PowerShell 5.1版本以上,从微软应用商店中下载最新的PowerShell) 17 | 复制以下代码,打开PowerShell并粘贴 18 | ```shell 19 | iwr https://github.com/MapoMagpie/rimedm/raw/main/install.ps1 -useb | iex 20 | ``` 21 | #### Lnux/MacOs 22 | ```shell 23 | curl -fsSL https://github.com/MapoMagpie/rimedm/raw/main/install.sh | bash -s 24 | ``` 25 | ### 手动安装 26 | #### Windows 27 | 1. 从发布页中下载最新的`rimedm_Windows_x86_64.zip`, [https://github.com/MapoMagpie/rimedm/releases](https://github.com/MapoMagpie/rimedm/releases) 。 28 | 2. 解压文件,文件中包含`rimedm.exe`。 29 | 3. 你可以在解压后的文件夹中打开终端,直接通过`.\rimedm.exe`运行本程序,程序会从小狼毫默认的用户目录中读取你的输入方案。 30 | 4. 你也可以将`rimedm.exe`移动至某个目录中,如:`%Appdata%\rimedm\`,并将该目录添加到环境变量Path中,之后便可以直接在终端中输入`rimedm`运行程序。 31 | #### Lnux/MacOs 32 | 1. 从发布页中下载最新的`rimedm_Linux_x86_64.tar.gz`或`rimedm_Darwin_x86_64.tar.gz`, [https://github.com/MapoMagpie/rimedm/releases](https://github.com/MapoMagpie/rimedm/releases) 。 33 | 2. 将其解压到常见的用户程序目录中,比如: `~/.local/bin`或`/usr/bin/`。 34 | 3. 输入命令`rimedm`即可。 35 | 36 | ## 配置 37 | > rimedm会根据Rime的相关配置自动生成一份自身所需的配置文件来达到开箱即用的效果。
38 | > 但也有可能存在系统环境的不同,导致无法自动指定主词典文件。
39 | > 此时需要你在配置文件中修改dict_paths的位置。
40 | > 默认的配置文件根据不同的系统所在位置为:
41 | > Windows: `%APPDATA%\rimedm\config.yaml`
42 | > Linux: `$HOME/.config/rimedm/config.yaml`
43 | > Windows: `%APPDATA%\rimedm\config.yaml`
44 | 45 | ```yaml 46 | # Rime Dict Manager config file 47 | # This file is generated by rime-dict-manager. 48 | 49 | # dict_paths 是主词典文件的路径,本程序会自动加载主词典所引用的其他拓展词典。 50 | # 支持多个主词典,注意是主词典,请不要将主词典与其所属拓展词典一同写在dict_paths:下 51 | # 在Linux + Fcitx5 + Fcitx5-Rime下,词典的路径一般是: $HOME/.local/share/fcitx5/rime/方案名.dict.yaml 52 | # 在Windows + 小狼毫下,词典的路径一般是: %Appdata%/rime/方案名.dict.yaml 53 | #dict_paths: 54 | # - 主词典1文件路径 55 | # - 主词典2文件路径 56 | # 禁止 57 | # - 主词典1下的拓展词典文件路径 58 | dict_paths: 59 | - $HOME/.local/share/fcitx5/rime/xkjd6.dict.yaml 60 | 61 | # user_path 是用户词典路径,可以为空, 62 | # 当指定了用户词典时,在添加新词时,用户词典会作为优先的添加选项。 63 | # 如果没有指定用户词典,你也可以在添加时的选项中选择用户词典或其他词典。 64 | #user_path: 65 | 66 | # 是否在每次添加、删除、修改时立即同步到词典文件,默认为 true 67 | sync_on_change: true 68 | # 在同步词典文件时,通过这个命令来重启 rime, 不同的系统环境下需要不同的命令。 69 | # 在Linux + Fcitx5 下可通过此命令来重启 rime: 70 | # dbus-send --session --print-reply --dest=org.fcitx.Fcitx5 /controller org.fcitx.Fcitx.Controller1.SetConfig string:'fcitx://config/addon/rime' variant:string:'' 71 | # 在Windows + 小狼毫 下可通过此命令来重启 rime(注意程序版本): 72 | # C:\PROGRA~2\Rime\weasel-0.14.3\WeaselDeployer.exe /deploy 73 | # 注:PROGRA~2 = Program Files (x86) PROGRA~1 = Program Files 74 | restart_rime_cmd: dbus-send --session --print-reply --dest=org.fcitx.Fcitx5 /controller org.fcitx.Fcitx.Controller1.SetConfig string:'fcitx://config/addon/rime' variant:string:'' 75 | ``` 76 | 77 | ### 通过参数运行rimedm 78 | 示例 79 | ```shell 80 | rimedm -d 词典文件 81 | ``` 82 | 完整的参数使用 83 | ```shell 84 | Usage of rimedm: 85 | -c string 86 | (可选)配置文件路径,默认位置:$HOME/.config/rimedm/config.yaml (default "$HOME/.config/rimedm/config.yaml") 87 | -cmd string 88 | (可选)同步到词典文件后,用于重新部署rime的命令,使更改即时生效,不同的系统环境下需要不同的命令 89 | -d value 90 | (当使用配置文件时可选)主词典文件(方案名.dict.yaml)路径,通过主词典会自动加载其他拓展词典,无需指定拓展词典。 91 | 支持多个主词典文件,e.g: rimedm -d ./xkjd6.dict.yaml -d ./xhup.dict.txt 92 | -sync 93 | (可选)是否在每次添加、删除、修改时立即同步到词典文件,默认为 true (default true) 94 | -u string 95 | (可选)用户词典路径 96 | -v 显示版本号 97 | ``` 98 | -------------------------------------------------------------------------------- /config/config.yaml: -------------------------------------------------------------------------------- 1 | # Rime Dict Manager config file 2 | # This file is generated by rime-dict-manager. 3 | 4 | # dict_paths 是主词典文件的路径,本程序会自动加载主词典所引用的其他拓展词典。 5 | # 支持多个主词典,注意是主词典,请不要将主词典与其所属拓展词典一同写在dict_paths:下 6 | # 在Linux + Fcitx5 + Fcitx5-Rime下,词典的路径一般是: $HOME/.local/share/fcitx5/rime/方案名.dict.yaml 7 | # 在Windows + 小狼毫下,词典的路径一般是: %Appdata%/rime/方案名.dict.yaml 8 | #dict_paths: 9 | # - 主词典1文件路径 10 | # - 主词典2文件路径 11 | # 禁止 12 | # - 主词典1下的拓展词典文件路径 13 | dict_paths: 14 | - ./rime/xkjd/xkjd6.dict.yaml 15 | # - ./rime/xhup/flypy_sys.txt 16 | 17 | # user_path 是用户词典路径,可以为空, 18 | # 当指定了用户词典时,在添加新词时,用户词典会作为优先的添加选项。 19 | # 如果没有指定用户词典,你也可以在添加时的选项中选择用户词典或其他词典。 20 | #user_path: 21 | 22 | # 是否在每次添加、删除、修改时立即同步到词典文件,默认为 true 23 | #sync_on_change: true 24 | # 在同步词典文件时,通过这个命令来重启 rime, 不同的系统环境下需要不同的命令。 25 | # 在Linux + Fcitx5 下可通过此命令来重启 rime: 26 | # dbus-send --session --print-reply --dest=org.fcitx.Fcitx5 /controller org.fcitx.Fcitx.Controller1.SetConfig string:'fcitx://config/addon/rime' variant:string:'' 27 | # 在Windows + 小狼毫 下可通过此命令来重启 rime(注意程序版本): 28 | # C:\PROGRA~2\Rime\weasel-0.14.3\WeaselDeployer.exe /deploy 29 | # 注:PROGRA~2 = Program Files (x86) PROGRA~1 = Program Files 30 | #restart_rime_cmd: dbus-send --session --print-reply --dest=org.fcitx.Fcitx5 /controller org.fcitx.Fcitx.Controller1.SetConfig string:'fcitx://config/addon/rime' variant:string:'' 31 | -------------------------------------------------------------------------------- /core/core.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "math" 8 | "os" 9 | "slices" 10 | "sort" 11 | "strings" 12 | "time" 13 | 14 | "github.com/MapoMagpie/rimedm/dict" 15 | "github.com/MapoMagpie/rimedm/tui" 16 | mutil "github.com/MapoMagpie/rimedm/util" 17 | 18 | tea "github.com/charmbracelet/bubbletea" 19 | ) 20 | 21 | func Start(opts *Options) { 22 | // load dict file and create dictionary 23 | start := time.Now() 24 | fes := dict.LoadItems(opts.DictPaths...) 25 | sort.Slice(fes, func(i, j int) bool { 26 | return fes[j].Cmp(fes[i]) 27 | }) 28 | since := time.Since(start) 29 | log.Printf("Load %s: %s\n", opts.DictPaths, since) 30 | dc := dict.NewDictionary(fes, &dict.CacheMatcher{}) 31 | 32 | // collect file name, will show on addition 33 | fileNames := make([]tui.ItemRender, 0) 34 | for _, fe := range fes { 35 | if fe.FilePath == opts.UserPath { 36 | fileNames = append([]tui.ItemRender{fe}, fileNames...) 37 | } else { 38 | fileNames = append(fileNames, fe) 39 | } 40 | } 41 | 42 | searchChan := make(chan string, 20) 43 | listManager := tui.NewListManager(searchChan) 44 | listManager.SetFiles(fileNames) 45 | 46 | // 添加菜单 47 | menuNameAdd := tui.Menu{Name: "A添加", 48 | Cb: func(m *tui.Model) (cmd tea.Cmd) { 49 | if len(m.Inputs) == 0 { 50 | return tui.ExitMenuCmd 51 | } 52 | file, err := m.CurrFile() 53 | fe := file.(*dict.FileEntries) 54 | if err != nil { 55 | panic(fmt.Sprintf("add to dict error: %v", err)) 56 | } 57 | raw := strings.TrimSpace(strings.Join(m.Inputs, "")) 58 | if raw == "" { 59 | return 60 | } 61 | pair, cols := dict.ParseInput(raw, slices.Index(fe.Columns, dict.COLUMN_STEM) != -1) 62 | if len(pair) == 0 { // allow add single column 63 | return tui.ExitMenuCmd 64 | } 65 | data, _ := dict.ParseData(pair, &cols) 66 | data.ResetColumns(&fe.Columns) 67 | curr, err := listManager.Curr() 68 | if err == nil { // 自动修改权重 69 | currEntry := curr.(*dict.MatchResult).Entry 70 | currEntryData := currEntry.Data() 71 | if currEntryData.Code == data.Code && data.Weight == 0 { // 新加项的码如果和当前项的码相同,则自动修改新加项的权重 72 | data.Weight = currEntryData.Weight + 1 73 | } 74 | } 75 | entryRaw := data.ToString() 76 | dc.Add(dict.NewEntryAdd(entryRaw, fe.ID, data)) 77 | log.Printf("add item: %s\n", entryRaw) 78 | m.Inputs = strings.Split(data.Code, "") 79 | m.InputCursor = len(m.Inputs) 80 | dc.ResetMatcher() 81 | FlushAndSync(opts, dc, opts.SyncOnChange) 82 | return tui.ExitMenuCmd 83 | }, 84 | OnSelected: func(m *tui.Model) { 85 | m.ListManager.ListMode = tui.LIST_MODE_FILE 86 | }, 87 | } 88 | 89 | // 删除菜单 90 | menuNameDelete := tui.Menu{Name: "D删除", 91 | Cb: func(m *tui.Model) (cmd tea.Cmd) { 92 | item, err := m.CurrItem() 93 | if err != nil { 94 | m.Inputs = []string{} 95 | m.InputCursor = 0 96 | return tui.ExitMenuCmd 97 | } 98 | switch item := item.(type) { 99 | case *dict.MatchResult: 100 | dc.Delete(item.Entry) 101 | log.Printf("delete item: %s\n", item) 102 | dc.ResetMatcher() 103 | FlushAndSync(opts, dc, opts.SyncOnChange) 104 | } 105 | return tui.ExitMenuCmd 106 | }, 107 | OnSelected: func(m *tui.Model) { 108 | m.ListManager.ListMode = tui.LIST_MODE_DICT 109 | }, 110 | } 111 | 112 | // 修改菜单 113 | var modifyingItem tui.ItemRender 114 | menuNameModify := tui.Menu{Name: "M修改", 115 | Cb: func(m *tui.Model) (cmd tea.Cmd) { 116 | item, err := m.CurrItem() 117 | if err != nil { 118 | m.Inputs = []string{} 119 | m.InputCursor = 0 120 | return tui.ExitMenuCmd 121 | } 122 | m.Modifying = true 123 | modifyingItem = item 124 | m.Inputs = strings.Split(strings.TrimSpace(modifyingItem.String()), "") 125 | m.InputCursor = len(m.Inputs) 126 | m.MenuIndex = 0 127 | return tui.ExitMenuCmd 128 | }, 129 | OnSelected: func(m *tui.Model) { 130 | m.ListManager.ListMode = tui.LIST_MODE_DICT 131 | }, 132 | } 133 | 134 | // 确认修改菜单 135 | menuNameConfirm := tui.Menu{Name: "C确认", Cb: func(m *tui.Model) tea.Cmd { 136 | m.Modifying = false 137 | raw := strings.Join(m.Inputs, "") 138 | switch item := modifyingItem.(type) { 139 | case *dict.MatchResult: 140 | feIndex := slices.IndexFunc(fes, func(fe *dict.FileEntries) bool { 141 | return fe.ID == item.Entry.FID 142 | }) 143 | if feIndex == -1 { 144 | panic("modify item error: this item does not belong to any file") 145 | } 146 | fe := fes[feIndex] 147 | pair, cols := dict.ParseInput(raw, slices.Index(fe.Columns, dict.COLUMN_STEM) != -1) 148 | if len(pair) > 1 { 149 | data, _ := dict.ParseData(pair, &cols) 150 | data.ResetColumns(&fe.Columns) 151 | entryRaw := data.ToString() 152 | log.Printf("modify confirm item: %s\n", entryRaw) 153 | item.Entry.ReRaw(entryRaw) 154 | if feIndex > -1 { 155 | item.Entry.ReRaw(data.ToStringWithColumns(&fes[feIndex].Columns)) 156 | } 157 | m.Inputs = strings.Split(data.Code, "") 158 | m.InputCursor = len(m.Inputs) 159 | } 160 | dc.ResetMatcher() 161 | FlushAndSync(opts, dc, opts.SyncOnChange) 162 | } 163 | return tui.ExitMenuCmd 164 | }} 165 | 166 | // 退出到列表菜单 167 | menuNameBack := tui.Menu{Name: "B返回", Cb: func(m *tui.Model) tea.Cmd { 168 | m.MenusShowing = false 169 | listManager.ListMode = tui.LIST_MODE_DICT 170 | return tui.ExitMenuCmd 171 | }} 172 | 173 | showMenus := []*tui.Menu{&menuNameAdd, &menuNameModify, &menuNameDelete, &menuNameBack} 174 | modifyingMenus := []*tui.Menu{&menuNameConfirm, &menuNameBack} 175 | helpMenus := []*tui.Menu{&menuNameBack} 176 | exportMenus := []*tui.Menu{&menuNameBack, &menuNameBack} // will change the first element later 177 | menuFetcher := func(m *tui.Model) []*tui.Menu { 178 | menus := []*tui.Menu{} 179 | switch m.ListManager.ListMode { 180 | case tui.LIST_MODE_DICT: 181 | if m.MenusShowing { 182 | if m.Modifying { 183 | menus = modifyingMenus 184 | } else { 185 | menus = showMenus 186 | } 187 | } 188 | case tui.LIST_MODE_FILE: 189 | menus = showMenus 190 | case tui.LIST_MODE_HELP: 191 | menus = helpMenus 192 | case tui.LIST_MODE_EXPO: 193 | menus = exportMenus 194 | } 195 | if len(menus) > 0 && m.MenuIndex >= len(menus) { 196 | m.MenuIndex = 0 197 | } 198 | return menus 199 | } 200 | model := tui.NewModel(listManager, menuFetcher) 201 | teaProgram := tea.NewProgram(model) 202 | 203 | listManager.ExportOptions = []tui.ItemRender{ 204 | tui.StringRender("字词"), 205 | tui.StringRender("编码"), 206 | tui.StringRender("权重"), 207 | tui.StringRender("---------排除标记-----------"), 208 | tui.StringRender("如将权重向上移动至排除标记后将不输出权重"), 209 | tui.StringRender("使用Ctrl+Up或Ctrl+Down调整下方的输出格式"), 210 | tui.StringRender("默认以 字词编码权重 每行输出到文件"), 211 | } 212 | // 导出码表菜单 213 | menuNameExport := tui.Menu{Name: "E导出", Cb: func(m *tui.Model) tea.Cmd { 214 | m.ListManager.ListMode = tui.LIST_MODE_DICT 215 | m.HideMenus() 216 | go func() { 217 | filePath := "output.txt" 218 | columns := make([]dict.Column, 0) 219 | options := listManager.ExportOptions[:3] 220 | for _, opt := range options { 221 | match := true 222 | switch opt.String() { 223 | case "字词": 224 | columns = append(columns, dict.COLUMN_TEXT) 225 | case "编码": 226 | columns = append(columns, dict.COLUMN_CODE) 227 | case "权重": 228 | columns = append(columns, dict.COLUMN_WEIGHT) 229 | default: 230 | match = false 231 | } 232 | if !match { 233 | break 234 | } 235 | } 236 | time.Sleep(time.Second) 237 | if len(columns) > 0 { 238 | dc.ExportDict(filePath, columns) 239 | teaProgram.Send(tui.NotifitionMsg("完成导出码表 > output.txt")) 240 | } else { 241 | teaProgram.Send(tui.NotifitionMsg("没有东西要导出")) 242 | } 243 | }() 244 | return func() tea.Msg { 245 | return tui.NotifitionMsg("正在导出码表...") 246 | } 247 | }} 248 | exportMenus[0] = &menuNameExport 249 | 250 | // events 251 | exitEvent := &tui.Event{ 252 | Keys: []string{"esc", "ctrl+c", "ctrl+d"}, 253 | Cb: func(key string, m *tui.Model) (tea.Model, tea.Cmd) { 254 | if key == "esc" { 255 | if m.Modifying || m.MenusShowing { 256 | if m.Modifying { 257 | m.Inputs = []string{} 258 | m.InputCursor = 0 259 | m.Modifying = false 260 | } 261 | m.ListManager.ListMode = tui.LIST_MODE_DICT 262 | m.HideMenus() 263 | return m, nil 264 | } 265 | } 266 | FlushAndSync(opts, dc, true) 267 | return m, tea.Quit 268 | }, 269 | } 270 | 271 | // 修改权重,这是一个高频操作,通过debouncer延迟同步到文件。 272 | modifyWeightDebouncer := mutil.NewDebouncer(time.Millisecond * 1000) // 一秒后 273 | modifyWeightEvent := &tui.Event{ 274 | Keys: []string{"ctrl+up", "ctrl+down", "ctrl+left", "ctrl+right"}, 275 | Cb: func(key string, m *tui.Model) (tea.Model, tea.Cmd) { 276 | // adjust the columns of export dict 277 | if m.ListManager.ListMode == tui.LIST_MODE_EXPO { 278 | var newIndex int 279 | if key == "ctrl+up" { 280 | newIndex = listManager.ExportOptionsIndex + 1 281 | if newIndex >= len(listManager.ExportOptions) { 282 | return m, nil 283 | } 284 | } else if key == "ctrl+down" { 285 | newIndex = listManager.ExportOptionsIndex - 1 286 | if newIndex < 0 { 287 | return m, nil 288 | } 289 | } 290 | listManager.ExportOptions[listManager.ExportOptionsIndex], listManager.ExportOptions[newIndex] = 291 | listManager.ExportOptions[newIndex], listManager.ExportOptions[listManager.ExportOptionsIndex] 292 | listManager.ExportOptionsIndex = newIndex 293 | return m, func() tea.Msg { return 0 } // trigger bubbletea update 294 | } 295 | curr, err := listManager.Curr() 296 | if err != nil { 297 | return m, nil 298 | } 299 | changed := false 300 | currEntry := curr.(*dict.MatchResult).Entry 301 | currEntryData := currEntry.Data() 302 | if key == "ctrl+up" || key == "ctrl+down" { 303 | list, _ := listManager.List() 304 | if len(list) <= 1 { 305 | return m, nil 306 | } 307 | // log.Println("list: ", list) 308 | var prev *dict.Entry = nil 309 | var next *dict.Entry = nil 310 | for i := range list { 311 | entry := list[i].(*dict.MatchResult).Entry 312 | if entry == currEntry { 313 | if i+1 < len(list) { 314 | next = list[i+1].(*dict.MatchResult).Entry 315 | } 316 | if i-1 >= 0 { 317 | prev = list[i-1].(*dict.MatchResult).Entry 318 | } 319 | break 320 | } 321 | } 322 | if key == "ctrl+up" && next != nil { 323 | currEntryData.Weight = int(math.Max(1, float64(next.Data().Weight-1))) 324 | changed = true 325 | } 326 | if key == "ctrl+down" && prev != nil { 327 | currEntryData.Weight = int(math.Max(1, float64(prev.Data().Weight+1))) 328 | changed = true 329 | } 330 | } 331 | if key == "ctrl+left" { 332 | currEntryData.Weight = int(math.Max(1, float64(currEntryData.Weight-1))) 333 | changed = true 334 | } 335 | if key == "ctrl+right" { 336 | currEntryData.Weight = int(math.Max(1, float64(currEntryData.Weight+1))) 337 | changed = true 338 | } 339 | if changed { 340 | currEntry.ReRaw(currEntryData.ToString()) 341 | listManager.ReSort() 342 | list, _ := listManager.List() 343 | // 重新设置 listManager 的 currIndex为当前修改的项 344 | for i, item := range list { 345 | if item.(*dict.MatchResult).Entry == currEntry { 346 | listManager.SetIndex(i) 347 | break 348 | } 349 | } 350 | // 延迟同步到文件 351 | // log.Println("modify weight sync: ", currEntry.Raw()) 352 | modifyWeightDebouncer.Do(func() { 353 | FlushAndSync(opts, dc, opts.SyncOnChange) 354 | }) 355 | } 356 | return m, func() tea.Msg { return 0 } // trigger bubbletea update 357 | }, 358 | } 359 | 360 | // 显示帮助 361 | showHelpEvent := &tui.Event{ 362 | Keys: []string{"ctrl+h"}, 363 | Cb: func(key string, m *tui.Model) (tea.Model, tea.Cmd) { 364 | if m.ListManager.ListMode == tui.LIST_MODE_HELP { 365 | m.ListManager.ListMode = tui.LIST_MODE_DICT 366 | return m, tui.ExitMenuCmd 367 | } else { 368 | m.ListManager.ListMode = tui.LIST_MODE_HELP 369 | m.ShowMenus() 370 | return m, func() tea.Msg { return 0 } // trigger bubbletea update 371 | } 372 | }, 373 | } 374 | // 显示导出码表 375 | showExportDictEvent := &tui.Event{ 376 | Keys: []string{"ctrl+o"}, 377 | Cb: func(key string, m *tui.Model) (tea.Model, tea.Cmd) { 378 | if m.ListManager.ListMode == tui.LIST_MODE_EXPO { 379 | m.ListManager.ListMode = tui.LIST_MODE_DICT 380 | m.MenusShowing = false 381 | return m, tui.ExitMenuCmd 382 | } else { 383 | m.ListManager.ListMode = tui.LIST_MODE_EXPO 384 | m.ShowMenus() 385 | return m, func() tea.Msg { return 0 } // trigger bubbletea update 386 | } 387 | }, 388 | } 389 | // 重新部署,强制保存变更到文件,并执行rime部署指令。 390 | redeployEvent := &tui.Event{ 391 | Keys: []string{"ctrl+s"}, 392 | Cb: func(_ string, m *tui.Model) (tea.Model, tea.Cmd) { 393 | FlushAndSync(opts, dc, true) 394 | return m, nil 395 | }, 396 | } 397 | // new model 398 | events := []*tui.Event{ 399 | tui.MoveEvent, 400 | tui.EnterEvent, 401 | tui.ClearInputEvent, 402 | exitEvent, 403 | redeployEvent, 404 | modifyWeightEvent, 405 | showHelpEvent, 406 | showExportDictEvent, 407 | } 408 | model.AddEvent(events...) 409 | // 输入处理 搜索 410 | go func() { 411 | var cancelFunc context.CancelFunc 412 | resultChan := make(chan dict.MatchResultChunk) 413 | timer := time.NewTicker(time.Millisecond * 100) // debounce 414 | hasAppend := false 415 | searchVersion := 0 416 | for { 417 | select { 418 | case raw := <-searchChan: // 等待搜索term 419 | ctx, cancel := context.WithCancel(context.Background()) 420 | if cancelFunc != nil { 421 | cancelFunc() 422 | } 423 | cancelFunc = cancel 424 | var rs string 425 | useColumn := dict.COLUMN_CODE 426 | if len(raw) > 0 { 427 | // if the input has code(码) then change the rs(search term) to code 428 | pairs, cols := dict.ParseInput(raw, false) 429 | if len(pairs) > 0 { 430 | codeIndex := slices.Index(cols, dict.COLUMN_CODE) 431 | if codeIndex != -1 { 432 | useColumn = dict.COLUMN_CODE 433 | rs = pairs[codeIndex] 434 | } else { 435 | if len(pairs) == 1 && mutil.IsAscii(pairs[0]) { 436 | useColumn = dict.COLUMN_CODE 437 | rs = pairs[0] 438 | } else { 439 | textIndex := slices.Index(cols, dict.COLUMN_TEXT) 440 | useColumn = dict.COLUMN_TEXT 441 | rs = pairs[textIndex] 442 | } 443 | } 444 | } 445 | } 446 | searchVersion++ 447 | listManager.NewList(searchVersion) 448 | go dc.Search(rs, useColumn, searchVersion, resultChan, ctx) 449 | case ret := <-resultChan: // 等待搜索结果 450 | list := make([]tui.ItemRender, len(ret.Result)) 451 | for i, entry := range ret.Result { 452 | list[i] = entry 453 | } 454 | // log.Printf("search result: %d, version: %d", len(list), ret.Version) 455 | listManager.AppendList(list, ret.Version) 456 | hasAppend = true 457 | case <-timer.C: // debounce, if appended then flush 458 | if hasAppend { 459 | hasAppend = false 460 | teaProgram.Send(0) // trigger bubbletea update 461 | } 462 | } 463 | } 464 | }() 465 | 466 | if _, err := teaProgram.Run(); err != nil { 467 | fmt.Printf("Tui Program Error: %v\n", err) 468 | os.Exit(1) 469 | } 470 | } 471 | 472 | var lock *mutil.FLock = mutil.NewFLock() 473 | 474 | // 同步变更到文件中,如果启用了自动部署Rime的功能则调用部署指令 475 | func FlushAndSync(opts *Options, dc *dict.Dictionary, sync bool) { 476 | // 此操作的阻塞的,但可能被异步调用,因此加上防止重复调用机制 477 | if !lock.Should() { 478 | return 479 | } 480 | defer lock.Done() 481 | if !sync { 482 | return 483 | } 484 | if dc.Flush() && opts.RestartRimeCmd != "" { 485 | // TODO: check RestartRimeCmd, if weasel updated, the program path may be changed 486 | cmd := mutil.Run(opts.RestartRimeCmd) 487 | err := cmd.Run() 488 | if err != nil { 489 | panic(fmt.Errorf("exec restart rime cmd error:%v", err)) 490 | } 491 | } 492 | } 493 | -------------------------------------------------------------------------------- /core/options.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "log" 9 | "os" 10 | "path/filepath" 11 | "regexp" 12 | "runtime" 13 | "strconv" 14 | "strings" 15 | 16 | "github.com/goccy/go-yaml" 17 | ) 18 | 19 | var version = "1.1.3" 20 | 21 | type Options struct { 22 | RestartRimeCmd string `yaml:"restart_rime_cmd"` 23 | UserPath string `yaml:"user_path"` 24 | DictPaths []string `yaml:"dict_paths"` 25 | SyncOnChange bool `yaml:"sync_on_change"` 26 | } 27 | 28 | func ParseOptions() (Options, string) { 29 | configDir, _ := os.UserConfigDir() 30 | configPath := filepath.Join(configDir, "rimedm", "config.yaml") 31 | 32 | var flags Options 33 | flag.Func("d", "(当使用配置文件时可选)主词典文件(方案名.dict.yaml)路径,通过主词典会自动加载其他拓展词典,无需指定拓展词典。\n支持多个主词典文件,e.g: rimedm -d ./xkjd6.dict.yaml -d ./xhup.dict.txt", func(path string) error { 34 | if flags.DictPaths == nil { 35 | flags.DictPaths = make([]string, 0) 36 | } 37 | flags.DictPaths = append(flags.DictPaths, path) 38 | return nil 39 | }) 40 | 41 | flag.StringVar(&flags.UserPath, "u", "", "(可选)用户词典路径") 42 | flag.StringVar(&flags.RestartRimeCmd, "cmd", "", "(可选)同步到词典文件后,用于重新部署rime的命令,使更改即时生效,不同的系统环境下需要不同的命令") 43 | flag.BoolVar(&flags.SyncOnChange, "sync", true, "(可选)是否在每次添加、删除、修改时立即同步到词典文件,默认为 true") 44 | flag.StringVar(&configPath, "c", configPath, "(可选)配置文件路径,默认位置:"+configPath) 45 | showVersion := flag.Bool("v", false, "显示版本号,在此检查最新版本 https://github.com/MapoMagpie/rimedm") 46 | flag.Parse() 47 | if *showVersion { 48 | fmt.Println(version) 49 | os.Exit(0) 50 | } 51 | 52 | configPath = fixPath(configPath) 53 | opts := parseFromFile(configPath) 54 | 55 | if len(flags.DictPaths) > 0 { 56 | opts.DictPaths = flags.DictPaths 57 | opts.UserPath = "" 58 | } 59 | if flags.UserPath != "" { 60 | opts.UserPath = flags.UserPath 61 | } 62 | if flags.RestartRimeCmd != "" { 63 | opts.RestartRimeCmd = flags.RestartRimeCmd 64 | } 65 | if !flags.SyncOnChange { 66 | opts.SyncOnChange = false 67 | } 68 | 69 | if len(opts.DictPaths) == 0 { 70 | panic(fmt.Sprintf("未指定词典文件,请检查配置文件[%s]或通过 -d 指定词典文件\n", configPath)) 71 | } 72 | 73 | for i := range opts.DictPaths { 74 | opts.DictPaths[i] = fixPath(opts.DictPaths[i]) 75 | } 76 | opts.UserPath = fixPath(opts.UserPath) 77 | return opts, configPath 78 | } 79 | 80 | func initConfigFile(filePath string) { 81 | dirPath := filepath.Dir(filePath) 82 | _, err := os.OpenFile(dirPath, os.O_RDONLY, 0666) 83 | if err != nil { 84 | if os.IsNotExist(err) { 85 | err = os.MkdirAll(dirPath, os.ModePerm) 86 | if err != nil { 87 | panic(fmt.Sprintf("mkdir [%s] err : %s", dirPath, err)) 88 | } 89 | } else { 90 | panic(fmt.Sprintf("open [%s] err : %s", dirPath, err)) 91 | } 92 | } 93 | file, err := os.Create(filePath) 94 | if err != nil { 95 | panic(fmt.Sprintf("create [%s] err : %s", filePath, err)) 96 | } 97 | defer func() { 98 | _ = file.Close() 99 | }() 100 | _, err = file.WriteString(initConfigTemplate()) 101 | if err != nil { 102 | panic(fmt.Sprintf("write [%s] err : %s", filePath, err)) 103 | } 104 | } 105 | 106 | func initConfigTemplate() string { 107 | dicts, restartRimeCmd := osRimeDefaultValue() 108 | 109 | sb := strings.Builder{} 110 | dedup := make(map[string]bool, 0) 111 | for i, dict := range dicts { 112 | if _, ok := dedup[dict]; ok { 113 | continue 114 | } 115 | dedup[dict] = true 116 | if i > 0 { 117 | sb.WriteString("#") 118 | } 119 | sb.WriteString(" - ") 120 | sb.WriteString(dict) 121 | sb.WriteString("\n") 122 | } 123 | return fmt.Sprintf(`# Rime Dict Manager config file 124 | # This file is generated by rime-dict-manager. 125 | 126 | # 在此检查最新版本 https://github.com/MapoMagpie/rimedm 127 | 128 | # dict_paths 是主词典文件的路径,本程序会自动加载主词典所引用的其他拓展词典。 129 | # 支持多个主词典,注意是主词典,请不要将主词典与其所属拓展词典一同写在dict_paths:下 130 | # 在Linux + Fcitx5 + Fcitx5-Rime下,词典的路径一般是: $HOME/.local/share/fcitx5/rime/方案名.dict.yaml 131 | # 在Windows + 小狼毫下,词典的路径一般是: %%Appdata%%/Rime/方案名.dict.yaml 132 | # dict_paths: 133 | # - 主词典1文件路径 134 | # - 主词典2文件路径 135 | # # 禁止 136 | # - 主词典1下的拓展词典文件路径 137 | 138 | dict_paths: 139 | %s 140 | 141 | # 此项的作用是:优先作为添加新词时的选项,比如挂载的方案专门留了个给用户添加新词的码表 142 | # 注意:需要此文件包含在主词典的拓展词典中。 143 | 144 | # user_path: 145 | 146 | # 是否在每次添加、删除、修改时立即同步到词典文件,默认为 true 147 | # 注意:由于Windows的文件性能不佳,部署小狼毫时不太流畅,可禁用此项 148 | # 然后通过ctrl+s手动实时同步,或在本程序退出时自动同步。 149 | 150 | sync_on_change: true 151 | 152 | # 在同步词典文件时,通过这个命令来重启 rime, 不同的系统环境下需要不同的命令。 153 | # 在Linux + Fcitx5 下可通过此命令来重启 rime: 154 | # dbus-send --session --print-reply --dest=org.fcitx.Fcitx5 /controller org.fcitx.Fcitx.Controller1.SetConfig string:'fcitx://config/addon/rime' variant:string:'' 155 | # 在Windows + 小狼毫 下可通过此命令来重启 rime(注意程序版本): 156 | # C:\PROGRA~2\Rime\weasel-0.14.3\WeaselDeployer.exe /deploy 157 | # 注:PROGRA~2 = Program Files (x86) PROGRA~1 = Program Files 158 | # 在MacOS + 鼠须管 下可通过此命令来重启 rime: 159 | # /Library/Input Methods/Squirrel.app/Contents/MacOS/Squirrel --reload 160 | 161 | restart_rime_cmd: %s`, sb.String(), restartRimeCmd) 162 | } 163 | 164 | func osRimeDefaultValue() (dicts []string, restartRimeCmd string) { 165 | configDir, err := os.UserConfigDir() 166 | if err != nil { 167 | return []string{}, "" 168 | } 169 | switch runtime.GOOS { 170 | case "windows": 171 | // find rime install path 172 | dirEntries, err := os.ReadDir("C:\\PROGRA~2\\Rime") 173 | var maxVersion string 174 | if err == nil && len(dirEntries) > 0 { 175 | for _, dir := range dirEntries { 176 | if dir.IsDir() && strings.HasPrefix(dir.Name(), "weasel") { 177 | dirName := dir.Name() 178 | if compareVersion(dirName, maxVersion) { 179 | maxVersion = dirName 180 | } 181 | } else { 182 | continue 183 | } 184 | } 185 | } 186 | dicts = findRimeDicts(filepath.Join(configDir, "rime")) 187 | if maxVersion != "" { 188 | restartRimeCmd = filepath.Join("C:\\PROGRA~2\\Rime", maxVersion, "WeaselDeployer.exe") + " /deploy" 189 | } 190 | case "dwain": 191 | homeDir, err := os.UserHomeDir() 192 | if err != nil { 193 | return []string{}, "" 194 | } 195 | dicts = findRimeDicts(filepath.Join(homeDir, "Library", "Rime")) 196 | restartRimeCmd = "\"/Library/Input Methods/Squirrel.app/Contents/MacOS/Squirrel\" --reload" // mabye 197 | default: 198 | homeDir, err := os.UserHomeDir() 199 | if err != nil { 200 | return []string{}, "" 201 | } 202 | dicts = findRimeDicts(filepath.Join(homeDir, ".local/share/fcitx5/rime")) 203 | restartRimeCmd = "dbus-send --session --print-reply --dest=org.fcitx.Fcitx5 /controller org.fcitx.Fcitx.Controller1.SetConfig string:'fcitx://config/addon/rime' variant:string:''" 204 | } 205 | return 206 | } 207 | 208 | func parseFromFile(path string) Options { 209 | path = fixPath(path) 210 | file, err := os.Open(path) 211 | if err != nil { 212 | if os.IsNotExist(err) { 213 | initConfigFile(path) 214 | file, err = os.Open(path) 215 | if err != nil { 216 | panic(fmt.Sprintf("init config file [%s] err : %s", path, err)) 217 | } 218 | } else { 219 | panic(fmt.Sprintf("open [%s] err : %s", path, err)) 220 | } 221 | } 222 | defer func() { 223 | _ = file.Close() 224 | }() 225 | stat, err := file.Stat() 226 | if err != nil { 227 | panic(fmt.Sprintf("file stat [%s] err : %s", path, err)) 228 | } 229 | bs := make([]byte, stat.Size()) 230 | _, _ = file.Read(bs) 231 | var opts Options 232 | err = yaml.Unmarshal(bs, &opts) 233 | if err != nil { 234 | panic(fmt.Sprintf("parse config [%s] err : %s", path, err)) 235 | } 236 | return opts 237 | } 238 | 239 | func findRimeDicts(rimeConfigDir string) []string { 240 | defaults := []string{ 241 | filepath.Join(rimeConfigDir, "default.yaml"), 242 | filepath.Join(rimeConfigDir, "default.custom.yaml"), 243 | } 244 | 245 | var file *os.File 246 | for _, de := range defaults { 247 | f, err := os.Open(fixPath(de)) 248 | if err != nil { 249 | continue 250 | } else { 251 | file = f 252 | break 253 | } 254 | } 255 | defer func() { 256 | _ = file.Close() 257 | }() 258 | 259 | reader := bufio.NewReader(file) 260 | schemes := make([]string, 0) 261 | for { 262 | line, eof := reader.ReadString('\n') 263 | if eof != nil { 264 | break 265 | } 266 | hashTagIndex := strings.Index(line, "#") 267 | if i := strings.Index(line, "- schema:"); i != -1 { 268 | end := len(line) 269 | if hashTagIndex != -1 { 270 | if hashTagIndex < i { 271 | continue 272 | } else { 273 | end = hashTagIndex 274 | } 275 | } 276 | schema := strings.TrimSpace(line[i+len("- schema:") : end]) 277 | schemes = append(schemes, schema+".schema.yaml") 278 | } 279 | } 280 | dicts := make([]string, 0) 281 | for _, schema := range schemes { 282 | schemaPath := fixPath(filepath.Join(rimeConfigDir, schema)) 283 | schemaFile, err := os.Open(schemaPath) 284 | if err != nil { 285 | log.Println("cannot find schema: ", schemaPath) 286 | continue 287 | } 288 | defer func(ff *os.File) { 289 | _ = ff.Close() 290 | }(schemaFile) 291 | // find dict prefix from schemaFile 292 | schemaReader := bufio.NewReader(schemaFile) 293 | duringTranslator := false 294 | for { 295 | line, eof := schemaReader.ReadString('\n') 296 | if eof != nil { 297 | break 298 | } 299 | if i := strings.Index(line, "translator:"); i == 0 { 300 | duringTranslator = true 301 | continue 302 | } 303 | if duringTranslator { 304 | first := line[:1] 305 | if (first >= "0" && first <= "9") || (first >= "a" && first <= "z") || (first >= "A" && first <= "Z") { 306 | duringTranslator = false 307 | break 308 | } 309 | hashTagIndex := strings.Index(line, "#") 310 | if i := strings.Index(line, "dictionary: "); i != -1 { 311 | end := len(line) 312 | if hashTagIndex != -1 { 313 | if hashTagIndex < i { 314 | continue 315 | } else { 316 | end = hashTagIndex 317 | } 318 | } 319 | dictPrefix := strings.TrimSpace(line[i+len("dictionary: ") : end]) 320 | dictPath := fixPath(filepath.Join(rimeConfigDir, dictPrefix+".dict.yaml")) 321 | if _, err := os.Stat(dictPath); errors.Is(err, os.ErrNotExist) { 322 | log.Println("cannot find dict: ", dictPath) 323 | continue 324 | } else { 325 | log.Println("find dict:", dictPath, "; schema:", schema) 326 | } 327 | // check dict file exist 328 | dicts = append(dicts, dictPath) 329 | break 330 | } 331 | } 332 | } 333 | } 334 | // dictPath = filepath.Join(configDir, "Rime", dicts+".dict.yaml") 335 | return dicts 336 | } 337 | 338 | func fixPath(path string) string { 339 | if strings.HasPrefix(path, "~") { 340 | homeDir, err := os.UserHomeDir() 341 | if err != nil { 342 | panic(err) 343 | } 344 | path = homeDir + path[1:] 345 | } 346 | return os.ExpandEnv(path) 347 | } 348 | 349 | func parseVersion(version string) []int { 350 | ret := make([]int, 0) 351 | reg := regexp.MustCompile(`\d+`) 352 | res := reg.FindAllString(version, -1) 353 | for _, v := range res { 354 | num, err := strconv.Atoi(v) 355 | if err != nil { 356 | fmt.Println("convert error", v) 357 | } 358 | ret = append(ret, num) 359 | } 360 | return ret 361 | } 362 | 363 | func compareVersion(v1, v2 string) bool { 364 | vi := parseVersion(v1) 365 | vj := parseVersion(v2) 366 | for k := 0; k < len(vi) && k < len(vj); k++ { 367 | if vi[k] != vj[k] { 368 | return vi[k] > vj[k] 369 | } 370 | } 371 | return v1 > v2 372 | } 373 | -------------------------------------------------------------------------------- /core/options_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "path/filepath" 5 | "reflect" 6 | "sort" 7 | "testing" 8 | ) 9 | 10 | func Test_compareMaxVersion(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | want string 14 | versions []string 15 | }{ 16 | {"case1", "weasel-0.15.15", []string{"weasel-0.14.3", "weasel-0.15.15", "weasel-0.13.5"}}, 17 | {"case2", "weasel-0.15.15", []string{"weasel-0.15.5", "weasel-0.15.15", "weasel-0.15.14"}}, 18 | {"case3", "weasel-0.115.5", []string{"weasel-0.115.5", "weasel-0.15.15", "weasel-0.15.14"}}, 19 | {"case4", "weasel-0.115.5", []string{"weasel-0.115.5", "", "weasel-0.15.14"}}, 20 | } 21 | for _, tt := range tests { 22 | t.Run(tt.name, func(_ *testing.T) { 23 | sort.Slice(tt.versions, func(i, j int) bool { 24 | return compareVersion(tt.versions[i], tt.versions[j]) 25 | }) 26 | if tt.versions[0] != tt.want { 27 | t.Errorf("compareMaxVersion() = %v, want %v", tt.versions[0], tt.want) 28 | } 29 | }) 30 | } 31 | } 32 | 33 | func Test_findRimeDicts(t *testing.T) { 34 | tests := []struct { 35 | name string // description of this test case 36 | // Named input parameters for target function. 37 | rimeConfigDir string 38 | want []string 39 | }{ 40 | { 41 | name: "forst", 42 | rimeConfigDir: "$HOME/code/rime-schemes/rime-frost", 43 | want: []string{ 44 | "rime_frost.dict.yaml", 45 | "rime_frost.dict.yaml", 46 | "rime_frost.dict.yaml", 47 | "rime_frost.dict.yaml", 48 | "rime_frost.dict.yaml", 49 | }, 50 | }, 51 | { 52 | name: "ice", 53 | rimeConfigDir: "$HOME/code/rime-schemes/rime-ice", 54 | want: []string{ 55 | "rime_ice.dict.yaml", 56 | "rime_ice.dict.yaml", 57 | "rime_ice.dict.yaml", 58 | "rime_ice.dict.yaml", 59 | "rime_ice.dict.yaml", 60 | "rime_ice.dict.yaml", 61 | "rime_ice.dict.yaml", 62 | }, 63 | }, 64 | { 65 | name: "moqi", 66 | rimeConfigDir: "$HOME/code/rime-schemes/rime-shuangpin-fuzhuma", 67 | want: []string{ 68 | "moqi_wan.extended.dict.yaml", 69 | "moqi_wan.extended.dict.yaml", 70 | "moqi_wan.extended.dict.yaml", 71 | "moqi_single.dict.yaml", 72 | }, 73 | }, 74 | { 75 | name: "xkjd6", 76 | rimeConfigDir: "$HOME/code/rime-schemes/Rime_JD/rime", // 有两个方案缺失 77 | want: []string{ 78 | "xkjd6.extended.dict.yaml", 79 | }, 80 | }, 81 | { 82 | name: "xmjd6-rere", 83 | rimeConfigDir: "$HOME/code/rime-schemes/xmjd6-rere", 84 | want: []string{ 85 | "xmjd6.extended.dict.yaml", 86 | }, 87 | }, 88 | } 89 | for _, tt := range tests[0:0] { // [0:0] disable this test 90 | t.Run(tt.name, func(t *testing.T) { 91 | got := findRimeDicts(tt.rimeConfigDir) 92 | base := make([]string, 0, len(got)) 93 | for _, g := range got { 94 | base = append(base, filepath.Base(g)) 95 | } 96 | // TODO: update the condition below to compare got with tt.want. 97 | if !reflect.DeepEqual(base, tt.want) { 98 | t.Errorf("findRimeDicts() = %v, want %v", base, tt.want) 99 | } 100 | }) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /dict/dictionary.go: -------------------------------------------------------------------------------- 1 | package dict 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "slices" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/MapoMagpie/rimedm/util" 14 | ) 15 | 16 | type Dictionary struct { 17 | matcher Matcher 18 | entries []*Entry 19 | fileEntries []*FileEntries 20 | } 21 | 22 | func NewDictionary(fes []*FileEntries, matcher Matcher) *Dictionary { 23 | if matcher == nil { 24 | matcher = &CacheMatcher{} 25 | } 26 | entries := make([]*Entry, 0) 27 | for _, fe := range fes { 28 | entries = append(entries, fe.Entries...) 29 | } 30 | return &Dictionary{ 31 | matcher: matcher, 32 | entries: entries, 33 | fileEntries: fes, 34 | } 35 | } 36 | 37 | func (d *Dictionary) Entries() []*Entry { 38 | return d.entries 39 | } 40 | 41 | func (d *Dictionary) Search(key string, useColumn Column, searchVersion int, resultChan chan<- MatchResultChunk, ctx context.Context) { 42 | // log.Printf("search key: %s, version: %d", string(key), searchVersion) 43 | if len(key) == 0 { 44 | done := false 45 | go func() { 46 | <-ctx.Done() 47 | done = true 48 | }() 49 | list := d.Entries() 50 | ret := make([]*MatchResult, len(list)) 51 | deleteCount := 0 // for ret (len = list), if skip deleted, shrink ret 52 | for i, entry := range list { 53 | if done { 54 | return 55 | } 56 | if entry.IsDelete() { 57 | deleteCount += 1 58 | continue 59 | } 60 | ret[i-deleteCount] = &MatchResult{Entry: entry} 61 | } 62 | resultChan <- MatchResultChunk{Result: ret[0 : len(ret)-deleteCount], Version: searchVersion} 63 | } else { 64 | d.matcher.Search(key, useColumn, searchVersion, d.Entries(), resultChan, ctx) 65 | } 66 | } 67 | 68 | func (d *Dictionary) Add(entry *Entry) { 69 | for _, fe := range d.fileEntries { 70 | if fe.ID == entry.FID { 71 | fe.Entries = append(fe.Entries, entry) 72 | } 73 | } 74 | d.entries = append(d.entries, entry) 75 | } 76 | 77 | func (d *Dictionary) Delete(entry *Entry) { 78 | entry.Delete() 79 | } 80 | 81 | func (d *Dictionary) ResetMatcher() { 82 | d.matcher.Reset() 83 | } 84 | 85 | func (d *Dictionary) Len() int { 86 | return len(d.entries) 87 | } 88 | 89 | func (d *Dictionary) Flush() (changed bool) { 90 | start := time.Now() 91 | changed = output(d.fileEntries) 92 | since := time.Since(start) 93 | if changed { 94 | log.Printf("flush dictionary: %v\n", since) 95 | } 96 | return changed 97 | } 98 | 99 | func (d *Dictionary) ExportDict(path string, columns []Column) { 100 | exportDict(path, d.fileEntries, columns) 101 | } 102 | 103 | type ModifyType int 104 | 105 | const ( 106 | NC ModifyType = iota // default no change 107 | DELETE 108 | MODIFY // by ReRaw 109 | ADD // by NewEntryAdd 110 | ) 111 | 112 | type Entry struct { 113 | FID uint8 114 | seek int64 115 | rawSize int64 116 | modType ModifyType 117 | raw string 118 | deleted bool 119 | data Data 120 | } 121 | 122 | func (e *Entry) Data() *Data { 123 | return &e.data 124 | } 125 | 126 | func (e *Entry) ReRaw(raw string) { 127 | e.raw = raw 128 | if e.data.cols == nil { 129 | panic(fmt.Sprintf("ReRaw: [%s], but data have no columns", raw)) 130 | } 131 | e.data = fastParseData(raw, e.data.cols) 132 | if e.modType != ADD { 133 | e.modType = MODIFY 134 | } 135 | // don't change rawSize 136 | } 137 | 138 | func (e *Entry) reSeek(seek int64, rawSize int64) { 139 | e.seek = seek 140 | e.rawSize = rawSize 141 | } 142 | 143 | func (e *Entry) Delete() { 144 | if e.deleted { 145 | return 146 | } 147 | e.deleted = true 148 | e.modType = DELETE 149 | } 150 | 151 | func (e *Entry) IsDelete() bool { 152 | return e.deleted 153 | } 154 | 155 | func (e *Entry) Raw() string { 156 | // return e.text.ToString() + "\t" + e.refFile 157 | return e.raw 158 | } 159 | 160 | func (e *Entry) Saved() { 161 | e.rawSize = int64(len(e.raw)) + 1 // + 1 for '\n' 162 | e.modType = NC 163 | } 164 | 165 | // Parse input string to a pair of strings 166 | // 支持乱序输入,如 "你好 nau 1" 或 "nau 1 你好" 167 | // 解析规则:将原始内容通过空白字符分割成单元,依次判断每个单元是否是汉字、纯数字、ascii, 168 | // 汉字将作为text,纯数字作为weight,其他ascii根据顺序,前面的将用空格连结合并为code,最后的为stem 169 | // stem(造字码,用于造词时代替code,比如 的 编码为 u,造字为 un,当造词 我的 时,会用un代替u,最终用tuun造词 我的) 170 | func ParseInput(raw string, hasStem bool) ([]string, []Column) { 171 | pair := make([]string, 0) 172 | cols := make([]Column, 0) 173 | // split by '\t' or ' ' 174 | splits := strings.Fields(raw) 175 | textIndex := -1 176 | codeIndex := -1 177 | for i := range splits { 178 | split := strings.TrimSpace(splits[i]) 179 | if len(split) == 0 { 180 | continue 181 | } 182 | if util.IsNumber(split) { 183 | cols = append(cols, COLUMN_WEIGHT) 184 | pair = append(pair, split) 185 | continue 186 | } 187 | if util.IsAscii(split) { 188 | codeIndex = slices.Index(cols, COLUMN_CODE) 189 | if codeIndex == -1 { 190 | cols = append(cols, COLUMN_CODE) 191 | pair = append(pair, split) 192 | } else { 193 | pair[codeIndex] = pair[codeIndex] + " " + split 194 | } 195 | continue 196 | } 197 | // 汉字 198 | if textIndex == -1 { 199 | textIndex = i 200 | pair = append(pair, split) 201 | cols = append(cols, COLUMN_TEXT) 202 | } else { 203 | // 表(汉字)的输入可能包含空格,类似 "富强 强国",因此在splited后重新拼接起来。 204 | pair[textIndex] = pair[textIndex] + " " + split 205 | } 206 | } 207 | 208 | if hasStem && codeIndex != -1 { 209 | code := pair[codeIndex] 210 | split := strings.Split(code, " ") 211 | if len(split) > 1 { 212 | cols = append(cols, COLUMN_STEM) 213 | pair = append(pair, split[len(split)-1]) 214 | } 215 | pair[codeIndex] = strings.Join(split[:len(split)-1], " ") 216 | } 217 | 218 | if textIndex == -1 { // 仍旧没有汉字,将code作为text, stem作为text 219 | codeIndex := slices.Index(cols, COLUMN_CODE) 220 | stemIndex := slices.Index(cols, COLUMN_STEM) 221 | if codeIndex != -1 { 222 | cols[codeIndex] = COLUMN_TEXT 223 | if stemIndex != -1 { 224 | cols[stemIndex] = COLUMN_CODE 225 | } 226 | } 227 | } 228 | return pair, cols 229 | } 230 | 231 | func ParseData(pair []string, columns *[]Column) (Data, error) { 232 | if len(pair) != len(*columns) { 233 | return Data{}, errors.New("raw") 234 | } 235 | var data Data 236 | data.cols = columns 237 | for i := range pair { 238 | term := pair[i] 239 | col := (*columns)[i] 240 | switch col { 241 | case COLUMN_TEXT: 242 | data.Text = term 243 | case COLUMN_CODE: 244 | data.Code = term 245 | case COLUMN_WEIGHT: 246 | data.Weight, _ = strconv.Atoi(term) 247 | case COLUMN_STEM: 248 | data.Stem = term 249 | default: 250 | continue 251 | } 252 | } 253 | return data, nil 254 | } 255 | 256 | func fastParseData(raw string, cols *[]Column) Data { 257 | split := strings.Split(raw, "\t") 258 | colsLen := len(*cols) 259 | data := Data{cols: cols} 260 | for s, c := 0, 0; s < len(split) && c < colsLen; { 261 | sp := split[s] 262 | col := (*cols)[c] 263 | c++ 264 | s++ 265 | switch col { 266 | case COLUMN_TEXT: 267 | data.Text = sp 268 | case COLUMN_WEIGHT: 269 | weight, err := strconv.Atoi(sp) 270 | if err != nil { // 不是weight,可能是code,跳到下一col,但重新处理当前sp 271 | s-- 272 | } 273 | data.Weight = weight 274 | case COLUMN_CODE: // 如果code列缺失,则可能导致之后的weight或stem作为code,如 code: 100,暂未处理 275 | data.Code = sp 276 | case COLUMN_STEM: 277 | data.Stem = sp 278 | } 279 | } 280 | return data 281 | } 282 | 283 | func NewEntry(raw []byte, fileID uint8, seek int64, size int64, cols *[]Column) *Entry { 284 | str := string(raw) 285 | data := fastParseData(str, cols) 286 | return &Entry{ 287 | FID: fileID, 288 | modType: NC, 289 | seek: seek, 290 | rawSize: size, 291 | raw: str, 292 | data: data, 293 | } 294 | } 295 | 296 | func NewEntryAdd(raw string, fileID uint8, data Data) *Entry { 297 | return &Entry{ 298 | FID: fileID, 299 | modType: ADD, 300 | raw: raw, 301 | data: data, 302 | } 303 | } 304 | 305 | type Data struct { 306 | Text string 307 | Code string 308 | Stem string 309 | Weight int 310 | cols *[]Column 311 | } 312 | 313 | func (d *Data) ToString() string { 314 | return d.ToStringWithColumns(d.cols) 315 | } 316 | 317 | func (d *Data) ResetColumns(cols *[]Column) { 318 | d.cols = cols 319 | } 320 | 321 | func (d *Data) ToStringWithColumns(cols *[]Column) string { 322 | sb := strings.Builder{} 323 | for _, col := range *cols { 324 | var b string 325 | switch col { 326 | case COLUMN_TEXT: 327 | b = d.Text 328 | case COLUMN_WEIGHT: 329 | b = strconv.Itoa(d.Weight) 330 | case COLUMN_CODE: 331 | b = d.Code 332 | case COLUMN_STEM: 333 | b = d.Stem 334 | } 335 | if sb.Len() > 0 { 336 | sb.WriteByte('\t') 337 | } 338 | sb.WriteString(b) 339 | } 340 | return sb.String() 341 | } 342 | 343 | type Column string 344 | 345 | const ( 346 | COLUMN_TEXT Column = "TEXT" 347 | COLUMN_CODE Column = "CODE" 348 | COLUMN_WEIGHT Column = "WEIGHT" 349 | COLUMN_STEM Column = "STEM" 350 | ) 351 | 352 | var DEFAULT_COLUMNS = []Column{COLUMN_TEXT, COLUMN_WEIGHT, COLUMN_CODE, COLUMN_STEM} 353 | -------------------------------------------------------------------------------- /dict/dictionary_test.go: -------------------------------------------------------------------------------- 1 | package dict 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math" 7 | "reflect" 8 | "sort" 9 | "testing" 10 | 11 | // "github.com/lithammer/fuzzysearch/fuzzy" 12 | "github.com/sahilm/fuzzy" 13 | ) 14 | 15 | func Test_fuzzy_Search(t *testing.T) { 16 | tests := []struct { 17 | name string 18 | pattern string 19 | list []string 20 | want []string 21 | }{ 22 | { 23 | name: "case1", pattern: "foo", 24 | list: []string{ 25 | "foo", 26 | "foofoo", 27 | "foobar", 28 | "fbarrrrrrrrrrrrrrroo", 29 | "bfaroo", 30 | "bfoo", 31 | "barfoo", 32 | "fo", 33 | }, 34 | want: []string{ 35 | "foo", 36 | "foobar", 37 | "foofoo", 38 | "bfoo", 39 | "barfoo", 40 | "fbarrrrrrrrrrrrrrroo", 41 | "bfaroo", 42 | // "fo", 43 | }, 44 | }, 45 | { 46 | name: "case2", pattern: "小", 47 | list: []string{ 48 | "小猪", 49 | "小狗", 50 | "小羊", 51 | "老虎", 52 | "西瓜", 53 | }, 54 | want: []string{ 55 | "小羊", 56 | "小狗", 57 | "小猪", 58 | }, 59 | }, 60 | } 61 | for _, tt := range tests { 62 | t.Run(tt.name, func(_ *testing.T) { 63 | got := make([]string, 0) 64 | ranks := fuzzy.Find(tt.pattern, tt.list) 65 | for _, rank := range ranks { 66 | fmt.Printf("all: %+v\n", rank) 67 | got = append(got, rank.Str) 68 | } 69 | // same 70 | // for _, str := range tt.list { 71 | // rank := fuzzy.Find(tt.pattern, []string{str}) 72 | // if len(rank) > 0 { 73 | // fmt.Printf("one by one: %+v\n", rank[0]) 74 | // got = append(got, rank[0].Str) 75 | // } 76 | // } 77 | if !reflect.DeepEqual(got, tt.want) { 78 | rawLen := len(got) 79 | wantLen := len(tt.want) 80 | info := "" 81 | for i := range int(math.Max(float64(rawLen), float64(wantLen))) { 82 | var raw string 83 | var want string 84 | if i < rawLen { 85 | raw = got[i] 86 | } 87 | if i < wantLen { 88 | want = tt.want[i] 89 | } 90 | info += "got: " + raw + "\t\twant:" + want + "\n" 91 | } 92 | t.Errorf("Search() key: [%s] got and want\n%s", tt.pattern, info) 93 | } 94 | }) 95 | } 96 | } 97 | 98 | func Test_Dictionary_Search(t *testing.T) { 99 | type args struct { 100 | key string 101 | fes []*FileEntries 102 | useColumn Column 103 | } 104 | cols := []Column{COLUMN_TEXT, COLUMN_CODE} 105 | fes1 := &FileEntries{ 106 | Entries: []*Entry{ 107 | NewEntry([]byte("helle world"), 0, 0, 0, &cols), 108 | NewEntry([]byte("问好 hi, did eve alive?"), 0, 0, 0, &cols), 109 | NewEntry([]byte("据说人是可以吃的 nihao"), 0, 0, 0, &cols), 110 | NewEntry([]byte("唉?你是说被吃? foobar"), 0, 0, 0, &cols), 111 | NewEntry([]byte("嗯!就是这个意思。 barfoo"), 0, 0, 0, &cols), 112 | NewEntry([]byte("真好啊,还能这样。 foofoo"), 0, 0, 0, &cols), 113 | NewEntry([]byte("是阿叶告诉我的。… fo"), 0, 0, 0, &cols), 114 | NewEntry([]byte("阿叶吗,不着调的家伙。 fooo"), 0, 0, 0, &cols), 115 | NewEntry([]byte("你有多久没进食过了? faoo"), 0, 0, 0, &cols), 116 | NewEntry([]byte("上次从地底出来的时候。 fbaroo"), 0, 0, 0, &cols), 117 | NewEntry([]byte("那你挺节能的。 end"), 0, 0, 0, &cols), 118 | }, 119 | } 120 | tests := []struct { 121 | name string 122 | args args 123 | want []*Entry 124 | }{ 125 | { 126 | name: "case1", 127 | args: args{"foo", []*FileEntries{fes1}, COLUMN_CODE}, 128 | want: []*Entry{ 129 | NewEntry([]byte("阿叶吗,不着调的家伙。 fooo"), 0, 0, 0, &cols), 130 | NewEntry([]byte("唉?你是说被吃? foobar"), 0, 0, 0, &cols), 131 | NewEntry([]byte("真好啊,还能这样。 foofoo"), 0, 0, 0, &cols), 132 | NewEntry([]byte("你有多久没进食过了? faoo"), 0, 0, 0, &cols), 133 | NewEntry([]byte("上次从地底出来的时候。 fbaroo"), 0, 0, 0, &cols), 134 | NewEntry([]byte("嗯!就是这个意思。 barfoo"), 0, 0, 0, &cols), 135 | }, 136 | }, 137 | { 138 | name: "case2", 139 | args: args{"是", []*FileEntries{fes1}, COLUMN_TEXT}, 140 | want: []*Entry{ 141 | NewEntry([]byte("是阿叶告诉我的。… fo"), 0, 0, 0, &cols), 142 | NewEntry([]byte("据说人是可以吃的 nihao"), 0, 0, 0, &cols), 143 | NewEntry([]byte("唉?你是说被吃? foobar"), 0, 0, 0, &cols), 144 | NewEntry([]byte("嗯!就是这个意思。 barfoo"), 0, 0, 0, &cols), 145 | }, 146 | }, 147 | } 148 | for _, tt := range tests { 149 | t.Run(tt.name, func(_ *testing.T) { 150 | dict := NewDictionary(tt.args.fes, &CacheMatcher{}) 151 | ctx := context.Background() 152 | ch := make(chan MatchResultChunk) 153 | fmt.Println("searching for", string(tt.args.key)) 154 | go func() { 155 | dict.Search(tt.args.key, tt.args.useColumn, 0, ch, ctx) 156 | close(ch) 157 | }() 158 | for ret := range ch { 159 | sort.Slice(ret.Result, func(i, j int) bool { 160 | return ret.Result[i].score > ret.Result[j].score 161 | }) 162 | fmt.Println("ret", ret) 163 | entries := make([]Entry, 0) 164 | for _, r := range ret.Result { 165 | fmt.Printf("ret: text: %s\tscore:%d\n", r.Entry.raw, r.score) 166 | entries = append(entries, *r.Entry) 167 | } 168 | want := make([]Entry, 0, len(tt.want)) 169 | for _, w := range tt.want { 170 | want = append(want, *w) 171 | } 172 | if !reflect.DeepEqual(entries, want) { 173 | rawLen := len(entries) 174 | wantLen := len(want) 175 | info := "" 176 | for i := range int(math.Max(float64(rawLen), float64(wantLen))) { 177 | raw := "EMPTY" 178 | wan := "EMPTY" 179 | if i < rawLen { 180 | raw = entries[i].raw 181 | } 182 | if i < wantLen { 183 | wan = want[i].raw 184 | } 185 | info += "got: " + raw + "\t\twant:" + wan + "\n" 186 | } 187 | t.Errorf("Search() key: [%s] got and want\n%s", tt.args.key, info) 188 | } 189 | } 190 | }) 191 | } 192 | } 193 | 194 | func Test_ParseInput(t *testing.T) { 195 | tests := []struct { 196 | name string 197 | args string 198 | hasStem bool 199 | wantPair []string 200 | wantCols []Column 201 | }{ 202 | { 203 | name: "case1", 204 | args: "你\t好", 205 | hasStem: true, 206 | wantPair: []string{"你 好"}, 207 | wantCols: []Column{COLUMN_TEXT}, 208 | }, 209 | { 210 | name: "case2", 211 | args: "你 好", 212 | hasStem: true, 213 | wantPair: []string{"你 好"}, 214 | wantCols: []Column{COLUMN_TEXT}, 215 | }, 216 | { 217 | name: "case3", 218 | args: "你 好", 219 | hasStem: true, 220 | wantPair: []string{"你 好"}, 221 | wantCols: []Column{COLUMN_TEXT}}, 222 | { 223 | name: "case4", 224 | args: "你\t 好", 225 | hasStem: true, 226 | wantPair: []string{"你 好"}, 227 | wantCols: []Column{COLUMN_TEXT}}, 228 | { 229 | name: "case5", 230 | args: "你 好\t 1", 231 | hasStem: true, 232 | wantPair: []string{"你 好", "1"}, 233 | wantCols: []Column{COLUMN_TEXT, COLUMN_WEIGHT}, 234 | }, 235 | { 236 | name: "case6", 237 | args: "你好 nau 1", 238 | hasStem: true, 239 | wantPair: []string{"你好", "nau", "1"}, 240 | wantCols: []Column{COLUMN_TEXT, COLUMN_CODE, COLUMN_WEIGHT}, 241 | }, 242 | { 243 | name: "case7", 244 | args: "nau 你好 1", 245 | hasStem: true, 246 | wantPair: []string{"nau", "你好", "1"}, 247 | wantCols: []Column{COLUMN_CODE, COLUMN_TEXT, COLUMN_WEIGHT}, 248 | }, 249 | { 250 | name: "case8", 251 | args: " nau 你好 1 ", 252 | hasStem: true, 253 | wantPair: []string{"nau", "你好", "1"}, 254 | wantCols: []Column{COLUMN_CODE, COLUMN_TEXT, COLUMN_WEIGHT}, 255 | }, 256 | { 257 | name: "case9", 258 | args: "nau hi你好ya 1 ", 259 | hasStem: true, 260 | wantPair: []string{"nau", "hi你好ya", "1"}, 261 | wantCols: []Column{COLUMN_CODE, COLUMN_TEXT, COLUMN_WEIGHT}, 262 | }, 263 | { 264 | name: "case10", 265 | args: "nau 1 hi 你好 ya 1i ", 266 | hasStem: true, 267 | wantPair: []string{"nau hi ya", "1", "你好", "1i"}, 268 | wantCols: []Column{COLUMN_CODE, COLUMN_WEIGHT, COLUMN_TEXT, COLUMN_STEM}, 269 | }, 270 | { 271 | name: "case11", 272 | args: "你好 ni hao 1", 273 | hasStem: true, 274 | wantPair: []string{"你好", "ni", "1", "hao"}, 275 | wantCols: []Column{COLUMN_TEXT, COLUMN_CODE, COLUMN_WEIGHT, COLUMN_STEM}, 276 | }, 277 | { 278 | name: "case12", 279 | args: "ni ni", 280 | hasStem: true, 281 | wantPair: []string{"ni", "ni"}, 282 | wantCols: []Column{COLUMN_TEXT, COLUMN_CODE}, 283 | }, 284 | { 285 | name: "case13", 286 | args: "你好nihao", 287 | hasStem: true, 288 | wantPair: []string{"你好nihao"}, 289 | wantCols: []Column{COLUMN_TEXT}, 290 | }, 291 | { 292 | name: "case14", 293 | args: "你好 ni hao 1", 294 | hasStem: false, 295 | wantPair: []string{"你好", "ni hao", "1"}, 296 | wantCols: []Column{COLUMN_TEXT, COLUMN_CODE, COLUMN_WEIGHT}, 297 | }, 298 | { 299 | name: "case15", 300 | args: "nau 1 hi 你好 ya 1i ", 301 | hasStem: false, 302 | wantPair: []string{"nau hi ya 1i", "1", "你好"}, 303 | wantCols: []Column{COLUMN_CODE, COLUMN_WEIGHT, COLUMN_TEXT}, 304 | }, 305 | } 306 | // fields := strings.Fields("你\t好") 307 | // fmt.Println(fields, len(fields)) 308 | for _, tt := range tests { 309 | t.Run(tt.name, func(t *testing.T) { 310 | pair, cols := ParseInput(tt.args, tt.hasStem) 311 | if !reflect.DeepEqual(pair, tt.wantPair) { 312 | t.Errorf("ParsePair() pair = %v, want %v", pair, tt.wantPair) 313 | } 314 | if !reflect.DeepEqual(cols, tt.wantCols) { 315 | t.Errorf("ParsePair() cols = %v, want %v", cols, tt.wantCols) 316 | } 317 | }) 318 | } 319 | } 320 | 321 | func Test_ParseData(t *testing.T) { 322 | tests := []struct { 323 | name string 324 | raw string 325 | hasStem bool 326 | // cols []Column 327 | want Data 328 | }{ 329 | { 330 | name: "case1", 331 | raw: "你好 nau", 332 | hasStem: true, 333 | // , 334 | want: Data{Text: "你好", Code: "nau", cols: &[]Column{COLUMN_TEXT, COLUMN_CODE}}, 335 | }, 336 | { 337 | name: "case2", 338 | raw: "你好\t\n", 339 | hasStem: true, 340 | // cols: []Column{COLUMN_TEXT, COLUMN_CODE}, 341 | want: Data{Text: "你好", Code: "", cols: &[]Column{COLUMN_TEXT}}, 342 | }, 343 | { 344 | name: "case3", 345 | raw: "你好 nau", 346 | hasStem: true, 347 | // cols: []Column{COLUMN_TEXT, COLUMN_CODE}, 348 | want: Data{Text: "你好", Code: "nau", cols: &[]Column{COLUMN_TEXT, COLUMN_CODE}}, 349 | }, 350 | { 351 | name: "case4", 352 | raw: "1 \t", 353 | hasStem: true, 354 | // cols: []Column{COLUMN_TEXT, COLUMN_CODE}, 355 | want: Data{Weight: 1, cols: &[]Column{COLUMN_WEIGHT}}, 356 | }, 357 | { 358 | name: "case5", 359 | raw: "你 好 nau 1", 360 | hasStem: true, 361 | // cols: []Column{COLUMN_TEXT, COLUMN_CODE}, 362 | want: Data{Text: "你 好", Code: "nau", Weight: 1, cols: &[]Column{COLUMN_TEXT, COLUMN_CODE, COLUMN_WEIGHT}}, 363 | }, 364 | { 365 | name: "case6", 366 | raw: "你 好\tnau\t1", 367 | hasStem: true, 368 | // cols: []Column{COLUMN_TEXT, COLUMN_CODE}, 369 | want: Data{Text: "你 好", Code: "nau", Weight: 1, cols: &[]Column{COLUMN_TEXT, COLUMN_CODE, COLUMN_WEIGHT}}, 370 | }, 371 | { 372 | name: "case7", 373 | raw: "你 好\t \tnau \t1", 374 | hasStem: true, 375 | // cols: []Column{COLUMN_TEXT, COLUMN_CODE}, 376 | want: Data{Text: "你 好", Code: "nau", Weight: 1, cols: &[]Column{COLUMN_TEXT, COLUMN_CODE, COLUMN_WEIGHT}}, 377 | }, 378 | { 379 | name: "case8", 380 | raw: "你好\t \tni hao\t1", 381 | hasStem: true, 382 | // cols: []Column{COLUMN_TEXT, COLUMN_CODE}, 383 | want: Data{Text: "你好", Code: "ni", Stem: "hao", Weight: 1, cols: &[]Column{COLUMN_TEXT, COLUMN_CODE, COLUMN_WEIGHT, COLUMN_STEM}}, 384 | }, 385 | { 386 | name: "case9", 387 | raw: "你好\t \tni hao\t1", 388 | hasStem: false, 389 | // cols: []Column{COLUMN_TEXT, COLUMN_CODE}, 390 | want: Data{Text: "你好", Code: "ni hao", Stem: "", Weight: 1, cols: &[]Column{COLUMN_TEXT, COLUMN_CODE, COLUMN_WEIGHT}}, 391 | }, 392 | } 393 | for _, tt := range tests { 394 | t.Run(tt.name, func(t *testing.T) { 395 | pair, cols := ParseInput(tt.raw, tt.hasStem) 396 | data, _ := ParseData(pair, &cols) 397 | if !reflect.DeepEqual(data, tt.want) { 398 | t.Errorf("ParsePair() = %+v, want %+v", data, tt.want) 399 | } 400 | }) 401 | } 402 | } 403 | 404 | func Test_fastParseData(t *testing.T) { 405 | cols1 := &[]Column{COLUMN_TEXT, COLUMN_CODE, COLUMN_WEIGHT} 406 | cols2 := &[]Column{COLUMN_TEXT, COLUMN_WEIGHT, COLUMN_CODE} 407 | cols3 := &[]Column{COLUMN_TEXT, COLUMN_CODE, COLUMN_WEIGHT, COLUMN_STEM} 408 | cols4 := &[]Column{COLUMN_TEXT, COLUMN_WEIGHT, COLUMN_CODE, COLUMN_STEM} 409 | tests := []struct { 410 | name string 411 | raw string 412 | cols *[]Column 413 | want Data 414 | }{ 415 | {name: "normal1", raw: "加 ja 100", cols: cols1, 416 | want: Data{Text: "加", Code: "ja", Stem: "", Weight: 100, cols: cols1}}, 417 | {name: "miss weight", raw: "加 ja", cols: cols1, 418 | want: Data{Text: "加", Code: "ja", Stem: "", Weight: 0, cols: cols1}}, 419 | {name: "normal2", raw: "加 100 ja", cols: cols2, 420 | want: Data{Text: "加", Code: "ja", Stem: "", Weight: 100, cols: cols2}}, 421 | {name: "miss weight in middle", raw: "加 ja", cols: cols2, 422 | want: Data{Text: "加", Code: "ja", Stem: "", Weight: 0, cols: cols2}}, 423 | {name: "normal3", raw: "加 ja 100 aj", cols: cols3, 424 | want: Data{Text: "加", Code: "ja", Stem: "aj", Weight: 100, cols: cols3}}, 425 | {name: "miss code in middle", raw: "加 100 aj", cols: cols3, 426 | want: Data{Text: "加", Code: "", Stem: "aj", Weight: 100, cols: cols3}}, 427 | // {name: "miss code column in middle", raw: "加 100 aj", cols: cols3, 428 | // want: Data{Text: "加", Code: "", Stem: "aj", Weight: 100, cols: cols3}}, 429 | {name: "miss code in middle", raw: "加 aj", cols: cols3, 430 | want: Data{Text: "加", Code: "aj", Stem: "", Weight: 0, cols: cols3}}, 431 | {name: "miss weight in middle 2", raw: "加 aj", cols: cols4, 432 | want: Data{Text: "加", Code: "aj", Stem: "", Weight: 0, cols: cols4}}, 433 | {name: "more column", raw: "加 100 aj ak al ac av", cols: cols4, 434 | want: Data{Text: "加", Code: "aj", Stem: "ak", Weight: 100, cols: cols4}}, 435 | {name: "more column and miss weight", raw: "加 aj ak al ac av", cols: cols4, 436 | want: Data{Text: "加", Code: "aj", Stem: "ak", Weight: 0, cols: cols4}}, 437 | } 438 | for _, tt := range tests { 439 | t.Run(tt.name, func(t *testing.T) { 440 | got := fastParseData(tt.raw, tt.cols) 441 | if !reflect.DeepEqual(got, tt.want) { 442 | t.Errorf("fastParseData() = %+v, want %+v", got, tt.want) 443 | } 444 | }) 445 | } 446 | } 447 | -------------------------------------------------------------------------------- /dict/loader.go: -------------------------------------------------------------------------------- 1 | package dict 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | "reflect" 11 | "strings" 12 | "sync" 13 | 14 | "github.com/MapoMagpie/rimedm/util" 15 | "github.com/goccy/go-yaml" 16 | ) 17 | 18 | type FileEntries struct { 19 | Err error 20 | FilePath string 21 | RawBs []byte 22 | Entries []*Entry 23 | Columns []Column 24 | ID uint8 25 | } 26 | 27 | func (fe *FileEntries) Id() int { 28 | return int(fe.ID) 29 | } 30 | 31 | func (fe *FileEntries) String() string { 32 | return fe.FilePath 33 | } 34 | 35 | func (fe *FileEntries) Cmp(ofe any) bool { 36 | if o, ok := ofe.(*FileEntries); ok { 37 | return fe.ID > o.ID 38 | } 39 | return false 40 | } 41 | 42 | func LoadItems(paths ...string) (fes []*FileEntries) { 43 | fes = make([]*FileEntries, 0) 44 | ch := make(chan *FileEntries) 45 | var wg sync.WaitGroup 46 | for _, path := range paths { 47 | wg.Add(1) 48 | go loadFromFile(path, util.IDGen.NextID(), ch, &wg) 49 | } 50 | go func() { 51 | wg.Wait() 52 | close(ch) 53 | }() 54 | fileNames := make(map[string]bool) 55 | for fe := range ch { 56 | if fe.Err != nil { 57 | fmt.Println("load dict file error: ", fe.Err) 58 | os.Exit(0) 59 | } 60 | if _, ok := fileNames[fe.FilePath]; ok { 61 | log.Printf("file [%s] already loaded", fe.FilePath) 62 | continue 63 | } 64 | fileNames[fe.FilePath] = true 65 | fes = append(fes, fe) 66 | } 67 | return 68 | } 69 | 70 | var ( 71 | YAML_BEGIN = "---" 72 | YAML_END = "..." 73 | ) 74 | 75 | func loadFromFile(path string, id uint8, ch chan<- *FileEntries, wg *sync.WaitGroup) { 76 | defer wg.Done() 77 | fe := &FileEntries{FilePath: path, Entries: make([]*Entry, 0), ID: id} 78 | file, err := os.OpenFile(path, os.O_RDONLY, 0666) 79 | if fe.Err = err; err != nil { 80 | ch <- fe 81 | return 82 | } 83 | defer func() { 84 | _ = file.Close() 85 | }() 86 | stat, err := file.Stat() 87 | if fe.Err = err; err != nil { 88 | ch <- fe 89 | return 90 | } 91 | bf := bytes.NewBuffer(make([]byte, 0, stat.Size())) 92 | _, err = io.Copy(bf, file) 93 | fe.RawBs = bf.Bytes() 94 | if fe.Err = err; err != nil { 95 | ch <- fe 96 | return 97 | } 98 | 99 | var seek int64 = 0 100 | // 在开始读取 码 之前,尝试先读取yaml内容, 101 | // 但是此文件也可能不包含yaml内容, 102 | // 如果不包含yaml,那么head(buffer)将与bf(buffer)一起用于读取 码 103 | head, size, exist := tryReadHead(bf) 104 | if exist { 105 | raw, err := io.ReadAll(head) 106 | if err != nil { 107 | panic("cant readAll bytes from head buffer") 108 | } 109 | seek = size 110 | config, _ := parseYAML(raw) 111 | fe.Columns = parseColumnsOrDefault(&config) 112 | loadExtendDict(path, &config, ch, wg) 113 | } else { 114 | fe.Columns = []Column{COLUMN_TEXT, COLUMN_CODE, COLUMN_WEIGHT} 115 | } 116 | 117 | // 函数:读取 码 118 | readEntries := func(buf *bytes.Buffer) { 119 | for { 120 | bs, eof := buf.ReadBytes('\n') 121 | size := len(bs) 122 | seek += int64(size) 123 | if size > 0 { 124 | if bs[0] == '#' { 125 | continue 126 | } 127 | bs = bytes.TrimSpace(bs) 128 | if len(bs) == 0 { 129 | continue 130 | } 131 | fe.Entries = append(fe.Entries, NewEntry(bs, fe.ID, seek-int64(size), int64(size), &fe.Columns)) 132 | } 133 | if eof != nil { 134 | break 135 | } 136 | } 137 | } 138 | 139 | if !exist { 140 | readEntries(head) 141 | } 142 | readEntries(bf) 143 | 144 | ch <- fe 145 | } 146 | 147 | func tryReadHead(buf *bytes.Buffer) (*bytes.Buffer, int64, bool) { 148 | var size int64 = 0 149 | lines := 0 150 | headBuf := bytes.NewBuffer(make([]byte, 0)) 151 | hasDictName := false 152 | for { 153 | bs, eof := buf.ReadBytes('\n') 154 | headBuf.Write(bs) // keep original content 155 | size += int64(len(bs)) 156 | lines += 1 157 | if size > 0 { 158 | line := strings.TrimSpace(string(bs)) 159 | if line == YAML_BEGIN { 160 | continue 161 | } 162 | if strings.Index(line, "name:") == 0 { 163 | hasDictName = true 164 | } 165 | if line == YAML_END { 166 | return headBuf, size, true 167 | } 168 | } 169 | if eof != nil || lines > 1000 { // 我不信有人的rime dict文件中,yaml部分能超过1000行。 170 | break 171 | } 172 | } 173 | // 正常运行到这里表示文件已读完,但是没有 YAML_END, 174 | // 这个文件可能是无头的码表 像小鹤的txt码表那样 175 | // 也可能只有yaml head,却没有 YAML_END 标志, 176 | // 因此通过判断存在`name: xmjd6.extended`这样的行 来确定此文件仅包含yaml head 177 | if hasDictName { 178 | return headBuf, size, true 179 | } 180 | return headBuf, size, false 181 | } 182 | 183 | func parseColumnsOrDefault(config *YAML) []Column { 184 | cols := parseColumns(config) 185 | if len(cols) == 0 { 186 | // TODO: get example from content to parse cols 187 | return []Column{COLUMN_TEXT, COLUMN_CODE, COLUMN_WEIGHT} 188 | } 189 | result := make([]Column, 0) 190 | for _, col := range cols { 191 | switch col { 192 | case "text": 193 | result = append(result, COLUMN_TEXT) 194 | case "weight": 195 | result = append(result, COLUMN_WEIGHT) 196 | case "code": 197 | result = append(result, COLUMN_CODE) 198 | case "stem": 199 | result = append(result, COLUMN_STEM) 200 | } 201 | } 202 | return result 203 | } 204 | 205 | func loadExtendDict(path string, config *YAML, ch chan<- *FileEntries, wg *sync.WaitGroup) { 206 | paths := parseExtendPaths(path, config) 207 | wg.Add(len(paths)) 208 | for _, extendPath := range paths { 209 | go func(newPath string, id uint8) { 210 | loadFromFile(newPath, id, ch, wg) 211 | }(extendPath, util.IDGen.NextID()) 212 | } 213 | } 214 | 215 | type YAML map[string]any 216 | 217 | func parseYAML(raw []byte) (YAML, error) { 218 | config := make(YAML) 219 | err := yaml.Unmarshal(raw, &config) 220 | return config, err 221 | } 222 | 223 | func parseExtendPaths(path string, config *YAML) []string { 224 | extends := make([]string, 0) 225 | importTables := (*config)["import_tables"] 226 | if importTables != nil { 227 | pathFixed := filepath.Dir(path) + string(os.PathSeparator) 228 | typeOf := reflect.TypeOf(importTables) 229 | if typeOf.Kind() == reflect.Slice { 230 | for _, extendDict := range importTables.([]any) { 231 | extends = append(extends, fmt.Sprintf("%s%s.dict.yaml", pathFixed, extendDict)) 232 | } 233 | } 234 | } 235 | return extends 236 | } 237 | func parseColumns(config *YAML) []string { 238 | result := make([]string, 0) 239 | columns := (*config)["columns"] 240 | if columns != nil { 241 | typeOf := reflect.TypeOf(columns) 242 | if typeOf.Kind() == reflect.Slice { 243 | for _, col := range columns.([]any) { 244 | result = append(result, fmt.Sprint(col)) 245 | } 246 | } 247 | } 248 | return result 249 | } 250 | -------------------------------------------------------------------------------- /dict/loader_test.go: -------------------------------------------------------------------------------- 1 | package dict 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "reflect" 7 | "testing" 8 | ) 9 | 10 | func Test_LoadItems_xhup(t *testing.T) { 11 | content := ` 12 | # coding: utf-8 13 | # 用户词库 14 | # 与系统词条重码时居后,如想居前,请把词条放到flypy_top.txt文件内 15 | # 16 | # 编码格式:字词+Tab符+编码(用户词库本身有重码则还需后面+Tab符+权重,权重大者居前,权重数字随意) 17 | # 18 | # -------- 强调一下 -------- 19 | # 20 | # 词条和编码之间的不是空格,而是Tab符 21 | # 按住键盘 G 键,切换到功能键盘,使用上面的Tab键 22 | # 23 | # ------------------------------- 24 | # 25 | # 系统次选词放在flypy_sys.txt文件内,可修改删除 26 | # 简词补全放本文件内,不需要可删除 27 | # 用户词库,下行开始添加,编码格式见上,部署后生效 28 | 29 | # 全码词 30 | 即使 jiui 31 | 回忆 hvyi 32 | 华为 hxww 33 | 一边 yibm 34 | 两边 llbm 35 | 整句 vgju 36 | 按键 anjm 37 | 单元 djyr 38 | 反思 fjsi` 39 | filename := createFile("./flypy_user.txt", content) 40 | defer func() { 41 | _ = os.RemoveAll("./flypy_user.txt") 42 | }() 43 | cols := []Column{COLUMN_TEXT, COLUMN_CODE, COLUMN_WEIGHT} 44 | tests := []struct { 45 | name string 46 | filename string 47 | want []Data 48 | }{ 49 | { 50 | name: "xhup", filename: filename, 51 | want: []Data{ 52 | {Text: "即使", Code: "jiui", cols: &cols}, 53 | {Text: "回忆", Code: "hvyi", cols: &cols}, 54 | {Text: "华为", Code: "hxww", cols: &cols}, 55 | }, 56 | }} 57 | for _, tt := range tests { 58 | t.Run(tt.name, func(_ *testing.T) { 59 | // start := time.Now() 60 | fes := LoadItems(tt.filename) 61 | // duration1 := time.Since(start) 62 | // fmt.Println("======================================================") 63 | // fmt.Println("fes >>", len(fes)) 64 | got := make([]Data, 0) 65 | if len(fes) > 0 { 66 | for _, fe := range fes { 67 | for _, entry := range fe.Entries { 68 | got = append(got, entry.data) 69 | } 70 | } 71 | } 72 | // fmt.Println("count >>", len(entries)) 73 | got = got[:3] 74 | if !reflect.DeepEqual(got, tt.want) { 75 | for _, d := range got { 76 | t.Errorf("Load Item Count got = %+v", d) 77 | } 78 | for _, d := range tt.want { 79 | t.Errorf("Load Item Count want = %+v", d) 80 | } 81 | } 82 | // fmt.Println("======================================================") 83 | // fmt.Println("load duration >>", duration1) 84 | }) 85 | } 86 | } 87 | 88 | func Test_LoadItems(t *testing.T) { 89 | filenames := mockFile() 90 | defer func() { 91 | _ = os.RemoveAll("./tmp") 92 | }() 93 | type args struct { 94 | path string 95 | } 96 | tests := []struct { 97 | name string 98 | args args 99 | want int 100 | }{ 101 | {"xkdj", args{filenames[0]}, 20}, 102 | {"tigress", args{filenames[1]}, 14}, 103 | {"onlyhead", args{filenames[2]}, 4}, 104 | } 105 | for _, tt := range tests { 106 | t.Run(tt.name, func(_ *testing.T) { 107 | // start := time.Now() 108 | fes := LoadItems(tt.args.path) 109 | // duration1 := time.Since(start) 110 | // fmt.Println("======================================================") 111 | // fmt.Println("fes >>", len(fes)) 112 | entries := make([]*Entry, 0) 113 | if len(fes) > 0 { 114 | for _, fe := range fes { 115 | // fmt.Println("fe >>", fe.FilePath) 116 | entries = append(entries, fe.Entries...) 117 | // for _, e := range fe.Entries { 118 | // fmt.Println("entry >>", string(e.raw)) 119 | // } 120 | } 121 | } 122 | // fmt.Println("count >>", len(entries)) 123 | if len(entries) != tt.want { 124 | t.Errorf("Load Item Count = %v, want %v", len(entries), tt.want) 125 | } 126 | // fmt.Println("======================================================") 127 | // fmt.Println("load duration >>", duration1) 128 | }) 129 | } 130 | } 131 | 132 | func mockFile() []string { 133 | filenames := make([]string, 0) 134 | // create ./tmp directory 135 | err := os.MkdirAll("./tmp", os.ModePerm) 136 | if err != nil { 137 | fmt.Println("mkdir error, ", err) 138 | panic(err) 139 | } 140 | content := ` 141 | # 键道6 扩展词库控制 142 | --- 143 | name: xkjd6 144 | version: "Q1" 145 | sort: original 146 | use_preset_vocabulary: false 147 | import_tables: 148 | # 扩展:单字 149 | - xkjd6.danzi 150 | # 扩展:词组 151 | - xkjd6.cizu 152 | # 扩展:符号 153 | - xkjd6.fuhao 154 | ... 155 | 所以 m 156 | ` 157 | filenames = append(filenames, createFile("./tmp/xkjd6.dict.yaml", content)) 158 | content = ` 159 | --- 160 | name: xkjd6.danzi 161 | version: "Q1" 162 | sort: original 163 | ... 164 | 不 b 165 | 宾 bb 166 | 滨 bba 167 | ` 168 | createFile("./tmp/xkjd6.danzi.dict.yaml", content) 169 | content = ` 170 | --- 171 | name: xkjd6.cizu 172 | version: "Q1" 173 | sort: original 174 | import_tables: 175 | # 扩展:单字 176 | - xkjd6.cizu2 177 | ... 178 | 并不比 bbb 179 | 彬彬 bbbb 180 | 斌斌 bbbbo 181 | ` 182 | createFile("./tmp/xkjd6.cizu.dict.yaml", content) 183 | content = ` 184 | ① oyk 185 | ② oxj 186 | ③ osf 187 | ④ osk 188 | ⑤ owj 189 | ⑥ olq 190 | ⑦ oqk 191 | ⑧ obs 192 | ⑨ ojq 193 | ⑩ oek 194 | ` 195 | createFile("./tmp/xkjd6.fuhao.dict.yaml", content) 196 | content = ` 197 | --- 198 | name: xkjd6.whatever 199 | version: "Q1" 200 | sort: original 201 | ... 202 | 造作 zzzl 203 | 早做 zzzlo 204 | 早早 zzzz 205 | ` 206 | createFile("./tmp/xkjd6.cizu2.dict.yaml", content) 207 | content = ` 208 | name: tigress 209 | version: "2025.03.07" 210 | sort: by_weight 211 | columns: 212 | - text 213 | - weight 214 | - code 215 | - stem 216 | encoder: 217 | rules: 218 | - length_equal: 2 219 | formula: "AaAbBaBb" 220 | - length_equal: 3 221 | formula: "AaBaCaCb" 222 | - length_in_range: [4, 10] 223 | formula: "AaBaCaZa" 224 | import_tables: 225 | - tigress_ci 226 | - tigress_simp_ci 227 | # - tigress_user 228 | ... 229 | 230 | 的 10359470 u un 231 | 的 256 uni 232 | 的 256 unid 233 | 一 4346343 f fi 234 | 一 256 fi 235 | ` 236 | filenames = append(filenames, createFile("./tmp/tigress.dict.yaml", content)) 237 | content = ` 238 | name: tigress_ci 239 | version: "2025.03.07" 240 | sort: by_weight 241 | use_preset_vocabulary: false 242 | columns: 243 | - text 244 | - weight 245 | - code 246 | - stem 247 | encoder: 248 | rules: 249 | - length_equal: 2 250 | formula: "AaAbBaBb" 251 | - length_equal: 3 252 | formula: "AaBaCaCb" 253 | - length_in_range: [4, 99] 254 | formula: "AaBaCaZa" 255 | 256 | ... 257 | 我们 116006 tuja 258 | 自己 109686 oivj 259 | 一个 105148 fijg 260 | 没有 90888 krnv 261 | 什么 80552 jntk 262 | ` 263 | createFile("./tmp/tigress_ci.dict.yaml", content) 264 | content = ` 265 | name: tigress_simp_ci 266 | version: "2025.03.07" 267 | sort: by_weight 268 | use_preset_vocabulary: false 269 | columns: 270 | - text 271 | - weight 272 | - code 273 | - stem 274 | encoder: 275 | rules: 276 | - length_equal: 2 277 | formula: "AaAbBaBb" 278 | - length_equal: 3 279 | formula: "AaBaCaCb" 280 | - length_in_range: [4, 99] 281 | formula: "AaBaCaZa" 282 | 283 | ... 284 | 那个 5000 a 285 | 如果 5000 b 286 | 不是 5000 c 287 | 哪个 5000 d 288 | 289 | ` 290 | createFile("./tmp/tigress_simp_ci.dict.yaml", content) 291 | content = ` 292 | name: onlyhead 293 | version: "2025.03.07" 294 | sort: by_weight 295 | import_tables: 296 | - onlyhead_extend 297 | - onlyhead_user 298 | # - tigress_user 299 | ` 300 | filenames = append(filenames, createFile("./tmp/onlyhead.dict.yaml", content)) 301 | content = ` 302 | name: onlyhead 303 | version: "2025.03.07" 304 | sort: by_weight 305 | ... 306 | 307 | 那个 5000 a 308 | 如果 5000 b 309 | ` 310 | createFile("./tmp/onlyhead_extend.dict.yaml", content) 311 | content = ` 312 | 不是 5000 c 313 | 哪个 5000 d 314 | ` 315 | createFile("./tmp/onlyhead_user.dict.yaml", content) 316 | return filenames 317 | } 318 | 319 | func createFile(name string, content string) string { 320 | file, err := os.Create(name) 321 | if err != nil { 322 | fmt.Println("create temp file error, ", err) 323 | panic(err) 324 | } 325 | defer func() { 326 | _ = file.Close() 327 | }() 328 | _, _ = file.WriteString(content) 329 | return file.Name() 330 | } 331 | -------------------------------------------------------------------------------- /dict/matcher.go: -------------------------------------------------------------------------------- 1 | package dict 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/sahilm/fuzzy" 7 | ) 8 | 9 | type MatchResult struct { 10 | Entry *Entry 11 | score int 12 | } 13 | 14 | type MatchResultChunk struct { 15 | Result []*MatchResult 16 | Version int 17 | } 18 | 19 | func (m *MatchResult) Id() int { 20 | return int(m.Entry.FID) 21 | } 22 | 23 | func (m *MatchResult) String() string { 24 | return string(m.Entry.raw) 25 | } 26 | 27 | func (m *MatchResult) Cmp(other any) bool { 28 | if o, ok := other.(*MatchResult); ok { 29 | if m.score == o.score { 30 | if m.Entry.data.Weight == o.Entry.data.Weight { 31 | return len(m.Entry.data.Code) < len(m.Entry.data.Code) 32 | } else { 33 | return m.Entry.data.Weight > o.Entry.data.Weight 34 | } 35 | } 36 | return m.score > o.score 37 | } 38 | return false 39 | } 40 | 41 | type Matcher interface { 42 | Search(key string, useColumn Column, searchVersion int, list []*Entry, resultChan chan<- MatchResultChunk, ctx context.Context) 43 | Reset() 44 | } 45 | 46 | type CacheMatcher struct { 47 | cache map[string][]*MatchResult 48 | } 49 | 50 | func (m *CacheMatcher) Reset() { 51 | m.cache = nil 52 | } 53 | 54 | // var slab = util.MakeSlab(200*1024, 4096) 55 | 56 | func (m *CacheMatcher) Search(key string, useColumn Column, searchVersion int, list []*Entry, resultChan chan<- MatchResultChunk, ctx context.Context) { 57 | var done bool 58 | go func() { 59 | <-ctx.Done() 60 | done = true 61 | }() 62 | var cache []*MatchResult 63 | if m.cache != nil { 64 | cachedKey := "" 65 | for i := len(key); i > 0; i-- { 66 | cachedKey = string(key[:i]) 67 | if cache = m.cache[cachedKey]; cache != nil { 68 | break 69 | } 70 | } 71 | if done { 72 | // log.Println("search canceld: ", key) 73 | return 74 | } 75 | // if cache != nil { 76 | // log.Println("search hit cache key: ", cachedKey) 77 | // } 78 | if cache != nil && cachedKey == string(key) { 79 | // log.Println("search directly use cache") 80 | resultChan <- MatchResultChunk{Result: cache, Version: searchVersion} 81 | return 82 | } 83 | } 84 | 85 | if cache != nil { 86 | list = make([]*Entry, len(cache)) 87 | // log.Println("search from cached: ", key) 88 | for i, m := range cache { 89 | list[i] = m.Entry 90 | } 91 | } 92 | 93 | getTarget := func(entry *Entry) string { 94 | return entry.data.Code 95 | } 96 | if useColumn != COLUMN_CODE { 97 | if useColumn == COLUMN_TEXT { 98 | getTarget = func(entry *Entry) string { 99 | return entry.data.Text 100 | } 101 | } else { 102 | getTarget = func(entry *Entry) string { 103 | return entry.raw 104 | } 105 | } 106 | } 107 | 108 | matched := make([]*MatchResult, 0) 109 | listLen := len(list) 110 | chunkSize := 50000 // chunkSize = listLen means no async search 111 | for c := 0; c < listLen; c += chunkSize { 112 | end := min(c+chunkSize, listLen) 113 | chunk := list[c:end] 114 | source := &ChunkSource{chunk, getTarget} 115 | matches := fuzzy.FindFromNoSort(key, source) 116 | if len(matches) == 0 { 117 | // log.Println("search zero matches: ", key) 118 | resultChan <- MatchResultChunk{Result: []*MatchResult{}, Version: searchVersion} 119 | continue 120 | } 121 | ret := make([]*MatchResult, 0, len(matches)) 122 | for _, ma := range matches { 123 | if chunk[ma.Index].IsDelete() { // cache matcher still need to determine whether it has been deleted 124 | continue 125 | } 126 | ret = append(ret, &MatchResult{chunk[ma.Index], ma.Score}) 127 | } 128 | if len(ret) > 0 { 129 | resultChan <- MatchResultChunk{Result: ret, Version: searchVersion} 130 | matched = append(matched, ret...) 131 | } 132 | } 133 | // log.Printf("Cache Matcher Search: Key: %s, List Len: %d, Cached: %v, Matched: %d", string(key), listLen, cache != nil, len(matched)) 134 | if len(matched) > 0 { 135 | if m.cache == nil { 136 | m.cache = make(map[string][]*MatchResult) 137 | } 138 | m.cache[key] = matched 139 | } 140 | } 141 | 142 | type ChunkSource struct { 143 | chunk []*Entry 144 | getTarget func(e *Entry) string 145 | } 146 | 147 | func (e *ChunkSource) Len() int { 148 | return len(e.chunk) 149 | } 150 | 151 | func (e *ChunkSource) String(i int) string { 152 | return e.getTarget(e.chunk[i]) 153 | } 154 | -------------------------------------------------------------------------------- /dict/output.go: -------------------------------------------------------------------------------- 1 | package dict 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "sort" 8 | "sync" 9 | ) 10 | 11 | func exportDict(path string, fes []*FileEntries, cols []Column) { 12 | file, err := os.Create(path) 13 | if err != nil { 14 | panic(err) 15 | } 16 | defer func() { 17 | _ = file.Close() 18 | }() 19 | for _, fe := range fes { 20 | if len(fe.Entries) == 0 { 21 | continue 22 | } 23 | log.Println("导出词库中:", fe.FilePath) 24 | for _, entry := range fe.Entries { 25 | _, err := file.WriteString(entry.data.ToStringWithColumns(&cols)) 26 | if err != nil { 27 | panic(err) 28 | } 29 | _, err = file.WriteString("\n") 30 | if err != nil { 31 | panic(err) 32 | } 33 | } 34 | } 35 | } 36 | 37 | func output(fes []*FileEntries) (changed bool) { 38 | var wg sync.WaitGroup 39 | for _, fe := range fes { 40 | if len(fe.Entries) == 0 { 41 | continue 42 | } 43 | wg.Add(1) 44 | go func(fe *FileEntries) { 45 | defer wg.Done() 46 | sort.Slice(fe.Entries, func(i, j int) bool { 47 | return fe.Entries[i].seek < fe.Entries[j].seek 48 | }) 49 | if outputFile(&fe.RawBs, fe.FilePath, fe.Entries) { 50 | changed = true 51 | } 52 | }(fe) 53 | } 54 | wg.Wait() 55 | return changed 56 | } 57 | 58 | func tryPanic(err error, format string, args ...any) { 59 | if err != nil { 60 | panic(fmt.Sprintf(format, args...)) 61 | } 62 | } 63 | 64 | func outputFile(rawBs *[]byte, path string, entries []*Entry) (changed bool) { 65 | file, err := os.OpenFile(path, os.O_RDWR, 0666) 66 | tryPanic(err, "open File failed, Err:%v", err) 67 | defer func() { _ = file.Close() }() 68 | bs := *rawBs 69 | willAddEntries := make([]*Entry, 0) 70 | seekFixed := int64(0) 71 | for _, entry := range entries { 72 | entry.seek += seekFixed 73 | if entry.modType == NC { 74 | continue 75 | } 76 | var modType string 77 | switch entry.modType { 78 | case DELETE: 79 | bs = append(bs[:entry.seek], bs[entry.seek+entry.rawSize:]...) 80 | seekFixed = seekFixed - entry.rawSize 81 | entry.Saved() 82 | modType = "DEL" 83 | case MODIFY: 84 | nbs := []byte(entry.Raw()) 85 | nbs = append(nbs, '\n') 86 | bs = append(bs[:entry.seek], append(nbs, bs[entry.seek+entry.rawSize:]...)...) 87 | seekFixed = seekFixed - entry.rawSize + int64(len(nbs)) 88 | entry.Saved() 89 | modType = "MOD" 90 | case ADD: 91 | willAddEntries = append(willAddEntries, entry) 92 | modType = "ADD" 93 | } 94 | log.Printf("modify dict type:%s | %s", modType, entry.Raw()) 95 | changed = true 96 | } 97 | if !changed { 98 | return 99 | } 100 | seek := int64(len(bs)) 101 | // append new entry to file 102 | if len(willAddEntries) > 0 { 103 | if bs[len(bs)-1] != '\n' { 104 | bs = append(bs, '\n') 105 | seek += 1 106 | } 107 | for _, entry := range willAddEntries { 108 | raw := entry.Raw() 109 | rawSize := int64(len(raw) + 1) 110 | bs = append(bs, raw...) 111 | bs = append(bs, '\n') 112 | entry.reSeek(seek, rawSize) 113 | entry.Saved() 114 | seek += entry.rawSize 115 | } 116 | } 117 | *rawBs = bs 118 | l, err := file.Write(bs) 119 | tryPanic(err, "write File failed, Err:%v", err) 120 | err = file.Truncate(int64(l)) 121 | tryPanic(err, "truncate File failed, Err:%v", err) 122 | return 123 | } 124 | -------------------------------------------------------------------------------- /dict/output_test.go: -------------------------------------------------------------------------------- 1 | package dict 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func Test_outputFile(t *testing.T) { 9 | _ = os.MkdirAll("./tmp", os.ModePerm) 10 | defer func() { _ = os.RemoveAll("./tmp") }() 11 | content1 := ` 12 | --- 13 | name: xkjd6.whatever 14 | version: "Q1" 15 | sort: original 16 | ... 17 | 早早 zzzzmod 18 | 早早 zzzz 19 | 测试 ceek 20 | ` 21 | content2 := ` 22 | name: xkjd6.whatever 23 | version: "Q1" 24 | sort: original 25 | ... 26 | 早早 zzzzmod 27 | 早早 zzzz 28 | 测试 ceek 29 | ` 30 | content3 := ` 31 | 早早 zzzzmod 32 | 早早 zzzz 33 | 测试 ceek 34 | ` 35 | content1_want1 := ` 36 | --- 37 | name: xkjd6.whatever 38 | version: "Q1" 39 | sort: original 40 | ... 41 | 早早 zzzz 42 | 测试 ceek 43 | ` 44 | 45 | content1_want2 := ` 46 | --- 47 | name: xkjd6.whatever 48 | version: "Q1" 49 | sort: original 50 | ... 51 | 早早 zzzz 52 | ` 53 | content1_want3 := ` 54 | --- 55 | name: xkjd6.whatever 56 | version: "Q1" 57 | sort: original 58 | ... 59 | 早早 zaozao 60 | 测试 ceshi 61 | ` 62 | content2_want1 := ` 63 | name: xkjd6.whatever 64 | version: "Q1" 65 | sort: original 66 | ... 67 | 早早 zzzz 68 | ` 69 | content3_want1 := ` 70 | 早早 zzzz 71 | ` 72 | content4 := ` 73 | name: xkjd6.whatever 74 | version: "Q1" 75 | sort: original 76 | columns: 77 | - text 78 | - code 79 | - weight 80 | ... 81 | 早早 zzzzmod 10 82 | 早早 zzzz 10 83 | 测试 ceek 10 84 | ` 85 | content4_want1 := ` 86 | name: xkjd6.whatever 87 | version: "Q1" 88 | sort: original 89 | columns: 90 | - text 91 | - code 92 | - weight 93 | ... 94 | 早早 zzzzmod 10 95 | 早早 zzzz 10 96 | 测试 ceek 10 97 | 伊藤萌子 jllh 10 98 | ` 99 | content4_want2 := ` 100 | name: xkjd6.whatever 101 | version: "Q1" 102 | sort: original 103 | columns: 104 | - text 105 | - code 106 | - weight 107 | ... 108 | 早早 zzzzmod 10 109 | 测 ceek 10 110 | ` 111 | tests := []struct { 112 | fe *FileEntries 113 | name string 114 | want string 115 | filename string 116 | shouldChanged bool 117 | }{ 118 | { 119 | name: "delete some 1", 120 | fe: func() *FileEntries { 121 | filename := createFile("./tmp/test_outputfile1.yaml", content1) 122 | fe := LoadItems(filename)[0] 123 | fe.Entries[0].Delete() 124 | return fe 125 | }(), 126 | want: content1_want1, 127 | shouldChanged: true, 128 | filename: "./tmp/test_outputfile1.yaml", 129 | }, 130 | { 131 | name: "delete some 2", 132 | fe: func() *FileEntries { 133 | filename := createFile("./tmp/test_outputfile2.yaml", content1) 134 | fe := LoadItems(filename)[0] 135 | fe.Entries[0].Delete() 136 | outputFile(&fe.RawBs, fe.FilePath, fe.Entries) 137 | fe.Entries[2].Delete() 138 | return fe 139 | }(), 140 | want: content1_want2, 141 | shouldChanged: true, 142 | filename: "./tmp/test_outputfile2.yaml", 143 | }, 144 | { 145 | name: "delete 1 mod 2", 146 | fe: func() *FileEntries { 147 | filename := createFile("./tmp/test_outputfile3.yaml", content1) 148 | fe := LoadItems(filename)[0] 149 | fe.Entries[0].Delete() 150 | fe.Entries[1].ReRaw("早早\tzaozao") 151 | fe.Entries[2].ReRaw("测试\tceshi") 152 | return fe 153 | }(), 154 | want: content1_want3, 155 | shouldChanged: true, 156 | filename: "./tmp/test_outputfile3.yaml", 157 | }, 158 | { 159 | name: "delete 1 mod 2 output multiple times", 160 | fe: func() *FileEntries { 161 | filename := createFile("./tmp/test_outputfile4.yaml", content1) 162 | fe := LoadItems(filename)[0] 163 | fe.Entries[0].Delete() 164 | outputFile(&fe.RawBs, fe.FilePath, fe.Entries) 165 | fe.Entries[1].ReRaw("早早\tzaozao") 166 | outputFile(&fe.RawBs, fe.FilePath, fe.Entries) 167 | fe.Entries[2].ReRaw("测试\tceshi") 168 | outputFile(&fe.RawBs, fe.FilePath, fe.Entries) 169 | return fe 170 | }(), 171 | want: content1_want3, 172 | shouldChanged: false, 173 | filename: "./tmp/test_outputfile4.yaml", 174 | }, 175 | { 176 | name: "delete and output multiple times", 177 | fe: func() *FileEntries { 178 | filename := createFile("./tmp/test_outputfile5.yaml", content1) 179 | fe := LoadItems(filename)[0] 180 | fe.Entries[0].Delete() 181 | outputFile(&fe.RawBs, fe.FilePath, fe.Entries) 182 | fe.Entries[2].Delete() 183 | outputFile(&fe.RawBs, fe.FilePath, fe.Entries) 184 | return fe 185 | }(), 186 | want: content1_want2, 187 | shouldChanged: false, 188 | filename: "./tmp/test_outputfile5.yaml", 189 | }, 190 | { 191 | name: "content2", 192 | fe: func() *FileEntries { 193 | filename := createFile("./tmp/test_outputfile6.yaml", content2) 194 | fe := LoadItems(filename)[0] 195 | fe.Entries[0].Delete() 196 | outputFile(&fe.RawBs, fe.FilePath, fe.Entries) 197 | 198 | fe.Entries[2].Delete() 199 | outputFile(&fe.RawBs, fe.FilePath, fe.Entries) 200 | 201 | return fe 202 | }(), 203 | want: content2_want1, 204 | shouldChanged: false, 205 | filename: "./tmp/test_outputfile6.yaml", 206 | }, 207 | { 208 | name: "content3", 209 | fe: func() *FileEntries { 210 | filename := createFile("./tmp/test_outputfile7.yaml", content3) 211 | fe := LoadItems(filename)[0] 212 | fe.Entries[0].Delete() 213 | outputFile(&fe.RawBs, fe.FilePath, fe.Entries) 214 | fe.Entries[2].Delete() 215 | outputFile(&fe.RawBs, fe.FilePath, fe.Entries) 216 | return fe 217 | }(), 218 | want: content3_want1, 219 | shouldChanged: false, 220 | filename: "./tmp/test_outputfile7.yaml", 221 | }, 222 | { 223 | name: "add and modify", 224 | fe: func() *FileEntries { 225 | filename := createFile("./tmp/test_outputfile8.yaml", content4) 226 | fe := LoadItems(filename)[0] 227 | 228 | // new entry then just delete 229 | ne0 := NewEntryAdd("萌子 lohi 1", 0, Data{cols: &fe.Columns}) 230 | fe.Entries = append(fe.Entries, ne0) 231 | // outputFile(&fe.RawBs, fe.FilePath, fe.Entries) 232 | ne0.Delete() 233 | 234 | ne1 := NewEntryAdd("萌子 lohi 1", 0, Data{cols: &fe.Columns}) 235 | fe.Entries = append(fe.Entries, ne1) 236 | outputFile(&fe.RawBs, fe.FilePath, fe.Entries) 237 | ne1.Delete() 238 | outputFile(&fe.RawBs, fe.FilePath, fe.Entries) 239 | ne2 := NewEntryAdd("伊藤 jblv 1", 0, Data{cols: &fe.Columns}) 240 | fe.Entries = append(fe.Entries, ne2) 241 | outputFile(&fe.RawBs, fe.FilePath, fe.Entries) 242 | ne2.ReRaw("伊藤 jblv 10") 243 | outputFile(&fe.RawBs, fe.FilePath, fe.Entries) 244 | ne2.ReRaw("伊藤萌子 jllh 10") 245 | outputFile(&fe.RawBs, fe.FilePath, fe.Entries) 246 | return fe 247 | }(), 248 | want: content4_want1, 249 | shouldChanged: false, 250 | filename: "./tmp/test_outputfile8.yaml", 251 | }, 252 | { 253 | name: "delete and save and delete again", 254 | fe: func() *FileEntries { 255 | filename := createFile("./tmp/test_outputfile9.yaml", content4) 256 | fe := LoadItems(filename)[0] 257 | de := fe.Entries[1] 258 | d1 := fe.Entries[2] 259 | d1.ReRaw("测 ceek 10") 260 | de.Delete() 261 | outputFile(&fe.RawBs, fe.FilePath, fe.Entries) 262 | de.Delete() 263 | outputFile(&fe.RawBs, fe.FilePath, fe.Entries) 264 | return fe 265 | }(), 266 | want: content4_want2, 267 | shouldChanged: false, 268 | filename: "./tmp/test_outputfile9.yaml", 269 | }, 270 | } 271 | 272 | for _, tt := range tests { 273 | t.Run(tt.name, func(_ *testing.T) { 274 | changed := outputFile(&tt.fe.RawBs, tt.fe.FilePath, tt.fe.Entries) 275 | c, err := os.ReadFile(tt.filename) 276 | if err != nil { 277 | panic(err) 278 | } 279 | if string(c) != string(tt.fe.RawBs) { 280 | t.Errorf("case:%v file content != file\nfile\n%v[fin]\nRawBs\n%v[fin]", tt.name, string(c), string(tt.fe.RawBs)) 281 | } 282 | if string(tt.fe.RawBs) != tt.want { 283 | t.Errorf("case:%v RawBs != want\nfe.RawBs\n%v[fin]\nwant\n%v[fin]", tt.name, string(tt.fe.RawBs), tt.want) 284 | } 285 | if tt.shouldChanged != changed { 286 | t.Errorf("case:%v, changed: %v, want: %v", tt.name, changed, tt.shouldChanged) 287 | } 288 | }) 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1744932701, 6 | "narHash": "sha256-fusHbZCyv126cyArUwwKrLdCkgVAIaa/fQJYFlCEqiU=", 7 | "owner": "nixos", 8 | "repo": "nixpkgs", 9 | "rev": "b024ced1aac25639f8ca8fdfc2f8c4fbd66c48ef", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "nixos", 14 | "ref": "nixos-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs", 22 | "systems": "systems" 23 | } 24 | }, 25 | "systems": { 26 | "locked": { 27 | "lastModified": 1681028828, 28 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 29 | "owner": "nix-systems", 30 | "repo": "default", 31 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 32 | "type": "github" 33 | }, 34 | "original": { 35 | "owner": "nix-systems", 36 | "repo": "default", 37 | "type": "github" 38 | } 39 | } 40 | }, 41 | "root": "root", 42 | "version": 7 43 | } 44 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "rimedm"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; 6 | systems.url = "github:nix-systems/default"; 7 | }; 8 | 9 | outputs = 10 | { 11 | self, 12 | nixpkgs, 13 | systems, 14 | ... 15 | }: 16 | let 17 | forEachSystem = nixpkgs.lib.genAttrs (import systems); 18 | pkgsFor = forEachSystem (system: import nixpkgs { inherit system; }); 19 | in 20 | { 21 | formatter = forEachSystem (system: pkgsFor.${system}.alejandra); 22 | 23 | devShells = forEachSystem (system: { 24 | default = pkgsFor.${system}.mkShell { 25 | packages = with pkgsFor.${system}; [ 26 | go 27 | gopls 28 | golangci-lint 29 | golangci-lint-langserver 30 | ]; 31 | shellHook = "exec zsh"; 32 | }; 33 | }); 34 | 35 | packages = forEachSystem (system: { 36 | default = pkgsFor.${system}.buildGoModule { 37 | pname = "rimedm"; 38 | version = "1.1.2"; 39 | src = ./.; 40 | vendorHash = "sha256-cANTPoe6oRSFuKvKW5rZbjUB3Ypm+oPmK7Fi8yDDhfg="; 41 | }; 42 | }); 43 | 44 | apps = forEachSystem (system: { 45 | default = { 46 | type = "app"; 47 | program = "${self.packages.${system}.default}/bin/rimedm"; 48 | }; 49 | }); 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/MapoMagpie/rimedm 2 | 3 | go 1.24.1 4 | 5 | require ( 6 | github.com/charmbracelet/bubbletea v0.25.0 7 | github.com/goccy/go-yaml v1.11.3 8 | github.com/mattn/go-runewidth v0.0.15 9 | github.com/sahilm/fuzzy v0.1.1 10 | golang.org/x/term v0.16.0 11 | ) 12 | 13 | require ( 14 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 15 | github.com/containerd/console v1.0.4 // indirect 16 | github.com/fatih/color v1.16.0 // indirect 17 | github.com/kylelemons/godebug v1.1.0 // indirect 18 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 19 | github.com/mattn/go-colorable v0.1.13 // indirect 20 | github.com/mattn/go-isatty v0.0.20 // indirect 21 | github.com/mattn/go-localereader v0.0.1 // indirect 22 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 23 | github.com/muesli/cancelreader v0.2.2 // indirect 24 | github.com/muesli/reflow v0.3.0 // indirect 25 | github.com/muesli/termenv v0.15.2 // indirect 26 | github.com/rivo/uniseg v0.4.6 // indirect 27 | golang.org/x/sync v0.6.0 // indirect 28 | golang.org/x/sys v0.16.0 // indirect 29 | golang.org/x/text v0.14.0 // indirect 30 | golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 31 | ) 32 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 2 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 3 | github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= 4 | github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= 5 | github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro= 6 | github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= 7 | github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 8 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 9 | github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= 10 | github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= 11 | github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= 12 | github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= 13 | github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE= 14 | github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= 15 | github.com/goccy/go-yaml v1.11.3 h1:B3W9IdWbvrUu2OYQGwvU1nZtvMQJPBKgBUuweJjLj6I= 16 | github.com/goccy/go-yaml v1.11.3/go.mod h1:wKnAMd44+9JAAnGQpWVEgBzGt3YuTaQ4uXoHvE4m7WU= 17 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 18 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 19 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 20 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 21 | github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= 22 | github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= 23 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 24 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 25 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 26 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 27 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 28 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 29 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 30 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 31 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 32 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 33 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 34 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 35 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 36 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 37 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 38 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 39 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 40 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 41 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 42 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 43 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 44 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 45 | github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg= 46 | github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 47 | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= 48 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 49 | golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= 50 | golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= 51 | golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= 52 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 53 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 54 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 55 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 56 | golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= 57 | golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 58 | golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= 59 | golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= 60 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 61 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 62 | golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= 63 | golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 64 | -------------------------------------------------------------------------------- /install.ps1: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env pwsh 2 | # inherit from https://deno.land/x/install@v0.1.4/install.ps1 3 | # Copyright 2018 the Deno authors. All rights reserved. MIT license. 4 | 5 | # required: 6 | # 1. $repo or $r 7 | # 2. $version or $v 8 | # 2. $exe or $e 9 | 10 | $ErrorActionPreference = 'Stop' 11 | 12 | $githubUrl = if ($github) { 13 | "${github}" 14 | } elseif ($g) { 15 | "${g}" 16 | }else { 17 | "https://github.com" 18 | } 19 | 20 | $owner = "MapoMagpie" 21 | $repoName = "rimedm" 22 | $exeName = "rimedm" 23 | 24 | if ([Environment]::Is64BitProcess) { 25 | $arch = "x86_64" 26 | } else { 27 | $arch = "i386" 28 | } 29 | 30 | $BinDir = "$Home\Appdata\Local\Programs\rimedm" 31 | $downloadedTagGz = "$BinDir\${exeName}.zip" 32 | $downloadedExe = "$BinDir\${exeName}.exe" 33 | $Target = "Windows_$arch" 34 | 35 | # GitHub requires TLS 1.2 36 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 37 | 38 | $ResourceUri = if (!$version) { 39 | "${githubUrl}/${owner}/${repoName}/releases/latest/download/${exeName}_${Target}.zip" 40 | } else { 41 | "${githubUrl}/${owner}/${repoName}/releases/download/${Version}/${exeName}_${Target}.zip" 42 | } 43 | 44 | if (!(Test-Path $BinDir)) { 45 | New-Item $BinDir -ItemType Directory | Out-Null 46 | } 47 | 48 | Invoke-WebRequest $ResourceUri -OutFile $downloadedTagGz -UseBasicParsing -ErrorAction Stop 49 | 50 | function Check-Command { 51 | param($Command) 52 | $found = $false 53 | try 54 | { 55 | $Command | Out-Null 56 | $found = $true 57 | } 58 | catch [System.Management.Automation.CommandNotFoundException] 59 | { 60 | $found = $false 61 | anakan 62 | } 63 | 64 | $found 65 | } 66 | 67 | function Unzip($zipFile, $dest) { 68 | Expand-Archive -Force -Path $zipFile -DestinationPath $dest 69 | } 70 | 71 | Unzip $downloadedTagGz $BinDir 72 | 73 | Remove-Item $downloadedTagGz 74 | 75 | $User = [EnvironmentVariableTarget]::User 76 | $Path = [Environment]::GetEnvironmentVariable('Path', $User) 77 | if (!(";$Path;".ToLower() -like "*;$BinDir;*".ToLower())) { 78 | [Environment]::SetEnvironmentVariable('Path', "$Path;$BinDir", $User) 79 | $Env:Path += ";$BinDir" 80 | } 81 | 82 | Write-Output "${exeName} was installed successfully to $downloadedExe" 83 | Write-Output "Run '${exeName} --h' to get started" 84 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | # eg. release-lab/whatchanged 6 | target="MapoMagpie/rimedm" 7 | owner="MapoMagpie" 8 | repo="rimedm" 9 | exe_name="rimedm" 10 | githubUrl="" 11 | githubApiUrl="" 12 | version="" 13 | 14 | get_arch() { 15 | # darwin/amd64: Darwin axetroydeMacBook-Air.local 20.5.0 Darwin Kernel Version 20.5.0: Sat May 8 05:10:33 PDT 2021; root:xnu-7195.121.3~9/RELEASE_X86_64 x86_64 16 | # linux/amd64: Linux test-ubuntu1804 5.4.0-42-generic #46~18.04.1-Ubuntu SMP Fri Jul 10 07:21:24 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux 17 | a=$(uname -m) 18 | case ${a} in 19 | "x86_64" | "amd64") 20 | echo "x86_64" 21 | ;; 22 | "i386" | "i486" | "i586") 23 | echo "i386" 24 | ;; 25 | "aarch64" | "arm64" | "arm") 26 | echo "arm64" 27 | ;; 28 | "mips64el") 29 | echo "mips64el" 30 | ;; 31 | "mips64") 32 | echo "mips64" 33 | ;; 34 | "mips") 35 | echo "mips" 36 | ;; 37 | *) 38 | echo ${NIL} 39 | ;; 40 | esac 41 | } 42 | 43 | get_os() { 44 | # darwin: Darwin 45 | echo $(uname -s) 46 | } 47 | 48 | # parse flag 49 | for i in "$@"; do 50 | case $i in 51 | -v=* | --version=*) 52 | version="${i#*=}" 53 | shift # past argument=value 54 | ;; 55 | -g=* | --github=*) 56 | githubUrl="${i#*=}" 57 | shift # past argument=value 58 | ;; 59 | *) 60 | # unknown option 61 | ;; 62 | esac 63 | done 64 | 65 | if [ -z "$githubUrl" ]; then 66 | githubUrl="https://github.com" 67 | fi 68 | if [ -z "$githubApiUrl" ]; then 69 | githubApiUrl="https://api.github.com" 70 | fi 71 | 72 | downloadFolder="${TMPDIR:-/tmp}" 73 | mkdir -p ${downloadFolder} # make sure download folder exists 74 | os=$(get_os) 75 | arch=$(get_arch) 76 | file_name="${exe_name}_${os}_${arch}.tar.gz" # the file name should be download 77 | downloaded_file="${downloadFolder}/${file_name}" # the file path should be download 78 | 79 | executable_folder="/usr/local/bin" # Eventually, the executable file will be placed here 80 | if [ ! -w "${executable_folder}" ]; then 81 | executable_folder="${HOME}/.local/bin" 82 | # check if the directory exists 83 | if [ ! -d "${executable_folder}" ]; then 84 | mkdir -p ${executable_folder} 85 | fi 86 | fi 87 | 88 | # if version is empty 89 | if [ -z "$version" ]; then 90 | asset_path=$( 91 | command curl -L \ 92 | -H "Accept: application/vnd.github+json" \ 93 | -H "X-GitHub-Api-Version: 2022-11-28" \ 94 | ${githubApiUrl}/repos/${owner}/${repo}/releases | 95 | command grep -o "/${owner}/${repo}/releases/download/.*/${file_name}" | 96 | command head -n 1 97 | ) 98 | if [[ ! "$asset_path" ]]; then 99 | echo "ERROR: unable to find a release asset called ${file_name}" 100 | exit 1 101 | fi 102 | asset_uri="${githubUrl}${asset_path}" 103 | else 104 | asset_uri="${githubUrl}/${owner}/${repo}/releases/download/${version}/${file_name}" 105 | fi 106 | 107 | echo "[1/3] Download ${asset_uri} to ${downloadFolder}" 108 | rm -f ${downloaded_file} 109 | curl --fail --location --output "${downloaded_file}" "${asset_uri}" 110 | 111 | echo "[2/3] Install ${exe_name} to the ${executable_folder}" 112 | tar -xz -f ${downloaded_file} -C ${executable_folder} 113 | exe=${executable_folder}/${exe_name} 114 | chmod +x ${exe} 115 | 116 | echo "[3/3] Set environment variables" 117 | echo "${exe_name} was installed successfully to ${exe}" 118 | if command -v $exe_name --version >/dev/null; then 119 | echo "Run '$exe_name -h' to get started" 120 | else 121 | echo "Manually add the directory to your \$HOME/.bash_profile (or similar)" 122 | echo " export PATH=${executable_folder}:\$PATH" 123 | echo "Run '$exe_name -h' to get started" 124 | fi 125 | 126 | exit 0 127 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "path/filepath" 6 | 7 | "github.com/MapoMagpie/rimedm/core" 8 | "github.com/charmbracelet/bubbletea" 9 | // "net/http" 10 | // _ "net/http/pprof" 11 | ) 12 | 13 | func main() { 14 | opts, configPath := core.ParseOptions() 15 | f, err := tea.LogToFile(filepath.Dir(configPath)+"/debug.log", "DEBUG") 16 | if err != nil { 17 | log.Fatalf("log to file err : %s", err) 18 | } 19 | // go func() { 20 | // http.ListenAndServe("localhost:10080", nil) 21 | // }() 22 | defer func() { 23 | _ = f.Close() 24 | }() 25 | core.Start(&opts) 26 | } 27 | -------------------------------------------------------------------------------- /tui/event.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import tea "github.com/charmbracelet/bubbletea" 4 | 5 | type EventManager struct { 6 | keyMap map[string]*Event 7 | } 8 | 9 | func NewEventManager() *EventManager { 10 | e := &EventManager{keyMap: make(map[string]*Event)} 11 | return e 12 | } 13 | 14 | func (e *EventManager) Find(key string) *Event { 15 | return e.keyMap[key] 16 | } 17 | 18 | func (e *EventManager) Add(events ...*Event) { 19 | for _, event := range events { 20 | for _, key := range event.Keys { 21 | e.keyMap[key] = event 22 | } 23 | } 24 | } 25 | 26 | type Event struct { 27 | Cb func(key string, m *Model) (tea.Model, tea.Cmd) 28 | Keys []string 29 | } 30 | 31 | var MoveEvent = &Event{ 32 | Keys: []string{"up", "ctrl+j", "down", "ctrl+k"}, 33 | Cb: func(key string, m *Model) (tea.Model, tea.Cmd) { 34 | switch key { 35 | case "up", "ctrl+k": 36 | m.ListManager.StepIndex(+1) 37 | case "down", "ctrl+j": 38 | m.ListManager.StepIndex(-1) 39 | } 40 | m.ClearMessage() 41 | return m, nil 42 | }, 43 | } 44 | 45 | var ClearInputEvent = &Event{ 46 | Keys: []string{"ctrl+x"}, 47 | Cb: func(_ string, m *Model) (tea.Model, tea.Cmd) { 48 | m.Inputs = []string{} 49 | m.InputCursor = 0 50 | m.ClearMessage() 51 | m.FreshList() 52 | return m, nil 53 | }, 54 | } 55 | 56 | var EnterEvent = &Event{ 57 | Keys: []string{"enter"}, 58 | Cb: func(_ string, m *Model) (tea.Model, tea.Cmd) { 59 | if !m.MenusShowing { 60 | m.ShowMenus() 61 | } else if len(m.menus) > 0 { 62 | if menu := m.menus[m.MenuIndex]; menu.Cb != nil { 63 | return m, menu.Cb(m) 64 | } 65 | } 66 | return m, nil 67 | }, 68 | } 69 | -------------------------------------------------------------------------------- /tui/tui.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "slices" 9 | "sort" 10 | "strconv" 11 | "strings" 12 | 13 | tea "github.com/charmbracelet/bubbletea" 14 | "github.com/mattn/go-runewidth" 15 | "golang.org/x/term" 16 | ) 17 | 18 | type ExitMenuMsg int 19 | 20 | func ExitMenuCmd() tea.Msg { 21 | return ExitMenuMsg(1) 22 | } 23 | 24 | type FreshListMsg int 25 | 26 | type NotifitionMsg string 27 | 28 | type ItemRender interface { 29 | Id() int 30 | String() string 31 | Cmp(other any) bool 32 | } 33 | 34 | type StringRender string 35 | 36 | func (h StringRender) Id() int { 37 | return 0 38 | } 39 | 40 | func (h StringRender) String() string { 41 | return string(h) 42 | } 43 | 44 | func (h StringRender) Cmp(_ any) bool { 45 | return true 46 | } 47 | 48 | type Menu struct { 49 | Cb func(m *Model) tea.Cmd 50 | OnSelected func(m *Model) 51 | Name string 52 | } 53 | 54 | type ListMode uint8 55 | 56 | var ( 57 | LIST_MODE_DICT ListMode = 1 58 | LIST_MODE_FILE ListMode = 2 59 | LIST_MODE_HELP ListMode = 3 60 | LIST_MODE_EXPO ListMode = 4 61 | ) 62 | 63 | type ListManager struct { 64 | SearchChan chan<- string 65 | list []ItemRender 66 | files []ItemRender 67 | ListMode ListMode 68 | currIndex int 69 | fileIndex int 70 | version int 71 | noSort bool 72 | ExportOptions []ItemRender 73 | ExportOptionsIndex int 74 | helpIndex int 75 | } 76 | 77 | func (l *ListManager) ReSort() { 78 | l.noSort = false 79 | } 80 | 81 | func (l *ListManager) SetSearchVersion(version int) { 82 | l.version = version 83 | } 84 | 85 | func NewListManager(searchChan chan<- string) *ListManager { 86 | return &ListManager{SearchChan: searchChan, ListMode: LIST_MODE_DICT} 87 | } 88 | 89 | func (l *ListManager) StepIndex(mod int) { 90 | var getIndex func() *int 91 | var getLen func() int 92 | switch l.ListMode { 93 | case LIST_MODE_DICT: 94 | getIndex = func() *int { 95 | return &l.currIndex 96 | } 97 | getLen = func() int { 98 | return len(l.list) 99 | } 100 | case LIST_MODE_FILE: 101 | getIndex = func() *int { 102 | return &l.fileIndex 103 | } 104 | getLen = func() int { 105 | return len(l.files) 106 | } 107 | case LIST_MODE_HELP: 108 | getIndex = func() *int { 109 | return &l.helpIndex 110 | } 111 | getLen = func() int { 112 | return len(l.Helps()) 113 | } 114 | case LIST_MODE_EXPO: 115 | getIndex = func() *int { 116 | return &l.ExportOptionsIndex 117 | } 118 | getLen = func() int { 119 | return len(l.ExportOptions) 120 | } 121 | } 122 | oldIndex := getIndex() 123 | newIndex := *oldIndex + mod 124 | if newIndex < 0 { 125 | newIndex = 0 126 | } else if newIndex >= getLen() { 127 | newIndex = getLen() - 1 128 | } 129 | *oldIndex = newIndex 130 | } 131 | 132 | func (l *ListManager) List() ([]ItemRender, int) { 133 | switch l.ListMode { 134 | case LIST_MODE_DICT: 135 | le := len(l.list) 136 | // fix currIndex 137 | if le == 0 { 138 | l.currIndex = 0 139 | } else if l.currIndex > le-1 { 140 | l.currIndex = le - 1 141 | } 142 | if !l.noSort { 143 | list := l.list // avoid data race 144 | sort.Slice(list, func(i, j int) bool { 145 | return list[i].Cmp(list[j]) 146 | }) 147 | // if le != len(l.list) { 148 | // log.Printf("list has changed, old len: %d new len: %d", le, len(l.list)) 149 | // } 150 | l.noSort = true 151 | } 152 | return l.list, l.currIndex 153 | case LIST_MODE_FILE: 154 | return l.files, l.fileIndex 155 | case LIST_MODE_HELP: 156 | return l.Helps(), l.helpIndex 157 | case LIST_MODE_EXPO: 158 | return l.ExportOptions, l.ExportOptionsIndex 159 | default: 160 | return []ItemRender{}, 0 161 | } 162 | } 163 | 164 | func (l *ListManager) Helps() []ItemRender { 165 | list := []ItemRender{ 166 | StringRender("Ctrl+S: 手动同步,如果没有启用自动同步,"), 167 | StringRender(" 可通过此按键手动将变更同步至文件,并部署Rime"), 168 | StringRender("Ctrl+O: 导出码表到当前目录下的output.txt文件中"), 169 | StringRender("Ctrl+Right: 修改权重,将当前项的权重加一"), 170 | StringRender("Ctrl+Left: 修改权重,将当前项的权重减一"), 171 | StringRender("Ctrl+Down: 修改权重,将当前项的权重增加到下一项之前"), 172 | StringRender("Ctrl+Up: 修改权重,将当前项的权重降低到上一项之后"), 173 | StringRender("Enter: 显示菜单"), 174 | StringRender("菜单项: [A添加] 将输入的内容(字词 字母码)添加到码表中,"), 175 | StringRender(" 支持乱序,如(字母码 权重 字词)输入,"), 176 | StringRender(" 上下方向键选择要添加到的文件"), 177 | StringRender("菜单项: [M修改] 修改选择的项(高亮),"), 178 | StringRender(" 回车后,输入框中的内容会被设置,"), 179 | StringRender(" 修改后,再次回车确认修改"), 180 | StringRender("菜单项: [D删除] 将选择的项(高亮)从码表中删除,通过上下键选择"), 181 | } 182 | slices.Reverse(list) 183 | return list 184 | } 185 | 186 | func (l *ListManager) Curr() (ItemRender, error) { 187 | if len(l.list) == 0 { 188 | return nil, errors.New("empty list") 189 | } else { 190 | return l.list[l.currIndex], nil 191 | } 192 | } 193 | 194 | func (l *ListManager) NewList(version int) { 195 | l.version = version 196 | l.list = make([]ItemRender, 0) 197 | } 198 | 199 | func (l *ListManager) newSearch(inputs []string) { 200 | // log.Printf("send search key: %v", strings.Join(inputs, "")) 201 | l.SearchChan <- strings.Join(inputs, "") 202 | // log.Printf("send search key finshed") 203 | } 204 | 205 | func (l *ListManager) AppendList(rs []ItemRender, version int) { 206 | if l.version == version { 207 | l.list = append(l.list, rs...) 208 | l.noSort = false 209 | } 210 | } 211 | 212 | func (l *ListManager) SetFiles(files []ItemRender) { 213 | l.files = files 214 | } 215 | 216 | func (l *ListManager) SetIndex(index int) { 217 | if index < 0 { 218 | index = 0 219 | } else if index > len(l.list)-1 { 220 | index = len(l.list) - 1 221 | } 222 | l.currIndex = index 223 | } 224 | 225 | func (l *ListManager) CurrIndex() int { 226 | return l.currIndex 227 | } 228 | 229 | type Model struct { 230 | ListManager *ListManager 231 | menuFetcher func(m *Model) []*Menu 232 | menus []*Menu 233 | MenusShowing bool 234 | eventManager *EventManager 235 | Inputs []string 236 | MenuIndex int 237 | wx int 238 | hx int 239 | InputCursor int 240 | Modifying bool 241 | message string 242 | } 243 | 244 | func (m *Model) CurrItem() (ItemRender, error) { 245 | return m.ListManager.Curr() 246 | } 247 | 248 | func (m *Model) CurrFile() (ItemRender, error) { 249 | files := m.ListManager.files 250 | if len(files) == 0 { 251 | return nil, errors.New("empty file list") 252 | } 253 | if m.ListManager.fileIndex > len(files)-1 { 254 | return nil, errors.New("file index out of range") 255 | } 256 | return files[m.ListManager.fileIndex], nil 257 | } 258 | 259 | func (m *Model) MessageOr(newMessage string) string { 260 | if m.message == "" { 261 | return newMessage 262 | } else { 263 | return m.message 264 | } 265 | } 266 | func (m *Model) ClearMessage() { 267 | m.message = "" 268 | } 269 | 270 | func (m *Model) CurrItemFile() string { 271 | currItem, err := m.CurrItem() 272 | if err != nil { 273 | return "" 274 | } 275 | for _, file := range m.ListManager.files { 276 | if file.Id() == currItem.Id() { 277 | return filepath.Base(file.String()) 278 | } 279 | } 280 | return "" 281 | } 282 | 283 | func (m *Model) FreshList() { 284 | if !m.MenusShowing { 285 | m.ListManager.newSearch(m.Inputs) 286 | } 287 | } 288 | 289 | func (m *Model) ShowMenus() { 290 | m.MenusShowing = true 291 | m.menus = m.menuFetcher(m) 292 | if len(m.menus) > 0 { 293 | if m.MenuIndex >= len(m.menus) { 294 | m.MenuIndex = 0 295 | } 296 | if menu := m.menus[m.MenuIndex]; menu.OnSelected != nil { 297 | menu.OnSelected(m) 298 | } 299 | } 300 | } 301 | 302 | func (m *Model) HideMenus() { 303 | m.MenusShowing = false 304 | m.menus = m.menuFetcher(m) 305 | } 306 | 307 | // var asciiPattern, _ = regexp.Compile("^[a-zA-z\\d]$") 308 | func (m *Model) inputCtl(key string) { 309 | switch strings.ToLower(key) { 310 | case "backspace": 311 | if m.InputCursor > 0 { 312 | m.Inputs = slices.Delete(m.Inputs, m.InputCursor-1, m.InputCursor) 313 | m.InputCursor-- 314 | m.FreshList() 315 | } 316 | case "left": 317 | if m.InputCursor > 0 { 318 | m.InputCursor-- 319 | } 320 | case "right": 321 | if m.InputCursor < len(m.Inputs) { 322 | m.InputCursor++ 323 | } 324 | default: 325 | // 过滤组合键,如shift+j ctrl+left 326 | if strings.Contains(key, "shift+") || strings.Contains(key, "ctrl+") || strings.Contains(key, "alt+") { 327 | return 328 | } 329 | if key == "tab" { 330 | key = "\t" 331 | } 332 | split := strings.Split(key, "") 333 | if m.InputCursor < len(m.Inputs) { 334 | m.Inputs = append(m.Inputs[:m.InputCursor], append(split, m.Inputs[m.InputCursor:]...)...) 335 | } else { 336 | m.Inputs = append(m.Inputs, split...) 337 | } 338 | m.InputCursor += len(split) 339 | m.FreshList() 340 | } 341 | } 342 | 343 | func (m *Model) menuCtl(key string) { 344 | switch key { 345 | case "left": 346 | if m.MenuIndex > 0 { 347 | m.MenuIndex-- 348 | } 349 | case "right": 350 | if m.MenuIndex < len(m.menus)-1 { 351 | m.MenuIndex++ 352 | } 353 | default: 354 | if len(key) == 1 && len(m.menus) > 0 { 355 | num, err := strconv.Atoi(key) 356 | // select menu by numpad 357 | if err == nil && num > 0 && num < 10 { 358 | index := num - 1 359 | if index < len(m.menus) { 360 | m.MenuIndex = index 361 | } 362 | } else { 363 | // select menu by letter 364 | for i, menu := range m.menus { 365 | if strings.ToLower(menu.Name[:1]) == key { 366 | m.MenuIndex = i 367 | break 368 | } 369 | } 370 | } 371 | } 372 | } 373 | if m.menus[m.MenuIndex].OnSelected != nil { 374 | m.menus[m.MenuIndex].OnSelected(m) 375 | } 376 | } 377 | 378 | func (m *Model) Init() tea.Cmd { 379 | m.FreshList() 380 | return nil 381 | } 382 | 383 | func (m *Model) View() string { 384 | var sb strings.Builder 385 | // header 386 | line := strings.Repeat("-", m.wx) 387 | sb.WriteString(line + "\n") 388 | renderCnt := m.hx - 5 // 5 is header lines(1) + footer lines(4) 389 | // body 390 | currIndex := m.ListManager.currIndex 391 | list, currIndex := m.ListManager.List() 392 | 393 | le := len(list) 394 | // body empty lines 395 | if remain := renderCnt - le; remain > 0 { 396 | sb.WriteString(strings.Repeat("\n", remain)) 397 | } 398 | if le < renderCnt { 399 | renderCnt = le 400 | } 401 | if renderCnt > 0 { 402 | top, bot := renderCnt-1, 0 403 | if currIndex > top { 404 | top, bot = currIndex, currIndex-renderCnt+1 405 | } 406 | for i := top; i >= bot; i-- { 407 | line := list[i].String() 408 | line = truncateString(line, m.wx-16) 409 | if i == currIndex { 410 | sb.WriteString(fmt.Sprintf("\x1b[31m>\x1b[0m \x1b[1;4;35m\x1b[47m%3d: %s\x1b[0m\n", i+1, line)) 411 | } else { 412 | sb.WriteString(fmt.Sprintf("> %3d: %s\n", i+1, line)) 413 | } 414 | } 415 | } 416 | // footer: search count and filepath of current, or notifition 417 | sb.WriteString(fmt.Sprintf("Total: %d; %s\n", le, m.MessageOr(m.CurrItemFile()))) 418 | sb.WriteString("Press[Enter:操作][Ctrl+X:清空输入][Ctrl+S:同步][ESC:退出][Ctrl+H:帮助]\n") 419 | if m.Modifying { 420 | sb.WriteString("----修改中 按回车提交修改") 421 | line = line[:max(len(line)-26, 1)] 422 | } 423 | sb.WriteString(line + "\n") 424 | 425 | if len(m.menus) > 0 { 426 | sb.WriteString(": ") 427 | for i, menu := range m.menus { 428 | nameR := []rune(menu.Name) 429 | if i == m.MenuIndex { 430 | sb.WriteString(fmt.Sprintf(" \x1b[5;1;31m[\x1b[0m\x1b[35m%s\x1b[0m%s\x1b[5;1;31m]\x1b[0m ", string(nameR[0]), string(nameR[1:]))) 431 | } else { 432 | sb.WriteString(fmt.Sprintf(" [\x1b[35m%s\x1b[0m%s] ", string(nameR[0]), string(nameR[1:]))) 433 | } 434 | } 435 | } else { 436 | inputCursor := "\x1b[5;1;31m|\x1b[0m" 437 | inp := strings.Join(m.Inputs[:m.InputCursor], "") + inputCursor + strings.Join(m.Inputs[m.InputCursor:], "") 438 | sb.WriteString(fmt.Sprintf(":%s", inp)) 439 | } 440 | s := sb.String() 441 | return s 442 | } 443 | 444 | func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 445 | switch msg := msg.(type) { 446 | case tea.KeyMsg: 447 | key := msg.String() 448 | event := m.eventManager.Find(strings.ToLower(key)) 449 | if event != nil { 450 | return event.Cb(key, m) 451 | } 452 | if m.MenusShowing { 453 | m.menuCtl(key) 454 | } else { // search 455 | m.inputCtl(key) 456 | } 457 | case FreshListMsg: 458 | m.FreshList() 459 | case ExitMenuMsg: 460 | m.ListManager.ListMode = LIST_MODE_DICT 461 | m.HideMenus() 462 | m.FreshList() 463 | case NotifitionMsg: 464 | m.message = string(msg) 465 | m.FreshList() 466 | case tea.WindowSizeMsg: 467 | m.wx = msg.Width 468 | m.hx = msg.Height 469 | } 470 | return m, nil 471 | } 472 | 473 | func (m *Model) AddEvent(events ...*Event) { 474 | m.eventManager.Add(events...) 475 | } 476 | 477 | func NewModel(listManager *ListManager, menuFetcher func(m *Model) []*Menu) *Model { 478 | fd := os.Stderr.Fd() 479 | wx, hx, err := term.GetSize(int(fd)) 480 | if err != nil { 481 | fmt.Printf("Terminal GetSize Error: %v\n", err) 482 | os.Exit(1) 483 | } 484 | model := &Model{ListManager: listManager, wx: wx, hx: hx, menuFetcher: menuFetcher, eventManager: NewEventManager(), message: ""} 485 | return model 486 | } 487 | 488 | func truncateString(s string, wx int) string { 489 | width := 0 490 | end := len(s) 491 | for i, r := range s { 492 | w := runewidth.RuneWidth(r) // 获取字符宽度 493 | if width+w > wx { 494 | end = i 495 | break 496 | } 497 | width += w 498 | } 499 | return s[:end] 500 | } 501 | -------------------------------------------------------------------------------- /util/command.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package util 4 | 5 | import ( 6 | "os" 7 | "os/exec" 8 | ) 9 | 10 | func Run(command string) *exec.Cmd { 11 | shell := os.Getenv("SHELL") 12 | if len(shell) == 0 { 13 | shell = "sh" 14 | } 15 | cmd := exec.Command(shell, "-c", command) 16 | return cmd 17 | } 18 | -------------------------------------------------------------------------------- /util/command_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package util 4 | 5 | import ( 6 | "os" 7 | "os/exec" 8 | ) 9 | 10 | func Run(command string) *exec.Cmd { 11 | shell := os.Getenv("SHELL") 12 | if len(shell) == 0 { 13 | shell = "cmd" 14 | } 15 | return exec.Command(shell, "/C", command) 16 | } 17 | -------------------------------------------------------------------------------- /util/debounce.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | ) 7 | 8 | // Debouncer 防抖结构体 9 | type Debouncer struct { 10 | duration time.Duration 11 | timer *time.Timer 12 | mu sync.Mutex 13 | } 14 | 15 | // New 创建一个新的防抖实例 16 | func NewDebouncer(duration time.Duration) *Debouncer { 17 | return &Debouncer{ 18 | duration: duration, 19 | } 20 | } 21 | 22 | // Debounce 防抖方法 23 | func (d *Debouncer) Do(f func()) { 24 | d.mu.Lock() 25 | defer d.mu.Unlock() 26 | 27 | // 如果已有定时器,先停止 28 | if d.timer != nil { 29 | d.timer.Stop() 30 | } 31 | 32 | // 设置新的定时器 33 | d.timer = time.AfterFunc(d.duration, f) 34 | } 35 | 36 | // 使用示例: 37 | /* 38 | func main() { 39 | debouncer := debounce.New(500 * time.Millisecond) 40 | 41 | for i := 0; i < 10; i++ { 42 | debouncer.Debounce(func() { 43 | fmt.Println("Executed after last call!") 44 | }) 45 | time.Sleep(100 * time.Millisecond) 46 | } 47 | 48 | // 等待足够长时间以确保最后一次执行 49 | time.Sleep(600 * time.Millisecond) 50 | } 51 | */ 52 | -------------------------------------------------------------------------------- /util/id_generator.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "sync" 4 | 5 | type IDGenerator struct { 6 | currentID uint8 7 | mutex sync.Mutex 8 | } 9 | 10 | var ( 11 | IDGen *IDGenerator = &IDGenerator{currentID: 0} 12 | ) 13 | 14 | func (gen *IDGenerator) NextID() uint8 { 15 | gen.mutex.Lock() 16 | defer gen.mutex.Unlock() 17 | 18 | gen.currentID++ 19 | return gen.currentID 20 | } 21 | 22 | func (gen *IDGenerator) Reset() { 23 | gen.mutex.Lock() 24 | defer gen.mutex.Unlock() 25 | 26 | gen.currentID = 0 27 | } 28 | -------------------------------------------------------------------------------- /util/lock.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | // FLock 用于防止函数重复调用的互斥锁工具 8 | type FLock struct { 9 | mu sync.Mutex 10 | inUse bool 11 | } 12 | 13 | // NewFLock 创建一个新的 FunctionLock 实例 14 | func NewFLock() *FLock { 15 | return &FLock{} 16 | } 17 | 18 | // Should 判断函数是否应该继续执行 19 | // 返回 true 表示可以执行,false 表示函数正在执行中 20 | func (fl *FLock) Should() bool { 21 | fl.mu.Lock() 22 | defer fl.mu.Unlock() 23 | 24 | if fl.inUse { 25 | return false 26 | } 27 | 28 | fl.inUse = true 29 | return true 30 | } 31 | 32 | // Done 标记函数执行完成 33 | func (fl *FLock) Done() { 34 | fl.mu.Lock() 35 | defer fl.mu.Unlock() 36 | 37 | fl.inUse = false 38 | } 39 | -------------------------------------------------------------------------------- /util/misc.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | func IsNumber(str string) bool { 4 | for _, r := range str { 5 | if r < '0' || r > '9' { 6 | return false 7 | } 8 | } 9 | return true 10 | } 11 | 12 | func IsAscii(str string) bool { 13 | for _, r := range str { 14 | if r >= 0x80 { 15 | return false 16 | } 17 | } 18 | return true 19 | } 20 | --------------------------------------------------------------------------------