├── .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 | 
8 | ### 修改
9 | 
10 | ### 删词
11 | 
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 |
--------------------------------------------------------------------------------