├── .babelrc ├── .eslintignore ├── .eslintrc ├── .github ├── ISSUE_TEMPLATE │ ├── 1_bug_report.md │ ├── 2_question.md │ ├── 3_feature-request.md │ └── 4_pre-pull-request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .prettierrc ├── LICENSE ├── README-ZH.md ├── README.md ├── assets ├── CHANGELOG.md ├── for-program │ ├── jieba_rs_wasm_bg.wasm │ ├── stop-words-en.txt │ ├── stop-words-zh.txt │ └── tiktoken_bg.wasm └── images │ ├── buymeacoffee.png │ ├── demo-privacy-mode.gif │ ├── in-file-floating-window-en.gif │ └── in-vault-modal-en.gif ├── esbuild.config.mjs ├── jest.config.js ├── manifest.json ├── package.json ├── pnpm-lock.yaml ├── src ├── dev-test.ts ├── globals │ ├── constants.ts │ ├── dev-option.ts │ ├── enums.ts │ ├── plugin-setting.ts │ └── search-types.ts ├── integrations │ ├── languages │ │ └── chinese-patch.ts │ ├── omnisearch.ts │ └── surfing.ts ├── main.ts ├── services │ ├── auxiliary │ │ └── auxiliary-service.ts │ ├── database │ │ └── database.ts │ ├── obsidian │ │ ├── command-registry.ts │ │ ├── plugin-manager.ts │ │ ├── private-api.ts │ │ ├── search-service.ts │ │ ├── setting-manager.ts │ │ ├── transformed-api.ts │ │ ├── translations │ │ │ ├── locale-helper.ts │ │ │ └── locale │ │ │ │ ├── en.ts │ │ │ │ └── zh-cn.ts │ │ ├── user-data │ │ │ ├── data-manager.ts │ │ │ ├── data-provider.ts │ │ │ ├── file-watcher.ts │ │ │ └── recent-file-manager.ts │ │ └── view-registry.ts │ └── search │ │ ├── highlighter.ts │ │ ├── lexical-engine.ts │ │ ├── semantic-engine.ts │ │ ├── tokenizer.ts │ │ └── truncate-option.ts ├── ui │ ├── MountedModal.svelte │ ├── floating-window.ts │ ├── search-modal.ts │ └── view-helper.ts ├── utils │ ├── data-structure.ts │ ├── event-bus.ts │ ├── file-util.ts │ ├── lang-util.ts │ ├── logger.ts │ ├── my-lib.ts │ ├── nlp.ts │ └── web │ │ ├── assets-provider.ts │ │ ├── http-client.ts │ │ └── request-test.ts └── web-workers │ ├── client.ts │ ├── server.ts │ └── worker-types.ts ├── styles.css ├── tests ├── __mocks__ │ └── jieba-wasm-mock.js └── src │ ├── jest-setup.ts │ └── utils │ ├── data-structure.test.ts │ ├── event-bus.test.ts │ ├── file-util.test.ts │ ├── logger.test.ts │ ├── my-lib.test.ts │ └── nlp.test.ts └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": "current" 8 | }, 9 | // 输出成ESM而不是CommonJS, 同时需要在jest.config.js设置 extensionsToTreatAsEsm: ['.ts'] 10 | "modules": false 11 | } 12 | ], 13 | "@babel/preset-typescript", 14 | ], 15 | "plugins": [ 16 | ["@babel/plugin-proposal-decorators", { "legacy": true }] 17 | ] 18 | } -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | main.js 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true, "jest": true }, 5 | "plugins": ["@typescript-eslint"], 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/eslint-recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "parserOptions": { 12 | "sourceType": "module" 13 | }, 14 | "rules": { 15 | "no-unused-vars": "off", 16 | "@typescript-eslint/no-unused-vars": "off", 17 | // "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 18 | "@typescript-eslint/no-explicit-any": "off", 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1_bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG] " 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Describe the bug 11 | A clear and concise description of what the bug is. 12 | 13 | 14 | ## Steps to reproduce 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | ## Expected behavior 21 | A clear and concise description of what you expected to happen. 22 | 23 | 24 | ## Screenshots 25 | If applicable, add screenshots to help explain your problem. 26 | 27 | ## Environment 28 | - OS: 29 | - Obsidian Version: 30 | - Plugin Version: 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2_question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Any question about this plugin 4 | title: "[Question] " 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/3_feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Feture Request] " 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Please describe your idea concisely 11 | 12 | 13 | ## Screenshots 14 | 15 | 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/4_pre-pull-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pre pull request 3 | about: Any improvements, features, or bug fixes you'd like to implement 4 | title: "[Pre pull request] " 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Please ensure you have first submitted an issue using the `Pre pull request` template to confirm that your work is likely to be merged into this repository. 2 | 3 | ## Description 4 | 5 | [Provide detailed information about the changes you have made.] 6 | 7 | ## Related Issue 8 | 9 | [Mention any issues related to this request, for example: 'fixes #'] 10 | 11 | ## Testing Steps 12 | 13 | [Describe how to test your changes.] 14 | 15 | ## Screenshots 16 | 17 | [If applicable, add screenshots to help explain your changes.] 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - uses: pnpm/action-setup@v4 20 | with: 21 | version: 8.12.1 22 | run_install: true 23 | 24 | - name: Use Node.js 25 | uses: actions/setup-node@v4 26 | with: 27 | cache: "pnpm" 28 | node-version: "20.x" 29 | 30 | - name: Install Dependencies 31 | run: pnpm install 32 | 33 | - name: Run Tests 34 | run: pnpm test 35 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Clever Search 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | env: 9 | PLUGIN_NAME: clever-search # Change this to match the id of your plugin. 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - uses: pnpm/action-setup@v4 21 | with: 22 | version: 8.12.1 23 | run_install: true 24 | 25 | - name: Use Node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | cache: 'pnpm' 29 | node-version: "20.x" 30 | 31 | - name: Build 32 | id: build 33 | run: | 34 | pnpm install 35 | pnpm run build 36 | mkdir ${{ env.PLUGIN_NAME }} 37 | cp dist/main.js ./styles.css ./manifest.json dist/cs-search-worker.js ${{ env.PLUGIN_NAME }} 38 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 39 | ls 40 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 41 | 42 | - name: Create Release 43 | id: create_release 44 | uses: actions/create-release@v1 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | VERSION: ${{ github.ref }} 48 | with: 49 | tag_name: ${{ github.ref }} 50 | release_name: ${{ github.ref }} 51 | draft: false 52 | prerelease: false 53 | 54 | - name: Upload zip file 55 | id: upload-zip 56 | uses: actions/upload-release-asset@v1 57 | env: 58 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | with: 60 | upload_url: ${{ steps.create_release.outputs.upload_url }} 61 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 62 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 63 | asset_content_type: application/zip 64 | 65 | - name: Upload main.js 66 | id: upload-main 67 | uses: actions/upload-release-asset@v1 68 | env: 69 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 70 | with: 71 | upload_url: ${{ steps.create_release.outputs.upload_url }} 72 | asset_path: ./dist/main.js 73 | asset_name: main.js 74 | asset_content_type: text/javascript 75 | 76 | - name: Upload manifest.json 77 | id: upload-manifest 78 | uses: actions/upload-release-asset@v1 79 | env: 80 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 81 | with: 82 | upload_url: ${{ steps.create_release.outputs.upload_url }} 83 | asset_path: ./manifest.json 84 | asset_name: manifest.json 85 | asset_content_type: application/json 86 | 87 | - name: Upload styles.css 88 | id: upload-styles 89 | uses: actions/upload-release-asset@v1 90 | env: 91 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 92 | with: 93 | upload_url: ${{ steps.create_release.outputs.upload_url }} 94 | asset_path: ./styles.css 95 | asset_name: styles.css 96 | asset_content_type: text/css 97 | 98 | # this is for test, might be removed in the future 99 | - name: Upload cs-search-worker.js 100 | id: upload-cs-search-worker 101 | uses: actions/upload-release-asset@v1 102 | env: 103 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 104 | with: 105 | upload_url: ${{ steps.create_release.outputs.upload_url }} 106 | asset_path: ./dist/cs-search-worker.js 107 | asset_name: cs-search-worker.js 108 | asset_content_type: text/javascript 109 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | 24 | # Custom 25 | coverage/ 26 | dist/ 27 | 28 | .editorconfig 29 | cs-search-worker.js -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-svelte"], 3 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 4 | } -------------------------------------------------------------------------------- /README-ZH.md: -------------------------------------------------------------------------------- 1 | # Obsidian Clever Search 2 | 3 | > 少按几键,速寻心间所记 4 | 5 | [English Doc](README.md) 6 | 7 | ## 演示 8 | 9 | ### 实时高亮和预览 10 | 11 | ![demo-search-in-file](assets/images/in-file-floating-window-en.gif) 12 | 13 | ![demo-search-in-file](assets/images/in-vault-modal-en.gif) 14 | 15 | ### 隐私模式 16 | 17 | ![demo-privacy-mode](assets/images/demo-privacy-mode.gif) 18 | 19 | ## 功能 20 | 21 | ### 主要功能 22 | 23 | - [x] 在资料库中进行语义搜索 (仅支持 Windows) 24 | - [x] 在资料库中进行模糊搜索 25 | - [x] 在当前笔记中模糊搜索 26 | - [x] 实时高亮和精确跳转到目标位置 27 | - [x] 切换隐私模式(仅编辑模式) 28 | - [ ] 补全提示 29 | - [ ] 持久的搜索历史 30 | 31 | ### 细微调整以提升用户体验 32 | 33 | - [x] 搜索选中文本 34 | - [x] 自动复制选中的结果文本 35 | - [x] 搜索指令(仅支持 in-vault lexical search)

36 | 可以通过在搜索栏中使用命令临时更改搜索选项。这些命令优先于设置选项卡中的设置,必须位于输入的开头或末尾,并且与搜索文本之间用空格分隔。 37 | 38 | 命令不能放置在搜索文本中间;可以任意组合命令,例如 `/ap/nf something /np`。后面的命令会覆盖相同类型的早期命令(例如 `/np` 会覆盖 `/ap`)。 39 | 40 | /ap allow prefix matching 41 | /np no prefix matching 42 | /af allow fuzziness 43 | /nf no fuzziness 44 | 45 | ### 集成其他插件 46 | 47 | - [x] `Style Settings` 48 | - [x] `Omnisearch` 49 |
详情 50 | 新命令:
"Search in file with last Omnisearch query Omnisearch"

51 | 使用场景:
52 | 当你通过 Omnisearch 确认一个全库搜索并且认为当前文件中可能还有更多未被 Omnisearch 列出的匹配文本时,触发这个命令将打开一个文件内搜索模态框,并且用 Omnisearch 的最后一次查询填充搜索栏。

53 | 注意:
这只是为更好的全库搜索提供一个临时解决方案,我将在未来实现功能更完善的全库搜索, 并且不依赖于Omnisearch 54 |
55 | 56 | ## 可用命令 57 | 58 | | 范围 | 名称 | 热键 | 59 | | -------- | -------------------- | ------------------------- | 60 | | 匹配项 | 查看上下文 | `左键点击` | 61 | | 模态框 | 下一项 | `Ctrl-J` | 62 | | 模态框 | 上一项 | `Ctrl-K` | 63 | | 模态框 | 下一子项 (全库搜索) | `Ctrl-N` | 64 | | 模态框 | 上一子项 | `Ctrl-P` | 65 | | 模态框 | 确认项 | `Enter` / `Right Click` / `Double Click` | 66 | | 模态框 | 在后台确认项 | `Ctrl` + `Enter` / `Right Click` / `Double Click` | 67 | | 模态框 | 切换语义 / 词汇搜索 | `Ctrl-S` | 68 | | 模态框 | 插入文件链接 | `Alt-I` | 69 | | Obsidian | 在资料库搜索 (语义) | 未定义 | 70 | | Obsidian | 在资料库搜索 (词汇) | 未定义 | 71 | | Obsidian | 在文件中搜索 | 未定义 | 72 | | Obsidian | 在文件中搜索,并使用Omnisearch的上次搜索文本 (纪念对本项目的启发) | 未定义 | 73 | | Obsidian | 切换隐私模式 | 未定义 | 74 | 75 | ## 局限 76 | 77 | 如果一个文件字数超过50万,文件内搜索可能比较慢,不过全库搜索没有这样的性能局限 78 | 79 | ## 安装 80 | 81 | - 通过 [BRAT](https://github.com/TfTHacker/obsidian42-brat) 安装,并且开启`自动更新`选项获取本插件最新的功能 82 | - 手动安装: 83 | 1. 从[最新发布版](https://github.com/yan42685/obsidian-clever-search/releases)下载最新的 `main.js`, `style.css`和 `manifest.json` 84 | 2. 在你的资料库位置的 `.obsidian/plugins` 中创建一个名为`clever-search`的文件夹 85 | 3. 将上述文件移动到你创建的文件夹中 86 | 4. 在 `设置 - 社区插件 - 已安装的插件` 中点击 `重新加载插件` 并启用 `Clever Search` 87 | 88 | ## [常见疑问](https://github.com/yan42685/obsidian-clever-search/wiki/Home-%E2%80%90-zh#%E5%B8%B8%E8%A7%81%E7%96%91%E9%97%AE) 89 | 90 | ## 声明 91 | 92 | 根据 Obsidian 开发者政策的要求,在此声明本插件从网络下载了必要的程序资源至 `userData` 目录。您可以在插件设置中查看这些资源的具体存放路径。 93 | 94 | ## 支持此项目 95 | 96 | 如果这个插件对你有用,希望能点个 star⭐,或者更进一步支持... 97 | 98 | > 感谢 @Moyf 对本项目的打赏支持 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian Clever Search 2 | 3 | > Enjoy swift access to your notes with minimal key presses 4 | 5 | [中文文档](README-ZH.md) | [English Doc](README.md) 6 | 7 | ## Demo 8 | 9 | ### Realtime highlight and preview 10 | 11 | ![demo-search-in-file](assets/images/in-file-floating-window-en.gif) 12 | 13 | ![demo-search-in-file](assets/images/in-vault-modal-en.gif) 14 | 15 | ### Privacy Mode 16 | 17 | ![demo-privacy-mode](assets/images/demo-privacy-mode.gif) 18 | 19 | ## Features 20 | 21 | ### Major 22 | 23 | - [x] Semantic search in the vault (Windows only) 24 | - [x] Fuzzy search in the vault 25 | - [x] Fuzzy search inside current note 26 | - [x] Realtime highlighting and precise jump to the target location 27 | - [x] Toggle privacy mode (Edit mode only) 28 | - [ ] AutoCompletion 29 | - [ ] Persistent search history 30 | 31 | ### Subtle Tweaks for Better UX 32 | 33 | - [x] Search from selection 34 | - [x] Automatically copy result text on selection 35 | - [x] Search commands (In-vault lexical search only)

36 | You can temporarily change search options using commands in the search bar. These commands take priority over settings in the settings tab and must be at the beginning or end of the input, separated from the search text by a space. 37 | 38 | Commands cannot be placed in the middle of the search text. You can combine commands, e.g., `/ap/nf something /np`. Later commands override earlier ones of the same type (e.g., `/np` overrides `/ap`). 39 | 40 | /ap allow prefix matching 41 | /np no prefix matching 42 | /af allow fuzziness 43 | /nf no fuzziness 44 | 45 | 46 | ### Integrate with other plugins 47 | 48 | - [x] `Style Settings` 49 | - [x] `Omnisearch` 50 |
Details 51 | New command:
"Search in file with last Omnisearch query"

52 | Use case:
53 | When you confirm an in-vault search by Omnisearch and think there might be more matched text that are not listed by Omnisearch in current file, trigger this command will open a in-file search modal and fill the search bar with last query in Omnisearch.

54 | Note:
This is just a temporary workaround for a better in-vault search. I will implement full-featured in-vault search without dependency on Omnisearch in the future. 55 |
56 | 57 | ## Available Commands 58 | 59 | | Scope | Name | Hotkey | 60 | | -------- | ------------------------------------------------------------------ | ------------------------ | 61 | | Item | View item context | `Left Click` | 62 | | Modal | Next item | `Ctrl-J` | 63 | | Modal | Previous item | `Ctrl-K` | 64 | | Modal | Next subItem (in-vault) | `Ctrl-N` | 65 | | Modal | Previous subItem | `Ctrl-P` | 66 | | Modal | Confirm item | `Enter` / `Right Click` / `Double Click` | 67 | | Modal | Confirm item in the background | `Ctrl` + `Enter` / `Right Click` / `Double Click` | 68 | | Modal | Toggle lexical / semantic search | `Ctrl-S` | 69 | | Modal | Insert file link | `Alt-I` | 70 | | Obsidian | Search in vault semantically | undefined | 71 | | Obsidian | Search in vault lexically | undefined | 72 | | Obsidian | Search in file | undefined | 73 | | Obsidian | Search in file with last Omnisearch query (preserved as a tribute) | undefined | 74 | | Obsidian | Toggle privacy mode | undefined | 75 | 76 | ## Limitations 77 | 78 | In-file Search performance may be slower when a file contains over 500k characters. However, there is no such performance limitation for in-vault search. 79 | 80 | ## Installation 81 | 82 | - Install through [BRAT](https://github.com/TfTHacker/obsidian42-brat) and turn on `Auto-update plugins at startup` option to automatically install the latest version when available. 83 | - (Manual) installation: 84 | 1. Download the latest `main.js`, `style.css` and `manifest.json` from the [latest release](https://github.com/yan42685/obsidian-clever-search/releases) 85 | 2. Create a folder named `clever-search` in `.obsidian/plugins` at your vault location 86 | 3. Move above files into the folder you created 87 | 4. click `reload plugins` at `Settings - Community plugins - installed plugins` and enable `Clever Search` 88 | 89 | ## [FAQ](https://github.com/yan42685/obsidian-clever-search/wiki/Home-%E2%80%90-en#FAQ) 90 | 91 | ## Declaration 92 | 93 | In compliance with the requirements of the Obsidian developer policy, this notice declares that this plugin downloads necessary program resources from the web into the `userData` directory. The specific location of these resources can be found in the plugin settings. 94 | 95 | ## Support 96 | 97 | If this plugin has been useful to you, I'd be sincerely thankful for your star⭐ or donation❤️. 98 | 99 | [![image](assets/images/buymeacoffee.png)](https://www.buymeacoffee.com/AlexClifton) 100 | 101 | > Special thanks to @Moyf for the generous contribution to this project. 102 | 103 | > Thanks again, @Moyf, for your second donation! (Received: August 28, 2025) 104 | -------------------------------------------------------------------------------- /assets/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 0.1.6 4 | 5 | - Auto-switch to editing mode to scroll into correct view 6 | 7 | ## 0.1.5 8 | 9 | ### New 10 | 11 | - Auto-copy on selection 12 | 13 | ### Fixed 14 | 15 | - `Enter` won't insert a new line character in the editor 16 | 17 | ## 0.1.4 18 | 19 | ### New 20 | 21 | - `Floating window for in-file search` option 22 | - Copyable result text 23 | 24 | ### Fixed 25 | 26 | - Sometimes it fails to scroll to the target location when opening a large file 27 | - Can't jump if no content matched, though the filenames or folders are matched 28 | 29 | ### BREAKING 30 | 31 | - Removed feature: keep focusing input bar 32 | - New `style settings` option: Main Background Color. Your current modal background color may be changed due to the default value for the new option 33 | 34 | ## 0.1.3 35 | 36 | ### New 37 | 38 | - Support arbitrary extensions for plaintext files 39 | - Path blacklist 40 | 41 | ### Improved 42 | 43 | - Loading index around 3x faster 44 | 45 | ## 0.1.2 46 | 47 | ### New 48 | 49 | - Automatically reindex the vault when the database have been updated 50 | 51 | 52 | ## 0.1.1 53 | 54 | ### New 55 | 56 | - Support for txt, html (rely on `Surfing` plugin) 57 | - Customizable filetypes to be indexed 58 | 59 | ### Fixed 60 | 61 | - Performance issue related with dexie 62 | - Conflicts with Excalidraw 63 | - Excessively frequent index updates 64 | 65 | 66 | ## 0.1.0 67 | 68 | ### New 69 | 70 | - Lexical search in vault (fuzzy and prefix match) 71 | - Language patch for Chinese 72 | - Automatically move the scrollbar when navigating items by hotkey 73 | - customizable stop words list 74 | - Share the necessary program assets across vaults to avoid repeated downloads for each vault 75 | 76 | ### Improved 77 | 78 | - Use debounced search and cached result to decrease the latency of input 79 | - Better in-file search performance 80 | - Better preview experience: most of the blank lines located at boundary won't appear anymore 81 | - Lots of performance optimization 82 | - Adjust the modal styles 83 | 84 | 85 | ### Thanks 86 | 87 | Thanks a lot to @scambier's Omnisearch, whose code was served as a reference -------------------------------------------------------------------------------- /assets/for-program/jieba_rs_wasm_bg.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yan42685/obsidian-clever-search/aea163fb31dc60dfeed25e75b9d54adddb6ad5e0/assets/for-program/jieba_rs_wasm_bg.wasm -------------------------------------------------------------------------------- /assets/for-program/stop-words-en.txt: -------------------------------------------------------------------------------- 1 | a 2 | about 3 | above 4 | across 5 | after 6 | again 7 | against 8 | all 9 | almost 10 | alone 11 | along 12 | already 13 | also 14 | although 15 | always 16 | am 17 | among 18 | an 19 | and 20 | another 21 | any 22 | anybody 23 | anyhow 24 | anyone 25 | anything 26 | anyway 27 | anywhere 28 | are 29 | around 30 | as 31 | at 32 | be 33 | became 34 | because 35 | become 36 | becomes 37 | been 38 | before 39 | behind 40 | being 41 | below 42 | beside 43 | between 44 | beyond 45 | both 46 | but 47 | by 48 | can 49 | cannot 50 | could 51 | did 52 | do 53 | does 54 | doing 55 | done 56 | down 57 | during 58 | each 59 | either 60 | enough 61 | etc 62 | ever 63 | every 64 | for 65 | from 66 | further 67 | get 68 | got 69 | had 70 | has 71 | have 72 | having 73 | he 74 | her 75 | here 76 | hers 77 | him 78 | his 79 | how 80 | however 81 | i 82 | if 83 | in 84 | into 85 | is 86 | it 87 | its 88 | just 89 | may 90 | me 91 | might 92 | my 93 | nor 94 | not 95 | of 96 | off 97 | on 98 | once 99 | one 100 | only 101 | or 102 | other 103 | our 104 | out 105 | over 106 | own 107 | same 108 | she 109 | should 110 | since 111 | so 112 | some 113 | such 114 | than 115 | that 116 | the 117 | their 118 | them 119 | then 120 | there 121 | these 122 | they 123 | this 124 | those 125 | through 126 | to 127 | too 128 | under 129 | until 130 | up 131 | upon 132 | us 133 | very 134 | was 135 | we 136 | were 137 | what 138 | when 139 | where 140 | which 141 | while 142 | who 143 | whom 144 | why 145 | will 146 | with 147 | would 148 | you 149 | your -------------------------------------------------------------------------------- /assets/for-program/stop-words-zh.txt: -------------------------------------------------------------------------------- 1 | ! 2 | " 3 | # 4 | $ 5 | % 6 | & 7 | ' 8 | ( 9 | ) 10 | * 11 | + 12 | , 13 | - 14 | -- 15 | . 16 | .. 17 | ... 18 | ./ 19 | / 20 | // 21 | 0 22 | 1 23 | 2 24 | 3 25 | 4 26 | 5 27 | 6 28 | 7 29 | 8 30 | 9 31 | : 32 | :// 33 | :: 34 | ; 35 | < 36 | = 37 | > 38 | >> 39 | ? 40 | @ 41 | A 42 | [ 43 | \ 44 | ] 45 | ^ 46 | _ 47 | ` 48 | | 49 | } 50 | ~ 51 | · 52 | × 53 | Δ 54 | Ψ 55 | γ 56 | μ 57 | φ 58 | φ. 59 | В 60 | — 61 | —— 62 | ——— 63 | ‘ 64 | ’ 65 | ’‘ 66 | “ 67 | ” 68 | ”, 69 | … 70 | …… 71 | ′∈ 72 | ′| 73 | ℃ 74 | Ⅲ 75 | ↑ 76 | → 77 | ∈[ 78 | ∪φ∈ 79 | ≈ 80 | ① 81 | ② 82 | ②c 83 | ③ 84 | ③] 85 | ④ 86 | ⑤ 87 | ⑥ 88 | ⑦ 89 | ⑧ 90 | ⑨ 91 | ⑩ 92 | ── 93 | ■ 94 | ▲ 95 |   96 | 、 97 | 。 98 | 〈 99 | 〉 100 | 《 101 | 》 102 | 》 103 | ) 104 | , 105 | 」 106 | 『 107 | 』 108 | 【 109 | 】 110 | 〔 111 | 〕 112 | 〕〔 113 | 一 114 | 一. 115 | 一一 116 | 的 117 | 了 118 | 在 119 | 是 120 | 有 121 | 和 122 | 与 123 | 也 124 | 不 125 | 为 126 | 由 127 | 这 128 | 那 129 | 于 130 | 上 131 | 下 132 | 以 133 | 因 134 | 就 135 | 着 136 | 吗 137 | 吧 138 | 啊 139 | 哈 140 | 呢 141 | 嘛 142 | 吗 143 | 而 144 | 与 145 | 或 146 | 但 147 | 却 148 | 而 149 | 若 150 | 即 151 | 即使 152 | 尽管 153 | 虽然 154 | 虽 155 | 而已 156 | 因为 157 | 因此 158 | 所以 159 | 以至 160 | 以致 161 | 由于 162 | 由此 163 | 除了 164 | 除非 165 | 如果 166 | 只要 167 | 即便 168 | 无论 169 | 既然 170 | 尽管 171 | 只有 172 | 要是 173 | 无非 174 | 不仅 175 | 以便 176 | 以免 177 | 即或 178 | 只怕 179 | 哪怕 180 | 何况 181 | 何必 182 | 何时 183 | 无论 184 | 无怪 185 | 无宁 186 | 何止 187 | 何尝 188 | 何须 189 | 总之 190 | 反正 191 | 反过来 192 | 反之 193 | 不然 194 | 不如 195 | 不足 196 | 不及 197 | 不只 198 | 不光 199 | 不单 200 | 不但 201 | 不至于 202 | 与其 203 | 宁肯 204 | 宁愿 205 | 宁可 206 | 宁为 207 | 甚至 208 | 甚至于 209 | 甚而 210 | 甚或 211 | 甚且 212 | 甚么 213 | 甚至 214 | 甚为 215 | 非但 216 | 不独 217 | 不只是 218 | 乃至 219 | 乃至于 220 | 既是 221 | 既然 222 | 既如此 223 | ︿ 224 | ! 225 | # 226 | $ 227 | % 228 | & 229 | ' 230 | ( 231 | ) 232 | )÷(1- 233 | )、 234 | * 235 | + 236 | +ξ 237 | ++ 238 | , 239 | ,也 240 | - 241 | -β 242 | -- 243 | -[*]- 244 | . 245 | / 246 | 0 247 | 1 248 | 1 249 | 2 250 | 3 251 | 4 252 | 5 253 | 6 254 | 7 255 | 8 256 | 9 257 | : 258 | ; 259 | < 260 | = 261 | > 262 | >λ 263 | ? 264 | @ 265 | A 266 | [ 267 | [] 268 | ] 269 | _ 270 | { 271 | { 272 | | 273 | } 274 | } 275 | > 276 | ~ 277 | ¥ 278 | -------------------------------------------------------------------------------- /assets/for-program/tiktoken_bg.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yan42685/obsidian-clever-search/aea163fb31dc60dfeed25e75b9d54adddb6ad5e0/assets/for-program/tiktoken_bg.wasm -------------------------------------------------------------------------------- /assets/images/buymeacoffee.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yan42685/obsidian-clever-search/aea163fb31dc60dfeed25e75b9d54adddb6ad5e0/assets/images/buymeacoffee.png -------------------------------------------------------------------------------- /assets/images/demo-privacy-mode.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yan42685/obsidian-clever-search/aea163fb31dc60dfeed25e75b9d54adddb6ad5e0/assets/images/demo-privacy-mode.gif -------------------------------------------------------------------------------- /assets/images/in-file-floating-window-en.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yan42685/obsidian-clever-search/aea163fb31dc60dfeed25e75b9d54adddb6ad5e0/assets/images/in-file-floating-window-en.gif -------------------------------------------------------------------------------- /assets/images/in-vault-modal-en.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yan42685/obsidian-clever-search/aea163fb31dc60dfeed25e75b9d54adddb6ad5e0/assets/images/in-vault-modal-en.gif -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import builtins from "builtin-modules"; 2 | import esbuild from "esbuild"; 3 | import esbuildSvelte from "esbuild-svelte"; 4 | import * as fsUtil from "fs"; 5 | import * as pathUtil from "path"; 6 | import process from "process"; 7 | import sveltePreprocess from "svelte-preprocess"; 8 | 9 | const banner = `/* 10 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 11 | if you want to view the source, please visit the github repository of this plugin 12 | */ 13 | `; 14 | const prod = process.argv[2] === "production"; 15 | 16 | // function debounce(delay, func) { 17 | // let timeoutId; 18 | 19 | // return (...args) => { 20 | // clearTimeout(timeoutId); 21 | // timeoutId = setTimeout(() => func(...args), delay); 22 | // }; 23 | // } 24 | 25 | // // 这里会多次使用esbuild进行编译,防止多次同时复制 26 | // let hasCopied = false; 27 | // function copyFile(src, dest) { 28 | // if (hasCopied) { 29 | // hasCopied = false; 30 | // // 跳过偶数次复制 31 | // return; 32 | // } 33 | // hasCopied = true; 34 | // fsUtil.copyFile(src, dest, (err) => { 35 | // const formattedTime = new Date().toLocaleTimeString("en-US", { 36 | // hour12: false, 37 | // hour: "2-digit", 38 | // minute: "2-digit", 39 | // second: "2-digit", 40 | // }); 41 | // if (err) { 42 | // console.error(`Error copying ${src}:`, err, ` - ${formattedTime}`); 43 | // } else { 44 | // console.log(`Copied ${src} to ${dest}`, ` - ${formattedTime}`); 45 | // } 46 | // }); 47 | // } 48 | 49 | // const copyFileDebounced = debounce(1000, copyFile); 50 | 51 | // const filesToCopy = ["./assets/styles.css", "./assets/manifest.json"]; 52 | // // 监听特定文件的变化 53 | // filesToCopy.forEach((file) => { 54 | // fsUtil.watch(file, (eventType, filename) => { 55 | // if (eventType === "change") { 56 | // copyFileDebounced(file, `./${filename}`); 57 | // } 58 | // }); 59 | // }); 60 | 61 | // // for the first time `pnpm dev` 62 | // if (!prod) { 63 | // filesToCopy.forEach((file) => { 64 | // const destination = `./${pathUtil.basename(file)}`; 65 | // copyFile(file, destination); 66 | // // 避免被这个变量影响,导致偶数文件无法复制 67 | // hasCopied = false; 68 | // }); 69 | // } 70 | 71 | async function printFilesSize(directory) { 72 | console.log(`Printing file sizes in directory: ${directory}`); 73 | try { 74 | const files = await fsUtil.promises.readdir(directory); 75 | for (const file of files) { 76 | const filePath = pathUtil.join(directory, file); 77 | const stats = await fsUtil.promises.stat(filePath); 78 | console.log(`${file}: ${(stats.size / 1024).toFixed(2)} KB`); 79 | } 80 | } catch (err) { 81 | console.error(err); 82 | } 83 | } 84 | 85 | 86 | const esbuildConfig = (outdir) => ({ 87 | banner: { 88 | js: banner, 89 | }, 90 | entryPoints: { 91 | main: "src/main.ts", 92 | "cs-search-worker": "src/web-workers/server.ts", 93 | }, 94 | bundle: true, 95 | minify: prod ? true : false, 96 | external: [ 97 | "obsidian", 98 | "electron", 99 | "@codemirror/autocomplete", 100 | "@codemirror/collab", 101 | "@codemirror/commands", 102 | "@codemirror/language", 103 | "@codemirror/lint", 104 | "@codemirror/search", 105 | "@codemirror/state", 106 | "@codemirror/view", 107 | "@lezer/common", 108 | "@lezer/highlight", 109 | "@lezer/lr", 110 | ...builtins, 111 | // 不打包测试文件夹 112 | "tests/*", 113 | ], 114 | format: "cjs", 115 | platform: "node", 116 | target: "es2018", 117 | logLevel: "info", 118 | sourcemap: prod ? false : "inline", 119 | treeShaking: true, 120 | outdir: outdir, 121 | define: { 122 | // need nested quotation mark 123 | // "process.env.NODE_ENV": prod ? '"production"' : '"development"', 124 | "process.env.NODE_ENV": `'${process.argv[2]}'`, 125 | }, 126 | 127 | plugins: [ 128 | esbuildSvelte({ 129 | compilerOptions: { css: true }, 130 | preprocess: sveltePreprocess(), 131 | }), 132 | ], 133 | }); 134 | 135 | const DIST_PATH = "dist"; 136 | const devContext = await esbuild.context(esbuildConfig("./")); 137 | const releaseContext = await esbuild.context(esbuildConfig(DIST_PATH)); 138 | 139 | if (prod) { 140 | await releaseContext.rebuild(); 141 | await printFilesSize(DIST_PATH); 142 | process.exit(0); 143 | } else { 144 | await devContext.watch(); 145 | } 146 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | module.exports = { 7 | // All imported modules in your tests should be mocked automatically 8 | // automock: false, 9 | 10 | // Stop running tests after `n` failures 11 | // bail: 0, 12 | 13 | // The directory where Jest should store its cached dependency information 14 | // cacheDirectory: "C:\\Users\\cambi\\AppData\\Local\\Temp\\jest", 15 | 16 | // Automatically clear mock calls, instances and results before every test 17 | clearMocks: true, 18 | 19 | // Indicates whether the coverage information should be collected while executing the test 20 | collectCoverage: true, 21 | 22 | // An array of glob patterns indicating a set of files for which coverage information should be collected 23 | // collectCoverageFrom: undefined, 24 | 25 | // The directory where Jest should output its coverage files 26 | coverageDirectory: 'coverage', 27 | 28 | // An array of regexp pattern strings used to skip coverage collection 29 | // coveragePathIgnorePatterns: [ 30 | // "\\\\node_modules\\\\" 31 | // ], 32 | 33 | // Indicates which provider should be used to instrument code for coverage 34 | coverageProvider: 'v8', 35 | 36 | // A list of reporter names that Jest uses when writing coverage reports 37 | // coverageReporters: [ 38 | // "json", 39 | // "text", 40 | // "lcov", 41 | // "clover" 42 | // ], 43 | 44 | // An object that configures minimum threshold enforcement for coverage results 45 | // coverageThreshold: undefined, 46 | 47 | // A path to a custom dependency extractor 48 | // dependencyExtractor: undefined, 49 | 50 | // Make calling deprecated APIs throw helpful error messages 51 | // errorOnDeprecated: false, 52 | 53 | // Force coverage collection from ignored files using an array of glob patterns 54 | // forceCoverageMatch: [], 55 | 56 | // A path to a module which exports an async function that is triggered once before all test suites 57 | // globalSetup: undefined, 58 | 59 | // A path to a module which exports an async function that is triggered once after all test suites 60 | // globalTeardown: undefined, 61 | 62 | // A set of global variables that need to be available in all test environments 63 | // globals: {}, 64 | 65 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 66 | // maxWorkers: "50%", 67 | 68 | // An array of directory names to be searched recursively up from the requiring module's location 69 | // 为了路径简写,可以让jest看懂 "utils" => "../../src/utils 70 | moduleDirectories: [ 71 | // see https://stackoverflow.com/questions/50171412/absolute-paths-baseurl-gives-error-cannot-find-module 72 | "node_modules", "" 73 | ], 74 | 75 | // An array of file extensions your modules use 76 | moduleFileExtensions: [ 77 | 'ts', 78 | // 'svelte', 79 | 'js', 80 | // "jsx", 81 | // "tsx", 82 | // "json", 83 | // "node" 84 | ], 85 | 86 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 87 | moduleNameMapper: { 88 | "jieba-wasm/pkg/web/jieba_rs_wasm": "/tests/__mocks__/jieba-wasm-mock.js", 89 | }, 90 | 91 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 92 | modulePathIgnorePatterns: [], 93 | // 将.ts文件也看作ES Module,这样在babel编译到ESM的时候能统一调用 94 | extensionsToTreatAsEsm: ['.ts'], 95 | 96 | // Activates notifications for test results 97 | // notify: false, 98 | 99 | // An enum that specifies notification mode. Requires { notify: true } 100 | // notifyMode: "failure-change", 101 | 102 | // A preset that is used as a base for Jest's configuration 103 | // preset: undefined, 104 | 105 | // Run tests from one or more projects 106 | // projects: undefined, 107 | 108 | // Use this configuration option to add custom reporters to Jest 109 | // reporters: undefined, 110 | 111 | // Automatically reset mock state before every test 112 | // resetMocks: false, 113 | 114 | // Reset the module registry before running each individual test 115 | // resetModules: false, 116 | 117 | // A path to a custom resolver 118 | // resolver: undefined, 119 | 120 | // Automatically restore mock state and implementation before every test 121 | // restoreMocks: false, 122 | 123 | // The root directory that Jest should scan for tests and modules within 124 | // rootDir: undefined, 125 | 126 | // A list of paths to directories that Jest should use to search for files in 127 | // roots: [ 128 | // "" 129 | // ], 130 | 131 | // Allows you to use a custom runner instead of Jest's default test runner 132 | // runner: "jest-runner", 133 | 134 | // The paths to modules that run some code to configure or set up the testing environment before each test 135 | // setupFiles: [], 136 | 137 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 138 | setupFilesAfterEnv: ["/tests/src/jest-setup.ts"], 139 | 140 | // The number of seconds after which a test is considered as slow and reported as such in the results. 141 | // slowTestThreshold: 5, 142 | 143 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 144 | // snapshotSerializers: [], 145 | 146 | // The test environment that will be used for testing 147 | testEnvironment: 'jsdom', 148 | // testEnvironment: 'node', 149 | 150 | // Options that will be passed to the testEnvironment 151 | // testEnvironmentOptions: {}, 152 | 153 | // Adds a location field to test results 154 | // testLocationInResults: false, 155 | 156 | // The glob patterns Jest uses to detect test files 157 | // testMatch: [ 158 | // "**/__tests__/**/*.[jt]s?(x)", 159 | // "**/?(*.)+(spec|test).[tj]s?(x)" 160 | // ], 161 | 162 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 163 | // testPathIgnorePatterns: [ 164 | // "\\\\node_modules\\\\" 165 | // ], 166 | 167 | // The regexp pattern or array of patterns that Jest uses to detect test files 168 | // testRegex: [], 169 | 170 | // This option allows the use of a custom results processor 171 | // testResultsProcessor: undefined, 172 | 173 | // This option allows use of a custom test runner 174 | // testRunner: "jest-circus/runner", 175 | 176 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 177 | // testURL: "http://localhost", 178 | 179 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 180 | // timers: "real", 181 | 182 | // A map from regular expressions to paths to transformers 183 | transform: { 184 | '^.+\\.(js|ts)$': 'babel-jest', 185 | // '^.+\\.svelte$': 'svelte-jester', 186 | }, 187 | 188 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 189 | // transformIgnorePatterns: [ 190 | // "\\\\node_modules\\\\", 191 | // "\\.pnp\\.[^\\\\]+$" 192 | // ], 193 | 194 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 195 | // unmockedModulePathPatterns: undefined, 196 | 197 | // Indicates whether each individual test should be reported during the run 198 | // verbose: undefined, 199 | 200 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 201 | // watchPathIgnorePatterns: [], 202 | 203 | // Whether to use watchman for file crawling 204 | // watchman: true, 205 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "clever-search", 3 | "name": "Clever Search", 4 | "author": "Alex Clifton", 5 | "description": "Helping you quickly locate the notes in your mind in the easiest way, without the need for complex search syntax to find relevant content.", 6 | "version": "0.2.19", 7 | "minAppVersion": "0.15.0", 8 | "fundingUrl": "https://www.buymeacoffee.com/alexclifton", 9 | "isDesktopOnly": true 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-clever-search", 3 | "version": "0.1.0", 4 | "description": "An obsidian search plugin that tunes your knowledge base to the rhythm of your fingertips.", 5 | "main": "main.js", 6 | "scripts": { 7 | "preinstall": "npx only-allow pnpm", 8 | "dev": "node esbuild.config.mjs development", 9 | "test": "node --experimental-vm-modules --no-warnings=ExperimentalWarning node_modules/jest/bin/jest.js", 10 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production" 11 | }, 12 | "keywords": [], 13 | "author": "alexclifton", 14 | "license": "GPL-3", 15 | "devDependencies": { 16 | "@babel/core": "^7.23.6", 17 | "@babel/plugin-proposal-decorators": "^7.23.6", 18 | "@babel/preset-env": "^7.23.6", 19 | "@babel/preset-typescript": "^7.23.3", 20 | "@codemirror/state": "^6.3.3", 21 | "@codemirror/view": "^6.22.3", 22 | "@jest/globals": "^29.7.0", 23 | "@tsconfig/svelte": "^3.0.0", 24 | "@types/dompurify": "^3.0.5", 25 | "@types/jest": "^29.5.11", 26 | "@types/node": "^16.11.6", 27 | "@types/throttle-debounce": "^5.0.2", 28 | "@typescript-eslint/eslint-plugin": "5.29.0", 29 | "@typescript-eslint/parser": "5.29.0", 30 | "babel-jest": "^29.7.0", 31 | "builtin-modules": "3.3.0", 32 | "esbuild": "0.17.3", 33 | "esbuild-svelte": "^0.7.4", 34 | "jest": "^29.7.0", 35 | "jest-environment-jsdom": "^29.7.0", 36 | "obsidian": "latest", 37 | "prettier": "^3.1.0", 38 | "prettier-plugin-svelte": "^3.1.2", 39 | "svelte": "^3.59.2", 40 | "svelte-preprocess": "^4.10.7", 41 | "tslib": "^2.4.0", 42 | "typescript": "^4.9.4" 43 | }, 44 | "dependencies": { 45 | "dexie": "^3.2.4", 46 | "dompurify": "^3.1.7", 47 | "franc-min": "^6.1.0", 48 | "fzf": "^0.5.2", 49 | "jieba-wasm": "^0.0.2", 50 | "minisearch": "^6.3.0", 51 | "reflect-metadata": "^0.1.13", 52 | "throttle-debounce": "^5.0.0", 53 | "tsyringe": "^4.8.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/dev-test.ts: -------------------------------------------------------------------------------- 1 | import { App, TFile, Vault, htmlToMarkdown } from "obsidian"; 2 | // import { encoding_for_model } from "tiktoken" 3 | import { OuterSetting } from "./globals/plugin-setting"; 4 | import { ChinesePatch } from "./integrations/languages/chinese-patch"; 5 | import { RenderMarkdownModal } from "./main"; 6 | import { SearchService } from "./services/obsidian/search-service"; 7 | import { DataProvider } from "./services/obsidian/user-data/data-provider"; 8 | import { SemanticEngine } from "./services/search/semantic-engine"; 9 | import { Tokenizer } from "./services/search/tokenizer"; 10 | import { logger } from "./utils/logger"; 11 | import { getInstance, monitorExecution } from "./utils/my-lib"; 12 | import { AssetsProvider } from "./utils/web/assets-provider"; 13 | 14 | function getApp() { 15 | return getInstance(App); 16 | } 17 | 18 | function testStemmer() { 19 | const words = ["gifs;d", "gifs", "哈哈", "很多只猫", "analyzers"]; 20 | } 21 | 22 | function testTsyringe() { 23 | const obj1 = getInstance(SearchService); 24 | const obj2 = getInstance(SearchService); 25 | // in tsyringe, the default scope for class is transient, so it should output "true" 26 | logger.info(`test equal: ${obj1 === obj2}`); 27 | } 28 | 29 | function testUnsupportedExtensions() { 30 | const vault = getApp().vault as any; 31 | logger.info(vault.getConfig("showUnsupportedFiles")); 32 | } 33 | 34 | // async function testLexicalSearch() { 35 | // const lexicalEngine = getInstance(LexicalEngine); 36 | // // await lexicalEngine.initAsync(); 37 | // // const query = "camera communiy"; 38 | // const query = "whoknowthisfolder/whereisit"; 39 | // const resultsOr = await lexicalEngine.searchFiles(query, "or"); 40 | // const resultsAnd = await lexicalEngine.searchFiles(query, "and"); 41 | 42 | // logger.debug(resultsOr); 43 | // // logger.debug(resultsAnd); 44 | // const vault = getApp().vault; 45 | // const tFile = vault.getAbstractFileByPath(resultsOr[0]?.path); 46 | // if (tFile) { 47 | // if (tFile instanceof TFile) { 48 | // // const content = await vault.cachedRead(tFile); 49 | // logger.info(`find first one: ${tFile.path}`); 50 | // } else { 51 | // logger.info(`it's a folder: ${tFile}`); 52 | // } 53 | // } else { 54 | // logger.info(`no document is found`); 55 | // } 56 | // } 57 | 58 | // async function testTikToken() { 59 | // // 获取 GPT-3.5 的 tokenizer 60 | // const enc = encoding_for_model("gpt-3.5-turbo"); 61 | 62 | // // 对字符串进行 tokenize、encoding 和 decoding 63 | // const inputString = "hello world"; 64 | // const encoded = enc.encode(inputString); 65 | // const decoded = new TextDecoder().decode(enc.decode(encoded)); 66 | 67 | // console.log("Original String:", inputString); 68 | // console.log("Encoded Tokens:", encoded); 69 | // console.log("Decoded String:", decoded); 70 | 71 | // // 验证编码后再解码的字符串是否与原始字符串相同 72 | 73 | // // 释放 encoder 资源 74 | // enc.free(); 75 | // } 76 | 77 | async function testTokenizer() { 78 | // getInstance(SearchClient).testTickToken() 79 | // getInstance(AssetsProvider).startDownload(); 80 | const cutter = getInstance(ChinesePatch); 81 | const tokenizer = getInstance(Tokenizer); 82 | // const text= "今天天气气候不错啊"; 83 | // const text= "陈志敏今天似乎好像没有来学校学习啊"; 84 | // const text= "In this digital age, 在这个数字时代, let's embrace the wisdom of the past while pushing the boundaries of the future. 让我们在推动未来的同时,拥抱过去的智慧。 past whileaaaaaaa"; 85 | // const text= "smart-Connection用起来还不错"; 86 | // const text = "camelCase嗟尔远道之人胡为乎来哉"; 87 | // const text = "abc/def/knsusg abc#def#ddd camelCase this-boy bush#真好 谷歌is a good thing"; 88 | // const text = "他来到了网易杭研大厦"; 89 | // const text = "生命的象征"; 90 | // const text = "个遮阳避雨的安全之所。" 91 | const text = "abc/nef/adg"; 92 | 93 | logger.info(cutter.cut(text, false)); 94 | logger.info(cutter.cut(text, true)); 95 | logger.info(tokenizer.tokenize(text, "index")); 96 | logger.info(tokenizer.tokenize(text, "search")); 97 | logger.info(getInstance(AssetsProvider).assets.stopWordsZh?.size); 98 | logger.info(getInstance(AssetsProvider).assets.stopWordsZh?.has("的")); 99 | } 100 | 101 | async function testParseHtml() { 102 | const vault = getInstance(Vault); 103 | 104 | const file: TFile = vault 105 | .getFiles() 106 | .filter((f) => f.basename === "CodeMirror Reference Manual")[0]; 107 | monitorExecution(async () => await parseHtml(file)); 108 | } 109 | 110 | async function parseHtml(file: TFile) { 111 | const htmlText = await getInstance(Vault).cachedRead(file); 112 | const mdText = htmlToMarkdown(htmlText); 113 | mdText.split("\n"); 114 | new RenderMarkdownModal(getInstance(App), mdText).open(); 115 | } 116 | 117 | async function testSemanticSearch() { 118 | // const files = getInstance(DataProvider).allFilesToBeIndexed().filter(f=>f.basename.includes("Chinese")); 119 | const files = getInstance(DataProvider) 120 | .allFilesToBeIndexed() 121 | // .filter((f) => f.basename.includes("短篇小说")); 122 | // .filter((f) => f.basename.includes("20万字") || f.basename.includes("Mysql")); 123 | .filter((f) => f.basename.includes("Mysql") || f.basename.includes("Breath")); 124 | 125 | const indexedDocs = 126 | await getInstance(DataProvider).generateAllIndexedDocuments(files); 127 | getInstance(SemanticEngine).reindexAll(indexedDocs); 128 | } 129 | 130 | export async function devTest() { 131 | const settings = getInstance(OuterSetting); 132 | // ====== API Request ===== 133 | // const httpClient = getInstance(HttpClient); 134 | // httpClient.testRequest(); 135 | 136 | // ====== vault files ===== 137 | // setTimeout(() => { 138 | // monitorExecution(async () => { 139 | // const plugin: CleverSearch = getInstance(THIS_PLUGIN); 140 | // const vault = plugin.app.vault; 141 | // logger.debug(vault.getRoot().path); // / 142 | // logger.debug(vault.configDir); // .obsidian 143 | // }); 144 | // }, 1300); 145 | 146 | // testStemmer(); 147 | // testTsyringe(); 148 | // testUnsupportedExtensions(); 149 | 150 | // monitorExecution(testLexicalSearch); 151 | // monitorExecution(async () => await testLexicalSearch()); 152 | // testLexicalSearch(); 153 | // logger.trace("test"); 154 | 155 | // const vault = getInstance(Vault); 156 | // logger.info(`${vault.configDir}`); 157 | 158 | // testTikToken(); 159 | // monitorExecution(() => testTokenizer()); 160 | // testUFuzzy(); 161 | 162 | // getInstance(SearchClient).testImageSearch(); 163 | 164 | // await testParseHtml(); 165 | monitorExecution(() => testSemanticSearch()); 166 | } 167 | -------------------------------------------------------------------------------- /src/globals/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Use this string as the key to retrieve the CleverSearch instance from the tsyringe container. 3 | */ 4 | export const THIS_PLUGIN = "CleverSearch"; 5 | export const ICON_COLLAPSE = '"▶"'; 6 | export const ICON_EXPAND = '"▼"'; 7 | export const NULL_NUMBER = -1; 8 | export const HTML_4_SPACES = "    "; 9 | 10 | export const DEFAULT_BLACKLIST_EXTENSION = [ 11 | ".zip", 12 | ".rar", 13 | ".7z", 14 | ".tar", 15 | ".gz", 16 | ".bz2", 17 | ".xz", 18 | ".lz", 19 | ".lzma", 20 | ".tgz", 21 | ]; -------------------------------------------------------------------------------- /src/globals/dev-option.ts: -------------------------------------------------------------------------------- 1 | // BUG: can't import { isDevEnvironment } from my-lib, I don't know why, 2 | // it will be undefined and report an error 3 | // import { isDevEnvironment } from "src/utils/my-lib"; 4 | 5 | const isDev = process.env.NODE_ENV === "development"; 6 | export const devOption = { 7 | traceLog: isDev ? false : false, 8 | loadIndexFromDatabase: isDev ? true : true, 9 | } -------------------------------------------------------------------------------- /src/globals/enums.ts: -------------------------------------------------------------------------------- 1 | export enum EventEnum { 2 | TEST_EVENT_A, 3 | TEST_EVENT_B, 4 | NEXT_ITEM, 5 | NEXT_ITEM_FLOATING_WINDOW, 6 | NEXT_SUB_ITEM, 7 | PREV_ITEM, 8 | PREV_ITEM_FLOATING_WINDOW, 9 | PREV_SUB_ITEM, 10 | CONFIRM_ITEM, 11 | CONFIRM_ITEM_IN_BACKGROUND, 12 | SWITCH_LEXICAL_SEMANTIC_MODE, 13 | 14 | IN_VAULT_SEARCH, 15 | 16 | INSERT_FILE_LINK, 17 | } 18 | 19 | export enum LanguageEnum { 20 | other = "Other Language", 21 | en = "English", 22 | zh = "简体中文", 23 | // "zh-TW" = "繁體中文", 24 | // ru = "Pусский", 25 | // ko = "한국어", 26 | // it = "Italiano", 27 | // id = "Bahasa Indonesia", 28 | // ro = "Română", 29 | // "pt-BR" = "Portugues do Brasil", 30 | // cz = "čeština", 31 | // de = "Deutsch", 32 | // es = "Español", 33 | // fr = "Français", 34 | // no = "Norsk", 35 | // pl = "język polski", 36 | // pt = "Português", 37 | // ja = "日本語", 38 | // da = "Dansk", 39 | // uk = "Український", 40 | // sq = "Shqip", 41 | // tr = "Türkçe (kısmi)", 42 | // hi = "हिन्दी (आंशिक)", 43 | // nl = "Nederlands (gedeeltelijk)", 44 | // ar = "العربية (جزئي)" 45 | } 46 | 47 | export enum ObsidianCommandEnum { 48 | FOCUS_ON_LAST_NOTE = "editor:focus", 49 | } 50 | -------------------------------------------------------------------------------- /src/globals/plugin-setting.ts: -------------------------------------------------------------------------------- 1 | import type { LogLevel } from "src/utils/logger"; 2 | import { isDevEnvironment } from "src/utils/my-lib"; 3 | 4 | // exposed to users 5 | export class OuterSetting { 6 | customExtensions: { 7 | plaintext: string[]; 8 | }; 9 | followObsidianExcludedFiles: boolean; 10 | excludedPaths: string[]; // NOTE: can't use Set() or it will be a non-iterable object after deserialization 11 | logLevel: LogLevel; 12 | isCaseSensitive: boolean; 13 | isPrefixMatch: boolean; 14 | isFuzzy: boolean; 15 | enableStopWordsEn: boolean; 16 | enableChinesePatch: boolean; 17 | enableStopWordsZh: boolean; 18 | apiProvider1: ApiProvider; 19 | apiProvider2: ApiProvider; 20 | ui: UISetting; 21 | semantic: SemanticSetting; 22 | } 23 | 24 | const isChineseUser = window.localStorage.getItem("language") === "zh"; 25 | 26 | export const DEFAULT_OUTER_SETTING: OuterSetting = { 27 | customExtensions: { plaintext: ["md"] }, 28 | followObsidianExcludedFiles: true, 29 | excludedPaths: [], 30 | logLevel: isDevEnvironment ? "trace" : "info", 31 | isCaseSensitive: false, 32 | isPrefixMatch: true, 33 | isFuzzy: true, 34 | enableStopWordsEn: true, 35 | // TODO: 繁体中文 36 | enableChinesePatch: isChineseUser ? true : false, 37 | enableStopWordsZh: isChineseUser ? true : false, 38 | apiProvider1: { 39 | domain: "", 40 | key: "", 41 | }, 42 | apiProvider2: { 43 | domain: "", 44 | key: "", 45 | }, 46 | ui: { 47 | openInNewPane: true, 48 | maxItemResults: 30, 49 | floatingWindowForInFile: true, 50 | showedExtension: "except md", 51 | // collapseDevSettingByDefault: isDevEnvironment ? false : true, 52 | collapseDevSettingByDefault: false, 53 | inFileFloatingWindowTop: "2.7em", 54 | inFileFloatingWindowLeft: "2.5em", 55 | }, 56 | semantic: { 57 | isEnabled: false, 58 | serverType: "local" 59 | } 60 | }; 61 | 62 | export type LogLevelOptions = { 63 | [K in LogLevel]: K; 64 | }; 65 | 66 | export type ApiProvider = { 67 | domain: string; 68 | key: string; 69 | }; 70 | 71 | export type UISetting = { 72 | openInNewPane: boolean; 73 | maxItemResults: number; 74 | floatingWindowForInFile: boolean; 75 | showedExtension: "none" | "except md" | "all"; 76 | collapseDevSettingByDefault: boolean; 77 | inFileFloatingWindowTop: string; 78 | inFileFloatingWindowLeft: string; 79 | inFileFloatingWindowWidth?: string; 80 | inFileFloatingWindowHeight?: string; 81 | }; 82 | 83 | export type SemanticSetting = { 84 | isEnabled: boolean 85 | serverType: "local" | "remote" 86 | } 87 | 88 | // ========== transparent for users ========== 89 | type InnerSetting = { 90 | search: { 91 | fuzzyProportion: number; 92 | minTermLengthForPrefixSearch: number; 93 | weightFilename: number; 94 | weightFolder: number; 95 | weightTagText: number; 96 | weightHeading: number; 97 | // weightH1: number; 98 | // weightH2: number; 99 | // weightH3: number; 100 | // weightH4: number; 101 | }; 102 | }; 103 | 104 | export const innerSetting: InnerSetting = { 105 | search: { 106 | fuzzyProportion: 0.2, 107 | minTermLengthForPrefixSearch: 2, 108 | weightFilename: 3, 109 | weightFolder: 2, 110 | weightTagText: 1.15, 111 | weightHeading: 1.27, 112 | // weightH1: 1.6, 113 | // weightH2: 1.4, 114 | // weightH3: 1.25, 115 | // weightH4: 1.1, 116 | }, 117 | }; 118 | -------------------------------------------------------------------------------- /src/globals/search-types.ts: -------------------------------------------------------------------------------- 1 | import type { SearchResult as MiniResult } from "minisearch"; 2 | import { 3 | ViewRegistry, 4 | type ViewType, 5 | } from "src/services/obsidian/view-registry"; 6 | import { FileUtil } from "src/utils/file-util"; 7 | import { getInstance } from "src/utils/my-lib"; 8 | 9 | export type MiniSearchResult = MiniResult; 10 | 11 | export type IndexedDocument = { 12 | path: string; 13 | basename: string; 14 | folder: string; 15 | content?: string; 16 | aliases?: string; 17 | tags?: string; 18 | headings?: string; 19 | }; 20 | 21 | export type DocumentFields = Array; 22 | 23 | export type DocumentWeight = { 24 | [K in keyof IndexedDocument]?: number; 25 | }; 26 | 27 | export type DocumentRef = { 28 | id?: number; 29 | path: string; 30 | updateTime: number; 31 | }; 32 | 33 | export type InFileDataSource = { 34 | lines: Line[]; 35 | path: string; 36 | }; 37 | 38 | export class Line { 39 | text: string; 40 | row: number; 41 | constructor(text: string, row: number) { 42 | this.text = text; 43 | this.row = row; 44 | } 45 | } 46 | 47 | export type LineFields = Array; 48 | 49 | // text: highlighted text 50 | // col: the first highlighted col text 51 | export type HighlightedContext = Line & { col: number }; 52 | 53 | export type MatchedLine = Line & { positions: Set }; // positions: columns of matched chars 54 | 55 | export type MatchedFile = { 56 | path: string; 57 | queryTerms: string[]; 58 | matchedTerms: string[]; 59 | }; 60 | 61 | export class SearchResult { 62 | sourcePath: string; 63 | items: Item[]; 64 | constructor(currPath: string, items: Item[]) { 65 | this.sourcePath = currPath; 66 | this.items = items; 67 | } 68 | } 69 | 70 | export enum SearchType { 71 | NONE, 72 | IN_FILE, 73 | IN_VAULT, 74 | } 75 | 76 | export enum EngineType { 77 | LEXICAL, 78 | SEMANTIC, 79 | } 80 | 81 | export abstract class Item { 82 | element?: HTMLElement; 83 | } 84 | 85 | export class LineItem extends Item { 86 | line: HighlightedContext; 87 | context: string; 88 | 89 | constructor(line: HighlightedContext, context: string) { 90 | super(); 91 | this.line = line; 92 | this.context = context; 93 | } 94 | } 95 | 96 | export class FileItem extends Item { 97 | engineType: EngineType; 98 | path: string; 99 | queryTerms: string[]; 100 | matchedTerms: string[]; 101 | subItems: FileSubItem[]; // for markdown viewType 102 | // TODO: impl this 103 | previewContent: any; // for non-markdown viewType 104 | // TODO: store the view type rather than relying on obsidian api 105 | get viewType(): ViewType { 106 | return getInstance(ViewRegistry).viewTypeByPath(this.path); 107 | } 108 | get basename() { 109 | return FileUtil.getBasename(this.path); 110 | } 111 | get extension() { 112 | return FileUtil.getExtension(this.path); 113 | } 114 | get folderPath() { 115 | return FileUtil.getFolderPath(this.path); 116 | } 117 | 118 | constructor( 119 | engineType: EngineType, 120 | path: string, 121 | queryTerms: string[], 122 | matchedTerms: string[], 123 | subItems: FileSubItem[], 124 | previewContent: any, 125 | ) { 126 | super(); 127 | this.engineType = engineType; 128 | this.path = path; 129 | this.queryTerms = queryTerms; 130 | this.matchedTerms = matchedTerms; 131 | this.subItems = subItems; 132 | this.previewContent = previewContent; 133 | } 134 | } 135 | 136 | export class FileSubItem extends Item { 137 | text: string; 138 | row: number; // for precisely jumping to the original file location 139 | col: number; 140 | constructor(text: string, row: number, col: number) { 141 | super(); 142 | this.text = text; 143 | this.row = row; 144 | this.col = col; 145 | } 146 | } 147 | 148 | export type Location = { 149 | row: number; 150 | col: number; 151 | }; 152 | 153 | export type LocatableFile = Location & { 154 | viewType: ViewType; 155 | path: string; 156 | }; 157 | -------------------------------------------------------------------------------- /src/integrations/languages/chinese-patch.ts: -------------------------------------------------------------------------------- 1 | import init, { cut_for_search } from "jieba-wasm/pkg/web/jieba_rs_wasm"; 2 | import { OuterSetting } from "src/globals/plugin-setting"; 3 | import { logger } from "src/utils/logger"; 4 | import { getInstance } from "src/utils/my-lib"; 5 | import { AssetsProvider } from "src/utils/web/assets-provider"; 6 | import { singleton } from "tsyringe"; 7 | 8 | /** 9 | * better segmentation for Chinese, Japanese and Korean 10 | */ 11 | @singleton() 12 | export class ChinesePatch { 13 | private isReady = false; 14 | private reportedError = false; 15 | async initAsync() { 16 | if (getInstance(OuterSetting).enableChinesePatch) { 17 | const jiebaBinary = getInstance(AssetsProvider).assets.jiebaBinary; 18 | await init(jiebaBinary); 19 | // perform an initial cut_for_search to warm up the system, 20 | // as the first cut operation tends to be slow 21 | cut_for_search("", false); 22 | this.isReady = true; 23 | } 24 | } 25 | 26 | cut(text: string, hmm: boolean): string[] { 27 | if (!this.isReady && !this.reportedError) { 28 | logger.error("jieba segmenter isn't ready"); 29 | this.reportedError = true; 30 | return [text]; 31 | } 32 | return cut_for_search(text, hmm); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/integrations/omnisearch.ts: -------------------------------------------------------------------------------- 1 | import Dexie from "dexie"; 2 | import { App } from "obsidian"; 3 | import { PrivateApi } from "src/services/obsidian/private-api"; 4 | import { MyNotice } from "src/services/obsidian/transformed-api"; 5 | import { t } from "src/services/obsidian/translations/locale-helper"; 6 | import { getInstance } from "src/utils/my-lib"; 7 | import { singleton } from "tsyringe"; 8 | 9 | @singleton() 10 | export class OmnisearchIntegration { 11 | // BUG: must be the same version defined by omnisearch, or we can't get the searchHistory 12 | // see: public static readonly dbVersion = ? at 13 | // https://github.com/scambier/obsidian-omnisearch/blob/master/src/database.ts 14 | private static readonly DB_VERSION = 8; 15 | private app: any = getInstance(App); 16 | private privateApi = getInstance(PrivateApi) 17 | private db: any; 18 | 19 | async initAsync() { 20 | if (this.app.plugins.plugins.omnisearch) { 21 | const dbName = "omnisearch/cache/" + this.privateApi.getAppId(); 22 | // console.log(dbName); 23 | const db: any = new Dexie(dbName); 24 | 25 | // the schema must be the same as what Omnisearch defined 26 | db.version(OmnisearchIntegration.DB_VERSION).stores({ 27 | searchHistory: "++id", 28 | minisearch: "date", 29 | }); 30 | this.db = db; 31 | } 32 | } 33 | /** 34 | * get the last query from database created by omnisearch. 35 | */ 36 | async getLastQuery(): Promise { 37 | let lastQuery = ""; 38 | if (this.checkOmnisearchStatus()) { 39 | const recentQueries = await this.db.searchHistory.toArray(); 40 | lastQuery = 41 | recentQueries.length !== 0 42 | ? recentQueries[recentQueries.length - 1].query 43 | : ""; 44 | // console.log("last query: " + lastQuery); 45 | } 46 | return lastQuery; 47 | } 48 | 49 | private checkOmnisearchStatus(): boolean { 50 | const omnisearch = this.app.plugins.plugins.omnisearch; 51 | let isAvailable = true; 52 | if (!omnisearch) { 53 | new MyNotice(t("Omnisearch isn't installed")); 54 | isAvailable = false; 55 | } else if (!omnisearch._loaded) { 56 | new MyNotice(t("Omnisearch is installed but not enabled")); 57 | isAvailable = false; 58 | } 59 | return isAvailable; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/integrations/surfing.ts: -------------------------------------------------------------------------------- 1 | import { App } from "obsidian"; 2 | import { getInstance } from "src/utils/my-lib"; 3 | import { singleton } from "tsyringe"; 4 | 5 | @singleton() 6 | export class SurfingIntegration { 7 | isEnabled() { 8 | const app: any = getInstance(App); 9 | const surfing = app.plugins.plugins.surfing; 10 | return surfing && surfing._loaded; 11 | } 12 | } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | Component, 4 | MarkdownRenderer, 5 | Modal, 6 | Plugin, 7 | Vault 8 | } from "obsidian"; 9 | import "reflect-metadata"; 10 | import { container } from "tsyringe"; 11 | import { THIS_PLUGIN } from "./globals/constants"; 12 | import { PluginManager } from "./services/obsidian/plugin-manager"; 13 | import { getInstance } from "./utils/my-lib"; 14 | 15 | export default class CleverSearch extends Plugin { 16 | async onload() { 17 | // can't register `this` as CleverSearch, because it is `export default` rather than `export` 18 | container.register(THIS_PLUGIN, { useValue: this }); 19 | container.register(App, { useValue: this.app }); 20 | container.register(Vault, { useValue: this.app.vault }); 21 | 22 | const pluginManager = getInstance(PluginManager); 23 | 24 | await pluginManager.onload(); 25 | // explicitly initialize this singleton because object is lazy-loading by default in tsyringe 26 | this.app.workspace.onLayoutReady(() => { 27 | pluginManager.onLayoutReady(); 28 | }); 29 | this.registerEvent( 30 | this.app.workspace.on("quit", () => pluginManager.onAppQuit(), this), 31 | ); 32 | 33 | // this.exampleCode(); 34 | } 35 | 36 | onunload() { 37 | document.body.classList.remove("cs-privacy-blur"); 38 | getInstance(PluginManager).onunload(); 39 | } 40 | 41 | exampleCode() { 42 | // If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin) 43 | // Using this function will automatically remove the event listener when this plugin is disabled. 44 | this.registerDomEvent(document, "click", (evt: MouseEvent) => { 45 | console.log("click", evt); 46 | }); 47 | 48 | // When registering intervals, this function will automatically clear the interval when the plugin is disabled. 49 | this.registerInterval( 50 | window.setInterval(() => console.log("setInterval"), 5 * 60 * 1000), 51 | ); 52 | } 53 | } 54 | 55 | export class RenderMarkdownModal extends Modal { 56 | mdContent: string; 57 | 58 | constructor(app: App, mdContent: string) { 59 | super(app); 60 | this.mdContent = mdContent; 61 | } 62 | 63 | onOpen() { 64 | this.containerEl.empty(); 65 | this.containerEl.style.display = "block"; 66 | this.containerEl.style.overflow = "auto"; 67 | this.containerEl.style.backgroundColor = "black"; 68 | MarkdownRenderer.render( 69 | getInstance(App), 70 | this.mdContent, 71 | this.containerEl, 72 | "", 73 | new Component(), 74 | ); 75 | } 76 | 77 | onClose() { 78 | // const { contentEl } = this; 79 | // contentEl.empty(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/services/auxiliary/auxiliary-service.ts: -------------------------------------------------------------------------------- 1 | import CleverSearch from "src/main"; 2 | import { logger } from "src/utils/logger"; 3 | import { getInstance } from "src/utils/my-lib"; 4 | import { singleton } from "tsyringe"; 5 | 6 | @singleton() 7 | export class AuxiliaryService { 8 | private privacyModeEnabled = false; 9 | private plugin: CleverSearch = getInstance(CleverSearch); 10 | 11 | togglePrivacyMode() { 12 | this.privacyModeEnabled = !this.privacyModeEnabled; 13 | if (this.privacyModeEnabled) { 14 | document.body.classList.add("cs-privacy-blur"); 15 | } else { 16 | document.body.classList.remove("cs-privacy-blur"); 17 | } 18 | } 19 | 20 | init() { 21 | this.watchSelectionAndAutoCopy(); 22 | } 23 | 24 | private watchSelectionAndAutoCopy() { 25 | this.plugin.registerDomEvent( 26 | document, 27 | "mousedown", 28 | (event: MouseEvent) => { 29 | const classesToCheck = [ 30 | "cs-modal", 31 | "cs-floating-window-container", 32 | ]; 33 | if (isEventTargetInClass(event, classesToCheck)) { 34 | document.addEventListener( 35 | "mouseup", 36 | () => { 37 | copySelectedText(); 38 | }, 39 | { once: true }, 40 | ); 41 | } 42 | }, 43 | ); 44 | } 45 | } 46 | 47 | function isEventTargetInClass(event: MouseEvent, classes: string[]): boolean { 48 | let element: HTMLElement | null = event.target as HTMLElement; 49 | while (element) { 50 | if ( 51 | classes.some((className) => element?.classList.contains(className)) 52 | ) { 53 | return true; 54 | } 55 | element = element.parentElement; 56 | } 57 | return false; 58 | } 59 | 60 | function copySelectedText(): void { 61 | const selection = window.getSelection(); 62 | if (selection && selection.rangeCount > 0) { 63 | const range = selection.getRangeAt(0); 64 | const selectedText = range.toString(); 65 | if (selectedText) { 66 | navigator.clipboard 67 | .writeText(selectedText) 68 | .then(() => logger.debug(`selection copied: ${selectedText}`)); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/services/database/database.ts: -------------------------------------------------------------------------------- 1 | import Dexie from "dexie"; 2 | import type { AsPlainObject } from "minisearch"; 3 | import type { OuterSetting } from "src/globals/plugin-setting"; 4 | import type { DocumentRef } from "src/globals/search-types"; 5 | import { logger } from "src/utils/logger"; 6 | import { getInstance, monitorDecorator } from "src/utils/my-lib"; 7 | import { inject, singleton } from "tsyringe"; 8 | import { PrivateApi } from "../obsidian/private-api"; 9 | 10 | @singleton() 11 | export class Database { 12 | readonly db = getInstance(DexieWrapper); 13 | async deleteMinisearchData() { 14 | this.db.minisearch.clear(); 15 | } 16 | 17 | // it may finished some time later even if using await 18 | async setMiniSearchData(data: AsPlainObject) { 19 | this.db.transaction("rw", this.db.minisearch, async () => { 20 | // Warning: The clear() here is just a marker for caution to avoid data duplication. 21 | // Ideally, clear() should be executed at an earlier stage. 22 | // Placing clear() and add() together, especially with large data sets, 23 | // may lead to conflicts and cause Obsidian to crash. It is an issue related to Dexie or IndexedDB 24 | await this.db.minisearch.clear(); 25 | await this.db.minisearch.add({ data: data }); 26 | logger.trace("minisearch data saved"); 27 | }); 28 | } 29 | 30 | @monitorDecorator 31 | async getMiniSearchData(): Promise { 32 | return (await this.db.minisearch.toArray())[0]?.data || null; 33 | } 34 | 35 | async setLexicalDocRefs(refs: DocumentRef[]) { 36 | this.db.transaction("rw", this.db.lexicalDocRefs, async () => { 37 | await this.db.lexicalDocRefs.clear(); 38 | await this.db.lexicalDocRefs.bulkAdd(refs); 39 | }); 40 | } 41 | 42 | @monitorDecorator 43 | async getLexicalDocRefs(): Promise { 44 | return (await this.db.lexicalDocRefs.toArray()) || null; 45 | } 46 | 47 | async setSemanticDocRefs(refs: DocumentRef[]) { 48 | this.db.transaction("rw", this.db.semanticDocRefs, async () => { 49 | await this.db.semanticDocRefs.clear(); 50 | await this.db.semanticDocRefs.bulkAdd(refs); 51 | }); 52 | } 53 | 54 | async getSemanticDocRefs(): Promise { 55 | return (await this.db.semanticDocRefs.toArray()) || null; 56 | } 57 | 58 | async setPluginSetting(setting: OuterSetting): Promise { 59 | try { 60 | await this.db.transaction("rw", this.db.pluginSetting, () => { 61 | this.db.pluginSetting.clear(); 62 | this.db.pluginSetting.add({ data: setting }); 63 | }); 64 | logger.trace("settings have been saved to database"); 65 | return true; 66 | } catch (e) { 67 | logger.trace(`settings failed to be saved: ${e}`); 68 | return false; 69 | } 70 | } 71 | 72 | // copied from https://github.com/scambier/obsidian-omnisearch/blob/master/src/database.ts#L36 73 | async deleteOldDatabases() { 74 | const toDelete = (await indexedDB.databases()).filter( 75 | (db) => 76 | db.name === this.db.dbName && 77 | // version multiplied by 10 https://github.com/dexie/Dexie.js/issues/59 78 | db.version !== this.db.dbVersion * 10, 79 | ); 80 | if (toDelete.length) { 81 | logger.info("Old version databases will be deleted"); 82 | for (const db of toDelete) { 83 | if (db.name) { 84 | indexedDB.deleteDatabase(db.name); 85 | } 86 | } 87 | } 88 | } 89 | } 90 | 91 | @singleton() 92 | class DexieWrapper extends Dexie { 93 | private static readonly _dbVersion = 2; 94 | private static readonly dbNamePrefix = "clever-search/"; 95 | private privateApi: PrivateApi; 96 | pluginSetting!: Dexie.Table<{ id?: number; data: OuterSetting }, number>; 97 | minisearch!: Dexie.Table<{ id?: number; data: AsPlainObject }, number>; 98 | // TODO: put data together because it takes lots of time for a database connection (70ms) in my machine 99 | lexicalDocRefs!: Dexie.Table; 100 | semanticDocRefs!: Dexie.Table; 101 | 102 | constructor(@inject(PrivateApi) privateApi: PrivateApi) { 103 | super(DexieWrapper.dbNamePrefix + privateApi.getAppId()); 104 | this.privateApi = privateApi; 105 | this.version(DexieWrapper._dbVersion).stores({ 106 | pluginSetting: "++id", 107 | minisearch: "++id", 108 | lexicalDocRefs: "++id", 109 | semanticDocRefs: "++id", 110 | }); 111 | } 112 | get dbVersion() { 113 | return DexieWrapper._dbVersion; 114 | } 115 | get dbName() { 116 | return DexieWrapper.dbNamePrefix + this.privateApi.getAppId(); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/services/obsidian/command-registry.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | Scope, 4 | type Command, 5 | type KeymapEventHandler, 6 | type Modifier 7 | } from "obsidian"; 8 | import { devTest } from "src/dev-test"; 9 | import { THIS_PLUGIN } from "src/globals/constants"; 10 | import { EventEnum } from "src/globals/enums"; 11 | import { OuterSetting } from "src/globals/plugin-setting"; 12 | import { SearchType } from "src/globals/search-types"; 13 | import { OmnisearchIntegration } from "src/integrations/omnisearch"; 14 | import type CleverSearch from "src/main"; 15 | import { FloatingWindowManager } from "src/ui/floating-window"; 16 | import { SearchModal } from "src/ui/search-modal"; 17 | import { eventBus } from "src/utils/event-bus"; 18 | import { getInstance, isDevEnvironment } from "src/utils/my-lib"; 19 | import { singleton } from "tsyringe"; 20 | import { AuxiliaryService } from "../auxiliary/auxiliary-service"; 21 | 22 | const CTRL: Modifier = "Ctrl"; 23 | const ALT: Modifier = "Alt"; 24 | const SHIFT: Modifier = "Shift"; 25 | 26 | @singleton() 27 | export class CommandRegistry { 28 | private plugin: CleverSearch = getInstance(THIS_PLUGIN); 29 | private setting = getInstance(OuterSetting); 30 | private app = getInstance(App); 31 | 32 | constructor() { 33 | getInstance(GlobalNavigationHotkeys).registerAll(); 34 | } 35 | 36 | // only for developer 37 | addDevCommands() { 38 | if (isDevEnvironment) { 39 | this.addCommand({ 40 | id: "cs-in-file-search-floating-window", 41 | name: "In file search - floating window", 42 | callback: () => 43 | getInstance(FloatingWindowManager).toggle("inFile"), 44 | }); 45 | 46 | this.addCommand({ 47 | id: "clever-search-triggerTest", 48 | name: "clever-search-triggerTest", 49 | // hotkeys: [{modifiers: [currModifier], key: "5"}], 50 | callback: async () => await devTest(), 51 | }); 52 | 53 | } 54 | } 55 | 56 | addCommandsWithoutDependency() { 57 | this.addCommand({ 58 | id: "clever-search-in-file", 59 | name: "Search in file", 60 | callback: () => { 61 | if (this.setting.ui.floatingWindowForInFile) { 62 | getInstance(FloatingWindowManager).toggle("inFile"); 63 | } else { 64 | new SearchModal(this.app, SearchType.IN_FILE, false).open(); 65 | } 66 | }, 67 | }); 68 | 69 | this.addCommand({ 70 | id: "cs-toggle-privacy-mode", 71 | name: "Toggle privacy mode", 72 | callback: () => getInstance(AuxiliaryService).togglePrivacyMode(), 73 | }); 74 | } 75 | 76 | addInVaultCommands() { 77 | this.addCommand({ 78 | id: "clever-search-in-vault", 79 | name: "Search in Vault", 80 | callback: () => { 81 | eventBus.emit(EventEnum.IN_VAULT_SEARCH); 82 | new SearchModal(this.app, SearchType.IN_VAULT, false).open(); 83 | }, 84 | }); 85 | 86 | this.addCommand({ 87 | id: "cs-in-file-search-with-omnisearch-query", 88 | name: "Search in file with last Omnisearch query", 89 | callback: async () => { 90 | new SearchModal( 91 | this.app, 92 | SearchType.IN_FILE, 93 | false, 94 | await getInstance(OmnisearchIntegration).getLastQuery(), 95 | ).open(); 96 | }, 97 | }); 98 | 99 | this.addCommand({ 100 | id: "clever-search-in-vault-semantic", 101 | name: "Search in vault semantically", 102 | callback: async () => 103 | new SearchModal(this.app, SearchType.IN_VAULT, true).open(), 104 | }); 105 | } 106 | 107 | onunload() { 108 | getInstance(GlobalNavigationHotkeys).unregisterAll(); 109 | } 110 | 111 | private addCommand(command: Command) { 112 | this.plugin.addCommand(command); 113 | } 114 | } 115 | 116 | function emitEvent(eventEnum: EventEnum) { 117 | return (e: Event) => { 118 | e.preventDefault(); 119 | eventBus.emit(eventEnum); 120 | console.log("emit..."); 121 | }; 122 | } 123 | 124 | // register global hotkeys for FloatingWindow and scoped hotkeys for each Modal 125 | abstract class AbstractNavigationHotkeys { 126 | protected handlers: KeymapEventHandler[] = []; 127 | protected scope: Scope; 128 | 129 | constructor(scope: Scope) { 130 | this.scope = scope; 131 | } 132 | 133 | abstract registerAll(): void; 134 | 135 | unregisterAll() { 136 | this.handlers.forEach((h) => { 137 | this.scope.unregister(h); 138 | }); 139 | this.handlers = []; 140 | } 141 | 142 | protected register( 143 | modifiers: Modifier[], 144 | key: string, 145 | eventEnum: EventEnum, 146 | ) { 147 | this.handlers.push( 148 | this.scope.register(modifiers, key, emitEvent(eventEnum)), 149 | ); 150 | } 151 | } 152 | 153 | @singleton() 154 | class GlobalNavigationHotkeys extends AbstractNavigationHotkeys { 155 | constructor() { 156 | super(getInstance(App).scope); 157 | } 158 | 159 | registerAll() { 160 | this.handlers = []; 161 | this.register([CTRL], "J", EventEnum.NEXT_ITEM_FLOATING_WINDOW); 162 | this.register([CTRL], "K", EventEnum.PREV_ITEM_FLOATING_WINDOW); 163 | } 164 | } 165 | 166 | // non-singleton, create for each modal 167 | export class ModalNavigationHotkeys extends AbstractNavigationHotkeys { 168 | constructor(scope: Scope) { 169 | super(scope); 170 | } 171 | 172 | registerAll(): void { 173 | this.register([CTRL], "J", EventEnum.NEXT_ITEM); 174 | this.register([CTRL], "K", EventEnum.PREV_ITEM); 175 | 176 | this.register([], "ArrowDown", EventEnum.NEXT_ITEM); 177 | this.register([], "ArrowUp", EventEnum.PREV_ITEM); 178 | this.register([], "Enter", EventEnum.CONFIRM_ITEM); 179 | this.register([CTRL], "Enter", EventEnum.CONFIRM_ITEM_IN_BACKGROUND); 180 | this.register([CTRL], "N", EventEnum.NEXT_SUB_ITEM); 181 | this.register([CTRL], "P", EventEnum.PREV_SUB_ITEM); 182 | this.register([CTRL], "S", EventEnum.SWITCH_LEXICAL_SEMANTIC_MODE); 183 | this.register([ALT], "I", EventEnum.INSERT_FILE_LINK); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/services/obsidian/plugin-manager.ts: -------------------------------------------------------------------------------- 1 | import { ChinesePatch } from "src/integrations/languages/chinese-patch"; 2 | import { OmnisearchIntegration } from "src/integrations/omnisearch"; 3 | import { FloatingWindowManager } from "src/ui/floating-window"; 4 | import { logger } from "src/utils/logger"; 5 | import { AssetsProvider } from "src/utils/web/assets-provider"; 6 | import { SearchClient } from "src/web-workers/client"; 7 | import { singleton } from "tsyringe"; 8 | import { getInstance, isDevEnvironment } from "../../utils/my-lib"; 9 | import { AuxiliaryService } from "../auxiliary/auxiliary-service"; 10 | import { CommandRegistry } from "./command-registry"; 11 | import { SettingManager } from "./setting-manager"; 12 | import { DataManager } from "./user-data/data-manager"; 13 | import { RecentFileManager } from "./user-data/recent-file-manager"; 14 | import { ViewRegistry } from "./view-registry"; 15 | 16 | @singleton() 17 | export class PluginManager { 18 | // private readonly obFileUtil = getInstance(Vault).adapter as FileSystemAdapter; 19 | 20 | async onload() { 21 | await getInstance(SettingManager).initAsync(); 22 | getInstance(ViewRegistry).init(); 23 | if (isDevEnvironment) { 24 | logger.warn("仅在开发模式开启RecentFileManager") 25 | getInstance(RecentFileManager).init(); 26 | } 27 | 28 | getInstance(CommandRegistry).addCommandsWithoutDependency(); 29 | 30 | await getInstance(AssetsProvider).initAsync(); 31 | await getInstance(ChinesePatch).initAsync(); 32 | await getInstance(SearchClient).createChildThreads(); 33 | 34 | getInstance(AuxiliaryService).init(); 35 | } 36 | 37 | async onLayoutReady() { 38 | const commandRegistry = getInstance(CommandRegistry); 39 | commandRegistry.addInVaultCommands(); 40 | commandRegistry.addDevCommands(); 41 | await getInstance(DataManager).initAsync(); 42 | await getInstance(OmnisearchIntegration).initAsync(); 43 | } 44 | 45 | // should be called in CleverSearch.onunload() 46 | onunload() { 47 | getInstance(CommandRegistry).onunload(); 48 | getInstance(DataManager).onunload(); 49 | getInstance(FloatingWindowManager).onunload(); 50 | } 51 | 52 | onAppQuit() { 53 | // getInstance(SettingManager).saveSettings(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/services/obsidian/private-api.ts: -------------------------------------------------------------------------------- 1 | import { App, FileSystemAdapter, type TFile } from "obsidian"; 2 | import { THIS_PLUGIN } from "src/globals/constants"; 3 | import type CleverSearch from "src/main"; 4 | import { pathUtil } from "src/utils/file-util"; 5 | import { getInstance } from "src/utils/my-lib"; 6 | import { singleton } from "tsyringe"; 7 | 8 | /* 9 | * APIs in this file are not declared in the official obsidian.d.ts but are available in js 10 | */ 11 | @singleton() 12 | export class PrivateApi { 13 | private app = getInstance(App) as any; 14 | private plugin: CleverSearch = getInstance(THIS_PLUGIN); 15 | public obsidianFs: FileSystemAdapter = this.app.vault.adapter; 16 | 17 | 18 | getVaultAbsolutePath(): string { 19 | return this.obsidianFs.getBasePath().replace(/\\/g, "/") + "/"; 20 | } 21 | getAbsolutePath(relativePath: string) { 22 | return pathUtil.join(this.getVaultAbsolutePath(), relativePath) ; 23 | } 24 | getFileBacklinks(file: TFile) { 25 | // @ts-ignore 26 | this.app.metadataCache.getBacklinksForFile(file); 27 | } 28 | getAppId() { 29 | // BUG: 最新的api移除了this.app.appId的定义,以后可能会废除这个属性 30 | // if this api is removed, use the following code to identify a vault: 31 | // public readonly vaultAbsolutePath = this.obsidianFs.getBasePath().replace(/\\/g, "/") + "/"; 32 | return this.app.appId; 33 | } 34 | 35 | executeCommandById(commandId: string) { 36 | this.app.commands.executeCommandById(commandId); 37 | } 38 | 39 | isNotObsidianExcludedPath(path: string) { 40 | return !( 41 | this.app.metadataCache.isUserIgnored && 42 | this.app.metadataCache.isUserIgnored(path) 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/services/obsidian/search-service.ts: -------------------------------------------------------------------------------- 1 | import { App } from "obsidian"; 2 | import { logger } from "src/utils/logger"; 3 | import { getInstance, monitorDecorator } from "src/utils/my-lib"; 4 | import { singleton } from "tsyringe"; 5 | import { 6 | EngineType, 7 | FileItem, 8 | FileSubItem, 9 | Line, 10 | LineItem, 11 | SearchResult, 12 | } from "../../globals/search-types"; 13 | import { FileUtil } from "../../utils/file-util"; 14 | import { LineHighlighter } from "../search/highlighter"; 15 | import { LexicalEngine } from "../search/lexical-engine"; 16 | import { SemanticEngine } from "../search/semantic-engine"; 17 | import { TruncateOption } from "../search/truncate-option"; 18 | import { DataProvider } from "./user-data/data-provider"; 19 | import { ViewRegistry, ViewType } from "./view-registry"; 20 | 21 | @singleton() 22 | export class SearchService { 23 | private readonly app = getInstance(App); 24 | private readonly dataProvider = getInstance(DataProvider); 25 | private readonly lexicalEngine = getInstance(LexicalEngine); 26 | private readonly semanticEngine = getInstance(SemanticEngine); 27 | private readonly lineHighlighter = getInstance(LineHighlighter); 28 | private readonly viewRegistry = getInstance(ViewRegistry); 29 | 30 | @monitorDecorator 31 | async searchInVault(queryText: string): Promise { 32 | const result = new SearchResult("no result", []); 33 | if (queryText.length === 0) { 34 | return result; 35 | } 36 | const sourcePath = 37 | this.app.workspace.getActiveFile()?.path || "no source path"; 38 | const lexicalMatches = await this.lexicalEngine.searchFiles(queryText); 39 | const lexicalResult = [] as FileItem[]; 40 | if (lexicalMatches.length !== 0) { 41 | return { 42 | sourcePath: sourcePath, 43 | items: lexicalMatches.map((matchedFile) => { 44 | // It is necessary to use a constructor with 'new', rather than using an object literal. 45 | // Otherwise, it is impossible to determine the type using 'instanceof', achieving polymorphic effects based on inheritance 46 | // (to correctly display data in Svelte components). 47 | return new FileItem( 48 | EngineType.LEXICAL, 49 | matchedFile.path, 50 | matchedFile.queryTerms, 51 | matchedFile.matchedTerms, 52 | [], // should be populated on demand 53 | "nothing", 54 | ); 55 | }), 56 | }; 57 | } else { 58 | logger.trace("lexical matched files count is 0"); 59 | // TODO: do semantic search 60 | return result; 61 | } 62 | } 63 | 64 | async searchInVaultSemantic(queryText: string): Promise { 65 | const result = new SearchResult("no result", []); 66 | if (queryText.length === 0) { 67 | return result; 68 | } else { 69 | const sourcePath = 70 | this.app.workspace.getActiveFile()?.path || "no source path"; 71 | const result = { 72 | sourcePath: sourcePath, 73 | items: await this.semanticEngine.search( 74 | // remove leading and trailing white spaces for consistent search result 75 | queryText.trim(), 76 | ViewType.MARKDOWN, 77 | ), 78 | } as SearchResult; 79 | logger.debug(result) 80 | return result; 81 | } 82 | } 83 | 84 | /** 85 | * it should be called on demand for better performance 86 | */ 87 | @monitorDecorator 88 | async getFileSubItems( 89 | queryText: string, 90 | fileItem: FileItem, 91 | ): Promise { 92 | const path = fileItem.path; 93 | 94 | if (this.viewRegistry.viewTypeByPath(path) !== ViewType.MARKDOWN) { 95 | logger.warn( 96 | `view type for path "${path}" is not supported for sub-items.`, 97 | ); 98 | return []; 99 | } 100 | 101 | const content = await this.dataProvider.readPlainText(path); 102 | const lines = content 103 | .split(FileUtil.SPLIT_EOL) 104 | .map((text, index) => new Line(text, index)); 105 | logger.debug("target file lines count: ", lines.length); 106 | 107 | const matchedLines = await this.lexicalEngine.searchLinesByFileItem( 108 | lines, 109 | "subItem", 110 | queryText, 111 | fileItem, 112 | 60, 113 | ); 114 | 115 | const fileSubItems = this.lineHighlighter 116 | .parseAll( 117 | lines, 118 | matchedLines, 119 | TruncateOption.forType("subItem", queryText), 120 | false, 121 | ) 122 | .map((itemContext) => { 123 | return { 124 | text: itemContext.text, 125 | row: itemContext.row, 126 | col: itemContext.col, 127 | } as FileSubItem; 128 | }); 129 | 130 | return fileSubItems; 131 | } 132 | 133 | @monitorDecorator 134 | async searchInFile(queryText: string): Promise { 135 | const result = new SearchResult("", []); 136 | const activeFile = this.app.workspace.getActiveFile(); 137 | if (!queryText || !activeFile) { 138 | return result; 139 | } 140 | 141 | if ( 142 | this.viewRegistry.viewTypeByPath(activeFile.path) !== 143 | ViewType.MARKDOWN 144 | ) { 145 | logger.trace("Current file isn't PLAINT_TEXT"); 146 | return result; 147 | } 148 | 149 | const lines = ( 150 | await this.dataProvider.readPlainTextLines(activeFile.path) 151 | ).map((line, index) => new Line(line, index)); 152 | const queryTextNoSpaces = queryText.replace(/\s/g, ""); 153 | 154 | const matchedLines = await this.lexicalEngine.fzfMatch( 155 | queryTextNoSpaces, 156 | lines, 157 | ); 158 | const lineItems = matchedLines.map((matchedLine) => { 159 | const highlightedLine = this.lineHighlighter.parse( 160 | lines, 161 | matchedLine, 162 | TruncateOption.forType("line"), 163 | false, 164 | ); 165 | // logger.debug(highlightedLine); 166 | const paragraphContext = this.lineHighlighter.parse( 167 | lines, 168 | matchedLine, 169 | TruncateOption.forType("paragraph"), 170 | true, 171 | ); 172 | return new LineItem(highlightedLine, paragraphContext.text); 173 | }); 174 | return { 175 | sourcePath: activeFile.path, 176 | items: lineItems, 177 | } as SearchResult; 178 | } 179 | 180 | @monitorDecorator 181 | /** 182 | * @deprecated since 0.1.x, use SearchService.searchInFile instead 183 | */ 184 | async deprecatedSearchInFile(queryText: string): Promise { 185 | const result = new SearchResult("", []); 186 | const activeFile = this.app.workspace.getActiveFile(); 187 | if ( 188 | !queryText || 189 | !activeFile || 190 | this.viewRegistry.viewTypeByPath(activeFile.path) !== 191 | ViewType.MARKDOWN 192 | ) { 193 | return result; 194 | } 195 | 196 | const path = activeFile.path; 197 | 198 | const lines = (await this.dataProvider.readPlainTextLines(path)).map( 199 | (line, index) => new Line(line, index), 200 | ); 201 | const lineItems = await this.lineHighlighter.parseLineItems( 202 | lines, 203 | queryText, 204 | ); 205 | 206 | return { 207 | sourcePath: path, 208 | items: lineItems, 209 | } as SearchResult; 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/services/obsidian/transformed-api.ts: -------------------------------------------------------------------------------- 1 | import { AbstractInputSuggest, App, Notice } from "obsidian"; 2 | import { getInstance } from "src/utils/my-lib"; 3 | 4 | export class MyNotice extends Notice { 5 | constructor(text: string, duration = 0) { 6 | super(text + "\n(clever-search)", duration); 7 | } 8 | } 9 | 10 | export class CommonSuggester extends AbstractInputSuggest { 11 | content: Set; 12 | 13 | constructor( 14 | private inputEl: HTMLInputElement, 15 | content: Set, 16 | private onSelectCb: (value: string) => void, 17 | ) { 18 | super(getInstance(App), inputEl); 19 | this.content = content; 20 | } 21 | 22 | getSuggestions(inputStr: string): string[] { 23 | return [...this.content].filter((content) => 24 | content.toLowerCase().contains(inputStr.toLowerCase()), 25 | ); 26 | } 27 | 28 | renderSuggestion(content: string, el: HTMLElement): void { 29 | el.setText(content); 30 | } 31 | 32 | selectSuggestion(content: string, evt: MouseEvent | KeyboardEvent): void { 33 | this.onSelectCb(content); 34 | this.inputEl.value = ""; 35 | this.close(); 36 | } 37 | } 38 | 39 | class MyObsidianApi {} 40 | 41 | export const myObApi = new MyObsidianApi(); 42 | -------------------------------------------------------------------------------- /src/services/obsidian/translations/locale-helper.ts: -------------------------------------------------------------------------------- 1 | // from https://github.com/valentine195/obsidian-admonition/blob/master/src/lang/helpers.ts 2 | 3 | import { moment } from 'obsidian'; 4 | import en from "./locale/en"; 5 | import zhCN from "./locale/zh-cn"; 6 | 7 | const localeMap: { [k: string]: Partial } = { 8 | en: en, 9 | 'zh-cn': zhCN, 10 | }; 11 | 12 | const locale = localeMap[moment.locale()]; 13 | 14 | export function t(str: keyof typeof en): string { 15 | return (locale && locale[str]) || en[str]; 16 | } -------------------------------------------------------------------------------- /src/services/obsidian/translations/locale/en.ts: -------------------------------------------------------------------------------- 1 | import { pathUtil } from "src/utils/file-util"; 2 | // eslint-disable-next-line @typescript-eslint/no-var-requires 3 | const electron = require("electron"); 4 | const userDataPath = (electron.app || electron.remote.app).getPath("userData"); 5 | 6 | const assetsDir = pathUtil.join(userDataPath, "clever-search"); 7 | export const stopWordsEnTargetUrl = pathUtil.join( 8 | assetsDir, 9 | "stop-words-en.txt", 10 | ); 11 | 12 | export default { 13 | // notification 14 | "Reindexing...": "Reindexing...", 15 | "Indexing finished": "Indexing finished", 16 | "files need to be indexed. Obsidian may freeze for a while": 17 | "files need to be indexed. Obsidian may freeze for a while", 18 | "Omnisearch isn't installed": "Omnisearch isn't installed", 19 | "Omnisearch is installed but not enabled": 20 | "Omnisearch is installed but not enabled", 21 | "Database has been updated, a reindex is required": 22 | "Database has been updated, a reindex is required", 23 | "Semantic init time": "The local model indexes at a speed of approximately 100-500 words per second. During this time, please do not close Obsidian.", 24 | "Semantic init finished": "Semantic engine is ready", 25 | 26 | "Downloading aiHelper": "Downloading clever-search-ai-helper.zip (972 MB)...", 27 | "Download success": "Successfully downloaded", 28 | "Download failure": "Failed to download clever-search-ai-helper.zip ", 29 | "Download manually": "Download manually", 30 | 31 | // setting tab 32 | "Max items count": "Max items count", 33 | "Max items count desc": 34 | "Due to renderer's limited capabilities, this plugin can find thousands of results, but cannot display them all at once", 35 | 36 | "Floating window for in-file search": "Floating window for in-file search", 37 | "Floating window for in-file search desc": "Execute 'in-file search' command again will close the existing floating window. Disable this option to use classic modal UI", 38 | 39 | "Case sensitive": "Case sensitive", 40 | "Prefix match":"Prefix match", 41 | "Character fuzzy allowed": "Character fuzzy allowed", 42 | 43 | "English word blacklist": "English word blacklist", 44 | "English word blacklist desc": `Exclude some meaningless English words like "do", "and", "them" from indexing, enhancing search and indexing speed. Modify the file at ${stopWordsEnTargetUrl} to tailor the list to your needs.`, 45 | "Chinese patch": "Chinese patch", 46 | "Chinese patch desc": "Better search result for Chinese", 47 | "Chinese word blacklist": "Chinese word blacklist", 48 | "Chinese word blacklist desc": `Activates only if the Chinese Patch is enabled. This excludes some meaningless Chinese words like "的", "所以", "尽管" listed in 'stop-words-zh.txt', improving search efficiency and speed. More details are listed in "English word blacklist" option`, 49 | 50 | 51 | // Advanced setting 52 | "Advanced": "Advanced", 53 | "Advanced.desc": "The previous settings cover most needs. For further customization, adjust the following options", 54 | 55 | // semantic search 56 | "Semantic search": "Semantic search", 57 | "Introduction": "Introduction", 58 | "Introduction.desc": "Semantic search is only supported on the Windows system. It is only recommended for small and medium-sized vault. If a vault has more than 8 million words, the initial indexing may take dozens of hours. You need to extract the .cache folder from the downloaded archive and place it in C:\\Users\\. Then, run clever-search-ai-helper.exe to start the semantic engine. Semantic search is intended to complement lexical search and is not as effective in exact matching as lexical search.", 59 | "Enable": "Enable", 60 | "Server type": "Server type", 61 | "Server type.desc": "For local server, Clever Search AI Helper needs to run in the background. For remote server, it will not be implemented in the short term.", 62 | "local": "local", 63 | "Utilities": "Utilities", 64 | "Test connection": "Test connection", 65 | "Download": "Download", 66 | "Additional Information": "Additional Information", 67 | "Additional Information.desc": "When semantic search is turned on and ai-helper is running, Reindex will be applied to both lexical and semantic engine; each time this plugin is loaded, the semantic engine will automatically perform an incremental index, and subsequent file modifications will not update the index to avoid blocking semantic search.", 68 | 69 | 70 | "Excluded files": "Excluded files", 71 | Manage: "Manage", 72 | "Follow Obsidian Excluded Files": "Follow Obsidian Excluded Files", 73 | "Enter path...": "Enter path...", 74 | Add: "Add", 75 | 76 | "Customize extensions": "Customize extensions", 77 | "extensionModal.desc": 78 | "Customize the file extensions you would like to index. By default, file types not natively supported by Obsidian, such as 'txt', will be opened with an external program. To open these files within Obsidian, you may need to install plugins like 'obsidian-custom-file-extension-plugin' or 'obsidian-vscode-editor'.", 79 | "extensionModal.plaintextName": "Plaintext", 80 | "extensionModal.plaintextDesc": 81 | "Extensions should be separated by a space or a newline character. Please do not include binary filetypes like 'pdf', 'jpg', 'mp4', etc, that can't be opened with notepad. Including them might cause indexing issues. Additionally, the indexing and searching of HTML files may be slower because they require conversion to clean Markdown format first. Furthermore, due to the extremely limited API, it seems impossible to automatically scroll to specific locations within HTML files.", 82 | 83 | // for development 84 | "For Development": "For Development", 85 | "Collapse development setting by default": 86 | "Collapse development setting by default", 87 | "Reindex the vault": "Reindex the vault", 88 | Reindex: "Reindex", 89 | "Log level": "Log level", 90 | "Reset floating window position": "Reset floating window position", 91 | "Reset floating window position desc": "In case the window is moved outside the visible area", 92 | "Reset position": "Reset position", 93 | 94 | "Support the Project": "Support the Project", 95 | "Support the Project desc": 96 | "Enjoying this plugin? Show your support with a star on GitHub!", 97 | "Visit GitHub": "Visit GitHub", 98 | }; 99 | -------------------------------------------------------------------------------- /src/services/obsidian/translations/locale/zh-cn.ts: -------------------------------------------------------------------------------- 1 | import { stopWordsEnTargetUrl } from "./en"; 2 | 3 | 4 | export default { 5 | // notification 6 | "Reindexing...": "重建索引中...", 7 | "Indexing finished": "索引完成", 8 | "files need to be indexed. Obsidian may freeze for a while": 9 | "文件需要被索引, Obsidian 可能会卡顿一会儿", 10 | "Omnisearch isn't installed": "未安装 Omnisearch", 11 | "Omnisearch is installed but not enabled": "安装了 Omnisearch 但是没有启用", 12 | "Database has been updated, a reindex is required": 13 | "数据库已更新,需要重建索引", 14 | "Semantic init time": "本地模型索引速度大约是 200-800 字每秒,在此期间请不要关闭 Obsidian", 15 | "Semantic init finished": "语义引擎已就绪", 16 | 17 | "Downloading aiHelper": "正在下载 clever-search-ai-helper.zip (972 MB)...", 18 | "Download success": "下载成功", 19 | "Download failure": "clever-search-ai-helper.zip 下载失败 ", 20 | "Download manually": "点此手动下载", 21 | 22 | // setting tab 23 | "Max items count": "最大候选项数", 24 | "Max items count desc": 25 | "虽然本插件可以搜索到成千上万个结果,但由于底层渲染器性能限制,不能即时渲染所有结果", 26 | 27 | "Floating window for in-file search": "文件内搜索使用悬浮窗口 UI", 28 | "Floating window for in-file search desc": "再次执行 '文件内搜索' 命令会关闭已有的悬浮窗口。关闭这个选项将使用模态框 UI", 29 | 30 | "Case sensitive": "区分大小写", 31 | "Prefix match":"单词前缀匹配", 32 | "Character fuzzy allowed": "允许字符模糊", 33 | 34 | "English word blacklist": "英文单词黑名单", 35 | "English word blacklist desc": `建立索引时,忽略一些含义模糊的单词,比如 "do", "and", "them", 可以加快索引和搜索速度,但是这些单词不会被搜索到. 可以在 ${stopWordsEnTargetUrl} 按需修改单词黑名单`, 36 | "Chinese patch": "中文搜索优化", 37 | "Chinese patch desc": "更好的中文搜索结果", 38 | "Chinese word blacklist": "中文词语黑名单", 39 | "Chinese word blacklist desc": `只在开启中文搜索优化时生效。忽略 "的", "所以", "尽管" 等词语,详细列表在 "stop-words-zh.txt"。详见 "英文单词黑名单" 选项`, 40 | 41 | 42 | "Advanced": "进阶设置", 43 | "Advanced.desc": "之前的设置通常能满足大多数使用场景,如果你想有更个性化的设置,可以调整下面的选项", 44 | 45 | 46 | // semantic search 47 | "Semantic search": "语义搜索", 48 | "Introduction": "介绍", 49 | "Introduction.desc": "语义搜索只支持 Windows 系统。仅推荐中小型资料库使用, 如果某个仓库有超过1500万字, 初次索引可能需要几十个小时。将下载的压缩包解压出的 .cache 文件夹放在 C:\\Users\\, 然后运行 clever-search-ai-helper.exe 来启动语义引擎。语义搜索旨在作为词汇搜索的补充,在精确匹配上效果是不如词汇搜索的", 50 | "Enable": "启用", 51 | "Server type": "服务器类型", 52 | "Server type.desc": "对于本地服务器,clever-search-ai-helper.exe 需要在后台运行; 远程服务器短期内不会实现", 53 | "local": "本地", 54 | "Utilities": "实用功能", 55 | "Test connection": "测试连接", 56 | "Download": "下载", 57 | "Additional Information": "补充信息", 58 | "Additional Information.desc": "在开启语义搜索并运行 ai-helper 的情况下,重新索引 会同时应用于词汇引擎和语义引擎;每次加载插件的时候,语义引擎会自动进行一次增量索引,之后修改文件不会更新索引以避免阻塞语义搜索。", 59 | 60 | "Excluded files": "忽略文件", 61 | Manage: "管理", 62 | "Follow Obsidian Excluded Files": "跟随 Obsidian 忽略文件设置", 63 | "Enter path...": "请输入路径...", 64 | Add: "添加", 65 | 66 | "Customize extensions": "自定义后缀名", 67 | "extensionModal.desc": 68 | "自定义希望索引的文件后缀名。默认情况下 Obsidian 不原生支持的文件类型,如 'txt',将通过外部程序打开。要在 Obsidian 内打开这些文件,可能需要安装插件,如 'obsidian-custom-file-extension-plugin' 或 'obsidian-vscode-editor'。", 69 | "extensionModal.plaintextName": "纯文本", 70 | "extensionModal.plaintextDesc": 71 | "后缀名使用空格或换行符分隔。请不要在此处包含 'pdf'、'jpg'、'mp4' 等无法用记事本打开的二进制文件,否则可能会导致索引问题。另外,索引和搜索 HTML 文件可能会更慢,因为它们首先需要转换为干净的 Markdown 格式。此外,受 API 限制, 自动滚动到 HTML 文件中的特定位置似乎不可行。", 72 | 73 | 74 | 75 | "For Development": "开发设置", 76 | "Collapse development setting by default": "默认折叠开发设置", 77 | "Reindex the vault": "重新索引全库", 78 | Reindex: "重新索引", 79 | "Log level": "日志等级", 80 | "Reset floating window position": "重置悬浮窗口位置", 81 | "Reset floating window position desc": "如果窗口被拖到看不见的位置, 可以点此按钮重置", 82 | "Reset position": "重置位置", 83 | 84 | "Support the Project": "支持这个项目", 85 | "Support the Project desc": 86 | "如果觉得本插件对你有帮助,希望能到 GitHub 点个 star", 87 | "Visit GitHub": "访问 GitHub", 88 | }; 89 | -------------------------------------------------------------------------------- /src/services/obsidian/user-data/data-manager.ts: -------------------------------------------------------------------------------- 1 | import type { AsPlainObject } from "minisearch"; 2 | import { TFile, type TAbstractFile } from "obsidian"; 3 | import { THIS_PLUGIN } from "src/globals/constants"; 4 | import { devOption } from "src/globals/dev-option"; 5 | import { EventEnum } from "src/globals/enums"; 6 | import { OuterSetting } from "src/globals/plugin-setting"; 7 | import type { DocumentRef } from "src/globals/search-types"; 8 | import type CleverSearch from "src/main"; 9 | import { Database } from "src/services/database/database"; 10 | import { LexicalEngine } from "src/services/search/lexical-engine"; 11 | import { SemanticEngine } from "src/services/search/semantic-engine"; 12 | import { BufferSet } from "src/utils/data-structure"; 13 | import { eventBus } from "src/utils/event-bus"; 14 | import { logger } from "src/utils/logger"; 15 | import { 16 | SHOULD_NOT_HAPPEN, 17 | getInstance, 18 | monitorDecorator, 19 | } from "src/utils/my-lib"; 20 | import { singleton } from "tsyringe"; 21 | import { MyNotice } from "../transformed-api"; 22 | import { t } from "../translations/locale-helper"; 23 | import { DataProvider } from "./data-provider"; 24 | import { FileWatcher } from "./file-watcher"; 25 | 26 | @singleton() 27 | export class DataManager { 28 | private plugin: CleverSearch = getInstance(THIS_PLUGIN); 29 | private database = getInstance(Database); 30 | private dataProvider = getInstance(DataProvider); 31 | private lexicalEngine = getInstance(LexicalEngine); 32 | private semanticEngine = getInstance(SemanticEngine); 33 | private semanticConfig = getInstance(OuterSetting).semantic; 34 | private shouldForceRefresh = false; 35 | private isLexicalEngineUpToDate = false; 36 | private isSemanticEngineUpToDate = false; 37 | 38 | private docOperationsHandler = async (operations: DocOperation[]) => { 39 | operations.sort((a, b) => a.time - b.time); 40 | for (const op of operations) { 41 | if (op instanceof DocAddOperation) { 42 | await this.addDocuments([op.file]); 43 | } else if (op instanceof DocDeleteOperation) { 44 | await this.deleteDocuments([op.path]); 45 | } else { 46 | throw Error(SHOULD_NOT_HAPPEN); 47 | } 48 | } 49 | }; 50 | // help avoid unnecessary add or delete operations 51 | private docOperationsBuffer = new BufferSet( 52 | this.docOperationsHandler, 53 | (op) => op.path + op.type, 54 | 3, 55 | ); 56 | 57 | @monitorDecorator 58 | async initAsync() { 59 | await this.database.deleteOldDatabases(); 60 | // TODO: delete old version databases 61 | await this.initLexicalEngine(); 62 | await this.initSemanticEngine(); 63 | 64 | if (!this.shouldForceRefresh) { 65 | // try to update semantic index once clever-search-ai-helper is launched 66 | this.plugin.registerInterval( 67 | window.setInterval(() => { 68 | if (!this.isSemanticEngineUpToDate) { 69 | this.initSemanticEngine(); 70 | } 71 | }, 3500), 72 | ); 73 | // don't need to eventBus.off because the life cycle of this singleton is the same with eventBus 74 | eventBus.on(EventEnum.IN_VAULT_SEARCH, () => 75 | this.docOperationsBuffer.forceFlush(), 76 | ); 77 | getInstance(FileWatcher).start(); 78 | } 79 | } 80 | 81 | onunload() { 82 | getInstance(FileWatcher).stop(); 83 | } 84 | 85 | receiveDocOperation(operation: DocOperation) { 86 | this.docOperationsBuffer.add(operation); 87 | } 88 | 89 | async refreshAllAsync() { 90 | const prevNotice = new MyNotice(t("Reindexing...")); 91 | this.shouldForceRefresh = true; 92 | await this.initAsync(); 93 | prevNotice.hide(); 94 | new MyNotice(t("Indexing finished"), 5000); 95 | this.shouldForceRefresh = false; 96 | } 97 | 98 | private async addDocuments(files: TAbstractFile[], isSemantic = false) { 99 | if (files.length > 0) { 100 | const tFiles: TFile[] = []; 101 | for (const f of files) { 102 | if (f instanceof TFile) { 103 | tFiles.push(f); 104 | } 105 | } 106 | const documents = 107 | await this.dataProvider.generateAllIndexedDocuments( 108 | tFiles.filter((f) => this.dataProvider.isIndexable(f)), 109 | ); 110 | if (!isSemantic) { 111 | await this.lexicalEngine.addDocuments(documents); 112 | } else if (this.semanticConfig.isEnabled) { 113 | await this.semanticEngine.addDocuments(documents); 114 | } 115 | } 116 | } 117 | 118 | private async deleteDocuments(paths: string[], isSemantic = false) { 119 | if (paths.length > 0) { 120 | const indexablePaths = paths.filter((p) => 121 | this.dataProvider.isIndexable(p), 122 | ); 123 | if (!isSemantic) { 124 | this.lexicalEngine.deleteDocuments(indexablePaths); 125 | } else if (this.semanticConfig.isEnabled) { 126 | await this.semanticEngine.deleteDocuments(indexablePaths); 127 | } 128 | } 129 | } 130 | 131 | private async initLexicalEngine() { 132 | logger.trace("Init lexical engine..."); 133 | let prevData: AsPlainObject | null; 134 | if (!devOption.loadIndexFromDatabase || this.shouldForceRefresh) { 135 | prevData = null; 136 | } else { 137 | prevData = await this.database.getMiniSearchData(); 138 | } 139 | if (prevData) { 140 | this.database.deleteMinisearchData(); // for minisearch update 141 | logger.trace("Previous minisearch data is found."); 142 | const isSuccessful = await this.lexicalEngine.reIndexAll(prevData); 143 | if (!isSuccessful) { 144 | new MyNotice( 145 | t("Database has been updated, a reindex is required"), 146 | 7000, 147 | ); 148 | await this.reindexLexicalEngineWithCurrFiles(); 149 | } 150 | } else { 151 | await this.reindexLexicalEngineWithCurrFiles(); 152 | } 153 | if (!this.isLexicalEngineUpToDate) { 154 | // update lexical document refs 155 | await this.updateDocRefByMtime(false); 156 | } 157 | logger.trace("Lexical engine is ready"); 158 | // serialize lexical engine 159 | await this.database.setMiniSearchData( 160 | this.lexicalEngine.filesIndex.toJSON(), 161 | ); 162 | } 163 | 164 | private async reindexLexicalEngineWithCurrFiles() { 165 | logger.trace("Indexing the whole vault..."); 166 | const filesToIndex = this.dataProvider.allFilesToBeIndexed(); 167 | let size = 0; 168 | for (const file of filesToIndex) { 169 | size += file.stat.size; 170 | } 171 | size /= 1024; 172 | if (size > 2000) { 173 | const sizeText = (size / 1024).toFixed(2) + " MB"; 174 | new MyNotice( 175 | `${sizeText} ${t( 176 | "files need to be indexed. Obsidian may freeze for a while", 177 | )}`, 178 | 7000, 179 | ); 180 | } 181 | const documents = 182 | await this.dataProvider.generateAllIndexedDocuments(filesToIndex); 183 | await this.lexicalEngine.reIndexAll(documents); 184 | this.isLexicalEngineUpToDate = true; 185 | } 186 | 187 | // use case: users have changed files without obsidian open. so we need to update the index and refs 188 | private async updateDocRefByMtime(isSemantic: boolean) { 189 | // update index data based on file modification time 190 | const currFiles = new Map( 191 | this.dataProvider 192 | .allFilesToBeIndexed() 193 | .map((file) => [file.path, file]), 194 | ); 195 | const preRefsList = isSemantic 196 | ? await this.database.getSemanticDocRefs() 197 | : await this.database.getLexicalDocRefs(); 198 | const prevRefs = new Map( 199 | preRefsList?.map((ref) => [ref.path, ref]), 200 | ); 201 | 202 | const docsToAdd: TAbstractFile[] = []; 203 | const docsToDelete: string[] = []; 204 | 205 | for (const [path, file] of currFiles) { 206 | const prevRef = prevRefs.get(path); 207 | if (!prevRef) { 208 | // to add 209 | docsToAdd.push(file); 210 | } else if (file.stat.mtime > prevRef.updateTime) { 211 | // to update 212 | docsToDelete.push(file.path); 213 | docsToAdd.push(file); 214 | } 215 | } 216 | 217 | // to delete 218 | for (const prevPath of prevRefs.keys()) { 219 | if (!currFiles.has(prevPath)) { 220 | docsToDelete.push(prevPath); 221 | } 222 | } 223 | 224 | // perform batch delete and add operations 225 | logger.trace(`docs to delete: ${docsToDelete.length}`); 226 | logger.trace(`docs to add: ${docsToAdd.length}`); 227 | await this.deleteDocuments(docsToDelete, isSemantic); 228 | await this.addDocuments(docsToAdd, isSemantic); 229 | await this.saveDocRefs(Array.from(currFiles.values()), isSemantic); 230 | } 231 | 232 | private async saveDocRefs(files: TFile[], isSemantic: boolean) { 233 | const updatedRefs = files.map((file) => ({ 234 | path: file.path, 235 | updateTime: file.stat.mtime, 236 | })); 237 | if (isSemantic) { 238 | await this.database.setSemanticDocRefs(updatedRefs); 239 | logger.trace(`${updatedRefs.length} semantic refs updated`); 240 | } else { 241 | await this.database.setLexicalDocRefs(updatedRefs); 242 | logger.trace(`${updatedRefs.length} lexical refs updated`); 243 | } 244 | } 245 | 246 | async initSemanticEngine() { 247 | if (this.semanticConfig.isEnabled) { 248 | const doesCollectionExist = 249 | await this.semanticEngine.doesCollectionExist(); 250 | 251 | // failed to connect to ai-helper 252 | if (doesCollectionExist === null) { 253 | return; 254 | } 255 | if (doesCollectionExist === false || this.shouldForceRefresh) { 256 | let prevNotice = null; 257 | if (this.semanticConfig.serverType === "local") { 258 | prevNotice = new MyNotice(t("Semantic init time"), 0); 259 | } 260 | const filesToIndex = this.dataProvider.allFilesToBeIndexed(); 261 | await this.saveDocRefs(filesToIndex, true); 262 | const documents = 263 | await this.dataProvider.generateAllIndexedDocuments( 264 | filesToIndex, 265 | ); 266 | await this.semanticEngine.reindexAll(documents); 267 | if (prevNotice) { 268 | prevNotice.hide(); 269 | } 270 | new MyNotice(t("Semantic init finished"), 0); 271 | this.isSemanticEngineUpToDate = true; 272 | } 273 | if (!this.isSemanticEngineUpToDate) { 274 | this.isSemanticEngineUpToDate = true; 275 | await this.updateDocRefByMtime(true); 276 | } 277 | logger.trace("Semantic engine is ready"); 278 | } 279 | } 280 | } 281 | 282 | abstract class DocOperation { 283 | readonly type: "add" | "delete"; 284 | readonly path: string; 285 | readonly time: number = performance.now(); 286 | constructor(type: "add" | "delete", fileOrPath: string | TAbstractFile) { 287 | this.type = type; 288 | if (typeof fileOrPath === "string") { 289 | this.path = fileOrPath; 290 | } else { 291 | this.path = fileOrPath.path; 292 | } 293 | } 294 | } 295 | 296 | export class DocAddOperation extends DocOperation { 297 | readonly file: TAbstractFile; 298 | constructor(file: TAbstractFile) { 299 | super("add", file); 300 | this.file = file; 301 | } 302 | } 303 | 304 | export class DocDeleteOperation extends DocOperation { 305 | constructor(path: string) { 306 | super("delete", path); 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /src/services/obsidian/user-data/data-provider.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | TAbstractFile, 4 | TFile, 5 | TFolder, 6 | Vault, 7 | htmlToMarkdown, 8 | parseFrontMatterAliases, 9 | type CachedMetadata, 10 | } from "obsidian"; 11 | import { OuterSetting } from "src/globals/plugin-setting"; 12 | import type { IndexedDocument } from "src/globals/search-types"; 13 | import { logger } from "src/utils/logger"; 14 | import { TO_BE_IMPL, getInstance } from "src/utils/my-lib"; 15 | import { singleton } from "tsyringe"; 16 | import { FileUtil } from "../../../utils/file-util"; 17 | import { PrivateApi } from "../private-api"; 18 | import { ViewRegistry, ViewType } from "../view-registry"; 19 | 20 | @singleton() 21 | export class DataProvider { 22 | private readonly vault = getInstance(Vault); 23 | private readonly app = getInstance(App); 24 | private readonly setting = getInstance(OuterSetting); 25 | private readonly privateApi = getInstance(PrivateApi); 26 | private readonly viewRegistry = getInstance(ViewRegistry); 27 | private readonly htmlParser = getInstance(HtmlParser); 28 | private excludedPaths: Set; 29 | private supportedExtensions: Set; 30 | 31 | private static readonly contentIndexableViewTypes = new Set([ 32 | ViewType.MARKDOWN, 33 | ]); 34 | 35 | constructor() { 36 | this.init(); 37 | } 38 | 39 | // update internal states based on OuterSetting 40 | init() { 41 | this.excludedPaths = new Set(this.setting.excludedPaths); 42 | this.supportedExtensions = new Set( 43 | this.setting.customExtensions.plaintext, 44 | ); 45 | } 46 | 47 | async generateAllIndexedDocuments( 48 | files: TFile[], 49 | ): Promise { 50 | return Promise.all( 51 | files.map(async (file) => { 52 | if (this.isContentIndexable(file)) { 53 | const metaData = this.app.metadataCache.getFileCache(file); 54 | if ( 55 | this.viewRegistry.viewTypeByPath(file.path) === 56 | ViewType.MARKDOWN 57 | ) { 58 | return { 59 | path: file.path, 60 | basename: file.basename, 61 | folder: FileUtil.getFolderPath(file.path), 62 | aliases: this.parseAliases(metaData), 63 | tags: this.parseTags(metaData), 64 | headings: this.parseHeadings(metaData), 65 | content: await this.readPlainText(file), 66 | } as IndexedDocument; 67 | } else { 68 | throw new Error(TO_BE_IMPL); 69 | } 70 | } else { 71 | return { 72 | path: file.path, 73 | basename: file.basename, 74 | folder: FileUtil.getFolderPath(file.path), 75 | }; 76 | } 77 | }), 78 | ); 79 | } 80 | 81 | // @monitorDecorator 82 | allFilesToBeIndexed(): TFile[] { 83 | // get all fileRefs cached by obsidian 84 | const files = this.vault.getFiles(); 85 | logger.debug(`all files: ${files.length}`); 86 | FileUtil.countFileByExtensions(files); 87 | 88 | const filesToIndex = files.filter((file) => this.isIndexable(file)); 89 | logger.debug(`indexable files: ${filesToIndex.length}`); 90 | FileUtil.countFileByExtensions(filesToIndex); 91 | 92 | return filesToIndex; 93 | } 94 | 95 | isIndexable(fileOrPath: TFile | TAbstractFile | string): boolean { 96 | if (fileOrPath instanceof TFolder) { 97 | return false; 98 | } 99 | let path: string; 100 | if (typeof fileOrPath === "string") { 101 | path = fileOrPath; 102 | } else { 103 | path = fileOrPath.path; 104 | } 105 | // TODO: filter by extensions and paths 106 | return ( 107 | this.supportedExtensions.has(FileUtil.getExtension(path)) && 108 | path.lastIndexOf("excalidraw.md") === -1 && 109 | (this.setting.followObsidianExcludedFiles 110 | ? this.privateApi.isNotObsidianExcludedPath(path) 111 | : true) && 112 | (this.excludedPaths.size === 0 113 | ? true 114 | : this.isNotCustomExcludedPath(path)) 115 | ); 116 | } 117 | 118 | // @monitorDecorator 119 | /** 120 | * Reads the content of a plain text file. 121 | * @param fileOrPath The file object or path string of the file to read. 122 | * @returns The content of the file as a string. 123 | * @throws Error if the file extension is not supported. 124 | */ 125 | async readPlainText(fileOrPath: TFile | string): Promise { 126 | const file = 127 | typeof fileOrPath === "string" 128 | ? this.vault.getAbstractFileByPath(fileOrPath) 129 | : fileOrPath; 130 | if (file instanceof TFile) { 131 | if ( 132 | this.viewRegistry.viewTypeByPath(file.path) === 133 | ViewType.MARKDOWN 134 | ) { 135 | const plainText = await this.vault.cachedRead(file); 136 | // return plainText; 137 | return file.extension === "html" 138 | ? this.htmlParser.toMarkdown(plainText) 139 | : plainText; 140 | } else { 141 | throw Error( 142 | `unsupported file extension as plain text to read, path: ${file.path}`, 143 | ); 144 | } 145 | } else { 146 | return ""; 147 | } 148 | } 149 | 150 | async readPlainTextLines(fileOrPath: TFile | string): Promise { 151 | return (await this.readPlainText(fileOrPath)).split(FileUtil.SPLIT_EOL); 152 | } 153 | 154 | private parseAliases(metadata: CachedMetadata | null): string { 155 | return (parseFrontMatterAliases(metadata?.frontmatter) || []).join(" "); 156 | } 157 | 158 | private parseTags(metaData: CachedMetadata | null): string { 159 | return metaData?.tags?.map((t) => t.tag.slice(1)).join(" ") || ""; 160 | } 161 | 162 | private parseHeadings(metadata: CachedMetadata | null): string { 163 | return metadata?.headings?.map((h) => h.heading).join(" ") || ""; 164 | } 165 | 166 | private isContentIndexable(file: TFile): boolean { 167 | return DataProvider.contentIndexableViewTypes.has( 168 | this.viewRegistry.viewTypeByPath(file.path), 169 | ); 170 | } 171 | 172 | private isNotCustomExcludedPath(path: string) { 173 | const parts = path.split("/"); 174 | let currentPath = ""; 175 | 176 | for (const part of parts) { 177 | currentPath += (currentPath ? "/" : "") + part; 178 | if (this.excludedPaths.has(currentPath)) { 179 | return false; 180 | } 181 | } 182 | return true; 183 | } 184 | } 185 | 186 | @singleton() 187 | class HtmlParser { 188 | private readonly MARKDOWN_LINK_REGEX = /\[([^[\]]+)\]\([^()]*\)/g; 189 | private readonly MARKDOWN_ASTERISK_REGEX = /\*\*(.*?)\*\*/g; 190 | private readonly MARKDOWN_BACKTICK_REGEX = /`(.*?)`/g; 191 | 192 | toMarkdown(htmlText: string) { 193 | // use a replacement function to determine how to replace the matched content 194 | let cleanMarkdown = htmlToMarkdown(htmlText); 195 | // 使用替换函数来确定如何替换匹配到的内容 196 | cleanMarkdown = cleanMarkdown.replace(this.MARKDOWN_LINK_REGEX, "$1"); 197 | cleanMarkdown = cleanMarkdown.replace( 198 | this.MARKDOWN_ASTERISK_REGEX, 199 | "$1", 200 | ); 201 | cleanMarkdown = cleanMarkdown.replace( 202 | this.MARKDOWN_BACKTICK_REGEX, 203 | "$1", 204 | ); 205 | return cleanMarkdown; 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/services/obsidian/user-data/file-watcher.ts: -------------------------------------------------------------------------------- 1 | import { App, TAbstractFile } from "obsidian"; 2 | import { logger } from "src/utils/logger"; 3 | import { getInstance } from "src/utils/my-lib"; 4 | import { singleton } from "tsyringe"; 5 | import { DataManager, DocAddOperation, DocDeleteOperation } from "./data-manager"; 6 | 7 | @singleton() 8 | export class FileWatcher { 9 | private readonly dataManager = getInstance(DataManager); 10 | private readonly app = getInstance(App); 11 | 12 | start() { 13 | this.stop(); // in case THIS_PLUGIN.onunload isn't called correctly, sometimes it happens 14 | this.app.vault.on("create", this.onCreate); 15 | this.app.vault.on("delete", this.onDelete); 16 | this.app.vault.on("rename", this.onRename); 17 | this.app.vault.on("modify", this.onModify); 18 | logger.trace("FileWatcher started"); 19 | } 20 | 21 | stop() { 22 | this.app.vault.off("create", this.onCreate); 23 | this.app.vault.off("delete", this.onDelete); 24 | this.app.vault.off("rename", this.onRename); 25 | this.app.vault.off("modify", this.onModify); 26 | } 27 | 28 | // should define callbacks as arrow functions rather than methods, 29 | // otherwise `this` will be changed when used as callbacks 30 | private readonly onCreate = (file: TAbstractFile) => { 31 | logger.debug(`created: ${file.path}`); 32 | this.dataManager.receiveDocOperation(new DocAddOperation(file)); 33 | }; 34 | private readonly onDelete = (file: TAbstractFile) => { 35 | logger.debug(`deleted: ${file.path}`); 36 | this.dataManager.receiveDocOperation(new DocDeleteOperation(file.path)); 37 | }; 38 | private readonly onRename = (file: TAbstractFile, oldPath: string) => { 39 | logger.debug(`renamed: ${oldPath} => ${file.path}`); 40 | this.dataManager.receiveDocOperation(new DocDeleteOperation(oldPath)); 41 | this.dataManager.receiveDocOperation(new DocAddOperation(file)); 42 | }; 43 | private readonly onModify = (file: TAbstractFile) => { 44 | logger.debug(`modified: ${file.path}`); 45 | this.dataManager.receiveDocOperation(new DocDeleteOperation(file.path)); 46 | this.dataManager.receiveDocOperation(new DocAddOperation(file)); 47 | }; 48 | 49 | } -------------------------------------------------------------------------------- /src/services/obsidian/user-data/recent-file-manager.ts: -------------------------------------------------------------------------------- 1 | import { App, MarkdownView, WorkspaceLeaf } from "obsidian"; 2 | import { THIS_PLUGIN } from "src/globals/constants"; 3 | import type CleverSearch from "src/main"; 4 | import { logger } from "src/utils/logger"; 5 | import { getInstance } from "src/utils/my-lib"; 6 | import { debounce } from "throttle-debounce"; 7 | import { singleton } from "tsyringe"; 8 | 9 | @singleton() 10 | export class RecentFileManager { 11 | private plugin: CleverSearch = getInstance(THIS_PLUGIN); 12 | private app: App = getInstance(App); 13 | private scrollElement: HTMLElement | null = null; 14 | private handleScroll = () => { 15 | const scrollTop = this.scrollElement?.scrollTop || 0; 16 | console.log("curr scrollTop:", scrollTop); 17 | const scrollInfo: ScrollInfo = {top: scrollTop, left: 0}; 18 | }; 19 | private handleScrollDebounced = debounce(1000, () => this.handleScroll()); 20 | 21 | init() { 22 | this.plugin.registerEvent( 23 | this.app.workspace.on( 24 | "active-leaf-change", 25 | async (newLeaf: WorkspaceLeaf | null) => { 26 | if (newLeaf && newLeaf.view instanceof MarkdownView) { 27 | if (this.scrollElement) { 28 | logger.info("remove listener..."); 29 | this.scrollElement.removeEventListener( 30 | "scroll", 31 | this.handleScrollDebounced, 32 | true, 33 | ); 34 | } 35 | 36 | // TODO: 处理编辑和阅读模式,类不一样(.cm-scroller) 37 | // 阅读模式 38 | // this.scrollElement = document.querySelector(".markdown-preview-view"); 39 | // 编辑模式 40 | this.scrollElement = 41 | newLeaf.view.containerEl.querySelector( 42 | ".cm-scroller", 43 | ); 44 | if (this.scrollElement) { 45 | logger.info("add listener..."); 46 | this.scrollElement.addEventListener( 47 | "scroll", 48 | this.handleScrollDebounced, 49 | ); 50 | } else { 51 | logger.error("can't find scrollElement"); 52 | } 53 | } 54 | }, 55 | ), 56 | ); 57 | } 58 | 59 | onAppQuit() {} 60 | } 61 | 62 | type ScrollInfo = { 63 | top: number; 64 | left: number; 65 | }; 66 | -------------------------------------------------------------------------------- /src/services/obsidian/view-registry.ts: -------------------------------------------------------------------------------- 1 | import { THIS_PLUGIN } from "src/globals/constants"; 2 | import { OuterSetting } from "src/globals/plugin-setting"; 3 | import type CleverSearch from "src/main"; 4 | import { FileUtil } from "src/utils/file-util"; 5 | import { getInstance } from "src/utils/my-lib"; 6 | import { singleton } from "tsyringe"; 7 | 8 | // inspired by https://github.com/MeepTech/obsidian-custom-file-extensions-plugin 9 | 10 | @singleton() 11 | export class ViewRegistry { 12 | private readonly setting = getInstance(OuterSetting); 13 | // BUG: `private readonly markdownExtensions = this.setting.customExtensions.plaintext` 14 | // won't be updated if the source array is changed. Current solution is to get the latest setting by getInstance(OuterSetting); 15 | 16 | // private readonly markdownExtensions = ["md", "txt", "html"]; 17 | // private readonly pdfExtensions = ["pdf"]; 18 | // private readonly canvasExtensions = ["canvas"]; 19 | // private readonly imageExtensions = ["jpg", "jpeg", "png", "gif", "svg"]; 20 | // private readonly audioExtensions = ["mp3", "wav"]; 21 | // private readonly videoExtensions = ["mp4", "webm"]; 22 | private readonly extensionViewMap = new Map(); 23 | private readonly plugin: CleverSearch = getInstance(THIS_PLUGIN); 24 | 25 | init() { 26 | this.fillMap(this.setting.customExtensions.plaintext, ViewType.MARKDOWN); 27 | // this.fillMap(this.pdfExtensions, ViewType.PDF); 28 | // this.fillMap(this.canvasExtensions, ViewType.CANVAS); 29 | // this.fillMap(this.imageExtensions, ViewType.IMAGE); 30 | // this.fillMap(this.audioExtensions, ViewType.AUDIO); 31 | // this.fillMap(this.videoExtensions, ViewType.VIDEO); 32 | 33 | // register additional extensions with existing obsidian ViewType 34 | // so that users can open files in the obsidian with these extensions 35 | // see all viewTypes by (getInstance(App) as any).viewRegistry.viewByType 36 | try { 37 | // this.plugin.registerExtensions(["txt"], ViewType.MARKDOWN); 38 | } catch (e) { 39 | // do nothing to suppress the error when using hot-reload for development which triggers the `plugin.onload()` multiple times 40 | } 41 | } 42 | 43 | // return the viewType in obsidian by path 44 | viewTypeByPath(path: string): ViewType { 45 | const viewType = this.extensionViewMap.get(FileUtil.getExtension(path)); 46 | return viewType === undefined ? ViewType.UNSUPPORTED : viewType; 47 | } 48 | 49 | refreshAll() { 50 | this.extensionViewMap.clear() 51 | this.init(); 52 | } 53 | 54 | private fillMap(extensions: string[], viewType: ViewType) { 55 | for (const ext of extensions) { 56 | this.extensionViewMap.set(ext, viewType); 57 | } 58 | } 59 | } 60 | 61 | export enum ViewType { 62 | UNSUPPORTED = "unsupported", 63 | MARKDOWN = "markdown", 64 | PDF = "pdf", 65 | CANVAS = "canvas", 66 | IMAGE = "image", 67 | AUDIO = "audio", 68 | VIDEO = "video", 69 | } 70 | -------------------------------------------------------------------------------- /src/services/search/semantic-engine.ts: -------------------------------------------------------------------------------- 1 | import type { RequestUrlResponse } from "obsidian"; 2 | import { 3 | FileItem, 4 | FileSubItem, 5 | type IndexedDocument, 6 | } from "src/globals/search-types"; 7 | import { logger } from "src/utils/logger"; 8 | import { SHOULD_NOT_HAPPEN, getInstance } from "src/utils/my-lib"; 9 | import { HttpClient } from "src/utils/web/http-client"; 10 | import { singleton } from "tsyringe"; 11 | import { PrivateApi } from "../obsidian/private-api"; 12 | import { MyNotice } from "../obsidian/transformed-api"; 13 | import { ViewRegistry, ViewType } from "../obsidian/view-registry"; 14 | 15 | @singleton() 16 | export class SemanticEngine { 17 | private request = getInstance(RemoteRequest); 18 | private viewRegistry = getInstance(ViewRegistry); 19 | private _status: "stopped" | "indexing" | "ready" = "stopped"; 20 | 21 | get status() { 22 | return this._status; 23 | } 24 | 25 | async testConnection(): Promise { 26 | const connected = await this.request.testConnection(); 27 | if (connected) { 28 | new MyNotice("Connected", 5000); 29 | } else { 30 | new MyNotice("Failed to connect", 5000); 31 | } 32 | } 33 | 34 | /** 35 | * @throws Error 36 | */ 37 | async doesCollectionExist(): Promise { 38 | try { 39 | const result = await this.request.doesCollectionExist(); 40 | this._status = "ready"; 41 | return result; 42 | } catch (e) { 43 | this._status = "stopped"; 44 | logger.warn( 45 | "failed to connect to ai-helper. launch clever-search-ai-helper or disable semantic search in the setting to avoid this warning.", 46 | ); 47 | logger.error(e); 48 | return null; 49 | } 50 | } 51 | 52 | async reindexAll(indexedDocs: IndexedDocument[]) { 53 | this._status = "indexing"; 54 | const docs = this.convertToDocuments(indexedDocs); 55 | try { 56 | await this.request.reindexAll(docs); 57 | this._status = "ready"; 58 | } catch (e) { 59 | logger.error(e); 60 | this._status = "stopped"; 61 | } 62 | } 63 | 64 | async addDocuments(indexedDocs: IndexedDocument[]): Promise { 65 | this._status = "indexing"; 66 | const docs = this.convertToDocuments(indexedDocs); 67 | try { 68 | const result = await this.request.addDocuments(docs); 69 | this._status = "ready"; 70 | return result; 71 | } catch (e) { 72 | logger.error(e); 73 | this._status = "stopped"; 74 | return false; 75 | } 76 | } 77 | 78 | async deleteDocuments(paths: string[]): Promise { 79 | this._status = "indexing"; 80 | try { 81 | const result = await this.request.deleteDocuments(paths); 82 | this._status = "ready"; 83 | return result; 84 | } catch (e) { 85 | logger.error(e); 86 | this._status = "stopped"; 87 | return false; 88 | } 89 | } 90 | 91 | async search(queryText: string, viewType: ViewType): Promise { 92 | try { 93 | const rawResults = await this.request.search(queryText, viewType); 94 | this._status = "ready"; 95 | 96 | return rawResults.map((rawResult) => { 97 | const subItems = rawResult.subItems.map( 98 | (subItemData) => 99 | new FileSubItem( 100 | subItemData.text, 101 | subItemData.row, 102 | subItemData.col, 103 | ), 104 | ); 105 | 106 | return new FileItem( 107 | rawResult.engineType, 108 | rawResult.path, 109 | rawResult.queryTerms, 110 | rawResult.matchedTerms, 111 | subItems, 112 | rawResult.previewContent, 113 | ); 114 | }); 115 | } catch (e) { 116 | logger.error(e); 117 | this._status = "stopped"; 118 | return []; 119 | } 120 | } 121 | 122 | async docsCount(): Promise { 123 | return this.request.docsCount(); 124 | } 125 | 126 | private convertToDocuments(indexedDocs: IndexedDocument[]): Document[] { 127 | return indexedDocs.map((x) => { 128 | return { 129 | path: x.path, 130 | view_type: this.viewRegistry.viewTypeByPath(x.path), 131 | content: x.content, 132 | } as Document; 133 | }); 134 | } 135 | } 136 | 137 | @singleton() 138 | class RemoteRequest { 139 | private responseProcessor = (resp: RequestUrlResponse): any | null => { 140 | if (resp.status === 200) { 141 | const res = resp.json as Result; 142 | if (res.code === 0) { 143 | return res.data; 144 | } else if (res.code === -1) { 145 | logger.error(`KnownException: ${res.message}`); 146 | return null; 147 | } else if (res.code === -2) { 148 | logger.error(`UnknownException: ${res.message}`); 149 | return null; 150 | } else { 151 | throw Error(SHOULD_NOT_HAPPEN); 152 | } 153 | } else { 154 | logger.error(resp.text); 155 | return null; 156 | } 157 | }; 158 | private client = new HttpClient({ 159 | baseUrl: "localhost:19528/api", 160 | protocol: "http", 161 | responseProcessor: this.responseProcessor, 162 | headers: { "X-vaultId": getInstance(PrivateApi).getAppId() }, 163 | }); 164 | 165 | async testConnection(): Promise { 166 | try { 167 | const connected = await this.client.get("testConnection"); 168 | return connected ? true : false; 169 | } catch (e) { 170 | logger.error(e); 171 | return false; 172 | } 173 | } 174 | 175 | async doesCollectionExist(): Promise { 176 | return await this.client.get("doesCollectionExist", undefined); 177 | } 178 | 179 | async reindexAll(docs: Document[]) { 180 | await this.client.post("reindexAll", undefined, docs); 181 | } 182 | 183 | async docsCount(): Promise { 184 | try { 185 | const count = await this.client.get("docsCount"); 186 | return count; 187 | } catch (e) { 188 | logger.error(e); 189 | return null; 190 | } 191 | } 192 | 193 | async addDocuments(docs: Document[]): Promise { 194 | return await this.client.post("addDocuments", undefined, docs); 195 | } 196 | 197 | async deleteDocuments(paths: string[]): Promise { 198 | return await this.client.post("deleteDocuments", undefined, paths); 199 | } 200 | 201 | async search(queryText: string, viewType: ViewType): Promise { 202 | return await this.client.get("search", { 203 | queryText, 204 | viewType, 205 | }); 206 | } 207 | } 208 | 209 | type Document = { 210 | path: string; 211 | view_type: ViewType; 212 | content: string; 213 | }; 214 | 215 | type Result = { 216 | data: any; 217 | message: string; 218 | code: number; 219 | }; 220 | -------------------------------------------------------------------------------- /src/services/search/tokenizer.ts: -------------------------------------------------------------------------------- 1 | import { OuterSetting } from "src/globals/plugin-setting"; 2 | import { ChinesePatch } from "src/integrations/languages/chinese-patch"; 3 | import { CHINESE_REGEX } from "src/utils/lang-util"; 4 | import { logger } from "src/utils/logger"; 5 | import { getInstance } from "src/utils/my-lib"; 6 | import { AssetsProvider } from "src/utils/web/assets-provider"; 7 | import { throttle } from "throttle-debounce"; 8 | import { singleton } from "tsyringe"; 9 | 10 | const SEGMENT_REGEX = /[\[\]{}()<>\s]+/u; 11 | // Thanks to @scambier's Omnisearch, whose code was served as a reference 12 | 13 | // ^=#%/*,.`:;?@_ 14 | const SEPERATOR_REGEX = 15 | /[\^=#%\/\*,\.`:;\?@\s\u00A0\u00A1\u00A7\u00AB\u00B6\u00B7\u00BB\u00BF\u037E\u0387\u055A-\u055F\u0589\u058A\u05BE\u05C0\u05C3\u05C6\u05F3\u05F4\u0609\u060A\u060C\u060D\u061B\u061E\u061F\u066A-\u066D\u06D4\u0700-\u070D\u07F7-\u07F9\u0830-\u083E\u085E\u0964\u0965\u0970\u09FD\u0A76\u0AF0\u0C77\u0C84\u0DF4\u0E4F\u0E5A\u0E5B\u0F04-\u0F12\u0F14\u0F3A-\u0F3D\u0F85\u0FD0-\u0FD4\u0FD9\u0FDA\u104A-\u104F\u10FB\u1360-\u1368\u1400\u166E\u1680\u169B\u169C\u16EB-\u16ED\u1735\u1736\u17D4-\u17D6\u17D8-\u17DA\u1800-\u180A\u1944\u1945\u1A1E\u1A1F\u1AA0-\u1AA6\u1AA8-\u1AAD\u1B5A-\u1B60\u1BFC-\u1BFF\u1C3B-\u1C3F\u1C7E\u1C7F\u1CC0-\u1CC7\u1CD3\u2000-\u200A\u2010-\u2029\u202F-\u2043\u2045-\u2051\u2053-\u205F\u207D\u207E\u208D\u208E\u2308-\u230B\u2329\u232A\u2768-\u2775\u27C5\u27C6\u27E6-\u27EF\u2983-\u2998\u29D8-\u29DB\u29FC\u29FD\u2CF9-\u2CFC\u2CFE\u2CFF\u2D70\u2E00-\u2E2E\u2E30-\u2E4F\u3000-\u3003\u3008-\u3011\u3014-\u301F\u3030\u303D\u30A0\u30FB\uA4FE\uA4FF\uA60D-\uA60F\uA673\uA67E\uA6F2-\uA6F7\uA874-\uA877\uA8CE\uA8CF\uA8F8-\uA8FA\uA8FC\uA92E\uA92F\uA95F\uA9C1-\uA9CD\uA9DE\uA9DF\uAA5C-\uAA5F\uAADE\uAADF\uAAF0\uAAF1\uABEB\uFD3E\uFD3F\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE61\uFE63\uFE68\uFE6A\uFE6B\uFF01-\uFF03\uFF05-\uFF0A\uFF0C-\uFF0F\uFF1A\uFF1B\uFF1F\uFF20\uFF3B-\uFF3D\uFF3F\uFF5B\uFF5D\uFF5F-\uFF65]+/u; 16 | 17 | // const CAMEL_CASE_REGEX = /([a-z](?=[A-Z]))/g; 18 | const HYPHEN_AND_CAMEL_CASE_REGEX = /[-_]|([a-z](?=[A-Z]))/g; 19 | 20 | 21 | @singleton() 22 | export class Tokenizer { 23 | private readonly setting = getInstance(OuterSetting); 24 | private readonly chsSegmenter = getInstance(ChinesePatch); 25 | private logThrottled = throttle(300, (any: any) => logger.info(any)); 26 | private readonly assetsProvider = getInstance(AssetsProvider); 27 | private readonly stopWordsZh = this.assetsProvider.assets.stopWordsZh; 28 | private readonly stopWordsEn = this.assetsProvider.assets.stopWordsEn; 29 | 30 | // TODO: synonym and lemmatization 31 | tokenize(text: string, mode: "index" | "search"): string[] { 32 | const tokens = new Set(); 33 | 34 | const segments = text.split(SEGMENT_REGEX); 35 | 36 | // TODO: extract path for search 37 | for (const segment of segments) { 38 | if (!segment) continue; // skip empty strings 39 | if ( 40 | this.setting.enableChinesePatch && 41 | CHINESE_REGEX.test(segment) 42 | ) { 43 | const words = this.chsSegmenter.cut(segment, true); 44 | for (const word of words) { 45 | if ( 46 | this.setting.enableStopWordsZh && 47 | this.stopWordsZh?.has(word) 48 | ) { 49 | if (mode === "search" && word.length > 1) { 50 | logger.debug(`excluded: ${word}`); 51 | } 52 | continue; 53 | } 54 | tokens.add(word); 55 | } 56 | } else { 57 | // don't add too short or too long segment for smallCharsetLanguage 58 | // TODO: is this step necessary? 59 | // if (segment.length > 1 && segment.length < 20) { 60 | // tokens.add(segment); 61 | // } 62 | 63 | const words = segment.split(SEPERATOR_REGEX); 64 | for (const word of words) { 65 | if ( 66 | word.length < 2 || // don't index single char for small charset 67 | (this.setting.enableStopWordsEn && 68 | this.stopWordsEn?.has(word)) 69 | ) { 70 | continue; 71 | } 72 | tokens.add(word); 73 | 74 | if (word.length > 3) { 75 | const subwords = word 76 | .replace(HYPHEN_AND_CAMEL_CASE_REGEX, "$1 ") 77 | .split(" "); 78 | for (const subword of subwords) { 79 | if (subword.length > 1) { 80 | tokens.add(subword); 81 | } 82 | } 83 | } 84 | } 85 | } 86 | } 87 | // discard lengthy token to avoid memory-overflow 88 | return Array.from(tokens).filter(token => token.length < 30); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/services/search/truncate-option.ts: -------------------------------------------------------------------------------- 1 | import { LanguageEnum } from "src/globals/enums"; 2 | import { LangUtil } from "src/utils/lang-util"; 3 | 4 | export type TruncateType = "line" | "paragraph" | "subItem"; 5 | 6 | export type TruncateLimit = { 7 | maxPreLines: number; 8 | maxPostLines: number; 9 | maxPreChars: number; 10 | maxPostChars: number; // include the EOL 11 | /** 12 | * if (currLine !== matchedLine && isFirstOrLastLine(currLine) && currLine.text.length < boundaryLineMinChars) 13 | * currLine won't be added to the context 14 | */ 15 | boundaryLineMinChars: number; 16 | }; 17 | 18 | export type AllTruncateOption = { 19 | [key in TruncateType]: TruncateLimit; 20 | }; 21 | 22 | export class TruncateOption { 23 | // default truncate options for all types and languages 24 | private static readonly default: AllTruncateOption = { 25 | line: { 26 | maxPreLines: 0, 27 | maxPostLines: 0, 28 | maxPreChars: 30, 29 | maxPostChars: 230, 30 | boundaryLineMinChars: 4, 31 | }, 32 | paragraph: { 33 | maxPreLines: 4, 34 | maxPostLines: 7, 35 | maxPreChars: 220, 36 | maxPostChars: 600, 37 | boundaryLineMinChars: 4, 38 | }, 39 | subItem: { 40 | maxPreLines: 3, 41 | maxPostLines: 3, 42 | maxPreChars: 180, 43 | maxPostChars: 200, 44 | boundaryLineMinChars: 4, 45 | }, 46 | }; 47 | 48 | // truncate options set by language 49 | private static readonly limitsByLanguage: Record< 50 | LanguageEnum, 51 | AllTruncateOption 52 | > = { 53 | [LanguageEnum.other]: TruncateOption.default, 54 | [LanguageEnum.en]: TruncateOption.default, 55 | [LanguageEnum.zh]: { 56 | line: { ...this.default.line, maxPreChars: 30, maxPostChars: 230 }, 57 | paragraph: { 58 | ...this.default.paragraph, 59 | maxPreChars: 220, 60 | maxPostChars: 600, 61 | }, 62 | subItem: { 63 | ...this.default.subItem, 64 | maxPreChars: 60, 65 | maxPostChars: 80, 66 | }, 67 | }, 68 | }; 69 | 70 | /** 71 | * retrieve the truncate options for a given type in the current language. 72 | */ 73 | // TODO: use token if performance permits. token: normal char +1 wide char +2 74 | static forType(type: TruncateType, text?: string): TruncateLimit { 75 | // return the option by char type, normal char or wide char 76 | if (text && LangUtil.testWideChar(text)) { 77 | return this.limitsByLanguage[LanguageEnum.zh][type]; 78 | } else { 79 | // return the default option 80 | return this.limitsByLanguage[LanguageEnum.en][type]; 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/ui/floating-window.ts: -------------------------------------------------------------------------------- 1 | import { OuterSetting } from "src/globals/plugin-setting"; 2 | import { SettingManager } from "src/services/obsidian/setting-manager"; 3 | import { singleton } from "tsyringe"; 4 | import { SearchType } from "../globals/search-types"; 5 | import { TO_BE_IMPL, getInstance } from "../utils/my-lib"; 6 | import MountedModal from "./MountedModal.svelte"; 7 | 8 | @singleton() 9 | export class FloatingWindowManager { 10 | toggle(windowType: "inFile" | "inVault") { 11 | if (windowType === "inFile") { 12 | getInstance(InFileFloatingWindow).toggle(); 13 | } else { 14 | throw Error(TO_BE_IMPL); 15 | } 16 | } 17 | 18 | resetAllPositions() { 19 | const uiSetting = getInstance(OuterSetting).ui; 20 | 21 | uiSetting.inFileFloatingWindowLeft = "2.7em"; 22 | uiSetting.inFileFloatingWindowTop = "2.5em"; 23 | getInstance(FloatingWindowManager).toggle("inFile"); 24 | getInstance(FloatingWindowManager).toggle("inFile"); 25 | } 26 | 27 | onunload() { 28 | getInstance(InFileFloatingWindow).onClose(); 29 | } 30 | } 31 | abstract class FloatingWindow { 32 | private isDragging = false; 33 | private dragStartX = 0; 34 | private dragStartY = 0; 35 | private isResizing = false; 36 | private resizeStartX = 0; 37 | private resizeStartY = 0; 38 | private resizeStartWidth = 0; 39 | private resizeStartHeight = 0; 40 | protected uiSetting = getInstance(OuterSetting).ui; 41 | protected containerEl: HTMLDivElement; 42 | private frameEl: HTMLDivElement; 43 | protected contentEl: HTMLDivElement; 44 | protected mountedElement: MountedModal | null = null; 45 | 46 | toggle(): FloatingWindow { 47 | if (this.mountedElement !== null) { 48 | this.onClose(); 49 | return this; 50 | } 51 | 52 | this.containerEl = document.body.createDiv(); 53 | this.frameEl = this.containerEl.createDiv(); 54 | this.frameEl.addClass('cs-floating-window-header'); 55 | this.contentEl = this.containerEl.createDiv(); 56 | 57 | this.frameEl.addEventListener("mousedown", this.handleMouseDown); 58 | document.addEventListener("mousemove", this.handleMouseMove); 59 | document.addEventListener("mouseup", this.handleMouseUp); 60 | 61 | this.containerEl.addClass("cs-floating-window-container"); 62 | this.containerEl.style.position = "fixed"; 63 | this.containerEl.style.minWidth = "200px"; 64 | this.containerEl.style.minHeight = "100px"; 65 | // load position and other states from setting 66 | this.loadContainerElStates(); 67 | this.containerEl.style.zIndex = "20"; 68 | this.containerEl.style.border = "1px solid #454545"; 69 | this.containerEl.style.borderRadius = "10px"; 70 | // avoid the frameEl overflowing so that borderRadius of containerEl is covered 71 | this.containerEl.style.overflow = "hidden"; 72 | this.containerEl.style.boxShadow = "0 2px 4px rgba(0,0,0,0.2)"; 73 | 74 | this.frameEl.style.width = "100%"; 75 | this.frameEl.style.height = "20px"; 76 | this.frameEl.style.backgroundColor = "#333"; 77 | this.frameEl.style.cursor = "move"; 78 | this.frameEl.style.color = "#fff"; 79 | this.frameEl.style.display = "flex"; 80 | this.frameEl.style.alignItems = "center"; 81 | this.frameEl.style.justifyContent = "right"; 82 | this.frameEl.style.padding = "10px 0 10px 10px"; 83 | 84 | // 关闭按钮 85 | const closeButton = this.frameEl.createSpan(); 86 | closeButton.addClass("cs-close-btn"); 87 | closeButton.innerHTML = ` 88 | 89 | 90 | `; 91 | closeButton.style.cursor = "pointer"; 92 | closeButton.style.padding = "2px"; 93 | closeButton.style.margin = "5px"; 94 | closeButton.style.display = "flex"; 95 | closeButton.style.alignItems = "center"; 96 | closeButton.style.justifyContent = "center"; 97 | closeButton.style.borderRadius = "4px"; 98 | closeButton.style.transition = "background-color 0.2s ease"; 99 | closeButton.addEventListener("mouseover", () => { 100 | closeButton.style.backgroundColor = "rgba(255, 255, 255, 0.1)"; 101 | }); 102 | closeButton.addEventListener("mouseout", () => { 103 | closeButton.style.backgroundColor = "transparent"; 104 | }); 105 | closeButton.addEventListener("click", this.onClose); 106 | this.frameEl.appendChild(closeButton); 107 | 108 | this.contentEl.style.padding = "10px 0 10px 10px"; 109 | // 添加滚动条支持 110 | // this.contentEl.style.overflow = "auto"; 111 | // this.contentEl.style.height = "calc(100% - 40px)"; // 减去标题栏高度 112 | this.contentEl.style.boxSizing = "border-box"; 113 | 114 | // 添加调整大小的手柄 115 | const resizeHandle = this.containerEl.createDiv(); 116 | resizeHandle.addClass("cs-resize-handle"); 117 | resizeHandle.style.position = "absolute"; 118 | resizeHandle.style.right = "0"; 119 | resizeHandle.style.bottom = "0"; 120 | resizeHandle.style.width = "10px"; 121 | resizeHandle.style.height = "10px"; 122 | resizeHandle.style.cursor = "se-resize"; 123 | 124 | resizeHandle.addEventListener("mousedown", this.handleResizeStart); 125 | document.addEventListener("mousemove", this.handleResize); 126 | document.addEventListener("mouseup", this.handleResizeEnd); 127 | 128 | // 添加窗口大小变化监听 129 | window.addEventListener("resize", this.handleWindowResize); 130 | 131 | this.mountComponent(); 132 | return this; 133 | } 134 | 135 | // should be called on unload 136 | onClose = () => { 137 | document.removeEventListener("mousemove", this.handleMouseMove); 138 | document.removeEventListener("mouseup", this.handleMouseUp); 139 | document.removeEventListener("mousemove", this.handleResize); 140 | document.removeEventListener("mouseup", this.handleResizeEnd); 141 | window.removeEventListener("resize", this.handleWindowResize); 142 | // destroy svelte component 143 | this.mountedElement?.$destroy(); 144 | this.mountedElement = null; 145 | this.containerEl?.remove(); 146 | }; 147 | 148 | protected abstract loadContainerElStates(): void; 149 | protected abstract saveContainerElStates(): void; 150 | protected abstract mountComponent(): void; 151 | 152 | private handleMouseDown = (e: MouseEvent) => { 153 | this.isDragging = true; 154 | this.containerEl.style.opacity = "0.75"; 155 | this.dragStartX = e.pageX - this.containerEl.offsetLeft; 156 | this.dragStartY = e.pageY - this.containerEl.offsetTop; 157 | e.preventDefault(); // prevents text selection during drag 158 | }; 159 | 160 | private handleMouseMove = (e: MouseEvent) => { 161 | if (this.isDragging) { 162 | const newLeft = e.pageX - this.dragStartX; 163 | const newTop = e.pageY - this.dragStartY; 164 | 165 | this.containerEl.style.left = `${newLeft}px`; 166 | this.containerEl.style.top = `${newTop}px`; 167 | 168 | // 在拖动时也进行位置调整 169 | this.adjustPosition(); 170 | } 171 | }; 172 | 173 | private handleMouseUp = () => { 174 | this.isDragging = false; 175 | this.containerEl.style.opacity = "1"; 176 | // remember position and other stated 177 | this.saveContainerElStates(); 178 | getInstance(SettingManager).postSettingUpdated(); 179 | }; 180 | 181 | private handleResizeStart = (e: MouseEvent) => { 182 | this.isResizing = true; 183 | this.resizeStartX = e.pageX; 184 | this.resizeStartY = e.pageY; 185 | this.resizeStartWidth = this.containerEl.offsetWidth; 186 | this.resizeStartHeight = this.containerEl.offsetHeight; 187 | e.preventDefault(); 188 | }; 189 | 190 | private handleResize = (e: MouseEvent) => { 191 | if (!this.isResizing) return; 192 | 193 | const newWidth = this.resizeStartWidth + (e.pageX - this.resizeStartX); 194 | const newHeight = this.resizeStartHeight + (e.pageY - this.resizeStartY); 195 | 196 | // 设置最小尺寸限制 197 | const width = Math.max(200, newWidth); 198 | const height = Math.max(100, newHeight); 199 | 200 | // 确保调整大小时不会超出视口 201 | const viewportWidth = window.innerWidth; 202 | const viewportHeight = window.innerHeight; 203 | const rect = this.containerEl.getBoundingClientRect(); 204 | 205 | // 设置一个尺寸比例限制,让浮动窗口可以一定程度超出范围 206 | const ratio = 0.5; 207 | 208 | if (rect.left * ratio + width <= viewportWidth) { 209 | this.containerEl.style.width = `${width}px`; 210 | } 211 | if (rect.top * ratio + height <= viewportHeight) { 212 | this.containerEl.style.height = `${height}px`; 213 | } 214 | }; 215 | 216 | private handleResizeEnd = () => { 217 | if (!this.isResizing) return; 218 | this.isResizing = false; 219 | this.saveContainerElStates(); 220 | getInstance(SettingManager).postSettingUpdated(); 221 | }; 222 | 223 | private handleWindowResize = () => { 224 | if (this.containerEl) { 225 | this.adjustPosition(); 226 | } 227 | }; 228 | 229 | private adjustPosition() { 230 | const rect = this.containerEl.getBoundingClientRect(); 231 | const viewportWidth = window.innerWidth; 232 | const viewportHeight = window.innerHeight; 233 | 234 | // 计算新位置,确保窗口完全在视口内 235 | let newLeft = parseInt(this.containerEl.style.left); 236 | let newTop = parseInt(this.containerEl.style.top); 237 | 238 | // 设置一个尺寸比例限制,让浮动窗口可以一定程度超出范围 239 | const ratio = 0.5; 240 | 241 | // 处理右边界 242 | if (rect.right - rect.width * ratio > viewportWidth) { 243 | newLeft = viewportWidth - rect.width * ratio; 244 | } 245 | // 处理下边界 246 | if (rect.bottom - rect.height * ratio > viewportHeight) { 247 | newTop = viewportHeight - rect.height * ratio; 248 | } 249 | // 处理左边界 250 | if (rect.left < 0) { 251 | newLeft = 0; 252 | } 253 | // 处理上边界 254 | if (rect.top < 0) { 255 | newTop = 0; 256 | } 257 | 258 | // 应用新位置 259 | this.containerEl.style.left = `${newLeft}px`; 260 | this.containerEl.style.top = `${newTop}px`; 261 | 262 | // 保存新位置到设置 263 | this.saveContainerElStates(); 264 | getInstance(SettingManager).postSettingUpdated(); 265 | } 266 | } 267 | 268 | @singleton() 269 | class InFileFloatingWindow extends FloatingWindow { 270 | protected mountComponent(): void { 271 | // 获取当前选中的文本 272 | const selection = window.getSelection(); 273 | const selectedText = selection ? selection.toString().trim() : ""; 274 | 275 | this.mountedElement = new MountedModal({ 276 | target: this.contentEl, 277 | props: { 278 | uiType: "floatingWindow", 279 | onConfirmExternal: () => {}, 280 | searchType: SearchType.IN_FILE, 281 | isSemantic: false, 282 | queryText: selectedText, 283 | }, 284 | }); 285 | } 286 | 287 | protected loadContainerElStates(): void { 288 | this.containerEl.style.top = this.uiSetting.inFileFloatingWindowTop; 289 | this.containerEl.style.left = this.uiSetting.inFileFloatingWindowLeft; 290 | this.containerEl.style.width = this.uiSetting.inFileFloatingWindowWidth || "300px"; 291 | this.containerEl.style.height = this.uiSetting.inFileFloatingWindowHeight || "200px"; 292 | } 293 | 294 | protected saveContainerElStates(): void { 295 | this.uiSetting.inFileFloatingWindowLeft = this.containerEl.style.left; 296 | this.uiSetting.inFileFloatingWindowTop = this.containerEl.style.top; 297 | this.uiSetting.inFileFloatingWindowWidth = this.containerEl.style.width; 298 | this.uiSetting.inFileFloatingWindowHeight = this.containerEl.style.height; 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /src/ui/search-modal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal } from "obsidian"; 2 | import type { SearchType } from "src/globals/search-types"; 3 | import { ModalNavigationHotkeys } from "src/services/obsidian/command-registry"; 4 | import MountedModal from "./MountedModal.svelte"; 5 | 6 | // TODO: make it an abstract class 7 | export class SearchModal extends Modal { 8 | mountedElement: any; 9 | constructor(app: App, searchType: SearchType, isSemantic: boolean, query?: string) { 10 | super(app); 11 | 12 | // get text selected by user 13 | const selectedText = window.getSelection()?.toString() || ""; 14 | const effectiveQuery = query || selectedText; 15 | 16 | // remove predefined child node 17 | this.modalEl.replaceChildren(); 18 | this.modalEl.addClass("cs-modal"); 19 | 20 | // BUG: In fact, the onMount method won't be called 21 | // Use custom init() method instead 22 | this.mountedElement = new MountedModal({ 23 | target: this.modalEl, 24 | props: { 25 | uiType: "modal", 26 | onConfirmExternal: () => this.close(), 27 | searchType: searchType, 28 | isSemantic: isSemantic, 29 | queryText: effectiveQuery || "", 30 | }, 31 | }); 32 | 33 | // register for transient scope. In this scope, app.scope won't accept keyMapEvents 34 | new ModalNavigationHotkeys(this.scope).registerAll(); 35 | } 36 | 37 | onOpen() { } 38 | 39 | onClose() { 40 | this.mountedElement.$destroy(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/ui/view-helper.ts: -------------------------------------------------------------------------------- 1 | // src/utils/view-helper.ts 2 | import { App, MarkdownView, TFile, Vault, type EditorPosition } from "obsidian"; 3 | import { NULL_NUMBER } from "src/globals/constants"; 4 | import { ObsidianCommandEnum } from "src/globals/enums"; 5 | import { OuterSetting } from "src/globals/plugin-setting"; 6 | import { 7 | FileItem, 8 | FileSubItem, 9 | Item, 10 | LineItem, 11 | SearchType, 12 | } from "src/globals/search-types"; 13 | import { PrivateApi } from "src/services/obsidian/private-api"; 14 | import { ViewType } from "src/services/obsidian/view-registry"; 15 | import { SemanticEngine } from "src/services/search/semantic-engine"; 16 | import { logger } from "src/utils/logger"; 17 | import { getInstance } from "src/utils/my-lib"; 18 | import { singleton } from "tsyringe"; 19 | // TODO: When DOMPurify 3.1.8 is released, remove @types/dompurify due to an unreleased PR: https://github.com/cure53/DOMPurify/pull/1006 20 | import DOMPurify from "dompurify"; 21 | 22 | @singleton() 23 | export class ViewHelper { 24 | private readonly app = getInstance(App); 25 | private readonly privateApi = getInstance(PrivateApi); 26 | private readonly setting = getInstance(OuterSetting); 27 | 28 | // avoid XSS 29 | purifyHTML(rawHtml: string): string { 30 | return DOMPurify.sanitize(rawHtml, { USE_PROFILES: { html: true } }); 31 | } 32 | 33 | updateSubItemIndex( 34 | subItems: FileSubItem[], 35 | currSubIndex: number, 36 | direction: "next" | "prev", 37 | ): number { 38 | const subItem = subItems[currSubIndex]; 39 | const maxIndex = subItems.length - 1; 40 | this.scrollTo("center", subItem, "auto"); 41 | if (direction === "next") { 42 | return currSubIndex < maxIndex ? currSubIndex + 1 : currSubIndex; 43 | } else { 44 | return currSubIndex > 0 ? currSubIndex - 1 : currSubIndex; 45 | } 46 | } 47 | 48 | async handleConfirmAsync( 49 | onConfirmExternal: () => void, 50 | sourcePath: string, 51 | searchType: SearchType, 52 | selectedItem: Item, 53 | currSubItemIndex: number, 54 | queryText: string, 55 | ) { 56 | onConfirmExternal(); 57 | if (selectedItem) { 58 | if (searchType === SearchType.IN_FILE) { 59 | const lineItem = selectedItem as LineItem; 60 | await this.jumpInVaultAsync( 61 | sourcePath, 62 | lineItem.line.row, 63 | lineItem.line.col, 64 | queryText, 65 | ); 66 | } else if (searchType === SearchType.IN_VAULT) { 67 | const fileItem = selectedItem as FileItem; 68 | const viewType = fileItem.viewType; 69 | if (currSubItemIndex !== NULL_NUMBER) { 70 | const subItem = fileItem.subItems[currSubItemIndex]; 71 | if (viewType === ViewType.MARKDOWN) { 72 | // TODO: reuse tab for html 73 | // if (fileItem.extension === "html") { 74 | // const absolutePath = 75 | // this.privateApi.getAbsolutePath(fileItem.path); 76 | // const matchedText = subItem.text.replace( 77 | // /|<\/mark>/g, 78 | // "", 79 | // ); 80 | // // logger.info(matchedText); 81 | // window.open( 82 | // `file:///${absolutePath}#:~:text=${matchedText}`, 83 | // "", 84 | // ); 85 | // } else { 86 | await this.jumpInVaultAsync( 87 | fileItem.path, 88 | subItem.row, 89 | subItem.col, 90 | queryText, 91 | ); 92 | // } 93 | } else { 94 | throw Error("unsupported viewType to jump"); 95 | } 96 | } else { 97 | // no content text matched, but filenames or folders are matched 98 | await this.jumpInVaultAsync(fileItem.path, 0, 0, queryText); 99 | } 100 | } else { 101 | throw Error(`unsupported search type to jump ${searchType}`); 102 | } 103 | } 104 | } 105 | 106 | // for scroll bar 107 | scrollTo( 108 | direction: ScrollLogicalPosition, 109 | item: Item | undefined, 110 | behavior: ScrollBehavior, 111 | ) { 112 | // wait until the dom states are updated 113 | setTimeout(() => { 114 | if (item && item.element) { 115 | item.element.scrollIntoView({ 116 | behavior: behavior, 117 | // behavior: "auto", 118 | // behavior: "instant", 119 | //@ts-ignore the type definition mistakenly spell `block` as `lock`, so there will be a warning 120 | block: direction, // vertical 121 | // inline: "center" // horizontal 122 | }); 123 | } 124 | }, 0); 125 | } 126 | 127 | focusInput() { 128 | setTimeout(() => { 129 | const inputElement = document.getElementById("cs-search-input"); 130 | inputElement?.focus(); 131 | }, 0); 132 | } 133 | 134 | showNoResult(isSemantic: boolean) { 135 | if (isSemantic) { 136 | if (!this.setting.semantic.isEnabled) { 137 | return "Semantic search need to be enabled at the setting tab"; 138 | } 139 | const semanticEngineStatus = getInstance(SemanticEngine).status; 140 | if (semanticEngineStatus === "ready") { 141 | return "No matched content"; 142 | } else { 143 | return `Semantic engine is ${semanticEngineStatus}`; 144 | } 145 | } else { 146 | return "No matched content"; 147 | } 148 | } 149 | 150 | insertFileLinkToActiveMarkdown(path: string | undefined) { 151 | if (path) { 152 | const activeMarkdownView = 153 | this.app.workspace.getActiveViewOfType(MarkdownView); 154 | if (!activeMarkdownView?.file) { 155 | logger.info("No markdown view to insert file link"); 156 | return; 157 | } 158 | 159 | const targetFile = getInstance(Vault).getAbstractFileByPath( 160 | path, 161 | ) as TFile; 162 | const linkText = this.app.fileManager.generateMarkdownLink( 163 | targetFile, 164 | activeMarkdownView.file.path, 165 | ); 166 | activeMarkdownView.editor.replaceSelection(linkText + "\n"); 167 | } 168 | } 169 | 170 | private async jumpInVaultAsync( 171 | path: string, 172 | row: number, 173 | col: number, 174 | queryText: string, 175 | ) { 176 | let alreadyOpen = false; 177 | this.app.workspace.iterateAllLeaves((leaf) => { 178 | if ( 179 | leaf.view instanceof MarkdownView && 180 | leaf.getViewState().state?.file === path 181 | ) { 182 | this.app.workspace.setActiveLeaf(leaf, { focus: true }); 183 | alreadyOpen = true; 184 | } 185 | }); 186 | if (alreadyOpen) { 187 | this.scrollIntoViewForExistingView(row, col, queryText); 188 | } else { 189 | await this.app.workspace.openLinkText( 190 | path, 191 | "", 192 | this.setting.ui.openInNewPane, 193 | ); 194 | this.scrollIntoViewForExistingView(row, col, queryText); 195 | } 196 | } 197 | 198 | private scrollIntoViewForExistingView( 199 | row: number, 200 | col: number, 201 | queryText: string, 202 | ) { 203 | // WARN: this command inside this function will cause a warning in the console: 204 | // [Violation] Forced reflow while executing JavaScript took 55ms 205 | // if removing the command in this function, we can't focus the editor when switching to an existing view 206 | this.privateApi.executeCommandById( 207 | ObsidianCommandEnum.FOCUS_ON_LAST_NOTE, 208 | ); 209 | const view = this.app.workspace.getActiveViewOfType(MarkdownView); 210 | const cursorPos: EditorPosition = { 211 | line: row, 212 | ch: col, 213 | }; 214 | 215 | if (view) { 216 | // auto-switch to editing mode if it's reading mode in target view 217 | const tmpViewState = view.getState(); 218 | tmpViewState.mode = "source"; 219 | view.setState(tmpViewState, { history: false }); 220 | 221 | view.editor.setCursor(cursorPos); 222 | 223 | this.app.workspace.onLayoutReady(() => { 224 | view.editor.scrollIntoView( 225 | { 226 | from: cursorPos, 227 | to: cursorPos, 228 | }, 229 | true, 230 | ); 231 | // the second jump is necessary because the images are lazy-rendered 232 | setTimeout(() => { 233 | view.editor.scrollIntoView( 234 | { 235 | from: cursorPos, 236 | to: cursorPos, 237 | }, 238 | true, 239 | ); 240 | 241 | // It doesn't take effect , use ObsidianCommandEnum.FOCUS_ON_LAST_NOTE instead 242 | // view.editor.focus(); 243 | // 选中搜索关键字 244 | const line = view.editor.getLine(row); 245 | const textLength = queryText.length; 246 | const startPos = line.indexOf(queryText, col); 247 | if (startPos !== -1) { 248 | view.editor.setSelection( 249 | { line: row, ch: startPos }, 250 | { line: row, ch: startPos + textLength }, 251 | ); 252 | } 253 | 254 | // this command need to be triggered again if the view mode has been switched to `editing` from `reading` 255 | this.privateApi.executeCommandById( 256 | ObsidianCommandEnum.FOCUS_ON_LAST_NOTE, 257 | ); 258 | }, 1); 259 | }); 260 | } else { 261 | logger.info("No markdown view to jump"); 262 | } 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/utils/data-structure.ts: -------------------------------------------------------------------------------- 1 | import { throttle } from "throttle-debounce"; 2 | import { logger } from "./logger"; 3 | 4 | export class Collections { 5 | static minInSet(set: Set) { 6 | let minInSet = Number.MAX_VALUE; 7 | for (const num of set) { 8 | if (num < minInSet) { 9 | minInSet = num; 10 | } 11 | } 12 | return minInSet; 13 | } 14 | } 15 | 16 | /** 17 | * A class that manages a set of buffered items. It automatically flushes the buffered items 18 | * to a handler function when a specified threshold is reached. If multiple items have the same 19 | * identifier, the older item will be replaced by the newer one. 20 | */ 21 | export class BufferSet { 22 | private elementsMap: Map; 23 | private handler: (elements: T[]) => Promise; 24 | private identifier: (element: T) => string; 25 | private autoFlushThreshold: number; 26 | private flushThrottled = throttle(10000, () => this.forceFlush()); 27 | 28 | /** 29 | * Creates an instance of BufferSet. 30 | * @param handler The function to handle elements. 31 | * @param identifier A function that provides a unique string identifier for each element. 32 | * @param autoFlushThreshold The number of elements at which the BufferSet should automatically flush. 33 | */ 34 | constructor( 35 | handler: (elements: T[]) => Promise, 36 | identifier: (element: T) => string, 37 | autoFlushThreshold: number, 38 | ) { 39 | this.elementsMap = new Map(); 40 | this.handler = handler; 41 | this.identifier = identifier; 42 | this.autoFlushThreshold = autoFlushThreshold; 43 | } 44 | 45 | /** 46 | * Adds a new element to the buffer. If the buffer reaches the autoFlushThreshold, it triggers a flush. 47 | * If an element with the same identifier already exists, it will be replaced by the new element. 48 | * @param element The element to be added to the buffer. 49 | */ 50 | add(element: T): void { 51 | const id = this.identifier(element); 52 | this.elementsMap.set(id, element); 53 | 54 | if (this.elementsMap.size >= this.autoFlushThreshold) { 55 | this.flushThrottled(); 56 | } 57 | } 58 | 59 | /** 60 | * Flushes all buffered elements to the handler function and clears the buffer. 61 | */ 62 | async forceFlush(): Promise { 63 | if (this.elementsMap.size === 0) { 64 | return; 65 | } 66 | 67 | const elementsToHandle = Array.from(this.elementsMap.values()); 68 | this.elementsMap.clear(); 69 | 70 | await this.handler(elementsToHandle); 71 | logger.debug("flushed"); 72 | } 73 | } 74 | 75 | type Comparator = (a: T, b: T) => number; 76 | 77 | // min heap 78 | export class PriorityQueue { 79 | private heap: T[]; 80 | private compare: Comparator; 81 | private capacity: number; 82 | 83 | constructor( 84 | comparator: Comparator, 85 | capacity: number = Number.MAX_VALUE, 86 | ) { 87 | this.heap = []; 88 | this.compare = comparator; 89 | this.capacity = capacity; 90 | } 91 | public values(): T[] { 92 | return this.heap; 93 | } 94 | 95 | public push(item: T): void { 96 | if (this.size() < this.capacity) { 97 | this.heap.push(item); 98 | this.heapifyUp(); 99 | } else if (this.compare(item, this.heap[0]) > 0) { 100 | this.heap[0] = item; 101 | this.heapifyDown(); 102 | } 103 | } 104 | 105 | public clear(): void { 106 | this.heap = []; 107 | } 108 | 109 | public pop(): T | undefined { 110 | if (this.isEmpty()) { 111 | return undefined; 112 | } 113 | const item = this.heap[0]; 114 | this.heap[0] = this.heap[this.heap.length - 1]; 115 | this.heap.pop(); 116 | this.heapifyDown(); 117 | return item; 118 | } 119 | 120 | public peek(): T | undefined { 121 | return this.heap[0]; 122 | } 123 | 124 | public isEmpty(): boolean { 125 | return this.heap.length === 0; 126 | } 127 | 128 | public size(): number { 129 | return this.heap.length; 130 | } 131 | 132 | private getLeftChildIndex(parentIndex: number): number { 133 | return 2 * parentIndex + 1; 134 | } 135 | 136 | private getRightChildIndex(parentIndex: number): number { 137 | return 2 * parentIndex + 2; 138 | } 139 | 140 | private getParentIndex(childIndex: number): number { 141 | return Math.floor((childIndex - 1) / 2); 142 | } 143 | 144 | private swap(index1: number, index2: number): void { 145 | [this.heap[index1], this.heap[index2]] = [ 146 | this.heap[index2], 147 | this.heap[index1], 148 | ]; 149 | } 150 | 151 | private heapifyUp(): void { 152 | let index = this.heap.length - 1; 153 | while ( 154 | this.getParentIndex(index) >= 0 && 155 | this.compare( 156 | this.heap[this.getParentIndex(index)], 157 | this.heap[index], 158 | ) > 0 159 | ) { 160 | this.swap(this.getParentIndex(index), index); 161 | index = this.getParentIndex(index); 162 | } 163 | } 164 | 165 | private heapifyDown(): void { 166 | let index = 0; 167 | while (this.getLeftChildIndex(index) < this.heap.length) { 168 | let smallerChildIndex = this.getLeftChildIndex(index); 169 | if ( 170 | this.getRightChildIndex(index) < this.heap.length && 171 | this.compare( 172 | this.heap[this.getRightChildIndex(index)], 173 | this.heap[smallerChildIndex], 174 | ) < 0 175 | ) { 176 | smallerChildIndex = this.getRightChildIndex(index); 177 | } 178 | 179 | if ( 180 | this.compare(this.heap[index], this.heap[smallerChildIndex]) < 0 181 | ) { 182 | break; 183 | } else { 184 | this.swap(index, smallerChildIndex); 185 | } 186 | index = smallerChildIndex; 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/utils/event-bus.ts: -------------------------------------------------------------------------------- 1 | import type { EventEnum } from "../globals/enums"; 2 | 3 | export type EventCallback = (...args: any[]) => void; 4 | 5 | export class EventBus { 6 | private listeners: Map; 7 | 8 | constructor() { 9 | this.listeners = new Map(); 10 | } 11 | 12 | // 监听事件 13 | on(event: EventEnum, callback: EventCallback): void { 14 | // console.log("registered"); 15 | if (!this.listeners.has(event)) { 16 | this.listeners.set(event, []); 17 | } 18 | this.listeners.get(event)?.push(callback); 19 | } 20 | 21 | // 移除监听器 22 | off(event: EventEnum, callback: EventCallback): void { 23 | // console.log("unregistered"); 24 | const callbacks = this.listeners.get(event); 25 | if (callbacks) { 26 | const index = callbacks.indexOf(callback); 27 | if (index > -1) { 28 | callbacks.splice(index, 1); 29 | } 30 | } 31 | } 32 | 33 | // 触发事件 34 | emit(event: EventEnum, ...args: any[]): void { 35 | this.listeners.get(event)?.forEach((callback) => { 36 | callback(...args); 37 | }); 38 | } 39 | } 40 | 41 | export const eventBus = new EventBus(); -------------------------------------------------------------------------------- /src/utils/file-util.ts: -------------------------------------------------------------------------------- 1 | import * as fsLib from "fs"; 2 | import type { TFile } from "obsidian"; 3 | import * as pathLib from "path"; 4 | import { singleton } from "tsyringe"; 5 | import { logger } from "./logger"; 6 | 7 | // for autocompletion 8 | export const fsUtil = fsLib; 9 | export const pathUtil = pathLib; 10 | 11 | @singleton() 12 | export class FileUtil { 13 | // static SPLIT_EOL = /\r?\n|\r/; // cross-platform end of line, used for strings.split() 14 | static readonly SPLIT_EOL = "\n"; // stay consistent with the logic that Obsidian uses to handle lines 15 | // static readonly JOIN_EOL = os.EOL; // cross-platform end of line, used for string.join() 16 | static readonly JOIN_EOL = "\n"; 17 | 18 | static getBasename(filePath: string): string { 19 | return pathUtil.basename(filePath, pathUtil.extname(filePath)); 20 | } 21 | 22 | static getExtension(filePath: string): string { 23 | return pathUtil.extname(filePath).slice(1); 24 | } 25 | 26 | static getFolderPath(filePath: string): string { 27 | const dirPath = pathUtil.dirname(filePath); 28 | if (dirPath === "." || dirPath === pathUtil.sep || dirPath === "/") { 29 | return "./"; 30 | } 31 | return dirPath.replace(new RegExp("\\" + pathUtil.sep, "g"), "/") + "/"; 32 | } 33 | 34 | static countFileByExtensions(files: TFile[]): Record { 35 | const extensionCountMap = new Map(); 36 | files.forEach((file) => { 37 | const ext = file.extension || "no_extension"; 38 | extensionCountMap.set(ext, (extensionCountMap.get(ext) || 0) + 1); 39 | }); 40 | const countResult = Object.fromEntries(extensionCountMap); 41 | logger.trace(countResult); 42 | return countResult; 43 | } 44 | 45 | // use ObsidianFs instead 46 | // static doesFileExist(path: string): boolean { 47 | // return fsUtil.existsSync(path); 48 | // } 49 | } 50 | -------------------------------------------------------------------------------- /src/utils/lang-util.ts: -------------------------------------------------------------------------------- 1 | 2 | // large charset language can apply fuzzier params and should show less preChars when previewing 3 | // currently only support Chinese 4 | const LARGE_CHARSET_LANGUAGE_REGEX = /[\u4e00-\u9fa5]/; 5 | // match all 6 | const LARGE_CHARSET_LANGUAGE_REGEX_G = /[\u4e00-\u9fa5]/g; 7 | 8 | export const CHINESE_REGEX = /[\u4e00-\u9fa5]/; 9 | // const JAPANESE_REGEX = /[\u3040-\u30ff\u31f0-\u31ff]/; 10 | // const KOREAN_REGEX = /[\uac00-\ud7af]/; 11 | // const CJK_REGEX = /[\u4e00-\u9fa5\uac00-\ud7af\u3040-\u30ff\u31f0-\u31ff]/; 12 | 13 | export class LangUtil { 14 | static isLargeCharset(text: string): boolean { 15 | const threshold = 0.35; 16 | const matches = text.match(LARGE_CHARSET_LANGUAGE_REGEX_G) || []; 17 | return matches.length >= Math.ceil(text.length * threshold); 18 | } 19 | 20 | static testWideChar(text: string): boolean { 21 | return LARGE_CHARSET_LANGUAGE_REGEX.test(text); 22 | } 23 | 24 | static wideCharProportion(text: string): number { 25 | const matches = text.match(LARGE_CHARSET_LANGUAGE_REGEX_G) || []; 26 | return matches.length / text.length; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { devOption } from "src/globals/dev-option"; 2 | 3 | class Logger { 4 | private logLevel: LogLevel = "debug"; 5 | private levelWeights: { [level in LogLevel]: number } = { 6 | trace: 1, 7 | debug: 2, 8 | info: 3, 9 | warn: 4, 10 | error: 5, 11 | none: 6, 12 | }; 13 | 14 | getLevel(): LogLevel { 15 | return this.logLevel; 16 | } 17 | 18 | setLevel(level: LogLevel) { 19 | this.logLevel = level; 20 | } 21 | 22 | trace(...args: any[]) { 23 | if (this.shouldLog("trace")) { 24 | if (devOption.traceLog) { 25 | console.groupCollapsed( 26 | `%c[trace] ${this.getCallerName()}\n`, 27 | "color: #5f6368;font-weight: 400;", 28 | ...args, 29 | ); 30 | console.trace(); 31 | console.groupEnd(); 32 | } else { 33 | console.log( 34 | `%c[trace] ${this.getCallerName()}\n`, 35 | "color: #5f6368;", 36 | ...args, 37 | ); 38 | } 39 | } 40 | } 41 | 42 | // NOTE: 如果console不显示debug信息,需要在所有级别里勾选详细 43 | debug(...args: any[]) { 44 | if (this.shouldLog("debug")) { 45 | if (devOption.traceLog) { 46 | console.groupCollapsed( 47 | `%c[debug] ${this.getCallerName()}\n`, 48 | "color: #379237;font-weight: 400;", 49 | ...args, 50 | ); 51 | console.trace(); 52 | console.groupEnd(); 53 | } else { 54 | console.debug( 55 | `%c[debug] ${this.getCallerName()}\n`, 56 | // "color: #96C291;", 57 | "color: #379237", 58 | ...args, 59 | ); 60 | } 61 | } 62 | } 63 | 64 | info(...args: any[]) { 65 | if (this.shouldLog("info")) { 66 | console.info( 67 | `%c[info] ${this.getCallerName()}\n`, 68 | "color: blue;", 69 | ...args, 70 | ); 71 | } 72 | } 73 | 74 | warn(...args: any[]) { 75 | if (this.shouldLog("warn")) { 76 | console.warn( 77 | `%c[warn] ${this.getCallerName()}\n`, 78 | "color: orange;", 79 | ...args, 80 | ); 81 | } 82 | } 83 | 84 | error(...args: any[]) { 85 | if (this.shouldLog("error")) { 86 | console.error( 87 | `%c[error] ${this.getCallerName()}\n`, 88 | "color: red;", 89 | ...args, 90 | ); 91 | } 92 | } 93 | 94 | private getCallerName() { 95 | const stackLines = new Error().stack?.split("\n"); 96 | // Skip the first three lines and find the actual caller 97 | const callerLine = stackLines?.slice(3).find( 98 | (line) => 99 | // if pass a template literal, such as `show: ${value}` to methods of logger, 100 | // there will be two more stack lines above the actual caller 101 | !line.includes("eval") && !line.includes("Array.map") 102 | ); 103 | // for node.js child thread in obsidian 104 | if (callerLine?.startsWith(" at blob:")) { 105 | return "child-thread"; 106 | } 107 | const match = callerLine?.match(/at (\S+)/); 108 | return match ? `<${match[1]}> ` : "failed to parse caller"; 109 | } 110 | 111 | private shouldLog(level: LogLevel): boolean { 112 | return this.levelWeights[level] >= this.levelWeights[this.logLevel]; 113 | } 114 | } 115 | 116 | // 不适合用大写字母,因为作为属性时,似乎只能用小写字母 117 | export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "none"; 118 | 119 | export const logger = new Logger(); 120 | -------------------------------------------------------------------------------- /src/utils/my-lib.ts: -------------------------------------------------------------------------------- 1 | import { LanguageEnum } from "src/globals/enums"; 2 | import { container, type InjectionToken } from "tsyringe"; 3 | import { logger } from "./logger"; 4 | 5 | export const isDevEnvironment = process.env.NODE_ENV === "development"; 6 | export const TO_BE_IMPL = "This branch hasn't been implemented"; 7 | export const SHOULD_NOT_HAPPEN = "this branch shouldn't be reached by design"; 8 | 9 | export class MyLib { 10 | static extractDomainFromHttpsUrl(url: string): string { 11 | if (url.startsWith("http://")) { 12 | logger.error("Only support https, current url starts with http"); 13 | return ""; 14 | } 15 | 16 | const domainRegex = /^(https?:\/\/)?([\w.-]+)(?:\/|$)/; 17 | const match = url.match(domainRegex); 18 | if (match && match[2]) { 19 | return match[2]; 20 | } else { 21 | return ""; 22 | } 23 | } 24 | /** 25 | * Appends elements from addition to host. 26 | */ 27 | static append(host: T[], addition: T[]): T[] { 28 | for (const element of addition) { 29 | host.push(element); 30 | } 31 | return host; 32 | } 33 | 34 | /** 35 | * deep version of Object.assign (will change the target) 36 | */ 37 | static mergeDeep(target: T, ...sources: T[]): T { 38 | if (!sources.length) return target; 39 | const source = sources.shift(); 40 | 41 | if (isObject(target) && isObject(source)) { 42 | for (const key in source) { 43 | if (isObject(source[key])) { 44 | if (!target[key as keyof T]) 45 | // @ts-ignore 46 | Object.assign(target, { [key]: {} }); 47 | // @ts-ignore 48 | MyLib.mergeDeep(target[key as keyof T], source[key]); 49 | } else { 50 | // @ts-ignore 51 | Object.assign(target, { [key]: source[key] }); 52 | } 53 | } 54 | } 55 | 56 | return MyLib.mergeDeep(target, ...sources); 57 | } 58 | 59 | /** 60 | * Get current runtime language 61 | */ 62 | static getCurrLanguage(): LanguageEnum { 63 | // getItem("language") will return `null` if currLanguage === "en" 64 | const langKey = window.localStorage.getItem("language") || "en"; 65 | if (langKey in LanguageEnum) { 66 | return LanguageEnum[langKey as keyof typeof LanguageEnum]; 67 | } else { 68 | return LanguageEnum.other; 69 | } 70 | } 71 | 72 | static sleep(ms: number) { 73 | return new Promise((resolve) => setTimeout(resolve, ms)); 74 | } 75 | } 76 | 77 | export async function monitorExecution( 78 | fn: (...args: any[]) => Promise, 79 | ...args: any[] 80 | ) { 81 | const startTime = Date.now(); 82 | await fn(...args); // Spread the arguments here 83 | const endTime = Date.now(); 84 | 85 | const duration = formatMillis(endTime - startTime); 86 | logger.debug( 87 | `<${fn.name.replace(/^bound /, "")}> Execution time: ${duration}`, 88 | ); 89 | } 90 | 91 | /** 92 | * A decorator for measuring and logging the execution time of a class method. 93 | * @param target - The target class. 94 | * @param propertyKey - The method name. 95 | * @param descriptor - The method descriptor. 96 | * @returns A modified descriptor with execution time monitoring. 97 | * 98 | * @example 99 | * class Example { 100 | * @monitorDecorator 101 | * async myMethod(arg1, arg2) { 102 | * // Method implementation... 103 | * } 104 | * } 105 | */ 106 | export function monitorDecorator( 107 | target: any, 108 | propertyKey: string, 109 | descriptor: PropertyDescriptor, 110 | ) { 111 | const originalMethod = descriptor.value; 112 | 113 | descriptor.value = function (...args: any[]) { 114 | const start = Date.now(); 115 | const result = originalMethod.apply(this, args); 116 | const logExecutionTime = () => { 117 | const end = Date.now(); 118 | logger.trace( 119 | `<${ 120 | target.constructor.name 121 | }-${propertyKey}> Execution time: ${formatMillis(end - start)}`, 122 | ); 123 | }; 124 | if (result instanceof Promise) { 125 | return result.then((res) => { 126 | logExecutionTime(); 127 | return res; 128 | }); 129 | } else { 130 | logExecutionTime(); 131 | return result; 132 | } 133 | }; 134 | 135 | return descriptor; 136 | } 137 | 138 | /** 139 | * get a better view of milliseconds 140 | */ 141 | export function formatMillis(millis: number): string { 142 | if (millis < 1000) { 143 | return `${millis} ms`; 144 | } else if (millis < 60000) { 145 | const seconds = Math.floor(millis / 1000); 146 | const milliseconds = millis % 1000; 147 | return `${seconds} s ${milliseconds} ms`; 148 | } else { 149 | const minutes = Math.floor(millis / 60000); 150 | const seconds = Math.floor((millis % 60000) / 1000); 151 | const milliseconds = millis % 1000; 152 | return `${minutes} min ${seconds} s ${milliseconds} ms`; 153 | } 154 | } 155 | 156 | export function getInstance(token: InjectionToken): T { 157 | try { 158 | return container.resolve(token); 159 | } catch (e) { 160 | const msg = 161 | "CleverSearch for developer:\nThere might be wrong usages for tsyringe:\n1. Inject an instance for static field\n2. Cycle dependencies without delay\n3. Unknown error"; 162 | logger.warn(msg); 163 | logger.error(e); 164 | alert(msg); 165 | return -1 as any; 166 | } 167 | } 168 | 169 | function isObject(item: any): boolean { 170 | return item && typeof item === "object" && !Array.isArray(item); 171 | } 172 | -------------------------------------------------------------------------------- /src/utils/nlp.ts: -------------------------------------------------------------------------------- 1 | import { franc } from "franc-min"; 2 | import { LanguageEnum } from "src/globals/enums"; 3 | 4 | /** 5 | * this module need to be loaded in the subprocess or should not be used at all 6 | * because the package size of "franc-min" is nearly 180kb 7 | */ 8 | 9 | export type LanguageProportionsResult = { 10 | mainLanguage: LanguageEnum; 11 | mainProportion: string; 12 | details: Record; 13 | }; 14 | 15 | type ISOLanguageCode = "und" | "eng" | "cmn"; 16 | 17 | // map ISO 639-3 language code to LanguageEnum 18 | const isoToEnum: Record = { 19 | ["eng"]: LanguageEnum.en, 20 | ["cmn"]: LanguageEnum.zh, 21 | ["und"]: LanguageEnum.other, 22 | }; 23 | 24 | export class TextAnalyzer { 25 | static detectLanguage(input: string | string[]): LanguageProportionsResult { 26 | const strArray = typeof input === "string" ? [input] : input; 27 | 28 | const languageCounts: Record = { 29 | [LanguageEnum.en]: 0, 30 | [LanguageEnum.zh]: 0, 31 | [LanguageEnum.other]: 0, 32 | }; 33 | 34 | let totalLength = 0; 35 | for (const text of strArray) { 36 | let langCode = franc(text, { only: Object.keys(isoToEnum) }); 37 | langCode = Object.keys(isoToEnum).includes(langCode) 38 | ? langCode 39 | : "und"; 40 | const language: LanguageEnum = 41 | isoToEnum[langCode as ISOLanguageCode]; 42 | 43 | const length = text.length; 44 | totalLength += length; 45 | languageCounts[language] += length; 46 | } 47 | let mainLanguage = LanguageEnum.other; 48 | let maxProportion = 0; 49 | const details: Record = {}; 50 | 51 | Object.keys(languageCounts).forEach((language) => { 52 | const lang = language as LanguageEnum; 53 | const proportion = 54 | ((languageCounts[lang] / totalLength) * 100).toFixed(2) + "%"; 55 | details[lang] = proportion; 56 | 57 | if (languageCounts[lang] > maxProportion) { 58 | maxProportion = languageCounts[lang]; 59 | mainLanguage = lang; 60 | } 61 | }); 62 | 63 | return { 64 | mainLanguage: mainLanguage, 65 | mainProportion: details[mainLanguage], 66 | details: details, 67 | }; 68 | } 69 | 70 | static printLanguageProportion(result: LanguageProportionsResult) { 71 | console.log( 72 | `Main Language: ${result.mainLanguage}, Main Proportion: ${result.mainProportion}`, 73 | ); 74 | console.log("Details:"); 75 | for (const [language, proportion] of Object.entries(result.details)) { 76 | console.log(`${language}: ${proportion}`); 77 | } 78 | console.log(""); 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /src/utils/web/assets-provider.ts: -------------------------------------------------------------------------------- 1 | import { Notice } from "obsidian"; 2 | import { OuterSetting } from "src/globals/plugin-setting"; 3 | import { MyNotice } from "src/services/obsidian/transformed-api"; 4 | import { t } from "src/services/obsidian/translations/locale-helper"; 5 | import { singleton } from "tsyringe"; 6 | import { FileUtil, fsUtil, pathUtil } from "../file-util"; 7 | import { logger } from "../logger"; 8 | import { getInstance } from "../my-lib"; 9 | 10 | // eslint-disable-next-line @typescript-eslint/no-var-requires 11 | const electron = require("electron"); 12 | const userDataPath = (electron.app || electron.remote.app).getPath("userData"); 13 | 14 | const assetsDir = pathUtil.join(userDataPath, "clever-search"); 15 | 16 | const myRemoteDirUrl1 = 17 | "https://bitbucket.org/alexclifton37/obsidian-clever-search/raw/dev/assets/for-program/"; 18 | const myRemoteDirUrl2 = 19 | "https://raw.githubusercontent.com/yan42685/obsidian-clever-search/dev/assets/for-program/"; 20 | const myRemoteLfsUrl = 21 | "https://bitbucket.org/alexclifton37/shared-assets/raw/master/"; 22 | 23 | const unpkgUrl = "https://unpkg.com/"; 24 | 25 | const tiktokenSourceUrl = unpkgUrl + "@dqbd/tiktoken@1.0.7/tiktoken_bg.wasm"; 26 | const tiktokenTargetUrl = pathUtil.join(assetsDir, "tiktoken_bg.wasm"); 27 | 28 | // const jiebaSourceUrl = 29 | // unpkgUrl + "jieba-wasm@0.0.2/pkg/web/jieba_rs_wasm_bg.wasm"; 30 | // const jiebaTargetUrl = pathUtil.join(assetsDir, "jieba_rs_wasm_bg.wasm"); 31 | 32 | // export const stopWordsEnTargetUrl = pathUtil.join( 33 | // assetsDir, 34 | // "stop-words-en.txt", 35 | // ); 36 | 37 | const jieba = "jieba_rs_wasm_bg.wasm"; 38 | const stopWordsEn = "stop-words-en.txt"; 39 | const stopWordsZh = "stop-words-zh.txt"; 40 | const aiHelper = "clever-search-ai-helper.zip"; 41 | 42 | @singleton() 43 | export class AssetsProvider { 44 | private readonly setting = getInstance(OuterSetting); 45 | private readonly _assets: Assets = EMPTY_ASSETS; 46 | get assets() { 47 | return this._assets; 48 | } 49 | 50 | async initAsync() { 51 | logger.trace("preparing assets..."); 52 | logger.trace(`target dir: ${assetsDir}`); 53 | try { 54 | await this.initHelper(myRemoteDirUrl1); 55 | } catch (e) { 56 | try { 57 | await this.initHelper(myRemoteDirUrl2); 58 | logger.info("download successfully"); 59 | } catch (e) { 60 | logger.warn("failed to download assets"); 61 | throw e; 62 | } 63 | } 64 | } 65 | 66 | async downloadAiHelper() { 67 | const downloadingNotice = new MyNotice(t("Downloading aiHelper")); 68 | try { 69 | await this.downloadFile( 70 | this.targetPath(aiHelper), 71 | myRemoteLfsUrl + aiHelper, 72 | ); 73 | new MyNotice(t("Download success"), 5000); 74 | // open two folder in the explorer 75 | // eslint-disable-next-line @typescript-eslint/no-var-requires 76 | const { shell } = require("electron"); 77 | shell.showItemInFolder(this.targetPath(aiHelper)); 78 | // eslint-disable-next-line @typescript-eslint/no-var-requires 79 | shell.openPath(require("os").userInfo().homedir); 80 | } catch (e) { 81 | logger.error(e); 82 | const failureInfo = document.createDocumentFragment(); 83 | const textNode: Text = document.createTextNode(t("Download failure")); 84 | const button: HTMLButtonElement = document.createElement("button"); 85 | button.textContent = t("Download manually"); 86 | button.onclick = () => 87 | window.open( 88 | "https://bitbucket.org/alexclifton37/shared-assets/raw/master/clever-search-ai-helper.zip", 89 | ); 90 | failureInfo.appendChild(textNode); 91 | failureInfo.appendChild(button); 92 | new Notice(failureInfo, 0); 93 | } finally { 94 | downloadingNotice.hide(); 95 | } 96 | } 97 | 98 | private async initHelper(remoteDir: string) { 99 | if (this.setting.enableChinesePatch) { 100 | await this.downloadFile(this.targetPath(jieba), remoteDir + jieba); 101 | await this.downloadFile( 102 | this.targetPath(stopWordsZh), 103 | remoteDir + stopWordsZh, 104 | ); 105 | this._assets.jiebaBinary = fsUtil.promises.readFile( 106 | this.targetPath(jieba), 107 | ); 108 | this._assets.stopWordsZh = await this.readLinesAsSet( 109 | this.targetPath(stopWordsZh), 110 | ); 111 | } 112 | await this.downloadFile( 113 | this.targetPath(stopWordsEn), 114 | remoteDir + stopWordsEn, 115 | ); 116 | this._assets.stopWordsEn = await this.readLinesAsSet( 117 | this.targetPath(stopWordsEn), 118 | ); 119 | } 120 | 121 | private targetPath(filename: string) { 122 | return pathUtil.join(assetsDir, filename); 123 | } 124 | 125 | private async readLinesAsSet(path: string): Promise> { 126 | return new Set( 127 | (await fsUtil.promises.readFile(path, { encoding: "utf-8" })).split( 128 | FileUtil.SPLIT_EOL, 129 | ), 130 | ); 131 | } 132 | 133 | private async downloadFile( 134 | targetPath: string, 135 | sourceUrl: string, 136 | ): Promise { 137 | // check if the file already exists 138 | try { 139 | await fsUtil.promises.access(targetPath); 140 | logger.trace("file exists:", targetPath); 141 | return; 142 | } catch (error) { 143 | if (error.code !== "ENOENT") throw error; // rethrow if it's an error other than "file not found" 144 | } 145 | 146 | // create dir recursively if not exists 147 | const targetDir = pathUtil.dirname(targetPath); 148 | await fsUtil.promises 149 | .mkdir(targetDir, { recursive: true }) 150 | .catch((error) => { 151 | if (error.code !== "EEXIST") throw error; // only ignore the error if the directory already exists 152 | }); 153 | 154 | // download the file 155 | const response = await fetch(sourceUrl); 156 | if (!response.ok) 157 | throw new Error(`unexpected response ${response.statusText}`); 158 | 159 | try { 160 | await fsUtil.promises.writeFile( 161 | targetPath, 162 | Buffer.from(await response.arrayBuffer()), 163 | "utf-8", 164 | ); 165 | logger.info(`successfully download from ${sourceUrl}`); 166 | } catch (error) { 167 | logger.error(`failed to download ${sourceUrl}`); 168 | throw error; 169 | } 170 | } 171 | } 172 | 173 | type Assets = { 174 | stopWordsZh: Set | null; 175 | stopWordsEn: Set | null; 176 | jiebaBinary: Promise; 177 | }; 178 | 179 | const EMPTY_ASSETS: Assets = { 180 | stopWordsZh: null, 181 | stopWordsEn: null, 182 | jiebaBinary: new Promise(() => null), 183 | }; 184 | -------------------------------------------------------------------------------- /src/utils/web/http-client.ts: -------------------------------------------------------------------------------- 1 | import { requestUrl, type RequestUrlResponse } from "obsidian"; 2 | 3 | export class HttpClientOption { 4 | baseUrl: string; 5 | protocol: "http" | "https" = "http"; 6 | responseProcessor: (resp: RequestUrlResponse) => any | null; 7 | headers?: Record; 8 | } 9 | export class HttpClient { 10 | option: HttpClientOption; 11 | constructor(option: HttpClientOption) { 12 | this.option = option; 13 | } 14 | 15 | async get(url: string, params?: Record) { 16 | return this.request("GET", url, params, undefined); 17 | } 18 | 19 | async post(url: string, params?: Record, body?: any) { 20 | return this.request("POST", url, params, body); 21 | } 22 | 23 | async put(url: string, params?: Record, body?: any) { 24 | return this.request("PUT", url, params, body); 25 | } 26 | 27 | async delete(url: string, params?: Record) { 28 | return this.request("DELETE", url, params, undefined); 29 | } 30 | 31 | private async request( 32 | method: string, 33 | url: string, 34 | params?: Record, 35 | body?: any, 36 | ): Promise { 37 | url = this.buildUrlWithParams(url, params); 38 | return this.option.responseProcessor( 39 | await requestUrl({ 40 | url, 41 | method, 42 | contentType: "application/json", 43 | body: body ? JSON.stringify(body) : undefined, 44 | headers: this.option.headers, 45 | }), 46 | ); 47 | } 48 | 49 | private buildUrlWithParams( 50 | url: string, 51 | params?: Record, 52 | ): string { 53 | url = `${this.option.protocol}://${this.option.baseUrl}/${url}`; 54 | const queryString = params ? this.serializeParams(params) : ""; 55 | return queryString ? `${url}?${queryString}` : url; 56 | } 57 | 58 | private serializeParams(params: Record): string { 59 | return Object.keys(params) 60 | .map((key) => { 61 | const value = params[key]; 62 | return `${encodeURIComponent(key)}=${encodeURIComponent( 63 | this.stringifyParam(value), 64 | )}`; 65 | }) 66 | .join("&"); 67 | } 68 | 69 | private stringifyParam(value: any): string { 70 | if (typeof value === "object") { 71 | return JSON.stringify(value); 72 | } 73 | return String(value); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/utils/web/request-test.ts: -------------------------------------------------------------------------------- 1 | import { requestUrl } from "obsidian"; 2 | import { OuterSetting } from "src/globals/plugin-setting"; 3 | import { MyNotice } from "src/services/obsidian/transformed-api"; 4 | import { throttle } from "throttle-debounce"; 5 | import { logger } from "../logger"; 6 | import { MyLib, getInstance } from "../my-lib"; 7 | 8 | export class RequestTest { 9 | private settings = getInstance(OuterSetting); 10 | private noticeThrottled = throttle( 11 | 5000, 12 | (text: string) => new MyNotice(text), 13 | ); 14 | 15 | private gptapiOption: any = { 16 | method: "POST", 17 | url: `https://${this.getDomain1()}/v1/embeddings`, 18 | headers: { 19 | Authorization: `Bearer ${this.settings.apiProvider1.key}`, 20 | "Content-Type": "application/json", 21 | }, 22 | contentType: "application/json", 23 | body: JSON.stringify({ 24 | input: "test", 25 | model: "text-embedding-ada-002", 26 | }), 27 | }; 28 | 29 | private openaiOption: any = { 30 | url: `https://${this.getDomain2()}/v1/embeddings`, 31 | method: "POST", 32 | headers: { 33 | Authorization: `Bearer ${this.settings.apiProvider2.key}`, 34 | "Content-Type": "application/json", 35 | }, 36 | contentType: "application/json", 37 | body: JSON.stringify({ 38 | input: "test", 39 | model: "text-embedding-ada-002", 40 | }), 41 | }; 42 | private getDomain1() { 43 | return MyLib.extractDomainFromHttpsUrl(this.settings.apiProvider1.domain); 44 | } 45 | private getDomain2() { 46 | return MyLib.extractDomainFromHttpsUrl(this.settings.apiProvider2.domain); 47 | 48 | } 49 | 50 | async testRequest() { 51 | this.request(this.gptapiOption); 52 | 53 | this.request(this.openaiOption); 54 | } 55 | async request(options: any) { 56 | try { 57 | const res = await requestUrl(options); 58 | logger.debug(res); 59 | logger.debug(res.json); 60 | } catch (err) { 61 | if (err.message.includes("401")) { 62 | const info = `Invalid key for ${MyLib.extractDomainFromHttpsUrl( 63 | options.url, 64 | )}`; 65 | logger.error(info); 66 | this.noticeThrottled(info); 67 | } else { 68 | const info = 69 | `Failed to connect to [${this.settings.apiProvider1.domain}], maybe the domain is wrong or the api provider is not available now or there is something wrong with your Internet connection`; 70 | logger.error(info); 71 | this.noticeThrottled(info); 72 | } 73 | logger.error(err); 74 | } 75 | } 76 | } 77 | 78 | -------------------------------------------------------------------------------- /src/web-workers/client.ts: -------------------------------------------------------------------------------- 1 | import { Vault } from "obsidian"; 2 | import { PrivateApi } from "src/services/obsidian/private-api"; 3 | import { pathUtil } from "src/utils/file-util"; 4 | import { logger } from "src/utils/logger"; 5 | import { getInstance, isDevEnvironment } from "src/utils/my-lib"; 6 | import { singleton } from "tsyringe"; 7 | import type { Message } from "./worker-types"; 8 | 9 | @singleton() 10 | export class SearchClient { 11 | worker?: Worker; 12 | 13 | async createChildThreads() { 14 | if (isDevEnvironment) { 15 | logger.warn("web worker only enabled on dev environment now"); 16 | } else { 17 | return; 18 | } 19 | logger.debug("init child threads..."); 20 | const obsidianFs = getInstance(PrivateApi).obsidianFs; 21 | const obConfigDir = getInstance(Vault).configDir; 22 | const workerPath = pathUtil.join( 23 | obConfigDir, 24 | "plugins/clever-search/dist/cs-search-worker.js", 25 | ); 26 | try { 27 | const workerScript = await obsidianFs.readBinary(workerPath); 28 | logger.debug( 29 | `worker script size (6x bigger than in production build): ${( 30 | workerScript.byteLength / 1000 31 | ).toFixed(2)} KB`, 32 | ); 33 | 34 | // 不直接new Worker创建child thread是为了绕过electron限制 35 | const blob = new Blob([workerScript], { type: "text/javascript" }); 36 | const workerUrl = URL.createObjectURL(blob); 37 | this.worker = new Worker(workerUrl); 38 | 39 | // Receive messages from Worker 40 | this.worker.addEventListener("message", (event: any) => { 41 | console.log("Received from worker:\n", event.data); 42 | }); 43 | } catch (error) { 44 | console.error("Error initializing worker:", error); 45 | } 46 | } 47 | 48 | async testImageSearch() { 49 | this.worker?.postMessage({ type: "image-search" } as Message); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/web-workers/server.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "src/utils/logger"; 2 | 3 | // self.addEventListener('message', (event) => { 4 | // // 接收主线程数据 5 | // logger.debug("abc:" + event.data); 6 | // console.log(event.data); 7 | // console.log('Received from main thread:', event.data); 8 | 9 | // // 处理数据并发送回主线程 10 | // self.postMessage('Processed data'); 11 | // }); 12 | 13 | self.addEventListener("message", (event) => { 14 | if (event.data === "tikToken") { 15 | // logger.debug("received tiktoken request from main thread"); 16 | self.postMessage("tikToken server: " ); 17 | } else { 18 | logger.debug("message isn't for tiktoken") 19 | } 20 | }); 21 | 22 | // 用来避免tsc报错 23 | // export { }; 24 | -------------------------------------------------------------------------------- /src/web-workers/worker-types.ts: -------------------------------------------------------------------------------- 1 | export type Message = { 2 | type: MessageType; 3 | }; 4 | 5 | export type Result = { 6 | msg?: string; 7 | data?: any; 8 | }; 9 | 10 | export type MessageType = "image-search"; 11 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* @settings 2 | name: Clever Search 3 | id: clever-search-styles 4 | settings: 5 | - 6 | id: cs-color-settings 7 | title: Color Settings 8 | title.zh: 颜色设置 9 | type: heading 10 | level: 2 11 | collapsed: false 12 | - 13 | id: cs-search-main-bgc 14 | title: Main Background Color 15 | title.zh: 主要背景色 16 | type: variable-color 17 | opacity: true 18 | format: hex 19 | default: '#' 20 | - 21 | id: cs-search-bar-bgc 22 | title: Search Bar Background Color 23 | title.zh: 搜索框背景色 24 | type: variable-color 25 | opacity: true 26 | format: hex 27 | default: '#' 28 | - 29 | id: cs-hint-char-color 30 | title: Hint Character Color 31 | title.zh: 提示字符颜色 32 | type: variable-color 33 | opacity: true 34 | format: hex 35 | default: '#' 36 | - 37 | id: cs-secondary-font-color 38 | title: Secondary Font Color (for folder) 39 | title.zh: 次要文字颜色 (文件夹) 40 | type: variable-color 41 | opacity: true 42 | format: hex 43 | default: '#' 44 | - 45 | id: cs-pane-bgc 46 | title: Pane Background Color 47 | title.zh: 窗格背景色 48 | type: variable-color 49 | opacity: true 50 | format: hex 51 | default: '#' 52 | - 53 | id: cs-item-selected-color 54 | title: Item Selected Color 55 | title.zh: 选中匹配项的颜色 56 | type: variable-color 57 | opacity: true 58 | format: hex 59 | default: '#' 60 | - 61 | id: cs-highlight-bgc 62 | title: Highlight Background Color 63 | title.zh: 高亮背景颜色 64 | type: variable-color 65 | opacity: true 66 | format: hex 67 | default: '#' 68 | - 69 | id: cs-highlight-char-color 70 | title: Highlight Character Color 71 | title.zh: 高亮字符颜色 72 | type: variable-color 73 | opacity: true 74 | format: hex 75 | default: '#' 76 | 77 | */ 78 | .cs-modal { 79 | width: 71.5%; 80 | height: 80%; 81 | padding: 1.5em 1em 1.5em 1.5em; 82 | /* remove unused room */ 83 | overflow: hidden; 84 | } 85 | 86 | .cs-modal, 87 | .cs-floating-window-container { 88 | background-color: var(--cs-search-main-bgc, var(--cs-background-center)); 89 | } 90 | /* mark { 91 | background-color: lightblue; 92 | color: black; 93 | } */ 94 | /* dynamic blur all elements except active line */ 95 | .cs-privacy-blur .titlebar-button-container:not(:hover), 96 | .cs-privacy-blur .workspace-ribbon.side-dock-ribbon:not(:hover), 97 | .cs-privacy-blur .workspace-split.mod-horizontal:not(:hover), 98 | .cs-privacy-blur .workspace-tab-header-container:not(:hover), 99 | .cs-privacy-blur .view-header:not(:hover), 100 | .cs-privacy-blur .inline-title:not(:hover), 101 | .cs-privacy-blur .cm-embed-block:not(:hover), 102 | .cs-privacy-blur .embedded-backlinks:not(:hover), 103 | .cs-privacy-blur .status-bar:not(:hover) { 104 | filter: blur(5px); 105 | } 106 | 107 | .cs-privacy-blur .cm-line:not(:hover), 108 | .cs-privacy-blur .callout:not(:hover), 109 | .cs-privacy-blur img:not(:hover) { 110 | filter: blur(5px) !important; 111 | } 112 | .cs-privacy-blur .cm-line.cm-active { 113 | filter: none !important; 114 | } 115 | 116 | /* ========== For setting tab =======*/ 117 | /* 定义设置组标题的样式 */ 118 | .cs-setting-group-dev-title { 119 | position: relative; 120 | cursor: pointer; 121 | margin-bottom: 15px; 122 | } 123 | 124 | /* 定义伪元素的样式 */ 125 | .cs-setting-group-dev-title::before { 126 | content: var(--cs-dev-collapse-icon); 127 | position: absolute; 128 | left: -20px; 129 | top: 0; 130 | } 131 | 132 | .cs-resize-handle { 133 | position: absolute; 134 | right: 0; 135 | bottom: 0; 136 | width: 10px; 137 | height: 10px; 138 | background-color: #666; 139 | cursor: se-resize; 140 | } 141 | 142 | .cs-resize-handle:hover { 143 | background-color: #888; 144 | } 145 | 146 | /* 浮动窗口的亮色模式 */ 147 | 148 | /* 可在此处更改颜色 ⬇ */ 149 | .theme-light /* 亮色模式 */ { 150 | --cs-background-center: var(--background-secondary); /* 按钮主体背景色 */ 151 | --cs-background-others: #F6F6F6; /* 整体背景色 */ 152 | --cs-background-select: var(--tag-background); /* 被选中时的背景色 */ 153 | } 154 | .theme-dark /* 暗色模式 */ { 155 | --cs-background-center: var(--background-primary); /* 按钮主体背景色 */ 156 | --cs-background-others: #262626; /* 整体背景色 */ 157 | /* 被选中时的背景色 */ 158 | /* --cs-background-select: #1E222E; */ 159 | --cs-background-select: var(--background-secondary); 160 | } 161 | 162 | /* .theme-light .cs-modal .svelte-1k7yxfd */ 163 | .theme-light .cs-modal button, 164 | .theme-light .cs-floating-window-container button { 165 | background-color: unset !important; 166 | } 167 | 168 | 169 | .theme-light .cs-modal button.selected { 170 | /* 默认选一个暗色作为亮色模式下的选中背景 */ 171 | background-color: var(--cs-item-selected-color, rgba(102, 129, 153, 0.1)) !important; 172 | /* 文本就用白色,作为对比 */ 173 | /* color: var(--tag-color) !important; */ 174 | } 175 | 176 | .theme-dark .cs-modal button.selected { 177 | background-color: var(--cs-item-selected-color, rgba(85, 85, 85, 0.45)) !important; 178 | } 179 | 180 | /* 亮色模式下的浮动窗口 */ 181 | .cs-floating-window-container button.selected { 182 | background-color: var(--cs-item-selected-color, var(--tag-background)) !important; 183 | color: var(--tag-color) !important; 184 | } 185 | 186 | /* 搜索结果标题行修复 */ 187 | .result-items.svelte-1dbevw ul button .file-item span.filename.svelte-1dbevw.svelte-1dbevw{ 188 | margin:0.1em !important; 189 | } 190 | 191 | /* 整体边框 */ 192 | 193 | .theme-light .cs-floating-window-container { 194 | border: 1px solid rgb(235, 235, 235) !important; 195 | 196 | .cs-floating-window-header { 197 | background-color: var(--background-secondary) !important; 198 | 199 | .cs-close-btn { 200 | color: var(--text-accent) !important; 201 | } 202 | } 203 | 204 | 205 | .search-bar input { 206 | background-color: var(--background-primary) !important; 207 | border: 1px solid rgb(235, 235, 235) !important; 208 | } 209 | } 210 | 211 | 212 | .theme-light .cs-modal .right-pane { 213 | background-color: var(--cs-pane-bgc, var(--background-primary)) !important; 214 | } 215 | 216 | .theme-light .cs-modal .search-bar input { 217 | background-color: var(--cs-search-bar-bgc, var(--background-primary)) !important; 218 | } 219 | 220 | 221 | .theme-light .cs-modal .right-pane .preview-container ul button.file-sub-item { 222 | background-color: var(--cs-pane-bgc, var(--background-primary)); 223 | } 224 | 225 | /* 浮动窗口的滚动条 */ 226 | .cs-floating-window-container { 227 | overflow-y: auto !important; 228 | } 229 | 230 | /* CS 的文件内浮动搜索框,限制大小 */ 231 | .cs-floating-window-container .search-container { 232 | 233 | /* overflow-y: scroll !important; */ 234 | 235 | .left-pane { 236 | max-width: 100% !important; 237 | width: 100% !important; 238 | } 239 | 240 | button { 241 | max-width: calc(100% - 1.5em) !important; 242 | margin-left: 1em !important; 243 | } 244 | 245 | /* sticky header */ 246 | .search-bar { 247 | input { 248 | border-radius: 6px !important; 249 | } 250 | position: absolute !important; 251 | margin-left: 10px !important; 252 | margin-top: 20px !important; 253 | top: 10px !important; 254 | width: calc(100% - 30px) !important; 255 | /* top: 20px !important; */ 256 | } 257 | 258 | .result-items { 259 | margin-top: 30px !important; 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /tests/__mocks__/jieba-wasm-mock.js: -------------------------------------------------------------------------------- 1 | // mock external module "jieba-wasm/pkg/web/jieba_rs_wasm", use `moduleNameMapper` in jest.config.js 2 | module.exports = { 3 | cut_for_search: jest.fn().mockImplementation((text) => { 4 | return ["mocked", "word", "list"]; 5 | }), 6 | init: jest.fn().mockImplementation((any) => {}) 7 | }; -------------------------------------------------------------------------------- /tests/src/jest-setup.ts: -------------------------------------------------------------------------------- 1 | // this file will run before each test 2 | // because of the option setupFilesAfterEnv: ["/tests/src/jest-setup.ts"] in jest.config.js 3 | import { jest } from "@jest/globals"; 4 | import "reflect-metadata"; 5 | global.jest = jest; 6 | 7 | // Mock the require function 8 | // @ts-ignore 9 | global.require = (moduleName) => { 10 | // mock require("electron") 11 | if (moduleName === 'electron') { 12 | return { 13 | app: { 14 | getPath: () => 'mockedPath', 15 | }, 16 | remote: { 17 | app: { 18 | getPath: () => 'mockedPath', 19 | }, 20 | }, 21 | }; 22 | } 23 | 24 | throw new Error(`Module '${moduleName}' is not mocked in jest-setup.ts`); 25 | }; -------------------------------------------------------------------------------- /tests/src/utils/data-structure.test.ts: -------------------------------------------------------------------------------- 1 | import { BufferSet, Collections } from "src/utils/data-structure"; 2 | 3 | describe("Collections", () => { 4 | test("minInSet returns the minimum value in a set", () => { 5 | const numbers = new Set([3, 1, 4, 1, 5, 9]); 6 | const min = Collections.minInSet(numbers); 7 | expect(min).toBe(1); 8 | }); 9 | }); 10 | 11 | type Request = { 12 | caller: string; 13 | query: string; 14 | }; 15 | 16 | describe("BufferSet", () => { 17 | let bufferSet: BufferSet; 18 | const mockHandler = jest.fn(() => { 19 | return new Promise((resolve) => { 20 | // Simulate a 10ms delay in handler (async flush) 21 | setTimeout(resolve, 10); 22 | }); 23 | }); 24 | 25 | const identifier = (req: Request) => req.caller + req.query; 26 | 27 | beforeEach(() => { 28 | jest.useFakeTimers(); 29 | bufferSet = new BufferSet(mockHandler, identifier, 3); 30 | jest.clearAllMocks(); 31 | }); 32 | 33 | afterEach(() => { 34 | jest.useRealTimers(); 35 | }); 36 | 37 | test("add or flush should not interfere with ongoing flush", async () => { 38 | bufferSet.add({ caller: "caller1", query: "query1" }); 39 | bufferSet.add({ caller: "caller2", query: "query2" }); 40 | bufferSet.add({ caller: "caller3", query: "query3" }); // should trigger flush 41 | 42 | // halfway through the flush 43 | jest.advanceTimersByTime(5); 44 | 45 | // add more while flushing 46 | bufferSet.add({ caller: "caller4", query: "query4" }); 47 | bufferSet.add({ caller: "caller5", query: "query5" }); 48 | bufferSet.forceFlush(); // won't be called at once but will be called later 49 | 50 | // complete first flush 51 | jest.advanceTimersByTime(5); 52 | 53 | // first flush should be called with the first three requests 54 | expect(mockHandler).toHaveBeenCalledWith([ 55 | { caller: "caller1", query: "query1" }, 56 | { caller: "caller2", query: "query2" }, 57 | { caller: "caller3", query: "query3" }, 58 | ]); 59 | 60 | // ensure the mock handler is resolved 61 | await mockHandler.mock.results[0].value; 62 | 63 | // Now that the first flush is done, we call flush again to process the remaining elements 64 | jest.advanceTimersByTime(10); 65 | await mockHandler.mock.results[1].value; 66 | 67 | // second flush should have been called with the remaining elements 68 | expect(mockHandler).toHaveBeenCalledWith([ 69 | { caller: "caller4", query: "query4" }, 70 | { caller: "caller5", query: "query5" }, 71 | ]); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /tests/src/utils/event-bus.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | import { EventEnum } from "src/globals/enums"; 3 | import { EventBus } from "src/utils/event-bus"; 4 | 5 | 6 | describe('EventBus', () => { 7 | let eventBus: EventBus; 8 | 9 | beforeEach(() => { 10 | eventBus = new EventBus(); 11 | }); 12 | 13 | it('should register and trigger an event', () => { 14 | const mockCallback = jest.fn(); 15 | eventBus.on(EventEnum.TEST_EVENT_A, mockCallback); 16 | 17 | eventBus.emit(EventEnum.TEST_EVENT_A, 'arg1', 'arg2'); 18 | expect(mockCallback).toHaveBeenCalledWith('arg1', 'arg2'); 19 | }); 20 | 21 | it('should not trigger callback after being removed', () => { 22 | const mockCallback = jest.fn(); 23 | eventBus.on(EventEnum.TEST_EVENT_A, mockCallback); 24 | eventBus.off(EventEnum.TEST_EVENT_A, mockCallback); 25 | 26 | eventBus.emit(EventEnum.TEST_EVENT_A, 'arg1', 'arg2'); 27 | expect(mockCallback).not.toHaveBeenCalled(); 28 | }); 29 | 30 | it('should handle multiple callbacks for a single event', () => { 31 | const mockCallback1 = jest.fn(); 32 | const mockCallback2 = jest.fn(); 33 | 34 | eventBus.on(EventEnum.TEST_EVENT_A, mockCallback1); 35 | eventBus.on(EventEnum.TEST_EVENT_A, mockCallback2); 36 | 37 | eventBus.emit(EventEnum.TEST_EVENT_A, 'arg1', 'arg2'); 38 | expect(mockCallback1).toHaveBeenCalledWith('arg1', 'arg2'); 39 | expect(mockCallback2).toHaveBeenCalledWith('arg1', 'arg2'); 40 | }); 41 | 42 | it('should not affect other events when removing a callback', () => { 43 | const mockCallback1 = jest.fn(); 44 | const mockCallback2 = jest.fn(); 45 | 46 | eventBus.on(EventEnum.TEST_EVENT_A, mockCallback1); 47 | eventBus.on(EventEnum.TEST_EVENT_B, mockCallback2); 48 | eventBus.off(EventEnum.TEST_EVENT_A, mockCallback1); 49 | 50 | eventBus.emit(EventEnum.TEST_EVENT_A, 'arg1'); 51 | eventBus.emit(EventEnum.TEST_EVENT_B, 'arg2'); 52 | 53 | expect(mockCallback1).not.toHaveBeenCalled(); 54 | expect(mockCallback2).toHaveBeenCalledWith('arg2'); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /tests/src/utils/file-util.test.ts: -------------------------------------------------------------------------------- 1 | import { FileUtil } from "src/utils/file-util"; 2 | 3 | 4 | describe("file-util test", () => { 5 | describe("getBasename", () => { 6 | it("should get the basename of a simple filepath", () => { 7 | const path = "/folder/file.txt"; 8 | expect(FileUtil.getBasename(path)).toBe("file"); 9 | }); 10 | 11 | it("should handle filenames with multiple dots", () => { 12 | const path = "/folder/my.file.name.with.dots.tar.gz"; 13 | expect(FileUtil.getBasename(path)).toBe( 14 | "my.file.name.with.dots.tar", 15 | ); 16 | }); 17 | 18 | it("should handle filenames without extension", () => { 19 | const path = "/folder/myfile"; 20 | expect(FileUtil.getBasename(path)).toBe("myfile"); 21 | }); 22 | 23 | it("should handle hidden files with extension", () => { 24 | const path = "/folder/.hiddenfile.txt"; 25 | expect(FileUtil.getBasename(path)).toBe(".hiddenfile"); 26 | }); 27 | }); 28 | 29 | describe("getExtension", () => { 30 | it("should get the extension of a simple filepath", () => { 31 | const path = "/folder/file.txt"; 32 | expect(FileUtil.getExtension(path)).toBe("txt"); 33 | }); 34 | 35 | it("should handle filenames with multiple dots", () => { 36 | const path = "/folder/my.file.name.with.dots.tar.gz"; 37 | expect(FileUtil.getExtension(path)).toBe("gz"); 38 | }); 39 | 40 | it("should return empty string for filenames without extension", () => { 41 | const path = "/folder/myfile"; 42 | expect(FileUtil.getExtension(path)).toBe(""); 43 | }); 44 | 45 | it("should handle hidden files with extension", () => { 46 | const path = "/folder/.hiddenfile.txt"; 47 | expect(FileUtil.getExtension(path)).toBe("txt"); 48 | }); 49 | }); 50 | 51 | describe("getFolderPath", () => { 52 | it("should return the folder path of a file", () => { 53 | expect(FileUtil.getFolderPath("path/to/file.txt")).toBe("path/to/"); 54 | }); 55 | 56 | it('should return "./" for files in the root directory', () => { 57 | expect(FileUtil.getFolderPath("/file.txt")).toBe("./"); 58 | }); 59 | 60 | it("should handle an empty path", () => { 61 | expect(FileUtil.getFolderPath("")).toBe("./"); 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /tests/src/utils/logger.test.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "src/utils/logger"; 2 | 3 | describe("Logger", () => { 4 | const originalConsole = { ...console }; 5 | let consoleSpy: any; 6 | let consoleGroupCollapsedSpy: any; 7 | 8 | beforeEach(() => { 9 | consoleSpy = { 10 | trace: jest.spyOn(console, "trace").mockImplementation(() => {}), 11 | debug: jest.spyOn(console, "debug").mockImplementation(() => {}), 12 | info: jest.spyOn(console, "info").mockImplementation(() => {}), 13 | warn: jest.spyOn(console, "warn").mockImplementation(() => {}), 14 | error: jest.spyOn(console, "error").mockImplementation(() => {}), 15 | }; 16 | consoleGroupCollapsedSpy = jest 17 | .spyOn(console, "groupCollapsed") 18 | .mockImplementation(() => {}); 19 | }); 20 | 21 | afterEach(() => { 22 | jest.restoreAllMocks(); 23 | console = originalConsole; 24 | }); 25 | // it("should log trace messages when log level is trace", () => { 26 | // logger.setLevel("trace"); 27 | // logger.trace("Trace message"); 28 | // expect(consoleSpy.trace).toHaveBeenCalled(); 29 | // }); 30 | 31 | it("should log debug messages when log level is debug", () => { 32 | logger.setLevel("debug"); 33 | logger.debug("Debug message"); 34 | expect(consoleSpy.debug).toHaveBeenCalledWith( 35 | expect.anything(), 36 | expect.anything(), 37 | "Debug message", 38 | ); 39 | }); 40 | 41 | it("should log info messages when log level is info", () => { 42 | logger.setLevel("info"); 43 | logger.info("Info message"); 44 | expect(consoleSpy.info).toHaveBeenCalledWith( 45 | expect.anything(), 46 | expect.anything(), 47 | "Info message", 48 | ); 49 | }); 50 | 51 | it("should log warning messages when log level is warn", () => { 52 | logger.setLevel("warn"); 53 | logger.warn("Warning message"); 54 | expect(consoleSpy.warn).toHaveBeenCalledWith( 55 | expect.anything(), 56 | expect.anything(), 57 | "Warning message", 58 | ); 59 | }); 60 | 61 | it("should log error messages when log level is error", () => { 62 | logger.setLevel("error"); 63 | logger.error("Error message"); 64 | expect(consoleSpy.error).toHaveBeenCalledWith( 65 | expect.anything(), 66 | expect.anything(), 67 | "Error message", 68 | ); 69 | }); 70 | 71 | it("should log info messages when log level is debug", () => { 72 | logger.setLevel("debug"); 73 | logger.info("Info message"); 74 | expect(consoleSpy.info).toHaveBeenCalledWith( 75 | expect.anything(), 76 | expect.anything(), 77 | "Info message", 78 | ); 79 | }); 80 | 81 | it("should not log trace messages when log level is debug or higher", () => { 82 | logger.setLevel("debug"); 83 | logger.trace("Trace message"); 84 | expect(consoleSpy.trace).not.toHaveBeenCalled(); 85 | }); 86 | 87 | it("should not log debug messages when log level is info", () => { 88 | logger.setLevel("info"); 89 | logger.debug("Debug message"); 90 | expect(consoleSpy.debug).not.toHaveBeenCalled(); 91 | }); 92 | 93 | it("should not log debug or info messages when log level is warn", () => { 94 | logger.setLevel("warn"); 95 | logger.debug("Debug message"); 96 | logger.info("Info message"); 97 | expect(consoleSpy.debug).not.toHaveBeenCalled(); 98 | expect(consoleSpy.info).not.toHaveBeenCalled(); 99 | }); 100 | 101 | it("should not log debug, info, or warn messages when log level is error", () => { 102 | logger.setLevel("error"); 103 | logger.debug("Debug message"); 104 | logger.info("Info message"); 105 | logger.warn("Warning message"); 106 | expect(consoleSpy.debug).not.toHaveBeenCalled(); 107 | expect(consoleSpy.info).not.toHaveBeenCalled(); 108 | expect(consoleSpy.warn).not.toHaveBeenCalled(); 109 | }); 110 | 111 | it("should not log any messages when log level is none", () => { 112 | logger.setLevel("none"); 113 | logger.debug("Debug message"); 114 | logger.info("Info message"); 115 | logger.warn("Warning message"); 116 | logger.error("Error message"); 117 | expect(consoleSpy.debug).not.toHaveBeenCalled(); 118 | expect(consoleSpy.info).not.toHaveBeenCalled(); 119 | expect(consoleSpy.warn).not.toHaveBeenCalled(); 120 | expect(consoleSpy.error).not.toHaveBeenCalled(); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /tests/src/utils/my-lib.test.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "src/utils/logger"; 2 | import { MyLib, formatMillis, monitorExecution } from "src/utils/my-lib"; 3 | 4 | describe("MyLib", () => { 5 | let originalConsoleError: any; 6 | let mockLoggerDebug: jest.SpyInstance; 7 | 8 | beforeAll(() => { 9 | originalConsoleError = console.error; 10 | console.error = jest.fn(); 11 | 12 | // Mock logger.debug 13 | mockLoggerDebug = jest 14 | .spyOn(logger, "debug") 15 | .mockImplementation(() => {}); 16 | }); 17 | 18 | afterAll(() => { 19 | console.error = originalConsoleError; 20 | mockLoggerDebug.mockRestore(); 21 | }); 22 | 23 | describe("append", () => { 24 | it("appends elements from one array to another", () => { 25 | const host = [1, 2, 3]; 26 | const addition = [4, 5, 6]; 27 | 28 | const result = MyLib.append(host, addition); 29 | 30 | expect(result).toEqual([1, 2, 3, 4, 5, 6]); 31 | // make sure the host has been changed 32 | expect(host).toEqual([1, 2, 3, 4, 5, 6]); 33 | }); 34 | 35 | it("handles empty arrays correctly", () => { 36 | const host: number[] = []; 37 | const addition = [1, 2, 3]; 38 | 39 | const result = MyLib.append(host, addition); 40 | 41 | expect(result).toEqual([1, 2, 3]); 42 | }); 43 | 44 | it("returns the original array if nothing to append", () => { 45 | const host = [1, 2, 3]; 46 | const addition: number[] = []; 47 | 48 | const result = MyLib.append(host, addition); 49 | 50 | expect(result).toEqual([1, 2, 3]); 51 | }); 52 | }); 53 | 54 | describe("extractDomainFromUrl", () => { 55 | it("should extract the domain from a valid HTTPS URL", () => { 56 | const url = "https://www.example.com/page"; 57 | expect(MyLib.extractDomainFromHttpsUrl(url)).toBe( 58 | "www.example.com", 59 | ); 60 | }); 61 | 62 | it("should return empty string for non-HTTPS URL", () => { 63 | const url = "http://www.example.com/page"; 64 | expect(MyLib.extractDomainFromHttpsUrl(url)).toBe(""); 65 | }); 66 | }); 67 | 68 | describe("formatMillis", () => { 69 | it("formats milliseconds correctly", () => { 70 | expect(formatMillis(500)).toBe("500 ms"); 71 | expect(formatMillis(1000)).toBe("1 s 0 ms"); 72 | expect(formatMillis(65000)).toBe("1 min 5 s 0 ms"); 73 | }); 74 | }); 75 | describe("monitorExecution", () => { 76 | it("monitors and logs execution time of a function", async () => { 77 | const mockFn = jest.fn().mockResolvedValue("test"); 78 | await monitorExecution(mockFn); 79 | 80 | expect(mockFn).toHaveBeenCalled(); 81 | expect(mockLoggerDebug).toHaveBeenCalled(); 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /tests/src/utils/nlp.test.ts: -------------------------------------------------------------------------------- 1 | import { LanguageEnum } from "src/globals/enums"; 2 | import { TextAnalyzer } from "src/utils/nlp"; 3 | 4 | // 定义测试字符串数组 5 | const testArrays = [ 6 | [ 7 | "This is the first test sentence. It is written in English.", 8 | "这是第一句测试语句。", 9 | "It's definitely in English.", 10 | ], 11 | [ 12 | "This text is mostly in English but it has some 中文 characters.", 13 | "Here is some more English text.", 14 | "这里有一些中文字符。", 15 | ], 16 | [ 17 | "这是主要的中文文本,但它有一些English words.", 18 | "这里还有更多的中文。", 19 | "Here is an English sentence to mix things up.", 20 | ], 21 | ]; 22 | 23 | // 模拟 console.log 24 | const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); 25 | 26 | describe("NLP functions", () => { 27 | afterEach(() => { 28 | // 清除 console.log 的模拟调用信息 29 | logSpy.mockClear(); 30 | }); 31 | 32 | afterAll(() => { 33 | // 恢复 console.log 34 | logSpy.mockRestore(); 35 | }); 36 | 37 | describe("detectLanguage function", () => { 38 | it("should detect the correct main language and proportions", () => { 39 | // 测试 detectLanguage 函数的逻辑 40 | const expectedResults = [ 41 | { 42 | mainLanguage: LanguageEnum.en, 43 | mainProportion: "89.47%", 44 | details: { 45 | [LanguageEnum.en]: "89.47%", 46 | [LanguageEnum.zh]: "10.53%", 47 | [LanguageEnum.other]: "0.00%", 48 | }, 49 | }, 50 | { 51 | mainLanguage: LanguageEnum.en, 52 | mainProportion: "90.20%", 53 | details: { 54 | [LanguageEnum.en]: "90.20%", 55 | [LanguageEnum.zh]: "9.80%", 56 | [LanguageEnum.other]: "0.00%", 57 | }, 58 | }, 59 | { 60 | mainLanguage: LanguageEnum.en, 61 | mainProportion: "53.57%", 62 | details: { 63 | [LanguageEnum.en]: "53.57%", 64 | [LanguageEnum.zh]: "46.43%", 65 | [LanguageEnum.other]: "0.00%", 66 | }, 67 | }, 68 | ]; 69 | 70 | testArrays.forEach((array, index) => { 71 | const result = TextAnalyzer.detectLanguage(array); 72 | expect(result).toEqual(expectedResults[index]); 73 | }); 74 | }); 75 | }); 76 | 77 | describe("printLanguageProportions function", () => { 78 | it("should print language proportions correctly", () => { 79 | const result = { 80 | mainLanguage: LanguageEnum.en, 81 | mainProportion: "50%", 82 | details: { 83 | [LanguageEnum.en]: "50%", 84 | [LanguageEnum.zh]: "50%", 85 | [LanguageEnum.other]: "0%", 86 | }, 87 | }; 88 | TextAnalyzer.printLanguageProportion(result); 89 | 90 | // 验证 console.log 是否被调用 91 | expect(logSpy).toHaveBeenCalledWith( 92 | expect.stringContaining( 93 | "Main Language: English, Main Proportion: 50%", 94 | ), 95 | ); 96 | }); 97 | }); 98 | }); 99 | 100 | it("should detect the correct main language and proportion for a single string", () => { 101 | const testString = "This is an English text string."; 102 | const result = TextAnalyzer.detectLanguage(testString); 103 | 104 | expect(result.mainLanguage).toEqual(LanguageEnum.en); 105 | expect(result.mainProportion).toBe("100.00%"); 106 | expect(result.details[LanguageEnum.en]).toBe("100.00%"); 107 | expect(result.details[LanguageEnum.zh]).toBe("0.00%"); 108 | expect(result.details[LanguageEnum.other]).toBe("0.00%"); 109 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | "compilerOptions": { 4 | // suppress error due to the extends for svelte/tsconfig.json 5 | // won't work on typescript 5.5 or above 6 | // "ignoreDeprecations": "5.0", 7 | "experimentalDecorators": true, 8 | "emitDecoratorMetadata": true, 9 | "types": ["svelte", "node", "jest"], 10 | "baseUrl": ".", 11 | "module": "ESNext", 12 | "target": "ES6", 13 | "allowJs": true, 14 | "noImplicitAny": true, 15 | "moduleResolution": "node", 16 | "importHelpers": true, 17 | "isolatedModules": true, 18 | "strictNullChecks": true, 19 | "lib": ["DOM", "ES5", "ES6", "ES7"] 20 | }, 21 | "include": ["**/*.ts", "src/search.service.ts", "tests/src/jest-setup.ts"] 22 | } 23 | --------------------------------------------------------------------------------